Compare commits

...
Author SHA1 Message Date
Brandon Thomas eb1322df4c proxy requests through tauri to avoid 3rd party cookie ban 2025-05-01 03:58:25 -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
bflatastic 1802b0939e fix object highlight, camera speed, after delete character hide and toggle out of FK mode 2025-04-28 22:53:33 +07:00
bflatastic 36b6aa78f8 fix visible fps lag on higher refresh rate screens 2025-04-28 22:28:04 +07:00
bflatastic 499c07548c bug fix: undefined fetch on page load 2025-04-28 22:07:06 +07:00
Kasisnu 65580ec502 small tweaks to imagen callback 2025-04-28 20:15:30 +05:30
bflatastic 5d5b782760 only update camera when there's change 2025-04-28 21:36:35 +07:00
BFlat 09d0ae263f freecam camera smoothing (#556) 2025-04-28 15:39:59 +07:00
Kasisnu c3509d770f updated sqlx migrations 2025-04-28 13:11:38 +05:30
bflatastic ee1043f92c add button styling fix 2025-04-28 14:30:51 +07:00
ArEnScandMichael Chung e88b6ce39b Feature/UI sign in (#555)
Co-authored-by: Michael Chung <michaelchung@michaels-mbp-2.home>
2025-04-27 22:42:34 -04:00
Brandon Thomas 0e535e52ad update job state from endpoint 2025-04-27 02:41:00 -04:00
Brandon Thomas aa594b773e upload media files from gpt-image-1 2025-04-27 02:17:53 -04:00
Brandon Thomas ac209f8f4f builder for media file records 2025-04-27 00:19:54 -04:00
Brandon Thomas 66abfd18ba simple image medadata functions 2025-04-26 21:42:38 -04:00
Brandon Thomas 79cf730270 bundled mimetype / file extension detection 2025-04-26 20:44:31 -04:00
Brandon Thomas 698351f2a3 stub endpoint for updating gpt image job statuses 2025-04-26 03:20:45 -04:00
155 changed files with 3986 additions and 3031 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>
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nUPDATE generic_inference_jobs\nSET\n status = \"complete_success\",\n on_success_result_entity_type = ?,\n on_success_result_entity_token = ?,\n failure_reason = NULL,\n internal_debugging_failure_reason = NULL,\n success_execution_millis = ?,\n success_inference_execution_millis = ?,\n retry_at = NULL,\n successfully_completed_at = NOW()\nWHERE token = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 5
},
"nullable": []
},
"hash": "2bd7a5b886c9624ae48533ddac409cfca0668f30ab1f7016c412876b5904168b"
}
Generated
+105 -10
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",
]
@@ -5653,8 +5673,10 @@ dependencies = [
name = "images"
version = "0.0.1"
dependencies = [
"anyhow",
"errors",
"image",
"mimetypes",
"testing",
"workspace-build-acceleration",
]
@@ -6768,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"
@@ -6868,7 +6899,9 @@ dependencies = [
name = "mimetypes"
version = "0.0.1"
dependencies = [
"infer 0.16.0",
"anyhow",
"infer 0.19.0",
"testing",
"workspace-build-acceleration",
]
@@ -7793,7 +7826,7 @@ dependencies = [
"toml 0.8.20",
"ureq",
"url",
"uuid",
"uuid 1.8.0",
"walkdir",
]
@@ -9700,7 +9733,7 @@ dependencies = [
"rkyv_derive",
"seahash",
"tinyvec",
"uuid",
"uuid 1.8.0",
]
[[package]]
@@ -10101,7 +10134,7 @@ dependencies = [
"serde",
"serde_json",
"url",
"uuid",
"uuid 1.8.0",
]
[[package]]
@@ -11191,6 +11224,7 @@ dependencies = [
"hostname 0.4.0",
"http_server_common",
"idempotency",
"images",
"infer 0.16.0",
"language-tags",
"lexical-sort",
@@ -11248,7 +11282,7 @@ dependencies = [
"user_traits_component",
"utoipa",
"utoipa-swagger-ui",
"uuid",
"uuid 1.8.0",
"videos",
"workspace-build-acceleration",
"zip 0.6.6",
@@ -11261,6 +11295,7 @@ dependencies = [
"anyhow",
"backoff",
"chrono",
"cookie 0.18.1",
"enums",
"enums_public",
"errors",
@@ -12042,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",
@@ -12063,6 +12101,7 @@ dependencies = [
"subprocess_common",
"tauri",
"tauri-build",
"tauri-plugin-http",
"tauri-plugin-log",
"tempdir",
"tempfile",
@@ -12117,7 +12156,7 @@ dependencies = [
"thiserror 2.0.12",
"time",
"url",
"uuid",
"uuid 1.8.0",
"walkdir",
]
@@ -12152,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"
@@ -12296,7 +12382,7 @@ dependencies = [
"toml 0.8.20",
"url",
"urlpattern",
"uuid",
"uuid 1.8.0",
"walkdir",
]
@@ -13540,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"
@@ -14701,7 +14796,7 @@ dependencies = [
"unicode-normalization",
"ureq",
"url",
"uuid",
"uuid 1.8.0",
"void",
"winapi 0.3.9",
"windows-sys 0.48.0",
+9
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"
@@ -177,7 +183,10 @@ enums = { path = "crates/_types/enums" }
enums_public = { path = "crates/_types/enums_public" }
errors = { path = "crates/lib/errors" }
filesys = { path = "crates/lib/files/filesys" }
images = { path = "crates/lib/files/images" }
memory_caching = { path = "crates/lib/caching/memory_caching" }
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" }
shared_service_components = { path = "crates/lib/shared_service_components" }
@@ -28,11 +28,12 @@ pub enum MediaFileOriginCategory {
DeviceApi,
/// From Storyteller Studio Engine
#[deprecated(note="This db field should only denote file provenance, not the product.")]
#[serde(rename = "studio")]
StorytellerStudio,
/// From Storyteller Studio Engine
#[deprecated(note = "Use StorytellerStudio instead")]
#[deprecated(note = "DO NOT USE. Originally deprecated in favor of `StorytellerStudio`.")]
StoryEngine,
}
@@ -39,6 +39,10 @@ pub enum MediaFileOriginProductCategory {
#[serde(rename = "vst")]
VideoStyleTransfer,
/// Image Studio
#[serde(rename = "image_studio")]
ImageStudio,
/// Storyteller Studio
#[serde(rename = "studio")]
StorytellerStudio,
@@ -87,6 +91,7 @@ impl MediaFileOriginProductCategory {
Self::FaceFusion => "face_fusion",
Self::FaceMirror => "face_mirror",
Self::VideoStyleTransfer => "vst",
Self::ImageStudio => "image_studio",
Self::StorytellerStudio => "studio",
Self::TextToSpeech => "tts",
Self::VoiceConversion => "voice_conversion",
@@ -105,6 +110,7 @@ impl MediaFileOriginProductCategory {
"face_fusion" => Ok(Self::FaceFusion),
"face_mirror" => Ok(Self::FaceMirror),
"vst" => Ok(Self::VideoStyleTransfer),
"image_studio" => Ok(Self::ImageStudio),
"studio" => Ok(Self::StorytellerStudio),
"tts" => Ok(Self::TextToSpeech),
"voice_conversion" => Ok(Self::VoiceConversion),
@@ -126,6 +132,7 @@ impl MediaFileOriginProductCategory {
Self::FaceFusion,
Self::FaceMirror,
Self::VideoStyleTransfer,
Self::ImageStudio,
Self::StorytellerStudio,
Self::TextToSpeech,
Self::VoiceConversion,
@@ -153,6 +160,7 @@ mod tests {
assert_serialization(MediaFileOriginProductCategory::FaceFusion, "face_fusion");
assert_serialization(MediaFileOriginProductCategory::FaceMirror, "face_mirror");
assert_serialization(MediaFileOriginProductCategory::VideoStyleTransfer, "vst");
assert_serialization(MediaFileOriginProductCategory::ImageStudio, "image_studio");
assert_serialization(MediaFileOriginProductCategory::StorytellerStudio, "studio");
assert_serialization(MediaFileOriginProductCategory::TextToSpeech, "tts");
assert_serialization(MediaFileOriginProductCategory::VoiceConversion, "voice_conversion");
@@ -170,6 +178,7 @@ mod tests {
assert_eq!(MediaFileOriginProductCategory::FaceFusion.to_str(), "face_fusion");
assert_eq!(MediaFileOriginProductCategory::FaceMirror.to_str(), "face_mirror");
assert_eq!(MediaFileOriginProductCategory::VideoStyleTransfer.to_str(), "vst");
assert_eq!(MediaFileOriginProductCategory::ImageStudio.to_str(), "image_studio");
assert_eq!(MediaFileOriginProductCategory::StorytellerStudio.to_str(), "studio");
assert_eq!(MediaFileOriginProductCategory::TextToSpeech.to_str(), "tts");
assert_eq!(MediaFileOriginProductCategory::VoiceConversion.to_str(), "voice_conversion");
@@ -187,6 +196,7 @@ mod tests {
assert_eq!(MediaFileOriginProductCategory::from_str("face_fusion").unwrap(), MediaFileOriginProductCategory::FaceFusion);
assert_eq!(MediaFileOriginProductCategory::from_str("face_mirror").unwrap(), MediaFileOriginProductCategory::FaceMirror);
assert_eq!(MediaFileOriginProductCategory::from_str("vst").unwrap(), MediaFileOriginProductCategory::VideoStyleTransfer);
assert_eq!(MediaFileOriginProductCategory::from_str("image_studio").unwrap(), MediaFileOriginProductCategory::ImageStudio);
assert_eq!(MediaFileOriginProductCategory::from_str("studio").unwrap(), MediaFileOriginProductCategory::StorytellerStudio);
assert_eq!(MediaFileOriginProductCategory::from_str("tts").unwrap(), MediaFileOriginProductCategory::TextToSpeech);
assert_eq!(MediaFileOriginProductCategory::from_str("voice_conversion").unwrap(), MediaFileOriginProductCategory::VoiceConversion);
@@ -200,12 +210,13 @@ mod tests {
#[test]
fn all_variants() {
let mut variants = MediaFileOriginProductCategory::all_variants();
assert_eq!(variants.len(), 13);
assert_eq!(variants.len(), 14);
assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::Unknown));
assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::FaceAnimator));
assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::FaceFusion));
assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::FaceMirror));
assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::VideoStyleTransfer));
assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::ImageStudio));
assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::StorytellerStudio));
assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::TextToSpeech));
assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::VoiceConversion));
+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;
+7 -6
View File
@@ -1,6 +1,6 @@
[package]
name = "images"
edition = "2018"
edition = "2024"
version = "0.0.1"
authors = [
"Brandon Thomas <bt@brand.io>",
@@ -18,15 +18,16 @@ path = "src/lib.rs"
normal = ["workspace-build-acceleration"]
[dependencies]
# Workspace hack (faster compile times)
workspace-build-acceleration = { workspace = true }
# Internal
errors = { path = "../../errors" }
# Workspace hack (faster compile times)
workspace-build-acceleration = { version = "0.1", path = "../../../workspace-build-acceleration" }
errors = { workspace = true}
mimetypes = { workspace = true }
# External
image = "0.25.1"
image = "0.25.6"
[dev-dependencies]
anyhow = { workspace = true }
testing = { path = "../../testing" }
@@ -0,0 +1,100 @@
use crate::image_info::image_info_error::ImageInfoError;
use image::{GenericImageView, ImageReader};
use mimetypes::mimetype_info::file_extension::FileExtension;
use mimetypes::mimetype_info::mimetype_info::MimetypeInfo;
use std::io::Cursor;
use std::path::Path;
#[derive(Clone, Debug)]
pub struct ImageInfo {
/// The mimetype we determined
mimetype_info: MimetypeInfo,
/// The width of the image in pixels
width: u32,
/// The height of the image in pixels
height: u32,
}
impl ImageInfo {
/// Note: This has to do a full file read, and we throw the parsed image on the floor.
pub fn decode_image_from_bytes(bytes: &[u8]) -> Result<Self, ImageInfoError> {
let image = ImageReader::new(Cursor::new(bytes)).with_guessed_format()?.decode()?;
let (width, height) = image.dimensions();
Ok(ImageInfo {
mimetype_info: MimetypeInfo::get_for_bytes(bytes)
.ok_or_else(|| ImageInfoError::CouldNotDetermineMimetype)?,
width,
height,
})
}
/// Note: This has to do a full file read, and we throw the parsed image on the floor.
pub fn decode_image_from_path<P: AsRef<Path>>(path: P) -> Result<Self, ImageInfoError> {
let image = ImageReader::open(&path)?.decode()?;
let (width, height) = image.dimensions();
Ok(ImageInfo {
mimetype_info: MimetypeInfo::get_for_path(path)?
.ok_or_else(|| ImageInfoError::CouldNotDetermineMimetype)?,
width,
height,
})
}
pub fn width(&self) -> u32 {
self.width
}
pub fn height(&self) -> u32 {
self.height
}
pub fn dimensions(&self) -> (u32, u32) {
(self.width, self.height)
}
pub fn mime_type(&self) -> &str {
self.mimetype_info.mime_type()
}
pub fn file_extension(&self) -> Option<FileExtension> {
self.mimetype_info.file_extension()
}
}
#[cfg(test)]
mod tests {
use crate::image_info::image_info::ImageInfo;
use mimetypes::mimetype_info::file_extension::FileExtension;
use testing::test_file_path::test_file_path;
#[test]
fn jpg_file() -> anyhow::Result<()> {
let path = test_file_path("test_data/image/mochi.jpg")?;
let info = ImageInfo::decode_image_from_path(path)?;
assert_eq!(info.width(), 753);
assert_eq!(info.height(), 1000);
assert_eq!(info.mime_type(), "image/jpeg");
let ext = info.file_extension().expect("should have extension");
assert_eq!(ext, FileExtension::Jpg);
assert_eq!(ext.extension_with_period(), ".jpg");
assert_eq!(ext.extension_without_period(), "jpg");
Ok(())
}
#[test]
fn jpg_bytes() -> anyhow::Result<()> {
let path = test_file_path("test_data/image/juno.jpg")?;
let bytes = std::fs::read(path)?;
let info = ImageInfo::decode_image_from_bytes(&bytes)?;
assert_eq!(info.width(), 1000);
assert_eq!(info.height(), 750);
assert_eq!(info.mime_type(), "image/jpeg");
let ext = info.file_extension().expect("should have extension");
assert_eq!(ext, FileExtension::Jpg);
assert_eq!(ext.extension_with_period(), ".jpg");
assert_eq!(ext.extension_without_period(), "jpg");
Ok(())
}
}
@@ -0,0 +1,39 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
use std::io;
#[derive(Debug)]
pub enum ImageInfoError {
CouldNotDetermineMimetype,
ImageError(image::ImageError),
IoError(io::Error),
}
impl Error for ImageInfoError {}
impl Display for ImageInfoError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
ImageInfoError::CouldNotDetermineMimetype => {
write!(f, "ImageInfoError::CouldNotDetermineMimetype")
}
ImageInfoError::ImageError(err) => {
write!(f, "ImageInfoError::ImageError: {}", err)
}
ImageInfoError::IoError(err) => {
write!(f, "ImageInfoError::IoError: {}", err)
}
}
}
}
impl From<image::ImageError> for ImageInfoError {
fn from(err: image::ImageError) -> Self {
ImageInfoError::ImageError(err)
}
}
impl From<io::Error> for ImageInfoError {
fn from(err: io::Error) -> Self {
ImageInfoError::IoError(err)
}
}
@@ -0,0 +1,2 @@
pub mod image_info;
pub mod image_info_error;
+1
View File
@@ -23,6 +23,7 @@
// Re-export image to everyone importing this library
pub use image;
pub mod image_info;
pub mod resize_image_file_preserving_aspect;
pub mod resize_preserving_aspect;
+4 -5
View File
@@ -16,15 +16,14 @@ path = "src/lib.rs"
normal = ["workspace-build-acceleration"]
[dependencies]
# Workspace hack (faster compile times)
workspace-build-acceleration = { workspace = true }
# Internal
errors = { path = "../../errors" }
# Workspace hack (faster compile times)
workspace-build-acceleration = { version = "0.1", path = "../../../workspace-build-acceleration" }
errors = { workspace = true }
# External
log = "0.4.14"
log = { workspace = true }
# Powerful audio decoder and demuxer
#symphonia = { version = "0.5.2", features = ["aac", "alac", "flac", "isomp4", "mkv", "mp3", "ogg", "vorbis", "wav"] }
+5 -5
View File
@@ -1,6 +1,6 @@
[package]
name = "mimetypes"
edition = "2018"
edition = "2024"
version = "0.0.1"
authors = [
"Brandon Thomas <bt@brand.io>",
@@ -18,11 +18,11 @@ normal = ["workspace-build-acceleration"]
[dependencies]
# Workspace hack (faster compile times)
workspace-build-acceleration = { version = "0.1", path = "../../../workspace-build-acceleration" }
workspace-build-acceleration = { workspace = true }
# External
infer = "0.16.0" # Mimetype detection
infer = "0.19.0" # Mimetype detection
[dev-dependencies]
# None yet
testing = { path = "../../testing" }
anyhow = { workspace = true }
+1
View File
@@ -21,4 +21,5 @@
pub mod mimetype_for_bytes;
pub mod mimetype_for_file;
pub mod mimetype_info;
pub mod mimetype_to_extension;
@@ -0,0 +1,68 @@
/// This is a constrained set of file extensions that:
/// 1. we care about
/// 2. we can detect with magic bytes
/// 3. are not text formats / are binary formats
#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash, Ord, PartialOrd)]
pub enum FileExtension {
Aac,
Flac,
Gif,
Jpg,
Mov,
Mp3,
Mp4,
Ogg,
Opus,
Png,
Wav,
Webm,
Webp,
}
impl FileExtension {
pub fn from_mimetype(mimetype: &str) -> Option<Self> {
match mimetype {
"audio/aac" => Some(FileExtension::Aac),
"audio/flac" => Some(FileExtension::Flac),
"image/gif" => Some(FileExtension::Gif),
"image/jpeg" => Some(FileExtension::Jpg),
"video/quicktime" => Some(FileExtension::Mov),
"audio/mpeg" => Some(FileExtension::Mp3),
"video/mp4" => Some(FileExtension::Mp4),
"audio/ogg" => Some(FileExtension::Ogg),
"audio/opus" => Some(FileExtension::Opus),
"image/png" => Some(FileExtension::Png),
"audio/wav" => Some(FileExtension::Wav),
"video/webm" => Some(FileExtension::Webm),
"image/webp" => Some(FileExtension::Webp),
_ => None,
}
}
pub fn extension_with_period(&self) -> &'static str {
self.with_and_without_period().0
}
pub fn extension_without_period(&self) -> &'static str {
self.with_and_without_period().1
}
fn with_and_without_period(&self) -> (&'static str, &'static str) {
match self {
FileExtension::Aac => (".aac", "aac"),
FileExtension::Flac => (".flac", "flac"),
FileExtension::Gif => (".gif", "gif"),
FileExtension::Jpg => (".jpg", "jpg"),
FileExtension::Mov => (".mov", "mov"),
FileExtension::Mp3 => (".mp3", "mp3"),
FileExtension::Mp4 => (".mp4", "mp4"),
FileExtension::Ogg => (".ogg", "ogg"),
FileExtension::Opus => (".opus", "opus"),
FileExtension::Png => (".png", "png"),
FileExtension::Wav => (".wav", "wav"),
FileExtension::Webm => (".webm", "webm"),
FileExtension::Webp => (".webp", "webp"),
}
}
}
@@ -0,0 +1,80 @@
use crate::mimetype_info::file_extension::FileExtension;
use infer::Type;
use std::io;
use std::path::Path;
#[derive(Clone, Debug)]
pub struct MimetypeInfo {
/// The mimetype we determined
mime_type: String,
/// The extension for the mimetype, if we could determine it.
file_extension: Option<FileExtension>,
}
impl MimetypeInfo {
pub fn get_for_bytes(bytes: &[u8]) -> Option<Self> {
infer::get(bytes)
.map(|t| Self::for_type(t))
}
pub fn get_for_path<P: AsRef<Path>>(path: P) -> io::Result<Option<Self>> {
infer::get_from_path(path)
.map(|maybe_type| maybe_type.map(|t| Self::for_type(t)))
}
fn for_type(r#type: Type) -> Self {
let mime_type = r#type.mime_type().to_string();
MimetypeInfo {
file_extension: FileExtension::from_mimetype(&mime_type),
mime_type,
}
}
pub fn mime_type(&self) -> &str {
&self.mime_type
}
pub fn file_extension(&self) -> Option<FileExtension> {
self.file_extension
}
}
#[cfg(test)]
mod tests {
use crate::mimetype_info::file_extension::FileExtension;
use crate::mimetype_info::mimetype_info::MimetypeInfo;
mod for_bytes {
use super::*;
#[test]
fn png() {
// NB: From "Hanashi Mask.png"
let bytes: Vec<u8> = vec![137, 80, 78, 71, 13, 10, 26, 10];
let t = MimetypeInfo::get_for_bytes(&bytes).expect("should detect bytes");
assert_eq!(t.mime_type(), "image/png");
let ext = t.file_extension.expect("should have extension");
assert_eq!(ext, FileExtension::Png);
assert_eq!(ext.extension_with_period(), ".png");
assert_eq!(ext.extension_without_period(), "png");
}
}
mod for_files {
use testing::test_file_path::test_file_path;
use super::*;
#[test]
fn mp3() -> anyhow::Result<()> {
let path = test_file_path("test_data/audio/mp3/super_mario_rpg_beware_the_forests_mushrooms.mp3")?;
let t = MimetypeInfo::get_for_path(path)?.expect("should detect type");
assert_eq!(t.mime_type(), "audio/mpeg");
let ext = t.file_extension.expect("should have extension");
assert_eq!(ext, FileExtension::Mp3);
assert_eq!(ext.extension_with_period(), ".mp3");
assert_eq!(ext.extension_without_period(), "mp3");
Ok(())
}
}
}
@@ -0,0 +1,2 @@
pub mod file_extension;
pub mod mimetype_info;
+3 -3
View File
@@ -18,14 +18,14 @@ normal = ["workspace-build-acceleration"]
[dependencies]
# Workspace hack (faster compile times)
workspace-build-acceleration = { version = "0.1", path = "../../../workspace-build-acceleration" }
workspace-build-acceleration = { workspace = true }
# Internal
errors = { path = "../../errors" }
errors = { workspace = true }
# External
ffprobe = "0.4.0"
log = "0.4.20"
log = { workspace = true }
# TODO(bt,2024-08-08): We might drop `mp4` crate because it crashes on reading
# some videos with the message "avcc not found"
mp4 = "0.14.0"
@@ -0,0 +1,57 @@
use std::time::Duration;
use anyhow::anyhow;
use sqlx::MySqlPool;
use enums::by_table::generic_inference_jobs::inference_result_type::InferenceResultType;
use errors::AnyhowResult;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use crate::queries::generic_inference::job::list_available_generic_inference_jobs::AvailableInferenceJob;
pub async fn mark_generic_inference_job_successfully_done_by_token(
pool: &MySqlPool,
token: &InferenceJobToken,
maybe_entity_type: Option<InferenceResultType>,
maybe_entity_token: Option<&str>,
total_job_duration: Option<Duration>,
inference_duration: Option<Duration>,
) -> AnyhowResult<()>
{
// NB: MySql's unsigned int (32 bits) can store integers up to 4,294,967,295.
// Given milliseconds, this is ~49.71 days, which should be plenty for us.
let truncated_total_job_execution_millis = total_job_duration
.map(|duration| duration.as_millis() as u32)
.unwrap_or(0);
let truncated_inference_execution_millis = inference_duration
.map(|duration| duration.as_millis() as u32)
.unwrap_or(0);
let query_result = sqlx::query!(
r#"
UPDATE generic_inference_jobs
SET
status = "complete_success",
on_success_result_entity_type = ?,
on_success_result_entity_token = ?,
failure_reason = NULL,
internal_debugging_failure_reason = NULL,
success_execution_millis = ?,
success_inference_execution_millis = ?,
retry_at = NULL,
successfully_completed_at = NOW()
WHERE token = ?
"#,
maybe_entity_type,
maybe_entity_token,
truncated_total_job_execution_millis,
truncated_inference_execution_millis,
token.as_str()
)
.execute(pool)
.await;
match query_result {
Err(err) => Err(anyhow!("error with query: {:?}", err)),
Ok(_r) => Ok(()),
}
}
@@ -7,4 +7,5 @@ pub mod job_status;
pub mod kill_generic_inference_jobs;
pub mod kill_jobs_in_development;
pub mod list_session_jobs;
pub mod mark_generic_inference_job_cancelled_by_user;
pub mod mark_generic_inference_job_cancelled_by_user;
pub mod mark_generic_inference_job_successfully_done_by_token;
@@ -0,0 +1,269 @@
use crate::queries::media_files::create::generic_insert::insert_media_file_generic::{insert_media_file_generic, InsertArgs};
use anyhow::anyhow;
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
use enums::by_table::generic_synthetic_ids::id_category::IdCategory;
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory;
use enums::by_table::media_files::media_file_origin_product_category::MediaFileOriginProductCategory;
use enums::by_table::media_files::media_file_type::MediaFileType;
use enums::common::visibility::Visibility;
use errors::AnyhowResult;
use sqlx::MySqlPool;
use tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken;
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::users::UserToken;
use crate::queries::media_files::create::insert_builder::media_file_insert_builder_error::MediaFileInsertBuilderError;
/// Builder for inserting a media files into the database.
/// Resembles an ORM, but can still use statically type checked queries (which is probably
/// really important since we don't have tests on database functionality yet).
#[derive(Clone)]
pub struct MediaFileInsertBuilder {
// TODO: Allow specifying the token externally
// token: MediaFileToken,
// Creator info
maybe_creator_user_token: Option<UserToken>,
maybe_creator_anonymous_visitor_token: Option<AnonymousVisitorTrackingToken>,
creator_ip_address: Option<String>, // NB: Non-nullable field
creator_set_visibility: Visibility, // NB: Non-nullable field
// Important database indices
media_file_class: MediaFileClass, // NB: Non-nullable field
media_file_type: Option<MediaFileType>, // NB: Non-nullable field
is_user_upload: bool,
is_intermediate_system_file: bool,
// Product and other origination information
origin_category: Option<MediaFileOriginCategory>, // NB: Non-nullable field
origin_product_category: MediaFileOriginProductCategory, // NB: Non-nullable field
// maybe_origin_model_type: Option<MediaFileOriginModelType>,
// maybe_origin_model_token: Option<&'a ModelWeightToken>,
// maybe_origin_filename: Option<String>,
// Media info
maybe_mime_type: Option<String>,
file_size_bytes: u64, // NB: Non-nullable field
// maybe_duration_millis: Option<u64>,
// maybe_audio_encoding: Option<&'a str>,
// maybe_video_encoding: Option<&'a str>,
maybe_frame_width: Option<u32>,
maybe_frame_height: Option<u32>,
checksum_sha2: Option<String>, // NB: Non-nullable field
// // User text information
// maybe_title: Option<&'a str>,
// maybe_text_transcript: Option<&'a str>,
// // If generated from a scene, this is the scene media file token.
// maybe_scene_source_media_file_token: Option<&'a MediaFileToken>,
// // If additional prompt details are stored, this is the prompt token.
// maybe_prompt_token: Option<&'a PromptToken>,
// // If batch generated, this is the batch token.
// maybe_batch_token: Option<&'a BatchGenerationToken>,
// Storage details
public_bucket_directory_hash: Option<MediaFileBucketPath>, // NB: Non-nullable field(s)
// Remaining fields...
}
impl MediaFileInsertBuilder {
//pub fn generate_token() -> Self {
// Self::with_token(&MediaFileToken::generate())
//}
pub fn new() -> Self {
MediaFileInsertBuilder {
//token: token.clone(),
maybe_creator_user_token: None,
maybe_creator_anonymous_visitor_token: None,
creator_ip_address: None,
creator_set_visibility: Visibility::Public,
media_file_class: MediaFileClass::Unknown,
media_file_type: None,
is_user_upload: false,
is_intermediate_system_file: false,
origin_category: None,
origin_product_category: MediaFileOriginProductCategory::Unknown,
maybe_mime_type: None,
file_size_bytes: 0,
maybe_frame_width: None,
maybe_frame_height: None,
checksum_sha2: None,
public_bucket_directory_hash: None,
}
}
pub fn creator_user(mut self, user_token: &UserToken) -> Self {
self.maybe_creator_user_token = Some(user_token.clone());
self
}
pub fn maybe_creator_user(mut self, maybe_user: Option<&UserToken>) -> Self {
self.maybe_creator_user_token = maybe_user.map(|token| token.clone());
self
}
pub fn creator_anonymous_visitor(mut self, avt: &AnonymousVisitorTrackingToken) -> Self {
self.maybe_creator_anonymous_visitor_token = Some(avt.clone());
self
}
pub fn maybe_creator_anonymous_visitor(mut self, maybe_avt: Option<&AnonymousVisitorTrackingToken>) -> Self {
self.maybe_creator_anonymous_visitor_token = maybe_avt.map(|token| token.clone());
self
}
pub fn creator_ip_address(mut self, ip_address: &str) -> Self {
self.creator_ip_address = Some(ip_address.to_string());
self
}
pub fn creator_set_visibility(mut self, visibility: Visibility) -> Self {
self.creator_set_visibility = visibility;
self
}
pub fn media_file_class(mut self, media_file_class: MediaFileClass) -> Self {
self.media_file_class = media_file_class;
self
}
pub fn media_file_type(mut self, media_file_type: MediaFileType) -> Self {
self.media_file_type = Some(media_file_type);
self
}
#[allow(clippy::wrong_self_convention)]
pub fn is_user_upload(mut self, is_user_upload: bool) -> Self {
self.is_user_upload = is_user_upload;
self
}
#[allow(clippy::wrong_self_convention)]
pub fn is_intermediate_system_file(mut self, is_intermediate_system_file: bool) -> Self {
self.is_intermediate_system_file = is_intermediate_system_file;
self
}
pub fn media_file_origin_category(mut self, origin_category: MediaFileOriginCategory) -> Self {
self.origin_category = Some(origin_category);
self
}
pub fn media_file_origin_product_category(mut self, origin_category: MediaFileOriginProductCategory) -> Self {
self.origin_product_category = origin_category;
self
}
// TODO: Method for maybe_origin_model_type
// TODO: Method for maybe_origin_model_token
// TODO: Method for maybe_origin_filename
pub fn mime_type(mut self, mime_type: &str) -> Self {
self.maybe_mime_type = Some(mime_type.to_string());
self
}
pub fn file_size_bytes(mut self, file_size_bytes: u64) -> Self {
self.file_size_bytes = file_size_bytes;
self
}
// TODO: maybe_duration_millis
// TODO: maybe_audio_encoding
// TODO: maybe_video_encoding
pub fn frame_width(mut self, frame_width: u32) -> Self {
self.maybe_frame_width = Some(frame_width);
self
}
pub fn frame_height(mut self, frame_height: u32) -> Self {
self.maybe_frame_height = Some(frame_height);
self
}
pub fn checksum_sha2(mut self, checksum_sha2: &str) -> Self {
self.checksum_sha2 = Some(checksum_sha2.to_string());
self
}
// TODO: maybe_title
// TODO: maybe_text_transcript
// TODO: maybe_scene_source_media_file_token
// TODO: maybe_prompt_token
// TODO: maybe_batch_token
pub fn public_bucket_directory_hash(mut self, public_bucket_directory_hash: &MediaFileBucketPath) -> Self {
self.public_bucket_directory_hash = Some(public_bucket_directory_hash.clone());
self
}
// TODO(bt,2025-04-26): Other connector options.
pub async fn insert_pool(self, mysql_pool: &MySqlPool) -> Result<MediaFileToken, MediaFileInsertBuilderError> {
let media_file_type = self.media_file_type
.ok_or_else(|| MediaFileInsertBuilderError::MissingRequiredField(
"Media file type is required".to_string()))?;
let origin_category = self.origin_category
.ok_or_else(|| MediaFileInsertBuilderError::MissingRequiredField(
"Origin category is required".to_string()))?;
let checksum_sha2 = self.checksum_sha2
.ok_or_else(|| MediaFileInsertBuilderError::MissingRequiredField(
"Checksum SHA2 is required".to_string()))?;
let bucket_path = self.public_bucket_directory_hash
.ok_or_else(|| MediaFileInsertBuilderError::MissingRequiredField(
"Public bucket directory hash is required".to_string()))?;
let result = insert_media_file_generic(InsertArgs {
pool: mysql_pool,
maybe_creator_user_token: self.maybe_creator_user_token.as_ref(),
maybe_creator_anonymous_visitor_token: self.maybe_creator_anonymous_visitor_token.as_ref(),
creator_ip_address: self.creator_ip_address.as_deref().unwrap_or("127.0.0.1"),
creator_set_visibility: self.creator_set_visibility,
media_class: self.media_file_class,
media_type: media_file_type,
is_user_upload: self.is_user_upload,
is_intermediate_system_file: self.is_intermediate_system_file,
origin_category,
origin_product_category: self.origin_product_category,
maybe_origin_model_type: None, // TODO
maybe_origin_model_token: None, // TODO
maybe_origin_filename: None, // TODO
maybe_mime_type: self.maybe_mime_type.as_deref(),
file_size_bytes: self.file_size_bytes,
maybe_duration_millis: None, // TODO
maybe_audio_encoding: None, // TODO
maybe_video_encoding: None, // TODO
maybe_frame_width: self.maybe_frame_width,
maybe_frame_height: self.maybe_frame_height,
checksum_sha2: &checksum_sha2,
maybe_title: None, // TODO
maybe_text_transcript: None, // TODO
maybe_scene_source_media_file_token: None, // TODO
maybe_prompt_token: None, // TODO
maybe_batch_token: None, // TODO
public_bucket_directory_hash: bucket_path.get_object_hash(),
maybe_public_bucket_prefix: bucket_path.get_optional_prefix(),
maybe_public_bucket_extension: bucket_path.get_optional_extension(),
maybe_creator_file_synthetic_id_category: IdCategory::MediaFile, // TODO: Remove this
maybe_creator_category_synthetic_id_category: IdCategory::FileUpload, // TODO: Remove this
maybe_extra_media_info: None, // TODO
is_generated_on_prem: false, // TODO
generated_by_worker: None, // TODO
generated_by_cluster: None, // TODO
maybe_mod_user_token: None, // TODO
}).await;
match result {
Ok(result) => Ok(result.0),
Err(err) => Err(MediaFileInsertBuilderError::ProbablyDatabaseError(err)),
}
}
}
@@ -0,0 +1,27 @@
use std::error::Error;
use std::fmt::{Debug, Display, Formatter};
use errors::AnyhowError;
#[derive(Debug)]
pub enum MediaFileInsertBuilderError {
/// A user-provided field is missing (400 error).
MissingRequiredField(String),
// TODO: We need to refactor to ensure these are actually DB errors (500s).
ProbablyDatabaseError(AnyhowError),
}
impl Error for MediaFileInsertBuilderError {}
impl Display for MediaFileInsertBuilderError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
MediaFileInsertBuilderError::MissingRequiredField(reason) => {
write!(f, "Missing required field: {}", reason)
}
MediaFileInsertBuilderError::ProbablyDatabaseError(reason) => {
write!(f, "Probably database error: {:?}", reason)
}
}
}
}
@@ -0,0 +1,2 @@
pub mod media_file_insert_builder;
mod media_file_insert_builder_error;
@@ -1,4 +1,5 @@
pub mod generic_insert;
pub mod insert_builder;
pub mod insert_media_file_from_face_fusion;
pub mod insert_media_file_from_gptsovits;
pub mod insert_media_file_from_live_portrait;
@@ -51,11 +51,12 @@ google_sign_in = { path = "../../../lib/api_clients/google_sign_in" }
hashing = { path = "../../../lib/files/hashing" }
http_server_common = { path = "../../../lib/deprecated/http_server_common" }
idempotency = { path = "../../../lib/idempotency" }
images = { workspace = true }
markdown = { path = "../../../lib/markdown" }
media = { path = "../../../lib/files/media" }
memory_caching = { path = "../../../lib/caching/memory_caching" }
migration = { path = "../../../lib/migration" }
mimetypes = { path = "../../../lib/files/mimetypes" }
mimetypes = { workspace = true }
mysql_queries = { path = "../../../lib/mysql_queries" }
openai_sora_client = { path = "../../../lib/api_clients/openai_sora_client" }
password = { path = "../../../lib/password" }
@@ -1,3 +1,4 @@
pub mod upload;
pub mod prompt;
pub mod get_sora_credentials_from_request;
pub mod get_sora_credentials_from_request;
pub mod update_gpt_image_job_status_handler;
@@ -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)?;
@@ -0,0 +1,296 @@
use std::fmt;
use std::sync::Arc;
use crate::http_server::endpoints::engine::create_scene_handler::CreateSceneError;
use crate::http_server::endpoints::inference_job::get::get_inference_job_status_handler::GetInferenceJobStatusError;
use crate::http_server::endpoints::media_files::upload::upload_error::MediaFileUploadError;
use crate::http_server::web_utils::response_error_helpers::to_simple_json_error;
use crate::http_server::web_utils::user_session::require_user_session::require_user_session;
use crate::state::server_state::ServerState;
use actix_web::error::ResponseError;
use actix_web::http::StatusCode;
use actix_web::web::{Data, Json, Path};
use actix_web::{web, HttpMessage, HttpRequest, HttpResponse};
use base64::alphabet::STANDARD;
use base64::prelude::BASE64_STANDARD;
use base64::Engine;
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
use cloud_storage::bucket_client::BucketClient;
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory;
use enums::by_table::media_files::media_file_origin_product_category::MediaFileOriginProductCategory;
use enums::by_table::media_files::media_file_type::MediaFileType;
use enums::common::job_status::JobStatus;
use enums::common::job_status_plus::JobStatusPlus;
use errors::AnyhowResult;
use hashing::sha256::sha256_hash_bytes::sha256_hash_bytes;
use http_server_common::request::get_request_ip::get_request_ip;
use images::image_info::image_info::ImageInfo;
use log::{error, info, warn};
use mysql_queries::queries::generic_inference::job::mark_generic_inference_job_completely_failed::mark_generic_inference_job_completely_failed;
use mysql_queries::queries::generic_inference::web::dismiss_finished_jobs_for_user::dismiss_finished_jobs_for_user;
use mysql_queries::queries::generic_inference::web::get_inference_job_status::{get_inference_job_status, get_inference_job_status_from_connection};
use mysql_queries::queries::generic_inference::web::job_status::GenericInferenceJobStatus;
use mysql_queries::queries::generic_inference::web::mark_generic_inference_job_cancelled_by_user::mark_generic_inference_job_cancelled_by_user;
use mysql_queries::queries::media_files::create::generic_insert::insert_media_file_generic::{insert_media_file_generic, InsertArgs};
use mysql_queries::queries::media_files::create::insert_builder::media_file_insert_builder::MediaFileInsertBuilder;
use mysql_queries::queries::media_files::create::specialized_insert::insert_media_file_from_file_upload::{insert_media_file_from_file_upload, InsertMediaFileFromUploadArgs, UploadType};
use mysql_queries::queries::tts::tts_inference_jobs::mark_tts_inference_job_permanently_dead::mark_tts_inference_job_permanently_dead;
use sqlx::{MySqlConnection, MySqlPool};
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::media_files::MediaFileToken;
use utoipa::ToSchema;
use mysql_queries::queries::generic_inference::web::mark_generic_inference_job_successfully_done_by_token::mark_generic_inference_job_successfully_done_by_token;
#[derive(Deserialize, ToSchema)]
pub struct UpdateGptImageJobStatusRequest {
/// The token of the job we're updating.
pub job_token: InferenceJobToken,
/// How to mark the job.
/// A subset of the job states that are relevant to image completion status.
pub job_status: UpdatedJobStatus,
/// Base64-encoded image data.
/// Only present on success.
/// In the success case, there may be one or more images.
pub images: Option<Vec<String>>,
}
#[derive(Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum UpdatedJobStatus {
Started,
CompleteSuccess,
CompleteFailure,
AttemptFailed,
Dead,
}
#[derive(Serialize, ToSchema)]
pub struct UpdateGptImageJobStatusSuccessResponse {
pub success: bool,
}
#[derive(Debug, ToSchema)]
pub enum UpdateGptImageJobStatusError {
ServerError,
NotFound,
NotAuthorized,
}
impl ResponseError for UpdateGptImageJobStatusError {
fn status_code(&self) -> StatusCode {
match *self {
UpdateGptImageJobStatusError::ServerError => StatusCode::INTERNAL_SERVER_ERROR,
UpdateGptImageJobStatusError::NotFound => StatusCode::NOT_FOUND,
UpdateGptImageJobStatusError::NotAuthorized => StatusCode::UNAUTHORIZED,
}
}
fn error_response(&self) -> HttpResponse {
let error_reason = match self {
Self::ServerError => "server error".to_string(),
Self::NotFound => "not found".to_string(),
Self::NotAuthorized => "unauthorized".to_string(),
};
to_simple_json_error(&error_reason, self.status_code())
}
}
// NB: Not using derive_more::Display since Clion doesn't understand it.
impl fmt::Display for UpdateGptImageJobStatusError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:?}", self)
}
}
/// [INTERNAL ENDPOINT] Update a GPT Image generation job status.
/// This is called by our internal infra, not users. Keep it guarded.
/// We can do secrets-based auth later.
#[utoipa::path(
post,
tag = "Jobs",
path = "/v1/image_studio/update_job_status",
responses(
(status = 200, body = UpdateGptImageJobStatusSuccessResponse),
(status = 500, body = UpdateGptImageJobStatusError),
),
params(
("request" = UpdateGptImageJobStatusRequest, description = "Request"),
)
)]
pub async fn update_gpt_image_job_status_handler(
http_request: HttpRequest,
request: Json<UpdateGptImageJobStatusRequest>,
server_state: web::Data<Arc<ServerState>>) -> Result<Json<UpdateGptImageJobStatusSuccessResponse>, UpdateGptImageJobStatusError>
{
//// TODO(bt,2024-06-16): Reuse connection
//let mut mysql_connection = server_state.mysql_pool.acquire()
// .await
// .map_err(|e| {
// warn!("Could not acquire DB pool: {:?}", e);
// UpdateGptImageJobStatusError::ServerError
// })?;
let inference_job = get_inference_job_status(
&request.job_token,
&server_state.mysql_pool
).await;
let inference_job = match inference_job {
Ok(Some(record)) => record,
Ok(None) => return Err(UpdateGptImageJobStatusError::NotFound),
Err(err) => {
error!("tts job query error: {:?}", err);
return Err(UpdateGptImageJobStatusError::ServerError);
}
};
match inference_job.status {
JobStatusPlus::CompleteSuccess => {
// Job has already been completed. Don't replay the request.
return Ok(Json(UpdateGptImageJobStatusSuccessResponse { success: true }))
},
// TODO: Handle other states as terminal states?
//JobStatusPlus::CompleteFailure => return Err(UpdateGptImageJobStatusError::ServerError),
//JobStatusPlus::AttemptFailed => {}
//JobStatusPlus::Dead => {}
//JobStatusPlus::CancelledByUser => return Err(UpdateGptImageJobStatusError::ServerError),
//JobStatusPlus::CancelledBySystem => {}
_ => {} // Intentional fall through.
}
let target_status = match request.job_status {
UpdatedJobStatus::Started => JobStatusPlus::Started,
UpdatedJobStatus::CompleteSuccess => JobStatusPlus::CompleteSuccess,
UpdatedJobStatus::CompleteFailure => JobStatusPlus::CompleteFailure,
UpdatedJobStatus::AttemptFailed => JobStatusPlus::AttemptFailed,
UpdatedJobStatus::Dead => JobStatusPlus::Dead,
};
if let Some(images) = &request.images {
for image in images.iter() {
let result = upload_and_save_image(
image,
&inference_job,
&server_state.mysql_pool,
&server_state.public_bucket_client,
).await;
let media_token = match result {
Ok(media_token) => media_token,
Err(err) => {
error!("Error uploading image: {:?}", err);
return Err(UpdateGptImageJobStatusError::ServerError);
}
};
}
}
info!("Updating job record for job: {:?}", request.job_token);
update_job_record(&request, &server_state, inference_job, target_status)
.await
.map_err(|err| {
error!("Error updating job: {:?}", err);
UpdateGptImageJobStatusError::ServerError
})?;
info!("Job record updated for job: {:?}", request.job_token);
Ok(Json(UpdateGptImageJobStatusSuccessResponse {
success: true,
}))
}
async fn upload_and_save_image(
base64_image: &str,
inference_job: &GenericInferenceJobStatus,
mysql_pool: &MySqlPool,
public_bucket_client: &BucketClient,
) -> AnyhowResult<MediaFileToken> {
let image_bytes = BASE64_STANDARD.decode(base64_image)?;
// Read file metadata
let file_size_bytes = image_bytes.len();
let file_hash = sha256_hash_bytes(&image_bytes)?;
let image_info = ImageInfo::decode_image_from_bytes(&image_bytes)?;
let mimetype = image_info.mime_type();
// Upload file
const PREFIX : Option<&str> = Some("image_");
let extension_with_period = image_info.file_extension()
.map(|ext| ext.extension_with_period())
.unwrap_or_else(|| ".png");
let media_file_type = match mimetype {
"image/png" => MediaFileType::Png,
"image/jpeg" => MediaFileType::Jpg,
_ => MediaFileType::Png,
};
let bucket_upload_path = MediaFileBucketPath::generate_new(
PREFIX, Some(extension_with_period));
info!("Uploading media to bucket path: {}", bucket_upload_path.get_full_object_path_str());
public_bucket_client.upload_file_with_content_type(
bucket_upload_path.get_full_object_path_str(),
image_bytes.as_ref(),
mimetype
).await?;
let media_token = MediaFileInsertBuilder::new()
.maybe_creator_user(inference_job.user_details.maybe_creator_user_token.as_ref())
.maybe_creator_anonymous_visitor(inference_job.user_details.maybe_creator_anonymous_visitor_token.as_ref())
.creator_ip_address(&inference_job.user_details.creator_ip_address)
.public_bucket_directory_hash(&bucket_upload_path)
.media_file_class(MediaFileClass::Image)
.media_file_type(media_file_type)
.media_file_origin_category(MediaFileOriginCategory::Inference)
.media_file_origin_product_category(MediaFileOriginProductCategory::ImageStudio)
.mime_type(mimetype)
.file_size_bytes(file_size_bytes as u64)
.frame_width(image_info.width())
.frame_height(image_info.height())
.checksum_sha2(&file_hash)
.insert_pool(mysql_pool)
.await?;
Ok(media_token)
}
async fn update_job_record(
request: &Json<UpdateGptImageJobStatusRequest>,
server_state: &Data<Arc<ServerState>>,
inference_job: GenericInferenceJobStatus,
target_status: JobStatusPlus,
) -> AnyhowResult<()> {
match target_status {
JobStatusPlus::Pending => {}
JobStatusPlus::Started => {}
JobStatusPlus::CompleteSuccess => {
info!("Matched job status complete_success");
mark_generic_inference_job_successfully_done_by_token(
&server_state.mysql_pool,
&request.job_token,
None,
None,
None,
None,
).await?;
}
// TODO: Handle terminal states
JobStatusPlus::CompleteFailure => {}
JobStatusPlus::AttemptFailed => {}
JobStatusPlus::Dead => {}
JobStatusPlus::CancelledByUser => {}
JobStatusPlus::CancelledBySystem => {}
}
Ok(())
}
@@ -1,4 +1,5 @@
use crate::http_server::endpoints::image_studio::prompt::enqueue_studio_image_generation_handler::enqueue_studio_image_generation_handler;
use crate::http_server::endpoints::image_studio::update_gpt_image_job_status_handler::update_gpt_image_job_status_handler;
use crate::http_server::endpoints::image_studio::upload::upload_snapshot_media_file_handler::upload_snapshot_media_file_handler;
use actix_http::body::MessageBody;
use actix_service::ServiceFactory;
@@ -25,5 +26,9 @@ where
.route(web::post().to(enqueue_studio_image_generation_handler))
.route(web::head().to(|| HttpResponse::Ok()))
)
.service(web::resource("/update_job_status")
.route(web::post().to(update_gpt_image_job_status_handler))
.route(web::head().to(|| HttpResponse::Ok()))
)
)
}
@@ -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
```

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