mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-04 13:28:06 -05:00
Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
921e58a308 | ||
|
|
65531dc63e | ||
|
|
0b60835bbc | ||
|
|
cca897d24d | ||
|
|
ea76917264 | ||
|
|
fc824a10ae | ||
|
|
0ee9b7c442 | ||
|
|
17495276e7 | ||
|
|
2d78fd5a6f | ||
|
|
98e513e917 | ||
|
|
97eb1c65ca | ||
|
|
ba82a438dd | ||
|
|
de7b990ec5 | ||
|
|
7b90c59675 | ||
|
|
11f1a5f41a | ||
|
|
b92cd58f81 | ||
|
|
914c26b4c6 | ||
|
|
fe8939a0f6 | ||
|
|
c809ff88fa | ||
|
|
86675568a8 | ||
|
|
a8927cc78d | ||
|
|
0e8dd19ecf | ||
|
|
b32a2e79b9 | ||
|
|
a662c5809d | ||
|
|
7498720674 | ||
|
|
c8a4490b52 | ||
|
|
c4e30e6dc7 |
Generated
+211
-9
@@ -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"
|
||||
|
||||
@@ -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
@@ -9,7 +9,6 @@ authors = [
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
# NB: Public "errors" crate is name-squatted, so we're fine to use this internally.
|
||||
name = "actix_cors_configs"
|
||||
path = "src/lib.rs"
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "openai_sora_client"
|
||||
edition = "2021"
|
||||
edition = "2024"
|
||||
version = "0.0.1"
|
||||
publish = false
|
||||
|
||||
|
||||
@@ -57,7 +57,13 @@ Message: {
|
||||
502 => {
|
||||
return SoraError::BadGateway(message);
|
||||
}
|
||||
504 => {
|
||||
if message.contains("cloudflare") || message.contains("Cloudflare") {
|
||||
return SoraError::CloudFlareTimeout(message);
|
||||
}
|
||||
}
|
||||
524 => {
|
||||
// NB: 524 is a cloudflare specific gateway timeout error with slightly different semantics than 504.
|
||||
if message.contains("cloudflare") || message.contains("Cloudflare") {
|
||||
return SoraError::CloudFlareTimeout(message);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
[package]
|
||||
name = "runwayml_client"
|
||||
edition = "2021"
|
||||
version = "0.0.1"
|
||||
publish = false
|
||||
|
||||
[package.metadata.cargo-udeps.ignore]
|
||||
normal = ["workspace-build-acceleration"]
|
||||
|
||||
[lib]
|
||||
name = "runwayml_client"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
|
||||
# Workspace hack (faster compile times)
|
||||
workspace-build-acceleration = { workspace = true }
|
||||
|
||||
# Internal
|
||||
crockford = { path = "../../data/crockford" }
|
||||
errors = { path = "../../errors" }
|
||||
idempotency = { path = "../../idempotency" }
|
||||
jwt_light = { workspace = true }
|
||||
subprocess_common = { path = "../../subprocess_common" }
|
||||
|
||||
# External
|
||||
anyhow = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
chrono = { workspace = true}
|
||||
cookie = { workspace = true }
|
||||
hex-literal = { workspace = true }
|
||||
log = { workspace = true }
|
||||
once_cell= { workspace = true }
|
||||
rand = { workspace = true }
|
||||
reqwest = { workspace = true, features = ["multipart", "gzip"] }
|
||||
serde = { workspace = true }
|
||||
serde_derive = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
sha3 = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { version = "1.38.0", features = ["macros"] }
|
||||
toml = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
testing = { path = "../../testing" }
|
||||
filesys = { path = "../../files/filesys" }
|
||||
@@ -0,0 +1,16 @@
|
||||
use crate::creds::jwt_bearer_token::JwtBearerToken;
|
||||
use crate::error::api_error::ApiError;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Credentials {
|
||||
pub jwt_bearer_token: Option<JwtBearerToken>,
|
||||
}
|
||||
|
||||
impl Credentials {
|
||||
pub fn from_jwt_str(jwt_str: &str) -> Result<Self, ApiError> {
|
||||
let jwt_bearer_token = JwtBearerToken::new(jwt_str.to_string())?;
|
||||
Ok(Self {
|
||||
jwt_bearer_token: Some(jwt_bearer_token),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
use crate::creds::jwt_claims::JwtClaims;
|
||||
use crate::error::api_error::ApiError;
|
||||
use jwt_light::parse_jwt_claims_trait::ParseJwtClaims;
|
||||
|
||||
/// Sora bearer tokens are JWT tokens that can be minted with a valid cookie/session.
|
||||
#[derive(Clone)]
|
||||
pub struct JwtBearerToken {
|
||||
raw_jwt: String,
|
||||
jwt_claims: JwtClaims,
|
||||
}
|
||||
|
||||
impl JwtBearerToken {
|
||||
pub fn new(raw_jwt: String) -> Result<Self, ApiError> {
|
||||
let jwt_claims = JwtClaims::parse_claims(&raw_jwt)?;
|
||||
Ok(Self {
|
||||
raw_jwt,
|
||||
jwt_claims,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn raw_jwt_str(&self) -> &str {
|
||||
&self.raw_jwt
|
||||
}
|
||||
|
||||
pub fn as_bytes(&self) -> &[u8] {
|
||||
self.raw_jwt.as_bytes()
|
||||
}
|
||||
|
||||
pub fn jwt_claims(&self) -> &JwtClaims {
|
||||
&self.jwt_claims
|
||||
}
|
||||
|
||||
pub fn to_authorization_header_value(&self) -> String {
|
||||
match self.raw_jwt.get(0..6) {
|
||||
Some("bearer") | Some("Bearer") => self.raw_jwt.clone(),
|
||||
_ => "Bearer ".to_owned() + &self.raw_jwt,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
use base64::Engine;
|
||||
use chrono::{DateTime, Utc};
|
||||
use jwt_light::common_claims::CommonClaims;
|
||||
use jwt_light::error::JwtError;
|
||||
use jwt_light::parse_jwt_claims_trait::ParseJwtClaims;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct JwtClaims {
|
||||
/// From the "iat" field
|
||||
pub created: DateTime<Utc>,
|
||||
/// From the "exp" field
|
||||
pub expiration: DateTime<Utc>,
|
||||
/// From the "id" field
|
||||
pub id: String,
|
||||
/// From the "email" field
|
||||
pub email: String,
|
||||
/// From the "sso" field
|
||||
pub sso: bool,
|
||||
}
|
||||
|
||||
impl ParseJwtClaims for JwtClaims {
|
||||
fn extract_claims(common_claims: CommonClaims, extra_claims: Map<String, Value>) -> Result<Self, JwtError> {
|
||||
let id = extra_claims.get("id")
|
||||
.ok_or_else(|| JwtError::CustomClaimsFieldError("no id claim".to_string()))?
|
||||
.as_i64()
|
||||
.ok_or_else(|| JwtError::CustomClaimsFieldError("id is not a string".to_string()))?
|
||||
.to_string();
|
||||
|
||||
let email = extra_claims.get("email")
|
||||
.ok_or_else(|| JwtError::CustomClaimsFieldError("no email claim".to_string()))?
|
||||
.as_str()
|
||||
.ok_or_else(|| JwtError::CustomClaimsFieldError("email is not a string".to_string()))?
|
||||
.to_string();
|
||||
|
||||
let sso = extra_claims.get("sso")
|
||||
.map(|val| val.as_bool())
|
||||
.flatten()
|
||||
.unwrap_or(false);
|
||||
|
||||
Ok(Self {
|
||||
created: common_claims.created,
|
||||
expiration: common_claims.expiration,
|
||||
id,
|
||||
email,
|
||||
sso,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::creds::jwt_claims::JwtClaims;
|
||||
use jwt_light::parse_jwt_claims_trait::ParseJwtClaims;
|
||||
|
||||
#[test]
|
||||
fn test_token() {
|
||||
/*{
|
||||
"id": 23129405,
|
||||
"email": "echelon@gmail.com",
|
||||
"exp": 1750201256.49,
|
||||
"iat": 1747609256.49,
|
||||
"sso": false
|
||||
}*/
|
||||
let jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MjMxMjk0MDUsImVtYWlsIjoiZWNoZWxvbkBnbWFpbC5jb20iLCJleHAiOjE3NTAyMDEyNTYuNDksImlhdCI6MTc0NzYwOTI1Ni40OSwic3NvIjpmYWxzZX0.UxbJozIiHSApqI8_Vl7o2d7q7CzqpXIzsZoazCtY75s";
|
||||
let claims = JwtClaims::parse_claims(jwt).unwrap();
|
||||
|
||||
assert_eq!(claims.id, "23129405");
|
||||
assert_eq!(claims.email, "echelon@gmail.com");
|
||||
assert_eq!(claims.sso, false);
|
||||
assert_eq!(claims.created.timestamp(), 1747609256);
|
||||
assert_eq!(claims.expiration.timestamp(), 1750201256);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod credentials;
|
||||
pub mod jwt_bearer_token;
|
||||
pub mod jwt_claims;
|
||||
@@ -0,0 +1,39 @@
|
||||
use jwt_light::error::JwtError;
|
||||
use reqwest::StatusCode;
|
||||
use std::error::Error;
|
||||
use std::fmt::{Display, Formatter};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ApiError {
|
||||
/// Inability to parse JWT.
|
||||
JwtError(JwtError),
|
||||
|
||||
/// An error that occurred as or after the request was sent.
|
||||
ReqwestError(reqwest::Error),
|
||||
|
||||
/// Some other failure response.
|
||||
UncategorizedBadResponse{
|
||||
status_code: StatusCode,
|
||||
message : String,
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for ApiError {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
ApiError::JwtError(err) => write!(f, "JwtError: {}", err),
|
||||
ApiError::ReqwestError(err) => write!(f, "ReqwestError: {}", err),
|
||||
ApiError::UncategorizedBadResponse { status_code, message } => {
|
||||
write!(f, "UncategorizedBadResponse: {:?} : {}", status_code, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for ApiError {}
|
||||
|
||||
impl From<JwtError> for ApiError {
|
||||
fn from(err: JwtError) -> Self {
|
||||
ApiError::JwtError(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
use crate::error::api_error::ApiError;
|
||||
use crate::error::runwayml_error::RunwayMlError;
|
||||
|
||||
/// This assumes the request failed and returned a non-200.
|
||||
/// The caller should check.
|
||||
pub async fn classify_http_error_response(response: reqwest::Response) -> RunwayMlError {
|
||||
let status = response.status();
|
||||
let message = match response.text().await {
|
||||
Ok(text) => text,
|
||||
Err(err) => return RunwayMlError::Api(ApiError::ReqwestError(err)),
|
||||
};
|
||||
|
||||
// TODO: Handle various error messages from the API.
|
||||
|
||||
//anyhow::anyhow!("Upload failed with status {}: {}", status, message))
|
||||
RunwayMlError::Api(ApiError::UncategorizedBadResponse { status_code: status, message })
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
use std::error::Error;
|
||||
use std::fmt::{Display, Formatter};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ClientError {
|
||||
/// No JWT bearer token in the credentials
|
||||
NoJwtBearerToken,
|
||||
|
||||
/// An error that occurred clientside before the request was sent.
|
||||
ReqwestError(reqwest::Error),
|
||||
}
|
||||
|
||||
impl Display for ClientError {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
ClientError::NoJwtBearerToken => write!(f, "No JWT bearer token in the credentials"),
|
||||
ClientError::ReqwestError(err) => write!(f, "Reqwest error: {}", err),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for ClientError {}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod api_error;
|
||||
pub mod classify_http_error_response;
|
||||
pub mod client_error;
|
||||
pub mod runwayml_error;
|
||||
@@ -0,0 +1,33 @@
|
||||
use crate::error::api_error::ApiError;
|
||||
use crate::error::client_error::ClientError;
|
||||
use std::error::Error;
|
||||
use std::fmt::{Display, Formatter};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum RunwayMlError {
|
||||
Api(ApiError),
|
||||
Client(ClientError),
|
||||
}
|
||||
|
||||
impl Display for RunwayMlError{
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Api(err) => write!(f, "ApiError: {}", err),
|
||||
Self::Client(err) => write!(f, "ClientError: {}", err),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for RunwayMlError {}
|
||||
|
||||
impl From<ApiError> for RunwayMlError {
|
||||
fn from(err: ApiError) -> Self {
|
||||
RunwayMlError::Api(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ClientError> for RunwayMlError {
|
||||
fn from(err: ClientError) -> Self {
|
||||
RunwayMlError::Client(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/// TODO DOCS
|
||||
|
||||
pub mod creds;
|
||||
pub mod error;
|
||||
pub mod requests;
|
||||
@@ -0,0 +1,158 @@
|
||||
use crate::creds::credentials::Credentials;
|
||||
use crate::error::api_error::ApiError;
|
||||
use crate::error::classify_http_error_response::classify_http_error_response;
|
||||
use crate::error::client_error::ClientError;
|
||||
use crate::error::runwayml_error::RunwayMlError;
|
||||
use log::{debug, error, warn};
|
||||
use reqwest::Client;
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
|
||||
const GENERATE_URL: &str = "https://api.runwayml.com/v1/tasks";
|
||||
|
||||
// TODO: This endpoint is not implemented yet.
|
||||
// TODO: This endpoint is not implemented yet.
|
||||
// TODO: This endpoint is not implemented yet.
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct TaskRequest {
|
||||
/// eg. "gen4"
|
||||
#[serde(rename = "taskType")]
|
||||
task_type: String,
|
||||
/// eg. false
|
||||
internal: bool,
|
||||
/// inner request payload
|
||||
options: TaskRequestOptions,
|
||||
/// eg. 23129405
|
||||
#[serde(rename = "asTeamId")]
|
||||
as_team_id: u64,
|
||||
/// eg. fc66957b-2651-4362-aca0-5468e4ee61b0
|
||||
#[serde(rename = "sessionId")]
|
||||
session_id: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct TaskRequestOptions {
|
||||
/// eg. Gen-4 3438043024
|
||||
name: String,
|
||||
/// eg. 3438043024
|
||||
seed: u64,
|
||||
/// Probably whether the request is free (exploreMode = unlimited queue)
|
||||
#[serde(rename = "exploreMode")]
|
||||
explore_mode: bool,
|
||||
/// eg. 5
|
||||
seconds: u8,
|
||||
/// eg. false
|
||||
watermark: bool,
|
||||
/// This can be blank / empty string!
|
||||
text_prompt: String,
|
||||
/// eg. "i2v"
|
||||
route: String,
|
||||
/// A URL, eg. https://d2jqrm6oza8nb6.cloudfront.net/datasets/586d77ff-b33b-4100-b4d3-c04f8b046b00.jpg?_jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJrZXlIYXNoIjoiNTJhMTY0N2RkZjFjMGU3NCIsImJ1Y2tldCI6InJ1bndheS1kYXRhc2V0cyIsInN0YWdlIjoicHJvZCIsImV4cCI6MTc0Nzc4NTYwMH0.N_TztqlU2pzPegOq7e8tn_6zb16OLmDmerlTtfr_-FI
|
||||
init_image: String,
|
||||
/// eg. 1280
|
||||
width: u16,
|
||||
/// eg. 720
|
||||
height: u16,
|
||||
/// eg. b5c42f29-d63d-4124-88c0-019ab1299829
|
||||
#[serde(rename = "assetGroupId")]
|
||||
asset_group_id: String,
|
||||
}
|
||||
|
||||
pub async fn gen4_image_to_video(
|
||||
credentials: &Credentials,
|
||||
) -> Result<(), RunwayMlError> {
|
||||
|
||||
let jwt = match credentials.jwt_bearer_token.as_ref() {
|
||||
Some(token) => token,
|
||||
None => {
|
||||
error!("Failed to generate bearer token. No JWT bearer token found.");
|
||||
return Err(ClientError::NoJwtBearerToken.into());
|
||||
}
|
||||
};
|
||||
|
||||
let endpoint_request = TaskRequest {
|
||||
task_type: "gen4".to_string(),
|
||||
internal: false,
|
||||
options: TaskRequestOptions {
|
||||
name: "Gen-4 3438043025".to_string(),
|
||||
seed: 3438043025,
|
||||
explore_mode: true,
|
||||
seconds: 5,
|
||||
watermark: false,
|
||||
text_prompt: "".to_string(),
|
||||
route: "i2v".to_string(),
|
||||
init_image: "https://d2jqrm6oza8nb6.cloudfront.net/datasets/586d77ff-b33b-4100-b4d3-c04f8b046b00.jpg?_jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJrZXlIYXNoIjoiNTJhMTY0N2RkZjFjMGU3NCIsImJ1Y2tldCI6InJ1bndheS1kYXRhc2V0cyIsInN0YWdlIjoicHJvZCIsImV4cCI6MTc0Nzc4NTYwMH0.N_TztqlU2pzPegOq7e8tn_6zb16OLmDmerlTtfr_-FI".to_string(),
|
||||
width: 1280,
|
||||
height: 720,
|
||||
asset_group_id: "b5c42f29-d63d-4124-88c0-019ab1299829".to_string(),
|
||||
},
|
||||
as_team_id: 23129405,
|
||||
session_id: "fc66957b-2651-4362-aca0-5468e4ee61b0".to_string(),
|
||||
};
|
||||
|
||||
let client = Client::builder()
|
||||
.gzip(true)
|
||||
.build()
|
||||
.map_err(|err| {
|
||||
error!("Failed to create HTTP client: {}", err);
|
||||
RunwayMlError::Client(ClientError::ReqwestError(err))
|
||||
})?;
|
||||
|
||||
let http_request = client.post(GENERATE_URL)
|
||||
.header("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:137.0) Gecko/20100101 Firefox/137.0")
|
||||
.header("Accept", "application/json")
|
||||
.header("Accept-Encoding", "gzip, deflate, br")
|
||||
.header("Accept-Language", "en-US,en;q=0.5")
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Authorization", jwt.to_authorization_header_value());
|
||||
|
||||
let http_request = http_request.json(&endpoint_request)
|
||||
.build()
|
||||
.map_err(|err| RunwayMlError::Client(ClientError::ReqwestError(err)))?;
|
||||
|
||||
let response = client.execute(http_request)
|
||||
.await
|
||||
.map_err(|err| RunwayMlError::Api(ApiError::ReqwestError(err)))?;
|
||||
|
||||
let status = response.status();
|
||||
|
||||
if !status.is_success() {
|
||||
warn!("Gen4 image to video failure. Status: {:?}", status);
|
||||
|
||||
let error = classify_http_error_response(response).await;
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
debug!("Gen4 I2V returned a 200.");
|
||||
|
||||
let response_body = &response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|err| RunwayMlError::Api(ApiError::ReqwestError(err)))?;
|
||||
|
||||
debug!("Response: {}", response_body);
|
||||
|
||||
//if &response_body == "null" {
|
||||
// error!("Failed to generate bearer token. Response was `null`.");
|
||||
// return Err(SoraError::FailedToGenerateBearer)
|
||||
//}
|
||||
|
||||
//let auth_response: SoraAuthResponse = serde_json::from_str(&response_body)?;
|
||||
|
||||
// TODO: This endpoint is not implemented yet.
|
||||
// TODO: This endpoint is not implemented yet.
|
||||
// TODO: This endpoint is not implemented yet.
|
||||
|
||||
todo!("This is not yet implemented")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use errors::AnyhowResult;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn send_test_request() -> AnyhowResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod gen4_image_to_video;
|
||||
@@ -0,0 +1,34 @@
|
||||
[package]
|
||||
name = "build_metadata"
|
||||
resolver = "2" # NB: Required by `build-info`
|
||||
edition = "2024"
|
||||
version = "0.0.1"
|
||||
authors = [
|
||||
"Brandon Thomas <bt@brand.io>",
|
||||
"Brandon Thomas <echelon@gmail.com>",
|
||||
]
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
name = "build_metadata"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[package.metadata.cargo-udeps.ignore]
|
||||
normal = ["workspace-build-acceleration"]
|
||||
|
||||
[dependencies]
|
||||
# Workspace hack (faster compile times)
|
||||
workspace-build-acceleration = { workspace = true }
|
||||
|
||||
# Internal
|
||||
# (None yet)
|
||||
|
||||
# External
|
||||
build-info = "0.0.40"
|
||||
|
||||
[dev-dependencies]
|
||||
# None yet
|
||||
|
||||
[build-dependencies]
|
||||
build-info-build = "0.0.40"
|
||||
|
||||
@@ -0,0 +1,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();
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
use std::ops::Deref;
|
||||
use build_info::chrono::{DateTime, Utc};
|
||||
|
||||
build_info::build_info!(fn version);
|
||||
|
||||
pub fn build_timestamp() -> DateTime<Utc>{
|
||||
version().timestamp
|
||||
}
|
||||
|
||||
pub fn git_commit_id() -> Option<&'static str> {
|
||||
version().version_control
|
||||
.as_ref()
|
||||
.map(|vc| vc.git())
|
||||
.flatten()
|
||||
.map(|git| git.commit_id.deref())
|
||||
}
|
||||
|
||||
pub fn git_commit_short_id() -> Option<&'static str> {
|
||||
version().version_control
|
||||
.as_ref()
|
||||
.map(|vc| vc.git())
|
||||
.flatten()
|
||||
.map(|git| git.commit_short_id.deref())
|
||||
}
|
||||
|
||||
pub fn git_commit_timestamp() -> Option<DateTime<Utc>> {
|
||||
version().version_control
|
||||
.as_ref()
|
||||
.map(|vc| vc.git())
|
||||
.flatten()
|
||||
.map(|git| git.commit_timestamp)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::git_commit_id;
|
||||
|
||||
#[test]
|
||||
#[ignore] // Run manually for testing, not in CI
|
||||
fn test_git_commit_id() {
|
||||
assert_eq!(git_commit_id(), None);
|
||||
}
|
||||
}
|
||||
@@ -17,13 +17,13 @@ path = "src/lib.rs"
|
||||
normal = ["workspace-build-acceleration"]
|
||||
|
||||
[dependencies]
|
||||
# cargo-hakari workspace hack (faster compile times)
|
||||
workspace-build-acceleration = { workspace = true }
|
||||
|
||||
# NB: This is one of the most central crates in the monorepo and everything depends on it.
|
||||
# Consider the dependencies here wisely.
|
||||
# Ideally we have almost nothing as a dependency of this crate.
|
||||
anyhow = "1.0.86"
|
||||
|
||||
# Workspace hack (faster compile times)
|
||||
workspace-build-acceleration = { version = "0.1", path = "../../workspace-build-acceleration" }
|
||||
anyhow = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
# None yet
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
[package]
|
||||
name = "jwt_light"
|
||||
edition = "2024"
|
||||
version = "0.0.1"
|
||||
publish = false
|
||||
|
||||
[package.metadata.cargo-udeps.ignore]
|
||||
normal = ["workspace-build-acceleration"]
|
||||
|
||||
[lib]
|
||||
name = "jwt_light"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
|
||||
# Workspace hack (faster compile times)
|
||||
workspace-build-acceleration = { workspace = true }
|
||||
|
||||
# Internal
|
||||
# (None)
|
||||
|
||||
# External
|
||||
base64 = { workspace = true }
|
||||
chrono = { workspace = true}
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
# (None)
|
||||
@@ -0,0 +1,109 @@
|
||||
use crate::error::JwtError;
|
||||
use crate::utils::json_number_to_datetime::json_number_to_datetime;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde_json::Value;
|
||||
|
||||
pub struct CommonClaims {
|
||||
/// From the "iat" field
|
||||
pub created: DateTime<Utc>,
|
||||
|
||||
/// From the "exp" field
|
||||
pub expiration: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl CommonClaims {
|
||||
pub fn from_json(raw_json_claims: &str) -> Result<Self, JwtError> {
|
||||
let claims : serde_json::Map<String, Value> = serde_json::from_str(&raw_json_claims)
|
||||
.map_err(|err| JwtError::CommonFieldError(
|
||||
format!("failure to parse claims json: {:?}", err)))?;
|
||||
|
||||
// NB: Some JWTs have "iat" and "exp" as integer numbers, some have them as floats
|
||||
let iat = {
|
||||
let iat = claims.get("iat")
|
||||
.map(|val| val.as_number())
|
||||
.flatten()
|
||||
.ok_or_else(|| JwtError::CommonFieldError("no iat claim".to_string()))?;
|
||||
|
||||
json_number_to_datetime(iat, "iat")?
|
||||
};
|
||||
|
||||
let exp = {
|
||||
let exp = claims.get("exp")
|
||||
.map(|val| val.as_number())
|
||||
.flatten()
|
||||
.ok_or_else(|| JwtError::CommonFieldError("no exp claim".to_string()))?;
|
||||
|
||||
json_number_to_datetime(exp, "exp")?
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
created: iat,
|
||||
expiration: exp,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::common_claims::CommonClaims;
|
||||
use crate::utils::raw_jwt_to_raw_json::raw_jwt_to_raw_json;
|
||||
use chrono::DateTime;
|
||||
|
||||
#[test]
|
||||
fn test_runwayml() {
|
||||
let jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MjMxMjk0MDUsImVtYWlsIjoiZWNoZWxvbkBnbWFpbC5jb20iLCJleHAiOjE3NTAyMDEyNTYuNDksImlhdCI6MTc0NzYwOTI1Ni40OSwic3NvIjpmYWxzZX0.UxbJozIiHSApqI8_Vl7o2d7q7CzqpXIzsZoazCtY75s";
|
||||
|
||||
let json = raw_jwt_to_raw_json(jwt).unwrap();
|
||||
|
||||
let claims = CommonClaims::from_json(&json).unwrap();
|
||||
|
||||
// jwt payload:
|
||||
// {
|
||||
// "id": 23129405,
|
||||
// "email": "echelon@gmail.com",
|
||||
// "exp": 1750201256.49,
|
||||
// "iat": 1747609256.49,
|
||||
// "sso": false
|
||||
// }
|
||||
|
||||
assert_eq!(claims.created, DateTime::from_timestamp(1747609256, 0).unwrap());
|
||||
assert_eq!(claims.expiration, DateTime::from_timestamp(1750201256, 0).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sora() {
|
||||
let token =
|
||||
"eyJhbGciOiJSUzI1NiIsImtpZCI6IjE5MzQ0ZTY1LWJiYzktNDRkMS1hOWQwLWY5NTdiMDc5YmQwZSIsInR5cCI6Ik\
|
||||
pXVCJ9.eyJhdWQiOlsiaHR0cHM6Ly9hcGkub3BlbmFpLmNvbS92MSJdLCJjbGllbnRfaWQiOiJhcHBfTTFuUTN0UjV2\
|
||||
VzdYOWpMMnBFNmdIOGRLNCIsImV4cCI6MTc0NTgwMDQ3MiwiaHR0cHM6Ly9hcGkub3BlbmFpLmNvbS9hdXRoIjp7InV\
|
||||
zZXJfaWQiOiJ1c2VyLTZOeEpmbEFIb0VCREp6Wmw5aVhocWJERyJ9LCJodHRwczovL2FwaS5vcGVuYWkuY29tL3Byb2\
|
||||
ZpbGUiOnsiZW1haWwiOiJ2b2NvZGVzMjAyMEBnbWFpbC5jb20iLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZX0sImlhdCI6M\
|
||||
Tc0NDkzNjQ3MSwiaXNzIjoiaHR0cHM6Ly9hdXRoLm9wZW5haS5jb20iLCJqdGkiOiI0NTZmZjY5Yi0xZTZhLTQzOWYt\
|
||||
YTYzMC1jNmFjYzI3NzU1YjAiLCJuYmYiOjE3NDQ5MzY0NzEsInB3ZF9hdXRoX3RpbWUiOjE3NDQwNTkzMTc3NTAsInN\
|
||||
jcCI6WyJvcGVuaWQiLCJlbWFpbCIsInByb2ZpbGUiLCJvZmZsaW5lX2FjY2VzcyJdLCJzZXNzaW9uX2lkIjoiYXV0aH\
|
||||
Nlc3NfcEE4UGl3V0trNmJsSE1qQnVCQnVSRmh2Iiwic3ViIjoiZ29vZ2xlLW9hdXRoMnwxMTMxMDE5Njc2MTIzOTY3O\
|
||||
TM3NzcifQ.JOxYfrQSIsHdf-VN1zcOqDOS3tF1HpUYnRVfPKgi65am3sNiniOM1kWTNrKE5-sh4DaZ33G_XniGkhg-9\
|
||||
QKF8tspXpZ6BnTNkf-QdGqR_v89AIO8pZgQ7oECLXhnmg-31G-LAHlGfwWedJ6qPVy4dL5zoR0wVyoQ8gYv5QzQC-zd\
|
||||
IF7uY-umKKloAAYP_tkKLlQHackjTynN-bpu2mKv55-h7a3hSEPfxvgX0SsvL69xtNsZkwb43bqyP1x_c2ErfYoPyzg\
|
||||
rqgMIRZjCw9TI33vISKM_BfQXBiZT_BHkX4s0Jng3ph4vrwvnBsa0HpNxBhQYKb1u9gZXs7XjLZhjZJZsDTQI1V4Wov\
|
||||
iAL7ihvZkLxJRtDCOmX7-5BTEsMInFgXUAhCDhQUMwSfLLJuinBS96NGSX5_TfIVMkbCl-HWzdQx7KZAsahNe_CmuYT\
|
||||
lZH32NuRn78ohY4eViRyfC8OXVLwoLxxpen2CWlV2dxV5wwXeij_kHt5wOXSGYpSfxz37cK0CaK8K2pwudasxQ0lv2V\
|
||||
_Xx_z4UaEOr5wqT_BK1A1HO4HRjvFX2soF-qm4GCSWAbmFZs7DVEClrotxRx2romifRVb8XRia2M6YTmDyIT0YphFgG\
|
||||
nnrDIZNKO23uDYT60LVMVYpCCY2RiwxCR9bn7AeGbmi7cpL9aYZ5uJRM";
|
||||
|
||||
let json = raw_jwt_to_raw_json(token).unwrap();
|
||||
|
||||
// jwt payload :
|
||||
// - "https://api.openai.com/auth".user_id = "user-6NxJflAHoEBDJzZl9iXhqbDG"
|
||||
// - "https://api.openai.com/profile".email = "vocodes2020@gmail.com"
|
||||
// - "https://api.openai.com/profile".email_verified = true
|
||||
// - "iat": 1744936471, - issued at claim (5 days ago)
|
||||
// - "nbf": 1744936471, - not before, don't use before date
|
||||
// - "exp": 1745800472, - in 5 days, expiry
|
||||
|
||||
let claims = CommonClaims::from_json(&json).unwrap();
|
||||
|
||||
assert_eq!(claims.created, DateTime::from_timestamp(1744936471, 0).unwrap());
|
||||
assert_eq!(claims.expiration, DateTime::from_timestamp(1745800472, 0).unwrap());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
use std::error::Error;
|
||||
use std::fmt::{Display, Formatter};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum JwtError {
|
||||
/// Generic error parsing the JWT
|
||||
ParseError(String),
|
||||
/// Error parsing the JWT JSON payload specifically
|
||||
JsonParseError(serde_json::Error),
|
||||
/// Error decoding the base64 payload
|
||||
Base64DecodeError(base64::DecodeError),
|
||||
/// Error decoding from UTF-8 (should be exceedingly rare)
|
||||
Utf8Error(std::string::FromUtf8Error),
|
||||
/// Error parsing a common field (iat, exp, etc.)
|
||||
CommonFieldError(String),
|
||||
/// Error parsing a custom claims field.
|
||||
CustomClaimsFieldError(String),
|
||||
}
|
||||
|
||||
impl Error for JwtError {}
|
||||
|
||||
impl Display for JwtError {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::ParseError(reason) => { write!(f, "JwtError::ParseError : {}", reason) }
|
||||
Self::JsonParseError(err) => { write!(f, "JwtError::JsonParseError : {:?}", err) }
|
||||
Self::Base64DecodeError(err) => { write!(f, "JwtError::Base64DecodeError : {:?}", err) }
|
||||
Self::Utf8Error(err) => { write!(f, "JwtError::Utf8Error : {:?}", err) }
|
||||
Self::CommonFieldError(reason) => { write!(f, "JwtError::CommonFieldError : {}", reason) }
|
||||
Self::CustomClaimsFieldError(reason) => { write!(f, "JwtError::CustomClaimsFieldError : {}", reason) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for JwtError {
|
||||
fn from(err: serde_json::Error) -> Self {
|
||||
JwtError::JsonParseError(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<base64::DecodeError> for JwtError {
|
||||
fn from(err: base64::DecodeError) -> Self {
|
||||
JwtError::Base64DecodeError(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::string::FromUtf8Error> for JwtError {
|
||||
fn from(err: std::string::FromUtf8Error) -> Self {
|
||||
JwtError::Utf8Error(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
//! Lightweight JWT library for Rust.
|
||||
//! No cryptographic verification, no OpenSSL, etc.
|
||||
//!
|
||||
//! DO NOT USE THIS FOR OUR OWN JWT VERIFICATION !!!
|
||||
//! It's okay to parse external vendors since we're not validating, but
|
||||
//! do not ever use this to secure our own server.
|
||||
|
||||
pub (crate) mod utils;
|
||||
|
||||
pub mod common_claims;
|
||||
pub mod error;
|
||||
pub mod parse_jwt_claims_trait;
|
||||
@@ -0,0 +1,27 @@
|
||||
use crate::common_claims::CommonClaims;
|
||||
use crate::error::JwtError;
|
||||
use crate::utils::decode_base64::decode_base64;
|
||||
use crate::utils::split_components::split_components;
|
||||
use serde_json::Value;
|
||||
|
||||
/// Trait for simple JWT claims parsing
|
||||
pub trait ParseJwtClaims : Sized {
|
||||
/// Parse any special fields relevant to the struct
|
||||
/// Implement this method to complete the parsing.
|
||||
fn extract_claims(
|
||||
common_claims: CommonClaims,
|
||||
extra_claims: serde_json::Map<String, Value>,
|
||||
) -> Result<Self, JwtError>;
|
||||
|
||||
fn parse_claims(raw_jwt: &str) -> Result<Self, JwtError> {
|
||||
let [_header_str, claims_str, _signature_str] = split_components(raw_jwt)?;
|
||||
|
||||
let raw_json = decode_base64(claims_str)?;
|
||||
|
||||
let common_claims = CommonClaims::from_json(&raw_json)?;
|
||||
|
||||
let extra_claims : serde_json::Map<String, Value> = serde_json::from_str(&raw_json)?;
|
||||
|
||||
Self::extract_claims(common_claims, extra_claims)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
use crate::error::JwtError;
|
||||
use base64::prelude::BASE64_URL_SAFE_NO_PAD;
|
||||
use base64::Engine;
|
||||
|
||||
pub fn decode_base64(raw: &str) -> Result<String, JwtError> {
|
||||
let json_bytes = BASE64_URL_SAFE_NO_PAD.decode(raw)?;
|
||||
let json_str = String::from_utf8(json_bytes.clone())?;
|
||||
Ok(json_str)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
use crate::error::JwtError;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde_json::Number;
|
||||
|
||||
// NB: Some JWTs have "iat" and "exp" as integer numbers, some have them as floats
|
||||
pub (crate) fn json_number_to_datetime(timestamp: &Number, field_name: &str) -> Result<DateTime<Utc>, JwtError> {
|
||||
let result;
|
||||
|
||||
if timestamp.is_i64() {
|
||||
// Integer case
|
||||
let timestamp = timestamp.as_i64()
|
||||
.ok_or_else(|| JwtError::CommonFieldError(format!("{} is not an integer", field_name)))?;
|
||||
|
||||
result = DateTime::from_timestamp(timestamp, 0)
|
||||
.ok_or_else(|| JwtError::CommonFieldError(format!("{} is not a valid timestamp", field_name)))?;
|
||||
} else if timestamp.is_f64() {
|
||||
// Float case
|
||||
let timestamp = timestamp.as_f64()
|
||||
.ok_or_else(|| JwtError::CommonFieldError(format!("{} is not an integer", field_name)))?;
|
||||
|
||||
let timestamp = timestamp.round() as i64;
|
||||
|
||||
result = DateTime::from_timestamp(timestamp, 0)
|
||||
.ok_or_else(|| JwtError::CommonFieldError(format!("{} is not a valid timestamp", field_name)))?;
|
||||
} else {
|
||||
return Err(JwtError::CommonFieldError(format!("{} is not a number", field_name)));
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub (crate) mod decode_base64;
|
||||
pub (crate) mod json_number_to_datetime;
|
||||
pub (crate) mod raw_jwt_to_raw_json;
|
||||
pub (crate) mod split_components;
|
||||
@@ -0,0 +1,9 @@
|
||||
use crate::error::JwtError;
|
||||
use crate::utils::decode_base64::decode_base64;
|
||||
use crate::utils::split_components::split_components;
|
||||
|
||||
pub (crate) fn raw_jwt_to_raw_json(raw_jwt: &str) -> Result<String, JwtError> {
|
||||
let [_header_str, claims_str, _signature_str] = split_components(raw_jwt)?;
|
||||
let raw_json = decode_base64(claims_str)?;
|
||||
Ok(raw_json)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
use crate::error::JwtError;
|
||||
|
||||
const SEPARATOR: &str = ".";
|
||||
|
||||
pub fn split_components(token: &str) -> Result<[&str; 3], JwtError> {
|
||||
let mut components = token.split(SEPARATOR);
|
||||
let header = components.next()
|
||||
.ok_or_else(|| jwt_error("no header component"))?;
|
||||
|
||||
let claims = components.next()
|
||||
.ok_or_else(|| jwt_error("no claims component"))?;
|
||||
|
||||
let signature = components.next()
|
||||
.ok_or_else(|| jwt_error("no signature component"))?;
|
||||
|
||||
if components.next().is_some() {
|
||||
return Err(jwt_error("too many components"));
|
||||
}
|
||||
|
||||
Ok([header, claims, signature])
|
||||
}
|
||||
|
||||
fn jwt_error(reason: &str) -> JwtError {
|
||||
JwtError::ParseError(reason.to_string())
|
||||
}
|
||||
@@ -16,12 +16,12 @@ path = "src/lib.rs"
|
||||
normal = ["workspace-build-acceleration"]
|
||||
|
||||
[dependencies]
|
||||
# cargo-hakari workspace hack (faster compile times)
|
||||
workspace-build-acceleration = { workspace = true }
|
||||
|
||||
# Internal
|
||||
# (None yet)
|
||||
|
||||
# Workspace hack (faster compile times)
|
||||
workspace-build-acceleration = { version = "0.1", path = "../../workspace-build-acceleration" }
|
||||
|
||||
# External
|
||||
# (None yet)
|
||||
|
||||
|
||||
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';
|
||||
+3
-1
@@ -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";
|
||||
|
||||
+2
-2
@@ -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";
|
||||
|
||||
+3
-2
@@ -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}
|
||||
/>
|
||||
|
||||
-160
@@ -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";
|
||||
-202
@@ -1,202 +0,0 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { FileUploader } from "react-drag-drop-files";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import {
|
||||
faCheck,
|
||||
faCircleXmark,
|
||||
faFileArrowUp,
|
||||
faFileAudio,
|
||||
faSpinnerThird,
|
||||
faXmark,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { Button, ButtonIcon, P, Tooltip, WaveformPlayer } from "~/components";
|
||||
import { MediaUploadApi } from "~/Classes/ApiManager";
|
||||
import { getFileName } from "~/utilities";
|
||||
import { AUDIO_FILE_TYPE, ToastTypes } from "~/enums";
|
||||
import { addToast } from "~/signals";
|
||||
const FILE_TYPES = Object.keys(AUDIO_FILE_TYPE);
|
||||
|
||||
interface Props {
|
||||
onFileStaged?: (file: File) => void;
|
||||
onClear?: () => void;
|
||||
onFileUploaded: (token: string) => void;
|
||||
}
|
||||
|
||||
export const UploadAudioComponent = ({
|
||||
onFileStaged,
|
||||
onClear,
|
||||
onFileUploaded,
|
||||
}: Props) => {
|
||||
const [{ file, uploadState }, setState] = useState<{
|
||||
file: File | undefined;
|
||||
uploadState: "none" | "staged" | "uploading" | "uploaded" | "error";
|
||||
}>({
|
||||
file: undefined,
|
||||
uploadState: "none",
|
||||
});
|
||||
|
||||
const audioUrl = file ? URL.createObjectURL(file) : "";
|
||||
|
||||
const handleChange = (file: File) => {
|
||||
setState((curr) => ({
|
||||
...curr,
|
||||
file: file,
|
||||
uploadState: "staged",
|
||||
}));
|
||||
if (onFileStaged) {
|
||||
onFileStaged(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setState((curr) => ({
|
||||
...curr,
|
||||
file: undefined,
|
||||
uploadState: "none",
|
||||
uploadToken: undefined,
|
||||
}));
|
||||
if (onClear) {
|
||||
onClear();
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadFile = useCallback(
|
||||
async (file: File) => {
|
||||
setState((curr) => ({ ...curr, uploadState: "uploading" }));
|
||||
|
||||
const mediaUploadApi = new MediaUploadApi();
|
||||
const request = {
|
||||
blob: file,
|
||||
fileName: getFileName(file),
|
||||
uuid: uuidv4(),
|
||||
maybe_title: getFileName(file),
|
||||
};
|
||||
const response = await mediaUploadApi.UploadAudio(request);
|
||||
if (response.success && response.data) {
|
||||
setState((curr) => ({
|
||||
...curr,
|
||||
uploadState: "uploaded",
|
||||
}));
|
||||
onFileUploaded(response.data);
|
||||
return;
|
||||
}
|
||||
//handle errors
|
||||
addToast(
|
||||
ToastTypes.ERROR,
|
||||
response.errorMessage || "Unknown Error: Upload Audio",
|
||||
);
|
||||
setState((curr) => ({
|
||||
...curr,
|
||||
uploadState: "staged",
|
||||
}));
|
||||
},
|
||||
[onFileUploaded],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Usage refer to https://github.com/KarimMokhtar/react-drag-drop-files */}
|
||||
<FileUploader
|
||||
handleChange={handleChange}
|
||||
name="file"
|
||||
types={FILE_TYPES}
|
||||
maxSize={50}
|
||||
>
|
||||
<DragAndDropZone file={file} />
|
||||
</FileUploader>
|
||||
|
||||
{file && (
|
||||
<>
|
||||
<div className="flex items-center gap-3 rounded-lg bg-brand-secondary p-3">
|
||||
<div className="grow">
|
||||
<WaveformPlayer audio={audioUrl} hasPlayButton />
|
||||
</div>
|
||||
<Tooltip content="Remove" position="top">
|
||||
<ButtonIcon
|
||||
icon={faXmark}
|
||||
onClick={handleClear}
|
||||
className="h-auto w-auto bg-transparent p-0 text-xl opacity-60 hover:bg-transparent hover:opacity-90"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
className="w-full py-2.5"
|
||||
variant="action"
|
||||
onClick={() => {
|
||||
if (!file) {
|
||||
//TODO: Maybe more verbose on not having file
|
||||
return false;
|
||||
}
|
||||
handleUploadFile(file);
|
||||
}}
|
||||
disabled={uploadState !== "staged"}
|
||||
icon={
|
||||
uploadState === "uploaded"
|
||||
? faCheck
|
||||
: uploadState === "error"
|
||||
? faCircleXmark
|
||||
: faFileArrowUp
|
||||
}
|
||||
>
|
||||
{uploadState === "uploaded"
|
||||
? "Uploaded"
|
||||
: uploadState === "error"
|
||||
? "Upload Error"
|
||||
: "Upload Audio"}
|
||||
{uploadState === "uploading" && (
|
||||
<FontAwesomeIcon icon={faSpinnerThird} spin />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const DragAndDropZone = ({ file }: { file: File | undefined }) => {
|
||||
const fileSize =
|
||||
file && file.size >= 1024 * 1024
|
||||
? (file.size / 1024 / 1024).toFixed(2) + " MB"
|
||||
: file
|
||||
? `${Math.floor(file.size / 1024)} KB`
|
||||
: null;
|
||||
const fileName =
|
||||
file && file.name ? file.name.split(".")[0].toUpperCase() : "";
|
||||
|
||||
if (!file) {
|
||||
return (
|
||||
<div className="flex cursor-pointer items-center gap-3.5 rounded-lg border border-dashed border-[#363636] bg-brand-secondary p-3">
|
||||
<FontAwesomeIcon icon={faFileArrowUp} className="text-4xl" />
|
||||
<div className="flex flex-col gap-0">
|
||||
<P className="font-medium">
|
||||
<u>Upload a file</u> or drop it here
|
||||
</P>
|
||||
<P className="flex items-center gap-2 text-sm font-normal opacity-50">
|
||||
{FILE_TYPES.join(", ").toString()} supported
|
||||
</P>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div className="flex cursor-pointer items-center gap-3.5 rounded-lg border border-dashed border-[#363636] bg-brand-secondary p-3">
|
||||
<FontAwesomeIcon icon={faFileAudio} className="text-4xl" />
|
||||
<div className="flex flex-col gap-0">
|
||||
<P className="font-medium">
|
||||
{file.name.slice(0, file.name.lastIndexOf("."))}
|
||||
</P>
|
||||
<P className="flex items-center gap-2 text-sm font-normal">
|
||||
<span className="opacity-50">
|
||||
{`${fileName} file size: ${fileSize} `}
|
||||
</span>
|
||||
<u className="transition-all hover:text-white/80">Change File</u>
|
||||
</P>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export { UploadAudioComponent } from "./UploadAudioComponent";
|
||||
@@ -1,8 +1,3 @@
|
||||
export * from "./ErrorDialog";
|
||||
export * from "./Toaster";
|
||||
export * from "./TopBar";
|
||||
export * from "./PremiumLock";
|
||||
|
||||
// all the Upload stuff will depend on the API Manager Signal
|
||||
export * from "./UploadAudioButtonDialogue";
|
||||
export * from "./UploadAudioComponent";
|
||||
|
||||
@@ -10,7 +10,7 @@ import "@fortawesome/fontawesome-svg-core/styles.css";
|
||||
import { config } from "@fortawesome/fontawesome-svg-core";
|
||||
import EnvironmentVariables from "~/Classes/EnvironmentVariables";
|
||||
import { pageHeight, pageWidth, persistLogin } from "~/signals";
|
||||
import { showWizard } from "~/pages/PageEnigma/Wizard/signals/wizard";
|
||||
|
||||
import { posthog } from "posthog-js";
|
||||
|
||||
config.autoAddCss = false; /* eslint-disable import/first */
|
||||
@@ -55,17 +55,9 @@ const GlobalSettingsManager = ({ env }: { env: Record<string, string> }) => {
|
||||
pageWidth.value = window.innerWidth;
|
||||
}
|
||||
|
||||
function initWizard() {
|
||||
if (showWizard.value) {
|
||||
return;
|
||||
}
|
||||
const wizard = localStorage.getItem("storyteller-wizard");
|
||||
showWizard.value = wizard ? "" : "initial";
|
||||
localStorage.setItem("storyteller-wizard", "shown");
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
initWizard();
|
||||
|
||||
setPage();
|
||||
window.addEventListener("resize", setPage);
|
||||
return () => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
+1
-1
@@ -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
-261
@@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
-15
@@ -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();
|
||||
// });
|
||||
-31
@@ -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() {}
|
||||
}
|
||||
-567
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-162
@@ -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);
|
||||
});
|
||||
-125
@@ -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
Reference in New Issue
Block a user