mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-01 23:35:40 -05:00
Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf2a3ad56f | ||
|
|
7b2b71eaf0 | ||
|
|
4883185ad8 | ||
|
|
a3b9cd71ad | ||
|
|
b1965a6019 | ||
|
|
29b1468e1a | ||
|
|
1008b6f69a | ||
|
|
f797d6f769 | ||
|
|
3cb2c31aa7 | ||
|
|
6f64086834 | ||
|
|
170ee9b069 | ||
|
|
75ab5887f7 | ||
|
|
8e67654b9d | ||
|
|
76172e3956 | ||
|
|
affc19abc5 | ||
|
|
15a04d5f52 | ||
|
|
49a5f67dc8 | ||
|
|
0db259b64a | ||
|
|
363e3fee37 | ||
|
|
5cead36b18 | ||
|
|
f435e9fb7e | ||
|
|
58ebe1f836 |
Generated
-13
@@ -10215,19 +10215,6 @@ dependencies = [
|
||||
"lazy_static",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shared_service_components"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"errors",
|
||||
"log",
|
||||
"openai_sora_client",
|
||||
"r2d2_redis",
|
||||
"reqwest 0.12.23",
|
||||
"workspace-build-acceleration",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "1.3.0"
|
||||
|
||||
@@ -80,7 +80,6 @@ members = [
|
||||
"crates/lib/migration",
|
||||
"crates/lib/password",
|
||||
"crates/lib/server_environment",
|
||||
"crates/lib/shared_service_components",
|
||||
"crates/lib/sqlx_mysql_helpers",
|
||||
"crates/lib/storyteller_root",
|
||||
"crates/lib/subprocess_common",
|
||||
|
||||
@@ -1,3 +1,17 @@
|
||||
<p align="center">
|
||||
<video src="https://github.com/user-attachments/assets/b4e24c27-d87d-4fd1-8599-dc0d0b8af48d" width="100%" autoplay="true" loop controls>
|
||||
</p>
|
||||
|
||||
<p align="center">The IDE for artists.</p>
|
||||
<p align="center">
|
||||
<a href="https://discord.gg/artcraft"><img alt="Discord" src="https://img.shields.io/discord/1359579021108842617?style=for-the-badge&label=discord&color=ffffff&logo=discord&logoColor=ffffff" /></a>
|
||||
<a href="https://www.youtube.com/@OfficialArtCraftStudios"><img alt="YouTube" src="https://img.shields.io/youtube/channel/subscribers/UCdjY4VG0ntoGwFsKZO4sVWA?style=for-the-badge&logo=YouTube" /></a>
|
||||
<a href="https://x.com/intent/follow?screen_name=get_artcraft"><img alt="X" src="https://img.shields.io/twitter/follow/get_artcraft?style=for-the-badge&label=follow&logo=x&logoColor=ffffff&color=ffffff" /></a>
|
||||
<a href="https://www.linkedin.com/company/artcraft-ai"><img alt="LinkedIn" src="https://img.shields.io/badge/linkedin--0A66C2?style=for-the-badge"></a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
ArtCraft
|
||||
========
|
||||
ArtCraft is the IDE for interactive AI image and video creation.
|
||||
@@ -66,10 +80,3 @@ those providers, for example: OpenArt, FreePik, etc.
|
||||
- [license](./LICENSE.md)
|
||||
- [roadmap](./ROADMAP.md)
|
||||
|
||||
## Social
|
||||
|
||||
[](https://discord.gg/artcraft)
|
||||
[](https://www.youtube.com/@OfficialArtCraftStudios)
|
||||
[](https://www.linkedin.com/company/artcraft-ai)
|
||||
[](https://x.com/get_artcraft)
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
|
||||
/// This is a comprehensive list of common aspect ratios.
|
||||
/// This is a comprehensive list of common aspect ratios you can specify when enqueuing a generation.
|
||||
/// Not every model will support every aspect ratio.
|
||||
/// In the case a model doesn't support the aspect ratio, pick the nearest option.
|
||||
/// In the case a model doesn't support the aspect ratio, gracefully pick the nearest option.
|
||||
#[derive(Copy, Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CommonAspectRatio {
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
pub mod aspect_ratio;
|
||||
pub mod aspect_ratio;
|
||||
pub mod resolution;
|
||||
@@ -0,0 +1,17 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
|
||||
/// This is a comprehensive list of common resolutions you can specify when enqueuing a generation.
|
||||
/// Not every model will support every resolution
|
||||
/// In the case a model doesn't support the resolution, gracefully pick the nearest option.
|
||||
#[derive(Copy, Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CommonResolution {
|
||||
/// Models: Nano Banana Pro
|
||||
OneK,
|
||||
/// Models: Nano Banana Pro
|
||||
TwoK,
|
||||
/// Models: (None)
|
||||
ThreeK,
|
||||
/// Models: Nano Banana Pro
|
||||
FourK,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod resolution_to_artcraft_nano_banana_pro;
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
use crate::core::api_adapters::resolution::common_resolution::CommonResolution;
|
||||
use artcraft_api_defs::generate::image::multi_function::nano_banana_pro_multi_function_image_gen::NanoBananaProMultiFunctionImageGenImageResolution;
|
||||
|
||||
pub fn resolution_to_artcraft_nano_banana_pro(resolution: CommonResolution) -> NanoBananaProMultiFunctionImageGenImageResolution {
|
||||
match resolution {
|
||||
CommonResolution::OneK => NanoBananaProMultiFunctionImageGenImageResolution::OneK,
|
||||
CommonResolution::TwoK => NanoBananaProMultiFunctionImageGenImageResolution::TwoK,
|
||||
// NB: ThreeK doesn't exist in Nano Banana Pro, use FourK as nearest higher resolution
|
||||
CommonResolution::ThreeK => NanoBananaProMultiFunctionImageGenImageResolution::FourK,
|
||||
CommonResolution::FourK => NanoBananaProMultiFunctionImageGenImageResolution::FourK,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod common_resolution;
|
||||
pub mod convert;
|
||||
+1
-2
@@ -62,8 +62,7 @@ pub async fn handle_artcraft_gpt_image_1p5_text_to_image(
|
||||
prompt: request.prompt.clone(),
|
||||
image_size: aspect_ratio,
|
||||
num_images,
|
||||
// Not provided for text-to-image
|
||||
image_media_tokens: None,
|
||||
image_media_tokens: request.image_media_tokens.clone(),
|
||||
mask_image_token: None,
|
||||
input_fidelity: None,
|
||||
// Not provided
|
||||
|
||||
+21
-8
@@ -1,5 +1,7 @@
|
||||
use crate::core::api_adapters::aspect_ratio::common_aspect_ratio::CommonAspectRatio;
|
||||
use crate::core::api_adapters::aspect_ratio::convert::aspect_ratio_to_artcraft_nano_banana_pro::aspect_ratio_to_artcraft_nano_banana_pro;
|
||||
use crate::core::api_adapters::resolution::common_resolution::CommonResolution;
|
||||
use crate::core::api_adapters::resolution::convert::resolution_to_artcraft_nano_banana_pro::resolution_to_artcraft_nano_banana_pro;
|
||||
use crate::core::commands::enqueue::generate_error::{BadInputReason, GenerateError, MissingCredentialsReason, ProviderFailureReason};
|
||||
use crate::core::commands::enqueue::image_edit::enqueue_edit_image_command::{EditImageQuality, EditImageResolution, EditImageSize};
|
||||
use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
|
||||
@@ -61,18 +63,12 @@ pub async fn handle_artcraft_nano_banana_pro_text_to_image(
|
||||
};
|
||||
|
||||
let aspect_ratio = get_aspect_ratio(request);
|
||||
|
||||
let resolution = request.image_resolution
|
||||
.map(|image_resolution| match image_resolution {
|
||||
TextToImageResolution::OneK => NanoBananaProMultiFunctionImageGenImageResolution::OneK,
|
||||
TextToImageResolution::TwoK => NanoBananaProMultiFunctionImageGenImageResolution::TwoK,
|
||||
TextToImageResolution::FourK => NanoBananaProMultiFunctionImageGenImageResolution::FourK,
|
||||
});
|
||||
let resolution = get_resolution(request);
|
||||
|
||||
let request = NanoBananaProMultiFunctionImageGenRequest {
|
||||
uuid_idempotency_token,
|
||||
prompt: request.prompt.clone(),
|
||||
image_media_tokens: None,
|
||||
image_media_tokens: request.image_media_tokens.clone(),
|
||||
num_images,
|
||||
resolution,
|
||||
aspect_ratio,
|
||||
@@ -126,3 +122,20 @@ fn get_aspect_ratio(request: &EnqueueTextToImageRequest) -> Option<NanoBananaPro
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn get_resolution(request: &EnqueueTextToImageRequest) -> Option<NanoBananaProMultiFunctionImageGenImageResolution> {
|
||||
if let Some(common_resolution) = request.common_resolution {
|
||||
return Some(resolution_to_artcraft_nano_banana_pro(common_resolution));
|
||||
}
|
||||
|
||||
if let Some(resolution) = request.image_resolution {
|
||||
// Deprecated resolution
|
||||
return Some(match resolution {
|
||||
TextToImageResolution::OneK => NanoBananaProMultiFunctionImageGenImageResolution::OneK,
|
||||
TextToImageResolution::TwoK => NanoBananaProMultiFunctionImageGenImageResolution::TwoK,
|
||||
TextToImageResolution::FourK => NanoBananaProMultiFunctionImageGenImageResolution::FourK,
|
||||
})
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
+13
-7
@@ -1,3 +1,5 @@
|
||||
use crate::core::api_adapters::aspect_ratio::common_aspect_ratio::CommonAspectRatio;
|
||||
use crate::core::api_adapters::resolution::common_resolution::CommonResolution;
|
||||
use crate::core::commands::enqueue::common::notify_frontend_of_errors::notify_frontend_of_errors;
|
||||
use crate::core::commands::enqueue::generate_error::{BadInputReason, GenerateError, MissingCredentialsReason};
|
||||
use crate::core::commands::enqueue::image_edit::image_edit_models::image_edit_model_to_model_type;
|
||||
@@ -16,6 +18,8 @@ use crate::core::events::generation_events::common::{GenerationAction, Generatio
|
||||
use crate::core::events::generation_events::generation_enqueue_success_event::GenerationEnqueueSuccessEvent;
|
||||
use crate::core::events::sendable_event_trait::SendableEvent;
|
||||
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
|
||||
use crate::core::state::artcraft_usage_tracker::artcraft_usage_tracker::ArtcraftUsageTracker;
|
||||
use crate::core::state::artcraft_usage_tracker::artcraft_usage_type::{ArtcraftUsagePage, ArtcraftUsageType};
|
||||
use crate::core::state::data_dir::app_data_root::AppDataRoot;
|
||||
use crate::core::state::provider_priority::{Provider, ProviderPriorityStore};
|
||||
use crate::core::state::task_database::TaskDatabase;
|
||||
@@ -34,9 +38,6 @@ use serde::{Deserialize, Serialize};
|
||||
use sqlite_tasks::queries::create_task::{create_task, CreateTaskArgs};
|
||||
use tauri::{AppHandle, State};
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use crate::core::api_adapters::aspect_ratio::common_aspect_ratio::CommonAspectRatio;
|
||||
use crate::core::state::artcraft_usage_tracker::artcraft_usage_tracker::ArtcraftUsageTracker;
|
||||
use crate::core::state::artcraft_usage_tracker::artcraft_usage_type::{ArtcraftUsagePage, ArtcraftUsageType};
|
||||
|
||||
/// This is used in the Tauri command bridge.
|
||||
/// Don't change the serializations without coordinating with the frontend.
|
||||
@@ -88,15 +89,20 @@ pub struct EnqueueTextToImageRequest {
|
||||
/// Text prompt for the image generation. Required.
|
||||
pub prompt: Option<String>,
|
||||
|
||||
/// Aspect ratio.
|
||||
#[deprecated(note="use common_aspect_ratio")]
|
||||
pub aspect_ratio: Option<TextToImageSize>,
|
||||
|
||||
/// New field for aspect ratio.
|
||||
/// Not all models support each of these aspect ratios, but we can choose or interpolate sensibly.
|
||||
pub common_aspect_ratio: Option<CommonAspectRatio>,
|
||||
|
||||
/// New field for resolution.
|
||||
/// Not all models support each of these resolutions, but we can choose or interpolate sensibly.
|
||||
pub common_resolution: Option<CommonResolution>,
|
||||
|
||||
/// Aspect ratio.
|
||||
#[deprecated(note="use common_aspect_ratio")]
|
||||
pub aspect_ratio: Option<TextToImageSize>,
|
||||
|
||||
/// Aspect ratio.
|
||||
#[deprecated(note="use common_resolution")]
|
||||
pub image_resolution: Option<TextToImageResolution>,
|
||||
|
||||
/// The number of images to generate.
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ use std::collections::HashSet;
|
||||
|
||||
use once_cell::sync::Lazy;
|
||||
|
||||
const TERMS : &str = include_str!("../../../../../../includes/binary_includes/dictionary_children_terms.txt");
|
||||
const TERMS : &str = include_str!("../../../../../../includes/binary_includes/safety/dictionary_children_terms.txt");
|
||||
|
||||
pub (crate) fn contains_children_keyword(prompt_tokens: &[String]) -> bool {
|
||||
static TERM_DICTIONARY : Lazy<HashSet<String>> = Lazy::new(|| {
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ use std::collections::HashSet;
|
||||
|
||||
use once_cell::sync::Lazy;
|
||||
|
||||
const TERMS: &str = include_str!("../../../../../../includes/binary_includes/dictionary_potential_minor_terms.txt");
|
||||
const TERMS: &str = include_str!("../../../../../../includes/binary_includes/safety/dictionary_potential_minor_terms.txt");
|
||||
|
||||
pub (crate) fn contains_potential_minor_keyword(prompt_tokens: &[String]) -> bool {
|
||||
static TERM_DICTIONARY: Lazy<HashSet<String>> = Lazy::new(|| {
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ use std::collections::HashSet;
|
||||
|
||||
use once_cell::sync::Lazy;
|
||||
|
||||
const TERMS : &str = include_str!("../../../../../../includes/binary_includes/dictionary_racist_terms.txt");
|
||||
const TERMS : &str = include_str!("../../../../../../includes/binary_includes/safety/dictionary_racist_terms.txt");
|
||||
|
||||
pub (crate) fn contains_racist_keyword(prompt_tokens: &[String]) -> bool {
|
||||
static TERM_DICTIONARY : Lazy<HashSet<String>> = Lazy::new(|| {
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::collections::HashSet;
|
||||
|
||||
use once_cell::sync::Lazy;
|
||||
|
||||
const TERMS : &str = include_str!("../../../../../../includes/binary_includes/dictionary_sex_terms.txt");
|
||||
const TERMS : &str = include_str!("../../../../../../includes/binary_includes/safety/dictionary_sex_terms.txt");
|
||||
pub (crate) fn contains_sex_keyword(prompt_tokens: &[String]) -> bool {
|
||||
static TERM_DICTIONARY : Lazy<HashSet<String>> = Lazy::new(|| {
|
||||
TERMS.lines()
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
[package]
|
||||
name = "shared_service_components"
|
||||
edition = "2021"
|
||||
version = "0.0.1"
|
||||
authors = [
|
||||
"Brandon Thomas <bt@brand.io>",
|
||||
"Brandon Thomas <echelon@gmail.com>",
|
||||
]
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
name = "shared_service_components"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[package.metadata.cargo-udeps.ignore]
|
||||
normal = ["workspace-build-acceleration"]
|
||||
|
||||
[dependencies]
|
||||
# cargo-hakari workspace hack (faster compile times)
|
||||
workspace-build-acceleration = { workspace = true }
|
||||
|
||||
# Internal
|
||||
openai_sora_client = { workspace = true }
|
||||
errors = { workspace = true }
|
||||
|
||||
# External
|
||||
anyhow = { workspace = true }
|
||||
log = { workspace = true }
|
||||
r2d2_redis = "0.14.0"
|
||||
reqwest = { workspace = true, features = ["multipart", "gzip"] }
|
||||
|
||||
[dev-dependencies]
|
||||
# None yet
|
||||
@@ -1,4 +0,0 @@
|
||||
//! This library contains code shared between `storyteller-web` and jobs.
|
||||
|
||||
pub mod sora_redis_credentials;
|
||||
pub mod sora_redis_sentinel_refresh;
|
||||
-33
@@ -1,33 +0,0 @@
|
||||
use crate::sora_redis_credentials::keys::{BEARER_SUBKEY, COOKIE_SUBKEY, SENTINEL_SUBKEY, SORA_SECRET_REDIS_KEY};
|
||||
use anyhow::anyhow;
|
||||
use errors::AnyhowResult;
|
||||
use openai_sora_client::creds::sora_credential_builder::SoraCredentialBuilder;
|
||||
use openai_sora_client::creds::sora_credential_set::SoraCredentialSet;
|
||||
use r2d2_redis::r2d2::PooledConnection;
|
||||
use r2d2_redis::redis::Commands;
|
||||
use r2d2_redis::RedisConnectionManager;
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub fn get_sora_credentials_from_redis(
|
||||
redis: &mut PooledConnection<RedisConnectionManager>
|
||||
) -> AnyhowResult<SoraCredentialSet> {
|
||||
|
||||
let values : HashMap<String, String> = redis.hgetall(SORA_SECRET_REDIS_KEY)
|
||||
.map_err(|e| anyhow!("Failed to get Sora credentials from Redis: {}", e))?;
|
||||
|
||||
let bearer = values.get(BEARER_SUBKEY);
|
||||
let cookie = values.get(COOKIE_SUBKEY);
|
||||
let sentinel = values.get(SENTINEL_SUBKEY);
|
||||
|
||||
match (bearer, cookie, sentinel) {
|
||||
(Some(b), Some(c), Some(s)) => {
|
||||
let creds = SoraCredentialBuilder::new()
|
||||
.with_jwt_bearer_token(&b)
|
||||
.with_cookies(&c)
|
||||
.with_sora_sentinel(&s)
|
||||
.build()?;
|
||||
Ok(creds)
|
||||
}
|
||||
_ => Err(anyhow!("redis sora credential values not present")),
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
/// Redis key
|
||||
pub (crate) const SORA_SECRET_REDIS_KEY : &str = "sora_secret";
|
||||
|
||||
/// HKEY field for the bearer token
|
||||
pub (crate) const BEARER_SUBKEY : &str = "bearer";
|
||||
|
||||
/// HKEY field for the cookie
|
||||
pub (crate) const COOKIE_SUBKEY : &str = "cookie";
|
||||
|
||||
/// HKEY field for the sentinel
|
||||
pub (crate) const SENTINEL_SUBKEY : &str = "sentinel";
|
||||
|
||||
/// Which subkey to write
|
||||
#[derive(Clone,Copy,Debug)]
|
||||
pub enum RedisSoraCredentialSubkey {
|
||||
Bearer,
|
||||
Cookie,
|
||||
Sentinel,
|
||||
}
|
||||
|
||||
impl RedisSoraCredentialSubkey {
|
||||
pub (crate) fn to_str(&self) -> &'static str {
|
||||
match self {
|
||||
RedisSoraCredentialSubkey::Bearer => BEARER_SUBKEY,
|
||||
RedisSoraCredentialSubkey::Cookie => COOKIE_SUBKEY,
|
||||
RedisSoraCredentialSubkey::Sentinel => SENTINEL_SUBKEY,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
pub mod keys;
|
||||
pub mod set_sora_credential_field_in_redis;
|
||||
pub mod get_sora_credentials_from_redis;
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
use anyhow::anyhow;
|
||||
use r2d2_redis::r2d2::PooledConnection;
|
||||
use r2d2_redis::redis::Commands;
|
||||
use r2d2_redis::RedisConnectionManager;
|
||||
use errors::AnyhowResult;
|
||||
use crate::sora_redis_credentials::keys::{RedisSoraCredentialSubkey, SORA_SECRET_REDIS_KEY};
|
||||
|
||||
pub fn set_sora_credential_field_in_redis(
|
||||
redis: &mut PooledConnection<RedisConnectionManager>,
|
||||
subkey: RedisSoraCredentialSubkey,
|
||||
value: &str,
|
||||
) -> AnyhowResult<()> {
|
||||
|
||||
redis.hset(SORA_SECRET_REDIS_KEY, subkey.to_str(), value)
|
||||
.map_err(|e| anyhow!("Failed to set Sora credentials in Redis: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
pub mod refresh;
|
||||
@@ -1,19 +0,0 @@
|
||||
/// Send a POST request to env variable SORA_SENTINEL_REFRESH_URL
|
||||
|
||||
use errors::AnyhowResult;
|
||||
use reqwest::Client;
|
||||
use std::env;
|
||||
|
||||
const SORA_SENTINEL_REFRESH_URL: &str = "http://oai-token-server/refresh_token";
|
||||
|
||||
/// Refresh the Sora sentinel
|
||||
/// The response body does _not_ contain the new sentinel
|
||||
/// We should read it again from redis or generate a new one here
|
||||
/// For now we refresh in the server handler
|
||||
pub async fn refresh_sentinel() -> AnyhowResult<String> {
|
||||
let client = Client::new();
|
||||
let url = env::var("SORA_SENTINEL_REFRESH_URL").unwrap_or(SORA_SENTINEL_REFRESH_URL.to_string());
|
||||
let response = client.post(url).send().await?;
|
||||
let body = response.text().await?;
|
||||
Ok(body)
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
#![forbid(unused_variables)]
|
||||
#![forbid(warnings)]
|
||||
|
||||
pub (crate) const BANNED_SLURS : &str = include_str!("../../../../includes/binary_includes/banned_slurs.txt");
|
||||
pub (crate) const BANNED_SLURS : &str = include_str!("../../../../includes/binary_includes/safety/banned_slurs.txt");
|
||||
|
||||
pub mod check_for_slurs;
|
||||
pub mod latin_alphabet;
|
||||
|
||||
@@ -73,7 +73,6 @@ redis_common = { path = "../../../schema/database/redis_common" }
|
||||
redis_schema = { path = "../../../schema/database/redis_schema" }
|
||||
reusable_types = { path = "../../../schema/public/reusable_types" }
|
||||
server_environment = { path = "../../../lib/server_environment" }
|
||||
#shared_service_components.workspace = true
|
||||
thumbnail_generator = { path = "../../../api_clients/thumbnail_generator" }
|
||||
tokens.workspace = true
|
||||
tts_common = { path = "../../../lib/tts_common" }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
|
||||
/// Full string matches that can not be used as usernames.
|
||||
pub const RESERVED_USERNAMES : &str = include_str!("../../../../../../includes/binary_includes/reserved_usernames.txt");
|
||||
pub const RESERVED_USERNAMES : &str = include_str!("../../../../../../includes/binary_includes/usernames/reserved_usernames.txt");
|
||||
|
||||
/// Substrings that are reserved and cannot be used as part of a username.
|
||||
pub const RESERVED_SUBSTRINGS : &str = include_str!("../../../../../../includes/binary_includes/reserved_usernames_including.txt");
|
||||
pub const RESERVED_SUBSTRINGS : &str = include_str!("../../../../../../includes/binary_includes/usernames/reserved_usernames_including.txt");
|
||||
|
||||
@@ -1,65 +1,5 @@
|
||||
use utoipa::OpenApi;
|
||||
|
||||
use artcraft_api_defs::common::responses::job_details::JobDetailsLipsyncRequest;
|
||||
use artcraft_api_defs::common::responses::job_details::JobDetailsLivePortraitRequest;
|
||||
use artcraft_api_defs::common::responses::media_links::*;
|
||||
use artcraft_api_defs::common::responses::simple_generic_json_success::SimpleGenericJsonSuccess;
|
||||
use artcraft_api_defs::generate::image::edit::gpt_image_1_edit_image::*;
|
||||
use artcraft_api_defs::generate::image::generate_flux_1_dev_text_to_image::GenerateFlux1DevTextToImageAspectRatio;
|
||||
use artcraft_api_defs::generate::image::generate_flux_1_dev_text_to_image::GenerateFlux1DevTextToImageNumImages;
|
||||
use artcraft_api_defs::generate::image::generate_flux_1_dev_text_to_image::GenerateFlux1DevTextToImageRequest;
|
||||
use artcraft_api_defs::generate::image::generate_flux_1_dev_text_to_image::GenerateFlux1DevTextToImageResponse;
|
||||
use artcraft_api_defs::generate::image::generate_flux_1_schnell_text_to_image::GenerateFlux1SchnellTextToImageAspectRatio;
|
||||
use artcraft_api_defs::generate::image::generate_flux_1_schnell_text_to_image::GenerateFlux1SchnellTextToImageNumImages;
|
||||
use artcraft_api_defs::generate::image::generate_flux_1_schnell_text_to_image::GenerateFlux1SchnellTextToImageRequest;
|
||||
use artcraft_api_defs::generate::image::generate_flux_1_schnell_text_to_image::GenerateFlux1SchnellTextToImageResponse;
|
||||
use artcraft_api_defs::generate::image::generate_flux_pro_11_text_to_image::GenerateFluxPro11TextToImageAspectRatio;
|
||||
use artcraft_api_defs::generate::image::generate_flux_pro_11_text_to_image::GenerateFluxPro11TextToImageNumImages;
|
||||
use artcraft_api_defs::generate::image::generate_flux_pro_11_text_to_image::GenerateFluxPro11TextToImageRequest;
|
||||
use artcraft_api_defs::generate::image::generate_flux_pro_11_text_to_image::GenerateFluxPro11TextToImageResponse;
|
||||
use artcraft_api_defs::generate::image::generate_flux_pro_11_ultra_text_to_image::GenerateFluxPro11UltraTextToImageAspectRatio;
|
||||
use artcraft_api_defs::generate::image::generate_flux_pro_11_ultra_text_to_image::GenerateFluxPro11UltraTextToImageNumImages;
|
||||
use artcraft_api_defs::generate::image::generate_flux_pro_11_ultra_text_to_image::GenerateFluxPro11UltraTextToImageRequest;
|
||||
use artcraft_api_defs::generate::image::generate_flux_pro_11_ultra_text_to_image::GenerateFluxPro11UltraTextToImageResponse;
|
||||
use artcraft_api_defs::generate::image::generate_gpt_image_1_text_to_image::GenerateGptImage1TextToImageImageQuality;
|
||||
use artcraft_api_defs::generate::image::generate_gpt_image_1_text_to_image::GenerateGptImage1TextToImageImageSize;
|
||||
use artcraft_api_defs::generate::image::generate_gpt_image_1_text_to_image::GenerateGptImage1TextToImageNumImages;
|
||||
use artcraft_api_defs::generate::image::generate_gpt_image_1_text_to_image::GenerateGptImage1TextToImageRequest;
|
||||
use artcraft_api_defs::generate::image::generate_gpt_image_1_text_to_image::GenerateGptImage1TextToImageResponse;
|
||||
use artcraft_api_defs::generate::image::remove_image_background::RemoveImageBackgroundRequest;
|
||||
use artcraft_api_defs::generate::image::remove_image_background::RemoveImageBackgroundResponse;
|
||||
use artcraft_api_defs::generate::object::generate_hunyuan_2_0_image_to_3d::GenerateHunyuan20ImageTo3dRequest;
|
||||
use artcraft_api_defs::generate::object::generate_hunyuan_2_0_image_to_3d::GenerateHunyuan20ImageTo3dResponse;
|
||||
use artcraft_api_defs::generate::object::generate_hunyuan_2_1_image_to_3d::GenerateHunyuan21ImageTo3dRequest;
|
||||
use artcraft_api_defs::generate::object::generate_hunyuan_2_1_image_to_3d::GenerateHunyuan21ImageTo3dResponse;
|
||||
use artcraft_api_defs::generate::video::generate_kling_1_6_pro_image_to_video::GenerateKling16ProAspectRatio;
|
||||
use artcraft_api_defs::generate::video::generate_kling_1_6_pro_image_to_video::GenerateKling16ProDuration;
|
||||
use artcraft_api_defs::generate::video::generate_kling_1_6_pro_image_to_video::GenerateKling16ProImageToVideoRequest;
|
||||
use artcraft_api_defs::generate::video::generate_kling_1_6_pro_image_to_video::GenerateKling16ProImageToVideoResponse;
|
||||
use artcraft_api_defs::generate::video::generate_kling_2_1_master_image_to_video::GenerateKling21MasterAspectRatio;
|
||||
use artcraft_api_defs::generate::video::generate_kling_2_1_master_image_to_video::GenerateKling21MasterDuration;
|
||||
use artcraft_api_defs::generate::video::generate_kling_2_1_master_image_to_video::GenerateKling21MasterImageToVideoRequest;
|
||||
use artcraft_api_defs::generate::video::generate_kling_2_1_master_image_to_video::GenerateKling21MasterImageToVideoResponse;
|
||||
use artcraft_api_defs::generate::video::generate_kling_2_1_pro_image_to_video::GenerateKling21ProAspectRatio;
|
||||
use artcraft_api_defs::generate::video::generate_kling_2_1_pro_image_to_video::GenerateKling21ProDuration;
|
||||
use artcraft_api_defs::generate::video::generate_kling_2_1_pro_image_to_video::GenerateKling21ProImageToVideoRequest;
|
||||
use artcraft_api_defs::generate::video::generate_kling_2_1_pro_image_to_video::GenerateKling21ProImageToVideoResponse;
|
||||
use artcraft_api_defs::generate::video::generate_seedance_1_0_lite_image_to_video::GenerateSeedance10LiteDuration;
|
||||
use artcraft_api_defs::generate::video::generate_seedance_1_0_lite_image_to_video::GenerateSeedance10LiteImageToVideoRequest;
|
||||
use artcraft_api_defs::generate::video::generate_seedance_1_0_lite_image_to_video::GenerateSeedance10LiteImageToVideoResponse;
|
||||
use artcraft_api_defs::generate::video::generate_seedance_1_0_lite_image_to_video::GenerateSeedance10LiteResolution;
|
||||
use artcraft_api_defs::generate::video::generate_veo_2_image_to_video::GenerateVeo2AspectRatio;
|
||||
use artcraft_api_defs::generate::video::generate_veo_2_image_to_video::GenerateVeo2Duration;
|
||||
use artcraft_api_defs::generate::video::generate_veo_2_image_to_video::GenerateVeo2ImageToVideoRequest;
|
||||
use artcraft_api_defs::generate::video::generate_veo_2_image_to_video::GenerateVeo2ImageToVideoResponse;
|
||||
use artcraft_api_defs::jobs::list_session_jobs::*;
|
||||
use artcraft_api_defs::media_file::delete_media_file::DeleteMediaFilePathInfo;
|
||||
use artcraft_api_defs::media_file::delete_media_file::DeleteMediaFileRequest;
|
||||
use artcraft_api_defs::prompts::create_prompt::CreatePromptRequest;
|
||||
use artcraft_api_defs::prompts::create_prompt::CreatePromptResponse;
|
||||
use billing_component::stripe::http_endpoints::checkout::create::stripe_create_checkout_session_error::CreateCheckoutSessionError;
|
||||
use billing_component::stripe::http_endpoints::checkout::create::stripe_create_checkout_session_json_handler::*;
|
||||
use billing_component::users::http_endpoints::list_active_user_subscriptions_handler::*;
|
||||
use crate::http_server::common_requests::auto_product_category::AutoProductCategory;
|
||||
use crate::http_server::common_requests::media_file_token_path_info::MediaFileTokenPathInfo;
|
||||
use crate::http_server::common_responses::media::cover_image_links::CoverImageLinks;
|
||||
@@ -137,6 +77,7 @@ use crate::http_server::endpoints::media_files::upload::upload_new_scene_media_f
|
||||
use crate::http_server::endpoints::media_files::upload::upload_pmx::upload_pmx_media_file_handler::*;
|
||||
use crate::http_server::endpoints::media_files::upload::upload_saved_scene_media_file_handler::*;
|
||||
use crate::http_server::endpoints::media_files::upload::upload_scene_snapshot_media_file_handler::*;
|
||||
use crate::http_server::endpoints::media_files::upload::upload_spz_media_file_handler::*;
|
||||
use crate::http_server::endpoints::media_files::upload::upload_studio_shot::upload_studio_shot_media_file_handler::*;
|
||||
use crate::http_server::endpoints::media_files::upload::upload_video_new::upload_new_video_media_file_handler::*;
|
||||
use crate::http_server::endpoints::media_files::upload::upload_video_old::upload_video_media_file_handler::*;
|
||||
@@ -148,8 +89,8 @@ use crate::http_server::endpoints::moderation::user_feature_flags::edit_user_fea
|
||||
use crate::http_server::endpoints::prompts::get_prompt_handler::*;
|
||||
use crate::http_server::endpoints::service::status_alert_handler::*;
|
||||
use crate::http_server::endpoints::stats::get_unified_queue_stats_handler::*;
|
||||
use crate::http_server::endpoints::studio_gen2::enqueue_studio_gen2_handler::*;
|
||||
use crate::http_server::endpoints::studio_gen2::enqueue_studio_gen2_handler::EnqueueStudioGen2Request;
|
||||
use crate::http_server::endpoints::studio_gen2::enqueue_studio_gen2_handler::*;
|
||||
use crate::http_server::endpoints::tags::list_tags_for_entity_handler::*;
|
||||
use crate::http_server::endpoints::tags::set_tags_for_entity_handler::*;
|
||||
use crate::http_server::endpoints::tts::enqueue_infer_tts_handler::enqueue_infer_tts_handler::*;
|
||||
@@ -181,6 +122,66 @@ use crate::http_server::endpoints::weights::list::list_weights_by_user_handler::
|
||||
use crate::http_server::endpoints::weights::search::search_model_weights_impl::*;
|
||||
use crate::http_server::endpoints::weights::update::set_model_weight_cover_image_handler::*;
|
||||
use crate::http_server::endpoints::weights::update::update_weight_handler::*;
|
||||
use artcraft_api_defs::common::responses::job_details::JobDetailsLipsyncRequest;
|
||||
use artcraft_api_defs::common::responses::job_details::JobDetailsLivePortraitRequest;
|
||||
use artcraft_api_defs::common::responses::media_links::*;
|
||||
use artcraft_api_defs::common::responses::simple_generic_json_success::SimpleGenericJsonSuccess;
|
||||
use artcraft_api_defs::generate::image::bg_removal::remove_image_background::RemoveImageBackgroundRequest;
|
||||
use artcraft_api_defs::generate::image::bg_removal::remove_image_background::RemoveImageBackgroundResponse;
|
||||
use artcraft_api_defs::generate::image::edit::gpt_image_1_edit_image::*;
|
||||
use artcraft_api_defs::generate::image::text::generate_flux_1_dev_text_to_image::GenerateFlux1DevTextToImageAspectRatio;
|
||||
use artcraft_api_defs::generate::image::text::generate_flux_1_dev_text_to_image::GenerateFlux1DevTextToImageNumImages;
|
||||
use artcraft_api_defs::generate::image::text::generate_flux_1_dev_text_to_image::GenerateFlux1DevTextToImageRequest;
|
||||
use artcraft_api_defs::generate::image::text::generate_flux_1_dev_text_to_image::GenerateFlux1DevTextToImageResponse;
|
||||
use artcraft_api_defs::generate::image::text::generate_flux_1_schnell_text_to_image::GenerateFlux1SchnellTextToImageAspectRatio;
|
||||
use artcraft_api_defs::generate::image::text::generate_flux_1_schnell_text_to_image::GenerateFlux1SchnellTextToImageNumImages;
|
||||
use artcraft_api_defs::generate::image::text::generate_flux_1_schnell_text_to_image::GenerateFlux1SchnellTextToImageRequest;
|
||||
use artcraft_api_defs::generate::image::text::generate_flux_1_schnell_text_to_image::GenerateFlux1SchnellTextToImageResponse;
|
||||
use artcraft_api_defs::generate::image::text::generate_flux_pro_11_text_to_image::GenerateFluxPro11TextToImageAspectRatio;
|
||||
use artcraft_api_defs::generate::image::text::generate_flux_pro_11_text_to_image::GenerateFluxPro11TextToImageNumImages;
|
||||
use artcraft_api_defs::generate::image::text::generate_flux_pro_11_text_to_image::GenerateFluxPro11TextToImageRequest;
|
||||
use artcraft_api_defs::generate::image::text::generate_flux_pro_11_text_to_image::GenerateFluxPro11TextToImageResponse;
|
||||
use artcraft_api_defs::generate::image::text::generate_flux_pro_11_ultra_text_to_image::GenerateFluxPro11UltraTextToImageAspectRatio;
|
||||
use artcraft_api_defs::generate::image::text::generate_flux_pro_11_ultra_text_to_image::GenerateFluxPro11UltraTextToImageNumImages;
|
||||
use artcraft_api_defs::generate::image::text::generate_flux_pro_11_ultra_text_to_image::GenerateFluxPro11UltraTextToImageRequest;
|
||||
use artcraft_api_defs::generate::image::text::generate_flux_pro_11_ultra_text_to_image::GenerateFluxPro11UltraTextToImageResponse;
|
||||
use artcraft_api_defs::generate::image::text::generate_gpt_image_1_text_to_image::GenerateGptImage1TextToImageImageQuality;
|
||||
use artcraft_api_defs::generate::image::text::generate_gpt_image_1_text_to_image::GenerateGptImage1TextToImageImageSize;
|
||||
use artcraft_api_defs::generate::image::text::generate_gpt_image_1_text_to_image::GenerateGptImage1TextToImageNumImages;
|
||||
use artcraft_api_defs::generate::image::text::generate_gpt_image_1_text_to_image::GenerateGptImage1TextToImageRequest;
|
||||
use artcraft_api_defs::generate::image::text::generate_gpt_image_1_text_to_image::GenerateGptImage1TextToImageResponse;
|
||||
use artcraft_api_defs::generate::object::generate_hunyuan_2_0_image_to_3d::GenerateHunyuan20ImageTo3dRequest;
|
||||
use artcraft_api_defs::generate::object::generate_hunyuan_2_0_image_to_3d::GenerateHunyuan20ImageTo3dResponse;
|
||||
use artcraft_api_defs::generate::object::generate_hunyuan_2_1_image_to_3d::GenerateHunyuan21ImageTo3dRequest;
|
||||
use artcraft_api_defs::generate::object::generate_hunyuan_2_1_image_to_3d::GenerateHunyuan21ImageTo3dResponse;
|
||||
use artcraft_api_defs::generate::video::generate_kling_1_6_pro_image_to_video::GenerateKling16ProAspectRatio;
|
||||
use artcraft_api_defs::generate::video::generate_kling_1_6_pro_image_to_video::GenerateKling16ProDuration;
|
||||
use artcraft_api_defs::generate::video::generate_kling_1_6_pro_image_to_video::GenerateKling16ProImageToVideoRequest;
|
||||
use artcraft_api_defs::generate::video::generate_kling_1_6_pro_image_to_video::GenerateKling16ProImageToVideoResponse;
|
||||
use artcraft_api_defs::generate::video::generate_kling_2_1_master_image_to_video::GenerateKling21MasterAspectRatio;
|
||||
use artcraft_api_defs::generate::video::generate_kling_2_1_master_image_to_video::GenerateKling21MasterDuration;
|
||||
use artcraft_api_defs::generate::video::generate_kling_2_1_master_image_to_video::GenerateKling21MasterImageToVideoRequest;
|
||||
use artcraft_api_defs::generate::video::generate_kling_2_1_master_image_to_video::GenerateKling21MasterImageToVideoResponse;
|
||||
use artcraft_api_defs::generate::video::generate_kling_2_1_pro_image_to_video::GenerateKling21ProAspectRatio;
|
||||
use artcraft_api_defs::generate::video::generate_kling_2_1_pro_image_to_video::GenerateKling21ProDuration;
|
||||
use artcraft_api_defs::generate::video::generate_kling_2_1_pro_image_to_video::GenerateKling21ProImageToVideoRequest;
|
||||
use artcraft_api_defs::generate::video::generate_kling_2_1_pro_image_to_video::GenerateKling21ProImageToVideoResponse;
|
||||
use artcraft_api_defs::generate::video::generate_seedance_1_0_lite_image_to_video::GenerateSeedance10LiteDuration;
|
||||
use artcraft_api_defs::generate::video::generate_seedance_1_0_lite_image_to_video::GenerateSeedance10LiteImageToVideoRequest;
|
||||
use artcraft_api_defs::generate::video::generate_seedance_1_0_lite_image_to_video::GenerateSeedance10LiteImageToVideoResponse;
|
||||
use artcraft_api_defs::generate::video::generate_seedance_1_0_lite_image_to_video::GenerateSeedance10LiteResolution;
|
||||
use artcraft_api_defs::generate::video::generate_veo_2_image_to_video::GenerateVeo2AspectRatio;
|
||||
use artcraft_api_defs::generate::video::generate_veo_2_image_to_video::GenerateVeo2Duration;
|
||||
use artcraft_api_defs::generate::video::generate_veo_2_image_to_video::GenerateVeo2ImageToVideoRequest;
|
||||
use artcraft_api_defs::generate::video::generate_veo_2_image_to_video::GenerateVeo2ImageToVideoResponse;
|
||||
use artcraft_api_defs::jobs::list_session_jobs::*;
|
||||
use artcraft_api_defs::media_file::delete_media_file::DeleteMediaFilePathInfo;
|
||||
use artcraft_api_defs::media_file::delete_media_file::DeleteMediaFileRequest;
|
||||
use artcraft_api_defs::prompts::create_prompt::CreatePromptRequest;
|
||||
use artcraft_api_defs::prompts::create_prompt::CreatePromptResponse;
|
||||
use billing_component::stripe::http_endpoints::checkout::create::stripe_create_checkout_session_error::CreateCheckoutSessionError;
|
||||
use billing_component::stripe::http_endpoints::checkout::create::stripe_create_checkout_session_json_handler::*;
|
||||
use billing_component::users::http_endpoints::list_active_user_subscriptions_handler::*;
|
||||
use enums::by_table::beta_keys::beta_key_product::BetaKeyProduct;
|
||||
use enums::by_table::comments::comment_entity_type::CommentEntityType;
|
||||
use enums::by_table::featured_items::featured_item_entity_type::FeaturedItemEntityType;
|
||||
@@ -285,6 +286,7 @@ use tokens::tokens::zs_voice_datasets::*;
|
||||
crate::http_server::endpoints::media_files::upload::upload_image_media_file_handler::upload_image_media_file_handler,
|
||||
crate::http_server::endpoints::media_files::upload::upload_new_engine_asset_media_file_handler::upload_new_engine_asset_media_file_handler,
|
||||
crate::http_server::endpoints::media_files::upload::upload_new_scene_media_file_handler::upload_new_scene_media_file_handler,
|
||||
crate::http_server::endpoints::media_files::upload::upload_spz_media_file_handler::upload_spz_media_file_handler,
|
||||
crate::http_server::endpoints::media_files::upload::upload_pmx::upload_pmx_media_file_handler::upload_pmx_media_file_handler,
|
||||
crate::http_server::endpoints::media_files::upload::upload_saved_scene_media_file_handler::upload_saved_scene_media_file_handler,
|
||||
crate::http_server::endpoints::media_files::upload::upload_scene_snapshot_media_file_handler::upload_scene_snapshot_media_file_handler,
|
||||
@@ -753,6 +755,8 @@ use tokens::tokens::zs_voice_datasets::*;
|
||||
UploadNewEngineAssetSuccessResponse,
|
||||
UploadNewSceneMediaFileForm,
|
||||
UploadNewSceneMediaFileSuccessResponse,
|
||||
UploadSpzMediaFileForm,
|
||||
UploadSpzMediaFileSuccessResponse,
|
||||
UploadNewVideoMediaFileForm,
|
||||
UploadNewVideoMediaFileSuccessResponse,
|
||||
UploadPmxFileForm,
|
||||
|
||||
+4
-2
@@ -153,7 +153,7 @@ pub async fn nano_banana_pro_multi_function_image_gen_handler(
|
||||
None => EnqueueNanoBananaProEditImageNumImages::One, // Default to One
|
||||
};
|
||||
|
||||
let resolution = match request.resolution {
|
||||
let mut resolution = match request.resolution {
|
||||
Some(NanoBananaProMultiFunctionImageGenImageResolution::OneK) => EnqueueNanoBananaProEditImageResolution::OneK,
|
||||
Some(NanoBananaProMultiFunctionImageGenImageResolution::TwoK) => EnqueueNanoBananaProEditImageResolution::TwoK,
|
||||
Some(NanoBananaProMultiFunctionImageGenImageResolution::FourK) => EnqueueNanoBananaProEditImageResolution::FourK,
|
||||
@@ -177,6 +177,7 @@ pub async fn nano_banana_pro_multi_function_image_gen_handler(
|
||||
|
||||
if downgrade_for_free_user {
|
||||
num_images = EnqueueNanoBananaProEditImageNumImages::One;
|
||||
resolution = EnqueueNanoBananaProEditImageResolution::OneK;
|
||||
}
|
||||
|
||||
let args = EnqueueNanoBananaProEditImageArgs {
|
||||
@@ -207,7 +208,7 @@ pub async fn nano_banana_pro_multi_function_image_gen_handler(
|
||||
None => EnqueueNanoBananaProTextToImageNumImages::One, // Default to One
|
||||
};
|
||||
|
||||
let resolution = match request.resolution {
|
||||
let mut resolution = match request.resolution {
|
||||
Some(NanoBananaProMultiFunctionImageGenImageResolution::OneK) => EnqueueNanoBananaProTextToImageResolution::OneK,
|
||||
Some(NanoBananaProMultiFunctionImageGenImageResolution::TwoK) => EnqueueNanoBananaProTextToImageResolution::TwoK,
|
||||
Some(NanoBananaProMultiFunctionImageGenImageResolution::FourK) => EnqueueNanoBananaProTextToImageResolution::FourK,
|
||||
@@ -231,6 +232,7 @@ pub async fn nano_banana_pro_multi_function_image_gen_handler(
|
||||
|
||||
if downgrade_for_free_user {
|
||||
num_images = EnqueueNanoBananaProTextToImageNumImages::One;
|
||||
resolution = EnqueueNanoBananaProTextToImageResolution::OneK;
|
||||
}
|
||||
|
||||
let args = EnqueueNanoBananaProTextToImageArgs {
|
||||
|
||||
@@ -5,6 +5,7 @@ pub mod upload_generic;
|
||||
pub mod upload_image_media_file_handler;
|
||||
pub mod upload_new_engine_asset_media_file_handler;
|
||||
pub mod upload_new_scene_media_file_handler;
|
||||
pub mod upload_spz_media_file_handler;
|
||||
pub mod upload_pmx;
|
||||
pub mod upload_saved_scene_media_file_handler;
|
||||
pub mod upload_scene_snapshot_media_file_handler;
|
||||
|
||||
+273
@@ -0,0 +1,273 @@
|
||||
use std::io::Read;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_multipart::form::tempfile::TempFile;
|
||||
use actix_multipart::form::text::Text;
|
||||
use actix_multipart::form::MultipartForm;
|
||||
use actix_web::web::Json;
|
||||
use actix_web::{web, HttpRequest};
|
||||
use log::{error, info, warn};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
|
||||
use enums::by_table::media_files::media_file_class::MediaFileClass;
|
||||
use enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory;
|
||||
use enums::by_table::media_files::media_file_type::MediaFileType;
|
||||
use enums::common::visibility::Visibility;
|
||||
use hashing::sha256::sha256_hash_bytes::sha256_hash_bytes;
|
||||
use http_server_common::request::get_request_ip::get_request_ip;
|
||||
use mimetypes::mimetype_for_bytes::get_mimetype_for_bytes;
|
||||
use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_idempotency_token;
|
||||
use mysql_queries::queries::media_files::create::insert_builder::media_file_insert_builder::MediaFileInsertBuilder;
|
||||
use mysql_queries::queries::media_files::create::specialized_insert::insert_media_file_from_file_upload::{insert_media_file_from_file_upload, InsertMediaFileFromUploadArgs, UploadType};
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::http_server::endpoints::media_files::upload::upload_error::MediaFileUploadError;
|
||||
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
const GZIP_MIME_TYPE: &str = "application/gzip";
|
||||
const SPZ_EXTENSION: &str = ".spz";
|
||||
const CERAMIC_SPZ_EXTENSION: &str = ".ceramic.spz";
|
||||
|
||||
/// Form-multipart request fields for SPZ (Gaussian Splat) upload.
|
||||
///
|
||||
/// IF VIEWING DOCS, PLEASE SEE BOTTOM OF PAGE `UploadSpzMediaFileForm` (Under "Schema") FOR DETAILS ON FIELDS AND NULLABILITY.
|
||||
#[derive(MultipartForm, ToSchema)]
|
||||
#[multipart(duplicate_field = "deny")]
|
||||
pub struct UploadSpzMediaFileForm {
|
||||
/// UUID for request idempotency
|
||||
#[multipart(limit = "2 KiB")]
|
||||
#[schema(value_type = String, format = Binary)]
|
||||
uuid_idempotency_token: Text<String>,
|
||||
|
||||
/// The uploaded SPZ file
|
||||
#[multipart(limit = "512 MiB")]
|
||||
#[schema(value_type = Vec<u8>, format = Binary)]
|
||||
file: TempFile,
|
||||
|
||||
/// Optional: Title (name) of the Gaussian splat
|
||||
#[multipart(limit = "2 KiB")]
|
||||
#[schema(value_type = Option<String>, format = Binary)]
|
||||
maybe_title: Option<Text<String>>,
|
||||
|
||||
/// Optional: Visibility of the Gaussian splat
|
||||
#[multipart(limit = "2 KiB")]
|
||||
#[schema(value_type = Option<String>, format = Binary)]
|
||||
maybe_visibility: Option<Text<Visibility>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct UploadSpzMediaFileSuccessResponse {
|
||||
pub success: bool,
|
||||
pub media_file_token: MediaFileToken,
|
||||
}
|
||||
|
||||
/// This endpoint is for uploading SPZ (Gaussian Splat) files.
|
||||
///
|
||||
/// SPZ files are compressed Gaussian splat files used for 3D scene representation.
|
||||
/// The file must have a `.spz` extension and be gzip-compressed.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
tag = "Media Files (Upload)",
|
||||
path = "/v1/media_files/upload/spz",
|
||||
responses(
|
||||
(status = 200, description = "Success", body = UploadSpzMediaFileSuccessResponse),
|
||||
(status = 400, description = "Bad input", body = MediaFileUploadError),
|
||||
(status = 401, description = "Not authorized", body = MediaFileUploadError),
|
||||
(status = 429, description = "Too many requests", body = MediaFileUploadError),
|
||||
(status = 500, description = "Server error", body = MediaFileUploadError),
|
||||
),
|
||||
params(
|
||||
(
|
||||
"request" = UploadSpzMediaFileForm,
|
||||
description = "IF VIEWING DOCS, PLEASE SEE BOTTOM OF PAGE `UploadSpzMediaFileForm` (Under 'Schema') FOR DETAILS ON FIELDS AND NULLABILITY."
|
||||
),
|
||||
)
|
||||
)]
|
||||
pub async fn upload_spz_media_file_handler(
|
||||
http_request: HttpRequest,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
MultipartForm(mut form): MultipartForm<UploadSpzMediaFileForm>,
|
||||
) -> Result<Json<UploadSpzMediaFileSuccessResponse>, MediaFileUploadError> {
|
||||
|
||||
let mut mysql_connection = server_state.mysql_pool
|
||||
.acquire()
|
||||
.await
|
||||
.map_err(|err| {
|
||||
error!("MySql pool error: {:?}", err);
|
||||
MediaFileUploadError::ServerError
|
||||
})?;
|
||||
|
||||
// ==================== READ SESSION ==================== //
|
||||
|
||||
let maybe_user_session = server_state
|
||||
.session_checker
|
||||
.maybe_get_user_session_from_connection(&http_request, &mut mysql_connection)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Session checker error: {:?}", e);
|
||||
MediaFileUploadError::ServerError
|
||||
})?;
|
||||
|
||||
let maybe_user_token = maybe_user_session
|
||||
.as_ref()
|
||||
.map(|session| session.get_strongly_typed_user_token());
|
||||
|
||||
let maybe_avt_token = server_state
|
||||
.avt_cookie_manager
|
||||
.get_avt_token_from_request(&http_request);
|
||||
|
||||
// ==================== BANNED USERS ==================== //
|
||||
|
||||
if let Some(ref user) = maybe_user_session {
|
||||
if user.is_banned {
|
||||
return Err(MediaFileUploadError::NotAuthorizedVerbose("user is banned".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== RATE LIMIT ==================== //
|
||||
|
||||
let rate_limiter = match maybe_user_session {
|
||||
None => &server_state.redis_rate_limiters.file_upload_logged_out,
|
||||
Some(ref _session) => &server_state.redis_rate_limiters.file_upload_logged_in,
|
||||
};
|
||||
|
||||
if let Err(_err) = rate_limiter.rate_limit_request(&http_request).await {
|
||||
return Err(MediaFileUploadError::RateLimited);
|
||||
}
|
||||
|
||||
// ==================== HANDLE IDEMPOTENCY ==================== //
|
||||
|
||||
let uuid_idempotency_token = form.uuid_idempotency_token.as_ref();
|
||||
|
||||
if let Err(reason) = validate_idempotency_token_format(uuid_idempotency_token) {
|
||||
return Err(MediaFileUploadError::BadInput(reason));
|
||||
}
|
||||
|
||||
insert_idempotency_token(uuid_idempotency_token, &mut *mysql_connection)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
error!("Error inserting idempotency token: {:?}", err);
|
||||
MediaFileUploadError::BadInput("invalid idempotency token".to_string())
|
||||
})?;
|
||||
|
||||
// ==================== UPLOAD METADATA ==================== //
|
||||
|
||||
let maybe_title = form.maybe_title
|
||||
.map(|title| title.trim().to_string())
|
||||
.filter(|title| !title.is_empty());
|
||||
|
||||
let creator_set_visibility = form.maybe_visibility
|
||||
.map(|visibility| visibility.0)
|
||||
.or_else(|| {
|
||||
maybe_user_session
|
||||
.as_ref()
|
||||
.map(|user_session| user_session.preferred_tts_result_visibility)
|
||||
})
|
||||
.unwrap_or(Visibility::default());
|
||||
|
||||
// ==================== USER DATA ==================== //
|
||||
|
||||
let ip_address = get_request_ip(&http_request);
|
||||
|
||||
// ==================== FILE VALIDATION ==================== //
|
||||
|
||||
let maybe_filename = form.file.file_name.as_deref()
|
||||
.as_deref()
|
||||
.map(|filename| PathBuf::from(filename));
|
||||
|
||||
let filename_lowercase = maybe_filename
|
||||
.as_ref()
|
||||
.and_then(|f| f.to_str())
|
||||
.map(|s| s.to_ascii_lowercase())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Validate file extension
|
||||
if !filename_lowercase.ends_with(".spz") {
|
||||
return Err(MediaFileUploadError::BadInput(
|
||||
"File must have .spz extension".to_string()
|
||||
));
|
||||
}
|
||||
|
||||
let mut file_bytes = Vec::new();
|
||||
form.file.file.read_to_end(&mut file_bytes)
|
||||
.map_err(|e| {
|
||||
error!("Problem reading file: {:?}", e);
|
||||
MediaFileUploadError::ServerError
|
||||
})?;
|
||||
|
||||
// Validate mimetype (SPZ files are gzip-compressed)
|
||||
let detected_mimetype = get_mimetype_for_bytes(file_bytes.as_ref());
|
||||
|
||||
if detected_mimetype != Some(GZIP_MIME_TYPE) {
|
||||
return Err(MediaFileUploadError::BadInput(
|
||||
"SPZ file must be gzip-compressed".to_string()
|
||||
));
|
||||
}
|
||||
|
||||
let file_size_bytes = file_bytes.len();
|
||||
|
||||
let hash = sha256_hash_bytes(&file_bytes)
|
||||
.map_err(|io_error| {
|
||||
error!("Problem hashing bytes: {:?}", io_error);
|
||||
MediaFileUploadError::ServerError
|
||||
})?;
|
||||
|
||||
// ==================== UPLOAD AND SAVE ==================== //
|
||||
|
||||
// Check for WorldLabs ceramic SPZ files
|
||||
let is_world_labs_spz = filename_lowercase.contains("ceramic") && filename_lowercase.ends_with(".spz");
|
||||
|
||||
let extension = if is_world_labs_spz {
|
||||
CERAMIC_SPZ_EXTENSION
|
||||
} else {
|
||||
SPZ_EXTENSION
|
||||
};
|
||||
|
||||
const PREFIX: Option<&str> = Some("artcraft_");
|
||||
|
||||
let public_upload_path = MediaFileBucketPath::generate_new(PREFIX, Some(extension));
|
||||
|
||||
info!("Uploading SPZ media to bucket path: {}", public_upload_path.get_full_object_path_str());
|
||||
|
||||
server_state.public_bucket_client.upload_file_with_content_type(
|
||||
public_upload_path.get_full_object_path_str(),
|
||||
file_bytes.as_ref(),
|
||||
GZIP_MIME_TYPE)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!("Upload SPZ bytes to bucket error: {:?}", e);
|
||||
MediaFileUploadError::ServerError
|
||||
})?;
|
||||
|
||||
let media_token = MediaFileInsertBuilder::new()
|
||||
.media_file_class(MediaFileClass::Dimensional)
|
||||
.media_file_type(MediaFileType::Spz)
|
||||
.maybe_creator_user(maybe_user_token.as_ref())
|
||||
.maybe_creator_anonymous_visitor(maybe_avt_token.as_ref())
|
||||
.creator_ip_address(&ip_address)
|
||||
.creator_set_visibility(creator_set_visibility)
|
||||
.media_file_origin_category(MediaFileOriginCategory::Upload)
|
||||
.mime_type(GZIP_MIME_TYPE)
|
||||
.file_size_bytes(file_size_bytes as u64)
|
||||
.checksum_sha2(&hash)
|
||||
.maybe_title(maybe_title.as_deref())
|
||||
.is_intermediate_system_file(false)
|
||||
.public_bucket_directory_hash(&public_upload_path)
|
||||
.maybe_origin_filename(form.file.file_name.as_deref())
|
||||
.insert_pool(&server_state.mysql_pool)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
warn!("New SPZ file creation DB error: {:?}", err);
|
||||
MediaFileUploadError::ServerError
|
||||
})?;
|
||||
|
||||
info!("new SPZ media file token: {:?}", &media_token);
|
||||
|
||||
Ok(Json(UploadSpzMediaFileSuccessResponse {
|
||||
success: true,
|
||||
media_file_token: media_token,
|
||||
}))
|
||||
}
|
||||
+5
@@ -29,6 +29,7 @@ use crate::http_server::endpoints::media_files::upload::upload_new_scene_media_f
|
||||
use crate::http_server::endpoints::media_files::upload::upload_pmx::upload_pmx_media_file_handler::upload_pmx_media_file_handler;
|
||||
use crate::http_server::endpoints::media_files::upload::upload_saved_scene_media_file_handler::upload_saved_scene_media_file_handler;
|
||||
use crate::http_server::endpoints::media_files::upload::upload_scene_snapshot_media_file_handler::upload_scene_snapshot_media_file_handler;
|
||||
use crate::http_server::endpoints::media_files::upload::upload_spz_media_file_handler::upload_spz_media_file_handler;
|
||||
use crate::http_server::endpoints::media_files::upload::upload_studio_shot::upload_studio_shot_media_file_handler::upload_studio_shot_media_file_handler;
|
||||
use crate::http_server::endpoints::media_files::upload::upload_video_new::upload_new_video_media_file_handler::upload_new_video_media_file_handler;
|
||||
use crate::http_server::endpoints::media_files::upload::upload_video_old::upload_video_media_file_handler::upload_video_media_file_handler;
|
||||
@@ -149,6 +150,10 @@ pub fn add_media_file_routes<T, B> (app: App<T>) -> App<T>
|
||||
.route(web::post().to(upload_new_scene_media_file_handler))
|
||||
.route(web::head().to(|| HttpResponse::Ok()))
|
||||
)
|
||||
.service(web::resource("/upload/spz")
|
||||
.route(web::post().to(upload_spz_media_file_handler))
|
||||
.route(web::head().to(|| HttpResponse::Ok()))
|
||||
)
|
||||
.service(web::resource("/upload/scene_snapshot")
|
||||
.route(web::post().to(upload_scene_snapshot_media_file_handler))
|
||||
.route(web::head().to(|| HttpResponse::Ok()))
|
||||
|
||||
@@ -16,6 +16,10 @@
|
||||
/news/ /index.html 200
|
||||
/news/* /index.html 200
|
||||
|
||||
/press-kit /index.html 200
|
||||
/press-kit/ /index.html 200
|
||||
/press-kit/* /index.html 200
|
||||
|
||||
# Redirects for growth measurement
|
||||
/reddit /?utm_source=reddit 301
|
||||
|
||||
|
||||
@@ -1,19 +1,30 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<!-- Google tag (gtag.js) -->
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id=G-VKW89949V0"></script>
|
||||
<script
|
||||
async
|
||||
src="https://www.googletagmanager.com/gtag/js?id=G-VKW89949V0"
|
||||
></script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
gtag('config', 'G-VKW89949V0');
|
||||
function gtag() {
|
||||
dataLayer.push(arguments);
|
||||
}
|
||||
gtag("js", new Date());
|
||||
gtag("config", "G-VKW89949V0");
|
||||
</script>
|
||||
|
||||
<meta charset="utf-8" />
|
||||
<title>ArtCraft. AI Video and Images. Fast and Open Desktop App.</title>
|
||||
<meta name="description" content="ArtCraft is an Open Desktop app for generating AI Video and Images. You own ArtCraft!" />
|
||||
<meta name="keywords" content="ArtCraft, AI, AI Video, AI Images, Open Source" />
|
||||
<meta
|
||||
name="description"
|
||||
content="ArtCraft is an Open Desktop app for generating AI Video and Images. You own ArtCraft!"
|
||||
/>
|
||||
<meta
|
||||
name="keywords"
|
||||
content="ArtCraft, AI, AI Video, AI Images, Open Source"
|
||||
/>
|
||||
|
||||
<base href="/" />
|
||||
|
||||
@@ -23,10 +34,9 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap"
|
||||
href="https://fonts.googleapis.com/css2?family=Outfit:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.8 MiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.2 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 806 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 2.3 MiB |
@@ -1,13 +1,21 @@
|
||||
import { Route, Routes } from "react-router-dom";
|
||||
import { Route, Routes, Navigate } from "react-router-dom";
|
||||
import Download from "../pages/download";
|
||||
import Media from "../pages/media";
|
||||
import PressKit from "../pages/press-kit";
|
||||
import Navbar from "../components/navbar";
|
||||
import Landing2 from "../pages/landing2";
|
||||
import TutorialsPage from "../pages/tutorials";
|
||||
import TutorialsArticle from "../pages/tutorials/article";
|
||||
import FaqIndex from "../pages/faq/index";
|
||||
import FaqArticle from "../pages/faq/article";
|
||||
import { NewsIndex, NewsPost } from "@storyteller/markdown-content";
|
||||
import NewsIndex from "../pages/news/news-index";
|
||||
import NewsPost from "../pages/news/news-post";
|
||||
import Pricing from "../pages/pricing";
|
||||
import Login from "../pages/login";
|
||||
import Signup from "../pages/signup";
|
||||
import ForgotPassword from "../pages/forgot-password";
|
||||
import Welcome from "../pages/welcome";
|
||||
import { CheckoutSuccess, CheckoutCancel } from "../pages/checkout";
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
@@ -19,12 +27,29 @@ export function App() {
|
||||
<Route path="/download" element={<Download />} />
|
||||
<Route path="/media" element={<Media />} />
|
||||
<Route path="/media/:id" element={<Media />} />
|
||||
<Route path="/press-kit" element={<PressKit />} />
|
||||
<Route path="/tutorials" element={<TutorialsPage />} />
|
||||
<Route path="/tutorials/:slug" element={<TutorialsArticle />} />
|
||||
<Route path="/faq" element={<FaqIndex />} />
|
||||
<Route path="/faq/:slug" element={<FaqArticle />} />
|
||||
<Route path="/news" element={<NewsIndex basePath="/news" />} />
|
||||
<Route path="/news/:slug" element={<NewsPost basePath="/news" />} />
|
||||
<Route path="/pricing" element={<Pricing />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/signup" element={<Signup />} />
|
||||
<Route path="/forgot-password" element={<ForgotPassword />} />
|
||||
<Route path="/welcome" element={<Welcome />} />
|
||||
<Route path="/checkout/success" element={<CheckoutSuccess />} />
|
||||
<Route path="/checkout/cancel" element={<CheckoutCancel />} />
|
||||
{/* Redirects for underscore-based URLs (legacy Stripe config) */}
|
||||
<Route
|
||||
path="/checkout_success"
|
||||
element={<Navigate to="/checkout/success" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="/checkout_cancel"
|
||||
element={<Navigate to="/checkout/cancel" replace />}
|
||||
/>
|
||||
</Routes>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./signup-form";
|
||||
@@ -0,0 +1,153 @@
|
||||
import { faGoogle } from "@fortawesome/free-brands-svg-icons";
|
||||
import { faEye, faEyeSlash, faSpinner } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { useState } from "react";
|
||||
import { UsersApi } from "@storyteller/api";
|
||||
|
||||
interface SignupFormProps {
|
||||
onSuccess: () => void;
|
||||
signupSource: string;
|
||||
className?: string;
|
||||
showGoogleButton?: boolean;
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
|
||||
export const SignupForm = ({
|
||||
onSuccess,
|
||||
signupSource,
|
||||
className = "",
|
||||
showGoogleButton = true,
|
||||
autoFocus = false,
|
||||
}: SignupFormProps) => {
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSignup = async () => {
|
||||
setError(null);
|
||||
|
||||
if (!email || !password) {
|
||||
setError("All fields are required");
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
setError("Password must be at least 8 characters");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
const api = new UsersApi();
|
||||
|
||||
// Generate a username from the email
|
||||
const emailPrefix = email.split("@")[0].replace(/[^a-zA-Z0-9]/g, "");
|
||||
// Ensure we don't exceed max length (7 chars from email + 3 digits = 10 total)
|
||||
const truncatedPrefix = emailPrefix.substring(0, 7);
|
||||
const randomSuffix = Math.floor(Math.random() * 900) + 100; // 100-999
|
||||
const generatedUsername = `${truncatedPrefix}${randomSuffix}`;
|
||||
|
||||
const response = await api.Signup({
|
||||
username: generatedUsername,
|
||||
email,
|
||||
password,
|
||||
passwordConfirmation: password,
|
||||
signupSource,
|
||||
});
|
||||
|
||||
setIsLoading(false);
|
||||
|
||||
if (response.success) {
|
||||
window.dispatchEvent(new Event("auth-change"));
|
||||
onSuccess();
|
||||
} else {
|
||||
setError(response.errorMessage || "Failed to create account");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`space-y-4 ${className}`}>
|
||||
{showGoogleButton && (
|
||||
<>
|
||||
<Button
|
||||
className="w-full bg-white text-black hover:bg-gray-100 border-none justify-center gap-3 font-medium h-12"
|
||||
onClick={() => {}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faGoogle} className="text-lg" />
|
||||
Continue with Google
|
||||
</Button>
|
||||
|
||||
<div className="relative flex items-center justify-center py-2">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-white/10"></div>
|
||||
</div>
|
||||
<span className="relative bg-[#1C1C20] px-4 text-xs text-white/40 uppercase tracking-widest">
|
||||
or
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/20 text-red-400 px-4 py-3 rounded-xl text-sm text-center animate-in fade-in slide-in-from-top-2">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
autoFocus={autoFocus}
|
||||
className="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSignup()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Min. 8 characters"
|
||||
className="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSignup()}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-white/30 hover:text-white/60 transition-colors"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<FontAwesomeIcon icon={showPassword ? faEyeSlash : faEye} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-12 mt-2"
|
||||
onClick={handleSignup}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<FontAwesomeIcon icon={faSpinner} spin />
|
||||
) : (
|
||||
"Create Account"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -17,7 +17,7 @@ export const DiscordButton = ({
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={`relative z-10 ${sizeClasses} font-semibold transition-all duration-300 shadow-lg hover:shadow-blue-500/25 ${className}`}
|
||||
className={`relative z-10 ${sizeClasses} font-semibold transition-all duration-300 shadow-lg bg-white text-black hover:bg-gray-200 ${className}`}
|
||||
icon={faDiscord}
|
||||
as="link"
|
||||
href={SOCIAL_LINKS.DISCORD}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { faGoogle } from "@fortawesome/free-brands-svg-icons";
|
||||
import {
|
||||
faTimes,
|
||||
faEnvelope,
|
||||
faArrowLeft,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { SignupForm } from "./auth";
|
||||
|
||||
interface DownloadModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const DownloadModal = ({ isOpen, onClose }: DownloadModalProps) => {
|
||||
const navigate = useNavigate();
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [view, setView] = useState<"menu" | "signup">("menu");
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setIsVisible(true);
|
||||
document.body.style.overflow = "hidden";
|
||||
} else {
|
||||
const timer = setTimeout(() => {
|
||||
setIsVisible(false);
|
||||
// Reset state after animation
|
||||
setView("menu");
|
||||
}, 300);
|
||||
document.body.style.overflow = "unset";
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isMounted) return null;
|
||||
|
||||
if (!isVisible && !isOpen) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className={`fixed inset-0 z-50 flex items-center justify-center px-4 transition-all duration-300 ${
|
||||
isOpen
|
||||
? "opacity-100 backdrop-blur-lg"
|
||||
: "opacity-0 backdrop-blur-none pointer-events-none"
|
||||
}`}
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/80 transition-opacity duration-300"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal Content */}
|
||||
<div
|
||||
className={`relative w-full max-w-md bg-[#1C1C20] border border-white/10 rounded-3xl p-8 shadow-2xl transform transition-all duration-300 ${
|
||||
isOpen ? "scale-100 translate-y-0" : "scale-95 translate-y-8"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 w-8 h-8 flex items-center justify-center rounded-full bg-white/5 hover:bg-white/10 text-white/40 hover:text-white/80 transition-colors z-20"
|
||||
>
|
||||
<FontAwesomeIcon icon={faTimes} />
|
||||
</button>
|
||||
|
||||
{view === "signup" && (
|
||||
<button
|
||||
onClick={() => setView("menu")}
|
||||
className="absolute top-4 left-4 w-8 h-8 flex items-center justify-center rounded-full bg-white/5 hover:bg-white/10 text-white/40 hover:text-white/80 transition-colors z-20"
|
||||
>
|
||||
<FontAwesomeIcon icon={faArrowLeft} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 bg-primary/20 rounded-2xl flex items-center justify-center mx-auto mb-6 text-primary">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-8 w-8 animate-bounce"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-white mb-2">
|
||||
Downloading ArtCraft...
|
||||
</h2>
|
||||
<p className="text-white/60 text-sm">
|
||||
Your download has started.
|
||||
<br />
|
||||
While you wait, create an account to store your creations.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{view === "menu" ? (
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
className="w-full bg-white text-black hover:bg-gray-100 border-none justify-center gap-3 font-medium h-12"
|
||||
onClick={() => {}} // Dummy op
|
||||
>
|
||||
<FontAwesomeIcon icon={faGoogle} className="text-lg" />
|
||||
Continue with Google
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
className="w-full bg-white/5 hover:bg-white/10 text-white border-white/10 justify-center gap-3 font-medium h-12"
|
||||
onClick={() => setView("signup")}
|
||||
>
|
||||
<FontAwesomeIcon icon={faEnvelope} className="text-lg" />
|
||||
Sign up with Email
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<SignupForm
|
||||
onSuccess={() => {
|
||||
onClose();
|
||||
navigate("/welcome");
|
||||
}}
|
||||
signupSource="artcraft"
|
||||
showGoogleButton={false}
|
||||
autoFocus={true}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="mt-8 text-center pt-6 border-t border-white/5">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-white/40 text-xs hover:text-white/60 transition-colors font-medium"
|
||||
>
|
||||
I already have an account
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
};
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
faDiscord,
|
||||
faYoutube,
|
||||
faGithubAlt,
|
||||
faLinkedinIn,
|
||||
faInstagram,
|
||||
} from "@fortawesome/free-brands-svg-icons";
|
||||
import { SOCIAL_LINKS } from "../../config/links";
|
||||
import { getFaqItems, getTutorialItems } from "@storyteller/markdown-content";
|
||||
@@ -39,7 +41,16 @@ const navigation = {
|
||||
href: SOCIAL_LINKS.GITHUB,
|
||||
icon: (props: any) => <FontAwesomeIcon icon={faGithubAlt} {...props} />,
|
||||
},
|
||||
|
||||
{
|
||||
name: "Instagram",
|
||||
href: SOCIAL_LINKS.INSTAGRAM,
|
||||
icon: (props: any) => <FontAwesomeIcon icon={faInstagram} {...props} />,
|
||||
},
|
||||
{
|
||||
name: "LinkedIn",
|
||||
href: SOCIAL_LINKS.LINKEDIN,
|
||||
icon: (props: any) => <FontAwesomeIcon icon={faLinkedinIn} {...props} />,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -94,6 +105,9 @@ export default function Example() {
|
||||
<Link to="/news" className="text-gray-400 hover:text-white">
|
||||
News
|
||||
</Link>
|
||||
<Link to="/press-kit" className="text-gray-400 hover:text-white">
|
||||
Press Kit
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-col items-center sm:items-start gap-2">
|
||||
<div className="text-gray-200 mb-1">Tutorials</div>
|
||||
@@ -121,7 +135,9 @@ export default function Example() {
|
||||
{getFaqItems().map((item) => {
|
||||
if (!item.isPublished) return null;
|
||||
const truncated =
|
||||
item.title.length > 36 ? item.title.slice(0, 33) + "…" : item.title;
|
||||
item.title.length > 36
|
||||
? item.title.slice(0, 33) + "…"
|
||||
: item.title;
|
||||
return (
|
||||
<Link
|
||||
key={item.slug}
|
||||
@@ -135,7 +151,7 @@ export default function Example() {
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-center text-sm/6 text-gray-400">
|
||||
© 2025 ArtCraft. All rights reserved.
|
||||
© 2026 ArtCraft. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./navbar";
|
||||
export * from "./footer";
|
||||
export * from "./download-button";
|
||||
export * from "./pricing-table";
|
||||
|
||||
@@ -1,20 +1,68 @@
|
||||
import { Disclosure } from "@headlessui/react";
|
||||
import {
|
||||
Disclosure,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuItem,
|
||||
MenuItems,
|
||||
Transition,
|
||||
} from "@headlessui/react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, Fragment } from "react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { DiscordButton } from "../discord-button";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { UsersApi, UserInfo } from "@storyteller/api";
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ name: "Home", href: "/" },
|
||||
{ name: "Tutorials", href: "/tutorials" },
|
||||
{ name: "News", href: "/news" },
|
||||
{ name: "FAQ", href: "/faq" },
|
||||
{ name: "Press Kit", href: "/press-kit" },
|
||||
{ name: "Download", href: "/download" },
|
||||
];
|
||||
|
||||
export default function Navbar() {
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const location = useLocation();
|
||||
// Underline hover handled purely via CSS
|
||||
const [user, setUser] = useState<UserInfo | undefined>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// Check session on mount
|
||||
// Check session on mount and when auth changes or location changes
|
||||
useEffect(() => {
|
||||
const checkSession = async () => {
|
||||
const api = new UsersApi();
|
||||
const response = await api.GetSession();
|
||||
if (
|
||||
response.success &&
|
||||
response.data &&
|
||||
response.data.loggedIn &&
|
||||
response.data.user
|
||||
) {
|
||||
setUser(response.data.user);
|
||||
} else {
|
||||
setUser(undefined);
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
checkSession();
|
||||
|
||||
const handleAuthChange = () => {
|
||||
setIsLoading(true);
|
||||
checkSession();
|
||||
};
|
||||
|
||||
window.addEventListener("auth-change", handleAuthChange);
|
||||
return () => window.removeEventListener("auth-change", handleAuthChange);
|
||||
}, [location.pathname]);
|
||||
|
||||
const handleLogout = async () => {
|
||||
const api = new UsersApi();
|
||||
await api.Logout();
|
||||
// Reload to clear state/cache
|
||||
window.location.href = "/";
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
@@ -32,10 +80,10 @@ export default function Navbar() {
|
||||
"z-20 fixed top-0 left-0 w-full transition-colors duration-200 bg-transparent",
|
||||
scrolled
|
||||
? "bg-[#1b1b1f]/70 backdrop-blur-lg lg:bg-transparent lg:backdrop-blur-none"
|
||||
: "bg-transparent"
|
||||
: "bg-transparent",
|
||||
)}
|
||||
>
|
||||
<div className="mx-auto max-w-screen sm:px-6 lg:px-8 px-6 md:px-16 xl:px-4">
|
||||
<div className="mx-auto max-w-screen sm:px-6 lg:px-8 px-4 md:px-16 xl:px-4">
|
||||
<div className="flex h-16 justify-between">
|
||||
<div className="flex">
|
||||
<div className="flex shrink-0 items-center">
|
||||
@@ -64,21 +112,21 @@ export default function Navbar() {
|
||||
isCurrent
|
||||
? "text-white"
|
||||
: "text-white/60 hover:text-white",
|
||||
"relative rounded-md text-[15px] font-semibold transition-all"
|
||||
"relative rounded-md text-[15px] font-semibold transition-all",
|
||||
)}
|
||||
>
|
||||
<span className="relative z-10">{item.name}</span>
|
||||
<span
|
||||
className={twMerge(
|
||||
"pointer-events-none absolute left-0 right-0 -bottom-1 h-[2px] overflow-hidden",
|
||||
isCurrent ? "" : ""
|
||||
isCurrent ? "" : "",
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span
|
||||
className={twMerge(
|
||||
"link-underline block h-full w-full bg-primary/90",
|
||||
isCurrent ? "visible-line" : ""
|
||||
isCurrent ? "visible-line" : "",
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
@@ -88,21 +136,102 @@ export default function Navbar() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div className="hidden md:ml-4 md:flex md:shrink-0 md:items-center">
|
||||
{/* <Button as="link" href="/download">
|
||||
Download
|
||||
</Button> */}
|
||||
<DiscordButton
|
||||
small
|
||||
className="bg-white text-black hover:bg-white/90"
|
||||
/>
|
||||
</div>
|
||||
<div className="-ml-2 flex items-center md:hidden">
|
||||
{/* Mobile menu button */}
|
||||
<DiscordButton
|
||||
className="text-sm bg-white text-black hover:bg-white/90"
|
||||
small
|
||||
/>
|
||||
{isLoading ? (
|
||||
// Loading placeholder
|
||||
<div className="hidden md:ml-4 md:flex items-center gap-6 opacity-0"></div>
|
||||
) : user ? (
|
||||
// Logged In State
|
||||
<div className="hidden md:ml-4 md:flex items-center gap-6">
|
||||
<Link
|
||||
to="/pricing"
|
||||
className="text-[15px] font-semibold text-white/60 hover:text-white transition-colors"
|
||||
>
|
||||
Pricing
|
||||
</Link>
|
||||
|
||||
<Menu as="div" className="relative ml-3">
|
||||
<div>
|
||||
<MenuButton className="flex rounded-full bg-gray-800 text-sm focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-gray-800">
|
||||
<span className="sr-only">Open user menu</span>
|
||||
<img
|
||||
className="h-8 w-8 rounded-full border border-white/10"
|
||||
src={`https://www.gravatar.com/avatar/${user.email_gravatar_hash}?d=mp`}
|
||||
alt=""
|
||||
/>
|
||||
</MenuButton>
|
||||
</div>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<MenuItems
|
||||
modal={false}
|
||||
className="absolute right-0 z-10 mt-2 w-48 origin-top-right rounded-md bg-[#1C1C20] py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none border border-white/10"
|
||||
>
|
||||
<div className="px-4 py-3 border-b border-white/10">
|
||||
<p className="text-sm text-white font-semibold truncate">
|
||||
{user.display_name || user.username}
|
||||
</p>
|
||||
</div>
|
||||
<MenuItem>
|
||||
{({ active }) => (
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className={twMerge(
|
||||
active ? "bg-white/5" : "",
|
||||
"block w-full text-left px-4 py-2 text-sm text-white/70",
|
||||
)}
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
)}
|
||||
</MenuItem>
|
||||
</MenuItems>
|
||||
</Transition>
|
||||
</Menu>
|
||||
</div>
|
||||
) : (
|
||||
// Logged Out State
|
||||
<div className="hidden md:ml-4 md:flex md:shrink-0 md:items-center gap-6">
|
||||
<Link
|
||||
to="/pricing"
|
||||
className="text-[15px] font-semibold text-white/60 hover:text-white transition-colors"
|
||||
>
|
||||
Pricing
|
||||
</Link>
|
||||
<Button
|
||||
as="link"
|
||||
href="/signup"
|
||||
className="bg-white text-black hover:bg-white/90 text-sm font-semibold px-4 py-2 h-auto rounded-lg shadow-md"
|
||||
>
|
||||
Sign up
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile Menu Button - simplified for now */}
|
||||
<div className="-ml-2 flex items-center md:hidden gap-3">
|
||||
{!user && (
|
||||
<Button
|
||||
as="link"
|
||||
href="/signup"
|
||||
className="bg-white text-black hover:bg-white/90 text-xs font-semibold px-3 py-1.5 h-auto rounded-lg"
|
||||
>
|
||||
Sign up
|
||||
</Button>
|
||||
)}
|
||||
{user && (
|
||||
<img
|
||||
className="h-8 w-8 rounded-full border border-white/10"
|
||||
src={`https://www.gravatar.com/avatar/${user.email_gravatar_hash}?d=mp`}
|
||||
alt=""
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+7
-3
@@ -16,7 +16,11 @@ export const OwnershipComparison = () => {
|
||||
<span>From Websites</span>
|
||||
</h2>
|
||||
<p className="md:text-xl text-lg text-white/80 max-w-2xl mx-auto">
|
||||
Take back control of your tools.
|
||||
We're artists, not landlords. We're going to give you ArtCraft to have and to hold <em>forever</em>. It's yours.
|
||||
</p>
|
||||
<br />
|
||||
<p className="md:text-xl text-lg text-white/80 max-w-2xl mx-auto">
|
||||
So you can dump that aggregator <s>subscription</s> rent payment.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -58,7 +62,7 @@ export const OwnershipComparison = () => {
|
||||
</h3>
|
||||
|
||||
<p className="text-white/80 mb-8 leading-relaxed opacity-100">
|
||||
You're paying for permission, not a product. If they shut down, you lose everything.
|
||||
You're paying for permission, not a product. If they shut down, you lose everything. (Ask them about downloading your LoRAs! 🤫)
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-3 text-xs sm:text-sm font-bold tracking-wide uppercase">
|
||||
@@ -96,7 +100,7 @@ export const OwnershipComparison = () => {
|
||||
<p className="text-white/80 mb-8 leading-relaxed opacity-100">
|
||||
Download ArtCraft. You own the code, the keys, and the output. You don't even have to pay us to use it.
|
||||
<br />
|
||||
<div className="text-xs text-white/50 pt-3">(Lots of 3rd party logins and API keys supported.)</div>
|
||||
<div className="text-xs text-white/50 pt-3">(Lots of 3rd party logins and API keys supported. Also please pay us. We turn that into coffee and development and movies.)</div>
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-3 text-xs sm:text-sm font-bold tracking-wide uppercase">
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { default as PricingTable } from "./pricing-table";
|
||||
@@ -0,0 +1,412 @@
|
||||
import { faCheck } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import {
|
||||
SUBSCRIPTION_PLANS,
|
||||
SubscriptionPlanDetails,
|
||||
} from "@storyteller/subscription";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { useState, useEffect } from "react";
|
||||
import { TabSelector } from "@storyteller/ui-tab-selector";
|
||||
import {
|
||||
UsersApi,
|
||||
BillingApi,
|
||||
UserInfo,
|
||||
StorytellerApiHostStore,
|
||||
} from "@storyteller/api";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
const billingTabs = [
|
||||
{ id: "yearly", label: "Yearly" },
|
||||
{ id: "monthly", label: "Monthly" },
|
||||
];
|
||||
|
||||
// Mapping from our plan slugs to API plan slugs
|
||||
const PLAN_SLUG_MAP: Record<string, string> = {
|
||||
artcraft_basic: "artcraft_basic",
|
||||
artcraft_pro: "artcraft_pro",
|
||||
artcraft_max: "artcraft_max",
|
||||
};
|
||||
|
||||
interface PricingTableProps {
|
||||
includeFree?: boolean;
|
||||
showHeader?: boolean;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const PricingTable = ({
|
||||
includeFree = false,
|
||||
showHeader = true,
|
||||
title = "Choose Your Plan",
|
||||
subtitle = "Support open-source development. Your subscription keeps ArtCraft free and open for everyone.",
|
||||
className = "",
|
||||
compact = false,
|
||||
}: PricingTableProps) => {
|
||||
const navigate = useNavigate();
|
||||
const [billingType, setBillingType] = useState("yearly");
|
||||
const isYearly = billingType === "yearly";
|
||||
|
||||
// User and subscription state
|
||||
const [user, setUser] = useState<UserInfo | undefined>(undefined);
|
||||
const [activePlanSlug, setActivePlanSlug] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [processingPlan, setProcessingPlan] = useState<string | null>(null);
|
||||
|
||||
// Fetch user session and subscriptions on mount
|
||||
useEffect(() => {
|
||||
const fetchUserData = async () => {
|
||||
try {
|
||||
const usersApi = new UsersApi();
|
||||
const sessionResponse = await usersApi.GetSession();
|
||||
|
||||
if (
|
||||
sessionResponse.success &&
|
||||
sessionResponse.data?.loggedIn &&
|
||||
sessionResponse.data?.user
|
||||
) {
|
||||
setUser(sessionResponse.data.user);
|
||||
|
||||
// Fetch active subscriptions
|
||||
const billingApi = new BillingApi();
|
||||
const subscriptionsResponse =
|
||||
await billingApi.ListActiveSubscriptions();
|
||||
|
||||
if (
|
||||
subscriptionsResponse.success &&
|
||||
subscriptionsResponse.data?.active_subscriptions
|
||||
) {
|
||||
// Find ArtCraft subscription
|
||||
const artcraftSub =
|
||||
subscriptionsResponse.data.active_subscriptions.find(
|
||||
(sub) => sub.namespace === "artcraft",
|
||||
);
|
||||
if (artcraftSub) {
|
||||
setActivePlanSlug(artcraftSub.product_slug);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching user data:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUserData();
|
||||
}, []);
|
||||
|
||||
const plans = includeFree
|
||||
? SUBSCRIPTION_PLANS
|
||||
: SUBSCRIPTION_PLANS.filter((plan) => plan.slug !== "free");
|
||||
|
||||
const getColorSchemeClasses = (
|
||||
colorScheme: SubscriptionPlanDetails["colorScheme"],
|
||||
) => {
|
||||
const baseClasses =
|
||||
"relative rounded-3xl p-6 md:p-8 border flex flex-col transition-all duration-300 backdrop-blur-md";
|
||||
|
||||
switch (colorScheme) {
|
||||
case "dark":
|
||||
return twMerge(
|
||||
baseClasses,
|
||||
"bg-[#1C1C20] border-white/10 hover:border-white/20",
|
||||
);
|
||||
case "green":
|
||||
return twMerge(
|
||||
baseClasses,
|
||||
"bg-gradient-to-b from-[#002D23]/80 via-[#006B54]/50 to-[#00D28B]/10 border-[#00a873]/50",
|
||||
"hover:border-[#00a873] hover:shadow-[0_0_30px_rgba(0,210,139,0.2)]",
|
||||
);
|
||||
case "purple":
|
||||
return twMerge(
|
||||
baseClasses,
|
||||
"bg-gradient-to-b from-[#2D004D]/80 via-[#6400A8]/50 to-[#C03FFF]/10 border-[#9D4CFF]/50",
|
||||
"hover:border-[#9D4CFF] hover:shadow-[0_0_30px_rgba(192,63,255,0.2)]",
|
||||
);
|
||||
case "orange":
|
||||
return twMerge(
|
||||
baseClasses,
|
||||
"bg-gradient-to-b from-[#332100]/80 via-[#B35C00]/50 to-[#FFB347]/10 border-[#FF8C00]/50",
|
||||
"hover:border-[#FF8C00] hover:shadow-[0_0_30px_rgba(255,179,71,0.2)]",
|
||||
);
|
||||
default:
|
||||
return twMerge(
|
||||
baseClasses,
|
||||
"bg-white/5 border-white/10 hover:border-white/20",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const isCurrentPlan = (planSlug: string) => {
|
||||
return planSlug === activePlanSlug;
|
||||
};
|
||||
|
||||
const getButtonText = (planSlug: string) => {
|
||||
if (planSlug === "free") return "Get Started";
|
||||
|
||||
if (isCurrentPlan(planSlug)) {
|
||||
return "Current Plan";
|
||||
}
|
||||
|
||||
// User has an active paid subscription - show "Switch Plan"
|
||||
if (activePlanSlug && activePlanSlug !== "free") {
|
||||
return "Switch Plan";
|
||||
}
|
||||
|
||||
// No subscription - show "Select Plan"
|
||||
return "Select Plan";
|
||||
};
|
||||
|
||||
const handlePlanClick = async (planSlug: string) => {
|
||||
// Free plan - redirect to download
|
||||
if (planSlug === "free") {
|
||||
navigate("/download");
|
||||
return;
|
||||
}
|
||||
|
||||
// If current plan, do nothing
|
||||
if (isCurrentPlan(planSlug)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Not logged in - redirect to signup
|
||||
if (!user) {
|
||||
navigate("/signup");
|
||||
return;
|
||||
}
|
||||
|
||||
// Set loading state for this plan
|
||||
setProcessingPlan(planSlug);
|
||||
|
||||
try {
|
||||
const cadence = isYearly ? "yearly" : "monthly";
|
||||
const apiPlanSlug = PLAN_SLUG_MAP[planSlug] || planSlug;
|
||||
|
||||
// Determine if we need checkout (new subscription) or portal (switch plan)
|
||||
const hasActiveSub = activePlanSlug && activePlanSlug !== "free";
|
||||
|
||||
const endpoint = hasActiveSub
|
||||
? "/v1/stripe_artcraft/portal/switch_plan"
|
||||
: "/v1/stripe_artcraft/checkout/subscription";
|
||||
|
||||
const apiHost =
|
||||
StorytellerApiHostStore.getInstance().getApiSchemeAndHost();
|
||||
const response = await fetch(`${apiHost}${endpoint}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
plan: apiPlanSlug,
|
||||
cadence: cadence,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Redirect to Stripe
|
||||
const redirectUrl =
|
||||
data.stripe_checkout_redirect_url || data.stripe_portal_url;
|
||||
if (redirectUrl) {
|
||||
window.location.href = redirectUrl;
|
||||
} else {
|
||||
console.error("No redirect URL returned from API");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error initiating checkout:", error);
|
||||
} finally {
|
||||
setProcessingPlan(null);
|
||||
}
|
||||
};
|
||||
|
||||
const formatPrice = (plan: SubscriptionPlanDetails) => {
|
||||
if (plan.monthlyPrice === 0) {
|
||||
return { current: 0, original: null };
|
||||
}
|
||||
|
||||
if (isYearly) {
|
||||
const val = Math.round(plan.yearlyPrice / 12);
|
||||
const original = plan.originalYearlyPrice
|
||||
? Math.round(plan.originalYearlyPrice / 12)
|
||||
: null;
|
||||
return { current: val, original };
|
||||
} else {
|
||||
const val = plan.originalMonthlyPrice || plan.monthlyPrice;
|
||||
return { current: val, original: null };
|
||||
}
|
||||
};
|
||||
|
||||
// Determine grid columns based on number of plans
|
||||
const gridCols =
|
||||
plans.length === 3
|
||||
? "grid-cols-1 md:grid-cols-3"
|
||||
: "grid-cols-1 md:grid-cols-2 lg:grid-cols-4";
|
||||
|
||||
return (
|
||||
<div className={twMerge("w-full", className)}>
|
||||
{showHeader && (
|
||||
<div className={`text-center mx-auto ${compact ? "mb-6" : "mb-10"}`}>
|
||||
<h2
|
||||
className={`font-bold mb-4 ${compact ? "text-2xl md:text-3xl" : "text-3xl sm:text-4xl md:text-5xl"}`}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
<p
|
||||
className={`text-white/70 leading-relaxed ${compact ? "text-base mb-4" : "text-xl mb-8"}`}
|
||||
>
|
||||
{subtitle}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Billing Toggle */}
|
||||
<div
|
||||
className={`flex items-center justify-center gap-4 relative w-fit mx-auto ${compact ? "mb-14" : "mb-14"}`}
|
||||
>
|
||||
<TabSelector
|
||||
tabs={billingTabs}
|
||||
activeTab={billingType}
|
||||
onTabChange={setBillingType}
|
||||
className="w-fit border border-white/20 rounded-lg bg-white/5"
|
||||
tabClassName="w-24 text-md"
|
||||
indicatorClassName="bg-primary/30 border border-primary"
|
||||
selectedTabClassName="text-white"
|
||||
/>
|
||||
<span className="bg-primary text-white px-3 py-0.5 rounded-full text-sm font-medium -top-3 -right-10 md:-left-6 md:right-auto absolute pointer-events-none transform md:-rotate-12 rotate-12">
|
||||
-20%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={`max-w-7xl mx-auto grid ${gridCols} gap-4 md:gap-6`}>
|
||||
{plans.map((plan) => {
|
||||
const isPopular = plan.slug === "artcraft_pro";
|
||||
const isCurrent = isCurrentPlan(plan.slug);
|
||||
const { current: price, original: originalPrice } = formatPrice(plan);
|
||||
const isProcessing = processingPlan === plan.slug;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={plan.slug}
|
||||
className={
|
||||
getColorSchemeClasses(plan.colorScheme) +
|
||||
(isPopular ? " transform md:-translate-y-4 shadow-2xl" : "") +
|
||||
(isCurrent ? " ring-2 ring-white/50" : "")
|
||||
}
|
||||
>
|
||||
{isPopular && !isCurrent && (
|
||||
<div className="absolute top-0 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-primary px-4 py-1 rounded-full text-sm font-bold shadow-lg whitespace-nowrap">
|
||||
MOST POPULAR
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isCurrent && (
|
||||
<div className="absolute top-0 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-white text-black px-4 py-1 rounded-full text-sm font-bold shadow-lg whitespace-nowrap">
|
||||
CURRENT PLAN
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h3 className="text-xl md:text-2xl font-bold mb-2 text-white">
|
||||
{plan.name}
|
||||
</h3>
|
||||
<div className="mb-1 flex items-baseline gap-2">
|
||||
{originalPrice !== null && (
|
||||
<span className="text-white/40 line-through text-lg md:text-xl decoration-white/40">
|
||||
${originalPrice}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-3xl md:text-4xl font-bold">${price}</span>
|
||||
<span className="text-white/60">/month</span>
|
||||
</div>
|
||||
<div className="text-xs text-white/40 mb-4 md:mb-6 uppercase tracking-wider font-semibold min-h-[1rem]">
|
||||
{plan.monthlyPrice === 0
|
||||
? "Free forever"
|
||||
: isYearly
|
||||
? "Billed yearly"
|
||||
: "Billed monthly"}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className={`w-full justify-center border-transparent mb-6 md:mb-8 ${
|
||||
isCurrent
|
||||
? "bg-white/20 cursor-default"
|
||||
: isPopular
|
||||
? "bg-primary hover:bg-primary-600"
|
||||
: "bg-white hover:bg-white/80 text-black"
|
||||
}`}
|
||||
onClick={() => handlePlanClick(plan.slug)}
|
||||
disabled={isCurrent || isProcessing || isLoading}
|
||||
>
|
||||
{isProcessing ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<svg
|
||||
className="animate-spin h-4 w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
Processing...
|
||||
</span>
|
||||
) : (
|
||||
getButtonText(plan.slug)
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<ul className="space-y-3 md:space-y-4 flex-1">
|
||||
{plan.features.map((feature, idx) => (
|
||||
<Feature
|
||||
key={idx}
|
||||
text={feature.text}
|
||||
highlighted={isPopular}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Feature = ({
|
||||
text,
|
||||
highlighted = false,
|
||||
}: {
|
||||
text: string;
|
||||
highlighted?: boolean;
|
||||
}) => (
|
||||
<li className="flex items-start gap-3">
|
||||
<div
|
||||
className={`mt-1 w-5 h-5 rounded-full flex items-center justify-center shrink-0 ${highlighted ? "bg-white/20 text-white" : "bg-white/10 text-white/70"}`}
|
||||
>
|
||||
<FontAwesomeIcon icon={faCheck} className="text-xs" />
|
||||
</div>
|
||||
<span className={`text-sm ${highlighted ? "text-white" : "text-white/80"}`}>
|
||||
{text}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
|
||||
export default PricingTable;
|
||||
@@ -0,0 +1,64 @@
|
||||
import { DiscordButton } from "../discord-button";
|
||||
|
||||
export const ReallyAI = () => {
|
||||
return (
|
||||
<>
|
||||
<div className="relative flex overflow-visible xl:items-center xl:pt-0 px-2 sm:px-4 md:px-0 md:-mt-36 min-[1980px]:-mt-64">
|
||||
{/* Gradient Orb for Section */}
|
||||
<div className="absolute left-[-200px] top-[-100px] w-[600px] h-[600px] rounded-full bg-gradient-to-br from-[#00AABA] via-blue-500 to-blue-700 opacity-15 blur-[80px] md:blur-[120px] z-0 pointer-events-none transform-gpu" />
|
||||
<div
|
||||
className="w-full flex flex-col items-center justify-center text-center pt-16 sm:pt-24 md:pt-32 px-2 sm:px-6 md:px-10"
|
||||
data-animate
|
||||
>
|
||||
<div className="relative">
|
||||
<h1 className="relative mb-4 sm:mb-6 md:mb-10 font-bold text-3xl sm:text-4xl md:text-[3rem] lg:text-[4rem] xl:text-[5rem] !leading-none text-shadow-lg">
|
||||
<span
|
||||
className="text-primary"
|
||||
style={{ textShadow: "0 0 15px var(--color-primary)" }}
|
||||
>
|
||||
"
|
||||
</span>
|
||||
Is this really AI?
|
||||
<span
|
||||
className="text-primary"
|
||||
style={{ textShadow: "0 0 15px var(--color-primary)" }}
|
||||
>
|
||||
"
|
||||
</span>
|
||||
</h1>
|
||||
<span className="absolute -right-6 sm:-right-5 -bottom-1 sm:bottom-4 md:-right-16 md:bottom-10 text-lg sm:text-xl md:text-3xl font-bold opacity-40 italic hover:opacity-80 transition-opacity duration-300">
|
||||
— You
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 mx-auto w-full max-w-[1200px] sm:px-4 md:px-16 lg:px-12 xl:px-32 pb-8 sm:pb-16 md:pb-24 lg:pb-32 overflow-visible px-4">
|
||||
{/* Gradient Orb for Section */}
|
||||
<div className="absolute right-[-250px] top-[-150px] w-[700px] h-[700px] rounded-full bg-gradient-to-br from-blue-700 via-[#00AABA] to-pink-500 opacity-[0.1] blur-[100px] md:blur-[140px] z-0 pointer-events-none transform-gpu" />
|
||||
<div
|
||||
className="items-center gap-8 sm:gap-12 md:gap-16 mb-8 sm:mb-12 p-2 sm:p-4 bg-white/10 backdrop-blur-md rounded-[24px] sm:rounded-[40px] shadow-xl"
|
||||
data-animate
|
||||
>
|
||||
<div className="rounded-[16px] sm:rounded-[24px] overflow-hidden bg-[#1C1C20] shadow-inner">
|
||||
<video
|
||||
muted
|
||||
autoPlay
|
||||
loop
|
||||
playsInline
|
||||
className="w-full aspect-[16/9] object-cover"
|
||||
>
|
||||
<source src="/videos/hero-video.mp4" type="video/mp4" />
|
||||
</video>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="flex justify-center"
|
||||
data-animate
|
||||
>
|
||||
<DiscordButton />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -3,4 +3,6 @@ export const SOCIAL_LINKS = {
|
||||
YOUTUBE: "https://www.youtube.com/@OfficialArtCraftStudios",
|
||||
TIKTOK: "https://www.tiktok.com/@artcraft.studios",
|
||||
GITHUB: "https://github.com/storytold/artcraft",
|
||||
INSTAGRAM: "https://www.instagram.com/get_artcraft",
|
||||
LINKEDIN: "https://www.linkedin.com/company/artcraft-ai",
|
||||
} as const;
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { faXmarkCircle, faArrowLeft } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { Link } from "react-router-dom";
|
||||
import Seo from "../../components/seo";
|
||||
|
||||
const CheckoutCancel = () => {
|
||||
return (
|
||||
<div className="relative min-h-screen overflow-hidden bg-[#101014] text-white">
|
||||
<Seo
|
||||
title="Checkout Cancelled - ArtCraft"
|
||||
description="Your checkout was cancelled. No payment was made."
|
||||
/>
|
||||
|
||||
{/* Background gradient - subtle/neutral */}
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-0">
|
||||
<div className="w-[900px] h-[900px] rounded-full bg-gradient-to-br from-gray-500/20 via-gray-600/10 to-gray-700/5 opacity-40 blur-[120px]"></div>
|
||||
</div>
|
||||
|
||||
<main className="relative z-10 pt-28 pb-20 px-4 sm:px-6 lg:px-8 flex flex-col items-center justify-center min-h-[calc(100vh-200px)]">
|
||||
{/* Cancel Card */}
|
||||
<div className="max-w-lg w-full">
|
||||
<div className="bg-[#1A1A1E] border border-white/10 rounded-3xl p-8 md:p-12 text-center">
|
||||
{/* Cancel Icon */}
|
||||
<div className="mb-6">
|
||||
<div className="w-20 h-20 mx-auto rounded-full bg-white/10 flex items-center justify-center">
|
||||
<FontAwesomeIcon
|
||||
icon={faXmarkCircle}
|
||||
className="text-5xl text-white/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<h1 className="text-3xl md:text-4xl font-bold mb-4 text-white">
|
||||
Checkout Cancelled
|
||||
</h1>
|
||||
<p className="text-lg text-white/60 mb-8 max-w-md mx-auto">
|
||||
No worries! Your checkout was cancelled and no payment was made.
|
||||
You can try again whenever you're ready.
|
||||
</p>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="bg-[#252529] rounded-2xl p-5 mb-8 text-left">
|
||||
<p className="text-white/70 text-sm">
|
||||
<span className="text-white font-medium">
|
||||
Changed your mind?
|
||||
</span>{" "}
|
||||
No problem. You can return to the pricing page to complete your
|
||||
purchase at any time.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* CTA Buttons */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Button
|
||||
as="link"
|
||||
href="/pricing"
|
||||
className="bg-primary hover:bg-primary-600 px-8 py-3 text-sm font-bold rounded-xl justify-center"
|
||||
>
|
||||
View Plans Again
|
||||
</Button>
|
||||
<Button
|
||||
as="link"
|
||||
href="/"
|
||||
className="bg-white/10 hover:bg-white/20 px-8 py-3 text-sm font-bold rounded-xl justify-center border-transparent"
|
||||
>
|
||||
<FontAwesomeIcon icon={faArrowLeft} />
|
||||
Back to Home
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer Links */}
|
||||
<div className="text-center mt-8 flex justify-center gap-6">
|
||||
<Link
|
||||
to="/faq"
|
||||
className="text-white/40 hover:text-white text-sm font-medium transition-colors"
|
||||
>
|
||||
FAQ
|
||||
</Link>
|
||||
<span className="text-white/20">•</span>
|
||||
<a
|
||||
href="https://discord.gg/artcraft"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-white/40 hover:text-white text-sm font-medium transition-colors"
|
||||
>
|
||||
Get Help
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CheckoutCancel;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as CheckoutSuccess } from "./success";
|
||||
export { default as CheckoutCancel } from "./cancel";
|
||||
@@ -0,0 +1,149 @@
|
||||
import {
|
||||
faCheckCircle,
|
||||
faDownload,
|
||||
faDesktop,
|
||||
faRocket,
|
||||
faArrowLeft,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { Link } from "react-router-dom";
|
||||
import { isMobile, isMacOs } from "react-device-detect";
|
||||
import { DOWNLOAD_LINKS } from "../../config/downloads";
|
||||
import Seo from "../../components/seo";
|
||||
|
||||
const CheckoutSuccess = () => {
|
||||
const downloadUrl = isMacOs ? DOWNLOAD_LINKS.MACOS : DOWNLOAD_LINKS.WINDOWS;
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen overflow-hidden bg-[#101014] text-white">
|
||||
<Seo
|
||||
title="Payment Successful - ArtCraft"
|
||||
description="Your ArtCraft subscription is now active. Download and start creating!"
|
||||
/>
|
||||
|
||||
{/* Background gradient - celebratory green/purple */}
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-0">
|
||||
<div className="w-[900px] h-[900px] rounded-full bg-gradient-to-br from-green-500/40 via-primary/30 to-purple-600/20 opacity-40 blur-[120px]"></div>
|
||||
</div>
|
||||
|
||||
<main className="relative z-10 pt-28 pb-20 px-4 sm:px-6 lg:px-8 flex flex-col items-center justify-center min-h-[calc(100vh-200px)]">
|
||||
{/* Success Card */}
|
||||
<div className="max-w-2xl w-full">
|
||||
<div className="bg-[#1A1A1E] border border-white/10 rounded-3xl p-8 md:p-12 text-center">
|
||||
{/* Success Icon */}
|
||||
<div className="mb-6">
|
||||
<div className="w-20 h-20 mx-auto rounded-full bg-green-500/20 flex items-center justify-center">
|
||||
<FontAwesomeIcon
|
||||
icon={faCheckCircle}
|
||||
className="text-5xl text-green-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<h1 className="text-3xl md:text-4xl font-bold mb-4 text-white">
|
||||
Thank You for Your Support!
|
||||
</h1>
|
||||
<p className="text-lg text-white/70 mb-8 max-w-md mx-auto">
|
||||
Your subscription is now active. Thank you for helping keep
|
||||
ArtCraft open-source and free for everyone. You're now ready to
|
||||
create amazing AI-generated art!
|
||||
</p>
|
||||
|
||||
{/* Next Steps */}
|
||||
<div className="bg-[#252529] rounded-2xl p-6 mb-8 text-left">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center">
|
||||
<FontAwesomeIcon
|
||||
icon={faRocket}
|
||||
className="text-primary text-sm"
|
||||
/>
|
||||
</div>
|
||||
<h2 className="text-lg font-bold text-white">Next Steps</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{[
|
||||
"Download ArtCraft if you haven't already",
|
||||
"Log in with your account",
|
||||
"Your subscription is automatically activated",
|
||||
"Start creating with all premium features!",
|
||||
].map((step, idx) => (
|
||||
<div key={idx} className="flex items-start gap-3">
|
||||
<div className="w-6 h-6 shrink-0 rounded-full bg-primary/50 flex items-center justify-center text-xs font-bold text-white mt-0.5">
|
||||
{idx + 1}
|
||||
</div>
|
||||
<span className="text-white/80">{step}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CTA Buttons */}
|
||||
{!isMobile ? (
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Button
|
||||
as="link"
|
||||
href={downloadUrl}
|
||||
className="bg-primary hover:bg-primary-600 px-8 py-3 text-sm font-bold rounded-xl justify-center"
|
||||
>
|
||||
<FontAwesomeIcon icon={faDownload} className="mr-2" />
|
||||
Download ArtCraft
|
||||
</Button>
|
||||
<Button
|
||||
as="link"
|
||||
href="/"
|
||||
className="bg-white/10 hover:bg-white/20 px-8 py-3 text-sm font-bold rounded-xl justify-center border-transparent"
|
||||
>
|
||||
<FontAwesomeIcon icon={faArrowLeft} />
|
||||
Back to Home
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-[#431407] border border-orange-900/50 rounded-2xl p-6 text-orange-200 text-sm leading-relaxed">
|
||||
<div className="flex items-center justify-center mb-3 text-orange-400">
|
||||
<FontAwesomeIcon icon={faDesktop} className="text-2xl" />
|
||||
</div>
|
||||
ArtCraft is a desktop application. <br />
|
||||
Please head to your computer to download and start creating.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Download Links */}
|
||||
{!isMobile && (
|
||||
<div className="mt-6 flex justify-center gap-6 text-sm font-medium text-white/40">
|
||||
<a
|
||||
href={DOWNLOAD_LINKS.WINDOWS}
|
||||
className="hover:text-white transition-colors flex items-center gap-2"
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-current"></span>
|
||||
Windows
|
||||
</a>
|
||||
<a
|
||||
href={DOWNLOAD_LINKS.MACOS}
|
||||
className="hover:text-white transition-colors flex items-center gap-2"
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-current"></span>
|
||||
Mac
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer Link */}
|
||||
<div className="text-center mt-8">
|
||||
<Link
|
||||
to="/pricing"
|
||||
className="text-white/40 hover:text-white text-sm font-medium transition-colors"
|
||||
>
|
||||
View Plan Details
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CheckoutSuccess;
|
||||
@@ -4,6 +4,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { DOWNLOAD_LINKS } from "../../config/downloads";
|
||||
import { isMobile } from "react-device-detect";
|
||||
import Seo from "../../components/seo";
|
||||
|
||||
const Download = () => {
|
||||
const systemRequirements = [
|
||||
@@ -29,6 +30,10 @@ const Download = () => {
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen bg-[#101014] text-white bg-dots">
|
||||
<Seo
|
||||
title="Download ArtCraft - Windows and macOS"
|
||||
description="Download ArtCraft for Windows and macOS. Start creating AI artwork today."
|
||||
/>
|
||||
<div className="dotted-pattern absolute inset-0 z-[0] opacity-50" />
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-0">
|
||||
<div className="w-[900px] h-[900px] rounded-full bg-gradient-to-br from-blue-700 via-blue-500 to-[#00AABA] opacity-30 blur-[120px]"></div>
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { faArrowLeft, faEnvelope } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import Seo from "../../components/seo";
|
||||
|
||||
const ForgotPassword = () => {
|
||||
const [email, setEmail] = useState("");
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen bg-[#101014] text-white bg-dots flex flex-col">
|
||||
<Seo
|
||||
title="Reset Password - ArtCraft"
|
||||
description="Reset your ArtCraft password."
|
||||
/>
|
||||
<div className="dotted-pattern absolute inset-0 z-[0] opacity-30" />
|
||||
|
||||
<main className="relative z-10 flex-1 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md bg-[#1C1C20] border border-white/10 rounded-3xl p-8 shadow-2xl">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-2xl font-bold mb-2">Reset Password</h1>
|
||||
<p className="text-white/60 text-sm">
|
||||
Enter your email to receive reset instructions
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!submitted ? (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
className="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-12 mt-2"
|
||||
onClick={() => setSubmitted(true)}
|
||||
>
|
||||
Send Reset Link
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<div className="w-16 h-16 bg-green-500/20 rounded-full flex items-center justify-center mx-auto mb-4 text-green-500">
|
||||
<FontAwesomeIcon icon={faEnvelope} className="text-2xl" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold mb-2">Check your email</h3>
|
||||
<p className="text-white/60 text-sm mb-8">
|
||||
We've sent a password reset link to <br />
|
||||
<span className="text-white font-medium">{email}</span>
|
||||
</p>
|
||||
<Button
|
||||
className="w-full bg-white/10 hover:bg-white/20 text-white border-none justify-center font-bold h-12"
|
||||
onClick={() => setSubmitted(false)}
|
||||
>
|
||||
Try another email
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8 text-center text-sm">
|
||||
<Link
|
||||
to="/login"
|
||||
className="text-white/40 hover:text-white transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<FontAwesomeIcon icon={faArrowLeft} /> Back to Log in
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ForgotPassword;
|
||||
@@ -0,0 +1,2 @@
|
||||
import ForgotPassword from "./forgot-password";
|
||||
export default ForgotPassword;
|
||||
@@ -1,6 +1,6 @@
|
||||
import { DiscordButton } from "../../components/discord-button";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { isMobile } from "react-device-detect";
|
||||
import { isMobile, isMacOs } from "react-device-detect";
|
||||
import { faWindows, faApple, faGithub } from "@fortawesome/free-brands-svg-icons";
|
||||
import {
|
||||
faVolumeMute,
|
||||
@@ -25,6 +25,8 @@ import ModelBadgeGrid from "../../components/model-badge-grid";
|
||||
import Seo from "../../components/seo";
|
||||
import { DOWNLOAD_LINKS } from "../../config/downloads";
|
||||
import { OwnershipComparison } from "../../components/ownership-comparison/ownership-comparison";
|
||||
import { DownloadModal } from "../../components/download-modal";
|
||||
import { UsersApi } from "@storyteller/api";
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
@@ -52,8 +54,33 @@ const Landing = () => {
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
const [activeFeature, setActiveFeature] = useState(0);
|
||||
const [activeVideo, setActiveVideo] = useState<number | null>(null);
|
||||
const [showDownloadModal, setShowDownloadModal] = useState(false);
|
||||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
const downloadUrl = isMacOs ? MAC_LINK : WINDOWS_LINK;
|
||||
|
||||
useEffect(() => {
|
||||
const checkSession = async () => {
|
||||
const api = new UsersApi();
|
||||
const response = await api.GetSession();
|
||||
if (response.success && response.data?.loggedIn) {
|
||||
setIsLoggedIn(true);
|
||||
}
|
||||
};
|
||||
checkSession();
|
||||
}, []);
|
||||
|
||||
const onDownloadClick = (e: React.MouseEvent) => {
|
||||
if (isLoggedIn) {
|
||||
return;
|
||||
}
|
||||
|
||||
setShowDownloadModal(true);
|
||||
|
||||
localStorage.setItem("artcraft_download_initiated", "true");
|
||||
};
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: faMapMarkerAlt,
|
||||
@@ -246,8 +273,8 @@ const Landing = () => {
|
||||
className="text-base mt-4 sm:text-xl md:text-2xl lg:text-2xl text-white/70 mb-8 sm:mb-14 max-w-2xl mx-auto font-medium drop-shadow-[0_2px_12px_rgba(80,80,255,0.10)]"
|
||||
data-animate
|
||||
>
|
||||
Text prompting sucks. Bring your true vision to life with
|
||||
unparalleled control and precision.
|
||||
Artists need and deserve unparalleled control and precision.
|
||||
ArtCraft’s got you covered.
|
||||
</p>
|
||||
|
||||
{/* CTA Buttons */}
|
||||
@@ -263,26 +290,17 @@ const Landing = () => {
|
||||
Download on a desktop
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
className="text-md px-4 py-3 font-semibold rounded-xl shadow-lg gap-3"
|
||||
as="link"
|
||||
href={WINDOWS_LINK}
|
||||
>
|
||||
<FontAwesomeIcon icon={faWindows} />
|
||||
Download Windows
|
||||
</Button>
|
||||
<Button
|
||||
className="text-md px-4 py-3 font-semibold rounded-xl shadow-lg gap-3"
|
||||
as="link"
|
||||
href={MAC_LINK}
|
||||
>
|
||||
<FontAwesomeIcon icon={faApple} />
|
||||
Download Mac
|
||||
</Button>
|
||||
</>
|
||||
<Button
|
||||
className="text-md px-8 py-4 text-lg font-semibold rounded-xl shadow-lg gap-3 transition-all duration-300 hover:scale-105 hover:shadow-primary/25"
|
||||
as="link"
|
||||
href={downloadUrl}
|
||||
onClick={onDownloadClick}
|
||||
>
|
||||
<FontAwesomeIcon icon={isMacOs ? faApple : faWindows} />
|
||||
Download for {isMacOs ? "Mac" : "Windows"}
|
||||
</Button>
|
||||
)}
|
||||
<img src="/images/try-free.png" alt="Try Free" draggable={false} className="absolute -left-[45%] -top-5 h-40 pointer-events-none select-none hidden md:block" />
|
||||
<img src="/images/try-free.png" alt="Try Free" draggable={false} className="absolute -left-[58%] -top-2 h-40 pointer-events-none select-none hidden md:block" />
|
||||
</div>
|
||||
|
||||
{/* Glassy Video Mockup */}
|
||||
@@ -351,7 +369,20 @@ const Landing = () => {
|
||||
We're Pulling You <br /> <span className="text-white">Out of Prompting</span>
|
||||
</h1>
|
||||
<p className="text-lg md:text-xl text-white/60 leading-relaxed max-w-2xl mx-auto">
|
||||
Text-to-image is great, but artists <em>need control</em>. Achieve consistency and repeatability with our advanced toolset.
|
||||
Text prompting is neat, but artists <em>crave control</em>.
|
||||
</p>
|
||||
|
||||
<br />
|
||||
|
||||
<p className="text-lg md:text-xl text-white/60 leading-relaxed max-w-2xl mx-auto">
|
||||
Before you type "rotate arm thirty degrees", try ArtCraft and let our advanced
|
||||
toolset help you achieve consistency and repeatability.
|
||||
</p>
|
||||
|
||||
<br />
|
||||
|
||||
<p className="text-lg md:text-xl text-white/60 leading-relaxed max-w-2xl mx-auto">
|
||||
ArtCraft is the control that mere words cannot buy.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -478,62 +509,7 @@ const Landing = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative flex overflow-visible xl:items-center xl:pt-0 px-2 sm:px-4 md:px-0 md:-mt-36 min-[1980px]:-mt-64">
|
||||
{/* Gradient Orb for Section */}
|
||||
<div className="absolute left-[-200px] top-[-100px] w-[600px] h-[600px] rounded-full bg-gradient-to-br from-[#00AABA] via-blue-500 to-blue-700 opacity-15 blur-[80px] md:blur-[120px] z-0 pointer-events-none transform-gpu" />
|
||||
<div
|
||||
className="w-full flex flex-col items-center justify-center text-center pt-16 sm:pt-24 md:pt-32 px-2 sm:px-6 md:px-10"
|
||||
data-animate
|
||||
>
|
||||
<div className="relative">
|
||||
<h1 className="relative mb-4 sm:mb-6 md:mb-10 font-bold text-3xl sm:text-4xl md:text-[3rem] lg:text-[4rem] xl:text-[5rem] !leading-none text-shadow-lg">
|
||||
<span
|
||||
className="text-primary"
|
||||
style={{ textShadow: "0 0 15px var(--color-primary)" }}
|
||||
>
|
||||
"
|
||||
</span>
|
||||
Is this really AI?
|
||||
<span
|
||||
className="text-primary"
|
||||
style={{ textShadow: "0 0 15px var(--color-primary)" }}
|
||||
>
|
||||
"
|
||||
</span>
|
||||
</h1>
|
||||
<span className="absolute -right-6 sm:-right-5 -bottom-1 sm:bottom-4 md:-right-16 md:bottom-10 text-lg sm:text-xl md:text-3xl font-bold opacity-40 italic hover:opacity-80 transition-opacity duration-300">
|
||||
— You
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 mx-auto w-full max-w-[1200px] sm:px-4 md:px-16 lg:px-12 xl:px-32 pb-8 sm:pb-16 md:pb-24 lg:pb-32 overflow-visible px-4">
|
||||
{/* Gradient Orb for Section */}
|
||||
<div className="absolute right-[-250px] top-[-150px] w-[700px] h-[700px] rounded-full bg-gradient-to-br from-blue-700 via-[#00AABA] to-pink-500 opacity-[0.1] blur-[100px] md:blur-[140px] z-0 pointer-events-none transform-gpu" />
|
||||
<div
|
||||
className="items-center gap-8 sm:gap-12 md:gap-16 mb-8 sm:mb-12 p-2 sm:p-4 bg-white/10 backdrop-blur-md rounded-[24px] sm:rounded-[40px] shadow-xl"
|
||||
data-animate
|
||||
>
|
||||
<div className="rounded-[16px] sm:rounded-[24px] overflow-hidden bg-[#1C1C20] shadow-inner">
|
||||
<video
|
||||
muted
|
||||
autoPlay
|
||||
loop
|
||||
playsInline
|
||||
className="w-full aspect-[16/9] object-cover"
|
||||
>
|
||||
<source src="/videos/hero-video.mp4" type="video/mp4" />
|
||||
</video>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="flex justify-center"
|
||||
data-animate
|
||||
>
|
||||
<DiscordButton />
|
||||
</div>
|
||||
</div>
|
||||
<OwnershipComparison />
|
||||
|
||||
<div className="relative flex overflow-visible xl:items-center xl:pt-0 mb-4 md:mb-12 px-2 sm:px-4 md:px-0">
|
||||
{/* Gradient Orb for Section */}
|
||||
@@ -748,7 +724,6 @@ const Landing = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<OwnershipComparison />
|
||||
|
||||
<div className="relative z-10 mx-auto w-full max-w-screen py-10 sm:py-20 md:py-32 sm:px-8 lg:px-32 overflow-visible px-4">
|
||||
{/* Gradient Orb for Section */}
|
||||
@@ -816,13 +791,13 @@ const Landing = () => {
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-12">
|
||||
{/* Discord CTA Section */}
|
||||
<div
|
||||
className="flex flex-col items-center justify-center text-center p-8 sm:p-12 lg:p-16 rounded-[24px] sm:rounded-[40px] bg-gradient-to-br from-primary/30 to-primary/50 backdrop-blur-lg border-[3px] sm:border-[6px] border-primary/70 shadow-2xl shadow-primary/30"
|
||||
className="flex flex-col items-center justify-center text-center p-8 sm:p-12 lg:p-16 rounded-[24px] sm:rounded-[40px] bg-[#1a1b36] backdrop-blur-lg border-[3px] sm:border-[6px] border-[#3f415c] shadow-2xl"
|
||||
data-animate
|
||||
>
|
||||
<h2 className="relative mb-4 sm:mb-6 font-bold text-2xl sm:text-3xl lg:text-4xl max-w-lg !leading-tight">
|
||||
<h2 className="relative mb-4 sm:mb-6 font-bold text-2xl sm:text-3xl lg:text-4xl max-w-lg !leading-tight text-white">
|
||||
Join the Discord
|
||||
</h2>
|
||||
<p className="text-base sm:text-lg text-gray-200 mb-6 sm:mb-8 max-w-md leading-relaxed">
|
||||
<p className="text-base sm:text-lg text-[#aab3c9] mb-6 sm:mb-8 max-w-md leading-relaxed">
|
||||
Connect with other creators, share your work, get feedback, and stay updated.
|
||||
</p>
|
||||
<DiscordButton />
|
||||
@@ -850,9 +825,41 @@ const Landing = () => {
|
||||
Star on GitHub
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Download CTA Section - New */}
|
||||
<div
|
||||
className="flex flex-col items-center justify-center text-center p-8 sm:p-12 lg:col-span-2 rounded-[24px] sm:rounded-[40px] bg-gradient-to-br from-primary/30 to-primary/50 backdrop-blur-lg border-[3px] sm:border-[6px] border-primary/70 shadow-2xl shadow-primary/30 overflow-hidden relative"
|
||||
data-animate
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/20 via-transparent to-purple-500/20 opacity-30 pointer-events-none" />
|
||||
<div className="absolute top-0 left-0 w-full h-[1px] bg-gradient-to-r from-transparent via-white/20 to-transparent" />
|
||||
|
||||
<h2 className="relative mb-4 sm:mb-6 font-bold text-3xl sm:text-4xl lg:text-5xl max-w-2xl !leading-tight text-white z-10">
|
||||
Ready to Create?
|
||||
</h2>
|
||||
<p className="text-base sm:text-l md:text-xl text-white/90 mb-8 sm:mb-10 max-w-xl leading-relaxed z-10">
|
||||
Join thousands of artists and filmmakers using ArtCraft to bring their vision to life.
|
||||
</p>
|
||||
|
||||
<div className="relative z-10">
|
||||
<Button
|
||||
className="text-md px-10 py-5 text-xl font-semibold rounded-2xl shadow-xl gap-3 transition-all duration-300 hover:scale-105 hover:shadow-primary/40 bg-white text-black hover:bg-white"
|
||||
as="link"
|
||||
href={downloadUrl}
|
||||
onClick={onDownloadClick}
|
||||
>
|
||||
<FontAwesomeIcon icon={isMacOs ? faApple : faWindows} />
|
||||
Download for {isMacOs ? "Mac" : "Windows"}
|
||||
</Button>
|
||||
<div className="mt-4 text-sm text-white/80 font-medium">Free to use.</div>
|
||||
<div className="text-xs text-white/80 mt-1 font-medium">Unlimited Nano Banana Pro for a limited time!</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DownloadModal isOpen={showDownloadModal} onClose={() => setShowDownloadModal(false)} />
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
import LogIn from "./login";
|
||||
export default LogIn;
|
||||
@@ -0,0 +1,154 @@
|
||||
import { faGoogle } from "@fortawesome/free-brands-svg-icons";
|
||||
import { faEye, faEyeSlash, faSpinner } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { UsersApi } from "@storyteller/api";
|
||||
import Seo from "../../components/seo";
|
||||
|
||||
const Login = () => {
|
||||
const navigate = useNavigate();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleLogin = async () => {
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
|
||||
const api = new UsersApi();
|
||||
const response = await api.Login({
|
||||
usernameOrEmail: email,
|
||||
password: password,
|
||||
});
|
||||
|
||||
setIsLoading(false);
|
||||
|
||||
if (response.success) {
|
||||
window.dispatchEvent(new Event("auth-change"));
|
||||
navigate("/");
|
||||
} else {
|
||||
setError(response.errorMessage || "Invalid credentials");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen bg-[#101014] text-white bg-dots flex flex-col">
|
||||
<Seo
|
||||
title="Login - ArtCraft"
|
||||
description="Login to your ArtCraft account."
|
||||
/>
|
||||
<div className="dotted-pattern absolute inset-0 z-[0] opacity-30" />
|
||||
|
||||
<main className="relative z-10 flex-1 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md bg-[#1C1C20] border border-white/10 rounded-3xl p-8 shadow-2xl">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-2xl font-bold mb-2">Welcome Back</h1>
|
||||
<p className="text-white/60 text-sm">Log in to your account</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Button
|
||||
className="w-full bg-white text-black hover:bg-gray-100 border-none justify-center gap-3 font-medium h-12"
|
||||
onClick={() => {}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faGoogle} className="text-lg" />
|
||||
Continue with Google
|
||||
</Button>
|
||||
|
||||
<div className="relative flex items-center justify-center py-2">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-white/10"></div>
|
||||
</div>
|
||||
<span className="relative bg-[#1C1C20] px-4 text-xs text-white/40 uppercase tracking-widest">
|
||||
or
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/20 text-red-500 px-4 py-3 rounded-xl text-sm text-center">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
|
||||
Email or Username
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com or username"
|
||||
className="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center ml-1">
|
||||
<label className="text-xs font-bold text-white/60 uppercase tracking-wide">
|
||||
Password
|
||||
</label>
|
||||
<Link
|
||||
to="/forgot-password"
|
||||
className="text-xs text-primary hover:text-primary-400 transition-colors"
|
||||
>
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Min. 8 characters"
|
||||
className="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-white/30 hover:text-white/60 transition-colors"
|
||||
>
|
||||
<FontAwesomeIcon icon={showPassword ? faEyeSlash : faEye} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-12 mt-2"
|
||||
onClick={handleLogin}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<FontAwesomeIcon icon={faSpinner} spin />
|
||||
) : (
|
||||
"Log in"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 text-center text-sm text-white/60">
|
||||
Don't have an account?{" "}
|
||||
<Link
|
||||
to="/signup"
|
||||
className="text-primary hover:text-primary-400 font-semibold transition-colors"
|
||||
>
|
||||
Sign up
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div className="relative z-10 py-6 text-center text-white/20 text-xs">
|
||||
© {new Date().getFullYear()} ArtCraft. All rights reserved.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
@@ -30,14 +30,15 @@ import {
|
||||
getProviderDisplayName,
|
||||
getProviderIconByName,
|
||||
} from "@storyteller/model-list";
|
||||
import Seo from "../../components/seo";
|
||||
|
||||
const VIDEO_EXTENSIONS = ['.mp4', '.webm', '.mov', '.avi', '.mkv', '.m4v'];
|
||||
const VIDEO_EXTENSIONS = [".mp4", ".webm", ".mov", ".avi", ".mkv", ".m4v"];
|
||||
const COPY_FEEDBACK_DURATION = 1500;
|
||||
const SHARE_URL_BASE = 'https://getartcraft.com/media/';
|
||||
const SHARE_URL_BASE = "https://getartcraft.com/media/";
|
||||
|
||||
const isVideoUrl = (url: string): boolean => {
|
||||
const urlLower = url.toLowerCase();
|
||||
return VIDEO_EXTENSIONS.some(ext => urlLower.includes(ext));
|
||||
return VIDEO_EXTENSIONS.some((ext) => urlLower.includes(ext));
|
||||
};
|
||||
|
||||
interface ContextImage {
|
||||
@@ -78,7 +79,7 @@ const EMPTY_PROMPT_DATA: PromptData = {
|
||||
const createPromptData = (
|
||||
data: any,
|
||||
hasToken: boolean,
|
||||
loading: boolean = false
|
||||
loading: boolean = false,
|
||||
): PromptData => ({
|
||||
text: data?.maybe_positive_prompt || null,
|
||||
loading,
|
||||
@@ -114,7 +115,13 @@ const useCopyFeedback = () => {
|
||||
return { copied, triggerCopy };
|
||||
};
|
||||
|
||||
const InfoRow = ({ label, value }: { label: string; value: React.ReactNode }) => (
|
||||
const InfoRow = ({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
value: React.ReactNode;
|
||||
}) => (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-white/5 last:border-0">
|
||||
<span className="text-sm text-white/60 font-medium">{label}</span>
|
||||
<span className="text-sm text-white font-medium flex items-center gap-2">
|
||||
@@ -155,7 +162,7 @@ export default function MediaPage() {
|
||||
if (mediaResponse.success && mediaResponse.data) {
|
||||
const file = mediaResponse.data;
|
||||
const url = file.media_links?.cdn_url || null;
|
||||
|
||||
|
||||
setMedia((prev) => {
|
||||
const isSameUrl = prev.url === url;
|
||||
return {
|
||||
@@ -171,8 +178,8 @@ export default function MediaPage() {
|
||||
});
|
||||
|
||||
if (file.maybe_prompt_token) {
|
||||
setPromptData(prev => ({ ...prev, hasToken: true, loading: true }));
|
||||
|
||||
setPromptData((prev) => ({ ...prev, hasToken: true, loading: true }));
|
||||
|
||||
try {
|
||||
const promptsApi = new PromptsApi();
|
||||
const promptResponse = await promptsApi.GetPromptsByToken({
|
||||
@@ -182,17 +189,31 @@ export default function MediaPage() {
|
||||
const data = promptResponse.success ? promptResponse.data : null;
|
||||
setPromptData(createPromptData(data, true, false));
|
||||
} catch {
|
||||
setPromptData(prev => ({ ...prev, loading: false }));
|
||||
setPromptData((prev) => ({ ...prev, loading: false }));
|
||||
}
|
||||
} else {
|
||||
setPromptData(EMPTY_PROMPT_DATA);
|
||||
}
|
||||
} else {
|
||||
setMedia({ url: null, token: null, createdAt: null, isVideo: false, isLoaded: false, creator: null });
|
||||
setMedia({
|
||||
url: null,
|
||||
token: null,
|
||||
createdAt: null,
|
||||
isVideo: false,
|
||||
isLoaded: false,
|
||||
creator: null,
|
||||
});
|
||||
toast.error("Media not found");
|
||||
}
|
||||
} catch {
|
||||
setMedia({ url: null, token: null, createdAt: null, isVideo: false, isLoaded: false, creator: null });
|
||||
setMedia({
|
||||
url: null,
|
||||
token: null,
|
||||
createdAt: null,
|
||||
isVideo: false,
|
||||
isLoaded: false,
|
||||
creator: null,
|
||||
});
|
||||
toast.error("Failed to load media");
|
||||
} finally {
|
||||
setMediaRecordLoading(false);
|
||||
@@ -205,8 +226,6 @@ export default function MediaPage() {
|
||||
}
|
||||
}, [mediaIdParam, loadMedia]);
|
||||
|
||||
|
||||
|
||||
const handleCopyPrompt = useCallback(async () => {
|
||||
if (!promptData.text) return;
|
||||
try {
|
||||
@@ -231,10 +250,12 @@ export default function MediaPage() {
|
||||
|
||||
const shareButtonIcon = shareCopy.copied ? faCheck : faLink;
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen w-full p-4 pt-16 bg-dots flex items-start lg:items-center justify-center">
|
||||
<Seo
|
||||
title="Shared Media - ArtCraft"
|
||||
description="View shared media from ArtCraft."
|
||||
/>
|
||||
<div className="mx-auto max-w-[1920px] w-full h-auto lg:h-[calc(100vh-100px)] min-h-[500px]">
|
||||
<div className="flex flex-col lg:flex-row h-full w-full overflow-hidden rounded-xl border border-white/[2%]">
|
||||
{/* Media Preview Area */}
|
||||
@@ -254,14 +275,14 @@ export default function MediaPage() {
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
onError={() => console.error('Video failed to load')}
|
||||
onError={() => console.error("Video failed to load")}
|
||||
onLoadedData={(e) => {
|
||||
const el = e.currentTarget;
|
||||
setMedia(prev => ({
|
||||
...prev,
|
||||
setMedia((prev) => ({
|
||||
...prev,
|
||||
isLoaded: true,
|
||||
width: el.videoWidth,
|
||||
height: el.videoHeight
|
||||
height: el.videoHeight,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
@@ -273,18 +294,22 @@ export default function MediaPage() {
|
||||
className="h-full w-full object-contain transition-opacity duration-300"
|
||||
style={{ opacity: media.isLoaded ? 1 : 0 }}
|
||||
onError={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).src = PLACEHOLDER_IMAGES.DEFAULT;
|
||||
(e.currentTarget as HTMLImageElement).style.opacity = "0.3";
|
||||
(e.currentTarget as HTMLImageElement).dataset.brokenurl = media.url || "";
|
||||
setMedia(prev => ({ ...prev, isLoaded: true }));
|
||||
(e.currentTarget as HTMLImageElement).src =
|
||||
PLACEHOLDER_IMAGES.DEFAULT;
|
||||
(e.currentTarget as HTMLImageElement).style.opacity =
|
||||
"0.3";
|
||||
(
|
||||
e.currentTarget as HTMLImageElement
|
||||
).dataset.brokenurl = media.url || "";
|
||||
setMedia((prev) => ({ ...prev, isLoaded: true }));
|
||||
}}
|
||||
onLoad={(e) => {
|
||||
const el = e.currentTarget;
|
||||
setMedia(prev => ({
|
||||
...prev,
|
||||
setMedia((prev) => ({
|
||||
...prev,
|
||||
isLoaded: true,
|
||||
width: el.naturalWidth,
|
||||
height: el.naturalHeight
|
||||
height: el.naturalHeight,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
@@ -327,7 +352,10 @@ export default function MediaPage() {
|
||||
<div className="h-4 w-32 bg-white/10 rounded" />
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="aspect-square w-full bg-white/10 rounded-lg" />
|
||||
<div
|
||||
key={i}
|
||||
className="aspect-square w-full bg-white/10 rounded-lg"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -341,8 +369,12 @@ export default function MediaPage() {
|
||||
size={36}
|
||||
username={media.creator.username}
|
||||
email_hash={media.creator.email_gravatar_hash}
|
||||
avatarIndex={media.creator.core_info.default_avatar.image_index}
|
||||
backgroundIndex={media.creator.core_info.default_avatar.color_index}
|
||||
avatarIndex={
|
||||
media.creator.core_info.default_avatar.image_index
|
||||
}
|
||||
backgroundIndex={
|
||||
media.creator.core_info.default_avatar.color_index
|
||||
}
|
||||
className="rounded-xl border-white/10"
|
||||
/>
|
||||
) : (
|
||||
@@ -351,8 +383,12 @@ export default function MediaPage() {
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-white text-sm font-semibold leading-none">{media.creator.display_name}</span>
|
||||
<span className="text-white/60 text-xs font-medium">Author</span>
|
||||
<span className="text-white text-sm font-semibold leading-none">
|
||||
{media.creator.display_name}
|
||||
</span>
|
||||
<span className="text-white/60 text-xs font-medium">
|
||||
Author
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -381,11 +417,13 @@ export default function MediaPage() {
|
||||
onClick={handleCopyPrompt}
|
||||
className="flex items-center gap-1.5 text-xs text-white/60 hover:text-white transition-colors"
|
||||
>
|
||||
<FontAwesomeIcon icon={promptCopy.copied ? faCheck : faCopy} />
|
||||
<FontAwesomeIcon
|
||||
icon={promptCopy.copied ? faCheck : faCopy}
|
||||
/>
|
||||
<span>{promptCopy.copied ? "Copied" : "Copy"}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="relative text-sm text-white/90 break-words px-4 py-3 rounded-xl bg-black/20 leading-relaxed border border-white/5">
|
||||
{promptData.loading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -404,52 +442,59 @@ export default function MediaPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{promptData.contextImages && promptData.contextImages.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs font-medium text-white/60">
|
||||
<FontAwesomeIcon icon={faImage} />
|
||||
<span>Reference Images</span>
|
||||
{promptData.contextImages &&
|
||||
promptData.contextImages.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs font-medium text-white/60">
|
||||
<FontAwesomeIcon icon={faImage} />
|
||||
<span>Reference Images</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{promptData.contextImages.map(
|
||||
(contextImage, index) => {
|
||||
const { thumbnail } =
|
||||
getContextImageThumbnail(contextImage, {
|
||||
size: THUMBNAIL_SIZES.SMALL,
|
||||
});
|
||||
return (
|
||||
<div
|
||||
key={contextImage.media_token}
|
||||
className="glass relative aspect-square overflow-hidden rounded-lg border border-white/10 hover:border-white/40 transition-colors"
|
||||
>
|
||||
<img
|
||||
src={thumbnail}
|
||||
alt={`Reference image ${index + 1}`}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{promptData.contextImages.map((contextImage, index) => {
|
||||
const { thumbnail } = getContextImageThumbnail(
|
||||
contextImage,
|
||||
{ size: THUMBNAIL_SIZES.SMALL }
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={contextImage.media_token}
|
||||
className="glass relative aspect-square overflow-hidden rounded-lg border border-white/10 hover:border-white/40 transition-colors"
|
||||
>
|
||||
<img
|
||||
src={thumbnail}
|
||||
alt={`Reference image ${index + 1}`}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{(promptData.provider || promptData.modelType || media.createdAt) && (
|
||||
{(promptData.provider ||
|
||||
promptData.modelType ||
|
||||
media.createdAt) && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs font-medium text-white/60">
|
||||
<FontAwesomeIcon icon={faCircleInfo} />
|
||||
<span>Information</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex flex-col rounded-xl bg-black/20 border border-white/5 overflow-hidden">
|
||||
|
||||
|
||||
{promptData.modelType && (
|
||||
<InfoRow
|
||||
label="Model"
|
||||
value={
|
||||
<>
|
||||
{getModelCreatorIcon(promptData.modelType)}
|
||||
<span>{getModelDisplayName(promptData.modelType)}</span>
|
||||
<span>
|
||||
{getModelDisplayName(
|
||||
promptData.modelType,
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
@@ -460,8 +505,15 @@ export default function MediaPage() {
|
||||
label="Provider"
|
||||
value={
|
||||
<>
|
||||
{getProviderIconByName(promptData.provider, "h-4 w-4 invert")}
|
||||
<span>{getProviderDisplayName(promptData.provider)}</span>
|
||||
{getProviderIconByName(
|
||||
promptData.provider,
|
||||
"h-4 w-4 invert",
|
||||
)}
|
||||
<span>
|
||||
{getProviderDisplayName(
|
||||
promptData.provider,
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
@@ -473,11 +525,13 @@ export default function MediaPage() {
|
||||
value={`${media.width} × ${media.height}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
{media.createdAt && (
|
||||
<InfoRow
|
||||
label="Created"
|
||||
value={dayjs(media.createdAt).format("MMMM D, YYYY")}
|
||||
value={dayjs(media.createdAt).format(
|
||||
"MMMM D, YYYY",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -504,11 +558,15 @@ export default function MediaPage() {
|
||||
"w-full inline-flex items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors border border-ui-panel-border",
|
||||
media.url
|
||||
? "bg-ui-controls/40 hover:bg-ui-controls/60 text-white"
|
||||
: "bg-ui-controls/20 text-white/60 cursor-not-allowed pointer-events-none"
|
||||
: "bg-ui-controls/20 text-white/60 cursor-not-allowed pointer-events-none",
|
||||
)}
|
||||
href={media.url ? addCorsParam(media.url) || media.url : undefined}
|
||||
href={
|
||||
media.url ? addCorsParam(media.url) || media.url : undefined
|
||||
}
|
||||
download={
|
||||
media.url ? `artcraft-${media.token || (media.isVideo ? "video" : "image")}` : undefined
|
||||
media.url
|
||||
? `artcraft-${media.token || (media.isVideo ? "video" : "image")}`
|
||||
: undefined
|
||||
}
|
||||
aria-disabled={!media.url}
|
||||
target="_blank"
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { NewsIndex as LibNewsIndex } from "@storyteller/markdown-content";
|
||||
import Seo from "../../components/seo";
|
||||
|
||||
const NewsIndex = ({ basePath }: { basePath: string }) => {
|
||||
return (
|
||||
<>
|
||||
<Seo
|
||||
title="News & Updates - ArtCraft"
|
||||
description="Latest updates, features, and announcements from the ArtCraft team."
|
||||
/>
|
||||
<LibNewsIndex basePath={basePath} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewsIndex;
|
||||
@@ -0,0 +1,25 @@
|
||||
import {
|
||||
NewsPost as LibNewsPost,
|
||||
getNewsPostBySlug,
|
||||
} from "@storyteller/markdown-content";
|
||||
import Seo from "../../components/seo";
|
||||
import { useParams } from "react-router-dom";
|
||||
|
||||
const NewsPost = ({ basePath }: { basePath: string }) => {
|
||||
const { slug } = useParams();
|
||||
const post = slug ? getNewsPostBySlug(slug) : null;
|
||||
|
||||
const title = post
|
||||
? `${post.title} - ArtCraft`
|
||||
: "Article Not Found - ArtCraft";
|
||||
const desc = post ? post.description : "";
|
||||
|
||||
return (
|
||||
<>
|
||||
<Seo title={title} description={desc} />
|
||||
<LibNewsPost basePath={basePath} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewsPost;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "./press-kit";
|
||||
@@ -0,0 +1,151 @@
|
||||
// ============================================================================
|
||||
// PRESS KIT ASSET DATA
|
||||
// ============================================================================
|
||||
//
|
||||
// To add a new asset, copy one of the existing objects and modify:
|
||||
// - type: "video" | "image" | "embed" | "link"
|
||||
// - title: Display title for the asset
|
||||
// - description: Optional short description
|
||||
// - thumbnail: URL to thumbnail image (can be YouTube thumbnail or custom)
|
||||
// - embedUrl: For YouTube embeds (use embed format: youtube.com/embed/VIDEO_ID)
|
||||
// - videoUrl: Direct video URL for type="video" (uses downloadUrl if not provided)
|
||||
// - downloadUrl: Direct link to the downloadable file (e.g., Cloudflare R2 URL)
|
||||
// - downloadLabel: Optional custom label for download button (default: "Download")
|
||||
// - fileSize: Optional file size display (e.g., "1.2 GB")
|
||||
// - containThumbnail: If true, thumbnail uses object-contain with padding (good for logos)
|
||||
//
|
||||
// ============================================================================
|
||||
|
||||
export interface PressKitAsset {
|
||||
type: "video" | "image" | "embed" | "link";
|
||||
title: string;
|
||||
description?: string;
|
||||
thumbnail?: string;
|
||||
embedUrl?: string;
|
||||
/** Direct video URL for type="video" - uses downloadUrl if not provided */
|
||||
videoUrl?: string;
|
||||
downloadUrl: string;
|
||||
downloadLabel?: string;
|
||||
fileSize?: string;
|
||||
/** If true, thumbnail uses object-contain with padding (good for logos) */
|
||||
containThumbnail?: boolean;
|
||||
}
|
||||
|
||||
export interface PressKitCategory {
|
||||
name: string;
|
||||
description?: string;
|
||||
assets: PressKitAsset[];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// EDIT THIS DATA TO ADD PRESS KIT ASSETS
|
||||
// ============================================================================
|
||||
|
||||
export const PRESS_KIT_CATEGORIES: PressKitCategory[] = [
|
||||
{
|
||||
name: "Promotional Videos",
|
||||
description: "High-quality promotional videos for press coverage",
|
||||
assets: [
|
||||
// ArtCraft Commercial / Trailer (direct video, same as homepage)
|
||||
{
|
||||
type: "video",
|
||||
title: "ArtCraft Commercial",
|
||||
description: "Official ArtCraft commercial showcasing the app",
|
||||
thumbnail: "/images/video-thumbnails/artcraft-commercial.png",
|
||||
downloadUrl:
|
||||
"https://pub-f7441936e5804042a1ea2bdc92e4dc71.r2.dev/artcraft_website_v2.mp4",
|
||||
fileSize: "125 MB",
|
||||
},
|
||||
// Sci-Fi Horror Posing Demo
|
||||
{
|
||||
type: "video",
|
||||
title: "ArtCraft 3D Sci-Fi Animation",
|
||||
description: "3D Pre-viz Workflow demonstrates precision control",
|
||||
thumbnail: "/images/video-thumbnails/artcraft-sci-fi.png",
|
||||
downloadUrl:
|
||||
"https://pub-f7441936e5804042a1ea2bdc92e4dc71.r2.dev/ArtCraft_SciFi_Horror.mp4",
|
||||
fileSize: "14.5 MB",
|
||||
},
|
||||
// WorldLabs Pirate Demo
|
||||
{
|
||||
type: "video",
|
||||
title: "ArtCraft x WorldLabs Pirate Demo",
|
||||
description: "ArtCraft with Gaussian Splats can be used to quickly create sets",
|
||||
thumbnail: "/images/video-thumbnails/artcraft-pirate.png",
|
||||
downloadUrl:
|
||||
"https://pub-f7441936e5804042a1ea2bdc92e4dc71.r2.dev/ArtCraft_World_Pirate.mp4",
|
||||
fileSize: "88.5 MB",
|
||||
},
|
||||
// Pose Ad
|
||||
{
|
||||
type: "video",
|
||||
title: "ArtCraft Posing and Blocking",
|
||||
description: "More examples of detailed posing and blocking in 3D",
|
||||
thumbnail: "/images/video-thumbnails/artcraft-knight-pose.png",
|
||||
downloadUrl:
|
||||
"https://pub-f7441936e5804042a1ea2bdc92e4dc71.r2.dev/ArtCraft_Pose_Ad.mp4",
|
||||
fileSize: "60.6 MB",
|
||||
},
|
||||
// WorldLabs Ad
|
||||
{
|
||||
type: "video",
|
||||
title: "ArtCraft x WorldLabs Ad",
|
||||
description: "Several shots crafted using WorldLabs' advanced Marble model",
|
||||
thumbnail: "/images/video-thumbnails/artcraft-worldlabs-ad.png",
|
||||
downloadUrl:
|
||||
"https://pub-f7441936e5804042a1ea2bdc92e4dc71.r2.dev/ArtCraft_WorldLabs_Ad.mp4",
|
||||
fileSize: "23.7 MB",
|
||||
},
|
||||
// Grinch: The Anime
|
||||
{
|
||||
type: "embed",
|
||||
title: "Grinch: The Anime",
|
||||
description: "Made using ArtCraft",
|
||||
thumbnail: "https://img.youtube.com/vi/oqoCWdOwr2U/maxresdefault.jpg",
|
||||
embedUrl: "https://www.youtube.com/embed/oqoCWdOwr2U",
|
||||
downloadUrl: "https://pub-f7441936e5804042a1ea2bdc92e4dc71.r2.dev/ArtCraft_Grinch_Anime.mp4",
|
||||
fileSize: "56.3 MB",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Logos & Branding",
|
||||
description: "Official ArtCraft logos and branding assets",
|
||||
assets: [
|
||||
{
|
||||
type: "image",
|
||||
title: "ArtCraft Logo (PNG)",
|
||||
thumbnail: "/images/artcraft-logo.png",
|
||||
downloadUrl: "/images/artcraft-logo.png",
|
||||
containThumbnail: true,
|
||||
},
|
||||
// Add more logo variants here:
|
||||
// {
|
||||
// type: "link",
|
||||
// title: "Full Logo Pack (ZIP)",
|
||||
// description: "All logos in PNG, SVG, and EPS formats",
|
||||
// downloadUrl: "https://your-r2-bucket.r2.dev/artcraft-logo-pack.zip",
|
||||
// fileSize: "12 MB",
|
||||
// },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Screenshots & Media",
|
||||
description: "High-resolution screenshots and promotional images",
|
||||
assets: [
|
||||
// Add screenshots here:
|
||||
// {
|
||||
// type: "image",
|
||||
// title: "Editor Interface",
|
||||
// thumbnail: "/images/screenshot-editor.jpg",
|
||||
// downloadUrl: "https://your-r2-bucket.r2.dev/screenshot-editor-hires.png",
|
||||
// },
|
||||
],
|
||||
},
|
||||
// Add more categories as needed:
|
||||
// {
|
||||
// name: "Tutorial Videos",
|
||||
// description: "Step-by-step tutorials",
|
||||
// assets: [],
|
||||
// },
|
||||
];
|
||||
@@ -0,0 +1,311 @@
|
||||
import { useState } from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
faArrowDownToLine,
|
||||
faPlay,
|
||||
faImage,
|
||||
faVideo,
|
||||
faFileZipper,
|
||||
faXmark,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import Footer from "../../components/footer";
|
||||
import Seo from "../../components/seo";
|
||||
import { PRESS_KIT_CATEGORIES, type PressKitAsset } from "./press-kit-data";
|
||||
|
||||
// ============================================================================
|
||||
// ASSET CARD COMPONENT
|
||||
// ============================================================================
|
||||
|
||||
const AssetCard = ({
|
||||
asset,
|
||||
onOpenMedia,
|
||||
}: {
|
||||
asset: PressKitAsset;
|
||||
onOpenMedia: (asset: PressKitAsset) => void;
|
||||
}) => {
|
||||
const getTypeIcon = () => {
|
||||
switch (asset.type) {
|
||||
case "video":
|
||||
case "embed":
|
||||
return faVideo;
|
||||
case "image":
|
||||
return faImage;
|
||||
case "link":
|
||||
return faFileZipper;
|
||||
default:
|
||||
return faImage;
|
||||
}
|
||||
};
|
||||
|
||||
const handleThumbnailClick = () => {
|
||||
if (asset.type === "embed" && asset.embedUrl) {
|
||||
onOpenMedia(asset);
|
||||
} else if (asset.type === "video") {
|
||||
onOpenMedia(asset);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="group relative flex flex-col bg-[#28282C] rounded-2xl overflow-hidden border border-white/5 transition-all duration-300 hover:border-white/10 hover:shadow-xl hover:shadow-primary/5">
|
||||
{/* Thumbnail */}
|
||||
<div
|
||||
className={`relative aspect-video bg-black/40 overflow-hidden ${
|
||||
asset.type === "embed" || asset.type === "video"
|
||||
? "cursor-pointer"
|
||||
: ""
|
||||
}`}
|
||||
onClick={handleThumbnailClick}
|
||||
>
|
||||
{asset.thumbnail ? (
|
||||
<img
|
||||
src={asset.thumbnail}
|
||||
alt={asset.title}
|
||||
className={`w-full h-full transition-transform duration-300 group-hover:scale-105 ${
|
||||
asset.containThumbnail
|
||||
? "object-contain p-6 bg-[#1a1a1e]"
|
||||
: "object-cover"
|
||||
}`}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-primary/20 to-primary/5">
|
||||
<FontAwesomeIcon
|
||||
icon={getTypeIcon()}
|
||||
className="text-4xl text-white/30"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Play button overlay for videos and embeds */}
|
||||
{(asset.type === "embed" || asset.type === "video") && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/20 transition-opacity duration-300 group-hover:bg-black/40">
|
||||
<div className="w-16 h-16 rounded-full bg-white/10 backdrop-blur-md flex items-center justify-center border border-white/20 transition-transform duration-300 group-hover:scale-110">
|
||||
<FontAwesomeIcon
|
||||
icon={faPlay}
|
||||
className="text-white text-xl ml-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Type badge - show "Video" for both video and embed types */}
|
||||
<div className="absolute top-3 left-3 px-3 py-1 rounded-full bg-black/60 backdrop-blur-sm text-xs font-medium text-white/80 flex items-center gap-1.5">
|
||||
<FontAwesomeIcon icon={getTypeIcon()} className="text-[10px]" />
|
||||
<span className="capitalize">
|
||||
{asset.type === "embed" ? "Video" : asset.type}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex flex-col flex-1 p-5">
|
||||
<h3 className="text-lg font-semibold text-white mb-1 line-clamp-2">
|
||||
{asset.title}
|
||||
</h3>
|
||||
{asset.description && (
|
||||
<p className="text-sm text-white/60 mb-4 line-clamp-2">
|
||||
{asset.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Spacer to push button to bottom */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Download button */}
|
||||
{asset.downloadUrl ? (
|
||||
<a
|
||||
href={asset.downloadUrl}
|
||||
download
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center justify-center gap-2 w-full mt-3 px-4 py-2.5 rounded-xl bg-white hover:bg-white/90 text-black font-medium text-sm transition-all duration-200"
|
||||
>
|
||||
<FontAwesomeIcon icon={faArrowDownToLine} />
|
||||
<span>{asset.downloadLabel || "Download"}</span>
|
||||
{asset.fileSize && (
|
||||
<span className="text-black/50 ml-1">({asset.fileSize})</span>
|
||||
)}
|
||||
</a>
|
||||
) : (
|
||||
<div className="inline-flex items-center justify-center gap-2 w-full mt-3 px-4 py-2.5 rounded-xl bg-white/10 text-white/40 font-medium text-sm cursor-not-allowed">
|
||||
<FontAwesomeIcon icon={faArrowDownToLine} />
|
||||
<span>Download Coming Soon</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// VIDEO MODAL COMPONENT
|
||||
// ============================================================================
|
||||
|
||||
const VideoModal = ({
|
||||
asset,
|
||||
onClose,
|
||||
}: {
|
||||
asset: PressKitAsset;
|
||||
onClose: () => void;
|
||||
}) => {
|
||||
const isEmbed = asset.type === "embed" && asset.embedUrl;
|
||||
const videoSrc = asset.videoUrl || asset.downloadUrl;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="relative w-full max-w-5xl aspect-video rounded-2xl overflow-hidden bg-black shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{isEmbed ? (
|
||||
<iframe
|
||||
src={`${asset.embedUrl}?autoplay=1`}
|
||||
title={asset.title}
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowFullScreen
|
||||
className="w-full h-full"
|
||||
/>
|
||||
) : (
|
||||
<video
|
||||
src={videoSrc}
|
||||
className="w-full h-full"
|
||||
controls
|
||||
autoPlay
|
||||
playsInline
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 w-10 h-10 rounded-full bg-black/60 hover:bg-black/80 text-white flex items-center justify-center transition-colors"
|
||||
aria-label="Close video"
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// PRESS KIT PAGE
|
||||
// ============================================================================
|
||||
|
||||
export default function PressKitPage() {
|
||||
const [activeMedia, setActiveMedia] = useState<PressKitAsset | null>(null);
|
||||
|
||||
const handleOpenMedia = (asset: PressKitAsset) => {
|
||||
setActiveMedia(asset);
|
||||
};
|
||||
|
||||
const handleCloseMedia = () => {
|
||||
setActiveMedia(null);
|
||||
};
|
||||
|
||||
// Filter out empty categories
|
||||
const categoriesWithAssets = PRESS_KIT_CATEGORIES.filter(
|
||||
(cat) => cat.assets.length > 0,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen bg-[#101014] text-white overflow-x-hidden bg-dots">
|
||||
<Seo
|
||||
title="Press Kit | ArtCraft"
|
||||
description="Download ArtCraft press assets including logos, promotional videos, screenshots, and branding materials for media coverage."
|
||||
/>
|
||||
|
||||
{/* Hero Section */}
|
||||
<main className="relative pt-24 sm:pt-32 pb-12 sm:pb-16 px-4 sm:px-6 lg:px-8">
|
||||
{/* Glowing Gradient Orb Background */}
|
||||
<div className="absolute inset-0 flex items-start justify-center pointer-events-none z-0 overflow-hidden">
|
||||
<div className="w-[800px] h-[600px] rounded-full bg-gradient-to-br from-blue-700 via-blue-500 to-[#00AABA] opacity-20 blur-[100px] md:blur-[150px] transform-gpu -translate-y-1/2" />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-12 sm:mb-16">
|
||||
<h1 className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-bold mb-6 leading-tight">
|
||||
Press Kit
|
||||
</h1>
|
||||
<p className="text-lg sm:text-xl text-white/70 max-w-2xl mx-auto leading-relaxed">
|
||||
Everything you need for press coverage, reviews, and content
|
||||
creation. Download high-quality assets and promotional materials
|
||||
about the world's only open source precision AI tool for artists.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Categories */}
|
||||
{categoriesWithAssets.length > 0 ? (
|
||||
<div className="space-y-16">
|
||||
{categoriesWithAssets.map((category) => (
|
||||
<section key={category.name}>
|
||||
<div className="mb-6">
|
||||
<h2 className="text-2xl sm:text-3xl font-bold text-white mb-2">
|
||||
{category.name}
|
||||
</h2>
|
||||
{category.description && (
|
||||
<p className="text-white/60">{category.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{category.assets.map((asset, index) => (
|
||||
<AssetCard
|
||||
key={`${category.name}-${index}`}
|
||||
asset={asset}
|
||||
onOpenMedia={handleOpenMedia}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-20 px-4">
|
||||
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-white/5 flex items-center justify-center">
|
||||
<FontAwesomeIcon
|
||||
icon={faFileZipper}
|
||||
className="text-3xl text-white/30"
|
||||
/>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-white mb-3">
|
||||
Press Kit Coming Soon
|
||||
</h2>
|
||||
<p className="text-white/60 max-w-md mx-auto">
|
||||
We're preparing our press assets. Check back soon for logos,
|
||||
videos, and promotional materials.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Contact Section */}
|
||||
<div className="mt-20 text-center py-12 px-6 rounded-3xl bg-white/5 border border-white/10 backdrop-blur-sm">
|
||||
<h2 className="text-2xl sm:text-3xl font-bold text-white mb-4">
|
||||
Need Something Specific?
|
||||
</h2>
|
||||
<p className="text-white/70 mb-6 max-w-lg mx-auto">
|
||||
For specific press inquiries, interview requests, or custom
|
||||
assets, reach out to us on Discord. Or text Brandon at <a href="tel:678-744-6080">(678) 744-6080</a>.
|
||||
</p>
|
||||
<a
|
||||
href="https://discord.gg/artcraft"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 rounded-xl bg-primary hover:bg-primary/90 text-white font-semibold transition-colors"
|
||||
>
|
||||
Contact Us on Discord
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
|
||||
{/* Video Modal */}
|
||||
{activeMedia && (
|
||||
<VideoModal asset={activeMedia} onClose={handleCloseMedia} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
import Pricing from "./pricing";
|
||||
export default Pricing;
|
||||
@@ -0,0 +1,33 @@
|
||||
import Footer from "../../components/footer";
|
||||
import Seo from "../../components/seo";
|
||||
import { PricingTable } from "../../components/pricing-table";
|
||||
|
||||
const Pricing = () => {
|
||||
return (
|
||||
<div className="relative min-h-screen bg-[#101014] text-white bg-dots">
|
||||
<Seo
|
||||
title="Pricing - ArtCraft"
|
||||
description="Simple, transparent pricing for ArtCraft. Start for free and scale as you grow."
|
||||
/>
|
||||
<div className="dotted-pattern absolute inset-0 z-[0] opacity-50" />
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-0">
|
||||
<div className="w-[900px] h-[900px] rounded-full bg-gradient-to-br from-blue-700 via-blue-500 to-[#00AABA] opacity-20 blur-[120px]"></div>
|
||||
</div>
|
||||
|
||||
<main className="relative z-10 pt-32 pb-20 px-4 sm:px-6 lg:px-8">
|
||||
<PricingTable
|
||||
title="Invest in ArtCraft"
|
||||
subtitle="Support open-source development. Your subscription makes ArtCraft better and gets you tons of generations."
|
||||
/>
|
||||
</main>
|
||||
|
||||
<p className="relative z-10 pt-32 pb-20 px-4 sm:px-6 lg:px-8">
|
||||
Note: Pricing table is wrong, but we'll fix this shortly.
|
||||
</p>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Pricing;
|
||||
@@ -0,0 +1,2 @@
|
||||
import SignUp from "./signup";
|
||||
export default SignUp;
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { SignupForm } from "../../components/auth";
|
||||
import Seo from "../../components/seo";
|
||||
|
||||
const Signup = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen bg-[#101014] text-white bg-dots flex flex-col">
|
||||
<Seo
|
||||
title="Sign Up - ArtCraft"
|
||||
description="Create your ArtCraft account."
|
||||
/>
|
||||
<div className="dotted-pattern absolute inset-0 z-[0] opacity-30" />
|
||||
|
||||
<main className="relative z-10 flex-1 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md bg-[#1C1C20] border border-white/10 rounded-3xl p-8 shadow-2xl">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-2xl font-bold mb-2">Create an Account</h1>
|
||||
<p className="text-white/60 text-sm">Join thousands of creators</p>
|
||||
</div>
|
||||
|
||||
<SignupForm
|
||||
onSuccess={() => navigate("/welcome")}
|
||||
signupSource="artcraft"
|
||||
/>
|
||||
|
||||
<div className="mt-8 text-center text-sm text-white/60">
|
||||
Already have an account?{" "}
|
||||
<Link
|
||||
to="/login"
|
||||
className="text-primary hover:text-primary-400 font-semibold transition-colors"
|
||||
>
|
||||
Log in
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div className="relative z-10 py-6 text-center text-white/20 text-xs">
|
||||
© {new Date().getFullYear()} ArtCraft. All rights reserved.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Signup;
|
||||
@@ -0,0 +1,2 @@
|
||||
import Welcome from "./welcome";
|
||||
export default Welcome;
|
||||
@@ -0,0 +1,161 @@
|
||||
import {
|
||||
faCheckCircle,
|
||||
faDownload,
|
||||
faDesktop,
|
||||
faRocket,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { Link } from "react-router-dom";
|
||||
import { isMobile, isMacOs } from "react-device-detect";
|
||||
import { DOWNLOAD_LINKS } from "../../config/downloads";
|
||||
import Seo from "../../components/seo";
|
||||
import { PricingTable } from "../../components/pricing-table";
|
||||
|
||||
const Welcome = () => {
|
||||
const downloadUrl = isMacOs ? DOWNLOAD_LINKS.MACOS : DOWNLOAD_LINKS.WINDOWS;
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen overflow-hidden bg-[#101014] text-white">
|
||||
<Seo
|
||||
title="Welcome - ArtCraft"
|
||||
description="Welcome to ArtCraft. Get started with your subscription."
|
||||
/>
|
||||
|
||||
{/* Background gradient */}
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-0">
|
||||
<div className="w-[900px] h-[900px] rounded-full bg-gradient-to-br from-primary/40 via-purple-600/30 to-blue-500/20 opacity-30 blur-[120px]"></div>
|
||||
</div>
|
||||
|
||||
<main className="relative z-10 pt-28 pb-20 px-4 sm:px-6 lg:px-8">
|
||||
{/* Success Header */}
|
||||
<div className="text-center mb-12">
|
||||
<div className="inline-flex items-center gap-3 bg-primary/10 border border-primary/30 rounded-full px-6 py-3 mb-6">
|
||||
<FontAwesomeIcon
|
||||
icon={faCheckCircle}
|
||||
className="text-xl text-primary"
|
||||
/>
|
||||
<span className="text-white font-medium">
|
||||
Account created successfully!
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-4xl sm:text-5xl md:text-6xl font-bold mb-4 tracking-tight">
|
||||
Welcome to <span className="text-primary">ArtCraft</span>
|
||||
</h1>
|
||||
<p className="text-xl text-white/70 max-w-2xl mx-auto">
|
||||
Support open-source development and unlock premium AI art features.
|
||||
Your subscription keeps ArtCraft free for everyone.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Pricing Table */}
|
||||
<div className="mb-16">
|
||||
<PricingTable
|
||||
showHeader={false}
|
||||
compact={true}
|
||||
className="max-w-5xl mx-auto"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Next Steps Section */}
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<div className="bg-[#1A1A1E] border border-white/10 rounded-3xl p-8 md:p-10">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-full bg-primary/20 flex items-center justify-center">
|
||||
<FontAwesomeIcon
|
||||
icon={faRocket}
|
||||
className="text-primary text-lg"
|
||||
/>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-white">Getting Started</h2>
|
||||
</div>
|
||||
|
||||
<p className="text-white/60 mb-6">
|
||||
Your download should have started automatically. Follow these
|
||||
steps to begin creating:
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-8">
|
||||
{[
|
||||
{ step: 1, text: "Open the installer" },
|
||||
{ step: 2, text: "Follow setup instructions" },
|
||||
{ step: 3, text: "Log in to your account" },
|
||||
{ step: 4, text: "Start creating!" },
|
||||
].map((item) => (
|
||||
<div
|
||||
key={item.step}
|
||||
className="bg-[#252529] p-4 rounded-xl flex items-center gap-4"
|
||||
>
|
||||
<div className="w-8 h-8 shrink-0 rounded-full bg-primary/60 flex items-center justify-center text-sm font-bold text-white">
|
||||
{item.step}
|
||||
</div>
|
||||
<div className="text-white/90 font-medium">{item.text}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Download Contingency */}
|
||||
{!isMobile && (
|
||||
<div className="pt-6 border-t border-white/10">
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<span className="text-white/50 text-sm">
|
||||
Download didn't start?
|
||||
</span>
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<Button
|
||||
as="link"
|
||||
href={downloadUrl}
|
||||
className="bg-white text-black hover:bg-gray-100 dark:bg-white dark:text-black dark:hover:bg-gray-200 px-6 py-2.5 text-sm font-bold rounded-xl"
|
||||
>
|
||||
<FontAwesomeIcon icon={faDownload} className="mr-2" />
|
||||
Download for {isMacOs ? "Mac" : "Windows"}
|
||||
</Button>
|
||||
<div className="flex gap-4 text-sm font-medium text-white/30">
|
||||
<a
|
||||
href={DOWNLOAD_LINKS.WINDOWS}
|
||||
className="hover:text-white transition-colors flex items-center gap-2"
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-current"></span>
|
||||
Windows
|
||||
</a>
|
||||
<a
|
||||
href={DOWNLOAD_LINKS.MACOS}
|
||||
className="hover:text-white transition-colors flex items-center gap-2"
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-current"></span>
|
||||
Mac
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isMobile && (
|
||||
<div className="bg-[#431407] border border-orange-900/50 rounded-2xl p-6 text-orange-200 text-sm leading-relaxed">
|
||||
<div className="flex items-center justify-center mb-3 text-orange-400">
|
||||
<FontAwesomeIcon icon={faDesktop} className="text-2xl" />
|
||||
</div>
|
||||
ArtCraft is a powerful desktop experience. <br />
|
||||
Please head to your computer to download and install.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer Link */}
|
||||
<div className="text-center mt-8">
|
||||
<Link
|
||||
to="/"
|
||||
className="text-white/40 hover:text-white text-sm font-medium transition-colors"
|
||||
>
|
||||
Back to Home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Welcome;
|
||||
@@ -3,8 +3,27 @@
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-family: "Outfit", sans-serif;
|
||||
}
|
||||
body {
|
||||
font-family: "Poppins", sans-serif;
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
Oxygen,
|
||||
Ubuntu,
|
||||
Cantarell,
|
||||
"Open Sans",
|
||||
"Helvetica Neue",
|
||||
sans-serif;
|
||||
color: #ffffff;
|
||||
background-color: #1b1b1f;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ module.exports = {
|
||||
content: [
|
||||
join(
|
||||
__dirname,
|
||||
"{src,pages,components,app}/**/*!(*.stories|*.spec).{ts,tsx,html}"
|
||||
"{src,pages,components,app}/**/*!(*.stories|*.spec).{ts,tsx,html}",
|
||||
),
|
||||
...createGlobPatternsForDependencies(__dirname),
|
||||
"./app/index.html",
|
||||
@@ -23,6 +23,7 @@ module.exports = {
|
||||
white: colors.white,
|
||||
gray: colors.gray,
|
||||
black: colors.black,
|
||||
red: colors.red,
|
||||
blue: colors.blue,
|
||||
orange: colors.orange,
|
||||
green: colors.green,
|
||||
|
||||
@@ -37,6 +37,12 @@
|
||||
"src/components/footer"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../libs/components/tab-selector/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/state/subscription/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/model-list/tsconfig.lib.json"
|
||||
},
|
||||
@@ -52,14 +58,14 @@
|
||||
{
|
||||
"path": "../../libs/components/loading-spinner/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/markdown-content/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/api/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/button/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/markdown-content/tsconfig.lib.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../libs/components/tab-selector"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/state/subscription"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/model-list"
|
||||
},
|
||||
@@ -17,15 +23,15 @@
|
||||
{
|
||||
"path": "../../libs/components/loading-spinner"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/markdown-content"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/api"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/button"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/markdown-content"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
},
|
||||
|
||||
@@ -32,6 +32,9 @@ export default defineConfig(() => ({
|
||||
target: 'https://api.storyteller.ai',
|
||||
changeOrigin: true,
|
||||
secure: true,
|
||||
headers: {
|
||||
Origin: 'https://api.storyteller.ai',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
import { ApiManager, ApiResponse } from "./ApiManager";
|
||||
import { authentication } from "~/signals";
|
||||
import type { Property } from "csstype";
|
||||
type Visibility = Property.Visibility;
|
||||
import { Visibility } from "~/enums";
|
||||
|
||||
import {
|
||||
FilterEngineCategories,
|
||||
@@ -333,4 +333,67 @@ export class MediaFilesApi extends ApiManager {
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
public async UploadSpzFile({
|
||||
file,
|
||||
fileName,
|
||||
uuid,
|
||||
maybe_title,
|
||||
maybe_visibility = Visibility.Public,
|
||||
}: {
|
||||
file: File;
|
||||
fileName: string;
|
||||
uuid: string;
|
||||
maybe_title?: string;
|
||||
maybe_visibility?: Visibility;
|
||||
}): Promise<ApiResponse<string>> {
|
||||
const endpoint = `${this.getApiSchemeAndHost()}/v1/media_files/upload/spz`;
|
||||
const options: Record<string, string | number | undefined> = {
|
||||
maybe_title,
|
||||
maybe_visibility: maybe_visibility?.toString(),
|
||||
};
|
||||
return this.Upload({ endpoint, blob: file, fileName, uuid, options });
|
||||
}
|
||||
|
||||
private async Upload({
|
||||
endpoint,
|
||||
uuid,
|
||||
blob,
|
||||
fileName,
|
||||
options,
|
||||
}: {
|
||||
endpoint: string;
|
||||
blob: Blob | File;
|
||||
fileName: string;
|
||||
uuid: string;
|
||||
options: Record<string, string | number | undefined>;
|
||||
}): Promise<ApiResponse<string>> {
|
||||
const formRecord = Object.entries(options).reduce(
|
||||
(allOptions, [key, value]) => {
|
||||
if (value === undefined) {
|
||||
return allOptions;
|
||||
}
|
||||
return { ...allOptions, [key]: value.toString() };
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
);
|
||||
|
||||
return await this.postForm<{
|
||||
success: boolean;
|
||||
media_file_token?: string;
|
||||
BadInput?: string;
|
||||
}>({ endpoint, formRecord, blob, blobFileName: fileName, uuid })
|
||||
.then((response) => ({
|
||||
success: Boolean(response.success ?? false),
|
||||
data: response.media_file_token,
|
||||
errorMessage: response.BadInput,
|
||||
}))
|
||||
.catch((err) => {
|
||||
return {
|
||||
success: false,
|
||||
errorMessage: err.message,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+15
-1
@@ -1,4 +1,4 @@
|
||||
import { MediaUploadApi } from "~/Classes/ApiManager";
|
||||
import { MediaFilesApi, MediaUploadApi } from "~/Classes/ApiManager";
|
||||
import { FilterEngineCategories, MediaFileAnimationType } from "~/enums";
|
||||
import { getFileExtension } from "~/utilities";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
@@ -14,11 +14,24 @@ export const uploadAsset = async ({
|
||||
engineCategory: FilterEngineCategories;
|
||||
animationType?: MediaFileAnimationType;
|
||||
}) => {
|
||||
// TODO: 'media_uploads' is the name of an ancient table nothing writes to. We shouldn't use this name here to mean the media_files table.
|
||||
const mediaUploadApi = new MediaUploadApi();
|
||||
const mediaFileApi = new MediaFilesApi();
|
||||
|
||||
const fileExtension = getFileExtension(file);
|
||||
|
||||
const assetReponse = await (async () => {
|
||||
switch (fileExtension) {
|
||||
case ".spz":
|
||||
console.log("Uploading asset as SPZ");
|
||||
return mediaFileApi.UploadSpzFile({
|
||||
file: file,
|
||||
fileName: file.name,
|
||||
uuid: uuidv4(),
|
||||
maybe_title: title,
|
||||
});
|
||||
case ".zip":
|
||||
console.log("Uploading asset as PMX");
|
||||
return mediaUploadApi.UploadPmx({
|
||||
file: file,
|
||||
fileName: file.name,
|
||||
@@ -28,6 +41,7 @@ export const uploadAsset = async ({
|
||||
uuid: uuidv4(),
|
||||
});
|
||||
default:
|
||||
console.log("Uploading asset as generic engine asset");
|
||||
return mediaUploadApi.UploadNewEngineAsset({
|
||||
file: file,
|
||||
fileName: file.name,
|
||||
|
||||
@@ -1,69 +1,68 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
faGear,
|
||||
faImages,
|
||||
faGem,
|
||||
faCoinFront,
|
||||
faGrid2,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import {
|
||||
faWindowRestore,
|
||||
faSquare,
|
||||
faXmark,
|
||||
faDash,
|
||||
faSquare,
|
||||
faWindowRestore,
|
||||
faXmark,
|
||||
} from "@fortawesome/pro-regular-svg-icons";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { PopoverMenu } from "@storyteller/ui-popover";
|
||||
import { SceneTitleInput } from "./SceneTitleInput";
|
||||
import {
|
||||
GalleryModal,
|
||||
galleryModalVisibleViewMode,
|
||||
galleryModalVisibleDuringDrag,
|
||||
galleryModalLightboxVisible,
|
||||
} from "@storyteller/ui-gallery-modal";
|
||||
import { SettingsModal } from "@storyteller/ui-settings-modal";
|
||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
import { downloadFileFromUrl, FilterMediaClasses } from "@storyteller/api";
|
||||
import {
|
||||
MenuIconSelector,
|
||||
MenuIconItem,
|
||||
} from "@storyteller/ui-menu-icon-selector";
|
||||
faCoinFront,
|
||||
faGear,
|
||||
faGem,
|
||||
faGrid2,
|
||||
faImages,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { signal } from "@preact/signals-react";
|
||||
import { useSignals } from "@preact/signals-react/runtime";
|
||||
import { setLogoutStates } from "~/signals/authentication/utilities";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { TabId, useTabStore } from "~/pages/Stores/TabState";
|
||||
import { FilterMediaClasses } from "@storyteller/api";
|
||||
import { useCreditsState } from "@storyteller/credits";
|
||||
import { gtagEvent } from "@storyteller/google-analytics";
|
||||
import { ProviderBillingModal } from "@storyteller/provider-billing-modal";
|
||||
import { ProviderSetupModal } from "@storyteller/provider-setup-modal";
|
||||
import { useSubscriptionState } from "@storyteller/subscription";
|
||||
import { DownloadUrl } from "@storyteller/tauri-api";
|
||||
import { useCreditsBalanceChangedEvent, useSubscriptionPlanChangedEvent } from "@storyteller/tauri-events";
|
||||
import {
|
||||
useTauriPlatform,
|
||||
useTauriWindowControls,
|
||||
} from "@storyteller/tauri-utils";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import {
|
||||
GalleryModal,
|
||||
galleryModalLightboxVisible,
|
||||
galleryModalVisibleDuringDrag,
|
||||
galleryModalVisibleViewMode,
|
||||
} from "@storyteller/ui-gallery-modal";
|
||||
import {
|
||||
MenuIconItem,
|
||||
MenuIconSelector,
|
||||
} from "@storyteller/ui-menu-icon-selector";
|
||||
import { PopoverMenu } from "@storyteller/ui-popover";
|
||||
import {
|
||||
useCreditsModalStore,
|
||||
usePricingModalStore,
|
||||
} from "@storyteller/ui-pricing-modal";
|
||||
import { RefImage, usePromptVideoStore } from "@storyteller/ui-promptbox";
|
||||
import { SettingsModal } from "@storyteller/ui-settings-modal";
|
||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { APP_DESCRIPTORS } from "~/config/appMenu";
|
||||
import { useSceneStore } from "~/pages/PageDraw/stores/SceneState";
|
||||
import {
|
||||
is3DEditorInitialized,
|
||||
is3DSceneLoaded,
|
||||
set3DPageMounted,
|
||||
} from "~/pages/PageEnigma/Editor/editor";
|
||||
import toast from "react-hot-toast";
|
||||
import { gtagEvent } from "@storyteller/google-analytics";
|
||||
import {
|
||||
useTauriWindowControls,
|
||||
useTauriPlatform,
|
||||
} from "@storyteller/tauri-utils";
|
||||
import { useEditStore } from "../../../pages/PageEdit/stores/EditState";
|
||||
import { BaseSelectorImage } from "../../../pages/PageEdit/BaseImageSelector";
|
||||
import { ProviderSetupModal } from "@storyteller/provider-setup-modal";
|
||||
import { ProviderBillingModal } from "@storyteller/provider-billing-modal";
|
||||
import {
|
||||
usePricingModalStore,
|
||||
useCreditsModalStore,
|
||||
} from "@storyteller/ui-pricing-modal";
|
||||
import { useCreditsBalanceChangedEvent } from "@storyteller/tauri-events";
|
||||
import { useSubscriptionPlanChangedEvent } from "@storyteller/tauri-events";
|
||||
import { useCreditsState } from "@storyteller/credits";
|
||||
import { useSubscriptionState } from "@storyteller/subscription";
|
||||
import { UploadImagesButton } from "./UploadImagesButton";
|
||||
import { TaskQueue } from "./TaskQueue";
|
||||
import { usePromptVideoStore, RefImage } from "@storyteller/ui-promptbox";
|
||||
import { APP_DESCRIPTORS } from "~/config/appMenu";
|
||||
import { AppsQuickMenu } from "./AppsQuickMenu";
|
||||
import { useRemoveBackgroundStore } from "~/pages/PageRemoveBackground/RemoveBackgroundStore";
|
||||
import { useImageTo3DWorldStore } from "~/pages/PageImageTo3DWorld/ImageTo3DWorldStore";
|
||||
import { DownloadUrl } from "@storyteller/tauri-api";
|
||||
import { useRemoveBackgroundStore } from "~/pages/PageRemoveBackground/RemoveBackgroundStore";
|
||||
import { TabId, useTabStore } from "~/pages/Stores/TabState";
|
||||
import { setLogoutStates } from "~/signals/authentication/utilities";
|
||||
import { BaseSelectorImage } from "../../../pages/PageEdit/BaseImageSelector";
|
||||
import { AppsQuickMenu } from "./AppsQuickMenu";
|
||||
import { SceneTitleInput } from "./SceneTitleInput";
|
||||
import { TaskQueue } from "./TaskQueue";
|
||||
import { UploadImagesButton } from "./UploadImagesButton";
|
||||
|
||||
interface Props {
|
||||
pageName: string;
|
||||
@@ -188,10 +187,10 @@ export const TopBar = ({ pageName }: Props) => {
|
||||
const handleEditFromGallery = async (url: string, mediaId?: string) => {
|
||||
try {
|
||||
// Reset editor state
|
||||
useEditStore.getState().RESET();
|
||||
useSceneStore.getState().RESET();
|
||||
|
||||
// Switch to EDIT tab
|
||||
useTabStore.getState().setActiveTab("EDIT");
|
||||
useTabStore.getState().setActiveTab("2D");
|
||||
|
||||
// Select the image for editing
|
||||
const baseImage: BaseSelectorImage = {
|
||||
@@ -200,8 +199,8 @@ export const TopBar = ({ pageName }: Props) => {
|
||||
};
|
||||
|
||||
// Add it to state history
|
||||
useEditStore.getState().addHistoryImageBundle({ images: [baseImage] });
|
||||
useEditStore.getState().setBaseImageInfo(baseImage);
|
||||
useSceneStore.getState().addHistoryImageBundle({ images: [baseImage] });
|
||||
useSceneStore.getState().setBaseImageInfo(baseImage);
|
||||
|
||||
// Close gallery modal and lightbox if open
|
||||
galleryModalVisibleViewMode.value = false;
|
||||
|
||||
@@ -2,13 +2,13 @@ import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
|
||||
import {
|
||||
faCube,
|
||||
faFilm,
|
||||
faPalette,
|
||||
faImage,
|
||||
faDroplet,
|
||||
faPhotoFilm,
|
||||
faGlobe,
|
||||
faPencil,
|
||||
faWandMagicSparkles,
|
||||
faPenNib,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { useTabStore, TabId } from "~/pages/Stores/TabState";
|
||||
import { set3DPageMounted } from "~/pages/PageEnigma/Editor/editor";
|
||||
@@ -46,25 +46,17 @@ export const APP_DESCRIPTORS: AppDescriptor[] = [
|
||||
label: "Image to Video",
|
||||
icon: faFilm,
|
||||
},
|
||||
{
|
||||
id: "EDIT",
|
||||
label: "Edit Image",
|
||||
icon: faPencil,
|
||||
imageSrc: "/resources/gifs/INPAINT_CANVAS_DEMO.gif",
|
||||
description: "Modify your image with AI",
|
||||
large: true,
|
||||
},
|
||||
{
|
||||
id: "2D",
|
||||
label: "2D Canvas",
|
||||
icon: faPalette,
|
||||
label: "Image Editor",
|
||||
icon: faPenNib,
|
||||
imageSrc: "/resources/gifs/2D_CANVAS_DEMO.gif",
|
||||
description: "Easy edits. Great for graphic design.",
|
||||
large: true,
|
||||
},
|
||||
{
|
||||
id: "3D",
|
||||
label: "3D Editor",
|
||||
label: "3D Stage",
|
||||
icon: faCube,
|
||||
imageSrc: "/resources/gifs/3D_CANVAS_DEMO.gif",
|
||||
description: "Precision control. Great for AI film.",
|
||||
@@ -127,7 +119,7 @@ export const ALL_APPS: FullAppItem[] = [
|
||||
description: "Change with inpainting",
|
||||
icon: faPencil,
|
||||
category: "edit",
|
||||
action: "EDIT",
|
||||
action: "2D",
|
||||
color: "bg-purple-600/40",
|
||||
},
|
||||
{
|
||||
@@ -171,16 +163,16 @@ export const ALL_APPS: FullAppItem[] = [
|
||||
|
||||
{
|
||||
id: "2d-canvas",
|
||||
label: "2D Canvas",
|
||||
label: "Image Editor",
|
||||
description: "Easy edits. Great for graphic design.",
|
||||
icon: faPalette,
|
||||
icon: faPenNib,
|
||||
category: "generate",
|
||||
action: "2D",
|
||||
color: "bg-sky-500/40",
|
||||
},
|
||||
{
|
||||
id: "3d-editor",
|
||||
label: "3D Editor",
|
||||
label: "3D Stage",
|
||||
description: "Precision control. Great for AI film.",
|
||||
icon: faCube,
|
||||
category: "generate",
|
||||
@@ -213,7 +205,6 @@ export const goToApp = (action?: string) => {
|
||||
[
|
||||
"IMAGE",
|
||||
"VIDEO",
|
||||
"EDIT",
|
||||
"2D",
|
||||
"3D",
|
||||
"VIDEO_FRAME_EXTRACTOR",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export enum SubscriptionNamespace {
|
||||
FAKEYOU = "fakeyou",
|
||||
ARTCRAFT = "artcraft",
|
||||
}
|
||||
export enum SubscriptionProduct {
|
||||
PLUS = "fakeyou_plus",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { PaintSurface } from "./PaintSurface";
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { DRAW_LAYER_ID, INPAINT_LAYER_ID, PaintSurface } from "./PaintSurface";
|
||||
import "./App.css";
|
||||
import PromptEditor from "./PromptEditor/PromptEditor";
|
||||
import SideToolbar from "./components/ui/SideToolbar";
|
||||
@@ -8,22 +8,26 @@ import { useUndoRedoHotkeys } from "./hooks/useUndoRedoHotkeys";
|
||||
import { useDeleteHotkeys } from "./hooks/useDeleteHotkeys";
|
||||
import { useCopyPasteHotkeys } from "./hooks/useCopyPasteHotkeys";
|
||||
import Konva from "konva";
|
||||
import { setCanvasRenderBitmap } from "../../signals/canvasRenderBitmap";
|
||||
import { captureStageImageBitmap } from "./hooks/useUpdateSnapshot";
|
||||
import { captureStageEditsBitmap } from "./hooks/useUpdateSnapshot";
|
||||
import { ContextMenuContainer } from "./components/ui/ContextMenu";
|
||||
import { ImageModel } from "@storyteller/model-list";
|
||||
import {
|
||||
CANVAS_2D_PAGE_MODEL_LIST,
|
||||
ClassyModelSelector,
|
||||
ModelPage,
|
||||
//ProviderSelector,
|
||||
//PROVIDER_LOOKUP_BY_PAGE,
|
||||
useSelectedImageModel,
|
||||
useSelectedProviderForModel,
|
||||
} from "@storyteller/ui-model-selector";
|
||||
import { useCanvasBgRemovedEvent } from "@storyteller/tauri-api";
|
||||
import { EnqueueEditImage, EnqueueEditImageRequest, EnqueueEditImageResolution, EnqueueEditImageSize, EnqueueImageInpaint, EnqueueImageInpaintRequest, useCanvasBgRemovedEvent } from "@storyteller/tauri-api";
|
||||
import { HelpMenuButton } from "@storyteller/ui-help-menu";
|
||||
import { GenerationProvider } from "@storyteller/api-enums";
|
||||
import { HistoryStack, ImageBundle } from "../PageEdit/HistoryStack";
|
||||
import { BaseImageSelector, BaseSelectorImage } from "../PageEdit/BaseImageSelector";
|
||||
import { normalizeCanvas } from "~/Helpers/CanvasHelpers";
|
||||
import { EncodeImageBitmapToBase64 } from "./utilities/EncodeImageBitmapToBase64";
|
||||
import { RefImage, usePrompt2DStore } from "@storyteller/ui-promptbox";
|
||||
import { PromptsApi } from "@storyteller/api";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
const PAGE_ID: ModelPage = ModelPage.Canvas2D;
|
||||
|
||||
@@ -59,13 +63,35 @@ const PageDraw = () => {
|
||||
const stageRef = useRef<Konva.Stage>({} as Konva.Stage);
|
||||
const transformerRefs = useRef<{ [key: string]: Konva.Transformer }>({});
|
||||
const store = useSceneStore();
|
||||
const promptStoreProvider = usePrompt2DStore;
|
||||
const generationCount = promptStoreProvider((state) => state.generationCount);
|
||||
const setGenerationCount = promptStoreProvider((state) => state.setGenerationCount);
|
||||
const useSystemPrompt = promptStoreProvider((state) => state.useSystemPrompt);
|
||||
const referenceImages = promptStoreProvider((state) => state.referenceImages);
|
||||
const prompt = promptStoreProvider((state) => state.prompt);
|
||||
|
||||
const baseImageKonvaRef = useRef<Konva.Image>({} as Konva.Image);
|
||||
const baseImageUrl = store.baseImageInfo?.url;
|
||||
const [pendingGenerations, setPendingGenerations] = useState<
|
||||
{ id: string; count: number }[]
|
||||
>([]);
|
||||
const addHistoryImageBundle = useSceneStore(
|
||||
(state) => state.addHistoryImageBundle,
|
||||
);
|
||||
const removeHistoryImage = useSceneStore((state) => state.removeHistoryImage);
|
||||
const historyImageBundles = useSceneStore(
|
||||
(state) => state.historyImageBundles,
|
||||
);
|
||||
|
||||
const selectedImageModel: ImageModel | undefined =
|
||||
useSelectedImageModel(PAGE_ID);
|
||||
|
||||
const selectedProvider : GenerationProvider | undefined =
|
||||
const selectedProvider: GenerationProvider | undefined =
|
||||
useSelectedProviderForModel(PAGE_ID, selectedImageModel?.id);
|
||||
|
||||
const supportsMaskedInpainting =
|
||||
selectedImageModel?.usesInpaintingMask ?? false;
|
||||
|
||||
useDeleteHotkeys({ onDelete: store.deleteSelectedItems });
|
||||
useUndoRedoHotkeys({ undo: store.undo, redo: store.redo });
|
||||
useCopyPasteHotkeys({
|
||||
@@ -94,6 +120,46 @@ const PageDraw = () => {
|
||||
);
|
||||
});
|
||||
|
||||
// Create a function to use the left layer ref and download the bitmap from it
|
||||
const getMaskArrayBuffer = async (): Promise<Uint8Array> => {
|
||||
if (
|
||||
!stageRef.current ||
|
||||
!baseImageKonvaRef.current
|
||||
) {
|
||||
console.error("Stage or left panel ref is not available");
|
||||
throw new Error("Stage or left panel or base image ref is not available");
|
||||
}
|
||||
|
||||
const layer = stageRef.current.getLayers().find((l) => l.id() === INPAINT_LAYER_ID)!;
|
||||
|
||||
// Get the canvas area that's covered by the image/rectangle
|
||||
const rect = baseImageKonvaRef.current;
|
||||
const layerCrop = layer.toCanvas({
|
||||
x: stageRef.current.x(),
|
||||
y: stageRef.current.y(),
|
||||
width: rect.width() * stageRef.current.scaleX(),
|
||||
height: rect.height() * stageRef.current.scaleY(),
|
||||
pixelRatio: 1 / stageRef.current.scaleX(),
|
||||
});
|
||||
|
||||
// Using the pixelRatio scaling may result in off-by-one rounding errors,
|
||||
// So we re-fit the image to a canvas of precise size.
|
||||
const fittedCanvas = normalizeCanvas(
|
||||
layerCrop,
|
||||
rect.width(),
|
||||
rect.height(),
|
||||
);
|
||||
|
||||
// Convert colored canvas to alpha mask
|
||||
// NOTE: This isn't needed because the tauri backend uses the alpha channel anyway
|
||||
// drawAlphaMask(fittedCanvas, rect.width(), rect.height());
|
||||
|
||||
const blob = await fittedCanvas.convertToBlob({ type: "image/png" });
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
|
||||
return new Uint8Array(arrayBuffer);
|
||||
};
|
||||
|
||||
// Listen for gallery drag and drop events
|
||||
useEffect(() => {
|
||||
const handleGallery2DDrop = (event: CustomEvent) => {
|
||||
@@ -180,23 +246,279 @@ const PageDraw = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const onEnqueuedPressed = async () => {
|
||||
const { width, height } = store.getAspectRatioDimensions();
|
||||
const handleTauriEnqueue = async (image: ImageBitmap, aspectRatio: EnqueueEditImageSize | undefined, resolution: EnqueueEditImageResolution | undefined, subscriberId: string) => {
|
||||
if (image === undefined) {
|
||||
console.log("image is undefined");
|
||||
return;
|
||||
}
|
||||
|
||||
// takes snap shot and then a global variable in the engine will invoke the inference.
|
||||
const image = await captureStageImageBitmap(
|
||||
stageRef,
|
||||
transformerRefs,
|
||||
const api = new PromptsApi();
|
||||
const base64Bitmap = await EncodeImageBitmapToBase64(image);
|
||||
|
||||
const byteString = atob(base64Bitmap);
|
||||
const mimeString = "image/png";
|
||||
|
||||
const ab = new ArrayBuffer(byteString.length);
|
||||
const ia = new Uint8Array(ab);
|
||||
|
||||
for (let i = 0; i < byteString.length; i++) {
|
||||
ia[i] = byteString.charCodeAt(i);
|
||||
}
|
||||
|
||||
const uuid = crypto.randomUUID(); // Generate a new UUID
|
||||
const file = new File([ab], `${uuid}.png`, { type: mimeString });
|
||||
|
||||
const snapshotMediaToken = await api.uploadSceneSnapshot({
|
||||
screenshot: file,
|
||||
});
|
||||
|
||||
if (snapshotMediaToken.data === undefined) {
|
||||
toast.error("Error: Unable to upload scene snapshot Please try again.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("useSystemPrompt", useSystemPrompt);
|
||||
console.log("Snapshot media token:", snapshotMediaToken.data);
|
||||
|
||||
const request: EnqueueEditImageRequest = {
|
||||
model: selectedImageModel,
|
||||
scene_image_media_token: snapshotMediaToken.data!,
|
||||
image_media_tokens: referenceImages
|
||||
.map((image) => image.mediaToken)
|
||||
.filter((t) => t.length > 0),
|
||||
disable_system_prompt: !useSystemPrompt,
|
||||
prompt: prompt,
|
||||
image_count: generationCount,
|
||||
aspect_ratio: aspectRatio,
|
||||
image_resolution: resolution,
|
||||
frontend_caller: "image_editor",
|
||||
frontend_subscriber_id: subscriberId,
|
||||
};
|
||||
|
||||
if (selectedProvider) {
|
||||
request.provider = selectedProvider;
|
||||
}
|
||||
|
||||
const generateResponse = await EnqueueEditImage(request);
|
||||
console.log("generateResponse", generateResponse);
|
||||
return generateResponse;
|
||||
}
|
||||
|
||||
const getCompositeCanvasFile = useCallback(async (): Promise<File | null> => {
|
||||
if (
|
||||
!stageRef.current ||
|
||||
!baseImageKonvaRef.current ||
|
||||
!store.baseImageBitmap
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const editsLayer = stageRef.current.getLayers().find((l) => l.id() === DRAW_LAYER_ID);
|
||||
|
||||
if (!editsLayer) {
|
||||
console.error("Edits layer not found");
|
||||
return null;
|
||||
}
|
||||
|
||||
const rect = baseImageKonvaRef.current;
|
||||
const width = rect.width();
|
||||
const height = rect.height();
|
||||
|
||||
const canvas = new OffscreenCanvas(width, height);
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return null;
|
||||
|
||||
ctx.drawImage(store.baseImageBitmap, 0, 0, width, height);
|
||||
|
||||
const markerLayerCanvas = editsLayer.toCanvas({
|
||||
x: stageRef.current.x(),
|
||||
y: stageRef.current.y(),
|
||||
width: rect.width() * stageRef.current.scaleX(),
|
||||
height: rect.height() * stageRef.current.scaleY(),
|
||||
pixelRatio: 1 / stageRef.current.scaleX(),
|
||||
});
|
||||
const fittedMarkerCanvas = normalizeCanvas(
|
||||
markerLayerCanvas,
|
||||
width,
|
||||
height,
|
||||
);
|
||||
if (!image) {
|
||||
console.error("Failed to capture stage image");
|
||||
return;
|
||||
} else {
|
||||
setCanvasRenderBitmap(image);
|
||||
}
|
||||
};
|
||||
ctx.drawImage(fittedMarkerCanvas, 0, 0, width, height);
|
||||
|
||||
const blob = await canvas.convertToBlob({ type: "image/png" });
|
||||
const uuid = crypto.randomUUID();
|
||||
return new File([blob], `${uuid}.png`, { type: "image/png" });
|
||||
}, [store.baseImageBitmap]);
|
||||
|
||||
const handleGenerate = useCallback(
|
||||
async (
|
||||
prompt: string,
|
||||
options?: {
|
||||
aspectRatio?: string;
|
||||
resolution?: string;
|
||||
images?: RefImage[];
|
||||
selectedProvider?: GenerationProvider;
|
||||
},
|
||||
) => {
|
||||
const editedImageToken = store.baseImageInfo?.mediaToken;
|
||||
|
||||
if (!editedImageToken) {
|
||||
console.error("Base image is not available");
|
||||
return;
|
||||
}
|
||||
|
||||
// Helper to map aspect ratio string to enum
|
||||
const mapAspectRatio = (
|
||||
ratio?: string,
|
||||
): EnqueueEditImageSize | undefined => {
|
||||
switch (ratio) {
|
||||
case "auto":
|
||||
return EnqueueEditImageSize.Auto;
|
||||
case "wide":
|
||||
return EnqueueEditImageSize.Wide;
|
||||
case "tall":
|
||||
return EnqueueEditImageSize.Tall;
|
||||
case "square":
|
||||
return EnqueueEditImageSize.Square;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to map resolution string to enum
|
||||
const mapResolution = (
|
||||
res?: string,
|
||||
): EnqueueEditImageResolution | undefined => {
|
||||
switch (res) {
|
||||
case "1k":
|
||||
return EnqueueEditImageResolution.OneK;
|
||||
case "2k":
|
||||
return EnqueueEditImageResolution.TwoK;
|
||||
case "4k":
|
||||
return EnqueueEditImageResolution.FourK;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const { width, height } = store.getAspectRatioDimensions();
|
||||
const subscriberId: string =
|
||||
crypto?.randomUUID?.() ??
|
||||
`inpaint-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
|
||||
// takes snap shot and then a global variable in the engine will invoke the inference.
|
||||
const image = await captureStageEditsBitmap(
|
||||
stageRef,
|
||||
transformerRefs,
|
||||
width,
|
||||
height,
|
||||
);
|
||||
|
||||
if (!image) {
|
||||
console.error("Failed to capture stage edits image");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let result;
|
||||
|
||||
if (selectedImageModel?.editingIsInpainting) {
|
||||
// CASE 1 - INPAINTING (Only a few models do this!)
|
||||
const arrayBuffer = await getMaskArrayBuffer();
|
||||
const request: EnqueueImageInpaintRequest = {
|
||||
model: selectedImageModel,
|
||||
image_media_token: editedImageToken,
|
||||
mask_image_raw_bytes: arrayBuffer,
|
||||
prompt: prompt,
|
||||
image_count: generationCount,
|
||||
frontend_caller: "image_editor",
|
||||
frontend_subscriber_id: subscriberId,
|
||||
};
|
||||
|
||||
if (options?.selectedProvider) {
|
||||
request.provider = options.selectedProvider;
|
||||
}
|
||||
|
||||
result = await EnqueueImageInpaint(request);
|
||||
} else if (selectedImageModel?.isNanoBananaModel()) {
|
||||
// CASE 2 - NANO BANANA
|
||||
const compositeFile = await getCompositeCanvasFile();
|
||||
|
||||
if (!compositeFile) {
|
||||
console.error("Failed to create composite canvas");
|
||||
return;
|
||||
}
|
||||
|
||||
const api = new PromptsApi();
|
||||
const snapshotResult = await api.uploadSceneSnapshot({
|
||||
screenshot: compositeFile,
|
||||
});
|
||||
|
||||
if (!snapshotResult.data) {
|
||||
console.error("Failed to upload scene snapshot");
|
||||
return;
|
||||
}
|
||||
|
||||
const imgs = options?.images || [];
|
||||
const request: EnqueueEditImageRequest = {
|
||||
model: selectedImageModel,
|
||||
scene_image_media_token: snapshotResult.data,
|
||||
image_media_tokens: imgs
|
||||
.map((img) => img.mediaToken)
|
||||
.filter((t) => t.length > 0),
|
||||
prompt: prompt,
|
||||
image_count: generationCount,
|
||||
frontend_caller: "image_editor",
|
||||
frontend_subscriber_id: subscriberId,
|
||||
aspect_ratio: mapAspectRatio(options?.aspectRatio),
|
||||
image_resolution: mapResolution(options?.resolution),
|
||||
};
|
||||
if (options?.selectedProvider) {
|
||||
request.provider = options.selectedProvider;
|
||||
}
|
||||
// if (selectedImageModel?.supportsNewAspectRatio()) {
|
||||
// request.common_aspect_ratio = commonAspectRatio;
|
||||
// }
|
||||
result = await EnqueueEditImage(request);
|
||||
} else {
|
||||
// CASE 3 - DEFAULT
|
||||
const imgs = options?.images || [];
|
||||
const request: EnqueueEditImageRequest = {
|
||||
model: selectedImageModel,
|
||||
image_media_tokens: [
|
||||
editedImageToken,
|
||||
...imgs
|
||||
.filter((img) => img.mediaToken !== editedImageToken)
|
||||
.map((img) => img.mediaToken),
|
||||
].filter((t) => t.length > 0),
|
||||
disable_system_prompt: true,
|
||||
prompt: prompt,
|
||||
image_count: generationCount,
|
||||
frontend_caller: "image_editor",
|
||||
frontend_subscriber_id: subscriberId,
|
||||
aspect_ratio: mapAspectRatio(options?.aspectRatio),
|
||||
image_resolution: mapResolution(options?.resolution),
|
||||
};
|
||||
if (options?.selectedProvider) {
|
||||
request.provider = options.selectedProvider;
|
||||
}
|
||||
// if (selectedImageModel?.supportsNewAspectRatio()) {
|
||||
// request.common_aspect_ratio = commonAspectRatio;
|
||||
// }
|
||||
result = await EnqueueEditImage(request);
|
||||
}
|
||||
if (result?.status === "success") {
|
||||
setPendingGenerations((prev) => [
|
||||
...prev,
|
||||
{ id: subscriberId as string, count: generationCount },
|
||||
]);
|
||||
}
|
||||
} catch (error) {
|
||||
setPendingGenerations((prev) =>
|
||||
prev.filter((p) => p.id !== subscriberId),
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}, [generationCount, getCompositeCanvasFile, selectedImageModel, store]);
|
||||
|
||||
const onFitPressed = async () => {
|
||||
// Get the stage and its container dimensions
|
||||
@@ -237,6 +559,13 @@ const PageDraw = () => {
|
||||
stage.batchDraw();
|
||||
};
|
||||
|
||||
// When the model inpainting support changes, we need to auto-change the tool so it's not set to inpainting
|
||||
useEffect(() => {
|
||||
if (!supportsMaskedInpainting && store.activeTool === "inpaint") {
|
||||
store.setActiveTool("select");
|
||||
}
|
||||
}, [store, supportsMaskedInpainting]);
|
||||
|
||||
// Auto-fit canvas to screen on initial load
|
||||
useEffect(() => {
|
||||
const autoFitCanvas = async () => {
|
||||
@@ -266,25 +595,77 @@ const PageDraw = () => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Display image selector on launch, otherwise hide it
|
||||
// Also show loading state if info is set but image is loading
|
||||
if (!store.baseImageInfo || !store.baseImageBitmap) {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"bg-ui-panel-gradient flex h-[calc(100vh-56px)] w-full items-center justify-center p-8"
|
||||
}
|
||||
>
|
||||
<div className="w-full max-w-5xl">
|
||||
<div className="aspect-video overflow-hidden rounded-2xl border border-ui-panel-border bg-ui-background shadow-lg">
|
||||
<BaseImageSelector
|
||||
onImageSelect={(image: BaseSelectorImage) => {
|
||||
addHistoryImageBundle({ images: [image] });
|
||||
store.setBaseImageInfo(image);
|
||||
}}
|
||||
showLoading={
|
||||
store.baseImageInfo !== null && store.baseImageInfo === null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 -z-10 bg-ui-background" />
|
||||
<div
|
||||
className={`preserve-aspect-ratio fixed bottom-0 left-1/2 z-10 -translate-x-1/2 transform ${
|
||||
isSelecting ? "pointer-events-none" : "pointer-events-auto"
|
||||
}`}
|
||||
className={`preserve-aspect-ratio fixed right-4 top-1/2 z-10 -translate-y-1/2 transform ${isSelecting ? "pointer-events-none" : "pointer-events-auto"
|
||||
}`}
|
||||
>
|
||||
<HistoryStack
|
||||
onClear={() => {
|
||||
store.RESET();
|
||||
setPendingGenerations([]);
|
||||
}}
|
||||
imageBundles={historyImageBundles}
|
||||
pendingPlaceholders={pendingGenerations}
|
||||
blurredBackgroundUrl={baseImageUrl}
|
||||
onImageSelect={(baseImage) => {
|
||||
store.clearLineNodes();
|
||||
store.setNodes([]);
|
||||
store.setBaseImageInfo(baseImage);
|
||||
}}
|
||||
onImageRemove={(baseImage) => {
|
||||
if (
|
||||
pendingGenerations.length === 0 &&
|
||||
store.historyImageBundles.length === 1 &&
|
||||
store.historyImageBundles[0].images.length <= 1
|
||||
) {
|
||||
store.RESET();
|
||||
} else {
|
||||
removeHistoryImage(baseImage);
|
||||
}
|
||||
}}
|
||||
onNewImageBundle={(newBundle: ImageBundle) => {
|
||||
addHistoryImageBundle(newBundle);
|
||||
}}
|
||||
onResolvePending={(id: string) =>
|
||||
setPendingGenerations((prev) => prev.filter((p) => p.id !== id))
|
||||
}
|
||||
selectedImageToken={store.baseImageInfo?.mediaToken}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`preserve-aspect-ratio fixed bottom-0 left-1/2 z-10 -translate-x-1/2 transform ${isSelecting ? "pointer-events-none" : "pointer-events-auto"
|
||||
}`}
|
||||
>
|
||||
<PromptEditor
|
||||
initialPrompt=""
|
||||
onPromptChange={(prompt: string) => {
|
||||
console.log("Prompt changed:", prompt);
|
||||
}}
|
||||
onRandomize={() => {
|
||||
console.log("Randomize clicked");
|
||||
}}
|
||||
onVary={() => {
|
||||
console.log("Vary clicked");
|
||||
}}
|
||||
onAspectRatioChange={async (ratio: string) => {
|
||||
const ratioToType = (ratio: string): AspectRatioType => {
|
||||
switch (ratio) {
|
||||
@@ -305,8 +686,13 @@ const PageDraw = () => {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
onFitPressed();
|
||||
}}
|
||||
onEnqueuePressed={onEnqueuedPressed}
|
||||
usePrompt2DStore={promptStoreProvider}
|
||||
EncodeImageBitmapToBase64={EncodeImageBitmapToBase64}
|
||||
onGenerateClick={handleGenerate}
|
||||
onFitPressed={onFitPressed}
|
||||
isDisabled={false}
|
||||
generationCount={generationCount}
|
||||
onGenerationCountChange={setGenerationCount}
|
||||
//selectedModelInfo={selectedModelInfo}
|
||||
selectedImageModel={selectedImageModel}
|
||||
selectedProvider={selectedProvider}
|
||||
@@ -374,9 +760,7 @@ const PageDraw = () => {
|
||||
input.value = "";
|
||||
input.click();
|
||||
}}
|
||||
onDelete={(): void => {
|
||||
store.RESET();
|
||||
}}
|
||||
supportsMaskTool={supportsMaskedInpainting}
|
||||
activeToolId={store.activeTool}
|
||||
currentShape={store.currentShape}
|
||||
/>
|
||||
@@ -446,6 +830,8 @@ const PageDraw = () => {
|
||||
onSelectionChange={setIsSelecting}
|
||||
stageRef={stageRef}
|
||||
transformerRefs={transformerRefs}
|
||||
baseImageRef={baseImageKonvaRef}
|
||||
showMaskLayer={supportsMaskedInpainting}
|
||||
/>
|
||||
</ContextMenuContainer>
|
||||
</div>
|
||||
@@ -455,7 +841,7 @@ const PageDraw = () => {
|
||||
page={PAGE_ID}
|
||||
panelTitle="Select Model"
|
||||
panelClassName="min-w-[300px]"
|
||||
buttonClassName="bg-transparent p-0 text-lg hover:bg-transparent text-white/80 hover:text-white"
|
||||
buttonClassName="bg-transparent p-0 text-lg hover:bg-transparent text-base-fg opacity-80 hover:opacity-100"
|
||||
showIconsInList
|
||||
triggerLabel="Model"
|
||||
/>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Line,
|
||||
Image,
|
||||
Transformer,
|
||||
Group,
|
||||
} from "react-konva";
|
||||
import Konva from "konva";
|
||||
import { LineNode, useSceneStore } from "./stores/SceneState";
|
||||
@@ -18,20 +19,30 @@ import SplitPane from "./components/ui/SplitPane";
|
||||
import { useRightPanelLayoutManagement } from "./hooks/useRightPanelLayoutManagement";
|
||||
import { useStageCentering } from "./hooks/useCenteredStage";
|
||||
import { useGlobalMouseUp } from "./hooks/useGlobalMouseUp";
|
||||
import { loadImageFromUrl } from "~/Helpers/ImageHelpers";
|
||||
import { checkerboard } from "@storyteller/common";
|
||||
import { DragState } from "../PageEdit/EditPaintSurface";
|
||||
|
||||
export type MiraiProps = {
|
||||
nodes: Node[];
|
||||
selectedNodeIds: string[];
|
||||
onCanvasSizeChange?: (width: number, height: number) => void;
|
||||
fillColor?: string;
|
||||
activeTool?: "select" | "draw" | "eraser" | "backgroundColor" | "shape";
|
||||
activeTool?: "select" | "draw" | "eraser" | "backgroundColor" | "shape" | "inpaint";
|
||||
brushColor?: string;
|
||||
brushSize?: number;
|
||||
onSelectionChange?: (isSelecting: boolean) => void;
|
||||
stageRef: React.RefObject<Konva.Stage>;
|
||||
transformerRefs: React.RefObject<{ [key: string]: Konva.Transformer }>;
|
||||
baseImageRef: React.RefObject<Konva.Image>;
|
||||
showMaskLayer: boolean;
|
||||
};
|
||||
|
||||
const InpaintingColor = "rgba(39, 187, 245, 0.54)";
|
||||
export const INPAINT_LAYER_ID = "invis-mask-layer";
|
||||
export const BG_LAYER_ID = "bg-layer";
|
||||
export const DRAW_LAYER_ID = "draw-layer";
|
||||
|
||||
export const PaintSurface = ({
|
||||
nodes,
|
||||
selectedNodeIds,
|
||||
@@ -43,6 +54,8 @@ export const PaintSurface = ({
|
||||
onSelectionChange,
|
||||
stageRef,
|
||||
transformerRefs,
|
||||
baseImageRef,
|
||||
showMaskLayer = false,
|
||||
}: MiraiProps) => {
|
||||
const singlePaneMode = true;
|
||||
|
||||
@@ -52,6 +65,11 @@ export const PaintSurface = ({
|
||||
const rightContainerRef = React.useRef<HTMLDivElement>(null);
|
||||
const cursorLayerRef = React.useRef<Konva.Layer>(null);
|
||||
const cursorShapeRef = React.useRef<Konva.Circle>(null);
|
||||
// TODO: Polish this by using canvas graphics instead of a bitmap
|
||||
// Or at least manually do the pattern fill to avoid image interpolation
|
||||
const [checkerImage, setCheckerImage] = useState<HTMLImageElement | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
const [containerDimensions, setContainerDimensions] = useState({
|
||||
@@ -76,7 +94,9 @@ export const PaintSurface = ({
|
||||
endY: number;
|
||||
} | null>(null);
|
||||
const [isSelecting, setIsSelecting] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState<DragState | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const isSelectingRef = React.useRef(false);
|
||||
const [currentLineId, setCurrentLineId] = useState<string | null>(null);
|
||||
|
||||
@@ -204,6 +224,7 @@ export const PaintSurface = ({
|
||||
activeTool !== "draw" &&
|
||||
activeTool !== "eraser" &&
|
||||
activeTool !== "shape" &&
|
||||
activeTool !== "inpaint" &&
|
||||
nodeDraggable &&
|
||||
!isMiddleMousePressed
|
||||
);
|
||||
@@ -228,7 +249,7 @@ export const PaintSurface = ({
|
||||
e.target.name()?.includes("rotater");
|
||||
|
||||
if (!isWithinLeftPanel(stagePoint) && !isTransformerTarget) {
|
||||
setIsDragging(true);
|
||||
setIsDragging({ ...point, anchorX: stage.x(), anchorY: stage.y() });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -238,35 +259,55 @@ export const PaintSurface = ({
|
||||
) {
|
||||
if ((e.evt as MouseEvent).button === 1) {
|
||||
setIsMiddleMousePressed(true);
|
||||
setIsDragging(true);
|
||||
setIsDragging({ ...point, anchorX: stage.x(), anchorY: stage.y() });
|
||||
return;
|
||||
}
|
||||
if (!isTransformerTarget) {
|
||||
setIsDragging(true);
|
||||
setIsDragging({ ...point, anchorX: stage.x(), anchorY: stage.y() });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
(activeTool === "draw" || activeTool === "eraser") &&
|
||||
(activeTool === "draw" || activeTool === "eraser" || activeTool === "inpaint") &&
|
||||
isWithinLeftPanel(stagePoint)
|
||||
) {
|
||||
const lineId = `line-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const lineId = `line-${activeTool}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const opacity = activeTool === "draw" ? store.brushOpacity : 1;
|
||||
const strokeColor =
|
||||
activeTool === "eraser"
|
||||
? "#FFFFFF"
|
||||
: activeTool === "draw"
|
||||
? brushColor
|
||||
: InpaintingColor;
|
||||
|
||||
const composite =
|
||||
activeTool === "eraser"
|
||||
? "destination-out"
|
||||
: "source-over";
|
||||
|
||||
console.log("Starting new line with composite:", composite);
|
||||
|
||||
const zindex =
|
||||
activeTool === "eraser" ?
|
||||
999 : activeTool === "inpaint" ?
|
||||
998 : 1;
|
||||
|
||||
const newLineNode: LineNode = {
|
||||
id: lineId,
|
||||
type: "line",
|
||||
points: [stagePoint.x, stagePoint.y],
|
||||
stroke: activeTool === "draw" ? brushColor : fillColor || "#ffffff",
|
||||
stroke: strokeColor,
|
||||
strokeWidth: brushSize / stage.scaleX(),
|
||||
draggable: true,
|
||||
opacity: opacity,
|
||||
locked: false,
|
||||
zIndex: store.lineNodes.length,
|
||||
zIndex: zindex,
|
||||
globalCompositeOperation: composite,
|
||||
x: 0,
|
||||
y: 0,
|
||||
};
|
||||
|
||||
store.selectNode(null);
|
||||
store.addLineNode(newLineNode, false);
|
||||
setCurrentLineId(lineId);
|
||||
@@ -359,7 +400,7 @@ export const PaintSurface = ({
|
||||
|
||||
const isWithinCanvas = isWithinLeftPanel(stagePoint);
|
||||
|
||||
if (activeTool === "draw" || activeTool === "eraser") {
|
||||
if (activeTool === "draw" || activeTool === "eraser" || activeTool === "inpaint") {
|
||||
if (isWithinCanvas || isDrawing) {
|
||||
stage.container().style.cursor = "none";
|
||||
store.setCursorPosition(pointer);
|
||||
@@ -383,34 +424,31 @@ export const PaintSurface = ({
|
||||
}
|
||||
store.setCursorVisible(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (isDragging) {
|
||||
const currentStage = e.target.getStage();
|
||||
if (!currentStage) return;
|
||||
if (isDragging) {
|
||||
const currentStage = e.target.getStage();
|
||||
if (!currentStage) return;
|
||||
|
||||
currentStage.container().style.cursor = "grabbing";
|
||||
currentStage.container().style.cursor = "grabbing";
|
||||
|
||||
const dragSensitivity = 3.0;
|
||||
let newPos = { x: currentStage.x(), y: currentStage.y() };
|
||||
if ("movementX" in e.evt && "movementY" in e.evt) {
|
||||
newPos = {
|
||||
x:
|
||||
currentStage.x() +
|
||||
(e.evt as MouseEvent).movementX * dragSensitivity,
|
||||
y:
|
||||
currentStage.y() +
|
||||
(e.evt as MouseEvent).movementY * dragSensitivity,
|
||||
const displacement = {
|
||||
x: pointer.x - isDragging.x,
|
||||
y: pointer.y - isDragging.y,
|
||||
};
|
||||
const newPos = {
|
||||
x: isDragging.anchorX + displacement.x,
|
||||
y: isDragging.anchorY + displacement.y,
|
||||
};
|
||||
currentStage.position(newPos);
|
||||
|
||||
return;
|
||||
}
|
||||
currentStage.position(newPos);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
isDrawing &&
|
||||
currentLineId &&
|
||||
(activeTool === "draw" || activeTool === "eraser")
|
||||
(activeTool === "draw" || activeTool === "eraser" || activeTool === "inpaint")
|
||||
) {
|
||||
const point = stage.getPointerPosition();
|
||||
if (!point) return;
|
||||
@@ -424,6 +462,7 @@ export const PaintSurface = ({
|
||||
const currentLine = store.lineNodes.find(
|
||||
(line) => line.id === currentLineId,
|
||||
);
|
||||
|
||||
if (currentLine) {
|
||||
const updatedPoints = [
|
||||
...currentLine.points,
|
||||
@@ -499,10 +538,10 @@ export const PaintSurface = ({
|
||||
setSelectionRect((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
endX: clampedPoint.x,
|
||||
endY: clampedPoint.y,
|
||||
}
|
||||
...prev,
|
||||
endX: clampedPoint.x,
|
||||
endY: clampedPoint.y,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
}
|
||||
@@ -561,7 +600,7 @@ export const PaintSurface = ({
|
||||
}
|
||||
}
|
||||
|
||||
setIsDragging(false);
|
||||
setIsDragging(undefined);
|
||||
setIsDrawing(false);
|
||||
setCurrentLineId(null);
|
||||
setIsSelecting(false);
|
||||
@@ -587,7 +626,7 @@ export const PaintSurface = ({
|
||||
const touch2 = touches[1];
|
||||
return Math.sqrt(
|
||||
Math.pow(touch2.clientX - touch1.clientX, 2) +
|
||||
Math.pow(touch2.clientY - touch1.clientY, 2),
|
||||
Math.pow(touch2.clientY - touch1.clientY, 2),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -719,7 +758,7 @@ export const PaintSurface = ({
|
||||
const container = e.target.getStage()?.container();
|
||||
if (!container) return;
|
||||
const defaultCursor =
|
||||
activeTool === "draw" || activeTool === "eraser"
|
||||
activeTool === "draw" || activeTool === "eraser" || activeTool === "inpaint"
|
||||
? "none"
|
||||
: activeTool === "select"
|
||||
? "grab"
|
||||
@@ -734,7 +773,7 @@ export const PaintSurface = ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeTool === "draw" || activeTool === "eraser") {
|
||||
if (activeTool === "draw" || activeTool === "eraser" || activeTool === "inpaint") {
|
||||
stage.container().style.cursor = "none";
|
||||
const pointer = stage.getPointerPosition();
|
||||
if (pointer) {
|
||||
@@ -765,7 +804,7 @@ export const PaintSurface = ({
|
||||
if (
|
||||
store.cursorVisible &&
|
||||
store.cursorPosition &&
|
||||
(activeTool === "draw" || activeTool === "eraser")
|
||||
(activeTool === "draw" || activeTool === "eraser" || activeTool === "inpaint")
|
||||
) {
|
||||
const stageX = stage.x();
|
||||
const stageY = stage.y();
|
||||
@@ -785,6 +824,10 @@ export const PaintSurface = ({
|
||||
cursorNode.fill(brushColor);
|
||||
cursorNode.stroke("rgba(255, 255, 255, 0.7)");
|
||||
cursorNode.strokeWidth(1);
|
||||
} else if (activeTool === "inpaint") {
|
||||
cursorNode.fill(InpaintingColor);
|
||||
cursorNode.stroke("rgba(255, 255, 255, 0.7)");
|
||||
cursorNode.strokeWidth(1);
|
||||
} else {
|
||||
cursorNode.fill("rgba(255, 255, 255, 0.3)");
|
||||
cursorNode.stroke("rgba(0, 0, 0, 0.7)");
|
||||
@@ -811,6 +854,7 @@ export const PaintSurface = ({
|
||||
if (
|
||||
activeTool === "draw" ||
|
||||
activeTool === "eraser" ||
|
||||
activeTool === "inpaint" ||
|
||||
activeTool === "shape"
|
||||
) {
|
||||
return;
|
||||
@@ -1050,6 +1094,10 @@ export const PaintSurface = ({
|
||||
offsetX={lineNode.offsetX || 0}
|
||||
offsetY={lineNode.offsetY || 0}
|
||||
listening={listeningEnabled}
|
||||
// @ts-expect-error: We don't have globalCompositeOperation in the type definition
|
||||
globalCompositeOperation={
|
||||
lineNode.globalCompositeOperation || "source-over"
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1236,6 +1284,11 @@ export const PaintSurface = ({
|
||||
return null;
|
||||
};
|
||||
|
||||
// Load the checkboard image for transparency background
|
||||
useEffect(() => {
|
||||
loadImageFromUrl(checkerboard).then(setCheckerImage);
|
||||
}, []);
|
||||
|
||||
// Separate function to render transformers on their own layer
|
||||
const renderTransformers = () => {
|
||||
// Don't render individual transformers when multiple nodes are selected
|
||||
@@ -1398,6 +1451,18 @@ export const PaintSurface = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedNodeIds]);
|
||||
|
||||
const inpaintingLineNodes: LineNode[] = [];
|
||||
const drawLineNodes: LineNode[] = [];
|
||||
|
||||
store.lineNodes.forEach((line) => {
|
||||
if (line.id.startsWith("line-eraser") || line.id.startsWith("line-inpaint")) {
|
||||
inpaintingLineNodes.push(line);
|
||||
}
|
||||
if (line.id.startsWith("line-eraser") || line.id.startsWith("line-draw")) {
|
||||
drawLineNodes.push(line);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<SplitPane
|
||||
singlePaneMode={singlePaneMode}
|
||||
@@ -1431,16 +1496,31 @@ export const PaintSurface = ({
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
<Layer
|
||||
ref={leftPanelRef}
|
||||
clipFunc={(ctx) => {
|
||||
ctx.rect(
|
||||
0,
|
||||
0,
|
||||
store.getAspectRatioDimensions().width,
|
||||
store.getAspectRatioDimensions().height,
|
||||
);
|
||||
); // leftPanelWidth, leftPanelHeight);
|
||||
}}
|
||||
imageSmoothingEnabled={false} // Disable image smoothing for pixel art
|
||||
zIndex={-2}
|
||||
id={BG_LAYER_ID}
|
||||
>
|
||||
<Rect
|
||||
x={0}
|
||||
y={0}
|
||||
fillPatternImage={checkerImage || undefined}
|
||||
fillPatternRepeat="repeat"
|
||||
fillPatternScaleX={window.devicePixelRatio}
|
||||
fillPatternScaleY={window.devicePixelRatio}
|
||||
width={store.getAspectRatioDimensions().width}
|
||||
height={store.getAspectRatioDimensions().height}
|
||||
listening={false}
|
||||
imageSmoothingEnabled={false} // Disable image smoothing for pixel art
|
||||
zIndex={-2}
|
||||
/>
|
||||
<Rect
|
||||
x={0}
|
||||
y={0}
|
||||
@@ -1451,10 +1531,38 @@ export const PaintSurface = ({
|
||||
zIndex={-1}
|
||||
/>
|
||||
|
||||
{[...nodes, ...store.lineNodes]
|
||||
.sort((a, b) => (a.zIndex || 0) - (b.zIndex || 0))
|
||||
<Image
|
||||
ref={baseImageRef}
|
||||
x={0}
|
||||
y={0}
|
||||
image={store.baseImageBitmap || undefined}
|
||||
width={store.getAspectRatioDimensions().width}
|
||||
height={store.getAspectRatioDimensions().height}
|
||||
listening={false}
|
||||
/>
|
||||
</Layer>
|
||||
<Layer
|
||||
id={DRAW_LAYER_ID}
|
||||
ref={leftPanelRef}
|
||||
clipFunc={(ctx) => {
|
||||
ctx.rect(
|
||||
0,
|
||||
0,
|
||||
store.getAspectRatioDimensions().width,
|
||||
store.getAspectRatioDimensions().height,
|
||||
);
|
||||
}}
|
||||
>
|
||||
|
||||
{nodes
|
||||
.map((node) => renderNode(node))}
|
||||
|
||||
<Group
|
||||
cach>
|
||||
{drawLineNodes
|
||||
.map((node) => renderNode(node))}
|
||||
</Group>
|
||||
|
||||
{selectionRect && (
|
||||
<Rect
|
||||
ref={selectionRectRef}
|
||||
@@ -1483,6 +1591,22 @@ export const PaintSurface = ({
|
||||
/>
|
||||
)}
|
||||
</Layer>
|
||||
<Layer
|
||||
clipFunc={(ctx) => {
|
||||
ctx.rect(
|
||||
0,
|
||||
0,
|
||||
store.getAspectRatioDimensions().width,
|
||||
store.getAspectRatioDimensions().height,
|
||||
);
|
||||
}}
|
||||
id={INPAINT_LAYER_ID}
|
||||
listening={false}
|
||||
visible={showMaskLayer}
|
||||
>
|
||||
{inpaintingLineNodes
|
||||
.map((node) => renderNode(node))}
|
||||
</Layer>
|
||||
<Layer ref={cursorLayerRef} listening={false} draggable={false}>
|
||||
<Circle ref={cursorShapeRef} visible={false} />
|
||||
</Layer>
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState } from "react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import ImageStyleSelector from "./ImageStyleSelector";
|
||||
import { PromptEditorProps, ImageStyle } from "./types";
|
||||
import { PromptBox2D } from "@storyteller/ui-promptbox";
|
||||
import { PromptBox2D, PromptBox2DProps } from "@storyteller/ui-promptbox";
|
||||
import { uploadImage } from "../../../components/reusable/UploadModalMedia/uploadImage";
|
||||
import { EncodeImageBitmapToBase64 } from "../utilities/EncodeImageBitmapToBase64";
|
||||
import { JobProvider, useJobContext } from "../JobContext";
|
||||
@@ -10,14 +10,19 @@ import { JobProvider, useJobContext } from "../JobContext";
|
||||
// Set this value on when enqueue is pressed nasty global variable.
|
||||
import { getCanvasRenderBitmap } from "../../../signals/canvasRenderBitmap";
|
||||
|
||||
const PromptEditor: React.FC<PromptEditorProps> = ({
|
||||
onImageStyleChange,
|
||||
onEnqueuePressed,
|
||||
onAspectRatioChange,
|
||||
onFitPressed,
|
||||
//selectedModelInfo,
|
||||
const PromptEditor: React.FC<PromptBox2DProps> = ({
|
||||
uploadImage,
|
||||
selectedImageModel,
|
||||
selectedProvider,
|
||||
EncodeImageBitmapToBase64,
|
||||
onGenerateClick,
|
||||
isDisabled = false,
|
||||
isEnqueueing = false,
|
||||
generationCount = 1,
|
||||
onGenerationCountChange,
|
||||
onAspectRatioChange,
|
||||
onFitPressed,
|
||||
usePrompt2DStore
|
||||
}) => {
|
||||
const [images, setImages] = useState<ImageStyle[]>([]);
|
||||
|
||||
@@ -31,7 +36,7 @@ const PromptEditor: React.FC<PromptEditorProps> = ({
|
||||
|
||||
const updatedImages = [...images, newImage];
|
||||
setImages(updatedImages);
|
||||
onImageStyleChange?.(updatedImages);
|
||||
// onImageStyleChange?.(updatedImages);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -40,16 +45,19 @@ const PromptEditor: React.FC<PromptEditorProps> = ({
|
||||
|
||||
<JobProvider>
|
||||
<PromptBox2D
|
||||
usePrompt2DStore={usePrompt2DStore}
|
||||
uploadImage={uploadImage}
|
||||
getCanvasRenderBitmap={getCanvasRenderBitmap}
|
||||
EncodeImageBitmapToBase64={EncodeImageBitmapToBase64}
|
||||
useJobContext={useJobContext}
|
||||
onEnqueuePressed={onEnqueuePressed}
|
||||
onAspectRatioChange={onAspectRatioChange}
|
||||
onFitPressed={onFitPressed}
|
||||
//selectedModelInfo={selectedModelInfo}
|
||||
selectedImageModel={selectedImageModel}
|
||||
selectedProvider={selectedProvider}
|
||||
EncodeImageBitmapToBase64={EncodeImageBitmapToBase64}
|
||||
onGenerateClick={onGenerateClick}
|
||||
isDisabled={isDisabled}
|
||||
isEnqueueing={isEnqueueing}
|
||||
generationCount={generationCount}
|
||||
onGenerationCountChange={onGenerationCountChange}
|
||||
onAspectRatioChange={onAspectRatioChange}
|
||||
onFitPressed={onFitPressed}
|
||||
/>
|
||||
</JobProvider>
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
faPaintBrush,
|
||||
faXmark,
|
||||
faTrashXmark,
|
||||
faEraser,
|
||||
faSendBack,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import {
|
||||
faShapes,
|
||||
@@ -22,7 +24,7 @@ import "../../App.css";
|
||||
import { HsvaColorPicker, HsvaColor } from "react-colorful";
|
||||
import { hsvaToHex } from "@uiw/color-convert";
|
||||
import SliderWithIndicator from "./SliderWithIndicator";
|
||||
import { useSceneStore } from "../../stores/SceneState";
|
||||
import { ActiveTool, useSceneStore } from "../../stores/SceneState";
|
||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
import {
|
||||
showActionReminder,
|
||||
@@ -51,9 +53,9 @@ export interface SideToolbarProps {
|
||||
onPaintBrush: (hex: string, size: number, opacity: number) => void;
|
||||
onCanvasBackground: (hex: string) => void;
|
||||
onUploadImage: () => void;
|
||||
onDelete: () => void;
|
||||
activeToolId: string;
|
||||
activeToolId: ActiveTool;
|
||||
className?: string;
|
||||
supportsMaskTool?: boolean;
|
||||
}
|
||||
|
||||
const SideToolbar: React.FC<SideToolbarProps> = ({
|
||||
@@ -63,7 +65,7 @@ const SideToolbar: React.FC<SideToolbarProps> = ({
|
||||
onPaintBrush,
|
||||
onCanvasBackground,
|
||||
onUploadImage,
|
||||
onDelete,
|
||||
supportsMaskTool = false,
|
||||
activeToolId,
|
||||
className = "",
|
||||
}) => {
|
||||
@@ -270,11 +272,19 @@ const SideToolbar: React.FC<SideToolbarProps> = ({
|
||||
popout: BrushPopout,
|
||||
},
|
||||
{
|
||||
id: "delete",
|
||||
label: "Delete",
|
||||
icon: <FontAwesomeIcon icon={faTrash} className="h-5 w-5" />,
|
||||
id: "inpaint",
|
||||
label: "Mask",
|
||||
icon: <FontAwesomeIcon icon={faSendBack} className="h-5 w-5" />,
|
||||
onClick: () => {
|
||||
onDelete();
|
||||
store.setActiveTool("inpaint");
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "erase",
|
||||
label: "Eraser",
|
||||
icon: <FontAwesomeIcon icon={faEraser} className="h-5 w-5" />,
|
||||
onClick: () => {
|
||||
store.setActiveTool("eraser");
|
||||
},
|
||||
},
|
||||
{ id: "separator-3", type: "separator" },
|
||||
@@ -377,6 +387,7 @@ const SideToolbar: React.FC<SideToolbarProps> = ({
|
||||
)}
|
||||
|
||||
{tools.map((tool) => {
|
||||
|
||||
if (tool.type === "separator") {
|
||||
return <div key={tool.id} className="my-1 h-px w-8 bg-white/15" />;
|
||||
}
|
||||
@@ -387,7 +398,9 @@ const SideToolbar: React.FC<SideToolbarProps> = ({
|
||||
(id === "add-shape" && activeToolId === "shape");
|
||||
const btnStyle = active
|
||||
? "bg-primary/30 border-2 !border-primary text-white"
|
||||
: "hover:bg-white/10 text-white";
|
||||
: (id === "inpaint" && !supportsMaskTool)
|
||||
? "opacity-50 cursor-not-allowed hover:bg-transparent hover:border-transparent hover:text-white/50"
|
||||
: "hover:bg-white/10 text-white";
|
||||
|
||||
let displayIcon = icon;
|
||||
if (id === "draw") {
|
||||
@@ -415,12 +428,14 @@ const SideToolbar: React.FC<SideToolbarProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
const tooltipLabel = (id === "inpaint" && !supportsMaskTool) ? label + " (Model unsupported)" : label;
|
||||
|
||||
return (
|
||||
<div key={id} className="relative">
|
||||
{/* Hide tooltip for brush tool when active and popover is open */}
|
||||
{!(id === "draw" && active && open === id) && (
|
||||
<Tooltip
|
||||
content={label}
|
||||
content={tooltipLabel}
|
||||
position="right"
|
||||
closeOnClick={true}
|
||||
className="ms-1 rounded-md px-3 py-1"
|
||||
@@ -464,6 +479,7 @@ const SideToolbar: React.FC<SideToolbarProps> = ({
|
||||
onClick?.();
|
||||
}}
|
||||
className={`${baseBtn} ${btnStyle}`}
|
||||
disabled={id === "inpaint" && !supportsMaskTool}
|
||||
>
|
||||
{displayIcon}
|
||||
</button>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import Konva from "konva";
|
||||
import { BG_LAYER_ID, DRAW_LAYER_ID } from "../PaintSurface";
|
||||
|
||||
export const captureStageImageBitmap = async (
|
||||
export const captureStageEditsBitmap = async (
|
||||
stageRef: React.RefObject<Konva.Stage>,
|
||||
transformerRefs: React.RefObject<{ [key: string]: Konva.Transformer }>,
|
||||
width: number = 1024,
|
||||
@@ -24,6 +25,16 @@ export const captureStageImageBitmap = async (
|
||||
},
|
||||
);
|
||||
|
||||
// Disable all the layers that we don't need
|
||||
// We only need the base image layer and the edits layer
|
||||
stageRef.current.getLayers().forEach((layer) => {
|
||||
if (layer.id() == BG_LAYER_ID || layer.id() == DRAW_LAYER_ID) {
|
||||
layer.visible(true);
|
||||
} else {
|
||||
layer.visible(false);
|
||||
}
|
||||
});
|
||||
|
||||
stageRef.current.position({ x: 0, y: 0 });
|
||||
stageRef.current.scale({ x: 1, y: 1 });
|
||||
stageRef.current.draw();
|
||||
@@ -47,6 +58,11 @@ export const captureStageImageBitmap = async (
|
||||
},
|
||||
);
|
||||
|
||||
// Restore all layers visibility
|
||||
stageRef.current.getLayers().forEach((layer) => {
|
||||
layer.visible(true);
|
||||
});
|
||||
|
||||
stageRef.current.position(oldPos);
|
||||
stageRef.current.scale(oldScale);
|
||||
stageRef.current.draw();
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { create } from "zustand";
|
||||
import { Node, NodeType } from "../Node";
|
||||
import { EnqueueImageBgRemoval } from "libs/tauri-api/src/lib/enqueue/EnqueueImageBgRemovalCommand";
|
||||
import { ImageBundle } from "~/pages/PageEdit/HistoryStack";
|
||||
import { BaseSelectorImage } from "~/pages/PageEdit/BaseImageSelector";
|
||||
|
||||
// Add LineNode type
|
||||
export type LineNode = {
|
||||
@@ -55,6 +57,7 @@ export const convertFileToBase64 = (file: File): Promise<string> => {
|
||||
export type ActiveTool =
|
||||
| "select"
|
||||
| "draw"
|
||||
| "inpaint"
|
||||
| "eraser"
|
||||
| "backgroundColor"
|
||||
| "shape";
|
||||
@@ -175,6 +178,7 @@ interface SceneState {
|
||||
dy: number,
|
||||
shouldSaveState?: boolean,
|
||||
) => void;
|
||||
clearLineNodes: () => void;
|
||||
|
||||
// Add a specific method for file uploads
|
||||
createImageFromFile: (
|
||||
@@ -253,6 +257,17 @@ interface SceneState {
|
||||
// Add aspect ratio action
|
||||
setAspectRatioType: (type: AspectRatioType) => void;
|
||||
getAspectRatioDimensions: () => { width: number; height: number };
|
||||
|
||||
// For the history stack
|
||||
historyImageBundles: ImageBundle[];
|
||||
clearHistoryImages: () => void;
|
||||
addHistoryImageBundle: (bundle: ImageBundle) => void;
|
||||
removeHistoryImage: (image: BaseSelectorImage) => void;
|
||||
|
||||
// Base image state
|
||||
baseImageInfo: BaseSelectorImage | null;
|
||||
baseImageBitmap: HTMLImageElement | null;
|
||||
setBaseImageInfo: (image: BaseSelectorImage | null) => void;
|
||||
}
|
||||
|
||||
export const generateId = (): string => {
|
||||
@@ -293,6 +308,13 @@ export const useSceneStore = create<SceneState>((set, get, store) => ({
|
||||
cursorPosition: null,
|
||||
cursorVisible: false,
|
||||
|
||||
// Base image initial state
|
||||
baseImageInfo: null,
|
||||
baseImageBitmap: null,
|
||||
|
||||
// History stack state
|
||||
historyImageBundles: [],
|
||||
|
||||
// Add initial aspect ratio state
|
||||
aspectRatioType: AspectRatioType.NONE,
|
||||
|
||||
@@ -1542,6 +1564,15 @@ export const useSceneStore = create<SceneState>((set, get, store) => ({
|
||||
},
|
||||
|
||||
getAspectRatioDimensions: () => {
|
||||
// First we check the base image info if it exists
|
||||
// Otherwise we use default landscape
|
||||
const baseImageInfo = get().baseImageBitmap;
|
||||
|
||||
// If base image exists, use its dimensions
|
||||
if (baseImageInfo) {
|
||||
return { width: baseImageInfo.width, height: baseImageInfo.height };
|
||||
}
|
||||
|
||||
const { aspectRatioType } = get();
|
||||
switch (aspectRatioType) {
|
||||
case AspectRatioType.PORTRAIT:
|
||||
@@ -1554,4 +1585,60 @@ export const useSceneStore = create<SceneState>((set, get, store) => ({
|
||||
return { width: 1024, height: 683 }; // Default to landscape
|
||||
}
|
||||
},
|
||||
|
||||
setBaseImageInfo: (image: BaseSelectorImage | null) => {
|
||||
set({ baseImageInfo: image });
|
||||
|
||||
if (!image) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Load the image bitmap as well
|
||||
const imgBitmap = new Image();
|
||||
imgBitmap.onload = () => {
|
||||
set({ baseImageBitmap: imgBitmap });
|
||||
};
|
||||
imgBitmap.onerror = (event) => {
|
||||
console.error("Failed to load base image, discarding", event);
|
||||
set({ baseImageInfo: null, baseImageBitmap: null });
|
||||
imgBitmap.onload = null;
|
||||
imgBitmap.onerror = null;
|
||||
};
|
||||
imgBitmap.crossOrigin = "anonymous";
|
||||
const isDataUrl =
|
||||
typeof image.url === "string" && image.url.startsWith("data:");
|
||||
imgBitmap.src = isDataUrl ? image.url : image.url + "?basecanvasimg";
|
||||
},
|
||||
|
||||
clearLineNodes() {
|
||||
set({ lineNodes: [] });
|
||||
},
|
||||
|
||||
clearHistoryImages: () => {
|
||||
set({ historyImageBundles: [] });
|
||||
},
|
||||
|
||||
addHistoryImageBundle(bundle) {
|
||||
set((state) => ({
|
||||
historyImageBundles: [...state.historyImageBundles, bundle],
|
||||
}));
|
||||
},
|
||||
|
||||
removeHistoryImage(image) {
|
||||
console.log("Removing history image:", image);
|
||||
set((state) => {
|
||||
const updatedBundles = state.historyImageBundles
|
||||
.map((bundle) => {
|
||||
return {
|
||||
...bundle,
|
||||
images: bundle.images.filter(
|
||||
(img) => img.mediaToken !== image.mediaToken,
|
||||
),
|
||||
};
|
||||
})
|
||||
.filter((bundle) => bundle.images.length > 0); // Remove empty bundles
|
||||
console.log("Updated history image bundles:", updatedBundles);
|
||||
return { historyImageBundles: updatedBundles };
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -481,11 +481,11 @@ export const EditPaintSurface = ({
|
||||
setSelectionRect((prev) =>
|
||||
prev
|
||||
? {
|
||||
// Ensure prev is not null
|
||||
...prev,
|
||||
endX: clampedPoint.x,
|
||||
endY: clampedPoint.y,
|
||||
}
|
||||
// Ensure prev is not null
|
||||
...prev,
|
||||
endX: clampedPoint.x,
|
||||
endY: clampedPoint.y,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
}
|
||||
@@ -638,8 +638,8 @@ export const EditPaintSurface = ({
|
||||
if (!container) return;
|
||||
const defaultCursor =
|
||||
activeTool === "edit" ||
|
||||
activeTool === "marker" ||
|
||||
activeTool === "eraser"
|
||||
activeTool === "marker" ||
|
||||
activeTool === "eraser"
|
||||
? "none"
|
||||
: activeTool === "select"
|
||||
? "grab"
|
||||
@@ -693,7 +693,7 @@ export const EditPaintSurface = ({
|
||||
const touch2 = touches[1];
|
||||
return Math.sqrt(
|
||||
Math.pow(touch2.clientX - touch1.clientX, 2) +
|
||||
Math.pow(touch2.clientY - touch1.clientY, 2),
|
||||
Math.pow(touch2.clientY - touch1.clientY, 2),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1405,31 +1405,7 @@ export const EditPaintSurface = ({
|
||||
store.getAspectRatioDimensions().height,
|
||||
); // leftPanelWidth, leftPanelHeight);
|
||||
}}
|
||||
imageSmoothingEnabled={false} // Disable image smoothing for pixel art
|
||||
>
|
||||
<Rect
|
||||
x={0}
|
||||
y={0}
|
||||
fillPatternImage={checkerImage || undefined}
|
||||
fillPatternRepeat="repeat"
|
||||
fillPatternScaleX={window.devicePixelRatio}
|
||||
fillPatternScaleY={window.devicePixelRatio}
|
||||
width={store.getAspectRatioDimensions().width}
|
||||
height={store.getAspectRatioDimensions().height}
|
||||
listening={false}
|
||||
zIndex={-2}
|
||||
imageSmoothingEnabled={false} // Disable image smoothing for pixel art
|
||||
/>
|
||||
</Layer>
|
||||
<Layer
|
||||
clipFunc={(ctx) => {
|
||||
ctx.rect(
|
||||
0,
|
||||
0,
|
||||
store.getAspectRatioDimensions().width,
|
||||
store.getAspectRatioDimensions().height,
|
||||
); // leftPanelWidth, leftPanelHeight);
|
||||
}}
|
||||
listening={false}
|
||||
>
|
||||
<Image
|
||||
ref={baseImageRef}
|
||||
@@ -1497,11 +1473,11 @@ export const EditPaintSurface = ({
|
||||
scaleY={previewScale}
|
||||
x={0}
|
||||
y={0}
|
||||
// style={{
|
||||
// background: '#f5f5f5',
|
||||
// border: '1px solid #ddd',
|
||||
// borderRadius: '8px',
|
||||
// }}
|
||||
// style={{
|
||||
// background: '#f5f5f5',
|
||||
// border: '1px solid #ddd',
|
||||
// borderRadius: '8px',
|
||||
// }}
|
||||
>
|
||||
<Layer>
|
||||
<Image
|
||||
|
||||
@@ -24,8 +24,8 @@ import {
|
||||
ClassyModelSelector,
|
||||
useSelectedImageModel,
|
||||
useSelectedProviderForModel,
|
||||
IMAGE_EDITOR_PAGE_MODEL_LIST,
|
||||
ModelPage,
|
||||
IMAGE_EDITOR_PAGE_MODEL_LIST,
|
||||
//ProviderSelector,
|
||||
//PROVIDER_LOOKUP_BY_PAGE,
|
||||
} from "@storyteller/ui-model-selector";
|
||||
@@ -43,9 +43,9 @@ const PageEdit = () => {
|
||||
const selectedImageModel: ImageModel | undefined =
|
||||
useSelectedImageModel(PAGE_ID);
|
||||
|
||||
const selectedProvider : GenerationProvider | undefined =
|
||||
const selectedProvider: GenerationProvider | undefined =
|
||||
useSelectedProviderForModel(PAGE_ID, selectedImageModel?.id);
|
||||
|
||||
|
||||
// State for canvas dimensions
|
||||
const canvasWidth = useRef<number>(1024);
|
||||
const canvasHeight = useRef<number>(1024);
|
||||
@@ -387,7 +387,7 @@ const PageEdit = () => {
|
||||
if (selectedImageModel?.editingIsInpainting) {
|
||||
// CASE 1 - INPAINTING (Only a few models do this!)
|
||||
const arrayBuffer = await getMaskArrayBuffer();
|
||||
let request : EnqueueImageInpaintRequest = {
|
||||
const request: EnqueueImageInpaintRequest = {
|
||||
model: selectedImageModel,
|
||||
image_media_token: editedImageToken,
|
||||
mask_image_raw_bytes: arrayBuffer,
|
||||
@@ -396,7 +396,7 @@ const PageEdit = () => {
|
||||
frontend_caller: "image_editor",
|
||||
frontend_subscriber_id: subscriberId,
|
||||
};
|
||||
if (!!options?.selectedProvider) {
|
||||
if (options?.selectedProvider) {
|
||||
request.provider = options.selectedProvider;
|
||||
}
|
||||
//if (selectedImageModel?.supportsNewAspectRatio()) {
|
||||
@@ -419,7 +419,7 @@ const PageEdit = () => {
|
||||
return;
|
||||
}
|
||||
const imgs = options?.images || [];
|
||||
let request: EnqueueEditImageRequest = {
|
||||
const request: EnqueueEditImageRequest = {
|
||||
model: selectedImageModel,
|
||||
scene_image_media_token: snapshotResult.data,
|
||||
image_media_tokens: imgs
|
||||
@@ -432,7 +432,7 @@ const PageEdit = () => {
|
||||
aspect_ratio: mapAspectRatio(options?.aspectRatio),
|
||||
image_resolution: mapResolution(options?.resolution),
|
||||
};
|
||||
if (!!options?.selectedProvider) {
|
||||
if (options?.selectedProvider) {
|
||||
request.provider = options.selectedProvider;
|
||||
}
|
||||
if (selectedImageModel?.supportsNewAspectRatio()) {
|
||||
@@ -442,7 +442,7 @@ const PageEdit = () => {
|
||||
} else {
|
||||
// CASE 3 - DEFAULT
|
||||
const imgs = options?.images || [];
|
||||
let request : EnqueueEditImageRequest = {
|
||||
const request: EnqueueEditImageRequest = {
|
||||
model: selectedImageModel,
|
||||
image_media_tokens: [
|
||||
editedImageToken,
|
||||
@@ -458,7 +458,7 @@ const PageEdit = () => {
|
||||
aspect_ratio: mapAspectRatio(options?.aspectRatio),
|
||||
image_resolution: mapResolution(options?.resolution),
|
||||
};
|
||||
if (!!options?.selectedProvider) {
|
||||
if (options?.selectedProvider) {
|
||||
request.provider = options.selectedProvider;
|
||||
}
|
||||
if (selectedImageModel?.supportsNewAspectRatio()) {
|
||||
@@ -554,9 +554,8 @@ const PageEdit = () => {
|
||||
{(store.activeTool === "marker" || store.activeTool === "eraser") &&
|
||||
isNanoBananaModel && (
|
||||
<div
|
||||
className={`preserve-aspect-ratio fixed left-1/2 top-0 z-10 -translate-x-1/2 transform ${
|
||||
isSelecting ? "pointer-events-none" : "pointer-events-auto"
|
||||
}`}
|
||||
className={`preserve-aspect-ratio fixed left-1/2 top-0 z-10 -translate-x-1/2 transform ${isSelecting ? "pointer-events-none" : "pointer-events-auto"
|
||||
}`}
|
||||
>
|
||||
<MarkerToolControlBar
|
||||
currentSize={
|
||||
@@ -579,9 +578,8 @@ const PageEdit = () => {
|
||||
{(store.activeTool === "edit" || store.activeTool === "eraser") &&
|
||||
supportsMaskedInpainting && (
|
||||
<div
|
||||
className={`preserve-aspect-ratio fixed left-1/2 top-0 z-10 -translate-x-1/2 transform ${
|
||||
isSelecting ? "pointer-events-none" : "pointer-events-auto"
|
||||
}`}
|
||||
className={`preserve-aspect-ratio fixed left-1/2 top-0 z-10 -translate-x-1/2 transform ${isSelecting ? "pointer-events-none" : "pointer-events-auto"
|
||||
}`}
|
||||
>
|
||||
<MarkerToolControlBar
|
||||
currentSize={
|
||||
@@ -602,9 +600,8 @@ const PageEdit = () => {
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`preserve-aspect-ratio fixed right-4 top-1/2 z-10 -translate-y-1/2 transform ${
|
||||
isSelecting ? "pointer-events-none" : "pointer-events-auto"
|
||||
}`}
|
||||
className={`preserve-aspect-ratio fixed right-4 top-1/2 z-10 -translate-y-1/2 transform ${isSelecting ? "pointer-events-none" : "pointer-events-auto"
|
||||
}`}
|
||||
>
|
||||
<HistoryStack
|
||||
onClear={() => {
|
||||
@@ -639,9 +636,8 @@ const PageEdit = () => {
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`preserve-aspect-ratio fixed bottom-0 left-1/2 z-10 -translate-x-1/2 transform ${
|
||||
isSelecting ? "pointer-events-none" : "pointer-events-auto"
|
||||
}`}
|
||||
className={`preserve-aspect-ratio fixed bottom-0 left-1/2 z-10 -translate-x-1/2 transform ${isSelecting ? "pointer-events-none" : "pointer-events-auto"
|
||||
}`}
|
||||
>
|
||||
<PromptEditor
|
||||
selectedImageModel={selectedImageModel}
|
||||
|
||||
@@ -119,7 +119,7 @@ export const PageEditor = () => {
|
||||
const selectedImageModel: ImageModel | undefined =
|
||||
useSelectedImageModel(PAGE_ID);
|
||||
|
||||
const selectedProvider : GenerationProvider | undefined =
|
||||
const selectedProvider: GenerationProvider | undefined =
|
||||
useSelectedProviderForModel(PAGE_ID, selectedImageModel?.id);
|
||||
|
||||
const height =
|
||||
@@ -598,11 +598,6 @@ export const PageEditor = () => {
|
||||
<ImageToVideo />
|
||||
</div>
|
||||
)}
|
||||
{tabStore.activeTabId == "EDIT" && (
|
||||
<div>
|
||||
<PageEdit />
|
||||
</div>
|
||||
)}
|
||||
{tabStore.activeTabId == "VIDEO_FRAME_EXTRACTOR" && (
|
||||
<div>
|
||||
<VideoFrameExtractor />
|
||||
|
||||
@@ -6,7 +6,6 @@ export type TabId =
|
||||
| "3D"
|
||||
| "VIDEO"
|
||||
| "IMAGE"
|
||||
| "EDIT"
|
||||
| "APPS"
|
||||
| "VIDEO_FRAME_EXTRACTOR"
|
||||
| "VIDEO_WATERMARK_REMOVAL"
|
||||
|
||||
@@ -8,7 +8,7 @@ interface SignupRequest {
|
||||
password: string;
|
||||
password_confirmation: string;
|
||||
signup_source?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export class UsersApi extends ApiManager {
|
||||
private async authFetch<B, T>(
|
||||
@@ -19,7 +19,7 @@ export class UsersApi extends ApiManager {
|
||||
}: {
|
||||
method: string;
|
||||
body?: B;
|
||||
}
|
||||
},
|
||||
): Promise<T> {
|
||||
const bodyInString = JSON.stringify(body);
|
||||
|
||||
@@ -33,8 +33,14 @@ export class UsersApi extends ApiManager {
|
||||
body: bodyInString,
|
||||
});
|
||||
|
||||
const responseData = await response.json();
|
||||
return responseData as T;
|
||||
const text = await response.text();
|
||||
try {
|
||||
const responseData = JSON.parse(text);
|
||||
return responseData as T;
|
||||
} catch (e) {
|
||||
console.error("Failed to parse response as JSON:", text);
|
||||
throw new Error(text || `Request failed with status ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
public GetSession(): Promise<
|
||||
@@ -164,7 +170,7 @@ export class UsersApi extends ApiManager {
|
||||
signupSource?: string;
|
||||
}): Promise<ApiResponse<{ signedSession?: string }>> {
|
||||
const endpoint = `${this.getApiSchemeAndHost()}/v1/create_account`;
|
||||
const body : SignupRequest = {
|
||||
const body: SignupRequest = {
|
||||
email_address: email,
|
||||
password,
|
||||
password_confirmation: passwordConfirmation,
|
||||
@@ -207,4 +213,4 @@ export class UsersApi extends ApiManager {
|
||||
}
|
||||
}
|
||||
|
||||
(window as any).UsersApi = new UsersApi();
|
||||
(window as any).UsersApi = new UsersApi();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export enum SubscriptionNamespace {
|
||||
FAKEYOU = "fakeyou",
|
||||
ARTCRAFT = "artcraft",
|
||||
}
|
||||
export enum SubscriptionProduct {
|
||||
PLUS = "fakeyou_plus",
|
||||
|
||||
@@ -7,8 +7,8 @@ export type TutorialItem = {
|
||||
};
|
||||
|
||||
export enum TutorialCategory {
|
||||
Editor3D = "3D Editor",
|
||||
Canvas2D = "2D Canvas",
|
||||
Editor3D = "3D Stage",
|
||||
Canvas2D = "Image Editor",
|
||||
}
|
||||
|
||||
export const defaultTutorials: TutorialItem[] = [
|
||||
@@ -22,14 +22,14 @@ export const defaultTutorials: TutorialItem[] = [
|
||||
},
|
||||
{
|
||||
id: "2d-editor-basics",
|
||||
title: "2D Editor Basics",
|
||||
title: "Image Editor Basics",
|
||||
thumbnailUrl: "/resources/images/tutorial-thumbnails/2D_Editor_Basics.jpg",
|
||||
videoUrl: "https://youtu.be/ZASPqlqUBQc?si=U9yRn6KETNJKaUAo",
|
||||
category: TutorialCategory.Canvas2D,
|
||||
},
|
||||
{
|
||||
id: "3d-editor-posing",
|
||||
title: "3D Editor & Posing",
|
||||
title: "3D Stage & Posing",
|
||||
thumbnailUrl: "/resources/images/tutorial-thumbnails/3D_Editor_Basics.jpg",
|
||||
videoUrl: "https://youtu.be/pM4CxN-isrY?si=XDyG5C1aMIL4KK5O",
|
||||
category: TutorialCategory.Editor3D,
|
||||
|
||||
@@ -18,7 +18,7 @@ const buildItems = (
|
||||
models: Model[],
|
||||
fallbackIcon: any
|
||||
) =>
|
||||
models.map((model : Model) => ({
|
||||
models.map((model: Model) => ({
|
||||
label: model.selectorName,
|
||||
icon: withIcon(getCreatorIcon(model.creator), fallbackIcon),
|
||||
description: model.selectorDescription,
|
||||
@@ -33,14 +33,14 @@ const buildItems = (
|
||||
/**
|
||||
* IN-PROGRESS MIGRATION (messy for now)
|
||||
* We're gradually going to phase out ModelList, ModelConfig, etc.
|
||||
* We won't index by name, but rather id, or simply will always have full
|
||||
* We won't index by name, but rather id, or simply will always have full
|
||||
* access to the object directly.
|
||||
*/
|
||||
*/
|
||||
|
||||
export const TEXT_TO_IMAGE_PAGE_MODEL_LIST : ModelList =
|
||||
export const TEXT_TO_IMAGE_PAGE_MODEL_LIST: ModelList =
|
||||
buildItems(
|
||||
(function() : Model[] {
|
||||
const set : Set<Model> = new Set();
|
||||
(function (): Model[] {
|
||||
const set: Set<Model> = new Set();
|
||||
IMAGE_MODELS
|
||||
.filter((model) => model.canTextToImage)
|
||||
.forEach((m) => set.add(m));
|
||||
@@ -51,10 +51,24 @@ export const TEXT_TO_IMAGE_PAGE_MODEL_LIST : ModelList =
|
||||
<FontAwesomeIcon icon={faImage} className="h-4 w-4" />
|
||||
);
|
||||
|
||||
export const CANVAS_2D_PAGE_MODEL_LIST : ModelList =
|
||||
export const CANVAS_2D_PAGE_MODEL_LIST: ModelList =
|
||||
buildItems(
|
||||
(function() : Model[] {
|
||||
const set : Set<Model> = new Set();
|
||||
(function (): Model[] {
|
||||
const set: Set<Model> = new Set();
|
||||
IMAGE_MODELS
|
||||
.filter((m) => m.canEditImages || m.tags?.includes(ModelTag.InstructiveEdit))
|
||||
.forEach((m) => set.add(m));
|
||||
const list = Array.from(set);
|
||||
list.sort((a, b) => a.selectorName?.localeCompare(b.selectorName));
|
||||
return list;
|
||||
})(),
|
||||
<FontAwesomeIcon icon={faImage} className="h-4 w-4" />
|
||||
);
|
||||
|
||||
export const STAGE_3D_PAGE_MODEL_LIST: ModelList =
|
||||
buildItems(
|
||||
(function (): Model[] {
|
||||
const set: Set<Model> = new Set();
|
||||
IMAGE_MODELS
|
||||
.filter((m) => m.tags?.includes(ModelTag.InstructiveEdit))
|
||||
.forEach((m) => set.add(m));
|
||||
@@ -65,29 +79,15 @@ export const CANVAS_2D_PAGE_MODEL_LIST : ModelList =
|
||||
<FontAwesomeIcon icon={faImage} className="h-4 w-4" />
|
||||
);
|
||||
|
||||
export const STAGE_3D_PAGE_MODEL_LIST : ModelList =
|
||||
buildItems(
|
||||
(function() : Model[] {
|
||||
const set : Set<Model> = new Set();
|
||||
IMAGE_MODELS
|
||||
.filter((m) => m.tags?.includes(ModelTag.InstructiveEdit))
|
||||
.forEach((m) => set.add(m));
|
||||
const list = Array.from(set);
|
||||
list.sort((a, b) => a.selectorName?.localeCompare(b.selectorName));
|
||||
return list;
|
||||
})(),
|
||||
<FontAwesomeIcon icon={faImage} className="h-4 w-4" />
|
||||
);
|
||||
|
||||
export const IMAGE_EDITOR_PAGE_MODEL_LIST : ModelList =
|
||||
export const IMAGE_EDITOR_PAGE_MODEL_LIST: ModelList =
|
||||
buildItems(
|
||||
//[
|
||||
// ALL_MODELS_BY_ID.get("flux_pro_inpaint")!,
|
||||
// ALL_MODELS_BY_ID.get("flux_dev_juggernaut_inpaint")!,
|
||||
// ALL_MODELS_BY_ID.get("flux_pro_kontext_max")!,
|
||||
//],
|
||||
(function() : Model[] {
|
||||
const set : Set<Model> = new Set();
|
||||
(function (): Model[] {
|
||||
const set: Set<Model> = new Set();
|
||||
//set.add(IMAGE_MODELS_BY_ID.get("gpt_image_1")!); // Place gpt_image_1 first.
|
||||
IMAGE_MODELS
|
||||
.filter((m) => m.canEditImages)
|
||||
@@ -99,14 +99,14 @@ export const IMAGE_EDITOR_PAGE_MODEL_LIST : ModelList =
|
||||
<FontAwesomeIcon icon={faImage} className="h-4 w-4" />
|
||||
);
|
||||
|
||||
export const IMAGE_TO_VIDEO_PAGE_MODEL_LIST : ModelList =
|
||||
export const IMAGE_TO_VIDEO_PAGE_MODEL_LIST: ModelList =
|
||||
buildItems(
|
||||
(function() : Model[] {
|
||||
const set : Set<Model> = new Set();
|
||||
VIDEO_MODELS.forEach((m) => set.add(m));
|
||||
(function (): Model[] {
|
||||
const set: Set<Model> = new Set();
|
||||
VIDEO_MODELS.forEach((m) => set.add(m));
|
||||
const list = Array.from(set);
|
||||
list.sort((a, b) => a.selectorName?.localeCompare(b.selectorName));
|
||||
return list;
|
||||
})(),
|
||||
<FontAwesomeIcon icon={faFilm} className="h-4 w-4" />
|
||||
);
|
||||
);
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useSignals } from "@preact/signals-react/runtime";
|
||||
import { toast } from "@storyteller/ui-toaster";
|
||||
import {
|
||||
JobContextType,
|
||||
MaybeCanvasRenderBitmapType,
|
||||
UploaderState,
|
||||
} from "@storyteller/common";
|
||||
import { PopoverMenu, PopoverItem } from "@storyteller/ui-popover";
|
||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
import { Button, ToggleButton } from "@storyteller/ui-button";
|
||||
@@ -16,7 +10,6 @@ import {
|
||||
faSparkles,
|
||||
faSpinnerThird,
|
||||
faFrame,
|
||||
faCopy,
|
||||
faExpand,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import {
|
||||
@@ -26,61 +19,63 @@ import {
|
||||
} from "@fortawesome/pro-regular-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { IconProp } from "@fortawesome/fontawesome-svg-core";
|
||||
import { IsDesktopApp } from "@storyteller/tauri-utils";
|
||||
import { PromptsApi } from "@storyteller/api";
|
||||
import {
|
||||
EnqueueEditImage,
|
||||
EnqueueEditImageSize,
|
||||
EnqueueEditImageResolution,
|
||||
EnqueueEditImageRequest,
|
||||
} from "@storyteller/tauri-api";
|
||||
import { usePrompt2DStore } from "./promptStore";
|
||||
import { gtagEvent } from "@storyteller/google-analytics";
|
||||
import { getCapabilitiesForModel } from "@storyteller/model-list";
|
||||
|
||||
import { Prompt2DStore, RefImage } from "./promptStore";
|
||||
import { ImageModel } from "@storyteller/model-list";
|
||||
import { ImagePromptRow } from "./ImagePromptRow";
|
||||
import { ImagePromptRow, UploadImageFn } from "./ImagePromptRow";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { GenerationProvider } from "@storyteller/api-enums";
|
||||
import { GenerationCountPicker } from "./common/GenerationCountPicker";
|
||||
import { StoreApi, UseBoundStore } from "zustand";
|
||||
import { useSubscriptionState } from "@storyteller/subscription";
|
||||
|
||||
export type AspectRatio = "wide" | "tall" | "square";
|
||||
|
||||
interface PromptBox2DProps {
|
||||
uploadImage: ({
|
||||
title,
|
||||
assetFile,
|
||||
progressCallback,
|
||||
}: {
|
||||
title: string;
|
||||
assetFile: File;
|
||||
progressCallback: (newState: UploaderState) => void;
|
||||
}) => Promise<void>;
|
||||
export interface PromptBox2DProps {
|
||||
uploadImage?: UploadImageFn;
|
||||
selectedImageModel?: ImageModel;
|
||||
selectedProvider?: GenerationProvider;
|
||||
getCanvasRenderBitmap: () => MaybeCanvasRenderBitmapType;
|
||||
EncodeImageBitmapToBase64: (imageBitmap: ImageBitmap) => Promise<string>;
|
||||
useJobContext: () => JobContextType;
|
||||
onEnqueuePressed?: () => void | Promise<void>;
|
||||
onGenerateClick: (
|
||||
prompt: string,
|
||||
options?: {
|
||||
aspectRatio?: string;
|
||||
resolution?: string;
|
||||
images?: RefImage[];
|
||||
selectedProvider?: GenerationProvider;
|
||||
},
|
||||
) => void | Promise<void>;
|
||||
isDisabled?: boolean;
|
||||
isEnqueueing?: boolean;
|
||||
generationCount?: number;
|
||||
onGenerationCountChange?: (count: number) => void;
|
||||
onAspectRatioChange?: (ratio: AspectRatio) => void;
|
||||
onFitPressed?: () => void | Promise<void>;
|
||||
usePrompt2DStore: UseBoundStore<StoreApi<Prompt2DStore>>;
|
||||
}
|
||||
|
||||
export const PromptBox2D = ({
|
||||
uploadImage,
|
||||
getCanvasRenderBitmap,
|
||||
EncodeImageBitmapToBase64,
|
||||
useJobContext,
|
||||
onEnqueuePressed,
|
||||
onAspectRatioChange,
|
||||
selectedImageModel,
|
||||
selectedProvider,
|
||||
EncodeImageBitmapToBase64,
|
||||
onGenerateClick,
|
||||
isDisabled = false,
|
||||
isEnqueueing = false,
|
||||
generationCount = 1,
|
||||
onGenerationCountChange,
|
||||
onAspectRatioChange,
|
||||
onFitPressed,
|
||||
usePrompt2DStore,
|
||||
}: PromptBox2DProps) => {
|
||||
useSignals();
|
||||
|
||||
// Check subscription status to determine if user is on free plan
|
||||
const hasPaidPlan = useSubscriptionState((s) => s.hasPaidPlan);
|
||||
const isFreeUser = !hasPaidPlan();
|
||||
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [content, setContent] = useState<React.ReactNode>("");
|
||||
const { addJobToken } = useJobContext();
|
||||
|
||||
//const { lastRenderedBitmap } = useCanvasSignal();
|
||||
const prompt = usePrompt2DStore((s) => s.prompt);
|
||||
@@ -91,10 +86,8 @@ export const PromptBox2D = ({
|
||||
const setAspectRatio = usePrompt2DStore((s) => s.setAspectRatio);
|
||||
const resolution = usePrompt2DStore((s) => s.resolution);
|
||||
const setResolution = usePrompt2DStore((s) => s.setResolution);
|
||||
const generationCount = usePrompt2DStore((s) => s.generationCount);
|
||||
const setGenerationCount = usePrompt2DStore((s) => s.setGenerationCount);
|
||||
const [internalEnqueueing, setInternalEnqueueing] = useState(false);
|
||||
|
||||
const [isEnqueueing, setIsEnqueueing] = useState(false);
|
||||
const referenceImages = usePrompt2DStore((s) => s.referenceImages);
|
||||
const setReferenceImages = usePrompt2DStore((s) => s.setReferenceImages);
|
||||
const [showImagePrompts, setShowImagePrompts] = useState(false);
|
||||
@@ -139,7 +132,7 @@ export const PromptBox2D = ({
|
||||
return;
|
||||
}
|
||||
const defaultGenerations = selectedImageModel?.defaultGenerationCount || 4;
|
||||
setGenerationCount(defaultGenerations);
|
||||
onGenerationCountChange?.(defaultGenerations);
|
||||
}, [selectedImageModel, generationCount]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -202,163 +195,84 @@ export const PromptBox2D = ({
|
||||
setPrompt(e.target.value);
|
||||
};
|
||||
|
||||
const handleTauriEnqueue = async () => {
|
||||
const api = new PromptsApi();
|
||||
let image = getCanvasRenderBitmap();
|
||||
if (image === undefined) {
|
||||
console.log("image is undefined");
|
||||
return;
|
||||
}
|
||||
const base64Bitmap = await EncodeImageBitmapToBase64(image);
|
||||
// const handleTauriEnqueue = async () => {
|
||||
// const api = new PromptsApi();
|
||||
// let image = getCanvasRenderBitmap();
|
||||
// if (image === undefined) {
|
||||
// console.log("image is undefined");
|
||||
// return;
|
||||
// }
|
||||
// const base64Bitmap = await EncodeImageBitmapToBase64(image);
|
||||
|
||||
const byteString = atob(base64Bitmap);
|
||||
const mimeString = "image/png";
|
||||
// const byteString = atob(base64Bitmap);
|
||||
// const mimeString = "image/png";
|
||||
|
||||
const ab = new ArrayBuffer(byteString.length);
|
||||
const ia = new Uint8Array(ab);
|
||||
// const ab = new ArrayBuffer(byteString.length);
|
||||
// const ia = new Uint8Array(ab);
|
||||
|
||||
for (let i = 0; i < byteString.length; i++) {
|
||||
ia[i] = byteString.charCodeAt(i);
|
||||
}
|
||||
// for (let i = 0; i < byteString.length; i++) {
|
||||
// ia[i] = byteString.charCodeAt(i);
|
||||
// }
|
||||
|
||||
const uuid = crypto.randomUUID(); // Generate a new UUID
|
||||
const file = new File([ab], `${uuid}.png`, { type: mimeString });
|
||||
// const uuid = crypto.randomUUID(); // Generate a new UUID
|
||||
// const file = new File([ab], `${uuid}.png`, { type: mimeString });
|
||||
|
||||
const snapshotMediaToken = await api.uploadSceneSnapshot({
|
||||
screenshot: file,
|
||||
});
|
||||
// const snapshotMediaToken = await api.uploadSceneSnapshot({
|
||||
// screenshot: file,
|
||||
// });
|
||||
|
||||
if (snapshotMediaToken.data === undefined) {
|
||||
toast.error("Error: Unable to upload scene snapshot Please try again.");
|
||||
return;
|
||||
}
|
||||
// if (snapshotMediaToken.data === undefined) {
|
||||
// toast.error("Error: Unable to upload scene snapshot Please try again.");
|
||||
// return;
|
||||
// }
|
||||
|
||||
console.log("useSystemPrompt", useSystemPrompt);
|
||||
console.log("Snapshot media token:", snapshotMediaToken.data);
|
||||
// console.log("useSystemPrompt", useSystemPrompt);
|
||||
// console.log("Snapshot media token:", snapshotMediaToken.data);
|
||||
|
||||
const aspectRatio = getCurrentAspectRatio();
|
||||
const resolution = getCurrentResolution();
|
||||
// const aspectRatio = getCurrentAspectRatio();
|
||||
// const resolution = getCurrentResolution();
|
||||
|
||||
let request : EnqueueEditImageRequest = {
|
||||
model: selectedImageModel,
|
||||
scene_image_media_token: snapshotMediaToken.data!,
|
||||
image_media_tokens: referenceImages
|
||||
.map((image) => image.mediaToken)
|
||||
.filter((t) => t.length > 0),
|
||||
disable_system_prompt: !useSystemPrompt,
|
||||
prompt: prompt,
|
||||
image_count: generationCount,
|
||||
aspect_ratio: aspectRatio,
|
||||
image_resolution: resolution,
|
||||
};
|
||||
// let request: EnqueueEditImageRequest = {
|
||||
// model: selectedImageModel,
|
||||
// scene_image_media_token: snapshotMediaToken.data!,
|
||||
// image_media_tokens: referenceImages
|
||||
// .map((image) => image.mediaToken)
|
||||
// .filter((t) => t.length > 0),
|
||||
// disable_system_prompt: !useSystemPrompt,
|
||||
// prompt: prompt,
|
||||
// image_count: generationCount,
|
||||
// aspect_ratio: aspectRatio,
|
||||
// image_resolution: resolution,
|
||||
// };
|
||||
|
||||
if (!!selectedProvider) {
|
||||
request.provider = selectedProvider;
|
||||
}
|
||||
// if (!!selectedProvider) {
|
||||
// request.provider = selectedProvider;
|
||||
// }
|
||||
|
||||
const generateResponse = await EnqueueEditImage(request);
|
||||
// const generateResponse = await EnqueueEditImage(request);
|
||||
|
||||
console.log("generateResponse", generateResponse);
|
||||
};
|
||||
// console.log("generateResponse", generateResponse);
|
||||
// };
|
||||
|
||||
const handleWebEnqueue = async () => {
|
||||
const api = new PromptsApi();
|
||||
// to take the snapshot of the canvas
|
||||
// Call onEnqueuePressed if it exists
|
||||
|
||||
let image = getCanvasRenderBitmap();
|
||||
if (image === undefined) {
|
||||
toast.error(
|
||||
"Error: Unable to generate image. Please check the input and try again.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const base64Bitmap = await EncodeImageBitmapToBase64(image);
|
||||
|
||||
const byteString = atob(base64Bitmap);
|
||||
const mimeString = "image/png";
|
||||
|
||||
const ab = new ArrayBuffer(byteString.length);
|
||||
const ia = new Uint8Array(ab);
|
||||
|
||||
for (let i = 0; i < byteString.length; i++) {
|
||||
ia[i] = byteString.charCodeAt(i);
|
||||
}
|
||||
|
||||
const uuid = crypto.randomUUID(); // Generate a new UUID
|
||||
const file = new File([ab], `${uuid}.png`, { type: mimeString });
|
||||
|
||||
const snapshotMediaToken = await api.uploadSceneSnapshot({
|
||||
screenshot: file,
|
||||
});
|
||||
|
||||
if (snapshotMediaToken.data === undefined) {
|
||||
toast.error("Error: Unable to upload scene snapshot Please try again.");
|
||||
return;
|
||||
}
|
||||
console.log("useSystemPrompt", useSystemPrompt);
|
||||
console.log("Snapshot media token:", snapshotMediaToken.data);
|
||||
|
||||
const response = await api.enqueueImageGeneration({
|
||||
disableSystemPrompt: !useSystemPrompt,
|
||||
prompt: prompt,
|
||||
snapshotMediaToken: snapshotMediaToken.data,
|
||||
additionalImages: referenceImages
|
||||
.map((image) => image.mediaToken)
|
||||
.filter((t) => t.length > 0),
|
||||
});
|
||||
|
||||
if (response.success === true) {
|
||||
toast.success("Please wait while we process your image.");
|
||||
if (response.data) {
|
||||
addJobToken(response.data);
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
toast.error("Failed to enqueue image generation. Please try again.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnqueue = async () => {
|
||||
console.log("Pressing Enqueue");
|
||||
if (onEnqueuePressed) {
|
||||
try {
|
||||
await onEnqueuePressed();
|
||||
} catch (error) {
|
||||
console.error("Error in onEnqueuePressed callback:", error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
gtagEvent("enqueue_2d");
|
||||
|
||||
if (isEnqueueing) return;
|
||||
setIsEnqueueing(true);
|
||||
if (!prompt.trim()) return;
|
||||
const handleGenerate = async () => {
|
||||
const busy = Boolean(isEnqueueing ?? internalEnqueueing);
|
||||
if (busy || isDisabled || !prompt.trim()) return;
|
||||
setInternalEnqueueing(true);
|
||||
const timeout = setTimeout(() => {
|
||||
setInternalEnqueueing(false);
|
||||
}, 10000);
|
||||
try {
|
||||
console.log(
|
||||
"(4) Enqueuing with prompt:",
|
||||
prompt,
|
||||
"and reference images:",
|
||||
referenceImages,
|
||||
);
|
||||
|
||||
const isDesktop = IsDesktopApp();
|
||||
console.log("Is this a desktop app?", isDesktop);
|
||||
|
||||
if (isDesktop) {
|
||||
await handleTauriEnqueue();
|
||||
} else {
|
||||
await handleWebEnqueue();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error during image generation:", error);
|
||||
toast.error(
|
||||
"An error occurred while generating the image. Please try again.",
|
||||
await Promise.resolve(
|
||||
onGenerateClick(prompt, {
|
||||
aspectRatio,
|
||||
resolution,
|
||||
images: referenceImages,
|
||||
selectedProvider: selectedProvider,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
setIsEnqueueing(false);
|
||||
clearTimeout(timeout);
|
||||
setInternalEnqueueing(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -369,32 +283,32 @@ export const PromptBox2D = ({
|
||||
return iconElement.props.icon;
|
||||
};
|
||||
|
||||
const getCurrentAspectRatio = (): EnqueueEditImageSize => {
|
||||
const selected = aspectRatioList.find((item) => item.selected);
|
||||
switch (selected?.label.toLowerCase()) {
|
||||
case "wide":
|
||||
return EnqueueEditImageSize.Wide;
|
||||
case "tall":
|
||||
return EnqueueEditImageSize.Tall;
|
||||
case "square":
|
||||
default:
|
||||
return EnqueueEditImageSize.Square;
|
||||
}
|
||||
};
|
||||
// const getCurrentAspectRatio = (): EnqueueEditImageSize => {
|
||||
// const selected = aspectRatioList.find((item) => item.selected);
|
||||
// switch (selected?.label.toLowerCase()) {
|
||||
// case "wide":
|
||||
// return EnqueueEditImageSize.Wide;
|
||||
// case "tall":
|
||||
// return EnqueueEditImageSize.Tall;
|
||||
// case "square":
|
||||
// default:
|
||||
// return EnqueueEditImageSize.Square;
|
||||
// }
|
||||
// };
|
||||
|
||||
const getCurrentResolution = (): EnqueueEditImageResolution | undefined => {
|
||||
const selected = resolutionList.find((item) => item.selected);
|
||||
switch (selected?.label) {
|
||||
case "1k":
|
||||
return EnqueueEditImageResolution.OneK;
|
||||
case "2k":
|
||||
return EnqueueEditImageResolution.TwoK;
|
||||
case "4k":
|
||||
return EnqueueEditImageResolution.FourK;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
// const getCurrentResolution = (): EnqueueEditImageResolution | undefined => {
|
||||
// const selected = resolutionList.find((item) => item.selected);
|
||||
// switch (selected?.label) {
|
||||
// case "1k":
|
||||
// return EnqueueEditImageResolution.OneK;
|
||||
// case "2k":
|
||||
// return EnqueueEditImageResolution.TwoK;
|
||||
// case "4k":
|
||||
// return EnqueueEditImageResolution.FourK;
|
||||
// default:
|
||||
// return undefined;
|
||||
// }
|
||||
// };
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// Stop propagation of keyboard events to prevent them from reaching the canvas
|
||||
@@ -402,7 +316,7 @@ export const PromptBox2D = ({
|
||||
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleEnqueue();
|
||||
handleGenerate();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -579,16 +493,21 @@ export const PromptBox2D = ({
|
||||
currentModel={selectedImageModel}
|
||||
currentCount={generationCount}
|
||||
handleCountChange={(count) => {
|
||||
setGenerationCount(count);
|
||||
onGenerationCountChange?.(count);
|
||||
}}
|
||||
isFreeUser={isFreeUser}
|
||||
/>
|
||||
<Button
|
||||
className="flex items-center border-none bg-primary px-3 text-sm text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||
icon={!isEnqueueing ? faSparkles : undefined}
|
||||
onClick={handleEnqueue}
|
||||
icon={
|
||||
!(isEnqueueing ?? internalEnqueueing) && !isDisabled
|
||||
? faSparkles
|
||||
: undefined
|
||||
}
|
||||
onClick={handleGenerate}
|
||||
disabled={isEnqueueing || !prompt.trim()}
|
||||
>
|
||||
{isEnqueueing ? (
|
||||
{(isEnqueueing ?? internalEnqueueing) ? (
|
||||
<FontAwesomeIcon
|
||||
icon={faSpinnerThird}
|
||||
className="animate-spin text-lg"
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
faSparkles,
|
||||
faSpinnerThird,
|
||||
faFrame,
|
||||
faCopy,
|
||||
faPen,
|
||||
faEraser,
|
||||
faUndo,
|
||||
@@ -25,13 +24,14 @@ import { ButtonIconSelect } from "@storyteller/ui-button-icon-select";
|
||||
import { PopoverMenu, PopoverItem } from "@storyteller/ui-popover";
|
||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { CommonAspectRatio, ImageModel, getCapabilitiesForModel } from "@storyteller/model-list";
|
||||
import { CommonAspectRatio, ImageModel } from "@storyteller/model-list";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { ImagePromptRow, type UploadImageFn } from "./ImagePromptRow";
|
||||
import { RefImage, usePromptEditStore } from "./promptStore";
|
||||
import { GenerationProvider } from "@storyteller/api-enums";
|
||||
import { AspectRatioPicker } from "./common/AspectRatioPicker";
|
||||
import { GenerationCountPicker } from "./common/GenerationCountPicker";
|
||||
import { useSubscriptionState } from "@storyteller/subscription";
|
||||
|
||||
export interface PromptBoxEditProps {
|
||||
onModeChange?: (mode: string) => void;
|
||||
@@ -80,6 +80,10 @@ export const PromptBoxEdit = ({
|
||||
onUndo,
|
||||
onRedo,
|
||||
}: PromptBoxEditProps) => {
|
||||
// Check subscription status to determine if user is on free plan
|
||||
const hasPaidPlan = useSubscriptionState((s) => s.hasPaidPlan);
|
||||
const isFreeUser = !hasPaidPlan();
|
||||
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [useSystemPrompt, setUseSystemPrompt] = useState(true);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
@@ -227,17 +231,17 @@ export const PromptBoxEdit = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerationCountSelect = (selectedItem: PopoverItem) => {
|
||||
const count = parseInt(selectedItem.label, 10);
|
||||
setGenerationCount(count);
|
||||
onGenerationCountChange?.(count);
|
||||
setGenerationCountList((prev) =>
|
||||
prev.map((item) => ({
|
||||
...item,
|
||||
selected: item.label === selectedItem.label,
|
||||
})),
|
||||
);
|
||||
};
|
||||
// const handleGenerationCountSelect = (selectedItem: PopoverItem) => {
|
||||
// const count = parseInt(selectedItem.label, 10);
|
||||
// setGenerationCount(count);
|
||||
// onGenerationCountChange?.(count);
|
||||
// setGenerationCountList((prev) =>
|
||||
// prev.map((item) => ({
|
||||
// ...item,
|
||||
// selected: item.label === selectedItem.label,
|
||||
// })),
|
||||
// );
|
||||
// };
|
||||
|
||||
const handleGenerate = async () => {
|
||||
const busy = Boolean(isEnqueueing ?? internalEnqueueing);
|
||||
@@ -421,28 +425,29 @@ export const PromptBoxEdit = ({
|
||||
handleCommonAspectRatioSelect={setCommonAspectRatio}
|
||||
/>
|
||||
)}
|
||||
{selectedImageModel?.canChangeAspectRatio && !selectedImageModel?.supportsNewAspectRatio() && (
|
||||
<Tooltip
|
||||
content="Aspect Ratio"
|
||||
position="top"
|
||||
className="z-50"
|
||||
closeOnClick={true}
|
||||
>
|
||||
<PopoverMenu
|
||||
items={aspectRatioList}
|
||||
onSelect={handleAspectRatioSelect}
|
||||
mode="toggle"
|
||||
panelTitle="Aspect Ratio"
|
||||
showIconsInList
|
||||
triggerIcon={
|
||||
<FontAwesomeIcon
|
||||
icon={getCurrentAspectRatioIcon()}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{selectedImageModel?.canChangeAspectRatio &&
|
||||
!selectedImageModel?.supportsNewAspectRatio() && (
|
||||
<Tooltip
|
||||
content="Aspect Ratio"
|
||||
position="top"
|
||||
className="z-50"
|
||||
closeOnClick={true}
|
||||
>
|
||||
<PopoverMenu
|
||||
items={aspectRatioList}
|
||||
onSelect={handleAspectRatioSelect}
|
||||
mode="toggle"
|
||||
panelTitle="Aspect Ratio"
|
||||
showIconsInList
|
||||
triggerIcon={
|
||||
<FontAwesomeIcon
|
||||
icon={getCurrentAspectRatioIcon()}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{selectedImageModel?.canChangeResolution && (
|
||||
<Tooltip
|
||||
content="Resolution"
|
||||
@@ -504,6 +509,7 @@ export const PromptBoxEdit = ({
|
||||
handleCountChange={(count) => {
|
||||
onGenerationCountChange?.(count);
|
||||
}}
|
||||
isFreeUser={isFreeUser}
|
||||
/>
|
||||
<Button
|
||||
className="flex items-center border-none bg-primary px-3 text-sm text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
faMessageCheck,
|
||||
faSparkles,
|
||||
faSpinnerThird,
|
||||
faCopy,
|
||||
faExpand,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import {
|
||||
@@ -27,7 +26,7 @@ import {
|
||||
} from "@fortawesome/pro-regular-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { IconProp } from "@fortawesome/fontawesome-svg-core";
|
||||
import { CommonAspectRatio, ImageModel, getCapabilitiesForModel } from "@storyteller/model-list";
|
||||
import { CommonAspectRatio, ImageModel } from "@storyteller/model-list";
|
||||
import { usePromptImageStore, RefImage } from "./promptStore";
|
||||
import { gtagEvent } from "@storyteller/google-analytics";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
@@ -35,6 +34,9 @@ import { ImagePromptRow } from "./ImagePromptRow";
|
||||
import { GenerationProvider } from "@storyteller/api-enums";
|
||||
import { AspectRatioPicker } from "./common/AspectRatioPicker";
|
||||
import { GenerationCountPicker } from "./common/GenerationCountPicker";
|
||||
import { ResolutionPicker } from "./common/ResolutionPicker";
|
||||
import { CommonResolution } from "@storyteller/model-list";
|
||||
import { useSubscriptionState } from "@storyteller/subscription";
|
||||
|
||||
interface PromptBoxImageProps {
|
||||
useJobContext: () => JobContextType;
|
||||
@@ -71,6 +73,10 @@ export const PromptBoxImage = ({
|
||||
}: PromptBoxImageProps) => {
|
||||
useSignals();
|
||||
|
||||
// Check subscription status to determine if user is on free plan
|
||||
const hasPaidPlan = useSubscriptionState((s) => s.hasPaidPlan);
|
||||
const isFreeUser = !hasPaidPlan();
|
||||
|
||||
console.debug("Selected model:", selectedModel);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -112,7 +118,14 @@ export const PromptBoxImage = ({
|
||||
uploadingImages.length > 0;
|
||||
|
||||
// New aspect ratio state will begin to phase out old aspect ratio
|
||||
const [commonAspectRatio, setCommonAspectRatio] = useState<CommonAspectRatio | undefined>(undefined);
|
||||
const [commonAspectRatio, setCommonAspectRatio] = useState<
|
||||
CommonAspectRatio | undefined
|
||||
>(undefined);
|
||||
|
||||
// New resolution state will begin to phase out old resolution
|
||||
const [commonResolution, setCommonResolution] = useState<
|
||||
CommonResolution | undefined
|
||||
>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
onImageRowVisibilityChange?.(isImageRowVisible);
|
||||
@@ -280,6 +293,10 @@ export const PromptBoxImage = ({
|
||||
request.common_aspect_ratio = commonAspectRatio;
|
||||
}
|
||||
|
||||
if (selectedModel?.supportsNewResolution()) {
|
||||
request.common_resolution = commonResolution;
|
||||
}
|
||||
|
||||
if (
|
||||
selectedModel?.canUseImagePrompt &&
|
||||
!!referenceImages &&
|
||||
@@ -450,47 +467,56 @@ export const PromptBoxImage = ({
|
||||
handleCommonAspectRatioSelect={setCommonAspectRatio}
|
||||
/>
|
||||
)}
|
||||
{selectedModel?.canChangeAspectRatio && !selectedModel?.supportsNewAspectRatio() && (
|
||||
<Tooltip
|
||||
content="Aspect Ratio (Legacy)"
|
||||
position="top"
|
||||
className="z-50"
|
||||
closeOnClick={true}
|
||||
>
|
||||
<PopoverMenu
|
||||
items={aspectRatioList}
|
||||
onSelect={handleAspectRatioSelect}
|
||||
mode="toggle"
|
||||
panelTitle="Aspect Ratio (Legacy)"
|
||||
showIconsInList
|
||||
triggerIcon={
|
||||
<FontAwesomeIcon
|
||||
icon={getCurrentResolutionIcon()}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{selectedModel?.canChangeResolution && (
|
||||
<Tooltip
|
||||
content="Resolution"
|
||||
position="top"
|
||||
className="z-50"
|
||||
closeOnClick={true}
|
||||
>
|
||||
<PopoverMenu
|
||||
items={resolutionList}
|
||||
onSelect={handleResolutionSelect}
|
||||
mode="toggle"
|
||||
panelTitle="Resolution"
|
||||
showIconsInList
|
||||
triggerIcon={
|
||||
<FontAwesomeIcon icon={faExpand} className="h-4 w-4" />
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
{selectedModel?.canChangeAspectRatio &&
|
||||
!selectedModel?.supportsNewAspectRatio() && (
|
||||
<Tooltip
|
||||
content="Aspect Ratio (Legacy)"
|
||||
position="top"
|
||||
className="z-50"
|
||||
closeOnClick={true}
|
||||
>
|
||||
<PopoverMenu
|
||||
items={aspectRatioList}
|
||||
onSelect={handleAspectRatioSelect}
|
||||
mode="toggle"
|
||||
panelTitle="Aspect Ratio (Legacy)"
|
||||
showIconsInList
|
||||
triggerIcon={
|
||||
<FontAwesomeIcon
|
||||
icon={getCurrentResolutionIcon()}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{selectedModel?.supportsNewResolution() && (
|
||||
<ResolutionPicker
|
||||
model={selectedModel}
|
||||
currentResolution={commonResolution}
|
||||
handleCommonResolutionSelect={setCommonResolution}
|
||||
/>
|
||||
)}
|
||||
{selectedModel?.canChangeResolution &&
|
||||
!selectedModel?.supportsNewResolution() && (
|
||||
<Tooltip
|
||||
content="Resolution"
|
||||
position="top"
|
||||
className="z-50"
|
||||
closeOnClick={true}
|
||||
>
|
||||
<PopoverMenu
|
||||
items={resolutionList}
|
||||
onSelect={handleResolutionSelect}
|
||||
mode="toggle"
|
||||
panelTitle="Resolution"
|
||||
showIconsInList
|
||||
triggerIcon={
|
||||
<FontAwesomeIcon icon={faExpand} className="h-4 w-4" />
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip
|
||||
content={
|
||||
useSystemPrompt
|
||||
@@ -516,6 +542,7 @@ export const PromptBoxImage = ({
|
||||
handleCountChange={(count) => {
|
||||
setGenerationCount(count);
|
||||
}}
|
||||
isFreeUser={isFreeUser}
|
||||
/>
|
||||
<Button
|
||||
className="flex items-center border-none bg-primary px-3 text-sm text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user