Compare commits

...
Author SHA1 Message Date
Brandon Thomas 6caced8820 manage api key in frontend 2025-05-13 08:15:19 -04:00
Brandon Thomas efef2ea206 end-to-end removal works! 2025-05-13 07:37:19 -04:00
Brandon Thomas 8d93104aa9 fix upload 2025-05-13 07:33:43 -04:00
Brandon Thomas f996ee85e9 almost end-to-end working 2025-05-13 07:24:26 -04:00
Brandon Thomas 3936ee4b6d endpoint handles media files and base64 images 2025-05-13 06:38:50 -04:00
Brandon Thomas 1998da9813 fal background removal endpoint 2025-05-13 06:10:03 -04:00
Brandon Thomas f01154f601 persist fal keys to disk 2025-05-13 05:33:18 -04:00
Brandon Thomas a9ccfdf9cd fal api key holder 2025-05-13 04:50:27 -04:00
Brandon Thomas c9ccb7680f remove background with fal 2025-05-13 04:31:35 -04:00
Brandon Thomas e0df1644a5 switch order of 2d/3d 2025-05-12 19:11:29 -04:00
Brandon Thomas 736d033ebf status alerts on 2d side 2025-05-12 18:54:42 -04:00
Brandon Thomas af3f0baa63 handle sora logged out state after app starts 2025-05-12 18:44:54 -04:00
Brandon Thomas a438dd13dc add Google Analytics GA4 2025-05-12 18:10:23 -04:00
Tanish Manku f7b76a7abe Add manual mouse support in Safari and AppleWebkit (#606) 2025-05-12 15:02:56 -07:00
Tanish Manku 9435af3e2a Change the relative pathing from base config (#605) 2025-05-12 14:23:09 -07:00
Tanish Manku 77efda1d66 Make 2D and 3D mount at the same time (#604) 2025-05-12 14:15:02 -07:00
Brandon Thomas fdc27b6ccd surface non-onboarding; do not retry non-onboarded requests 2025-05-12 17:00:25 -04:00
Brandon Thomas e3abd500db Explicit CSP on Mac instead of wildcards; this works on production Mac but may not fix Windows (#603) 2025-05-12 15:32:49 -04:00
Brandon Thomas ee4de4126d timeouts for uploads / remix (#602) 2025-05-12 12:26:41 -04:00
Brandon Thomas 4810ed30ae cache sora stats 2025-05-11 04:41:51 -04:00
Brandon Thomas 0b6593a063 fix preferences 2025-05-11 04:00:25 -04:00
Brandon Thomas b0e2dce140 fix event listeners for toasts; extra sounds (#601) 2025-05-11 03:41:14 -04:00
Brandon Thomas c4f401923b various sora / generate improvements (#600) 2025-05-11 01:02:00 -04:00
Brandon Thomas 24559a3554 username error 2025-05-09 19:00:28 -04:00
ArEnScandMichael Chung 5a26aed728 The bug was due to forggeting to bind this to the snapShotOfCurrentFr… (#599)
fix the generation problem

---------

Co-authored-by: Michael Chung <michaelchung@michaels-mbp-2.home>
2025-05-09 18:07:55 -04:00
Tanish Manku 95773c1c23 Point 3D tsconfig to the right subdirectory (#598) 2025-05-09 14:27:13 -07:00
Michael Chung 2d01073434 Remove tab selector 2025-05-09 13:56:00 -04:00
BFlat 1c62092d94 fix 3d resizing bug (#597) 2025-05-09 13:13:53 -04:00
bflatastic 5e22e17dfb popover width fix 2025-05-09 22:49:05 +07:00
bflatastic 611da775a9 bug fix: add image fix 2025-05-09 22:23:42 +07:00
bflatastic 6fc3f73579 artcraft block color update 2025-05-09 22:10:53 +07:00
BFlat 562cb99c51 2d editor fixes (#596)
- left toolbar now updates properly
- modals open properly
- styling fixes
- adjusted canvas center positioning to accommodate top header bar
2025-05-09 21:53:58 +07:00
bflatastic 67d280df84 remove old demo modal 2025-05-09 20:38:05 +07:00
8ccc8927a9 Feature/merge 2d 3d diverge (#593)
Co-authored-by: Michael Chung <michaelchung@michaels-mbp-2.home>
Co-authored-by: Tanish Manku <tanish2k09.dev@gmail.com>
2025-05-08 23:46:30 -04:00
Brandon Thomas a21e7331cc persist window position to disk and restore it 2025-05-08 22:54:58 -04:00
Brandon Thomas 0f0f8eb39c window size heuristic to keep dev tools from resizing window 2025-05-08 22:32:02 -04:00
Brandon Thomas b64ed411b1 artcraft account login/logout (#595) 2025-05-08 21:33:38 -04:00
ArEnScandMichael Chung a155b419e1 Fix 3d editor menu buttons hide the scene saving and loading. (#594)
Co-authored-by: Michael Chung <michaelchung@michaels-mbp-2.home>
2025-05-08 18:53:04 -04:00
Brandon Thomas e6177005df never block the prompt box 2025-05-08 17:59:32 -04:00
Brandon Thomas 1c78e17565 events for enqueue success / fail + more sounds and toasts (#592) 2025-05-08 17:43:12 -04:00
Brandon Thomas 0769bfad28 sora logout handler 2025-05-08 14:00:14 -04:00
BFlat 183946a943 Bflat/settings pane update (#591) 2025-05-08 17:40:54 +07:00
BFlat d43ee0f6c9 login modal and settings panel update (#590)
- login now flows automatically smoothly to the next step after login
- settings modal: misc/audio panes styling and toggle switch
- account settings disconnect sora account button

remaining TODO: 
- link logout sora endpoint
2025-05-08 14:21:01 +07:00
Brandon Thomas eec59c218d add login success event 2025-05-08 01:07:33 -04:00
Brandon Thomas 5c1e72f8f4 rename package 2025-05-07 23:59:44 -04:00
Brandon Thomas d91243b1f5 success and failure toasts; less discord info spam 2025-05-07 23:47:30 -04:00
Brandon Thomas 83cecad280 build script for mac 2025-05-07 23:13:23 -04:00
Brandon Thomas 06313f54a5 update settings panes (#588) 2025-05-07 22:52:47 -04:00
Brandon Thomas ef3ca88054 sound playing restored 2025-05-07 22:02:38 -04:00
Brandon Thomas cfcf7ab5aa refactor events: centrally located in rust for better discoverability 2025-05-07 21:15:13 -04:00
Brandon Thomas 4cc215495a slower polling 2025-05-07 19:11:19 -04:00
Brandon Thomas f1d14ab73f better scripts for unix development 2025-05-07 17:59:49 -04:00
Brandon Thomas 5cb8f5b93a cloudflare 524 2025-05-07 17:35:38 -04:00
Brandon Thomas 9decf8bf88 fix windows builds 2025-05-07 15:35:28 -04:00
Brandon Thomas 63a7f99c53 fix windows build issue 2025-05-07 14:55:48 -04:00
Brandon Thomas 9dd16f2e80 windows production build script 2025-05-07 14:42:40 -04:00
Brandon Thomas abe2d2a093 Windows scripts for running dev mode 2025-05-07 14:31:07 -04:00
bflatastic 552d5553c0 uncommented login modal and added temp next button 2025-05-07 23:15:59 +07:00
BFlat 0d33358ead upload flow update (#587)
- changed and moved the main upload asset button to the left sidebar
above the categories for better visibility
- added individual upload buttons to all the category pages except
skybox - they open the same upload modal but sepcific to its category
only.
- flow update: once an upload succeeds the upload modal closes itself
and automatically reopens the asset modal to the mine tab and previously
uploaded category (so users don't have to have a hard time finding or
navigating to it)
2025-05-07 16:18:45 +07:00
Brandon Thomas 908ff43be3 fix sora / chatgpt login, enhance login flow (#586) 2025-05-07 03:33:53 -04:00
452 changed files with 21323 additions and 4962 deletions
Generated
+157 -89
View File
@@ -463,7 +463,7 @@ dependencies = [
"getrandom 0.2.14",
"once_cell",
"version_check",
"zerocopy 0.7.33",
"zerocopy",
]
[[package]]
@@ -536,7 +536,7 @@ dependencies = [
"easyenv",
"enums",
"errors",
"futures 0.3.29",
"futures 0.3.31",
"hostname 0.4.0",
"log",
"mysql_queries",
@@ -646,9 +646,9 @@ checksum = "70033777eb8b5124a81a1889416543dddef2de240019b674c81285a2635a7e1e"
[[package]]
name = "anyhow"
version = "1.0.97"
version = "1.0.98"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dcfed56ad506cb2c684a14971b8861fdc3baaaae314b9e5f9bb532cbe3ba7a4f"
checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487"
dependencies = [
"backtrace",
]
@@ -817,18 +817,20 @@ dependencies = [
"dirs 6.0.0",
"discord-rich-presence",
"errors",
"fal_client",
"filesys",
"futures 0.3.29",
"futures 0.3.31",
"image",
"indexmap 2.9.0",
"libloading 0.8.6",
"log",
"memory_caching",
"memory_store",
"mimetypes",
"once_cell",
"openai_sora_client",
"primitives",
"rand 0.9.0",
"rand 0.9.1",
"reqwest 0.12.15",
"serde",
"serde_derive",
@@ -1063,7 +1065,7 @@ dependencies = [
"serde_path_to_error",
"serde_qs 0.10.1",
"sha2 0.10.8",
"smart-default",
"smart-default 0.6.0",
"smol_str",
"thiserror 1.0.63",
"tokio",
@@ -1374,7 +1376,7 @@ dependencies = [
"serde_json",
"sqlx",
"tokio",
"toml 0.8.20",
"toml 0.8.22",
"url_config",
"user_traits_component",
"utoipa",
@@ -2052,7 +2054,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02260d489095346e5cafd04dea8e8cb54d1d74fcd759022a9b72986ebe9a1257"
dependencies = [
"serde",
"toml 0.8.20",
"toml 0.8.22",
]
[[package]]
@@ -2155,9 +2157,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chrono"
version = "0.4.40"
version = "0.4.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a7964611d71df112cb1730f2ee67324fcf4d0fc6606acbbe9bfe06df124637c"
checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d"
dependencies = [
"android-tzdata",
"iana-time-zone",
@@ -3105,7 +3107,7 @@ dependencies = [
"easyenv",
"enums",
"errors",
"futures 0.3.29",
"futures 0.3.31",
"log",
"mysql_queries",
"serde",
@@ -3936,7 +3938,7 @@ dependencies = [
"cc",
"memchr",
"rustc_version 0.4.0",
"toml 0.8.20",
"toml 0.8.22",
"vswhom",
"winreg 0.52.0",
]
@@ -4024,7 +4026,7 @@ dependencies = [
"sqlx-core",
"sqlx-mysql",
"strum",
"toml 0.8.20",
"toml 0.8.22",
"utoipa",
"workspace-build-acceleration",
]
@@ -4038,7 +4040,7 @@ dependencies = [
"serde_derive",
"serde_json",
"strum",
"toml 0.8.20",
"toml 0.8.22",
"utoipa",
"workspace-build-acceleration",
]
@@ -4114,7 +4116,7 @@ dependencies = [
"elasticsearch_schema",
"enums",
"errors",
"futures 0.3.29",
"futures 0.3.31",
"hostname 0.4.0",
"log",
"mysql_queries",
@@ -4180,6 +4182,17 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "eventsource-stream"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74fef4569247a5f429d9156b9d0a2599914385dd189c539334c625d8099d90ab"
dependencies = [
"futures-core",
"nom",
"pin-project-lite",
]
[[package]]
name = "exr"
version = "1.73.0"
@@ -4201,6 +4214,51 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365"
[[package]]
name = "fal"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd7dd8565317310f05569296166e2cbdd2bc870173b2b24b77ed681166268d0"
dependencies = [
"base64 0.22.1",
"eventsource-stream",
"fal-derive",
"futures 0.3.31",
"image",
"reqwest 0.12.15",
"serde",
"serde_json",
"smart-default 0.7.1",
"thiserror 1.0.63",
]
[[package]]
name = "fal-derive"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87f377f2f18a6bf107f907aa687df6bda2874bd38b25b1531f2a6fb38a9ca643"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.87",
]
[[package]]
name = "fal_client"
version = "0.0.1"
dependencies = [
"anyhow",
"errors",
"fal",
"file_to_base64_url",
"filesys",
"futures 0.3.31",
"testing",
"tokio",
"url",
"workspace-build-acceleration",
]
[[package]]
name = "fancy-regex"
version = "0.13.0"
@@ -4281,6 +4339,19 @@ dependencies = [
"rustc_version 0.4.0",
]
[[package]]
name = "file_to_base64_url"
version = "0.0.1"
dependencies = [
"anyhow",
"base64 0.22.1",
"errors",
"filesys",
"mimetypes",
"testing",
"workspace-build-acceleration",
]
[[package]]
name = "filesys"
version = "0.0.1"
@@ -4456,9 +4527,9 @@ checksum = "3a471a38ef8ed83cd6e40aa59c1ffe17db6855c18e3604d9c4ed8c08ebc28678"
[[package]]
name = "futures"
version = "0.3.29"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da0290714b38af9b4a7b094b8a37086d1b4e61f2df9122c3cad2577669145335"
checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
dependencies = [
"futures-channel",
"futures-core",
@@ -4471,9 +4542,9 @@ dependencies = [
[[package]]
name = "futures-channel"
version = "0.3.30"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78"
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
dependencies = [
"futures-core",
"futures-sink",
@@ -4481,15 +4552,15 @@ dependencies = [
[[package]]
name = "futures-core"
version = "0.3.30"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d"
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
[[package]]
name = "futures-executor"
version = "0.3.30"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a576fc72ae164fca6b9db127eaa9a9dda0d61316034f33a0a0d4eda41f02b01d"
checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f"
dependencies = [
"futures-core",
"futures-task",
@@ -4510,9 +4581,9 @@ dependencies = [
[[package]]
name = "futures-io"
version = "0.3.30"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1"
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
[[package]]
name = "futures-lite"
@@ -4544,9 +4615,9 @@ dependencies = [
[[package]]
name = "futures-macro"
version = "0.3.30"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac"
checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
dependencies = [
"proc-macro2",
"quote",
@@ -4555,21 +4626,21 @@ dependencies = [
[[package]]
name = "futures-sink"
version = "0.3.30"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5"
checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7"
[[package]]
name = "futures-task"
version = "0.3.30"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004"
checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
[[package]]
name = "futures-util"
version = "0.3.30"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48"
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
dependencies = [
"futures-channel",
"futures-core",
@@ -5361,7 +5432,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112fa2f6ad4ab815b9e1b938b4b1e437032d055e2f92ed10fd6ab2e62d02c6b6"
dependencies = [
"dirs 5.0.1",
"futures 0.3.29",
"futures 0.3.31",
"http 1.1.0",
"indicatif",
"log",
@@ -7147,7 +7218,7 @@ dependencies = [
"sqlx",
"tokens",
"tokio",
"toml 0.8.20",
"toml 0.8.22",
"workspace-build-acceleration",
]
@@ -7320,7 +7391,7 @@ dependencies = [
"directories 6.0.0",
"dirs 6.0.0",
"filesys",
"futures 0.3.29",
"futures 0.3.31",
"half",
"hf-hub 0.3.2",
"image",
@@ -7834,7 +7905,7 @@ dependencies = [
"log",
"serde",
"serde_derive",
"toml 0.8.20",
"toml 0.8.22",
]
[[package]]
@@ -8178,7 +8249,7 @@ dependencies = [
"sha2 0.10.8",
"tar",
"thiserror 1.0.63",
"toml 0.8.20",
"toml 0.8.22",
"ureq",
"url",
"uuid 1.8.0",
@@ -8240,7 +8311,7 @@ dependencies = [
"idempotency",
"log",
"once_cell",
"rand 0.9.0",
"rand 0.9.1",
"reqwest 0.12.15",
"serde",
"serde_derive",
@@ -8250,7 +8321,7 @@ dependencies = [
"testing",
"thiserror 2.0.12",
"tokio",
"toml 0.8.20",
"toml 0.8.22",
"workspace-build-acceleration",
]
@@ -9555,13 +9626,12 @@ dependencies = [
[[package]]
name = "rand"
version = "0.9.0"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3779b94aeb87e8bd4e834cee3650289ee9e0d5677f976ecdb6d219e5f4f6cd94"
checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97"
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.3",
"zerocopy 0.8.24",
]
[[package]]
@@ -9791,7 +9861,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6435842fc2fea44b528719eb8c32203bbc1bb2f5b619fbe0c0a3d8350fd8d2a8"
dependencies = [
"bytes 1.10.0",
"futures 0.3.29",
"futures 0.3.31",
"pin-project-lite",
]
@@ -10095,7 +10165,7 @@ dependencies = [
"serde",
"serde_derive",
"sqlx",
"toml 0.8.20",
"toml 0.8.22",
"workspace-build-acceleration",
]
@@ -10284,7 +10354,7 @@ dependencies = [
"base64 0.22.1",
"bytes 1.10.0",
"cfg-if 1.0.0",
"futures 0.3.29",
"futures 0.3.31",
"hex",
"hmac 0.12.1",
"http 1.1.0",
@@ -10977,7 +11047,7 @@ version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b258109f244e1d6891bf1053a55d63a5cd4f8f4c30cf9a1280989f80e7a1fa9"
dependencies = [
"futures 0.3.29",
"futures 0.3.31",
"log",
"once_cell",
"parking_lot 0.12.2",
@@ -11243,6 +11313,17 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "smart-default"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eb01866308440fc64d6c44d9e86c5cc17adfe33c4d6eed55da9145044d0ffc1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.87",
]
[[package]]
name = "smol_str"
version = "0.1.24"
@@ -11670,7 +11751,7 @@ dependencies = [
"errors",
"filesys",
"futures 0.1.31",
"futures 0.3.29",
"futures 0.3.31",
"futures-core",
"futures-util",
"google_sign_in",
@@ -11702,7 +11783,7 @@ dependencies = [
"prompt_engineering",
"pulldown-cmark 0.11.0",
"r2d2_redis",
"rand 0.9.0",
"rand 0.9.1",
"redis_caching",
"redis_common",
"redis_schema",
@@ -11728,7 +11809,7 @@ dependencies = [
"time",
"tokens",
"tokio",
"toml 0.8.20",
"toml 0.8.22",
"tts_common",
"url",
"url_config",
@@ -12393,7 +12474,7 @@ dependencies = [
"cfg-expr",
"heck 0.5.0",
"pkg-config",
"toml 0.8.20",
"toml 0.8.22",
"version-compare",
]
@@ -12538,7 +12619,7 @@ dependencies = [
"serde_json",
"tauri-utils",
"tauri-winres",
"toml 0.8.20",
"toml 0.8.22",
"walkdir",
]
@@ -12596,7 +12677,7 @@ dependencies = [
"serde",
"serde_json",
"tauri-utils",
"toml 0.8.20",
"toml 0.8.22",
"walkdir",
]
@@ -12636,7 +12717,7 @@ dependencies = [
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.12",
"toml 0.8.20",
"toml 0.8.22",
"url",
"uuid 1.8.0",
]
@@ -12783,7 +12864,7 @@ dependencies = [
"serde_with 3.12.0",
"swift-rs",
"thiserror 2.0.12",
"toml 0.8.20",
"toml 0.8.22",
"url",
"urlpattern",
"uuid 1.8.0",
@@ -12797,7 +12878,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56eaa45f707bedf34d19312c26d350bc0f3c59a47e58e8adbeecdc850d2c13a0"
dependencies = [
"embed-resource",
"toml 0.8.20",
"toml 0.8.22",
]
[[package]]
@@ -13123,16 +13204,16 @@ dependencies = [
"sqlx-core",
"sqlx-mysql",
"strum",
"toml 0.8.20",
"toml 0.8.22",
"utoipa",
"workspace-build-acceleration",
]
[[package]]
name = "tokio"
version = "1.43.0"
version = "1.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d61fa4ffa3de412bfea335c6ecff681de2b609ba3c77ef3e00e521813a9ed9e"
checksum = "2513ca694ef9ede0fb23fe71a4ee4107cb102b9dc1930f6d0fd77aae068ae165"
dependencies = [
"backtrace",
"bytes 1.10.0",
@@ -13332,21 +13413,21 @@ dependencies = [
[[package]]
name = "toml"
version = "0.8.20"
version = "0.8.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd87a5cdd6ffab733b2f74bc4fd7ee5fff6634124999ac278c35fc78c6120148"
checksum = "05ae329d1f08c4d17a59bed7ff5b5a769d062e64a62d34a3261b219e62cd5aae"
dependencies = [
"serde",
"serde_spanned",
"toml_datetime",
"toml_edit 0.22.24",
"toml_edit 0.22.26",
]
[[package]]
name = "toml_datetime"
version = "0.6.8"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41"
checksum = "3da5db5a963e24bc68be8b17b6fa82814bb22ee8660f192bb182771d498f09a3"
dependencies = [
"serde",
]
@@ -13388,17 +13469,24 @@ dependencies = [
[[package]]
name = "toml_edit"
version = "0.22.24"
version = "0.22.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17b4795ff5edd201c7cd6dca065ae59972ce77d1b80fa0a84d94950ece7d1474"
checksum = "310068873db2c5b3e7659d2cc35d21855dbafa50d1ce336397c666e3cb08137e"
dependencies = [
"indexmap 2.9.0",
"serde",
"serde_spanned",
"toml_datetime",
"toml_write",
"winnow 0.7.6",
]
[[package]]
name = "toml_write"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfb942dfe1d8e29a7ee7fcbde5bd2b9a25fb89aa70caea2eba3bee836ff41076"
[[package]]
name = "tonic"
version = "0.11.0"
@@ -15186,7 +15274,7 @@ dependencies = [
"digest 0.10.7",
"either",
"flate2",
"futures 0.3.29",
"futures 0.3.31",
"futures-channel",
"futures-core",
"futures-executor",
@@ -15504,16 +15592,7 @@ version = "0.7.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "087eca3c1eaf8c47b94d02790dd086cd594b912d2043d4de4bfdd466b3befb7c"
dependencies = [
"zerocopy-derive 0.7.33",
]
[[package]]
name = "zerocopy"
version = "0.8.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2586fea28e186957ef732a5f8b3be2da217d65c5969d4b1e17f973ebbe876879"
dependencies = [
"zerocopy-derive 0.8.24",
"zerocopy-derive",
]
[[package]]
@@ -15527,17 +15606,6 @@ dependencies = [
"syn 2.0.87",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a996a8f63c5c4448cd959ac1bab0aaa3306ccfd060472f85943ee0750f0169be"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.87",
]
[[package]]
name = "zerofrom"
version = "0.1.5"
+9 -4
View File
@@ -30,6 +30,7 @@ members = [
"crates/lib/actix_cors_configs",
"crates/lib/actix_helpers",
"crates/lib/api_clients/cloud_storage",
"crates/lib/api_clients/fal_client",
"crates/lib/api_clients/google_drive_common",
"crates/lib/api_clients/google_sign_in",
"crates/lib/api_clients/openai_sora_client",
@@ -54,6 +55,7 @@ members = [
"crates/lib/email_sender",
"crates/lib/errors",
"crates/lib/files/archives",
"crates/lib/files/file_to_base64_url",
"crates/lib/files/filesys",
"crates/lib/files/hashing",
"crates/lib/files/images",
@@ -157,15 +159,16 @@ workspace-build-acceleration = { path = "crates/workspace-build-acceleration" }
# External libs
# Centrally pinned versions so that we can uniformly upgrade/downgrade versions across the workspace
# If a crate needs to use an older/newer version, the dependency should be managed in that crate's own Cargo.toml
anyhow = "1.0.97"
anyhow = "1.0.98"
base64 = "0.22.1"
chrono = { version = "0.4.40", features = ["serde"] }
chrono = { version = "0.4.41", features = ["serde"] }
cookie = "0.18.1"
futures = { version = "0.3.31" }
hex-literal = "1.0.0"
indexmap = "2.9.0"
log = "0.4.27"
once_cell = "1.21.3"
rand = "0.9.0"
rand = "0.9.1"
regex = "1.11.1"
reqwest = { version = "0.12.15", features = ["json", "cookies"] }
serde = { version = "1.0.219", features = ["derive"] }
@@ -175,7 +178,8 @@ serde_yaml = "0.9.1"
sha3 = "0.10.8"
tauri = { version = "2.4.1", features = ["unstable", "devtools"] } # NB: "devtools" prevents App Store distribution
thiserror = "2.0.12"
toml = "0.8.20"
tokio = { version = "1.45.0", features = ["macros"] }
toml = "0.8.22"
url = { version = "2.5.4", features = ["serde"] }
# Internal libs
@@ -183,6 +187,7 @@ crockford = { path = "crates/lib/data/crockford" }
enums = { path = "crates/_types/enums" }
enums_public = { path = "crates/_types/enums_public" }
errors = { path = "crates/lib/errors" }
fal_client = { path = "crates/lib/api_clients/fal_client" }
filesys = { path = "crates/lib/files/filesys" }
idempotency = { path = "crates/lib/idempotency" }
images = { path = "crates/lib/files/images" }
+2
View File
@@ -25,9 +25,11 @@ workspace-build-acceleration = { workspace = true }
# Internal
crockford = { workspace = true }
errors = { workspace = true }
fal_client = { workspace = true }
filesys = { workspace = true }
memory_caching = { workspace = true }
memory_store = { workspace = true }
mimetypes = { workspace = true }
openai_sora_client = { workspace = true }
primitives = { workspace = true }
storyteller_client = { workspace = true }
@@ -17,7 +17,15 @@ pub struct AppPreferencesPayload {
/// Play sounds on events.
pub play_sounds: bool,
/// Key pointing to file; defined in the frontend code.
/// Defined for enqueue since image enqueue can be async
pub enqueue_success_sound: Option<String>,
/// Key pointing to file; defined in the frontend code.
/// Defined for enqueue since image enqueue can be async
pub enqueue_failure_sound: Option<String>,
/// Key pointing to file; defined in the frontend code.
pub generation_success_sound: Option<String>,
@@ -25,6 +33,7 @@ pub struct AppPreferencesPayload {
pub generation_failure_sound: Option<String>,
/// Key pointing to file; defined in the frontend code.
#[deprecated]
pub generation_enqueue_sound: Option<String>,
}
@@ -52,6 +61,8 @@ async fn get_prefs(app_prefs: &AppPreferencesManager) -> AnyhowResult<AppPrefere
Ok(AppPreferencesPayload {
preferred_download_directory: prefs.preferred_download_directory,
play_sounds: prefs.play_sounds,
enqueue_success_sound: prefs.enqueue_success_sound,
enqueue_failure_sound: prefs.enqueue_failure_sound,
generation_success_sound: prefs.generation_success_sound,
generation_failure_sound: prefs.generation_failure_sound,
generation_enqueue_sound: prefs.generation_enqueue_sound,
@@ -29,9 +29,10 @@ pub enum ValueType {
pub enum PreferenceName {
PreferredDownloadDirectory,
PlaySounds,
EnqueueSuccessSound,
EnqueueFailureSound,
GenerationSuccessSound,
GenerationFailureSound,
GenerationEnqueueSound,
}
#[derive(Serialize)]
@@ -85,6 +86,16 @@ async fn update_prefs(
return Err(anyhow!("Invalid value: {:?}", request.value)),
}
}
PreferenceName::EnqueueSuccessSound => {
prefs.enqueue_success_sound = request.value
.map(|val| string_value(&val))
.transpose()?;
}
PreferenceName::EnqueueFailureSound => {
prefs.enqueue_failure_sound = request.value
.map(|val| string_value(&val))
.transpose()?;
}
PreferenceName::GenerationSuccessSound => {
prefs.generation_success_sound = request.value
.map(|val| string_value(&val))
@@ -95,11 +106,6 @@ async fn update_prefs(
.map(|val| string_value(&val))
.transpose()?;
}
PreferenceName::GenerationEnqueueSound => {
prefs.generation_enqueue_sound = request.value
.map(|val| string_value(&val))
.transpose()?;
}
}
app_prefs.set_clone(&prefs)?;
@@ -0,0 +1,132 @@
use serde::Serialize;
use std::error::Error;
use std::str::FromStr;
/// Easy to use Result<T, E> type.
pub type CommandResult<SuccessPayload, ErrType, ErrPayload> =
Result<CommandSuccessResponseWrapper<SuccessPayload>, CommandErrorResponseWrapper<ErrType, ErrPayload>>;
/// No inner payloads for this type. Just strings as messages.
pub type SimpleCommandResult = CommandResult<(), (), ()>;
/// Statuses for successful commands.
#[derive(Serialize, Clone, Debug)]
#[serde(rename_all = "snake_case")]
pub enum CommandSuccessStatus {
Success, // 200
}
/// Statuses for errorful commands.
#[derive(Serialize, Clone, Debug)]
#[serde(rename_all = "snake_case")]
pub enum CommandErrorStatus {
BadRequest, // 400
Unauthorized, // 401
TooManyRequests, // 429
ServerError, // 500
}
#[derive(Serialize, Debug)]
pub struct CommandSuccessResponseWrapper<T: Serialize> {
/// Both "success" and "error" types have a `status` field for the frontend.
pub status: CommandSuccessStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub success_message: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub payload: Option<T>,
}
#[derive(Serialize, Debug)]
pub struct CommandErrorResponseWrapper<ErrType: Serialize, ErrPayload: Serialize> {
/// Both "success" and "error" types have a `status` field for the frontend.
pub status: CommandErrorStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub error_message: Option<String>,
/// Represents a "type" of error, preferably an enum without payloads.
/// Preferably this serializes to a string.
#[serde(skip_serializing_if = "Option::is_none")]
pub error_type: Option<ErrType>,
/// Represents a full error details object or payload.
/// This could be a structure, message, or whatever.
#[serde(skip_serializing_if = "Option::is_none")]
pub error_details: Option<ErrPayload>,
}
impl CommandSuccessResponseWrapper<()> {
pub fn empty() -> Self {
CommandSuccessResponseWrapper {
status: CommandSuccessStatus::Success,
success_message: None,
payload: None,
}
}
}
/// NB: This is a hack because of `()` (a.k.a. the "unit" type) and `Serialize` trait coherence and overlap.
/// We cannot currently implement a trait for `()` and generic `Serialize` because `()` itself implements
/// `Serialize`. Rust's `"specialization"`, which would let us account for this, is an unstable, nightly-only
/// feature. So instead we manually mark all types other than `()` with `SerializeMarker` as a hack to
/// distinguish the two.
pub trait SerializeMarker : Serialize {}
// NB: () is Serialize! So we can't broadly implement just `T: Serialize`.
impl From<()> for CommandSuccessResponseWrapper<()> {
fn from(_: ()) -> Self {
CommandSuccessResponseWrapper {
status: CommandSuccessStatus::Success,
success_message: None,
payload: None,
}
}
}
/// NB: Note the `"+ SerializeMarker"` bound here. This is a hack to distinguish between `()` and other types
/// for which we will manually mark as `SerializeMarker`.
impl <T> From<T> for CommandSuccessResponseWrapper<T> where T: Serialize + SerializeMarker {
fn from(val: T) -> Self {
CommandSuccessResponseWrapper {
status: CommandSuccessStatus::Success,
success_message: None,
payload: Some(val),
}
}
}
impl From<&str> for CommandSuccessResponseWrapper<String> {
fn from(msg: &str) -> Self {
CommandSuccessResponseWrapper {
status: CommandSuccessStatus::Success,
success_message: Some(msg.to_string()),
payload: None,
}
}
}
impl From<&str> for CommandErrorResponseWrapper<(), ()> {
fn from(value: &str) -> Self {
CommandErrorResponseWrapper {
status: CommandErrorStatus::BadRequest, // NB: Default to 500 error.
error_message: Some(value.to_string()),
error_type: None,
error_details: None,
}
}
}
#[cfg(test)]
mod tests {
#[test]
fn test_empty_tuple_serialize() {
let response = super::CommandSuccessResponseWrapper::<()>::from(());
let serialized = serde_json::to_string(&response).unwrap();
// NB: This tests that the blanket "Serialize" impl doesn't produce a "null payload" field.
assert_eq!(serialized, r#"{"status":"success"}"#);
}
}
@@ -0,0 +1,296 @@
use crate::commands::command_response_wrapper::{CommandErrorResponseWrapper, CommandErrorStatus, CommandResult, SerializeMarker};
use crate::events::sendable_event_trait::SendableEvent;
use crate::events::sora::sora_image_enqueue_failure_event::SoraImageEnqueueFailureEvent;
use crate::events::sora::sora_image_enqueue_success_event::SoraImageEnqueueSuccessEvent;
use crate::state::data_dir::app_data_root::AppDataRoot;
use crate::state::data_dir::trait_data_subdir::DataSubdir;
use crate::state::fal::fal_credential_manager::FalCredentialManager;
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::state::storyteller::storyteller_credential_manager::StorytellerCredentialManager;
use crate::utils::get_url_file_extension::get_url_file_extension;
use crate::utils::simple_http_download::simple_http_download;
use crate::utils::simple_http_download_to_tempfile::simple_http_download_to_tempfile;
use anyhow::anyhow;
use base64::prelude::BASE64_STANDARD;
use base64::{DecodeError, Engine};
use errors::{AnyhowError, AnyhowResult};
use fal_client::fal_error_plus::FalErrorPlus;
use fal_client::requests::remove_background_rembg::remove_background_rembg;
use image::codecs::png::{CompressionType, FilterType, PngEncoder};
use image::{DynamicImage, EncodableLayout, ImageReader};
use log::{debug, error, info, warn};
use mimetypes::mimetype_info::mimetype_info::MimetypeInfo;
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::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;
use openai_sora_client::sora_error::SoraError;
use reqwest::Url;
use serde_derive::{Deserialize, Serialize};
use std::fs::read_to_string;
use std::io::{Cursor, Read, Write};
use std::ops::Add;
use std::path::PathBuf;
use std::time::Duration;
use storyteller_client::api_error::ApiError;
use storyteller_client::api_error::ApiError::InternalServerError;
use storyteller_client::media_files::get_media_file::{get_media_file, GetMediaFileSuccessResponse};
use storyteller_client::media_files::upload_image_media_file_from_file::upload_image_media_file_from_file;
use storyteller_client::utils::api_host::ApiHost;
use tauri::{AppHandle, Emitter, Manager, State};
use tempfile::NamedTempFile;
use filesys::file_read_bytes::file_read_bytes;
use tokens::tokens::media_files::MediaFileToken;
#[derive(Deserialize)]
pub struct FalBackgroundRemovalRequest {
/// Image media file; the image to remove the background from.
pub image_media_token: Option<MediaFileToken>,
/// Base64-encoded image
pub base64_image: Option<String>,
}
#[derive(Serialize)]
pub struct FalBackgroundRemovalSuccessResponse {
/// Result media token
pub media_token: MediaFileToken,
/// Result URL
pub cdn_url: Url,
/// Base64 bytes
pub base64_bytes: String,
}
impl SerializeMarker for FalBackgroundRemovalSuccessResponse {}
#[derive(Serialize, Debug)]
#[serde(rename_all = "snake_case")]
pub enum SoraImageRemixErrorType {
/// Generic server error
ServerError,
/// No Fal API key available
NeedsFalApiKey,
}
#[tauri::command]
pub async fn fal_background_removal_command(
request: FalBackgroundRemovalRequest,
app_data_root: State<'_, AppDataRoot>,
fal_creds_manager: State<'_, FalCredentialManager>,
storyteller_creds_manager: State<'_, StorytellerCredentialManager>,
) -> CommandResult<FalBackgroundRemovalSuccessResponse, SoraImageRemixErrorType, ()> {
info!("fal_background_removal_command called; image media token: {:?}", request.image_media_token);
let has_credentials = fal_creds_manager
.has_apparent_api_token()
.unwrap_or(true); // NB: Failures would be lock issues
if !has_credentials {
warn!("No API key found");
return Err(CommandErrorResponseWrapper {
status: CommandErrorStatus::Unauthorized,
error_message: Some("You need to set a FAL api key".to_string()),
error_type: Some(SoraImageRemixErrorType::NeedsFalApiKey),
error_details: None,
});
}
let result = remove_background(
request,
&app_data_root,
&fal_creds_manager,
&storyteller_creds_manager
).await;
match result {
Err(err) => {
error!("error: {:?}", err);
//let event = SoraImageEnqueueFailureEvent {};
//let result = event.send(&app);
//if let Err(err) = result {
// error!("Failed to emit event: {:?}", err);
//}
let mut status = CommandErrorStatus::ServerError;
let mut error_type = SoraImageRemixErrorType::ServerError;
let mut error_message = "A server error occurred. Please try again. If it continues, please tell our staff about the problem.";
//match (err) {
// _ => {},
//}
Err(CommandErrorResponseWrapper {
status,
error_message: Some(error_message.to_string()),
error_type: Some(error_type),
error_details: None,
})
}
Ok(result) => {
//let event = SoraImageEnqueueSuccessEvent {};
//let result = event.send(&app);
//if let Err(err) = result {
// error!("Failed to emit event: {:?}", err);
//}
Ok(FalBackgroundRemovalSuccessResponse {
media_token: result.0.media_file.token,
cdn_url: result.0.media_file.media_links.cdn_url,
base64_bytes: result.1,
}.into())
}
}
}
#[derive(Debug)]
enum InnerError {
FalError(FalErrorPlus),
AnyhowError(AnyhowError),
StorytellerApiError(ApiError),
DecodeError(DecodeError),
//ImageError(image::ImageError),
IoError(std::io::Error),
}
impl From<AnyhowError> for InnerError {
fn from(value: AnyhowError) -> Self {
Self::AnyhowError(value)
}
}
impl From<FalErrorPlus> for InnerError {
fn from(value: FalErrorPlus) -> Self {
Self::FalError(value)
}
}
impl From<ApiError> for InnerError {
fn from(value: ApiError) -> Self {
Self::StorytellerApiError(value)
}
}
impl From<DecodeError> for InnerError {
fn from(value: DecodeError) -> Self {
Self::DecodeError(value)
}
}
//impl From<image::ImageError> for InnerError {
// fn from(value: image::ImageError) -> Self {
// Self::ImageError(value)
// }
//}
//
impl From<std::io::Error> for InnerError {
fn from(value: std::io::Error) -> Self {
Self::IoError(value)
}
}
pub async fn remove_background(
request: FalBackgroundRemovalRequest,
app_data_root: &AppDataRoot,
fal_creds_manager: &FalCredentialManager,
storyteller_creds_manager: &StorytellerCredentialManager,
) -> Result<(GetMediaFileSuccessResponse, String), InnerError> {
let api_key = fal_creds_manager.get_key_required()?;
let creds = storyteller_creds_manager.get_credentials_required()?;
let mut temp_download;
if let Some(media_token) = request.image_media_token {
temp_download = download_media_file(&app_data_root, &media_token).await?;
} else if let Some(base64_bytes) = request.base64_image {
temp_download = persist_base64(&app_data_root, base64_bytes).await?;
} else {
return Err(InnerError::AnyhowError(anyhow!("No image media token or base64 image provided")));
}
info!("Calling FAL image background removal...");
let filename = temp_download.path().to_path_buf();
let result = remove_background_rembg(filename, &api_key).await?;
let extension_with_dot = get_url_file_extension(&result.image_url)
.map(|ext| format!(".{}", ext))
.unwrap_or_else(|| ".png".to_string());
let mut result_file = app_data_root.temp_dir().new_named_temp_file_with_extension(&extension_with_dot)?;
info!("Downloading file result file...");
simple_http_download_to_tempfile(&result.image_url, &mut result_file).await?;
let result_filename = result_file.path().to_path_buf();
// NB: We couldn't pass the existing file handle as I think the file handle pointer is already at the EOF
let bytes = file_read_bytes(&result_filename)?;
let base64_bytes = BASE64_STANDARD.encode(bytes.as_bytes());
info!("Uploading image media file...");
let upload_result = upload_image_media_file_from_file(
&ApiHost::Storyteller,
Some(&creds),
result_filename
).await?;
// TODO: Don't re-request to simply build MediaLinks (or CDN URL). Get those from the upload API in one turn.
info!("Re-requesting media file...");
let response = get_media_file(&ApiHost::Storyteller, &upload_result.media_file_token).await?;
info!("Uploaded media file: {:?}", response.media_file.token);
Ok((response, base64_bytes))
}
async fn download_media_file(app_data_root: &AppDataRoot, token: &MediaFileToken) -> Result<NamedTempFile, InnerError> {
let response = get_media_file(&ApiHost::Storyteller, token).await?;
let media_file_url = &response.media_file.media_links.cdn_url;
let extension_with_dot = get_url_file_extension(media_file_url)
.map(|ext| format!(".{}", ext))
.unwrap_or_else(|| ".png".to_string());
//let filename = format!("{}{}", response.media_file.token.as_str(), extension_with_dot);
//let filename = app_data_root.downloads_dir().path().join(&filename);
let mut file = app_data_root.temp_dir().new_named_temp_file_with_extension(&extension_with_dot)?;
simple_http_download_to_tempfile(&media_file_url, &mut file).await?;
Ok(file) // NB: Must return TempFile to not drop / delete it
}
async fn persist_base64(app_data_root: &AppDataRoot, base64_image: String) -> Result<NamedTempFile, InnerError> {
let bytes = BASE64_STANDARD.decode(base64_image)?;
let extension = MimetypeInfo::get_for_bytes(&bytes)
.map(|info| info.file_extension())
.flatten()
.map(|ext| ext.extension_with_period().to_string())
.unwrap_or_else(|| ".png".to_string());
let mut file = app_data_root.temp_dir().new_named_temp_file_with_extension(&extension)?;
file.write_all(bytes.as_ref())?;
Ok(file) // NB: Must return TempFile to not drop / delete it
}
@@ -0,0 +1,46 @@
use crate::commands::command_response_wrapper::{CommandResult, SerializeMarker};
use crate::state::app_preferences::app_preferences_manager::AppPreferencesManager;
use crate::state::app_preferences::preferred_download_directory::PreferredDownloadDirectory;
use crate::state::data_dir::app_data_root::AppDataRoot;
use crate::state::fal::fal_credential_manager::FalCredentialManager;
use anyhow::anyhow;
use errors::AnyhowResult;
use fal_client::creds::fal_api_key::FalApiKey;
use log::{error, info};
use openai_sora_client::sora_error::SoraError::AnyhowError;
use primitives::traits::trim_or_emptyable::TrimOrEmptyable;
use serde_derive::{Deserialize, Serialize};
use tauri::{AppHandle, State};
#[derive(Serialize)]
pub struct GetFalApiKeyResponse {
pub key: Option<String>,
}
impl SerializeMarker for GetFalApiKeyResponse {}
#[tauri::command]
pub async fn get_fal_api_key_command(
creds_manager: State<'_, FalCredentialManager>,
) -> CommandResult<GetFalApiKeyResponse, (), ()> {
let key = get_key(&creds_manager)
.await
.map_err(|err| {
error!("Error getting API key: {:?}", err);
"error getting key"
})?;
Ok(key.into())
}
async fn get_key(
creds_manager: &FalCredentialManager,
) -> AnyhowResult<GetFalApiKeyResponse> {
let maybe_key = creds_manager.get_key()?
.map(|key| key.0)
.map(|key| key.trim().to_string())
.filter(|key| !key.is_empty());
Ok(GetFalApiKeyResponse { key: maybe_key })
}
@@ -0,0 +1,3 @@
pub mod fal_background_removal_command;
pub mod get_fal_api_key_command;
pub mod set_fal_api_key_command;
@@ -0,0 +1,59 @@
use crate::commands::command_response_wrapper::CommandResult;
use crate::state::app_preferences::app_preferences_manager::AppPreferencesManager;
use crate::state::app_preferences::preferred_download_directory::PreferredDownloadDirectory;
use crate::state::data_dir::app_data_root::AppDataRoot;
use crate::state::fal::fal_credential_manager::FalCredentialManager;
use anyhow::anyhow;
use errors::AnyhowResult;
use fal_client::creds::fal_api_key::FalApiKey;
use log::{error, info};
use openai_sora_client::sora_error::SoraError::AnyhowError;
use serde_derive::{Deserialize, Serialize};
use tauri::{AppHandle, State};
/// For now, we'll only update a single preference at a time.
#[derive(Deserialize)]
pub struct SetFalApiKeyRequest{
pub key: Option<String>,
}
#[tauri::command]
pub async fn set_fal_api_key_command(
request: SetFalApiKeyRequest,
creds_manager: State<'_, FalCredentialManager>,
) -> CommandResult<(), (), ()> {
info!("update_app_preferences_command called");
set_key(request, &creds_manager)
.await
.map_err(|err| {
error!("Error getting app preferences: {:?}", err);
"error setting key"
})?;
Ok(().into())
}
async fn set_key(
request: SetFalApiKeyRequest,
creds_manager: &FalCredentialManager,
) -> AnyhowResult<()> {
let key = request.key
.map(|key| key.trim().to_string())
.filter(|key| !key.is_empty())
.map(|key| FalApiKey::from_str(&key));
match key {
None => {
creds_manager.clear_key()?;
creds_manager.purge_api_key_from_disk()?;
}
Some(key) => {
creds_manager.set_key(&key)?;
creds_manager.persist_to_disk()?;
}
}
Ok(())
}
@@ -1,4 +1,6 @@
pub mod app_preferences;
pub mod command_response_wrapper;
pub mod fal;
pub mod flip_image;
pub mod platform_info_command;
pub mod sora;
@@ -0,0 +1,114 @@
use std::ops::Add;
use chrono::{DateTime, TimeDelta, Utc};
use crate::commands::app_preferences::get_app_preferences_command::AppPreferencesPayload;
use crate::state::app_preferences::app_preferences_manager::AppPreferencesManager;
use crate::state::data_dir::app_data_root::AppDataRoot;
use crate::state::sora::sora_credential_manager::SoraCredentialManager;
use crate::windows::sora_login_window::open_sora_login_window::open_sora_login_window;
use errors::AnyhowResult;
use log::{error, info};
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::requests::list_media::list_media::list_media;
use serde_derive::Serialize;
use tauri::{AppHandle, State};
const CACHE_PERIOD : TimeDelta = TimeDelta::milliseconds(1000 * 60 * 5); // 5 minutes
#[derive(Serialize)]
#[serde(rename_all = "snake_case")]
pub struct CheckSoraSessionCommand {
pub state: SoraSessionState,
pub maybe_account_email: Option<String>,
}
#[derive(Serialize)]
#[serde(rename_all = "snake_case")]
pub enum SoraSessionState{
#[serde(rename = "not_set_up")]
NotSetUp,
#[serde(rename = "expired_or_error")]
ExpiredOrError,
#[serde(rename = "valid")]
Valid,
}
#[tauri::command]
pub async fn check_sora_session_command(
app: AppHandle,
sora_creds_manager: State<'_, SoraCredentialManager>,
) -> Result<CheckSoraSessionCommand, CheckSoraSessionCommand> {
info!("check_sora_session_command called");
match do_check(&sora_creds_manager).await {
Ok(state) => Ok(state),
Err(err) => {
error!("Error checking session state: {:?}", err);
Err(CheckSoraSessionCommand {
state: SoraSessionState::ExpiredOrError,
maybe_account_email: None,
})
}
}
}
async fn do_check(
sora_creds_manager: &SoraCredentialManager,
) -> AnyhowResult<CheckSoraSessionCommand> {
let mut creds = match sora_creds_manager.get_credentials()? {
Some(creds) => creds,
None => {
return Ok(CheckSoraSessionCommand {
state: SoraSessionState::NotSetUp,
maybe_account_email: None,
});
}
};
let mut needs_upgrade_or_check = false;
let stats = sora_creds_manager.get_credential_stats()?;
match stats.last_consecutive_credential_success {
None => {
info!("Session never checked, needs upgrade or status checking.");
needs_upgrade_or_check= true;
}
Some(last_success) => {
let now = chrono::Utc::now();
let expires = last_success.add(CACHE_PERIOD);
if now.gt(&expires) {
info!("Session success expired, needs upgrade or status checking.");
needs_upgrade_or_check = true;
}
}
}
let mut upgraded = false;
if needs_upgrade_or_check {
info!("Attempting to upgrade session...");
upgraded = maybe_upgrade_or_renew_session(&mut creds).await?;
info!("Attempting to check session...");
list_media(&creds).await?;
sora_creds_manager.record_credential_success()?;
}
if upgraded {
info!("Saving upgraded session to manager and disk...");
sora_creds_manager.set_credentials(&creds)?;
sora_creds_manager.persist_all_to_disk()?;
}
let maybe_account_email = creds
.jwt_bearer_token
.map(|token| token.jwt_claims().email.clone());
Ok(CheckSoraSessionCommand {
state: SoraSessionState::Valid,
maybe_account_email,
})
}
@@ -1,3 +1,5 @@
pub mod check_sora_session_command;
pub mod open_sora_login_command;
pub mod sora_image_generation_command;
pub mod sora_image_remix_command;
pub mod sora_logout_command;
@@ -1,31 +1,21 @@
use crate::threads::sora_session_login_thread::{LOGIN_WINDOW_NAME, SORA_LOGIN_URL};
use crate::utils::clear_all_webview_cookies::clear_all_webview_cookies;
use crate::state::app_preferences::app_preferences_manager::AppPreferencesManager;
use crate::state::data_dir::app_data_root::AppDataRoot;
use crate::state::sora::sora_credential_manager::SoraCredentialManager;
use crate::windows::sora_login_window::open_sora_login_window::open_sora_login_window;
use errors::AnyhowResult;
use log::{error, info};
use once_cell::sync::Lazy;
use std::time::Duration;
use tauri::{AppHandle, Manager, Url, WebviewUrl, WebviewWindowBuilder, WindowBuilder};
use tokio::time::sleep_until;
pub const START_URL_STR: &str = "https://storyteller.ai/";
pub static START_URL: Lazy<Url> = Lazy::new(|| {
Url::parse(START_URL_STR).expect("URL should parse")
});
pub const SORA_HOMEPAGE_URL_STR: &str = "https://sora.com/";
pub static SORA_HOMEPAGE_URL : Lazy<Url> = Lazy::new(|| {
Url::parse(SORA_HOMEPAGE_URL_STR).expect("URL should parse")
});
use tauri::{AppHandle, State};
#[tauri::command]
pub async fn open_sora_login_command(
app: AppHandle,
app_data_root: State<'_, AppDataRoot>,
sora_creds_manager: State<'_, SoraCredentialManager>,
) -> Result<String, String> {
info!("open_login_command called");
do_open_login(&app)
do_open_login(&app, &app_data_root, &sora_creds_manager)
.await
.map_err(|err| {
error!("Error opening login: {:?}", err);
@@ -35,37 +25,14 @@ pub async fn open_sora_login_command(
Ok("result".to_string())
}
async fn do_open_login(app: &AppHandle) -> AnyhowResult<()> {
async fn do_open_login(
app: &AppHandle,
app_data_root: &AppDataRoot,
sora_creds_manager: &SoraCredentialManager,
) -> AnyhowResult<()> {
info!("Building login window...");
let url = WebviewUrl::External(START_URL.clone());
let window = WebviewWindowBuilder::new(app, LOGIN_WINDOW_NAME, url)
//.user_agent(openai_sora_client::credentials::USER_AGENT)
.always_on_top(true)
.title("Login to OpenAI")
.center()
.resizable(true)
.visible(true)
.closable(true)
.focused(true)
.devtools(true)
.build()?;
for (name, webview) in window.webviews() {
if name != LOGIN_WINDOW_NAME {
continue;
}
clear_all_webview_cookies(&webview)?;
// NB: We're starting to get Cloudflare protection screens. Let's try to avoid.
webview.navigate(SORA_HOMEPAGE_URL.clone())?;
tokio::time::sleep(Duration::from_millis(100)).await;
webview.navigate(SORA_LOGIN_URL.clone())?;
break;
}
open_sora_login_window(app, app_data_root, sora_creds_manager).await?;
info!("Done.");
Ok(())
@@ -14,8 +14,13 @@ use openai_sora_client::requests::image_gen::sora_image_gen_remix::{sora_image_g
use openai_sora_client::requests::upload::upload_media_from_bytes::sora_media_upload_from_bytes;
use std::fs::read_to_string;
use std::io::Cursor;
use std::time::Duration;
use tauri::{AppHandle, Manager, State};
const SORA_IMAGE_UPLOAD_TIMEOUT: Duration = Duration::from_millis(1000 * 30); // 30 seconds
const SORA_IMAGE_REMIX_TIMEOUT: Duration = Duration::from_millis(1000 * 30); // 30 seconds
#[tauri::command]
pub async fn sora_image_generation_command(
prompt: &str,
@@ -55,8 +60,13 @@ pub async fn generate_image(
let image_bytes = BASE64_STANDARD.decode(image)?;
let filename = "image.png".to_string();
let response = sora_media_upload_from_bytes(image_bytes, filename, CredentialMigrationRef::New(&creds))
let response = sora_media_upload_from_bytes(
image_bytes,
filename,
CredentialMigrationRef::New(&creds),
Some(SORA_IMAGE_UPLOAD_TIMEOUT))
.await
.map_err(|err| {
error!("Failed to upload image to Sora: {:?}", err);
@@ -72,6 +82,7 @@ pub async fn generate_image(
image_size: ImageSize::Square,
sora_media_tokens: sora_media_tokens.clone(),
credentials: CredentialMigrationRef::New(&creds),
request_timeout: Some(SORA_IMAGE_REMIX_TIMEOUT),
}).await
.map_err(|err| {
error!("Failed to call Sora image generation: {:?}", err);
@@ -1,3 +1,7 @@
use crate::commands::command_response_wrapper::{CommandErrorResponseWrapper, CommandErrorStatus, CommandResult};
use crate::events::sendable_event_trait::SendableEvent;
use crate::events::sora::sora_image_enqueue_failure_event::SoraImageEnqueueFailureEvent;
use crate::events::sora::sora_image_enqueue_success_event::SoraImageEnqueueSuccessEvent;
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;
@@ -8,10 +12,10 @@ 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;
use base64::Engine;
use errors::AnyhowResult;
use errors::{AnyhowError, AnyhowResult};
use image::codecs::png::{CompressionType, FilterType, PngEncoder};
use image::{DynamicImage, ImageReader};
use log::{debug, error, info};
use log::{debug, error, info, warn};
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};
@@ -21,23 +25,22 @@ 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::sora_media_upload_from_file;
use openai_sora_client::sora_error::SoraError;
use serde_derive::{Deserialize, Serialize};
use std::fs::read_to_string;
use std::io::Cursor;
use std::ops::Add;
use std::time::Duration;
use storyteller_client::api_error::ApiError;
use storyteller_client::api_error::ApiError::InternalServerError;
use storyteller_client::media_files::get_media_file::get_media_file;
use storyteller_client::utils::api_host::ApiHost;
use tauri::{AppHandle, Emitter, Manager, State};
use tokens::tokens::media_files::MediaFileToken;
use crate::threads::sora_task_polling_thread::SoraImageGenerationComplete;
#[derive(Debug, Clone, Serialize)]
pub struct SoraImageEnqueueFailure {
// TODO: Reason.
}
const SORA_IMAGE_UPLOAD_TIMEOUT: Duration = Duration::from_millis(1000 * 30); // 30 seconds
#[derive(Debug, Clone, Serialize)]
pub struct SoraImageEnqueueSuccess {
}
const SORA_IMAGE_REMIX_TIMEOUT: Duration = Duration::from_millis(1000 * 30); // 30 seconds
#[derive(Deserialize)]
pub struct SoraImageRemixCommand {
@@ -56,6 +59,22 @@ pub struct SoraImageRemixCommand {
pub maybe_number_of_samples: Option<u32>,
}
#[derive(Serialize, Debug)]
#[serde(rename_all = "snake_case")]
pub enum SoraImageRemixErrorType {
/// Generic server error
ServerError,
/// The user is sending too many requests
TooManyConcurrentTasks,
/// User is not logged into Sora!
SoraLoginRequired,
/// The user needs to create a Sora account
SoraUsernameNotYetCreated,
/// The Sora service is having problems. Try again soon.
SoraIsHavingProblems,
}
#[tauri::command]
pub async fn sora_image_remix_command(
app: AppHandle,
@@ -63,32 +82,106 @@ pub async fn sora_image_remix_command(
app_data_root: State<'_, AppDataRoot>,
sora_creds_manager: State<'_, SoraCredentialManager>,
sora_task_queue: State<'_, SoraTaskQueue>,
) -> Result<String, String> {
) -> CommandResult<(), SoraImageRemixErrorType, ()> {
info!("image_generation_command called; scene media token: {:?}, additional images: {:?}",
request.snapshot_media_token, request.maybe_additional_images);
// TODO(bt,2025-04-24): Better error messages to caller
let has_credentials = sora_creds_manager
.has_apparently_complete_credentials()
.unwrap_or(true); // NB: Failures would be lock issues
if !has_credentials {
warn!("No apparently completed credentials found");
return Err(CommandErrorResponseWrapper {
status: CommandErrorStatus::Unauthorized,
error_message: Some("You need to log into Sora to continue. See the settings menu.".to_string()),
error_type: Some(SoraImageRemixErrorType::SoraLoginRequired),
error_details: None,
});
}
let result = generate_image(request, &app_data_root, &sora_creds_manager, &sora_task_queue).await;
if let Err(err) = result {
error!("error: {:?}", err);
match result {
Err(err) => {
error!("error: {:?}", err);
let result = app.emit("sora-image-enqueue-failure", SoraImageEnqueueFailure{});
if let Err(err) = result {
error!("Failed to emit event: {:?}", err);
let event = SoraImageEnqueueFailureEvent {};
let result = event.send(&app);
if let Err(err) = result {
error!("Failed to emit event: {:?}", err);
}
let mut status = CommandErrorStatus::ServerError;
let mut error_type = SoraImageRemixErrorType::ServerError;
let mut error_message = "A server error occurred. Please try again. If it continues, please tell our staff about the problem.";
match (err) {
InnerError::SoraError(SoraError::TooManyConcurrentTasks) => {
error_type = SoraImageRemixErrorType::TooManyConcurrentTasks;
status = CommandErrorStatus::ServerError;
error_message = "You already have work in progress. Please wait for it to finish.";
},
InnerError::SoraError(SoraError::SoraUsernameNotYetCreated) => {
error_type = SoraImageRemixErrorType::SoraUsernameNotYetCreated;
status = CommandErrorStatus::BadRequest;
error_message = "You need to create a username on Sora.com to continue.";
},
InnerError::SoraError(SoraError::BadGateway(_) | SoraError::CloudFlareTimeout(_)) => {
error_type = SoraImageRemixErrorType::SoraIsHavingProblems;
status = CommandErrorStatus::ServerError;
error_message = "Sora is having problems. Please wait a moment and then retry.";
}
_ => {},
}
Err(CommandErrorResponseWrapper {
status,
error_message: Some(error_message.to_string()),
error_type: Some(error_type),
error_details: None,
})
}
return Err("there was an error".to_string());
}
Ok(_) => {
let event = SoraImageEnqueueSuccessEvent {};
let result = event.send(&app);
let result = app.emit("sora-image-enqueue-success", SoraImageEnqueueSuccess{});
if let Err(err) = result {
error!("Failed to emit event: {:?}", err);
}
if let Err(err) = result {
error!("Failed to emit event: {:?}", err);
}
Ok("success".to_string())
Ok(().into())
}
}
}
#[derive(Debug)]
enum InnerError {
SoraError(SoraError),
AnyhowError(AnyhowError),
StorytellerApiError(ApiError),
}
impl From<AnyhowError> for InnerError {
fn from(value: AnyhowError) -> Self {
Self::AnyhowError(value)
}
}
impl From<SoraError> for InnerError {
fn from(value: SoraError) -> Self {
Self::SoraError(value)
}
}
impl From<ApiError> for InnerError {
fn from(value: ApiError) -> Self {
Self::StorytellerApiError(value)
}
}
pub async fn generate_image(
@@ -96,7 +189,7 @@ pub async fn generate_image(
app_data_root: &AppDataRoot,
sora_creds_manager: &SoraCredentialManager,
sora_task_queue: &SoraTaskQueue,
) -> AnyhowResult<()> {
) -> Result<(), InnerError> {
let response = get_media_file(&ApiHost::Storyteller, &request.snapshot_media_token).await?;
@@ -135,6 +228,7 @@ pub async fn generate_image(
image_upload_from_file_with_session_auto_renew(ImageUploadFromFileAutoRenewRequest {
file_path,
credentials: &creds,
request_timeout: Some(SORA_IMAGE_UPLOAD_TIMEOUT),
}).await?;
if let Some(new_creds) = maybe_new_credentials {
@@ -159,6 +253,7 @@ pub async fn generate_image(
image_size: ImageSize::Square,
sora_media_tokens: sora_media_tokens.clone(),
credentials: &creds,
request_timeout: Some(SORA_IMAGE_REMIX_TIMEOUT),
}).await?;
if let Some(new_creds) = maybe_new_creds {
@@ -0,0 +1,39 @@
use crate::commands::app_preferences::get_app_preferences_command::AppPreferencesPayload;
use crate::commands::command_response_wrapper::{CommandResult, CommandSuccessResponseWrapper, SimpleCommandResult};
use crate::state::app_preferences::app_preferences_manager::AppPreferencesManager;
use crate::state::data_dir::app_data_root::AppDataRoot;
use crate::state::sora::sora_credential_manager::SoraCredentialManager;
use crate::windows::sora_login_window::open_sora_login_window::open_sora_login_window;
use errors::{AnyhowError, AnyhowResult};
use log::{error, info};
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::requests::list_media::list_media::list_media;
use serde_derive::Serialize;
use std::fmt::format;
use tauri::{AppHandle, State};
#[tauri::command]
pub async fn sora_logout_command(
sora_creds_manager: State<'_, SoraCredentialManager>,
) -> SimpleCommandResult {
info!("sora_logout_command called");
if let Err(err) = sora_creds_manager.purge_credentials_from_disk() {
error!("Error purging credentials from disk: {:?}", err);
return Err("error purging credentials from disk".into());
}
if let Err(err) = sora_creds_manager.clear_credentials() {
error!("Error clearing credentials from memory: {:?}", err);
return Err("error clearing credentials from memory".into());
}
if let Err(err) = sora_creds_manager.reset_credential_stats() {
error!("Error resetting credential stats: {:?}", err);
return Err("error resetting credential stats".into());
}
Ok(().into())
}
+4 -1
View File
@@ -1 +1,4 @@
pub mod notification_event;
pub mod notification_event;
pub mod sendable_event_error;
pub mod sendable_event_trait;
pub mod sora;
@@ -0,0 +1,31 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
#[derive(Debug)]
pub enum SendableEventError {
TauriError(tauri::Error),
AnyhowError(anyhow::Error),
}
impl Error for SendableEventError {}
impl Display for SendableEventError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::TauriError(err) => write!(f, "SendableEventError::TauriError: {}", err),
Self::AnyhowError(err) => write!(f, "SendableEventError::AnyhowError: {}", err),
}
}
}
impl From<anyhow::Error> for SendableEventError {
fn from(err: anyhow::Error) -> Self {
SendableEventError::AnyhowError(err)
}
}
impl From<tauri::Error> for SendableEventError {
fn from(err: tauri::Error) -> Self {
SendableEventError::TauriError(err)
}
}
@@ -0,0 +1,21 @@
use log::info;
use crate::events::sendable_event_error::SendableEventError;
use serde::Serialize;
use tauri::{AppHandle, Emitter};
/// Implement this on a Serializable object to be able to send it as an event to the frontend.
pub trait SendableEvent : Serialize {
/// This is the name of the event that the frontend subscribes to.
const FRONTEND_EVENT_NAME: &'static str;
/// Default implementation of send().
/// This serializes and sends the event to the frontend.
fn send(&self, app: &AppHandle) -> Result<(), SendableEventError> {
info!("Sending event to frontend: {}", Self::FRONTEND_EVENT_NAME);
let result = app.emit(Self::FRONTEND_EVENT_NAME, self);
if let Err(err) = result {
return Err(SendableEventError::from(err));
}
Ok(())
}
}
@@ -0,0 +1,5 @@
pub mod sora_image_enqueue_failure_event;
pub mod sora_image_enqueue_success_event;
pub mod sora_image_generation_complete_event;
pub mod sora_image_generation_failed_event;
pub mod sora_login_success_event;
@@ -0,0 +1,11 @@
use crate::events::sendable_event_trait::SendableEvent;
use serde_derive::Serialize;
#[derive(Debug, Clone, Serialize)]
pub struct SoraImageEnqueueFailureEvent {
// TODO: Reason.
}
impl SendableEvent for SoraImageEnqueueFailureEvent {
const FRONTEND_EVENT_NAME: &'static str = "sora-image-enqueue-failure";
}
@@ -0,0 +1,11 @@
use crate::events::sendable_event_trait::SendableEvent;
use serde_derive::Serialize;
#[derive(Debug, Clone, Serialize)]
pub struct SoraImageEnqueueSuccessEvent {
// TODO: Token, etc.
}
impl SendableEvent for SoraImageEnqueueSuccessEvent {
const FRONTEND_EVENT_NAME: &'static str = "sora-image-enqueue-success";
}
@@ -0,0 +1,12 @@
use serde_derive::Serialize;
use tokens::tokens::media_files::MediaFileToken;
use crate::events::sendable_event_trait::SendableEvent;
#[derive(Debug, Clone, Serialize)]
pub struct SoraImageGenerationCompleteEvent {
pub media_file_token: MediaFileToken,
}
impl SendableEvent for SoraImageGenerationCompleteEvent {
const FRONTEND_EVENT_NAME: &'static str = "sora-image-generation-complete";
}
@@ -0,0 +1,12 @@
use serde_derive::Serialize;
use crate::events::sendable_event_trait::SendableEvent;
#[derive(Debug, Clone, Serialize)]
pub struct SoraImageGenerationFailedEvent {
pub prompt: String,
}
impl SendableEvent for SoraImageGenerationFailedEvent {
const FRONTEND_EVENT_NAME: &'static str = "sora-image-generation-failed";
}
@@ -0,0 +1,11 @@
use crate::events::sendable_event_trait::SendableEvent;
use serde_derive::Serialize;
#[derive(Debug, Clone, Serialize)]
pub struct SoraLoginSuccessEvent {
// TODO: Reason.
}
impl SendableEvent for SoraLoginSuccessEvent {
const FRONTEND_EVENT_NAME: &'static str = "sora-login-success";
}
+38 -7
View File
@@ -3,30 +3,37 @@ pub mod events;
pub mod state;
pub mod threads;
pub mod utils;
pub mod windows;
use tauri::Manager;
use crate::commands::flip_image::flip_image;
use crate::commands::app_preferences::get_app_preferences_command::get_app_preferences_command;
use crate::commands::app_preferences::update_app_preference_command::update_app_preferences_command;
use crate::commands::fal::fal_background_removal_command::fal_background_removal_command;
use crate::commands::fal::get_fal_api_key_command::get_fal_api_key_command;
use crate::commands::fal::set_fal_api_key_command::set_fal_api_key_command;
use crate::commands::flip_image::flip_image;
use crate::commands::platform_info_command::platform_info_command;
use crate::commands::sora::check_sora_session_command::check_sora_session_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::commands::sora::sora_logout_command::sora_logout_command;
use crate::state::app_preferences::app_preferences_manager::load_app_preferences_or_default;
use crate::state::data_dir::app_data_root::AppDataRoot;
use crate::state::fal::fal_credential_manager::FalCredentialManager;
use crate::state::main_window_position::MainWindowPosition;
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 crate::utils::webview_unsafe::webview_unsafe_for_app;
use crate::state::app_preferences::app_preferences_manager::load_app_preferences_or_default;
use tauri_plugin_http;
use tauri_plugin_dialog;
use tauri_plugin_http;
use tauri_plugin_log::Target;
use tauri_plugin_log::TargetKind;
@@ -44,10 +51,14 @@ pub fn run() {
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();
let storyteller_creds_manager_3 = storyteller_creds_manager.clone();
println!("Attempting to read existing credentials...");
println!("Attempting to read existing sora credentials...");
let sora_creds_manager = SoraCredentialManager::initialize_from_disk_infallible(&app_data_root);
let sora_creds_manager_2 = sora_creds_manager.clone();
println!("Attempting to read existing fal credentials...");
let fal_creds_manager = FalCredentialManager::initialize_from_disk_infallible(&app_data_root);
// Other state
let sora_task_queue = SoraTaskQueue::new();
@@ -108,7 +119,20 @@ pub fn run() {
}
}
tauri::async_runtime::spawn(sora_session_login_thread(app_2, app_data_root_2, sora_creds_manager_2));
match MainWindowPosition::from_filesystem_configs(&app_data_root_3) {
Ok(None) => {}
Ok(Some(pos)) => {
println!("Moving window to: {:?}", pos);
let result = pos.apply_to_main_window(&app);
if let Err(err) = result {
eprintln!("Could not set window position: {:?}", err);
}
}
Err(err) => {
eprintln!("Failed to read window position from disk: {:?}", err);
}
}
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_4, app_data_root_4, sora_creds_manager_3, storyteller_creds_manager_3, sora_task_queue_2));
tauri::async_runtime::spawn(discord_presence_thread());
@@ -118,15 +142,22 @@ pub fn run() {
.manage(app_data_root)
.manage(app_preferences)
.manage(sora_creds_manager)
.manage(fal_creds_manager)
.manage(sora_task_queue)
.manage(storyteller_creds_manager_3)
.invoke_handler(tauri::generate_handler![
check_sora_session_command,
fal_background_removal_command,
flip_image,
get_app_preferences_command,
update_app_preferences_command,
get_fal_api_key_command,
open_sora_login_command,
platform_info_command,
set_fal_api_key_command,
sora_image_generation_command,
sora_image_remix_command,
sora_logout_command,
update_app_preferences_command,
])
.run(tauri::generate_context!("tauri.conf.json"))
.expect("error while running tauri application");
@@ -9,6 +9,14 @@ pub struct AppPreferences {
/// Play sounds on events.
pub play_sounds: bool,
/// Key pointing to file; defined in the frontend code.
/// Defined for enqueue since image enqueue can be async
pub enqueue_success_sound: Option<String>,
/// Key pointing to file; defined in the frontend code.
/// Defined for enqueue since image enqueue can be async
pub enqueue_failure_sound: Option<String>,
/// Key pointing to file; defined in the frontend code.
pub generation_success_sound: Option<String>,
@@ -17,6 +25,7 @@ pub struct AppPreferences {
pub generation_failure_sound: Option<String>,
/// Key pointing to file; defined in the frontend code.
#[deprecated]
pub generation_enqueue_sound: Option<String>,
}
@@ -26,7 +35,9 @@ impl Default for AppPreferences {
preferred_download_directory: PreferredDownloadDirectory::System(SystemDownloadDirectory::Downloads),
play_sounds: true,
// NB: These are defined in the frontend.
generation_success_sound: Some("flower".to_string()),
enqueue_success_sound: Some("done".to_string()),
enqueue_failure_sound: Some("spike_throw".to_string()),
generation_success_sound: Some("special_flower".to_string()),
generation_failure_sound: Some("crumble".to_string()),
generation_enqueue_sound: Some("done".to_string()),
}
@@ -40,4 +40,8 @@ impl AppCredentialsDir {
pub fn get_storyteller_session_cookie_file_path(&self) -> PathBuf {
self.path.join("artcraft_session.txt")
}
pub fn get_fal_api_key_file_path(&self) -> PathBuf {
self.path.join("fal_api_key.txt")
}
}
@@ -136,6 +136,10 @@ impl AppDataRoot {
pub fn get_window_size_config_file(&self) -> PathBuf {
self.path.join("window_size.json")
}
pub fn get_window_position_config_file(&self) -> PathBuf {
self.path.join("window_position.json")
}
}
// eg. /home/bob/artcraft, /Users/bob/artcraft, or C:\Users\Alice\artcraft
@@ -35,4 +35,17 @@ impl TemporaryDir {
.tempfile_in(&self.path)?;
Ok(tempfile)
}
pub fn new_named_temp_file_with_extension(&self, extension: &str) -> anyhow::Result<NamedTempFile> {
let extension = if extension.starts_with(".") {
extension.to_string()
} else {
format!(".{}", extension)
};
let tempfile = Builder::new()
.prefix("temp_")
.suffix(&extension)
.tempfile_in(&self.path)?;
Ok(tempfile)
}
}
@@ -0,0 +1,98 @@
use crate::state::data_dir::app_data_root::AppDataRoot;
use crate::state::fal::read_fal_api_key_from_disk::read_fal_api_key_from_disk;
use anyhow::anyhow;
use errors::AnyhowResult;
use fal_client::creds::fal_api_key::FalApiKey;
use memory_store::clone_slot::CloneSlot;
use std::fs::OpenOptions;
use std::io::Write;
use std::path::Path;
use std::{fs, io};
#[derive(Clone)]
pub struct FalCredentialManager {
key: CloneSlot<FalApiKey>,
app_data_root: AppDataRoot,
}
impl FalCredentialManager {
pub fn new(app_data_root: &AppDataRoot) -> Self {
Self {
key: CloneSlot::empty(),
app_data_root: app_data_root.clone(),
}
}
pub fn initialize_from_disk_infallible(app_data_root: &AppDataRoot) -> Self {
let mut holder = CloneSlot::empty();
match read_fal_api_key_from_disk(app_data_root) {
Err(err) => {
log::warn!("Failed to read FAL API key from disk: {:?}", err);
}
Ok(api_key) => {
holder.set_clone(&api_key).expect("Failed to set FAL API key");
}
}
Self {
key: holder,
app_data_root: app_data_root.clone(),
}
}
pub fn set_key(&self, key: &FalApiKey) -> AnyhowResult<()> {
self.key.set_clone(key)
}
pub fn get_key(&self) -> AnyhowResult<Option<FalApiKey>> {
self.key.get_clone()
}
pub fn get_key_required(&self) -> AnyhowResult<FalApiKey> {
self.key.get_clone_required()
}
pub fn clear_key(&self) -> AnyhowResult<()> {
self.key.clear()
}
/// Does not check the token for validity
pub fn has_apparent_api_token(&self) -> AnyhowResult<bool> {
self.key.is_some()
}
pub fn purge_api_key_from_disk(&self) -> Result<(), io::Error> {
let filename = self.app_data_root.credentials_dir().get_fal_api_key_file_path();
if filename.exists() && filename.is_file() {
fs::remove_file(filename)?;
}
Ok(())
}
pub fn persist_to_disk(&self) -> AnyhowResult<()> {
let key = match self.get_key() {
Ok(Some(key)) => key,
Ok(None) => {
self.purge_api_key_from_disk()?;
return Ok(());
},
Err(err) => {
return Err(anyhow!("Failed to get FAL key: {:?}", err));
}
};
let filename = self.app_data_root.credentials_dir().get_fal_api_key_file_path();
let mut file = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(filename)?;
file.write_all(key.0.as_bytes())?;
file.flush()?;
Ok(())
}
}
@@ -0,0 +1,2 @@
pub mod fal_credential_manager;
pub mod read_fal_api_key_from_disk;
@@ -0,0 +1,19 @@
use crate::state::data_dir::app_data_root::AppDataRoot;
use anyhow::anyhow;
use errors::AnyhowResult;
use fal_client::creds::fal_api_key::FalApiKey;
use std::fs::read_to_string;
pub fn read_fal_api_key_from_disk(app_data_root: &AppDataRoot) -> AnyhowResult<FalApiKey> {
let api_key_file = app_data_root.credentials_dir().get_fal_api_key_file_path();
if !api_key_file.exists() {
return Err(anyhow!("API key file does not exist: {:?}", api_key_file));
}
let value = read_to_string(api_key_file)?
.trim()
.to_string();
Ok(FalApiKey::new(value))
}
@@ -0,0 +1,73 @@
use crate::state::data_dir::app_data_root::AppDataRoot;
use crate::windows::main_window::constants::MAIN_WINDOW_NAME;
use errors::AnyhowResult;
use serde_derive::{Deserialize, Serialize};
use std::fs::OpenOptions;
use std::io::Write;
use tauri::{AppHandle, Manager, PhysicalPosition, Window};
#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq)]
pub struct MainWindowPosition {
pub x: i32,
pub y: i32,
}
impl MainWindowPosition {
pub fn new(x: i32, y: i32) -> Self {
Self { x, y }
}
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 position = window.outer_position()?;
Ok(Self {
x: position.x,
y: position.y,
})
}
pub fn from_filesystem_configs(app_data_root: &AppDataRoot) -> AnyhowResult<Option<Self>> {
let filename = app_data_root.get_window_position_config_file();
if !filename.exists() {
return Ok(None);
}
let contents = std::fs::read_to_string(filename)?;
let pos : MainWindowPosition = serde_json::from_str(&contents)?;
Ok(Some(pos))
}
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_position(PhysicalPosition::new(self.x, self.y))?;
Ok(())
}
pub fn persist_to_filesystem(&self, app_data_root: &AppDataRoot) -> AnyhowResult<()> {
let filename = app_data_root.get_window_position_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_position(&self) -> PhysicalPosition<i32> {
PhysicalPosition::new(self.x, self.y)
}
pub fn matches_physical_position(&self, pos: &PhysicalPosition<i32>) -> bool {
self.x == pos.x && self.y == pos.y
}
}
@@ -1,12 +1,12 @@
use crate::state::data_dir::app_data_root::AppDataRoot;
use crate::utils::best_window_size_heuristic::best_window_size_heuristic;
use crate::windows::main_window::constants::MAIN_WINDOW_NAME;
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,
@@ -26,7 +26,7 @@ impl MainWindowSize {
}
pub fn from_window(window: &Window) -> AnyhowResult<Self> {
let size = window.inner_size()?;
let size = best_window_size_heuristic(window)?;
Ok(Self {
width: size.width,
height: size.height,
+2
View File
@@ -2,6 +2,8 @@ pub mod app_preferences;
pub mod app_startup_time;
pub mod data_dir;
pub mod expanduser;
pub mod fal;
pub mod main_window_position;
pub mod main_window_size;
pub mod os_platform;
pub mod sora;
@@ -2,4 +2,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_stats;
pub mod sora_task_queue;
@@ -54,4 +54,20 @@ impl SoraCredentialHolder {
}
}
}
/// This does not guarantee the credentials are correct, just that they're set.
pub fn has_apparently_complete_credentials(&self) -> AnyhowResult<bool> {
match self.credentials.read() {
Err(err) => Err(anyhow!("Failed to acquire read lock: {:?}", err)),
Ok(creds) => match &*creds {
None => Ok(false),
Some(creds) => {
let has_cookies = !creds.cookies.as_str().is_empty();
let has_bearer = creds.jwt_bearer_token.is_some();
let has_sentinel = creds.sora_sentinel.is_some();
Ok(has_cookies && has_bearer && has_sentinel)
},
}
}
}
}
@@ -1,21 +1,29 @@
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::sora::sora_credential_stats::SoraCredentialStats;
use anyhow::anyhow;
use errors::AnyhowResult;
use log::{error, info, warn};
use memory_store::clone_cell::CloneCell;
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::requests::sentinel_refresh::generate::token::generate_token;
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;
use std::path::Path;
use std::{fs, io};
#[derive(Clone)]
pub struct SoraCredentialManager {
holder: SoraCredentialHolder,
app_data_root: AppDataRoot,
/// This is meant to help debug credential issues, keep credentials cached, fresh, etc.
// TODO: Put this into the same structure as the credentials and also persist to disk.
stats: CloneCell<SoraCredentialStats>,
}
impl SoraCredentialManager {
@@ -33,9 +41,41 @@ impl SoraCredentialManager {
Self {
holder,
app_data_root: app_data_root.clone(),
stats: CloneCell::with_owned(SoraCredentialStats::new()),
}
}
pub fn record_credential_success(&self) -> AnyhowResult<()> {
let mut stats = self.stats.get_clone()?;
stats.record_credential_success();
self.stats.set_owned(stats)?;
Ok(())
}
pub fn record_credential_failure(&self) -> AnyhowResult<()> {
let mut stats = self.stats.get_clone()?;
stats.record_credential_failure();
self.stats.set_owned(stats)?;
Ok(())
}
pub fn reset_credential_stats(&self) -> AnyhowResult<()> {
self.stats.set_owned(SoraCredentialStats::new())
}
/// This does not guarantee the credentials are correct
pub fn has_apparently_complete_credentials(&self) -> AnyhowResult<bool> {
self.holder.has_apparently_complete_credentials()
}
pub fn get_credential_stats(&self) -> AnyhowResult<SoraCredentialStats> {
self.stats.get_clone()
}
pub fn set_credential_stats(&self, stats: SoraCredentialStats) -> AnyhowResult<()> {
self.stats.set_owned(stats)
}
pub fn set_credentials(&self, creds: &SoraCredentialSet) -> AnyhowResult<()> {
self.holder.set_credentials(creds)
}
@@ -45,6 +85,31 @@ impl SoraCredentialManager {
Ok(())
}
/// This is meant to be infallible.
pub fn purge_credentials_from_disk(&self) -> Result<(), io::Error> {
let creds_dir = self.app_data_root.credentials_dir();
remove_file_if_exists(creds_dir.get_sora_cookie_file_path())?;
remove_file_if_exists(creds_dir.get_sora_bearer_token_file_path())?;
remove_file_if_exists(creds_dir.get_sora_sentinel_file_path())?;
Ok(())
}
/// This is meant to be infallible.
pub fn try_purge_credentials_from_disk(&self) {
let creds_dir = self.app_data_root.credentials_dir();
if let Err(err) = fs::remove_file(creds_dir.get_sora_cookie_file_path()) {
error!("Failed to remove cookie file: {:?}", err);
}
if let Err(err) = fs::remove_file(creds_dir.get_sora_bearer_token_file_path()) {
error!("Failed to remove bearer token file: {:?}", err);
}
if let Err(err) = fs::remove_file(creds_dir.get_sora_sentinel_file_path()) {
error!("Failed to remove sentinel file: {:?}", err);
}
}
pub fn get_credentials(&self) -> AnyhowResult<Option<SoraCredentialSet>> {
self.holder.get_credentials()
}
@@ -155,3 +220,11 @@ fn persist_sentinel_to_disk(sentinel: &SoraSentinel, app_data_root: &AppDataRoot
Ok(())
}
fn remove_file_if_exists<P: AsRef<Path>>(path: P) -> Result<(), io::Error> {
let p = path.as_ref();
if p.exists() && p.is_file() {
fs::remove_file(p)?;
}
Ok(())
}
@@ -0,0 +1,47 @@
/// This is meant to track 401s and other credential failures, not 500s.
#[derive(Clone)]
pub struct SoraCredentialStats {
pub last_credential_failure: Option<chrono::DateTime<chrono::Utc>>,
pub last_credential_success: Option<chrono::DateTime<chrono::Utc>>,
pub credential_success_count: u32,
pub credential_failure_count: u32,
/// This resets on each failure
pub last_consecutive_credential_success: Option<chrono::DateTime<chrono::Utc>>,
/// This resets on each failure
pub consecutive_credential_success_count: u32,
/// This resets on each success
pub consecutive_credential_failure_count: u32,
}
impl SoraCredentialStats {
pub fn new() -> Self {
Self {
last_credential_failure: None,
last_credential_success: None,
last_consecutive_credential_success: None,
credential_success_count: 0,
credential_failure_count: 0,
consecutive_credential_success_count: 0,
consecutive_credential_failure_count: 0,
}
}
pub fn record_credential_success(&mut self) {
let now = chrono::Utc::now();
self.last_credential_success = Some(now);
self.last_consecutive_credential_success = Some(now);
self.credential_success_count += 1;
self.consecutive_credential_success_count += 1;
self.consecutive_credential_failure_count = 0;
}
pub fn record_credential_failure(&mut self) {
let now = chrono::Utc::now();
self.last_credential_failure = Some(now);
self.last_consecutive_credential_success = None;
self.credential_failure_count += 1;
self.consecutive_credential_failure_count += 1;
self.consecutive_credential_success_count = 0;
}
}
@@ -1,7 +1,7 @@
use anyhow::anyhow;
use discord_rich_presence::{DiscordIpc, DiscordIpcClient};
use errors::AnyhowResult;
use log::info;
use log::{debug, info};
/// Our Discord App ID. Not sure if this is a secret.
const DISCORD_APP_ID : &str = "1366596912593113138";
@@ -15,13 +15,13 @@ pub async fn discord_presence_thread() -> ! {
}
async fn discord_main_loop(mut client: DiscordIpcClient) -> AnyhowResult<()> {
info!("Connecting Discord IPC client");
info!("(Re)-connecting Discord IPC client...");
client.connect()
.map_err(|err| anyhow!("Error connecting to Discord IPC: {:?}", err))?;
loop {
info!("Notifying discord...");
debug!("Notifying discord presence API...");
let assets = discord_rich_presence::activity::Assets::new()
.large_image("https://storyteller.ai/android-chrome-512x512.png")
@@ -37,6 +37,6 @@ async fn discord_main_loop(mut client: DiscordIpcClient) -> AnyhowResult<()> {
.assets(assets))
.map_err(|err| anyhow!("Error setting Discord activity: {:?}", err))?;
tokio::time::sleep(std::time::Duration::from_millis(30_000)).await;
tokio::time::sleep(std::time::Duration::from_millis(60_000)).await;
}
}
@@ -1,13 +1,15 @@
use crate::state::app_startup_time::AppStartupTime;
use crate::state::data_dir::app_data_root::AppDataRoot;
use crate::state::main_window_position::MainWindowPosition;
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_position_task::persist_window_position_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";
@@ -18,6 +20,7 @@ pub async fn main_window_thread(
) -> ! {
// TODO: Move these into some kind of dependency injection framework
let window_size_slot: CloneSlot<MainWindowSize> = CloneSlot::empty();
let window_pos_slot: CloneSlot<MainWindowPosition> = CloneSlot::empty();
let app_startup_time = AppStartupTime::new();
loop {
@@ -29,6 +32,7 @@ pub async fn main_window_thread(
&app_data_root,
&storyteller_creds_manager,
&window_size_slot,
&window_pos_slot,
&app_startup_time,
).await;
if let Err(err) = result {
@@ -46,10 +50,12 @@ pub async fn handle_main_window(
app_data_root: &AppDataRoot,
storyteller_creds_manager: &StorytellerCredentialManager,
window_size_slot: &CloneSlot<MainWindowSize>,
window_pos_slot: &CloneSlot<MainWindowPosition>,
app_startup_time: &AppStartupTime,
) -> AnyhowResult<()> {
loop {
log_errors(persist_window_resize_task(window, app_data_root, window_size_slot).await);
log_errors(persist_window_position_task(window, app_data_root, window_pos_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;
}
@@ -1,3 +1,4 @@
pub mod main_window_thread;
pub mod persist_storyteller_cookies_task;
pub mod persist_window_position_task;
pub mod persist_window_resize_task;
@@ -1,7 +1,6 @@
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;
@@ -0,0 +1,34 @@
use crate::state::data_dir::app_data_root::AppDataRoot;
use crate::state::main_window_position::MainWindowPosition;
use crate::state::main_window_size::MainWindowSize;
use crate::utils::best_window_size_heuristic::best_window_size_heuristic;
use errors::AnyhowResult;
use log::info;
use memory_store::clone_slot::CloneSlot;
use tauri::Window;
pub async fn persist_window_position_task(
window: &Window,
app_data_root: &AppDataRoot,
window_pos_slot: &CloneSlot<MainWindowPosition>,
) -> AnyhowResult<()> {
let current_window_position = MainWindowPosition::from_window(window)?;
let current_physical_pos = window.outer_position()?;
let mut save_size_to_disk = true;
if let Ok(Some(old_size)) = window_pos_slot.get_clone() {
if old_size.matches_physical_position(&current_physical_pos) {
save_size_to_disk = false;
}
}
if save_size_to_disk {
info!("Saving window position configs to disk...");
current_window_position.persist_to_filesystem(app_data_root)?;
window_pos_slot.set_clone(&current_window_position)?;
}
Ok(())
}
@@ -1,5 +1,6 @@
use crate::state::data_dir::app_data_root::AppDataRoot;
use crate::state::main_window_size::MainWindowSize;
use crate::utils::best_window_size_heuristic::best_window_size_heuristic;
use errors::AnyhowResult;
use log::info;
use memory_store::clone_slot::CloneSlot;
@@ -12,7 +13,7 @@ pub async fn persist_window_resize_task(
) -> AnyhowResult<()> {
let current_window_size = MainWindowSize::from_window(window)?;
let current_physical_size = window.inner_size()?;
let current_physical_size = best_window_size_heuristic(window)?;
let mut save_size_to_disk = true;
@@ -1,4 +1,3 @@
pub mod discord_presence_thread;
pub mod main_window_thread;
pub mod sora_session_login_thread;
pub mod sora_task_polling_thread;
@@ -1,178 +0,0 @@
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 anyhow::anyhow;
use chrono::{DateTime, NaiveDateTime, TimeDelta};
use errors::AnyhowResult;
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;
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";
pub const SORA_LOGIN_URL_STR: &str = "https://sora.com/auth/login?callback_path=/";
pub static SORA_LOGIN_URL: Lazy<Url> = Lazy::new(|| {
Url::parse(SORA_LOGIN_URL_STR).expect("URL should parse")
});
pub const SORA_ROOT_URL_STR: &str = "https://sora.com/";
pub static SORA_ROOT_URL: Lazy<Url> = Lazy::new(|| {
Url::parse(SORA_ROOT_URL_STR).expect("URL should parse")
});
pub async fn sora_session_login_thread(
app: AppHandle,
app_data_root: AppDataRoot,
sora_creds_manager: SoraCredentialManager
) -> ! {
loop {
for (window_name, webview) in app.webviews() {
if window_name == LOGIN_WINDOW_NAME {
let result = check_login_window(
&webview,
&app_data_root,
&sora_creds_manager,
).await;
if let Err(err) = result {
error!("Error checking login window: {:?}", err);
}
break;
}
}
tokio::time::sleep(std::time::Duration::from_millis(2_000)).await;
}
}
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, sora_credential_manager).await?;
//initialize_sora_jwt_bearer_token(app_data_root).await?; // TODO: This only runs once. We need better management.
Ok(())
}
fn keep_on_task(webview: &Webview) -> AnyhowResult<()> {
let url = webview.url()?;
let hostname= url.host()
.ok_or(anyhow!("no host in url"))?
.to_string();
match hostname.as_str() {
"auth.openai.com" => {},
"openai.com" => {},
"sora.com" => {},
// Third party SSO
"accounts.google.com" => {},
"accounts.youtube.com" => {},
"login.live.com" => {},
"appleid.apple.com" => {},
_ => {
info!("Non login hostname: {}", hostname);
webview.navigate(SORA_LOGIN_URL.clone())?;
}
}
Ok(())
}
/// This is just so we have a way to clear browsing data.
fn clear_browsing_data_on_test_domain(webview: &Webview) -> AnyhowResult<()> {
let url = webview.url()?;
let hostname= url.host()
.ok_or(anyhow!("no host in url"))?
.to_string();
match hostname.as_str() {
"storyteller.ai" => {
info!("Clearing all browsing data...");
webview.clear_all_browsing_data()?;
}
_ => {}
}
Ok(())
}
// TODO(bt,2025-04-07): Heuristic to detect when logged in. Only write when logged in.
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();
if new_cookies.is_empty() {
return Ok(());
}
let session_cookie_presence = has_session_cookie(&new_cookies)
.unwrap_or_else(|err| {
error!("Failed to check for session cookie: {:?}", err);
SessionCookiePresence::MaybePresent
});
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
}
}
}
@@ -1,3 +1,6 @@
use crate::events::sendable_event_trait::SendableEvent;
use crate::events::sora::sora_image_generation_complete_event::SoraImageGenerationCompleteEvent;
use crate::events::sora::sora_image_generation_failed_event::SoraImageGenerationFailedEvent;
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;
@@ -18,16 +21,6 @@ use tauri::{AppHandle, Emitter};
use tempdir::TempDir;
use tokens::tokens::media_files::MediaFileToken;
#[derive(Debug, Clone, Serialize)]
pub struct SoraImageGenerationComplete {
pub media_file_token: MediaFileToken,
}
#[derive(Debug, Clone, Serialize)]
pub struct SoraImageGenerationFailed {
pub prompt: String,
}
pub async fn sora_task_polling_thread(
app_handle: AppHandle,
app_data_root: AppDataRoot,
@@ -102,9 +95,10 @@ async fn polling_loop(
for task in failed_tasks.iter() {
if sora_task_queue.contains_key(&task.id)? {
app_handle.emit("sora-image-generation-failed", SoraImageGenerationFailed {
let event = SoraImageGenerationFailedEvent {
prompt: task.prompt.clone(),
})?;
};
event.send(&app_handle)?;
}
}
@@ -133,9 +127,11 @@ async fn polling_loop(
info!("Uploaded to API backend: {:?}", result.media_file_token);
app_handle.emit("sora-image-generation-complete", SoraImageGenerationComplete {
media_file_token: result.media_file_token
})?;
let event = SoraImageGenerationCompleteEvent {
media_file_token: result.media_file_token,
};
event.send(&app_handle)?;
}
}
@@ -146,7 +142,7 @@ async fn polling_loop(
sora_task_queue.remove_list(&succeeded_task_ids)?;
tokio::time::sleep(std::time::Duration::from_millis(2_000)).await;
tokio::time::sleep(std::time::Duration::from_millis(6_000)).await;
}
}
@@ -0,0 +1,25 @@
use tauri::{PhysicalSize, Window};
use errors::AnyhowResult;
// A typical height difference when dev tools are open:
// Window inner size = PhysicalSize { width: 3704, height: 732 }
// Window outer size = PhysicalSize { width: 3704, height: 1476 }
const HEIGHT_DIFFERENCE_THRESHOLD: u32 = 300;
/// Get the best window size
pub fn best_window_size_heuristic(window: &Window) -> AnyhowResult<PhysicalSize<u32>> {
// NB: Ordinarily we'd use inner size, as the outer size includes the window decorations.
// However, dev tools contract the inner size, and in the event they're open, we should
// prefer the outer size.
let inner = window.inner_size()?;
let outer = window.outer_size()?;
let height_delta = outer.height.saturating_sub(inner.height);
if height_delta > HEIGHT_DIFFERENCE_THRESHOLD {
// Dev tools are open, use the outer size
Ok(outer)
} else {
// Dev tools are not open, use the inner size
Ok(inner)
}
}
@@ -1 +0,0 @@
pub mod sora_webview_cookies;
@@ -1,26 +0,0 @@
use once_cell::sync::Lazy;
use reqwest::Url;
use tauri::Webview;
use tauri::webview::Cookie;
use errors::AnyhowResult;
const SORA_ROOT_COOKIE_URL_STR: &str = "https://sora.com/";
static SORA_ROOT_COOKIE_URL: Lazy<Url> = Lazy::new(|| {
Url::parse(SORA_ROOT_COOKIE_URL_STR).expect("URL should parse")
});
pub fn get_all_sora_cookies(webview: &Webview) -> AnyhowResult<Vec<Cookie>> {
let cookies = webview.cookies_for_url(SORA_ROOT_COOKIE_URL.clone())?;
Ok(cookies)
}
pub fn get_all_sora_cookies_as_string(webview: &Webview) -> AnyhowResult<String> {
let cookies = get_all_sora_cookies(webview)?;
let cookie_string = cookies
.iter()
.map(|cookie| format!("{}={}", cookie.name(), cookie.value()))
.collect::<Vec<String>>()
.join("; ");
Ok(cookie_string)
}
+3 -2
View File
@@ -1,9 +1,10 @@
pub mod best_window_size_heuristic;
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 simple_http_download_to_tempfile;
pub mod sora;
pub mod webview_unsafe;
pub mod webview_unsafe;
@@ -0,0 +1,26 @@
use errors::AnyhowResult;
use reqwest::Client;
use reqwest::Url;
use std::io::Write;
use tempfile::NamedTempFile;
const USER_AGENT: &str = "storyteller-client/1.0";
/// Downloads a (binary) file to a filesystem path. Good for images, etc. Not great for large files.
pub async fn simple_http_download_to_tempfile(url: &Url, temp_file: &mut NamedTempFile) -> AnyhowResult<()> {
let client = Client::builder()
.gzip(true)
.build()?;
let response = client.get(url.clone()) // NB: No IntoUrl for &Url.
.header("User-Agent", USER_AGENT)
.header("Accept", "*/*")
.send()
.await?;
let bytes = response.bytes().await?;
temp_file.write_all(&bytes)?;
Ok(())
}
@@ -0,0 +1,4 @@
/// The name of the main application window.
/// This is the Artcraft application UX itself.
pub const MAIN_WINDOW_NAME : &str = "main";
@@ -0,0 +1 @@
pub mod constants;
@@ -0,0 +1,2 @@
pub mod sora_login_window;
pub mod main_window;
@@ -0,0 +1,20 @@
use anyhow::anyhow;
use log::info;
use tauri::Webview;
use errors::AnyhowResult;
// This is just so we have a way to clear browsing data while debugging / building.
pub fn clear_browsing_data_on_test_domain(webview: &Webview) -> AnyhowResult<()> {
let url = webview.url()?;
let hostname= url.host()
.ok_or(anyhow!("no host in url"))?
.to_string();
match hostname.as_str() {
"storyteller.ai" => {
info!("Clearing all browsing data...");
webview.clear_all_browsing_data()?;
}
_ => {}
}
Ok(())
}
@@ -0,0 +1,28 @@
use errors::AnyhowResult;
use once_cell::sync::Lazy;
use reqwest::Url;
use tauri::webview::Cookie;
use tauri::WebviewWindow;
const CHAT_GPT_ROOT_COOKIE_URL_STR: &str = "https://chatgpt.com";
static CHAT_GPT_ROOT_COOKIE_URL: Lazy<Url> = Lazy::new(|| {
Url::parse(CHAT_GPT_ROOT_COOKIE_URL_STR).expect("URL should parse")
});
pub fn extract_sora_webview_cookies(webview: &WebviewWindow) -> AnyhowResult<String> {
// NB(bt): Cookies were originally on sora.com, but now they're located on
// the sora.chatgpt.com / *.chatgpt.com domains.
let cookies = get_all_chatgpt_cookies(webview)?;
let cookie_string = cookies
.iter()
.map(|cookie| format!("{}={}", cookie.name(), cookie.value()))
.collect::<Vec<String>>()
.join("; ");
Ok(cookie_string)
}
fn get_all_chatgpt_cookies(webview: &WebviewWindow) -> AnyhowResult<Vec<Cookie>> {
let cookies = webview.cookies_for_url(CHAT_GPT_ROOT_COOKIE_URL.clone())?;
Ok(cookies)
}
@@ -0,0 +1,28 @@
use crate::windows::sora_login_window::open_sora_login_window::SORA_LOGIN_URL;
use anyhow::anyhow;
use errors::AnyhowResult;
use log::info;
use tauri::Webview;
// NB: Probably best not to use this.
pub fn keep_on_login_domains(webview: &Webview) -> AnyhowResult<()> {
let url = webview.url()?;
let hostname= url.host()
.ok_or(anyhow!("no host in url"))?
.to_string();
match hostname.as_str() {
"auth.openai.com" => {},
"openai.com" => {},
"sora.com" => {},
// Third party SSO
"accounts.google.com" => {},
"accounts.youtube.com" => {},
"login.live.com" => {},
"appleid.apple.com" => {},
_ => {
info!("Non login hostname: {}", hostname);
webview.navigate(SORA_LOGIN_URL.clone())?;
}
}
Ok(())
}
@@ -0,0 +1,5 @@
pub mod clear_browsing_on_test_domain;
pub mod extract_sora_webview_cookies;
pub mod keep_on_login_domains;
pub mod open_sora_login_window;
pub mod sora_login_thread;
@@ -0,0 +1,84 @@
use crate::state::data_dir::app_data_root::AppDataRoot;
use crate::state::sora::sora_credential_manager::SoraCredentialManager;
use crate::utils::clear_all_webview_cookies::clear_all_webview_cookies;
use crate::windows::sora_login_window::sora_login_thread::sora_login_thread;
use anyhow::anyhow;
use errors::AnyhowResult;
use once_cell::sync::Lazy;
use reqwest::Url;
use std::time::Duration;
use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder};
/// Name of the window
pub const LOGIN_WINDOW_NAME: &str = "login_window";
pub const START_URL_STR: &str = "https://google.com";
pub static START_URL: Lazy<Url> = Lazy::new(|| {
Url::parse(START_URL_STR).expect("URL should parse")
});
pub const SORA_HOMEPAGE_URL_STR: &str = "https://sora.com/";
pub static SORA_HOMEPAGE_URL : Lazy<Url> = Lazy::new(|| {
Url::parse(SORA_HOMEPAGE_URL_STR).expect("URL should parse")
});
pub const SORA_LOGIN_URL_STR: &str = "https://chatgpt.com/auth/login?next=%2Fsora%2F";
pub static SORA_LOGIN_URL: Lazy<Url> = Lazy::new(|| {
Url::parse(SORA_LOGIN_URL_STR).expect("URL should parse")
});
//pub const SORA_ROOT_URL_STR: &str = "https://sora.com/";
//
//pub static SORA_ROOT_URL: Lazy<Url> = Lazy::new(|| {
// Url::parse(SORA_ROOT_URL_STR).expect("URL should parse")
//});
pub async fn open_sora_login_window(
app: &AppHandle,
app_data_root: &AppDataRoot,
sora_creds_manager: &SoraCredentialManager,
) -> AnyhowResult<()> {
if app.get_window(LOGIN_WINDOW_NAME).is_some() {
return Err(anyhow!("Login window already open"));
}
let url = WebviewUrl::External(START_URL.clone());
let window = WebviewWindowBuilder::new(app, LOGIN_WINDOW_NAME, url)
//.user_agent(openai_sora_client::credentials::USER_AGENT)
.always_on_top(true)
.title("Login to OpenAI's Sora")
.center()
.resizable(true)
.visible(true)
.closable(true)
.min_inner_size(200.0, 800.0)
.focused(true)
.devtools(true)
.build()?;
let webview = window.get_webview(LOGIN_WINDOW_NAME)
.ok_or_else(|| anyhow!("no webview found"))?;
clear_all_webview_cookies(&webview)?;
webview.navigate(SORA_HOMEPAGE_URL.clone())?;
// NB: We're starting to get Cloudflare protection screens. Let's try to avoid.
tokio::time::sleep(Duration::from_millis(100)).await;
webview.navigate(SORA_LOGIN_URL.clone())?;
let app_handle = app.clone();
let app_data_root = app_data_root.clone();
let sora_creds_manager = sora_creds_manager.clone();
let _ = tauri::async_runtime::spawn(async move {
sora_login_thread(app_handle, app_data_root, sora_creds_manager).await;
});
Ok(())
}
@@ -0,0 +1,157 @@
use crate::events::sendable_event_trait::SendableEvent;
use crate::events::sora::sora_login_success_event::SoraLoginSuccessEvent;
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::sora::initialize_sora_jwt_bearer_token::initialize_sora_jwt_bearer_token;
use crate::windows::sora_login_window::extract_sora_webview_cookies::extract_sora_webview_cookies;
use crate::windows::sora_login_window::open_sora_login_window::LOGIN_WINDOW_NAME;
use anyhow::anyhow;
use chrono::{DateTime, NaiveDateTime, TimeDelta};
use errors::AnyhowResult;
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;
use std::fs::{read_to_string, OpenOptions};
use std::io::Write;
use std::ops::Sub;
use tauri::{AppHandle, Manager, Webview, WebviewWindow};
pub async fn sora_login_thread(
app: AppHandle,
app_data_root: AppDataRoot,
sora_creds_manager: SoraCredentialManager
) {
loop {
let login_webview_window = match app.get_webview_window(LOGIN_WINDOW_NAME) {
Some(webview) => webview,
None => {
info!("Exit sora login thread.");
return; // NB: Only exit if we don't have the webview.
}
};
let result = check_login_window(
&app,
&login_webview_window,
&app_data_root,
&sora_creds_manager,
).await;
match result {
Err(err) => {
error!("Error checking login window: {:?}", err);
}
Ok(false) => {} // Continue iteration and try again...
Ok(true) => {
info!("Successfully saved cookies from login window. Closing.");
if let Err(err) = login_webview_window.close() {
error!("Error closing login window: {:?}", err);
}
return;
}
}
tokio::time::sleep(std::time::Duration::from_millis(2_000)).await;
}
}
/// Returns true if we can exit.
async fn check_login_window(
app_handle: &AppHandle,
webview_window: &WebviewWindow,
app_data_root: &AppDataRoot,
sora_credential_manager: &SoraCredentialManager,
) -> AnyhowResult<bool> {
/* Login flow looks like this:
1. Start: https://sora.chatgpt.com/
2. Login Start: https://chatgpt.com/auth/login?next=%2Fsora%2F [this is where we start]
3. Login Continue: https://auth.openai.com/log-in
4. SSO / Google Login (etc): https://accounts.google.com/v3/signin/challenge/pwd?[query]
5. Done / Landing: https://sora.chatgpt.com/explore
*/
/*
- Do not use credential manager until the end (we don't load old cookies!)
- Check if we're on the correct domain, if not exit? (or inverse, that we're not in login flow)
*/
let hostname = get_hostname(webview_window)?;
let mut maybe_at_destination = false;
match hostname.as_str() {
"sora.com" | // Old destination
"sora.chatgpt.com" // New destination
=> {
maybe_at_destination = true;
}
// chatgpt.com/auth is also an auth domain
"auth.openai.com" |
"accounts.google.com" |
"accounts.youtube.com" |
"login.live.com" |
"appleid.apple.com"
=> {
// NB: We're in auth flow.
info!("Sora login webview is in auth flow; hostname `{}`.", hostname);
return Ok(false)
}
_ => {}, // We just don't know...
}
let webview_cookies = extract_sora_webview_cookies(webview_window)?.trim().to_string();
let session_cookie_presence = has_session_cookie(&webview_cookies)
.unwrap_or_else(|err| {
error!("Failed to check for session cookie: {:?}", err);
SessionCookiePresence::MaybePresent
});
info!("Sora login webview is at hostname `{}`; cookie status: {:?}.", hostname, session_cookie_presence);
match session_cookie_presence {
SessionCookiePresence::Absent => {
info!("Session cookies are absent.");
return Ok(false);
}
_ => {},
}
let mut new_credentials =
SoraCredentialSet::initialize_with_just_cookies_str(&webview_cookies);
let _upgraded = maybe_upgrade_or_renew_session(&mut new_credentials).await?;
// TODO(bt): Race conditions ahead.
sora_credential_manager.clear_credentials()?;
sora_credential_manager.try_purge_credentials_from_disk();
sora_credential_manager.set_credentials(&new_credentials)?;
sora_credential_manager.persist_all_to_disk()?;
// NB: Event sent to the frontend for the login flow. We shouldn't rely on just this
// alone as it could be brittle if the events aren't caught.
let event = SoraLoginSuccessEvent {};
if let Err(err) = event.send(app_handle) {
error!("Error sending Sora login success event: {:?}", err);
}
Ok(true)
}
fn get_hostname(webview: &WebviewWindow) -> AnyhowResult<String> {
let url = webview.url()?;
let url_hostname= url.host()
.ok_or(anyhow!("no host in url"))?
.to_string();
Ok(url_hostname)
}
+37 -3
View File
@@ -21,11 +21,45 @@
],
"security": {
"csp": {
"default-src": "*",
"connect-src": "*",
"default-src": [
"'self'",
"'unsafe-eval'",
"'unsafe-inline'",
"blob:",
"data:",
"https://*.fakeyou.com",
"https://*.storyteller.ai",
"https://www.googletagmanager.com",
"ipc://localhost",
"tauri://localhost"
],
"connect-src": [
"'self'",
"'unsafe-eval'",
"'unsafe-inline'",
"blob:",
"data:",
"https://*.fakeyou.com",
"https://*.storyteller.ai",
"https://unpkg.com/detect-gpu@5.0.70/",
"https://www.googletagmanager.com",
"ipc://localhost",
"tauri://localhost"
],
"font-src": "*",
"img-src": "* data: blob: 'unsafe-inline'",
"style-src": "*"
"style-src": [
"'self'",
"'unsafe-eval'",
"'unsafe-inline'",
"blob:",
"data:",
"https://*.fakeyou.com",
"https://*.storyteller.ai",
"https://fonts.googleapis.com",
"ipc://localhost",
"tauri://localhost"
]
}
}
},
@@ -0,0 +1,32 @@
[package]
name = "fal_client"
edition = "2021"
version = "0.0.1"
publish = false
[package.metadata.cargo-udeps.ignore]
normal = ["workspace-build-acceleration"]
[lib]
name = "fal_client"
path = "src/lib.rs"
[dependencies]
# Workspace hack (faster compile times)
workspace-build-acceleration = { workspace = true }
# Internal
file_to_base64_url = { path = "../../files/file_to_base64_url" }
errors = { workspace = true }
# External
anyhow = { workspace = true }
fal = { version = "0.3.6", features = ["endpoints"] }
futures = { workspace = true }
tokio = { workspace = true }
url = { workspace = true }
[dev-dependencies]
testing = { path = "../../testing" }
filesys = { path = "../../files/filesys" }
@@ -0,0 +1,13 @@
#[derive(Clone)]
pub struct FalApiKey(pub String);
impl FalApiKey {
pub fn new(api_key: String) -> Self {
Self(api_key)
}
pub fn from_str(api_key: &str) -> Self {
Self(api_key.to_string())
}
}
@@ -0,0 +1 @@
pub mod fal_api_key;
@@ -0,0 +1,43 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
/// Additional errors that aren't included in `fal::FalError`.
#[derive(Debug)]
pub enum FalErrorPlus {
/// An error arising in the `fal` crate.
FalError(fal::FalError),
/// Another error we didn't handle.
AnyhowError(anyhow::Error),
/// URL parse errors.
UrlParseError(url::ParseError),
}
impl Display for FalErrorPlus {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::FalError(err) => write!(f, "FalErrorPlus::FalError: {:?}", err),
Self::AnyhowError(err) => write!(f, "FalErrorPlus::AnyhowError: {:?}", err),
Self::UrlParseError(err) => write!(f, "FalErrorPlus::UrlParseError: {:?}", err),
}
}
}
impl Error for FalErrorPlus {}
impl From<fal::FalError> for FalErrorPlus {
fn from(err: fal::FalError) -> Self {
FalErrorPlus::FalError(err)
}
}
impl From<anyhow::Error> for FalErrorPlus {
fn from(err: anyhow::Error) -> Self {
FalErrorPlus::AnyhowError(err)
}
}
impl From<url::ParseError> for FalErrorPlus {
fn from(err: url::ParseError) -> Self {
FalErrorPlus::UrlParseError(err)
}
}
@@ -0,0 +1,7 @@
//! A wrapper for the "fal" crate that bakes in a few extra recipes and utilities.
extern crate fal;
pub mod creds;
pub mod fal_error_plus;
pub mod requests;
@@ -0,0 +1 @@
pub mod remove_background_rembg;
@@ -0,0 +1,79 @@
use crate::creds::fal_api_key::FalApiKey;
use crate::fal_error_plus::FalErrorPlus;
use fal::endpoints::fal_ai::imageutils::rembg::RemoveBackgroundInput;
use fal::prelude::fal_ai::imageutils::rembg::rembg;
use fal::prelude::*;
use fal_client::file_to_base64_url::file_to_base64_url;
use futures::StreamExt;
use std::path::Path;
use url::Url;
pub struct RemBgArgs<'a, P: AsRef<Path>> {
pub image_path: P,
pub api_key: &'a FalApiKey,
}
pub struct RemBgResponse {
pub image_url: Url,
}
/// remove background using `fal_ai/imageutils/rembg`
pub async fn remove_background_rembg<P: AsRef<Path>>(image_path: P, api_key: &FalApiKey) -> Result<RemBgResponse, FalErrorPlus> {
let image_url = file_to_base64_url(image_path)?;
/*
TODO: Handle error messages -
FalError(FalError(Other("{\"detail\": \"Invalid Key Authorization header format. Expected '<key_id>:<key_secret>'.\"}")))
FalError(FalError(Other("{\"detail\": \"No user found for Key ID and Secret\"}")))
*/
let request = RemoveBackgroundInput {
image_url,
crop_to_bbox: None,
sync_mode: None
};
let result = rembg(request)
.with_api_key(&api_key.0)
.queue()
.await?;
let mut stream = result.stream(true).await?;
while let Some(status) = stream.next().await {
let status = status?;
if status.status == Status::Completed {
break;
}
}
let output = result.response().await?;
let url = Url::parse(&output.image.url)?;
Ok(RemBgResponse {
image_url: url,
})
}
#[cfg(test)]
mod tests {
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::remove_background_rembg::remove_background_rembg;
use errors::AnyhowResult;
use testing::test_file_path::test_file_path;
#[tokio::test]
#[ignore]
async fn test_remove_background_rembg() -> AnyhowResult<()> {
let image = test_file_path("test_data/image/juno.jpg")?;
// TODO: DO NOT COMMIT SECRET !
const KEY : &str = "DO NOT COMMIT";
let api_key = FalApiKey::from_str(KEY);
let result = remove_background_rembg(image, &api_key).await?;
Ok(())
}
}
@@ -1,3 +1,4 @@
use std::time::Duration;
use anyhow::anyhow;
use log::{info, warn};
use errors::AnyhowResult;
@@ -7,7 +8,7 @@ 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::{image_gen_http_request, SoraImageGenError};
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;
@@ -18,6 +19,8 @@ pub struct ImageRemixAutoRenewRequest<'a> {
pub image_size: ImageSize,
pub sora_media_tokens: Vec<String>,
pub credentials: &'a SoraCredentialSet,
/// This function can try several different requests. This is the timeout for *individual* requests.
pub request_timeout: Option<Duration>,
}
/// Image request with retry and session auto-renewal.
@@ -29,6 +32,7 @@ pub async fn image_remix_with_session_auto_renew(request: ImageRemixAutoRenewReq
image_size: request.image_size,
sora_media_tokens: request.sora_media_tokens.clone(), // NB: Clone because used again
credentials: CredentialMigrationRef::New(request.credentials),
request_timeout: request.request_timeout,
}).await;
let err = match result {
@@ -43,29 +47,32 @@ pub async fn image_remix_with_session_auto_renew(request: ImageRemixAutoRenewReq
match err {
// We'll fail these requests...
image_gen_http_request::SoraError::TooManyConcurrentTasks(_) => {
SoraImageGenError::TooManyConcurrentTasks(_) => {
return Err(SoraError::TooManyConcurrentTasks);
}
image_gen_http_request::SoraError::GenericError(err) => {
SoraImageGenError::GenericError(err) => {
return Err(SoraError::OtherBadStatus(anyhow!("image remix failed with GenericError: {:?}", err)))
}
image_gen_http_request::SoraError::NetworkError(err) => {
SoraImageGenError::NetworkError(err) => {
// TODO: The underlying type should be a reqwest::Error.
return Err(SoraError::OtherBadStatus(anyhow!("network error: {:?}", err)))
}
SoraImageGenError::UsernameRequired(_err) => {
return Err(SoraError::SoraUsernameNotYetCreated)
}
// We'll retry these requests...
image_gen_http_request::SoraError::SentinelBlock(err) => {
SoraImageGenError::SentinelBlock(err) => {
warn!("Image generation failed due to sentinel block error: {:?}", err);
refresh_sentinel = true;
}
image_gen_http_request::SoraError::TokenExpired(err) => {
SoraImageGenError::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) => {
SoraImageGenError::InvalidJwt(err) => {
warn!("Image generation failed due to invalid jwt error: {:?}", err);
refresh_jwt = true;
}
@@ -123,12 +130,13 @@ pub async fn image_remix_with_session_auto_renew(request: ImageRemixAutoRenewReq
image_size: request.image_size,
sora_media_tokens: request.sora_media_tokens,
credentials: CredentialMigrationRef::New(&new_creds),
request_timeout: request.request_timeout,
}).await;
match result {
Ok(response) => Ok((response, Some(new_creds))),
Err(err) => match err {
image_gen_http_request::SoraError::TooManyConcurrentTasks(err) => {
image_gen_http_request::SoraImageGenError::TooManyConcurrentTasks(err) => {
Err(SoraError::TooManyConcurrentTasks)
}
_ => {
@@ -14,12 +14,16 @@ use crate::sora_error::SoraError;
use anyhow::anyhow;
use errors::AnyhowResult;
use log::{error, info, warn};
use std::time::Duration;
pub struct ImageUploadFromBytesAutoRenewRequest<'a> {
pub file_bytes: Vec<u8>,
pub filename: String,
pub mime_type: &'a str,
pub credentials: &'a SoraCredentialSet,
/// This function can try to upload several times. This is the timeout for *individual* requests.
pub request_timeout: Option<Duration>,
}
/// Image upload with retry and session auto-renewal.
@@ -32,6 +36,7 @@ pub async fn image_upload_from_bytes_with_session_auto_renew(
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),
request.request_timeout,
).await;
let err = match result {
@@ -51,6 +56,7 @@ pub async fn image_upload_from_bytes_with_session_auto_renew(
request.file_bytes,
request.filename,
CredentialMigrationRef::New(&new_creds),
request.request_timeout,
).await?;
Ok((result, Some(new_creds)))
@@ -6,10 +6,14 @@ use crate::requests::upload::upload_media_http_request::{upload_media_http_reque
use crate::sora_error::SoraError;
use log::{info, warn};
use std::path::Path;
use std::time::Duration;
pub struct ImageUploadFromFileAutoRenewRequest<'a, P: AsRef<Path>> {
pub file_path: P,
pub credentials: &'a SoraCredentialSet,
/// This function can try to upload several times. This is the timeout for *individual* requests.
pub request_timeout: Option<Duration>,
}
/// Image upload with retry and session auto-renewal.
@@ -21,10 +25,15 @@ pub async fn image_upload_from_file_with_session_auto_renew<P: AsRef<Path>>(
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),
/// This function can try to upload several times. This is the timeout for *individual* requests.
request.request_timeout,
).await;
let err = match result {
Ok(response) => return Ok((response, None)),
// We can't retry some errors.
Err(err @ SoraError::SoraUsernameNotYetCreated) => return Err(err),
// Retry all other errors.
Err(err) => err,
};
@@ -37,6 +46,7 @@ pub async fn image_upload_from_file_with_session_auto_renew<P: AsRef<Path>>(
let result = sora_media_upload_from_file(
request.file_path,
CredentialMigrationRef::New(&new_creds),
request.request_timeout,
).await?;
Ok((result, Some(new_creds)))
@@ -39,11 +39,10 @@ pub async fn list_sora_task_status_with_session_auto_renew(
new_creds = Some(updated_creds);
}
let legacy_creds = new_creds.as_ref()
.unwrap_or_else(|| &args.credentials)
.to_legacy_credentials()?;
let upgraded_creds = new_creds.as_ref()
.unwrap_or_else(|| &args.credentials);
let result = get_image_gen_status(&request, &legacy_creds).await;
let result = get_image_gen_status(&request, &upgraded_creds).await;
let err = match result {
Ok(response) => return Ok((response, new_creds)),
@@ -56,7 +55,7 @@ pub async fn list_sora_task_status_with_session_auto_renew(
info!("Retrying image upload with new credentials...");
let result = get_image_gen_status(&request, &legacy_creds).await?;
let result = get_image_gen_status(&request, &upgraded_creds).await?;
Ok((result, Some(new_creds)))
}
@@ -7,13 +7,14 @@ use chrono::{DateTime, TimeDelta, Utc};
use errors::AnyhowResult;
use log::info;
use std::ops::Sub;
use crate::sora_error::SoraError;
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 maybe_upgrade_or_renew_session(sora_credentials: &mut SoraCredentialSet) -> AnyhowResult<bool> {
pub async fn maybe_upgrade_or_renew_session(sora_credentials: &mut SoraCredentialSet) -> Result<bool, SoraError> {
let mut refresh_jwt = !sora_credentials.jwt_bearer_token.is_some();
let refresh_sentinel = !sora_credentials.sora_sentinel.is_some();
@@ -2,6 +2,7 @@ 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;
use crate::creds::sora_credential_set::SoraCredentialSet;
pub async fn wait_for_image_gen_status(task_id: &String, credentials: &SoraCredentials, retry_limit: Option<u32>) -> AnyhowResult<TaskResponse> {
let status_request = StatusRequest {
@@ -10,11 +11,13 @@ pub async fn wait_for_image_gen_status(task_id: &String, credentials: &SoraCrede
// before: Some(task_id.clone()),
};
let retry_limit = retry_limit.unwrap_or(10);
let new_creds = SoraCredentialSet::from_legacy_credentials(&credentials)?;
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?;
let status_response = get_image_gen_status(&status_request, &new_creds).await?;
match find_task_response_by_id(&status_response, task_id.clone()) {
Some(task_response) => match TaskStatus::from_str(&task_response.status) {
File diff suppressed because one or more lines are too long
@@ -1,7 +1,9 @@
use crate::creds::credential_migration::CredentialMigrationRef;
use serde_derive::{Deserialize, Serialize};
use thiserror::Error;
use crate::requests::image_gen::image_gen_status::TaskId;
use log::warn;
use serde_derive::{Deserialize, Serialize};
use std::time::Duration;
use thiserror::Error;
const SORA_IMAGE_GEN_URL: &str = "https://sora.com/backend/video_gen";
@@ -9,7 +11,7 @@ const SORA_IMAGE_GEN_URL: &str = "https://sora.com/backend/video_gen";
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(Error, Debug)]
pub enum SoraError {
pub enum SoraImageGenError {
#[error("Sora token expired: {0}")]
TokenExpired(String),
@@ -27,6 +29,9 @@ pub enum SoraError {
#[error("Network error: {0}")]
NetworkError(String),
#[error("Sora username required: {0}")]
UsernameRequired(String),
}
#[derive(Serialize, Debug)]
@@ -127,6 +132,7 @@ pub enum SoraErrorCode {
InvalidJwt,
TokenExpired,
TooManyConcurrentTasks,
UsernameRequired,
Unknown(String),
}
@@ -147,7 +153,11 @@ 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: CredentialMigrationRef<'_>) -> Result<RawSoraResponse, SoraError> {
pub (crate) async fn image_gen_http_request(
sora_request: RawSoraImageGenRequest,
credentials: CredentialMigrationRef<'_>,
request_timeout: Option<Duration>,
) -> Result<RawSoraResponse, SoraImageGenError> {
let client = reqwest::Client::new();
let mut cookie;
@@ -161,78 +171,88 @@ pub (crate) async fn image_gen_http_request(sora_request: RawSoraImageGenRequest
// 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()))?;
.ok_or(SoraImageGenError::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()))?
.ok_or(SoraImageGenError::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()))?;
.ok_or(SoraImageGenError::SentinelBlock("Sentinel is required for image generation.".to_string()))?;
}
}
let http_request = client.post(SORA_IMAGE_GEN_URL)
let mut http_request = client.post(SORA_IMAGE_GEN_URL)
.header("User-Agent", USER_AGENT)
.header("Cookie", &cookie)
.header("Authorization", &authorization_header)
.header("Content-Type", "application/json")
.header("OpenAI-Sentinel-Token", &sentinel);
if let Some(timeout) = request_timeout {
http_request = http_request.timeout(timeout);
}
let http_request = http_request.json(&sora_request).build()
.map_err(|e| SoraError::NetworkError(e.to_string()))?;
.map_err(|e| SoraImageGenError::NetworkError(e.to_string()))?;
let response = client.execute(http_request)
.await
.map_err(|e| SoraError::NetworkError(e.to_string()))?;
.map_err(|e| SoraImageGenError::NetworkError(e.to_string()))?;
let status = response.status();
let response_body = &response.text().await
.map_err(|e| SoraError::NetworkError(e.to_string()))?;
.map_err(|e| SoraImageGenError::NetworkError(e.to_string()))?;
if status != reqwest::StatusCode::OK {
warn!("Sora image generation failure. Raw response: {:?}", response_body);
let error_response: RawSoraErrorResponse = serde_json::from_str(response_body)
.map_err(|e| SoraError::GenericError(format!("Failed to parse error response: {}", e)))?;
.map_err(|e| SoraImageGenError::GenericError(format!("Failed to parse error response: {}", e)))?;
// Check for specific error codes
if let Some(code) = &error_response.error.code {
match code {
SoraErrorCode::TokenExpired => {
return Err(SoraError::TokenExpired(error_response.error.message));
return Err(SoraImageGenError::TokenExpired(error_response.error.message));
}
SoraErrorCode::SentinelBlock => {
return Err(SoraError::SentinelBlock(error_response.error.message));
return Err(SoraImageGenError::SentinelBlock(error_response.error.message));
}
SoraErrorCode::TooManyConcurrentTasks => {
return Err(SoraError::TooManyConcurrentTasks(error_response.error.message));
return Err(SoraImageGenError::TooManyConcurrentTasks(error_response.error.message));
}
SoraErrorCode::Unknown(_) => {
return Err(SoraError::GenericError(error_response.error.message));
return Err(SoraImageGenError::GenericError(error_response.error.message));
}
SoraErrorCode::InvalidJwt => {
return Err(SoraError::InvalidJwt(error_response.error.message));
return Err(SoraImageGenError::InvalidJwt(error_response.error.message));
}
SoraErrorCode::UsernameRequired => {
return Err(SoraImageGenError::UsernameRequired(error_response.error.message));
}
}
}
return Err(SoraError::GenericError(error_response.error.message));
return Err(SoraImageGenError::GenericError(error_response.error.message));
}
let response = serde_json::from_str(response_body)
.map_err(|e| SoraError::GenericError(format!("Failed to parse success response: {}", e)))?;
.map_err(|e| SoraImageGenError::GenericError(format!("Failed to parse success response: {}", e)))?;
Ok(response)
}
#[cfg(test)]
mod tests {
use errors::AnyhowResult;
use crate::requests::image_gen::image_gen_http_request::RawSoraResponse;
use errors::AnyhowResult;
#[test]
fn deserialize_task_id() -> AnyhowResult<()> {
@@ -6,7 +6,9 @@ use reqwest::Url;
use serde_derive::{Deserialize, Serialize};
use std::fmt::{Debug, Display};
use std::{fs::File, io::Write, path::Path};
use log::debug;
use log::{debug, warn};
use crate::creds::sora_credential_set::SoraCredentialSet;
use crate::creds::sora_jwt_bearer_token::SoraJwtBearerToken;
const SORA_STATUS_URL: &str = "https://sora.com/backend/video_gen";
@@ -171,7 +173,7 @@ pub struct InpaintItem {
pub r#type: String,
pub preset_id: Option<String>,
pub generation_id: Option<String>,
pub upload_media_id: String,
pub upload_media_id: Option<String>,
pub frame_index: i32,
pub source_start_frame: i32,
}
@@ -199,7 +201,16 @@ impl StatusRequest {
}
/// Gets the status of image generation tasks from Sora API
pub async fn get_image_gen_status(status_request: &StatusRequest, credentials: &SoraCredentials) -> Result<VideoGenStatusResponse, SoraError> {
pub async fn get_image_gen_status(status_request: &StatusRequest, credentials: &SoraCredentialSet) -> Result<VideoGenStatusResponse, SoraError> {
let bearer_header = match credentials.jwt_bearer_token.as_ref() {
Some(bearer) => bearer.to_authorization_header_value(),
None => {
warn!("No JWT bearer for getting image gen status!");
return Err(SoraError::NoBearerTokenAvailable)
},
};
let client = reqwest::Client::new();
let mut url = reqwest::Url::parse(SORA_STATUS_URL)
@@ -216,8 +227,8 @@ pub async fn get_image_gen_status(status_request: &StatusRequest, credentials: &
let http_request = client.get(url)
.header("User-Agent", USER_AGENT)
.header("Cookie", &credentials.cookie)
.header("Authorization", credentials.authorization_header_value())
.header("Cookie", credentials.cookies.as_str())
.header("Authorization", bearer_header)
.header("Content-Type", "application/json");
let http_request = http_request.build()?;
@@ -235,9 +246,15 @@ pub async fn get_image_gen_status(status_request: &StatusRequest, credentials: &
return Err(SoraError::AnyhowError(anyhow::anyhow!("The status request failed; status = {:?}, message = {:?}", status, response_body)));
}
let response: VideoGenStatusResponse = serde_json::from_str(response_body)?;
let maybe_response = serde_json::from_str::<VideoGenStatusResponse>(response_body);
Ok(response)
match maybe_response {
Ok(response) => Ok(response),
Err(err) => {
warn!("Failed to parse status response: {:?}", err);
Err(SoraError::JsonErrorWithBody(err, response_body.to_string()))
}
}
}
pub async fn save_generations_to_dir(generations: &[Generation], dir: &str) -> AnyhowResult<()> {
@@ -275,6 +292,7 @@ mod tests {
use errors::AnyhowResult;
use std::fs::read_to_string;
use testing::test_file_path::test_file_path;
use crate::creds::sora_credential_set::SoraCredentialSet;
#[ignore]
#[tokio::test]
@@ -297,10 +315,12 @@ mod tests {
let bearer = bearer.trim().to_string();
let creds = SoraCredentials { bearer_token: bearer, cookie, sentinel: Some(sentinel) };
let new_creds = SoraCredentialSet::from_legacy_credentials(&creds)?;
// Get the task status for a specific task
// let response = get_image_gen_status(&StatusRequest::new(None, Some("task_01jr9yvpfyetx9r7qvvx38scna".to_string())), &creds).await?;
let response = get_image_gen_status(&StatusRequest::new(Some(50), None), &creds).await?;
let response = get_image_gen_status(&StatusRequest::new(Some(50), None), &new_creds).await?;
println!("task_id: {}", response.task_responses[0].id);
let task_id = response.task_responses[0].id.clone();
@@ -1,5 +1,5 @@
pub (crate) mod image_gen_http_request;
pub use image_gen_http_request::SoraError;
pub use image_gen_http_request::SoraImageGenError;
pub mod common;
pub mod sora_image_gen_remix;
pub mod sora_image_gen_simple;
@@ -1,7 +1,8 @@
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};
use crate::requests::image_gen::image_gen_http_request::{image_gen_http_request, InpaintItem, InpaintItemType, OperationType, RawSoraImageGenRequest, SoraImageGenError, VideoGenType};
use log::info;
use std::time::Duration;
pub struct SoraImageGenRemixRequest<'a> {
pub prompt: String,
@@ -9,11 +10,12 @@ pub struct SoraImageGenRemixRequest<'a> {
pub image_size: ImageSize,
pub sora_media_tokens: Vec<String>,
pub credentials: CredentialMigrationRef<'a>,
pub request_timeout: Option<Duration>,
}
/// The "remix" commands let you supply additional images as context.
/// Sora "media tokens" of previously uploaded images must be supplied.
pub async fn sora_image_gen_remix(request: SoraImageGenRemixRequest<'_>) -> Result<SoraImageGenResponse, SoraError> {
pub async fn sora_image_gen_remix(request: SoraImageGenRemixRequest<'_>) -> Result<SoraImageGenResponse, SoraImageGenError> {
let args = RawSoraImageGenRequest {
r#type: VideoGenType::ImageGen,
operation: OperationType::Remix,
@@ -39,7 +41,7 @@ pub async fn sora_image_gen_remix(request: SoraImageGenRemixRequest<'_>) -> Resu
// 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?;
let result = image_gen_http_request(args, request.credentials, request.request_timeout).await?;
Ok(SoraImageGenResponse {
task_id: result.id,
@@ -80,6 +82,7 @@ mod tests {
image_size: ImageSize::Square,
sora_media_tokens: vec!["media_01jqyhrz4detyvtzwp2p4j63ad".to_string()],
credentials: CredentialMigrationRef::Legacy(&creds),
request_timeout: None,
}).await?;
println!("task_id: {}", response.task_id);
@@ -1,14 +1,15 @@
use crate::credentials::SoraCredentials;
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, OperationType, RawSoraImageGenRequest, VideoGenType};
use errors::AnyhowResult;
use crate::creds::credential_migration::CredentialMigrationRef;
use std::time::Duration;
pub struct SoraImageGenSimpleRequest<'a> {
pub prompt: String,
pub num_images: NumImages,
pub image_size: ImageSize,
pub credentials: CredentialMigrationRef<'a>,
pub request_timeout: Option<Duration>,
}
pub async fn sora_image_gen_simple(args: SoraImageGenSimpleRequest<'_>) -> AnyhowResult<SoraImageGenResponse> {
@@ -24,7 +25,7 @@ pub async fn sora_image_gen_simple(args: SoraImageGenSimpleRequest<'_>) -> Anyho
};
// TODO: Error handling.
let result = image_gen_http_request(sora_request, args.credentials).await?;
let result = image_gen_http_request(sora_request, args.credentials, args.request_timeout).await?;
Ok(SoraImageGenResponse {
task_id: result.id,
@@ -34,12 +35,12 @@ pub async fn sora_image_gen_simple(args: SoraImageGenSimpleRequest<'_>) -> Anyho
#[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_simple::{sora_image_gen_simple, SoraImageGenSimpleRequest};
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]
@@ -64,6 +65,7 @@ mod tests {
num_images: NumImages::One,
image_size: ImageSize::Square,
credentials: CredentialMigrationRef::Legacy(&creds),
request_timeout: None,
}).await?;
println!("task_id: {}", response.task_id);
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
pub mod list_media;
@@ -1,5 +1,6 @@
pub mod bearer;
pub mod image_gen;
pub mod job;
pub mod list_media;
pub mod sentinel_refresh;
pub mod upload;
@@ -2,10 +2,16 @@ use crate::creds::credential_migration::CredentialMigrationRef;
use crate::requests::upload::upload_media_http_request::{upload_media_http_request, SoraMediaUploadResponse};
use crate::sora_error::SoraError;
use std::path::PathBuf;
use std::time::Duration;
/// 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: CredentialMigrationRef<'_>) -> Result<SoraMediaUploadResponse, SoraError> {
pub async fn sora_media_upload_from_bytes(
bytes: Vec<u8>,
file_name: String,
creds: CredentialMigrationRef<'_>,
maybe_timeout: Option<Duration>,
) -> Result<SoraMediaUploadResponse, SoraError> {
let file_path = PathBuf::from(&file_name);
// TODO: Read file magic bytes first, then fall back to this.
@@ -20,5 +26,5 @@ pub async fn sora_media_upload_from_bytes(bytes: Vec<u8>, file_name: String, cre
_ => "application/octet-stream",
};
Ok(upload_media_http_request(bytes, file_name, mime_type, creds).await?)
Ok(upload_media_http_request(bytes, file_name, mime_type, creds, maybe_timeout).await?)
}
@@ -2,6 +2,7 @@ use crate::creds::credential_migration::CredentialMigrationRef;
use crate::requests::upload::upload_media_http_request::{upload_media_http_request, SoraMediaUploadResponse};
use crate::sora_error::SoraError;
use std::path::Path;
use std::time::Duration;
use tokio::fs::File;
use tokio::io::AsyncReadExt;
@@ -9,7 +10,11 @@ use tokio::io::AsyncReadExt;
/// There's a better way to implement this.
const INITIAL_BUFFER_SIZE : usize = 1024*1024;
pub async fn sora_media_upload_from_file<P: AsRef<Path>>(file_path: P, creds: CredentialMigrationRef<'_>) -> Result<SoraMediaUploadResponse, SoraError> {
pub async fn sora_media_upload_from_file<P: AsRef<Path>>(
file_path: P,
creds: CredentialMigrationRef<'_>,
maybe_timeout: Option<Duration>,
) -> 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?;
@@ -31,7 +36,7 @@ pub async fn sora_media_upload_from_file<P: AsRef<Path>>(file_path: P, creds: Cr
_ => "application/octet-stream",
};
Ok(upload_media_http_request(buffer, filename, mime_type, creds).await?)
Ok(upload_media_http_request(buffer, filename, mime_type, creds, maybe_timeout).await?)
}
#[cfg(test)]
@@ -63,6 +68,7 @@ mod tests {
let response = sora_media_upload_from_file(
image_path,
CredentialMigrationRef::Legacy(&creds),
None,
).await?;
println!("media: {:?}", response);
@@ -1,3 +1,4 @@
use std::time::Duration;
use crate::credentials::{SoraCredentials, USER_AGENT};
use crate::creds::credential_migration::CredentialMigrationRef;
use crate::sora_error::SoraError;
@@ -69,6 +70,7 @@ pub (crate) async fn upload_media_http_request(
filename: String,
mime_type: &str,
credentials: CredentialMigrationRef<'_>,
maybe_timeout: Option<Duration>,
) -> Result<SoraMediaUploadResponse, SoraError> {
// Create multipart form
@@ -97,11 +99,15 @@ pub (crate) async fn upload_media_http_request(
// Make API request
let client = Client::new();
let request_builder = client.post(SORA_UPLOAD_MEDIA_URL)
let mut request_builder = client.post(SORA_UPLOAD_MEDIA_URL)
.multipart(form)
.header("User-Agent", USER_AGENT)
.header("Cookie", &cookie)
.header("Authorization", &auth_header);
if let Some(timeout) = maybe_timeout {
request_builder = request_builder.timeout(timeout);
}
let response = request_builder.send().await?;
@@ -26,10 +26,27 @@ pub enum SoraError {
/// }
UnauthorizedCookieOrBearerExpired,
/// Failed to generate a bearer token despite the `200 OK` response.
/// This has been generated by sending old/expired session cookies or empty cookies.
FailedToGenerateBearer,
/// Sora requires users to set up an account username before they can generate images.
/// This requires the user to set up that username.
SoraUsernameNotYetCreated,
/// Something is wrong with the JWT bearer token.
/// This error originates on our end as we try to parse the JWT.
LocalJwtClaimsParseError(String),
/// Sora is having issues.
/// Typically served from Cloudflare
/// We preserve the message for debugging.
BadGateway(String),
/// HTTP 524 (Cloudflare-specific)
/// Cloudflare formed a TCP connection to Sora, but no payload was delivered before timeout
/// We preserve the message for debugging
CloudFlareTimeout(String),
/// Another error occurred.
OtherBadStatus(anyhow::Error),
@@ -45,6 +62,10 @@ pub enum SoraError {
/// serde_json::Error, likely from JSON deserialization schema mismatch.
JsonError(serde_json::Error),
/// serde_json::Error, likely from JSON deserialization schema mismatch.
/// Includes the original body.
JsonErrorWithBody(serde_json::Error, String),
}
impl Error for SoraError {}
@@ -55,6 +76,15 @@ impl Display for SoraError {
Self::NoBearerTokenAvailable => {
write!(f, "Unauthorized: no bearer token set")
}
Self::FailedToGenerateBearer => {
write!(f, "Failed to generate bearer. Probably invalid session.")
}
Self::SoraUsernameNotYetCreated => {
write!(f, "Sora username not created. Please set up a username.")
}
Self::LocalJwtClaimsParseError(message) => {
write!(f, "Local JWT claims parse error: {}", message)
}
Self::TooManyConcurrentTasks => {
write!(f, "Too many concurrent tasks. Please wait.")
}
@@ -64,6 +94,9 @@ impl Display for SoraError {
Self::BadGateway(message) => {
write!(f, "Bad Gateway; Sora is likely having issues: {:?}", message)
}
Self::CloudFlareTimeout(message) => {
write!(f, "Cloudflare Timeout (524); Sora is likely having issues: {:?}", message)
}
Self::OtherBadStatus(err) => {
write!(f, "Other error: {}", err)
}
@@ -79,6 +112,9 @@ impl Display for SoraError {
Self::JsonError(err) => {
write!(f, "serde_json error: {}", err)
}
Self::JsonErrorWithBody(err, message) => {
write!(f, "serde_json error: {:?} body: {:?}", err, message)
}
}
}
}
@@ -8,17 +8,61 @@ pub async fn classify_general_http_error(response: reqwest::Response) -> SoraErr
Ok(text) => text,
Err(err) => return SoraError::ReqwestError(err),
};
/*
Old cookie -
Message: {
"error": {
"message": "Your authentication token has expired. Please try signing in again.",
"type": "invalid_request_error",
"param": null,
"code": "token_expired"
}
}
No cookie -
Message: {
"error": {
"message": "Your authentication token has expired. Please try signing in again.",
"type": "invalid_request_error",
"param": null,
"code": "token_expired"
}
}
*/
// TODO: I *think* this is just the bearer token, not the cookie.
let cookie_expired =
message.contains("Your authentication token has expired. Please try signing in again.")
|| message.contains("token_expired");
if cookie_expired {
return SoraError::UnauthorizedCookieOrBearerExpired
return SoraError::UnauthorizedCookieOrBearerExpired;
}
let needs_onboarding =
message.contains("You must onboard before using this service")
|| message.contains("onboarding_required");
if needs_onboarding {
return SoraError::SoraUsernameNotYetCreated;
}
if status.as_u16() == 502 {
return SoraError::BadGateway(message);
let status_code = status.as_u16();
match status_code {
502 => {
return SoraError::BadGateway(message);
}
524 => {
if message.contains("cloudflare") || message.contains("Cloudflare") {
return SoraError::CloudFlareTimeout(message);
}
}
_ => {}, // Fall-through
}
SoraError::OtherBadStatus(anyhow::anyhow!("Upload failed with status {}: {}", status, message))
@@ -4,7 +4,8 @@ use cookie::Cookie;
use once_cell::sync::Lazy;
use errors::AnyhowResult;
pub const SESSION_COOKIE_NAME : &str = "__Secure-authjs.session-token";
pub const SESSION_COOKIE_NAME_OLD : &str = "__Secure-authjs.session-token";
pub const SESSION_COOKIE_NAME_NEW : &str = "__Secure-next-auth.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.
@@ -51,7 +52,9 @@ pub fn has_session_cookie(cookies_header: &str) -> AnyhowResult<SessionCookiePre
for cookie in cookies {
let cookie= cookie?;
let cookie_name = cookie.name();
if cookie_name == SESSION_COOKIE_NAME {
if cookie_name == SESSION_COOKIE_NAME_NEW {
return Ok(SessionCookiePresence::Present);
} else if cookie_name == SESSION_COOKIE_NAME_NEW {
return Ok(SessionCookiePresence::Present);
} else if !IRRELEVANT_COOKIE_NAMES.contains(cookie_name) {
unknown_cookie_count += 1;
@@ -1,12 +1,13 @@
//! Adapted from 'jwt' crate
use crate::sora_error::SoraError;
use anyhow::anyhow;
use base64::Engine;
use base64::prelude::BASE64_URL_SAFE_NO_PAD;
use base64::Engine;
use chrono::{DateTime, Utc};
use errors::AnyhowResult;
use log::warn;
use serde_json::Value;
use errors::AnyhowResult;
const SEPARATOR: &str = ".";
@@ -19,48 +20,53 @@ pub struct SoraJwtClaims {
pub email_verified: bool,
}
pub fn lightweight_sora_jwt_parse(token: &str) -> AnyhowResult<SoraJwtClaims> {
pub fn lightweight_sora_jwt_parse(token: &str) -> Result<SoraJwtClaims, SoraError> {
let [_header_str, claims_str, _signature_str] = split_components(token)?;
let claims = decode_base64(claims_str)?;
let claims : serde_json::Map<String, Value> = serde_json::from_str(&claims)?;
let claims = decode_base64(claims_str)
.map_err(|err| SoraError::LocalJwtClaimsParseError(
format!("failure to parse claims base64: {:?}", err)))?;
let claims : serde_json::Map<String, Value> = serde_json::from_str(&claims)
.map_err(|err| SoraError::LocalJwtClaimsParseError(
format!("failure to parse claims json: {:?}", err)))?;
let iat = claims.get("iat")
.map(|val| val.as_number())
.flatten()
.ok_or(anyhow!("no iat claim"))?;
.ok_or_else(|| jwt_error("no iat claim"))?;
let iat = iat.as_i64()
.ok_or(anyhow!("iat is not a number"))?;
.ok_or_else(|| jwt_error("iat is not a number"))?;
let iat = DateTime::from_timestamp(iat, 0)
.ok_or(anyhow!("iat is not a valid timestamp"))?;
.ok_or_else(|| jwt_error("iat is not a valid timestamp"))?;
let exp = claims.get("exp")
.map(|val| val.as_number())
.flatten()
.ok_or(anyhow!("no exp claim"))?;
.ok_or_else(|| jwt_error("no exp claim"))?;
let exp = exp.as_i64()
.ok_or(anyhow!("exp is not a number"))?;
.ok_or_else(|| jwt_error("exp is not a number"))?;
let exp = DateTime::from_timestamp(exp, 0)
.ok_or(anyhow!("iat is not a valid timestamp"))?;
.ok_or_else(|| jwt_error("iat is not a valid timestamp"))?;
let user_id = claims.get("https://api.openai.com/auth")
.map(|val| val.get("user_id"))
.flatten()
.map(|val| val.as_str())
.flatten()
.ok_or(anyhow!("no user_id claim"))?;
.ok_or_else(|| jwt_error("no user_id claim"))?;
let profile = claims.get("https://api.openai.com/profile")
.ok_or(anyhow!("no user_id claim"))?;
.ok_or_else(|| jwt_error("no user_id claim"))?;
let email = profile.get("email")
.map(|val| val.as_str())
.flatten()
.ok_or(anyhow!("no email claim"))?;
.ok_or_else(|| jwt_error("no email claim"))?;
let email_verified = profile.get("email_verified")
.map(|val| val.as_bool())
@@ -79,14 +85,19 @@ pub fn lightweight_sora_jwt_parse(token: &str) -> AnyhowResult<SoraJwtClaims> {
})
}
fn split_components(token: &str) -> AnyhowResult<[&str; 3]> {
fn split_components(token: &str) -> Result<[&str; 3], SoraError> {
let mut components = token.split(SEPARATOR);
let header = components.next().ok_or(anyhow!("no header component"))?;
let claims = components.next().ok_or(anyhow!("no claims component"))?;
let signature = components.next().ok_or(anyhow!("no signature component"))?;
let header = components.next()
.ok_or_else(|| jwt_error("no header component"))?;
let claims = components.next()
.ok_or_else(|| jwt_error("no claims component"))?;
let signature = components.next()
.ok_or_else(|| jwt_error("no signature component"))?;
if components.next().is_some() {
return Err(anyhow!("too many components"));
return Err(jwt_error("too many components"));
}
Ok([header, claims, signature])
@@ -98,6 +109,10 @@ fn decode_base64(raw: &str) -> AnyhowResult<String> {
Ok(json_str)
}
fn jwt_error(reason: &str) -> SoraError {
SoraError::LocalJwtClaimsParseError(reason.to_string())
}
#[cfg(test)]
mod tests {
use crate::utils::lightweight_sora_jwt_parse::lightweight_sora_jwt_parse;
@@ -31,7 +31,17 @@ impl <T: Clone> CloneSlot<T> {
Ok(store) => Ok(store.clone()),
}
}
pub fn get_clone_required(&self) -> AnyhowResult<T> {
match self.store.read() {
Err(err) => Err(anyhow!("Failed to acquire read lock: {:?}", err)),
Ok(store) => match &*store {
None => Err(anyhow!("Required stored item is not set")),
Some(inner) => Ok(inner.clone()),
}
}
}
pub fn set_clone(&self, value: &T) -> AnyhowResult<()> {
match self.store.write() {
Err(err) => Err(anyhow!("Failed to acquire write lock: {:?}", err)),
@@ -0,0 +1,29 @@
[package]
name = "file_to_base64_url"
edition = "2021"
version = "0.0.1"
publish = false
[package.metadata.cargo-udeps.ignore]
normal = ["workspace-build-acceleration"]
[lib]
name = "fal_client"
path = "src/lib.rs"
[dependencies]
# Workspace hack (faster compile times)
workspace-build-acceleration = { workspace = true }
# Internal
errors = { path = "../../errors" }
# External
anyhow = { workspace = true }
base64 = { workspace = true }
mimetypes = { workspace = true }
[dev-dependencies]
testing = { path = "../../testing" }
filesys = { path = "../../files/filesys" }
@@ -0,0 +1,26 @@
use anyhow::anyhow;
use base64::prelude::{BASE64_STANDARD, BASE64_URL_SAFE, BASE64_URL_SAFE_NO_PAD};
use base64::Engine;
use errors::AnyhowResult;
use mimetypes::mimetype_info::mimetype_info::MimetypeInfo;
use std::path::Path;
/// Read a file into a base64 URL-safe string.
pub fn file_to_base64_url<P: AsRef<Path>>(path: P) -> AnyhowResult<String> {
let bytes = std::fs::read(path.as_ref())?;
let mime = match MimetypeInfo::get_for_bytes(&bytes) {
Some(mime) => mime,
None => {
return Err(anyhow!("could not determine mimetype"));
},
};
let base64_bytes = BASE64_STANDARD.encode(&bytes);
match mime.mime_type() {
"image/png" => Ok(format!("data:image/png;base64,{}", base64_bytes)),
"image/jpeg" => Ok(format!("data:image/jpeg;base64,{}", base64_bytes)),
_ => Err(anyhow!("unknown or unsupported mime type: {}", mime.mime_type())),
}
}
@@ -0,0 +1 @@
pub mod file_to_base64_url;
@@ -15,7 +15,7 @@ use log::warn;
use log::{debug, error};
use mysql_queries::queries::media_files::get::get_media_file::get_media_file;
use openai_sora_client::credentials::SoraCredentials;
use openai_sora_client::requests::image_gen::SoraError;
use openai_sora_client::requests::image_gen::SoraImageGenError;
use openai_sora_client::requests::sentinel_refresh::generate::token::generate_token;
use serde::Deserialize;
use serde::Serialize;
@@ -326,7 +326,7 @@ pub async fn enqueue_studio_image_generation_handler(http_request: HttpRequest,
debug!("Sora image gen response: {:?}", response);
if let Err(SoraError::SentinelBlock(msg)) = &response {
if let Err(SoraImageGenError::SentinelBlock(msg)) = &response {
error!("Sora sentinel block, attempting refresh: {}", msg);
match generate_token().await {
@@ -382,16 +382,16 @@ pub async fn enqueue_studio_image_generation_handler(http_request: HttpRequest,
let response = response.map_err(|err| {
match err {
SoraError::TokenExpired(msg) => {
SoraImageGenError::TokenExpired(msg) => {
error!("Sora token expired, needs refresh: {}", msg);
// TODO: Implement token refresh logic here
EnqueueImageGenRequestError::ServerError
},
SoraError::SentinelBlock(msg) => {
SoraImageGenError::SentinelBlock(msg) => {
error!("Sora sentinel block still occurring after refresh: {}", msg);
EnqueueImageGenRequestError::ServerError
},
SoraError::TooManyConcurrentTasks(msg) => {
SoraImageGenError::TooManyConcurrentTasks(msg) => {
error!("Sora too many concurrent tasks: {}", msg);
EnqueueImageGenRequestError::TooManyConcurrentTasks
},

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