Compare commits

...
31 Commits
Author SHA1 Message Date
Brandon Thomas 6c14f35c9f new endpoint with build info 2025-05-25 11:04:08 -04:00
Brandon Thomas 921e58a308 build info as a crate 2025-05-24 21:23:14 -04:00
CrossPr0duct 65531dc63e Removed video VideoProcessor and timeline saving and save manager timeline work and ammo.js 2025-05-23 18:18:40 -04:00
CrossPr0duct 0b60835bbc Added some todos to move components.
need to fix some todos.
2025-05-23 16:52:10 -04:00
CrossPr0duct cca897d24d Remove the Wizard 2025-05-23 16:27:41 -04:00
CrossPr0duct ea76917264 Replace the button component with the shared one. 2025-05-23 16:15:13 -04:00
CrossPr0duct fc824a10ae Removing not needed files and components. 2025-05-23 14:34:23 -04:00
Michael Chung 0ee9b7c442 fix: remove DialogTitle from DialogEditText 2025-05-23 11:33:48 -04:00
bflatastic 17495276e7 Refactor GalleryModal refresh logic and add refresh button
- Simplified the refresh logic by extracting it into a separate `refreshGallery` function.
- Added a refresh button with a tooltip to allow users to manually refresh the gallery items.
- Updated the useEffect to call the new refresh function when the modal opens, ensuring the gallery is always up-to-date.
2025-05-23 20:47:29 +07:00
bflatastic 2d78fd5a6f library always refreshed on open 2025-05-23 20:39:40 +07:00
BFlat 98e513e917 gallery dnd code refactor (#630)
- cleaner code + reusable image drop function for later 2d image drop
implementation
2025-05-23 09:21:43 +07:00
ArEnScandMichael Chung 97eb1c65ca Fix cors allow all urls for now, since there isn't really a way to ha… (#628)
…ck the app lol.

Co-authored-by: Michael Chung <michaelchung@mac.home>
2025-05-22 18:43:59 -04:00
bflatastic ba82a438dd image not dropping at correct position fix 2025-05-23 02:11:54 +07:00
bflatastic de7b990ec5 bug fix: select mode library modal not closing on X click 2025-05-23 01:35:50 +07:00
BFlat 7b90c59675 library modal signals bug fix (#627)
- fixed weird behavior with library modal not opening
- image drag and drop more accurate position (production drag and drop
still doesnt work - looking into it)
2025-05-23 01:31:49 +07:00
Brandon Thomas 11f1a5f41a sora aspect ratios (#626) 2025-05-22 00:09:59 -04:00
BFlat b92cd58f81 Enhance GalleryModal UX with Filters, Drag-and-Drop, Pagination, and UI Improvements (#625)
**✨ Features & Enhancements:**
- GalleryModal Filters: Replaced tab-based navigation with a more
scalable filter system.
- Image Fit Toggle: Allows toggling image fit display mode for better
previewing.
- Pagination & Infinite Scroll: Improved item loading performance using
soft refresh and infinite scroll logic.
- Drag-and-drop setup for gallery items
- Prevented dragging for video items
- Added support for image drop in PageEditor (3D)
- "My Library" Label Updates
2025-05-21 23:29:06 +07:00
Brandon Thomas 914c26b4c6 classify gateway timeout 2025-05-21 11:04:32 -04:00
Brandon Thomas fe8939a0f6 incomplete runwayml implementation (pausing for now) (#624) 2025-05-20 23:39:34 -04:00
Tanish Manku c809ff88fa Add save/load support for character poses (#623) 2025-05-20 20:13:04 -04:00
bflatastic 86675568a8 Update modal components for improved drag functionality and styling
- Increased the height of the drag handle in AssetModal and GalleryModal for better usability.
- Adjusted z-index properties for elements within AssetModal to ensure proper layering.
- Enhanced the modal's drag behavior to allow overflow beyond viewport edges, improving user experience when repositioning.
2025-05-21 02:32:20 +07:00
bflatastic a8927cc78d Refactor expand button in modal component for improved styling and functionality
- Updated the ExpandButton component to accept size props for better responsiveness.
- Replaced SVG icons with FontAwesome icons for a more consistent design.
- Adjusted the GalleryModal layout to include a gap between buttons for enhanced usability.
2025-05-21 02:17:17 +07:00
bflatastic 0e8dd19ecf Enhance modal functionality with draggable support and expand button
- Added draggable functionality to the modal, allowing users to reposition it.
- Introduced an expand button to toggle full-screen mode for the modal.
- Updated modal backdrop to support interaction options and improved styling.
- Adjusted AssetModal and GalleryModal components to utilize new modal features.
2025-05-21 02:06:51 +07:00
bflatastic b32a2e79b9 promptbox height bug fix 2025-05-20 22:47:38 +07:00
bflatastic a662c5809d lightbox videos fix 2025-05-20 22:31:27 +07:00
Michael Chung 7498720674 Fixing the gallery modal to show videos and images. 2025-05-20 11:14:36 -04:00
ArEnScandMichael Chung c8a4490b52 Feature/add to scene video 2 (#621)
Co-authored-by: Michael Chung <michaelchung@michaels-mbp-2.home>
2025-05-20 09:22:46 -04:00
Brandon Thomas c4e30e6dc7 swap out the background (#620) 2025-05-19 23:01:33 -04:00
ArEnScandMichael Chung 12070f14ad Feature/add to scene video 2 (#619)
Adding videos to scene via signal.

---------

Co-authored-by: Michael Chung <michaelchung@michaels-mbp-2.home>
2025-05-19 02:19:31 -04:00
BFlat 77a103de06 gallery videos tab (#617)
- 16/9 ratio grid items
- lightbox for videos will only show download button
2025-05-19 10:58:34 +07:00
BFlat 82f5805f24 image-to-video generation page with background gallery and UI components (#616) 2025-05-19 09:51:52 +07:00
199 changed files with 7180 additions and 6580 deletions
Generated
+212 -9
View File
@@ -74,7 +74,7 @@ checksum = "b340e9cfa5b08690aae90fb61beb44e9b06f44fe3d0f93781aaa58cfba86245e"
dependencies = [
"actix-utils",
"actix-web",
"derive_more",
"derive_more 0.99.17",
"futures-util",
"log",
"once_cell",
@@ -97,7 +97,7 @@ dependencies = [
"brotli 6.0.0",
"bytes 1.10.0",
"bytestring",
"derive_more",
"derive_more 0.99.17",
"encoding_rs",
"flate2",
"futures-core",
@@ -140,7 +140,7 @@ dependencies = [
"actix-utils",
"actix-web",
"bytes 1.10.0",
"derive_more",
"derive_more 0.99.17",
"futures-core",
"futures-util",
"httparse",
@@ -253,7 +253,7 @@ dependencies = [
"bytestring",
"cfg-if 1.0.0",
"cookie 0.16.2",
"derive_more",
"derive_more 0.99.17",
"encoding_rs",
"futures-core",
"futures-util",
@@ -322,7 +322,7 @@ dependencies = [
"bytes 1.10.0",
"bytestring",
"csv",
"derive_more",
"derive_more 0.99.17",
"futures-core",
"futures-util",
"http 0.2.11",
@@ -809,6 +809,7 @@ dependencies = [
"anyhow",
"assertor",
"base64 0.22.1",
"build_metadata",
"bytes 1.10.0",
"chrono",
"const_format",
@@ -1384,6 +1385,15 @@ dependencies = [
"workspace-build-acceleration",
]
[[package]]
name = "bincode"
version = "1.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
dependencies = [
"serde",
]
[[package]]
name = "bindgen"
version = "0.70.1"
@@ -1663,6 +1673,79 @@ dependencies = [
"workspace-build-acceleration",
]
[[package]]
name = "build-info"
version = "0.0.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "288657edd15bfa5a7f30ca3b123c1af2c503eaf218e517fa10bc9063dbc2ad99"
dependencies = [
"bincode",
"build-info-common",
"build-info-proc",
]
[[package]]
name = "build-info-build"
version = "0.0.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3146483d5bc5081ec26f9c4e60232e770980d750b2802d6cc2563cded665cc73"
dependencies = [
"anyhow",
"base64 0.22.1",
"bincode",
"build-info-common",
"cargo_metadata",
"chrono",
"git2",
"glob",
"pretty_assertions",
"rustc_version 0.4.0",
"serde_json",
"zstd 0.13.1",
]
[[package]]
name = "build-info-common"
version = "0.0.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8524ad59f5958f37f95e66bf6c9a8fa8440c4f56c069247c44244434cfca3eb1"
dependencies = [
"chrono",
"derive_more 2.0.1",
"semver 1.0.23",
"serde",
]
[[package]]
name = "build-info-proc"
version = "0.0.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b58fb02636d968e8327d84a5a256df9704ac27a1eda98429c35dbe50a278f69"
dependencies = [
"anyhow",
"base64 0.22.1",
"bincode",
"build-info-common",
"chrono",
"num-bigint",
"num-traits",
"proc-macro-error2",
"proc-macro2",
"quote",
"serde_json",
"syn 2.0.87",
"zstd 0.13.1",
]
[[package]]
name = "build_metadata"
version = "0.0.1"
dependencies = [
"build-info",
"build-info-build",
"workspace-build-acceleration",
]
[[package]]
name = "built"
version = "0.7.6"
@@ -3287,6 +3370,27 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "derive_more"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678"
dependencies = [
"derive_more-impl",
]
[[package]]
name = "derive_more-impl"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.87",
"unicode-xid",
]
[[package]]
name = "des"
version = "0.7.0"
@@ -3381,6 +3485,12 @@ dependencies = [
"syn 2.0.87",
]
[[package]]
name = "diff"
version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8"
[[package]]
name = "digest"
version = "0.9.0"
@@ -5119,6 +5229,19 @@ dependencies = [
"winapi 0.3.9",
]
[[package]]
name = "git2"
version = "0.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2deb07a133b1520dc1a5690e9bd08950108873d7ed5de38dcc74d3b5ebffa110"
dependencies = [
"bitflags 2.5.0",
"libc",
"libgit2-sys",
"log",
"url",
]
[[package]]
name = "glib"
version = "0.18.5"
@@ -6496,6 +6619,16 @@ dependencies = [
"zeroize",
]
[[package]]
name = "jwt_light"
version = "0.0.1"
dependencies = [
"base64 0.22.1",
"chrono",
"serde_json",
"workspace-build-acceleration",
]
[[package]]
name = "k256"
version = "0.13.3"
@@ -6735,6 +6868,18 @@ dependencies = [
"cc",
]
[[package]]
name = "libgit2-sys"
version = "0.18.1+1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1dcb20f84ffcdd825c7a311ae347cce604a6f084a767dec4a4929829645290e"
dependencies = [
"cc",
"libc",
"libz-sys",
"pkg-config",
]
[[package]]
name = "libloading"
version = "0.7.4"
@@ -6783,6 +6928,18 @@ dependencies = [
"vcpkg",
]
[[package]]
name = "libz-sys"
version = "1.1.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b70e7a7df205e92a1a4cd9aaae7898dac0aa555503cc0a649494d0d60e7651d"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "limitation"
version = "0.1.1"
@@ -9074,6 +9231,16 @@ dependencies = [
"termtree",
]
[[package]]
name = "pretty_assertions"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d"
dependencies = [
"diff",
"yansi",
]
[[package]]
name = "prettyplease"
version = "0.2.25"
@@ -10301,6 +10468,36 @@ dependencies = [
"realfft",
]
[[package]]
name = "runwayml_client"
version = "0.0.1"
dependencies = [
"anyhow",
"base64 0.22.1",
"chrono",
"cookie 0.18.1",
"crockford",
"errors",
"filesys",
"hex-literal",
"idempotency",
"jwt_light",
"log",
"once_cell",
"rand 0.9.1",
"reqwest 0.12.15",
"serde",
"serde_derive",
"serde_json",
"sha3",
"subprocess_common",
"testing",
"thiserror 2.0.12",
"tokio",
"toml 0.8.22",
"workspace-build-acceleration",
]
[[package]]
name = "rust-embed"
version = "8.0.0"
@@ -10778,7 +10975,7 @@ checksum = "df320f1889ac4ba6bc0cdc9c9af7af4bd64bb927bccdf32d81140dc1f9be12fe"
dependencies = [
"bitflags 1.3.2",
"cssparser 0.27.2",
"derive_more",
"derive_more 0.99.17",
"fxhash",
"log",
"matches",
@@ -10798,7 +10995,7 @@ checksum = "4eb30575f3638fc8f6815f448d50cb1a2e255b0897985c8c59f4d37b72a07b06"
dependencies = [
"bitflags 2.5.0",
"cssparser 0.31.2",
"derive_more",
"derive_more 0.99.17",
"fxhash",
"log",
"new_debug_unreachable",
@@ -11749,7 +11946,7 @@ dependencies = [
"cookies",
"crockford",
"datetimes",
"derive_more",
"derive_more 0.99.17",
"diesel",
"dotenv",
"easyenv",
@@ -15278,7 +15475,7 @@ dependencies = [
"crossbeam-utils 0.8.20",
"crypto-common",
"deranged",
"derive_more",
"derive_more 0.99.17",
"digest 0.10.7",
"either",
"flate2",
@@ -15511,6 +15708,12 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "yansi"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
[[package]]
name = "yoke"
version = "0.7.5"
+5
View File
@@ -34,10 +34,12 @@ 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/runwayml_client",
"crates/lib/api_clients/storyteller_client",
"crates/lib/api_clients/thumbnail_generator",
"crates/lib/bootstrap",
"crates/lib/bucket_paths",
"crates/lib/build_metadata",
"crates/lib/caching/memory_caching",
"crates/lib/caching/memory_store",
"crates/lib/caching/redis_caching",
@@ -65,6 +67,7 @@ members = [
"crates/lib/idempotency",
"crates/lib/image_algorithms",
"crates/lib/jobs_common",
"crates/lib/jwt_light",
"crates/lib/logging",
"crates/lib/machine_learning/ml_models",
"crates/lib/machine_learning/ml_weights_registry",
@@ -183,6 +186,7 @@ toml = "0.8.22"
url = { version = "2.5.4", features = ["serde"] }
# Internal libs
build_metadata = { path = "crates/lib/build_metadata" }
crockford = { path = "crates/lib/data/crockford" }
enums = { path = "crates/_types/enums" }
enums_public = { path = "crates/_types/enums_public" }
@@ -191,6 +195,7 @@ fal_client = { path = "crates/lib/api_clients/fal_client" }
filesys = { path = "crates/lib/files/filesys" }
idempotency = { path = "crates/lib/idempotency" }
images = { path = "crates/lib/files/images" }
jwt_light = { path = "crates/lib/jwt_light" }
memory_caching = { path = "crates/lib/caching/memory_caching" }
memory_store = { path = "crates/lib/caching/memory_store" }
mimetypes = { path = "crates/lib/files/mimetypes" }
+1
View File
@@ -23,6 +23,7 @@ tauri-build = { version = "2.1.1", features = [] }
workspace-build-acceleration = { workspace = true }
# Internal
build_metadata = { workspace = true }
crockford = { workspace = true }
errors = { workspace = true }
fal_client = { workspace = true }
@@ -10,19 +10,12 @@
"log:default",
"upload:allow-download",
"upload:default",
"http:allow-fetch",
{
"identifier": "http:default",
"allow": [
{
"url": "https://*.storyteller.ai"
},
{
"url": "https://*.fakeyou.com"
}
],
"deny": [
{
"url": "https://private.tauri.app"
"url": "https://*"
}
]
},
@@ -0,0 +1,35 @@
use crate::core::state::os_platform::OsPlatform;
use base64::prelude::BASE64_STANDARD;
use base64::Engine;
use image::codecs::png::{CompressionType, FilterType, PngEncoder};
use image::ImageReader;
use log::info;
use serde_derive::Serialize;
use std::io::Cursor;
use chrono::{DateTime, Utc};
#[derive(Debug, Serialize)]
pub struct BuildInfoResponse {
pub build_timestamp: DateTime<Utc>,
pub git_commit_id: Option<String>,
pub git_commit_short_id: Option<String>,
pub git_commit_short_timestamp: Option<DateTime<Utc>>,
}
#[tauri::command]
pub fn get_build_info_command() -> Result<BuildInfoResponse, String> {
info!("get_build_info_command called...");
let build_timestamp = build_metadata::build_timestamp();
let git_commit_id = build_metadata::git_commit_id();
let git_commit_short_id = build_metadata::git_commit_short_id();
let git_commit_short_timestamp = build_metadata::git_commit_timestamp();
Ok(BuildInfoResponse {
build_timestamp,
git_commit_id: git_commit_id.map(|s| s.to_string()),
git_commit_short_id: git_commit_short_id.map(|s| s.to_string()),
git_commit_short_timestamp,
})
}
@@ -1,4 +1,5 @@
pub mod app_preferences;
pub mod command_response_wrapper;
pub mod flip_image;
pub mod get_build_info_command;
pub mod platform_info_command;
+2
View File
@@ -6,6 +6,7 @@ use tauri::Manager;
use crate::core::commands::app_preferences::get_app_preferences_command::get_app_preferences_command;
use crate::core::commands::app_preferences::update_app_preference_command::update_app_preferences_command;
use crate::core::commands::flip_image::flip_image;
use crate::core::commands::get_build_info_command::get_build_info_command;
use crate::core::commands::platform_info_command::platform_info_command;
use crate::core::state::app_preferences::app_preferences_manager::load_app_preferences_or_default;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
@@ -164,6 +165,7 @@ pub fn run() {
fal_kling_image_to_video_command,
flip_image,
get_app_preferences_command,
get_build_info_command,
get_fal_api_key_command,
open_sora_login_command,
platform_info_command,
@@ -42,7 +42,9 @@ const SORA_IMAGE_UPLOAD_TIMEOUT: Duration = Duration::from_millis(1000 * 30); //
const SORA_IMAGE_REMIX_TIMEOUT: Duration = Duration::from_millis(1000 * 30); // 30 seconds
#[derive(Deserialize)]
const DEFAULT_ASPECT_RATIO : SoraAspectRatio = SoraAspectRatio::Square;
#[derive(Deserialize, Debug)]
pub struct SoraImageRemixCommand {
/// Image media file; the engine or canvas snapshot (screenshot).
pub snapshot_media_token: MediaFileToken,
@@ -57,6 +59,17 @@ pub struct SoraImageRemixCommand {
pub maybe_additional_images: Option<Vec<MediaFileToken>>,
pub maybe_number_of_samples: Option<u32>,
/// Aspect ratio.
pub aspect_ratio: Option<SoraAspectRatio>,
}
#[derive(Deserialize, Debug, Copy, Clone)]
#[serde(rename_all = "snake_case")]
pub enum SoraAspectRatio {
Square,
Wide,
Tall,
}
#[derive(Serialize, Debug)]
@@ -74,7 +87,6 @@ pub enum SoraImageRemixErrorType {
SoraIsHavingProblems,
}
#[tauri::command]
pub async fn sora_image_remix_command(
app: AppHandle,
@@ -84,8 +96,8 @@ pub async fn sora_image_remix_command(
sora_task_queue: State<'_, SoraTaskQueue>,
) -> CommandResult<(), SoraImageRemixErrorType, ()> {
info!("image_generation_command called; scene media token: {:?}, additional images: {:?}",
request.snapshot_media_token, request.maybe_additional_images);
info!("image_generation_command called; scene media token: {:?}, additional images: {:?}, full request: {:?}",
&request.snapshot_media_token, &request.maybe_additional_images, &request);
// TODO(bt,2025-04-24): Better error messages to caller
@@ -240,6 +252,14 @@ pub async fn generate_image(
sora_media_tokens.push(response.id);
}
let aspect_ratio = request.aspect_ratio.unwrap_or(DEFAULT_ASPECT_RATIO);
let aspect_ratio = match aspect_ratio {
SoraAspectRatio::Square => ImageSize::Square,
SoraAspectRatio::Wide => ImageSize::Wide,
SoraAspectRatio::Tall => ImageSize::Tall,
};
info!("Calling image generation...");
// TODO(bt,2025-04-21): Download media tokens.
@@ -250,7 +270,7 @@ pub async fn generate_image(
image_remix_with_session_auto_renew(ImageRemixAutoRenewRequest {
prompt: request.prompt.to_string(),
num_images: NumImages::One,
image_size: ImageSize::Square,
image_size: aspect_ratio,
sora_media_tokens: sora_media_tokens.clone(),
credentials: &creds,
request_timeout: Some(SORA_IMAGE_REMIX_TIMEOUT),
@@ -0,0 +1,8 @@
{
"build": {
"frontendDist": "../../../frontend/apps/editor3d/dist",
"beforeDevCommand": "echo 'no dev'",
"devUrl": "http://localhost:5173",
"beforeBuildCommand": "npx nx run editor3d:build"
}
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"default":{"identifier":"default","description":"enables the default permissions","local":true,"windows":["main"],"permissions":["core:default","log:default"]}}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-1
View File
@@ -9,7 +9,6 @@ authors = [
publish = false
[lib]
# NB: Public "errors" crate is name-squatted, so we're fine to use this internally.
name = "actix_cors_configs"
path = "src/lib.rs"
@@ -1,6 +1,6 @@
[package]
name = "openai_sora_client"
edition = "2021"
edition = "2024"
version = "0.0.1"
publish = false
@@ -57,7 +57,13 @@ Message: {
502 => {
return SoraError::BadGateway(message);
}
504 => {
if message.contains("cloudflare") || message.contains("Cloudflare") {
return SoraError::CloudFlareTimeout(message);
}
}
524 => {
// NB: 524 is a cloudflare specific gateway timeout error with slightly different semantics than 504.
if message.contains("cloudflare") || message.contains("Cloudflare") {
return SoraError::CloudFlareTimeout(message);
}
@@ -0,0 +1,46 @@
[package]
name = "runwayml_client"
edition = "2021"
version = "0.0.1"
publish = false
[package.metadata.cargo-udeps.ignore]
normal = ["workspace-build-acceleration"]
[lib]
name = "runwayml_client"
path = "src/lib.rs"
[dependencies]
# Workspace hack (faster compile times)
workspace-build-acceleration = { workspace = true }
# Internal
crockford = { path = "../../data/crockford" }
errors = { path = "../../errors" }
idempotency = { path = "../../idempotency" }
jwt_light = { workspace = true }
subprocess_common = { path = "../../subprocess_common" }
# External
anyhow = { workspace = true }
base64 = { workspace = true }
chrono = { workspace = true}
cookie = { workspace = true }
hex-literal = { workspace = true }
log = { workspace = true }
once_cell= { workspace = true }
rand = { workspace = true }
reqwest = { workspace = true, features = ["multipart", "gzip"] }
serde = { workspace = true }
serde_derive = { workspace = true }
serde_json = { workspace = true }
sha3 = { workspace = true }
thiserror = { workspace = true }
tokio = { version = "1.38.0", features = ["macros"] }
toml = { workspace = true }
[dev-dependencies]
testing = { path = "../../testing" }
filesys = { path = "../../files/filesys" }
@@ -0,0 +1,16 @@
use crate::creds::jwt_bearer_token::JwtBearerToken;
use crate::error::api_error::ApiError;
#[derive(Clone)]
pub struct Credentials {
pub jwt_bearer_token: Option<JwtBearerToken>,
}
impl Credentials {
pub fn from_jwt_str(jwt_str: &str) -> Result<Self, ApiError> {
let jwt_bearer_token = JwtBearerToken::new(jwt_str.to_string())?;
Ok(Self {
jwt_bearer_token: Some(jwt_bearer_token),
})
}
}
@@ -0,0 +1,39 @@
use crate::creds::jwt_claims::JwtClaims;
use crate::error::api_error::ApiError;
use jwt_light::parse_jwt_claims_trait::ParseJwtClaims;
/// Sora bearer tokens are JWT tokens that can be minted with a valid cookie/session.
#[derive(Clone)]
pub struct JwtBearerToken {
raw_jwt: String,
jwt_claims: JwtClaims,
}
impl JwtBearerToken {
pub fn new(raw_jwt: String) -> Result<Self, ApiError> {
let jwt_claims = JwtClaims::parse_claims(&raw_jwt)?;
Ok(Self {
raw_jwt,
jwt_claims,
})
}
pub fn raw_jwt_str(&self) -> &str {
&self.raw_jwt
}
pub fn as_bytes(&self) -> &[u8] {
self.raw_jwt.as_bytes()
}
pub fn jwt_claims(&self) -> &JwtClaims {
&self.jwt_claims
}
pub fn to_authorization_header_value(&self) -> String {
match self.raw_jwt.get(0..6) {
Some("bearer") | Some("Bearer") => self.raw_jwt.clone(),
_ => "Bearer ".to_owned() + &self.raw_jwt,
}
}
}
@@ -0,0 +1,75 @@
use base64::Engine;
use chrono::{DateTime, Utc};
use jwt_light::common_claims::CommonClaims;
use jwt_light::error::JwtError;
use jwt_light::parse_jwt_claims_trait::ParseJwtClaims;
use serde_json::{Map, Value};
#[derive(Clone, Debug)]
pub struct JwtClaims {
/// From the "iat" field
pub created: DateTime<Utc>,
/// From the "exp" field
pub expiration: DateTime<Utc>,
/// From the "id" field
pub id: String,
/// From the "email" field
pub email: String,
/// From the "sso" field
pub sso: bool,
}
impl ParseJwtClaims for JwtClaims {
fn extract_claims(common_claims: CommonClaims, extra_claims: Map<String, Value>) -> Result<Self, JwtError> {
let id = extra_claims.get("id")
.ok_or_else(|| JwtError::CustomClaimsFieldError("no id claim".to_string()))?
.as_i64()
.ok_or_else(|| JwtError::CustomClaimsFieldError("id is not a string".to_string()))?
.to_string();
let email = extra_claims.get("email")
.ok_or_else(|| JwtError::CustomClaimsFieldError("no email claim".to_string()))?
.as_str()
.ok_or_else(|| JwtError::CustomClaimsFieldError("email is not a string".to_string()))?
.to_string();
let sso = extra_claims.get("sso")
.map(|val| val.as_bool())
.flatten()
.unwrap_or(false);
Ok(Self {
created: common_claims.created,
expiration: common_claims.expiration,
id,
email,
sso,
})
}
}
#[cfg(test)]
mod tests {
use crate::creds::jwt_claims::JwtClaims;
use jwt_light::parse_jwt_claims_trait::ParseJwtClaims;
#[test]
fn test_token() {
/*{
"id": 23129405,
"email": "echelon@gmail.com",
"exp": 1750201256.49,
"iat": 1747609256.49,
"sso": false
}*/
let jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MjMxMjk0MDUsImVtYWlsIjoiZWNoZWxvbkBnbWFpbC5jb20iLCJleHAiOjE3NTAyMDEyNTYuNDksImlhdCI6MTc0NzYwOTI1Ni40OSwic3NvIjpmYWxzZX0.UxbJozIiHSApqI8_Vl7o2d7q7CzqpXIzsZoazCtY75s";
let claims = JwtClaims::parse_claims(jwt).unwrap();
assert_eq!(claims.id, "23129405");
assert_eq!(claims.email, "echelon@gmail.com");
assert_eq!(claims.sso, false);
assert_eq!(claims.created.timestamp(), 1747609256);
assert_eq!(claims.expiration.timestamp(), 1750201256);
}
}
@@ -0,0 +1,3 @@
pub mod credentials;
pub mod jwt_bearer_token;
pub mod jwt_claims;
@@ -0,0 +1,39 @@
use jwt_light::error::JwtError;
use reqwest::StatusCode;
use std::error::Error;
use std::fmt::{Display, Formatter};
#[derive(Debug)]
pub enum ApiError {
/// Inability to parse JWT.
JwtError(JwtError),
/// An error that occurred as or after the request was sent.
ReqwestError(reqwest::Error),
/// Some other failure response.
UncategorizedBadResponse{
status_code: StatusCode,
message : String,
}
}
impl Display for ApiError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
ApiError::JwtError(err) => write!(f, "JwtError: {}", err),
ApiError::ReqwestError(err) => write!(f, "ReqwestError: {}", err),
ApiError::UncategorizedBadResponse { status_code, message } => {
write!(f, "UncategorizedBadResponse: {:?} : {}", status_code, message)
}
}
}
}
impl Error for ApiError {}
impl From<JwtError> for ApiError {
fn from(err: JwtError) -> Self {
ApiError::JwtError(err)
}
}
@@ -0,0 +1,17 @@
use crate::error::api_error::ApiError;
use crate::error::runwayml_error::RunwayMlError;
/// This assumes the request failed and returned a non-200.
/// The caller should check.
pub async fn classify_http_error_response(response: reqwest::Response) -> RunwayMlError {
let status = response.status();
let message = match response.text().await {
Ok(text) => text,
Err(err) => return RunwayMlError::Api(ApiError::ReqwestError(err)),
};
// TODO: Handle various error messages from the API.
//anyhow::anyhow!("Upload failed with status {}: {}", status, message))
RunwayMlError::Api(ApiError::UncategorizedBadResponse { status_code: status, message })
}
@@ -0,0 +1,22 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
#[derive(Debug)]
pub enum ClientError {
/// No JWT bearer token in the credentials
NoJwtBearerToken,
/// An error that occurred clientside before the request was sent.
ReqwestError(reqwest::Error),
}
impl Display for ClientError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
ClientError::NoJwtBearerToken => write!(f, "No JWT bearer token in the credentials"),
ClientError::ReqwestError(err) => write!(f, "Reqwest error: {}", err),
}
}
}
impl Error for ClientError {}
@@ -0,0 +1,4 @@
pub mod api_error;
pub mod classify_http_error_response;
pub mod client_error;
pub mod runwayml_error;
@@ -0,0 +1,33 @@
use crate::error::api_error::ApiError;
use crate::error::client_error::ClientError;
use std::error::Error;
use std::fmt::{Display, Formatter};
#[derive(Debug)]
pub enum RunwayMlError {
Api(ApiError),
Client(ClientError),
}
impl Display for RunwayMlError{
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::Api(err) => write!(f, "ApiError: {}", err),
Self::Client(err) => write!(f, "ClientError: {}", err),
}
}
}
impl Error for RunwayMlError {}
impl From<ApiError> for RunwayMlError {
fn from(err: ApiError) -> Self {
RunwayMlError::Api(err)
}
}
impl From<ClientError> for RunwayMlError {
fn from(err: ClientError) -> Self {
RunwayMlError::Client(err)
}
}
@@ -0,0 +1,5 @@
/// TODO DOCS
pub mod creds;
pub mod error;
pub mod requests;
@@ -0,0 +1,158 @@
use crate::creds::credentials::Credentials;
use crate::error::api_error::ApiError;
use crate::error::classify_http_error_response::classify_http_error_response;
use crate::error::client_error::ClientError;
use crate::error::runwayml_error::RunwayMlError;
use log::{debug, error, warn};
use reqwest::Client;
use serde_derive::{Deserialize, Serialize};
const GENERATE_URL: &str = "https://api.runwayml.com/v1/tasks";
// TODO: This endpoint is not implemented yet.
// TODO: This endpoint is not implemented yet.
// TODO: This endpoint is not implemented yet.
#[derive(Serialize)]
struct TaskRequest {
/// eg. "gen4"
#[serde(rename = "taskType")]
task_type: String,
/// eg. false
internal: bool,
/// inner request payload
options: TaskRequestOptions,
/// eg. 23129405
#[serde(rename = "asTeamId")]
as_team_id: u64,
/// eg. fc66957b-2651-4362-aca0-5468e4ee61b0
#[serde(rename = "sessionId")]
session_id: String,
}
#[derive(Serialize)]
struct TaskRequestOptions {
/// eg. Gen-4 3438043024
name: String,
/// eg. 3438043024
seed: u64,
/// Probably whether the request is free (exploreMode = unlimited queue)
#[serde(rename = "exploreMode")]
explore_mode: bool,
/// eg. 5
seconds: u8,
/// eg. false
watermark: bool,
/// This can be blank / empty string!
text_prompt: String,
/// eg. "i2v"
route: String,
/// A URL, eg. https://d2jqrm6oza8nb6.cloudfront.net/datasets/586d77ff-b33b-4100-b4d3-c04f8b046b00.jpg?_jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJrZXlIYXNoIjoiNTJhMTY0N2RkZjFjMGU3NCIsImJ1Y2tldCI6InJ1bndheS1kYXRhc2V0cyIsInN0YWdlIjoicHJvZCIsImV4cCI6MTc0Nzc4NTYwMH0.N_TztqlU2pzPegOq7e8tn_6zb16OLmDmerlTtfr_-FI
init_image: String,
/// eg. 1280
width: u16,
/// eg. 720
height: u16,
/// eg. b5c42f29-d63d-4124-88c0-019ab1299829
#[serde(rename = "assetGroupId")]
asset_group_id: String,
}
pub async fn gen4_image_to_video(
credentials: &Credentials,
) -> Result<(), RunwayMlError> {
let jwt = match credentials.jwt_bearer_token.as_ref() {
Some(token) => token,
None => {
error!("Failed to generate bearer token. No JWT bearer token found.");
return Err(ClientError::NoJwtBearerToken.into());
}
};
let endpoint_request = TaskRequest {
task_type: "gen4".to_string(),
internal: false,
options: TaskRequestOptions {
name: "Gen-4 3438043025".to_string(),
seed: 3438043025,
explore_mode: true,
seconds: 5,
watermark: false,
text_prompt: "".to_string(),
route: "i2v".to_string(),
init_image: "https://d2jqrm6oza8nb6.cloudfront.net/datasets/586d77ff-b33b-4100-b4d3-c04f8b046b00.jpg?_jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJrZXlIYXNoIjoiNTJhMTY0N2RkZjFjMGU3NCIsImJ1Y2tldCI6InJ1bndheS1kYXRhc2V0cyIsInN0YWdlIjoicHJvZCIsImV4cCI6MTc0Nzc4NTYwMH0.N_TztqlU2pzPegOq7e8tn_6zb16OLmDmerlTtfr_-FI".to_string(),
width: 1280,
height: 720,
asset_group_id: "b5c42f29-d63d-4124-88c0-019ab1299829".to_string(),
},
as_team_id: 23129405,
session_id: "fc66957b-2651-4362-aca0-5468e4ee61b0".to_string(),
};
let client = Client::builder()
.gzip(true)
.build()
.map_err(|err| {
error!("Failed to create HTTP client: {}", err);
RunwayMlError::Client(ClientError::ReqwestError(err))
})?;
let http_request = client.post(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", "application/json")
.header("Accept-Encoding", "gzip, deflate, br")
.header("Accept-Language", "en-US,en;q=0.5")
.header("Content-Type", "application/json")
.header("Authorization", jwt.to_authorization_header_value());
let http_request = http_request.json(&endpoint_request)
.build()
.map_err(|err| RunwayMlError::Client(ClientError::ReqwestError(err)))?;
let response = client.execute(http_request)
.await
.map_err(|err| RunwayMlError::Api(ApiError::ReqwestError(err)))?;
let status = response.status();
if !status.is_success() {
warn!("Gen4 image to video failure. Status: {:?}", status);
let error = classify_http_error_response(response).await;
return Err(error);
}
debug!("Gen4 I2V returned a 200.");
let response_body = &response
.text()
.await
.map_err(|err| RunwayMlError::Api(ApiError::ReqwestError(err)))?;
debug!("Response: {}", response_body);
//if &response_body == "null" {
// error!("Failed to generate bearer token. Response was `null`.");
// return Err(SoraError::FailedToGenerateBearer)
//}
//let auth_response: SoraAuthResponse = serde_json::from_str(&response_body)?;
// TODO: This endpoint is not implemented yet.
// TODO: This endpoint is not implemented yet.
// TODO: This endpoint is not implemented yet.
todo!("This is not yet implemented")
}
#[cfg(test)]
mod tests {
use errors::AnyhowResult;
#[tokio::test]
#[ignore]
async fn send_test_request() -> AnyhowResult<()> {
Ok(())
}
}
@@ -0,0 +1 @@
pub mod gen4_image_to_video;
+34
View File
@@ -0,0 +1,34 @@
[package]
name = "build_metadata"
resolver = "2" # NB: Required by `build-info`
edition = "2024"
version = "0.0.1"
authors = [
"Brandon Thomas <bt@brand.io>",
"Brandon Thomas <echelon@gmail.com>",
]
publish = false
[lib]
name = "build_metadata"
path = "src/lib.rs"
[package.metadata.cargo-udeps.ignore]
normal = ["workspace-build-acceleration"]
[dependencies]
# Workspace hack (faster compile times)
workspace-build-acceleration = { workspace = true }
# Internal
# (None yet)
# External
build-info = "0.0.40"
[dev-dependencies]
# None yet
[build-dependencies]
build-info-build = "0.0.40"
+9
View File
@@ -0,0 +1,9 @@
fn main() {
// Tell Cargo that if the given file changes, to rerun this build script.
// TODO: Use this to test the GIT_SHA, etc. files that CI dumps into the working directory
// println!("cargo::rerun-if-changed=Cargo.toml");
// Make build info available in the main crate sources.
build_info_build::build_script();
}
+43
View File
@@ -0,0 +1,43 @@
use std::ops::Deref;
use build_info::chrono::{DateTime, Utc};
build_info::build_info!(fn version);
pub fn build_timestamp() -> DateTime<Utc> {
version().timestamp
}
pub fn git_commit_id() -> Option<&'static str> {
version().version_control
.as_ref()
.map(|vc| vc.git())
.flatten()
.map(|git| git.commit_id.deref())
}
pub fn git_commit_short_id() -> Option<&'static str> {
version().version_control
.as_ref()
.map(|vc| vc.git())
.flatten()
.map(|git| git.commit_short_id.deref())
}
pub fn git_commit_timestamp() -> Option<DateTime<Utc>> {
version().version_control
.as_ref()
.map(|vc| vc.git())
.flatten()
.map(|git| git.commit_timestamp)
}
#[cfg(test)]
mod tests {
use crate::git_commit_id;
#[test]
#[ignore] // Run manually for testing, not in CI
fn test_git_commit_id() {
assert_eq!(git_commit_id(), None);
}
}
+4 -4
View File
@@ -17,13 +17,13 @@ path = "src/lib.rs"
normal = ["workspace-build-acceleration"]
[dependencies]
# cargo-hakari workspace hack (faster compile times)
workspace-build-acceleration = { workspace = true }
# NB: This is one of the most central crates in the monorepo and everything depends on it.
# Consider the dependencies here wisely.
# Ideally we have almost nothing as a dependency of this crate.
anyhow = "1.0.86"
# Workspace hack (faster compile times)
workspace-build-acceleration = { version = "0.1", path = "../../workspace-build-acceleration" }
anyhow = { workspace = true }
[dev-dependencies]
# None yet
+28
View File
@@ -0,0 +1,28 @@
[package]
name = "jwt_light"
edition = "2024"
version = "0.0.1"
publish = false
[package.metadata.cargo-udeps.ignore]
normal = ["workspace-build-acceleration"]
[lib]
name = "jwt_light"
path = "src/lib.rs"
[dependencies]
# Workspace hack (faster compile times)
workspace-build-acceleration = { workspace = true }
# Internal
# (None)
# External
base64 = { workspace = true }
chrono = { workspace = true}
serde_json = { workspace = true }
[dev-dependencies]
# (None)
+109
View File
@@ -0,0 +1,109 @@
use crate::error::JwtError;
use crate::utils::json_number_to_datetime::json_number_to_datetime;
use chrono::{DateTime, Utc};
use serde_json::Value;
pub struct CommonClaims {
/// From the "iat" field
pub created: DateTime<Utc>,
/// From the "exp" field
pub expiration: DateTime<Utc>,
}
impl CommonClaims {
pub fn from_json(raw_json_claims: &str) -> Result<Self, JwtError> {
let claims : serde_json::Map<String, Value> = serde_json::from_str(&raw_json_claims)
.map_err(|err| JwtError::CommonFieldError(
format!("failure to parse claims json: {:?}", err)))?;
// NB: Some JWTs have "iat" and "exp" as integer numbers, some have them as floats
let iat = {
let iat = claims.get("iat")
.map(|val| val.as_number())
.flatten()
.ok_or_else(|| JwtError::CommonFieldError("no iat claim".to_string()))?;
json_number_to_datetime(iat, "iat")?
};
let exp = {
let exp = claims.get("exp")
.map(|val| val.as_number())
.flatten()
.ok_or_else(|| JwtError::CommonFieldError("no exp claim".to_string()))?;
json_number_to_datetime(exp, "exp")?
};
Ok(Self {
created: iat,
expiration: exp,
})
}
}
#[cfg(test)]
mod tests {
use crate::common_claims::CommonClaims;
use crate::utils::raw_jwt_to_raw_json::raw_jwt_to_raw_json;
use chrono::DateTime;
#[test]
fn test_runwayml() {
let jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MjMxMjk0MDUsImVtYWlsIjoiZWNoZWxvbkBnbWFpbC5jb20iLCJleHAiOjE3NTAyMDEyNTYuNDksImlhdCI6MTc0NzYwOTI1Ni40OSwic3NvIjpmYWxzZX0.UxbJozIiHSApqI8_Vl7o2d7q7CzqpXIzsZoazCtY75s";
let json = raw_jwt_to_raw_json(jwt).unwrap();
let claims = CommonClaims::from_json(&json).unwrap();
// jwt payload:
// {
// "id": 23129405,
// "email": "echelon@gmail.com",
// "exp": 1750201256.49,
// "iat": 1747609256.49,
// "sso": false
// }
assert_eq!(claims.created, DateTime::from_timestamp(1747609256, 0).unwrap());
assert_eq!(claims.expiration, DateTime::from_timestamp(1750201256, 0).unwrap());
}
#[test]
fn test_sora() {
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";
let json = raw_jwt_to_raw_json(token).unwrap();
// 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 claims = CommonClaims::from_json(&json).unwrap();
assert_eq!(claims.created, DateTime::from_timestamp(1744936471, 0).unwrap());
assert_eq!(claims.expiration, DateTime::from_timestamp(1745800472, 0).unwrap());
}
}
+51
View File
@@ -0,0 +1,51 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
#[derive(Debug)]
pub enum JwtError {
/// Generic error parsing the JWT
ParseError(String),
/// Error parsing the JWT JSON payload specifically
JsonParseError(serde_json::Error),
/// Error decoding the base64 payload
Base64DecodeError(base64::DecodeError),
/// Error decoding from UTF-8 (should be exceedingly rare)
Utf8Error(std::string::FromUtf8Error),
/// Error parsing a common field (iat, exp, etc.)
CommonFieldError(String),
/// Error parsing a custom claims field.
CustomClaimsFieldError(String),
}
impl Error for JwtError {}
impl Display for JwtError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::ParseError(reason) => { write!(f, "JwtError::ParseError : {}", reason) }
Self::JsonParseError(err) => { write!(f, "JwtError::JsonParseError : {:?}", err) }
Self::Base64DecodeError(err) => { write!(f, "JwtError::Base64DecodeError : {:?}", err) }
Self::Utf8Error(err) => { write!(f, "JwtError::Utf8Error : {:?}", err) }
Self::CommonFieldError(reason) => { write!(f, "JwtError::CommonFieldError : {}", reason) }
Self::CustomClaimsFieldError(reason) => { write!(f, "JwtError::CustomClaimsFieldError : {}", reason) }
}
}
}
impl From<serde_json::Error> for JwtError {
fn from(err: serde_json::Error) -> Self {
JwtError::JsonParseError(err)
}
}
impl From<base64::DecodeError> for JwtError {
fn from(err: base64::DecodeError) -> Self {
JwtError::Base64DecodeError(err)
}
}
impl From<std::string::FromUtf8Error> for JwtError {
fn from(err: std::string::FromUtf8Error) -> Self {
JwtError::Utf8Error(err)
}
}
+12
View File
@@ -0,0 +1,12 @@
//! Lightweight JWT library for Rust.
//! No cryptographic verification, no OpenSSL, etc.
//!
//! DO NOT USE THIS FOR OUR OWN JWT VERIFICATION !!!
//! It's okay to parse external vendors since we're not validating, but
//! do not ever use this to secure our own server.
pub (crate) mod utils;
pub mod common_claims;
pub mod error;
pub mod parse_jwt_claims_trait;
@@ -0,0 +1,27 @@
use crate::common_claims::CommonClaims;
use crate::error::JwtError;
use crate::utils::decode_base64::decode_base64;
use crate::utils::split_components::split_components;
use serde_json::Value;
/// Trait for simple JWT claims parsing
pub trait ParseJwtClaims : Sized {
/// Parse any special fields relevant to the struct
/// Implement this method to complete the parsing.
fn extract_claims(
common_claims: CommonClaims,
extra_claims: serde_json::Map<String, Value>,
) -> Result<Self, JwtError>;
fn parse_claims(raw_jwt: &str) -> Result<Self, JwtError> {
let [_header_str, claims_str, _signature_str] = split_components(raw_jwt)?;
let raw_json = decode_base64(claims_str)?;
let common_claims = CommonClaims::from_json(&raw_json)?;
let extra_claims : serde_json::Map<String, Value> = serde_json::from_str(&raw_json)?;
Self::extract_claims(common_claims, extra_claims)
}
}
@@ -0,0 +1,9 @@
use crate::error::JwtError;
use base64::prelude::BASE64_URL_SAFE_NO_PAD;
use base64::Engine;
pub fn decode_base64(raw: &str) -> Result<String, JwtError> {
let json_bytes = BASE64_URL_SAFE_NO_PAD.decode(raw)?;
let json_str = String::from_utf8(json_bytes.clone())?;
Ok(json_str)
}
@@ -0,0 +1,30 @@
use crate::error::JwtError;
use chrono::{DateTime, Utc};
use serde_json::Number;
// NB: Some JWTs have "iat" and "exp" as integer numbers, some have them as floats
pub (crate) fn json_number_to_datetime(timestamp: &Number, field_name: &str) -> Result<DateTime<Utc>, JwtError> {
let result;
if timestamp.is_i64() {
// Integer case
let timestamp = timestamp.as_i64()
.ok_or_else(|| JwtError::CommonFieldError(format!("{} is not an integer", field_name)))?;
result = DateTime::from_timestamp(timestamp, 0)
.ok_or_else(|| JwtError::CommonFieldError(format!("{} is not a valid timestamp", field_name)))?;
} else if timestamp.is_f64() {
// Float case
let timestamp = timestamp.as_f64()
.ok_or_else(|| JwtError::CommonFieldError(format!("{} is not an integer", field_name)))?;
let timestamp = timestamp.round() as i64;
result = DateTime::from_timestamp(timestamp, 0)
.ok_or_else(|| JwtError::CommonFieldError(format!("{} is not a valid timestamp", field_name)))?;
} else {
return Err(JwtError::CommonFieldError(format!("{} is not a number", field_name)));
}
Ok(result)
}
+4
View File
@@ -0,0 +1,4 @@
pub (crate) mod decode_base64;
pub (crate) mod json_number_to_datetime;
pub (crate) mod raw_jwt_to_raw_json;
pub (crate) mod split_components;
@@ -0,0 +1,9 @@
use crate::error::JwtError;
use crate::utils::decode_base64::decode_base64;
use crate::utils::split_components::split_components;
pub (crate) fn raw_jwt_to_raw_json(raw_jwt: &str) -> Result<String, JwtError> {
let [_header_str, claims_str, _signature_str] = split_components(raw_jwt)?;
let raw_json = decode_base64(claims_str)?;
Ok(raw_json)
}
@@ -0,0 +1,25 @@
use crate::error::JwtError;
const SEPARATOR: &str = ".";
pub fn split_components(token: &str) -> Result<[&str; 3], JwtError> {
let mut components = token.split(SEPARATOR);
let header = components.next()
.ok_or_else(|| jwt_error("no header component"))?;
let claims = components.next()
.ok_or_else(|| jwt_error("no claims component"))?;
let signature = components.next()
.ok_or_else(|| jwt_error("no signature component"))?;
if components.next().is_some() {
return Err(jwt_error("too many components"));
}
Ok([header, claims, signature])
}
fn jwt_error(reason: &str) -> JwtError {
JwtError::ParseError(reason.to_string())
}
+3 -3
View File
@@ -16,12 +16,12 @@ path = "src/lib.rs"
normal = ["workspace-build-acceleration"]
[dependencies]
# cargo-hakari workspace hack (faster compile times)
workspace-build-acceleration = { workspace = true }
# Internal
# (None yet)
# Workspace hack (faster compile times)
workspace-build-acceleration = { version = "0.1", path = "../../workspace-build-acceleration" }
# External
# (None yet)
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 320 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 564 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 452 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 337 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 446 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 299 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 396 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 521 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 396 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 316 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

@@ -1,5 +1,5 @@
import { twMerge } from "tailwind-merge";
// TODO: Move to shared component
interface BadgeProps {
label: string;
color?: string;
@@ -1,20 +0,0 @@
import { Canvas, Meta } from '@storybook/blocks';
import * as ButtonStories from './Button.stories';
<Meta of={ButtonStories} />
# Button
A button denotes a possible action to be taken on behalf of the user.
There are three types of Button: primary, secondary, action.
<Canvas of={ButtonStories.Primary} />
<Canvas of={ButtonStories.Secondary} />
<Canvas of={ButtonStories.Action} />
It can be shown with an optional icon. If "iconFlip" is true, the icon shows after the "children", if false it shows before.
<Canvas of={ButtonStories.WithIconFlip} />
<Canvas of={ButtonStories.WithIconNoFlip} />
@@ -1,46 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Button } from "./Button";
import { withActions } from "@storybook/addon-actions/decorator";
import { faAngleLeft } from "@fortawesome/pro-solid-svg-icons";
const meta: Meta<typeof Button> = {
component: Button,
parameters: {
actions: {
handles: ["click"],
},
},
decorators: [withActions],
};
type Story = StoryObj<typeof Button>;
export const Primary: Story = {
render: () => <Button variant="primary">Primary</Button>,
};
export const Secondary: Story = {
render: () => <Button variant="secondary">Secondary</Button>,
};
export const Action: Story = {
render: () => <Button variant="action">Action</Button>,
};
export const WithIconFlip: Story = {
render: () => (
<Button variant="secondary" iconFlip icon={faAngleLeft}>
Icon Flip
</Button>
),
};
export const WithIconNoFlip: Story = {
render: () => (
<Button variant="secondary" icon={faAngleLeft}>
Icon
</Button>
),
};
export default meta;
@@ -1,107 +0,0 @@
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { ButtonHTMLAttributes } from "react";
import { faSpinnerThird } from "@fortawesome/pro-solid-svg-icons";
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
icon?: IconDefinition;
iconClassName?: string;
iconFlip?: boolean;
htmlFor?: string;
variant?: "primary" | "secondary" | "action";
loading?: boolean;
}
export const Button = ({
icon,
iconClassName,
children,
className: propsClassName,
htmlFor,
variant: propsVariant = "primary",
disabled,
iconFlip = false,
loading,
...rest
}: ButtonProps) => {
function getVariantClassNames(variant: string) {
switch (variant) {
case "secondary": {
return "bg-brand-secondary hover:bg-brand-secondary-700 text-white focus-visible:outline-brand-secondary";
}
case "action": {
return "bg-action hover:bg-action-900 text-white focus-visible:outline-action";
}
case "primary":
default: {
return "bg-brand-primary hover:bg-brand-primary-400 text-white focus-visible:outline-brand-primary-600";
}
}
}
const disabledClass = twMerge(
disabled || loading ? "opacity-50 pointer-events-none" : "",
);
const className = twMerge(
"text-sm font-medium rounded-lg px-3 py-2 border border-transparent shadow-sm focus-visible:outline focus-visible:outline-0 focus-visible:outline-offset-0 transition-all duration-150 flex gap-2 items-center justify-center",
getVariantClassNames(propsVariant),
propsClassName,
disabledClass,
);
// const ButtonType = htmlFor ? "label" : "button";
if (htmlFor) {
return (
<label className={className} htmlFor={htmlFor} style={rest.style}>
{loading && !iconFlip ? (
<FontAwesomeIcon icon={faSpinnerThird} className="animate-spin" />
) : (
<>
{icon && !iconFlip ? (
<FontAwesomeIcon icon={icon} className={iconClassName} />
) : null}
</>
)}
{children}
{loading && iconFlip ? (
<FontAwesomeIcon icon={faSpinnerThird} className="animate-spin" />
) : (
<>
{icon && iconFlip ? (
<FontAwesomeIcon icon={icon} className={iconClassName} />
) : null}
</>
)}
</label>
);
}
return (
<button
className={className}
disabled={disabled || loading}
{...{ ...rest, htmlFor }}
>
{loading && !iconFlip ? (
<FontAwesomeIcon icon={faSpinnerThird} className="animate-spin" />
) : (
<>
{icon && !iconFlip ? (
<FontAwesomeIcon icon={icon} className={iconClassName} />
) : null}
</>
)}
{children}
{loading && iconFlip ? (
<FontAwesomeIcon icon={faSpinnerThird} className="animate-spin" />
) : (
<>
{icon && iconFlip ? (
<FontAwesomeIcon icon={icon} className={iconClassName} />
) : null}
</>
)}
</button>
);
};
@@ -1 +0,0 @@
export * from "./Button";
@@ -1,81 +0,0 @@
import { useState } from "react";
import { TransitionDialogue, Button, ButtonProps } from "~/components";
type UnionedButtonProps = { label?: string } & ButtonProps;
interface ButtonDialoguePropsI {
buttonProps?: UnionedButtonProps;
confirmButtonProps?: UnionedButtonProps;
closeButtonProps?: UnionedButtonProps;
dialogProps?: {
className?: string;
};
title?: React.ReactNode;
children: React.ReactNode;
showClose?: boolean;
}
export const ButtonDialogue = ({
dialogProps = {},
buttonProps: unionedButtonProps,
confirmButtonProps,
closeButtonProps: unionedCloseButtonProps,
title,
children,
showClose = true,
}: ButtonDialoguePropsI) => {
const [isOpen, setIsOpen] = useState(false);
const closeModal = () => setIsOpen(false);
const openModal = () => setIsOpen(true);
const { label: buttonLabel, ...buttonProps } = unionedButtonProps || {
label: "Open",
};
const { label: closeButtonLabel, ...closeButtonProps } =
unionedCloseButtonProps || { label: "Close" };
return (
<>
<Button type="button" onClick={openModal} {...buttonProps}>
{buttonLabel}
</Button>
<TransitionDialogue
title={title}
isOpen={isOpen}
onClose={closeModal}
className={dialogProps.className}
>
<div className="mt-2 h-full">{children}</div>
<div className="mt-6 flex justify-end gap-2">
{showClose && (
<Button
type="button"
onClick={closeModal}
{...closeButtonProps}
variant="secondary"
>
{closeButtonLabel}
</Button>
)}
{confirmButtonProps && (
<Button
type="button"
{...confirmButtonProps}
onClick={(e) => {
if (confirmButtonProps.onClick) {
confirmButtonProps.onClick(e);
}
closeModal();
}}
>
{confirmButtonProps.label ? confirmButtonProps.label : "Confirm"}
</Button>
)}
</div>
</TransitionDialogue>
</>
);
};
@@ -1 +0,0 @@
export { ButtonDialogue } from './ButtonDialogue';
@@ -1,3 +1,4 @@
// TODO: Move to shared component.
import { ButtonHTMLAttributes, Fragment, useState } from "react";
import { Menu, Transition } from "@headlessui/react";
import {
@@ -5,7 +6,8 @@ import {
faChevronDown,
} from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button, ButtonProps, TransitionDialogue } from "~/components";
import { TransitionDialogue } from "~/components";
import { Button, ButtonProps } from "@storyteller/ui-button";
import { twMerge } from "tailwind-merge";
type UnionedButtonProps = { label?: string } & ButtonProps;
@@ -1,3 +1,4 @@
// TODO: create a shared component.
import {
FontAwesomeIcon,
FontAwesomeIconProps,
@@ -1,7 +1,7 @@
import { Fragment, useCallback } from "react";
import { Dialog, Transition } from "@headlessui/react";
import { DoNotShow } from "~/constants";
import { Button } from "~/components";
import { Button } from "@storyteller/ui-button"
interface Props {
text: string;
@@ -1,7 +1,7 @@
import { ReactNode } from "react";
import { Popover, Transition } from "@headlessui/react";
import { twMerge } from "tailwind-merge";
import { Button } from "../Button";
import { Button } from "@storyteller/ui-button"
export interface PopoverItem {
label: string;
@@ -1,9 +1,11 @@
// TODO: DELETE
import {
faCircleXmark,
faFilterList,
faSearch,
} from "@fortawesome/pro-solid-svg-icons";
import { Button, Input, Tooltip } from "~/components";
import { Input, Tooltip } from "~/components";
import { Button } from "@storyteller/ui-button"
import debounce from "lodash/debounce";
import { useRef, useState, useEffect, useCallback } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@@ -1,34 +0,0 @@
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import { Button } from "~/components";
import { twMerge } from "tailwind-merge";
interface ToggleButtonProps {
isActive: boolean;
icon?: IconDefinition;
activeIcon?: IconDefinition;
onClick: () => void;
className?: string;
}
export const ToggleButton = ({
isActive,
icon,
activeIcon,
onClick,
className,
}: ToggleButtonProps) => {
return (
<Button
className={twMerge(
"flex h-9 w-9 items-center border-2 border-transparent text-sm text-white backdrop-blur-lg transition-all",
isActive
? "border-white/20 bg-brand-primary/40 hover:border-white/30 hover:bg-brand-primary/40"
: "bg-[#5F5F68]/60 hover:bg-[#5F5F68]/90",
className,
)}
variant="secondary"
icon={isActive && activeIcon ? activeIcon : icon}
onClick={onClick}
/>
);
};
@@ -1 +0,0 @@
export * from "./ToggleButton";
@@ -1,6 +1,8 @@
import { Button, P, H3 } from "~/components";
import { P, H3 } from "~/components";
import { FilterEngineCategories } from "~/enums";
import {
Button
} from "@storyteller/ui-button"
interface Props {
onCancel: () => void;
onRetry: () => void;
@@ -1,4 +1,7 @@
import { Button, H6, Input, ListDropdown } from "~/components";
import { H6, Input, ListDropdown } from "~/components";
import {
Button
} from "@storyteller/ui-button"
import { FileUploader } from "./FileUploader";
import { useState } from "react";
import {
@@ -1,4 +1,6 @@
import { Button } from "~/components";
import {
Button
} from "@storyteller/ui-button"
interface Props {
title: string;
@@ -1,5 +1,8 @@
import { useCallback, useEffect, useState, useRef } from "react";
import { Button, H6, Input, Label, ListDropdown } from "~/components";
import { H6, Input, Label, ListDropdown } from "~/components";
import {
Button
} from "@storyteller/ui-button"
import { FileUploader } from "../UploadModal/FileUploader";
import { loadPreviewOnCanvas, snapshotCanvasAsThumbnail } from "./utilities";
import { upload3DObjects } from "./utilities/upload3DObjects";
@@ -1,6 +1,6 @@
import { useState } from "react";
import { Button, H6, Input } from "~/components";
import { H6, Input } from "~/components";
import { Button } from "@storyteller/ui-button"
import { FileUploader } from "../UploadModal/FileUploader";
import { uploadPlane } from "./uploadPlane";
import { UploaderState } from "~/models";
@@ -1,8 +1,9 @@
import { useState } from "react";
import { Button, H6, Input } from "~/components";
import { H6, Input } from "~/components";
import { Button } from "@storyteller/ui-button"
import { FileUploader } from "../UploadModal/FileUploader";
import { uploadPlane } from "./uploadPlane";
import { UploaderState } from "~/models";
import { uploadImage } from "./uploadImage";
@@ -122,3 +122,53 @@ const takeVideoSnapshot = async (
return blob;
};
export const uploadPlaneFromMediaToken = async ({
title,
mediaToken,
progressCallback,
}: {
title: string;
mediaToken: string;
progressCallback: (newState: UploaderState) => void;
}) => {
// Step 1: Set uploading state (simulate asset upload)
progressCallback({ status: UploaderStates.uploadingAsset });
// Step 2: Fetch the media file info (to get type, etc.)
const mediaFilesApi = new MediaFilesApi();
const mediaFileResponse = await mediaFilesApi.GetMediaFileByToken({
mediaFileToken: mediaToken,
});
if (!mediaFileResponse.success || !mediaFileResponse.data) {
progressCallback({
status: UploaderStates.assetError,
errorMessage:
mediaFileResponse.errorMessage ||
"Could not fetch media file by token.",
});
return;
}
// Step 3: Optionally set the title (rename)
if (title && mediaFileResponse.data.maybe_title !== title) {
const renameResponse = await mediaFilesApi.RenameMediaFileByToken({
mediaToken,
name: title,
});
if (!renameResponse.success) {
progressCallback({
status: UploaderStates.assetError,
errorMessage:
renameResponse.errorMessage || "Could not rename media file.",
});
return;
}
}
// Step 4: (Optional) Set cover image if needed (skip if already set)
// For simplicity, we skip cover image upload here, but you could add logic to set a cover if needed.
// Step 5: Success
progressCallback({ status: UploaderStates.success });
};
@@ -1,6 +1,4 @@
export * from "./Badge";
export * from "./Button";
export * from "./ButtonDialogue";
export * from "./ButtonDropdown";
export * from "./ButtonIcon";
export * from "./ButtonIconSelect";
@@ -25,7 +23,6 @@ export * from "./Select";
export * from "./Slider";
export * from "./Tabs";
export * from "./Textarea";
export * from "./ToggleButton";
export * from "./Tooltip";
export * from "./TransitionDialogue";
export * from "./Typography";
@@ -1,7 +1,8 @@
import { useSignals } from "@preact/signals-react/runtime";
import { faTriangleExclamation } from "@fortawesome/pro-solid-svg-icons";
import { Button, TransitionDialogue } from "~/components";
import { TransitionDialogue } from "~/components";
import { Button } from "@storyteller/ui-button"
import {
showErrorDialog,
errorDialogMessage,
@@ -1,101 +0,0 @@
import { Link } from "react-router-dom";
import React from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faLock } from "@fortawesome/pro-solid-svg-icons";
import { twMerge } from "tailwind-merge";
interface PremiumLockProps {
requiredPlan?: "any" | "plus" | "pro" | "elite";
children: React.ReactNode;
plural?: boolean; // if the message should be plural for multiple features
className?: string;
}
// replace with api to check
const sessionSubscriptions = {
hasPaidFeatures: () => true, // if has any paid features at all
hasActivePlusSubscription: () => false, // if has plus or not
hasActiveProSubscription: () => false, // if has pro or not
hasActiveEliteSubscription: () => false, // if has elite or not
};
export function PremiumLock({
requiredPlan = "plus",
plural = false,
children,
className,
}: PremiumLockProps) {
const getPlanMessage = (plan: string) => {
switch (plan) {
case "any":
return "premium";
case "plus":
return "premium plus";
case "pro":
return "premium pro";
case "elite":
return "premium elite";
default:
return "premium";
}
};
const hasAccess = () => {
switch (requiredPlan) {
case "any":
return sessionSubscriptions.hasPaidFeatures();
case "plus":
return (
sessionSubscriptions.hasActivePlusSubscription() ||
sessionSubscriptions.hasActiveProSubscription() ||
sessionSubscriptions.hasActiveEliteSubscription()
);
case "pro":
return (
sessionSubscriptions.hasActiveProSubscription() ||
sessionSubscriptions.hasActiveEliteSubscription()
);
case "elite":
return sessionSubscriptions.hasActiveEliteSubscription();
default:
return sessionSubscriptions.hasPaidFeatures();
}
};
return (
<>
{hasAccess() ? (
children
) : (
<div
className={twMerge(
"relative overflow-hidden rounded-lg bg-ui-controls px-3",
className,
)}
>
<div className="pointer-events-none pb-3 pt-10">{children}</div>
<div className="absolute left-0 top-0 z-10 flex h-full w-full rounded-lg border border-brand-primary/40 bg-brand-secondary/60 pl-3 text-white/90">
<div className="mt-2 flex justify-center">
<div>
<FontAwesomeIcon icon={faLock} className="me-2 text-sm" />
<span className="text-sm font-medium">
{plural
? "These features require "
: "This feature requires "}
<Link
to="/pricing"
className="text-brand-primary brightness-125 transition-all hover:brightness-150"
>
{getPlanMessage(requiredPlan)}
</Link>
</span>
</div>
</div>
</div>
</div>
)}
</>
);
}
@@ -1 +0,0 @@
export * from "./PremiumLock";
@@ -1,104 +0,0 @@
import { useSignals } from "@preact/signals-react/runtime";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faHexagonXmark,
faTriangleExclamation,
faXmark,
} from "@fortawesome/pro-solid-svg-icons";
import { ToastTypes } from "~/enums";
import { toasts, deleteToast } from "~/signals";
import { useEffect, useRef, useState } from "react";
export type ToastDataType = {
type: ToastTypes;
message: string;
};
export const Toaster = () => {
useSignals();
const TITLES = {
[ToastTypes.ERROR]: "Error!",
[ToastTypes.WARNING]: "Warning!",
[ToastTypes.SUCCESS]: "Success",
};
const ICONS: Record<string, React.ReactNode> = {
error: <FontAwesomeIcon icon={faHexagonXmark} className="text-[#FF5F5F]" />,
warning: (
<FontAwesomeIcon
icon={faTriangleExclamation}
className="text-[#FFDC5F]"
/>
),
success: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512"
className="h-4 w-4"
>
<path
opacity="1"
d="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM369 209L241 337c-9.4 9.4-24.6 9.4-33.9 0l-64-64c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0l47 47L335 175c-9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9z"
fill="#3ACA86"
/>
<path
d="M369 175c-9.4 9.4-9.4 24.6 0 33.9L241 337c-9.4 9.4-24.6 9.4-33.9 0l-64-64c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0l47 47L335 175c-9.4-9.4 24.6-9.4 33.9 0z"
fill="#FFFFFF"
/>
</svg>
),
};
const toastTypeToKey: Record<ToastTypes, string> = {
[ToastTypes.ERROR]: "error",
[ToastTypes.WARNING]: "warning",
[ToastTypes.SUCCESS]: "success",
};
const toastRefs = useRef<(HTMLDivElement | null)[]>([]);
const [heights, setHeights] = useState<number[]>([]);
useEffect(() => {
const newHeights = toastRefs.current.map((ref) => ref?.offsetHeight || 0);
setHeights(newHeights);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [toasts.value]);
return (
<>
{toasts.value.map((toast, index) => (
<div
key={toast.id}
ref={(el) => (toastRefs.current[index] = el)}
className="absolute z-50 w-[360px] animate-fadeIn rounded-lg border border-[#5E5E7C] bg-ui-controls p-3 shadow-xl"
style={{
top: heights
.slice(0, index)
.reduce((acc, height) => acc + height + 8, 52),
right: 8,
}}
>
<div className="flex justify-between rounded-lg">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
{ICONS[toastTypeToKey[toast.type]]}
<div className="text-base font-bold text-white">
{TITLES[toast.type]}
</div>
</div>
<div className="text-left text-sm text-white opacity-75">
{toast.message}
</div>
</div>
<button
onClick={() => deleteToast(toast.id)}
className="ml-4 text-xl font-bold text-white opacity-50"
>
<FontAwesomeIcon icon={faXmark} />
</button>
</div>
</div>
))}
</>
);
};
@@ -2,7 +2,7 @@ import { useSignals } from "@preact/signals-react/runtime";
import { twMerge } from "tailwind-merge";
import { faFilm } from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button } from "~/components";
import { Button } from "@storyteller/ui-button";
import { generateMovieId, viewMyMovies } from "~/pages/PageEnigma/signals";
import { activeWorkflowJobs } from "~/signals";
@@ -5,17 +5,23 @@ import {
faImages,
} from "@fortawesome/pro-solid-svg-icons";
import { useLocation, useParams } from "react-router-dom";
import { Button, ButtonLink } from "~/components";
import { Button } from "@storyteller/ui-button"
import { ButtonLink } from "~/components";
import { AuthButtons } from "./AuthButtons";
import { SceneTitleInput } from "./SceneTitleInput";
import { getCurrentLocationWithoutParams } from "~/utilities";
import { Activity } from "~/pages/PageEnigma/comps/GenerateModals/Activity";
import { GalleryModal } from "@storyteller/ui-gallery-modal";
import {
GalleryModal,
galleryModalVisibleViewMode,
galleryModalVisibleDuringDrag,
} from "@storyteller/ui-gallery-modal";
import { SettingsModal } from "@storyteller/ui-settings-modal";
import { Tooltip } from "@storyteller/ui-tooltip";
import { downloadFileFromUrl } from "@storyteller/api";
import { TabSelector, TabItem } from "@storyteller/ui-tab-selector";
import { Signal } from "@preact/signals-react";
import { Signal, signal } from "@preact/signals-react";
import { useSignals } from "@preact/signals-react/runtime";
import { setLogoutStates } from "~/signals/authentication/utilities";
import { EngineContext } from "~/pages/PageEnigma/contexts/EngineContext";
@@ -36,9 +42,12 @@ interface Props {
const appTabs: TabItem[] = [
{ id: "2D", label: "2D" },
{ id: "3D", label: "3D" },
// { id: "VIDEO", label: "Video" },
{ id: "VIDEO", label: "Video" },
];
export const topNavMediaId = signal<string>("");
export const topNavMediaUrl = signal<string>("");
export const TopBar = ({
pageName,
appTabIdSignal,
@@ -51,10 +60,11 @@ export const TopBar = ({
useLocation().pathname,
useParams(),
);
const [isLibraryModalOpen, setIsLibraryModalOpen] = useState(false);
const [activeLibraryTab, setActiveLibraryTab] = useState("my-media");
const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false);
const [url, setUrl] = useState<string>("");
const [mediaId, setMediaId] = useState<string>("");
const engine3D = useContext(EngineContext);
const handleTabChange = (tabId: string) => {
if (appTabIdSignal.peek() == "3D") {
@@ -75,40 +85,38 @@ export const TopBar = ({
url: string,
media_id: string | undefined,
) => {
console.log("add to scene", url);
console.log("Items to add to scene", currentAppTabId);
console.log("url", url);
console.log("media_id", media_id);
setUrl(url);
setMediaId(media_id ?? "");
if (currentAppTabId === "2D") {
console.log("Adding to 2D scene");
// from the uploaded image url.
} else if (currentAppTabId === "3D") {
console.log("Adding to 3D scene");
// from the uploaded image url.
// engine3D?.activeScene.loadObjectFromUrl(
// "https://cdn-2.fakeyou.com/media/r/h/0/5/3/rh053gbx8yvjwk1mdvag4gkr5c0h9wbh/image_rh053gbx8yvjwk1mdvag4gkr5c0h9wbh.png",
// );
// from the gallery modal.
// engine3D?.activeScene.loadObjectFromUrl(
// "https://cdn-2.fakeyou.com/media/1/t/b/0/e/1tb0e53691mdgqmtd4fmzy58wp96vwxz/image_1tb0e53691mdgqmtd4fmzy58wp96vwxz.png",
// );
// // working media id m_ehq8051fx4hddhja54a7zmz897cgrk
// engine3D?.activeScene.loadObject(
// "m_ehq8051fx4hddhja54a7zmz897cgrk",
// "image",
// true,
// );
// media id from the image selected from gallery.
engine3D?.activeScene.loadObject(media_id, "image", true);
if (media_id) {
engine3D?.activeScene.loadObject(media_id, "image", true);
} else {
console.warn("No media id provided");
}
} else if (currentAppTabId === "VIDEO") {
console.log("Adding to Video scene");
topNavMediaId.value = media_id ?? "";
topNavMediaUrl.value = url;
} else {
console.warn(`Unknown tab type: ${currentAppTabId}`);
}
};
const handleOpenGalleryModal = () => {
galleryModalVisibleViewMode.value = true;
galleryModalVisibleDuringDrag.value = true;
};
return (
<>
<header className="fixed left-0 top-0 z-[60] w-full border-b border-white/5 bg-ui-background">
@@ -140,7 +148,11 @@ export const TopBar = ({
</div>
<div className="flex items-center justify-center gap-2 font-medium">
<SceneTitleInput pageName={pageName} />
{currentAppTabId === "3D" ? (
<SceneTitleInput pageName={pageName} />
) : (
<h1>{currentAppTabId === "2D" ? "Canvas" : "Generate Video"}</h1>
)}
</div>
<div className="flex justify-end gap-3.5">
@@ -156,9 +168,9 @@ export const TopBar = ({
<Button
variant="secondary"
icon={faImages}
onClick={() => setIsLibraryModalOpen(true)}
onClick={handleOpenGalleryModal}
>
My Gallery
My Library
</Button>
<Activity />
@@ -177,15 +189,7 @@ export const TopBar = ({
/>
<GalleryModal
isOpen={isLibraryModalOpen}
onClose={() => setIsLibraryModalOpen(false)}
mode="view"
tabs={[
{ id: "my-media", label: "My generations" },
{ id: "uploads", label: "My uploads" },
]}
activeTab={activeLibraryTab}
onTabChange={setActiveLibraryTab}
onDownloadClicked={downloadFileFromUrl}
onAddToSceneClicked={handleAddToScene}
/>
@@ -1,160 +0,0 @@
import { useEffect, useState } from "react";
import { v4 as uuidv4 } from "uuid";
import { faCirclePlus, faSpinnerThird } from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
Button,
FileUploader,
H2,
TransitionDialogue,
WaveformPlayer,
} from "~/components";
import { MediaUploadApi } from "~/Classes/ApiManager/MediaUploadApi";
import { AUDIO_FILE_TYPE, ToastTypes } from "~/enums";
export const FILE_TYPES = Object.keys(AUDIO_FILE_TYPE);
import { getFileName } from "~/utilities";
import { addToast } from "~/signals";
type ComponentState = {
isOpen: boolean;
file: File | undefined;
audioUrl: string;
uploadState: "init" | "staged" | "uploading" | "uploaded" | "error";
fileToken: string | undefined;
};
const initialValues: ComponentState = {
isOpen: false,
file: undefined,
audioUrl: "",
uploadState: "init",
fileToken: undefined,
};
export const UploadAudioButtonDialogue = ({
onUploaded,
}: {
onUploaded: (fileToken: string) => void;
}) => {
const [{ isOpen, file, audioUrl, uploadState, fileToken }, setState] =
useState<ComponentState>(initialValues);
const closeModal = () => setState(initialValues);
const openModal = () => setState((curr) => ({ ...curr, isOpen: true }));
const handleFileChange = (file: File) => {
setState((curr) => ({
...curr,
file: file,
audioUrl: URL.createObjectURL(file),
uploadState: "staged",
}));
};
const handleClear = () => {
setState({
...initialValues,
isOpen: true,
});
};
const submitUpload = async () => {
if (!file) {
//TODO: Maybe more verbose on not having file
return;
}
setState((curr) => ({
...curr,
uploadState: "uploading",
}));
const mediaApi = new MediaUploadApi();
const request = {
blob: file,
fileName: getFileName(file),
uuid: uuidv4(),
maybe_title: getFileName(file),
};
const response = await mediaApi.UploadAudio(request);
if (response.success && response.data) {
setState((curr) => ({
...curr,
uploadState: "uploaded",
fileToken: response.data,
}));
return;
}
//handle errors
addToast(
ToastTypes.ERROR,
response.errorMessage || "Unknown Error: Upload Audio",
);
setState((curr) => ({
...curr,
uploadState: "staged",
}));
};
useEffect(() => {
if (fileToken && uploadState === "uploaded") {
//calling the callback on a successful upload
onUploaded(fileToken);
}
}, [fileToken, uploadState, onUploaded]);
return (
<>
<Button
className="grow py-3 text-sm font-medium"
variant="action"
icon={faCirclePlus}
type="button"
onClick={openModal}
>
Upload Audio
</Button>
<TransitionDialogue
isOpen={isOpen}
onClose={closeModal}
title={<H2>Upload Audio</H2>}
>
<div className="flex w-full flex-col gap-3">
<FileUploader
file={file}
fileTypes={FILE_TYPES}
handleChange={handleFileChange}
/>
{file && (
<div className="flex items-center gap-3 rounded-lg bg-brand-secondary p-3">
<div className="grow">
<WaveformPlayer audio={audioUrl} hasPlayButton />
</div>
</div>
)}
<div className="flex justify-end gap-3">
<Button variant="secondary" onClick={closeModal}>
{uploadState === "uploaded" ? "Close" : "Cancel"}
</Button>
{uploadState !== "uploaded" && (
<Button
onClick={submitUpload}
disabled={uploadState !== "staged"}
>
Upload
{uploadState === "uploading" && (
<FontAwesomeIcon icon={faSpinnerThird} spin />
)}
</Button>
)}
{uploadState === "uploaded" && (
<Button onClick={handleClear}>Upload Another</Button>
)}
</div>
</div>
</TransitionDialogue>
</>
);
};
@@ -1 +0,0 @@
export { UploadAudioButtonDialogue } from "./UploadAudioButtonDialogue";
@@ -1,202 +0,0 @@
import { useCallback, useState } from "react";
import { FileUploader } from "react-drag-drop-files";
import { v4 as uuidv4 } from "uuid";
import {
faCheck,
faCircleXmark,
faFileArrowUp,
faFileAudio,
faSpinnerThird,
faXmark,
} from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button, ButtonIcon, P, Tooltip, WaveformPlayer } from "~/components";
import { MediaUploadApi } from "~/Classes/ApiManager";
import { getFileName } from "~/utilities";
import { AUDIO_FILE_TYPE, ToastTypes } from "~/enums";
import { addToast } from "~/signals";
const FILE_TYPES = Object.keys(AUDIO_FILE_TYPE);
interface Props {
onFileStaged?: (file: File) => void;
onClear?: () => void;
onFileUploaded: (token: string) => void;
}
export const UploadAudioComponent = ({
onFileStaged,
onClear,
onFileUploaded,
}: Props) => {
const [{ file, uploadState }, setState] = useState<{
file: File | undefined;
uploadState: "none" | "staged" | "uploading" | "uploaded" | "error";
}>({
file: undefined,
uploadState: "none",
});
const audioUrl = file ? URL.createObjectURL(file) : "";
const handleChange = (file: File) => {
setState((curr) => ({
...curr,
file: file,
uploadState: "staged",
}));
if (onFileStaged) {
onFileStaged(file);
}
};
const handleClear = () => {
setState((curr) => ({
...curr,
file: undefined,
uploadState: "none",
uploadToken: undefined,
}));
if (onClear) {
onClear();
}
};
const handleUploadFile = useCallback(
async (file: File) => {
setState((curr) => ({ ...curr, uploadState: "uploading" }));
const mediaUploadApi = new MediaUploadApi();
const request = {
blob: file,
fileName: getFileName(file),
uuid: uuidv4(),
maybe_title: getFileName(file),
};
const response = await mediaUploadApi.UploadAudio(request);
if (response.success && response.data) {
setState((curr) => ({
...curr,
uploadState: "uploaded",
}));
onFileUploaded(response.data);
return;
}
//handle errors
addToast(
ToastTypes.ERROR,
response.errorMessage || "Unknown Error: Upload Audio",
);
setState((curr) => ({
...curr,
uploadState: "staged",
}));
},
[onFileUploaded],
);
return (
<div className="flex flex-col gap-3">
{/* Usage refer to https://github.com/KarimMokhtar/react-drag-drop-files */}
<FileUploader
handleChange={handleChange}
name="file"
types={FILE_TYPES}
maxSize={50}
>
<DragAndDropZone file={file} />
</FileUploader>
{file && (
<>
<div className="flex items-center gap-3 rounded-lg bg-brand-secondary p-3">
<div className="grow">
<WaveformPlayer audio={audioUrl} hasPlayButton />
</div>
<Tooltip content="Remove" position="top">
<ButtonIcon
icon={faXmark}
onClick={handleClear}
className="h-auto w-auto bg-transparent p-0 text-xl opacity-60 hover:bg-transparent hover:opacity-90"
/>
</Tooltip>
</div>
<div className="flex gap-2">
<Button
className="w-full py-2.5"
variant="action"
onClick={() => {
if (!file) {
//TODO: Maybe more verbose on not having file
return false;
}
handleUploadFile(file);
}}
disabled={uploadState !== "staged"}
icon={
uploadState === "uploaded"
? faCheck
: uploadState === "error"
? faCircleXmark
: faFileArrowUp
}
>
{uploadState === "uploaded"
? "Uploaded"
: uploadState === "error"
? "Upload Error"
: "Upload Audio"}
{uploadState === "uploading" && (
<FontAwesomeIcon icon={faSpinnerThird} spin />
)}
</Button>
</div>
</>
)}
</div>
);
};
const DragAndDropZone = ({ file }: { file: File | undefined }) => {
const fileSize =
file && file.size >= 1024 * 1024
? (file.size / 1024 / 1024).toFixed(2) + " MB"
: file
? `${Math.floor(file.size / 1024)} KB`
: null;
const fileName =
file && file.name ? file.name.split(".")[0].toUpperCase() : "";
if (!file) {
return (
<div className="flex cursor-pointer items-center gap-3.5 rounded-lg border border-dashed border-[#363636] bg-brand-secondary p-3">
<FontAwesomeIcon icon={faFileArrowUp} className="text-4xl" />
<div className="flex flex-col gap-0">
<P className="font-medium">
<u>Upload a file</u> or drop it here
</P>
<P className="flex items-center gap-2 text-sm font-normal opacity-50">
{FILE_TYPES.join(", ").toString()} supported
</P>
</div>
</div>
);
} else {
return (
<div className="flex cursor-pointer items-center gap-3.5 rounded-lg border border-dashed border-[#363636] bg-brand-secondary p-3">
<FontAwesomeIcon icon={faFileAudio} className="text-4xl" />
<div className="flex flex-col gap-0">
<P className="font-medium">
{file.name.slice(0, file.name.lastIndexOf("."))}
</P>
<P className="flex items-center gap-2 text-sm font-normal">
<span className="opacity-50">
{`${fileName} file size: ${fileSize} `}
</span>
<u className="transition-all hover:text-white/80">Change File</u>
</P>
</div>
</div>
);
}
};
@@ -1 +0,0 @@
export { UploadAudioComponent } from "./UploadAudioComponent";
@@ -1,8 +1,3 @@
export * from "./ErrorDialog";
export * from "./Toaster";
export * from "./TopBar";
export * from "./PremiumLock";
// all the Upload stuff will depend on the API Manager Signal
export * from "./UploadAudioButtonDialogue";
export * from "./UploadAudioComponent";
+2 -10
View File
@@ -10,7 +10,7 @@ import "@fortawesome/fontawesome-svg-core/styles.css";
import { config } from "@fortawesome/fontawesome-svg-core";
import EnvironmentVariables from "~/Classes/EnvironmentVariables";
import { pageHeight, pageWidth, persistLogin } from "~/signals";
import { showWizard } from "~/pages/PageEnigma/Wizard/signals/wizard";
import { posthog } from "posthog-js";
config.autoAddCss = false; /* eslint-disable import/first */
@@ -55,17 +55,9 @@ const GlobalSettingsManager = ({ env }: { env: Record<string, string> }) => {
pageWidth.value = window.innerWidth;
}
function initWizard() {
if (showWizard.value) {
return;
}
const wizard = localStorage.getItem("storyteller-wizard");
showWizard.value = wizard ? "" : "initial";
localStorage.setItem("storyteller-wizard", "shown");
}
useEffect(() => {
initWizard();
setPage();
window.addEventListener("resize", setPage);
return () => {

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