Compare commits

...
Author SHA1 Message Date
bflatastic 1ef7422ef5 fix 2025-05-01 23:53:37 +07:00
bflatastic c0327f82cb posthog fix 3d 2025-05-01 23:50:06 +07:00
bflatastic 083680232c editor2d posthog api key as env variable 2025-05-01 23:29:41 +07:00
Brandon Thomas b1b44ceb60 proxy requests through tauri to avoid 3rd party cookie ban (#565) 2025-05-01 04:04:06 -04:00
Brandon Thomas d988acbf6e document in code 2025-05-01 02:16:21 -04:00
Brandon Thomas 6e05b7a1b5 this seemingly avoids the startup race condition (see details)
There's an issue when the "main window thread" inquires about the
webview cookies shortly after app startup. When it attempts to dump the
cookies within a few milliseconds of app launch, it deadlocks and the
app never launches correctly. The entire webview goes blank and the app
freezes.

The timed waiting seems to fix this entirely.
2025-05-01 02:13:27 -04:00
Brandon Thomas 8a364605cf bug is still present, might be a race condition 2025-05-01 01:10:45 -04:00
Brandon Thomas 30a8e72efe comment out broken thread 2025-05-01 00:32:55 -04:00
Brandon Thomas ff1a83898b correct persistence behavior for window / storyteller cookies ? 2025-05-01 00:01:21 -04:00
Brandon Thomas f1b9299e46 memory store utilties to reuse 2025-04-30 23:43:24 -04:00
Brandon Thomas 2eefaaf7a1 storyteller cookie saving; temporarily broke window size persist 2025-04-30 22:31:20 -04:00
Brandon Thomas a21b4a4275 storyteller credentials store 2025-04-30 22:31:20 -04:00
ArEnScandMichael Chung e4c561eb41 Fixing imports and removing unused code and working on scene management. (#563)
Co-authored-by: Michael Chung <michaelchung@michaels-mbp-2.home>
2025-04-30 22:01:12 -04:00
Tanish Manku 420a23d359 Disable pointer lock controls if we detect safari webkit (#562) 2025-04-30 18:10:18 -07:00
Brandon Thomas 9cd3b493c6 command with info on platform 2025-04-30 20:13:12 -04:00
Tanish Manku bbd5953000 Use arrow keys to rotate cam (#560) 2025-04-30 16:41:08 -07:00
Brandon Thomas b346750968 intellij default project settings 2025-04-30 18:42:13 -04:00
Brandon Thomas e5749f0e77 storyteller api credentials WIP, but need to switch gears 2025-04-30 18:32:52 -04:00
bflatastic 826cf00cbb new videos for 2d and 3d 2025-04-30 16:52:55 +07:00
bflatastic 6b7d034068 added tauri2.conf.json 2025-04-30 15:39:18 +07:00
Brandon Thomas 7bd3f6f8c6 remove core-foundation for breaking windows 2025-04-30 03:42:32 -04:00
bflatastic 0c550fcb1a fixed empty snapshot data string 2025-04-30 14:11:41 +07:00
Brandon Thomas 3b2b7ede18 open sora login dialog on 3d 2025-04-30 03:10:29 -04:00
Brandon Thomas 782b68e9a2 move credentials dir 2025-04-30 02:58:57 -04:00
Brandon Thomas 5c23568646 refactor directory support 2025-04-30 02:41:51 -04:00
Brandon Thomas 43e781571f save window size between runs (super annoying to not have this) 2025-04-29 23:38:32 -04:00
Brandon Thomas 84361eb93e polling works 2025-04-29 21:59:00 -04:00
Brandon Thomas 38ab84d52e rename script 2025-04-29 20:07:06 -04:00
Brandon Thomas 82e87fa883 much better way to launch tauri dev (this was annoying to discover) 2025-04-29 20:04:56 -04:00
Brandon Thomas de5daa3f92 it's hard to build tauri in dev mode without it stepping on our feet 2025-04-29 19:37:19 -04:00
Brandon Thomas fc0fba7573 task draining / filtering 2025-04-29 17:34:49 -04:00
Brandon Thomas b199738ee0 auto-healing task polling, beginnings of a task polling thread 2025-04-29 16:29:08 -04:00
Tanish MankuandTanish Manku aa76113e8c Fix 3D performance decay after generation (#559)
Use 3JS size api instead of direct domElement access (avoids implicit
pixel density scaling)

Co-authored-by: Tanish Manku <-h>
2025-04-29 01:29:24 -07:00
Brandon Thomas bfe02a7880 dispatch to tauri or web on 3D 2025-04-29 00:29:36 -04:00
Brandon Thomas ac81de6a48 artcraft desktop: report discord status 2025-04-28 23:13:26 -04:00
Brandon Thomas 2c2a63e80b nit: fix metadata 2025-04-28 21:55:23 -04:00
Michael Chung 3521f73142 Fix ux issue with prompt box when enqueueing 2025-04-28 20:58:55 -04:00
ArEnScandMichael Chung 88fd732eec Ok fixed some minor issues. (#558)
Co-authored-by: Michael Chung <michaelchung@michaels-mbp-2.home>
2025-04-28 20:02:36 -04:00
Brandon Thomas 490915a3e7 artcraft website build configs 2025-04-28 18:24:36 -04:00
118 changed files with 2390 additions and 2894 deletions
+13
View File
@@ -0,0 +1,13 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<codeStyleSettings language="Rust">
<option name="RIGHT_MARGIN" value="180" />
<option name="ALIGN_MULTILINE_PARAMETERS" value="false" />
<option name="ALIGN_MULTILINE_PARAMETERS_IN_CALLS" value="false" />
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="TAB_SIZE" value="2" />
</indentOptions>
</codeStyleSettings>
</code_scheme>
</component>
+5
View File
@@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>
Generated
+99 -9
View File
@@ -1921,7 +1921,7 @@ checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f"
dependencies = [
"byteorder",
"fnv",
"uuid",
"uuid 1.8.0",
]
[[package]]
@@ -2877,6 +2877,12 @@ version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e962a19be5cfc3f3bf6dd8f61eb50107f356ad6270fbb3ed41476571db78be5"
[[package]]
name = "data-url"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c297a1c74b71ae29df00c3e22dd9534821d60eb9af5a0192823fa2acea70c2a"
[[package]]
name = "datetimes"
version = "0.0.1"
@@ -3258,6 +3264,20 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "discord-rich-presence"
version = "0.2.5"
source = "git+https://github.com/vionya/discord-rich-presence#9c6cabe04a70cfcc822affd1bf8be51321f8a821"
dependencies = [
"log",
"serde",
"serde_derive",
"serde_json",
"serde_repr",
"thiserror 2.0.12",
"uuid 0.8.2",
]
[[package]]
name = "dispatch"
version = "0.2.0"
@@ -5547,7 +5567,7 @@ dependencies = [
name = "idempotency"
version = "0.0.1"
dependencies = [
"uuid",
"uuid 1.8.0",
"workspace-build-acceleration",
]
@@ -6770,6 +6790,15 @@ dependencies = [
"workspace-build-acceleration",
]
[[package]]
name = "memory_store"
version = "0.0.1"
dependencies = [
"anyhow",
"errors",
"workspace-build-acceleration",
]
[[package]]
name = "metal"
version = "0.27.0"
@@ -7797,7 +7826,7 @@ dependencies = [
"toml 0.8.20",
"ureq",
"url",
"uuid",
"uuid 1.8.0",
"walkdir",
]
@@ -9704,7 +9733,7 @@ dependencies = [
"rkyv_derive",
"seahash",
"tinyvec",
"uuid",
"uuid 1.8.0",
]
[[package]]
@@ -10105,7 +10134,7 @@ dependencies = [
"serde",
"serde_json",
"url",
"uuid",
"uuid 1.8.0",
]
[[package]]
@@ -11253,7 +11282,7 @@ dependencies = [
"user_traits_component",
"utoipa",
"utoipa-swagger-ui",
"uuid",
"uuid 1.8.0",
"videos",
"workspace-build-acceleration",
"zip 0.6.6",
@@ -11266,6 +11295,7 @@ dependencies = [
"anyhow",
"backoff",
"chrono",
"cookie 0.18.1",
"enums",
"enums_public",
"errors",
@@ -12047,13 +12077,16 @@ dependencies = [
"crockford",
"directories 6.0.0",
"dirs 6.0.0",
"discord-rich-presence",
"errors",
"filesys",
"futures 0.3.29",
"image",
"indexmap 2.9.0",
"libloading 0.8.6",
"log",
"memory_caching",
"memory_store",
"once_cell",
"openai_sora_client",
"primitives",
@@ -12068,6 +12101,7 @@ dependencies = [
"subprocess_common",
"tauri",
"tauri-build",
"tauri-plugin-http",
"tauri-plugin-log",
"tempdir",
"tempfile",
@@ -12122,7 +12156,7 @@ dependencies = [
"thiserror 2.0.12",
"time",
"url",
"uuid",
"uuid 1.8.0",
"walkdir",
]
@@ -12157,6 +12191,53 @@ dependencies = [
"walkdir",
]
[[package]]
name = "tauri-plugin-fs"
version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88371e340ad2f07409a3b68294abe73f20bc9c1bc1b631a31dc37a3d0161f682"
dependencies = [
"anyhow",
"dunce",
"glob",
"percent-encoding",
"schemars",
"serde",
"serde_json",
"serde_repr",
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.12",
"toml 0.8.20",
"url",
"uuid 1.8.0",
]
[[package]]
name = "tauri-plugin-http"
version = "2.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40dcd6c922a1885e1f0bcebc6768fec6e005bd4b9001c5d90a2f5d4cab297729"
dependencies = [
"bytes 1.10.0",
"cookie_store",
"data-url",
"http 1.1.0",
"regex",
"reqwest 0.12.15",
"schemars",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tauri-plugin-fs",
"thiserror 2.0.12",
"tokio",
"url",
"urlpattern",
]
[[package]]
name = "tauri-plugin-log"
version = "2.3.1"
@@ -12301,7 +12382,7 @@ dependencies = [
"toml 0.8.20",
"url",
"urlpattern",
"uuid",
"uuid 1.8.0",
"walkdir",
]
@@ -13545,6 +13626,15 @@ dependencies = [
"zip 1.1.4",
]
[[package]]
name = "uuid"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7"
dependencies = [
"getrandom 0.2.14",
]
[[package]]
name = "uuid"
version = "1.8.0"
@@ -14706,7 +14796,7 @@ dependencies = [
"unicode-normalization",
"ureq",
"url",
"uuid",
"uuid 1.8.0",
"void",
"winapi 0.3.9",
"windows-sys 0.48.0",
+7
View File
@@ -39,6 +39,7 @@ members = [
"crates/lib/bootstrap",
"crates/lib/bucket_paths",
"crates/lib/caching/memory_caching",
"crates/lib/caching/memory_store",
"crates/lib/caching/redis_caching",
"crates/lib/concurrency",
"crates/lib/cookies",
@@ -147,6 +148,10 @@ publish = false
# - tauri-runtime-wry
# - tauri-utils
#
# Broken on Windows:
# - core-foundation
# - core-foundation-sys
#
workspace-build-acceleration = { path = "crates/workspace-build-acceleration" }
# External libs
@@ -157,6 +162,7 @@ base64 = "0.22.1"
chrono = { version = "0.4.40", features = ["serde"] }
cookie = "0.18.1"
hex-literal = "1.0.0"
indexmap = "2.9.0"
log = "0.4.27"
once_cell = "1.21.3"
rand = "0.9.0"
@@ -179,6 +185,7 @@ errors = { path = "crates/lib/errors" }
filesys = { path = "crates/lib/files/filesys" }
images = { path = "crates/lib/files/images" }
memory_caching = { path = "crates/lib/caching/memory_caching" }
memory_store = { path = "crates/lib/caching/memory_store" }
mimetypes = { path = "crates/lib/files/mimetypes" }
openai_sora_client = { path = "crates/lib/api_clients/openai_sora_client" }
primitives = { path = "crates/lib/data/primitives" }
+9 -4
View File
@@ -27,6 +27,7 @@ crockford = { workspace = true }
errors = { workspace = true }
filesys = { workspace = true }
memory_caching = { workspace = true }
memory_store = { workspace = true }
openai_sora_client = { workspace = true }
primitives = { workspace = true }
storyteller_client = { workspace = true }
@@ -34,10 +35,8 @@ subprocess_common = { workspace = true }
tokens = { workspace = true }
# External
#openssl = { version = "0.10", features = ["vendored"] }
# NB(bt,2025-03-10): default-features = false on Windows while I debug openssl / rustls
anyhow = { workspace = true }
base64 = "0.22.1"
base64 = { workspace = true }
bytes = "1.10"
chrono = { workspace = true }
const_format = "0.2.34"
@@ -45,21 +44,27 @@ directories = "6.0.0"
dirs = "6.0.0"
futures = "0.3"
image = "0.25.5"
indexmap = { workspace = true }
log = { workspace = true }
once_cell = { workspace = true }
rand = { workspace = true }
reqwest = { workspace = true, features = ["multipart", "stream"], default-features = false }
reqwest = { workspace = true, features = ["multipart", "stream"], default-features = false } # NB(bt,2025-03-10): default-features = false on Windows while I debug openssl / rustls
serde = { workspace = true }
serde_derive = { workspace = true }
serde_json = { workspace = true }
serde_yaml = { workspace = true }
tauri = { version = "2.4.1", features = ["unstable"] }
tauri-plugin-http = "2.0.0"
tauri-plugin-log = "2.3.1"
tempdir = "0.3.7"
tempfile = "3.18.0"
tokio = { version = "1.42", features = ["rt", "rt-multi-thread", "fs"] }
tokio-util = { version = "0.7", features = ["codec"] }
# NB: The version of the `discord-rich-presence` crate (0.2.5) last published has non-Send/Sync error
# types and can't do threading. We need to wait for a new version to unpin from Git.
discord-rich-presence = { git = "https://github.com/vionya/discord-rich-presence" }
[dev-dependencies]
libloading = "0.8.6"
serial_test = "3.2.0"
@@ -7,6 +7,14 @@
],
"permissions": [
"core:default",
"log:default"
"log:default",
{
"identifier": "http:default",
"allow": [
{ "url": "https://*.storyteller.ai" },
{ "url": "https://*.fakeyou.com" }
],
"deny": [{ "url": "https://private.tauri.app" }]
}
]
}
@@ -1,2 +1,3 @@
pub mod flip_image;
pub mod platform_info_command;
pub mod sora;
@@ -0,0 +1,63 @@
use crate::state::os_platform::OsPlatform;
use base64::prelude::BASE64_STANDARD;
use base64::Engine;
use image::codecs::png::{CompressionType, FilterType, PngEncoder};
use image::ImageReader;
use log::info;
use serde_derive::Serialize;
use std::io::Cursor;
#[derive(Debug, Serialize)]
pub struct PlatformInfoResponse {
pub os_platform: DetectedOs,
pub webview_runtime: WebviewRuntime,
}
#[derive(Copy, Clone, Debug, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum DetectedOs {
Windows,
#[serde(rename = "macos")]
MacOS,
Linux,
Unknown,
}
#[derive(Copy, Clone, Debug, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum WebviewRuntime {
#[serde(rename = "webkit_safari")]
WebkitSafari,
#[serde(rename = "webkit_gtk")]
WebkitGtk,
#[serde(rename = "edge_webview_2")]
EdgeWebview2,
Unknown,
}
#[tauri::command]
pub fn platform_info_command() -> Result<PlatformInfoResponse, String> {
info!("platform_info_command called...");
let os_platform = match OsPlatform::maybe_get() {
Some(OsPlatform::Linux) => DetectedOs::Linux,
Some(OsPlatform::MacOs) => DetectedOs::MacOS,
Some(OsPlatform::Windows) => DetectedOs::Windows,
None => DetectedOs::Unknown,
};
// These are the webviews that Tauri uses on each OS:
// https://tauri.app/reference/webview-versions/
let webview_runtime = match os_platform {
DetectedOs::Windows => WebviewRuntime::EdgeWebview2,
DetectedOs::MacOS => WebviewRuntime::WebkitSafari,
DetectedOs::Linux => WebviewRuntime::WebkitGtk,
DetectedOs::Unknown => WebviewRuntime::Unknown,
};
Ok(PlatformInfoResponse {
os_platform,
webview_runtime,
})
}
@@ -1,4 +1,4 @@
use crate::state::app_dir::AppDataRoot;
use crate::state::data_dir::app_data_root::AppDataRoot;
use crate::state::sora::read_sora_credentials_from_disk::read_sora_credentials_from_disk;
use crate::state::sora::sora_credential_holder::SoraCredentialHolder;
use base64::prelude::BASE64_STANDARD;
@@ -1,7 +1,9 @@
use crate::state::app_dir::AppDataRoot;
use crate::state::data_dir::app_data_root::AppDataRoot;
use crate::state::data_dir::trait_data_subdir::DataSubdir;
use crate::state::sora::read_sora_credentials_from_disk::read_sora_credentials_from_disk;
use crate::state::sora::sora_credential_holder::SoraCredentialHolder;
use crate::state::sora::sora_credential_manager::SoraCredentialManager;
use crate::state::sora::sora_task_queue::SoraTaskQueue;
use crate::utils::get_url_file_extension::get_url_file_extension;
use crate::utils::simple_http_download::simple_http_download;
use base64::prelude::BASE64_STANDARD;
@@ -50,12 +52,13 @@ pub async fn sora_image_remix_command(
request: SoraImageRemixCommand,
app_data_root: State<'_, AppDataRoot>,
sora_creds_manager: State<'_, SoraCredentialManager>,
sora_task_queue: State<'_, SoraTaskQueue>,
) -> Result<String, String> {
info!("image_generation_command called; processing image...");
// TODO(bt,2025-04-24): Better error messages to caller
generate_image(request, &app_data_root, &sora_creds_manager)
generate_image(request, &app_data_root, &sora_creds_manager, &sora_task_queue)
.await
.map_err(|err| {
error!("error: {:?}", err);
@@ -69,6 +72,7 @@ pub async fn generate_image(
request: SoraImageRemixCommand,
app_data_root: &AppDataRoot,
sora_creds_manager: &SoraCredentialManager,
sora_task_queue: &SoraTaskQueue,
) -> AnyhowResult<()> {
let response = get_media_file(&ApiHost::Storyteller, &request.snapshot_media_token).await?;
@@ -85,8 +89,6 @@ pub async fn generate_image(
let files_to_upload = vec![filename];
let mut creds = sora_creds_manager.get_credentials_required()?;
let credential_updated = maybe_upgrade_or_renew_session(&mut creds)
@@ -141,7 +143,9 @@ pub async fn generate_image(
sora_creds_manager.set_credentials(&new_creds)?;
}
println!(">> TASK ID: {:?} ", response.task_id);
info!("New Sora Task ID: {:?} ", response.task_id);
sora_task_queue.insert(&response.task_id)?;
Ok(())
}
+50 -6
View File
@@ -4,20 +4,30 @@ pub mod state;
pub mod threads;
pub mod utils;
use tauri::Manager;
use crate::commands::flip_image::flip_image;
use crate::commands::platform_info_command::platform_info_command;
use crate::commands::sora::open_sora_login_command::open_sora_login_command;
use crate::commands::sora::sora_image_generation_command::sora_image_generation_command;
use crate::commands::sora::sora_image_remix_command::sora_image_remix_command;
use crate::state::app_config::AppConfig;
use crate::state::main_window_size::MainWindowSize;
use crate::state::sora::sora_credential_manager::SoraCredentialManager;
use crate::state::sora::sora_task_queue::SoraTaskQueue;
use crate::state::storyteller::storyteller_credential_manager::StorytellerCredentialManager;
use crate::threads::discord_presence_thread::discord_presence_thread;
use crate::threads::main_window_thread::main_window_thread::main_window_thread;
use crate::threads::sora_session_login_thread::sora_session_login_thread;
use crate::threads::sora_task_polling_thread::sora_task_polling_thread;
use tauri_plugin_http;
use tauri_plugin_log::Target;
use tauri_plugin_log::TargetKind;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
// NB: Tauri wants to install the logger itself, so we can't rely on the logger crate
// NB: Tauri wants to install the logger itself, so we can't rely on the logger crate
// until the tauri runtime begins.
println!("Loading model config...");
@@ -25,15 +35,24 @@ pub fn run() {
.expect("config should load");
let app_data_root = config.app_data_root.clone();
let app_data_root2 = config.app_data_root.clone();
let app_data_root_2 = config.app_data_root.clone();
println!("Attempting to read existing artcraft credentials...");
let storyteller_creds_manager = StorytellerCredentialManager::initialize_from_disk_infallible(&app_data_root);
let storyteller_creds_manager_2 = storyteller_creds_manager.clone();
println!("Attempting to read existing credentials...");
let sora_creds_manager = SoraCredentialManager::initialize_from_disk_infallible(&app_data_root);
let sora_creds_manager2 = sora_creds_manager.clone();
let sora_creds_manager_2 = sora_creds_manager.clone();
// Other state
let sora_task_queue = SoraTaskQueue::new();
let sora_task_queue_2 = sora_task_queue.clone();
println!("Initializing backend runtime...");
tauri::Builder::default()
.plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_log::Builder::new()
.level(log::LevelFilter::Info)
.targets(vec![
@@ -43,7 +62,7 @@ pub fn run() {
}),
])
.build())
.setup(|app| {
.setup(move |app| {
// TODO(bt): This is broken on windows
// log_environment_details();
@@ -56,16 +75,41 @@ pub fn run() {
//}
let app = app.handle().clone();
tauri::async_runtime::spawn(sora_session_login_thread(app, app_data_root2, sora_creds_manager2));
let app_2 = app.clone();
let app_3 = app.clone();
let app_data_root_3 = app_data_root_2.clone();
let app_data_root_4 = app_data_root_2.clone();
let sora_creds_manager_3 = sora_creds_manager_2.clone();
match MainWindowSize::from_filesystem_configs(&app_data_root_3) {
Ok(None) => {}
Ok(Some(size)) => {
println!("Resizing window to: {:?}", size);
let result = size.apply_to_main_window(&app);
if let Err(err) = result {
eprintln!("Could not set window size: {:?}", err);
}
}
Err(err) => {
eprintln!("Failed to read window size from disk: {:?}", err);
}
}
tauri::async_runtime::spawn(sora_session_login_thread(app_2, app_data_root_2, sora_creds_manager_2));
tauri::async_runtime::spawn(main_window_thread(app_3, app_data_root_3, storyteller_creds_manager_2));
tauri::async_runtime::spawn(sora_task_polling_thread(app_data_root_4, sora_creds_manager_3, sora_task_queue_2));
tauri::async_runtime::spawn(discord_presence_thread());
Ok(())
})
.manage(config)
.manage(app_data_root)
.manage(config)
.manage(sora_creds_manager)
.manage(sora_task_queue)
.invoke_handler(tauri::generate_handler![
flip_image,
open_sora_login_command,
platform_info_command,
sora_image_generation_command,
sora_image_remix_command,
])
@@ -1,4 +1,4 @@
use crate::state::app_dir::AppDataRoot;
use crate::state::data_dir::app_data_root::AppDataRoot;
use crate::state::os_platform::OsPlatform;
use crate::state::yaml_config::YamlConfig;
@@ -1,264 +0,0 @@
use crate::state::expanduser::expanduser;
use crate::state::os_platform::OsPlatform;
use anyhow::anyhow;
use directories::UserDirs;
use std::path::{Path, PathBuf};
use chrono::{DateTime, Local};
use tempdir::TempDir;
use tempfile::{Builder, NamedTempFile};
const DEFAULT_DATA_DIR : &str = "artcraft";
const ASSETS_SUBDIRECTORY : &str = "assets";
const DOWNLOADS_SUBDIRECTORY : &str = "downloads";
const WEIGHTS_SUBDIRECTORY : &str = "weights";
const TEMPORARY_SUBDIRECTORY : &str = "temp";
/// Note: Tauri appends ".log" to the end of the filename.
const LOG_FILE_NAME : &str = "application_debug";
/// The path to the application data directory, which includes "asset" and "weights" data.
#[derive(Clone)]
pub struct AppDataRoot {
path: PathBuf,
assets_dir: AppAssetsDir,
downloads_dir: AppDownloadsDir,
weights_dir: AppWeightsDir,
temp_dir: TemporaryDir,
log_file_name: PathBuf,
log_file_name_string: String,
}
#[derive(Clone)]
pub struct AppAssetsDir {
path: PathBuf,
}
#[derive(Clone)]
pub struct AppDownloadsDir {
path: PathBuf,
}
#[derive(Clone)]
pub struct AppWeightsDir {
path: PathBuf,
}
#[derive(Clone)]
pub struct TemporaryDir {
path: PathBuf,
}
impl AppDataRoot {
pub fn create_default() -> anyhow::Result<Self> {
let directory = get_default_data_dir()?;
Self::create_existing(directory)
}
pub fn create_existing<P: AsRef<Path>>(dir: P) -> anyhow::Result<Self> {
let mut dir = dir.as_ref().to_path_buf();
match OsPlatform::get() {
OsPlatform::Linux | OsPlatform::MacOs => {
if let Some(d) = dir.as_os_str().to_str() {
dir = expanduser(d)?;
}
},
OsPlatform::Windows => {}
}
if !dir.is_dir() {
println!("Creating directory {:?}", dir);
std::fs::create_dir_all(&dir)?;
}
match dir.canonicalize() {
Ok(d) => dir = d,
Err(err) => {
println!("Error canonicalizing {:?}: {}", dir, err);
}
}
let assets_dir = AppAssetsDir::create_existing(dir.join(ASSETS_SUBDIRECTORY))?;
let downloads_dir = AppDownloadsDir::create_existing(dir.join(DOWNLOADS_SUBDIRECTORY))?;
let weights_dir = AppWeightsDir::create_existing(dir.join(WEIGHTS_SUBDIRECTORY))?;
let temp_dir = TemporaryDir::create_existing(dir.join(TEMPORARY_SUBDIRECTORY))?;
let log_file_name = dir.join(LOG_FILE_NAME);
let log_file_name_string = log_file_name
.to_str()
.ok_or(anyhow!("couldn't convert log path to str"))?
.to_string();
Ok(Self {
path: dir,
assets_dir,
downloads_dir,
weights_dir,
temp_dir,
log_file_name,
log_file_name_string,
})
}
pub fn assets_dir(&self) -> &AppAssetsDir {
&self.assets_dir
}
pub fn downloads_dir(&self) -> &AppDownloadsDir {
&self.downloads_dir
}
pub fn weights_dir(&self) -> &AppWeightsDir {
&self.weights_dir
}
pub fn temp_dir(&self) -> &TemporaryDir {
&self.temp_dir
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn log_file_name(&self) -> &Path {
&self.log_file_name
}
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")
}
pub fn get_sora_bearer_token_file_path(&self) -> PathBuf {
self.path.join("sora_bearer_token.txt")
}
pub fn get_sora_sentinel_file_path(&self) -> PathBuf {
self.path.join("sora_sentinel.txt")
}
}
impl AppAssetsDir {
pub fn create_existing<P: AsRef<Path>>(dir: P) -> anyhow::Result<Self> {
let mut dir = dir.as_ref().to_path_buf();
match dir.canonicalize() {
Ok(d) => dir = d,
Err(err) => {
println!("Error canonicalizing {:?}: {}", dir, err);
}
}
if !dir.exists() {
println!("Creating directory {:?}", dir);
std::fs::create_dir(&dir)?;
}
Ok(Self {
path: dir,
})
}
pub fn path(&self) -> &Path {
&self.path
}
/// Return the current date asset directory
/// If it doesn't exist, create it.
pub fn make_or_get_current_date_dir(&self) -> anyhow::Result<PathBuf> {
let format: DateTime<Local> = Local::now();
let date_directory_name = format.format("%Y-%d-%m").to_string();
let full_path = self.path.join(date_directory_name);
if !full_path.exists() {
std::fs::create_dir(&full_path)?;
}
Ok(full_path)
}
}
impl AppDownloadsDir {
pub fn create_existing<P: AsRef<Path>>(dir: P) -> anyhow::Result<Self> {
let mut dir = dir.as_ref().to_path_buf();
match dir.canonicalize() {
Ok(d) => dir = d,
Err(err) => {
println!("Error canonicalizing {:?}: {}", dir, err);
}
}
if !dir.exists() {
println!("Creating directory {:?}", dir);
std::fs::create_dir(&dir)?;
}
Ok(Self {
path: dir,
})
}
pub fn path(&self) -> &Path {
&self.path
}
}
impl AppWeightsDir {
pub fn create_existing<P: AsRef<Path>>(dir: P) -> anyhow::Result<Self> {
let mut dir = dir.as_ref().to_path_buf();
match dir.canonicalize() {
Ok(d) => dir = d,
Err(err) => {
println!("Error canonicalizing {:?}: {}", dir, err);
}
}
if !dir.exists() {
println!("Creating directory {:?}", dir);
std::fs::create_dir(&dir)?;
}
Ok(Self {
path: dir,
})
}
pub fn path(&self) -> &Path {
&self.path
}
}
impl TemporaryDir {
pub fn create_existing<P: AsRef<Path>>(dir: P) -> anyhow::Result<Self> {
let mut dir = dir.as_ref().to_path_buf();
match dir.canonicalize() {
Ok(d) => dir = d,
Err(err) => {
println!("Error canonicalizing {:?}: {}", dir, err);
}
}
if !dir.exists() {
println!("Creating directory {:?}", dir);
std::fs::create_dir(&dir)?;
}
Ok(Self {
path: dir,
})
}
pub fn with_prefix(&self, prefix: &str) -> anyhow::Result<TempDir> {
let tempdir = TempDir::new_in(&self.path, prefix)?;
Ok(tempdir)
}
pub fn new_named_temp_file(&self) -> anyhow::Result<NamedTempFile> {
let tempfile = Builder::new()
.prefix("temp_")
.suffix(".bin")
.tempfile_in(&self.path)?;
Ok(tempfile)
}
}
// eg. /home/bob/artcraft, /Users/bob/artcraft, or C:\Users\Alice\artcraft
fn get_default_data_dir() -> anyhow::Result<PathBuf> {
Ok(UserDirs::new()
.ok_or_else(|| anyhow!("could not determine user home directory"))?
.home_dir()
.join(DEFAULT_DATA_DIR))
}
@@ -0,0 +1,22 @@
use chrono::{DateTime, TimeDelta, Utc};
pub struct AppStartupTime {
startup_time: DateTime<Utc>,
}
impl AppStartupTime {
pub fn new() -> Self {
Self {
startup_time: Utc::now(),
}
}
pub fn get_startup_time(&self) -> DateTime<Utc> {
self.startup_time
}
pub fn time_delta_since(&self) -> TimeDelta {
let now = Utc::now();
now.signed_duration_since(self.startup_time)
}
}
@@ -0,0 +1,36 @@
use crate::state::data_dir::trait_data_subdir::DataSubdir;
use chrono::{DateTime, Local};
use std::path::{Path, PathBuf};
#[derive(Clone)]
pub struct AppAssetsDir {
path: PathBuf,
}
impl DataSubdir for AppAssetsDir {
const DIRECTORY_NAME: &'static str = "assets";
fn new_from<P: AsRef<Path>>(dir: P) -> Self {
Self {
path: dir.as_ref().to_path_buf(),
}
}
fn path(&self) -> &Path {
&self.path
}
}
impl AppAssetsDir {
/// Return the current date asset directory
/// If it doesn't exist, create it.
pub fn make_or_get_current_date_dir(&self) -> anyhow::Result<PathBuf> {
let format: DateTime<Local> = Local::now();
let date_directory_name = format.format("%Y-%d-%m").to_string();
let full_path = self.path.join(date_directory_name);
if !full_path.exists() {
std::fs::create_dir(&full_path)?;
}
Ok(full_path)
}
}
@@ -0,0 +1,43 @@
use crate::state::data_dir::trait_data_subdir::DataSubdir;
use std::path::{Path, PathBuf};
#[derive(Clone)]
pub struct AppCredentialsDir {
path: PathBuf,
}
impl DataSubdir for AppCredentialsDir {
const DIRECTORY_NAME: &'static str = "credentials";
fn new_from<P: AsRef<Path>> (dir: P) -> Self {
Self {
path: dir.as_ref().to_path_buf(),
}
}
fn path(&self) -> &Path {
&self.path
}
}
impl AppCredentialsDir {
pub fn get_sora_cookie_file_path(&self) -> PathBuf {
self.path.join("sora_cookies.txt")
}
pub fn get_sora_bearer_token_file_path(&self) -> PathBuf {
self.path.join("sora_bearer_token.txt")
}
pub fn get_sora_sentinel_file_path(&self) -> PathBuf {
self.path.join("sora_sentinel.txt")
}
pub fn get_storyteller_avt_cookie_file_path(&self) -> PathBuf {
self.path.join("artcraft_avt.txt")
}
pub fn get_storyteller_session_cookie_file_path(&self) -> PathBuf {
self.path.join("artcraft_session.txt")
}
}
@@ -0,0 +1,140 @@
use crate::state::data_dir::app_assets_dir::AppAssetsDir;
use crate::state::data_dir::app_credentials_dir::AppCredentialsDir;
use crate::state::data_dir::app_downloads_dir::AppDownloadsDir;
use crate::state::data_dir::app_weights_dir::AppWeightsDir;
use crate::state::data_dir::temporary_dir::TemporaryDir;
use crate::state::data_dir::trait_data_subdir::DataSubdir;
use crate::state::expanduser::expanduser;
use crate::state::os_platform::OsPlatform;
use anyhow::anyhow;
use directories::UserDirs;
use std::path::{Path, PathBuf};
const DEFAULT_DATA_DIR : &str = "Artcraft";
/// Note: Tauri appends ".log" to the end of the filename.
const LOG_FILE_NAME : &str = "application_debug";
/// The path to the application data directory, which includes "asset" and "weights" data.
#[derive(Clone)]
pub struct AppDataRoot {
path: PathBuf,
assets_dir: AppAssetsDir,
credentials_dir: AppCredentialsDir,
downloads_dir: AppDownloadsDir,
weights_dir: AppWeightsDir,
temp_dir: TemporaryDir,
log_file_name: PathBuf,
log_file_name_string: String,
}
impl AppDataRoot {
pub fn create_default() -> anyhow::Result<Self> {
let directory = get_default_data_dir()?;
Self::create_existing(directory)
}
pub fn create_existing<P: AsRef<Path>>(dir: P) -> anyhow::Result<Self> {
let mut dir = dir.as_ref().to_path_buf();
match OsPlatform::get() {
OsPlatform::Linux | OsPlatform::MacOs => {
if let Some(d) = dir.as_os_str().to_str() {
dir = expanduser(d)?;
}
},
OsPlatform::Windows => {}
}
if !dir.is_dir() {
println!("Creating directory {:?}", dir);
std::fs::create_dir_all(&dir)?;
}
match dir.canonicalize() {
Ok(d) => dir = d,
Err(err) => {
println!("Error canonicalizing {:?}: {}", dir, err);
}
}
let assets_dir = AppAssetsDir::get_or_create_in_root_dir(&dir)?;
let downloads_dir = AppDownloadsDir::get_or_create_in_root_dir(&dir)?;
let credentials_dir = AppCredentialsDir::get_or_create_in_root_dir(&dir)?;
let weights_dir = AppWeightsDir::get_or_create_in_root_dir(&dir)?;
let temp_dir = TemporaryDir::get_or_create_in_root_dir(&dir)?;
let log_file_name = dir.join(LOG_FILE_NAME);
let log_file_name_string = log_file_name
.to_str()
.ok_or(anyhow!("couldn't convert log path to str"))?
.to_string();
Ok(Self {
path: dir,
assets_dir,
credentials_dir,
downloads_dir,
weights_dir,
temp_dir,
log_file_name,
log_file_name_string,
})
}
pub fn assets_dir(&self) -> &AppAssetsDir {
&self.assets_dir
}
pub fn credentials_dir(&self) -> &AppCredentialsDir {
&self.credentials_dir
}
pub fn downloads_dir(&self) -> &AppDownloadsDir {
&self.downloads_dir
}
pub fn weights_dir(&self) -> &AppWeightsDir {
&self.weights_dir
}
pub fn temp_dir(&self) -> &TemporaryDir {
&self.temp_dir
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn log_file_name(&self) -> &Path {
&self.log_file_name
}
pub fn log_file_name_str(&self) -> &str {
&self.log_file_name_string
}
pub fn get_sora_cookie_file_path(&self) -> PathBuf {
self.credentials_dir().get_sora_cookie_file_path()
}
pub fn get_sora_bearer_token_file_path(&self) -> PathBuf {
self.credentials_dir().get_sora_bearer_token_file_path()
}
pub fn get_sora_sentinel_file_path(&self) -> PathBuf {
self.credentials_dir().get_sora_sentinel_file_path()
}
pub fn get_window_size_config_file(&self) -> PathBuf {
self.path.join("window_size.json")
}
}
// eg. /home/bob/artcraft, /Users/bob/artcraft, or C:\Users\Alice\artcraft
fn get_default_data_dir() -> anyhow::Result<PathBuf> {
Ok(UserDirs::new()
.ok_or_else(|| anyhow!("could not determine user home directory"))?
.home_dir()
.join(DEFAULT_DATA_DIR))
}
@@ -0,0 +1,21 @@
use crate::state::data_dir::trait_data_subdir::DataSubdir;
use std::path::{Path, PathBuf};
#[derive(Clone)]
pub struct AppDownloadsDir {
path: PathBuf,
}
impl DataSubdir for AppDownloadsDir {
const DIRECTORY_NAME: &'static str = "downloads";
fn new_from<P: AsRef<Path>>(dir: P) -> Self {
Self {
path: dir.as_ref().to_path_buf(),
}
}
fn path(&self) -> &Path {
&self.path
}
}
@@ -0,0 +1,21 @@
use crate::state::data_dir::trait_data_subdir::DataSubdir;
use std::path::{Path, PathBuf};
#[derive(Clone)]
pub struct AppWeightsDir {
path: PathBuf,
}
impl DataSubdir for AppWeightsDir {
const DIRECTORY_NAME: &'static str = "weights";
fn new_from<P: AsRef<Path>>(dir: P) -> Self {
Self {
path: dir.as_ref().to_path_buf(),
}
}
fn path(&self) -> &Path {
&self.path
}
}
@@ -0,0 +1,7 @@
pub mod app_assets_dir;
pub mod app_credentials_dir;
pub mod app_data_root;
pub mod app_downloads_dir;
pub mod app_weights_dir;
pub mod temporary_dir;
pub mod trait_data_subdir;
@@ -0,0 +1,38 @@
use crate::state::data_dir::trait_data_subdir::DataSubdir;
use std::path::{Path, PathBuf};
use tempdir::TempDir;
use tempfile::{Builder, NamedTempFile};
#[derive(Clone)]
pub struct TemporaryDir {
path: PathBuf,
}
impl DataSubdir for TemporaryDir {
const DIRECTORY_NAME: &'static str = "temp";
fn new_from<P: AsRef<Path>>(dir: P) -> Self {
Self {
path: dir.as_ref().to_path_buf(),
}
}
fn path(&self) -> &Path {
&self.path
}
}
impl TemporaryDir {
pub fn with_prefix(&self, prefix: &str) -> anyhow::Result<TempDir> {
let tempdir = TempDir::new_in(&self.path, prefix)?;
Ok(tempdir)
}
pub fn new_named_temp_file(&self) -> anyhow::Result<NamedTempFile> {
let tempfile = Builder::new()
.prefix("temp_")
.suffix(".bin")
.tempfile_in(&self.path)?;
Ok(tempfile)
}
}
@@ -0,0 +1,31 @@
use std::path::Path;
pub trait DataSubdir : Sized {
const DIRECTORY_NAME: &'static str;
fn new_from<P: AsRef<Path>>(dir: P) -> Self;
fn path(&self) -> &Path;
fn get_or_create_in_root_dir<P: AsRef<Path>>(root_dir: P) -> anyhow::Result<Self> {
Self::get_or_create_dir(root_dir.as_ref().join(Self::DIRECTORY_NAME))
}
fn get_or_create_dir<P: AsRef<Path>>(dir: P) -> anyhow::Result<Self> {
let mut dir = dir.as_ref().to_path_buf();
match dir.canonicalize() {
Ok(d) => dir = d,
Err(err) => {
println!("Error canonicalizing {:?}: {}", dir, err);
}
}
if !dir.exists() {
println!("Creating directory {:?}", dir);
std::fs::create_dir(&dir)?;
}
Ok(Self::new_from(dir))
}
}
@@ -0,0 +1,74 @@
use crate::state::data_dir::app_data_root::AppDataRoot;
use errors::AnyhowResult;
use serde_derive::{Deserialize, Serialize};
use std::fs::OpenOptions;
use std::io::Write;
use tauri::{AppHandle, Manager, PhysicalSize, Window};
const MAIN_WINDOW_NAME: &str = "main";
#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq)]
pub struct MainWindowSize {
pub width: u32,
pub height: u32,
}
impl MainWindowSize {
pub fn new(width: u32, height: u32) -> Self {
Self { width, height }
}
pub fn from_main_window(app: &AppHandle) -> AnyhowResult<Self> {
let windows = app.windows();
windows.get(MAIN_WINDOW_NAME)
.map(|window| Self::from_window(window))
.unwrap_or_else(|| Err(anyhow::anyhow!("Main window not found")))
}
pub fn from_window(window: &Window) -> AnyhowResult<Self> {
let size = window.inner_size()?;
Ok(Self {
width: size.width,
height: size.height,
})
}
pub fn from_filesystem_configs(app_data_root: &AppDataRoot) -> AnyhowResult<Option<Self>> {
let filename = app_data_root.get_window_size_config_file();
if !filename.exists() {
return Ok(None);
}
let contents = std::fs::read_to_string(filename)?;
let size: MainWindowSize = serde_json::from_str(&contents)?;
Ok(Some(size))
}
pub fn apply_to_main_window(&self, app: &AppHandle) -> AnyhowResult<()> {
let windows = app.windows();
let window = windows.get(MAIN_WINDOW_NAME)
.ok_or_else(|| anyhow::anyhow!("Main window not found"))?;
window.set_size(PhysicalSize::new(self.width, self.height))?;
Ok(())
}
pub fn persist_to_filesystem(&self, app_data_root: &AppDataRoot) -> AnyhowResult<()> {
let filename = app_data_root.get_window_size_config_file();
let json = serde_json::to_string(self)?;
let mut file = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(filename)?;
file.write_all(json.as_bytes())?;
file.flush()?;
Ok(())
}
pub fn to_physical_size(&self) -> PhysicalSize<u32> {
PhysicalSize::new(self.width, self.height)
}
pub fn matches_physical_size(&self, size: &PhysicalSize<u32>) -> bool {
self.width == size.width && self.height == size.height
}
}
@@ -1,6 +1,9 @@
pub mod app_config;
pub mod app_dir;
pub mod app_startup_time;
pub mod data_dir;
pub mod expanduser;
pub mod main_window_size;
pub mod os_platform;
pub mod sora;
pub mod storyteller;
pub mod yaml_config;
@@ -7,14 +7,18 @@ pub enum OsPlatform {
}
impl OsPlatform {
pub fn get() -> OsPlatform {
pub fn maybe_get() -> Option<Self> {
// Totally scientific.
// https://doc.rust-lang.org/std/env/consts/constant.OS.html
match std::env::consts::OS {
"linux" => OsPlatform::Linux,
"windows" => OsPlatform::Windows,
"macos" | "apple" => OsPlatform::MacOs,
_ => OsPlatform::Linux,
"linux" => Some(Self::Linux),
"windows" => Some(Self::Windows),
"macos" | "apple" => Some(Self::MacOs),
_ => None,
}
}
pub fn get() -> Self {
Self::maybe_get().unwrap_or(Self::Linux)
}
}
@@ -1,4 +1,5 @@
pub mod read_sora_credentials_from_disk;
pub mod read_sora_credentials_from_disk_with_upgrade;
pub mod sora_credential_holder;
pub mod sora_credential_manager;
pub mod sora_credential_manager;
pub mod sora_task_queue;
@@ -1,4 +1,4 @@
use crate::state::app_dir::AppDataRoot;
use crate::state::data_dir::app_data_root::AppDataRoot;
use anyhow::anyhow;
use errors::AnyhowResult;
use openai_sora_client::creds::sora_cookies::SoraCookies;
@@ -1,4 +1,4 @@
use crate::state::app_dir::AppDataRoot;
use crate::state::data_dir::app_data_root::AppDataRoot;
use anyhow::anyhow;
use errors::AnyhowResult;
use log::{info, warn};
@@ -1,4 +1,4 @@
use crate::state::app_dir::AppDataRoot;
use crate::state::data_dir::app_data_root::AppDataRoot;
use crate::state::sora::read_sora_credentials_from_disk::read_sora_credentials_from_disk;
use crate::state::sora::sora_credential_holder::SoraCredentialHolder;
use anyhow::anyhow;
@@ -0,0 +1,231 @@
use std::collections::HashSet;
use chrono::{DateTime, Utc};
use errors::AnyhowResult;
use indexmap::IndexMap;
use openai_sora_client::requests::image_gen::image_gen_status::TaskId;
use std::sync::{Arc, RwLock};
#[derive(Clone)]
pub struct TaskDetails {
pub enqueue_time: DateTime<Utc>
}
impl TaskDetails {
pub fn new() -> Self {
Self {
enqueue_time: Utc::now()
}
}
}
#[derive(Clone)]
pub struct TaskIdAndDetails {
pub task_id: TaskId,
pub details: TaskDetails,
}
#[derive(Clone)]
pub struct SoraTaskQueue {
// Insertion-order map of task IDs to task details
queue: Arc<RwLock<IndexMap<TaskId, TaskDetails>>>
}
impl SoraTaskQueue {
pub fn new() -> Self {
Self {
queue: Arc::new(RwLock::new(IndexMap::new()))
}
}
/// Determine if queue is empty
/// This is an O(1) operation.
pub fn is_empty(&self) -> AnyhowResult<bool> {
match self.queue.read() {
Err(err) => {
Err(anyhow::anyhow!("Failed to acquire read lock: {:?}", err))
}
Ok(lock) => Ok(lock.is_empty())
}
}
/// Get length of set
/// This is an O(1) operation.
pub fn len(&self) -> AnyhowResult<usize> {
match self.queue.read() {
Err(err) => {
Err(anyhow::anyhow!("Failed to acquire read lock: {:?}", err))
}
Ok(lock) => Ok(lock.len())
}
}
/// Determine if the key is in the queue
/// This is an O(1) operation.
pub fn contains_key(&self, task_id: &TaskId) -> AnyhowResult<bool> {
match self.queue.read() {
Err(err) => {
Err(anyhow::anyhow!("Failed to acquire read lock: {:?}", err))
}
Ok(lock) => Ok(lock.contains_key(task_id))
}
}
/// Get the first enqueued task
/// This is an O(1) operation.
pub fn first(&self) -> AnyhowResult<Option<TaskIdAndDetails>> {
match self.queue.read() {
Err(err) => {
Err(anyhow::anyhow!("Failed to acquire read lock: {:?}", err))
}
Ok(lock) => match lock.first() {
None => Ok(None),
Some((task_id, task_details)) => Ok(Some(TaskIdAndDetails {
task_id: task_id.clone(),
details: task_details.clone(),
}))
}
}
}
/// Get the last enqueued task
/// This is an O(1) operation.
pub fn last(&self) -> AnyhowResult<Option<TaskIdAndDetails>> {
match self.queue.read() {
Err(err) => {
Err(anyhow::anyhow!("Failed to acquire read lock: {:?}", err))
}
Ok(lock) => match lock.last() {
None => Ok(None),
Some((task_id, task_details)) => Ok(Some(TaskIdAndDetails {
task_id: task_id.clone(),
details: task_details.clone(),
}))
}
}
}
/// Insert a new task. If a task already exists, we update its value.
/// This is an O(1) operation.
pub fn insert(&self, task_id: &TaskId) -> AnyhowResult<Option<TaskDetails>> {
match self.queue.write() {
Err(err) => {
Err(anyhow::anyhow!("Failed to acquire write lock: {:?}", err))
}
Ok(mut lock) => {
// NB: Probably fine to compute TaskDetails here for now. We may eventually expose this if we include additional info.
let task_details = TaskDetails::new();
Ok(lock.insert(task_id.clone(), task_details))
}
}
}
/// Remove a task by ID.
/// This is an O(n) operation.
pub fn remove(&self, task_id: &TaskId) -> AnyhowResult<Option<TaskDetails>> {
match self.queue.write() {
Err(err) => {
Err(anyhow::anyhow!("Failed to acquire write lock: {:?}", err))
}
Ok(mut lock) => {
Ok(lock.shift_remove(task_id))
}
}
}
/// Remove a list of tasks by ID.
/// This is an O(n) operation.
pub fn remove_list(&self, task_ids: &[&TaskId]) -> AnyhowResult<()> {
match self.queue.write() {
Err(err) => {
Err(anyhow::anyhow!("Failed to acquire write lock: {:?}", err))
}
Ok(mut lock) => {
// TODO: Cleaner way to do this?
let mut set : HashSet<&TaskId, _> = HashSet::with_capacity(task_ids.len());
set.extend(task_ids);
lock.retain(|k, v| !set.contains(k));
Ok(())
}
}
}
}
#[cfg(test)]
mod tests {
use errors::AnyhowResult;
use openai_sora_client::requests::image_gen::image_gen_status::TaskId;
use crate::state::sora::sora_task_queue::SoraTaskQueue;
#[test]
fn remove_list() -> AnyhowResult<()> {
let task_queue = SoraTaskQueue::new();
assert!(task_queue.is_empty()?);
assert_eq!(task_queue.len()?, 0);
task_queue.insert(&TaskId("a".to_string()))?;
task_queue.insert(&TaskId("b".to_string()))?;
task_queue.insert(&TaskId("c".to_string()))?;
task_queue.insert(&TaskId("d".to_string()))?;
task_queue.insert(&TaskId("e".to_string()))?;
task_queue.insert(&TaskId("f".to_string()))?;
assert!(!task_queue.is_empty()?);
assert_eq!(task_queue.len()?, 6);
task_queue.insert(&TaskId("a".to_string()))?;
task_queue.insert(&TaskId("b".to_string()))?;
task_queue.insert(&TaskId("c".to_string()))?;
task_queue.insert(&TaskId("d".to_string()))?;
task_queue.insert(&TaskId("e".to_string()))?;
task_queue.insert(&TaskId("f".to_string()))?;
assert!(!task_queue.is_empty()?);
assert_eq!(task_queue.len()?, 6);
task_queue.remove_list(&[
&TaskId("a".to_string()),
&TaskId("c".to_string()),
&TaskId("e".to_string()),
])?;
assert!(!task_queue.is_empty()?);
assert_eq!(task_queue.len()?, 3);
assert!(task_queue.contains_key(&TaskId("b".to_string()))?);
assert!(task_queue.contains_key(&TaskId("d".to_string()))?);
assert!(task_queue.contains_key(&TaskId("f".to_string()))?);
assert!(!task_queue.contains_key(&TaskId("a".to_string()))?);
assert!(!task_queue.contains_key(&TaskId("c".to_string()))?);
assert!(!task_queue.contains_key(&TaskId("e".to_string()))?);
task_queue.remove_list(&[
&TaskId("foo".to_string()),
&TaskId("bar".to_string()),
&TaskId("baz".to_string()),
])?;
assert!(!task_queue.is_empty()?);
assert_eq!(task_queue.len()?, 3);
assert!(task_queue.contains_key(&TaskId("b".to_string()))?);
assert!(task_queue.contains_key(&TaskId("d".to_string()))?);
assert!(task_queue.contains_key(&TaskId("f".to_string()))?);
task_queue.remove_list(&[
&TaskId("b".to_string()),
&TaskId("d".to_string()),
&TaskId("f".to_string()),
])?;
assert!(task_queue.is_empty()?);
assert_eq!(task_queue.len()?, 0);
assert!(!task_queue.contains_key(&TaskId("b".to_string()))?);
assert!(!task_queue.contains_key(&TaskId("d".to_string()))?);
assert!(!task_queue.contains_key(&TaskId("f".to_string()))?);
Ok(())
}
}
@@ -0,0 +1,3 @@
pub mod read_storyteller_credentials_from_disk;
pub mod storyteller_credential_holder;
pub mod storyteller_credential_manager;
@@ -0,0 +1,39 @@
use crate::state::data_dir::app_data_root::AppDataRoot;
use errors::AnyhowResult;
use std::fs::read_to_string;
use storyteller_client::credentials::storyteller_avt_cookie::StorytellerAvtCookie;
use storyteller_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use storyteller_client::credentials::storyteller_session_cookie::StorytellerSessionCookie;
pub fn read_storyteller_credentials_from_disk(app_data_root: &AppDataRoot) -> AnyhowResult<StorytellerCredentialSet> {
let creds_dir= app_data_root.credentials_dir();
let mut avt = None;
{
let avt_file = creds_dir.get_storyteller_avt_cookie_file_path();
if avt_file.exists() {
let contents = read_to_string(&avt_file)?
.trim()
.to_string();
avt = Some(StorytellerAvtCookie::new(contents));
}
}
let mut session = None;
{
let session_file = creds_dir.get_storyteller_session_cookie_file_path();
if session_file.exists() {
let contents = read_to_string(&session_file)?
.trim()
.to_string();
session = Some(StorytellerSessionCookie::new(contents));
}
}
Ok(StorytellerCredentialSet::initialize(
avt,
session,
))
}
@@ -0,0 +1,57 @@
use anyhow::anyhow;
use errors::AnyhowResult;
use std::sync::{Arc, RwLock};
use storyteller_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
/// Hold credentials for the application.
#[derive(Clone)]
pub struct StorytellerCredentialHolder {
credentials: Arc<RwLock<Option<StorytellerCredentialSet>>>,
}
impl StorytellerCredentialHolder {
pub fn new() -> Self {
Self {
credentials: Arc::new(RwLock::new(None)),
}
}
pub fn set_credentials(&self, credentials: &StorytellerCredentialSet) -> AnyhowResult<()> {
match self.credentials.write() {
Err(err) => Err(anyhow!("Failed to acquire write lock: {:?}", err)),
Ok(mut creds) => {
*creds = Some(credentials.clone());
Ok(())
}
}
}
pub fn clear_credentials(&self) -> AnyhowResult<()> {
match self.credentials.write() {
Err(err) => Err(anyhow!("Failed to acquire write lock: {:?}", err)),
Ok(mut creds) => {
*creds = None;
Ok(())
}
}
}
pub fn get_credentials(&self) -> AnyhowResult<Option<StorytellerCredentialSet>> {
match self.credentials.read() {
Err(err) => Err(anyhow!("Failed to acquire read lock: {:?}", err)),
Ok(creds) => {
Ok(creds.clone())
}
}
}
pub fn get_credentials_required(&self) -> AnyhowResult<StorytellerCredentialSet> {
match self.credentials.read() {
Err(err) => Err(anyhow!("Failed to acquire read lock: {:?}", err)),
Ok(creds) => match &*creds {
None => Err(anyhow!("Credentials not set")),
Some(creds) => Ok(creds.clone()),
}
}
}
}
@@ -0,0 +1,109 @@
use crate::state::data_dir::app_data_root::AppDataRoot;
use crate::state::sora::read_sora_credentials_from_disk::read_sora_credentials_from_disk;
use crate::state::sora::sora_credential_holder::SoraCredentialHolder;
use crate::state::storyteller::read_storyteller_credentials_from_disk::read_storyteller_credentials_from_disk;
use crate::state::storyteller::storyteller_credential_holder::StorytellerCredentialHolder;
use anyhow::anyhow;
use errors::AnyhowResult;
use log::{error, info, warn};
use std::fs::OpenOptions;
use std::io::Write;
use storyteller_client::credentials::storyteller_avt_cookie::StorytellerAvtCookie;
use storyteller_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use storyteller_client::credentials::storyteller_session_cookie::StorytellerSessionCookie;
#[derive(Clone)]
pub struct StorytellerCredentialManager {
holder: StorytellerCredentialHolder,
app_data_root: AppDataRoot,
}
impl StorytellerCredentialManager {
pub fn initialize_from_disk_infallible(app_data_root: &AppDataRoot) -> Self {
let holder = StorytellerCredentialHolder::new();
match read_storyteller_credentials_from_disk(app_data_root) {
Err(err) => warn!("Failed to read credentials from disk: {:?}", err),
Ok(creds) => {
holder.set_credentials(&creds).expect("Failed to set credentials");
}
}
Self {
holder,
app_data_root: app_data_root.clone(),
}
}
pub fn set_credentials(&self, creds: &StorytellerCredentialSet) -> AnyhowResult<()> {
self.holder.set_credentials(creds)
}
pub fn clear_credentials(&self) -> AnyhowResult<()> {
self.holder.clear_credentials()?;
Ok(())
}
pub fn get_credentials(&self) -> AnyhowResult<Option<StorytellerCredentialSet>> {
self.holder.get_credentials()
}
pub fn get_credentials_required(&self) -> AnyhowResult<StorytellerCredentialSet> {
self.holder.get_credentials_required()
}
pub fn reset_from_disk(&self) -> AnyhowResult<StorytellerCredentialSet> {
let creds = read_storyteller_credentials_from_disk(&self.app_data_root)?;
self.holder.set_credentials(&creds)?;
Ok(creds)
}
pub fn persist_all_to_disk(&self) -> AnyhowResult<()> {
let credentials = self.holder.get_credentials_required()?;
info!("Persisting avt cookie to disk...");
if let Some(avt) = credentials.avt.as_ref() {
persist_avt_to_disk(&avt, &self.app_data_root)?;
}
info!("Persisting session cookie to disk...");
if let Some(session) = credentials.session.as_ref() {
persist_session_to_disk(&session, &self.app_data_root)?;
}
Ok(())
}
}
fn persist_session_to_disk(session: &StorytellerSessionCookie, app_data_root: &AppDataRoot) -> AnyhowResult<()> {
let creds_dir = app_data_root.credentials_dir();
let filename = creds_dir.get_storyteller_session_cookie_file_path();
let mut file = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(filename)?;
file.write_all(session.as_bytes())?;
file.flush()?;
Ok(())
}
fn persist_avt_to_disk(avt: &StorytellerAvtCookie, app_data_root: &AppDataRoot) -> AnyhowResult<()> {
let creds_dir = app_data_root.credentials_dir();
let filename = creds_dir.get_storyteller_avt_cookie_file_path();
let mut file = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(filename)?;
file.write_all(avt.as_bytes())?;
file.flush()?;
Ok(())
}
@@ -0,0 +1,42 @@
use anyhow::anyhow;
use discord_rich_presence::{DiscordIpc, DiscordIpcClient};
use errors::AnyhowResult;
use log::info;
/// Our Discord App ID. Not sure if this is a secret.
const DISCORD_APP_ID : &str = "1366596912593113138";
pub async fn discord_presence_thread() -> ! {
loop {
let client = DiscordIpcClient::new(DISCORD_APP_ID);
let _err = discord_main_loop(client).await;
tokio::time::sleep(std::time::Duration::from_millis(10_000)).await;
}
}
async fn discord_main_loop(mut client: DiscordIpcClient) -> AnyhowResult<()> {
info!("Connecting Discord IPC client");
client.connect()
.map_err(|err| anyhow!("Error connecting to Discord IPC: {:?}", err))?;
loop {
info!("Notifying discord...");
let assets = discord_rich_presence::activity::Assets::new()
.large_image("https://storyteller.ai/android-chrome-512x512.png")
.large_text("https://GetArtCraft.com");
//.small_image("https://storyteller.ai/android-chrome-192x192.png")
//.small_text("small text");
client.set_activity(discord_rich_presence::activity::Activity::new()
//.state("Anyone can make arts!")
.state("GetArtCraft.com")
.details("Making Arts with ArtCraft")
.activity_type(discord_rich_presence::activity::ActivityType::Playing)
.assets(assets))
.map_err(|err| anyhow!("Error setting Discord activity: {:?}", err))?;
tokio::time::sleep(std::time::Duration::from_millis(30_000)).await;
}
}
@@ -0,0 +1,60 @@
use crate::state::data_dir::app_data_root::AppDataRoot;
use crate::state::main_window_size::MainWindowSize;
use crate::state::storyteller::storyteller_credential_manager::StorytellerCredentialManager;
use crate::threads::main_window_thread::persist_storyteller_cookies_task::persist_storyteller_cookies_task;
use crate::threads::main_window_thread::persist_window_resize_task::persist_window_resize_task;
use errors::AnyhowResult;
use log::{error, info};
use memory_store::clone_slot::CloneSlot;
use tauri::{AppHandle, Manager, Webview, Window};
use crate::state::app_startup_time::AppStartupTime;
const MAIN_WINDOW_NAME : &str = "main";
pub async fn main_window_thread(
app: AppHandle,
app_data_root: AppDataRoot,
storyteller_creds_manager: StorytellerCredentialManager,
) -> ! {
// TODO: Move these into some kind of dependency injection framework
let window_size_slot: CloneSlot<MainWindowSize> = CloneSlot::empty();
let app_startup_time = AppStartupTime::new();
loop {
for (window_name, window) in app.windows() {
if window_name == MAIN_WINDOW_NAME {
let result = handle_main_window(
&window,
&app_data_root,
&storyteller_creds_manager,
&window_size_slot,
&app_startup_time,
).await;
if let Err(err) = result {
error!("Error handling main window: {:?}", err);
}
}
}
tokio::time::sleep(std::time::Duration::from_millis(1_000)).await;
}
}
pub async fn handle_main_window(
window: &Window,
app_data_root: &AppDataRoot,
storyteller_creds_manager: &StorytellerCredentialManager,
window_size_slot: &CloneSlot<MainWindowSize>,
app_startup_time: &AppStartupTime,
) -> AnyhowResult<()> {
loop {
log_errors(persist_window_resize_task(window, app_data_root, window_size_slot).await);
log_errors(persist_storyteller_cookies_task(window, app_data_root, storyteller_creds_manager, app_startup_time).await);
tokio::time::sleep(std::time::Duration::from_millis(1_000)).await;
}
}
pub fn log_errors<T>(result: AnyhowResult<T>) {
if let Err(err) = result {
error!("Error persisting window size: {:?}", err);
}
}
@@ -0,0 +1,3 @@
pub mod main_window_thread;
pub mod persist_storyteller_cookies_task;
pub mod persist_window_resize_task;
@@ -0,0 +1,119 @@
use crate::state::data_dir::app_data_root::AppDataRoot;
use crate::state::storyteller::storyteller_credential_manager::StorytellerCredentialManager;
use crate::threads::sora_session_login_thread::LOGIN_WINDOW_NAME;
use anyhow::anyhow;
use errors::AnyhowResult;
use log::{debug, error, info};
use once_cell::sync::Lazy;
use reqwest::Url;
use std::fs;
use chrono::TimeDelta;
use storyteller_client::credentials::storyteller_avt_cookie::StorytellerAvtCookie;
use storyteller_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use storyteller_client::credentials::storyteller_session_cookie::StorytellerSessionCookie;
use tauri::{AppHandle, Manager, Webview, Window};
use crate::state::app_startup_time::AppStartupTime;
const MAIN_WEBVIEW_NAME: &str = "main";
const STORYTELLER_ROOT_COOKIE_URL_STR: &str = "https://api.storyteller.ai/";
static STORYTELLER_ROOT_COOKIE_URL: Lazy<Url> = Lazy::new(|| {
Url::parse(STORYTELLER_ROOT_COOKIE_URL_STR).expect("URL should parse")
});
const AVT_COOKIE_NAME : &str = "visitor";
const SESSION_COOKIE_NAME : &str = "session";
/// There's some kind of race condition that we need to wait for before inspecting cookies.
const RACE_CONDITION_WAIT_TIME: TimeDelta = TimeDelta::milliseconds(5000);
pub async fn persist_storyteller_cookies_task(
window: &Window,
app_data_root: &AppDataRoot,
storyteller_credential_manager: &StorytellerCredentialManager,
app_startup_time: &AppStartupTime,
) -> AnyhowResult<()> {
if app_startup_time.time_delta_since() < RACE_CONDITION_WAIT_TIME {
// NB: There's an issue when the "main window thread" inquires about the
// webview cookies shortly after app startup. When it attempts to dump the
// cookies within a few milliseconds of app launch, it deadlocks and the
// app never launches correctly. The entire webview goes blank and the app
// freezes. This hack seems to fix the problem.
return Ok(())
}
for webview in window.webviews() {
let label = webview.label();
if label == MAIN_WEBVIEW_NAME {
persist_webview_cookies(&webview, app_data_root, storyteller_credential_manager).await?;
break;
}
}
Ok(())
}
async fn persist_webview_cookies(
webview: &Webview,
app_data_root: &AppDataRoot,
storyteller_credential_manager: &StorytellerCredentialManager,
) -> AnyhowResult<()> {
let current_webview_credentials = get_storyteller_cookies(webview).await?;
if current_webview_credentials.is_empty() {
// TODO: handle logout / cookie deletion
return Ok(());
}
let mut replace_credentials = true;
let maybe_old_credentials = storyteller_credential_manager.get_credentials()?;
if let Some(old_credentials) = maybe_old_credentials {
if old_credentials.equals(&current_webview_credentials) {
replace_credentials = false;
}
}
if replace_credentials {
info!("Writing ArtCraft credentials to disk...");
storyteller_credential_manager.set_credentials(&current_webview_credentials)?;
storyteller_credential_manager.persist_all_to_disk()?;
}
Ok(())
}
pub async fn get_storyteller_cookies(webview: &Webview) -> AnyhowResult<StorytellerCredentialSet> {
debug!("Getting storyteller cookies...");
//// FIXME: THIS IS A TOTAL HACK. / DO NOT REMOVE.
//// NB: If we don't call a method on the webview, the very next call to get cookies will deadlock.
//if let Ok(url) = webview.url() {
// debug!("For url: {:?}", url);
//}
let cookies = webview.cookies_for_url(STORYTELLER_ROOT_COOKIE_URL.clone())?;
info!("Got storyteller cookies: {:?}", cookies);
info!("Got storyteller cookies: {:?}", cookies.len());
info!("Got storyteller location: {:?}", webview.url());
let mut avt_cookie = None;
let mut session_cookie = None;
for cookie in cookies.into_iter() {
if let Some(avt) = StorytellerAvtCookie::maybe_from_cookie(&cookie) {
avt_cookie = Some(avt);
} else if let Some(session) = StorytellerSessionCookie::maybe_from_cookie(&cookie) {
session_cookie = Some(session);
}
}
Ok(StorytellerCredentialSet::initialize(
avt_cookie,
session_cookie,
))
}
@@ -0,0 +1,32 @@
use crate::state::data_dir::app_data_root::AppDataRoot;
use crate::state::main_window_size::MainWindowSize;
use errors::AnyhowResult;
use log::info;
use memory_store::clone_slot::CloneSlot;
use tauri::Window;
pub async fn persist_window_resize_task(
window: &Window,
app_data_root: &AppDataRoot,
window_size_slot: &CloneSlot<MainWindowSize>,
) -> AnyhowResult<()> {
let current_window_size = MainWindowSize::from_window(window)?;
let current_physical_size = window.inner_size()?;
let mut save_size_to_disk = true;
if let Ok(Some(old_size)) = window_size_slot.get_clone() {
if old_size.matches_physical_size(&current_physical_size) {
save_size_to_disk = false;
}
}
if save_size_to_disk {
info!("Saving window size configs to disk...");
current_window_size.persist_to_filesystem(app_data_root)?;
window_size_slot.set_clone(&current_window_size)?;
}
Ok(())
}
@@ -1 +1,4 @@
pub mod sora_session_login_thread;
pub mod discord_presence_thread;
pub mod main_window_thread;
pub mod sora_session_login_thread;
pub mod sora_task_polling_thread;
@@ -1,22 +1,22 @@
use std::fs;
use crate::state::app_dir::AppDataRoot;
use crate::state::data_dir::app_data_root::AppDataRoot;
use crate::state::sora::sora_credential_holder::SoraCredentialHolder;
use crate::state::sora::sora_credential_manager::SoraCredentialManager;
use crate::utils::cookies::sora_webview_cookies::get_all_sora_cookies_as_string;
use crate::utils::sora::initialize_sora_jwt_bearer_token::initialize_sora_jwt_bearer_token;
use crate::utils::sora_webview_cookies::get_all_sora_cookies_as_string;
use anyhow::anyhow;
use chrono::{DateTime, NaiveDateTime, TimeDelta};
use errors::AnyhowResult;
use log::{error, info, warn};
use once_cell::sync::Lazy;
use reqwest::Url;
use std::fs::{read_to_string, OpenOptions};
use std::io::Write;
use std::ops::Sub;
use chrono::{DateTime, NaiveDateTime, TimeDelta};
use tauri::{AppHandle, Manager, Webview};
use openai_sora_client::creds::sora_credential_set::SoraCredentialSet;
use openai_sora_client::recipes::maybe_upgrade_or_renew_session::maybe_upgrade_or_renew_session;
use openai_sora_client::utils::has_session_cookie::{has_session_cookie, SessionCookiePresence};
use reqwest::Url;
use std::fs;
use std::fs::{read_to_string, OpenOptions};
use std::io::Write;
use std::ops::Sub;
use tauri::{AppHandle, Manager, Webview};
pub const LOGIN_WINDOW_NAME: &str = "login_window";
@@ -0,0 +1,89 @@
use crate::state::data_dir::app_data_root::AppDataRoot;
use crate::state::sora::sora_credential_manager::SoraCredentialManager;
use crate::state::sora::sora_task_queue::SoraTaskQueue;
use errors::AnyhowResult;
use log::{error, info};
use openai_sora_client::recipes::list_sora_task_status_with_session_auto_renew::{list_sora_task_status_with_session_auto_renew, StatusRequestArgs};
use openai_sora_client::requests::image_gen::image_gen_status::{TaskId, TaskStatus};
use tauri::AppHandle;
pub async fn sora_task_polling_thread(
app_data_root: AppDataRoot,
sora_creds_manager: SoraCredentialManager,
sora_task_queue: SoraTaskQueue,
) -> ! {
loop {
let res = polling_loop(&sora_creds_manager, &sora_task_queue).await;
if let Err(err) = res {
error!("An error occurred: {:?}", err);
}
tokio::time::sleep(std::time::Duration::from_millis(30_000)).await;
}
}
async fn polling_loop(
sora_creds_manager: &SoraCredentialManager,
sora_task_queue: &SoraTaskQueue,
) -> AnyhowResult<()> {
loop {
if sora_task_queue.is_empty()? {
// No need to poll if we don't have pending tasks.
tokio::time::sleep(std::time::Duration::from_millis(1_000)).await;
continue;
}
info!("Task queue has {} pending tasks.", sora_task_queue.len()?);
let creds = sora_creds_manager.get_credentials_required()?;
let (response, maybe_new_creds) = list_sora_task_status_with_session_auto_renew(StatusRequestArgs {
limit: None,
// TODO: How can we use the task id to poll better? Our existing code doesn't seem to illuminate this.
before: None,
credentials: creds,
}).await?;
if let Some(new_creds) = maybe_new_creds {
info!("Saving new credentials.");
sora_creds_manager.set_credentials(&new_creds)?;
}
let mut succeeded_tasks = Vec::new();
let mut failed_tasks = Vec::new();
// TODO: The cursoring logic likely needs to improve.
for task in response.task_responses {
let status = TaskStatus::from_str(&task.status);
match status {
TaskStatus::Succeeded => {
succeeded_tasks.push(task);
}
TaskStatus::Failed => {
failed_tasks.push(task);
}
TaskStatus::Queued => {}
TaskStatus::Running => {}
TaskStatus::Unknown(_) => {}
}
}
let failed_task_ids: Vec<&TaskId> = failed_tasks
.iter()
.map(|task| &task.id)
.collect();
sora_task_queue.remove_list(&failed_task_ids)?;
// TODO: Handle succeeded tasks.
let succeeded_task_ids : Vec<&TaskId> = succeeded_tasks
.iter()
.map(|task| &task.id)
.collect();
sora_task_queue.remove_list(&succeeded_task_ids)?;
tokio::time::sleep(std::time::Duration::from_millis(2_000)).await;
}
}
@@ -0,0 +1 @@
pub mod sora_webview_cookies;
@@ -1,7 +1,8 @@
pub mod clear_all_webview_cookies;
pub mod cookies;
pub mod get_url_file_extension;
pub mod image;
pub mod large_file_transfer;
pub mod object_store;
pub mod simple_http_download;
pub mod sora_webview_cookies;
pub mod sora;
pub mod sora;
@@ -1,4 +1,4 @@
use crate::state::app_dir::AppDataRoot;
use crate::state::data_dir::app_data_root::AppDataRoot;
use anyhow::anyhow;
use errors::AnyhowResult;
use log::info;
@@ -0,0 +1,6 @@
# For some reason, we can't pass json files to the tauri dev command
[build]
frontendDist = "../../../frontend/apps/editor3d/dist"
#beforeDevCommand = "nx run editor3d:dev"
beforeDevCommand = "echo 'no dev'"
devUrl = "http://localhost:5173"
@@ -4,7 +4,7 @@
"version": "0.0.1",
"identifier": "ai.artcraft.app",
"build": {
"frontendDist": "../../../frontend/apps/editor2d/dist",
"frontendDist": "../../../",
"devUrl": "http://localhost:5741",
"beforeDevCommand": "nx run editor2d:dev",
"beforeBuildCommand": "nx run editor2d:build"
@@ -0,0 +1,37 @@
{
"$schema": "https://schema.tauri.app/config/2.0.0-rc",
"productName": "ArtCraft",
"version": "0.0.1",
"identifier": "ai.artcraft.app",
"build": {
"frontendDist": "../../../frontend/apps/editor3d/dist",
"devUrl": "http://localhost:5173",
"beforeDevCommand": "echo",
"beforeBuildCommand": "nx run editor2d:build"
},
"app": {
"windows": [
{
"title": "ArtCraft",
"width": 2400,
"height": 1300,
"resizable": true,
"fullscreen": false
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}
@@ -21,6 +21,7 @@ pub struct ImageRemixAutoRenewRequest<'a> {
}
/// Image request with retry and session auto-renewal.
/// If a new sora credential is returned, replace the old one with the new one.
pub async fn image_remix_with_session_auto_renew(request: ImageRemixAutoRenewRequest<'_>) -> Result<(SoraImageGenResponse, Option<SoraCredentialSet>), SoraError> {
let result = sora_image_gen_remix(SoraImageGenRemixRequest {
prompt: request.prompt.clone(), // NB: Clone because used again
@@ -23,6 +23,7 @@ pub struct ImageUploadFromBytesAutoRenewRequest<'a> {
}
/// Image upload with retry and session auto-renewal.
/// If a new sora credential is returned, replace the old one with the new one.
pub async fn image_upload_from_bytes_with_session_auto_renew(
request: ImageUploadFromBytesAutoRenewRequest<'_>
) -> Result<(SoraMediaUploadResponse, Option<SoraCredentialSet>), SoraError> {
@@ -13,6 +13,7 @@ pub struct ImageUploadFromFileAutoRenewRequest<'a, P: AsRef<Path>> {
}
/// Image upload with retry and session auto-renewal.
/// If a new sora credential is returned, replace the old one with the new one.
pub async fn image_upload_from_file_with_session_auto_renew<P: AsRef<Path>>(
request: ImageUploadFromFileAutoRenewRequest<'_, P>
) -> Result<(SoraMediaUploadResponse, Option<SoraCredentialSet>), SoraError> {
@@ -0,0 +1,62 @@
use crate::creds::credential_migration::CredentialMigrationRef;
use crate::creds::sora_credential_set::SoraCredentialSet;
use crate::recipes::image_upload_from_file_with_session_auto_renew::ImageUploadFromFileAutoRenewRequest;
use crate::recipes::maybe_refresh_credentials_on_sora_error::maybe_refresh_credentials_on_sora_error;
use crate::recipes::maybe_upgrade_or_renew_session::maybe_upgrade_or_renew_session;
use crate::requests::image_gen::image_gen_status::{get_image_gen_status, StatusRequest, TaskId, TaskResponse, VideoGenStatusResponse};
use crate::requests::upload::upload_media_from_file::sora_media_upload_from_file;
use crate::requests::upload::upload_media_http_request::SoraMediaUploadResponse;
use crate::sora_error::SoraError;
use anyhow::Error;
use log::{info, warn};
use std::path::Path;
pub struct StatusRequestArgs {
pub limit: Option<u32>,
pub before: Option<TaskId>,
pub credentials: SoraCredentialSet,
}
/// Check Sora task statuses with session auto-renewal.
/// If a new sora credential is returned, replace the old one with the new one.
pub async fn list_sora_task_status_with_session_auto_renew(
args: StatusRequestArgs,
) -> Result<(VideoGenStatusResponse, Option<SoraCredentialSet>), SoraError> {
let request = StatusRequest {
limit: args.limit,
before: args.before.map(|t| t.0),
};
let mut new_creds = None;
if args.credentials.jwt_bearer_token.is_none() {
info!("JWT not set. Upgrading credentials...");
let mut updated_creds = args.credentials.clone();
if let Err(err) = maybe_upgrade_or_renew_session(&mut updated_creds).await {
warn!("Failed to upgrade or renew session: {:?}", err);
}
new_creds = Some(updated_creds);
}
let legacy_creds = new_creds.as_ref()
.unwrap_or_else(|| &args.credentials)
.to_legacy_credentials()?;
let result = get_image_gen_status(&request, &legacy_creds).await;
let err = match result {
Ok(response) => return Ok((response, new_creds)),
Err(err) => err,
};
warn!("Task status polling failed: {:?}", err);
let new_creds = maybe_refresh_credentials_on_sora_error(&args.credentials, err).await?;
info!("Retrying image upload with new credentials...");
let result = get_image_gen_status(&request, &legacy_creds).await?;
Ok((result, Some(new_creds)))
}
@@ -1,6 +1,7 @@
pub mod image_remix_with_session_auto_renew;
pub mod image_upload_from_bytes_with_session_auto_renew;
pub mod image_upload_from_file_with_session_auto_renew;
pub mod list_sora_task_status_with_session_auto_renew;
pub mod maybe_refresh_credentials_on_sora_error;
pub mod maybe_upgrade_or_renew_session;
pub mod wait_for_image_gen_status;
@@ -47,5 +47,5 @@ pub async fn wait_for_image_gen_status(task_id: &String, credentials: &SoraCrede
}
fn find_task_response_by_id(status_response: &VideoGenStatusResponse, task_id: String) -> Option<&TaskResponse> {
status_response.task_responses.iter().find(|task| task.id == task_id)
status_response.task_responses.iter().find(|task| task.id.0 == task_id)
}
@@ -1,7 +1,8 @@
use crate::requests::image_gen::image_gen_status::TaskId;
#[derive(Debug, Clone)]
pub struct SoraImageGenResponse {
pub task_id: String,
pub task_id: TaskId,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -1,6 +1,7 @@
use crate::creds::credential_migration::CredentialMigrationRef;
use serde_derive::{Deserialize, Serialize};
use thiserror::Error;
use crate::requests::image_gen::image_gen_status::TaskId;
const SORA_IMAGE_GEN_URL: &str = "https://sora.com/backend/video_gen";
@@ -94,7 +95,7 @@ pub (crate) struct RawSoraImageGenRequest {
#[serde(rename_all = "snake_case")]
pub (crate) struct RawSoraResponse {
/// eg. "task_01jqsz9dsae9tvjygf1abrv3xf"
pub id: String,
pub id: TaskId,
/// not known
pub priority: Option<String>,
}
@@ -227,3 +228,17 @@ pub (crate) async fn image_gen_http_request(sora_request: RawSoraImageGenRequest
Ok(response)
}
#[cfg(test)]
mod tests {
use errors::AnyhowResult;
use crate::requests::image_gen::image_gen_http_request::RawSoraResponse;
#[test]
fn deserialize_task_id() -> AnyhowResult<()> {
let json = "{\"id\": \"task_foobarbaz\"}";
let response : RawSoraResponse = serde_json::from_str(json)?;
assert_eq!(response.id.0, "task_foobarbaz");
Ok(())
}
}
@@ -1,15 +1,28 @@
use std::{fs::File, io::Write, path::Path};
use crate::credentials::SoraCredentials;
use crate::sora_error::SoraError;
use anyhow::anyhow;
use errors::AnyhowResult;
use reqwest::Url;
use serde_derive::Deserialize;
use serde_derive::{Deserialize, Serialize};
use std::fmt::{Debug, Display};
use std::{fs::File, io::Write, path::Path};
use log::debug;
const SORA_STATUS_URL: &str = "https://sora.com/backend/video_gen";
/// This user agent is tied to the sentinel generation. If we need to change it, we may need to change sentinel generation too.
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";
/// A strongly typed task ID for Sora tasks.
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize)]
pub struct TaskId(pub String);
impl Display for TaskId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
std::fmt::Display::fmt(&self.0, f)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub enum TaskStatus {
Queued,
@@ -42,7 +55,7 @@ pub struct VideoGenStatusResponse {
#[derive(Deserialize, Clone, Debug)]
#[serde(rename_all = "snake_case")]
pub struct TaskResponse {
pub id: String,
pub id: TaskId,
pub user: String,
pub created_at: String,
pub status: String,
@@ -79,7 +92,7 @@ pub struct TaskResponse {
#[serde(rename_all = "snake_case")]
pub struct Generation {
pub id: String,
pub task_id: String,
pub task_id: TaskId,
pub created_at: String,
pub deleted_at: Option<String>,
pub url: String,
@@ -170,7 +183,7 @@ pub struct ModerationResult {
pub results_by_frame_index: serde_json::Value,
pub code: Option<String>,
pub is_output_rejection: bool,
pub task_id: String,
pub task_id: TaskId,
}
pub struct StatusRequest {
@@ -186,10 +199,11 @@ impl StatusRequest {
}
/// Gets the status of image generation tasks from Sora API
pub async fn get_image_gen_status(status_request: &StatusRequest, credentials: &SoraCredentials) -> AnyhowResult<VideoGenStatusResponse> {
pub async fn get_image_gen_status(status_request: &StatusRequest, credentials: &SoraCredentials) -> Result<VideoGenStatusResponse, SoraError> {
let client = reqwest::Client::new();
let mut url = reqwest::Url::parse(SORA_STATUS_URL)?;
let mut url = reqwest::Url::parse(SORA_STATUS_URL)
.map_err(|err| anyhow!("error parsing URL: {:?}", err))?;
// Add query parameters
if let Some(limit) = status_request.limit {
@@ -200,7 +214,11 @@ pub async fn get_image_gen_status(status_request: &StatusRequest, credentials: &
url.query_pairs_mut().append_pair("before", &before);
}
let http_request = client.get(url).header("User-Agent", USER_AGENT).header("Cookie", &credentials.cookie).header("Authorization", credentials.authorization_header_value()).header("Content-Type", "application/json");
let http_request = client.get(url)
.header("User-Agent", USER_AGENT)
.header("Cookie", &credentials.cookie)
.header("Authorization", credentials.authorization_header_value())
.header("Content-Type", "application/json");
let http_request = http_request.build()?;
@@ -209,10 +227,12 @@ pub async fn get_image_gen_status(status_request: &StatusRequest, credentials: &
let status = response.status();
let response_body = &response.text().await?;
println!("response_body: {}", response_body);
debug!("response_body: {}", response_body);
if status != reqwest::StatusCode::OK {
return Err(anyhow::anyhow!("The status request failed; status = {:?}, message = {:?}", status, response_body));
// TODO: Handle non-200 status codes, esp. JWT errors and credential errors
return Err(SoraError::AnyhowError(anyhow::anyhow!("The status request failed; status = {:?}, message = {:?}", status, response_body)));
}
let response: VideoGenStatusResponse = serde_json::from_str(response_body)?;
@@ -285,7 +305,7 @@ mod tests {
let task_id = response.task_responses[0].id.clone();
let task_response = wait_for_image_gen_status(
&task_id,
&task_id.0,
&creds,
Some(10)
).await?;
@@ -84,7 +84,7 @@ mod tests {
println!("task_id: {}", response.task_id);
assert!(response.task_id.starts_with("task_"));
assert!(response.task_id.0.starts_with("task_"));
Ok(())
}
}
@@ -68,7 +68,7 @@ mod tests {
println!("task_id: {}", response.task_id);
assert!(response.task_id.starts_with("task_"));
assert!(response.task_id.0.starts_with("task_"));
Ok(())
}
}
@@ -5,12 +5,14 @@ use std::io;
/// The single error type this crate should surface to all callers.
#[derive(Debug)]
pub enum SoraError {
/// We haven't received a bearer token yet (this is our application error)
/// We haven't received a bearer token yet
/// This is our own internal application state error, not something Sora returns.
NoBearerTokenAvailable,
/// We're sending too many tasks to Sora.
TooManyConcurrentTasks,
// TODO: Try to make concrete error types for these.
/// Unauthorized, cookie and/or bearer token expired. We'll need to ask for a refreshed login.
///
/// Example message, e.g. from the upload endpoint:
@@ -27,7 +29,7 @@ pub enum SoraError {
/// Another error occurred.
OtherBadStatus(anyhow::Error),
/// Reqwest Error
/// Reqwest Error, eg. connection failures.
ReqwestError(reqwest::Error),
/// std::io Error that arises from our end, eg. reading from the filesystem.
@@ -35,6 +37,9 @@ pub enum SoraError {
/// anyhow::Error arises from our end
AnyhowError(anyhow::Error),
/// serde_json::Error, likely from JSON deserialization schema mismatch.
JsonError(serde_json::Error),
}
impl Error for SoraError {}
@@ -63,6 +68,9 @@ impl Display for SoraError {
Self::AnyhowError(err) => {
write!(f, "Anyhow error: {}", err)
}
Self::JsonError(err) => {
write!(f, "serde_json error: {}", err)
}
}
}
}
@@ -84,3 +92,9 @@ impl From<anyhow::Error> for SoraError {
Self::AnyhowError(err)
}
}
impl From<serde_json::Error> for SoraError {
fn from(err: serde_json::Error) -> Self {
Self::JsonError(err)
}
}
@@ -25,6 +25,7 @@ tokens = { workspace = true }
# External
anyhow = { workspace = true }
chrono = { workspace = true }
cookie = { workspace = true }
log = { workspace = true }
reqwest = { workspace = true, features = ["multipart", "gzip"] }
url = { workspace = true }
@@ -0,0 +1,3 @@
pub mod storyteller_avt_cookie;
pub mod storyteller_credential_set;
pub mod storyteller_session_cookie;
@@ -0,0 +1,40 @@
use cookie::Cookie;
const AVT_COOKIE_NAME : &str = "visitor";
/// The "anonymous visitor token" cookie allows users to interact with
/// the service in a logged-out state with some continuity.
#[derive(Clone)]
pub struct StorytellerAvtCookie {
cookie: String,
}
impl StorytellerAvtCookie {
pub fn new(cookie: String) -> Self {
Self { cookie }
}
pub fn maybe_from_cookie(cookie: &Cookie) -> Option<Self> {
if cookie.name() == AVT_COOKIE_NAME {
Some(Self::new(cookie.value().to_string()))
} else {
None
}
}
pub fn as_str(&self) -> &str {
&self.cookie
}
pub fn as_bytes(&self) -> &[u8] {
self.cookie.as_bytes()
}
pub fn to_string(&self) -> String {
self.cookie.clone()
}
pub fn equals(&self, other: &Self) -> bool {
self.cookie == other.cookie
}
}
@@ -0,0 +1,69 @@
use crate::credentials::storyteller_avt_cookie::StorytellerAvtCookie;
use crate::credentials::storyteller_session_cookie::StorytellerSessionCookie;
#[derive(Clone)]
pub struct StorytellerCredentialSet {
pub avt: Option<StorytellerAvtCookie>,
pub session: Option<StorytellerSessionCookie>,
}
impl StorytellerCredentialSet {
pub fn empty() -> Self {
Self {
avt: None,
session: None,
}
}
pub fn initialize(
avt: Option<StorytellerAvtCookie>,
session: Option<StorytellerSessionCookie>,
) -> Self {
Self {
avt,
session,
}
}
pub fn initialize_with_just_cookie(session: StorytellerSessionCookie) -> Self {
Self {
avt: None,
session: Some(session),
}
}
pub fn initialize_with_just_avt(avt: StorytellerAvtCookie) -> Self {
Self {
avt: Some(avt),
session: None,
}
}
pub fn is_empty(&self) -> bool {
self.avt.is_none() && self.session.is_none()
}
pub fn equals(&self, other: &Self) -> bool {
match (&self.avt, &other.avt) {
(None, None) => {} // Fallthrough
(Some(avt), Some(other_avt)) => {
if !avt.equals(other_avt) {
return false;
}
}
_ => return false,
}
match (&self.session, &other.session) {
(None, None) => {} // Fallthrough
(Some(session), Some(other_session)) => {
if !session.equals(other_session) {
return false;
}
}
_ => return false,
}
true
}
}
@@ -0,0 +1,39 @@
use cookie::Cookie;
const SESSION_COOKIE_NAME : &str = "session";
/// The primary FakeYou / Storyteller / Artcraft backend session identifier.
#[derive(Clone)]
pub struct StorytellerSessionCookie {
cookie: String,
}
impl StorytellerSessionCookie {
pub fn new(cookie: String) -> Self {
Self { cookie }
}
pub fn maybe_from_cookie(cookie: &Cookie) -> Option<Self> {
if cookie.name() == SESSION_COOKIE_NAME {
Some(Self::new(cookie.value().to_string()))
} else {
None
}
}
pub fn as_str(&self) -> &str {
&self.cookie
}
pub fn as_bytes(&self) -> &[u8] {
self.cookie.as_bytes()
}
pub fn to_string(&self) -> String {
self.cookie.clone()
}
pub fn equals(&self, other: &Self) -> bool {
self.cookie == other.cookie
}
}
@@ -1,4 +1,7 @@
//! An API client for Storyteller / FakeYou
pub mod api_error;
pub mod credentials;
pub mod media_files;
pub mod shared_response_types;
pub mod utils;
mod api_error;
@@ -0,0 +1,26 @@
[package]
name = "memory_store"
edition = "2018"
version = "0.0.1"
authors = [
"Brandon Thomas <bt@brand.io>",
"Brandon Thomas <echelon@gmail.com>",
]
publish = false
[lib]
name = "memory_store"
path = "src/lib.rs"
[package.metadata.cargo-udeps.ignore]
normal = ["workspace-build-acceleration"]
[dependencies]
# Workspace hack (faster compile times)
workspace-build-acceleration = { workspace = true }
# Internal
errors = { workspace = true }
# External
anyhow = { workspace = true }
@@ -0,0 +1,54 @@
use anyhow::anyhow;
use errors::AnyhowResult;
use std::sync::{Arc, RwLock};
/// Store a threadsafe value that can be passed around and cloned.
/// The object is interior mutable so it can be changed without mut/&mut references.
/// This is useful for storing a value that may or may not be set between threads.
///
/// Unlike `CloneSlot`, this always holds an object.
#[derive(Clone)]
pub struct CloneCell<T: Clone> {
pub store: Arc<RwLock<T>>,
}
impl <T: Clone> CloneCell<T> {
pub fn with_clone(object: &T) -> Self {
Self {
store: Arc::new(RwLock::new(object.clone())),
}
}
pub fn with_owned(object: T) -> Self {
Self {
store: Arc::new(RwLock::new(object)),
}
}
pub fn get_clone(&self) -> AnyhowResult<T> {
match self.store.read() {
Err(err) => Err(anyhow!("Failed to acquire read lock: {:?}", err)),
Ok(store) => Ok(store.clone()),
}
}
pub fn set_clone(&self, value: &T) -> AnyhowResult<()> {
match self.store.write() {
Err(err) => Err(anyhow!("Failed to acquire write lock: {:?}", err)),
Ok(mut store) => {
*store = value.clone();
Ok(())
}
}
}
pub fn set_owned(&self, value: T) -> AnyhowResult<()> {
match self.store.write() {
Err(err) => Err(anyhow!("Failed to acquire write lock: {:?}", err)),
Ok(mut store) => {
*store = value;
Ok(())
}
}
}
}
@@ -0,0 +1,77 @@
use anyhow::anyhow;
use errors::AnyhowResult;
use std::sync::{Arc, RwLock};
/// Store a threadsafe value that can be passed around and cloned.
/// The object is interior mutable so it can be changed without mut/&mut references.
/// This is useful for storing a value that may or may not be set between threads.
///
/// Unlike `CloneCell`, this holds an optional object.
#[derive(Clone)]
pub struct CloneSlot<T: Clone> {
pub store: Arc<RwLock<Option<T>>>,
}
impl <T: Clone> CloneSlot<T> {
pub fn empty() -> Self {
Self {
store: Arc::new(RwLock::new(None)),
}
}
pub fn with_clone(object: &T) -> Self {
Self {
store: Arc::new(RwLock::new(Some(object.clone()))),
}
}
pub fn get_clone(&self) -> AnyhowResult<Option<T>> {
match self.store.read() {
Err(err) => Err(anyhow!("Failed to acquire read lock: {:?}", err)),
Ok(store) => Ok(store.clone()),
}
}
pub fn set_clone(&self, value: &T) -> AnyhowResult<()> {
match self.store.write() {
Err(err) => Err(anyhow!("Failed to acquire write lock: {:?}", err)),
Ok(mut store) => {
*store = Some(value.clone());
Ok(())
}
}
}
pub fn clear(&self) -> AnyhowResult<()> {
match self.store.write() {
Err(err) => Err(anyhow!("Failed to acquire write lock: {:?}", err)),
Ok(mut store) => {
*store = None;
Ok(())
}
}
}
pub fn take(&self) -> AnyhowResult<Option<T>> {
match self.store.write() {
Err(err) => Err(anyhow!("Failed to acquire write lock: {:?}", err)),
Ok(mut store) => {
Ok(store.take())
}
}
}
pub fn is_some(&self) -> AnyhowResult<bool> {
match self.store.read() {
Err(err) => Err(anyhow!("Failed to acquire read lock: {:?}", err)),
Ok(store) => Ok(store.is_some()),
}
}
pub fn is_none(&self) -> AnyhowResult<bool> {
match self.store.read() {
Err(err) => Err(anyhow!("Failed to acquire read lock: {:?}", err)),
Ok(store) => Ok(store.is_none()),
}
}
}
@@ -0,0 +1,2 @@
pub mod clone_cell;
pub mod clone_slot;
@@ -142,18 +142,16 @@ impl Display for EnqueueImageGenRequestError {
post,
tag = "Image Studio",
path = "/v1/image_studio/prompt",
params(
("request" = EnqueueStudioImageGenRequest, description = "Payload for Image Generation Request"),
),
responses(
(
status = 200,
description = "Enqueue TTS generically",
body = EnqueueImageGenRequestSuccessResponse,
),
(status = 400, description = "Bad input", body = EnqueueImageGenRequestError),
(status = 401, description = "Not authorized", body = EnqueueImageGenRequestError),
(status = 429, description = "Rate limited", body = EnqueueImageGenRequestError),
(status = 500, description = "Server error", body = EnqueueImageGenRequestError)
(status = 200, description = "Success response", body = EnqueueImageGenRequestSuccessResponse),
(status = 400, description = "Bad input", body = EnqueueImageGenRequestError),
(status = 401, description = "Not authorized", body = EnqueueImageGenRequestError),
(status = 429, description = "Rate limited", body = EnqueueImageGenRequestError),
(status = 500, description = "Server error", body = EnqueueImageGenRequestError),
),
params(("request" = EnqueueStudioImageGenRequest, description = "Payload for Image Generation Request"))
)]
pub async fn enqueue_studio_image_generation_handler(http_request: HttpRequest, request: Json<EnqueueStudioImageGenRequest>, server_state: Data<Arc<ServerState>>) -> Result<HttpResponse, EnqueueImageGenRequestError> {
validate_request(&request)?;
@@ -33,8 +33,8 @@ bytes = { version = "1.10.0", features = ["serde"] }
chrono = { version = "0.4.40", features = ["serde"] }
clap = { version = "4.5.13", features = ["derive"] }
cookie = { version = "0.18.1", default-features = false, features = ["percent-encode"] }
core-foundation = { version = "0.9.4" }
core-foundation-sys = { version = "0.8.7" }
#core-foundation = { version = "0.9.4" }
#core-foundation-sys = { version = "0.8.7" }
crossbeam-utils = { version = "0.8.20" }
crypto-common = { version = "0.1.6", default-features = false, features = ["std"] }
#cudarc = { version = "0.13.7", features = ["cuda-version-from-build-system", "cudnn", "dynamic-linking", "f16", "nccl"] }
+9
View File
@@ -55,4 +55,13 @@ npm install
2.import it first in code
3.nx sync
4.nx build
or
# frontend build
in frontend folder
% nx build -project type
% nx build editor3d
% nx dev editor3d
```
@@ -0,0 +1,23 @@
[build]
command = "./apps/artcraft-website/script/netlify_build.sh"
publish = "apps/artcraft-website/dist/"
[[headers]]
for = "/build/*"
[headers.values]
"Cache-Control" = "public, max-age=31560000, immutable"
# [[redirects]]
# from = "/ingest/static/*"
# to = "https://us-assets.i.posthog.com/static/:splat"
# host = "us-assets.i.posthog.com"
# status = 200
# force = true
#
# [[redirects]]
# from = "/ingest/*"
# to = "https://us.i.posthog.com/:splat"
# host = "us.i.posthog.com"
# status = 200
# force = true
+20
View File
@@ -0,0 +1,20 @@
#!/bin/bash
set -euxo pipefail
echo "Run build script (TODO: Make strict)"
nx build artcraft-website
echo "Change to project dir"
pushd apps/artcraft-website/
#echo "Copy netlify configs into dist"
#cp _headers dist/
#cp _redirects dist/
#echo "Copy netlify 404.html page into dist"
#cp "404.html" dist/
echo "List final files in build"
find dist/
@@ -1,33 +0,0 @@
import { ApiManager, ApiResponse } from "./ApiManager";
import { Subscription } from "./models/Billing";
import { LoyaltyProgram } from "./enums/Billing";
export class BillingApi extends ApiManager {
public async ListActiveSubscriptions(): Promise<
ApiResponse<{
active_subscriptions: Subscription[];
maybe_loyalty_program?: LoyaltyProgram;
}>
> {
const endpoint = `${this.ApiTargets.BaseApi}/v1/billing/active_subscriptions`;
return await this.get<{
success: boolean;
active_subscriptions?: Subscription[];
maybe_loyalty_program?: LoyaltyProgram;
error_message?: string;
}>({ endpoint: endpoint })
.then((response) => ({
success: response.success,
data: {
active_subscriptions: response.active_subscriptions || [],
maybe_loyalty_program: response.maybe_loyalty_program,
},
}))
.catch((err) => {
return {
success: false,
errorMessage: err.message,
};
});
}
}
@@ -1,91 +0,0 @@
import { ApiManager, ApiResponse } from "./ApiManager";
export class CommentsApi extends ApiManager {
public CreateComment({
commentMarkdown,
entityToken,
entityType,
uuidIdempotencyToken,
}: {
commentMarkdown: string;
entityToken: string;
entityType: string;
uuidIdempotencyToken: string;
}): Promise<ApiResponse<string>> {
const endpoint = `${this.ApiTargets.BaseApi}/v1/comments/new`;
const body = {
comment_markdown: commentMarkdown,
entity_token: entityToken,
entity_type: entityType,
uuid_idempotency_token: uuidIdempotencyToken,
};
return this.post<
{
comment_markdown: string;
entity_token: string;
entity_type: string;
uuid_idempotency_token: string;
},
{
success?: boolean;
comment_token?: string;
BadInput?: string;
}
>({ endpoint, body })
.then((response) => ({
success: response.success ?? false,
data: response.comment_token,
errorMessage: response.BadInput,
}))
.catch((err) => {
return { success: false, errorMessage: err.message };
});
}
public DeleteComment({
commentToken,
}: {
commentToken: string;
}): Promise<ApiResponse<undefined>> {
const endpoint = `${this.ApiTargets.BaseApi}/v1/comments/delete/${commentToken}`;
return this.post<
{ as_mod: boolean },
{
success?: boolean;
BadInput?: string;
}
>({ endpoint, body: { as_mod: true } })
.then((response) => ({
success: response.success ?? false,
errorMessage: response.BadInput,
}))
.catch((err) => {
return { success: false, errorMessage: err.message };
});
}
public ListCommentsByEntity({
entityType,
entityToken,
}: {
entityType: string;
entityToken: string;
}): Promise<ApiResponse<string[]>> {
const endpoint = `${this.ApiTargets.BaseApi}/v1/comments/list/${entityType}/${entityToken}`;
return this.get<{
success: boolean;
comments: string[];
}>({ endpoint })
.then((response) => ({
success: true,
data: response.comments,
}))
.catch((err) => {
return { success: false, errorMessage: err.message };
});
}
}
@@ -1,77 +0,0 @@
import { ApiManager, ApiResponse } from "./ApiManager";
import { Job, JobPreview } from "./models/Job";
export class JobsApi extends ApiManager {
public GetPreviewStatusByJobToken({
token,
}: {
token: string;
}): Promise<ApiResponse<JobPreview>> {
console.log(token);
const endpoint = `${this.ApiTargets.BaseApi}/v1/workflows/preview_status/${token}`;
return this.get<ApiResponse<JobPreview>>({ endpoint })
.then((response) => ({
success: response.success,
data: response.data,
}))
.catch((err) => {
return { success: false, errorMessage: err.message };
});
}
public GetJobByToken({
token,
}: {
token: string;
}): Promise<ApiResponse<Job>> {
const endpoint = `${this.ApiTargets.BaseApi}/v1/jobs/job/${token}`;
return this.get<{
success: boolean;
state: Job;
}>({ endpoint })
.then((response) => ({
success: response.success,
data: response.state,
}))
.catch((err) => {
return { success: false, errorMessage: err.message };
});
}
public ListRecentJobs(): Promise<ApiResponse<Job[]>> {
const endpoint = `${this.ApiTargets.BaseApi}/v1/jobs/session`;
return this.get<{
success: boolean;
jobs: Job[];
error_reason?: string;
}>({ endpoint })
.then((response) => ({
success: response.success,
data: response.jobs,
}))
.catch((err) => {
return { success: false, errorMessage: err.message };
});
}
public DeleteJobByToken(jobToken: string): Promise<ApiResponse<undefined>> {
const endpoint = `${this.ApiTargets.BaseApi}/v1/jobs/job/${jobToken}`;
return this.delete<
undefined,
{
success: boolean;
}
>({ endpoint })
.then((response) => ({
success: response.success,
}))
.catch((err) => {
return { success: false, errorMessage: err.message };
});
}
}
@@ -1,26 +0,0 @@
import { ApiManager, ApiResponse } from "./ApiManager";
import { StatusAlert } from "./models/StatusAlert";
export class MiscApi extends ApiManager {
public GetStatusAlertCheck(): Promise<ApiResponse<StatusAlert>> {
const endpoint = `${this.ApiTargets.BaseApi}/v1/session`;
return this.get<{
success: boolean;
maybe_alert?: Omit<StatusAlert, "refresh_interval_millis">;
refresh_interval_millis: number;
}>({ endpoint: endpoint })
.then((response) => ({
success: response.success,
data: {
...response.maybe_alert,
refresh_interval_millis: response.refresh_interval_millis,
},
}))
.catch((err) => {
return {
success: false,
errorMessage: err.mesasge,
};
});
}
}
@@ -1,42 +0,0 @@
import { ApiManager, ApiResponse } from "./ApiManager";
export class ModerationApi extends ApiManager {
public async UpdateFeatureFlagsByToken({
usernameOrToken,
flags,
}: {
usernameOrToken: string;
flags: string[];
}): Promise<
ApiResponse<{
inference_job_token?: string;
inference_job_token_type?: string;
}>
> {
const endpoint = `${this.ApiTargets.BaseApi}/v1/moderation/user_feature_flags/${usernameOrToken}`;
const body = {
action: {
AddFlags: {
flags,
},
},
};
return this.post<
{ action: { AddFlags: { flags: string[] } } },
{ success?: boolean; BadInput?: string }
>({
endpoint,
body: body,
})
.then((response) => ({
success: response.success ?? false,
errorMessage: response.BadInput,
}))
.catch((err) => ({
success: false,
errorMessage: err.message,
}));
}
}
@@ -1,25 +0,0 @@
import { ApiManager, ApiResponse } from "./ApiManager";
import { Prompts } from "./models/Prompts";
export class PromptsApi extends ApiManager {
public GetPromptsByToken({
token,
}: {
token: string;
}): Promise<ApiResponse<Prompts>> {
const endpoint = `${this.ApiTargets.BaseApi}/v1/prompts/${token}`;
return this.get<{
success: boolean;
prompt: Prompts;
error_reason?: string;
}>({ endpoint })
.then((response) => ({
success: response.success,
data: response.prompt,
}))
.catch((err) => {
return { success: false, errorMessage: err.message };
});
}
}
@@ -1,64 +0,0 @@
import { ApiManager, ApiResponse } from "./ApiManager";
import { GenerateTtsAudioErrorType } from "./enums/Tts";
import { Visibility } from "./enums/Visibility";
export interface GenerateTtsAudioRequest {
creator_set_visibility?: Visibility;
uuid_idempotency_token: string;
tts_model_token: string;
inference_text: string;
is_storyteller_demo?: boolean;
}
export interface GenerateTtsAudioResponse {
success?: boolean;
inference_job_token?: string;
inference_job_token_type?: string;
BadInput?: string;
}
export class TtsApi extends ApiManager {
public async GenerateTtsAudio(request: GenerateTtsAudioRequest): Promise<
ApiResponse<{
inference_job_token?: string;
inference_job_token_type?: string;
}>
> {
const endpoint = `${this.ApiTargets.BaseApi}/v1/tts/inference`;
const body = {
...request,
...(request.creator_set_visibility
? {}
: { creator_set_visibility: Visibility.Public }),
};
return this.post<GenerateTtsAudioRequest, GenerateTtsAudioResponse>({
endpoint,
body: body,
})
.then((response) => {
if (!response.success) {
return {
success: false,
errorMessage: response.BadInput,
data: {},
};
}
return {
success: true,
data: {
inference_job_token: response.inference_job_token,
inference_job_token_type: response.inference_job_token_type,
},
};
})
.catch(() => {
return {
success: false,
errorMessage: GenerateTtsAudioErrorType.UnknownError,
data: {},
};
});
}
}
@@ -1,201 +0,0 @@
import { ApiManager, ApiResponse } from "./ApiManager";
import {
UserBookmarkBatch,
UserBookmarkByEntity,
UserBookmarkByUser,
} from "./models/UserBookmark";
import { Pagination } from "./models/Pagination";
export enum ScopedEntityTypes {
USER = "user",
TTS_MODEL = "tts-model",
TTS_RESULT = "tts-result",
W2L_TEMPLATE = "w2l-template",
W2L_RESULT = "w2l-result",
MEDIA_FILE = "media_file",
MODEL_WEIGHT = "model_weight",
VOICE_CONVERSION_MODEL = "voice_conversion_model",
ZS_VOICE = "z_voice",
}
export enum ScopedWeightTypes {
HIFIGAN_TTl2 = "hifigan_tt2",
RVC_V2 = "rvc_v2",
SD_1_5 = "sd_1.5",
SDXL = "sdxl",
SO_VITS_SVC = "so_vits_svc",
TT2 = "tt2",
LORA = "loRA",
VALL_E = "vall_e",
COMFY_UI = "comfy_ui",
}
export enum ScopedMediaFileType {
IMAGE_GENERATION = "image_generation",
TEXT_TO_SPEECH = "text_to_speech",
VOCODER = "vocoder",
VOICE_CONVERSION = "voice_conversion",
WORKFLOW_CONFIG = "workflow_config",
}
interface ListUserBookmarksByUserRequest {
username: string;
sort_ascending?: boolean;
page_size?: number;
page_index?: number;
maybe_scoped_entity_type?: ScopedEntityTypes[];
maybe_scoped_weight_type?: ScopedWeightTypes[];
maybe_scoped_media_file_type?: ScopedMediaFileType[];
}
export class UserBookmarksApi extends ApiManager {
public CreateUserBookmark({
entityToken,
entityType,
}: {
entityToken: string;
entityType: string;
}): Promise<
ApiResponse<{
new_bookmark_count_for_entity?: number;
user_bookmark_token?: string;
}>
> {
const endpoint = `${this.ApiTargets.BaseApi}/v1/user_bookmarks/create`;
const body = {
entity_token: entityToken,
entity_type: entityType,
};
return this.post<
{
entity_token: string;
entity_type: string;
},
{
success?: boolean;
new_bookmark_count_for_entity?: number;
user_bookmark_token: string;
BadInput?: string;
}
>({ endpoint, body })
.then((response) => ({
success: response.success ?? false,
data: {
new_bookmark_count_for_entity: response.new_bookmark_count_for_entity,
user_bookmark_token: response.user_bookmark_token,
},
errorMessage: response.BadInput,
}))
.catch((err) => {
return { success: false, errorMessage: err.message };
});
}
public DeleteUserBookmark({
entityToken,
}: {
entityToken: string;
}): Promise<ApiResponse<undefined>> {
const endpoint = `${this.ApiTargets.BaseApi}/v1/user_bookmarks/delete/${entityToken}`;
return this.delete<
{ as_mod: boolean },
{
success?: boolean;
BadInput?: string;
}
>({ endpoint, body: { as_mod: true } })
.then((response) => ({
success: response.success ?? false,
errorMessage: response.BadInput,
}))
.catch((err) => {
return { success: false, errorMessage: err.message };
});
}
public ListUserBookmarks(): Promise<ApiResponse<UserBookmarkBatch[]>> {
const endpoint = `${this.ApiTargets.BaseApi}/v1/user_bookmarks/batch`;
return this.get<{
success?: boolean;
bookmarks?: UserBookmarkBatch[];
BadInput?: string;
}>({ endpoint })
.then((response) => ({
success: response.success ?? false,
data: response.bookmarks,
errorMessage: response.BadInput,
}))
.catch((err) => {
return { success: false, errorMessage: err.message };
});
}
public ListUserBookmarksByUser({
username,
sort_ascending,
page_size,
page_index,
maybe_scoped_entity_type,
maybe_scoped_weight_type,
maybe_scoped_media_file_type,
}: ListUserBookmarksByUserRequest): Promise<
ApiResponse<UserBookmarkByUser[], Pagination>
> {
const user = username;
const endpoint = `${this.ApiTargets.BaseApi}/v1/user_bookmarks/list/${user}`;
const query = {
sort_ascending,
page_size,
page_index,
maybe_scoped_entity_type: maybe_scoped_entity_type
? maybe_scoped_entity_type.join(",")
: undefined,
maybe_scoped_weight_type: maybe_scoped_weight_type
? maybe_scoped_weight_type.join(",")
: undefined,
maybe_scoped_media_file_type: maybe_scoped_media_file_type
? maybe_scoped_media_file_type.join(",")
: undefined,
};
return this.get<{
success?: boolean;
pagination?: Pagination;
results: UserBookmarkByUser[];
}>({ endpoint, query })
.then((response) => ({
success: response.success ?? false,
data: response.results,
pagination: response.pagination,
}))
.catch((err) => {
return { success: false, errorMessage: err.message };
});
}
public ListUserBookmarksByEntity({
entityType,
entityToken,
}: {
entityType: string;
entityToken: string;
}): Promise<ApiResponse<UserBookmarkByEntity[]>> {
const endpoint = `${this.ApiTargets.BaseApi}/v1/user_bookmarks/list/${entityType}/${entityToken}`;
return this.get<{
success: boolean;
user_bookmarks: UserBookmarkByEntity[];
}>({ endpoint })
.then((response) => ({
success: true,
data: response.user_bookmarks,
}))
.catch((err) => {
return { success: false, errorMessage: err.message };
});
}
}
@@ -1,162 +0,0 @@
import { ApiManager, ApiResponse } from "./ApiManager";
import { UserInfo } from "./models/Users";
export class UsersApi extends ApiManager {
public GetSession(): Promise<
ApiResponse<{
loggedIn: boolean;
user?: UserInfo;
}>
> {
const endpoint = `${this.ApiTargets.BaseApi}/v1/session`;
return this.get<{
success: boolean;
logged_in: boolean;
user?: UserInfo;
error_message?: string;
}>({ endpoint: endpoint })
.then((response) => ({
success: response.success,
data: {
loggedIn: response.logged_in,
user: response.user,
},
}))
.catch((err) => {
return {
success: false,
errorMessage: err.mesasge,
};
});
}
public GetUserProfile(username: string): Promise<
ApiResponse<{
user?: UserInfo;
}>
> {
const endpoint = `${this.ApiTargets.BaseApi}/v1/user/${username}/profile`;
return this.get<{
success: boolean;
user?: UserInfo;
error_message?: string;
}>({ endpoint: endpoint })
.then((response) => ({
success: response.success,
data: {
user: response.user,
},
}))
.catch((err) => {
return {
success: false,
error_message: err.message,
};
});
}
public async Login({
usernameOrEmail,
password,
}: {
usernameOrEmail: string;
password: string;
}): Promise<ApiResponse<{ signedSession?: string }>> {
const endpoint = `${this.ApiTargets.BaseApi}/v1/login`;
const body = {
username_or_email: usernameOrEmail,
password: password,
};
return await this.post<
{ username_or_email: string; password: string },
{
success: boolean;
signed_session?: string;
error_message?: string;
error_type?: string;
}
>({
endpoint: endpoint,
body: body,
})
.then((response) => ({
success: response.success,
data: { signedSession: response.signed_session },
errorMessage: response.error_message,
}))
.catch((err) => {
return {
success: false,
error_message: err.message,
};
});
}
public Logout(): Promise<ApiResponse<null>> {
const endpoint = `${this.ApiTargets.BaseApi}/v1/logout`;
return this.post<null, { success: boolean; error_message?: string }>({
endpoint: endpoint,
})
.then((response) => ({
success: response.success,
}))
.catch((err) => {
return {
success: false,
errorMessage: err.message,
};
});
}
public async Signup({
username,
email,
password,
passwordConfirmation,
}: {
username: string;
email: string;
password: string;
passwordConfirmation: string;
}): Promise<ApiResponse<{ signedSession?: string }>> {
const endpoint = `${this.ApiTargets.BaseApi}/v1/create_account`;
const body = {
email_address: email,
password,
password_confirmation: passwordConfirmation,
username,
};
return await this.post<
{
username: string;
email_address: string;
password: string;
password_confirmation: string;
},
{
success: boolean;
signed_session?: string;
error_fields?: Record<string, string>;
error_message?: string;
error_type?: string;
}
>({
endpoint: endpoint,
body: body,
})
.then((response) => {
return {
success: response.success,
data: { signedSession: response.signed_session },
errorMessage: Object.values(response.error_fields ?? {}).join(","),
};
})
.catch((err) => {
return {
success: false,
error_message: err.message,
};
});
}
}
@@ -1,18 +1,9 @@
// https://storyteller-docs.netlify.app/#/
export * from "./AnalyticsApi";
export * from "./BetaKeysApi";
export * from "./BillingApi";
export * from "./CommentsApi";
export * from "./EngineApi";
export * from "./FeaturedItemsApi";
export * from "./JobsApi";
export * from "./MediaFilesApi";
export * from "./MediaUploadApi";
export * from "./MiscApi";
export * from "./ModerationApi";
export * from "./PromptsApi";
export * from "./TtsApi";
export * from "./UserBookmarksApi";
export * from "./UsersApi";
export * from "./VideoApi";
export * from "./WeightsApi";
@@ -3,6 +3,7 @@ import { useEffect, useState } from "react";
import { Button } from "~/components/ui"
const testUrl = "https://api.fakeyou.com/v1/media_files/list/user/hanashi?include_user_uploads=true&page_size=24&filter_media_classes=unknown";
const getTestData = (callback: (data: any) => void) => {
const netCall = async () => {
// GET the test url
@@ -1,607 +0,0 @@
import Konva from "konva";
import { Container } from "konva/lib/Container";
import { Shape } from "konva/lib/Shape";
import { Group } from "konva/lib/Group";
import { DiffusionSharedWorkerClient } from "../SharedWorkers/Diffusion/DiffusionSharedWorkerClient";
import {
// SharedWorkerRequest,
SharedWorkerResponse,
} from "../WorkerPrimitives/SharedWorkerBase";
import {
DiffusionSharedWorkerProgressData,
DiffusionSharedWorkerResponseData,
// DiffusionSharedWorker,
DiffusionSharedWorkerItemData,
} from "../SharedWorkers/Diffusion/DiffusionSharedWorker";
import { RenderingOptions } from "../Engine";
import { FileUtilities } from "../FileUtilities/FileUtilities";
import { ImageNode, VideoNode, TextNode } from "../Nodes";
import { MediaNode } from "../types";
import { RenderTask } from "./RenderTask";
import { OffScreenSceneCanvas } from "./OffScreenSceneCanvas";
// https://www.aiseesoft.com/resource/phone-aspect-ratio-screen-resolution.html#:~:text=16%3A9%20Aspect%20Ratio
export class RenderEngine {
private videoNodes: VideoNode[];
private imageNodes: (ImageNode | TextNode)[];
private offScreenCanvas: OffscreenCanvas;
private context: OffscreenCanvasRenderingContext2D | null;
private isProcessing: boolean;
// private frames: ImageBitmap[];
// capturing composite within window
private bgLayerRef: Konva.Layer;
private mediaLayerRef: Konva.Layer;
private height: number;
private width: number;
private positionX: number;
private positionY: number;
private canUseSharedWorker: boolean;
private port: MessagePort | undefined;
private upperMaxFrames: number;
public captureCanvas: Konva.Rect;
public videoLoadingCanvas: VideoNode | undefined;
public fps: number = 24;
constructor({
width,
height,
bgLayerRef,
mediaLayerRef,
offScreenCanvas,
onRenderingSystemMessageRecieved,
}: {
width: number;
height: number;
bgLayerRef: Konva.Layer;
mediaLayerRef: Konva.Layer;
offScreenCanvas: OffscreenCanvas;
onRenderingSystemMessageRecieved: (
response: SharedWorkerResponse<
DiffusionSharedWorkerResponseData,
DiffusionSharedWorkerProgressData
>,
) => void;
}) {
this.videoLoadingCanvas = undefined;
this.videoNodes = [];
this.imageNodes = [];
this.isProcessing = false;
this.onRenderingSystemMessageRecieved = onRenderingSystemMessageRecieved;
// TODO: Make this dynamic and update this on change of canvas.
this.width = width;
this.height = height;
this.positionX = window.innerWidth / 2 - this.width / 2;
this.positionY = window.innerHeight / 2 - this.height / 2;
this.offScreenCanvas = offScreenCanvas;
this.offScreenCanvas.width = this.width;
this.offScreenCanvas.height = this.height;
this.context = this.offScreenCanvas.getContext("2d");
// this.frames = [];
this.bgLayerRef = bgLayerRef;
this.mediaLayerRef = mediaLayerRef;
this.port = undefined;
this.fps = 24;
this.captureCanvas = new Konva.Rect({
name: "CaptureCanvas",
x: this.positionX,
y: this.positionY,
width: this.width,
height: this.height,
fill: "white",
stroke: "black",
strokeWidth: 1,
draggable: false,
});
this.upperMaxFrames = 7 * this.fps; // seconds by fps
this.bgLayerRef.add(this.captureCanvas);
// send back
this.captureCanvas.setZIndex(0);
this.canUseSharedWorker = false;
this.setupSharedWorker();
//this.debug();
}
private onRenderingSystemMessageRecieved: (
response: SharedWorkerResponse<
DiffusionSharedWorkerResponseData,
DiffusionSharedWorkerProgressData
>,
) => void;
private diffusionWorker:
| DiffusionSharedWorkerClient<
DiffusionSharedWorkerItemData,
DiffusionSharedWorkerResponseData,
DiffusionSharedWorkerProgressData
>
| undefined;
async updateCaptureCanvas(
width: number | undefined,
height: number | undefined,
) {
if (!this.captureCanvas) {
return;
}
if (width) {
this.width = width;
}
if (height) {
this.height = height;
}
// Ensures that all the nodes stag in the same place should
// there be a window resize.
// recompute the position
// to ensure that the position of this stays
const oldPositionX = this.positionX;
const oldPositionY = this.positionY;
// recompute the position
this.positionX = window.innerWidth / 2 - this.width / 2;
this.positionY = window.innerHeight / 2 - this.height / 2;
this.captureCanvas.setPosition({ x: this.positionX, y: this.positionY });
this.captureCanvas.size({ width: this.width, height: this.height });
// this is the change in positions
const deltaX = this.positionX - oldPositionX;
const deltaY = this.positionY - oldPositionY;
var children = this.mediaLayerRef.getChildren();
for (let i = 0; i < children.length; i++) {
let node = children[i];
// skip the capture canvas update.
if (node.name() === "CaptureCanvas") {
continue;
}
const pos = node.getPosition();
node.setPosition({
x: pos.x + deltaX,
y: pos.y + deltaY,
});
}
// update the context menu
// TODO: find selected node and update that position
// this.videoNodes.forEach((node) => {
// node.updateContextComponents();
// });
this.mediaLayerRef.batchDraw();
}
debug() {
// DEBUG ONLY
const rectangle = new Konva.Rect({
x: this.positionX,
y: this.positionY,
width: 100,
height: 100,
fill: "green",
stroke: "black",
strokeWidth: 1,
draggable: false,
});
this.mediaLayerRef.add(rectangle);
}
async sendCanvasPayload(renderTask: RenderTask) {
if (!this.port) {
return console.log("Undefined Worker");
}
this.port.postMessage(renderTask);
}
setupSharedWorker() {
if (typeof SharedWorker !== "undefined") {
console.log("Shared Workers are supported in this browser.");
// Debug chrome://inspect/#workers
// "src\\KonvaApp\\SharedWorkers\\Diffusion\\DiffusionSharedWorker.ts",
this.diffusionWorker = new DiffusionSharedWorkerClient(
this.onRenderingSystemMessageRecieved,
);
this.canUseSharedWorker = true;
} else {
console.log("Shared Workers are not supported in this browser.");
// Handle the lack of Shared Worker support (e.g., fallback to another solution)
this.canUseSharedWorker = false;
}
}
public placeDebugRect(
x: number,
y: number,
width: number,
height: number,
layer: Konva.Layer,
): void {
const rect = new Konva.Rect({
x: x,
y: y,
width: width, // Default width
height: height, // Default height
fill: "green",
draggable: false,
});
layer.add(rect);
layer.draw();
}
// This function uses a portion of the video layer to capture just the capture canvas.
// capture everything after seeking each video node.
renderPortionOfLayer(
layer: Konva.Layer,
x: number,
y: number,
width: number,
height: number,
): HTMLCanvasElement {
const canvas = layer.toCanvas({
x: x,
y: y,
width: width,
height: height,
});
return canvas;
}
private findLongestVideoLength(): number {
let maxLength = 0;
// could simplify this logic to ensure that imageNodes has number of frames.
if (this.imageNodes.length > 0 && this.videoNodes.length === 0) {
return this.fps * 3; // three seconds determine whether to change this
}
this.videoNodes.forEach((node) => {
const videoLength = node.getNumberFrames();
if (videoLength > maxLength) {
maxLength = videoLength;
}
});
return maxLength;
}
public addNodes(node: MediaNode) {
if (node instanceof VideoNode) {
this.videoNodes.push(node);
} else if (node instanceof ImageNode || node instanceof TextNode) {
this.imageNodes.push(node);
}
}
public removeNodes(node: MediaNode) {
if (node instanceof VideoNode) {
const index = this.videoNodes.indexOf(node);
if (index > -1) {
this.videoNodes.splice(index, 1);
}
} else if (node instanceof ImageNode || node instanceof TextNode) {
const index = this.imageNodes.indexOf(node);
if (index > -1) {
this.imageNodes.splice(index, 1);
}
}
}
// Do a bunch of precondition checks and error out early on.
public async startProcessing(renderingOptions?: RenderingOptions) {
// Start processing and lock everything
this.isProcessing = true;
try {
// or not loaded
if (this.videoNodes.length + this.imageNodes.length < 1) {
throw Error("Must at least have 1 item on the board.");
}
// error out if nodes are not all loaded.
// todo remove when we have error handling + and ui
var failed = false;
// if there are atleast 1 video node images are all covered.
for (let i = 0; i < this.videoNodes.length; i++) {
const item = this.videoNodes[i];
if (!item.kNode) {
return;
}
item.kNode.listening(false);
item.unhighlight();
if (item.didFinishLoading == false) {
// error out and show error message
//this.startProcessing();
failed = true;
setTimeout(this.startProcessing.bind(this), 1000);
break;
}
item.setProcessing(true);
}
// todo remove
if (failed) {
// throw error
throw Error("Wait For Items to Finish Processing.");
}
//this.videoNodes.forEach((item: VideoNode) => {});
// find the longest video node
let numberOfFrames = this.findLongestVideoLength();
numberOfFrames = Math.min(numberOfFrames, this.upperMaxFrames);
console.log(`Number Of Frames: ${numberOfFrames}`);
await this.render(numberOfFrames, renderingOptions);
} catch (error) {
console.log(error);
throw error;
} finally {
this.isProcessing = false;
// enable all nodes again.
for (let i = 0; i < this.videoNodes.length; i++) {
const item = this.videoNodes[i];
if (!item.kNode) {
return;
}
item.setProcessing(false);
item.kNode.listening(true);
}
}
}
// public stopProcessing() {
// this.isProcessing = false;
// }
// find the frame time given the frame number
private calculateFrameTime(frameNumber: number, frameRate: number): number {
return frameNumber / frameRate;
}
/**
*
* @param config
*/
private async renderFrame(config: {
layerOfInterest: Konva.Layer; // layer where the element that you want to clip lives.
// XY and height of a captureCanvas ( region of interest )
x?: number; // x position of the region of interest
y?: number; // y position of the region of interest
width?: number; // size of the region of interest
height?: number; // size of the region of interest
pixelRatio?: number; // higher means higher quality
mimeType?: string; // image/jpeg or image/png
quality?: number; // 1.0 is the best.
test: boolean; // true == blob else Image Bitmap
}): Promise<ImageBitmap | Blob> {
try {
const box = config.layerOfInterest.getClientRect();
const stage = config.layerOfInterest.getStage();
const x = config.x !== undefined ? config.x : Math.floor(box.x);
const y = config.y !== undefined ? config.y : Math.floor(box.y);
const pixelRatio = config.pixelRatio || 1;
const container = config.layerOfInterest as Container<Group | Shape>;
const offScreenSceneCanvas = new OffScreenSceneCanvas({
width:
config.width || Math.ceil(box.width) || (stage ? stage.width() : 0),
height:
config.height ||
Math.ceil(box.height) ||
(stage ? stage.height() : 0),
pixelRatio: pixelRatio,
});
// TODO: fill canvas white properly
const context = offScreenSceneCanvas.getContext();
// context.fillStyle = "white";
// context.fillRect(
// 0,
// 0,
// offScreenSceneCanvas.width,
// offScreenSceneCanvas.height,
// );
const buffer = new OffScreenSceneCanvas({
width:
offScreenSceneCanvas.width / offScreenSceneCanvas.pixelRatio +
Math.abs(x),
height:
offScreenSceneCanvas.height / offScreenSceneCanvas.pixelRatio +
Math.abs(y),
pixelRatio: offScreenSceneCanvas.pixelRatio,
});
context.save();
if (x || y) {
context.translate(-1 * x, -1 * y);
}
container.drawScene(offScreenSceneCanvas, undefined, buffer);
// Not a type mistake ... DO NOT FIX
// @ts-ignore
const offscreenCanvas = offScreenSceneCanvas._canvas as OffscreenCanvas;
let result = undefined;
// if config.test is true, the result is downloaded to the local files
// config.test = true;
if (config.test) {
const blob = await offscreenCanvas.convertToBlob({
quality: config.quality ?? 1.0,
type: "image/jpeg",
});
await FileUtilities.blobToFileJpeg(blob, "1");
result = blob;
} else {
result = offscreenCanvas.transferToImageBitmap();
}
context.restore();
return result;
} catch (error) {
throw error;
}
}
/**
find longest video
then seek through each node 1 step.
stop ignore stepping if the duration is less.
**/
private async render(
largestNumberOfFrames: number,
renderingOptions?: RenderingOptions,
) {
if (!this.isProcessing) return;
// Stop all nodes first
console.log(`LargestNumberOfFrames:${largestNumberOfFrames}`);
for (let k = 0; k < this.videoNodes.length; k++) {
const videoNode = this.videoNodes[k];
if (videoNode.didFinishLoading === false) {
throw Error("Videos Did Not Finish Loading Please Try Again.");
}
await videoNode.reset();
}
// only pick nodes that intersect with the canvas on screen bounds to freeze.
for (let j = 0; j < largestNumberOfFrames; j++) {
// Seek Video Nodes first then draw
let frameTime = undefined;
for (let i = 0; i < this.videoNodes.length; i++) {
const currentVideoNode = this.videoNodes[i];
frameTime = this.calculateFrameTime(j, currentVideoNode.fps);
frameTime = parseFloat(frameTime.toFixed(2));
if (frameTime < currentVideoNode.duration) {
// console.log(`CurrentFrame:${j}`);
// console.log(`FrameTime:${frameTime}`);
// console.log(`Duration:${currentVideoNode.duration}`);
await currentVideoNode.seek(frameTime);
} // end of if context
} // End frame time
this.mediaLayerRef.draw();
// use main thread
if (this.canUseSharedWorker === false) {
// SCOPES the capture for the context
// Correct size for the mobile canvas.
this.offScreenCanvas.width = this.width;
this.offScreenCanvas.height = this.height;
if (this.context) {
// This crops it starting at position X / Y where the mobile canvas is
// Then picks the height and width range
// then we draw it at 0,0,width and height of the canvas
this.context.drawImage(
this.mediaLayerRef.canvas._canvas,
this.positionX,
this.positionY,
this.width,
this.height,
0,
0,
this.width,
this.height,
);
//TODO write the non webworker version
const blob = await this.offScreenCanvas.convertToBlob({
quality: 1.0,
type: "image/jpeg",
});
await FileUtilities.blobToFileJpeg(blob, "1");
break;
} // end of for each frame
} else {
// decode on the shared webworker.
if (!this.context) {
console.log("Context Didn't Initialize");
return;
}
if (!this.diffusionWorker) {
console.log("Didn't Initialize Diffusion");
return;
}
console.log(
`context: x:${this.positionX} y:${this.positionY} ${this.width} x ${this.height}`,
);
const bitmap = await this.renderFrame({
layerOfInterest: this.mediaLayerRef,
x: this.captureCanvas.x(),
y: this.captureCanvas.y(),
width: this.width,
height: this.height,
mimeType: "image/jpeg",
pixelRatio: 1,
quality: 1.0,
test: false,
});
const data: DiffusionSharedWorkerItemData = {
height: this.height,
width: this.width,
imageBitmap: bitmap as ImageBitmap,
frame: j,
totalFrames: largestNumberOfFrames,
prompt: renderingOptions,
};
console.log(
`Processing Frame:${j + 1} out of ${largestNumberOfFrames}`,
);
this.diffusionWorker.sendData(1, data, false);
}
} // end of largest number of frames loop
// finished looping
if (!this.diffusionWorker) {
console.log("Didn't Initialize Diffusion");
return;
}
const jobID = 1;
this.diffusionWorker.sendData(
jobID,
{
height: this.height,
width: this.width,
imageBitmap: undefined,
frame: -1,
totalFrames: largestNumberOfFrames,
prompt: renderingOptions,
},
true,
);
}
}
@@ -1,396 +0,0 @@
// Example of this working.
import { BlobReader, BlobWriter, ZipWriter } from "@zip.js/zip.js";
import { MediaUploadApi, VideoApi, MediaFilesApi } from "~/Classes/ApiManager";
import { Visibility } from "~/Classes/ApiManager/enums/Visibility";
import { v4 as uuidv4 } from "uuid";
import { JobsApi } from "~/Classes/ApiManager";
import {
ProgressData,
WorkResult,
} from "~/KonvaApp/WorkerPrimitives/GenericWorker";
import {
SharedWorkerBase,
SharedWorkerRequest,
SharedWorkerResponse,
ResponseType,
} from "~/KonvaApp/WorkerPrimitives/SharedWorkerBase";
import { JobStatus } from "~/Classes/ApiManager/enums/Job";
import { RenderingOptions } from "~/KonvaApp/Engine";
import { MediaFile } from "~/Classes/ApiManager/models/MediaFile";
export interface DiffusionSharedWorkerProgressData {
url: string;
status: JobStatus;
progress: number;
mediaToken: string;
}
export interface DiffusionSharedWorkerItemData {
imageBitmap: ImageBitmap | undefined;
totalFrames: number;
frame: number;
height: number;
width: number;
prompt?: RenderingOptions;
}
export interface DiffusionSharedWorkerErrorData {
error: string;
}
export interface DiffusionSharedWorkerResponseData {
videoUrl: string;
}
export class DiffusionSharedWorker extends SharedWorkerBase<
DiffusionSharedWorkerItemData,
DiffusionSharedWorkerResponseData | MediaFile,
DiffusionSharedWorkerProgressData
> {
public name: string;
public zipFileWriter: BlobWriter;
public zipWriter: ZipWriter<Blob>;
public imageType: string;
public totalFrames: number;
public offscreenCanvas: OffscreenCanvas | undefined;
public bitmapContext: ImageBitmapRenderingContext | undefined | null;
public mediaAPI: MediaUploadApi;
public mediaFilesAPI: MediaFilesApi;
public videoAPI: VideoApi;
public jobsAPI: JobsApi;
public blobs: Blob[];
constructor(port: MessagePort) {
super(port);
this.name = "DiffusionWorker";
this.setup(this.workFunction.bind(this), this.progressFunction.bind(this));
this.offscreenCanvas = undefined;
this.bitmapContext = undefined;
this.imageType = "image/jpeg";
this.zipFileWriter = new BlobWriter(this.imageType);
this.zipWriter = new ZipWriter(this.zipFileWriter);
this.totalFrames = 0;
this.videoAPI = new VideoApi();
this.jobsAPI = new JobsApi();
this.mediaAPI = new MediaUploadApi();
this.mediaFilesAPI = new MediaFilesApi();
this.blobs = [];
this.totalFrames = 0;
}
async zipBlobs(): Promise<Blob> {
for (let i = 0; i < this.blobs.length; i++) {
const blob = this.blobs[i];
const name = String(i).padStart(5, "0"); // '0009'
await this.zipWriter.add(`${name}.jpg`, new BlobReader(blob));
}
const zipBlob = await this.zipWriter.close();
return zipBlob;
}
async reset() {
this.zipFileWriter = new BlobWriter(this.imageType);
this.zipWriter = new ZipWriter(this.zipFileWriter);
this.totalFrames = 0;
this.blobs = [];
}
// Data here will be shipped off for progressive loading
async workFunction(
isDoneStreaming: boolean,
item: DiffusionSharedWorkerItemData,
reportProgress: (data: DiffusionSharedWorkerProgressData) => void,
): Promise<
[DiffusionSharedWorkerResponseData | MediaFile | undefined, boolean]
> {
// make request via api with options
try {
this.totalFrames = item.totalFrames;
if (this.offscreenCanvas === undefined) {
this.offscreenCanvas = new OffscreenCanvas(item.width, item.height);
this.bitmapContext = this.offscreenCanvas.getContext("bitmaprenderer");
}
if (!this.bitmapContext) {
console.log("Failed to create bitmap context.");
throw Error("Bitmap Rendering Context Not Availible.");
}
if (item.imageBitmap !== undefined) {
this.bitmapContext.transferFromImageBitmap(item.imageBitmap);
const blob = await this.offscreenCanvas.convertToBlob({
quality: 1.0,
type: this.imageType,
});
this.blobs.push(blob);
console.log("Length of blob");
console.log(this.blobs.length);
}
// progress
const aproxSteps = this.totalFrames;
const totalPercent = item.prompt ? 0.25 : 0.9;
const progressData: DiffusionSharedWorkerProgressData = {
url: "",
status: JobStatus.PENDING,
progress: (item.frame / aproxSteps) * totalPercent,
mediaToken: "",
};
console.log("Lets report progress.");
reportProgress(progressData); // once finished gives you up to 50%
if (isDoneStreaming === false) {
return [undefined, false];
}
console.log("Prepare to Zip");
const zipBlob = await this.zipBlobs();
console.log("Zipped", zipBlob);
const progressMedia: DiffusionSharedWorkerProgressData = {
url: "",
status: JobStatus.PENDING,
progress: ((item.frame + 0.5) / aproxSteps) * totalPercent,
mediaToken: "",
};
console.log("Lets report progress.");
reportProgress(progressMedia); // once finished gives you up to 50%
const response = await this.mediaAPI.UploadStudioShot({
maybe_title: "",
uuid_idempotency_token: uuidv4(),
blob: zipBlob,
fileName: "media.zip",
maybe_visibility: Visibility.Public,
});
const mediaToken = response.data;
if (!mediaToken) {
this.reset();
throw Error(`Server Failed Try Again: ${response.errorMessage}`);
}
if (!item.prompt) {
console.log("got video >>", response);
const videoRepsonse = await this.mediaFilesAPI.GetMediaFileByToken({
mediaFileToken: mediaToken,
});
this.reset();
return [videoRepsonse.data, true];
}
console.log(item.prompt);
const studioResponse = await this.videoAPI.EnqueueStudio({
enqueueVideo: {
disable_lcm: false,
enable_lipsync: item.prompt.lipSync,
input_file: mediaToken, // Replace with actual media token
negative_prompt: item.prompt.negativePrompt,
prompt: item.prompt.positivePrompt,
remove_watermark: false,
style: item.prompt.artstyle, // Map to the appropriate art style
frame_skip: 2,
travel_prompt: "",
trim_end_millis: 7000,
trim_start_millis: 0,
use_cinematic: item.prompt.cinematic,
use_face_detailer: item.prompt.faceDetail,
use_strength: item.prompt.styleStrength,
use_upscaler: item.prompt.upscale,
uuid_idempotency_token: uuidv4(),
global_ipa_media_token: "",
input_depth_file: "",
input_normal_file: "",
input_outline_file: "",
creator_set_visibility: Visibility.Public,
},
});
let resultURL = undefined;
if (studioResponse.success && studioResponse.data?.inference_job_token) {
console.log("Start Streaming Result");
if (!studioResponse.data.inference_job_token) {
await this.reset(); // reset on error
throw Error("No Job Token Returned Try Again");
}
const jobToken = studioResponse.data.inference_job_token;
// if error send it back through the pipe
let jobIsProcessing = true;
while (jobIsProcessing) {
const job = await this.jobsAPI.GetJobByToken({ token: jobToken });
// TODO: MAKE REQUEST FOR PREVIEW HERE
console.log(job);
if (!job.data) {
console.log("No Job Data resetting the zip state");
await this.reset();
console.log(job.data);
continue;
}
const status = job.data.status.status;
const progress = job.data.status.progress_percentage;
let computedProgress = 30;
if (progress === 0) {
computedProgress = 30;
}
computedProgress = 30 + progress * 0.7;
let renderProgressData: DiffusionSharedWorkerProgressData = {
url: "",
status: status,
progress: computedProgress / 100,
mediaToken: mediaToken,
};
renderProgressData.status = status;
switch (status) {
case JobStatus.PENDING:
console.log("Pending");
reportProgress(renderProgressData); // once finished gives you up to 50%
break;
case JobStatus.STARTED:
console.log("Started");
reportProgress(renderProgressData);
break;
case JobStatus.ATTEMPT_FAILED:
console.log("Attempt Failed");
reportProgress(renderProgressData);
break;
case JobStatus.COMPLETE_SUCCESS:
console.log("Complete Success");
renderProgressData.progress = 100;
jobIsProcessing = false;
if (!job.data.maybe_result.media_links.cdn_url) {
await this.reset();
throw Error("Server Failed To Return Result");
}
resultURL = job.data.maybe_result.media_links.cdn_url;
reportProgress(renderProgressData);
break;
case JobStatus.COMPLETE_FAILURE:
console.log("Complete Failure");
jobIsProcessing = false;
renderProgressData.progress = 0;
reportProgress(renderProgressData);
break;
case JobStatus.DEAD:
console.log("Dead");
jobIsProcessing = false;
reportProgress(renderProgressData);
throw Error("Server Failed to Process Please Try Again.");
case JobStatus.CANCELLED_BY_SYSTEM:
console.log("Cancelled by system");
jobIsProcessing = false;
reportProgress(renderProgressData);
break;
case JobStatus.CANCELLED_BY_USER:
console.log("Cancelled by user");
jobIsProcessing = false;
reportProgress(renderProgressData);
break;
}
await this.sleep(500);
} // end while loop
}
if (!resultURL) {
await this.reset(); // reset on error
throw Error("Media URL Result Missing");
}
const responseData: DiffusionSharedWorkerResponseData = {
videoUrl: resultURL,
};
await this.reset();
return [responseData, true];
} catch (error) {
await this.reset();
console.log(error);
throw error;
}
}
async sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
progressFunction(progress: ProgressData<DiffusionSharedWorkerProgressData>) {
console.log(
`Progress Function JobID:${progress.jobID} Data:${progress.data}`,
);
// send out to node as a worker response
this.send({
jobID: progress.jobID,
responseType: ResponseType.progress,
data: progress.data,
});
}
async reportResult(result: WorkResult<DiffusionSharedWorkerResponseData>) {
this.send({
jobID: result.jobID,
responseType: ResponseType.result,
data: result.data,
});
}
async errorFunction(
error: SharedWorkerResponse<
DiffusionSharedWorkerItemData,
DiffusionSharedWorkerResponseData
>,
) {
this.send({
jobID: error.jobID,
responseType: ResponseType.error,
data: error.data?.toString(),
});
}
async receive(request: SharedWorkerRequest<DiffusionSharedWorkerItemData>) {
console.log("Received Request");
console.log(request);
this.submitWork({
jobID: request.jobID,
data: request.data,
isDoneStreaming: request.isDoneStreaming,
});
}
}
// This is a copy paste to create a worker now.
//@ts-ignore
self.onconnect = (e: any) => {
const port = e.ports[0];
console.log("DiffusionSharedWorker Started");
let worker: DiffusionSharedWorker | undefined = undefined;
let started = false;
if (started === false) {
started = true;
worker = new DiffusionSharedWorker(port);
worker.start();
}
// Response For Start.
const workerResult = "DiffusionSharedWorker Started";
port.postMessage(workerResult);
port.start(); // Required when using addEventListener. Otherwise called implicitly by onmessage setter.
};
@@ -1,88 +0,0 @@
import { v4 as uuidv4 } from "uuid";
import {
SharedWorkerRequest,
SharedWorkerResponse,
} from "~/KonvaApp/WorkerPrimitives/SharedWorkerBase";
import { ResponseType } from "~/KonvaApp/WorkerPrimitives/SharedWorkerBase";
import DiffusionSharedWorker from "./DiffusionSharedWorker?sharedworker";
export class DiffusionSharedWorkerClient<
DiffusionSharedWorkerItemData,
DiffusionSharedWorkerResponseData,
DiffusionSharedWorkerProgressData,
> {
private port: MessagePort;
private sharedWorker: SharedWorker;
private messageReceived: (
response: SharedWorkerResponse<
DiffusionSharedWorkerResponseData,
DiffusionSharedWorkerProgressData
>,
) => void;
constructor(
messageReceived: (
response: SharedWorkerResponse<
DiffusionSharedWorkerResponseData,
DiffusionSharedWorkerProgressData
>,
) => void,
) {
this.messageReceived = messageReceived;
try {
console.log("This is running a worker in production");
this.sharedWorker = new DiffusionSharedWorker({
name: "DiffusionWorker-" + uuidv4(),
});
this.sharedWorker.addEventListener("error", (value) => {
console.log("Shared worker ERROR:");
console.log(value);
});
this.port = this.sharedWorker.port;
this.port.onmessage = this.onMessage.bind(this);
this.port.start();
console.log("launched shared worker (?)");
} catch (error) {
console.log("ERROR with shared worker!");
console.log(error);
throw Error("Could Not Start Worker");
}
}
async onMessage(event: MessageEvent) {
// returns progress and returns result need to check error tomorrow then were good to go.
//console.log(`incoming`);
// console.log(event);
if (event.data.responseType === ResponseType.error) {
console.log(`DiffusionSharedWorkerClient Error`);
console.log(event.data);
this.messageReceived(event.data);
} else if (event.data.responseType === ResponseType.progress) {
// console.log(`DiffusionSharedWorkerClient Progress`);
//console.log(event.data);
this.messageReceived(event.data);
} else if (event.data.responseType === ResponseType.result) {
// console.log(`DiffusionSharedWorkerClient Result`);
//console.log(event.data);
this.messageReceived(event.data);
} else {
console.log(`DiffusionSharedWorkerClient Message Unknown:`);
console.log(event.data);
}
}
async sendData(
jobID: number,
data: DiffusionSharedWorkerItemData,
isDoneStreaming: boolean,
) {
const payload: SharedWorkerRequest<DiffusionSharedWorkerItemData> = {
jobID: jobID,
data: data,
isDoneStreaming: isDoneStreaming,
};
this.port.postMessage(payload);
}
}
+7 -5
View File
@@ -11,7 +11,7 @@ import "./global.css";
import { useRenderCounter } from "~/hooks/useRenderCounter";
function PostHogInit() {
const apiKey = "phc_jBFgac0mVALAFk3negnSfYCcHgvkT00yBLQDmCDYNBb";
const apiKey = import.meta.env.VITE_POSTHOG_API_KEY;
posthog.init(apiKey, {
api_host: "https://us.i.posthog.com/",
ui_host: "https://us.i.posthog.com/",
@@ -41,10 +41,12 @@ const App = () => {
}
return (
<>
<JobProvider>
<RouterProvider router={router} />
<Toaster />
</JobProvider>
<PostHogProvider client={posthog}>
<JobProvider>
<RouterProvider router={router} />
<Toaster />
</JobProvider>
</PostHogProvider>
</>
);
};
@@ -44,10 +44,10 @@ export const Login = () => {
const usernameOrEmail = form.get("usernameOrEmail")?.toString();
const password = form.get("password")?.toString();
if (usernameOrEmail && password) {
let api = new UsersApi();
let response = await api.Login({
usernameOrEmail,
password,
@@ -99,7 +99,6 @@ export const Login = () => {
shouldShowLoader && "opacity-0",
)}
>
<Input
label="Username or Email"
icon={faUser}
@@ -1,7 +1,7 @@
import { useParams } from "react-router-dom";
import { withProtectionRoute } from "~/components/hoc";
import { useRenderCounter } from "~/hooks/useRenderCounter";
import { Resource, invoke } from "@tauri-apps/api/core";
import { invoke } from "@tauri-apps/api/core";
//Components of the Konva App are all in the KonvaComponent
import { KonvaRootComponent } from "~/KonvaRootComponent";
import { ToolbarTopLeft } from "~/components/features/ToolbarTopLeft";
@@ -1,524 +0,0 @@
import React, { useEffect, useState, useRef } from "react";
import {
ServerClient,
fileToBase64,
base64ToImageUrl,
} from "../PyServer/ServerClient";
import {
ProgressUpdateMessage,
SetupResponse,
GenerateResponse,
StatusResponse,
UpdateSettingsResponse,
} from "../PyServer/ServerTypes";
// Default server URL
const DEFAULT_SERVER_URL = "ws://localhost:8765";
export const ImageGenerator: React.FC = () => {
// Server connection state
const [isConnected, setIsConnected] = useState(false);
const [serverUrl, setServerUrl] = useState(DEFAULT_SERVER_URL);
const clientRef = useRef<ServerClient | null>(null);
// Model state
const [modelPath, setModelPath] = useState("");
const [loraPath, setLoraPath] = useState("");
const [isModelInitialized, setIsModelInitialized] = useState(false);
// Generation parameters
const [sourceImage, setSourceImage] = useState<File | null>(null);
const [sourceImageUrl, setSourceImageUrl] = useState<string | null>(null);
const [prompt, setPrompt] = useState("");
const [loraStrength, setLoraStrength] = useState(1.0);
const [imageToImageStrength, setImageToImageStrength] = useState(0.75);
const [steps, setSteps] = useState(4);
const [guidanceScale, setGuidanceScale] = useState(1.0);
const [width, setWidth] = useState(1024);
const [height, setHeight] = useState(1024);
// Results and UI state
const [generatedImageUrl, setGeneratedImageUrl] = useState<string | null>(
null,
);
const [isLoading, setIsLoading] = useState(false);
const [progress, setProgress] = useState<ProgressUpdateMessage | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
// Initialize the client
useEffect(() => {
const client = new ServerClient(serverUrl, {
onOpen: () => {
console.log("Connected to server");
setIsConnected(true);
setErrorMessage(null);
// Request status to see if model is already initialized
client.requestStatus().catch((error) => {
console.error("Error requesting status:", error);
});
},
onClose: () => {
console.log("Disconnected from server");
setIsConnected(false);
setIsModelInitialized(false);
},
onError: (error) => {
console.error("Error:", error);
setErrorMessage(error.error);
setIsLoading(false);
},
onProgressUpdate: (update) => {
console.log("Progress:", update);
setProgress(update);
if (update.stage !== "error") {
setErrorMessage(null);
}
},
onSetupResponse: (response) => {
console.log("Setup response:", response);
setIsLoading(false);
if (response.success) {
setIsModelInitialized(true);
setErrorMessage(null);
} else {
setErrorMessage(response.error || "Unknown error during setup");
}
},
onUpdateSettingsResponse: (response) => {
console.log("Update settings response:", response);
setIsLoading(false);
if (response.success) {
setErrorMessage(null);
} else {
setErrorMessage(response.error || "Unknown error updating settings");
}
},
onGenerateResponse: (response) => {
console.log("Generate response:", response);
setIsLoading(false);
if (response.success && response.image) {
setGeneratedImageUrl(base64ToImageUrl(response.image));
setErrorMessage(null);
} else {
setErrorMessage(response.error || "Unknown error generating image");
}
},
onStatusResponse: (response) => {
console.log("Status response:", response);
if (response.success) {
setIsModelInitialized(response.model_initialized);
if (response.current_settings) {
// Update UI with current settings
setModelPath(response.current_settings.sdxl_checkpoint_path || "");
setLoraPath(response.current_settings.lora_path || "");
// Update generation parameters if they exist
if (response.current_settings.default_lora_strength !== undefined) {
setLoraStrength(response.current_settings.default_lora_strength);
}
if (
response.current_settings.default_image_to_image_strength !==
undefined
) {
setImageToImageStrength(
response.current_settings.default_image_to_image_strength,
);
}
if (response.current_settings.default_steps !== undefined) {
setSteps(response.current_settings.default_steps);
}
if (
response.current_settings.default_guidance_scale !== undefined
) {
setGuidanceScale(
response.current_settings.default_guidance_scale,
);
}
if (response.current_settings.default_width !== undefined) {
setWidth(response.current_settings.default_width);
}
if (response.current_settings.default_height !== undefined) {
setHeight(response.current_settings.default_height);
}
}
}
},
});
clientRef.current = client;
// Connect to the server
client.connect().catch((error) => {
console.error("Error connecting to server:", error);
setErrorMessage(`Failed to connect to server: ${error.message}`);
});
return () => {
client.disconnect();
clientRef.current = null;
};
}, [serverUrl]);
// Handle source image selection
const handleImageChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setSourceImage(file);
setSourceImageUrl(URL.createObjectURL(file));
}
};
// Handle setup submission
const handleSetup = async () => {
if (!clientRef.current) return;
setIsLoading(true);
setErrorMessage(null);
try {
await clientRef.current.setup(modelPath, loraPath || undefined);
} catch (error) {
console.error("Error setting up model:", error);
setErrorMessage(`Error setting up model: ${(error as Error).message}`);
setIsLoading(false);
}
};
// Handle update settings
const handleUpdateSettings = async () => {
if (!clientRef.current) return;
setIsLoading(true);
setErrorMessage(null);
try {
await clientRef.current.updateSettings({
lora_path: loraPath || null,
default_lora_strength: loraStrength,
default_image_to_image_strength: imageToImageStrength,
default_steps: steps,
default_guidance_scale: guidanceScale,
default_width: width,
default_height: height,
});
} catch (error) {
console.error("Error updating settings:", error);
setErrorMessage(`Error updating settings: ${(error as Error).message}`);
setIsLoading(false);
}
};
// Handle generation
const handleGenerate = async () => {
if (!clientRef.current || !sourceImage) {
setErrorMessage("Please select a source image first");
return;
}
setIsLoading(true);
setErrorMessage(null);
setGeneratedImageUrl(null);
try {
const base64Image = await fileToBase64(sourceImage);
await clientRef.current.generate({
image: base64Image,
prompt,
lora_strength: loraStrength,
image_to_image_strength: imageToImageStrength,
num_inference_steps: steps,
guidance_scale: guidanceScale,
generated_image_width: width,
generated_image_height: height,
});
} catch (error) {
console.error("Error generating image:", error);
setErrorMessage(`Error generating image: ${(error as Error).message}`);
setIsLoading(false);
}
};
return (
<div className="mx-auto max-w-4xl p-6">
<h1 className="mb-6 text-2xl font-bold">SDXL Image Generator</h1>
{/* Connection and Setup */}
<div className="mb-6 rounded-lg bg-gray-100 p-4">
<h2 className="mb-2 text-xl font-semibold">Server Connection</h2>
<div className="mb-4 flex gap-2">
<input
type="text"
value={serverUrl}
onChange={(e) => setServerUrl(e.target.value)}
className="flex-grow rounded border p-2"
placeholder="WebSocket URL"
/>
<button
onClick={() => clientRef.current?.connect()}
disabled={isConnected}
className="rounded bg-blue-500 px-4 py-2 text-white disabled:bg-gray-400"
>
Connect
</button>
<button
onClick={() => clientRef.current?.disconnect()}
disabled={!isConnected}
className="rounded bg-red-500 px-4 py-2 text-white disabled:bg-gray-400"
>
Disconnect
</button>
</div>
<div className="mb-2 flex items-center">
<span className="mr-2">Status:</span>
<span
className={`rounded px-2 py-1 text-white ${isConnected ? "bg-green-500" : "bg-red-500"}`}
>
{isConnected ? "Connected" : "Disconnected"}
</span>
<span className="ml-4 mr-2">Model:</span>
<span
className={`rounded px-2 py-1 text-white ${isModelInitialized ? "bg-green-500" : "bg-yellow-500"}`}
>
{isModelInitialized ? "Initialized" : "Not Initialized"}
</span>
</div>
</div>
{/* Model Setup */}
<div className="mb-6 rounded-lg bg-gray-100 p-4">
<h2 className="mb-2 text-xl font-semibold">Model Setup</h2>
<div className="space-y-4">
<div>
<label className="mb-1 block">SDXL Checkpoint Path:</label>
<input
type="text"
value={modelPath}
onChange={(e) => setModelPath(e.target.value)}
className="w-full rounded border p-2"
placeholder="/path/to/sdxl/model"
/>
</div>
<div>
<label className="mb-1 block">LoRA Path (optional):</label>
<input
type="text"
value={loraPath}
onChange={(e) => setLoraPath(e.target.value)}
className="w-full rounded border p-2"
placeholder="/path/to/lora"
/>
</div>
<div className="flex gap-2">
<button
onClick={handleSetup}
disabled={!isConnected || !modelPath || isLoading}
className="rounded bg-green-500 px-4 py-2 text-white disabled:bg-gray-400"
>
Initialize Model
</button>
<button
onClick={handleUpdateSettings}
disabled={!isConnected || !isModelInitialized || isLoading}
className="rounded bg-purple-500 px-4 py-2 text-white disabled:bg-gray-400"
>
Update Settings
</button>
</div>
</div>
</div>
{/* Generation Parameters */}
<div className="mb-6 rounded-lg bg-gray-100 p-4">
<h2 className="mb-2 text-xl font-semibold">Generation Parameters</h2>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label className="mb-1 block">Prompt:</label>
<textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
className="h-20 w-full rounded border p-2"
placeholder="Enter your prompt..."
/>
</div>
<div>
<label className="mb-1 block">Source Image:</label>
<input
type="file"
accept="image/*"
onChange={handleImageChange}
className="mb-2"
/>
{sourceImageUrl && (
<img
src={sourceImageUrl}
alt="Source"
className="max-h-40 rounded border"
/>
)}
</div>
<div>
<label className="mb-1 block">
LoRA Strength: {loraStrength.toFixed(2)}
</label>
<input
type="range"
min="0"
max="1"
step="0.05"
value={loraStrength}
onChange={(e) => setLoraStrength(parseFloat(e.target.value))}
className="w-full"
/>
</div>
<div>
<label className="mb-1 block">
Image-to-Image Strength: {imageToImageStrength.toFixed(2)}
</label>
<input
type="range"
min="0"
max="1"
step="0.05"
value={imageToImageStrength}
onChange={(e) =>
setImageToImageStrength(parseFloat(e.target.value))
}
className="w-full"
/>
</div>
<div>
<label className="mb-1 block">Steps: {steps}</label>
<input
type="range"
min="1"
max="20"
step="1"
value={steps}
onChange={(e) => setSteps(parseInt(e.target.value))}
className="w-full"
/>
</div>
<div>
<label className="mb-1 block">
Guidance Scale: {guidanceScale.toFixed(2)}
</label>
<input
type="range"
min="0"
max="10"
step="0.1"
value={guidanceScale}
onChange={(e) => setGuidanceScale(parseFloat(e.target.value))}
className="w-full"
/>
</div>
<div>
<label className="mb-1 block">Width:</label>
<select
value={width}
onChange={(e) => setWidth(parseInt(e.target.value))}
className="w-full rounded border p-2"
>
<option value="512">512</option>
<option value="768">768</option>
<option value="1024">1024</option>
<option value="1280">1280</option>
</select>
</div>
<div>
<label className="mb-1 block">Height:</label>
<select
value={height}
onChange={(e) => setHeight(parseInt(e.target.value))}
className="w-full rounded border p-2"
>
<option value="512">512</option>
<option value="768">768</option>
<option value="1024">1024</option>
<option value="1280">1280</option>
</select>
</div>
</div>
<button
onClick={handleGenerate}
disabled={
!isConnected || !isModelInitialized || !sourceImage || isLoading
}
className="mt-4 rounded bg-blue-500 px-6 py-2 text-white disabled:bg-gray-400"
>
Generate Image
</button>
</div>
{/* Progress and Results */}
{isLoading && progress && (
<div className="mb-6 rounded-lg bg-gray-100 p-4">
<h2 className="mb-2 text-xl font-semibold">Progress</h2>
<div className="mb-2">
<div className="mb-1 flex justify-between">
<span>{progress.stage}</span>
<span>{progress.progress}%</span>
</div>
<div className="h-2.5 w-full rounded-full bg-gray-300">
<div
className="h-2.5 rounded-full bg-blue-600"
style={{ width: `${progress.progress}%` }}
></div>
</div>
{progress.file && (
<div className="mt-1 text-sm text-gray-600">{progress.file}</div>
)}
</div>
</div>
)}
{errorMessage && (
<div className="mb-6 rounded border border-red-400 bg-red-100 px-4 py-3 text-red-700">
<p>{errorMessage}</p>
</div>
)}
{generatedImageUrl && (
<div className="mb-6 rounded-lg bg-gray-100 p-4">
<h2 className="mb-2 text-xl font-semibold">Generated Image</h2>
<div className="flex justify-center">
<img
src={generatedImageUrl}
alt="Generated"
className="max-w-full rounded border"
/>
</div>
</div>
)}
</div>
);
};
@@ -2,17 +2,13 @@ import { useState, useRef, useEffect } from "react";
import { invoke } from "@tauri-apps/api/core";
import { useSignals } from "@preact/signals-react/runtime";
import { toast } from "sonner";
import {
UploaderStates,
// UploaderState,
uploadImage,
} from "~/components/UploadImage";
import { UploaderStates, uploadImage } from "~/components/UploadImage";
import { PopoverMenu } from "~/components/reusable/Popover";
import { Tooltip } from "~/components/reusable/Tooltip";
import { Button } from "~/components/reusable/Button";
import { Tooltip } from "@storyteller/ui-tooltip";
import { Button } from "@storyteller/ui-button";
import { ToggleButton } from "~/components/reusable/ToggleButton";
import { Toaster } from "~/components/ui/Toast";
import { Modal } from "@storyteller/ui-modal";
import {
faMessageXmark,
faMessageCheck,
@@ -24,7 +20,6 @@ import {
faImages,
} from "@fortawesome/pro-solid-svg-icons";
import {
faRectangleWide,
faRectangleVertical,
faSquare,
faRectangle,
@@ -33,7 +28,7 @@ import { getCanvasRenderBitmap } from "~/signal/canvasRenderBitmap";
import { EncodeImageBitmapToBase64 } from "~/utilities/EncodeImageBitmapToBase64";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { PopoverItem } from "~/components/reusable/Popover";
import { Api, ApiManager } from "~/KonvaApp/Api";
import { Api } from "~/KonvaApp/Api";
import { useJobContext } from "~/components/JobContext";
import { IsDesktopApp } from "@storyteller/tauri-utils";
import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal";
@@ -49,13 +44,14 @@ export const PromptBox = () => {
useSignals();
const isDesktopApp = window.navigator.userAgent.includes("Tauri");
console.log("Is this a desktop app?", isDesktopApp);
const [isModalOpen, setIsModalOpen] = useState(false);
const [content, setContent] = useState("");
const { jobTokens, addJobToken, removeJobToken, clearJobTokens } =
useJobContext();
//const { lastRenderedBitmap } = useCanvasSignal();
const [prompt, setPrompt] = useState("");
const [isEnqueueing, setisEnqueueing] = useState(false);
const [isEnqueueing, setIsEnqueueing] = useState(false);
const [useSystemPrompt, setUseSystemPrompt] = useState(true);
const [isGalleryModalOpen, setIsGalleryModalOpen] = useState(false);
const [selectedGalleryImages, setSelectedGalleryImages] = useState<string[]>(
@@ -317,10 +313,9 @@ export const PromptBox = () => {
}
};
const handleEnqueue = async () => {
if (isEnqueueing) return;
setIsEnqueueing(true);
if (!prompt.trim()) return;
setisEnqueueing(true);
try {
console.log(
"Enqueuing with prompt:",
@@ -333,9 +328,9 @@ export const PromptBox = () => {
console.log("Is this a desktop app?", isDesktop);
if (isDesktop) {
handleTauriEnqueue();
await handleTauriEnqueue();
} else {
handleWebEnqueue();
await handleWebEnqueue();
}
} catch (error) {
console.error("Error during image generation:", error);
@@ -343,7 +338,8 @@ export const PromptBox = () => {
"An error occurred while generating the image. Please try again.",
);
} finally {
setisEnqueueing(false);
await new Promise((resolve) => setTimeout(resolve, 100));
setIsEnqueueing(false);
}
};
@@ -363,6 +359,15 @@ export const PromptBox = () => {
return (
<>
<Modal
isOpen={isModalOpen}
onClose={() => {
setIsModalOpen(false);
setContent("");
}}
>
{content}
</Modal>
<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">
@@ -44,8 +44,12 @@ export function ProfileDropdown() {
size={34}
username={userInfo.value.display_name}
email_hash={userInfo.value.email_gravatar_hash}
avatarIndex={Number(userInfo.value.core_info.default_avatar.image_index) || 0}
backgroundIndex={Number(userInfo.value.core_info.default_avatar.color_index) || 0}
avatarIndex={
Number(userInfo.value.core_info.default_avatar.image_index) || 0
}
backgroundIndex={
Number(userInfo.value.core_info.default_avatar.color_index) || 0
}
/>
<FontAwesomeIcon icon={faChevronDown} />
</MenuButton>

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