Compare commits

...
Author SHA1 Message Date
Michael Chung 48f80f201b Add back button to login and signup pages for 2d navigation 2025-05-01 22:51:55 -04:00
Michael Chung 86c77e9625 Removed 2D canvas button and 3D canvas button go to from the TopBar to prevent web access from the desktop apps. 2025-05-01 22:36:35 -04:00
Michael Chung 776312ef6e When the desktop app is running, the profile dropdown should not be white. 2025-05-01 22:31:42 -04:00
Michael Chung 7da40d5177 Moved all the prompting specific code into a separate file lib/PromptsApi.ts 2025-05-01 19:27:49 -04:00
Brandon Thomas f06b5098e2 remove dead package from package lock 2025-05-01 18:52:29 -04:00
Tanish Manku eac503af46 Use shared lightbox component in 3D 2025-05-01 15:48:59 -07:00
Tanish Manku 16932374f5 Some project config improvements (nx + tsconfig) (#561) 2025-05-01 15:09:46 -07:00
ArEnScandMichael Chung 35940a4a63 Fix lightbox modal referrer policy (#574)
Co-authored-by: Michael Chung <michaelchung@michaels-mbp-2.home>
2025-05-01 18:03:33 -04:00
ArEnScandMichael Chung 55543b3735 Launch editor3d in the background and delete myapp (#573)
Co-authored-by: Michael Chung <michaelchung@michaels-mbp-2.home>
2025-05-01 17:52:31 -04:00
Brandon Thomas 3d44e6e29e "fix cors" 2025-05-01 17:44:39 -04:00
Brandon Thomas 3244b802fa cors headers for vite dev 2025-05-01 17:30:05 -04:00
Brandon Thomas 5e7a5d2cff fixing cors mixed with other refactoring 2025-05-01 17:18:39 -04:00
ArEnScandMichael Chung ecb73e4f80 Gallery fixes using thumbnail template. (#572)
Co-authored-by: Michael Chung <michaelchung@michaels-mbp-2.home>
2025-05-01 16:45:15 -04:00
ArEnScandMichael Chung 39092fa866 Fix image gallery data mapping (#571)
Co-authored-by: Michael Chung <michaelchung@michaels-mbp-2.home>
2025-05-01 16:36:16 -04:00
ArEnScandMichael Chung ca80275d28 Fix image gallery data mapping (#570)
Co-authored-by: Michael Chung <michaelchung@michaels-mbp-2.home>
2025-05-01 16:35:58 -04:00
Brandon Thomas 8c192b17f1 (messy) upload sora media files end-to-end 2025-05-01 16:30:44 -04:00
Brandon Thomas 40da6f6500 (messy) uploading to storyteller backend works, sans creds 2025-05-01 16:30:44 -04:00
ArEnScandMichael Chung 2a3982aa5a Feature/fix desktop 3d run (#569)
Co-authored-by: Michael Chung <michaelchung@michaels-mbp-2.home>
2025-05-01 15:43:12 -04:00
Brandon Thomas a1c2b4f5c8 revert script, add copy for michael 2025-05-01 15:17:32 -04:00
ArEnScandMichael Chung 213d3f7bd2 Feature/fix desktop 2d run (#568)
added a quick runner in 2d editor and 3d editor

---------

Co-authored-by: Michael Chung <michaelchung@michaels-mbp-2.home>
2025-05-01 15:11:23 -04:00
Brandon Thomas 0788e98b54 pass storyteller creds manager 2025-05-01 15:08:31 -04:00
Brandon Thomas 65c8ddfa91 sync creds now 2025-05-01 15:08:31 -04:00
ArEnScandMichael Chung a60e20c0b4 Added run run_desktop_3d (#567)
Co-authored-by: Michael Chung <michaelchung@michaels-mbp-2.home>
2025-05-01 14:16:49 -04:00
Brandon Thomas 54d417f4c5 use vendored tauri-plugin-http from tauri app 2025-05-01 14:02:05 -04:00
Brandon Thomas 98a74e3a96 Begin vendoring in tauri-plugin-http @ 144461bd (see notes)
From fork of plugins here:
https://github.com/tauri-apps/tauri-plugin-http

SHA: 144461bd068300f7436db25c6c7ad952dbefdb21

The original upstream is here:

https://github.com/tauri-apps/plugins-workspace
2025-05-01 13:50:05 -04:00
Michael Chung b537223ffa Fix issue with the 3d editor developer experience kill dev server 2025-05-01 13:47:03 -04:00
Brandon Thomas 8001f24195 fix request proxying 2025-05-01 13:35:04 -04:00
BFlat 778a4e72c8 posthog fix editor 3d? (#566) 2025-05-01 23:54:54 +07:00
bflatastic 083680232c editor2d posthog api key as env variable 2025-05-01 23:29:41 +07:00
Brandon Thomas b1b44ceb60 proxy requests through tauri to avoid 3rd party cookie ban (#565) 2025-05-01 04:04:06 -04:00
Brandon Thomas d988acbf6e document in code 2025-05-01 02:16:21 -04:00
Brandon Thomas 6e05b7a1b5 this seemingly avoids the startup race condition (see details)
There's an issue when the "main window thread" inquires about the
webview cookies shortly after app startup. When it attempts to dump the
cookies within a few milliseconds of app launch, it deadlocks and the
app never launches correctly. The entire webview goes blank and the app
freezes.

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

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

---------

Co-authored-by: Tanish Manku <-h>
2025-04-24 20:40:01 +07:00
Tanish MankuandTanish Manku d3e1d7fab9 Fix api lib (ApiManager) imports (#552)
Co-authored-by: Tanish Manku <-h>
2025-04-24 00:10:55 -07:00
313 changed files with 10760 additions and 4950 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
+144 -14
View File
@@ -840,11 +840,14 @@ version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc2d0cfb2a7388d34f590e76686704c494ed7aaceed62ee1ba35cbf363abc2a5"
dependencies = [
"brotli 3.5.0",
"flate2",
"futures-core",
"memchr",
"pin-project-lite",
"tokio",
"zstd 0.13.1",
"zstd-safe 7.1.0",
]
[[package]]
@@ -1921,7 +1924,7 @@ checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f"
dependencies = [
"byteorder",
"fnv",
"uuid",
"uuid 1.8.0",
]
[[package]]
@@ -2877,6 +2880,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 +3267,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"
@@ -5325,6 +5348,7 @@ dependencies = [
"hyper 1.6.0",
"hyper-util",
"rustls 0.23.13",
"rustls-native-certs",
"rustls-pki-types",
"tokio",
"tokio-rustls",
@@ -5547,7 +5571,7 @@ dependencies = [
name = "idempotency"
version = "0.0.1"
dependencies = [
"uuid",
"uuid 1.8.0",
"workspace-build-acceleration",
]
@@ -5653,8 +5677,10 @@ dependencies = [
name = "images"
version = "0.0.1"
dependencies = [
"anyhow",
"errors",
"image",
"mimetypes",
"testing",
"workspace-build-acceleration",
]
@@ -6768,6 +6794,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 +6903,9 @@ dependencies = [
name = "mimetypes"
version = "0.0.1"
dependencies = [
"infer 0.16.0",
"anyhow",
"infer 0.19.0",
"testing",
"workspace-build-acceleration",
]
@@ -7793,7 +7830,7 @@ dependencies = [
"toml 0.8.20",
"ureq",
"url",
"uuid",
"uuid 1.8.0",
"walkdir",
]
@@ -7844,12 +7881,14 @@ dependencies = [
"anyhow",
"base64 0.22.1",
"chrono",
"cookie 0.18.1",
"crockford",
"errors",
"filesys",
"hex-literal",
"idempotency",
"log",
"once_cell",
"rand 0.9.0",
"reqwest 0.12.15",
"serde",
@@ -9618,6 +9657,7 @@ dependencies = [
"pin-project-lite",
"quinn",
"rustls 0.23.13",
"rustls-native-certs",
"rustls-pemfile 2.1.2",
"rustls-pki-types",
"serde",
@@ -9628,6 +9668,7 @@ dependencies = [
"tokio",
"tokio-native-tls",
"tokio-rustls",
"tokio-socks",
"tokio-util",
"tower 0.5.2",
"tower-service",
@@ -9698,7 +9739,7 @@ dependencies = [
"rkyv_derive",
"seahash",
"tinyvec",
"uuid",
"uuid 1.8.0",
]
[[package]]
@@ -9955,6 +9996,19 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rustls-native-certs"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fcaf18a4f2be7326cd874a5fa579fae794320a0f388d365dca7e480e55f83f8a"
dependencies = [
"openssl-probe",
"rustls-pemfile 2.1.2",
"rustls-pki-types",
"schannel",
"security-framework",
]
[[package]]
name = "rustls-pemfile"
version = "1.0.4"
@@ -10099,7 +10153,7 @@ dependencies = [
"serde",
"serde_json",
"url",
"uuid",
"uuid 1.8.0",
]
[[package]]
@@ -10633,6 +10687,7 @@ dependencies = [
"log",
"openai_sora_client",
"r2d2_redis",
"reqwest 0.12.15",
"workspace-build-acceleration",
]
@@ -11188,6 +11243,7 @@ dependencies = [
"hostname 0.4.0",
"http_server_common",
"idempotency",
"images",
"infer 0.16.0",
"language-tags",
"lexical-sort",
@@ -11245,7 +11301,7 @@ dependencies = [
"user_traits_component",
"utoipa",
"utoipa-swagger-ui",
"uuid",
"uuid 1.8.0",
"videos",
"workspace-build-acceleration",
"zip 0.6.6",
@@ -11258,9 +11314,11 @@ dependencies = [
"anyhow",
"backoff",
"chrono",
"cookie 0.18.1",
"enums",
"enums_public",
"errors",
"idempotency",
"log",
"reqwest 0.12.15",
"serde",
@@ -11269,6 +11327,7 @@ dependencies = [
"tokens",
"tokio",
"url",
"uuid 1.8.0",
"workspace-build-acceleration",
]
@@ -12039,13 +12098,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",
@@ -12060,6 +12122,7 @@ dependencies = [
"subprocess_common",
"tauri",
"tauri-build",
"tauri-plugin-http",
"tauri-plugin-log",
"tempdir",
"tempfile",
@@ -12114,7 +12177,7 @@ dependencies = [
"thiserror 2.0.12",
"time",
"url",
"uuid",
"uuid 1.8.0",
"walkdir",
]
@@ -12134,9 +12197,9 @@ dependencies = [
[[package]]
name = "tauri-plugin"
version = "2.1.1"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4190775d6ff73fe66d9af44c012739a2659720efd9c0e1e56a918678038699d"
checksum = "37a5ebe6a610d1b78a94650896e6f7c9796323f408800cef436e0fa0539de601"
dependencies = [
"anyhow",
"glob",
@@ -12149,6 +12212,52 @@ 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"
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",
"tracing",
"url",
"urlpattern",
]
[[package]]
name = "tauri-plugin-log"
version = "2.3.1"
@@ -12261,9 +12370,9 @@ dependencies = [
[[package]]
name = "tauri-utils"
version = "2.3.1"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f037e66c7638cc0a2213f61566932b9a06882b8346486579c90e4b019bac447"
checksum = "b2900399c239a471bcff7f15c4399eb1a8c4fe511ba2853e07c996d771a5e0a4"
dependencies = [
"anyhow",
"brotli 7.0.0",
@@ -12293,7 +12402,7 @@ dependencies = [
"toml 0.8.20",
"url",
"urlpattern",
"uuid",
"uuid 1.8.0",
"walkdir",
]
@@ -12746,6 +12855,18 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-socks"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d4770b8024672c1101b3f6733eab95b18007dbe0847a8afe341fcf79e06043f"
dependencies = [
"either",
"futures-util",
"thiserror 1.0.63",
"tokio",
]
[[package]]
name = "tokio-stream"
version = "0.1.16"
@@ -13537,6 +13658,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"
@@ -14698,7 +14828,7 @@ dependencies = [
"unicode-normalization",
"ureq",
"url",
"uuid",
"uuid 1.8.0",
"void",
"winapi 0.3.9",
"windows-sys 0.48.0",
+13
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",
@@ -117,6 +118,7 @@ members = [
"crates/vendor/candle/candle-transformers",
"crates/vendor/candle/tensor-tools",
"crates/vendor/limitation",
"crates/vendor/tauri-plugin-http",
# NB(bt,2023-12-12): Newrelic keeps breaking with upgrades. Kill for now.
#"crates/vendor/newrelic-telemetry-sdk-rust",
@@ -147,6 +149,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
@@ -155,7 +161,9 @@ workspace-build-acceleration = { path = "crates/workspace-build-acceleration" }
anyhow = "1.0.97"
base64 = "0.22.1"
chrono = { version = "0.4.40", features = ["serde"] }
cookie = "0.18.1"
hex-literal = "1.0.0"
indexmap = "2.9.0"
log = "0.4.27"
once_cell = "1.21.3"
rand = "0.9.0"
@@ -166,6 +174,7 @@ serde_derive = "1.0.219"
serde_json = "1.0.140"
serde_yaml = "0.9.1"
sha3 = "0.10.8"
tauri = { version = "2.4.1", features = ["unstable"] }
thiserror = "2.0.12"
toml = "0.8.20"
url = { version = "2.5.4", features = ["serde"] }
@@ -176,7 +185,11 @@ enums = { path = "crates/_types/enums" }
enums_public = { path = "crates/_types/enums_public" }
errors = { path = "crates/lib/errors" }
filesys = { path = "crates/lib/files/filesys" }
idempotency = { path = "crates/lib/idempotency" }
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));
+12 -5
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,29 @@ 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 = { workspace = true }
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: We need to vendor this in so that we can access the cookies inside via the private API
tauri-plugin-http = { path = "../../vendor/tauri-plugin-http" }
# 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;
@@ -8,10 +8,10 @@ use image::codecs::png::{CompressionType, FilterType, PngEncoder};
use image::{DynamicImage, ImageReader};
use log::{error, info};
use openai_sora_client::credentials::SoraCredentials;
use openai_sora_client::creds::credential_migration::CredentialMigrationRef;
use openai_sora_client::requests::image_gen::common::{ImageSize, NumImages};
use openai_sora_client::requests::image_gen::sora_image_gen_remix::{sora_image_gen_remix, SoraImageGenRemixRequest};
use openai_sora_client::requests::upload::upload_media_from_bytes::sora_media_upload_from_bytes;
use openai_sora_client::requests::upload::upload_media_from_file::SoraMediaUploadRequest;
use std::fs::read_to_string;
use std::io::Cursor;
use tauri::{AppHandle, Manager, State};
@@ -43,14 +43,12 @@ pub async fn generate_image(
sora_creds_holder: &SoraCredentialHolder,
) -> AnyhowResult<()> {
let sora_credentials = read_sora_credentials_from_disk(app_data_root)
let creds = read_sora_credentials_from_disk(app_data_root)
.map_err(|err| {
error!("Failed to read Sora credentials from disk: {:?}", err);
err
})?;
//let sora_credentials = sora_creds_holder.get_credentials()?;
let mut sora_media_tokens = vec![];
if let Some(image) = maybe_image {
@@ -58,7 +56,7 @@ pub async fn generate_image(
let filename = "image.png".to_string();
let response = sora_media_upload_from_bytes(image_bytes, filename, &sora_credentials)
let response = sora_media_upload_from_bytes(image_bytes, filename, CredentialMigrationRef::New(&creds))
.await
.map_err(|err| {
error!("Failed to upload image to Sora: {:?}", err);
@@ -73,7 +71,7 @@ pub async fn generate_image(
num_images: NumImages::One,
image_size: ImageSize::Square,
sora_media_tokens: sora_media_tokens.clone(),
credentials: &sora_credentials,
credentials: CredentialMigrationRef::New(&creds),
}).await
.map_err(|err| {
error!("Failed to call Sora image generation: {:?}", err);
@@ -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;
@@ -11,11 +13,14 @@ use image::codecs::png::{CompressionType, FilterType, PngEncoder};
use image::{DynamicImage, ImageReader};
use log::{debug, error, info};
use openai_sora_client::credentials::SoraCredentials;
use openai_sora_client::creds::credential_migration::CredentialMigrationRef;
use openai_sora_client::recipes::image_remix_with_session_auto_renew::{image_remix_with_session_auto_renew, ImageRemixAutoRenewRequest};
use openai_sora_client::recipes::image_upload_from_file_with_session_auto_renew::{image_upload_from_file_with_session_auto_renew, ImageUploadFromFileAutoRenewRequest};
use openai_sora_client::recipes::maybe_upgrade_or_renew_session::maybe_upgrade_or_renew_session;
use openai_sora_client::requests::image_gen::common::{ImageSize, NumImages};
use openai_sora_client::requests::image_gen::sora_image_gen_remix::{sora_image_gen_remix, SoraImageGenRemixRequest};
use openai_sora_client::requests::sentinel_refresh::refresh_sentinel;
use openai_sora_client::requests::upload::upload_media_from_bytes::sora_media_upload_from_bytes;
use openai_sora_client::requests::upload::upload_media_from_file::{sora_media_upload_from_file, SoraMediaUploadRequest};
use openai_sora_client::requests::upload::upload_media_from_file::sora_media_upload_from_file;
use serde_derive::Deserialize;
use std::fs::read_to_string;
use std::io::Cursor;
@@ -47,10 +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...");
generate_image(request, &app_data_root, &sora_creds_manager)
// TODO(bt,2025-04-24): Better error messages to caller
generate_image(request, &app_data_root, &sora_creds_manager, &sora_task_queue)
.await
.map_err(|err| {
error!("error: {:?}", err);
@@ -64,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?;
@@ -80,78 +89,63 @@ pub async fn generate_image(
let files_to_upload = vec![filename];
// TODO(bt,2025-04-21): Read from in-memory cache instead, but allow for desktop replacement.
let mut sora_creds = read_sora_credentials_from_disk(app_data_root)
.map_err(|err| {
error!("Failed to read Sora credentials from disk: {:?}", err);
err
})?;
let mut creds = sora_creds_manager.get_credentials_required()?;
let no_sentinel = !app_data_root.get_sora_sentinel_file_path().is_file()
|| sora_creds.sentinel.is_none();
let credential_updated = maybe_upgrade_or_renew_session(&mut creds)
.await
.map_err(|err| {
error!("Failed to upgrade or renew session: {:?}", err);
err
})?;
if no_sentinel {
info!("Refreshing credentials...");
sora_creds = sora_creds_manager.call_sentinel_refresh()
.await
.map_err(|err| {
error!("Failed to refresh: {:?}", err);
err
})?;
// TODO: Write to disk.
if credential_updated {
info!("Storing updated credentials");
sora_creds_manager.set_credentials(&creds)?;
}
let mut sora_media_tokens = Vec::with_capacity(files_to_upload.len());
for file_path in files_to_upload {
let sora_upload_response = sora_media_upload_from_file(file_path, &sora_creds)
.await?;
for (i, file_path) in files_to_upload.iter().enumerate() {
info!("Uploading image {} of {}...", (i+1), files_to_upload.len());
sora_media_tokens.push(sora_upload_response.id);
let (response, maybe_new_credentials) =
image_upload_from_file_with_session_auto_renew(ImageUploadFromFileAutoRenewRequest {
file_path,
credentials: &creds,
}).await?;
if let Some(new_creds) = maybe_new_credentials {
info!("Storing updated credentials.");
sora_creds_manager.set_credentials(&new_creds)?;
creds = new_creds;
}
sora_media_tokens.push(response.id);
}
info!("Calling image generation...");
// TODO(bt,2025-04-21): Download media tokens.
// Note: This is incredibly inefficient. We should keep a local cache.
// Also, if they've already been uploaded to OpenAI, we shouldn't continue to re-upload.
let mut response = sora_image_gen_remix(SoraImageGenRemixRequest {
prompt: request.prompt.to_string(),
num_images: NumImages::One,
image_size: ImageSize::Square,
sora_media_tokens: sora_media_tokens.clone(),
credentials: &sora_creds,
}).await;
let (response, maybe_new_creds) =
image_remix_with_session_auto_renew(ImageRemixAutoRenewRequest {
prompt: request.prompt.to_string(),
num_images: NumImages::One,
image_size: ImageSize::Square,
sora_media_tokens: sora_media_tokens.clone(),
credentials: &creds,
}).await?;
if let Err(err) = &response {
error!("Error in generating image: {:?}", err);
sora_creds = sora_creds_manager.call_sentinel_refresh()
.await
.map_err(|err| {
error!("Failed to refresh: {:?}", err);
err
})?;
info!("Retrying request...");
response = sora_image_gen_remix(SoraImageGenRemixRequest {
prompt: request.prompt.to_string(),
num_images: NumImages::One,
image_size: ImageSize::Square,
sora_media_tokens: sora_media_tokens.clone(),
credentials: &sora_creds,
}).await;
if let Some(new_creds) = maybe_new_creds {
info!("Storing updated credentials.");
sora_creds_manager.set_credentials(&new_creds)?;
}
let response = response
.map_err(|err| {
error!("Failed to call Sora image generation: {:?}", err);
err
})?;
info!("New Sora Task ID: {:?} ", response.task_id);
println!(">> TASK ID: {:?} ", response.task_id);
sora_task_queue.insert(&response.task_id)?;
Ok(())
}
+51 -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,42 @@ 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();
let storyteller_creds_manager_3 = storyteller_creds_manager.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, storyteller_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,3 +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,36 +1,48 @@
use std::fs::read_to_string;
use crate::state::data_dir::app_data_root::AppDataRoot;
use anyhow::anyhow;
use errors::AnyhowResult;
use openai_sora_client::credentials::SoraCredentials;
use crate::state::app_dir::AppDataRoot;
use openai_sora_client::creds::sora_cookies::SoraCookies;
use openai_sora_client::creds::sora_credential_set::SoraCredentialSet;
use openai_sora_client::creds::sora_jwt_bearer_token::SoraJwtBearerToken;
use openai_sora_client::creds::sora_sentinel::SoraSentinel;
use std::fs::read_to_string;
pub fn read_sora_credentials_from_disk(app_data_root: &AppDataRoot) -> AnyhowResult<SoraCredentials> {
pub fn read_sora_credentials_from_disk(app_data_root: &AppDataRoot) -> AnyhowResult<SoraCredentialSet> {
let cookie_file = app_data_root.get_sora_cookie_file_path();
let bearer_file = app_data_root.get_sora_bearer_token_file_path();
let sentinel_file = app_data_root.get_sora_sentinel_file_path();
let cookie = read_to_string(cookie_file)?
if !cookie_file.exists() {
return Err(anyhow!("Cookie file does not exist: {:?}", cookie_file));
}
let value = read_to_string(cookie_file)?
.trim()
.to_string();
let bearer = read_to_string(bearer_file)?
.trim()
.to_string();
let cookie = SoraCookies::new(value);
if sentinel_file.exists() && sentinel_file.is_file() {
let sentinel = read_to_string(sentinel_file)?
let mut bearer = None;
let mut sentinel = None;
if bearer_file.exists() {
let value = read_to_string(&bearer_file)?
.trim()
.to_string();
bearer = Some(SoraJwtBearerToken::new(value)?);
}
if sentinel_file.exists() {
let value = read_to_string(&sentinel_file)?
.trim()
.to_string();
return Ok(SoraCredentials {
bearer_token: bearer,
cookie,
sentinel: Some(sentinel),
});
sentinel = Some(SoraSentinel::new(value));
}
Ok(SoraCredentials {
bearer_token: bearer,
Ok(SoraCredentialSet::initialize(
cookie,
sentinel: None,
})
bearer,
sentinel,
))
}
@@ -0,0 +1,92 @@
use crate::state::data_dir::app_data_root::AppDataRoot;
use anyhow::anyhow;
use errors::AnyhowResult;
use log::{info, warn};
use openai_sora_client::creds::sora_cookies::SoraCookies;
use openai_sora_client::creds::sora_credential_set::SoraCredentialSet;
use openai_sora_client::creds::sora_jwt_bearer_token::SoraJwtBearerToken;
use openai_sora_client::creds::sora_sentinel::SoraSentinel;
use openai_sora_client::recipes::maybe_upgrade_or_renew_session::maybe_upgrade_or_renew_session;
use std::fs::{read_to_string, OpenOptions};
use std::io::Write;
pub async fn read_sora_credentials_from_disk_with_upgrade(app_data_root: &AppDataRoot) -> AnyhowResult<SoraCredentialSet> {
let cookie_file = app_data_root.get_sora_cookie_file_path();
let bearer_file = app_data_root.get_sora_bearer_token_file_path();
let sentinel_file = app_data_root.get_sora_sentinel_file_path();
if !cookie_file.exists() {
return Err(anyhow!("Cookie file does not exist: {:?}", cookie_file));
}
let value = read_to_string(cookie_file)?
.trim()
.to_string();
let cookie = SoraCookies::new(value);
let mut bearer = None;
let mut sentinel = None;
if bearer_file.exists() {
let value = read_to_string(&bearer_file)?
.trim()
.to_string();
bearer = Some(SoraJwtBearerToken::new(value)?);
}
if sentinel_file.exists() {
let value = read_to_string(&sentinel_file)?
.trim()
.to_string();
sentinel = Some(SoraSentinel::new(value));
}
let mut credentials = SoraCredentialSet::initialize(
cookie,
bearer,
sentinel,
);
let response = maybe_upgrade_or_renew_session(&mut credentials).await;
if let Err(err) = response {
// NB: Make this portion infallible. Don't die on setup. We can recover downstream.
warn!("Failed to upgrade or renew session: {:?}", err);
}
if !bearer_file.exists() {
if let Some(bearer) = &credentials.jwt_bearer_token {
info!("Persisting bearer token value...");
let value = bearer.token_str();
let mut file = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&bearer_file)?;
file.write_all(value.as_bytes())?;
file.flush()?;
}
}
if !sentinel_file.exists() {
if let Some(sentinel) = &credentials.sora_sentinel {
info!("Persisting sentinel value...");
let value = sentinel.get_sentinel();
let mut file = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&sentinel_file)?;
file.write_all(value.as_bytes())?;
file.flush()?;
}
}
Ok(credentials)
}
@@ -1,23 +1,22 @@
use std::ops::Deref;
use std::sync::{Arc, RwLock};
use anyhow::anyhow;
use errors::AnyhowResult;
use openai_sora_client::credentials::SoraCredentials;
use openai_sora_client::creds::sora_credential_set::SoraCredentialSet;
use std::sync::{Arc, RwLock};
/// Hold credentials for the application.
#[derive(Clone)]
pub struct SoraCredentialHolder {
credentials: Arc<RwLock<Option<SoraCredentials>>>
credentials: Arc<RwLock<Option<SoraCredentialSet>>>,
}
impl SoraCredentialHolder {
pub fn new() -> Self {
Self {
credentials: Arc::new(RwLock::new(None))
credentials: Arc::new(RwLock::new(None)),
}
}
pub fn set_credentials(&self, credentials: &SoraCredentials) -> AnyhowResult<()> {
pub fn set_credentials(&self, credentials: &SoraCredentialSet) -> AnyhowResult<()> {
match self.credentials.write() {
Err(err) => Err(anyhow!("Failed to acquire write lock: {:?}", err)),
Ok(mut creds) => {
@@ -37,7 +36,7 @@ impl SoraCredentialHolder {
}
}
pub fn get_credentials(&self) -> AnyhowResult<Option<SoraCredentials>> {
pub fn get_credentials(&self) -> AnyhowResult<Option<SoraCredentialSet>> {
match self.credentials.read() {
Err(err) => Err(anyhow!("Failed to acquire read lock: {:?}", err)),
Ok(creds) => {
@@ -46,10 +45,10 @@ impl SoraCredentialHolder {
}
}
pub fn get_credentials_required(&self) -> AnyhowResult<SoraCredentials> {
pub fn get_credentials_required(&self) -> AnyhowResult<SoraCredentialSet> {
match self.credentials.read() {
Err(err) => Err(anyhow!("Failed to acquire read lock: {:?}", err)),
Ok(creds) => match creds.deref() {
Ok(creds) => match &*creds {
None => Err(anyhow!("Credentials not set")),
Some(creds) => Ok(creds.clone()),
}
@@ -1,14 +1,16 @@
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;
use errors::AnyhowResult;
use log::{error, info, warn};
use openai_sora_client::credentials::SoraCredentials;
use openai_sora_client::creds::sora_credential_set::SoraCredentialSet;
use openai_sora_client::creds::sora_sentinel::SoraSentinel;
use openai_sora_client::requests::sentinel_refresh::generate::token::generate_token;
use openai_sora_client::requests::sentinel_refresh::refresh_sentinel;
use std::fs::OpenOptions;
use std::io::Write;
use openai_sora_client::creds::sora_cookies::SoraCookies;
use openai_sora_client::creds::sora_jwt_bearer_token::SoraJwtBearerToken;
#[derive(Clone)]
pub struct SoraCredentialManager {
@@ -34,30 +36,50 @@ impl SoraCredentialManager {
}
}
pub fn set_credentials(&self, creds: &SoraCredentials) -> AnyhowResult<()> {
pub fn set_credentials(&self, creds: &SoraCredentialSet) -> AnyhowResult<()> {
self.holder.set_credentials(creds)
}
pub fn clear_credentials(&self) -> AnyhowResult<()> {
self.holder.clear_credentials()
self.holder.clear_credentials()?;
Ok(())
}
pub fn get_credentials(&self) -> AnyhowResult<Option<SoraCredentials>> {
pub fn get_credentials(&self) -> AnyhowResult<Option<SoraCredentialSet>> {
self.holder.get_credentials()
}
pub fn get_credentials_required(&self) -> AnyhowResult<SoraCredentials> {
pub fn get_credentials_required(&self) -> AnyhowResult<SoraCredentialSet> {
self.holder.get_credentials_required()
}
pub fn reset_from_disk(&self) -> AnyhowResult<SoraCredentials> {
pub fn reset_from_disk(&self) -> AnyhowResult<SoraCredentialSet> {
let creds = read_sora_credentials_from_disk(&self.app_data_root)?;
self.holder.set_credentials(&creds)?;
Ok(creds)
}
pub fn persist_all_to_disk(&self) -> AnyhowResult<()> {
let credentials = self.holder.get_credentials_required()?;
info!("Persisting cookies to disk...");
persist_cookies_to_disk(&credentials.cookies, &self.app_data_root)?;
if let Some(bearer) = &credentials.jwt_bearer_token {
info!("Persisting JWT to disk...");
persist_jwt_bearer_to_disk(bearer, &self.app_data_root)?;
}
if let Some(sentinel) = &credentials.sora_sentinel {
info!("Persisting sentinel to disk...");
persist_sentinel_to_disk(sentinel, &self.app_data_root)?;
}
Ok(())
}
/// Refresh the sentinel token from Sora's API
pub async fn call_sentinel_refresh(&self) -> AnyhowResult<SoraCredentials> {
pub async fn call_sentinel_refresh(&self) -> AnyhowResult<SoraCredentialSet> {
// NB(bt,2025-04-21): Technically we don't need credentials to get a sentinel.
let mut creds = self.holder.get_credentials_required()?;
@@ -72,7 +94,7 @@ impl SoraCredentialManager {
info!("Token obtained.");
creds.sentinel = Some(sentinel.clone());
creds.sora_sentinel = Some(SoraSentinel::new(sentinel.clone()));
self.holder.set_credentials(&creds)?;
@@ -88,3 +110,48 @@ impl SoraCredentialManager {
Ok(creds)
}
}
fn persist_cookies_to_disk(cookies: &SoraCookies, app_data_root: &AppDataRoot) -> AnyhowResult<()> {
let filename = app_data_root.get_sora_cookie_file_path();
let mut file = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(filename)?;
file.write_all(cookies.as_bytes())?;
file.flush()?;
Ok(())
}
fn persist_jwt_bearer_to_disk(bearer: &SoraJwtBearerToken, app_data_root: &AppDataRoot) -> AnyhowResult<()> {
let filename = app_data_root.get_sora_bearer_token_file_path();
let mut file = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(filename)?;
file.write_all(bearer.as_bytes())?;
file.flush()?;
Ok(())
}
fn persist_sentinel_to_disk(sentinel: &SoraSentinel, app_data_root: &AppDataRoot) -> AnyhowResult<()> {
let filename = app_data_root.get_sora_sentinel_file_path();
let mut file = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(filename)?;
file.write_all(sentinel.as_bytes())?;
file.flush()?;
Ok(())
}
@@ -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,62 @@
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(
&app,
&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(
app: &AppHandle,
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(app, 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,114 @@
use crate::state::app_startup_time::AppStartupTime;
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 chrono::TimeDelta;
use errors::AnyhowResult;
use log::{debug, error, info};
use once_cell::sync::Lazy;
use reqwest::Url;
use std::fs;
use std::sync::LockResult;
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 tauri_plugin_http::reqwest_cookie_store::CookieStore;
use tauri_plugin_http::Http;
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(
app: &AppHandle,
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(())
//}
let maybe_http = app.try_state::<Http>();
let http = match maybe_http {
None => {
error!("No HTTP plugin found");
return Err(anyhow!("No HTTP plugin found"));
}
Some(http) => http,
};
match http.cookies_jar.store.lock() {
Err(err) => {
error!("Failed to lock cookie jar: {:?}", err);
return Err(anyhow!("Failed to lock cookie jar"));
}
Ok(cookie_jar) => {
sync_tauri_credentials(&cookie_jar, storyteller_credential_manager)?;
}
}
Ok(())
}
fn sync_tauri_credentials(
cookie_store: &CookieStore,
storyteller_credential_manager: &StorytellerCredentialManager,
) -> AnyhowResult<()> {
let current_http_plugin_credentials = get_credentials_from_cookie_store(cookie_store)?;
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_http_plugin_credentials) {
replace_credentials = false;
}
}
if replace_credentials {
info!("Syncing ArtCraft credentials ...");
storyteller_credential_manager.set_credentials(&current_http_plugin_credentials)?;
// NB: tauri-plugin-http stores the credentials on disk, so we can defer to that for now.
//storyteller_credential_manager.persist_all_to_disk()?;
}
Ok(())
}
pub fn get_credentials_from_cookie_store(cookie_jar: &CookieStore) -> AnyhowResult<StorytellerCredentialSet> {
let mut avt_cookie = None;
let mut session_cookie = None;
for cookie in cookie_jar.iter_unexpired() {
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,15 +1,21 @@
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};
use log::{error, info, warn};
use once_cell::sync::Lazy;
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::OpenOptions;
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";
@@ -34,7 +40,11 @@ pub async fn sora_session_login_thread(
loop {
for (window_name, webview) in app.webviews() {
if window_name == LOGIN_WINDOW_NAME {
let result = check_login_window(&webview, &app_data_root).await;
let result = check_login_window(
&webview,
&app_data_root,
&sora_creds_manager,
).await;
if let Err(err) = result {
error!("Error checking login window: {:?}", err);
}
@@ -45,10 +55,14 @@ pub async fn sora_session_login_thread(
}
}
async fn check_login_window(webview: &Webview, app_data_root: &AppDataRoot) -> AnyhowResult<()> {
async fn check_login_window(
webview: &Webview,
app_data_root: &AppDataRoot,
sora_credential_manager: &SoraCredentialManager,
) -> AnyhowResult<()> {
clear_browsing_data_on_test_domain(webview)?;
//keep_on_task(webview)?;
extract_cookies_to_file(webview, app_data_root)?;
extract_cookies_to_file(webview, app_data_root, sora_credential_manager).await?;
//initialize_sora_jwt_bearer_token(app_data_root).await?; // TODO: This only runs once. We need better management.
Ok(())
}
@@ -92,19 +106,67 @@ fn clear_browsing_data_on_test_domain(webview: &Webview) -> AnyhowResult<()> {
}
// TODO(bt,2025-04-07): Heuristic to detect when logged in. Only write when logged in.
fn extract_cookies_to_file(webview: &Webview, app_data_root: &AppDataRoot) -> AnyhowResult<()> {
let cookies = get_all_sora_cookies_as_string(webview)?;
async fn extract_cookies_to_file(
webview: &Webview,
app_data_root: &AppDataRoot,
sora_credential_manager: &SoraCredentialManager,
) -> AnyhowResult<()> {
let new_cookies = get_all_sora_cookies_as_string(webview)?.trim().to_string();
let cookie_file = app_data_root.get_sora_cookie_file_path();
if new_cookies.is_empty() {
return Ok(());
}
let mut file = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(cookie_file)?;
let session_cookie_presence = has_session_cookie(&new_cookies)
.unwrap_or_else(|err| {
error!("Failed to check for session cookie: {:?}", err);
SessionCookiePresence::MaybePresent
});
file.write_all(cookies.as_bytes())?;
file.flush()?;
info!("Session cookies are: {:?}", session_cookie_presence);
let (should_write_cookies, should_upgrade_session) = match session_cookie_presence {
SessionCookiePresence::Present => (true, true),
SessionCookiePresence::MaybePresent => (true, false),
SessionCookiePresence::Absent => (false, false),
};
if !should_write_cookies {
return Ok(());
}
// TODO(bt): Race conditions ahead.
sora_credential_manager.clear_credentials()?;
let _r = fs::remove_file(app_data_root.get_sora_cookie_file_path());
let _r = fs::remove_file(app_data_root.get_sora_bearer_token_file_path());
let _r = fs::remove_file(app_data_root.get_sora_sentinel_file_path());
let mut new_credentials =
SoraCredentialSet::initialize_with_just_cookies_str(&new_cookies);
if should_upgrade_session {
let _upgraded = maybe_upgrade_or_renew_session(&mut new_credentials).await?;
}
sora_credential_manager.set_credentials(&new_credentials)?;
sora_credential_manager.persist_all_to_disk()?;
Ok(())
}
pub fn read_sora_cookies_from_disk(app_data_root: &AppDataRoot) -> Option<String> {
let cookie_file = app_data_root.get_sora_cookie_file_path();
if !cookie_file.exists() {
return None;
}
match read_to_string(cookie_file) {
Ok(contents) => Some(contents.trim().to_string()),
Err(err) => {
warn!("Failed to read cookie file: {:?}", err);
None
}
}
}
@@ -0,0 +1,142 @@
use crate::state::data_dir::app_data_root::AppDataRoot;
use crate::state::data_dir::trait_data_subdir::DataSubdir;
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 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::{Generation, TaskId, TaskStatus};
use reqwest::Url;
use std::fs::File;
use std::io::Write;
use std::path::PathBuf;
use tauri::AppHandle;
use tempdir::TempDir;
use storyteller_client::media_files::upload_image_media_file_from_file::upload_image_media_file_from_file;
use storyteller_client::utils::api_host::ApiHost;
pub async fn sora_task_polling_thread(
app_data_root: AppDataRoot,
sora_creds_manager: SoraCredentialManager,
storyteller_creds_manager: StorytellerCredentialManager,
sora_task_queue: SoraTaskQueue,
) -> ! {
loop {
let res = polling_loop(
&sora_creds_manager,
&storyteller_creds_manager,
&sora_task_queue,
&app_data_root,
).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,
storyteller_creds_manager: &StorytellerCredentialManager,
sora_task_queue: &SoraTaskQueue,
app_data_root: &AppDataRoot,
) -> 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)?;
let creds = storyteller_creds_manager.get_credentials_required()?;
let api_host = ApiHost::Storyteller;
for task in succeeded_tasks.iter() {
if !sora_task_queue.contains_key(&task.id)? {
continue;
}
info!("Task succeeded: {:?}", task.id);
for (i, generation) in task.generations.iter().enumerate() {
info!("Downloading generated file...");
let download_path = download_generation(generation, &app_data_root).await?;
info!("Uploading to backend...");
let result = upload_image_media_file_from_file(&api_host, Some(&creds), download_path).await?;
info!("Uploaded to API backend: {:?}", result.media_file_token);
}
}
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;
}
}
async fn download_generation(generation: &Generation, app_data_root: &AppDataRoot) -> AnyhowResult<PathBuf> {
let url = Url::parse(&generation.url)?;
let response = reqwest::get(&generation.url).await?;
let image_bytes = response.bytes().await?;
let ext = url.path().split(".").last().unwrap_or("png");
let tempdir = app_data_root.temp_dir().path();
let download_filename = format!("{}.{}", generation.id, ext);
let download_path = tempdir.join(download_filename);
let mut file = File::create(&download_path)?;
file.write_all(&image_bytes)?;
Ok(download_path)
}
@@ -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;
@@ -1,5 +1,5 @@
# For some reason, we can't pass json files to the tauri dev command
[build]
frontendDist = "../../../frontend/apps/editor2d/dist"
beforeDevCommand = "nx run editor2d:dev"
beforeDevCommand = "npx nx run editor2d:dev"
devUrl = "http://localhost:5741"
@@ -0,0 +1,5 @@
# For some reason, we can't pass json files to the tauri dev command
[build]
frontendDist = "../../../frontend/apps/editor2d/dist"
beforeDevCommand = "echo 'no dev'"
devUrl = "http://localhost:5741"
@@ -1,5 +1,5 @@
# 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 = "npx nx run editor3d:dev"
devUrl = "http://localhost:5173"
@@ -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"
@@ -20,7 +20,11 @@
}
],
"security": {
"csp": null
"csp": null,
"headers": {
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Embedder-Policy": "require-corp"
}
}
},
"bundle": {
@@ -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"
]
}
}
@@ -19,24 +19,26 @@ workspace-build-acceleration = { workspace = true }
# Internal
crockford = { path = "../../data/crockford" }
errors = { path = "../../errors" }
subprocess_common = { path = "../../subprocess_common" }
idempotency = { path = "../../idempotency" }
subprocess_common = { path = "../../subprocess_common" }
# External
anyhow = { workspace = true }
base64 = { workspace = true }
chrono = { workspace = true}
cookie = { workspace = true }
hex-literal = { workspace = true }
log = { workspace = true }
once_cell= { workspace = true }
rand = { workspace = true }
reqwest = { workspace = true, features = ["multipart", "gzip"] }
serde = { workspace = true }
serde_derive = { workspace = true }
serde_json = { workspace = true }
sha3 = { workspace = true }
hex-literal = { workspace = true }
base64 = { workspace = true }
thiserror = { workspace = true }
tokio = { version = "1.38.0", features = ["macros"] }
toml = { workspace = true }
thiserror = { workspace = true }
chrono = { workspace = true}
rand = { workspace = true }
[dev-dependencies]
testing = { path = "../../testing" }
@@ -7,13 +7,18 @@ use errors::AnyhowResult;
pub const USER_AGENT : &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36";
#[derive(Clone, Deserialize)]
#[deprecated(note="Use `SoraCredentialSet` instead as it has auto-healing behavior and doesn't require a bearer token initially.")]
pub struct SoraCredentials {
#[deprecated(note="Use `SoraCredentialSet` instead as it has auto-healing behavior and doesn't require a bearer token initially.")]
pub bearer_token: String,
#[deprecated(note="Use `SoraCredentialSet` instead as it has auto-healing behavior and doesn't require a bearer token initially.")]
pub cookie: String,
#[deprecated(note="Use `SoraCredentialSet` instead as it has auto-healing behavior and doesn't require a bearer token initially.")]
pub sentinel: Option<String>,
}
impl SoraCredentials {
#[deprecated(note="Use `SoraCredentialSet` instead as it has auto-healing behavior and doesn't require a bearer token initially.")]
pub fn authorization_header_value(&self) -> String {
match self.bearer_token.get(0..6) {
Some("bearer") | Some("Bearer") => self.bearer_token.clone(),
@@ -21,6 +26,7 @@ impl SoraCredentials {
}
}
#[deprecated(note="Use `SoraCredentialSet` instead as it has auto-healing behavior and doesn't require a bearer token initially.")]
pub fn from_env() -> AnyhowResult<Self> {
let bearer = std::env::var("SORA_BEARER_TOKEN")?;
let cookie = std::env::var("SORA_COOKIE")?;
@@ -29,12 +35,14 @@ impl SoraCredentials {
Ok(Self { bearer_token: bearer, cookie, sentinel })
}
#[deprecated(note="Use `SoraCredentialSet` instead as it has auto-healing behavior and doesn't require a bearer token initially.")]
pub fn from_toml_file<P: AsRef<Path>>(filename: P) -> AnyhowResult<Self> {
let contents = std::fs::read_to_string(filename)?;
let credentials = toml::from_str(&contents)?;
Ok(credentials)
}
#[deprecated(note="Use `SoraCredentialSet` instead as it has auto-healing behavior and doesn't require a bearer token initially.")]
pub fn add_credential_headers_to_request(&self, request: RequestBuilder) -> RequestBuilder {
let bearer_header = self.authorization_header_value();
@@ -0,0 +1,12 @@
use crate::credentials::SoraCredentials;
use crate::creds::sora_credential_set::SoraCredentialSet;
pub enum CredentialMigration {
Legacy(SoraCredentials),
New(SoraCredentialSet),
}
pub enum CredentialMigrationRef<'a> {
Legacy(&'a SoraCredentials),
New(&'a SoraCredentialSet),
}
@@ -1,3 +1,4 @@
pub mod credential_migration;
pub mod sora_cookies;
pub mod sora_credential_set;
pub mod sora_jwt_bearer_token;
@@ -11,7 +11,15 @@ impl SoraCookies {
SoraCookies { cookies }
}
pub fn get_cookies(&self) -> &str {
pub fn as_str(&self) -> &str {
&self.cookies
}
pub fn as_bytes(&self) -> &[u8] {
self.cookies.as_bytes()
}
pub fn to_string(&self) -> String {
self.cookies.clone()
}
}
@@ -1,15 +1,30 @@
use anyhow::anyhow;
use errors::AnyhowResult;
use crate::credentials::SoraCredentials;
use crate::creds::sora_cookies::SoraCookies;
use crate::creds::sora_jwt_bearer_token::SoraJwtBearerToken;
use crate::creds::sora_sentinel::SoraSentinel;
#[derive(Clone)]
pub struct SoraCredentialSet {
pub (crate) cookies: SoraCookies,
pub (crate) jwt_bearer_token: Option<SoraJwtBearerToken>,
pub (crate) sora_sentinel: Option<SoraSentinel>,
pub cookies: SoraCookies,
pub jwt_bearer_token: Option<SoraJwtBearerToken>,
pub sora_sentinel: Option<SoraSentinel>,
}
impl SoraCredentialSet {
pub fn initialize(
cookies: SoraCookies,
bearer: Option<SoraJwtBearerToken>,
sentinel: Option<SoraSentinel>
) -> Self {
Self {
cookies,
jwt_bearer_token: bearer,
sora_sentinel: sentinel,
}
}
pub fn initialize_with_just_cookies(cookies: SoraCookies) -> Self {
SoraCredentialSet {
cookies,
@@ -23,4 +38,38 @@ impl SoraCredentialSet {
SoraCookies::new(cookies.to_string()),
)
}
// TODO(bt,2025-04-23): Just here for migration
pub fn from_legacy_credentials(credentials: &SoraCredentials) -> AnyhowResult<Self> {
let cookies = SoraCookies::new(credentials.cookie.clone());
let jwt_bearer_token = SoraJwtBearerToken::new(credentials.bearer_token.clone())?;
let mut sora_sentinel = None;
if let Some(sentinel) = &credentials.sentinel {
sora_sentinel = Some(SoraSentinel::new(sentinel.clone()));
}
Ok(SoraCredentialSet {
cookies,
jwt_bearer_token: Some(jwt_bearer_token),
sora_sentinel,
})
}
pub fn to_legacy_credentials(&self) -> AnyhowResult<SoraCredentials> {
let bearer_token= self.jwt_bearer_token
.as_ref()
.map(|token| token.token_str().to_string())
.ok_or_else(|| anyhow!("There is no bearer token to convert to a required legacy bearer token"))?;
let sentinel = self.sora_sentinel
.as_ref()
.map(|sentinel| sentinel.get_sentinel().to_string());
Ok(SoraCredentials {
cookie: self.cookies.as_str().to_string(),
bearer_token,
sentinel,
})
}
}
@@ -21,7 +21,18 @@ impl SoraJwtBearerToken {
&self.token
}
pub fn as_bytes(&self) -> &[u8] {
self.token.as_bytes()
}
pub fn jwt_claims(&self) -> &SoraJwtClaims {
&self.jwt_claims
}
pub fn to_authorization_header_value(&self) -> String {
match self.token.get(0..6) {
Some("bearer") | Some("Bearer") => self.token.clone(),
_ => "Bearer ".to_owned() + &self.token,
}
}
}
@@ -3,6 +3,9 @@
#[derive(Clone)]
pub struct SoraSentinel {
sentinel: String,
// TODO(bt,2025-04-23): We may want to store the user agent alongside the sentinel
// as that appears to be important for the validity of the sentinel token.
}
impl SoraSentinel {
@@ -13,4 +16,8 @@ impl SoraSentinel {
pub fn get_sentinel(&self) -> &str {
&self.sentinel
}
pub fn as_bytes(&self) -> &[u8] {
self.sentinel.as_bytes()
}
}
@@ -1,6 +1,6 @@
pub mod credentials;
pub mod creds;
pub mod recipes;
pub mod requests;
pub mod sora_error;
pub mod utils;
mod recipes;
pub mod utils;
@@ -0,0 +1,140 @@
use anyhow::anyhow;
use log::{info, warn};
use errors::AnyhowResult;
use crate::creds::credential_migration::CredentialMigrationRef;
use crate::creds::sora_credential_set::SoraCredentialSet;
use crate::creds::sora_jwt_bearer_token::SoraJwtBearerToken;
use crate::creds::sora_sentinel::SoraSentinel;
use crate::requests::bearer::generate::generate_bearer_with_cookie;
use crate::requests::image_gen::common::{ImageSize, NumImages, SoraImageGenResponse};
use crate::requests::image_gen::image_gen_http_request;
use crate::requests::image_gen::sora_image_gen_remix::{sora_image_gen_remix, SoraImageGenRemixRequest};
use crate::requests::sentinel_refresh::generate::token::generate_token;
use crate::sora_error::SoraError;
pub struct ImageRemixAutoRenewRequest<'a> {
pub prompt: String,
pub num_images: NumImages,
pub image_size: ImageSize,
pub sora_media_tokens: Vec<String>,
pub credentials: &'a SoraCredentialSet,
}
/// Image request with retry and session auto-renewal.
/// 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
num_images: request.num_images,
image_size: request.image_size,
sora_media_tokens: request.sora_media_tokens.clone(), // NB: Clone because used again
credentials: CredentialMigrationRef::New(request.credentials),
}).await;
let err = match result {
Ok(response) => return Ok((response, None)),
Err(err) => err,
};
warn!("Image generation failed: {:?}", err);
let mut refresh_jwt = false;
let mut refresh_sentinel = false;
match err {
// We'll fail these requests...
image_gen_http_request::SoraError::TooManyConcurrentTasks(_) => {
return Err(SoraError::TooManyConcurrentTasks);
}
image_gen_http_request::SoraError::GenericError(err) => {
return Err(SoraError::OtherBadStatus(anyhow!("image remix failed with GenericError: {:?}", err)))
}
image_gen_http_request::SoraError::NetworkError(err) => {
// TODO: The underlying type should be a reqwest::Error.
return Err(SoraError::OtherBadStatus(anyhow!("network error: {:?}", err)))
}
// We'll retry these requests...
image_gen_http_request::SoraError::SentinelBlock(err) => {
warn!("Image generation failed due to sentinel block error: {:?}", err);
refresh_sentinel = true;
}
image_gen_http_request::SoraError::TokenExpired(err) => {
warn!("Image generation failed due to token expired error: {:?}", err);
refresh_sentinel = true; // TODO: Not sure what this error is, actually.
refresh_jwt = true;
}
image_gen_http_request::SoraError::InvalidJwt(err) => {
warn!("Image generation failed due to invalid jwt error: {:?}", err);
refresh_jwt = true;
}
}
let mut new_bearer = None;
if refresh_jwt {
info!("Generating new JWT bearer token...");
let cookies = request.credentials.cookies.as_str();
let response = generate_bearer_with_cookie(cookies).await;
match response {
Err(err) => {
return Err(SoraError::OtherBadStatus(anyhow!("failed to generate new JWT bearer token: {:?}", err)))
}
Ok(bearer) => {
match SoraJwtBearerToken::new(bearer) {
Err(err) => {
return Err(SoraError::OtherBadStatus(anyhow!("Failed to parse new JWT bearer token: {:?}", err)));
}
Ok(bearer) => new_bearer = Some(bearer),
}
},
}
}
let mut new_sentinel = None;
if refresh_sentinel {
info!("Generating new sentinel...");
let response = generate_token().await;
match response {
Err(err) => {
return Err(SoraError::OtherBadStatus(anyhow!("failed to generate new sentinel: {:?}", err)))
}
Ok(sentinel) => new_sentinel = Some(SoraSentinel::new(sentinel)),
}
}
let mut new_creds = request.credentials.clone();
if let Some(bearer) = new_bearer {
new_creds.jwt_bearer_token = Some(bearer);
}
if let Some(sentinel) = new_sentinel {
new_creds.sora_sentinel = Some(sentinel);
}
// Now try again...
let result = sora_image_gen_remix(SoraImageGenRemixRequest {
prompt: request.prompt,
num_images: request.num_images,
image_size: request.image_size,
sora_media_tokens: request.sora_media_tokens,
credentials: CredentialMigrationRef::New(&new_creds),
}).await;
match result {
Ok(response) => Ok((response, Some(new_creds))),
Err(err) => match err {
image_gen_http_request::SoraError::TooManyConcurrentTasks(err) => {
Err(SoraError::TooManyConcurrentTasks)
}
_ => {
warn!("Image remix failed again: {:?}", err);
Err(SoraError::OtherBadStatus(anyhow!("image remix failed again: {:?}", err)))
}
}
}
}
@@ -0,0 +1,57 @@
use crate::creds::credential_migration::CredentialMigrationRef;
use crate::creds::sora_credential_set::SoraCredentialSet;
use crate::creds::sora_jwt_bearer_token::SoraJwtBearerToken;
use crate::creds::sora_sentinel::SoraSentinel;
use crate::recipes::maybe_refresh_credentials_on_sora_error::maybe_refresh_credentials_on_sora_error;
use crate::requests::bearer::generate::generate_bearer_with_cookie;
use crate::requests::image_gen::common::{ImageSize, NumImages, SoraImageGenResponse};
use crate::requests::image_gen::image_gen_http_request;
use crate::requests::image_gen::sora_image_gen_remix::{sora_image_gen_remix, SoraImageGenRemixRequest};
use crate::requests::sentinel_refresh::generate::token::generate_token;
use crate::requests::upload::upload_media_from_bytes::sora_media_upload_from_bytes;
use crate::requests::upload::upload_media_http_request::{upload_media_http_request, SoraMediaUploadResponse};
use crate::sora_error::SoraError;
use anyhow::anyhow;
use errors::AnyhowResult;
use log::{error, info, warn};
pub struct ImageUploadFromBytesAutoRenewRequest<'a> {
pub file_bytes: Vec<u8>,
pub filename: String,
pub mime_type: &'a str,
pub credentials: &'a SoraCredentialSet,
}
/// Image upload with retry and session auto-renewal.
/// 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> {
let result = sora_media_upload_from_bytes(
request.file_bytes.clone(), // FIXME(bt): This is horrible, but the client needs to take ownership. :(
request.filename.clone(), // FIXME: Same
CredentialMigrationRef::New(request.credentials),
).await;
let err = match result {
Ok(response) => return Ok((response, None)),
Err(err) => err,
};
warn!("Image upload failed: {:?}", err);
let new_creds = maybe_refresh_credentials_on_sora_error(request.credentials, err).await?;
// Now try again...
info!("Retrying image upload with new credentials...");
let result = sora_media_upload_from_bytes(
request.file_bytes,
request.filename,
CredentialMigrationRef::New(&new_creds),
).await?;
Ok((result, Some(new_creds)))
}
@@ -0,0 +1,44 @@
use crate::creds::credential_migration::CredentialMigrationRef;
use crate::creds::sora_credential_set::SoraCredentialSet;
use crate::recipes::maybe_refresh_credentials_on_sora_error::maybe_refresh_credentials_on_sora_error;
use crate::requests::upload::upload_media_from_file::sora_media_upload_from_file;
use crate::requests::upload::upload_media_http_request::{upload_media_http_request, SoraMediaUploadResponse};
use crate::sora_error::SoraError;
use log::{info, warn};
use std::path::Path;
pub struct ImageUploadFromFileAutoRenewRequest<'a, P: AsRef<Path>> {
pub file_path: P,
pub credentials: &'a SoraCredentialSet,
}
/// Image upload with retry and session auto-renewal.
/// 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> {
let result = sora_media_upload_from_file(
request.file_path.as_ref().clone(), // FIXME(bt): This is horrible, but the client needs to take ownership. :(
CredentialMigrationRef::New(request.credentials),
).await;
let err = match result {
Ok(response) => return Ok((response, None)),
Err(err) => err,
};
warn!("Image upload failed: {:?}", err);
let new_creds = maybe_refresh_credentials_on_sora_error(request.credentials, err).await?;
info!("Retrying image upload with new credentials...");
let result = sora_media_upload_from_file(
request.file_path,
CredentialMigrationRef::New(&new_creds),
).await?;
Ok((result, Some(new_creds)))
}
@@ -0,0 +1,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)))
}
@@ -0,0 +1,49 @@
use crate::creds::sora_credential_set::SoraCredentialSet;
use crate::creds::sora_jwt_bearer_token::SoraJwtBearerToken;
use crate::requests::bearer::generate::generate_bearer_with_cookie;
use crate::sora_error::SoraError;
use anyhow::anyhow;
use log::{error, info, warn};
// TODO: Make other APIs work with this.
pub async fn maybe_refresh_credentials_on_sora_error(creds: &SoraCredentialSet, error: SoraError) -> Result<SoraCredentialSet, SoraError> {
match error {
SoraError::NoBearerTokenAvailable => {
warn!("Previous request failed due to missing bearer token.");
}
SoraError::UnauthorizedCookieOrBearerExpired => {
warn!("Previous request failed due to invalid bearer token.");
}
_ => {
error!("Previous request failed with error: {:?}", error);
return Err(SoraError::OtherBadStatus(anyhow!("previous request failed with error: {:?}", error)))
}
}
info!("Generating new JWT bearer token...");
let cookies = creds.cookies.as_str();
let response = generate_bearer_with_cookie(cookies).await;
let new_bearer = match response {
Err(err) => {
return Err(SoraError::OtherBadStatus(anyhow!("failed to generate new JWT bearer token: {:?}", err)))
}
Ok(bearer) => {
match SoraJwtBearerToken::new(bearer) {
Err(err) => {
return Err(SoraError::OtherBadStatus(anyhow!("failed to parse new JWT bearer token: {:?}", err)));
}
Ok(bearer) => bearer,
}
}
};
let mut new_creds = creds.clone();
new_creds.jwt_bearer_token = Some(new_bearer);
Ok(new_creds)
}
@@ -13,7 +13,7 @@ const EXPIRATION_DEADLINE : TimeDelta = TimeDelta::hours(12);
/// Call this at session startup to generate the tokens for the first time.
/// Call this periodically to refresh the JWT as it nears its expiration date.
/// This will not refresh the sentinel token once it's been generated - we can use other flows to accomplish that.
pub async fn upgrade_or_renew_session(sora_credentials: &mut SoraCredentialSet) -> AnyhowResult<()> {
pub async fn maybe_upgrade_or_renew_session(sora_credentials: &mut SoraCredentialSet) -> AnyhowResult<bool> {
let mut refresh_jwt = !sora_credentials.jwt_bearer_token.is_some();
let refresh_sentinel = !sora_credentials.sora_sentinel.is_some();
@@ -29,8 +29,10 @@ pub async fn upgrade_or_renew_session(sora_credentials: &mut SoraCredentialSet)
}
}
let mut credential_updated = false;
if refresh_jwt {
let cookies = sora_credentials.cookies.get_cookies();
let cookies = sora_credentials.cookies.as_str();
info!("Generating new JWT bearer token...");
let token = generate_bearer_with_cookie(cookies).await?;
@@ -38,6 +40,7 @@ pub async fn upgrade_or_renew_session(sora_credentials: &mut SoraCredentialSet)
info!("Parsing JWT bearer token.");
let token = SoraJwtBearerToken::new(token)?;
sora_credentials.jwt_bearer_token = Some(token);
credential_updated = true;
}
if refresh_sentinel {
@@ -45,7 +48,8 @@ pub async fn upgrade_or_renew_session(sora_credentials: &mut SoraCredentialSet)
let token = generate_token().await?;
let token = SoraSentinel::new(token);
sora_credentials.sora_sentinel = Some(token);
credential_updated = true;
}
Ok(())
Ok(credential_updated)
}
@@ -1 +1,7 @@
mod upgrade_or_renew_session;
pub mod image_remix_with_session_auto_renew;
pub mod image_upload_from_bytes_with_session_auto_renew;
pub mod image_upload_from_file_with_session_auto_renew;
pub mod 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;
@@ -0,0 +1,51 @@
use crate::credentials::SoraCredentials;
use crate::requests::image_gen::image_gen_status::TaskStatus;
use crate::requests::image_gen::image_gen_status::{get_image_gen_status, StatusRequest, TaskResponse, VideoGenStatusResponse};
use errors::AnyhowResult;
pub async fn wait_for_image_gen_status(task_id: &String, credentials: &SoraCredentials, retry_limit: Option<u32>) -> AnyhowResult<TaskResponse> {
let status_request = StatusRequest {
limit: None,
before: None,
// before: Some(task_id.clone()),
};
let retry_limit = retry_limit.unwrap_or(10);
for _ in 0..retry_limit {
tokio::time::sleep(std::time::Duration::from_secs(10)).await;
let status_response = get_image_gen_status(&status_request, credentials).await?;
match find_task_response_by_id(&status_response, task_id.clone()) {
Some(task_response) => match TaskStatus::from_str(&task_response.status) {
TaskStatus::Succeeded => {
return Ok(task_response.clone());
},
TaskStatus::Failed => {
return Err(anyhow::anyhow!("Task failed"));
},
TaskStatus::Unknown(status) => {
println!("Unknown task status: {}", status);
continue;
},
TaskStatus::Queued => {
println!("Task is queued");
continue;
},
TaskStatus::Running => {
println!("Task is running");
continue;
},
},
None => {
return Err(anyhow::anyhow!("Task not found"));
},
}
}
Err(anyhow::anyhow!("Task not found"))
}
fn find_task_response_by_id(status_response: &VideoGenStatusResponse, task_id: String) -> Option<&TaskResponse> {
status_response.task_responses.iter().find(|task| task.id.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 crate::credentials::SoraCredentials;
use thiserror::Error;
use crate::requests::image_gen::image_gen_status::TaskId;
const SORA_IMAGE_GEN_URL: &str = "https://sora.com/backend/video_gen";
@@ -28,13 +29,13 @@ pub enum SoraError {
NetworkError(String),
}
#[derive(Serialize)]
#[derive(Serialize, Debug)]
#[serde(rename_all = "snake_case")]
pub (crate) enum VideoGenType {
ImageGen,
}
#[derive(Serialize)]
#[derive(Serialize, Debug)]
#[serde(rename_all = "snake_case")]
pub (crate) enum OperationType {
/// Simple prompt without reference images
@@ -43,13 +44,13 @@ pub (crate) enum OperationType {
Remix,
}
#[derive(Serialize)]
#[derive(Serialize, Debug)]
#[serde(rename_all = "snake_case")]
pub (crate) enum InpaintItemType {
Image,
}
#[derive(Serialize)]
#[derive(Serialize, Debug)]
#[serde(rename_all = "snake_case")]
pub (crate) struct InpaintItem {
pub r#type: InpaintItemType,
@@ -69,7 +70,7 @@ pub (crate) struct InpaintItem {
pub crop_bounds: Option<String>,
}
#[derive(Serialize)]
#[derive(Serialize, Debug)]
#[serde(rename_all = "snake_case")]
pub (crate) struct RawSoraImageGenRequest {
/// eg. "image_gen"
@@ -90,11 +91,11 @@ pub (crate) struct RawSoraImageGenRequest {
pub inpaint_items: Vec<InpaintItem>,
}
#[derive(Deserialize)]
#[derive(Deserialize, Debug)]
#[serde(rename_all = "snake_case")]
pub (crate) struct RawSoraResponse {
/// eg. "task_01jqsz9dsae9tvjygf1abrv3xf"
pub id: String,
pub id: TaskId,
/// not known
pub priority: Option<String>,
}
@@ -146,20 +147,41 @@ impl std::fmt::Display for RawSoraErrorInner {
}
/// Don't expose the internal request implementation as there are only a few "correct" ways to call the API.
pub (crate) async fn image_gen_http_request(sora_request: RawSoraImageGenRequest, credentials: &SoraCredentials) -> Result<RawSoraResponse, SoraError> {
pub (crate) async fn image_gen_http_request(sora_request: RawSoraImageGenRequest, credentials: CredentialMigrationRef<'_>) -> Result<RawSoraResponse, SoraError> {
let client = reqwest::Client::new();
let sentinel = match credentials.sentinel.as_deref() {
None => return Err(SoraError::GenericError("Calls to image gen require a sentinel in the credentials payload.".to_string())),
Some(sentinel) => sentinel,
};
let mut cookie;
let mut authorization_header;
let mut sentinel;
match credentials {
CredentialMigrationRef::Legacy(creds) => {
cookie = creds.cookie.clone();
authorization_header = creds.authorization_header_value();
// TODO(bt,2025-04-23): We're using a Sora payload error in place of application state error. Surface this differently.
sentinel = creds.sentinel.as_ref()
.map(|sentinel| sentinel.to_string())
.ok_or(SoraError::SentinelBlock("Sentinel is required for image generation.".to_string()))?;
}
CredentialMigrationRef::New(creds) => {
cookie = creds.cookies.to_string();
// TODO(bt,2025-04-23): We're using a Sora payload error in place of application state error. Surface this differently.
authorization_header = creds.jwt_bearer_token.as_ref()
.ok_or(SoraError::InvalidJwt("JWT bearer is required for image generation".to_string()))?
.to_authorization_header_value();
// TODO(bt,2025-04-23): We're using a Sora payload error in place of application state error. Surface this differently.
sentinel = creds.sora_sentinel.as_ref()
.map(|sentinel| sentinel.get_sentinel().to_string())
.ok_or(SoraError::SentinelBlock("Sentinel is required for image generation.".to_string()))?;
}
}
let http_request = client.post(SORA_IMAGE_GEN_URL)
.header("User-Agent", USER_AGENT)
.header("Cookie", &credentials.cookie)
.header("Authorization", credentials.authorization_header_value())
.header("Cookie", &cookie)
.header("Authorization", &authorization_header)
.header("Content-Type", "application/json")
.header("OpenAI-Sentinel-Token", sentinel);
.header("OpenAI-Sentinel-Token", &sentinel);
let http_request = http_request.json(&sora_request).build()
.map_err(|e| SoraError::NetworkError(e.to_string()))?;
@@ -206,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)?;
@@ -220,47 +240,6 @@ pub async fn get_image_gen_status(status_request: &StatusRequest, credentials: &
Ok(response)
}
pub async fn wait_for_image_gen_status(task_id: &String, credentials: &SoraCredentials, retry_limit: Option<u32>) -> AnyhowResult<TaskResponse> {
let status_request = StatusRequest {
limit: None,
before: None,
// before: Some(task_id.clone()),
};
let retry_limit = retry_limit.unwrap_or(10);
for _ in 0..retry_limit {
tokio::time::sleep(std::time::Duration::from_secs(10)).await;
let status_response = get_image_gen_status(&status_request, credentials).await?;
match find_task_response_by_id(&status_response, task_id.clone()) {
Some(task_response) => match TaskStatus::from_str(&task_response.status) {
TaskStatus::Succeeded => {
return Ok(task_response.clone());
},
TaskStatus::Failed => {
return Err(anyhow::anyhow!("Task failed"));
},
TaskStatus::Unknown(status) => {
println!("Unknown task status: {}", status);
continue;
},
TaskStatus::Queued => {
println!("Task is queued");
continue;
},
TaskStatus::Running => {
println!("Task is running");
continue;
},
},
None => {
return Err(anyhow::anyhow!("Task not found"));
},
}
}
Err(anyhow::anyhow!("Task not found"))
}
pub async fn save_generations_to_dir(generations: &[Generation], dir: &str) -> AnyhowResult<()> {
let dir = Path::new(dir);
if !dir.exists() {
@@ -288,14 +267,11 @@ pub async fn save_generations_to_dir(generations: &[Generation], dir: &str) -> A
Ok(())
}
fn find_task_response_by_id(status_response: &VideoGenStatusResponse, task_id: String) -> Option<&TaskResponse> {
status_response.task_responses.iter().find(|task| task.id == task_id)
}
#[cfg(test)]
mod tests {
use crate::credentials::SoraCredentials;
use crate::image_gen::image_gen_status::{get_image_gen_status, save_generations_to_dir, wait_for_image_gen_status, StatusRequest, VideoGenStatusResponse};
use crate::recipes::wait_for_image_gen_status::wait_for_image_gen_status;
use crate::requests::image_gen::image_gen_status::{get_image_gen_status, save_generations_to_dir, StatusRequest, VideoGenStatusResponse};
use errors::AnyhowResult;
use std::fs::read_to_string;
use testing::test_file_path::test_file_path;
@@ -329,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?;
@@ -4,4 +4,3 @@ pub mod common;
pub mod sora_image_gen_remix;
pub mod sora_image_gen_simple;
pub mod image_gen_status;
pub use image_gen_status::{get_image_gen_status, save_generations_to_dir, wait_for_image_gen_status, Generation, StatusRequest, TaskResponse, VideoGenStatusResponse};
@@ -1,4 +1,5 @@
use crate::credentials::SoraCredentials;
use log::info;
use crate::creds::credential_migration::CredentialMigrationRef;
use crate::requests::image_gen::common::{ImageSize, NumImages, SoraImageGenResponse};
use crate::requests::image_gen::image_gen_http_request::{image_gen_http_request, InpaintItem, InpaintItemType, OperationType, RawSoraImageGenRequest, SoraError, VideoGenType};
@@ -7,7 +8,7 @@ pub struct SoraImageGenRemixRequest<'a> {
pub num_images: NumImages,
pub image_size: ImageSize,
pub sora_media_tokens: Vec<String>,
pub credentials: &'a SoraCredentials,
pub credentials: CredentialMigrationRef<'a>,
}
/// The "remix" commands let you supply additional images as context.
@@ -35,7 +36,10 @@ pub async fn sora_image_gen_remix(request: SoraImageGenRemixRequest<'_>) -> Resu
}).collect(),
};
let result = image_gen_http_request(args, &request.credentials).await?;
// TODO: Replace this with debug level logging in the future.
info!("Sending remix request: {:?}", args);
let result = image_gen_http_request(args, request.credentials).await?;
Ok(SoraImageGenResponse {
task_id: result.id,
@@ -45,6 +49,7 @@ pub async fn sora_image_gen_remix(request: SoraImageGenRemixRequest<'_>) -> Resu
#[cfg(test)]
mod tests {
use crate::credentials::SoraCredentials;
use crate::creds::credential_migration::CredentialMigrationRef;
use crate::requests::image_gen::common::{ImageSize, NumImages};
use crate::requests::image_gen::sora_image_gen_remix::{sora_image_gen_remix, SoraImageGenRemixRequest};
use errors::AnyhowResult;
@@ -74,12 +79,12 @@ mod tests {
num_images: NumImages::One,
image_size: ImageSize::Square,
sora_media_tokens: vec!["media_01jqyhrz4detyvtzwp2p4j63ad".to_string()],
credentials: &creds,
credentials: CredentialMigrationRef::Legacy(&creds),
}).await?;
println!("task_id: {}", response.task_id);
assert!(response.task_id.starts_with("task_"));
assert!(response.task_id.0.starts_with("task_"));
Ok(())
}
}
@@ -2,12 +2,13 @@ use crate::credentials::SoraCredentials;
use crate::requests::image_gen::common::{ImageSize, NumImages, SoraImageGenResponse};
use crate::requests::image_gen::image_gen_http_request::{image_gen_http_request, OperationType, RawSoraImageGenRequest, VideoGenType};
use errors::AnyhowResult;
use crate::creds::credential_migration::CredentialMigrationRef;
pub struct SoraImageGenSimpleRequest<'a> {
pub prompt: String,
pub num_images: NumImages,
pub image_size: ImageSize,
pub credentials: &'a SoraCredentials,
pub credentials: CredentialMigrationRef<'a>,
}
pub async fn sora_image_gen_simple(args: SoraImageGenSimpleRequest<'_>) -> AnyhowResult<SoraImageGenResponse> {
@@ -38,6 +39,7 @@ mod tests {
use errors::AnyhowResult;
use std::fs::read_to_string;
use testing::test_file_path::test_file_path;
use crate::creds::credential_migration::CredentialMigrationRef;
#[ignore] // You can manually run "ignore" tests in the IDE, but they won't run in CI.
#[tokio::test]
@@ -61,12 +63,12 @@ mod tests {
prompt: "A pirate and a ninja fight in a battle inside a UFO. Fully photo realistic, lifelike, lens flare".to_string(),
num_images: NumImages::One,
image_size: ImageSize::Square,
credentials: &creds,
credentials: CredentialMigrationRef::Legacy(&creds),
}).await?;
println!("task_id: {}", response.task_id);
assert!(response.task_id.starts_with("task_"));
assert!(response.task_id.0.starts_with("task_"));
Ok(())
}
}
@@ -1,29 +1,45 @@
use crate::creds::credential_migration::CredentialMigrationRef;
use crate::sora_error::SoraError;
const USER_AGENT : &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36";
// https://sora.com/backend/notif?limit=100&before=task_01jqwwrkvgeqp8jsf5mqk1jceh
use crate::credentials::SoraCredentials;
pub async fn sora_job_status(
task_id: &str,
credentials: &SoraCredentials,
) -> anyhow::Result<()> {
credentials: CredentialMigrationRef<'_>,
) -> Result<(), SoraError> {
let url = format!("https://sora.com/backend/notif?limit=100&before={}", task_id);
let mut cookie;
let mut authorization_header;
match credentials {
CredentialMigrationRef::Legacy(creds) => {
cookie = creds.cookie.clone();
authorization_header = creds.authorization_header_value();
}
CredentialMigrationRef::New(creds) => {
cookie = creds.cookies.to_string();
// TODO(bt,2025-04-23): We're using a Sora payload error in place of application state error. Surface this differently.
authorization_header = creds.jwt_bearer_token.as_ref()
.ok_or(SoraError::NoBearerTokenAvailable)?
.to_authorization_header_value();
}
}
let client = reqwest::Client::new();
let request = client
.get(url)
.header("Authorization", &credentials.bearer_token)
.header("Authorization", &authorization_header)
.header("User-Agent", USER_AGENT)
.header("Cookie", &credentials.cookie);
.header("Cookie", &cookie);
let response = request.send().await?;
let json_response = &response.text().await?;
println!(" >>> response = {:?}", json_response);
//let response = serde_json::from_str(json_response)?;
Ok(())
@@ -31,11 +47,12 @@ pub async fn sora_job_status(
#[cfg(test)]
mod tests {
use std::fs::read_to_string;
use errors::AnyhowResult;
use testing::test_file_path::test_file_path;
use crate::credentials::SoraCredentials;
use crate::job::sora_job_status::sora_job_status;
use crate::creds::credential_migration::CredentialMigrationRef;
use crate::requests::job::sora_job_status::sora_job_status;
use errors::AnyhowResult;
use std::fs::read_to_string;
use testing::test_file_path::test_file_path;
#[ignore]
#[tokio::test]
@@ -57,7 +74,7 @@ mod tests {
let task_id = "task_01jqwwrkvgeqp8jsf5mqk1jceh";
let result = sora_job_status(task_id, &creds).await?;
let result = sora_job_status(task_id, CredentialMigrationRef::Legacy(&creds)).await?;
Ok(())
}
@@ -1,4 +1 @@
pub use refresh::refresh_sentinel;
pub mod refresh;
pub mod generate;
@@ -1,16 +1,11 @@
use crate::credentials::SoraCredentials;
use crate::creds::credential_migration::CredentialMigrationRef;
use crate::requests::upload::upload_media_http_request::{upload_media_http_request, SoraMediaUploadResponse};
use errors::AnyhowResult;
use crate::sora_error::SoraError;
use std::path::PathBuf;
pub struct SoraMediaUploadRequest<'a> {
pub file_path: String,
pub credentials: &'a SoraCredentials,
}
/// Upload bytes.
/// The underlying reqwest lib needs to own the bytes, so we can't pass as a reference.
pub async fn sora_media_upload_from_bytes(bytes: Vec<u8>, file_name: String, creds: &SoraCredentials) -> AnyhowResult<SoraMediaUploadResponse> {
pub async fn sora_media_upload_from_bytes(bytes: Vec<u8>, file_name: String, creds: CredentialMigrationRef<'_>) -> Result<SoraMediaUploadResponse, SoraError> {
let file_path = PathBuf::from(&file_name);
// TODO: Read file magic bytes first, then fall back to this.
@@ -1,6 +1,6 @@
use crate::credentials::SoraCredentials;
use crate::creds::credential_migration::CredentialMigrationRef;
use crate::requests::upload::upload_media_http_request::{upload_media_http_request, SoraMediaUploadResponse};
use errors::AnyhowResult;
use crate::sora_error::SoraError;
use std::path::Path;
use tokio::fs::File;
use tokio::io::AsyncReadExt;
@@ -9,12 +9,7 @@ use tokio::io::AsyncReadExt;
/// There's a better way to implement this.
const INITIAL_BUFFER_SIZE : usize = 1024*1024;
pub struct SoraMediaUploadRequest<'a> {
pub file_path: String,
pub credentials: &'a SoraCredentials,
}
pub async fn sora_media_upload_from_file<P: AsRef<Path>>(file_path: P, creds: &SoraCredentials) -> AnyhowResult<SoraMediaUploadResponse> {
pub async fn sora_media_upload_from_file<P: AsRef<Path>>(file_path: P, creds: CredentialMigrationRef<'_>) -> Result<SoraMediaUploadResponse, SoraError> {
let mut file = File::open(&file_path).await?;
let mut buffer = Vec::with_capacity(INITIAL_BUFFER_SIZE);
file.read_to_end(&mut buffer).await?;
@@ -42,7 +37,8 @@ pub async fn sora_media_upload_from_file<P: AsRef<Path>>(file_path: P, creds: &S
#[cfg(test)]
mod tests {
use crate::credentials::SoraCredentials;
use crate::upload::upload_media_from_file::sora_media_upload_from_file;
use crate::creds::credential_migration::CredentialMigrationRef;
use crate::requests::upload::upload_media_from_file::sora_media_upload_from_file;
use errors::AnyhowResult;
use std::fs::read_to_string;
use testing::test_file_path::test_file_path;
@@ -66,7 +62,7 @@ mod tests {
let response = sora_media_upload_from_file(
image_path,
&creds,
CredentialMigrationRef::Legacy(&creds),
).await?;
println!("media: {:?}", response);
@@ -1,4 +1,6 @@
use log::info;
use crate::credentials::{SoraCredentials, USER_AGENT};
use crate::creds::credential_migration::CredentialMigrationRef;
use crate::sora_error::SoraError;
use reqwest::multipart::{Form, Part};
use reqwest::Client;
@@ -65,7 +67,7 @@ pub (crate) async fn upload_media_http_request(
file_bytes: Vec<u8>,
filename: String,
mime_type: &str,
credentials: &SoraCredentials,
credentials: CredentialMigrationRef<'_>,
) -> Result<SoraMediaUploadResponse, SoraError> {
// Create multipart form
@@ -75,18 +77,36 @@ pub (crate) async fn upload_media_http_request(
let form = Form::new().part("file", part);
let cookie;
let auth_header;
match credentials {
CredentialMigrationRef::Legacy(creds) => {
cookie = creds.cookie.clone();
auth_header = creds.authorization_header_value();
}
CredentialMigrationRef::New(creds) => {
cookie = creds.cookies.to_string();
auth_header = creds.jwt_bearer_token
.as_ref()
.ok_or(SoraError::NoBearerTokenAvailable)?
.to_authorization_header_value();
}
}
// Make API request
let client = Client::new();
let request_builder = client.post(SORA_UPLOAD_MEDIA_URL)
.multipart(form)
.header("User-Agent", USER_AGENT)
.header("Cookie", &credentials.cookie)
.header("Authorization", credentials.authorization_header_value());
.header("Cookie", &cookie)
.header("Authorization", &auth_header);
let response = request_builder.send().await?;
// Check response status
if !response.status().is_success() {
info!("Error uploading image: {:?}", response.status());
let error = classify_error(response).await;
return Err(error);
}
@@ -1,8 +1,18 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
use std::io;
/// The single error type this crate should surface to all callers.
#[derive(Debug)]
pub enum SoraError {
/// We haven't received a bearer token yet
/// This is our 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:
@@ -19,8 +29,17 @@ 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.
IoError(io::Error),
/// 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 {}
@@ -28,21 +47,54 @@ impl Error for SoraError {}
impl Display for SoraError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
SoraError::UnauthorizedCookieOrBearerExpired => {
Self::NoBearerTokenAvailable => {
write!(f, "Unauthorized: no bearer token set")
}
Self::TooManyConcurrentTasks => {
write!(f, "Too many concurrent tasks. Please wait.")
}
Self::UnauthorizedCookieOrBearerExpired => {
write!(f, "Unauthorized: cookie and/or bearer token expired")
}
SoraError::OtherBadStatus(err) => {
Self::OtherBadStatus(err) => {
write!(f, "Other error: {}", err)
}
SoraError::ReqwestError(err) => {
Self::ReqwestError(err) => {
write!(f, "Reqwest error: {}", err)
}
Self::IoError(err) => {
write!(f, "IO error: {}", err)
}
Self::AnyhowError(err) => {
write!(f, "Anyhow error: {}", err)
}
Self::JsonError(err) => {
write!(f, "serde_json error: {}", err)
}
}
}
}
impl From<reqwest::Error> for SoraError {
fn from(err: reqwest::Error) -> SoraError {
SoraError::ReqwestError(err)
Self::ReqwestError(err)
}
}
impl From<io::Error> for SoraError {
fn from(err: io::Error) -> Self {
Self::IoError(err)
}
}
impl From<anyhow::Error> for SoraError {
fn from(err: anyhow::Error) -> Self {
Self::AnyhowError(err)
}
}
impl From<serde_json::Error> for SoraError {
fn from(err: serde_json::Error) -> Self {
Self::JsonError(err)
}
}
@@ -0,0 +1,123 @@
use std::collections::HashSet;
use std::iter::Map;
use cookie::Cookie;
use once_cell::sync::Lazy;
use errors::AnyhowResult;
pub const SESSION_COOKIE_NAME : &str = "__Secure-authjs.session-token";
/// Cookies Sora.com mints that are not the session cookie. We can ignore these.
/// This list is very likely to change over time.
const IRRELEVANT_COOKIE_NAMES: Lazy<HashSet<&'static str>> = Lazy::new(|| {
HashSet::from([
"cf_clearance",
"oai-did",
"_cfuvid",
"__cflb",
"__cf_bm",
"__Host-authjs.csrf-token",
"__Secure-authjs.callback-url",
"__Secure-authjs.pkce.code_verifier",
"__Secure-authjs.state",
])
});
/// Super naive heuristic for "logged out" total cookie length being ~1972 characters.
const SESSION_COOKIE_LENGTH_HEURISTIC : usize = 2000;
//pub fn has_session_cookie(cookies: &str) -> bool {
// // NB: This is not a valid check for cookies, just a cheap heuristic.
// cookies.contains(SESSION_COOKIE_NAME)
//}
#[derive(Copy,Clone,Eq,PartialEq,Debug)]
pub enum SessionCookiePresence {
Present,
MaybePresent,
Absent,
}
/// OpenAI might change the name of the session cookie, and we don't want to break clients,
/// so we use some heuristics to determine if the cookie might be present.
pub fn has_session_cookie(cookies_header: &str) -> AnyhowResult<SessionCookiePresence> {
let cookies_header = cookies_header.trim();
if (cookies_header.is_empty()) {
return Ok(SessionCookiePresence::Absent);
}
let cookies = Cookie::split_parse(cookies_header);
let mut unknown_cookie_count = 0; // NB: In the event they change their session cookie
for cookie in cookies {
let cookie= cookie?;
let cookie_name = cookie.name();
if cookie_name == SESSION_COOKIE_NAME {
return Ok(SessionCookiePresence::Present);
} else if !IRRELEVANT_COOKIE_NAMES.contains(cookie_name) {
unknown_cookie_count += 1;
}
}
// This is a really stupid heuristic, but we want to be safe.
if cookies_header.len() > SESSION_COOKIE_LENGTH_HEURISTIC {
return Ok(SessionCookiePresence::MaybePresent);
}
// Another stupid heuristic.
if unknown_cookie_count > 3 {
return Ok(SessionCookiePresence::MaybePresent);
}
Ok(SessionCookiePresence::Absent)
}
#[cfg(test)]
mod tests {
use crate::utils::has_session_cookie::{has_session_cookie, SessionCookiePresence};
#[test]
fn test() {
assert_eq!(has_session_cookie("").unwrap(), SessionCookiePresence::Absent);
assert_eq!(has_session_cookie("foo=bar").unwrap(), SessionCookiePresence::Absent);
// Heuristic: unknown cookies.
assert_eq!(has_session_cookie("foo=bar;bin=baz;bat=ban;bash=barn").unwrap(), SessionCookiePresence::MaybePresent);
// Heuristic: cookie length
let cookies = format!("cookie={}", "a".repeat(10000));
assert_eq!(has_session_cookie(&cookies).unwrap(), SessionCookiePresence::MaybePresent);
let cookies = "__Host-authjs.csrf-token=a2783b5f89ee8dcb184f64723c3fdc91868f89840cb842fb20c3bd3\
d6b087d97%7C8e85a84ac3870dfab160d96c45fb8a0430350ae4b4755784c03c3b97a927eb06; __Secure-authjs.c\
allback-url=https%3A%2F%2Fsora.com%2F; __cflb=0H28vtyuPgbsDEUozcoHSWoAnvpSqAh9PUFn8MimKwT; __Se\
cure-authjs.session-token=eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2Q0JDLUhTNTEyIiwia2lkIjoiYWNBYVdTWHBfa\
DZnNkxtUEs1S0RvTjR3NHFxTEsxVlpSTHA4VWdiaFdWNjctMG5pbDdVVjBCN2dSUU9Bem84ZXBjMVFjb1ozNGxwa0c1OEUy\
U2Q4cUEifQ..I4nYqp8dqgnuBwIgzbtoTg.etnsxNRheMCGgAFaTzAxGdqjao68Xf_NCHqv8h5ZBhfDx_b0dg2bzDIfNFF5\
_8VG1dwZkqe83k3JrOejBVKi3wDSI5p_EgU8YKf_oxshusBn09mGZmAPd49_F73DARypF0eHKyUnGfelkFmVW610P0nV3Pn\
KNJhjIHHxAh_9dHnRvQ5WtaYMvSTE5Yc8Ut1VwKs9QmYdTkfcC_gDATQ-dgF8eew5Seh4KALizGcQqYhcENMVzfshSMwNrr\
mZ-RL6X71wTqhKV3XiJnp_s7e6DxRfcb25V-aOD_rsVa1uAxpYzYvc197Cd0psqQ1MpPjGohUSXjwg177FvBmloAxy8-Qbf\
Moz9ofugywgEqQBJIEznddwEOScVqoOhRbYXGpqBY-rU5ChDG4qetbfKNCX2DT0R0lShR1n-UXo-XJJuuFJ-FRPTOGMVJHX\
5CT-k258ybtsMBM7sMQhohpjMnJuSZkloj0L6gZltG21syJy-14X4ykU20lfneVn-yKorTo2JbDMWUvWhdEIVt5-5czXmA7\
csWiqmCgrkSHsw0NescOtLT1VfqW2aAd6X8ShsDzyMjfBC5GW84S1uBUURbr0WkcxLZFb_IsKaAA0xsfUri0rHXi35rabhM\
KgH4mn8RztNsyod-tEwLil1LZS2HmoyB8RAC1xh5HIhDe3RJYaHyCdXJqeZjb1KBC1xDQV2QbQZ91y0JZzBXvg6NTFYJ-wb\
CytyOnEbknHG_AM0p-GvxjckkL1PbasHdiOOKyT49hYCEQF0Zrnmq4DlvO46CpLBvpENC3NlqDJ3d3RD9_-WVn1yjVHI4Yl\
VPFAyrYnfhtRF7WlgBPOnq6mPDF17dSIdLLWH3iQQQFfq-zxULLn0RaHWgeWndOaOLVPLe6fxZ_6-_lFLR-Vi-DiJj8dAM4\
lRTUucyoXv93vqzCxN653V-LOTmg7SFpe2OXFsmXHiufoFQcVm-erpNZ3_hlxOOdYJh8wuQUBUKPhwqunTcIHZIr2QqjmnX\
SSmBPw1-HQtWkEVGA5qKWLwciaX74Z8NtUWnbC4ZmYGTbnirIbsZ0EyYRNDvNjNGMoWrJBCyi_K3uP6g1GT-Zhc123dGZqu\
1REqPiCmGK8l3ofOHEVFFG7k-Yc80RiVI7EQ2i5qN7nJEavDetXuC-nlbTbYqjqEut31T9G-IC3HRkHg7HLEBIOVYKbPLe5\
BNzfAYO48YlBc1CLRFcx5LQMM1X_hS_WKLtYPkGxE7BZnR11Acf904kELPbL1-7-ZXnAYiQodwa9wzS6uLqdy6FCuqOG08E\
K2mCDsIZXw3mjqOlDLXSSLNmu6CH5r3aXcnVaYHJQpSryPfNpNltXdYnrxF2CgWHPAOuleCKHY6nH9aKIH6RsFN7ZhgLZl4\
5NnVzU3dx5PiSuwELiXFbziCXqfcbbUrhfwnJKPMxQqxErqadM6u9b4hB4QTZq41_OEu3S3Y8y2E1aGPZ81IMK4QRb5A4wh\
WR9gBGF1FUud9JB5kfI479rpcwlDtoCxTkaixTsU0DwNzqA0gZvXZbJAqtSjyJIhTU_OBz94YzMsYYbuLSi82dm_a90zV6n\
EnCYjuPuS6F2nUgW7BhNRGzusP7oIiECCJM8US2iWAGcQffhsEiwmB7YxET3gTifxNv6TI96muD0qA0G7zoNJkTQ11jxnWk\
PqDHH1Mk545Ge7hL3YvjjFcJwhypNHxJCicQeuqOuzJtiS8DAzC1T7xS6ZbpFi8vcXmmv439RKYw4FoDfkl3JA8dBLM-Dii\
2UlSX0byjppcjDOVX1G4azYoJawXhAchOFTkh7ZgAmAC-XIQYN3KZIwUpWIbnkTt-8V-SQlf56YW6VNNP7dAYM8rhqC7quC\
QfFicZYH32oTenr6-75Bq6dFGCdF4Ieh4M1eVI8tgaOM9X-kVy5gliQ61gyyPFiVLGkPJQfJT6jWPVAu1JrB6v0mNm7G10m\
qUPptxw38K_75Mvqd4D5ye6JUAii9iCMbZ8YMZHD4ncum-WJ2cDofssiXM_psgB6W94-PxxFZxfzL8MIqKe1ozAA8pJIyVu\
gqm-AYcFJprbW3C6cgTfeZNW1_4k0stDgE3N2jCVLvGMKkiGTYC_SwgzqF3DU8Up0p_jMoJQ9hh3re7fjEGuKvtIfc_jeFq\
c9-q1LsVirGizzhS_VTA5Ltnh4quwaIU3zkI_y3N9xV62ijUectvOPWONl42Qm9NUU4fQZynrUbTd9sSjYRNHfveIMDhbCj\
dDWjzHjMgCeUopSyrLLra1uC0NsJrBoq24QMQ00jHgKWOnqpdFTjPopZ-zDcN0E4FkYB8kKbrz4u4ZIn0fNNnE2bzlU8rbH";
assert_eq!(has_session_cookie(cookies).unwrap(), SessionCookiePresence::Present);
}
}
@@ -1 +1,2 @@
pub mod has_session_cookie;
pub mod lightweight_sora_jwt_parse;
@@ -20,11 +20,13 @@ workspace-build-acceleration = { workspace = true }
enums = { workspace = true }
enums_public = { workspace = true }
errors = { workspace = true }
idempotency = { workspace = true }
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 }
@@ -35,7 +37,8 @@ serde_derive = { workspace = true }
serde_json = { workspace = true }
# HTTP
backoff = { version = "0.4.0", features = ["futures"] } # Exponential backoff retry
backoff = { version = "0.4.0", features = ["futures"] }
uuid = { version = "1.8.0", features = ["v4"] } # Exponential backoff retry
[dev-dependencies]
tokio = { version = "1.38.0", features = ["macros"] }
@@ -1,5 +1,6 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
use std::io;
use errors::AnyhowError;
#[derive(Debug)]
@@ -33,6 +34,9 @@ pub enum ApiError {
/// Uncategorized reqwest error.
OtherReqwestError(reqwest::Error),
/// An error doing file I/O (on our side)
IoError(io::Error),
/// Another type of error.
Other(AnyhowError),
@@ -55,6 +59,8 @@ impl Display for ApiError {
// Network errors
ApiError::Timeout(msg) => write!(f, "Timeout: {}", msg),
ApiError::NetworkError(msg) => write!(f, "Network error: {}", msg),
// I/O errors
ApiError::IoError(error) => write!(f, "IO error: {}", error),
// Other
ApiError::OtherReqwestError(error) => write!(f, "Reqwest error: {}", error),
ApiError::Other(error) => write!(f, "Other error: {}", error),
@@ -79,3 +85,9 @@ impl From<serde_json::Error> for ApiError {
ApiError::DeserializationError(error)
}
}
impl From<io::Error> for ApiError {
fn from(error: io::Error) -> Self {
ApiError::IoError(error)
}
}
@@ -0,0 +1,3 @@
pub mod storyteller_avt_cookie;
pub mod storyteller_credential_set;
pub mod storyteller_session_cookie;
@@ -0,0 +1,44 @@
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
}
pub fn as_cookie_header(&self) -> String {
format!("{}={}", AVT_COOKIE_NAME, self.cookie)
}
}
@@ -0,0 +1,136 @@
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
}
pub fn maybe_as_cookie_header(&self) -> Option<String> {
let mut cookies = Vec::new();
if let Some(avt) = &self.avt {
cookies.push(avt.as_cookie_header());
}
if let Some(session) = &self.session {
cookies.push(session.as_cookie_header());
}
if cookies.is_empty() {
None
} else {
Some(cookies.join("; "))
}
}
}
#[cfg(test)]
mod tests {
use crate::credentials::storyteller_avt_cookie::StorytellerAvtCookie;
use crate::credentials::storyteller_credential_set::StorytellerCredentialSet;
use crate::credentials::storyteller_session_cookie::StorytellerSessionCookie;
mod cookies_header {
use super::*;
#[test]
fn no_cookie() {
let creds = StorytellerCredentialSet::empty();
let header = creds.maybe_as_cookie_header();
assert_eq!(header, None);
}
#[test]
fn avt_cookies_header() {
let creds = StorytellerCredentialSet::initialize_with_just_avt(
StorytellerAvtCookie::new("bob".to_string()),
);
let header = creds.maybe_as_cookie_header();
assert_eq!(header, Some("visitor=bob".to_string()));
}
#[test]
fn session_cookies_header() {
let creds = StorytellerCredentialSet::initialize_with_just_cookie(
StorytellerSessionCookie::new("bob".to_string()),
);
let header = creds.maybe_as_cookie_header();
assert_eq!(header, Some("session=bob".to_string()));
}
#[test]
fn both_cookies_header() {
let creds = StorytellerCredentialSet::initialize(
Some(StorytellerAvtCookie::new("bob".to_string())),
Some(StorytellerSessionCookie::new("alice".to_string())),
);
let header = creds.maybe_as_cookie_header();
assert_eq!(header, Some("visitor=bob; session=alice".to_string()));
}
}
}
@@ -0,0 +1,43 @@
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
}
pub fn as_cookie_header(&self) -> String {
format!("{}={}", SESSION_COOKIE_NAME, self.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;
@@ -1,18 +1,3 @@
use chrono::{DateTime, Utc};
use log::debug;
use serde_derive::{Deserialize};
use enums::by_table::media_files::media_file_animation_type::MediaFileAnimationType;
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_engine_category::MediaFileEngineCategory;
use enums::by_table::media_files::media_file_type::MediaFileType;
use enums::by_table::model_weights::weights_category::WeightsCategory;
use enums::common::visibility::Visibility;
use enums::no_table::style_transfer::style_transfer_name::StyleTransferName;
use enums_public::by_table::model_weights::public_weights_types::PublicWeightsType;
use tokens::tokens::batch_generations::BatchGenerationToken;
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::model_weights::ModelWeightToken;
use tokens::tokens::prompts::PromptToken;
use crate::api_error::ApiError;
use crate::shared_response_types::media_file_cover_image_details::MediaFileCoverImageDetails;
use crate::shared_response_types::media_links::MediaLinks;
@@ -21,6 +6,21 @@ use crate::shared_response_types::user_details_light::UserDetailsLight;
use crate::utils::api_host::ApiHost;
use crate::utils::filter_bad_response::filter_bad_response;
use crate::utils::http_get_anonymous::http_get_anonymous;
use chrono::{DateTime, Utc};
use enums::by_table::media_files::media_file_animation_type::MediaFileAnimationType;
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_engine_category::MediaFileEngineCategory;
use enums::by_table::media_files::media_file_type::MediaFileType;
use enums::by_table::model_weights::weights_category::WeightsCategory;
use enums::common::visibility::Visibility;
use enums::no_table::style_transfer::style_transfer_name::StyleTransferName;
use enums_public::by_table::model_weights::public_weights_types::PublicWeightsType;
use log::debug;
use serde_derive::Deserialize;
use tokens::tokens::batch_generations::BatchGenerationToken;
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::model_weights::ModelWeightToken;
use tokens::tokens::prompts::PromptToken;
/// Get details about a media file from our backend
pub async fn get_media_file(api_host: &ApiHost, media_file_token: &MediaFileToken) -> Result<GetMediaFileSuccessResponse, ApiError> {
@@ -205,9 +205,9 @@ pub struct GetMediaFileModelInfo {
#[cfg(test)]
mod tests {
use tokens::tokens::media_files::MediaFileToken;
use crate::media_files::get_media_file::get_media_file;
use crate::utils::api_host::ApiHost;
use tokens::tokens::media_files::MediaFileToken;
#[tokio::test]
#[ignore] // Don't run in CI. Requires valid cookie
@@ -1 +1,2 @@
pub mod get_media_file;
pub mod get_media_file;
pub mod upload_image_media_file_from_file;
@@ -0,0 +1,90 @@
use crate::api_error::ApiError;
use crate::credentials::storyteller_credential_set::StorytellerCredentialSet;
use crate::shared_response_types::media_file_cover_image_details::MediaFileCoverImageDetails;
use crate::shared_response_types::media_links::MediaLinks;
use crate::shared_response_types::simple_entity_stats::SimpleEntityStats;
use crate::shared_response_types::user_details_light::UserDetailsLight;
use crate::utils::api_host::ApiHost;
use crate::utils::constants::{APPLICATION_JSON, USER_AGENT};
use crate::utils::filter_bad_response::filter_bad_response;
use crate::utils::http_get_anonymous::http_get_anonymous;
use chrono::{DateTime, Utc};
use enums::by_table::media_files::media_file_animation_type::MediaFileAnimationType;
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_engine_category::MediaFileEngineCategory;
use enums::by_table::media_files::media_file_type::MediaFileType;
use enums::by_table::model_weights::weights_category::WeightsCategory;
use enums::common::visibility::Visibility;
use enums::no_table::style_transfer::style_transfer_name::StyleTransferName;
use enums_public::by_table::model_weights::public_weights_types::PublicWeightsType;
use idempotency::uuid::generate_random_uuid;
use log::debug;
use reqwest::multipart::Form;
use reqwest::Client;
use serde_derive::{Deserialize, Serialize};
use std::fs::File;
use std::io::Read;
use std::path::Path;
use tokens::tokens::batch_generations::BatchGenerationToken;
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::model_weights::ModelWeightToken;
use tokens::tokens::prompts::PromptToken;
use uuid::uuid;
/// Upload an image media file from a file.
pub async fn upload_image_media_file_from_file<P: AsRef<Path>>(
api_host: &ApiHost,
maybe_creds: Option<&StorytellerCredentialSet>,
path: P,
) -> Result<UploadImageMediaFileSuccessResponse, ApiError> {
let url = get_route(api_host);
debug!("Requesting {:?}", &url);
//let mut file = File::open(path)?;
let client = Client::builder()
.gzip(true)
.build()?;
let form = Form::new()
.text("uuid_idempotency_token", generate_random_uuid())
.file("file", path)
.await?;
let mut request_builder = client.post(url)
.header("User-Agent", USER_AGENT)
.header("Accept", APPLICATION_JSON);
if let Some(creds) = maybe_creds {
if let Some(header) = &creds.maybe_as_cookie_header() {
request_builder = request_builder.header("Cookie", header);
}
}
let response = request_builder
.multipart(form)
.send()
.await?;
let response = filter_bad_response(response).await?;
let response_body = &response.text().await?;
let media_file = serde_json::from_str(&response_body)?;
Ok(media_file)
}
fn get_route(api_host: &ApiHost) -> String {
let api_hostname = api_host.to_api_hostname();
format!("https://{}/v1/media_files/upload/image", api_hostname)
}
// TODO(bt,2025-04-22): Share API definitions between client and server in common crate.
#[derive(Deserialize, Debug)]
pub struct UploadImageMediaFileSuccessResponse {
pub success: bool,
pub media_file_token: MediaFileToken,
}
@@ -0,0 +1,4 @@
pub const USER_AGENT: &str = "storyteller-client/1.0";
pub const APPLICATION_JSON : &str = "application/json";
@@ -1,4 +1,5 @@
pub mod api_host;
pub mod constants;
pub mod filter_bad_response;
pub mod http_get_anonymous;
pub mod status_codes;

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