Compare commits

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

Co-authored-by: Michael Chung <michaelchung@mac.home>
2025-05-22 18:43:59 -04:00
bflatastic ba82a438dd image not dropping at correct position fix 2025-05-23 02:11:54 +07:00
bflatastic de7b990ec5 bug fix: select mode library modal not closing on X click 2025-05-23 01:35:50 +07:00
BFlat 7b90c59675 library modal signals bug fix (#627)
- fixed weird behavior with library modal not opening
- image drag and drop more accurate position (production drag and drop
still doesnt work - looking into it)
2025-05-23 01:31:49 +07:00
Brandon Thomas 11f1a5f41a sora aspect ratios (#626) 2025-05-22 00:09:59 -04:00
BFlat b92cd58f81 Enhance GalleryModal UX with Filters, Drag-and-Drop, Pagination, and UI Improvements (#625)
**✨ Features & Enhancements:**
- GalleryModal Filters: Replaced tab-based navigation with a more
scalable filter system.
- Image Fit Toggle: Allows toggling image fit display mode for better
previewing.
- Pagination & Infinite Scroll: Improved item loading performance using
soft refresh and infinite scroll logic.
- Drag-and-drop setup for gallery items
- Prevented dragging for video items
- Added support for image drop in PageEditor (3D)
- "My Library" Label Updates
2025-05-21 23:29:06 +07:00
Brandon Thomas 914c26b4c6 classify gateway timeout 2025-05-21 11:04:32 -04:00
Brandon Thomas fe8939a0f6 incomplete runwayml implementation (pausing for now) (#624) 2025-05-20 23:39:34 -04:00
Tanish Manku c809ff88fa Add save/load support for character poses (#623) 2025-05-20 20:13:04 -04:00
bflatastic 86675568a8 Update modal components for improved drag functionality and styling
- Increased the height of the drag handle in AssetModal and GalleryModal for better usability.
- Adjusted z-index properties for elements within AssetModal to ensure proper layering.
- Enhanced the modal's drag behavior to allow overflow beyond viewport edges, improving user experience when repositioning.
2025-05-21 02:32:20 +07:00
bflatastic a8927cc78d Refactor expand button in modal component for improved styling and functionality
- Updated the ExpandButton component to accept size props for better responsiveness.
- Replaced SVG icons with FontAwesome icons for a more consistent design.
- Adjusted the GalleryModal layout to include a gap between buttons for enhanced usability.
2025-05-21 02:17:17 +07:00
bflatastic 0e8dd19ecf Enhance modal functionality with draggable support and expand button
- Added draggable functionality to the modal, allowing users to reposition it.
- Introduced an expand button to toggle full-screen mode for the modal.
- Updated modal backdrop to support interaction options and improved styling.
- Adjusted AssetModal and GalleryModal components to utilize new modal features.
2025-05-21 02:06:51 +07:00
bflatastic b32a2e79b9 promptbox height bug fix 2025-05-20 22:47:38 +07:00
bflatastic a662c5809d lightbox videos fix 2025-05-20 22:31:27 +07:00
Michael Chung 7498720674 Fixing the gallery modal to show videos and images. 2025-05-20 11:14:36 -04:00
ArEnScandMichael Chung c8a4490b52 Feature/add to scene video 2 (#621)
Co-authored-by: Michael Chung <michaelchung@michaels-mbp-2.home>
2025-05-20 09:22:46 -04:00
Brandon Thomas c4e30e6dc7 swap out the background (#620) 2025-05-19 23:01:33 -04:00
172 changed files with 6723 additions and 6409 deletions
Generated
+211 -9
View File
@@ -74,7 +74,7 @@ checksum = "b340e9cfa5b08690aae90fb61beb44e9b06f44fe3d0f93781aaa58cfba86245e"
dependencies = [
"actix-utils",
"actix-web",
"derive_more",
"derive_more 0.99.17",
"futures-util",
"log",
"once_cell",
@@ -97,7 +97,7 @@ dependencies = [
"brotli 6.0.0",
"bytes 1.10.0",
"bytestring",
"derive_more",
"derive_more 0.99.17",
"encoding_rs",
"flate2",
"futures-core",
@@ -140,7 +140,7 @@ dependencies = [
"actix-utils",
"actix-web",
"bytes 1.10.0",
"derive_more",
"derive_more 0.99.17",
"futures-core",
"futures-util",
"httparse",
@@ -253,7 +253,7 @@ dependencies = [
"bytestring",
"cfg-if 1.0.0",
"cookie 0.16.2",
"derive_more",
"derive_more 0.99.17",
"encoding_rs",
"futures-core",
"futures-util",
@@ -322,7 +322,7 @@ dependencies = [
"bytes 1.10.0",
"bytestring",
"csv",
"derive_more",
"derive_more 0.99.17",
"futures-core",
"futures-util",
"http 0.2.11",
@@ -1384,6 +1384,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 +1672,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 +3369,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 +3484,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 +5228,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 +6618,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 +6867,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 +6927,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 +9230,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 +10467,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 +10974,7 @@ checksum = "df320f1889ac4ba6bc0cdc9c9af7af4bd64bb927bccdf32d81140dc1f9be12fe"
dependencies = [
"bitflags 1.3.2",
"cssparser 0.27.2",
"derive_more",
"derive_more 0.99.17",
"fxhash",
"log",
"matches",
@@ -10798,7 +10994,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 +11945,7 @@ dependencies = [
"cookies",
"crockford",
"datetimes",
"derive_more",
"derive_more 0.99.17",
"diesel",
"dotenv",
"easyenv",
@@ -15278,7 +15474,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 +15707,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"
+4
View File
@@ -34,10 +34,12 @@ members = [
"crates/lib/api_clients/google_drive_common",
"crates/lib/api_clients/google_sign_in",
"crates/lib/api_clients/openai_sora_client",
"crates/lib/api_clients/runwayml_client",
"crates/lib/api_clients/storyteller_client",
"crates/lib/api_clients/thumbnail_generator",
"crates/lib/bootstrap",
"crates/lib/bucket_paths",
"crates/lib/build_metadata",
"crates/lib/caching/memory_caching",
"crates/lib/caching/memory_store",
"crates/lib/caching/redis_caching",
@@ -65,6 +67,7 @@ members = [
"crates/lib/idempotency",
"crates/lib/image_algorithms",
"crates/lib/jobs_common",
"crates/lib/jwt_light",
"crates/lib/logging",
"crates/lib/machine_learning/ml_models",
"crates/lib/machine_learning/ml_weights_registry",
@@ -191,6 +194,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" }
@@ -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://*"
}
]
},
@@ -42,7 +42,9 @@ const SORA_IMAGE_UPLOAD_TIMEOUT: Duration = Duration::from_millis(1000 * 30); //
const SORA_IMAGE_REMIX_TIMEOUT: Duration = Duration::from_millis(1000 * 30); // 30 seconds
#[derive(Deserialize)]
const DEFAULT_ASPECT_RATIO : SoraAspectRatio = SoraAspectRatio::Square;
#[derive(Deserialize, Debug)]
pub struct SoraImageRemixCommand {
/// Image media file; the engine or canvas snapshot (screenshot).
pub snapshot_media_token: MediaFileToken,
@@ -57,6 +59,17 @@ pub struct SoraImageRemixCommand {
pub maybe_additional_images: Option<Vec<MediaFileToken>>,
pub maybe_number_of_samples: Option<u32>,
/// Aspect ratio.
pub aspect_ratio: Option<SoraAspectRatio>,
}
#[derive(Deserialize, Debug, Copy, Clone)]
#[serde(rename_all = "snake_case")]
pub enum SoraAspectRatio {
Square,
Wide,
Tall,
}
#[derive(Serialize, Debug)]
@@ -74,7 +87,6 @@ pub enum SoraImageRemixErrorType {
SoraIsHavingProblems,
}
#[tauri::command]
pub async fn sora_image_remix_command(
app: AppHandle,
@@ -84,8 +96,8 @@ pub async fn sora_image_remix_command(
sora_task_queue: State<'_, SoraTaskQueue>,
) -> CommandResult<(), SoraImageRemixErrorType, ()> {
info!("image_generation_command called; scene media token: {:?}, additional images: {:?}",
request.snapshot_media_token, request.maybe_additional_images);
info!("image_generation_command called; scene media token: {:?}, additional images: {:?}, full request: {:?}",
&request.snapshot_media_token, &request.maybe_additional_images, &request);
// TODO(bt,2025-04-24): Better error messages to caller
@@ -240,6 +252,14 @@ pub async fn generate_image(
sora_media_tokens.push(response.id);
}
let aspect_ratio = request.aspect_ratio.unwrap_or(DEFAULT_ASPECT_RATIO);
let aspect_ratio = match aspect_ratio {
SoraAspectRatio::Square => ImageSize::Square,
SoraAspectRatio::Wide => ImageSize::Wide,
SoraAspectRatio::Tall => ImageSize::Tall,
};
info!("Calling image generation...");
// TODO(bt,2025-04-21): Download media tokens.
@@ -250,7 +270,7 @@ pub async fn generate_image(
image_remix_with_session_auto_renew(ImageRemixAutoRenewRequest {
prompt: request.prompt.to_string(),
num_images: NumImages::One,
image_size: ImageSize::Square,
image_size: aspect_ratio,
sora_media_tokens: sora_media_tokens.clone(),
credentials: &creds,
request_timeout: Some(SORA_IMAGE_REMIX_TIMEOUT),
@@ -0,0 +1,8 @@
{
"build": {
"frontendDist": "../../../frontend/apps/editor3d/dist",
"beforeDevCommand": "echo 'no dev'",
"devUrl": "http://localhost:5173",
"beforeBuildCommand": "npx nx run editor3d:build"
}
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"default":{"identifier":"default","description":"enables the default permissions","local":true,"windows":["main"],"permissions":["core:default","log:default"]}}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-1
View File
@@ -9,7 +9,6 @@ authors = [
publish = false
[lib]
# NB: Public "errors" crate is name-squatted, so we're fine to use this internally.
name = "actix_cors_configs"
path = "src/lib.rs"
@@ -1,6 +1,6 @@
[package]
name = "openai_sora_client"
edition = "2021"
edition = "2024"
version = "0.0.1"
publish = false
@@ -57,7 +57,13 @@ Message: {
502 => {
return SoraError::BadGateway(message);
}
504 => {
if message.contains("cloudflare") || message.contains("Cloudflare") {
return SoraError::CloudFlareTimeout(message);
}
}
524 => {
// NB: 524 is a cloudflare specific gateway timeout error with slightly different semantics than 504.
if message.contains("cloudflare") || message.contains("Cloudflare") {
return SoraError::CloudFlareTimeout(message);
}
@@ -0,0 +1,46 @@
[package]
name = "runwayml_client"
edition = "2021"
version = "0.0.1"
publish = false
[package.metadata.cargo-udeps.ignore]
normal = ["workspace-build-acceleration"]
[lib]
name = "runwayml_client"
path = "src/lib.rs"
[dependencies]
# Workspace hack (faster compile times)
workspace-build-acceleration = { workspace = true }
# Internal
crockford = { path = "../../data/crockford" }
errors = { path = "../../errors" }
idempotency = { path = "../../idempotency" }
jwt_light = { workspace = true }
subprocess_common = { path = "../../subprocess_common" }
# External
anyhow = { workspace = true }
base64 = { workspace = true }
chrono = { workspace = true}
cookie = { workspace = true }
hex-literal = { workspace = true }
log = { workspace = true }
once_cell= { workspace = true }
rand = { workspace = true }
reqwest = { workspace = true, features = ["multipart", "gzip"] }
serde = { workspace = true }
serde_derive = { workspace = true }
serde_json = { workspace = true }
sha3 = { workspace = true }
thiserror = { workspace = true }
tokio = { version = "1.38.0", features = ["macros"] }
toml = { workspace = true }
[dev-dependencies]
testing = { path = "../../testing" }
filesys = { path = "../../files/filesys" }
@@ -0,0 +1,16 @@
use crate::creds::jwt_bearer_token::JwtBearerToken;
use crate::error::api_error::ApiError;
#[derive(Clone)]
pub struct Credentials {
pub jwt_bearer_token: Option<JwtBearerToken>,
}
impl Credentials {
pub fn from_jwt_str(jwt_str: &str) -> Result<Self, ApiError> {
let jwt_bearer_token = JwtBearerToken::new(jwt_str.to_string())?;
Ok(Self {
jwt_bearer_token: Some(jwt_bearer_token),
})
}
}
@@ -0,0 +1,39 @@
use crate::creds::jwt_claims::JwtClaims;
use crate::error::api_error::ApiError;
use jwt_light::parse_jwt_claims_trait::ParseJwtClaims;
/// Sora bearer tokens are JWT tokens that can be minted with a valid cookie/session.
#[derive(Clone)]
pub struct JwtBearerToken {
raw_jwt: String,
jwt_claims: JwtClaims,
}
impl JwtBearerToken {
pub fn new(raw_jwt: String) -> Result<Self, ApiError> {
let jwt_claims = JwtClaims::parse_claims(&raw_jwt)?;
Ok(Self {
raw_jwt,
jwt_claims,
})
}
pub fn raw_jwt_str(&self) -> &str {
&self.raw_jwt
}
pub fn as_bytes(&self) -> &[u8] {
self.raw_jwt.as_bytes()
}
pub fn jwt_claims(&self) -> &JwtClaims {
&self.jwt_claims
}
pub fn to_authorization_header_value(&self) -> String {
match self.raw_jwt.get(0..6) {
Some("bearer") | Some("Bearer") => self.raw_jwt.clone(),
_ => "Bearer ".to_owned() + &self.raw_jwt,
}
}
}
@@ -0,0 +1,75 @@
use base64::Engine;
use chrono::{DateTime, Utc};
use jwt_light::common_claims::CommonClaims;
use jwt_light::error::JwtError;
use jwt_light::parse_jwt_claims_trait::ParseJwtClaims;
use serde_json::{Map, Value};
#[derive(Clone, Debug)]
pub struct JwtClaims {
/// From the "iat" field
pub created: DateTime<Utc>,
/// From the "exp" field
pub expiration: DateTime<Utc>,
/// From the "id" field
pub id: String,
/// From the "email" field
pub email: String,
/// From the "sso" field
pub sso: bool,
}
impl ParseJwtClaims for JwtClaims {
fn extract_claims(common_claims: CommonClaims, extra_claims: Map<String, Value>) -> Result<Self, JwtError> {
let id = extra_claims.get("id")
.ok_or_else(|| JwtError::CustomClaimsFieldError("no id claim".to_string()))?
.as_i64()
.ok_or_else(|| JwtError::CustomClaimsFieldError("id is not a string".to_string()))?
.to_string();
let email = extra_claims.get("email")
.ok_or_else(|| JwtError::CustomClaimsFieldError("no email claim".to_string()))?
.as_str()
.ok_or_else(|| JwtError::CustomClaimsFieldError("email is not a string".to_string()))?
.to_string();
let sso = extra_claims.get("sso")
.map(|val| val.as_bool())
.flatten()
.unwrap_or(false);
Ok(Self {
created: common_claims.created,
expiration: common_claims.expiration,
id,
email,
sso,
})
}
}
#[cfg(test)]
mod tests {
use crate::creds::jwt_claims::JwtClaims;
use jwt_light::parse_jwt_claims_trait::ParseJwtClaims;
#[test]
fn test_token() {
/*{
"id": 23129405,
"email": "echelon@gmail.com",
"exp": 1750201256.49,
"iat": 1747609256.49,
"sso": false
}*/
let jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MjMxMjk0MDUsImVtYWlsIjoiZWNoZWxvbkBnbWFpbC5jb20iLCJleHAiOjE3NTAyMDEyNTYuNDksImlhdCI6MTc0NzYwOTI1Ni40OSwic3NvIjpmYWxzZX0.UxbJozIiHSApqI8_Vl7o2d7q7CzqpXIzsZoazCtY75s";
let claims = JwtClaims::parse_claims(jwt).unwrap();
assert_eq!(claims.id, "23129405");
assert_eq!(claims.email, "echelon@gmail.com");
assert_eq!(claims.sso, false);
assert_eq!(claims.created.timestamp(), 1747609256);
assert_eq!(claims.expiration.timestamp(), 1750201256);
}
}
@@ -0,0 +1,3 @@
pub mod credentials;
pub mod jwt_bearer_token;
pub mod jwt_claims;
@@ -0,0 +1,39 @@
use jwt_light::error::JwtError;
use reqwest::StatusCode;
use std::error::Error;
use std::fmt::{Display, Formatter};
#[derive(Debug)]
pub enum ApiError {
/// Inability to parse JWT.
JwtError(JwtError),
/// An error that occurred as or after the request was sent.
ReqwestError(reqwest::Error),
/// Some other failure response.
UncategorizedBadResponse{
status_code: StatusCode,
message : String,
}
}
impl Display for ApiError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
ApiError::JwtError(err) => write!(f, "JwtError: {}", err),
ApiError::ReqwestError(err) => write!(f, "ReqwestError: {}", err),
ApiError::UncategorizedBadResponse { status_code, message } => {
write!(f, "UncategorizedBadResponse: {:?} : {}", status_code, message)
}
}
}
}
impl Error for ApiError {}
impl From<JwtError> for ApiError {
fn from(err: JwtError) -> Self {
ApiError::JwtError(err)
}
}
@@ -0,0 +1,17 @@
use crate::error::api_error::ApiError;
use crate::error::runwayml_error::RunwayMlError;
/// This assumes the request failed and returned a non-200.
/// The caller should check.
pub async fn classify_http_error_response(response: reqwest::Response) -> RunwayMlError {
let status = response.status();
let message = match response.text().await {
Ok(text) => text,
Err(err) => return RunwayMlError::Api(ApiError::ReqwestError(err)),
};
// TODO: Handle various error messages from the API.
//anyhow::anyhow!("Upload failed with status {}: {}", status, message))
RunwayMlError::Api(ApiError::UncategorizedBadResponse { status_code: status, message })
}
@@ -0,0 +1,22 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
#[derive(Debug)]
pub enum ClientError {
/// No JWT bearer token in the credentials
NoJwtBearerToken,
/// An error that occurred clientside before the request was sent.
ReqwestError(reqwest::Error),
}
impl Display for ClientError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
ClientError::NoJwtBearerToken => write!(f, "No JWT bearer token in the credentials"),
ClientError::ReqwestError(err) => write!(f, "Reqwest error: {}", err),
}
}
}
impl Error for ClientError {}
@@ -0,0 +1,4 @@
pub mod api_error;
pub mod classify_http_error_response;
pub mod client_error;
pub mod runwayml_error;
@@ -0,0 +1,33 @@
use crate::error::api_error::ApiError;
use crate::error::client_error::ClientError;
use std::error::Error;
use std::fmt::{Display, Formatter};
#[derive(Debug)]
pub enum RunwayMlError {
Api(ApiError),
Client(ClientError),
}
impl Display for RunwayMlError{
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::Api(err) => write!(f, "ApiError: {}", err),
Self::Client(err) => write!(f, "ClientError: {}", err),
}
}
}
impl Error for RunwayMlError {}
impl From<ApiError> for RunwayMlError {
fn from(err: ApiError) -> Self {
RunwayMlError::Api(err)
}
}
impl From<ClientError> for RunwayMlError {
fn from(err: ClientError) -> Self {
RunwayMlError::Client(err)
}
}
@@ -0,0 +1,5 @@
/// TODO DOCS
pub mod creds;
pub mod error;
pub mod requests;
@@ -0,0 +1,158 @@
use crate::creds::credentials::Credentials;
use crate::error::api_error::ApiError;
use crate::error::classify_http_error_response::classify_http_error_response;
use crate::error::client_error::ClientError;
use crate::error::runwayml_error::RunwayMlError;
use log::{debug, error, warn};
use reqwest::Client;
use serde_derive::{Deserialize, Serialize};
const GENERATE_URL: &str = "https://api.runwayml.com/v1/tasks";
// TODO: This endpoint is not implemented yet.
// TODO: This endpoint is not implemented yet.
// TODO: This endpoint is not implemented yet.
#[derive(Serialize)]
struct TaskRequest {
/// eg. "gen4"
#[serde(rename = "taskType")]
task_type: String,
/// eg. false
internal: bool,
/// inner request payload
options: TaskRequestOptions,
/// eg. 23129405
#[serde(rename = "asTeamId")]
as_team_id: u64,
/// eg. fc66957b-2651-4362-aca0-5468e4ee61b0
#[serde(rename = "sessionId")]
session_id: String,
}
#[derive(Serialize)]
struct TaskRequestOptions {
/// eg. Gen-4 3438043024
name: String,
/// eg. 3438043024
seed: u64,
/// Probably whether the request is free (exploreMode = unlimited queue)
#[serde(rename = "exploreMode")]
explore_mode: bool,
/// eg. 5
seconds: u8,
/// eg. false
watermark: bool,
/// This can be blank / empty string!
text_prompt: String,
/// eg. "i2v"
route: String,
/// A URL, eg. https://d2jqrm6oza8nb6.cloudfront.net/datasets/586d77ff-b33b-4100-b4d3-c04f8b046b00.jpg?_jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJrZXlIYXNoIjoiNTJhMTY0N2RkZjFjMGU3NCIsImJ1Y2tldCI6InJ1bndheS1kYXRhc2V0cyIsInN0YWdlIjoicHJvZCIsImV4cCI6MTc0Nzc4NTYwMH0.N_TztqlU2pzPegOq7e8tn_6zb16OLmDmerlTtfr_-FI
init_image: String,
/// eg. 1280
width: u16,
/// eg. 720
height: u16,
/// eg. b5c42f29-d63d-4124-88c0-019ab1299829
#[serde(rename = "assetGroupId")]
asset_group_id: String,
}
pub async fn gen4_image_to_video(
credentials: &Credentials,
) -> Result<(), RunwayMlError> {
let jwt = match credentials.jwt_bearer_token.as_ref() {
Some(token) => token,
None => {
error!("Failed to generate bearer token. No JWT bearer token found.");
return Err(ClientError::NoJwtBearerToken.into());
}
};
let endpoint_request = TaskRequest {
task_type: "gen4".to_string(),
internal: false,
options: TaskRequestOptions {
name: "Gen-4 3438043025".to_string(),
seed: 3438043025,
explore_mode: true,
seconds: 5,
watermark: false,
text_prompt: "".to_string(),
route: "i2v".to_string(),
init_image: "https://d2jqrm6oza8nb6.cloudfront.net/datasets/586d77ff-b33b-4100-b4d3-c04f8b046b00.jpg?_jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJrZXlIYXNoIjoiNTJhMTY0N2RkZjFjMGU3NCIsImJ1Y2tldCI6InJ1bndheS1kYXRhc2V0cyIsInN0YWdlIjoicHJvZCIsImV4cCI6MTc0Nzc4NTYwMH0.N_TztqlU2pzPegOq7e8tn_6zb16OLmDmerlTtfr_-FI".to_string(),
width: 1280,
height: 720,
asset_group_id: "b5c42f29-d63d-4124-88c0-019ab1299829".to_string(),
},
as_team_id: 23129405,
session_id: "fc66957b-2651-4362-aca0-5468e4ee61b0".to_string(),
};
let client = Client::builder()
.gzip(true)
.build()
.map_err(|err| {
error!("Failed to create HTTP client: {}", err);
RunwayMlError::Client(ClientError::ReqwestError(err))
})?;
let http_request = client.post(GENERATE_URL)
.header("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:137.0) Gecko/20100101 Firefox/137.0")
.header("Accept", "application/json")
.header("Accept-Encoding", "gzip, deflate, br")
.header("Accept-Language", "en-US,en;q=0.5")
.header("Content-Type", "application/json")
.header("Authorization", jwt.to_authorization_header_value());
let http_request = http_request.json(&endpoint_request)
.build()
.map_err(|err| RunwayMlError::Client(ClientError::ReqwestError(err)))?;
let response = client.execute(http_request)
.await
.map_err(|err| RunwayMlError::Api(ApiError::ReqwestError(err)))?;
let status = response.status();
if !status.is_success() {
warn!("Gen4 image to video failure. Status: {:?}", status);
let error = classify_http_error_response(response).await;
return Err(error);
}
debug!("Gen4 I2V returned a 200.");
let response_body = &response
.text()
.await
.map_err(|err| RunwayMlError::Api(ApiError::ReqwestError(err)))?;
debug!("Response: {}", response_body);
//if &response_body == "null" {
// error!("Failed to generate bearer token. Response was `null`.");
// return Err(SoraError::FailedToGenerateBearer)
//}
//let auth_response: SoraAuthResponse = serde_json::from_str(&response_body)?;
// TODO: This endpoint is not implemented yet.
// TODO: This endpoint is not implemented yet.
// TODO: This endpoint is not implemented yet.
todo!("This is not yet implemented")
}
#[cfg(test)]
mod tests {
use errors::AnyhowResult;
#[tokio::test]
#[ignore]
async fn send_test_request() -> AnyhowResult<()> {
Ok(())
}
}
@@ -0,0 +1 @@
pub mod gen4_image_to_video;
+34
View File
@@ -0,0 +1,34 @@
[package]
name = "build_metadata"
resolver = "2" # NB: Required by `build-info`
edition = "2024"
version = "0.0.1"
authors = [
"Brandon Thomas <bt@brand.io>",
"Brandon Thomas <echelon@gmail.com>",
]
publish = false
[lib]
name = "build_metadata"
path = "src/lib.rs"
[package.metadata.cargo-udeps.ignore]
normal = ["workspace-build-acceleration"]
[dependencies]
# Workspace hack (faster compile times)
workspace-build-acceleration = { workspace = true }
# Internal
# (None yet)
# External
build-info = "0.0.40"
[dev-dependencies]
# None yet
[build-dependencies]
build-info-build = "0.0.40"
+8
View File
@@ -0,0 +1,8 @@
fn main() {
// Tell Cargo that if the given file changes, to rerun this build script.
println!("cargo::rerun-if-changed=Cargo.toml");
// Make build info available in the main crate sources.
build_info_build::build_script();
}
+43
View File
@@ -0,0 +1,43 @@
use std::ops::Deref;
use build_info::chrono::{DateTime, Utc};
build_info::build_info!(fn version);
pub fn build_timestamp() -> DateTime<Utc>{
version().timestamp
}
pub fn git_commit_id() -> Option<&'static str> {
version().version_control
.as_ref()
.map(|vc| vc.git())
.flatten()
.map(|git| git.commit_id.deref())
}
pub fn git_commit_short_id() -> Option<&'static str> {
version().version_control
.as_ref()
.map(|vc| vc.git())
.flatten()
.map(|git| git.commit_short_id.deref())
}
pub fn git_commit_timestamp() -> Option<DateTime<Utc>> {
version().version_control
.as_ref()
.map(|vc| vc.git())
.flatten()
.map(|git| git.commit_timestamp)
}
#[cfg(test)]
mod tests {
use crate::git_commit_id;
#[test]
#[ignore] // Run manually for testing, not in CI
fn test_git_commit_id() {
assert_eq!(git_commit_id(), None);
}
}
+4 -4
View File
@@ -17,13 +17,13 @@ path = "src/lib.rs"
normal = ["workspace-build-acceleration"]
[dependencies]
# cargo-hakari workspace hack (faster compile times)
workspace-build-acceleration = { workspace = true }
# NB: This is one of the most central crates in the monorepo and everything depends on it.
# Consider the dependencies here wisely.
# Ideally we have almost nothing as a dependency of this crate.
anyhow = "1.0.86"
# Workspace hack (faster compile times)
workspace-build-acceleration = { version = "0.1", path = "../../workspace-build-acceleration" }
anyhow = { workspace = true }
[dev-dependencies]
# None yet
+28
View File
@@ -0,0 +1,28 @@
[package]
name = "jwt_light"
edition = "2024"
version = "0.0.1"
publish = false
[package.metadata.cargo-udeps.ignore]
normal = ["workspace-build-acceleration"]
[lib]
name = "jwt_light"
path = "src/lib.rs"
[dependencies]
# Workspace hack (faster compile times)
workspace-build-acceleration = { workspace = true }
# Internal
# (None)
# External
base64 = { workspace = true }
chrono = { workspace = true}
serde_json = { workspace = true }
[dev-dependencies]
# (None)
+109
View File
@@ -0,0 +1,109 @@
use crate::error::JwtError;
use crate::utils::json_number_to_datetime::json_number_to_datetime;
use chrono::{DateTime, Utc};
use serde_json::Value;
pub struct CommonClaims {
/// From the "iat" field
pub created: DateTime<Utc>,
/// From the "exp" field
pub expiration: DateTime<Utc>,
}
impl CommonClaims {
pub fn from_json(raw_json_claims: &str) -> Result<Self, JwtError> {
let claims : serde_json::Map<String, Value> = serde_json::from_str(&raw_json_claims)
.map_err(|err| JwtError::CommonFieldError(
format!("failure to parse claims json: {:?}", err)))?;
// NB: Some JWTs have "iat" and "exp" as integer numbers, some have them as floats
let iat = {
let iat = claims.get("iat")
.map(|val| val.as_number())
.flatten()
.ok_or_else(|| JwtError::CommonFieldError("no iat claim".to_string()))?;
json_number_to_datetime(iat, "iat")?
};
let exp = {
let exp = claims.get("exp")
.map(|val| val.as_number())
.flatten()
.ok_or_else(|| JwtError::CommonFieldError("no exp claim".to_string()))?;
json_number_to_datetime(exp, "exp")?
};
Ok(Self {
created: iat,
expiration: exp,
})
}
}
#[cfg(test)]
mod tests {
use crate::common_claims::CommonClaims;
use crate::utils::raw_jwt_to_raw_json::raw_jwt_to_raw_json;
use chrono::DateTime;
#[test]
fn test_runwayml() {
let jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MjMxMjk0MDUsImVtYWlsIjoiZWNoZWxvbkBnbWFpbC5jb20iLCJleHAiOjE3NTAyMDEyNTYuNDksImlhdCI6MTc0NzYwOTI1Ni40OSwic3NvIjpmYWxzZX0.UxbJozIiHSApqI8_Vl7o2d7q7CzqpXIzsZoazCtY75s";
let json = raw_jwt_to_raw_json(jwt).unwrap();
let claims = CommonClaims::from_json(&json).unwrap();
// jwt payload:
// {
// "id": 23129405,
// "email": "echelon@gmail.com",
// "exp": 1750201256.49,
// "iat": 1747609256.49,
// "sso": false
// }
assert_eq!(claims.created, DateTime::from_timestamp(1747609256, 0).unwrap());
assert_eq!(claims.expiration, DateTime::from_timestamp(1750201256, 0).unwrap());
}
#[test]
fn test_sora() {
let token =
"eyJhbGciOiJSUzI1NiIsImtpZCI6IjE5MzQ0ZTY1LWJiYzktNDRkMS1hOWQwLWY5NTdiMDc5YmQwZSIsInR5cCI6Ik\
pXVCJ9.eyJhdWQiOlsiaHR0cHM6Ly9hcGkub3BlbmFpLmNvbS92MSJdLCJjbGllbnRfaWQiOiJhcHBfTTFuUTN0UjV2\
VzdYOWpMMnBFNmdIOGRLNCIsImV4cCI6MTc0NTgwMDQ3MiwiaHR0cHM6Ly9hcGkub3BlbmFpLmNvbS9hdXRoIjp7InV\
zZXJfaWQiOiJ1c2VyLTZOeEpmbEFIb0VCREp6Wmw5aVhocWJERyJ9LCJodHRwczovL2FwaS5vcGVuYWkuY29tL3Byb2\
ZpbGUiOnsiZW1haWwiOiJ2b2NvZGVzMjAyMEBnbWFpbC5jb20iLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZX0sImlhdCI6M\
Tc0NDkzNjQ3MSwiaXNzIjoiaHR0cHM6Ly9hdXRoLm9wZW5haS5jb20iLCJqdGkiOiI0NTZmZjY5Yi0xZTZhLTQzOWYt\
YTYzMC1jNmFjYzI3NzU1YjAiLCJuYmYiOjE3NDQ5MzY0NzEsInB3ZF9hdXRoX3RpbWUiOjE3NDQwNTkzMTc3NTAsInN\
jcCI6WyJvcGVuaWQiLCJlbWFpbCIsInByb2ZpbGUiLCJvZmZsaW5lX2FjY2VzcyJdLCJzZXNzaW9uX2lkIjoiYXV0aH\
Nlc3NfcEE4UGl3V0trNmJsSE1qQnVCQnVSRmh2Iiwic3ViIjoiZ29vZ2xlLW9hdXRoMnwxMTMxMDE5Njc2MTIzOTY3O\
TM3NzcifQ.JOxYfrQSIsHdf-VN1zcOqDOS3tF1HpUYnRVfPKgi65am3sNiniOM1kWTNrKE5-sh4DaZ33G_XniGkhg-9\
QKF8tspXpZ6BnTNkf-QdGqR_v89AIO8pZgQ7oECLXhnmg-31G-LAHlGfwWedJ6qPVy4dL5zoR0wVyoQ8gYv5QzQC-zd\
IF7uY-umKKloAAYP_tkKLlQHackjTynN-bpu2mKv55-h7a3hSEPfxvgX0SsvL69xtNsZkwb43bqyP1x_c2ErfYoPyzg\
rqgMIRZjCw9TI33vISKM_BfQXBiZT_BHkX4s0Jng3ph4vrwvnBsa0HpNxBhQYKb1u9gZXs7XjLZhjZJZsDTQI1V4Wov\
iAL7ihvZkLxJRtDCOmX7-5BTEsMInFgXUAhCDhQUMwSfLLJuinBS96NGSX5_TfIVMkbCl-HWzdQx7KZAsahNe_CmuYT\
lZH32NuRn78ohY4eViRyfC8OXVLwoLxxpen2CWlV2dxV5wwXeij_kHt5wOXSGYpSfxz37cK0CaK8K2pwudasxQ0lv2V\
_Xx_z4UaEOr5wqT_BK1A1HO4HRjvFX2soF-qm4GCSWAbmFZs7DVEClrotxRx2romifRVb8XRia2M6YTmDyIT0YphFgG\
nnrDIZNKO23uDYT60LVMVYpCCY2RiwxCR9bn7AeGbmi7cpL9aYZ5uJRM";
let json = raw_jwt_to_raw_json(token).unwrap();
// jwt payload :
// - "https://api.openai.com/auth".user_id = "user-6NxJflAHoEBDJzZl9iXhqbDG"
// - "https://api.openai.com/profile".email = "vocodes2020@gmail.com"
// - "https://api.openai.com/profile".email_verified = true
// - "iat": 1744936471, - issued at claim (5 days ago)
// - "nbf": 1744936471, - not before, don't use before date
// - "exp": 1745800472, - in 5 days, expiry
let claims = CommonClaims::from_json(&json).unwrap();
assert_eq!(claims.created, DateTime::from_timestamp(1744936471, 0).unwrap());
assert_eq!(claims.expiration, DateTime::from_timestamp(1745800472, 0).unwrap());
}
}
+51
View File
@@ -0,0 +1,51 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
#[derive(Debug)]
pub enum JwtError {
/// Generic error parsing the JWT
ParseError(String),
/// Error parsing the JWT JSON payload specifically
JsonParseError(serde_json::Error),
/// Error decoding the base64 payload
Base64DecodeError(base64::DecodeError),
/// Error decoding from UTF-8 (should be exceedingly rare)
Utf8Error(std::string::FromUtf8Error),
/// Error parsing a common field (iat, exp, etc.)
CommonFieldError(String),
/// Error parsing a custom claims field.
CustomClaimsFieldError(String),
}
impl Error for JwtError {}
impl Display for JwtError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::ParseError(reason) => { write!(f, "JwtError::ParseError : {}", reason) }
Self::JsonParseError(err) => { write!(f, "JwtError::JsonParseError : {:?}", err) }
Self::Base64DecodeError(err) => { write!(f, "JwtError::Base64DecodeError : {:?}", err) }
Self::Utf8Error(err) => { write!(f, "JwtError::Utf8Error : {:?}", err) }
Self::CommonFieldError(reason) => { write!(f, "JwtError::CommonFieldError : {}", reason) }
Self::CustomClaimsFieldError(reason) => { write!(f, "JwtError::CustomClaimsFieldError : {}", reason) }
}
}
}
impl From<serde_json::Error> for JwtError {
fn from(err: serde_json::Error) -> Self {
JwtError::JsonParseError(err)
}
}
impl From<base64::DecodeError> for JwtError {
fn from(err: base64::DecodeError) -> Self {
JwtError::Base64DecodeError(err)
}
}
impl From<std::string::FromUtf8Error> for JwtError {
fn from(err: std::string::FromUtf8Error) -> Self {
JwtError::Utf8Error(err)
}
}
+12
View File
@@ -0,0 +1,12 @@
//! Lightweight JWT library for Rust.
//! No cryptographic verification, no OpenSSL, etc.
//!
//! DO NOT USE THIS FOR OUR OWN JWT VERIFICATION !!!
//! It's okay to parse external vendors since we're not validating, but
//! do not ever use this to secure our own server.
pub (crate) mod utils;
pub mod common_claims;
pub mod error;
pub mod parse_jwt_claims_trait;
@@ -0,0 +1,27 @@
use crate::common_claims::CommonClaims;
use crate::error::JwtError;
use crate::utils::decode_base64::decode_base64;
use crate::utils::split_components::split_components;
use serde_json::Value;
/// Trait for simple JWT claims parsing
pub trait ParseJwtClaims : Sized {
/// Parse any special fields relevant to the struct
/// Implement this method to complete the parsing.
fn extract_claims(
common_claims: CommonClaims,
extra_claims: serde_json::Map<String, Value>,
) -> Result<Self, JwtError>;
fn parse_claims(raw_jwt: &str) -> Result<Self, JwtError> {
let [_header_str, claims_str, _signature_str] = split_components(raw_jwt)?;
let raw_json = decode_base64(claims_str)?;
let common_claims = CommonClaims::from_json(&raw_json)?;
let extra_claims : serde_json::Map<String, Value> = serde_json::from_str(&raw_json)?;
Self::extract_claims(common_claims, extra_claims)
}
}
@@ -0,0 +1,9 @@
use crate::error::JwtError;
use base64::prelude::BASE64_URL_SAFE_NO_PAD;
use base64::Engine;
pub fn decode_base64(raw: &str) -> Result<String, JwtError> {
let json_bytes = BASE64_URL_SAFE_NO_PAD.decode(raw)?;
let json_str = String::from_utf8(json_bytes.clone())?;
Ok(json_str)
}
@@ -0,0 +1,30 @@
use crate::error::JwtError;
use chrono::{DateTime, Utc};
use serde_json::Number;
// NB: Some JWTs have "iat" and "exp" as integer numbers, some have them as floats
pub (crate) fn json_number_to_datetime(timestamp: &Number, field_name: &str) -> Result<DateTime<Utc>, JwtError> {
let result;
if timestamp.is_i64() {
// Integer case
let timestamp = timestamp.as_i64()
.ok_or_else(|| JwtError::CommonFieldError(format!("{} is not an integer", field_name)))?;
result = DateTime::from_timestamp(timestamp, 0)
.ok_or_else(|| JwtError::CommonFieldError(format!("{} is not a valid timestamp", field_name)))?;
} else if timestamp.is_f64() {
// Float case
let timestamp = timestamp.as_f64()
.ok_or_else(|| JwtError::CommonFieldError(format!("{} is not an integer", field_name)))?;
let timestamp = timestamp.round() as i64;
result = DateTime::from_timestamp(timestamp, 0)
.ok_or_else(|| JwtError::CommonFieldError(format!("{} is not a valid timestamp", field_name)))?;
} else {
return Err(JwtError::CommonFieldError(format!("{} is not a number", field_name)));
}
Ok(result)
}
+4
View File
@@ -0,0 +1,4 @@
pub (crate) mod decode_base64;
pub (crate) mod json_number_to_datetime;
pub (crate) mod raw_jwt_to_raw_json;
pub (crate) mod split_components;
@@ -0,0 +1,9 @@
use crate::error::JwtError;
use crate::utils::decode_base64::decode_base64;
use crate::utils::split_components::split_components;
pub (crate) fn raw_jwt_to_raw_json(raw_jwt: &str) -> Result<String, JwtError> {
let [_header_str, claims_str, _signature_str] = split_components(raw_jwt)?;
let raw_json = decode_base64(claims_str)?;
Ok(raw_json)
}
@@ -0,0 +1,25 @@
use crate::error::JwtError;
const SEPARATOR: &str = ".";
pub fn split_components(token: &str) -> Result<[&str; 3], JwtError> {
let mut components = token.split(SEPARATOR);
let header = components.next()
.ok_or_else(|| jwt_error("no header component"))?;
let claims = components.next()
.ok_or_else(|| jwt_error("no claims component"))?;
let signature = components.next()
.ok_or_else(|| jwt_error("no signature component"))?;
if components.next().is_some() {
return Err(jwt_error("too many components"));
}
Ok([header, claims, signature])
}
fn jwt_error(reason: &str) -> JwtError {
JwtError::ParseError(reason.to_string())
}
+3 -3
View File
@@ -16,12 +16,12 @@ path = "src/lib.rs"
normal = ["workspace-build-acceleration"]
[dependencies]
# cargo-hakari workspace hack (faster compile times)
workspace-build-acceleration = { workspace = true }
# Internal
# (None yet)
# Workspace hack (faster compile times)
workspace-build-acceleration = { version = "0.1", path = "../../workspace-build-acceleration" }
# External
# (None yet)
Binary file not shown.

After

Width:  |  Height:  |  Size: 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";
@@ -39,8 +45,6 @@ const appTabs: TabItem[] = [
{ id: "VIDEO", label: "Video" },
];
import { signal } from "@preact/signals-react";
export const topNavMediaId = signal<string>("");
export const topNavMediaUrl = signal<string>("");
@@ -56,8 +60,6 @@ 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>("");
@@ -110,6 +112,11 @@ export const TopBar = ({
}
};
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">
@@ -161,9 +168,9 @@ export const TopBar = ({
<Button
variant="secondary"
icon={faImages}
onClick={() => setIsLibraryModalOpen(true)}
onClick={handleOpenGalleryModal}
>
My Gallery
My Library
</Button>
<Activity />
@@ -182,16 +189,7 @@ export const TopBar = ({
/>
<GalleryModal
isOpen={isLibraryModalOpen}
onClose={() => setIsLibraryModalOpen(false)}
mode="view"
tabs={[
{ id: "my-media", label: "Generations" },
{ id: "uploads", label: "Uploads" },
{ id: "videos", label: "Videos" },
]}
activeTab={activeLibraryTab}
onTabChange={setActiveLibraryTab}
onDownloadClicked={downloadFileFromUrl}
onAddToSceneClicked={handleAddToScene}
/>
@@ -1,160 +0,0 @@
import { useEffect, useState } from "react";
import { v4 as uuidv4 } from "uuid";
import { faCirclePlus, faSpinnerThird } from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
Button,
FileUploader,
H2,
TransitionDialogue,
WaveformPlayer,
} from "~/components";
import { MediaUploadApi } from "~/Classes/ApiManager/MediaUploadApi";
import { AUDIO_FILE_TYPE, ToastTypes } from "~/enums";
export const FILE_TYPES = Object.keys(AUDIO_FILE_TYPE);
import { getFileName } from "~/utilities";
import { addToast } from "~/signals";
type ComponentState = {
isOpen: boolean;
file: File | undefined;
audioUrl: string;
uploadState: "init" | "staged" | "uploading" | "uploaded" | "error";
fileToken: string | undefined;
};
const initialValues: ComponentState = {
isOpen: false,
file: undefined,
audioUrl: "",
uploadState: "init",
fileToken: undefined,
};
export const UploadAudioButtonDialogue = ({
onUploaded,
}: {
onUploaded: (fileToken: string) => void;
}) => {
const [{ isOpen, file, audioUrl, uploadState, fileToken }, setState] =
useState<ComponentState>(initialValues);
const closeModal = () => setState(initialValues);
const openModal = () => setState((curr) => ({ ...curr, isOpen: true }));
const handleFileChange = (file: File) => {
setState((curr) => ({
...curr,
file: file,
audioUrl: URL.createObjectURL(file),
uploadState: "staged",
}));
};
const handleClear = () => {
setState({
...initialValues,
isOpen: true,
});
};
const submitUpload = async () => {
if (!file) {
//TODO: Maybe more verbose on not having file
return;
}
setState((curr) => ({
...curr,
uploadState: "uploading",
}));
const mediaApi = new MediaUploadApi();
const request = {
blob: file,
fileName: getFileName(file),
uuid: uuidv4(),
maybe_title: getFileName(file),
};
const response = await mediaApi.UploadAudio(request);
if (response.success && response.data) {
setState((curr) => ({
...curr,
uploadState: "uploaded",
fileToken: response.data,
}));
return;
}
//handle errors
addToast(
ToastTypes.ERROR,
response.errorMessage || "Unknown Error: Upload Audio",
);
setState((curr) => ({
...curr,
uploadState: "staged",
}));
};
useEffect(() => {
if (fileToken && uploadState === "uploaded") {
//calling the callback on a successful upload
onUploaded(fileToken);
}
}, [fileToken, uploadState, onUploaded]);
return (
<>
<Button
className="grow py-3 text-sm font-medium"
variant="action"
icon={faCirclePlus}
type="button"
onClick={openModal}
>
Upload Audio
</Button>
<TransitionDialogue
isOpen={isOpen}
onClose={closeModal}
title={<H2>Upload Audio</H2>}
>
<div className="flex w-full flex-col gap-3">
<FileUploader
file={file}
fileTypes={FILE_TYPES}
handleChange={handleFileChange}
/>
{file && (
<div className="flex items-center gap-3 rounded-lg bg-brand-secondary p-3">
<div className="grow">
<WaveformPlayer audio={audioUrl} hasPlayButton />
</div>
</div>
)}
<div className="flex justify-end gap-3">
<Button variant="secondary" onClick={closeModal}>
{uploadState === "uploaded" ? "Close" : "Cancel"}
</Button>
{uploadState !== "uploaded" && (
<Button
onClick={submitUpload}
disabled={uploadState !== "staged"}
>
Upload
{uploadState === "uploading" && (
<FontAwesomeIcon icon={faSpinnerThird} spin />
)}
</Button>
)}
{uploadState === "uploaded" && (
<Button onClick={handleClear}>Upload Another</Button>
)}
</div>
</div>
</TransitionDialogue>
</>
);
};
@@ -1 +0,0 @@
export { UploadAudioButtonDialogue } from "./UploadAudioButtonDialogue";
@@ -1,202 +0,0 @@
import { useCallback, useState } from "react";
import { FileUploader } from "react-drag-drop-files";
import { v4 as uuidv4 } from "uuid";
import {
faCheck,
faCircleXmark,
faFileArrowUp,
faFileAudio,
faSpinnerThird,
faXmark,
} from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button, ButtonIcon, P, Tooltip, WaveformPlayer } from "~/components";
import { MediaUploadApi } from "~/Classes/ApiManager";
import { getFileName } from "~/utilities";
import { AUDIO_FILE_TYPE, ToastTypes } from "~/enums";
import { addToast } from "~/signals";
const FILE_TYPES = Object.keys(AUDIO_FILE_TYPE);
interface Props {
onFileStaged?: (file: File) => void;
onClear?: () => void;
onFileUploaded: (token: string) => void;
}
export const UploadAudioComponent = ({
onFileStaged,
onClear,
onFileUploaded,
}: Props) => {
const [{ file, uploadState }, setState] = useState<{
file: File | undefined;
uploadState: "none" | "staged" | "uploading" | "uploaded" | "error";
}>({
file: undefined,
uploadState: "none",
});
const audioUrl = file ? URL.createObjectURL(file) : "";
const handleChange = (file: File) => {
setState((curr) => ({
...curr,
file: file,
uploadState: "staged",
}));
if (onFileStaged) {
onFileStaged(file);
}
};
const handleClear = () => {
setState((curr) => ({
...curr,
file: undefined,
uploadState: "none",
uploadToken: undefined,
}));
if (onClear) {
onClear();
}
};
const handleUploadFile = useCallback(
async (file: File) => {
setState((curr) => ({ ...curr, uploadState: "uploading" }));
const mediaUploadApi = new MediaUploadApi();
const request = {
blob: file,
fileName: getFileName(file),
uuid: uuidv4(),
maybe_title: getFileName(file),
};
const response = await mediaUploadApi.UploadAudio(request);
if (response.success && response.data) {
setState((curr) => ({
...curr,
uploadState: "uploaded",
}));
onFileUploaded(response.data);
return;
}
//handle errors
addToast(
ToastTypes.ERROR,
response.errorMessage || "Unknown Error: Upload Audio",
);
setState((curr) => ({
...curr,
uploadState: "staged",
}));
},
[onFileUploaded],
);
return (
<div className="flex flex-col gap-3">
{/* Usage refer to https://github.com/KarimMokhtar/react-drag-drop-files */}
<FileUploader
handleChange={handleChange}
name="file"
types={FILE_TYPES}
maxSize={50}
>
<DragAndDropZone file={file} />
</FileUploader>
{file && (
<>
<div className="flex items-center gap-3 rounded-lg bg-brand-secondary p-3">
<div className="grow">
<WaveformPlayer audio={audioUrl} hasPlayButton />
</div>
<Tooltip content="Remove" position="top">
<ButtonIcon
icon={faXmark}
onClick={handleClear}
className="h-auto w-auto bg-transparent p-0 text-xl opacity-60 hover:bg-transparent hover:opacity-90"
/>
</Tooltip>
</div>
<div className="flex gap-2">
<Button
className="w-full py-2.5"
variant="action"
onClick={() => {
if (!file) {
//TODO: Maybe more verbose on not having file
return false;
}
handleUploadFile(file);
}}
disabled={uploadState !== "staged"}
icon={
uploadState === "uploaded"
? faCheck
: uploadState === "error"
? faCircleXmark
: faFileArrowUp
}
>
{uploadState === "uploaded"
? "Uploaded"
: uploadState === "error"
? "Upload Error"
: "Upload Audio"}
{uploadState === "uploading" && (
<FontAwesomeIcon icon={faSpinnerThird} spin />
)}
</Button>
</div>
</>
)}
</div>
);
};
const DragAndDropZone = ({ file }: { file: File | undefined }) => {
const fileSize =
file && file.size >= 1024 * 1024
? (file.size / 1024 / 1024).toFixed(2) + " MB"
: file
? `${Math.floor(file.size / 1024)} KB`
: null;
const fileName =
file && file.name ? file.name.split(".")[0].toUpperCase() : "";
if (!file) {
return (
<div className="flex cursor-pointer items-center gap-3.5 rounded-lg border border-dashed border-[#363636] bg-brand-secondary p-3">
<FontAwesomeIcon icon={faFileArrowUp} className="text-4xl" />
<div className="flex flex-col gap-0">
<P className="font-medium">
<u>Upload a file</u> or drop it here
</P>
<P className="flex items-center gap-2 text-sm font-normal opacity-50">
{FILE_TYPES.join(", ").toString()} supported
</P>
</div>
</div>
);
} else {
return (
<div className="flex cursor-pointer items-center gap-3.5 rounded-lg border border-dashed border-[#363636] bg-brand-secondary p-3">
<FontAwesomeIcon icon={faFileAudio} className="text-4xl" />
<div className="flex flex-col gap-0">
<P className="font-medium">
{file.name.slice(0, file.name.lastIndexOf("."))}
</P>
<P className="flex items-center gap-2 text-sm font-normal">
<span className="opacity-50">
{`${fileName} file size: ${fileSize} `}
</span>
<u className="transition-all hover:text-white/80">Change File</u>
</P>
</div>
</div>
);
}
};
@@ -1 +0,0 @@
export { UploadAudioComponent } from "./UploadAudioComponent";
@@ -1,8 +1,3 @@
export * from "./ErrorDialog";
export * from "./Toaster";
export * from "./TopBar";
export * from "./PremiumLock";
// all the Upload stuff will depend on the API Manager Signal
export * from "./UploadAudioButtonDialogue";
export * from "./UploadAudioComponent";
+2 -10
View File
@@ -10,7 +10,7 @@ import "@fortawesome/fontawesome-svg-core/styles.css";
import { config } from "@fortawesome/fontawesome-svg-core";
import EnvironmentVariables from "~/Classes/EnvironmentVariables";
import { pageHeight, pageWidth, persistLogin } from "~/signals";
import { showWizard } from "~/pages/PageEnigma/Wizard/signals/wizard";
import { posthog } from "posthog-js";
config.autoAddCss = false; /* eslint-disable import/first */
@@ -55,17 +55,9 @@ const GlobalSettingsManager = ({ env }: { env: Record<string, string> }) => {
pageWidth.value = window.innerWidth;
}
function initWizard() {
if (showWizard.value) {
return;
}
const wizard = localStorage.getItem("storyteller-wizard");
showWizard.value = wizard ? "" : "initial";
localStorage.setItem("storyteller-wizard", "shown");
}
useEffect(() => {
initWizard();
setPage();
window.addEventListener("resize", setPage);
return () => {
+2 -1
View File
@@ -13,7 +13,8 @@ import { AUTH_STATUS } from "~/enums";
import { authentication, login, logout } from "~/signals";
import { createRoot } from "react-dom/client";
import { Button, Input, ConfirmationModal } from "~/components";
import { Input, ConfirmationModal } from "~/components";
import { Button } from "@storyteller/ui-button";
import "./styles/normalize.css";
import "./styles/tailwind.css";
@@ -92,7 +92,7 @@ export const DialogEditText = ({
return (
<Modal isOpen={isOpen} onClose={closeCallback} className="w-fit max-w-2xl">
<div className="flex flex-col gap-3">
<DialogTitle className="text-2xl font-bold">Edit Text</DialogTitle>
<h2 className="text-2xl font-bold">Edit Text</h2>
<TextEditor
text={text}
formatData={textFormatData}
@@ -1,5 +1,5 @@
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
import { Button } from "~/components/ui/Button";
import { Button } from "@storyteller/ui-button";
import { useState, useRef } from "react";
export interface LabeledSelectProps {
@@ -1,41 +0,0 @@
import { faArrowsRotate } from "@fortawesome/pro-solid-svg-icons";
import { Button } from "@storyteller/ui-button";
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
export interface ToggleButtonProps {
label?: string;
value: string;
onChange: (value: string) => void;
options?: string[];
icons?: { [key: string]: IconDefinition };
}
export const ToggleButton = ({
label,
value,
onChange,
options = ["1", "2", "3"],
icons,
}: ToggleButtonProps) => {
const handleClick = () => {
const currentIndex = options.indexOf(value);
const nextIndex = (currentIndex + 1) % options.length;
onChange(options[nextIndex]);
};
return (
<Button
icon={icons?.[value] ?? faArrowsRotate}
variant="secondary"
className="glass px-3 py-1.5"
onClick={handleClick}
>
{label && (
<>
{label} <span className="opacity-60">—</span>
</>
)}{" "}
{value}
</Button>
);
};
@@ -0,0 +1,76 @@
import { Object3D, ObjectLoader } from "three";
export class BoneJSONHelper {
model: Object3D;
constructor(model: Object3D) {
this.model = model;
}
private findHipBone(objectGroup: Object3D[]): Object3D | undefined {
let hipBone = undefined;
for (const child of objectGroup) {
if (child.type === "Bone" && child.name.toLowerCase().includes("mixamorighips")) {
hipBone = child;
break;
}
}
return hipBone;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
public toJSON(): any {
if (!this.model.userData.isCharacter) {
return;
}
if (this.model.children.length < 1) {
return undefined;
}
// Find the hip bone
const hipBone = this.findHipBone(this.model.children[0].children);
if (!hipBone) {
console.error("No hip bone found in the model.");
return undefined;
}
return hipBone.toJSON();
}
/* Applies the bone tree in a recursive BFS manner */
private applyBoneTree(hipBone: Object3D, boneTree: Object3D): void {
hipBone.position.copy(boneTree.position);
hipBone.quaternion.copy(boneTree.quaternion);
hipBone.scale.copy(boneTree.scale);
const hipChildren = hipBone.children;
const treeChildren = boneTree.children;
// NOTE: Assumption made that the lengths match!
// This shouldn't be a problem unless the skeletal model is changed
for (let i = 0; i < hipChildren.length; i++) {
const hipChild = hipChildren[i];
const treeChild = treeChildren[i];
if (hipChild && treeChild) {
this.applyBoneTree(hipChild, treeChild);
}
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
public poseFromBoneJSON(boneJSON: any): void {
const hipBone = this.findHipBone(this.model.children[0].children);
if (!hipBone) {
return;
}
const loader = new ObjectLoader();
const boneTree = loader.parse(boneJSON);
this.applyBoneTree(hipBone, boneTree);
}
}
File diff suppressed because one or more lines are too long
@@ -1,261 +0,0 @@
import { ImageFormat } from "./video_audio_preprocessor";
import * as THREE from "three";
import { FrameWorkerManager } from "./workers/frame_worker_manager";
import { BlobReader, BlobWriter, ZipWriter } from "@zip.js/zip.js";
export interface IEngineFrameBuffers {
colorFrames: (Blob | null)[];
clearBuffer(name: BufferType): Promise<void>;
checkIfBufferIsEmpty(buffer: BufferType): Promise<boolean>;
countNonNullFrames(buffer: BufferType): Promise<number>;
}
export enum BufferType {
COLOR = "colorFrames",
}
export interface EngineFrameBuffersError {
message: string;
code: number;
}
export class EngineFrameBuffers implements IEngineFrameBuffers {
colorFrames: (Blob | null)[];
private numberOfFrames: number;
public hasCache: boolean;
public colorRenderQueue: Promise<Blob>[];
private webWorkers: boolean;
public payloadZip: Blob | null;
public frameWorkerManager: FrameWorkerManager | undefined;
// for the client side zip.
private zipFileWriter: BlobWriter | undefined;
private zipWriter: ZipWriter<Blob> | undefined;
private imageType = "image/jpeg";
constructor(width: number, height: number) {
this.colorFrames = [];
this.colorRenderQueue = [];
this.payloadZip = null;
this.hasCache = false;
this.zipFileWriter = undefined;
this.zipWriter = undefined;
//Check for SharedWorker support
if (typeof Worker === "undefined") {
console.error("Web Workers is not supported in this browser.");
this.webWorkers = false;
} else {
this.webWorkers = true;
console.error("Web Workers is supported in this browser.");
this.frameWorkerManager = new FrameWorkerManager(
width,
height,
"image/png",
);
}
this.webWorkers = false;
this.imageType = "image/jpeg";
if (this.webWorkers == false) {
this.zipFileWriter = new BlobWriter(this.imageType);
this.zipWriter = new ZipWriter(this.zipFileWriter);
}
this.numberOfFrames = 0;
}
async retrieveFrame(
rawRenderer: THREE.WebGLRenderer,
imageFormat: ImageFormat,
): Promise<Blob> {
return new Promise((resolve, reject) => {
rawRenderer.domElement.toBlob(
(blob) => {
if (blob) {
const reader = new FileReader();
reader.onloadend = () => {
//const imgData = reader.result as string;
// if needed as a base 64
resolve(blob);
};
reader.onerror = () => {
reject({
message: "Failed to Retrieve Frame and Serialize it.",
code: 100,
});
};
reader.readAsDataURL(blob);
}
},
imageFormat,
1.0,
);
});
}
async setRenderSurfaceSize(width: number, height: number) {
if (this.webWorkers && this.frameWorkerManager) {
this.frameWorkerManager.setCanvas(width, height);
}
}
async enqueueWork(
rawRenderer: THREE.WebGLRenderer,
scene: THREE.Scene,
camera: THREE.PerspectiveCamera,
): Promise<void> {
this.numberOfFrames += 1;
if (this.webWorkers && this.frameWorkerManager) {
this.frameWorkerManager.render(scene, camera, this.numberOfFrames);
} else {
const colorRenderTask = this.retrieveFrame(rawRenderer, ImageFormat.JPEG);
this.colorRenderQueue.push(colorRenderTask);
}
}
private downloadBlob(blob: Blob, filename: string): void {
// Create an object URL for the Blob
const url = URL.createObjectURL(blob);
// Create a temporary anchor element
const a = document.createElement("a");
a.href = url;
a.download = filename; // Specify the filename
// Append the anchor to the document body
document.body.appendChild(a);
// Trigger a click event on the anchor
a.click();
// Clean up by revoking the object URL and removing the anchor element
URL.revokeObjectURL(url);
document.body.removeChild(a);
}
async equeueAudio(audio: Blob) {}
async collectColorFrames(final_length: number): Promise<void> {
try {
// webworker requires the COEP.
if (this.webWorkers) {
console.time("ColorQueueRenderTime Worker Time");
if (this.webWorkers && this.frameWorkerManager) {
console.log(`Collected Number of Frames: ${final_length}`);
// this is sent off to video generation ...
this.payloadZip =
await this.frameWorkerManager.collectedFrames(final_length);
}
console.timeEnd("ColorQueueRenderTime Worker Time");
} else {
console.time("ColorQueueRenderTime");
try {
// going to have to zip here with the audio
if (this.webWorkers == false) {
console.log("Using Other Pathway.");
this.colorFrames = await Promise.all(this.colorRenderQueue);
console.log(this.colorFrames);
// Check if zipFileWriter or zipWriter is undefined or null
if (!this.zipFileWriter || !this.zipWriter) {
console.log("Failed to Startup Zip File Writer");
return;
}
this.colorFrames.forEach(async (blob, idx) => {
try {
if (!blob || !this.zipFileWriter) {
console.log("Failed to get Blob and Zip File Writer");
return;
}
//console.log(`Zipping Frame ${idx} to Zip`);
const name = String(idx).padStart(5, "0"); // '0009'
if (this.zipWriter) {
await this.zipWriter.add(`${name}.jpg`, new BlobReader(blob));
} else {
console.error("zipWriter is undefined");
}
} catch (error) {
console.log(error);
}
});
this.payloadZip = await this.zipWriter.close();
//this.downloadBlob(this.payloadZip, "test.zip");
}
} catch (error) {
console.log("Error in Collecting Color Frames", error);
}
console.timeEnd("ColorQueueRenderTime");
}
} catch (error) {
console.log(error);
}
this.hasCache = false;
}
// Testing function
public downloadImageBlob(blob: Blob, filename: string) {
// Create a URL for the blob
const url = URL.createObjectURL(blob);
// Create a temporary anchor element
const a = document.createElement("a");
a.href = url;
a.download = filename;
// Append the anchor to the document body
document.body.appendChild(a);
// Trigger a click event on the anchor
a.click();
// Remove the anchor from the document
document.body.removeChild(a);
// Revoke the object URL to free up memory
URL.revokeObjectURL(url);
}
async clearBuffer(name: BufferType): Promise<void> {
switch (name) {
case BufferType.COLOR:
this.colorFrames = [];
this.colorRenderQueue = [];
this.payloadZip = null;
this.numberOfFrames = 0;
// reset the writers
this.zipFileWriter = new BlobWriter(this.imageType);
this.zipWriter = new ZipWriter(this.zipFileWriter);
break;
default:
throw new Error("Invalid buffer name");
}
}
async countNonNullFrames(buffer: BufferType): Promise<number> {
return this[buffer].filter((frame) => frame !== null).length;
}
async checkIfBufferIsEmpty(buffer: BufferType): Promise<boolean> {
return this[buffer].every((frame) => frame === null);
}
async logBufferInfo(buffer: BufferType): Promise<void> {
const nonNullFrameCount = await this.countNonNullFrames(buffer);
console.log(
`Buffer Info - ${buffer}: ${nonNullFrameCount} non-null frames`,
);
}
}
@@ -1,15 +0,0 @@
// import { VideoAudioPreProcessor } from "app/pages/PageEnigma/Editor/VideoProcessor/video_audio_preprocessor.ts";
// const processor = new VideoAudioPreProcessor();
// // chrome://inspect/#workers debug shared worker.
// self.addEventListener("connect", function (event: MessageEvent) {
// const port = (event as MessageEvent & { ports: MessagePort[] }).ports[0];
// port.addEventListener("message", function (event: MessageEvent) {
// console.log("Message received from main script:", event.data);
// port.postMessage("Hello from the Shared Worker!");
// });
// port.start();
// });
@@ -1,31 +0,0 @@
class VideoProcessingSharedWorkerManager {
constructor() {
if (typeof SharedWorker !== "undefined") {
console.log("Shared Workers are supported.");
// You can safely create a Shared Worker here
const worker = new SharedWorker(
"app/pages/PageEnigma/Editor/VideoProcessor/shared_workers/video_shared_worker_manager.ts",
{ type: "module" },
);
// Get the port for communication
const port = worker.port;
// Listen for messages from the worker
port.addEventListener("message", function (event) {
console.log("Message received from worker:", event.data);
});
// Start the port
port.start();
// Send a message to the worker
port.postMessage("Hello from the main script!");
} else {
console.log("Shared Workers are not supported in this browser.");
}
}
async enqueueWork(frames: [string]) {}
async completedWork() {}
}
@@ -1,567 +0,0 @@
import { MediaFilesApi } from "~/Classes/ApiManager/MediaFilesApi";
import { BucketConfig } from "~/api/BucketConfig";
// import { createFFmpeg, fetchFile, FFmpeg } from "@ffmpeg/ffmpeg";
import { ClipUI } from "../../clips/clip_ui";
export interface VideoPreProcessorError {
message: string;
code: number;
}
export interface ProcessStatus {
message: string;
percent: string;
}
export interface StreamingProgressResponse<T> {
success: boolean;
errorMessage?: string;
data?: T;
}
interface VideoPreProcessorOutputProperties {
frameRate: number;
imageFormat: ImageFormat;
timelineTrackLengthSeconds: number;
}
export interface AudioBuffer {
buffer: AudioClipInfo[];
}
// This is needed from the audio clip
export interface AudioClipInfo {
media_id: string; // file location
offset: number; // in frames
length: number; // in frames
}
export enum ImageFormat {
JPEG = "image/jpeg",
PNG = "image/png",
}
// 44099.10498046875 for the image buffer loop and memory pressure.
export class VideoAudioPreProcessor {
// ffmpeg: FFmpeg;
properties: VideoPreProcessorOutputProperties;
onProgress: (response: StreamingProgressResponse<ProcessStatus>) => void;
mediaFilesApi: MediaFilesApi;
bucketConfig: BucketConfig;
constructor(
onProgress: (response: StreamingProgressResponse<ProcessStatus>) => void,
properties: VideoPreProcessorOutputProperties = {
frameRate: 60,
imageFormat: ImageFormat.PNG,
timelineTrackLengthSeconds: 7,
},
) {
// this.ffmpeg = createFFmpeg({ log: true, logger: (p) => console.log(p) });
this.properties = properties;
this.onProgress = onProgress;
this.mediaFilesApi = new MediaFilesApi();
this.bucketConfig = new BucketConfig();
}
// You have to call this early on because it can take a while to load from the CDN.
async initialize() {
// if (this.ffmpeg.isLoaded() == false) {
// await this.ffmpeg.load();
// console.log("Video Generation: Loaded FFMPEG");
// } else {
// console.log("Video Generation: Already Loaded");
// }
}
// This retrieves by creating dom elements
async removeInMemoryWavs(): Promise<void> {
try {
// const fileNames = this.ffmpeg.FS("readdir", "/");
// const extension = ".wav";
// for (const fileName of fileNames) {
// if (fileName.endsWith(extension)) {
// await this.ffmpeg.FS("unlink", fileName);
// }
// }
} catch (error) {
const processError: VideoPreProcessorError = {
message: "Failed to delete files from FFmpeg memory store:",
code: 101,
};
const errorMessage =
error instanceof Error ? error.message : String(error);
console.error(errorMessage);
throw processError;
}
}
async removeInMemoryImages(): Promise<void> {
try {
// const fileNames = this.ffmpeg.FS("readdir", "/");
const imageFormat = this.properties.imageFormat;
let extension = "";
if (imageFormat == ImageFormat.JPEG) {
extension = ".jpeg";
} else {
extension = ".png";
}
// for (const fileName of fileNames) {
// if (fileName.endsWith(extension)) {
// await this.ffmpeg.FS("unlink", fileName);
// }
// }
} catch (error) {
const processError: VideoPreProcessorError = {
message: "Failed to delete files from FFmpeg memory store:",
code: 101,
};
const errorMessage =
error instanceof Error ? error.message : String(error);
console.error(errorMessage);
throw processError;
}
}
async removeAllVideos(): Promise<void> {
try {
const fileNames = this.ffmpeg.FS("readdir", "/");
for (const fileName of fileNames) {
if (fileName.endsWith(".mp4")) {
await this.ffmpeg.FS("unlink", fileName);
}
}
} catch (error) {
console.error("Failed to delete files from FFmpeg memory store:", error);
const processError: VideoPreProcessorError = {
message: "Failed to delete files from FFmpeg memory store:",
code: 102,
};
throw processError;
}
}
//Used in the case where the CDN does not properly return the core
// If this fails then keep trying during the generation process.
async preConditionCheck(): Promise<boolean> {
if (this.ffmpeg.isLoaded() == false) {
await this.ffmpeg.load();
return false;
}
return true;
}
async setOutputProperties(properties: VideoPreProcessorOutputProperties) {
this.properties = properties;
}
// Just Encase Clip UI Changes we have this as a proxy to keep things going as per usual
public async audioClipsToAudioBuffer(
audioClips: ClipUI[],
): Promise<AudioBuffer> {
const audioClipInfos = audioClips.map((val) => {
return {
media_id: val.media_id, // file location
offset: val.offset, // in frames
length: val.length, // in frames
};
});
return { buffer: audioClipInfos };
}
async combineAudioAndVideo(
audioTrackFileName: string,
colorVideoFileName: string,
outputFileName: string,
): Promise<Blob> {
try {
// Error ahndle properly
await this.ffmpeg.run(
"-i",
`${colorVideoFileName}.mp4`,
"-i",
`${audioTrackFileName}.wav`,
"-c:v",
"copy",
"-c:a",
"aac",
"-map",
"0:v:0",
"-map",
"1:a:0",
"-strict",
"experimental",
`${outputFileName}.mp4`,
);
// Upload individual videos or single color video
const output = this.ffmpeg.FS("readFile", `${outputFileName}.mp4`);
// Create a Blob from the output file for downloading
const blob = new Blob([output.buffer], { type: "video/mp4" });
return blob;
} catch (error) {
console.log(error);
const processError: VideoPreProcessorError = {
message: "Failed to Combine Video with Audio.",
code: 103,
};
throw processError;
}
}
private async getMediaUrlFromToken(mediaToken: string): Promise<string> {
try {
const response = await this.mediaFilesApi.GetMediaFileByToken({
mediaFileToken: mediaToken,
});
if (response.success && response.data) {
this.bucketConfig.isLocalDev = false;
const mediaUrl = this.bucketConfig.getGcsUrl(
response.data.public_bucket_path,
);
return mediaUrl;
} else {
const processError: VideoPreProcessorError = {
message: "Failed to get MediaUrlFrom Token",
code: 104,
};
throw processError;
}
} catch (error) {
console.log(error);
throw error;
}
}
// debug download mp4 from blob
async blobToVideoDownload(blob: Blob): Promise<void> {
if (!blob) {
console.error("Invalid blob provided");
return;
}
console.log("Creating Blob URL");
const blobUrl = URL.createObjectURL(blob);
console.log("Blob URL created:", blobUrl);
const link = document.createElement("a");
link.href = blobUrl;
link.download = "video.mp4";
document.body.appendChild(link);
console.log("Link appended to document");
link.click();
console.log("Link clicked to trigger download");
document.body.removeChild(link);
console.log("Link removed from document");
URL.revokeObjectURL(blobUrl);
console.log("Blob URL revoked");
}
// Only use for debugging
// This is to quickly grab the file from the memory
async outputWav(fileName: string) {
// Read the WAV file from FFmpeg's in-memory filesystem
const output = this.ffmpeg.FS("readFile", `${fileName}.wav`);
// Create a Blob from the file data
const blob = new Blob([output.buffer], { type: "audio/wav" });
// Create a link element and trigger download
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${fileName}.wav`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
console.log("Download triggered");
}
// Only use for debugging
// This is to quickly grab the file from the memory
async outputMp4(fileName: string) {
// Read the WAV file from FFmpeg's in-memory filesystem
const output = this.ffmpeg.FS("readFile", `${fileName}.mp4`);
// Create a Blob from the file data
const blob = new Blob([output.buffer], { type: "video/mp4" });
// Create a link element and trigger download
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${fileName}.wav`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
console.log("Download triggered");
}
async processAudioWithBuffer(
audioBuffer: AudioBuffer,
audioOutputFileName: string,
includeBlob: boolean,
): Promise<Blob | void> {
const outputName = `${audioOutputFileName}`;
try {
// Create the samples in memory
for (let i = 0; i < audioBuffer.buffer.length; i++) {
const buffer: AudioClipInfo = audioBuffer.buffer[i];
const urlPath = await this.getMediaUrlFromToken(buffer.media_id);
const audio = await fetchFile(urlPath);
console.log(urlPath);
console.log(audio);
if (audio.length === 0) {
console.log("audio is 0");
const processError: VideoPreProcessorError = {
message: `Audio length is 0 for ${urlPath}`,
code: 103,
};
throw processError;
}
await this.ffmpeg.FS("writeFile", `tmp${i}.wav`, audio);
}
console.log("Write the Audio");
// Construct the FFmpeg command to mix the WAV files
const inputArgs = [];
const filterComplexArgs = [];
// Add the silent audio file and account for it below. i + 1
inputArgs.push(
"-f",
"lavfi",
"-t",
"7",
"-i",
"anullsrc=r=44100:cl=stereo",
);
// Add the existing audio clips
for (let i = 0; i < audioBuffer.buffer.length; i++) {
const endTime =
audioBuffer.buffer[i].length / this.properties.frameRate; // allows trimming from the right
const offset = audioBuffer.buffer[i].offset / this.properties.frameRate; // where it will end up on the track.
const startTime =
audioBuffer.buffer[i].offset / this.properties.frameRate; // allows trimming from the left eventually
const end = endTime - startTime;
inputArgs.push("-i", `tmp${i}.wav`);
// trim the audio to the end time then offset
filterComplexArgs.push(
`[${i + 1}:a]atrim=start=${0}:end=${end},adelay=${offset * 1000}|${offset * 1000}[a${i}];`,
);
console.log("Write Audio and Offset Position");
}
// Add the silent audio track to the filter complex arguments
filterComplexArgs.push("[0:a]");
// Add the existing audio tracks to the filter complex arguments
for (let i = 0; i < audioBuffer.buffer.length; i++) {
filterComplexArgs.push(`[a${i}]`);
}
// Mix the silent audio track with the existing audio tracks
filterComplexArgs.push(`amix=inputs=${audioBuffer.buffer.length + 1}[a]`);
// Run the FFmpeg command to mix the WAV files
await this.ffmpeg.run(
...inputArgs,
"-filter_complex",
filterComplexArgs.join(""),
"-map",
"[a]",
`${outputName}.wav`,
);
this.debugListFiles();
if (includeBlob) {
const blob = await this.outputWavFromMemory(`${outputName}`);
return blob;
} else {
return;
}
} catch (err) {
console.log(err);
throw err;
}
}
// Testing function
public downloadImageBlob(blob: Blob, filename: string) {
// Create a URL for the blob
const url = URL.createObjectURL(blob);
// Create a temporary anchor element
const a = document.createElement("a");
a.href = url;
a.download = filename;
// Append the anchor to the document body
document.body.appendChild(a);
// Trigger a click event on the anchor
a.click();
// Remove the anchor from the document
document.body.removeChild(a);
// Revoke the object URL to free up memory
URL.revokeObjectURL(url);
}
async processVideoWithBuffer(
buffer: (string | null)[],
fileName: string,
outFileName: string,
imagePrefix: string, // use a different one to populate the in memory file system, if they overlap you will have problems
): Promise<Blob> {
try {
const imageFormat = this.properties.imageFormat;
let extension = "";
if (imageFormat == ImageFormat.JPEG) {
extension = ".jpeg";
} else {
extension = ".png";
}
console.log(`Image Files of extension ${extension}`);
console.log(`Buffer Length ${buffer.length}`);
for (let index = 0; index < buffer.length; index++) {
const element = buffer[index];
if (element === null) {
continue;
}
// this can fail so catch any errors.
const image = await fetchFile(element);
await this.ffmpeg.FS(
"writeFile",
`${imagePrefix}_image${index}${extension}`,
image,
);
}
await this.ffmpeg.run(
"-framerate",
"" + this.properties.frameRate,
"-i",
`${imagePrefix}_image%d${extension}`,
`${fileName}.mp4`,
);
await this.ffmpeg.run(
"-i",
`${fileName}.mp4`,
"-f",
"lavfi",
"-i",
"anullsrc", // This adds a silent audio track
"-max_muxing_queue_size",
"999999",
"-vf", // WE REMOVED THE BLACK FRAME IN FFMEPG INSTEAD OF THE ENGINE
"select=gte(n\\,1)", // WE REMOVED THE BLACK FRAME IN FFMEPG INSTEAD OF THE ENGINE Applies a video filter to remove the first frame.
// "select=gte(n\\,1),scale=1024:576",
"-c:v",
"libx264", // Specify video codec (optional, but recommended for MP4)
"-c:a",
"aac", // Specify audio codec (optional, but recommended for MP4)
"-shortest", // Ensure output duration matches the shortest stream (video or audio)
"-pix_fmt",
"yuv420p",
"-f",
"mp4",
`${outFileName}.mp4`,
);
const blob = await this.outputMp4FromMemory(`${outFileName}`);
return blob;
} catch (error) {
console.error(error);
const preProcessingError: VideoPreProcessorError = {
message: "Could Not Process Color Video.",
code: 105,
};
throw preProcessingError;
}
}
// Grab the file from in memory fs and provide it as a blob
async outputMp4FromMemory(fileName: string): Promise<Blob> {
// Upload individual videos or single color video
const output = this.ffmpeg.FS("readFile", `${fileName}.mp4`);
// Create a Blob from the output file for downloading
const blob = new Blob([output.buffer], { type: "video/mp4" });
return blob;
}
// Grab the file from in memory fs and provide it as a blob
async outputImageFromMemory(
fileName: string,
imageExtension: ImageFormat = ImageFormat.JPEG,
): Promise<Blob> {
// Upload individual videos or single color video
const output = this.ffmpeg.FS("readFile", `${fileName}`);
// Create a Blob from the output file for downloading
const blob = new Blob([output.buffer], { type: `${imageExtension}` });
return blob;
}
async outputWavFromMemory(fileName: string): Promise<Blob> {
// Upload individual videos or single color video
const output = this.ffmpeg.FS("readFile", `${fileName}.wav`);
// Create a Blob from the output file for downloading
const blob = new Blob([output.buffer], { type: "audio/wav" });
return blob;
}
// This must be called every time before a render is called
async clear() {
// List all files in the in-memory filesystem
const files = this.ffmpeg.FS("readdir", "/");
await this.deleteFilesFromFFmpegMemoryStore(files);
}
async debugListFiles() {
const files = this.ffmpeg.FS("readdir", "/");
for (const fileName of files) {
console.log(fileName);
}
}
async deleteFilesFromFFmpegMemoryStore(fileNames: string[]): Promise<void> {
for (const fileName of fileNames) {
if (
fileName.endsWith(".png") ||
fileName.endsWith(".mp4") ||
fileName.endsWith(".wav") ||
fileName.endsWith(".jpeg")
) {
await this.ffmpeg.FS("unlink", fileName);
}
}
}
}
@@ -1,162 +0,0 @@
interface Task {
imageBitmap: ImageBitmap;
width: number;
height: number;
id: number;
}
import { ClipUI } from "~/pages/PageEnigma/clips/clip_ui";
import { BlobReader, BlobWriter, ZipWriter } from "@zip.js/zip.js";
class FrameProcessorWorker {
private holdingCanvas: OffscreenCanvas;
private ctx: ImageBitmapRenderingContext | null;
public imageType: string;
private zipFileWriter: BlobWriter;
private zipWriter: ZipWriter<Blob>;
private decodeQueue: Task[];
public audio_data: ClipUI[];
public isProcessing: boolean;
public final_length: number;
public item_count: number;
constructor() {
this.final_length = 0;
this.holdingCanvas = new OffscreenCanvas(0, 0);
this.ctx = this.holdingCanvas.getContext("bitmaprenderer");
this.imageType = "image/jpeg";
// Zipping
this.decodeQueue = [];
this.zipFileWriter = new BlobWriter(this.imageType);
this.zipWriter = new ZipWriter(this.zipFileWriter);
this.audio_data = [];
this.isProcessing = false;
this.item_count = 0;
}
async startZipProcessQueue(): Promise<void> {
if (this.decodeQueue.length > 0 && this.isProcessing == false) {
this.isProcessing = true;
const task = this.decodeQueue.shift();
if (task) {
const { imageBitmap, width, height, id } = task;
// Proceed with processing the task
//console.log(`Working On an Frame Id ${id}`);
this.holdingCanvas.width = width;
this.holdingCanvas.height = height;
try {
// Create or reuse the offscreen canvas
this.ctx?.transferFromImageBitmap(imageBitmap);
// Convert the canvas content to a Blob
const blob = await this.holdingCanvas.convertToBlob({
quality: 1.0,
type: this.imageType,
});
// Send the Blob back to the main thread
//console.log(`Zipping Frame ${id} to Zip`);
const name = String(id).padStart(5, "0"); // '0009'
await this.zipWriter.add(`${name}.jpg`, new BlobReader(blob));
this.item_count++;
} catch (error) {
console.error("Error processing image:", error);
self.postMessage({ error: (error as Error).message }); //
} finally {
this.isProcessing = false;
}
} else {
console.log("Queue is empty, no task to process.");
return;
}
}
// have this function loop
setTimeout(this.startZipProcessQueue.bind(this), 0);
}
async zipFile() {
// ensure that this queue is done before zipping
console.log(`Waiting to Finish Decoding: ${this.decodeQueue.length}`);
console.log(`Decoded Count: ${this.item_count}`);
console.log(`Final Length: ${this.final_length}`);
if (this.item_count === this.final_length) {
console.log("Zipping");
const result = await this.zipWriter.close();
self.postMessage({ result: result });
// reset the item count and final length
this.item_count = 0;
this.final_length = 0;
// Zipping
this.decodeQueue = [];
this.zipFileWriter = new BlobWriter(this.imageType);
this.zipWriter = new ZipWriter(this.zipFileWriter);
this.audio_data = [];
this.isProcessing = false;
return;
} else {
setTimeout(this.zipFile.bind(this), 0);
}
}
public enqueueTask(
imageBitmap: ImageBitmap,
width: number,
height: number,
id: number,
): void {
this.decodeQueue.push({ imageBitmap, width, height, id });
}
}
const runner = new FrameProcessorWorker();
let start = false;
self.addEventListener("message", async (event: MessageEvent) => {
const {
type,
imageBitmap,
width,
height,
frame,
audio_data,
zip,
final_length,
} = event.data;
// console.log(
// `${type}: ${imageBitmap} ${width} ${height} ${frame} ${audio_data} ${zip} ${final_length}`,
// );
if (start == false) {
start = true;
runner.imageType = type;
await runner.startZipProcessQueue();
}
if (audio_data || zip) {
if (audio_data) {
runner.audio_data = audio_data;
}
if (zip) {
console.log(`Worker Reported Final Length: ${final_length}`);
runner.final_length = final_length;
await runner.zipFile();
}
return;
}
runner.enqueueTask(imageBitmap, width, height, frame);
});
@@ -1,125 +0,0 @@
import * as THREE from "three";
// import { BlobReader, BlobWriter, ZipWriter } from "@zip.js/zip.js";
interface WorkerMessageEvent extends MessageEvent {
data: {
result: Blob;
};
}
export class FrameWorkerManager {
private worker: Worker;
private offscreenCanvas: OffscreenCanvas;
private offScreenRenderer: THREE.WebGLRenderer;
public type: string;
private blobResult: Blob | undefined;
constructor(width: number, height: number, type: string = "image/jpeg") {
this.type = type;
this.worker = new Worker(
"app/pages/PageEnigma/Editor/VideoProcessor/workers/frame_processor_worker.ts",
{ type: "module" },
);
this.worker.addEventListener("message", this.handleMessage.bind(this));
this.offscreenCanvas = new OffscreenCanvas(width, height);
this.offScreenRenderer = new THREE.WebGLRenderer({
canvas: this.offscreenCanvas,
});
}
setCanvas(width: number, height: number): void {
this.offscreenCanvas.width = width;
this.offscreenCanvas.height = height;
}
handleMessage(event: WorkerMessageEvent): void {
const { result } = event.data;
console.log(result);
// zip file
this.blobResult = result;
}
async sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async collectedFrames(final_length: number): Promise<Blob> {
// to get the zip file
console.log(`The Last Frame Number is: ${final_length}`);
this.worker.postMessage({
type: this.type,
imageBitmap: undefined,
width: undefined,
height: undefined,
frame: undefined,
audio_data: undefined,
zip: true,
final_length: final_length,
});
while (this.blobResult === undefined) {
await this.sleep(1);
}
const result = this.blobResult;
this.blobResult = undefined;
// For testing.
//this.downloadBlob(result, "test.zip");
if (!result) {
throw Error("Failed to Compress Payload");
}
return result;
}
private downloadBlob(blob: Blob, filename: string): void {
// Create an object URL for the Blob
const url = URL.createObjectURL(blob);
// Create a temporary anchor element
const a = document.createElement("a");
a.href = url;
a.download = filename; // Specify the filename
// Append the anchor to the document body
document.body.appendChild(a);
// Trigger a click event on the anchor
a.click();
// Clean up by revoking the object URL and removing the anchor element
URL.revokeObjectURL(url);
document.body.removeChild(a);
}
public terminate(): void {
this.worker.terminate();
console.log("Worker Terminated");
}
public render(scene: THREE.Scene, camera: THREE.Camera, frame: number): void {
this.offScreenRenderer.render(scene, camera);
const imageBitmap = this.offscreenCanvas.transferToImageBitmap();
this.processFrame(imageBitmap, frame);
}
private processFrame(imageBitmap: ImageBitmap, frame: number): void {
const width = this.offscreenCanvas.width;
const height = this.offscreenCanvas.height;
const type = this.type;
this.worker.postMessage({
type,
imageBitmap,
width,
height,
audio_data: undefined,
frame,
zip: false,
});
}
}
@@ -3,6 +3,7 @@ import {
uploadNewScene as uploadNewSceneEndpoint,
updateExistingScene as updateExistingSceneEndpoint,
} from "~/api";
import { FetchProxy as fetch } from "@storyteller/tauri-utils";
export const uploadNewScene = async (file: File, sceneTitle: string) => {
const endpoint = uploadNewSceneEndpoint;
@@ -48,11 +48,7 @@ import { MediaUploadApi } from "~/Classes/ApiManager";
import { SceneManager } from "./scene_manager_api";
import { CustomOutlinePass } from "./CustomOutlinePass.js";
import FindSurfaces from "./FindSurfaces.js";
import {
ImageFormat,
//VideoAudioPreProcessor,
} from "./VideoProcessor/video_audio_preprocessor";
import { BufferType, EngineFrameBuffers } from "./VideoProcessor/engine_buffer";
import Stats from "three/examples/jsm/libs/stats.module.js";
import { CharacterAnimationEngine } from "./Engines/CharacterAnimationEngine";
@@ -224,7 +220,7 @@ class Editor {
// life cycle fix
// Version and name.
this.version = 1.2;
this.version = 2.0;
// Clock, scene and camera essentials.
// global names
this.camera_name = "::CAM::";
@@ -325,16 +321,9 @@ class Editor {
// set image type at this stage
const imageFormat = ImageFormat.JPEG;
this.renderIndex = 0;
this.engineFrameBuffers = new EngineFrameBuffers(
this.getRenderDimensions().width,
this.getRenderDimensions().height,
);
if (this.engineFrameBuffers.frameWorkerManager) {
this.engineFrameBuffers.frameWorkerManager.type = imageFormat;
}
}
// Add helper method to convert focal length to FOV
@@ -1252,7 +1241,7 @@ class Editor {
console.log(`COLLECTING COLOR FRAMES COUNTED: ${this.renderIndex}`);
await this.engineFrameBuffers.collectColorFrames(this.renderIndex);
await this.engineFrameBuffers.logBufferInfo(BufferType.COLOR);
this.renderIndex = 0;
console.time("Stop Playback And Upload Video Time");
await this.stopPlaybackAndUploadVideo();
@@ -1263,7 +1252,7 @@ class Editor {
} catch (error) {
// don't use cache in this case.
this.processingHasFailed = true;
this.engineFrameBuffers.clearBuffer(BufferType.COLOR);
console.log(`Video Generation: ${error}`);
}
} // End Timeline Playing
@@ -1,7 +1,7 @@
import * as THREE from "three";
import { SceneGenereationMetaData } from "../models/sceneGenerationMetadata";
import { StoryTellerProxyScene } from "../proxy/storyteller_proxy_scene";
import { StoryTellerProxyTimeline } from "../proxy/storyteller_proxy_timeline";
import {
setCameraAspectRatio,
hideEditorLoader,
@@ -53,19 +53,11 @@ export class SaveManager {
);
const scene_json = await proxyScene.saveToScene(this.editor.version);
const proxyTimeline = new StoryTellerProxyTimeline(
this.editor.version,
this.editor.timeline,
this.editor.transform_engine,
this.editor.animation_engine,
this.editor.audio_engine,
this.editor.lipsync_engine,
this.editor.emotion_engine,
);
const timeline_json = await proxyTimeline.saveToJson();
const check_sum_data = {
scene: scene_json,
timeline: timeline_json,
timeline: "",
camera_data: {
position: this.editor.camera?.position,
rotation: this.editor.camera?.rotation,
@@ -89,16 +81,7 @@ export class SaveManager {
);
const scene_json = await proxyScene.saveToScene(this.editor.version);
const proxyTimeline = new StoryTellerProxyTimeline(
this.editor.version,
this.editor.timeline,
this.editor.transform_engine,
this.editor.animation_engine,
this.editor.audio_engine,
this.editor.lipsync_engine,
this.editor.emotion_engine,
);
const timeline_json = await proxyTimeline.saveToJson();
// Save all cameras data
const camerasData = cameras.value.map((cam: Camera) => ({
@@ -114,7 +97,7 @@ export class SaveManager {
version: this.editor.version,
scene: scene_json,
...sceneGenerationMetadata,
timeline: timeline_json,
timeline: "",
skybox: this.editor.activeScene.skybox,
camera_data: {
position: this.editor.camera?.position,
@@ -150,17 +133,7 @@ export class SaveManager {
);
const scene_json = await proxyScene.saveToScene(this.editor.version);
console.log(scene_json);
const proxyTimeline = new StoryTellerProxyTimeline(
this.editor.version,
this.editor.timeline,
this.editor.transform_engine,
this.editor.animation_engine,
this.editor.audio_engine,
this.editor.lipsync_engine,
this.editor.emotion_engine,
);
const timeline_json = await proxyTimeline.saveToJson();
// Save all cameras data
const camerasData = cameras.value.map((cam: Camera) => ({
id: cam.id,
@@ -175,7 +148,7 @@ export class SaveManager {
version: this.editor.version,
scene: scene_json,
...sceneGenerationMetadata,
timeline: timeline_json,
timeline: "",
skybox: this.editor.activeScene.skybox,
camera_data: {
position: this.editor.camera?.position,
@@ -297,17 +270,7 @@ export class SaveManager {
child.layers.set(1);
});
const proxyTimeline = new StoryTellerProxyTimeline(
this.editor.version,
this.editor.timeline,
this.editor.transform_engine,
this.editor.animation_engine,
this.editor.audio_engine,
this.editor.lipsync_engine,
this.editor.emotion_engine,
);
console.debug("Loading Timeline", scene_json["timeline"]);
await proxyTimeline.loadFromJson(scene_json["timeline"]);
this.editor.timeline.checkEditorCanPlay();
@@ -206,6 +206,7 @@ class Scene {
color: 0xffffff,
map: texture,
transparent: true,
side: THREE.DoubleSide,
});
obj = new THREE.Mesh(geometry, image_material);
obj.userData["media_id"] = name;
@@ -13,7 +13,7 @@ import { v4 as uuidv4 } from "uuid";
import { SceneGenereationMetaData as SceneGenerationMetaData } from "~/pages/PageEnigma/models/sceneGenerationMetadata";
import { MediaUploadApi, StudioGen2Api, VideoApi } from "~/Classes/ApiManager";
import { globalIPAMediaToken } from "../signals";
import { BufferType } from "./VideoProcessor/engine_buffer";
// TODO THIS CLASS MAKES NO SENSE
// Refactor so we generate all the frames first.
@@ -98,7 +98,7 @@ export class VideoGeneration {
console.log(`Studio recording: https://storyteller.ai/media/${mediaTokens.color}`);
} finally {
await this.editor.engineFrameBuffers.clearBuffer(BufferType.COLOR);
}
return mediaTokens;
@@ -50,8 +50,22 @@ import { LoginModal } from "@storyteller/ui-login-modal";
import { DomLevels } from "./signals/hotkeys";
import { AUTH_STATUS } from "../../enums/authentication";
import { authentication } from "../../signals/authentication/authentication";
import { topNavMediaId } from "~/components/signaled/TopBar/TopBar";
import { topNavMediaUrl } from "~/components/signaled/TopBar/TopBar";
import {
topNavMediaId,
topNavMediaUrl,
} from "~/components/signaled/TopBar/TopBar";
import { uploadPlaneFromMediaToken } from "~/components/reusable/UploadModalMedia/uploadPlane";
import { addObject } from "./signals/objectGroup/addObject";
import { AssetType } from "~/enums";
import { v4 as uuidv4 } from "uuid";
import { MediaItem } from "~/pages/PageEnigma/models";
import { UploaderState } from "~/models";
import * as THREE from "three";
import {
GalleryItem,
onImageDrop,
removeImageDropListener,
} from "@storyteller/ui-gallery-modal";
export const PageEditor = () => {
useSignals();
@@ -234,6 +248,67 @@ export const PageEditor = () => {
});
};
// Image drop from gallery/library modal
useEffect(() => {
const handler = onImageDrop(
(item: GalleryItem, position: { x: number; y: number }) => {
console.log("Drop debug (event):", {
editorEngine,
camera: editorEngine?.camera,
renderer: editorEngine?.renderer,
position,
});
// Calculate world position from cursor immediately on drop
let worldPosition = undefined;
if (editorEngine?.camera && editorEngine?.renderer && position) {
const rect = editorEngine.renderer.domElement.getBoundingClientRect();
// Convert client coordinates to canvas coordinates
const canvasX = position.x - rect.left;
const canvasY = position.y - rect.top;
const ndcX = (canvasX / rect.width) * 2 - 1;
const ndcY = -(canvasY / rect.height) * 2 + 1;
const vector = new THREE.Vector3(ndcX, ndcY, 0.5);
vector.unproject(editorEngine.camera);
worldPosition = vector;
}
(async () => {
try {
// Place the object immediately to avoid upload delay affecting placement
const mediaItem: MediaItem = {
version: 1,
type: AssetType.OBJECT,
media_id: item.id || uuidv4(),
name: item.label || "Image Plane",
...(worldPosition && {
position: {
x: worldPosition.x,
y: worldPosition.y,
z: worldPosition.z,
},
}),
};
addObject(mediaItem);
await uploadPlaneFromMediaToken({
title: item.label || "Image Plane",
mediaToken: item.id,
progressCallback: (state: UploaderState) => {
if (state.status) console.log("Upload status:", state.status);
},
});
} catch (err) {
console.error("Failed to add image plane:", err);
}
})();
},
);
return () => {
removeImageDropListener(handler);
};
}, []);
const display3d = appTabId.value === "3D" ? "block" : "none";
const display2d = appTabId.value === "2D" ? "block" : "none";
const displayVideo = appTabId.value === "VIDEO" ? "block" : "none";
@@ -24,6 +24,8 @@ import { InstallSounds } from "~/pages/PageEnigma/InstallSounds";
import { useImageGenerationFailureEvent } from "./TauriEvents/useImageGenerationFailureEvent";
import { useImageGenerationSuccessEvent } from "./TauriEvents/useImageGenerationSuccessEvent";
import { PageEditor } from "~/pages/PageEnigma/PageEditor";
import { GalleryDragComponent } from "@storyteller/ui-gallery-modal";
export const PageEnigma = ({ sceneToken }: { sceneToken?: string }) => {
useSignals();
useActiveJobs();
@@ -104,10 +106,11 @@ export const PageEnigma = ({ sceneToken }: { sceneToken?: string }) => {
}
return (
<TrackProvider>
<EngineProvider sceneToken={sceneToken}>
<PageEditor />
<DragComponent />
<GalleryDragComponent />
<PrecisionSelector
showSignal={showPrecisionSelector}
coordSignal={precisionSelectorMenuCoords}
@@ -116,10 +119,10 @@ export const PageEnigma = ({ sceneToken }: { sceneToken?: string }) => {
/>
<GenerateModals />
<ErrorDialog />
{/* <Wizard /> */}
</EngineProvider>
<EditorLoadingBar />
<EditorLoadingBar/>
<Toaster offsetTop={70} offsetRight={12} />
</TrackProvider>
</EngineProvider>
);
};
@@ -1,6 +1,7 @@
import { useSignals } from "@preact/signals-react/runtime";
import { faAngleLeft, faFilm, faPlus } from "@fortawesome/pro-solid-svg-icons";
import { Button, TopBar } from "~/components";
import { TopBar } from "~/components";
import { Button } from "@storyteller/ui-button";
import { PreviewImages } from "~/pages/PageEnigma/comps/PreviewImages";
import { useContext, useState } from "react";
import { EngineContext } from "~/pages/PageEnigma/contexts/EngineContext";
@@ -1,100 +0,0 @@
import { WizardItem } from "~/pages/PageEnigma/Wizard/Wizard";
import { ObjectSelectionButton } from "~/pages/PageEnigma/Wizard/ObjectSelectionButton";
import { BucketConfig } from "~/api/BucketConfig";
import { MediaInfo } from "~/pages/PageEnigma/models";
import { AssetType, FilterEngineCategories, ToastTypes } from "~/enums";
import { useCallback, useEffect, useState } from "react";
import { MediaFilesApi } from "~/Classes/ApiManager";
import { addToast } from "~/signals";
import {
backgroundWizardItems,
currentStep,
selectedBackground,
} from "~/pages/PageEnigma/Wizard/signals/wizard";
import { useSignals } from "@preact/signals-react/runtime";
import { BackgroundList } from "~/pages/PageEnigma/Wizard/BackgroundList";
export const Background = () => {
useSignals();
const item = currentStep.value as WizardItem;
const [showList, setShowList] = useState(false);
const responseMapping = (data: MediaInfo[]) => {
return data
.filter(
(item) =>
[
"Sci-fi City",
"Cherry Blossom Petals",
"Barbie Dreamland Test",
"Space Station",
"graveyard_2",
"sand tile",
"Tiny Island",
"dock",
].indexOf(item.maybe_title ?? "") > -1,
)
.map((item) => {
const bucketConfig = new BucketConfig();
const itemThumb = bucketConfig.getCdnUrl(
item.cover_image.maybe_cover_image_public_bucket_path ?? "",
600,
100,
);
return {
colorIndex: item.cover_image.default_cover.color_index,
imageIndex: item.cover_image.default_cover.image_index,
media_id: item.token,
name: item.maybe_title ?? "Unknown",
type: AssetType.OBJECT,
media_type: item.media_type,
version: 1,
...(item.cover_image.maybe_cover_image_public_bucket_path
? {
thumbnail: itemThumb,
}
: {}),
};
});
};
const fetchFeaturedObjects = useCallback(async () => {
const mediaFilesApi = new MediaFilesApi();
const response = await mediaFilesApi.ListFeaturedMediaFiles({
filter_engine_categories: [FilterEngineCategories.OBJECT],
});
if (response.success && response.data) {
backgroundWizardItems.value = responseMapping(response.data);
return;
}
addToast(
ToastTypes.ERROR,
response.errorMessage || "Unknown Error in Fetching Featured Objects",
);
}, []);
useEffect(() => {
fetchFeaturedObjects();
}, [fetchFeaturedObjects]);
if (showList) {
return <BackgroundList onClose={() => setShowList(false)} />;
}
return (
<div>
<div>{item.label}</div>
<ObjectSelectionButton
onClick={() => setShowList(true)}
label={
selectedBackground.value ? "Selected Background" : "Select Background"
}
busy={!backgroundWizardItems.value}
imageSrc={
selectedBackground.value?.thumbnail ??
backgroundWizardItems.value?.[0]?.thumbnail ??
""
}
/>
</div>
);
};
@@ -1,52 +0,0 @@
import { useContext } from "react";
import { ItemElement } from "./ItemElement";
import Queue from "~/pages/PageEnigma/Queue/Queue";
import { QueueNames } from "~/pages/PageEnigma/Queue";
import { toEngineActions } from "~/pages/PageEnigma/Queue/toEngineActions";
import { objectGroup } from "~/pages/PageEnigma/signals";
import { EngineContext } from "~/pages/PageEnigma/contexts/EngineContext";
import {
backgroundWizardItems,
selectedBackground,
} from "~/pages/PageEnigma/Wizard/signals/wizard";
import { useSignals } from "@preact/signals-react/runtime";
interface Props {
onClose: () => void;
}
export const BackgroundList = ({ onClose }: Props) => {
useSignals();
const editorEngine = useContext(EngineContext);
return (
<div className="flex h-4/5 flex-wrap gap-4 overflow-y-auto">
{(backgroundWizardItems.value ?? []).map((item) => (
<button
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
const existingBackground = objectGroup.value.objects.find(
(object) => object.name === selectedBackground.value?.name,
);
if (existingBackground) {
editorEngine?.deleteObject(existingBackground?.object_uuid ?? "");
}
selectedBackground.value = item;
Queue.publish({
queueName: QueueNames.TO_ENGINE,
action: toEngineActions.ADD_OBJECT,
data: item,
});
onClose();
}}
key={item.object_uuid}
className="w-[144px]"
>
<ItemElement item={item} />
</button>
))}
</div>
);
};
@@ -1,72 +0,0 @@
import { Clip, MediaItem } from "~/pages/PageEnigma/models";
import { useEffect, useRef } from "react";
import { ItemElement } from "./ItemElement";
import Queue from "~/pages/PageEnigma/Queue/Queue";
import { QueueNames } from "~/pages/PageEnigma/Queue";
import { fromEngineActions } from "~/pages/PageEnigma/Queue/fromEngineActions";
import { AssetType } from "~/enums";
import {
characterWizardItems,
selectedCharacters,
showList,
} from "~/pages/PageEnigma/Wizard/signals/wizard";
import { toEngineActions } from "~/pages/PageEnigma/Queue/toEngineActions";
import { useSignals } from "@preact/signals-react/runtime";
export const CharacterList = () => {
useSignals();
const addingObject = useRef<MediaItem | null>(null);
useEffect(() => {
Queue.subscribe(QueueNames.FROM_ENGINE, "wizard-character", (message) => {
switch (message.action) {
case fromEngineActions.UPDATE_CHARACTER_ID:
if (addingObject.current) {
const data = message.data as Clip;
selectedCharacters.value = [
...selectedCharacters.value,
{
...addingObject.current,
version: data.version,
type: data.type.toString() as AssetType,
media_id: data.media_id,
name: data.name,
object_uuid: data.object_uuid,
},
];
}
break;
case fromEngineActions.DELETE_OBJECT:
selectedCharacters.value = selectedCharacters.value.filter(
(object) =>
object.object_uuid !== (message.data as Clip).object_uuid,
);
break;
}
});
}, []);
return (
<div className="flex h-4/5 flex-wrap gap-4 overflow-y-auto">
{(characterWizardItems.value ?? []).map((item) => (
<button
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
addingObject.current = item;
showList.value = null;
Queue.publish({
queueName: QueueNames.TO_ENGINE,
action: toEngineActions.ADD_CHARACTER,
data: item,
});
}}
key={item.object_uuid}
className="w-[144px]"
>
<ItemElement item={item} />
</button>
))}
</div>
);
};
@@ -1,106 +0,0 @@
import { WizardItem, WizardType } from "~/pages/PageEnigma/Wizard/Wizard";
import { ObjectSelectionButton } from "~/pages/PageEnigma/Wizard/ObjectSelectionButton";
import { BucketConfig } from "~/api/BucketConfig";
import { MediaInfo } from "~/pages/PageEnigma/models";
import { AssetType, FilterEngineCategories, ToastTypes } from "~/enums";
import { useCallback, useContext, useEffect } from "react";
import { MediaFilesApi } from "~/Classes/ApiManager";
import { addToast } from "~/signals";
import { ItemElement } from "./ItemElement";
import { EngineContext } from "~/pages/PageEnigma/contexts/EngineContext";
import {
characterWizardItems,
currentStep,
selectedCharacters,
} from "~/pages/PageEnigma/Wizard/signals/wizard";
import { useSignals } from "@preact/signals-react/runtime";
import { demoCharacterItems } from "~/pages/PageEnigma/signals";
export const Characters = () => {
useSignals();
const item = currentStep.value as WizardItem;
const editorEngine = useContext(EngineContext);
const responseMapping = (data: MediaInfo[]) => {
return data.map((item) => {
const bucketConfig = new BucketConfig();
const itemThumb = bucketConfig.getCdnUrl(
item.cover_image.maybe_cover_image_public_bucket_path ?? "",
600,
100,
);
return {
object_uuid: item.token,
colorIndex: item.cover_image.default_cover.color_index,
imageIndex: item.cover_image.default_cover.image_index,
media_id: item.token,
name: item.maybe_title ?? "Unknown",
type: AssetType.OBJECT,
media_type: item.media_type,
version: 1,
...(item.cover_image.maybe_cover_image_public_bucket_path
? {
thumbnail: itemThumb,
}
: {}),
};
});
};
const fetchFeaturedCharacters = useCallback(async () => {
const mediaFilesApi = new MediaFilesApi();
const response = await mediaFilesApi.ListFeaturedMediaFiles({
filter_engine_categories: [FilterEngineCategories.CHARACTER],
});
if (response.success && response.data) {
characterWizardItems.value = [
...responseMapping(response.data),
...demoCharacterItems.value,
];
return;
}
addToast(
ToastTypes.ERROR,
response.errorMessage || "Unknown Error in Fetching Featured Objects",
);
}, []);
useEffect(() => {
fetchFeaturedCharacters();
}, [fetchFeaturedCharacters]);
console.log(characterWizardItems.value?.[0]?.thumbnail ?? "");
const initialCharacter = characterWizardItems.value?.[0] ?? {
imageIndex: 0,
thumbnail: "",
};
const defaultThumb = `/resources/images/default-covers/${(initialCharacter.imageIndex || 0) % 24}.webp`;
const thumbnail = initialCharacter.thumbnail
? initialCharacter.thumbnail
: defaultThumb;
return (
<div>
<div>{item.label}</div>
<div className="mb-2 flex flex-wrap gap-2">
{selectedCharacters.value.map((item) => (
<button
key={item.object_uuid}
className="w-[80px]"
onClick={() => {
editorEngine?.deleteObject(item.object_uuid!);
}}
>
<ItemElement item={item} showDelete />
</button>
))}
</div>
<ObjectSelectionButton
onClick={() => {}}
label="Selected Character"
busy={!characterWizardItems.value}
imageSrc={thumbnail}
/>
</div>
);
};
@@ -1,56 +0,0 @@
import { MediaItem } from "~/pages/PageEnigma/models";
import { useSignals } from "@preact/signals-react/runtime";
import { SyntheticEvent } from "react";
import { Badge } from "~/components";
import { AssetType } from "~/enums";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faTrash } from "@fortawesome/pro-solid-svg-icons";
interface Props {
item: MediaItem;
showDelete?: boolean;
}
export const ItemElement = ({ item, showDelete }: Props) => {
useSignals();
const defaultThumb = `/resources/images/default-covers/${(item.imageIndex || 0) % 24}.webp`;
const thumbnail = item.thumbnail ? item.thumbnail : defaultThumb;
return (
<div className="group relative w-full cursor-pointer select-none overflow-hidden rounded-lg transition-all duration-200 hover:brightness-110">
{item.media_type && !showDelete && (
<Badge
label={
item.type === AssetType.OBJECT
? item.media_type.toUpperCase()
: item.media_type === "fbx" ||
item.media_type.toLowerCase() === "glb"
? "Mixamo"
: item.media_type === "pmx"
? "MMD"
: item.media_type.toUpperCase()
}
className="absolute right-0 mr-[3px] mt-[3px]"
/>
)}
{showDelete && (
<div className="absolute right-0 mr-[3px] mt-[3px] text-brand-primary">
<FontAwesomeIcon icon={faTrash} />
</div>
)}
<img
crossOrigin="anonymous"
src={thumbnail}
onError={(e: SyntheticEvent<HTMLImageElement>) => {
e.currentTarget.src = defaultThumb;
}}
alt={item.name}
className="pointer-events-none aspect-[4.5/5] w-full select-none bg-gradient-to-b from-[#CCCCCC] to-[#A0A0A0] object-cover object-center"
/>
<div className="pointer-events-none w-full select-none truncate bg-ui-controls px-2 py-1 text-center text-[12px] transition-all duration-200 group-hover:bg-ui-controls-button/50">
{item.name || item.media_id}
</div>
</div>
);
};

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