Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c14f35c9f | ||
|
|
921e58a308 | ||
|
|
65531dc63e | ||
|
|
0b60835bbc | ||
|
|
cca897d24d | ||
|
|
ea76917264 | ||
|
|
fc824a10ae | ||
|
|
0ee9b7c442 | ||
|
|
17495276e7 | ||
|
|
2d78fd5a6f | ||
|
|
98e513e917 | ||
|
|
97eb1c65ca | ||
|
|
ba82a438dd | ||
|
|
de7b990ec5 | ||
|
|
7b90c59675 | ||
|
|
11f1a5f41a | ||
|
|
b92cd58f81 | ||
|
|
914c26b4c6 | ||
|
|
fe8939a0f6 | ||
|
|
c809ff88fa | ||
|
|
86675568a8 | ||
|
|
a8927cc78d | ||
|
|
0e8dd19ecf | ||
|
|
b32a2e79b9 | ||
|
|
a662c5809d | ||
|
|
7498720674 | ||
|
|
c8a4490b52 | ||
|
|
c4e30e6dc7 | ||
|
|
12070f14ad | ||
|
|
77a103de06 | ||
|
|
82f5805f24 |
@@ -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"
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"default":{"identifier":"default","description":"enables the default permissions","local":true,"windows":["main"],"permissions":["core:default","log:default"]}}
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 276 KiB |
|
After Width: | Height: | Size: 320 KiB |
|
After Width: | Height: | Size: 564 KiB |
|
After Width: | Height: | Size: 366 KiB |
|
After Width: | Height: | Size: 452 KiB |
|
After Width: | Height: | Size: 337 KiB |
|
After Width: | Height: | Size: 378 KiB |
|
After Width: | Height: | Size: 302 KiB |
|
After Width: | Height: | Size: 446 KiB |
|
After Width: | Height: | Size: 238 KiB |
|
After Width: | Height: | Size: 344 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 299 KiB |
|
After Width: | Height: | Size: 396 KiB |
|
After Width: | Height: | Size: 318 KiB |
|
After Width: | Height: | Size: 521 KiB |
|
After Width: | Height: | Size: 318 KiB |
|
After Width: | Height: | Size: 396 KiB |
|
After Width: | Height: | Size: 316 KiB |
|
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";
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||