Compare commits

..
Author SHA1 Message Date
bflatastic 85819578ff restore signal 2025-04-09 01:43:27 +07:00
bflatastic 2516534b3f modal backdrop darker 2025-04-09 01:37:37 +07:00
bflatastic b221aa5df6 jobs activity with dummy polling for now 2025-04-09 01:37:37 +07:00
Brandon Thomas ea783c14de forgotten file 2025-04-08 13:59:49 -04:00
BFlat 01093918da library modal (#486)
- for selecting existing images from library (+ in prompt box)
- for viewing media in library (my library button on top right)
2025-04-08 22:42:56 +07:00
Brandon Thomas 2ea9464be2 rewire prompting 2025-04-08 02:21:17 -04:00
Brandon Thomas b41b4f8e17 read/write same sora cookie, avoid cloudflare heuristics 2025-04-07 21:17:26 -04:00
ArEnScandMichael Chung 09cf4529c0 Feature/reshape UI (#482)
Co-authored-by: Michael Chung <michaelchung@michaels-mbp-2.home>
2025-04-07 18:40:17 -04:00
Brandon Thomas bf035f9048 login flow 2025-04-07 17:50:48 -04:00
Brandon Thomas 448c760339 fix login 2025-04-07 16:35:14 -04:00
30 changed files with 1449 additions and 523 deletions
@@ -6,39 +6,27 @@ use image::codecs::png::{CompressionType, FilterType, PngEncoder};
use image::{DynamicImage, ImageReader};
use std::io::Cursor;
use log::{error, info};
use tauri::{AppHandle, Manager};
use tauri::{AppHandle, Manager, State};
use openai_sora_client::credentials::SoraCredentials;
use openai_sora_client::image_gen::common::{ImageSize, NumImages};
use openai_sora_client::image_gen::sora_image_gen_remix::{sora_image_gen_remix, SoraImageGenRemixRequest};
use openai_sora_client::upload::upload_media_from_bytes::sora_media_upload_from_bytes;
use openai_sora_client::upload::upload_media_from_file::SoraMediaUploadRequest;
use crate::state::app_dir::AppDataRoot;
#[tauri::command]
pub async fn image_generation_command(
image: &str,
prompt: &str,
app: AppHandle,
app_data_root: State<'_, AppDataRoot>,
) -> Result<String, String> {
info!("image_generation_command called; processing image...");
for (w, webview) in app.webviews() {
info!("sending webview: {}", w);
//let result = webview.eval("window.location.replace('https://google.com');"); // works
//let result = webview.eval("document.body.innerHTML = 'hello'"); // works
let result = webview.eval("document.body.innerHTML = document.cookie");
if let Err(err) = result {
error!("Error with webview: {:?}", err)
}
}
if true {
return Ok("true".to_string());
}
let bytes = BASE64_STANDARD.decode(image)
.map_err(|err| format!("Base64 decode error: {}", err))?;
generate_image(bytes, prompt)
generate_image(bytes, prompt, &app_data_root)
.await
.map_err(|err| {
error!("error: {:?}", err);
@@ -48,8 +36,8 @@ pub async fn image_generation_command(
Ok("success".to_string())
}
pub async fn generate_image(file_bytes: Vec<u8>, prompt: &str) -> AnyhowResult<()> {
let sora_credentials = get_credentials()?;
pub async fn generate_image(file_bytes: Vec<u8>, prompt: &str, app_data_root: &AppDataRoot) -> AnyhowResult<()> {
let sora_credentials = get_credentials(app_data_root)?;
let filename = "image.png".to_string();
@@ -79,16 +67,21 @@ pub async fn generate_image(file_bytes: Vec<u8>, prompt: &str) -> AnyhowResult<(
Ok(())
}
fn get_credentials() -> AnyhowResult<SoraCredentials> {
fn get_credentials(app_data_root: &AppDataRoot) -> AnyhowResult<SoraCredentials> {
let cookie_file = app_data_root.get_sora_cookie_file_path();
let bearer = read_to_string("/Users/bt/dev/storyteller/storyteller-rust/test_data/temp/bearer.txt")?
.trim()
.to_string();
let cookie= read_to_string("/Users/bt/dev/storyteller/storyteller-rust/test_data/temp/cookie.txt")?
let cookie= read_to_string(cookie_file)?
.trim()
.to_string();
let sentinel = read_to_string("/Users/bt/dev/storyteller/storyteller-rust/test_data/temp/sentinel.txt")?
.trim()
.to_string();
Ok(SoraCredentials {
bearer_token: bearer,
cookie: cookie,
@@ -1,5 +1,23 @@
use std::time::Duration;
use log::{error, info};
use tauri::{AppHandle, WindowBuilder};
use once_cell::sync::Lazy;
use tauri::{AppHandle, Manager, Url, WebviewUrl, WebviewWindowBuilder, WindowBuilder};
use tokio::time::sleep_until;
use errors::AnyhowResult;
use crate::threads::login_thread::{LOGIN_WINDOW_NAME, SORA_LOGIN_URL};
use crate::utils::clear_all_webview_cookies::clear_all_webview_cookies;
pub const START_URL_STR: &str = "https://storyteller.ai/";
pub static START_URL: Lazy<Url> = Lazy::new(|| {
Url::parse(START_URL_STR).expect("URL should parse")
});
pub const SORA_HOMEPAGE_URL_STR: &str = "https://sora.com/";
pub static SORA_HOMEPAGE_URL : Lazy<Url> = Lazy::new(|| {
Url::parse(SORA_HOMEPAGE_URL_STR).expect("URL should parse")
});
#[tauri::command]
pub async fn open_login_command(
@@ -7,35 +25,48 @@ pub async fn open_login_command(
) -> Result<String, String> {
info!("open_login_command called");
let _window_builder = WindowBuilder::new(&app, "login")
.always_on_top(true)
.title("Login")
.resizable(true)
.visible(true)
.build()
.map_err(|err| {
error!("Failed to create window: {}", err);
format!("Failed to create window: {}", err)
})?;
/*app.create_tao_window(move || {
let window = tauri::window::WindowBuilder::from_config(&app, &app.config().app.windows.get(0).unwrap().clone())
("WGPU Target".to_string(), window_builder)
});*/
/* "login",
tauri::WindowUrl::App("https://google.com".into()),
tauri::WindowBuilder::new(
&app,
"login",
tauri::WindowUrl::App("https://google.com".into()),
)
.title("Login")
.inner_size(800.0, 600.0)
.decorations(true)
.resizable(true)
).map_err(|err| format!("Failed to create window: {}", err))?;*/
do_open_login(&app)
.await
.map_err(|err| {
error!("Error opening login: {:?}", err);
format!("Error opening login: {:?}", err)
})?;
Ok("result".to_string())
}
}
async fn do_open_login(app: &AppHandle) -> AnyhowResult<()> {
info!("Building login window...");
let url = WebviewUrl::External(START_URL.clone());
let window = WebviewWindowBuilder::new(app, LOGIN_WINDOW_NAME, url)
//.user_agent(openai_sora_client::credentials::USER_AGENT)
.always_on_top(true)
.title("Login to OpenAI")
.center()
.resizable(true)
.visible(true)
.closable(true)
.focused(true)
.devtools(true)
.build()?;
for (name, webview) in window.webviews() {
if name != LOGIN_WINDOW_NAME {
continue;
}
clear_all_webview_cookies(&webview)?;
// NB: We're starting to get Cloudflare protection screens. Let's try to avoid.
webview.navigate(SORA_HOMEPAGE_URL.clone())?;
tokio::time::sleep(Duration::from_millis(100)).await;
webview.navigate(SORA_LOGIN_URL.clone())?;
break;
}
info!("Done.");
Ok(())
}
+1 -2
View File
@@ -23,7 +23,6 @@ pub fn run() {
let config = AppConfig::init()
.expect("config should load");
let app_data_root = config.app_data_root.clone();
let app_data_root2 = config.app_data_root.clone();
@@ -50,7 +49,7 @@ pub fn run() {
//}
let app = app.handle().clone();
tauri::async_runtime::spawn(login_thread(app));
tauri::async_runtime::spawn(login_thread(app, app_data_root2));
Ok(())
})
@@ -114,6 +114,10 @@ impl AppDataRoot {
pub fn log_file_name_str(&self) -> &str {
&self.log_file_name_string
}
pub fn get_sora_cookie_file_path(&self) -> PathBuf {
self.path.join("sora_cookies.txt")
}
}
impl AppAssetsDir {
@@ -1,42 +1,103 @@
use std::fs::OpenOptions;
use std::io::Write;
use anyhow::anyhow;
use log::{error, info};
use once_cell::sync::Lazy;
use reqwest::Url;
use tauri::{AppHandle, Manager, Webview};
use errors::AnyhowResult;
use crate::state::app_dir::AppDataRoot;
use crate::utils::sora_webview_cookies::get_all_sora_cookies_as_string;
pub async fn login_thread(app: AppHandle) -> ! {
pub const LOGIN_WINDOW_NAME: &str = "login_window";
pub const SORA_LOGIN_URL_STR: &str = "https://sora.com/auth/login?callback_path=/";
pub static SORA_LOGIN_URL: Lazy<Url> = Lazy::new(|| {
Url::parse(SORA_LOGIN_URL_STR).expect("URL should parse")
});
pub const SORA_ROOT_URL_STR: &str = "https://sora.com/";
pub static SORA_ROOT_URL: Lazy<Url> = Lazy::new(|| {
Url::parse(SORA_ROOT_URL_STR).expect("URL should parse")
});
pub async fn login_thread(app: AppHandle, app_data_root: AppDataRoot) -> ! {
loop {
/*for (w, webview) in app.webviews() {
info!("Sending webview {}", w);
if let Err(err) = random_behavior(&webview) {
error!("Error in webview : {:?}", err);
for (window_name, webview) in app.webviews() {
if window_name == LOGIN_WINDOW_NAME {
let result = check_login_window(&webview, &app_data_root).await;
if let Err(err) = result {
error!("Error checking login window: {:?}", err);
}
break;
}
}*/
tokio::time::sleep(std::time::Duration::from_millis(5_000)).await;
}
tokio::time::sleep(std::time::Duration::from_millis(2_000)).await;
}
}
pub fn random_behavior(webview: &Webview) -> AnyhowResult<()> {
let url = webview.url();
//webview.navigate(url)?;
//webview.cookies_for_url(url)?;
async fn check_login_window(webview: &Webview, app_data_root: &AppDataRoot) -> AnyhowResult<()> {
clear_browsing_data_on_test_domain(webview)?;
//keep_on_task(webview)?;
extract_cookies_to_file(webview, app_data_root)?;
// TODO: Other maintenance tasks ...
Ok(())
}
let rand = rand::random_range(0..5u8);
match rand {
0..3 => {
redirect_google(&webview)?;
}
fn keep_on_task(webview: &Webview) -> AnyhowResult<()> {
let url = webview.url()?;
let hostname= url.host()
.ok_or(anyhow!("no host in url"))?
.to_string();
match hostname.as_str() {
"auth.openai.com" => {},
"openai.com" => {},
"sora.com" => {},
// Third party SSO
"accounts.google.com" => {},
"accounts.youtube.com" => {},
"login.live.com" => {},
"appleid.apple.com" => {},
_ => {
write_cookies_to_body(&webview)?;
info!("Non login hostname: {}", hostname);
webview.navigate(SORA_LOGIN_URL.clone())?;
}
}
Ok(())
}
pub fn redirect_google(webview: &Webview) -> AnyhowResult<()> {
webview.eval("window.location.replace('https://google.com');")?;
/// This is just so we have a way to clear browsing data.
fn clear_browsing_data_on_test_domain(webview: &Webview) -> AnyhowResult<()> {
let url = webview.url()?;
let hostname= url.host()
.ok_or(anyhow!("no host in url"))?
.to_string();
match hostname.as_str() {
"storyteller.ai" => {
info!("Clearing all browsing data...");
webview.clear_all_browsing_data()?;
}
_ => {}
}
Ok(())
}
pub fn write_cookies_to_body(webview: &Webview) -> AnyhowResult<()> {
webview.eval("document.body.innerHTML = document.cookie")?;
// 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)?;
let cookie_file = app_data_root.get_sora_cookie_file_path();
let mut file = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(cookie_file)?;
file.write_all(cookies.as_bytes())?;
file.flush()?;
Ok(())
}
@@ -0,0 +1,7 @@
use tauri::Webview;
use errors::AnyhowResult;
pub fn clear_all_webview_cookies(webview: &Webview) -> AnyhowResult<()> {
webview.clear_all_browsing_data()?;
Ok(())
}
+3 -1
View File
@@ -1 +1,3 @@
pub mod image;
pub mod clear_all_webview_cookies;
pub mod image;
pub mod sora_webview_cookies;
@@ -0,0 +1,26 @@
use once_cell::sync::Lazy;
use reqwest::Url;
use tauri::Webview;
use tauri::webview::Cookie;
use errors::AnyhowResult;
const SORA_ROOT_COOKIE_URL_STR: &str = "https://sora.com/";
static SORA_ROOT_COOKIE_URL: Lazy<Url> = Lazy::new(|| {
Url::parse(SORA_ROOT_COOKIE_URL_STR).expect("URL should parse")
});
pub fn get_all_sora_cookies(webview: &Webview) -> AnyhowResult<Vec<Cookie>> {
let cookies = webview.cookies_for_url(SORA_ROOT_COOKIE_URL.clone())?;
Ok(cookies)
}
pub fn get_all_sora_cookies_as_string(webview: &Webview) -> AnyhowResult<String> {
let cookies = get_all_sora_cookies(webview)?;
let cookie_string = cookies
.iter()
.map(|cookie| format!("{}={}", cookie.name(), cookie.value()))
.collect::<Vec<String>>()
.join("; ");
Ok(cookie_string)
}
@@ -4,7 +4,7 @@ use serde_derive::Deserialize;
use errors::AnyhowResult;
// NB: It appears that the sentinel may require a matching user agent.
pub (crate) 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";
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)]
pub struct SoraCredentials {
+22 -55
View File
@@ -9,13 +9,17 @@
"version": "0.0.0",
"dependencies": {
"@devnomic/marquee": "^1.0.3",
"@fortawesome/free-brands-svg-icons": "^6.6.0",
"@fortawesome/fontawesome-svg-core": "^6.7.2",
"@fortawesome/free-brands-svg-icons": "^6.7.2",
"@fortawesome/pro-regular-svg-icons": "^6.7.2",
"@fortawesome/pro-solid-svg-icons": "^6.7.2",
"@fortawesome/react-fontawesome": "^0.2.2",
"@headlessui/react": "^2.1.3",
"@preact/signals-react": "^2.2.0",
"@tauri-apps/api": "^2.2.0",
"@techstark/opencv-js": "^4.10.0-release.1",
"@zip.js/zip.js": "^2.7.52",
"dayjs": "^1.11.13",
"fortawesome": "^0.0.1-security",
"konva": "^9.3.14",
"onnxruntime-web": "^1.19.2",
@@ -28,14 +32,10 @@
"react-router-dom": "^6.26.1",
"sonner": "^2.0.1",
"tailwind-merge": "^2.5.2",
"uuid": "^10.0.0",
"yarn": "^1.22.22"
"uuid": "^10.0.0"
},
"devDependencies": {
"@eslint/js": "^9.9.0",
"@fortawesome/fontawesome-svg-core": "^6.6.0",
"@fortawesome/pro-solid-svg-icons": "^6.7.2",
"@fortawesome/react-fontawesome": "^0.2.2",
"@preact/signals-react-transform": "^0.4.0",
"@tauri-apps/cli": "^2.4.0",
"@types/deep-equal": "^1.0.4",
@@ -590,33 +590,30 @@
"integrity": "sha512-X8R8Oj771YRl/w+c1HqAC1szL8zWQRwFvgDwT129k9ACdBoud/+/rX9V0qiMl6LWUdP9voC2nDVZYPMQQsb6eA=="
},
"node_modules/@fortawesome/fontawesome-common-types": {
"version": "6.6.0",
"resolved": "https://npm.fontawesome.com/@fortawesome/fontawesome-common-types/-/6.6.0/fontawesome-common-types-6.6.0.tgz",
"integrity": "sha512-xyX0X9mc0kyz9plIyryrRbl7ngsA9jz77mCZJsUkLl+ZKs0KWObgaEBoSgQiYWAsSmjz/yjl0F++Got0Mdp4Rw==",
"license": "MIT",
"version": "6.7.2",
"resolved": "https://npm.fontawesome.com/@fortawesome/fontawesome-common-types/-/6.7.2/fontawesome-common-types-6.7.2.tgz",
"integrity": "sha512-Zs+YeHUC5fkt7Mg1l6XTniei3k4bwG/yo3iFUtZWd/pMx9g3fdvkSK9E0FOC+++phXOka78uJcYb8JaFkW52Xg==",
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/fontawesome-svg-core": {
"version": "6.6.0",
"resolved": "https://npm.fontawesome.com/@fortawesome/fontawesome-svg-core/-/6.6.0/fontawesome-svg-core-6.6.0.tgz",
"integrity": "sha512-KHwPkCk6oRT4HADE7smhfsKudt9N/9lm6EJ5BVg0tD1yPA5hht837fB87F8pn15D8JfTqQOjhKTktwmLMiD7Kg==",
"dev": true,
"license": "MIT",
"version": "6.7.2",
"resolved": "https://npm.fontawesome.com/@fortawesome/fontawesome-svg-core/-/6.7.2/fontawesome-svg-core-6.7.2.tgz",
"integrity": "sha512-yxtOBWDrdi5DD5o1pmVdq3WMCvnobT0LU6R8RyyVXPvFRd2o79/0NCuQoCjNTeZz9EzA9xS3JxNWfv54RIHFEA==",
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.6.0"
"@fortawesome/fontawesome-common-types": "6.7.2"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-brands-svg-icons": {
"version": "6.6.0",
"resolved": "https://npm.fontawesome.com/@fortawesome/free-brands-svg-icons/-/6.6.0/free-brands-svg-icons-6.6.0.tgz",
"integrity": "sha512-1MPD8lMNW/earme4OQi1IFHtmHUwAKgghXlNwWi9GO7QkTfD+IIaYpIai4m2YJEzqfEji3jFHX1DZI5pbY/biQ==",
"version": "6.7.2",
"resolved": "https://npm.fontawesome.com/@fortawesome/free-brands-svg-icons/-/6.7.2/free-brands-svg-icons-6.7.2.tgz",
"integrity": "sha512-zu0evbcRTgjKfrr77/2XX+bU+kuGfjm0LbajJHVIgBWNIDzrhpRxiCPNT8DW5AdmSsq7Mcf9D1bH0aSeSUSM+Q==",
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.6.0"
"@fortawesome/fontawesome-common-types": "6.7.2"
},
"engines": {
"node": ">=6"
@@ -634,20 +631,10 @@
"node": ">=6"
}
},
"node_modules/@fortawesome/pro-regular-svg-icons/node_modules/@fortawesome/fontawesome-common-types": {
"version": "6.7.2",
"resolved": "https://npm.fontawesome.com/@fortawesome/fontawesome-common-types/-/6.7.2/fontawesome-common-types-6.7.2.tgz",
"integrity": "sha512-Zs+YeHUC5fkt7Mg1l6XTniei3k4bwG/yo3iFUtZWd/pMx9g3fdvkSK9E0FOC+++phXOka78uJcYb8JaFkW52Xg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/pro-solid-svg-icons": {
"version": "6.7.2",
"resolved": "https://npm.fontawesome.com/@fortawesome/pro-solid-svg-icons/-/6.7.2/pro-solid-svg-icons-6.7.2.tgz",
"integrity": "sha512-SY2aP8d7uBlg1rNYznA9w9SVnnQhu3x/C7CCKQYcOGOC1YtPPHgIJhxKGbSzm6RkeJs1HVEfAkORX3J0XB5K9Q==",
"dev": true,
"license": "UNLICENSED",
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.7.2"
@@ -656,22 +643,10 @@
"node": ">=6"
}
},
"node_modules/@fortawesome/pro-solid-svg-icons/node_modules/@fortawesome/fontawesome-common-types": {
"version": "6.7.2",
"resolved": "https://npm.fontawesome.com/@fortawesome/fontawesome-common-types/-/6.7.2/fontawesome-common-types-6.7.2.tgz",
"integrity": "sha512-Zs+YeHUC5fkt7Mg1l6XTniei3k4bwG/yo3iFUtZWd/pMx9g3fdvkSK9E0FOC+++phXOka78uJcYb8JaFkW52Xg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/react-fontawesome": {
"version": "0.2.2",
"resolved": "https://npm.fontawesome.com/@fortawesome/react-fontawesome/-/0.2.2/react-fontawesome-0.2.2.tgz",
"integrity": "sha512-EnkrprPNqI6SXJl//m29hpaNzOp1bruISWaOiRtkMi/xSvHJlzc2j2JAYS7egxt/EbjSNV/k6Xy0AQI6vB2+1g==",
"dev": true,
"license": "MIT",
"dependencies": {
"prop-types": "^15.8.1"
},
@@ -2470,6 +2445,11 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/dayjs": {
"version": "1.11.13",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz",
"integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg=="
},
"node_modules/debug": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz",
@@ -9358,19 +9338,6 @@
"node": ">= 14"
}
},
"node_modules/yarn": {
"version": "1.22.22",
"resolved": "https://registry.npmjs.org/yarn/-/yarn-1.22.22.tgz",
"integrity": "sha512-prL3kGtyG7o9Z9Sv8IPfBNrWTDmXB4Qbes8A9rEzt6wkJV8mUvoirjU0Mp3GGAU06Y0XQyA3/2/RQFVuK7MTfg==",
"hasInstallScript": true,
"bin": {
"yarn": "bin/yarn.js",
"yarnpkg": "bin/yarn.js"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+6 -6
View File
@@ -12,13 +12,17 @@
},
"dependencies": {
"@devnomic/marquee": "^1.0.3",
"@fortawesome/free-brands-svg-icons": "^6.6.0",
"@fortawesome/fontawesome-svg-core": "^6.7.2",
"@fortawesome/free-brands-svg-icons": "^6.7.2",
"@fortawesome/pro-regular-svg-icons": "^6.7.2",
"@fortawesome/pro-solid-svg-icons": "^6.7.2",
"@fortawesome/react-fontawesome": "^0.2.2",
"@headlessui/react": "^2.1.3",
"@preact/signals-react": "^2.2.0",
"@tauri-apps/api": "^2.2.0",
"@techstark/opencv-js": "^4.10.0-release.1",
"@zip.js/zip.js": "^2.7.52",
"dayjs": "^1.11.13",
"fortawesome": "^0.0.1-security",
"konva": "^9.3.14",
"onnxruntime-web": "^1.19.2",
@@ -31,14 +35,10 @@
"react-router-dom": "^6.26.1",
"sonner": "^2.0.1",
"tailwind-merge": "^2.5.2",
"uuid": "^10.0.0",
"yarn": "^1.22.22"
"uuid": "^10.0.0"
},
"devDependencies": {
"@eslint/js": "^9.9.0",
"@fortawesome/fontawesome-svg-core": "^6.6.0",
"@fortawesome/pro-solid-svg-icons": "^6.7.2",
"@fortawesome/react-fontawesome": "^0.2.2",
"@preact/signals-react-transform": "^0.4.0",
"@tauri-apps/cli": "^2.4.0",
"@types/deep-equal": "^1.0.4",
-2
View File
@@ -338,8 +338,6 @@ export class Engine {
});
uiEvents.toolbarMain.SAVE.onClick(async (/*event*/) => {
//document.location.href = "https://auth.openai.com/log-in";
//console.log('save clicked');
//await this.realTimeDrawEngine.saveOutput(); // This works.
await invoke("open_login_command");
});
@@ -27,6 +27,7 @@ import {
import { ensureBase64Prefix } from "../EngineUtitlities/Base64Helpers";
import { DecodeBase64ToImage } from "~/utilities/DecodeBase64ToImage.ts";
import { EncodeImageBitmapToBase64 } from "~/utilities/EncodeImageBitmapToBase64.ts";
import { setCanvasRenderBitmap } from "~/signal/canvasRenderBitmap";
interface ServerSettings {
model_path: string;
@@ -789,6 +790,11 @@ export class RealTimeDrawEngine {
const base64Bitmap = await this.imageBitmapToBase64(
this.lastRenderedBitmap,
);
const generateResponse = await invoke("image_generation_command", {
image: base64Bitmap,
prompt: this.currentPrompt,
});
}
public async render() {
@@ -822,18 +828,9 @@ export class RealTimeDrawEngine {
this.lastRenderedBitmap = bitmap;
setCanvasRenderBitmap(bitmap);
try {
//const base64Bitmap = await this.imageBitmapToBase64(bitmap);
//const base64BitmapResponse = await invoke("image_generation_command", {
// image: base64Bitmap,
// prompt: this.currentPrompt,
//});
////console.log(base64BitmapResponse);
//const decoded = await DecodeBase64ToImage(
// base64BitmapResponse as string,
//);
//this.outputBitmap = decoded;
//this.previewCanvas.image(decoded);
} catch (error) {
console.error("Error during image processing:", error);
} finally {
+33 -88
View File
@@ -1,8 +1,9 @@
import { useState, useRef, useEffect } from "react";
import { invoke } from "@tauri-apps/api/core";
import { useSignals } from "@preact/signals-react/runtime";
import {
UploaderStates,
UploaderState,
// UploaderState,
uploadImage,
} from "~/components/UploadImage";
@@ -12,11 +13,8 @@ import { Button } from "~/components/reusable/Button";
import { ToggleButton } from "~/components/reusable/ToggleButton";
import {
faCamera,
faMessageXmark,
faMessageCheck,
faDownload,
faSave,
faSparkles,
faSpinnerThird,
faTimes,
@@ -30,7 +28,8 @@ import {
faSquare,
faRectangle,
} from "@fortawesome/pro-regular-svg-icons";
import { getCanvasRenderBitmap } from "~/signal/canvasRenderBitmap";
import { EncodeImageBitmapToBase64 } from "~/utilities/EncodeImageBitmapToBase64";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { PopoverItem } from "~/components/reusable/Popover";
@@ -41,13 +40,10 @@ interface ReferenceImage {
mediaToken: string;
}
interface ExtendedPopoverItem extends PopoverItem {
id: string;
focalLength: number;
}
export const PromptBox = () => {
useSignals();
//const { lastRenderedBitmap } = useCanvasSignal();
const [prompt, setPrompt] = useState("");
const [isEnqueueing, setisEnqueueing] = useState(false);
const [useSystemPrompt, setUseSystemPrompt] = useState(true);
@@ -55,7 +51,6 @@ export const PromptBox = () => {
const [uploadingImages, setUploadingImages] = useState<
{ id: string; file: File }[]
>([]);
const [isCameraSettingsOpen, setIsCameraSettingsOpen] = useState(false);
const [aspectRatioList, setAspectRatioList] = useState<PopoverItem[]>([
{
label: "3:2",
@@ -78,22 +73,6 @@ export const PromptBox = () => {
icon: <FontAwesomeIcon icon={faRectangleWide} className="h-4 w-4" />,
},
]);
const [cameraList, setCameraList] = useState<ExtendedPopoverItem[]>([
{
id: "main",
label: "Main View",
selected: true,
icon: <FontAwesomeIcon icon={faCamera} className="h-4 w-4" />,
focalLength: 35,
},
{
id: "cam2",
label: "Camera 2",
selected: false,
icon: <FontAwesomeIcon icon={faCamera} className="h-4 w-4" />,
focalLength: 35,
},
]);
const fileInputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -105,41 +84,6 @@ export const PromptBox = () => {
}
}, [prompt]);
// Update aspect ratio list based on the current cameraAspectRatio signal
useEffect(() => {
setAspectRatioList((prev) => prev);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleCameraSelect = (selectedItem: PopoverItem) => {};
const handleAddCamera = () => {
const newIndex = cameraList.length + 1;
const newId = `cam${newIndex}`;
setCameraList((prev) => [
...prev,
{
id: newId,
label: `Camera ${newIndex}`,
selected: false,
icon: <FontAwesomeIcon icon={faCamera} className="h-4 w-4" />,
focalLength: 35,
},
]);
};
const handleCameraNameChange = (id: string, newName: string) => {
setCameraList((prev) =>
prev.map((cam) => (cam.id === id ? { ...cam, label: newName } : cam)),
);
};
const handleCameraFocalLengthChange = (id: string, value: number) => {
setCameraList((prev) =>
prev.map((cam) => (cam.id === id ? { ...cam, focalLength: value } : cam)),
);
};
const handleAspectRatioSelect = (selectedItem: PopoverItem) => {
setAspectRatioList((prev) =>
prev.map((item) => ({
@@ -147,15 +91,6 @@ export const PromptBox = () => {
selected: item.label === selectedItem.label,
})),
);
// Map the selected label to the corresponding CameraAspectRatio enum value
// Publish the change to the engine
// Queue.publish({
// queueName: QueueNames.TO_ENGINE,
// action: toEngineActions.CHANGE_CAMERA_ASPECT_RATIO,
// data: newRatio,
// });
};
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
@@ -246,25 +181,42 @@ export const PromptBox = () => {
setisEnqueueing(true);
try {
// Here we would pass both the prompt and reference images to the generation
console.log(
"Enqueuing with prompt:",
prompt,
"and reference images:",
referenceImages,
);
let image = getCanvasRenderBitmap();
if (image === undefined) {
return;
}
const base64Bitmap = await EncodeImageBitmapToBase64(image);
const generateResponse = await invoke("image_generation_command", {
image: base64Bitmap,
prompt: prompt,
});
await new Promise((resolve) => setTimeout(resolve, 2000));
} finally {
setisEnqueueing(false);
}
};
// Get the current aspect ratio icon based on the cameraAspectRatio signal
const getCurrentAspectRatioIcon = () => {};
const getCurrentAspectRatioIcon = () => {
const selected = aspectRatioList.find((item) => item.selected);
if (!selected || !selected.icon) return faRectangle;
const iconElement = selected.icon as React.ReactElement;
return iconElement.props.icon;
};
const handleSaveFrame = async () => {};
// const handleSaveFrame = async () => {};
const handleDownloadFrame = () => {};
// const handleDownloadFrame = () => {};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
@@ -389,13 +341,6 @@ export const PromptBox = () => {
}
/>
</Tooltip>
<Tooltip
content="Camera"
position="top"
className="z-50"
delay={300}
closeOnClick={true}
></Tooltip>
<Tooltip
content={
useSystemPrompt
@@ -415,7 +360,7 @@ export const PromptBox = () => {
</Tooltip>
</div>
<div className="flex items-center gap-2">
<Tooltip
{/* <Tooltip
content="Download frame"
position="top"
className="z-50"
@@ -428,18 +373,18 @@ export const PromptBox = () => {
icon={faDownload}
onClick={handleDownloadFrame}
/>
</Tooltip>
</Tooltip> */}
<Button
{/* <Button
className="flex items-center border-none bg-[#5F5F68]/60 px-3 text-sm text-white backdrop-blur-lg hover:bg-[#5F5F68]/90"
variant="secondary"
icon={faSave}
onClick={handleSaveFrame}
>
Save frame
</Button>
</Button> */}
<Button
className="bg-brand-primary flex items-center border-none px-3 text-sm text-white disabled:cursor-not-allowed disabled:opacity-50"
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()}
@@ -1,50 +1,15 @@
import {
faRectangle,
faRectanglePortrait,
faSave,
faSquare,
} from "@fortawesome/pro-solid-svg-icons";
import { twMerge } from "tailwind-merge";
import { Button } from "~/components/ui/Button";
import { toolbarMain } from "~/signals/uiAccess/toolbarMain";
import { dispatchers } from "~/signals/uiEvents/toolbarMain";
import { ToolbarMainButtonNames } from "../ToolbarMain/enum";
import { ToggleButton } from "~/components/ui/ToggleButton";
import { useState } from "react";
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";
export const ToolbarTopRight = () => {
const saveButtonState =
toolbarMain.signal.value.buttonStates[ToolbarMainButtonNames.SAVE];
const [aspectRatio, setAspectRatio] = useState("1:1");
return (
<div
className={twMerge("relative z-50 flex h-fit w-fit items-center gap-2")}
>
{import.meta.env.DEV && ( // only show in dev mode for now
<ToggleButton
label="Ratio"
value={aspectRatio}
onChange={setAspectRatio}
options={["1:1", "3:2", "2:3"]}
icons={{
"1:1": faSquare,
"3:2": faRectangle,
"2:3": faRectanglePortrait,
}}
/>
)}
<Button
icon={faSave}
onClick={dispatchers[ToolbarMainButtonNames.SAVE]}
disabled={saveButtonState?.disabled}
>
Save Image
</Button>
<Activity />
</div>
);
};
@@ -1,5 +1,10 @@
import { ReactNode } from "react";
import { Popover, Transition } from "@headlessui/react";
import {
Popover,
PopoverButton,
PopoverPanel,
Transition,
} from "@headlessui/react";
import { twMerge } from "tailwind-merge";
import { Button } from "../Button";
@@ -83,11 +88,11 @@ export const PopoverMenu = ({
<Popover className="relative">
{({ close }) => (
<>
<Popover.Button className={className}>
<PopoverButton className={className}>
{triggerIcon}
{mode === "toggle" && selectedItem ? selectedItem.label : null}
{mode === "default" && triggerLabel ? triggerLabel : null}
</Popover.Button>
</PopoverButton>
<div
className={twMerge(
@@ -112,7 +117,7 @@ export const PopoverMenu = ({
: "-translate-y-2 opacity-0"
}
>
<Popover.Panel
<PopoverPanel
className={twMerge(
position === "bottom" ? "origin-top" : "origin-bottom",
)}
@@ -189,7 +194,7 @@ export const PopoverMenu = ({
</div>
)}
</div>
</Popover.Panel>
</PopoverPanel>
</Transition>
</div>
</>
@@ -22,7 +22,7 @@ export const ToggleButton = ({
className={twMerge(
"flex h-9 w-9 items-center border-2 border-transparent text-sm text-white backdrop-blur-lg transition-all",
isActive
? "bg-brand-primary/40 hover:bg-brand-primary/40 border-white/20 hover:border-white/30"
? "border-white/20 bg-primary/40 hover:border-white/30 hover:bg-primary/40"
: "bg-[#5F5F68]/60 hover:bg-[#5F5F68]/90",
className,
)}
@@ -0,0 +1,154 @@
import { useEffect, useState } from "react";
import { faBell, faSpinnerThird } from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { PopoverMenu } from "~/components/reusable/Popover";
import { CompletedCard } from "./CompletedCard";
import { InProgressCard } from "./InProgressCard";
// Dummy data types
interface CompletedItem {
token: string;
maybe_title: string;
public_bucket_path: string;
updated_at: string;
}
interface ActiveJob {
job_token: string;
request: {
maybe_model_title: string;
};
status: {
status: string;
progress_percentage: number;
};
}
// Dummy data for testing
const dummyCompletedItems: CompletedItem[] = [
{
token: "1",
maybe_title: "Completed Item 1",
public_bucket_path: "/test1",
updated_at: new Date().toISOString(),
},
{
token: "2",
maybe_title: "Completed Item 2",
public_bucket_path: "/test2",
updated_at: new Date().toISOString(),
},
];
const dummyJobs: ActiveJob[] = [
{
job_token: "job1",
request: {
maybe_model_title: "In Progress Job 1",
},
status: {
status: "STARTED",
progress_percentage: 45,
},
},
{
job_token: "job2",
request: {
maybe_model_title: "In Progress Job 2",
},
status: {
status: "PENDING",
progress_percentage: 0,
},
},
];
export function Activity() {
const [completedItems, setCompletedItems] = useState<CompletedItem[]>([]);
const [jobs, setJobs] = useState<ActiveJob[]>([]);
const [loading, setLoading] = useState(true);
// Dummy polling function
useEffect(() => {
const pollData = () => {
// Simulate API calls
setCompletedItems(dummyCompletedItems);
setJobs(dummyJobs);
setLoading(false);
};
// Initial load
pollData();
// Set up polling interval (every 5 seconds)
const interval = setInterval(pollData, 5000);
return () => clearInterval(interval);
}, []);
return (
<PopoverMenu
mode="default"
buttonClassName="h-[42px] px-4"
panelClassName="w-[360px] p-2"
position="bottom"
align="end"
triggerIcon={
<div>
<span className="text-md flex items-center gap-2">
<>
{jobs.length > 0 ? (
<FontAwesomeIcon
icon={faSpinnerThird}
className="animate-spin"
/>
) : (
<FontAwesomeIcon icon={faBell} />
)}
Activity
</>
</span>
{jobs.length > 0 && (
<div className="absolute -right-1 -top-1 h-3 w-3 animate-pulse rounded-full bg-blue-500" />
)}
</div>
}
>
<div className="max-h-[480px] overflow-y-auto">
{/* In progress */}
{jobs.length > 0 && (
<div>
{jobs.map((job) => (
<InProgressCard key={job.job_token} job={job} />
))}
</div>
)}
{loading ? (
<div className="flex h-48 w-full flex-col justify-center gap-4 p-4 text-center align-middle">
<FontAwesomeIcon
icon={faSpinnerThird}
spin
size="2x"
className="text-gray-400"
/>
<h3 className="text-gray-300">Retrieving Activities</h3>
</div>
) : completedItems.length === 0 && jobs.length === 0 ? (
<div className="flex h-48 w-full flex-col justify-center gap-4 p-4 text-center align-middle">
<h3 className="text-gray-300">No activities yet</h3>
</div>
) : (
<div>
{/* Completed */}
<div className="flex flex-col">
{completedItems.map((item) => (
<CompletedCard key={item.token} job={item} />
))}
</div>
</div>
)}
</div>
</PopoverMenu>
);
}
@@ -0,0 +1,89 @@
import { useState } from "react";
import dayjs from "dayjs";
import { BaseDialog } from "../BaseDialog";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faXmark } from "@fortawesome/pro-solid-svg-icons";
interface Props {
job: {
token: string;
maybe_title: string;
public_bucket_path: string;
updated_at: string;
maybe_style_name?: string;
};
}
export function CompletedCard({ job }: Props) {
const [loadError, setLoadError] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
const imageUrl = job.public_bucket_path + "-thumb.gif";
const fullImageUrl = job.public_bucket_path;
return (
<>
<div
className="flex w-full items-center justify-between rounded-lg p-2 text-start transition-all duration-150 hover:cursor-pointer hover:bg-white/10"
onClick={() => setIsModalOpen(true)}
>
<div className="flex gap-4">
<div className="flex aspect-square h-14 w-14 justify-center overflow-hidden rounded-lg border border-[#A9A9A9]/50 bg-black/60">
<img
src={
loadError ? "/resources/images/movie-placeholder.png" : imageUrl
}
className="h-full w-full object-cover"
alt={job.maybe_title ?? "unknown"}
crossOrigin="anonymous"
onError={() => setLoadError(true)}
loading="lazy"
/>
</div>
<div className="flex flex-col justify-center gap-1">
<div className="font-medium">{job.maybe_title || "Untitled"}</div>
<div className="text-sm text-white/60">
{dayjs(job.updated_at).format("MMM D, YYYY HH:mm:ss")}
</div>
</div>
</div>
</div>
<BaseDialog
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
className="h-[80vh] w-[75vw] max-w-[75vw] p-0"
>
<div className="relative grid h-full grid-cols-3 gap-6">
<button
onClick={() => setIsModalOpen(false)}
className="absolute right-4 top-4 z-10 flex h-7 w-7 items-center justify-center rounded-full bg-black/40 text-white/60 transition-all hover:bg-black/70 hover:text-white"
>
<FontAwesomeIcon icon={faXmark} className="text-lg" />
</button>
<div className="col-span-2 flex h-full items-center justify-center overflow-hidden rounded-l-xl bg-black/40">
<img
src={fullImageUrl}
className="h-full w-full object-contain"
alt={job.maybe_title ?? "unknown"}
crossOrigin="anonymous"
onError={() => setLoadError(true)}
/>
</div>
<div className="flex flex-col gap-4 py-5">
<div className="text-xl font-medium">
{job.maybe_title || "Untitled"}
</div>
<div className="text-sm text-white/60">
Created: {dayjs(job.updated_at).format("MMM D, YYYY HH:mm:ss")}
</div>
{job.maybe_style_name && (
<div className="text-sm text-white/60">
Style: {job.maybe_style_name}
</div>
)}
</div>
</div>
</BaseDialog>
</>
);
}
@@ -0,0 +1,78 @@
import { useCallback } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faClose,
faCircleExclamation,
faSpinnerThird,
} from "@fortawesome/pro-solid-svg-icons";
interface Props {
job: {
job_token: string;
request: {
maybe_model_title: string;
};
status: {
status: string;
progress_percentage: number;
};
};
}
function getPercent(status: string, percentage: number) {
if (["COMPLETE_FAILURE", "ATTEMPT_FAILED", "DEAD"].includes(status)) {
return 0;
} else {
return percentage;
}
}
export function InProgressCard({ job }: Props) {
const completePercent = getPercent(
job.status.status,
job.status.progress_percentage,
);
const deleteJob = useCallback(async (jobToDelete: Props["job"]) => {
// Simulate API call
console.log("Deleting job:", jobToDelete.job_token);
}, []);
return (
<div className="flex w-full items-center justify-between rounded-lg p-2 text-start transition-all duration-150 hover:bg-white/10">
<div className="flex gap-4">
<div className="flex aspect-square h-14 w-14 items-center justify-center overflow-hidden rounded-lg border border-[#A9A9A9]/50 bg-black/60">
{(job.status.status === "STARTED" ||
job.status.status === "PENDING") && (
<FontAwesomeIcon icon={faSpinnerThird} spin size={"lg"} />
)}
{(job.status.status === "COMPLETE_FAILURE" ||
job.status.status === "ATTEMPT_FAILED") && (
<FontAwesomeIcon icon={faCircleExclamation} size={"lg"} />
)}
</div>
<div className="flex flex-col justify-center gap-1">
<div className="font-medium">
{job.request.maybe_model_title || "Untitled"}
</div>
<div className="text-sm capitalize text-white/60">
{job.status.status.split("_").join(" ")}... {completePercent}%
</div>
</div>
</div>
{job.status.status !== "STARTED" && (
<div className="pr-5">
<button
onClick={() => deleteJob(job)}
className="text-[15px] font-medium text-white/50 transition-all duration-150 hover:text-white/100"
title="Cancel"
>
<FontAwesomeIcon icon={faClose} />
</button>
</div>
)}
</div>
);
}
@@ -0,0 +1 @@
export { Activity } from "./Activity";
@@ -33,7 +33,7 @@ export const BaseDialog = ({
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-black/30" aria-hidden="true" />
<div className="fixed inset-0 bg-black/60" aria-hidden="true" />
</TransitionChild>
<div className="fixed inset-0 flex items-center justify-center p-4">
@@ -0,0 +1,15 @@
import {
signal,
} from "@preact/signals-react";
export type MaybeCanvasRenderBitmapType = ImageBitmap | undefined;
const canvasRenderBitmap = signal<MaybeCanvasRenderBitmapType>(undefined);
export const setCanvasRenderBitmap = (bitmap: ImageBitmap) => {
canvasRenderBitmap.value = bitmap;
}
export const getCanvasRenderBitmap = () : MaybeCanvasRenderBitmapType => {
return canvasRenderBitmap.value;
}
+1 -1
View File
@@ -22,7 +22,7 @@ class BucketConfig {
//const media_api_base_url = environmentVariables.values.GOOGLE_API;
//return `${media_api_base_url}/${bucket}${path}`;
const media_api_base_url = GetCdnOrigin();
return `${media_api_base_url}/${path}`;
return `${media_api_base_url}${path}`;
}
//private getBucket(): string {
@@ -0,0 +1,109 @@
import React, { useRef, useEffect, useState } from "react";
import { Tab } from "@headlessui/react";
import { twMerge } from "tailwind-merge";
import { Tooltip } from "~/components";
export interface TabItem {
id: string;
label: string;
}
interface TabSelectorProps {
tabs: TabItem[];
activeTab: string;
onTabChange: (tabId: string) => void;
className?: string;
disabled?: boolean;
disabledMessage?: string;
}
export const TabSelector: React.FC<TabSelectorProps> = ({
tabs,
activeTab,
onTabChange,
className,
disabled,
disabledMessage,
}) => {
// Find the index of the active tab
const selectedIndex = tabs.findIndex((tab) => tab.id === activeTab);
const tabsRef = useRef<(HTMLElement | null)[]>([]);
const [indicatorStyle, setIndicatorStyle] = useState({
left: 0,
width: 0,
});
// Handle tab change
const handleTabChange = (index: number) => {
onTabChange(tabs[index].id);
};
// Update the indicator position when the selected tab changes
useEffect(() => {
if (selectedIndex >= 0 && tabsRef.current[selectedIndex]) {
const currentTab = tabsRef.current[selectedIndex];
if (currentTab) {
setIndicatorStyle({
left: currentTab.offsetLeft,
width: currentTab.offsetWidth,
});
}
}
}, [selectedIndex, tabs]);
const TabGroupElement = (
<div
className={twMerge(
"w-full",
className,
disabled ? "cursor-not-allowed opacity-60" : "",
)}
>
<Tab.Group selectedIndex={selectedIndex} onChange={handleTabChange}>
<Tab.List className="glass relative inline-flex min-w-fit overflow-x-auto rounded-lg p-0.5 py-1">
{/* Animated indicator */}
<div
className="absolute top-1 z-10 h-[calc(100%-8px)] rounded-md bg-brand-primary/30 transition-all duration-200 ease-in-out"
style={{
left: indicatorStyle.left,
width: indicatorStyle.width,
}}
/>
{tabs.map((tab, index) => (
<Tab
key={tab.id}
ref={(el) => (tabsRef.current[index] = el)}
disabled={disabled}
className={({ selected }) =>
twMerge(
"relative z-20 mx-0.5 min-w-max rounded-md border-2 border-transparent px-4 py-0.5 text-center text-sm font-semibold transition-all duration-200 ease-in-out",
selected ? "text-white" : "text-gray-300 hover:text-white",
disabled ? "cursor-not-allowed opacity-60" : "",
)
}
>
{tab.label}
</Tab>
))}
</Tab.List>
</Tab.Group>
</div>
);
return (
<>
{disabled && (
<Tooltip
content={
disabledMessage ?? "Cannot change tab. Generation in progress."
}
position="bottom"
>
{TabGroupElement}
</Tooltip>
)}
{!disabled && TabGroupElement}
</>
);
};
@@ -5,6 +5,8 @@ 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 { useState } from "react";
function isEditorPath(path: string) {
if (path === "/") return true;
@@ -20,45 +22,65 @@ export const TopBar = ({ pageName }: Props) => {
useLocation().pathname,
useParams(),
);
const [isLibraryModalOpen, setIsLibraryModalOpen] = useState(false);
const [activeLibraryTab, setActiveLibraryTab] = useState("my-media");
return (
<header className="fixed left-0 top-0 z-30 w-full border-b border-ui-panel-border bg-ui-background">
<nav
className="mx-auto grid h-[64px] w-screen grid-cols-3 items-center justify-between p-3"
aria-label="Global"
>
<div className="flex gap-4">
<a href="/" className="">
<span className="sr-only">Storyteller.ai</span>
<img
className="h-[39px] w-auto pb-[3px]"
src="/resources/images/Storyteller-Logo-1.png"
alt="Logo StoryTeller.ai"
/>
</a>
{!isEditorPath(currentLocation) && (
<ButtonLink to={"/"} variant="secondary" icon={faChevronLeft}>
Back to Editor
</ButtonLink>
)}
</div>
<div className="flex items-center justify-center gap-2 font-medium">
<SceneTitleInput pageName={pageName} />
</div>
<div className="flex justify-end gap-5">
<div className="flex gap-2">
<Button variant="secondary" icon={faImages} disabled>
My Library
</Button>
<Activity />
<>
<header className="fixed left-0 top-0 z-30 w-full border-b border-ui-panel-border bg-ui-background">
<nav
className="mx-auto grid h-[64px] w-screen grid-cols-3 items-center justify-between p-3"
aria-label="Global"
>
<div className="flex gap-4">
<a href="/" className="">
<span className="sr-only">Storyteller.ai</span>
<img
className="h-[39px] w-auto pb-[3px]"
src="/resources/images/Storyteller-Logo-1.png"
alt="Logo StoryTeller.ai"
/>
</a>
{!isEditorPath(currentLocation) && (
<ButtonLink to={"/"} variant="secondary" icon={faChevronLeft}>
Back to Editor
</ButtonLink>
)}
</div>
<div className="flex justify-end gap-2">
<AuthButtons />
<div className="flex items-center justify-center gap-2 font-medium">
<SceneTitleInput pageName={pageName} />
</div>
</div>
</nav>
</header>
<div className="flex justify-end gap-5">
<div className="flex gap-2">
<Button
variant="secondary"
icon={faImages}
onClick={() => setIsLibraryModalOpen(true)}
>
My Library
</Button>
<Activity />
</div>
<div className="flex justify-end gap-2">
<AuthButtons />
</div>
</div>
</nav>
</header>
<LibraryModal
isOpen={isLibraryModalOpen}
onClose={() => setIsLibraryModalOpen(false)}
mode="view"
tabs={[
{ id: "my-media", label: "My media" },
{ id: "uploads", label: "Uploads" },
]}
activeTab={activeLibraryTab}
onTabChange={setActiveLibraryTab}
/>
</>
);
};
@@ -0,0 +1,367 @@
import { TransitionDialogue } from "~/components/reusable/TransitionDialogue";
import { faCheck, faXmark } from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button, LoadingSpinner } from "~/components";
import { TabSelector } from "~/components/reusable/TabSelector";
import { useState, useEffect, Fragment } from "react";
import { LibraryModalApi } from "./LibraryModalApi";
import { FilterMediaClasses } from "../../../../enums";
import { BucketConfig } from "../../../../api/BucketConfig";
import { twMerge } from "tailwind-merge";
import { createPortal } from "react-dom";
import { Transition } from "@headlessui/react";
export interface LibraryItem {
id: string;
label: string;
thumbnail: string | null;
fullImage?: string | null;
createdAt: string;
}
interface GroupedItems {
[date: string]: LibraryItem[];
}
type ModalMode = "select" | "view";
interface LibraryModalProps {
isOpen: boolean;
onClose: () => void;
mode: ModalMode;
selectedItemIds?: string[];
onSelectItem?: (id: string) => void;
maxSelections?: number;
onUseSelected?: (selectedItems: LibraryItem[]) => void;
tabs: { id: string; label: string }[];
activeTab: string;
onTabChange: (tabId: string) => void;
}
export const LibraryModal = ({
isOpen,
onClose,
mode = "view",
selectedItemIds = [],
onSelectItem,
maxSelections = 4,
onUseSelected,
tabs,
activeTab,
onTabChange,
}: LibraryModalProps) => {
const [groupedItems, setGroupedItems] = useState<GroupedItems>({});
const [loading, setLoading] = useState(false);
const [lightboxImage, setLightboxImage] = useState<LibraryItem | null>(null);
const [isLightboxVisible, setIsLightboxVisible] = useState(false);
const [failedImageUrls] = useState<Set<string>>(new Set());
const bucketConfig = new BucketConfig();
const api = new LibraryModalApi();
const formatDate = (date: string) => {
const d = new Date(date);
return d.toLocaleDateString("en-US", {
weekday: "short",
month: "short",
day: "numeric",
});
};
const groupItemsByDate = (items: LibraryItem[]) => {
const grouped = items.reduce((acc: GroupedItems, item) => {
const dateKey = formatDate(item.createdAt);
if (!acc[dateKey]) {
acc[dateKey] = [];
}
acc[dateKey].push(item);
return acc;
}, {});
// Sort dates in descending order
return Object.fromEntries(
Object.entries(grouped).sort(
(a, b) =>
new Date(b[1][0].createdAt).getTime() -
new Date(a[1][0].createdAt).getTime(),
),
);
};
const handleImageError = (url: string) => {
failedImageUrls.add(url);
};
const getImageUrl = (path: string | undefined | null) => {
if (!path) return null;
const url = bucketConfig.getGcsUrl(path);
return failedImageUrls.has(url) ? null : url;
};
useEffect(() => {
const loadItems = async () => {
setLoading(true);
try {
const response = await api.listUserMediaFiles({
filter_media_classes: [FilterMediaClasses.IMAGE],
});
if (response.success && response.data) {
const newItems = response.data.map((item) => ({
id: item.token,
label: item.maybe_title || "Untitled",
thumbnail: getImageUrl(
item.cover_image.maybe_cover_image_public_bucket_path ||
item.public_bucket_path,
),
fullImage: getImageUrl(item.public_bucket_path),
createdAt: item.created_at,
}));
setGroupedItems(groupItemsByDate(newItems));
}
} catch (error) {
console.error("Failed to fetch library items:", error);
}
setLoading(false);
};
if (isOpen) {
loadItems();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTab, isOpen]);
const handleItemClick = (item: LibraryItem) => {
if (mode === "select" && onSelectItem) {
onSelectItem(item.id);
} else {
setLightboxImage(item);
setIsLightboxVisible(true);
}
};
const handleCloseLightbox = () => {
setIsLightboxVisible(false);
// Wait for animation to complete before removing the image
setTimeout(() => setLightboxImage(null), 300);
};
return (
<>
<TransitionDialogue
isOpen={isOpen}
onClose={onClose}
className={twMerge(
"h-[620px] max-w-4xl",
mode === "view" && "h-[80vh] max-h-[870px] max-w-7xl",
)}
childPadding={false}
>
<div className="flex h-full flex-col">
<div className="border-b border-white/10 p-4">
<div className="grid grid-cols-3 items-center">
<h2 className="text-xl font-semibold">
{mode === "select" ? "Select Images" : "My Library"}
</h2>
<div className="flex items-center justify-center">
<TabSelector
tabs={tabs}
activeTab={activeTab}
onTabChange={onTabChange}
className="w-auto"
/>
</div>
<div className="flex justify-end">
<button
onClick={onClose}
className="flex h-7 w-7 items-center justify-center rounded-full bg-black/40 text-white/60 transition-all hover:bg-black/70 hover:text-white"
>
<FontAwesomeIcon icon={faXmark} className="text-lg" />
</button>
</div>
</div>
</div>
<div className="flex-1 overflow-y-auto p-4">
{loading ? (
<div className="flex h-full items-center justify-center">
<div className="text-white/60">
<LoadingSpinner className="h-12 w-12" />
</div>
</div>
) : (
<div className="space-y-6">
{Object.entries(groupedItems).map(([date, dateItems]) => (
<div key={date}>
<h3 className="text-md mb-2 font-medium text-white/60">
{date}
</h3>
<div
className={twMerge(
"grid grid-cols-5 gap-1",
mode === "view" && "grid-cols-5",
)}
>
{dateItems.map((item) => (
<button
key={item.id}
className={twMerge(
"group relative aspect-square overflow-hidden rounded-md border-[3px] transition-all",
mode === "select" &&
selectedItemIds.includes(item.id)
? "border-brand-primary"
: "border-transparent hover:border-white",
)}
onClick={() => handleItemClick(item)}
>
<div className="relative h-full w-full">
{!item.thumbnail ? (
<div className="flex h-full w-full items-center justify-center bg-gray-800">
<span className="text-white/60">
Image not available
</span>
</div>
) : (
<img
src={item.thumbnail}
alt={item.label}
className="h-full w-full object-cover"
onError={() =>
handleImageError(item.thumbnail!)
}
crossOrigin="anonymous"
referrerPolicy="no-referrer"
/>
)}
{mode === "select" &&
selectedItemIds.includes(item.id) && (
<div className="absolute inset-0 bg-white/30" />
)}
</div>
{mode === "select" &&
selectedItemIds.includes(item.id) && (
<div className="absolute right-2 top-2 flex h-6 w-6 items-center justify-center rounded-full bg-brand-primary">
<FontAwesomeIcon
icon={faCheck}
className="text-sm"
/>
</div>
)}
</button>
))}
</div>
</div>
))}
</div>
)}
</div>
{mode === "select" && (
<div className="flex items-center justify-between border-t border-white/10 bg-black/25 p-4">
<div className="flex items-center gap-3">
<div className="text-sm font-semibold text-white/80">
{selectedItemIds.length}/{maxSelections} selected
</div>
{selectedItemIds.length > 0 && (
<span className="text-white/10">|</span>
)}
{selectedItemIds.length > 0 && (
<button
onClick={() => {
selectedItemIds.forEach((id) => onSelectItem?.(id));
}}
className="text-sm text-white/60 hover:text-white"
>
Deselect All
</button>
)}
</div>
<Button
onClick={() => {
const selectedItems = Object.values(groupedItems)
.flat()
.filter((item) => selectedItemIds.includes(item.id));
onUseSelected?.(selectedItems);
}}
disabled={selectedItemIds.length === 0}
>
Use selected
</Button>
</div>
)}
</div>
</TransitionDialogue>
{/* Lightbox Modal */}
{lightboxImage &&
createPortal(
<Transition appear show={isLightboxVisible} as={Fragment}>
<div className="fixed inset-0 z-[100]">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div
className="fixed inset-0 cursor-pointer bg-black/60"
onClick={handleCloseLightbox}
/>
</Transition.Child>
<div
className="fixed inset-0 flex items-center justify-center p-4"
onClick={handleCloseLightbox}
>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<div
className="relative h-[90vh] w-[80vw] rounded-xl bg-[#2C2C2C]"
onClick={(e) => e.stopPropagation()}
>
<button
onClick={handleCloseLightbox}
className="absolute right-4 top-4 z-10 flex h-7 w-7 items-center justify-center rounded-full bg-black/40 text-white/60 transition-all hover:bg-black/70 hover:text-white"
>
<FontAwesomeIcon icon={faXmark} className="text-lg" />
</button>
<div className="grid h-full grid-cols-3 gap-6">
<div className="col-span-2 flex h-full items-center justify-center overflow-hidden bg-black/40">
{!lightboxImage.fullImage ? (
<div className="flex h-full w-full items-center justify-center bg-gray-800">
<span className="text-white/60">
Image not available
</span>
</div>
) : (
<img
src={lightboxImage.fullImage}
alt={lightboxImage.label}
className="h-full w-full object-contain"
onError={() =>
handleImageError(lightboxImage.fullImage!)
}
crossOrigin="anonymous"
referrerPolicy="no-referrer"
/>
)}
</div>
<div className="py-5">Prompt</div>
</div>
</div>
</Transition.Child>
</div>
</div>
</Transition>,
document.body,
)}
</>
);
};
@@ -0,0 +1,30 @@
import { MediaFilesApi } from "../../../../Classes/ApiManager/MediaFilesApi";
import {
FilterMediaClasses,
FilterMediaType,
FilterEngineCategories,
} from "../../../../enums";
interface ListUserMediaQuery {
include_user_uploads?: boolean;
filter_media_classes?: FilterMediaClasses[];
filter_media_type?: FilterMediaType[];
filter_engine_categories?: FilterEngineCategories[];
page?: number;
limit?: number;
}
export class LibraryModalApi extends MediaFilesApi {
constructor() {
super();
}
public async listUserMediaFiles(query: ListUserMediaQuery) {
return await this.ListUserMediaFiles({
...query,
filter_media_classes: query.filter_media_classes,
filter_media_type: query.filter_media_type,
filter_engine_categories: query.filter_engine_categories,
});
}
}
@@ -0,0 +1 @@
export * from "./LibraryModal";
@@ -38,6 +38,9 @@ import {
import { QueueNames } from "~/pages/PageEnigma/Queue/QueueNames";
import Queue from "~/pages/PageEnigma/Queue/Queue";
import { toEngineActions } from "~/pages/PageEnigma/Queue/toEngineActions";
import { LibraryModal } from "../LibraryModal/LibraryModal";
import { BucketConfig } from "../../../../api/BucketConfig";
import type { LibraryItem } from "../LibraryModal/LibraryModal";
interface ReferenceImage {
id: string;
@@ -107,6 +110,11 @@ export const PromptBox = () => {
const fileInputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [isCameraSettingsOpen, setIsCameraSettingsOpen] = useState(false);
const [isLibraryModalOpen, setIsLibraryModalOpen] = useState(false);
const [selectedLibraryImages, setSelectedLibraryImages] = useState<string[]>(
[],
);
const [activeLibraryTab, setActiveLibraryTab] = useState("my-media");
useEffect(() => {
if (textareaRef.current) {
@@ -267,7 +275,39 @@ export const PromptBox = () => {
};
const handleLibrarySelect = () => {
console.log("Opening library selector");
setIsLibraryModalOpen(true);
};
const handleLibraryClose = () => {
setIsLibraryModalOpen(false);
setSelectedLibraryImages([]);
};
const handleImageSelect = (id: string) => {
setSelectedLibraryImages((prev) => {
if (prev.includes(id)) {
return prev.filter((imageId) => imageId !== id);
}
if (prev.length >= 4) {
return prev;
}
return [...prev, id];
});
};
const handleLibraryImages = (selectedItems: LibraryItem[]) => {
selectedItems.forEach((item) => {
if (!item.fullImage) return;
const referenceImage: ReferenceImage = {
id: Math.random().toString(36).substring(7),
url: item.fullImage,
file: new File([], "library-image"),
mediaToken: item.id,
};
setReferenceImages((prev) => [...prev, referenceImage]);
});
setIsLibraryModalOpen(false);
setSelectedLibraryImages([]);
};
const handleAction = (action: string) => {
@@ -402,218 +442,238 @@ 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"
crossOrigin="anonymous"
referrerPolicy="no-referrer"
/>
<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: "library",
},
]}
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={() => {
disableHotkeyInput(DomLevels.INPUT);
}}
onBlur={() => {
enableHotkeyInput(DomLevels.INPUT);
}}
/>
</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="Camera"
position="top"
className="z-50"
delay={300}
closeOnClick={true}
>
<PopoverMenu
items={cameraList}
onSelect={handleCameraSelect}
onAdd={handleAddCamera}
triggerIcon={
<FontAwesomeIcon icon={faCamera} className="h-4 w-4" />
}
showAddButton
showIconsInList
mode="toggle"
panelTitle="Camera"
panelActionLabel="Settings"
onPanelAction={() => setIsCameraSettingsOpen(true)}
/>
</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">
<Tooltip
content="Download frame"
position="top"
className="z-50"
closeOnClick={true}
delay={200}
>
<Button
className="flex h-9 items-center border-none bg-[#5F5F68]/60 px-3 text-sm text-white backdrop-blur-lg hover:bg-[#5F5F68]/90"
variant="secondary"
icon={faDownload}
onClick={handleDownloadFrame}
/>
</Tooltip>
<Button
className="flex items-center border-none bg-[#5F5F68]/60 px-3 text-sm text-white backdrop-blur-lg hover:bg-[#5F5F68]/90"
variant="secondary"
icon={faSave}
onClick={handleSaveFrame}
>
Save frame
</Button>
<Button
className="flex items-center border-none bg-brand-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>
);
})}
) : (
"Generate"
)}
</Button>
</div>
</div>
</div>
)}
<div className="glass w-[730px] rounded-xl p-4">
<input
type="file"
ref={fileInputRef}
className="hidden"
accept="image/*"
onChange={handleFileUpload}
multiple
<CameraSettingsModal
isOpen={isCameraSettingsOpen}
onClose={() => setIsCameraSettingsOpen(false)}
cameras={cameraList}
onCameraNameChange={handleCameraNameChange}
onCameraFocalLengthChange={handleCameraFocalLengthChange}
onAddCamera={handleAddCamera}
selectedCameraId={selectedCameraId}
onSelectCamera={setSelectedCameraId}
onDeleteCamera={handleDeleteCamera}
/>
<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={() => {
disableHotkeyInput(DomLevels.INPUT);
}}
onBlur={() => {
enableHotkeyInput(DomLevels.INPUT);
}}
/>
</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="Camera"
position="top"
className="z-50"
delay={300}
closeOnClick={true}
>
<PopoverMenu
items={cameraList}
onSelect={handleCameraSelect}
onAdd={handleAddCamera}
triggerIcon={
<FontAwesomeIcon icon={faCamera} className="h-4 w-4" />
}
showAddButton
showIconsInList
mode="toggle"
panelTitle="Camera"
panelActionLabel="Settings"
onPanelAction={() => setIsCameraSettingsOpen(true)}
/>
</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">
<Tooltip
content="Download frame"
position="top"
className="z-50"
closeOnClick={true}
delay={200}
>
<Button
className="flex h-9 items-center border-none bg-[#5F5F68]/60 px-3 text-sm text-white backdrop-blur-lg hover:bg-[#5F5F68]/90"
variant="secondary"
icon={faDownload}
onClick={handleDownloadFrame}
/>
</Tooltip>
<Button
className="flex items-center border-none bg-[#5F5F68]/60 px-3 text-sm text-white backdrop-blur-lg hover:bg-[#5F5F68]/90"
variant="secondary"
icon={faSave}
onClick={handleSaveFrame}
>
Save frame
</Button>
<Button
className="flex items-center border-none bg-brand-primary px-3 text-sm text-white disabled:cursor-not-allowed disabled:opacity-50"
icon={!isEnqueueing ? faSparkles : undefined}
onClick={handleEnqueue}
disabled={isEnqueueing || !prompt.trim()}
>
{isEnqueueing ? (
<FontAwesomeIcon
icon={faSpinnerThird}
className="animate-spin text-lg"
/>
) : (
"Generate"
)}
</Button>
</div>
</div>
</div>
<CameraSettingsModal
isOpen={isCameraSettingsOpen}
onClose={() => setIsCameraSettingsOpen(false)}
cameras={cameraList}
onCameraNameChange={handleCameraNameChange}
onCameraFocalLengthChange={handleCameraFocalLengthChange}
onAddCamera={handleAddCamera}
selectedCameraId={selectedCameraId}
onSelectCamera={setSelectedCameraId}
onDeleteCamera={handleDeleteCamera}
<LibraryModal
isOpen={isLibraryModalOpen}
onClose={handleLibraryClose}
mode="select"
selectedItemIds={selectedLibraryImages}
onSelectItem={handleImageSelect}
maxSelections={4}
onUseSelected={handleLibraryImages}
tabs={[
{ id: "my-media", label: "My media" },
{ id: "uploads", label: "Uploads" },
]}
activeTab={activeLibraryTab}
onTabChange={setActiveLibraryTab}
/>
</div>
</>
);
};