Compare commits

...
Author SHA1 Message Date
Brandon Thomas bf2a3ad56f update pricing table 2026-01-27 05:34:18 -05:00
bflatastic 7b2b71eaf0 pricing stuff updates 2026-01-27 05:24:32 -05:00
bflatastic 4883185ad8 updates 2026-01-27 05:24:31 -05:00
bflatastic a3b9cd71ad sign up download flow 2026-01-27 05:24:27 -05:00
Brandon Thomas b1965a6019 "what year is it?!" 2026-01-27 04:45:31 -05:00
Brandon Thomas 29b1468e1a website copy improvements (#1017) 2026-01-27 03:17:41 -05:00
Brandon Thomas 1008b6f69a press kit - more videos (#1016)
if you're reading this please help us market this. idk what I'm doing. I
just want to build stuff and make movies.
2026-01-27 02:20:02 -05:00
BFlat f797d6f769 Feature/website press kit page (#1012)
website press kit page for downloadable media
2026-01-27 00:06:31 -05:00
Brandon Thomas 3cb2c31aa7 rename 2d and 3d pages; change icon (#1015) 2026-01-27 00:05:00 -05:00
Brandon Thomas 6f64086834 fix how spz files are manually uploaded in tauri (#1014) 2026-01-26 19:15:01 -05:00
bflatastic 170ee9b069 generation count tooltip for non subbed users 2026-01-26 13:18:18 +07:00
Brandon Thomas 75ab5887f7 dedicated spz upload handler (#1011) 2026-01-25 20:13:37 -05:00
66 changed files with 3276 additions and 526 deletions
@@ -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,
@@ -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;
@@ -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,
}))
}
@@ -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
+19 -9
View File
@@ -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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

+27 -2
View File
@@ -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">
&copy; 2025 ArtCraft. All rights reserved.
&copy; 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>
@@ -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&rsquo;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">
&copy; {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">
&copy; {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;
+20 -1
View File
@@ -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"
}
]
}
+9 -3
View File
@@ -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,
};
});
}
}
@@ -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,
@@ -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";
@@ -48,15 +48,15 @@ export const APP_DESCRIPTORS: AppDescriptor[] = [
},
{
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.",
@@ -163,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",
@@ -1,5 +1,6 @@
export enum SubscriptionNamespace {
FAKEYOU = "fakeyou",
ARTCRAFT = "artcraft",
}
export enum SubscriptionProduct {
PLUS = "fakeyou_plus",
+12 -6
View File
@@ -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,
@@ -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,23 +19,15 @@ 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 { Prompt2DStore, RefImage } from "./promptStore";
import { gtagEvent } from "@storyteller/google-analytics";
import { getCapabilitiesForModel } from "@storyteller/model-list";
import { ImageModel } from "@storyteller/model-list";
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";
@@ -85,6 +70,10 @@ export const PromptBox2D = ({
}: 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>("");
@@ -206,64 +195,64 @@ 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 handleGenerate = async () => {
const busy = Boolean(isEnqueueing ?? internalEnqueueing);
@@ -294,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
@@ -370,8 +359,8 @@ export const PromptBox2D = ({
className={twMerge(
"glass w-[730px] rounded-xl p-4",
selectedImageModel?.canUseImagePrompt &&
isImageRowVisible &&
"rounded-t-none",
isImageRowVisible &&
"rounded-t-none",
)}
>
<div className="flex justify-center gap-2">
@@ -417,8 +406,8 @@ export const PromptBox2D = ({
onChange={handleChange}
onPaste={handlePaste}
onKeyDown={handleKeyDown}
onFocus={() => { }}
onBlur={() => { }}
onFocus={() => {}}
onBlur={() => {}}
/>
</div>
<div className="mt-2 flex items-center justify-between gap-2">
@@ -506,6 +495,7 @@ export const PromptBox2D = ({
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"
@@ -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";
@@ -37,6 +36,7 @@ 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;
@@ -73,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(() => {
@@ -114,10 +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);
const [commonResolution, setCommonResolution] = useState<
CommonResolution | undefined
>(undefined);
useEffect(() => {
onImageRowVisibilityChange?.(isImageRowVisible);
@@ -459,28 +467,29 @@ 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?.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}
@@ -488,25 +497,26 @@ export const PromptBoxImage = ({
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>
)}
{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
@@ -532,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"
@@ -1,33 +1,46 @@
import { faCopy } from "@fortawesome/pro-solid-svg-icons"
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
import { faCopy, faCrown } from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { PopoverMenu, PopoverItem } from "@storyteller/ui-popover";
import { Tooltip } from "@storyteller/ui-tooltip";
import { ImageModel } from "@storyteller/model-list";
const DEFAULT_GENERATION_COUNT : number = 4;
const DEFAULT_GENERATION_COUNT: number = 4;
interface GenerationCountPickerProps {
currentModel?: ImageModel,
currentCount: number,
handleCountChange: (count: number) => void,
currentModel?: ImageModel;
currentCount: number;
handleCountChange: (count: number) => void;
isFreeUser?: boolean;
}
export const GenerationCountPicker = ({
export const GenerationCountPicker = ({
currentModel,
currentCount,
handleCountChange
handleCountChange,
isFreeUser = false,
}: GenerationCountPickerProps) => {
const maxGenerationCount = currentModel?.maxGenerationCount || DEFAULT_GENERATION_COUNT;
const maxGenerationCount =
currentModel?.maxGenerationCount || DEFAULT_GENERATION_COUNT;
const hasPredefinedOptions = !!currentModel?.predefinedGenerationCounts;
const isNanoBananaPro = currentModel?.id === "nano_banana_pro";
const shouldLimitGenerations = isFreeUser && isNanoBananaPro;
let generationCountOptions: PopoverItem[];
// Count pickers either have a "[1,2, ... max]" set of options, or a pre-defined list of options.
if (hasPredefinedOptions) {
generationCountOptions = buildPredefinedCountOptions(currentModel?.predefinedGenerationCounts || [], currentCount);
// Free users on Nano Banana Pro are limited to 1 generation at a time
if (shouldLimitGenerations) {
generationCountOptions = [{ label: "1", selected: true }];
} else if (hasPredefinedOptions) {
// Count pickers either have a "[1,2, ... max]" set of options, or a pre-defined list of options.
generationCountOptions = buildPredefinedCountOptions(
currentModel?.predefinedGenerationCounts || [],
currentCount,
);
} else {
generationCountOptions = buildSequentialCountOptions(maxGenerationCount, currentCount);
generationCountOptions = buildSequentialCountOptions(
maxGenerationCount,
currentCount,
);
}
const onSelect = (item: PopoverItem) => {
@@ -42,30 +55,47 @@ export const GenerationCountPicker = ({
handleCountChange(count);
};
// Tooltip content changes based on subscription status (only for Nano Banana Pro)
const tooltipContent = shouldLimitGenerations ? (
<div className="flex flex-col gap-1 max-w-[200px]">
<div className="flex items-center gap-1.5 font-medium">
<FontAwesomeIcon icon={faCrown} className="h-3 w-3 text-yellow-400" />
<span>Generate more images</span>
</div>
<p className="text-xs text-base-fg/70">
Subscribe to ArtCraft to generate multiple images at once.
</p>
</div>
) : (
"Number of generations"
);
return (
<>
<Tooltip
content="Number of generations"
content={tooltipContent}
position="top"
className="z-50"
closeOnClick={true}
delay={0}
>
<PopoverMenu
items={generationCountOptions}
onSelect={onSelect}
mode="toggle"
panelTitle="No. of images"
triggerIcon={
<FontAwesomeIcon icon={faCopy} className="h-4 w-4" />
}
triggerIcon={<FontAwesomeIcon icon={faCopy} className="h-4 w-4" />}
buttonClassName="h-9"
/>
</Tooltip>
</>
)
}
);
};
const buildSequentialCountOptions = (maxCount: number, currentCount: number): PopoverItem[] => {
const buildSequentialCountOptions = (
maxCount: number,
currentCount: number,
): PopoverItem[] => {
const options = [];
for (let i = 0; i < maxCount; i++) {
const count = i + 1;
@@ -75,11 +105,14 @@ const buildSequentialCountOptions = (maxCount: number, currentCount: number): Po
});
}
return options;
}
};
const buildPredefinedCountOptions = (options: number[], currentCount: number): PopoverItem[] => {
const buildPredefinedCountOptions = (
options: number[],
currentCount: number,
): PopoverItem[] => {
const result: PopoverItem[] = [];
options.forEach(option => {
options.forEach((option) => {
result.push({
label: String(option),
selected: option === currentCount,
@@ -1,25 +1,27 @@
import { faSquare, IconDefinition } from "@fortawesome/pro-regular-svg-icons"
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
import { PopoverItem, PopoverMenu } from "@storyteller/ui-popover"
import { Tooltip } from "@storyteller/ui-tooltip"
import { CommonResolution, ImageModel } from "@storyteller/model-list"
import { faDiamond, faExpand, faHighDefinition, faRectangleHd, faStandardDefinition } from "@fortawesome/pro-solid-svg-icons"
import { faSquare, IconDefinition } from "@fortawesome/pro-regular-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { PopoverItem, PopoverMenu } from "@storyteller/ui-popover";
import { Tooltip } from "@storyteller/ui-tooltip";
import { CommonResolution, ImageModel } from "@storyteller/model-list";
import {
faHighDefinition,
faStandardDefinition,
} from "@fortawesome/pro-solid-svg-icons";
interface ResolutionPickerProps {
model: ImageModel,
currentResolution?: CommonResolution,
model: ImageModel;
currentResolution?: CommonResolution;
handleCommonResolutionSelect: (selected: CommonResolution) => void;
//model: ImageModel | VideoModel
}
/**
* Stateless component.
*
* Picker for "common aspect ratios", the new data structure Tauri accepts for
* all image and video models (Note: this is not fully rolled out yet. Some/most
*
* Picker for "common aspect ratios", the new data structure Tauri accepts for
* all image and video models (Note: this is not fully rolled out yet. Some/most
* models may still use the old format.)
*
*
* @param model - currently selected model
* @param currentAspectRatio - currently selected aspect ratio
* @param handleCommonResolutionSelect - callback when a resolution is selected
@@ -29,39 +31,44 @@ export const ResolutionPicker = ({
currentResolution,
handleCommonResolutionSelect,
}: ResolutionPickerProps) => {
const useResolution = currentResolution ?? model.defaultResolution ?? undefined;
const useResolution =
currentResolution ?? model.defaultResolution ?? undefined;
console.log("resolution - currentResolution:", currentResolution);
console.log("resolution - useResolution:", useResolution);
const getCurrentResolutionIcon = () : IconDefinition => {
const getCurrentResolutionIcon = (): IconDefinition => {
if (!useResolution) {
return faSquare;
}
return getResolutionIcon(useResolution);
}
};
const handleSelectAdapter = (item: PopoverItem) => {
const resolution = popOverLabelToResolution(item.label, model);
handleCommonResolutionSelect(resolution);
}
};
let resolutionList : PopoverItem[] = [];
let resolutionList: PopoverItem[] = [];
model.resolutions?.forEach((resolution: CommonResolution) => {
resolutionList.push({
label: getResolutionTextLabel(resolution),
selected: useResolution === resolution,
description: `foo ${resolution}`,
icon: <FontAwesomeIcon icon={getResolutionIcon(resolution)} className="h-4 w-4" />,
icon: (
<FontAwesomeIcon
icon={getResolutionIcon(resolution)}
className="h-4 w-4"
/>
),
});
});
return (
<>
<Tooltip
content="Aspect Ratio"
content="Resolution"
position="top"
className="z-50"
closeOnClick={true}
@@ -81,11 +88,10 @@ export const ResolutionPicker = ({
/>
</Tooltip>
</>
)
}
);
};
const getResolutionIcon = (resolution: CommonResolution) : IconDefinition => {
const getResolutionIcon = (resolution: CommonResolution): IconDefinition => {
switch (resolution) {
case CommonResolution.OneK:
return faStandardDefinition;
@@ -97,9 +103,9 @@ const getResolutionIcon = (resolution: CommonResolution) : IconDefinition => {
console.error("Unknown resolution in icon mapping:", resolution);
return faStandardDefinition; // Fail open-ish
}
}
};
const getResolutionTextLabel = (resolution: CommonResolution) : string => {
const getResolutionTextLabel = (resolution: CommonResolution): string => {
switch (resolution) {
case CommonResolution.OneK:
return "1K";
@@ -111,16 +117,22 @@ const getResolutionTextLabel = (resolution: CommonResolution) : string => {
console.error("Unknown resolution:", resolution);
return "1K"; // Fail open-ish
}
}
};
// Note: We only need this to deal with turning PopOverItems back into typesafe aspect ratios
const popOverLabelToResolution = (label: string, model: ImageModel): CommonResolution => {
const popOverLabelToResolution = (
label: string,
model: ImageModel,
): CommonResolution => {
switch (label) {
case "1K": return CommonResolution.OneK;
case "2K": return CommonResolution.TwoK;
case "4K": return CommonResolution.FourK;
case "1K":
return CommonResolution.OneK;
case "2K":
return CommonResolution.TwoK;
case "4K":
return CommonResolution.FourK;
}
console.error("Unknown resolution label:", label, "for model:", model.id);
// If we can't find it, return the model's default resolution or 1K as fallback
return model.defaultResolution || CommonResolution.OneK;
}
};
@@ -8,6 +8,9 @@
{
"path": "../camera-settings-modal"
},
{
"path": "../../state/subscription"
},
{
"path": "../../api-enums"
},
@@ -49,6 +49,9 @@
{
"path": "../camera-settings-modal/tsconfig.lib.json"
},
{
"path": "../../state/subscription/tsconfig.lib.json"
},
{
"path": "../../api-enums/tsconfig.lib.json"
},
@@ -26,10 +26,10 @@ export const SUBSCRIPTION_PLANS: SubscriptionPlanDetails[] = [
originalMonthlyPrice: 10,
originalYearlyPrice: 120,
features: [
{ text: "~1,000 Flux images", included: true },
{ text: "~100 GPT-Image-1 images", included: true },
{ text: "~10 minutes Kling video", included: true },
{ text: "Commercial license", included: true },
{ text: "~10,000 Nano Banana Pro 4K images", included: true },
{ text: "~10,000 GPT-Image-1.5 images", included: true },
{ text: "~60 minutes Kling video", included: true },
{ text: "You Own ArtCraft Forever", included: true },
],
colorScheme: "green" as const,
},
@@ -42,10 +42,10 @@ export const SUBSCRIPTION_PLANS: SubscriptionPlanDetails[] = [
originalMonthlyPrice: 35,
originalYearlyPrice: 420,
features: [
{ text: "~5,000 Flux images", included: true },
{ text: "~300 GPT-Image-1 images", included: true },
{ text: "~30 minutes Kling video", included: true },
{ text: "Commercial license", included: true },
{ text: "~50,000 Nano Banana Pro 4K images", included: true },
{ text: "~50,000 GPT-Image-1.5 images", included: true },
{ text: "~2 hours Kling video", included: true },
{ text: "You Own ArtCraft Forever", included: true },
],
colorScheme: "purple" as const,
},
@@ -58,10 +58,10 @@ export const SUBSCRIPTION_PLANS: SubscriptionPlanDetails[] = [
originalMonthlyPrice: 60,
originalYearlyPrice: 720,
features: [
{ text: "~15,000 Flux images", included: true },
{ text: "~500 GPT-Image-1 images", included: true },
{ text: "~60 minutes Kling video", included: true },
{ text: "Commercial license", included: true },
{ text: "~100,000 Nano Banana Pro 4K images", included: true },
{ text: "~100,000 GPT-Image-1.5 images", included: true },
{ text: "~6 hours Kling video", included: true },
{ text: "You Own ArtCraft Forever", included: true },
],
colorScheme: "orange" as const,
},
File diff suppressed because one or more lines are too long