mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-03 13:02:37 -05:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85819578ff | ||
|
|
2516534b3f | ||
|
|
b221aa5df6 | ||
|
|
ea783c14de | ||
|
|
01093918da | ||
|
|
2ea9464be2 | ||
|
|
b41b4f8e17 | ||
|
|
09cf4529c0 | ||
|
|
bf035f9048 | ||
|
|
448c760339 |
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Generated
+22
-55
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user