Compare commits

...
Author SHA1 Message Date
bflatastic af55db0731 removed background gallery to fix build issue 2025-04-24 20:35:54 +07:00
bflatastic 7cb9873d60 pathing fix and renaming 2025-04-24 20:28:58 +07:00
bflatastic 1573c927bf api fixes and sort only user uploads on my uploads tab, shared gallery button for both 2d and 3d 2025-04-24 19:21:01 +07:00
Tanish Manku e5627fd225 Add the last three dependencies (needs api fix) 2025-04-24 14:21:07 +07:00
bflatastic 24f98f8c3b shared lightbox, tab selector, loading spinner 2025-04-24 14:19:28 +07:00
Tanish MankuandTanish Manku d3e1d7fab9 Fix api lib (ApiManager) imports (#552)
Co-authored-by: Tanish Manku <-h>
2025-04-24 00:10:55 -07:00
bflatastic e34f9cad68 change favicon for all projects 2025-04-24 13:08:23 +07:00
bflatastic 37f29e2e48 artcraft website: responsive fixes 2025-04-24 13:00:26 +07:00
Brandon Thomas f2a2aa6e28 upgrade / refresh tokens 2025-04-23 12:08:18 -04:00
bflatastic 76a639a045 artcraft website favicon update 2025-04-23 22:08:40 +07:00
Brandon Thomas 4fd69c2439 refactor sora client packages so we can make a "recipes" auto-refreshing client 2025-04-23 10:41:45 -04:00
Brandon Thomas 64948e82a6 JWT claims from Sora parsed 2025-04-23 10:25:12 -04:00
Kasisnu 878763c987 ci-test: include macos runner for tauri builds 2025-04-23 14:11:43 +05:30
BFlat 959bdcd918 artcraft website (#550)
landing page and download for artcraft tauri app
2025-04-23 13:22:30 +07:00
Brandon Thomas a9967f0e76 resetting bearer token fails 2025-04-22 23:47:17 -04:00
Brandon Thomas cd680ac319 refactor 2025-04-22 23:29:17 -04:00
Brandon Thomas 3eebb7fec9 download and upload media files with sora remix requests 2025-04-22 20:43:14 -04:00
Brandon Thomas 63753c878a fix decode 2025-04-22 19:15:16 -04:00
Brandon Thomas 769ee07d69 Storyteller Rust API client 2025-04-22 18:55:30 -04:00
bflatastic 2fee3e12d5 editor3d: font fix 2025-04-22 22:40:51 +07:00
Kasisnu 5eb1e256e7 oai-client: basic method for getting a bearer token against a cookie 2025-04-22 20:39:43 +05:30
BFlat 46b687aca2 reusable demo modal + 3d demo video (#548)
- made the demo modal a shared component
2025-04-22 19:25:47 +07:00
Tanish MankuandTanish Manku df07e1f1e9 Hotfix rerendering spam - Outliner and Promptbox (#547)
Could be done better, but this works just as well under time
constraints.

For future reference: The freecam movement speed logic should be moved
to React land instead of processing the update every frame using the
time delta. Just use performance.now() in a useEffect or something.

---------

Co-authored-by: Tanish Manku <-h>
2025-04-22 01:26:49 -07:00
bflatastic bb78fb389f updated demo video 2025-04-22 12:46:00 +07:00
bflatastic 5015cd30a1 Merge branch 'main' of https://github.com/storytold/storyteller-rust 2025-04-22 11:02:45 +07:00
bflatastic 33699a16ef bug fix: WASD moving scene on promptbox focus + make prompt box highlighted on textarea focus 2025-04-22 11:02:42 +07:00
Brandon Thomas 9e54e9b864 another failure condition 2025-04-21 23:50:37 -04:00
bflatastic d11c30c914 bug fix: cannot type letter "b" in asset modal when open 2025-04-22 10:40:18 +07:00
Brandon Thomas fe9dfbb63a better credential behavior 2025-04-21 22:52:18 -04:00
Brandon Thomas aca784042e detect tauri vs browser (#546) 2025-04-21 19:19:41 -04:00
ArEnScandMichael Chung a2e14aaea3 PromptBox (#545)
Co-authored-by: Michael Chung <michaelchung@michaels-mbp-2.home>
2025-04-21 18:46:55 -04:00
Brandon Thomas ab97e73279 storyteller-web wasn't logging due to this ommission 2025-04-21 18:30:15 -04:00
Brandon Thomas 4313ccc980 storyteller-web cors for localhost 2d/3d 2025-04-21 18:18:50 -04:00
Tanish Manku 397172e036 Fix "forearm" filter due to "ear" (#544) 2025-04-21 15:14:37 -07:00
Tanish Manku d7566dd7ab Add a blacklist filter for the FK bones (#543) 2025-04-21 15:06:28 -07:00
ArEnScandMichael Chung acf5852acc PromptBox (#542)
Co-authored-by: Michael Chung <michaelchung@michaels-mbp-2.home>
2025-04-21 17:54:16 -04:00
Brandon Thomas d5e2a222b5 threadsafe credential holder, sora endpoints 2025-04-21 17:40:29 -04:00
ArEnScandMichael Chung 06ca3be4f5 Used Aspect Ratio to fix. (#541)
Prompt box handles both web and desktop.

Co-authored-by: Michael Chung <michaelchung@michaels-mbp-2.home>
2025-04-21 16:59:20 -04:00
Brandon Thomas a4e7a785d7 new sora tauri endpoints 2025-04-21 16:13:37 -04:00
Brandon Thomas dd8b03692a refactor sora module naming 2025-04-21 15:45:27 -04:00
bflatastic 638f453cb8 tsconfig and package lock update 2025-04-22 02:16:01 +07:00
bflatastic 5e5da8c95b added shared tooltip component + asset modal to use new shared components 2025-04-22 02:14:57 +07:00
253 changed files with 6269 additions and 798 deletions
+63
View File
@@ -0,0 +1,63 @@
name: 'publish'
on:
push:
branches:
- tauri-prod-release
# This workflow will trigger on each push to the `release` branch to create or update a GitHub release, build your app, and upload the artifacts to the release.
jobs:
publish-tauri:
permissions:
contents: write
strategy:
fail-fast: false
matrix:
include:
- platform: 'macos-latest' # for Arm based macs (M1 and above).
args: '--target aarch64-apple-darwin'
# - platform: 'macos-latest' # for Intel based macs.
# args: '--target x86_64-apple-darwin'
# - platform: 'ubuntu-22.04' # for Tauri v1 you could replace this with ubuntu-20.04.
# args: ''
# - platform: 'windows-latest'
# args: ''
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@v4
- name: setup node
uses: actions/setup-node@v4
with:
node-version: lts/*
- name: install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
# Those targets are only used on macos runners so it's in an `if` to slightly speed up windows and linux builds.
targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
- name: install dependencies (ubuntu only)
if: matrix.platform == 'ubuntu-22.04' # This must match the platform value defined above.
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.0-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
# webkitgtk 4.0 is for Tauri v1 - webkitgtk 4.1 is for Tauri v2.
# You can remove the one that doesn't apply to your app to speed up the workflow a bit.
- name: install frontend dependencies
run: cd ./crates/desktop/tauri-artcraft && yarn install # change this to npm, pnpm or bun depending on which one you use.
- uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
projectPath: ./crates/desktop/tauri-artcraft
tagName: app-v__VERSION__ # the action automatically replaces \_\_VERSION\_\_ with the app version.
releaseName: 'App v__VERSION__'
releaseBody: 'See the assets to download this version and install.'
releaseDraft: true
prerelease: false
args: ${{ matrix.args }}
Generated
+110 -67
View File
@@ -1079,6 +1079,19 @@ dependencies = [
"tower-service",
]
[[package]]
name = "backoff"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b62ddb9cb1ec0a098ad4bbf9344d0713fa193ae1a80af55febcff2627b6a00c1"
dependencies = [
"futures-core",
"getrandom 0.2.14",
"instant",
"pin-project-lite",
"rand 0.8.5",
]
[[package]]
name = "backtrace"
version = "0.3.69"
@@ -1179,7 +1192,7 @@ dependencies = [
"serde_json",
"sqlx",
"tokio",
"toml 0.8.14",
"toml 0.8.20",
"url_config",
"user_traits_component",
"utoipa",
@@ -1834,7 +1847,7 @@ dependencies = [
"semver 1.0.23",
"serde",
"serde_json",
"thiserror 2.0.11",
"thiserror 2.0.12",
]
[[package]]
@@ -1844,7 +1857,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02260d489095346e5cafd04dea8e8cb54d1d74fcd759022a9b72986ebe9a1257"
dependencies = [
"serde",
"toml 0.8.14",
"toml 0.8.20",
]
[[package]]
@@ -3666,7 +3679,7 @@ dependencies = [
"cc",
"memchr",
"rustc_version 0.4.0",
"toml 0.8.14",
"toml 0.8.20",
"vswhom",
"winreg 0.52.0",
]
@@ -3727,7 +3740,7 @@ dependencies = [
"sqlx-core",
"sqlx-mysql",
"strum",
"toml 0.8.14",
"toml 0.8.20",
"utoipa",
"workspace-build-acceleration",
]
@@ -3741,7 +3754,7 @@ dependencies = [
"serde_derive",
"serde_json",
"strum",
"toml 0.8.14",
"toml 0.8.20",
"utoipa",
"workspace-build-acceleration",
]
@@ -4881,7 +4894,7 @@ dependencies = [
"futures-sink",
"futures-util",
"http 0.2.11",
"indexmap 2.2.6",
"indexmap 2.9.0",
"slab",
"tokio",
"tokio-util",
@@ -4900,7 +4913,7 @@ dependencies = [
"futures-core",
"futures-sink",
"http 1.1.0",
"indexmap 2.2.6",
"indexmap 2.9.0",
"slab",
"tokio",
"tokio-util",
@@ -4940,6 +4953,12 @@ dependencies = [
"allocator-api2",
]
[[package]]
name = "hashbrown"
version = "0.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289"
[[package]]
name = "hashing"
version = "0.0.1"
@@ -5034,7 +5053,7 @@ dependencies = [
"reqwest 0.12.15",
"serde",
"serde_json",
"thiserror 2.0.11",
"thiserror 2.0.12",
"tokio",
"ureq",
]
@@ -5665,12 +5684,12 @@ dependencies = [
[[package]]
name = "indexmap"
version = "2.2.6"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "168fb715dda47215e360912c096649d23d58bf392ac62f73919e831745e40f26"
checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e"
dependencies = [
"equivalent",
"hashbrown 0.14.5",
"hashbrown 0.15.2",
"serde",
]
@@ -6798,7 +6817,7 @@ dependencies = [
"sqlx",
"tokens",
"tokio",
"toml 0.8.14",
"toml 0.8.20",
"workspace-build-acceleration",
]
@@ -7086,7 +7105,7 @@ dependencies = [
"once_cell",
"png",
"serde",
"thiserror 2.0.11",
"thiserror 2.0.12",
"windows-sys 0.59.0",
]
@@ -7470,7 +7489,7 @@ dependencies = [
"log",
"serde",
"serde_derive",
"toml 0.8.14",
"toml 0.8.20",
]
[[package]]
@@ -7771,7 +7790,7 @@ dependencies = [
"sha2 0.10.8",
"tar",
"thiserror 1.0.63",
"toml 0.8.14",
"toml 0.8.20",
"ureq",
"url",
"uuid",
@@ -7839,9 +7858,9 @@ dependencies = [
"sha3",
"subprocess_common",
"testing",
"thiserror 2.0.11",
"thiserror 2.0.12",
"tokio",
"toml 0.7.8",
"toml 0.8.20",
"workspace-build-acceleration",
]
@@ -8283,7 +8302,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db"
dependencies = [
"fixedbitset",
"indexmap 2.2.6",
"indexmap 2.9.0",
]
[[package]]
@@ -8486,7 +8505,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42cf17e9a1800f5f396bc67d193dc9411b59012a5876445ef450d449881e1016"
dependencies = [
"base64 0.22.1",
"indexmap 2.2.6",
"indexmap 2.9.0",
"quick-xml 0.32.0",
"serde",
"time",
@@ -9467,7 +9486,7 @@ checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b"
dependencies = [
"getrandom 0.2.14",
"libredox",
"thiserror 2.0.11",
"thiserror 2.0.12",
]
[[package]]
@@ -9571,6 +9590,7 @@ version = "0.12.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d19c46a6fdd48bc4dab94b6103fccc55d34c67cc0ad04653aad4ea2a07cd7bbb"
dependencies = [
"async-compression",
"base64 0.22.1",
"bytes 1.10.0",
"cookie 0.18.1",
@@ -9628,7 +9648,7 @@ dependencies = [
"serde",
"serde_derive",
"sqlx",
"toml 0.8.14",
"toml 0.8.20",
"workspace-build-acceleration",
]
@@ -10305,7 +10325,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "224e6a14f315852940f3ec103125aa6482f0e224732ed91ed3330ed633077c34"
dependencies = [
"form_urlencoded",
"indexmap 2.2.6",
"indexmap 2.9.0",
"itoa 1.0.11",
"ryu",
"serde",
@@ -10317,7 +10337,7 @@ version = "1.0.140"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373"
dependencies = [
"indexmap 2.2.6",
"indexmap 2.9.0",
"itoa 1.0.11",
"memchr",
"ryu",
@@ -10378,9 +10398,9 @@ dependencies = [
[[package]]
name = "serde_spanned"
version = "0.6.6"
version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79e674e01f999af37c49f70a6ede167a8a60b2503e56c5599532a65baa5969a0"
checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1"
dependencies = [
"serde",
]
@@ -10417,7 +10437,7 @@ dependencies = [
"chrono",
"hex",
"indexmap 1.9.3",
"indexmap 2.2.6",
"indexmap 2.9.0",
"serde",
"serde_derive",
"serde_json",
@@ -10455,7 +10475,7 @@ version = "0.9.34+deprecated"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
dependencies = [
"indexmap 2.2.6",
"indexmap 2.9.0",
"itoa 1.0.11",
"ryu",
"serde",
@@ -10911,7 +10931,7 @@ dependencies = [
"futures-util",
"hashlink",
"hex",
"indexmap 2.2.6",
"indexmap 2.9.0",
"log",
"memchr",
"native-tls",
@@ -11217,7 +11237,7 @@ dependencies = [
"time",
"tokens",
"tokio",
"toml 0.8.14",
"toml 0.8.20",
"tts_common",
"url",
"url_config",
@@ -11231,6 +11251,27 @@ dependencies = [
"zip 0.6.6",
]
[[package]]
name = "storyteller_client"
version = "0.0.1"
dependencies = [
"anyhow",
"backoff",
"chrono",
"enums",
"enums_public",
"errors",
"log",
"reqwest 0.12.15",
"serde",
"serde_derive",
"serde_json",
"tokens",
"tokio",
"url",
"workspace-build-acceleration",
]
[[package]]
name = "storyteller_root"
version = "0.0.1"
@@ -11858,7 +11899,7 @@ dependencies = [
"cfg-expr",
"heck 0.5.0",
"pkg-config",
"toml 0.8.14",
"toml 0.8.20",
"version-compare",
]
@@ -11974,7 +12015,7 @@ dependencies = [
"tauri-runtime",
"tauri-runtime-wry",
"tauri-utils",
"thiserror 2.0.11",
"thiserror 2.0.12",
"tokio",
"tray-icon",
"url",
@@ -12015,12 +12056,14 @@ dependencies = [
"serde_json",
"serde_yaml",
"serial_test",
"storyteller_client",
"subprocess_common",
"tauri",
"tauri-build",
"tauri-plugin-log",
"tempdir",
"tempfile",
"tokens",
"tokio",
"tokio-util",
"workspace-build-acceleration",
@@ -12044,7 +12087,7 @@ dependencies = [
"serde_json",
"tauri-utils",
"tauri-winres",
"toml 0.8.14",
"toml 0.8.20",
"walkdir",
]
@@ -12068,7 +12111,7 @@ dependencies = [
"sha2 0.10.8",
"syn 2.0.87",
"tauri-utils",
"thiserror 2.0.11",
"thiserror 2.0.12",
"time",
"url",
"uuid",
@@ -12102,7 +12145,7 @@ dependencies = [
"serde",
"serde_json",
"tauri-utils",
"toml 0.8.14",
"toml 0.8.20",
"walkdir",
]
@@ -12124,7 +12167,7 @@ dependencies = [
"swift-rs",
"tauri",
"tauri-plugin",
"thiserror 2.0.11",
"thiserror 2.0.12",
"time",
]
@@ -12184,7 +12227,7 @@ dependencies = [
"serde",
"serde_json",
"tauri-utils",
"thiserror 2.0.11",
"thiserror 2.0.12",
"url",
"windows 0.60.0",
]
@@ -12246,8 +12289,8 @@ dependencies = [
"serde_json",
"serde_with 3.12.0",
"swift-rs",
"thiserror 2.0.11",
"toml 0.8.14",
"thiserror 2.0.12",
"toml 0.8.20",
"url",
"urlpattern",
"uuid",
@@ -12261,7 +12304,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56eaa45f707bedf34d19312c26d350bc0f3c59a47e58e8adbeecdc850d2c13a0"
dependencies = [
"embed-resource",
"toml 0.8.14",
"toml 0.8.20",
]
[[package]]
@@ -12350,11 +12393,11 @@ dependencies = [
[[package]]
name = "thiserror"
version = "2.0.11"
version = "2.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d452f284b73e6d76dd36758a0c8684b1d5be31f92b89d07fd5822175732206fc"
checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708"
dependencies = [
"thiserror-impl 2.0.11",
"thiserror-impl 2.0.12",
]
[[package]]
@@ -12370,9 +12413,9 @@ dependencies = [
[[package]]
name = "thiserror-impl"
version = "2.0.11"
version = "2.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26afc1baea8a989337eeb52b6e72a039780ce45c3edfcc9c5b9d112feeb173c2"
checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d"
dependencies = [
"proc-macro2",
"quote",
@@ -12587,7 +12630,7 @@ dependencies = [
"sqlx-core",
"sqlx-mysql",
"strum",
"toml 0.8.14",
"toml 0.8.20",
"utoipa",
"workspace-build-acceleration",
]
@@ -12783,21 +12826,21 @@ dependencies = [
[[package]]
name = "toml"
version = "0.8.14"
version = "0.8.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f49eb2ab21d2f26bd6db7bf383edc527a7ebaee412d17af4d40fdccd442f335"
checksum = "cd87a5cdd6ffab733b2f74bc4fd7ee5fff6634124999ac278c35fc78c6120148"
dependencies = [
"serde",
"serde_spanned",
"toml_datetime",
"toml_edit 0.22.14",
"toml_edit 0.22.24",
]
[[package]]
name = "toml_datetime"
version = "0.6.6"
version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4badfd56924ae69bcc9039335b2e017639ce3f9b001c393c1b2d1ef846ce2cbf"
checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41"
dependencies = [
"serde",
]
@@ -12808,7 +12851,7 @@ version = "0.19.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421"
dependencies = [
"indexmap 2.2.6",
"indexmap 2.9.0",
"serde",
"serde_spanned",
"toml_datetime",
@@ -12821,7 +12864,7 @@ version = "0.20.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70f427fce4d84c72b5b732388bf4a9f4531b53f74e2887e3ecb2481f68f66d81"
dependencies = [
"indexmap 2.2.6",
"indexmap 2.9.0",
"toml_datetime",
"winnow 0.5.40",
]
@@ -12832,22 +12875,22 @@ version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a8534fd7f78b5405e860340ad6575217ce99f38d4d5c8f2442cb5ecb50090e1"
dependencies = [
"indexmap 2.2.6",
"indexmap 2.9.0",
"toml_datetime",
"winnow 0.5.40",
]
[[package]]
name = "toml_edit"
version = "0.22.14"
version = "0.22.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f21c7aaf97f1bd9ca9d4f9e73b0a6c74bd5afef56f2bc931943a6e1c37e04e38"
checksum = "17b4795ff5edd201c7cd6dca065ae59972ce77d1b80fa0a84d94950ece7d1474"
dependencies = [
"indexmap 2.2.6",
"indexmap 2.9.0",
"serde",
"serde_spanned",
"toml_datetime",
"winnow 0.6.7",
"winnow 0.7.6",
]
[[package]]
@@ -13030,7 +13073,7 @@ dependencies = [
"once_cell",
"png",
"serde",
"thiserror 2.0.11",
"thiserror 2.0.12",
"windows-sys 0.59.0",
]
@@ -13457,7 +13500,7 @@ version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ff05e3bac2c9428f57ade702667753ca3f5cf085e2011fe697de5bfd49aa72d"
dependencies = [
"indexmap 2.2.6",
"indexmap 2.9.0",
"serde",
"serde_json",
"utoipa-gen",
@@ -13933,7 +13976,7 @@ version = "0.36.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfb27fccd3c27f68e9a6af1bcf48c2d82534b8675b83608a4d81446d095a17ac"
dependencies = [
"thiserror 2.0.11",
"thiserror 2.0.12",
"windows 0.60.0",
"windows-core 0.60.1",
]
@@ -14489,9 +14532,9 @@ dependencies = [
[[package]]
name = "winnow"
version = "0.6.7"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "14b9415ee827af173ebb3f15f9083df5a122eb93572ec28741fb153356ea2578"
checksum = "63d3fcd9bba44b03821e7d699eeee959f3126dcc4aa8e4ae18ec617c2a5cea10"
dependencies = [
"memchr",
]
@@ -14577,7 +14620,7 @@ dependencies = [
"hyper-util",
"image",
"indexmap 1.9.3",
"indexmap 2.2.6",
"indexmap 2.9.0",
"itertools 0.12.1",
"itertools 0.13.0",
"lazy_static",
@@ -14714,7 +14757,7 @@ dependencies = [
"sha2 0.10.8",
"soup3",
"tao-macros",
"thiserror 2.0.11",
"thiserror 2.0.12",
"url",
"webkit2gtk",
"webkit2gtk-sys",
@@ -14934,7 +14977,7 @@ dependencies = [
"crossbeam-utils 0.8.20",
"displaydoc",
"flate2",
"indexmap 2.2.6",
"indexmap 2.9.0",
"num_enum",
"thiserror 1.0.63",
]
@@ -14955,7 +14998,7 @@ dependencies = [
"displaydoc",
"flate2",
"hmac 0.12.1",
"indexmap 2.2.6",
"indexmap 2.9.0",
"lzma-rs",
"memchr",
"pbkdf2 0.12.2",
+10 -4
View File
@@ -34,6 +34,7 @@ members = [
"crates/lib/api_clients/google_drive_common",
"crates/lib/api_clients/google_sign_in",
"crates/lib/api_clients/openai_sora_client",
"crates/lib/api_clients/storyteller_client",
"crates/lib/api_clients/thumbnail_generator",
"crates/lib/bootstrap",
"crates/lib/bucket_paths",
@@ -165,15 +166,20 @@ serde_derive = "1.0.219"
serde_json = "1.0.140"
serde_yaml = "0.9.1"
sha3 = "0.10.8"
thiserror = "2"
toml = "0.7.4"
url = "2.5.4"
thiserror = "2.0.12"
toml = "0.8.20"
url = { version = "2.5.4", features = ["serde"] }
# Internal libs
crockford = { path = "crates/lib/data/crockford" }
enums = { path = "crates/_types/enums" }
enums_public = { path = "crates/_types/enums_public" }
errors = { path = "crates/lib/errors" }
filesys = { path = "crates/lib/files/filesys" }
memory_caching = { path = "crates/lib/caching/memory_caching" }
shared_service_components = { path = "crates/lib/shared_service_components" }
openai_sora_client = { path = "crates/lib/api_clients/openai_sora_client" }
primitives = { path = "crates/lib/data/primitives" }
shared_service_components = { path = "crates/lib/shared_service_components" }
storyteller_client = { path = "crates/lib/api_clients/storyteller_client" }
subprocess_common = { path = "crates/lib/subprocess_common" }
tokens = { path = "crates/_types/tokens" }
+2
View File
@@ -29,7 +29,9 @@ filesys = { workspace = true }
memory_caching = { workspace = true }
openai_sora_client = { workspace = true }
primitives = { workspace = true }
storyteller_client = { workspace = true }
subprocess_common = { workspace = true }
tokens = { workspace = true }
# External
#openssl = { version = "0.10", features = ["vendored"] }
@@ -1,90 +0,0 @@
use std::fs::read_to_string;
use base64::prelude::BASE64_STANDARD;
use errors::AnyhowResult;
use base64::Engine;
use image::codecs::png::{CompressionType, FilterType, PngEncoder};
use image::{DynamicImage, ImageReader};
use std::io::Cursor;
use log::{error, info};
use tauri::{AppHandle, Manager, State};
use openai_sora_client::credentials::SoraCredentials;
use openai_sora_client::image_gen::common::{ImageSize, NumImages};
use openai_sora_client::image_gen::sora_image_gen_remix::{sora_image_gen_remix, SoraImageGenRemixRequest};
use openai_sora_client::upload::upload_media_from_bytes::sora_media_upload_from_bytes;
use openai_sora_client::upload::upload_media_from_file::SoraMediaUploadRequest;
use crate::state::app_dir::AppDataRoot;
#[tauri::command]
pub async fn image_generation_command(
image: &str,
prompt: &str,
app: AppHandle,
app_data_root: State<'_, AppDataRoot>,
) -> Result<String, String> {
info!("image_generation_command called; processing image...");
let bytes = BASE64_STANDARD.decode(image)
.map_err(|err| format!("Base64 decode error: {}", err))?;
generate_image(bytes, prompt, &app_data_root)
.await
.map_err(|err| {
error!("error: {:?}", err);
"there was an error".to_string()
})?;
Ok("success".to_string())
}
pub async fn generate_image(file_bytes: Vec<u8>, prompt: &str, app_data_root: &AppDataRoot) -> AnyhowResult<()> {
let sora_credentials = get_credentials(app_data_root)?;
let filename = "image.png".to_string();
let response= sora_media_upload_from_bytes(file_bytes, filename, &sora_credentials)
.await
.map_err(|err| {
error!("Failed to upload image to Sora: {:?}", err);
err
})?;
let sora_media_tokens = vec![response.id];
let response = sora_image_gen_remix(SoraImageGenRemixRequest {
prompt: prompt.to_string(),
num_images: NumImages::One,
image_size: ImageSize::Square,
sora_media_tokens: sora_media_tokens.clone(),
credentials: &sora_credentials,
}).await
.map_err(|err| {
error!("Failed to call Sora image generation: {:?}", err);
err
})?;
println!(">> TASK ID: {:?} ", response.task_id);
Ok(())
}
fn get_credentials(app_data_root: &AppDataRoot) -> AnyhowResult<SoraCredentials> {
let cookie_file = app_data_root.get_sora_cookie_file_path();
let bearer = read_to_string("/Users/bt/dev/storyteller/storyteller-rust/test_data/temp/bearer.txt")?
.trim()
.to_string();
let cookie= read_to_string(cookie_file)?
.trim()
.to_string();
let sentinel = read_to_string("/Users/bt/dev/storyteller/storyteller-rust/test_data/temp/sentinel.txt")?
.trim()
.to_string();
Ok(SoraCredentials {
bearer_token: bearer,
cookie: cookie,
sentinel: Some(sentinel),
})
}
@@ -1,3 +1,2 @@
pub mod flip_image;
pub mod image_generation_command;
pub mod open_login_command;
pub mod sora;
@@ -0,0 +1,3 @@
pub mod open_sora_login_command;
pub mod sora_image_generation_command;
pub mod sora_image_remix_command;
@@ -1,11 +1,11 @@
use std::time::Duration;
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 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;
use errors::AnyhowResult;
use crate::threads::login_thread::{LOGIN_WINDOW_NAME, SORA_LOGIN_URL};
use crate::utils::clear_all_webview_cookies::clear_all_webview_cookies;
pub const START_URL_STR: &str = "https://storyteller.ai/";
@@ -20,7 +20,7 @@ pub static SORA_HOMEPAGE_URL : Lazy<Url> = Lazy::new(|| {
});
#[tauri::command]
pub async fn open_login_command(
pub async fn open_sora_login_command(
app: AppHandle,
) -> Result<String, String> {
info!("open_login_command called");
@@ -0,0 +1,86 @@
use crate::state::app_dir::AppDataRoot;
use crate::state::sora::read_sora_credentials_from_disk::read_sora_credentials_from_disk;
use crate::state::sora::sora_credential_holder::SoraCredentialHolder;
use base64::prelude::BASE64_STANDARD;
use base64::Engine;
use errors::AnyhowResult;
use image::codecs::png::{CompressionType, FilterType, PngEncoder};
use image::{DynamicImage, ImageReader};
use log::{error, info};
use openai_sora_client::credentials::SoraCredentials;
use openai_sora_client::requests::image_gen::common::{ImageSize, NumImages};
use openai_sora_client::requests::image_gen::sora_image_gen_remix::{sora_image_gen_remix, SoraImageGenRemixRequest};
use openai_sora_client::requests::upload::upload_media_from_bytes::sora_media_upload_from_bytes;
use openai_sora_client::requests::upload::upload_media_from_file::SoraMediaUploadRequest;
use std::fs::read_to_string;
use std::io::Cursor;
use tauri::{AppHandle, Manager, State};
#[tauri::command]
pub async fn sora_image_generation_command(
prompt: &str,
image: Option<&str>,
_app: AppHandle,
app_data_root: State<'_, AppDataRoot>,
sora_creds_holder: State<'_, SoraCredentialHolder>,
) -> Result<String, String> {
info!("image_generation_command called; processing image...");
generate_image(image, prompt, &app_data_root, &sora_creds_holder)
.await
.map_err(|err| {
error!("error: {:?}", err);
"there was an error".to_string()
})?;
Ok("success".to_string())
}
pub async fn generate_image(
maybe_image: Option<&str>,
prompt: &str,
app_data_root: &AppDataRoot,
sora_creds_holder: &SoraCredentialHolder,
) -> AnyhowResult<()> {
let sora_credentials = read_sora_credentials_from_disk(app_data_root)
.map_err(|err| {
error!("Failed to read Sora credentials from disk: {:?}", err);
err
})?;
//let sora_credentials = sora_creds_holder.get_credentials()?;
let mut sora_media_tokens = vec![];
if let Some(image) = maybe_image {
let image_bytes = BASE64_STANDARD.decode(image)?;
let filename = "image.png".to_string();
let response = sora_media_upload_from_bytes(image_bytes, filename, &sora_credentials)
.await
.map_err(|err| {
error!("Failed to upload image to Sora: {:?}", err);
err
})?;
sora_media_tokens.push(response.id);
}
let response = sora_image_gen_remix(SoraImageGenRemixRequest {
prompt: prompt.to_string(),
num_images: NumImages::One,
image_size: ImageSize::Square,
sora_media_tokens: sora_media_tokens.clone(),
credentials: &sora_credentials,
}).await
.map_err(|err| {
error!("Failed to call Sora image generation: {:?}", err);
err
})?;
println!(">> TASK ID: {:?} ", response.task_id);
Ok(())
}
@@ -0,0 +1,157 @@
use crate::state::app_dir::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_manager::SoraCredentialManager;
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 image::codecs::png::{CompressionType, FilterType, PngEncoder};
use image::{DynamicImage, ImageReader};
use log::{debug, error, info};
use openai_sora_client::credentials::SoraCredentials;
use openai_sora_client::requests::image_gen::common::{ImageSize, NumImages};
use openai_sora_client::requests::image_gen::sora_image_gen_remix::{sora_image_gen_remix, SoraImageGenRemixRequest};
use openai_sora_client::requests::sentinel_refresh::refresh_sentinel;
use openai_sora_client::requests::upload::upload_media_from_bytes::sora_media_upload_from_bytes;
use openai_sora_client::requests::upload::upload_media_from_file::{sora_media_upload_from_file, SoraMediaUploadRequest};
use serde_derive::Deserialize;
use std::fs::read_to_string;
use std::io::Cursor;
use storyteller_client::media_files::get_media_file::get_media_file;
use storyteller_client::utils::api_host::ApiHost;
use tauri::{AppHandle, Manager, State};
use tokens::tokens::media_files::MediaFileToken;
#[derive(Deserialize)]
pub struct SoraImageRemixCommand {
/// Image media file; the engine or canvas snapshot (screenshot).
pub snapshot_media_token: MediaFileToken,
/// The user's image generation prompt.
pub prompt: String,
/// Turn off the system prompt.
pub disable_system_prompt: Option<bool>,
/// Additional images to include (optional). Up to nine images.
pub maybe_additional_images: Option<Vec<MediaFileToken>>,
pub maybe_number_of_samples: Option<u32>,
}
#[tauri::command]
pub async fn sora_image_remix_command(
_app: AppHandle,
request: SoraImageRemixCommand,
app_data_root: State<'_, AppDataRoot>,
sora_creds_manager: State<'_, SoraCredentialManager>,
) -> Result<String, String> {
info!("image_generation_command called; processing image...");
generate_image(request, &app_data_root, &sora_creds_manager)
.await
.map_err(|err| {
error!("error: {:?}", err);
"there was an error".to_string()
})?;
Ok("success".to_string())
}
pub async fn generate_image(
request: SoraImageRemixCommand,
app_data_root: &AppDataRoot,
sora_creds_manager: &SoraCredentialManager,
) -> AnyhowResult<()> {
let response = get_media_file(&ApiHost::Storyteller, &request.snapshot_media_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);
simple_http_download(&media_file_url, &filename).await?;
let files_to_upload = vec![filename];
// TODO(bt,2025-04-21): Read from in-memory cache instead, but allow for desktop replacement.
let mut sora_creds = read_sora_credentials_from_disk(app_data_root)
.map_err(|err| {
error!("Failed to read Sora credentials from disk: {:?}", err);
err
})?;
let no_sentinel = !app_data_root.get_sora_sentinel_file_path().is_file()
|| sora_creds.sentinel.is_none();
if no_sentinel {
info!("Refreshing credentials...");
sora_creds = sora_creds_manager.call_sentinel_refresh()
.await
.map_err(|err| {
error!("Failed to refresh: {:?}", err);
err
})?;
// TODO: Write to disk.
}
let mut sora_media_tokens = Vec::with_capacity(files_to_upload.len());
for file_path in files_to_upload {
let sora_upload_response = sora_media_upload_from_file(file_path, &sora_creds)
.await?;
sora_media_tokens.push(sora_upload_response.id);
}
// TODO(bt,2025-04-21): Download media tokens.
// Note: This is incredibly inefficient. We should keep a local cache.
// Also, if they've already been uploaded to OpenAI, we shouldn't continue to re-upload.
let mut response = sora_image_gen_remix(SoraImageGenRemixRequest {
prompt: request.prompt.to_string(),
num_images: NumImages::One,
image_size: ImageSize::Square,
sora_media_tokens: sora_media_tokens.clone(),
credentials: &sora_creds,
}).await;
if let Err(err) = &response {
error!("Error in generating image: {:?}", err);
sora_creds = sora_creds_manager.call_sentinel_refresh()
.await
.map_err(|err| {
error!("Failed to refresh: {:?}", err);
err
})?;
info!("Retrying request...");
response = sora_image_gen_remix(SoraImageGenRemixRequest {
prompt: request.prompt.to_string(),
num_images: NumImages::One,
image_size: ImageSize::Square,
sora_media_tokens: sora_media_tokens.clone(),
credentials: &sora_creds,
}).await;
}
let response = response
.map_err(|err| {
error!("Failed to call Sora image generation: {:?}", err);
err
})?;
println!(">> TASK ID: {:?} ", response.task_id);
Ok(())
}
+17 -8
View File
@@ -2,14 +2,15 @@ pub mod commands;
pub mod events;
pub mod state;
pub mod threads;
pub mod transfer;
pub mod utils;
use crate::commands::flip_image::flip_image;
use crate::commands::image_generation_command::image_generation_command;
use crate::commands::open_login_command::open_login_command;
use crate::commands::sora::open_sora_login_command::open_sora_login_command;
use crate::commands::sora::sora_image_generation_command::sora_image_generation_command;
use crate::commands::sora::sora_image_remix_command::sora_image_remix_command;
use crate::state::app_config::AppConfig;
use crate::threads::login_thread::login_thread;
use crate::state::sora::sora_credential_manager::SoraCredentialManager;
use crate::threads::sora_session_login_thread::sora_session_login_thread;
use tauri_plugin_log::Target;
use tauri_plugin_log::TargetKind;
@@ -26,6 +27,10 @@ pub fn run() {
let app_data_root = config.app_data_root.clone();
let app_data_root2 = config.app_data_root.clone();
println!("Attempting to read existing credentials...");
let sora_creds_manager = SoraCredentialManager::initialize_from_disk_infallible(&app_data_root);
let sora_creds_manager2 = sora_creds_manager.clone();
println!("Initializing backend runtime...");
tauri::Builder::default()
@@ -33,7 +38,9 @@ pub fn run() {
.level(log::LevelFilter::Info)
.targets(vec![
Target::new(TargetKind::Stdout),
Target::new(TargetKind::LogDir { file_name: Some(app_data_root.log_file_name_str().to_string()) }),
Target::new(TargetKind::LogDir {
file_name: Some(app_data_root.log_file_name_str().to_string())
}),
])
.build())
.setup(|app| {
@@ -49,16 +56,18 @@ pub fn run() {
//}
let app = app.handle().clone();
tauri::async_runtime::spawn(login_thread(app, app_data_root2));
tauri::async_runtime::spawn(sora_session_login_thread(app, app_data_root2, sora_creds_manager2));
Ok(())
})
.manage(config)
.manage(app_data_root)
.manage(sora_creds_manager)
.invoke_handler(tauri::generate_handler![
flip_image,
image_generation_command,
open_login_command,
open_sora_login_command,
sora_image_generation_command,
sora_image_remix_command,
])
.run(tauri::generate_context!("tauri.conf.json"))
.expect("error while running tauri application");
@@ -9,6 +9,7 @@ use tempfile::{Builder, NamedTempFile};
const DEFAULT_DATA_DIR : &str = "artcraft";
const ASSETS_SUBDIRECTORY : &str = "assets";
const DOWNLOADS_SUBDIRECTORY : &str = "downloads";
const WEIGHTS_SUBDIRECTORY : &str = "weights";
const TEMPORARY_SUBDIRECTORY : &str = "temp";
@@ -21,6 +22,7 @@ const LOG_FILE_NAME : &str = "application_debug";
pub struct AppDataRoot {
path: PathBuf,
assets_dir: AppAssetsDir,
downloads_dir: AppDownloadsDir,
weights_dir: AppWeightsDir,
temp_dir: TemporaryDir,
log_file_name: PathBuf,
@@ -32,6 +34,11 @@ pub struct AppAssetsDir {
path: PathBuf,
}
#[derive(Clone)]
pub struct AppDownloadsDir {
path: PathBuf,
}
#[derive(Clone)]
pub struct AppWeightsDir {
path: PathBuf,
@@ -73,6 +80,7 @@ impl AppDataRoot {
}
let assets_dir = AppAssetsDir::create_existing(dir.join(ASSETS_SUBDIRECTORY))?;
let downloads_dir = AppDownloadsDir::create_existing(dir.join(DOWNLOADS_SUBDIRECTORY))?;
let weights_dir = AppWeightsDir::create_existing(dir.join(WEIGHTS_SUBDIRECTORY))?;
let temp_dir = TemporaryDir::create_existing(dir.join(TEMPORARY_SUBDIRECTORY))?;
let log_file_name = dir.join(LOG_FILE_NAME);
@@ -84,6 +92,7 @@ impl AppDataRoot {
Ok(Self {
path: dir,
assets_dir,
downloads_dir,
weights_dir,
temp_dir,
log_file_name,
@@ -94,7 +103,11 @@ impl AppDataRoot {
pub fn assets_dir(&self) -> &AppAssetsDir {
&self.assets_dir
}
pub fn downloads_dir(&self) -> &AppDownloadsDir {
&self.downloads_dir
}
pub fn weights_dir(&self) -> &AppWeightsDir {
&self.weights_dir
}
@@ -118,6 +131,14 @@ impl AppDataRoot {
pub fn get_sora_cookie_file_path(&self) -> PathBuf {
self.path.join("sora_cookies.txt")
}
pub fn get_sora_bearer_token_file_path(&self) -> PathBuf {
self.path.join("sora_bearer_token.txt")
}
pub fn get_sora_sentinel_file_path(&self) -> PathBuf {
self.path.join("sora_sentinel.txt")
}
}
impl AppAssetsDir {
@@ -155,6 +176,30 @@ impl AppAssetsDir {
}
}
impl AppDownloadsDir {
pub fn create_existing<P: AsRef<Path>>(dir: P) -> anyhow::Result<Self> {
let mut dir = dir.as_ref().to_path_buf();
match dir.canonicalize() {
Ok(d) => dir = d,
Err(err) => {
println!("Error canonicalizing {:?}: {}", dir, err);
}
}
if !dir.exists() {
println!("Creating directory {:?}", dir);
std::fs::create_dir(&dir)?;
}
Ok(Self {
path: dir,
})
}
pub fn path(&self) -> &Path {
&self.path
}
}
impl AppWeightsDir {
pub fn create_existing<P: AsRef<Path>>(dir: P) -> anyhow::Result<Self> {
let mut dir = dir.as_ref().to_path_buf();
@@ -1,5 +1,6 @@
pub mod app_config;
pub mod app_dir;
pub mod expanduser;
pub mod os_platform;
pub mod yaml_config;
pub mod expanduser;
pub mod sora;
pub mod yaml_config;
@@ -0,0 +1,3 @@
pub mod read_sora_credentials_from_disk;
pub mod sora_credential_holder;
pub mod sora_credential_manager;
@@ -0,0 +1,36 @@
use std::fs::read_to_string;
use errors::AnyhowResult;
use openai_sora_client::credentials::SoraCredentials;
use crate::state::app_dir::AppDataRoot;
pub fn read_sora_credentials_from_disk(app_data_root: &AppDataRoot) -> AnyhowResult<SoraCredentials> {
let cookie_file = app_data_root.get_sora_cookie_file_path();
let bearer_file = app_data_root.get_sora_bearer_token_file_path();
let sentinel_file = app_data_root.get_sora_sentinel_file_path();
let cookie = read_to_string(cookie_file)?
.trim()
.to_string();
let bearer = read_to_string(bearer_file)?
.trim()
.to_string();
if sentinel_file.exists() && sentinel_file.is_file() {
let sentinel = read_to_string(sentinel_file)?
.trim()
.to_string();
return Ok(SoraCredentials {
bearer_token: bearer,
cookie,
sentinel: Some(sentinel),
});
}
Ok(SoraCredentials {
bearer_token: bearer,
cookie,
sentinel: None,
})
}
@@ -0,0 +1,58 @@
use std::ops::Deref;
use std::sync::{Arc, RwLock};
use anyhow::anyhow;
use errors::AnyhowResult;
use openai_sora_client::credentials::SoraCredentials;
/// Hold credentials for the application.
#[derive(Clone)]
pub struct SoraCredentialHolder {
credentials: Arc<RwLock<Option<SoraCredentials>>>
}
impl SoraCredentialHolder {
pub fn new() -> Self {
Self {
credentials: Arc::new(RwLock::new(None))
}
}
pub fn set_credentials(&self, credentials: &SoraCredentials) -> AnyhowResult<()> {
match self.credentials.write() {
Err(err) => Err(anyhow!("Failed to acquire write lock: {:?}", err)),
Ok(mut creds) => {
*creds = Some(credentials.clone());
Ok(())
}
}
}
pub fn clear_credentials(&self) -> AnyhowResult<()> {
match self.credentials.write() {
Err(err) => Err(anyhow!("Failed to acquire write lock: {:?}", err)),
Ok(mut creds) => {
*creds = None;
Ok(())
}
}
}
pub fn get_credentials(&self) -> AnyhowResult<Option<SoraCredentials>> {
match self.credentials.read() {
Err(err) => Err(anyhow!("Failed to acquire read lock: {:?}", err)),
Ok(creds) => {
Ok(creds.clone())
}
}
}
pub fn get_credentials_required(&self) -> AnyhowResult<SoraCredentials> {
match self.credentials.read() {
Err(err) => Err(anyhow!("Failed to acquire read lock: {:?}", err)),
Ok(creds) => match creds.deref() {
None => Err(anyhow!("Credentials not set")),
Some(creds) => Ok(creds.clone()),
}
}
}
}
@@ -0,0 +1,90 @@
use crate::state::app_dir::AppDataRoot;
use crate::state::sora::read_sora_credentials_from_disk::read_sora_credentials_from_disk;
use crate::state::sora::sora_credential_holder::SoraCredentialHolder;
use anyhow::anyhow;
use errors::AnyhowResult;
use log::{error, info, warn};
use openai_sora_client::credentials::SoraCredentials;
use openai_sora_client::requests::sentinel_refresh::generate::token::generate_token;
use openai_sora_client::requests::sentinel_refresh::refresh_sentinel;
use std::fs::OpenOptions;
use std::io::Write;
#[derive(Clone)]
pub struct SoraCredentialManager {
holder: SoraCredentialHolder,
app_data_root: AppDataRoot,
}
impl SoraCredentialManager {
pub fn initialize_from_disk_infallible(app_data_root: &AppDataRoot) -> Self {
let holder = SoraCredentialHolder::new();
match read_sora_credentials_from_disk(app_data_root) {
Err(err) => warn!("Failed to read credentials from disk: {:?}", err),
Ok(creds) => {
holder.set_credentials(&creds).expect("Failed to set credentials");
}
}
Self {
holder,
app_data_root: app_data_root.clone(),
}
}
pub fn set_credentials(&self, creds: &SoraCredentials) -> AnyhowResult<()> {
self.holder.set_credentials(creds)
}
pub fn clear_credentials(&self) -> AnyhowResult<()> {
self.holder.clear_credentials()
}
pub fn get_credentials(&self) -> AnyhowResult<Option<SoraCredentials>> {
self.holder.get_credentials()
}
pub fn get_credentials_required(&self) -> AnyhowResult<SoraCredentials> {
self.holder.get_credentials_required()
}
pub fn reset_from_disk(&self) -> AnyhowResult<SoraCredentials> {
let creds = read_sora_credentials_from_disk(&self.app_data_root)?;
self.holder.set_credentials(&creds)?;
Ok(creds)
}
/// Refresh the sentinel token from Sora's API
pub async fn call_sentinel_refresh(&self) -> AnyhowResult<SoraCredentials> {
// NB(bt,2025-04-21): Technically we don't need credentials to get a sentinel.
let mut creds = self.holder.get_credentials_required()?;
info!("Generating token...");
let sentinel = generate_token()
.await
.map_err(|err| {
error!("Failed to refresh: {:?}", err);
err
})?;
info!("Token obtained.");
creds.sentinel = Some(sentinel.clone());
self.holder.set_credentials(&creds)?;
let mut file = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(self.app_data_root.get_sora_sentinel_file_path())?;
file.write_all(sentinel.as_bytes())?;
file.flush()?;
Ok(creds)
}
}
@@ -1 +1 @@
pub mod login_thread;
pub mod sora_session_login_thread;
@@ -1,13 +1,16 @@
use std::fs::OpenOptions;
use std::io::Write;
use crate::state::app_dir::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::utils::sora_webview_cookies::get_all_sora_cookies_as_string;
use anyhow::anyhow;
use errors::AnyhowResult;
use log::{error, info};
use once_cell::sync::Lazy;
use reqwest::Url;
use std::fs::OpenOptions;
use std::io::Write;
use tauri::{AppHandle, Manager, Webview};
use errors::AnyhowResult;
use crate::state::app_dir::AppDataRoot;
use crate::utils::sora_webview_cookies::get_all_sora_cookies_as_string;
pub const LOGIN_WINDOW_NAME: &str = "login_window";
@@ -23,7 +26,11 @@ pub static SORA_ROOT_URL: Lazy<Url> = Lazy::new(|| {
Url::parse(SORA_ROOT_URL_STR).expect("URL should parse")
});
pub async fn login_thread(app: AppHandle, app_data_root: AppDataRoot) -> ! {
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 {
@@ -42,7 +49,7 @@ async fn check_login_window(webview: &Webview, app_data_root: &AppDataRoot) -> A
clear_browsing_data_on_test_domain(webview)?;
//keep_on_task(webview)?;
extract_cookies_to_file(webview, app_data_root)?;
// TODO: Other maintenance tasks ...
//initialize_sora_jwt_bearer_token(app_data_root).await?; // TODO: This only runs once. We need better management.
Ok(())
}
@@ -0,0 +1,35 @@
use std::path::Path;
use reqwest::Url;
pub fn get_url_file_extension(url: &Url) -> Option<&str> {
Path::new(url.path())
.extension()
.filter(|ext| !ext.is_empty())
.and_then(|ext| ext.to_str())
}
#[cfg(test)]
mod tests {
use reqwest::Url;
#[test]
fn extension_exists() {
let url = Url::parse("https://example.com/image.png").unwrap();
let extension = super::get_url_file_extension(&url);
assert_eq!(extension, Some("png"));
}
#[test]
fn extension_does_not_exist() {
let url = Url::parse("https://example.com/foo").unwrap();
let extension = super::get_url_file_extension(&url);
assert_eq!(extension, None);
}
#[test]
fn period_not_in_path() {
let url = Url::parse("https://example.com/invalid.period/foo").unwrap();
let extension = super::get_url_file_extension(&url);
assert_eq!(extension, None);
}
}
@@ -1,9 +1,10 @@
//! Implemented from https://github.com/huggingface/hf_transfer/blob/main/src/lib.rs
use crate::transfer::error::Error;
use crate::transfer::util::{exponential_backoff, BASE_WAIT_TIME, MAX_WAIT_TIME};
use crate::utils::large_file_transfer::error::Error;
use crate::utils::large_file_transfer::util::{exponential_backoff, BASE_WAIT_TIME, MAX_WAIT_TIME};
use futures::stream::FuturesUnordered;
use futures::StreamExt;
use log::info;
use reqwest::header::{
HeaderMap, HeaderName, HeaderValue, AUTHORIZATION, CONTENT_RANGE,
RANGE,
@@ -14,15 +15,14 @@ use std::fmt::Display;
use std::fs::remove_file;
use std::io::SeekFrom;
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::mpsc::Sender;
use std::sync::Arc;
use std::time::Duration;
use log::info;
use tempfile::NamedTempFile;
use tokio::fs::OpenOptions;
use tokio::io::AsyncWriteExt;
use tokio::io::AsyncSeekExt;
use tokio::io::AsyncWriteExt;
use tokio::sync::Semaphore;
use tokio::time::sleep;
@@ -1,7 +1,7 @@
//! Implemented from https://github.com/huggingface/hf_transfer/blob/main/src/lib.rs
use crate::transfer::error::Error;
use crate::transfer::util::{exponential_backoff, BASE_WAIT_TIME, MAX_WAIT_TIME};
use crate::utils::large_file_transfer::error::Error;
use crate::utils::large_file_transfer::util::{exponential_backoff, BASE_WAIT_TIME, MAX_WAIT_TIME};
use futures::stream::FuturesUnordered;
use futures::StreamExt;
use reqwest::header::CONTENT_LENGTH;
@@ -1,3 +1,7 @@
pub mod clear_all_webview_cookies;
pub mod get_url_file_extension;
pub mod image;
pub mod sora_webview_cookies;
pub mod large_file_transfer;
pub mod simple_http_download;
pub mod sora_webview_cookies;
pub mod sora;
@@ -0,0 +1,34 @@
use std::fs::OpenOptions;
use std::io::Write;
use std::path::Path;
use reqwest::Url;
use reqwest::Client;
use errors::AnyhowResult;
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<P: AsRef<Path>>(url: &Url, download_path: P) -> 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 response : Response = reqwest::get(url).await?;
let bytes = response.bytes().await?;
let mut file = OpenOptions::new()
.create(true) // To create a new file
.write(true)
.truncate(true)
.open(download_path)?;
file.write_all(&bytes)?;
Ok(())
}
@@ -0,0 +1,28 @@
use crate::state::app_dir::AppDataRoot;
use anyhow::anyhow;
use errors::AnyhowResult;
use log::info;
use openai_sora_client::requests::bearer::generate::generate_bearer_with_cookie;
/// Loading the JWT bearer token for the first time.
pub async fn initialize_sora_jwt_bearer_token(app_data_root: &AppDataRoot) -> AnyhowResult<()> {
if !app_data_root.get_sora_cookie_file_path().exists() {
return Err(anyhow!("Cookie file does not exist"));
}
if app_data_root.get_sora_bearer_token_file_path().exists() {
return Ok(()); // Bearer token already exists.
}
let cookie = std::fs::read_to_string(app_data_root.get_sora_cookie_file_path())?
.trim()
.to_string();
info!("Requesting initial JWT bearer token...");
let bearer = generate_bearer_with_cookie(&cookie).await?;
std::fs::write(app_data_root.get_sora_bearer_token_file_path(), bearer)?;
Ok(())
}
@@ -0,0 +1 @@
pub mod initialize_sora_jwt_bearer_token;
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2.0.0-rc",
"productName": "ArtCraft",
"version": "0.1.0",
"version": "0.0.1",
"identifier": "ai.artcraft.app",
"build": {
"frontendDist": "../../../frontend/apps/editor2d/dist",
@@ -10,4 +10,8 @@ pub fn add_artcraft_2d(cors: Cors, _is_production: bool) -> Cors {
.allowed_origin_fn(|origin, _req_head| {
netlify_branch_domain_matches(origin, "storyteller-2d.netlify.app")
})
// Tauri localhost (2D engine, first three ports)
.allowed_origin("http://localhost:5741")
.allowed_origin("http://localhost:5742") // If already started
.allowed_origin("http://localhost:5743") // If already started
}
@@ -10,4 +10,8 @@ pub fn add_artcraft_3d(cors: Cors, _is_production: bool) -> Cors {
.allowed_origin_fn(|origin, _req_head| {
netlify_branch_domain_matches(origin, "storyteller-3d.netlify.app")
})
// Tauri localhost (3D engine, first three ports)
.allowed_origin("http://localhost:5173")
.allowed_origin("http://localhost:5174") // If already started
.allowed_origin("http://localhost:5175") // If already started
}
@@ -25,7 +25,7 @@ idempotency = { path = "../../idempotency" }
# External
anyhow = { workspace = true }
log = { workspace = true }
reqwest = { workspace = true, features = ["multipart"] }
reqwest = { workspace = true, features = ["multipart", "gzip"] }
serde = { workspace = true }
serde_derive = { workspace = true }
serde_json = { workspace = true }
@@ -40,4 +40,4 @@ rand = { workspace = true }
[dev-dependencies]
testing = { path = "../../testing" }
filesys = { path = "../../files/filesys" }
filesys = { path = "../../files/filesys" }
@@ -0,0 +1,4 @@
pub mod sora_cookies;
pub mod sora_credential_set;
pub mod sora_jwt_bearer_token;
pub mod sora_sentinel;
@@ -0,0 +1,17 @@
/// Cookies are the credential that are always required.
/// You can mint a JWT bearer token with just the cookies.
#[derive(Clone)]
pub struct SoraCookies {
cookies: String,
}
impl SoraCookies {
pub fn new(cookies: String) -> Self {
SoraCookies { cookies }
}
pub fn get_cookies(&self) -> &str {
&self.cookies
}
}
@@ -0,0 +1,26 @@
use crate::creds::sora_cookies::SoraCookies;
use crate::creds::sora_jwt_bearer_token::SoraJwtBearerToken;
use crate::creds::sora_sentinel::SoraSentinel;
#[derive(Clone)]
pub struct SoraCredentialSet {
pub (crate) cookies: SoraCookies,
pub (crate) jwt_bearer_token: Option<SoraJwtBearerToken>,
pub (crate) sora_sentinel: Option<SoraSentinel>,
}
impl SoraCredentialSet {
pub fn initialize_with_just_cookies(cookies: SoraCookies) -> Self {
SoraCredentialSet {
cookies,
jwt_bearer_token: None,
sora_sentinel: None,
}
}
pub fn initialize_with_just_cookies_str(cookies: &str) -> Self {
Self::initialize_with_just_cookies(
SoraCookies::new(cookies.to_string()),
)
}
}
@@ -0,0 +1,27 @@
use crate::utils::lightweight_sora_jwt_parse::{lightweight_sora_jwt_parse, SoraJwtClaims};
use errors::AnyhowResult;
/// Sora bearer tokens are JWT tokens that can be minted with a valid cookie/session.
#[derive(Clone)]
pub struct SoraJwtBearerToken {
token: String,
jwt_claims: SoraJwtClaims,
}
impl SoraJwtBearerToken {
pub fn new(token: String) -> AnyhowResult<Self> {
let jwt_claims = lightweight_sora_jwt_parse(&token)?;
Ok(SoraJwtBearerToken {
token,
jwt_claims,
})
}
pub fn token_str(&self) -> &str {
&self.token
}
pub fn jwt_claims(&self) -> &SoraJwtClaims {
&self.jwt_claims
}
}
@@ -0,0 +1,16 @@
/// Sora Sentinel tokens can be generated without cookies or JWTs, and are needed for some API calls.
#[derive(Clone)]
pub struct SoraSentinel {
sentinel: String,
}
impl SoraSentinel {
pub fn new(sentinel: String) -> Self {
SoraSentinel { sentinel }
}
pub fn get_sentinel(&self) -> &str {
&self.sentinel
}
}
@@ -1,6 +1,6 @@
pub mod credentials;
pub mod image_gen;
pub mod job;
pub mod upload;
pub mod sentinel_refresh;
mod sora_error;
pub mod creds;
pub mod requests;
pub mod sora_error;
pub mod utils;
mod recipes;
@@ -0,0 +1 @@
mod upgrade_or_renew_session;
@@ -0,0 +1,51 @@
use crate::creds::sora_credential_set::SoraCredentialSet;
use crate::creds::sora_jwt_bearer_token::SoraJwtBearerToken;
use crate::creds::sora_sentinel::SoraSentinel;
use crate::requests::bearer::generate::generate_bearer_with_cookie;
use crate::requests::sentinel_refresh::generate::token::generate_token;
use chrono::{DateTime, TimeDelta, Utc};
use errors::AnyhowResult;
use log::info;
use std::ops::Sub;
const EXPIRATION_DEADLINE : TimeDelta = TimeDelta::hours(12);
/// Call this at session startup to generate the tokens for the first time.
/// Call this periodically to refresh the JWT as it nears its expiration date.
/// This will not refresh the sentinel token once it's been generated - we can use other flows to accomplish that.
pub async fn upgrade_or_renew_session(sora_credentials: &mut SoraCredentialSet) -> AnyhowResult<()> {
let mut refresh_jwt = !sora_credentials.jwt_bearer_token.is_some();
let refresh_sentinel = !sora_credentials.sora_sentinel.is_some();
if let Some(jwt) = &sora_credentials.jwt_bearer_token {
let now = Utc::now();
let refresh_deadline = now.sub(EXPIRATION_DEADLINE);
if jwt.jwt_claims().expiration.lt(&now) {
info!("JWT expiration time has elapsed.");
refresh_jwt = true;
} else if jwt.jwt_claims().expiration.lt(&refresh_deadline) {
info!("JWT expiration time is under the expiration deadline.");
refresh_jwt = true;
}
}
if refresh_jwt {
let cookies = sora_credentials.cookies.get_cookies();
info!("Generating new JWT bearer token...");
let token = generate_bearer_with_cookie(cookies).await?;
info!("Parsing JWT bearer token.");
let token = SoraJwtBearerToken::new(token)?;
sora_credentials.jwt_bearer_token = Some(token);
}
if refresh_sentinel {
info!("Generating new sentinel...");
let token = generate_token().await?;
let token = SoraSentinel::new(token);
sora_credentials.sora_sentinel = Some(token);
}
Ok(())
}
@@ -0,0 +1,67 @@
use errors::AnyhowResult;
use log::error;
use reqwest::Client;
use serde_derive::Deserialize;
const SORA_BEARER_GENERATE_URL: &str = "https://sora.com/api/auth/session";
#[derive(Debug, Deserialize)]
pub struct SoraAuthResponse {
pub user: SoraUser,
pub expires: String,
#[serde(rename = "accessToken")]
pub access_token: String,
#[serde(rename = "internalApiBase")]
pub internal_api_base: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct SoraUser {
pub id: String,
pub name: Option<String>,
pub email: Option<String>,
pub image: Option<String>,
pub picture: Option<String>,
pub provider: Option<String>,
#[serde(rename = "lastAuthorizationCheck")]
pub last_authorization_check: Option<i64>,
}
pub async fn generate_bearer_with_cookie(cookie: &str) -> AnyhowResult<String> {
let client = Client::builder()
.gzip(true)
.build()?;
let response = client.get(SORA_BEARER_GENERATE_URL)
.header("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:137.0) Gecko/20100101 Firefox/137.0")
.header("Accept", "*/*")
.header("Accept-Encoding", "gzip, deflate, br")
.header("Accept-Language", "en-US,en;q=0.5")
.header("Cookie", cookie)
.send()
.await?;
if !response.status().is_success() {
error!("Failed to generate bearer token: {}", response.status());
return Err(anyhow::anyhow!("Failed to generate bearer token: {}", response.status()));
}
let response_body = &response.text().await?;
println!("response_body: {}", response_body);
let auth_response: SoraAuthResponse = serde_json::from_str(&response_body)?;
Ok(auth_response.access_token)
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
#[ignore] // Don't run in CI. Requires valid cookie
async fn test_generate_bearer_with_cookie() {
let cookie = "cf_clearance=nelyAZB.T1J.3ToyOuc_NozgPVqEmOoZXZgVqICRVLQ-1745296855-1.2.1.1-R..9Ca0ZCZjSoqDaWLD62phe1hUu.43nPpJV2zqxDibkFPCLJ2R2Am6ygUqJTB1_b86ctFGktBZ4YIPrHHow3jqPk5Mfu_VubL2A5r0R6R_1ILrdvY168CLSpAFRsJKIupMUPBFDePmQ738P7afr8W0YK6dO_m0qjYDYquZyCu5dC3u9CRUGaM8SLDZTTZkxoLzuxDzB1Skuns5rqdjTft4DoNUI_7EJvsYxKoklWylvL2FqcETULoyVq5FyElbZenz8VblXU4HLgspRTz3q0_SNh4_LAIxHygfXaCOBmmii1KY6mFHE83z4JYWgx7MM9jqxKKz1pHgVd8JR72aYgTo7WwE6ZpKmvuxhTtgobsA; oai-did=8ae351f6-bc99-45c9-9fff-bb8dd6eb128b; __Secure-authjs.session-token=eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2Q0JDLUhTNTEyIiwia2lkIjoiYWNBYVdTWHBfaDZnNkxtUEs1S0RvTjR3NHFxTEsxVlpSTHA4VWdiaFdWNjctMG5pbDdVVjBCN2dSUU9Bem84ZXBjMVFjb1ozNGxwa0c1OEUyU2Q4cUEifQ..n6nMP5MA40XKktVazIps6w.tsgjkdkBaHt2jpO7GjIBzgammeanUH5W-3SQMPUDIG_cMrGcgmW_uaMwQY-LJ7qfWGfSqwZyGND6LQD_e-8vYdWbhE_gl1O8AQVJaLfCxJbpauCaXQTsSD0PD_XzGYsW0GZjWIbb-J8YFc-XKi8Y2AhjPhf2El5LJrvkQ4MbtlIJAhDOBNAp7UiBR4SWvWgtD60WqqnW3BhUjqXSigqhopUIkcsXAx_NHkfQpwFyJfkI8WK7JJoW4SbD6hdOX0BCKPjXKn7bTpYG---QkfjJ5f5wsRl0QYRVIQ4lzz4iak0A7oyV5jyX_aXYzK4iroVtEjUBAt77wTDOZ8ID3idkPOWQHPWnaugDzX9ZxZ8sWOxsttqbIMWWpEIm9G9ODc3-F29XHPLzciiLuRlRp2tn2ky12vXBdpiFINL7i5-OEryQ1xs1yEsSnAkzrGcXboXDgPSh-wPpmOhWLwqsydmflSahxQH-k0fUQAnjCq2EocdXnfIL84kqwnOxD36YGsKcX0DOir_3gL2PnAcE25Dql1OTwo9SBSLEf2c8dejDVfFT63UHmCkEqvHgdtTnZRApeqJj57A_2aFvy4d8mhflSaSD5qYmYUIdRo5I-uJumWOuHeDOwXOrlnCzxpgvcl1c5ng560I_oYgNUAhIGUnL5ezMQ3MD6jKwVqk-0kKZUPr2ebE1_w7jh4Rau6esLYiMcJI-Jfm_0TfSxlAXMKC3CKIII50PcXXOrvxy0yiBU_DwU79L6fYPAq7NFJ26P6821iOQGP-0mkaHPZHxEJGaPVKKjg2Y9ZQJ9u1gnm0GWwssY4uhFet6CxURnpqzIXKf78kevXOwkEsgFa-2hhMnr_OWBzKSH4UfVIymulxqoAoOh7Em51NUF-HJ0I0rr_3m7wBlhNvVVnmaxSYkuArZoboixX9ka-JVDopTPXUvoEykm45LRRvPdGiPB5hvCrglzNrp_vep4n-YbcLp4Uzn9ljcMjhSFZfXAzqquaiO7H5ys50_-mBquTq0Hoag2pHbVr5uJL8I1V-b8F1XWOnRtRIlj5pHoLX6KyxlT7pNTTSRgMo9PwAWxmLXpRba1h4ceKQTDputfrD1-itFJfZ3FZgkAXVQGrYyXEQIj79ERqq9xw18qGoBi9-GQkqBmCg1TxeS35SBnc62L7gh-UsZ_LSchizC4l4KODSeKdyNGHRONzK0cNHAGWqAJpLu5vBh_VxiljeaQxc4-p0SRiMXPYQf06uWDaRPdglCcavnlPKOcbL1GLku8CzMMCTAeqN3TJBV8BOHXsW81jw73oaBv18peTpgaJS6-Og9jMQVDb8pwlCi7SZnf1uafeGnBb6jJN4qCBN5EjZcoQikHxv0SOYmjs2XN7xE7EyczkovGcU2K3v03cnbbe01U_1mBAXgiJrW8gb4u3x8kngtphuWjEbStqHivLCtP5dyD22LTEMUE4ixvDmKwuLoB2NukM4QcB7PwrMnjI30SfCx0l5OVToz-TmLkLa-Ous-2cckw-D5pXD4RsgLv5NsxGQ9rtaL3qWeA8KnOByvYH1thLFtBUw6rjHtwLnQUGPomJkXIV8OuEuIWWnbsD1jW7hIpbYynhizTyrLChTnxuD8Witu01NtRa0dqSEhy3H5Y0qDXqFhaziydmW_kIdDxQBCWvPBNVBMAlXldqXgazM05eK3DFP86Div8b8k0pjXNoEtmEcwGLR06taiwQ3Fjbyc3y4HSLnQ-_yYz2jKLbDzpVJdaS6tlWT9rOrgniCkfvxJ5LE6KMF_2i6L_yFprOQ1DCYniM39CFehsD-7XVpS2Ui9t-Kx1g7eebK229kdQzB6Dm_HBEHTOxyXMiR4C0n24WYxEHukfCSiAvChiXHhfHOldmzusKY0r-MB_knKgM69elcXN_VAiukuUo1EdrsaqBprHqwmbkEWYfg-TmYefXJnf-58Xtx0lC2T7Uq8JJDdQL4f4PTnuyU_JX5NOwg9CwDUZoPE4gvYTicshEKxw4TABawLB6LXzr5vxjr1PK9lP1H7iwSyutBUGHEyK_R7fM0OzFQSfMEENwKtaGmGT5GnMTkvF4iGTyEtmZS1RhWqYyIagSFkf66ZRIwt5uohQJhZ8kpU_EOfihCkPC6c8he18W2TPtvGWxe0ljNfykMl-w4564IuHIYO3sESs9RzOdHkul9YqpjZC1C831zzEyM3vgM3hYWKo2VmovC5uMYZO0roqQyuy9RNbGPbXnbzMCE5tTPNeHMd1FVYNGy76t-BLj69MfJN0C-U9RgaLRe2Nz34EZBBfK3v9NNCLYhqm04WhM7XwX3CMrG9_j8mlMKpb_MxYqvQUodtttrBoe3bS4WId7OSz8k3KiswcZD86yopGq5X_a910-C3VzVfxo9jCLlRVP4moY2iohjc9asuQrKjqkUgEXp0bLy-gBB67UxV69rXMBEtz2pLYqfNKEUFFL7cVF1d407QUuSRpANitXUFbmtWo5M-0mJQM7LA0ktMDZUl3_-hPxNHFt9Sba4xDEiSyjPdMZDd--iNpCkJAGvManh-cNYI7HIBQM3kLE4GwD3ihStlW3IT5MfITEXfaaz0vWSvYdMXXmcVjVzgVn5JchynFilRsx7QK4JT7jPb_LQ1vAENBCfIuEERty8dgrKOvS4k21Br6IXckd9hl6l_5GoZUpbQzEY4BWGA8Ly8xKGbwwF4L-Mncc4TQt-2y09qYqU0TMTTXKBGgFfjVn4r0yIvryieCRAHSWuethGyYVX_Qhk_nZB3jqFkp42vMc1mkTjBCR-jlUCJWMbMcmL0tW2_SXDOk5JrACsq-T1djWeUCPgGYXCqYIygtSEE0f025feOpQJAynV78UM2w6fbgGBvsODpVr8Q2hjiI9Ydwh6MIJW1owo1QDDnuEOvTmdwdsQT9wX1H-SyN0cusSITAzgNMEp-NCj8NDW60kqTCbi7kMLF4OtPOK8THCBBkZ5c_SasAvSIxXo36fnMEKGPOLZs9GzLKMtKupym5PZ4H6H2zl_Y6iOIBniyQ3HBs5edLBBGQ3qqeeI_j7Xpwsr06cTCuQGYFAsNhu9dSs3LQTKlVfVeTiqBjRkxAiaQJgPUvFpOCvhIe2t-5n-MNj4.0dhenwDdC-iJ2W4uNnbx9YQcfEgnYSJzo1vXNawiifA; __Host-authjs.csrf-token=61aa1209933a1c967b7af6a787481f4e249a2d6b1d535924540addd4bd4ba196%7C510cca933fb90ce231290471f3885cdee2fdaf4f8f6994d96cfef9c63227e5f7; __Secure-authjs.callback-url=https%3A%2F%2Fsora.com; _cfuvid=XjJAS2zDerYymewq8Gl3e572OMiJ3cyyMqsmFiuGMn4-1745275079858-0.0.1.1-604800000; __cf_bm=hVGZ2X.67OhOZrqzUiW9Xg4IYGdCnss73d7GDU75YHk-1745296490-1.0.1.1-ppPUYZX7zAWYWXknUVzYPLjI7HDdBfmQz9DDMT5pMUNvqKBMhbUrJCp30PzxmBjMYwQL3injMe2IammgQNE5cj7JO7o6Alljt.MN1Pp8s8k; __cflb=0H28vtyuPgbsDEUozcoHSWoAnvpSqAh9friCA2BEchB; _dd_s=rum=2&id=b52d0910-2332-43c2-9fa0-da1c33d06227&created=1745296855240&expire=1745297781201";
let result = generate_bearer_with_cookie(cookie).await;
assert!(result.is_ok());
println!("Bearer token: {}", result.unwrap());
}
}
@@ -0,0 +1 @@
pub mod generate;
@@ -18,6 +18,9 @@ pub enum SoraError {
#[error("Sora too many concurrent tasks: {0}")]
TooManyConcurrentTasks(String),
#[error("Sora invalid JWT: {0}")]
InvalidJwt(String),
#[error("Sora API error: {0}")]
GenericError(String),
@@ -120,6 +123,7 @@ pub enum RawSoraError {
#[serde(rename_all = "snake_case")]
pub enum SoraErrorCode {
SentinelBlock,
InvalidJwt,
TokenExpired,
TooManyConcurrentTasks,
Unknown(String),
@@ -188,6 +192,9 @@ pub (crate) async fn image_gen_http_request(sora_request: RawSoraImageGenRequest
SoraErrorCode::Unknown(_) => {
return Err(SoraError::GenericError(error_response.error.message));
}
SoraErrorCode::InvalidJwt => {
return Err(SoraError::InvalidJwt(error_response.error.message));
}
}
}
@@ -1,7 +1,6 @@
use errors::AnyhowResult;
use crate::credentials::SoraCredentials;
use crate::image_gen::common::{ImageSize, NumImages, SoraImageGenResponse};
use crate::image_gen::image_gen_http_request::{image_gen_http_request, InpaintItem, InpaintItemType, OperationType, RawSoraImageGenRequest, SoraError, VideoGenType};
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};
pub struct SoraImageGenRemixRequest<'a> {
pub prompt: String,
@@ -45,12 +44,12 @@ pub async fn sora_image_gen_remix(request: SoraImageGenRemixRequest<'_>) -> Resu
#[cfg(test)]
mod tests {
use std::fs::read_to_string;
use errors::AnyhowResult;
use testing::test_file_path::test_file_path;
use crate::credentials::SoraCredentials;
use crate::image_gen::common::{ImageSize, NumImages};
use crate::image_gen::sora_image_gen_remix::{sora_image_gen_remix, SoraImageGenRemixRequest};
use crate::requests::image_gen::common::{ImageSize, NumImages};
use crate::requests::image_gen::sora_image_gen_remix::{sora_image_gen_remix, SoraImageGenRemixRequest};
use errors::AnyhowResult;
use std::fs::read_to_string;
use testing::test_file_path::test_file_path;
#[ignore] // You can manually run "ignore" tests in the IDE, but they won't run in CI.
#[tokio::test]
@@ -1,7 +1,7 @@
use errors::AnyhowResult;
use crate::credentials::SoraCredentials;
use crate::image_gen::common::{ImageSize, NumImages, SoraImageGenResponse};
use crate::image_gen::image_gen_http_request::{image_gen_http_request, OperationType, RawSoraImageGenRequest, VideoGenType};
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;
pub struct SoraImageGenSimpleRequest<'a> {
pub prompt: String,
@@ -32,12 +32,12 @@ pub async fn sora_image_gen_simple(args: SoraImageGenSimpleRequest<'_>) -> Anyho
#[cfg(test)]
mod tests {
use std::fs::read_to_string;
use errors::AnyhowResult;
use testing::test_file_path::test_file_path;
use crate::credentials::SoraCredentials;
use crate::image_gen::common::{ImageSize, NumImages};
use crate::image_gen::sora_image_gen_simple::{sora_image_gen_simple, SoraImageGenSimpleRequest};
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;
#[ignore] // You can manually run "ignore" tests in the IDE, but they won't run in CI.
#[tokio::test]
@@ -0,0 +1,5 @@
pub mod bearer;
pub mod image_gen;
pub mod job;
pub mod sentinel_refresh;
pub mod upload;
@@ -1,10 +1,10 @@
use crate::sentinel_refresh::generate::config::{get_random_core_count, get_random_document_key, get_random_navigator_key, get_random_window_key};
use crate::requests::sentinel_refresh::generate::config::{get_random_core_count, get_random_document_key, get_random_navigator_key, get_random_window_key};
use base64::prelude::BASE64_STANDARD;
use base64::Engine;
use chrono::FixedOffset;
use chrono::Utc;
use idempotency::uuid::generate_random_uuid;
use rand::Rng;
use base64::prelude::BASE64_STANDARD;
use base64::Engine;
use sha3::{Digest, Sha3_512};
// [
@@ -18,7 +18,6 @@ pub async fn refresh_sentinel() -> AnyhowResult<String> {
let response = client.post(url).send().await?;
let body = response.text().await?;
Ok(body)
@@ -1,7 +1,7 @@
use std::path::{Path, PathBuf};
use errors::AnyhowResult;
use crate::credentials::SoraCredentials;
use crate::upload::upload_media_http_request::{upload_media_http_request, SoraMediaUploadResponse};
use crate::requests::upload::upload_media_http_request::{upload_media_http_request, SoraMediaUploadResponse};
use errors::AnyhowResult;
use std::path::PathBuf;
pub struct SoraMediaUploadRequest<'a> {
pub file_path: String,
@@ -1,9 +1,9 @@
use crate::credentials::SoraCredentials;
use crate::requests::upload::upload_media_http_request::{upload_media_http_request, SoraMediaUploadResponse};
use errors::AnyhowResult;
use std::path::Path;
use tokio::fs::File;
use tokio::io::AsyncReadExt;
use errors::AnyhowResult;
use crate::credentials::SoraCredentials;
use crate::upload::upload_media_http_request::{upload_media_http_request, SoraMediaUploadResponse};
/// Try to prevent buffer reallocations.
/// There's a better way to implement this.
@@ -41,11 +41,11 @@ pub async fn sora_media_upload_from_file<P: AsRef<Path>>(file_path: P, creds: &S
#[cfg(test)]
mod tests {
use std::fs::read_to_string;
use errors::AnyhowResult;
use testing::test_file_path::test_file_path;
use crate::credentials::SoraCredentials;
use crate::upload::upload_media_from_file::sora_media_upload_from_file;
use errors::AnyhowResult;
use std::fs::read_to_string;
use testing::test_file_path::test_file_path;
#[ignore] // You can manually run "ignore" tests in the IDE, but they won't run in CI.
#[tokio::test]
@@ -1,9 +1,8 @@
use crate::credentials::{SoraCredentials, USER_AGENT};
use errors::AnyhowResult;
use crate::sora_error::SoraError;
use reqwest::multipart::{Form, Part};
use reqwest::Client;
use serde::Deserialize;
use crate::sora_error::SoraError;
const SORA_UPLOAD_MEDIA_URL: &str = "https://sora.com/backend/uploads";
@@ -0,0 +1,139 @@
//! Adapted from 'jwt' crate
use anyhow::anyhow;
use base64::Engine;
use base64::prelude::BASE64_URL_SAFE_NO_PAD;
use chrono::{DateTime, Utc};
use log::warn;
use serde_json::Value;
use errors::AnyhowResult;
const SEPARATOR: &str = ".";
#[derive(Clone, Debug)]
pub struct SoraJwtClaims {
pub created: DateTime<Utc>,
pub expiration: DateTime<Utc>,
pub user_id: String,
pub email: String,
pub email_verified: bool,
}
pub fn lightweight_sora_jwt_parse(token: &str) -> AnyhowResult<SoraJwtClaims> {
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 iat = claims.get("iat")
.map(|val| val.as_number())
.flatten()
.ok_or(anyhow!("no iat claim"))?;
let iat = iat.as_i64()
.ok_or(anyhow!("iat is not a number"))?;
let iat = DateTime::from_timestamp(iat, 0)
.ok_or(anyhow!("iat is not a valid timestamp"))?;
let exp = claims.get("exp")
.map(|val| val.as_number())
.flatten()
.ok_or(anyhow!("no exp claim"))?;
let exp = exp.as_i64()
.ok_or(anyhow!("exp is not a number"))?;
let exp = DateTime::from_timestamp(exp, 0)
.ok_or(anyhow!("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"))?;
let profile = claims.get("https://api.openai.com/profile")
.ok_or(anyhow!("no user_id claim"))?;
let email = profile.get("email")
.map(|val| val.as_str())
.flatten()
.ok_or(anyhow!("no email claim"))?;
let email_verified = profile.get("email_verified")
.map(|val| val.as_bool())
.flatten()
.unwrap_or_else(|| {
warn!("no email_verified claim");
false
});
Ok(SoraJwtClaims {
created: iat,
expiration: exp,
user_id: user_id.to_string(),
email: email.to_string(),
email_verified,
})
}
fn split_components(token: &str) -> AnyhowResult<[&str; 3]> {
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"))?;
if components.next().is_some() {
return Err(anyhow!("too many components"));
}
Ok([header, claims, signature])
}
fn decode_base64(raw: &str) -> AnyhowResult<String> {
let json_bytes = BASE64_URL_SAFE_NO_PAD.decode(raw)?;
let json_str = String::from_utf8(json_bytes.clone())?;
Ok(json_str)
}
#[cfg(test)]
mod tests {
use crate::utils::lightweight_sora_jwt_parse::lightweight_sora_jwt_parse;
#[test]
fn test_sora_jwt_bearer_token() {
let token =
"eyJhbGciOiJSUzI1NiIsImtpZCI6IjE5MzQ0ZTY1LWJiYzktNDRkMS1hOWQwLWY5NTdiMDc5YmQwZSIsInR5cCI6Ik\
pXVCJ9.eyJhdWQiOlsiaHR0cHM6Ly9hcGkub3BlbmFpLmNvbS92MSJdLCJjbGllbnRfaWQiOiJhcHBfTTFuUTN0UjV2\
VzdYOWpMMnBFNmdIOGRLNCIsImV4cCI6MTc0NTgwMDQ3MiwiaHR0cHM6Ly9hcGkub3BlbmFpLmNvbS9hdXRoIjp7InV\
zZXJfaWQiOiJ1c2VyLTZOeEpmbEFIb0VCREp6Wmw5aVhocWJERyJ9LCJodHRwczovL2FwaS5vcGVuYWkuY29tL3Byb2\
ZpbGUiOnsiZW1haWwiOiJ2b2NvZGVzMjAyMEBnbWFpbC5jb20iLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZX0sImlhdCI6M\
Tc0NDkzNjQ3MSwiaXNzIjoiaHR0cHM6Ly9hdXRoLm9wZW5haS5jb20iLCJqdGkiOiI0NTZmZjY5Yi0xZTZhLTQzOWYt\
YTYzMC1jNmFjYzI3NzU1YjAiLCJuYmYiOjE3NDQ5MzY0NzEsInB3ZF9hdXRoX3RpbWUiOjE3NDQwNTkzMTc3NTAsInN\
jcCI6WyJvcGVuaWQiLCJlbWFpbCIsInByb2ZpbGUiLCJvZmZsaW5lX2FjY2VzcyJdLCJzZXNzaW9uX2lkIjoiYXV0aH\
Nlc3NfcEE4UGl3V0trNmJsSE1qQnVCQnVSRmh2Iiwic3ViIjoiZ29vZ2xlLW9hdXRoMnwxMTMxMDE5Njc2MTIzOTY3O\
TM3NzcifQ.JOxYfrQSIsHdf-VN1zcOqDOS3tF1HpUYnRVfPKgi65am3sNiniOM1kWTNrKE5-sh4DaZ33G_XniGkhg-9\
QKF8tspXpZ6BnTNkf-QdGqR_v89AIO8pZgQ7oECLXhnmg-31G-LAHlGfwWedJ6qPVy4dL5zoR0wVyoQ8gYv5QzQC-zd\
IF7uY-umKKloAAYP_tkKLlQHackjTynN-bpu2mKv55-h7a3hSEPfxvgX0SsvL69xtNsZkwb43bqyP1x_c2ErfYoPyzg\
rqgMIRZjCw9TI33vISKM_BfQXBiZT_BHkX4s0Jng3ph4vrwvnBsa0HpNxBhQYKb1u9gZXs7XjLZhjZJZsDTQI1V4Wov\
iAL7ihvZkLxJRtDCOmX7-5BTEsMInFgXUAhCDhQUMwSfLLJuinBS96NGSX5_TfIVMkbCl-HWzdQx7KZAsahNe_CmuYT\
lZH32NuRn78ohY4eViRyfC8OXVLwoLxxpen2CWlV2dxV5wwXeij_kHt5wOXSGYpSfxz37cK0CaK8K2pwudasxQ0lv2V\
_Xx_z4UaEOr5wqT_BK1A1HO4HRjvFX2soF-qm4GCSWAbmFZs7DVEClrotxRx2romifRVb8XRia2M6YTmDyIT0YphFgG\
nnrDIZNKO23uDYT60LVMVYpCCY2RiwxCR9bn7AeGbmi7cpL9aYZ5uJRM";
// jwt payload :
// - "https://api.openai.com/auth".user_id = "user-6NxJflAHoEBDJzZl9iXhqbDG"
// - "https://api.openai.com/profile".email = "vocodes2020@gmail.com"
// - "https://api.openai.com/profile".email_verified = true
// - "iat": 1744936471, - issued at claim (5 days ago)
// - "nbf": 1744936471, - not before, don't use before date
// - "exp": 1745800472, - in 5 days, expiry
let result = lightweight_sora_jwt_parse(token).expect("claim should parse");
assert_eq!(result.email, "vocodes2020@gmail.com");
assert_eq!(result.email_verified, true);
}
}
@@ -0,0 +1 @@
pub mod lightweight_sora_jwt_parse;
@@ -0,0 +1,41 @@
[package]
name = "storyteller_client"
edition = "2021"
version = "0.0.1"
authors = [
"Brandon Thomas <bt@brand.io>",
"Brandon Thomas <echelon@gmail.com>",
]
publish = false
[lib]
name = "storyteller_client"
path = "src/lib.rs"
[dependencies]
# Workspace hack (faster compile times)
workspace-build-acceleration = { workspace = true }
# Internal
enums = { workspace = true }
enums_public = { workspace = true }
errors = { workspace = true }
tokens = { workspace = true }
# External
anyhow = { workspace = true }
chrono = { workspace = true }
log = { workspace = true }
reqwest = { workspace = true, features = ["multipart", "gzip"] }
url = { workspace = true }
# Serialization
serde = { workspace = true }
serde_derive = { workspace = true }
serde_json = { workspace = true }
# HTTP
backoff = { version = "0.4.0", features = ["futures"] } # Exponential backoff retry
[dev-dependencies]
tokio = { version = "1.38.0", features = ["macros"] }
@@ -0,0 +1,81 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
use errors::AnyhowError;
#[derive(Debug)]
pub enum ApiError {
/// 400. The request was invalid.
InvalidRequest(String),
/// 401. The request was not authorized.
Unauthorized(String),
/// 403. The request was forbidden.
Forbidden(String),
/// 404. The requested resource was not found.
NotFound(String),
/// 429. Too many requests.
TooManyRequests(String),
/// 500. An internal server error occurred.
InternalServerError(String),
/// A deserialization error with the response.
DeserializationError(serde_json::Error),
/// The request timed out.
Timeout(String),
/// A network error occurred.
NetworkError(String),
/// Uncategorized reqwest error.
OtherReqwestError(reqwest::Error),
/// Another type of error.
Other(AnyhowError),
}
impl Error for ApiError {}
impl Display for ApiError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
// Server response code errors
ApiError::InvalidRequest(msg) => write!(f, "Invalid request: {}", msg),
ApiError::Unauthorized(msg) => write!(f, "Unauthorized: {}", msg),
ApiError::Forbidden(msg) => write!(f, "Forbidden: {}", msg),
ApiError::NotFound(msg) => write!(f, "Not found: {}", msg),
ApiError::TooManyRequests(msg) => write!(f, "Too many requests: {}", msg),
ApiError::InternalServerError(msg) => write!(f, "Internal server error: {}", msg),
// Server response handling errors
ApiError::DeserializationError(error) => write!(f, "Deserialization error: {}", error),
// Network errors
ApiError::Timeout(msg) => write!(f, "Timeout: {}", msg),
ApiError::NetworkError(msg) => write!(f, "Network error: {}", msg),
// Other
ApiError::OtherReqwestError(error) => write!(f, "Reqwest error: {}", error),
ApiError::Other(error) => write!(f, "Other error: {}", error),
}
}
}
impl From<reqwest::Error> for ApiError {
fn from(error: reqwest::Error) -> Self {
if error.is_timeout() {
ApiError::Timeout(error.to_string())
} else if error.is_connect() {
ApiError::NetworkError(error.to_string())
} else {
ApiError::OtherReqwestError(error)
}
}
}
impl From<serde_json::Error> for ApiError {
fn from(error: serde_json::Error) -> Self {
ApiError::DeserializationError(error)
}
}
@@ -0,0 +1,4 @@
pub mod media_files;
pub mod shared_response_types;
pub mod utils;
mod api_error;
@@ -0,0 +1,223 @@
use chrono::{DateTime, Utc};
use log::debug;
use serde_derive::{Deserialize};
use enums::by_table::media_files::media_file_animation_type::MediaFileAnimationType;
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_engine_category::MediaFileEngineCategory;
use enums::by_table::media_files::media_file_type::MediaFileType;
use enums::by_table::model_weights::weights_category::WeightsCategory;
use enums::common::visibility::Visibility;
use enums::no_table::style_transfer::style_transfer_name::StyleTransferName;
use enums_public::by_table::model_weights::public_weights_types::PublicWeightsType;
use tokens::tokens::batch_generations::BatchGenerationToken;
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::model_weights::ModelWeightToken;
use tokens::tokens::prompts::PromptToken;
use crate::api_error::ApiError;
use crate::shared_response_types::media_file_cover_image_details::MediaFileCoverImageDetails;
use crate::shared_response_types::media_links::MediaLinks;
use crate::shared_response_types::simple_entity_stats::SimpleEntityStats;
use crate::shared_response_types::user_details_light::UserDetailsLight;
use crate::utils::api_host::ApiHost;
use crate::utils::filter_bad_response::filter_bad_response;
use crate::utils::http_get_anonymous::http_get_anonymous;
/// Get details about a media file from our backend
pub async fn get_media_file(api_host: &ApiHost, media_file_token: &MediaFileToken) -> Result<GetMediaFileSuccessResponse, ApiError> {
let url = get_media_file_token_route(api_host, media_file_token);
debug!("Requesting {:?}", &url);
let response = http_get_anonymous(url).await?;
let response = filter_bad_response(response).await?;
let response_body = &response.text().await?;
let media_file = serde_json::from_str(&response_body)?;
Ok(media_file)
}
fn get_media_file_token_route(api_host: &ApiHost, media_file_token: &MediaFileToken) -> String {
let api_hostname = api_host.to_api_hostname();
let media_file_token = media_file_token.as_str();
format!("https://{}/v1/media_files/file/{}", api_hostname, media_file_token)
}
// TODO(bt,2025-04-22): Share API definitions between client and server in common crate.
#[derive(Deserialize, Debug)]
pub struct GetMediaFileSuccessResponse {
pub success: bool,
pub media_file: MediaFileInfo,
}
#[derive(Deserialize, Debug)]
pub struct MediaFileInfo {
pub token: MediaFileToken,
/// The coarse-grained class of media file: image, video, etc.
pub media_class: MediaFileClass,
/// Type of media will dictate which fields are populated and what
/// the frontend should display (eg. video player vs audio player).
/// This is closer in meaning to a "mime type".
pub media_type: MediaFileType,
/// If this is an engine/3D asset, this is the broad category (scene,
/// animation, etc.) of that object.
/// This can also be used for filtering in list/batch endpoints.
pub maybe_engine_category: Option<MediaFileEngineCategory>,
/// If this is an engine/3D asset for an animation or a rig that can
/// be animated with either (or both) skeletal or blend shape animations,
/// this describes the animation regime used or supported.
pub maybe_animation_type: Option<MediaFileAnimationType>,
// /// If the media file has a subtype, we'll report it.
// /// This is mostly used for Bevy engine files.
// #[deprecated(note="This was for the Bevy engine. Do not use.")]
// pub maybe_media_subtype: Option<MediaFileSubtype>,
// /// Extension for the engine to load over remote:// URLs.
// #[deprecated(note="This was for the Bevy engine. Do not use.")]
// pub maybe_engine_extension: Option<String>,
/// If the file was generated as part of a batch, this is the token for the batch.
pub maybe_batch_token: Option<BatchGenerationToken>,
/// If this media file was generated by a scene from the engine, this is a
/// link to that file.
pub maybe_scene_source_media_file_token: Option<MediaFileToken>,
// /// (DEPRECATED) URL path to the media file
// #[deprecated(note="This field doesn't point to the full URL. Use media_links instead to leverage the CDN.")]
// pub public_bucket_path: String,
// /// (DEPRECATED) Full URL to the media file
// #[deprecated(note="This points to the bucket. Use media_links instead to leverage the CDN.")]
// pub public_bucket_url: Url,
/// Rich CDN links to the media, including thumbnails, previews, and more.
pub media_links: MediaLinks,
/// Information about the cover image. Many media files do not require a cover image,
/// e.g. image files, video files with thumbnails, audio files, etc.
/// 3D files require them.
pub cover_image: MediaFileCoverImageDetails,
//// Provenance Data
//pub origin_product: MediaFileOriginProductCategory,
//pub origin_category: MediaFileOriginCategory,
//pub maybe_origin_model_type: Option<MediaFileOriginModelType>,
//pub maybe_origin_model_token: Option<String>,
//pub maybe_origin_filename: Option<String>,
/// Possible model info (if generated by inference)
pub maybe_model_weight_info: Option<GetMediaFileModelInfo>,
/// User info
pub maybe_creator_user: Option<UserDetailsLight>,
pub creator_set_visibility: Visibility,
/// The file was uploaded by the user.
/// This does not include files generated on the client side, like studio renders.
pub is_user_upload: bool,
/// The file was created by the system.
/// This includes files generated on the client side, like studio renders.
pub is_intermediate_system_file: bool,
/// The name or title of the media file (optional)
pub maybe_title: Option<String>,
/// Text transcripts for TTS, etc.
pub maybe_text_transcript: Option<String>,
// /// OPTIONAL. Details on live portrait generated media files,
// /// if this is a live portrait generated file.
// pub maybe_live_portrait_details: Option<MediaFileLivePortraitDetails>,
/// For Comfy / Video Style Transfer jobs, this might include
/// the name of the selected style.
pub maybe_style_name: Option<StyleTransferName>,
/// For Comfy / Video Style Transfer jobs, this indicates use of face detailer.
pub used_face_detailer: bool,
/// For Comfy / Video Style Transfer jobs, this indicates use of upscaling.
pub used_upscaler: bool,
/// The foreign key to the prompt used to generate the media, if applicable.
pub maybe_prompt_token: Option<PromptToken>,
/// The original filename for uploaded files, if they were provided.
/// In the future we'll provide our own internal optional filenames.
pub maybe_original_filename: Option<String>,
/// Duration for audio and video files, if available.
/// Measured in milliseconds.
pub maybe_duration_millis: Option<u64>,
/// We can simulate media files for "tts_results" records.
/// If this is set as true, this informs the frontend and API callers not to treat
/// the token as a media file and improperly assume it can be used with the rest of
/// the media file APIs.
pub is_emulated_media_file: bool,
/// Whether the media file is featured by staff (the featured_items API) or not.
pub is_featured: bool,
/// Statistics about the media file
pub stats: SimpleEntityStats,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub maybe_moderator_fields: Option<GetMediaFileModeratorFields>,
}
#[derive(Deserialize, Debug)]
pub struct GetMediaFileModeratorFields {
/// This may become public in the future.
pub maybe_style_transfer_source_media_file_token: Option<MediaFileToken>,
//pub creator_ip_address: String,
//pub creator_is_banned_if_user: bool,
//pub maybe_creator_deleted_at: Option<DateTime<Utc>>,
//pub maybe_mod_deleted_at: Option<DateTime<Utc>>,
}
#[derive(Deserialize, Debug)]
pub struct GetMediaFileModelInfo {
pub weight_token: ModelWeightToken,
pub weight_type: PublicWeightsType,
pub weight_category: WeightsCategory,
pub title: String,
/// Cover images are small descriptive images that can be set for any model.
/// If a cover image is set, this is the path to the asset.
pub maybe_cover_image_public_bucket_path: Option<String>,
/// NB: Technically this shouldn't be a nullable field, but since there are so many
/// joins we'll relax that this must exist (for now).
pub maybe_weight_creator: Option<UserDetailsLight>,
}
#[cfg(test)]
mod tests {
use tokens::tokens::media_files::MediaFileToken;
use crate::media_files::get_media_file::get_media_file;
use crate::utils::api_host::ApiHost;
#[tokio::test]
#[ignore] // Don't run in CI. Requires valid cookie
async fn test_request() {
let host = ApiHost::Storyteller;
let token = MediaFileToken::new_from_str("m_gff67btr810vg3ng9szj85zskztcgy");
let result = get_media_file(&host, &token).await.unwrap();
println!("Result: {:?}", &result);
assert_eq!(result.success, false); // NB: Will fail so we can see debug printed result.
}
}
@@ -0,0 +1 @@
pub mod get_media_file;
@@ -0,0 +1,15 @@
use serde_derive::Deserialize;
use url::Url;
/// Cover image links can only be jpg, png, etc. No videos.
#[derive(Deserialize, Debug)]
pub struct CoverImageLinks {
/// Primary link to the cover image via the CDN.
pub cdn_url: Url,
/// Template to construct thumbnail URLs.
/// Replace the string `{WIDTH}` with the desired width.
/// Only relevant for image media files. (Video media files instead have
/// video previews, which, in turn, have their own thumbnail templates.)
pub thumbnail_template: String,
}
@@ -0,0 +1,40 @@
use serde_derive::{Deserialize};
use url::Url;
use crate::shared_response_types::cover_image_links::CoverImageLinks;
/// Everything we need to create a cover image.
/// Cover images are small descriptive images that can be set for any media file.
/// If a cover image is set, this is the path to the asset.
#[derive(Deserialize, Debug)]
pub struct MediaFileCoverImageDetails {
// /// (DEPRECATED) URL path to the media file
// #[deprecated(note="This field doesn't point to the full URL. Use media_links instead to leverage the CDN.")]
// pub maybe_cover_image_public_bucket_path: Option<String>,
// /// (DEPRECATED) Full URL to the media file
// #[deprecated(note="This points to the bucket. Use media_links instead to leverage the CDN.")]
// pub maybe_cover_image_public_bucket_url: Option<Url>,
// NB(bt,2024-09-19): I accidentally rolled this field out to production.
// I don't think this field is in use, but maybe ...
// /// (DEPRECATED) Use maybe_links instead.
// #[deprecated(note="Use `maybe_links` instead.")]
// pub maybe_media_links: Option<MediaLinks>,
/// Links to the cover image (CDN direct link, thumbnail template)
/// If a cover image is set, this is the path to the asset.
/// If a cover image is not set, use the information in `default_cover` instead.
/// Rich CDN links to the media, including thumbnails, previews, and more.
pub maybe_links: Option<CoverImageLinks>,
/// For items without a cover image, we can use one of our own.
pub default_cover: MediaFileDefaultCover,
}
/// The default cover is composed of an image and color pair that are
/// predefined by the frontend.
#[derive(Deserialize, Debug)]
pub struct MediaFileDefaultCover {
pub image_index: u8,
pub color_index: u8,
}
@@ -0,0 +1,34 @@
use serde_derive::Deserialize;
use url::Url;
/// Links to media file locations (bucket, CDN, etc.)
#[derive(Deserialize, Debug)]
pub struct MediaLinks {
/// Primary link to the asset via the CDN.
pub cdn_url: Url,
/// Template to construct thumbnail URLs.
/// Replace the string `{WIDTH}` with the desired width.
/// Only relevant for image media files. (Video media files instead have
/// video previews, which, in turn, have their own thumbnail templates.)
pub maybe_thumbnail_template: Option<String>,
/// Video preview images (still and animated gif) for mp4 video files.
/// These are only set for video media files.
/// These are not set for "cover images" that are gif or webms, but rather video files.
pub maybe_video_previews: Option<VideoPreviews>,
}
#[derive(Deserialize, Debug)]
pub struct VideoPreviews {
/// A static single frame preview image of the video.
pub still: Url,
/// An animated gif preview of the video.
pub animated: Url,
/// A template used to construct the still thumbnail URL.
/// Replace the string `{WIDTH}` with the desired width.
pub still_thumbnail_template: String,
/// A template used to construct the animated thumbnail URL.
/// Replace the string `{WIDTH}` with the desired width.
pub animated_thumbnail_template: String,
}
@@ -0,0 +1,5 @@
pub mod cover_image_links;
pub mod media_file_cover_image_details;
pub mod media_links;
pub mod simple_entity_stats;
pub mod user_details_light;
@@ -0,0 +1,10 @@
use serde_derive::Deserialize;
/// Simple stats that can be attached to any entity
#[derive(Deserialize, Debug)]
pub struct SimpleEntityStats {
/// Number of positive ratings (or "likes") for this item
pub positive_rating_count: u32,
/// Number of bookmarks for this item
pub bookmark_count: u32,
}
@@ -0,0 +1,33 @@
use serde_derive::Deserialize;
use tokens::tokens::users::UserToken;
/// Everything we need to refer to a user on the public web interface.
#[derive(Deserialize, Debug)]
pub struct UserDetailsLight {
/// The token for the user
pub user_token: UserToken,
/// The unique username someone logs in with
/// As of 2023-08-23, this is always lowercase
pub username: String,
/// As of 2023-08-23, this is the username with capitalization
/// (In the future, a display name can be customized by the user.)
pub display_name: String,
/// Email hash for Gravatar
/// Always set for now since login is email/username+password.
/// In the future this will need to become an optional *OR* be filled with a bogus hash.
pub gravatar_hash: String,
/// For users without a gravatar, we show one of our own avatars.
pub default_avatar: UserDefaultAvatarInfo,
// In the future, we'll also support user-uploaded avatars that we store on our servers.
}
#[derive(Deserialize, Debug)]
pub struct UserDefaultAvatarInfo {
pub image_index: u8,
pub color_index: u8,
}
@@ -0,0 +1,17 @@
#[derive(Clone)]
pub enum ApiHost {
Storyteller,
FakeYou,
Localhost { port: u32 },
}
impl ApiHost {
pub fn to_api_hostname(&self) -> String {
match self {
ApiHost::Storyteller => "api.storyteller.ai".to_string(),
ApiHost::FakeYou => "api.fakeyou.com".to_string(),
ApiHost::Localhost { port } => format!("localhost:{}", port),
}
}
}
@@ -0,0 +1,33 @@
use anyhow::anyhow;
use crate::api_error::ApiError;
use crate::utils::status_codes::*;
use reqwest::StatusCode;
/// Pass request errors to this method for standard error handling behavior.
/// This function needs to take temporary ownership, but will return it to the caller.
///
/// Example use:
/// let response = client.get().await?;
/// let response = filter_bad_response(response).await?;
/// ... 200 or other valid response
pub async fn filter_bad_response(response: reqwest::Response) -> Result<reqwest::Response, ApiError> {
let status = response.status();
if !status.is_success() {
let response_body = match response.text().await {
Ok(text) => text.to_string(),
Err(err) => format!("Could not read response body: {:?}", err),
};
return match status.as_u16() {
STATUS_401_UNAUTHORIZED => Err(ApiError::Unauthorized(response_body)),
STATUS_403_FORBIDDEN => Err(ApiError::Forbidden(response_body)),
STATUS_404_NOT_FOUND => Err(ApiError::NotFound(response_body)),
STATUS_429_TOO_MANY_REQUESTS => Err(ApiError::TooManyRequests(response_body)),
STATUS_500_INTERNAL_SERVER_ERROR => Err(ApiError::InternalServerError(response_body)),
_ => Err(ApiError::Other(anyhow!("Bad status code: {}; message: {:?}", status, response_body))),
}
}
Ok(response)
}
@@ -0,0 +1,18 @@
use reqwest::Client;
use crate::api_error::ApiError;
const USER_AGENT: &str = "storyteller-client/1.0";
pub async fn http_get_anonymous(url: String) -> Result<reqwest::Response, ApiError> {
let client = Client::builder()
.gzip(true)
.build()?;
let response = client.get(url)
.header("User-Agent", USER_AGENT)
.header("Accept", "application/json")
//.header("Accept-Encoding", "gzip, deflate, br")
.send()
.await?;
Ok(response)
}
@@ -0,0 +1,4 @@
pub mod api_host;
pub mod filter_bad_response;
pub mod http_get_anonymous;
pub mod status_codes;
@@ -0,0 +1,23 @@
pub const STATUS_401_UNAUTHORIZED : u16 = 401u16;
pub const STATUS_403_FORBIDDEN : u16 = 403u16;
pub const STATUS_404_NOT_FOUND : u16 = 404u16;
pub const STATUS_429_TOO_MANY_REQUESTS : u16 = 429u16;
pub const STATUS_500_INTERNAL_SERVER_ERROR : u16 = 500u16;
#[cfg(test)]
mod tests {
use crate::utils::status_codes::*;
#[test]
pub fn test_codes() {
assert_eq!(STATUS_401_UNAUTHORIZED, 401);
assert_eq!(STATUS_403_FORBIDDEN, 403);
assert_eq!(STATUS_404_NOT_FOUND, 404);
assert_eq!(STATUS_429_TOO_MANY_REQUESTS, 429);
assert_eq!(STATUS_500_INTERNAL_SERVER_ERROR, 500);
}
}
@@ -8,7 +8,8 @@ pub const DEFAULT_RUST_LOG: &str = concat!(
"sqlx::query=warn,",
"hyper::proto::h1::io=warn,",
"storyteller_web::threads::db_health_checker_thread::db_health_checker_thread=warn,",
"http_server_common::request::get_request_ip=info," // Debug spams Rust logs
"http_server_common::request::get_request_ip=info,", // Debug spams Rust logs
"info", // Default level for everything else
);
/// The default Redis connection string for use in development
@@ -17,30 +17,30 @@ use crate::job::job_loop::job_success_result::{JobSuccessResult, ResultEntity};
use crate::job::job_loop::process_single_job_error::ProcessSingleJobError;
use crate::state::job_dependencies::JobDependencies;
use crate::util::extractors::get_polymorphic_args_from_job::get_polymorphic_args_from_job;
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
use cloud_storage::remote_file_manager::remote_cloud_file_manager::RemoteCloudFileClient;
use composite_identifiers::by_table::batch_generations::batch_generation_entity::BatchGenerationEntity;
use errors::AnyhowResult;
use filesys::file_read_bytes::file_read_bytes;
use hashing::sha256::sha256_hash_file::sha256_hash_file;
use log::info;
use mimetypes::mimetype_for_bytes::get_mimetype_for_bytes;
use mysql_queries::queries::batch_generations::insert_batch_generation_records::{insert_batch_generation_records, InsertBatchArgs};
use mysql_queries::queries::generic_inference::job::list_available_generic_inference_jobs::AvailableInferenceJob;
use openai_sora_client::credentials::SoraCredentials;
use openai_sora_client::image_gen::image_gen_status::TaskStatus;
use openai_sora_client::image_gen::{wait_for_image_gen_status, Generation};
use openai_sora_client::requests::image_gen::image_gen_status::TaskStatus;
use openai_sora_client::requests::image_gen::{wait_for_image_gen_status, Generation};
use r2d2_redis::r2d2::PooledConnection;
use r2d2_redis::redis::Commands;
use r2d2_redis::RedisConnectionManager;
use shared_service_components::sora_redis_credentials::get_sora_credentials_from_redis::get_sora_credentials_from_redis;
use std::collections::HashMap;
use std::fs::File;
use std::io::Write;
use std::path::PathBuf;
use log::info;
use tempdir::TempDir;
use url::Url;
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
use filesys::file_read_bytes::file_read_bytes;
use hashing::sha256::sha256_hash_file::sha256_hash_file;
use mimetypes::mimetype_for_bytes::get_mimetype_for_bytes;
use shared_service_components::sora_redis_credentials::get_sora_credentials_from_redis::get_sora_credentials_from_redis;
use tokens::tokens::batch_generations::BatchGenerationToken;
use url::Url;
const IMAGE_PREFIX : &str = "image_";
const IMAGE_SUFFIX : &str = ".png"; // TODO: Vary based on filetype.
@@ -68,7 +68,7 @@ reusable_types = { path = "../../../_types/reusable_types" }
server_environment = { path = "../../../lib/server_environment" }
shared_service_components = { workspace = true }
thumbnail_generator = { path = "../../../lib/api_clients/thumbnail_generator" }
tokens = { path = "../../../_types/tokens" }
tokens = { workspace = true }
tts_common = { path = "../../../lib/tts_common" }
url_config = { path = "../../../lib/url_config" }
user_input_common = { path = "../../../lib/user_input_common" }
@@ -98,7 +98,7 @@ strum_macros = "0.26.4"
symphonia = { git = "https://github.com/pdeljanov/Symphonia.git", rev = "e1a70095c4409478c1ded499fb8dd4afa540fb90", features = ["aac", "alac", "flac", "isomp4", "mkv", "mp3", "ogg", "opus", "vorbis", "wav"] }
time = "0.3.30"
uuid = { version = "1.8.0", features = ["v4"] }
url = { version = "2.5.2", features = ["serde"] }
url = { workspace = true }
# Language tag parsing
# TODO: Move to language support crate
@@ -11,17 +11,17 @@ use actix_web::web::Json;
use actix_web::{web, HttpRequest, HttpResponse};
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
use cloud_storage::bucket_client::BucketClient;
use log::{debug, error, info};
use log::warn;
use log::{debug, error, info};
use mysql_queries::queries::media_files::get::get_media_file::get_media_file;
use openai_sora_client::credentials::SoraCredentials;
use openai_sora_client::image_gen::SoraError;
use openai_sora_client::sentinel_refresh::generate::token::generate_token;
use openai_sora_client::sentinel_refresh::refresh_sentinel;
use openai_sora_client::requests::image_gen::SoraError;
use openai_sora_client::requests::sentinel_refresh::generate::token::generate_token;
use openai_sora_client::requests::sentinel_refresh::refresh_sentinel;
use serde::Deserialize;
use serde::Serialize;
use shared_service_components::sora_redis_credentials::set_sora_credential_field_in_redis::set_sora_credential_field_in_redis;
use shared_service_components::sora_redis_credentials::keys::RedisSoraCredentialSubkey;
use shared_service_components::sora_redis_credentials::set_sora_credential_field_in_redis::set_sora_credential_field_in_redis;
use sqlx::MySqlPool;
use tempdir::TempDir;
use utoipa::ToSchema;
@@ -37,9 +37,9 @@ use mysql_queries::payloads::generic_inference_args::generic_inference_args::{Ge
use mysql_queries::payloads::generic_inference_args::inner_payloads::sora_image_gen_args::SoraImageGenArgs;
use mysql_queries::queries::generic_inference::web::insert_generic_inference_job::{insert_generic_inference_job, InsertGenericInferenceArgs};
use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_idempotency_token;
use openai_sora_client::image_gen::common::{ImageSize, NumImages};
use openai_sora_client::image_gen::sora_image_gen_remix::{sora_image_gen_remix, SoraImageGenRemixRequest};
use openai_sora_client::upload::upload_media_from_file::{sora_media_upload_from_file, SoraMediaUploadRequest};
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_file::{sora_media_upload_from_file, SoraMediaUploadRequest};
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::model_weights::ModelWeightToken;
+22
View File
@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>ArtCraft</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="stylesheet" href="/src/styles.css" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Source+Sans+3:ital,wght@0,200..900;1,200..900&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
@@ -0,0 +1,5 @@
{
"name": "@frontend/artcraft-website",
"version": "0.0.1",
"private": true
}
@@ -0,0 +1,15 @@
const { join } = require('path');
// Note: If you use library-specific PostCSS/Tailwind configuration then you should remove the `postcssConfig` build
// option from your application's configuration (i.e. project.json).
//
// See: https://nx.dev/guides/using-tailwind-css-in-react#step-4:-applying-configuration-to-libraries
module.exports = {
plugins: {
tailwindcss: {
config: join(__dirname, 'tailwind.config.js'),
},
autoprefixer: {},
},
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

@@ -0,0 +1 @@
/* Your styles goes here. */
@@ -0,0 +1,16 @@
import { render } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import App from './app';
describe('App', () => {
it('should render successfully', () => {
const { baseElement } = render(<BrowserRouter><App /></BrowserRouter>);
expect(baseElement).toBeTruthy();
});
it('should have a greeting as the title', () => {
const { getAllByText } = render(<BrowserRouter><App /></BrowserRouter>);
expect(getAllByText(new RegExp('Welcome @frontend/artcraft-website', 'gi')).length > 0).toBeTruthy();
});
});
@@ -0,0 +1,22 @@
import { Route, Routes } from "react-router-dom";
import Landing from "../pages/landing";
import Download from "../pages/download";
import Navbar from "../components/navbar";
import Footer from "../components/footer";
export function App() {
return (
<div className="relative">
<Navbar />
<Routes>
<Route path="/" element={<Landing />} />
<Route path="/download" element={<Download />} />
</Routes>
<Footer />
</div>
);
}
export default App;
@@ -0,0 +1,60 @@
import { Button } from "@storyteller/ui-button";
import {
faApple,
faWindows,
faLinux,
} from "@fortawesome/free-brands-svg-icons";
import { isMobile, isWindows, isMacOs } from "react-device-detect";
import { DOWNLOAD_LINKS } from "../../config/downloads";
interface DownloadButtonProps {
className?: string;
}
export const DownloadButton = ({ className = "" }: DownloadButtonProps) => {
// Helper function to detect Linux
const isLinux =
!isWindows &&
!isMacOs &&
navigator.platform.toLowerCase().includes("linux");
const handleDownload = () => {
const downloadLink = isWindows
? DOWNLOAD_LINKS.WINDOWS
: isMacOs
? DOWNLOAD_LINKS.MACOS
: isLinux
? DOWNLOAD_LINKS.LINUX
: null;
if (downloadLink) {
window.open(downloadLink, "_blank");
}
};
const getIcon = () => {
if (isMobile) return undefined;
if (isWindows) return faWindows;
if (isMacOs) return faApple;
if (isLinux) return faLinux;
return undefined;
};
return (
<Button
className={`rounded-xl px-8 py-4 text-md transition-all duration-300 shadow-lg hover:shadow-blue-500/25 ${className}`}
disabled={isMobile || (!isWindows && !isMacOs && !isLinux)}
icon={getIcon()}
onClick={handleDownload}
>
{isMobile
? "Download on desktop"
: isWindows
? "Download for Windows"
: isMacOs
? "Download for MacOS"
: isLinux
? "Download for Linux"
: "Not available on your device"}
</Button>
);
};
@@ -0,0 +1 @@
export * from "./download-button";

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