Compare commits

..
Author SHA1 Message Date
Brandon Thomas b9005b0b04 refactor endpoint calls in storyteller_client 2025-09-27 17:04:54 -04:00
74 changed files with 686 additions and 1541 deletions
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO analytics_app_sessions\nSET\n session_token = ?,\n app_namespace = ?,\n user_token = ?,\n app_version = ?,\n os_platform = ?,\n os_version = ?,\n session_duration_seconds = ?,\n ip_address = ?,\n measurement_count = measurement_count + 1,\n first_active_at = NOW(),\n last_active_at = NOW()\nON DUPLICATE KEY UPDATE\n app_namespace = ?,\n user_token = ?,\n app_version = ?,\n os_platform = ?,\n os_version = ?,\n session_duration_seconds = ?,\n ip_address = ?,\n measurement_count = measurement_count + 1,\n last_active_at = NOW()\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 15
},
"nullable": []
},
"hash": "ff39576a5ec6a23c67f6f61e7eba8392e70b05c42b540bea260a9a4cdc478a5b"
}
@@ -2,8 +2,6 @@
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
-- Track user activity over time
-- User clients ping the endpoint that writes this data.
CREATE TABLE analytics_app_active_users (
-- Not used for anything except replication.
id BIGINT(20) NOT NULL AUTO_INCREMENT,
@@ -1,5 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
drop table analytics_app_sessions;
@@ -1,65 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
-- Track user session durations
-- User clients ping the endpoint that writes this data.
CREATE TABLE analytics_app_sessions (
-- Not used for anything except replication.
id BIGINT(20) NOT NULL AUTO_INCREMENT,
-- Generated client-side
-- Wide enough for a UUID, but typically one of our internal tokens.
session_token VARCHAR(36) NOT NULL,
-- If we host multiple sites with distinct categories, this will enable us to
-- segregate analytics.
app_namespace VARCHAR(32) NOT NULL,
-- Who sent the event.
user_token VARCHAR(32) NOT NULL,
-- Updated on every write.
-- The "user agent" or app version string of the client.
app_version VARCHAR(255) DEFAULT NULL,
-- Updated on every write.
-- The OS platform for the client.
os_platform VARCHAR(16) DEFAULT NULL,
-- Updated on every write.
-- The OS platform version for the client.
os_version VARCHAR(64) DEFAULT NULL,
-- Updated on every write.
-- How long the current user's session has been open
session_duration_seconds INT(10) UNSIGNED DEFAULT NULL,
-- Updated on every write.
-- The user's last known IP address.
ip_address VARCHAR(40) NOT NULL,
-- Incrementing count of events.
-- NB: This is kind of an "hours spent" measure, but will lose information if we ever
-- change the ping cadence (eg. 1 minute pings --> 5 minute pings).
measurement_count BIGINT UNSIGNED NOT NULL DEFAULT 0,
-- ========== RECORD TIMESTAMPS ==========
-- Written at create only.
first_active_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- Updated on every ping.
last_active_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- ========== INDICES ==========
PRIMARY KEY (id),
UNIQUE KEY (session_token),
INDEX idx_app_namespace (app_namespace),
INDEX idx_user_token (user_token),
INDEX idx_os_platform (os_platform),
INDEX idx_os_version (os_version),
INDEX idx_app_version (app_version),
INDEX idx_last_active_at (last_active_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -1,65 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
-- Track user session durations
-- User clients ping the endpoint that writes this data.
CREATE TABLE analytics_app_sessions (
-- Not used for anything except replication.
id BIGINT(20) NOT NULL AUTO_INCREMENT,
-- Generated client-side
-- Wide enough for a UUID, but typically one of our internal tokens.
session_token VARCHAR(36) NOT NULL,
-- If we host multiple sites with distinct categories, this will enable us to
-- segregate analytics.
app_namespace VARCHAR(32) NOT NULL,
-- Who sent the event.
user_token VARCHAR(32) NOT NULL,
-- Updated on every write.
-- The "user agent" or app version string of the client.
app_version VARCHAR(255) DEFAULT NULL,
-- Updated on every write.
-- The OS platform for the client.
os_platform VARCHAR(16) DEFAULT NULL,
-- Updated on every write.
-- The OS platform version for the client.
os_version VARCHAR(64) DEFAULT NULL,
-- Updated on every write.
-- How long the current user's session has been open
session_duration_seconds INT(10) UNSIGNED DEFAULT NULL,
-- Updated on every write.
-- The user's last known IP address.
ip_address VARCHAR(40) NOT NULL,
-- Incrementing count of events.
-- NB: This is kind of an "hours spent" measure, but will lose information if we ever
-- change the ping cadence (eg. 1 minute pings --> 5 minute pings).
measurement_count BIGINT UNSIGNED NOT NULL DEFAULT 0,
-- ========== RECORD TIMESTAMPS ==========
-- Written at create only.
first_active_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- Updated on every ping.
last_active_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- ========== INDICES ==========
PRIMARY KEY (id),
UNIQUE KEY (session_token),
INDEX idx_app_namespace (app_namespace),
INDEX idx_user_token (user_token),
INDEX idx_os_platform (os_platform),
INDEX idx_os_version (os_version),
INDEX idx_app_version (app_version),
INDEX idx_last_active_at (last_active_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -1,15 +1,10 @@
use serde_derive::{Deserialize, Serialize};
use tokens::tokens::app_session::AppSessionToken;
use utoipa::ToSchema;
pub const LOG_ACTIVE_USER_PATH: &str = "/v1/analytics/active_user";
#[derive(Serialize, Deserialize, ToSchema, Debug)]
pub struct LogAppActiveUserRequest {
/// Clientside-generated session token.
/// If present, it'll be validated against an expected format.
pub maybe_app_session_token: Option<AppSessionToken>,
/// An override for the application platform/OS (windows, mac, linux).
pub maybe_os_platform: Option<String>,
@@ -15,10 +15,6 @@ pub async fn log_active_user(
let mut query = HashMap::new();
if let Some(token) = request.maybe_app_session_token {
query.insert("maybe_app_session_token".to_string(), token.to_string());
}
if let Some(os_platform) = request.maybe_os_platform {
query.insert("maybe_os_platform".to_string(), os_platform);
}
@@ -1,18 +0,0 @@
use crate::credentials::storyteller_credential_set::StorytellerCredentialSet;
use crate::error::storyteller_error::StorytellerError;
use crate::utils::api_host::ApiHost;
use crate::utils::basic_json_post_request::basic_json_post_request;
use artcraft_api_defs::generate::video::generate_veo_3_fast_image_to_video::{GenerateVeo3FastImageToVideoRequest, GenerateVeo3FastImageToVideoResponse, GENERATE_VEO_3_FAST_IMAGE_TO_VIDEO_URL_PATH};
pub async fn generate_veo_3_fast_image_to_video(
api_host: &ApiHost,
maybe_creds: Option<&StorytellerCredentialSet>,
request: GenerateVeo3FastImageToVideoRequest,
) -> Result<GenerateVeo3FastImageToVideoResponse, StorytellerError> {
Ok(basic_json_post_request(
api_host,
GENERATE_VEO_3_FAST_IMAGE_TO_VIDEO_URL_PATH,
maybe_creds,
request,
).await?)
}
@@ -1,18 +0,0 @@
use crate::credentials::storyteller_credential_set::StorytellerCredentialSet;
use crate::error::storyteller_error::StorytellerError;
use crate::utils::api_host::ApiHost;
use crate::utils::basic_json_post_request::basic_json_post_request;
use artcraft_api_defs::generate::video::generate_veo_3_image_to_video::{GenerateVeo3ImageToVideoRequest, GenerateVeo3ImageToVideoResponse, GENERATE_VEO_3_IMAGE_TO_VIDEO_URL_PATH};
pub async fn generate_veo_3_image_to_video(
api_host: &ApiHost,
maybe_creds: Option<&StorytellerCredentialSet>,
request: GenerateVeo3ImageToVideoRequest,
) -> Result<GenerateVeo3ImageToVideoResponse, StorytellerError> {
Ok(basic_json_post_request(
api_host,
GENERATE_VEO_3_IMAGE_TO_VIDEO_URL_PATH,
maybe_creds,
request,
).await?)
}
@@ -2,6 +2,4 @@ pub mod generate_kling_16_pro_image_to_video;
pub mod generate_kling_21_master_image_to_video;
pub mod generate_kling_21_pro_image_to_video;
pub mod generate_seedance_1_0_lite_image_to_video;
pub mod generate_veo_2_image_to_video;
pub mod generate_veo_3_fast_image_to_video;
pub mod generate_veo_3_image_to_video;
pub mod generate_veo_2_image_to_video;
@@ -25,7 +25,7 @@ pub async fn basic_query_string_post_request<Res: DeserializeOwned>(
// TODO: Please stop using this URL Builder library. It's not very safe or intuitive.
let url = get_route(api_host, route_path, query_params);
debug!("Requesting {:?}", &url);
info!("Requesting {:?}", &url);
let client = Client::builder()
.gzip(true)
@@ -41,12 +41,6 @@ pub enum VideoModel {
#[serde(rename = "veo_2")]
Veo2,
#[serde(rename = "veo_3")]
Veo3,
#[serde(rename = "veo_3_fast")]
Veo3Fast,
}
#[derive(Deserialize)]
@@ -13,8 +13,6 @@ use artcraft_api_defs::generate::video::generate_kling_2_1_master_image_to_video
use artcraft_api_defs::generate::video::generate_kling_2_1_pro_image_to_video::{GenerateKling21ProAspectRatio, GenerateKling21ProImageToVideoRequest};
use artcraft_api_defs::generate::video::generate_seedance_1_0_lite_image_to_video::GenerateSeedance10LiteImageToVideoRequest;
use artcraft_api_defs::generate::video::generate_veo_2_image_to_video::{GenerateVeo2AspectRatio, GenerateVeo2ImageToVideoRequest};
use artcraft_api_defs::generate::video::generate_veo_3_fast_image_to_video::GenerateVeo3FastImageToVideoRequest;
use artcraft_api_defs::generate::video::generate_veo_3_image_to_video::{GenerateVeo3AspectRatio, GenerateVeo3ImageToVideoRequest};
use enums::common::generation_provider::GenerationProvider;
use enums::tauri::tasks::task_type::TaskType;
use fal_client::requests::queue::image_gen::enqueue_flux_pro_11_ultra_text_to_image::{enqueue_flux_pro_11_ultra_text_to_image, FluxPro11UltraTextToImageArgs};
@@ -27,8 +25,6 @@ use storyteller_client::endpoints::generate::video::generate_kling_21_master_ima
use storyteller_client::endpoints::generate::video::generate_kling_21_pro_image_to_video::generate_kling_21_pro_image_to_video;
use storyteller_client::endpoints::generate::video::generate_seedance_1_0_lite_image_to_video::generate_seedance_1_0_lite_image_to_video;
use storyteller_client::endpoints::generate::video::generate_veo_2_image_to_video::generate_veo_2_image_to_video;
use storyteller_client::endpoints::generate::video::generate_veo_3_fast_image_to_video::generate_veo_3_fast_image_to_video;
use storyteller_client::endpoints::generate::video::generate_veo_3_image_to_video::generate_veo_3_image_to_video;
use storyteller_client::utils::api_host::ApiHost;
use tauri::AppHandle;
@@ -206,61 +202,6 @@ pub async fn handle_video_artcraft(
}
}
}
Some(VideoModel::Veo3) => {
info!("enqueue Veo 3 with Artcraft API");
selected_model = Some(GenerationModel::Veo3);
let request = GenerateVeo3ImageToVideoRequest {
uuid_idempotency_token,
media_file_token: request.image_media_token,
aspect_ratio: Some(GenerateVeo3AspectRatio::WideSixteenNine),
prompt: request.prompt,
duration: None,
resolution: None,
generate_audio: None,
};
let result = generate_veo_3_image_to_video(
&app_env_configs.storyteller_host,
Some(&creds),
request,
).await;
match result {
Ok(enqueued) => {
info!("Successfully enqueued Artcraft Veo 3 video generation");
enqueued.inference_job_token
}
Err(err) => {
error!("Failed to use Artcraft Veo 3 video generation: {:?}", err);
return Err(GenerateError::from(err));
}
}
}
Some(VideoModel::Veo3Fast) => {
info!("enqueue Veo 3 Fast with Artcraft API");
selected_model = Some(GenerationModel::Veo3Fast);
let request = GenerateVeo3FastImageToVideoRequest {
uuid_idempotency_token,
media_file_token: request.image_media_token,
prompt: request.prompt,
duration: None,
resolution: None,
generate_audio: None,
};
let result = generate_veo_3_fast_image_to_video(
&app_env_configs.storyteller_host,
Some(&creds),
request,
).await;
match result {
Ok(enqueued) => {
info!("Successfully enqueued Artcraft Veo 3 video generation");
enqueued.inference_job_token
}
Err(err) => {
error!("Failed to use Artcraft Veo 3 video generation: {:?}", err);
return Err(GenerateError::from(err));
}
}
}
};
info!("Successfully enqueued video generation");
@@ -71,8 +71,6 @@ impl TaskEnqueueSuccess{
Some(GenerationModel::Kling21Master) => Some(TaskModelType::Kling21Master),
Some(GenerationModel::Seedance10Lite) => Some(TaskModelType::Seedance10Lite),
Some(GenerationModel::Veo2) => Some(TaskModelType::Veo2),
Some(GenerationModel::Veo3) => Some(TaskModelType::Veo3),
Some(GenerationModel::Veo3Fast) => Some(TaskModelType::Veo3Fast),
Some(GenerationModel::Hunyuan3d2_0) => Some(TaskModelType::Hunyuan3d2_0),
Some(GenerationModel::Hunyuan3d2_1) => Some(TaskModelType::Hunyuan3d2_1),
Some(GenerationModel::Midjourney) => Some(TaskModelType::Midjourney), // NB: This is a generic Midjourney model, version unknown.
@@ -59,10 +59,6 @@ pub enum GenerationModel {
Seedance10Lite,
#[serde(rename = "veo_2")]
Veo2,
#[serde(rename = "veo_3")]
Veo3,
#[serde(rename = "veo_3_fast")]
Veo3Fast,
}
#[derive(Debug, Copy, Clone, Serialize)]
@@ -1,7 +1,6 @@
use crate::core::events::basic_sendable_event_trait::BasicSendableEvent;
use crate::core::events::generation_events::common::GenerationServiceProvider;
use crate::core::events::generation_events::generation_complete_event::GenerationCompleteEvent;
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::core::utils::download_url_to_temp_dir::download_url_to_temp_dir;
use crate::services::fal::state::fal_credential_manager::FalCredentialManager;
@@ -16,6 +15,7 @@ use storyteller_client::recipes::upload_media_file_from_file::upload_media_file_
use storyteller_client::utils::api_host::ApiHost;
use tauri::AppHandle;
use tokens::tokens::media_files::MediaFileToken;
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
pub async fn fal_task_polling_thread(
app_handle: AppHandle,
@@ -14,7 +14,7 @@ use enums::common::job_status_plus::JobStatusPlus;
use enums::tauri::tasks::task_status::TaskStatus;
use enums::tauri::tasks::task_type::TaskType;
use errors::AnyhowResult;
use log::{debug, error, info};
use log::{error, info};
use os_info::Info;
use reqwest::Url;
use sqlite_tasks::queries::list_tasks_by_provider_and_tokens::{list_tasks_by_provider_and_tokens, ListTasksArgs, Task};
@@ -27,7 +27,6 @@ use storyteller_client::error::storyteller_error::StorytellerError;
use storyteller_client::endpoints::jobs::list_session_jobs::{list_session_jobs, States};
use storyteller_client::endpoints::media_files::upload_image_media_file_from_file::upload_image_media_file_from_file;
use tauri::AppHandle;
use tokens::tokens::app_session::AppSessionToken;
// TODO: Configure this with the build and increment.
const CLIENT_NAME : &str = "artcraft";
@@ -41,17 +40,12 @@ pub async fn storyteller_activity_thread(
storyteller_creds_manager: StorytellerCredentialManager,
) -> ! {
let startup = Instant::now();
let app_session_token = AppSessionToken::generate();
info!("Session started at {:?} with token: {:?}", startup, app_session_token);
loop {
let res = polling_loop(
&app_env_configs,
&storyteller_creds_manager,
startup,
&artcraft_platform_info,
&app_session_token,
).await;
if let Err(err) = res {
error!("An error occurred: {:?}", err);
@@ -66,7 +60,6 @@ async fn polling_loop(
storyteller_creds_manager: &StorytellerCredentialManager,
startup: Instant,
artcraft_platform_info: &ArtcraftPlatformInfo,
app_session_token: &AppSessionToken,
) -> AnyhowResult<()> {
loop {
let creds = storyteller_creds_manager.get_credentials()?;
@@ -88,7 +81,6 @@ async fn polling_loop(
let time_since_startup = Instant::now().duration_since(startup);
let request = LogAppActiveUserRequest {
maybe_app_session_token: Some(app_session_token.clone()),
maybe_app_name: Some(CLIENT_NAME.to_string()),
maybe_app_version: Some(artcraft_platform_info.artcraft_version.clone()),
maybe_os_platform: Some(artcraft_platform_info.os_platform.as_str().to_owned()),
@@ -96,7 +88,7 @@ async fn polling_loop(
maybe_session_duration_seconds: Some(time_since_startup.as_secs()),
};
debug!("Logging active user with storyteller.");
info!("Logging active user with storyteller.");
let result = log_active_user(
&app_env_configs.storyteller_host,
@@ -1,2 +1 @@
pub mod upsert_analytics_app_active_user;
pub mod upsert_analytics_app_session;
pub mod upsert_analytics_app_active_user;
@@ -1,80 +0,0 @@
use crate::errors::mysql_error::MysqlError;
use crate::errors::subtypes::upsert_error::UpsertError;
use enums::common::payments_namespace::PaymentsNamespace;
use sqlx::mysql::MySqlArguments;
use sqlx::pool::PoolConnection;
use sqlx::query::Query;
use sqlx::MySql;
use tokens::tokens::app_session::AppSessionToken;
use tokens::tokens::users::UserToken;
pub struct UpsertAnalyticsAppSession<'a> {
pub app_session_token: &'a AppSessionToken,
// TODO: Swap PaymentsNamespace (artcraft,fakeyou,etc.) for an AnalyticsNamespace enum.
pub namespace: PaymentsNamespace,
pub user_token: &'a UserToken,
pub ip_address: &'a str,
pub app_version: Option<&'a str>,
pub os_platform: Option<&'a str>,
pub os_version: Option<&'a str>,
pub session_duration_seconds: Option<u64>,
}
impl <'a> UpsertAnalyticsAppSession<'a> {
fn query(&self) -> Query<MySql, MySqlArguments> {
sqlx::query!(
r#"
INSERT INTO analytics_app_sessions
SET
session_token = ?,
app_namespace = ?,
user_token = ?,
app_version = ?,
os_platform = ?,
os_version = ?,
session_duration_seconds = ?,
ip_address = ?,
measurement_count = measurement_count + 1,
first_active_at = NOW(),
last_active_at = NOW()
ON DUPLICATE KEY UPDATE
app_namespace = ?,
user_token = ?,
app_version = ?,
os_platform = ?,
os_version = ?,
session_duration_seconds = ?,
ip_address = ?,
measurement_count = measurement_count + 1,
last_active_at = NOW()
"#,
// Insert case
self.app_session_token.as_str(),
self.namespace.to_str(),
self.user_token.as_str(),
self.app_version,
self.os_platform,
self.os_version,
self.session_duration_seconds,
self.ip_address,
// Update case
self.namespace.to_str(),
self.user_token.as_str(),
self.app_version,
self.os_platform,
self.os_version,
self.session_duration_seconds,
self.ip_address,
)
}
pub async fn upsert_with_connection(
&self,
mysql_connection: &mut PoolConnection<MySql>
) -> Result<(), MysqlError<UpsertError>> {
let _query_result = self.query()
.execute(&mut **mysql_connection)
.await?;
Ok(())
}
}
@@ -48,10 +48,6 @@ pub enum TaskModelType {
Seedance10Lite,
#[serde(rename = "veo_2")]
Veo2,
#[serde(rename = "veo_3")]
Veo3,
#[serde(rename = "veo_3_fast")]
Veo3Fast,
// 3D Object generation models
#[serde(rename = "hunyuan_3d_2.0")]
@@ -87,8 +83,6 @@ impl TaskModelType {
Self::Kling21Master => "kling_2.1_master",
Self::Seedance10Lite => "seedance_1.0_lite",
Self::Veo2 => "veo_2",
Self::Veo3 => "veo_3",
Self::Veo3Fast => "veo_3_fast",
// 3D Object generation models
Self::Hunyuan3d2_0 => "hunyuan_3d_2.0",
Self::Hunyuan3d2_1 => "hunyuan_3d_2.1",
@@ -115,8 +109,6 @@ impl TaskModelType {
"kling_2.1_master" => Ok(Self::Kling21Master),
"seedance_1.0_lite" => Ok(Self::Seedance10Lite),
"veo_2" => Ok(Self::Veo2),
"veo_3" => Ok(Self::Veo3),
"veo_3_fast" => Ok(Self::Veo3Fast),
// 3D Object generation models
"hunyuan_3d_2.0" => Ok(Self::Hunyuan3d2_0),
"hunyuan_3d_2.1" => Ok(Self::Hunyuan3d2_1),
@@ -146,8 +138,6 @@ impl TaskModelType {
Self::Kling21Master,
Self::Seedance10Lite,
Self::Veo2,
Self::Veo3,
Self::Veo3Fast,
// 3D Object generation models
Self::Hunyuan3d2_0,
Self::Hunyuan3d2_1,
@@ -183,8 +173,6 @@ mod tests {
assert_serialization(TaskModelType::Kling21Master, "kling_2.1_master");
assert_serialization(TaskModelType::Seedance10Lite, "seedance_1.0_lite");
assert_serialization(TaskModelType::Veo2, "veo_2");
assert_serialization(TaskModelType::Veo3, "veo_3");
assert_serialization(TaskModelType::Veo3Fast, "veo_3_fast");
// 3D Object generation models
assert_serialization(TaskModelType::Hunyuan3d2_0, "hunyuan_3d_2.0");
assert_serialization(TaskModelType::Hunyuan3d2_1, "hunyuan_3d_2.1");
@@ -210,8 +198,6 @@ mod tests {
assert_eq!(TaskModelType::Kling21Master.to_str(), "kling_2.1_master");
assert_eq!(TaskModelType::Seedance10Lite.to_str(), "seedance_1.0_lite");
assert_eq!(TaskModelType::Veo2.to_str(), "veo_2");
assert_eq!(TaskModelType::Veo3.to_str(), "veo_3");
assert_eq!(TaskModelType::Veo3Fast.to_str(), "veo_3_fast");
// 3D Object generation models
assert_eq!(TaskModelType::Hunyuan3d2_0.to_str(), "hunyuan_3d_2.0");
assert_eq!(TaskModelType::Hunyuan3d2_1.to_str(), "hunyuan_3d_2.1");
@@ -237,8 +223,6 @@ mod tests {
assert_eq!(TaskModelType::from_str("kling_2.1_master").unwrap(), TaskModelType::Kling21Master);
assert_eq!(TaskModelType::from_str("seedance_1.0_lite").unwrap(), TaskModelType::Seedance10Lite);
assert_eq!(TaskModelType::from_str("veo_2").unwrap(), TaskModelType::Veo2);
assert_eq!(TaskModelType::from_str("veo_3").unwrap(), TaskModelType::Veo3);
assert_eq!(TaskModelType::from_str("veo_3_fast").unwrap(), TaskModelType::Veo3Fast);
// 3D Object generation models
assert_eq!(TaskModelType::from_str("hunyuan_3d_2.0").unwrap(), TaskModelType::Hunyuan3d2_0);
assert_eq!(TaskModelType::from_str("hunyuan_3d_2.1").unwrap(), TaskModelType::Hunyuan3d2_1);
@@ -247,7 +231,7 @@ mod tests {
#[test]
fn all_variants() {
let mut variants = TaskModelType::all_variants();
assert_eq!(variants.len(), 20);
assert_eq!(variants.len(), 18);
// Image models
assert_eq!(variants.pop_first(), Some(TaskModelType::Flux1Dev));
assert_eq!(variants.pop_first(), Some(TaskModelType::Flux1Schnell));
@@ -266,8 +250,6 @@ mod tests {
assert_eq!(variants.pop_first(), Some(TaskModelType::Kling21Master));
assert_eq!(variants.pop_first(), Some(TaskModelType::Seedance10Lite));
assert_eq!(variants.pop_first(), Some(TaskModelType::Veo2));
assert_eq!(variants.pop_first(), Some(TaskModelType::Veo3));
assert_eq!(variants.pop_first(), Some(TaskModelType::Veo3Fast));
// 3D Object generation models
assert_eq!(variants.pop_first(), Some(TaskModelType::Hunyuan3d2_0));
assert_eq!(variants.pop_first(), Some(TaskModelType::Hunyuan3d2_1));
@@ -23,7 +23,6 @@ use strum::EnumIter;
#[cfg_attr(test, derive(EnumIter, EnumCount))]
pub(crate) enum TokenPrefix {
AnonymousVisitorTracking, // AVTs are not stored as primary keys in any table, but an index in many tables.
AppSession, // NB: These are generated client-side (!)
AuditLog,
BatchGeneration,
BetaKey,
@@ -106,7 +105,6 @@ impl PrefixGenerator for TokenPrefix {
fn prefix(self) -> &'static str {
match self {
Self::AnonymousVisitorTracking => "avt_",
Self::AppSession => "app_session_",
Self::AuditLog => "audit_",
Self::BatchGeneration => "batch_g_",
Self::BetaKey => "beta_key_",
@@ -1,17 +0,0 @@
use std::fmt::Debug;
use serde::Deserialize;
use serde::Serialize;
use utoipa::ToSchema;
use crate::prefixes::TokenPrefix;
/// The primary key for users.
#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize, Default, ToSchema)]
#[cfg_attr(feature = "database", derive(sqlx::Type))]
#[cfg_attr(feature = "database", sqlx(transparent))]
pub struct AppSessionToken(pub String);
impl_mysql_token_from_row!(AppSessionToken);
impl_string_token!(AppSessionToken);
impl_crockford_generator!(AppSessionToken, 32usize, TokenPrefix::AppSession, CrockfordMixed);
@@ -1,7 +1,6 @@
pub mod anonymous_visitor_tracking;
pub mod api_tokens_external;
pub mod api_tokens_internal;
pub mod app_session;
pub mod audit_logs;
pub mod batch_generations;
pub mod beta_keys;
@@ -5,14 +5,11 @@ use crate::state::server_state::ServerState;
use actix_helpers::extractors::get_request_user_agent::get_request_user_agent;
use actix_web::web::{Json, Query};
use actix_web::{web, HttpRequest};
use log::info;
use artcraft_api_defs::analytics::log_active_user::{LogAppActiveUserRequest, LogAppActiveUserResponse};
use enums::common::payments_namespace::PaymentsNamespace;
use errors::AnyhowResult;
use http_server_common::request::get_request_ip::get_request_ip;
use log::{info, warn};
use mysql_queries::queries::analytics_active_users::upsert_analytics_app_active_user::UpsertAnalyticsAppActiveUser;
use mysql_queries::queries::analytics_active_users::upsert_analytics_app_session::UpsertAnalyticsAppSession;
use tokens::tokens::app_session::AppSessionToken;
use utoipa::ToSchema;
const CLIENT_WAIT_FOR_RETRY_MILLIS: u64 = 1_000 * 60 * 1; // Every minute
@@ -103,23 +100,6 @@ pub async fn log_app_active_user_handler(
};
upsert.upsert_with_connection(&mut mysql_connection).await?;
if let Some(token) = request.maybe_app_session_token.as_ref() {
validate_app_session_token_format(token)?;
let upsert = UpsertAnalyticsAppSession{
app_session_token: token,
namespace: PaymentsNamespace::Artcraft,
user_token: &user_token,
ip_address: &ip_address,
app_version: app_version.as_deref(),
os_platform: request.maybe_os_platform.as_deref(),
os_version: request.maybe_os_version.as_deref(),
session_duration_seconds: request.maybe_session_duration_seconds,
};
upsert.upsert_with_connection(&mut mysql_connection).await?;
}
Ok(Json(LogAppActiveUserResponse {
success: true,
@@ -127,11 +107,3 @@ pub async fn log_app_active_user_handler(
}))
}
fn validate_app_session_token_format(app_session_token: &AppSessionToken) -> Result<(), CommonWebError> {
if !app_session_token.as_str().starts_with(AppSessionToken::token_prefix()) {
warn!("App session token has invalid prefix: {}", app_session_token.as_str());
return Err(CommonWebError::BadInputWithSimpleMessage("Invalid app session token format".to_string()));
}
Ok(())
}
@@ -1,4 +0,0 @@
/tutorials /index.html 200
/tutorials/ /index.html 200
Binary file not shown.

Before

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 155 KiB

@@ -8,9 +8,9 @@ nx build artcraft-website
echo "Change to project dir"
pushd apps/artcraft-website/
echo "Copy netlify configs into dist"
#echo "Copy netlify configs into dist"
#cp _headers dist/
cp _redirects dist/
#cp _redirects dist/
#echo "Copy netlify 404.html page into dist"
#cp "404.html" dist/
@@ -2,7 +2,6 @@ import { Route, Routes } from "react-router-dom";
import Download from "../pages/download";
import Navbar from "../components/navbar";
import Landing2 from "../pages/landing2";
import TutorialsPage from "../pages/tutorials";
export function App() {
return (
@@ -12,7 +11,6 @@ export function App() {
<Routes>
<Route path="/" element={<Landing2 />} />
<Route path="/download" element={<Download />} />
<Route path="/tutorials" element={<TutorialsPage />} />
</Routes>
</div>
);
@@ -3,8 +3,6 @@ import {
faTiktok,
faDiscord,
faYoutube,
faGithub,
faGithubAlt,
} from "@fortawesome/free-brands-svg-icons";
import { SOCIAL_LINKS } from "../../config/links";
@@ -29,11 +27,6 @@ const navigation = {
href: SOCIAL_LINKS.TIKTOK,
icon: (props: any) => <FontAwesomeIcon icon={faTiktok} {...props} />,
},
{
name: "GitHub",
href: SOCIAL_LINKS.GITHUB,
icon: (props: any) => <FontAwesomeIcon icon={faGithubAlt} {...props} />,
},
],
};
@@ -1,37 +1,10 @@
import { Disclosure } from "@headlessui/react";
import { twMerge } from "tailwind-merge";
import { useEffect, useRef, useState } from "react";
import { useLocation } from "react-router-dom";
import { useEffect, useState } from "react";
import { DiscordButton } from "../discord-button";
const NAV_ITEMS = [
{ name: "Home", href: "/" },
{ name: "Tutorials", href: "/tutorials" },
];
export default function Navbar() {
const [scrolled, setScrolled] = useState(false);
const location = useLocation();
const [underlineAnim, setUnderlineAnim] = useState<
Record<string, "in" | "out" | null>
>({});
const outTimersRef = useRef<Record<string, number | undefined>>({});
const handleEnter = (href: string) => {
if (outTimersRef.current[href] !== undefined) {
window.clearTimeout(outTimersRef.current[href]);
delete outTimersRef.current[href];
}
setUnderlineAnim((prev) => ({ ...prev, [href]: "in" }));
};
const handleLeave = (href: string) => {
setUnderlineAnim((prev) => ({ ...prev, [href]: "out" }));
outTimersRef.current[href] = window.setTimeout(() => {
setUnderlineAnim((prev) => ({ ...prev, [href]: null }));
delete outTimersRef.current[href];
}, 260);
};
useEffect(() => {
const handleScroll = () => {
@@ -64,49 +37,23 @@ export default function Navbar() {
/>
</a>
</div>
<div className="hidden md:ml-10 md:flex md:items-center md:space-x-6">
{NAV_ITEMS.map((item) => {
const isCurrent = location.pathname === item.href;
const anim = underlineAnim[item.href] ?? null;
return (
<a
key={item.name}
href={item.href}
aria-current={isCurrent ? "page" : undefined}
className={twMerge(
isCurrent
? "text-white"
: "text-white/60 hover:text-white",
"relative rounded-md text-[15px] font-semibold transition-all"
)}
onMouseEnter={() => !isCurrent && handleEnter(item.href)}
onMouseLeave={() => !isCurrent && handleLeave(item.href)}
>
<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 ? "" : ""
)}
aria-hidden="true"
>
<span
className={twMerge(
"link-underline block h-full w-full bg-primary/90",
isCurrent
? "visible-line"
: anim === "in"
? "animate-in"
: anim === "out"
? "animate-out"
: "hidden-line"
)}
/>
</span>
</a>
);
})}
</div>
{/* <div className="hidden md:ml-7 md:flex md:items-center md:space-x-3">
{navigation.map((item) => (
<a
key={item.name}
href={item.href}
aria-current={item.current ? "page" : undefined}
className={twMerge(
item.current
? "text-white/80 hover:text-white"
: "text-white/80 hover:text-white",
"rounded-md px-3 py-2 text-[15px] font-medium transition-all"
)}
>
{item.name}
</a>
))}
</div> */}
</div>
<div className="flex items-center">
<div className="hidden md:ml-4 md:flex md:shrink-0 md:items-center">
@@ -149,31 +96,6 @@ export default function Navbar() {
))}
</div>
</DisclosurePanel> */}
<style>{`
.link-underline {
transform-origin: left center;
transform: scaleX(0) translateX(0);
opacity: 0;
}
.visible-line {
transform: scaleX(1) translateX(0);
opacity: 1;
}
.hidden-line {
transform: scaleX(0) translateX(0);
opacity: 0;
}
@keyframes underline-in {
0% { transform: scaleX(0) translateX(0); opacity: 0.8; }
100% { transform: scaleX(1) translateX(0); opacity: 1; }
}
@keyframes underline-out {
0% { transform: scaleX(1) translateX(0); opacity: 1; }
100% { transform: scaleX(1) translateX(100%); opacity: 0; }
}
.animate-in { animation: underline-in 220ms ease-out forwards; }
.animate-out { animation: underline-out 220ms ease-in forwards; }
`}</style>
</Disclosure>
);
}
@@ -2,5 +2,4 @@ export const SOCIAL_LINKS = {
DISCORD: "https://discord.gg/75svZP2Vje",
YOUTUBE: "https://www.youtube.com/@OfficialArtCraftStudios",
TIKTOK: "https://www.tiktok.com/@artcraft.studios",
GITHUB: "https://github.com/storytold",
} as const;
@@ -1,4 +1,3 @@
export * from "./landing";
export * from "./landing2";
export * from "./download";
export * from "./tutorials";
@@ -23,8 +23,7 @@ import ModelBadgeGrid from "../../components/model-badge-grid";
//const MAC_VERSION = "2025.07.28";
//const MAC_VERSION = "2025.08.23"; // NB: The "08.22 (twenty two)" release is broken.
//const MAC_VERSION = "2025.09.23";
//const MAC_VERSION = "2025.09.24"; // Works! Good release.
const MAC_VERSION = "2025.09.27";
const MAC_VERSION = "2025.09.24";
// Current Windows version (update this when a new version is released)
// If a rollback is necessary, roll back to the last known good date.
@@ -34,8 +33,7 @@ const MAC_VERSION = "2025.09.27";
//const WINDOWS_VERSION = "2025.07.28";
//const WINDOWS_VERSION = "2025.08.22";
//const WINDOWS_VERSION = "2025.09.23";
//const WINDOWS_VERSION = "2025.09.24"; // Works! Good release.
const WINDOWS_VERSION = "2025.09.27";
const WINDOWS_VERSION = "2025.09.24";
// Link to the current production Mac build
const MAC_LINK = `https://pub-3b58c874772a4e04b9c291815224128c.r2.dev/mac/ArtCraft_0.0.1_universal_${MAC_VERSION}.dmg`;
@@ -1 +0,0 @@
export { default } from "./tutorials";
@@ -1,139 +0,0 @@
import { useMemo, useState } from "react";
import { twMerge } from "tailwind-merge";
import { defaultTutorials, TutorialItem } from "@storyteller/ui-tutorial-modal";
import { Button } from "@storyteller/ui-button";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faChevronLeft, faPlay } from "@fortawesome/pro-solid-svg-icons";
const categoriesFrom = (items: TutorialItem[]): string[] => {
const set = new Set<string>();
for (const t of items) if (t.category) set.add(t.category);
return ["All", ...Array.from(set)];
};
const websiteThumb = (url: string): string => {
// Map shared modal thumbnail paths to website public path
return url.startsWith("/resources/images/")
? url.replace("/resources/images/", "/images/")
: url;
};
export const TutorialsPage = () => {
const [activeCategory, setActiveCategory] = useState<string>("All");
const [selected, setSelected] = useState<TutorialItem | null>(null);
const tutorials = defaultTutorials;
const categories = useMemo(() => categoriesFrom(tutorials), [tutorials]);
const visible = useMemo(() => {
if (activeCategory === "All") return tutorials;
return tutorials.filter((t) => t.category === activeCategory);
}, [activeCategory, tutorials]);
return (
<div className="relative min-h-screen bg-[#101014] text-white overflow-hidden bg-dots">
<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-25 blur-[120px]"></div>
</div>
<div className="relative z-10 mx-auto w-full max-w-[1200px] px-4 sm:px-8 pt-28 sm:pt-36 pb-12">
{/* Hero */}
<div className="text-center mb-8 sm:mb-12">
<h1 className="text-4xl sm:text-6xl font-bold mb-3">Tutorials</h1>
<p className="text-white/70 text-base sm:text-lg">
Learn tips, tricks, and workflows for ArtCraft.
</p>
</div>
{/* Categories */}
{!selected && (
<div className="flex flex-wrap gap-3 justify-center mb-8">
{categories.map((cat) => (
<button
key={cat}
type="button"
onClick={() => setActiveCategory(cat)}
className={twMerge(
"px-4 py-2 rounded-xl border",
activeCategory === cat
? "bg-primary/30 border-primary/90"
: "bg-white/5 border-white/10 hover:bg-white/10"
)}
>
{cat}
</button>
))}
</div>
)}
{/* Content */}
{!selected ? (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{visible.map((item) => (
<button
key={item.id}
type="button"
onClick={() => setSelected(item)}
className="group block overflow-hidden rounded-lg border border-white/10 bg-white/5 hover:bg-white/10 text-left"
>
<div className="aspect-video w-full overflow-hidden relative">
<img
src={websiteThumb(item.thumbnailUrl)}
alt={item.title}
className="h-full w-full object-cover transition-transform group-hover:scale-[1.02]"
/>
<div className="absolute inset-0 flex items-center justify-center bg-black/10 opacity-0 group-hover:opacity-100 transition-opacity">
<span className="inline-flex items-center gap-2 text-white/90 text-sm font-medium">
<FontAwesomeIcon icon={faPlay} />
Watch
</span>
</div>
</div>
<div className="p-3 text-sm text-white/90 flex items-center justify-between">
<span>{item.title}</span>
{item.category && (
<span className="px-2 py-0.5 text-xs rounded-full bg-white/10 border border-white/10">
{item.category}
</span>
)}
</div>
</button>
))}
</div>
) : (
<div className="flex w-full flex-col">
<div className="mb-3 flex items-center gap-4 md:flex-row flex-col">
<Button
onClick={() => setSelected(null)}
className="w-fit text-base-fg opacity-80 hover:opacity-100 font-medium border-none"
variant="action"
icon={faChevronLeft}
>
Back
</Button>
<div className="text-lg font-bold text-base-fg">
Tutorial: {selected.title}
</div>
</div>
<div className="w-full">
<div className="aspect-video w-full overflow-hidden rounded-lg border border-white/10 bg-black">
<iframe
title={selected.title}
src={selected.videoUrl.replace(
"youtu.be/",
"www.youtube.com/embed/"
)}
className="h-full w-full"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
</div>
</div>
</div>
)}
</div>
</div>
);
};
export default TutorialsPage;
@@ -37,9 +37,6 @@
"src/components/footer"
],
"references": [
{
"path": "../../libs/components/tutorial-modal/tsconfig.lib.json"
},
{
"path": "../../libs/components/button/tsconfig.lib.json"
}
@@ -2,9 +2,6 @@
"files": [],
"include": [],
"references": [
{
"path": "../../libs/components/tutorial-modal"
},
{
"path": "../../libs/components/button"
},
Binary file not shown.

Before

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 155 KiB

@@ -19,7 +19,6 @@ import {
} from "@storyteller/ui-model-selector";
import { useCanvasBgRemovedEvent } from "@storyteller/tauri-api";
import { getSelectedImageModel } from "@storyteller/ui-model-selector";
import { TutorialModalButton } from "@storyteller/ui-tutorial-modal";
const PAGE_ID: ModelPage = ModelPage.Canvas2D;
@@ -452,9 +451,6 @@ const PageDraw = () => {
triggerLabel="Model"
/>
</div>
<div className="absolute bottom-6 right-6 z-20 flex items-center gap-2">
<TutorialModalButton />
</div>
</>
);
};
@@ -9,7 +9,6 @@ import toast from "react-hot-toast";
import { uploadImage } from "~/components/reusable/UploadModalMedia/uploadImage";
import { UploaderStates } from "~/enums";
import { MediaFilesApi } from "@storyteller/api";
import { TutorialModalButton } from "@storyteller/ui-tutorial-modal";
export interface BaseSelectorImage {
url: string;
@@ -176,9 +175,6 @@ export const BaseImageSelector = ({
</div>
)}
</div>
<div className="fixed bottom-6 right-6 z-20 flex items-center gap-2">
<TutorialModalButton />
</div>
<GalleryModal
isOpen={!!isGalleryModalOpen}
onClose={handleGalleryClose}
@@ -19,7 +19,6 @@ import {
} from "@storyteller/ui-model-selector";
import { ImageModel } from "@storyteller/model-list";
import { HistoryStack, ImageBundle } from "./HistoryStack";
import { TutorialModalButton } from "@storyteller/ui-tutorial-modal";
const PAGE_ID: ModelPage = ModelPage.ImageEditor;
@@ -474,9 +473,6 @@ const PageEdit = () => {
triggerLabel="Model"
/>
</div>
<div className="absolute bottom-6 right-6 z-20 flex items-center gap-2">
<TutorialModalButton />
</div>
</>
);
};
@@ -77,7 +77,6 @@ import PageDraw from "../PageDraw/PageDraw";
import { useTabStore } from "../Stores/TabState";
import PageEdit from "../PageEdit/PageEdit";
import { ImageModel } from "@storyteller/model-list";
import { TutorialModalButton } from "@storyteller/ui-tutorial-modal";
const PAGE_ID: ModelPage = ModelPage.Stage3D;
@@ -552,9 +551,6 @@ export const PageEditor = () => {
triggerLabel="Model"
/>
</div>
<div className="absolute bottom-6 right-6 z-20 flex items-center gap-2">
<TutorialModalButton />
</div>
</div>
</div>
</div>
@@ -11,6 +11,7 @@ import { useSignals } from "@preact/signals-react/runtime";
import { useEffect, useState } from "react";
import * as gpu from "detect-gpu";
import { UsersApi } from "~/Classes/ApiManager";
import { TurnOnGpu } from "~/pages/PageEnigma/TurnOnGpu";
import { PrecisionSelector } from "./comps/PrecisionSelector/PrecisionSelector";
import {
precisionSelectedValue,
@@ -21,6 +22,7 @@ import {
import { InstallSounds } from "~/pages/PageEnigma/InstallSounds";
import { PageEditor } from "~/pages/PageEnigma/PageEditor";
import { GalleryDragComponent } from "@storyteller/ui-gallery-modal";
import { LoadingDots } from "@storyteller/ui-loading";
import {
PricingModal,
CreditsModal,
@@ -103,6 +105,13 @@ export const PageEnigma = ({ sceneToken }: { sceneToken?: string }) => {
InstallSounds();
});
if (validGpu === "unknown") {
return <LoadingDots />;
}
if (validGpu === "error") {
return <TurnOnGpu />;
}
const currentReminderModalProps = actionReminderProps.value;
return (
@@ -0,0 +1,210 @@
import * as CopyToClipboard from "react-copy-to-clipboard";
import { useEffect, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCopy } from "@fortawesome/pro-solid-svg-icons";
export const TurnOnGpu = () => {
const [copied1, setCopied1] = useState(false);
const [copied2, setCopied2] = useState(false);
useEffect(() => {
if (copied1) {
setTimeout(() => setCopied1(false), 2000);
}
}, [copied1]);
useEffect(() => {
if (copied2) {
setTimeout(() => setCopied2(false), 2000);
}
}, [copied2]);
return (
<div className="fixed inset-0 h-screen overflow-y-auto p-16">
<div>
Hardware acceleration is <b>required</b> for StoryTeller Studio to run
properly. To check if this is enabled, please follow the steps for your
browser below.
</div>
<div className="my-4">
<b>Google Chrome: </b>
</div>
<ol>
<li>
<div>
Click the three stacked dots in upper right of the browser window.
</div>
</li>
<li>
<div>
Click the arrow next to <em>&quot;Advanced&quot;</em> and select{" "}
<em>&quot;System.&quot;</em>
</div>
</li>
<li>
<div>
Make sure the box next to{" "}
<em>&quot;Use hardware acceleration when available&quot;</em> is
toggled on.
</div>
</li>
<li>
<div>
Close and restart the browser for the change to take effect.
</div>
</li>
<ul>
<li>
<div>
You can also copy and paste this link{" "}
<CopyToClipboard.CopyToClipboard
text="chrome://settings/system"
onCopy={() => setCopied1(true)}
>
<span className="text-blue">
chrome://settings/system&nbsp;
{copied1 ? (
<span style={{ color: "red" }}>Copied.</span>
) : (
<span className="text-sm">
<FontAwesomeIcon icon={faCopy} />
</span>
)}
&nbsp;
</span>
</CopyToClipboard.CopyToClipboard>
into the address bar. This will bring you right to the system
settings.
</div>
</li>
</ul>
</ol>
<div className="my-4">
<b>Firefox:</b>
</div>
<ol>
<li>
<div>
Click on <em>&quot;Firefox&quot;</em> then{" "}
<em>&quot;Preferences.&quot;</em>
</div>
</li>
<li>
<div>
In the&nbsp;<em>&quot;General&quot;</em>&nbsp;panel, go to the&nbsp;
<em>&quot;Performance&quot; </em>section.
</div>
</li>
<li>
<div>
Uncheck the box next to&nbsp;
<em>&quot;Use recommended performance settings&quot;</em> and make
sure the box next to{" "}
<em>&quot;Use hardware acceleration when available&quot;</em> is
selected.
</div>
</li>
<li>
<div>
Close and restart the browser for the change to take effect.
</div>
</li>
<ul>
<li>
<div>
If you need help, please follow this link:{" "}
<a
href="https://support.mozilla.org/en-US/kb/performance-settings?as=u&amp;utm_source=inproduct"
target="_blank"
rel="noreferrer"
className="text-blue underline"
>
https://support.mozilla.org/en-US/kb/performance-settings?as=u&amp;utm_source=inproduct
</a>
</div>
</li>
</ul>
</ol>
<div className="my-4">
<b>Microsoft Edge:</b>
</div>
<ol>
<li>
<div>
Click the &quot;3 dots&quot; menu icon in the upper right of the
browser window
</div>
</li>
<li>
<div>
Click on&nbsp;<em>&quot;Settings&quot;</em> then{" "}
<em>&quot;System&quot;</em> on the left side.
</div>
</li>
<li>
<div>
Toggle on{" "}
<em>&quot;Use hardware acceleration when available&quot;</em> if
this is not enabled.
</div>
</li>
<li>
<div>
Close and restart the browser for the change to take effect.
</div>
</li>
<ul>
<li>
<div>
You can also copy and paste this link{" "}
<CopyToClipboard.CopyToClipboard
text="edge://settings/system"
onCopy={() => setCopied2(true)}
>
<span className="text-blue">
edge://settings/system&nbsp;
{copied2 ? (
<span style={{ color: "red" }}>Copied.</span>
) : (
<span className="text-sm">
<FontAwesomeIcon icon={faCopy} />
</span>
)}
&nbsp;
</span>
</CopyToClipboard.CopyToClipboard>
into the address bar. This will bring you right to the system
settings.
</div>
</li>
</ul>
</ol>
<div className="my-4">
<b>Safari (Mac Only)</b>
</div>
<div>
As of macOS Catalina (version 10.15), Hardware acceleration is
automatically enabled, and there is no way turn this on and off. If you
are using an older version of MacOS, please use the steps below:
</div>
<ol>
<li>
<div>
Click on the &quot;Safari&quot; then &quot;Settings&quot; &gt;{" "}
</div>
</li>
<li>
<div>
Click &quot;Advanced&quot; on the top right of the Settings window
and ensure &quot;Use hardware acceleration&quot; is enabled
</div>
</li>
</ol>
<div>
If you are still having an issue with enabling hardware acceleration, we
recommend you update your graphics card drivers, if possible. If these
updates do not help, you may have to consider upgrading your graphics
card or playing on a different device.
</div>
</div>
);
};
@@ -23,7 +23,6 @@ import {
} from "@storyteller/ui-gallery-modal";
import { Badge } from "@storyteller/ui-badge";
import { twMerge } from "tailwind-merge";
import { TutorialModalButton } from "@storyteller/ui-tutorial-modal";
const PAGE_ID: ModelPage = ModelPage.TextToImage;
@@ -187,7 +186,7 @@ const TextToImage = ({ imageMediaId, imageUrl }: TextToImageProps) => {
>
<button
onClick={() => resetBatches()}
className="rounded-md bg-red/20 px-3 py-1 text-xs text-white/70 transition-colors hover:bg-red/30"
className="bg-red/20 hover:bg-red/30 rounded-md px-3 py-1 text-xs text-white/70 transition-colors"
>
Clear session
</button>
@@ -225,9 +224,6 @@ const TextToImage = ({ imageMediaId, imageUrl }: TextToImageProps) => {
triggerLabel="Model"
/>
</div>
<div className="absolute bottom-6 right-6 z-20 flex items-center gap-2">
<TutorialModalButton />
</div>
</div>
</div>
</div>
@@ -18,7 +18,6 @@ import {
// import { Badge } from "@storyteller/ui-badge";
import { twMerge } from "tailwind-merge";
import { uploadImage } from "../../components/reusable/UploadModalMedia/uploadImage";
import { TutorialModalButton } from "@storyteller/ui-tutorial-modal";
const PAGE_ID: ModelPage = ModelPage.ImageToVideo;
@@ -212,9 +211,6 @@ const ImageToVideo = ({ imageMediaId, imageUrl }: ImageToVideoProps) => {
triggerLabel="Model"
/>
</div>
<div className="absolute bottom-6 right-6 z-20 flex items-center gap-2">
<TutorialModalButton />
</div>
</div>
</div>
</div>
-3
View File
@@ -76,9 +76,6 @@
{
"path": "../../libs/components/promptbox"
},
{
"path": "../../libs/components/tutorial-modal"
},
{
"path": "../../libs/components/model-selector"
},
@@ -1,21 +1,10 @@
import React, { useMemo, useRef } from "react";
import React, { useRef } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faCheck,
faEllipsis,
faPencil,
faTrashCan,
} from "@fortawesome/pro-solid-svg-icons";
import { faCheck } from "@fortawesome/pro-solid-svg-icons";
import { twMerge } from "tailwind-merge";
import galleryDnd from "./galleryDnd";
import { Tooltip } from "@storyteller/ui-tooltip";
import { GalleryItem } from "./gallery-modal";
import { PopoverMenu } from "@storyteller/ui-popover";
import {
showActionReminder,
isActionReminderOpen,
} from "@storyteller/ui-action-reminder-modal";
import { MediaFilesApi } from "@storyteller/api";
type ModalMode = "select" | "view";
@@ -28,8 +17,6 @@ interface GalleryDraggableItemProps {
onImageError: () => void;
disableTooltipAndBadge?: boolean;
imageFit?: "cover" | "contain";
onDeleted?: (id: string) => void;
onEditClicked?: (url: string, media_id?: string) => Promise<void> | void;
}
export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
@@ -41,40 +28,9 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
onImageError,
disableTooltipAndBadge = false,
imageFit = "cover",
onDeleted,
onEditClicked,
}) => {
const imgRef = useRef<HTMLImageElement>(null);
const dragStarted = useRef(false);
const mediaFilesApi = useMemo(() => new MediaFilesApi(), []);
const handleDelete = () => {
showActionReminder({
reminderType: "default",
title: "Delete this media?",
message: (
<p className="text-sm text-white/70">
This will permanently remove the media from your library. This action
cannot be undone.
</p>
),
primaryActionText: "Delete",
secondaryActionText: "Cancel",
primaryActionBtnClassName: "bg-red text-white hover:bg-red/90",
onPrimaryAction: async () => {
try {
const res = await mediaFilesApi.DeleteMediaFileByToken({
mediaFileToken: item.id,
});
if (res.success) {
onDeleted?.(item.id);
}
} finally {
isActionReminderOpen.value = false;
}
},
});
};
const handlePointerDown = (event: React.PointerEvent<HTMLButtonElement>) => {
// Disable dragging for video items
@@ -121,65 +77,9 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
activeFilter === "video" ? "aspect-square" : "aspect-square"
)}
>
{/* dropdown menu */}
<div
className="absolute right-2 top-2 z-30 opacity-0 group-hover:opacity-100 transition-opacity duration-75"
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
}}
>
<PopoverMenu
position="bottom"
align="end"
mode="default"
triggerIcon={
<FontAwesomeIcon icon={faEllipsis} className="text-base-fg" />
}
buttonClassName="h-7 w-7 p-0 rounded-full bg-ui-controls/60 hover:bg-ui-controls/90 text-base-fg border border-ui-controls-border"
panelClassName="min-w-28 p-1"
closeOnUnhover
>
{(close) => (
<div className="flex flex-col">
{item.mediaClass === "image" && (
<button
type="button"
className="flex items-center gap-2 px-2 py-2 rounded-md hover:bg-ui-controls/60 text-base-fg text-sm"
onClick={async (e) => {
e.stopPropagation();
if (onEditClicked && (item.fullImage || item.thumbnail)) {
await onEditClicked(
item.fullImage || item.thumbnail!,
item.id
);
}
close();
}}
>
<FontAwesomeIcon icon={faPencil} className="text-base-fg" />
<span>Edit image</span>
</button>
)}
<button
type="button"
className="flex items-center gap-2 px-2 py-2 rounded-md hover:bg-ui-controls/60 text-sm"
onClick={(e) => {
e.stopPropagation();
handleDelete();
close();
}}
>
<FontAwesomeIcon icon={faTrashCan} className="text-red" />
<span className="text-red">Delete</span>
</button>
</div>
)}
</PopoverMenu>
</div>
{/* Media class badge on hover */}
{!disableTooltipAndBadge && item.mediaClass && (
<div className="pointer-events-none absolute left-2 top-2 z-20 rounded-full bg-black/50 backdrop-blur-lg px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-white opacity-0 group-hover:opacity-100 transition-opacity duration-150">
<div className="pointer-events-none absolute right-2 top-2 z-20 rounded-full bg-black/50 backdrop-blur-lg px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-white opacity-0 group-hover:opacity-100 transition-opacity duration-150">
{item.mediaClass === "dimensional" ? "3D" : item.mediaClass}
</div>
)}
@@ -227,7 +127,7 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
/>
)}
{selected && (
<div className="absolute right-8 top-2 flex h-6 w-6 items-center justify-center rounded-full bg-primary">
<div className="absolute right-2 top-2 flex h-6 w-6 items-center justify-center rounded-full bg-primary">
<FontAwesomeIcon icon={faCheck} className="text-sm" />
</div>
)}
@@ -299,7 +199,7 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
/>
)}
{selected && (
<div className="absolute right-8 top-2 flex h-6 w-6 items-center justify-center rounded-full bg-primary">
<div className="absolute right-2 top-2 flex h-6 w-6 items-center justify-center rounded-full bg-primary">
<FontAwesomeIcon icon={faCheck} className="text-sm" />
</div>
)}
@@ -120,7 +120,7 @@ export const GalleryModal = React.memo(
// NB: We've got some kind of cursoring issue where subsequent pages are not requested.
// For now, let's set the pagination limit really high.
const pageSize = 100;
const pageSize = 1000;
const imageUrl = lightboxImageSignal.value?.fullImage || "";
@@ -339,10 +339,6 @@ export const GalleryModal = React.memo(
onUseSelected?.(selectedItems);
}, [groupItemsByDate, selectedItemIds, onUseSelected]);
const handleItemDeleted = useCallback((id: string) => {
setAllItems((prev) => prev.filter((it) => it.id !== id));
}, []);
useSignals();
// Synchronize lightbox image when external media id signal is set
@@ -613,8 +609,6 @@ export const GalleryModal = React.memo(
}
disableTooltipAndBadge={mode === "select"}
imageFit={imageFit}
onDeleted={handleItemDeleted}
onEditClicked={onEditClicked}
/>
))}
</div>
@@ -5,6 +5,12 @@
{
"path": "../slider-v2"
},
{
"path": "../popover"
},
{
"path": "../../api"
},
{
"path": "../loading-spinner"
},
@@ -20,15 +26,6 @@
{
"path": "../modal"
},
{
"path": "../../api"
},
{
"path": "../action-reminder-modal"
},
{
"path": "../popover"
},
{
"path": "../tooltip"
},
@@ -46,6 +46,12 @@
{
"path": "../slider-v2/tsconfig.lib.json"
},
{
"path": "../popover/tsconfig.lib.json"
},
{
"path": "../../api/tsconfig.lib.json"
},
{
"path": "../loading-spinner/tsconfig.lib.json"
},
@@ -61,15 +67,6 @@
{
"path": "../modal/tsconfig.lib.json"
},
{
"path": "../../api/tsconfig.lib.json"
},
{
"path": "../action-reminder-modal/tsconfig.lib.json"
},
{
"path": "../popover/tsconfig.lib.json"
},
{
"path": "../tooltip/tsconfig.lib.json"
}
@@ -50,7 +50,6 @@ interface PopoverMenuProps {
align?: "start" | "center" | "end";
panelActionLabel?: string;
onOpenChange?: (open: boolean) => void;
closeOnUnhover?: boolean;
}
export const PopoverMenu = ({
@@ -72,7 +71,6 @@ export const PopoverMenu = ({
align = "start",
panelActionLabel,
onOpenChange,
closeOnUnhover = false,
}: PopoverMenuProps) => {
const selectedItem = items.find((item) => item.selected);
@@ -89,7 +87,7 @@ export const PopoverMenu = ({
const className = twMerge(
"text-sm font-medium rounded-lg px-3 py-2 shadow-sm",
"flex gap-2 items-center justify-center outline-none",
"transition-all duration-100",
"transition-all duration-150",
"bg-ui-controls/60 px-3 text-base-fg hover:bg-ui-controls/90 border border-ui-controls-border",
"active:scale-95 transform",
buttonClassName
@@ -121,7 +119,7 @@ export const PopoverMenu = ({
}, []);
const handleButtonMouseEnter = (open: boolean, openFn: () => void) => {
if (!(mode === "hoverSelect" || closeOnUnhover)) return;
if (mode !== "hoverSelect") return;
setIsHovering(true);
if (hoverTimeoutRef.current) {
@@ -129,7 +127,7 @@ export const PopoverMenu = ({
hoverTimeoutRef.current = null;
}
if (!open && mode === "hoverSelect") {
if (!open) {
hoverTimeoutRef.current = setTimeout(() => {
openFn();
}, 100);
@@ -137,7 +135,7 @@ export const PopoverMenu = ({
};
const handleButtonMouseLeave = (closeFn: () => void) => {
if (!(mode === "hoverSelect" || closeOnUnhover)) return;
if (mode !== "hoverSelect") return;
setIsHovering(false);
@@ -145,18 +143,15 @@ export const PopoverMenu = ({
clearTimeout(hoverTimeoutRef.current);
}
hoverTimeoutRef.current = setTimeout(
() => {
if (!isHovering) {
closeFn();
}
},
mode === "hoverSelect" ? 300 : 150
);
hoverTimeoutRef.current = setTimeout(() => {
if (!isHovering) {
closeFn();
}
}, 300);
};
const handlePanelMouseEnter = () => {
if (!(mode === "hoverSelect" || closeOnUnhover)) return;
if (mode !== "hoverSelect") return;
setIsHovering(true);
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
@@ -165,19 +160,16 @@ export const PopoverMenu = ({
};
const handlePanelMouseLeave = (closeFn: () => void) => {
if (!(mode === "hoverSelect" || closeOnUnhover)) return;
if (mode !== "hoverSelect") return;
setIsHovering(false);
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
}
hoverTimeoutRef.current = setTimeout(
() => {
closeFn();
},
mode === "hoverSelect" ? 300 : 150
);
hoverTimeoutRef.current = setTimeout(() => {
closeFn();
}, 300);
};
return (
@@ -229,19 +221,19 @@ export const PopoverMenu = ({
<Transition
show={open}
enter="transition duration-75 ease-out"
enter="transition duration-100 ease-out"
enterFrom={
position === "bottom"
? "translate-y-1 opacity-0"
: "-translate-y-1 opacity-0"
? "translate-y-2 opacity-0"
: "-translate-y-2 opacity-0"
}
enterTo="translate-y-0 opacity-100"
leave="transition duration-75 ease-in"
leave="transition duration-100 ease-in"
leaveFrom="translate-y-0 opacity-100"
leaveTo={
position === "bottom"
? "translate-y-1 opacity-0"
: "-translate-y-1 opacity-0"
? "translate-y-2 opacity-0"
: "-translate-y-2 opacity-0"
}
>
<PopoverPanel
@@ -1,12 +0,0 @@
{
"presets": [
[
"@nx/react/babel",
{
"runtime": "automatic",
"useBuiltIns": "usage"
}
]
],
"plugins": []
}
@@ -1,7 +0,0 @@
# @storyteller/ui-tutorial-modal
This library was generated with [Nx](https://nx.dev).
## Running unit tests
Run `nx test @storyteller/ui-tutorial-modal` to execute the unit tests via [Vitest](https://vitest.dev/).
@@ -1,17 +0,0 @@
{
"name": "@storyteller/ui-tutorial-modal",
"version": "0.0.1",
"type": "module",
"main": "./src/index.ts",
"module": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
"./package.json": "./package.json",
".": {
"development": "./src/index.ts",
"types": "./src/index.ts",
"import": "./src/index.ts",
"default": "./src/index.ts"
}
}
}
@@ -1,2 +0,0 @@
export * from "./lib/tutorial-modal";
export * from "./lib/tutorials";
@@ -1,27 +0,0 @@
import { create } from "zustand";
import type { TutorialItem } from "./tutorials";
type TutorialView = "grid" | "video";
type TutorialModalStore = {
view: TutorialView;
selected: TutorialItem | null;
progressSecondsById: Record<string, number>;
setGrid: () => void;
viewTutorial: (item: TutorialItem) => void;
getProgress: (id: string) => number;
setProgress: (id: string, seconds: number) => void;
};
export const useTutorialModalStore = create<TutorialModalStore>((set, get) => ({
view: "grid",
selected: null,
progressSecondsById: {},
setGrid: () => set({ view: "grid", selected: null }),
viewTutorial: (item) => set({ view: "video", selected: item }),
getProgress: (id) => get().progressSecondsById[id] ?? 0,
setProgress: (id, seconds) =>
set((state) => ({
progressSecondsById: { ...state.progressSecondsById, [id]: seconds },
})),
}));
@@ -1,7 +0,0 @@
/*
* Replace this with your own classes
*
* e.g.
* .container {
* }
*/
@@ -1,12 +0,0 @@
import { render } from '@testing-library/react';
import StorytellerUiTutorialModal from './tutorial-modal';
describe('StorytellerUiTutorialModal', () => {
it('should render successfully', () => {
const { baseElement } = render(<StorytellerUiTutorialModal />);
expect(baseElement).toBeTruthy();
});
});
@@ -1,220 +0,0 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { faBook, faChevronLeft } from "@fortawesome/pro-solid-svg-icons";
import { Modal } from "@storyteller/ui-modal";
import { defaultTutorials, TutorialItem } from "./tutorials.js";
import { useTutorialModalStore } from "./tutorial-modal-store";
import { Button } from "@storyteller/ui-button";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
export type TutorialModalButtonProps = {
items?: TutorialItem[];
panelTitle?: string;
className?: string;
onOpenChange?: (open: boolean) => void;
};
export function TutorialModalButton({
items,
panelTitle = "Tutorials",
className,
onOpenChange,
}: TutorialModalButtonProps) {
const [open, setOpen] = useState(false);
const tutorials = useMemo(() => items ?? defaultTutorials, [items]);
const view = useTutorialModalStore((s) => s.view);
const selected = useTutorialModalStore((s) => s.selected);
const setGrid = useTutorialModalStore((s) => s.setGrid);
const viewTutorial = useTutorialModalStore((s) => s.viewTutorial);
const getProgress = useTutorialModalStore((s) => s.getProgress);
const setProgress = useTutorialModalStore((s) => s.setProgress);
const handleOpen = () => {
setOpen(true);
onOpenChange?.(true);
};
const handleClose = () => {
setOpen(false);
onOpenChange?.(false);
};
// ------------------------------------------------------
// YouTube Player setup for restoring and saving progress
// ------------------------------------------------------
const playerContainerRef = useRef<HTMLDivElement | null>(null);
const playerRef = useRef<any | null>(null);
const progressTimerRef = useRef<number | null>(null);
const extractYouTubeId = (url: string): string | null => {
try {
const u = new URL(url);
const host = u.hostname.replace(/^www\./, "");
if (host === "youtube.com" && u.pathname.startsWith("/embed/")) {
return u.pathname.split("/")[2] ?? null;
}
if (host.includes("youtube.com") && u.pathname.startsWith("/shorts/")) {
return u.pathname.split("/")[2] ?? null;
}
if (host.includes("youtube.com") && u.pathname === "/watch") {
return u.searchParams.get("v");
}
if (host === "youtu.be") {
return u.pathname.slice(1) || null;
}
} catch {}
return null;
};
const loadYouTubeIframeAPI = async (): Promise<void> => {
if (typeof window === "undefined") return;
const w = window as any;
if (w.YT && w.YT.Player) return;
await new Promise<void>((resolve) => {
const existing = document.getElementById("youtube-iframe-api");
if (existing) {
const check = () =>
w.YT && w.YT.Player ? resolve() : setTimeout(check, 50);
check();
return;
}
const tag = document.createElement("script");
tag.id = "youtube-iframe-api";
tag.src = "https://www.youtube.com/iframe_api";
document.head.appendChild(tag);
w.onYouTubeIframeAPIReady = () => resolve();
});
};
useEffect(() => {
if (!open || view !== "video" || !selected) return;
const run = async () => {
await loadYouTubeIframeAPI();
const w = window as any;
const videoId = extractYouTubeId(selected.videoUrl);
if (!videoId) return;
if (!playerContainerRef.current) return;
// Clear any previous iframe content
playerContainerRef.current.innerHTML = "";
const startSeconds = getProgress(selected.id) || 0;
playerRef.current = new w.YT.Player(playerContainerRef.current, {
videoId,
playerVars: {
rel: 0,
modestbranding: 1,
start: Math.floor(startSeconds),
playsinline: 1,
},
events: {
onReady: () => {
// Start polling current time
progressTimerRef.current = window.setInterval(() => {
try {
const t = Math.floor(
playerRef.current?.getCurrentTime?.() ?? 0
);
if (Number.isFinite(t)) setProgress(selected.id, t);
} catch {}
}, 1000);
},
},
});
};
run();
return () => {
if (progressTimerRef.current) {
window.clearInterval(progressTimerRef.current);
progressTimerRef.current = null;
}
try {
playerRef.current?.destroy?.();
} catch {}
playerRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, view, selected?.id]);
return (
<div className={className}>
<Button
aria-label="Open tutorials"
onClick={handleOpen}
variant="action"
title={view === "grid" ? "Tutorials" : selected?.title ?? "Tutorials"}
>
<span className="inline-flex items-center gap-2">
<FontAwesomeIcon icon={faBook} className="text-base-fg" />
<span className="text-base-fg">Tutorials</span>
</span>
</Button>
<Modal
isOpen={open}
onClose={handleClose}
title={view === "video" ? undefined : panelTitle}
titleIcon={faBook}
accessibleTitle={panelTitle}
className="max-w-5xl"
>
{view === "grid" && (
<>
{tutorials.length === 0 ? (
<div className="flex items-center justify-center py-12 text-white/60">
No tutorials yet.
</div>
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{tutorials.map((item: TutorialItem) => (
<button
key={item.id}
type="button"
onClick={() => viewTutorial(item)}
className="group block overflow-hidden rounded-lg border border-white/10 bg-white/5 hover:bg-white/10 text-left"
>
<div className="aspect-video w-full overflow-hidden">
<img
src={item.thumbnailUrl}
alt={item.title}
className="h-full w-full object-cover transition-transform group-hover:scale-[1.02]"
/>
</div>
<div className="p-3 text-sm text-white/90">
{item.title}
</div>
</button>
))}
</div>
)}
</>
)}
{view === "video" && selected && (
<div className="flex w-full flex-col">
<div className="mb-3 flex items-center gap-3">
<Button
onClick={setGrid}
className="w-fit text-base-fg opacity-80 hover:opacity-100 font-medium"
variant="action"
icon={faChevronLeft}
>
Back
</Button>
<div className="text-lg font-bold text-base-fg">
Tutorial: {selected.title}
</div>
</div>
<div className="w-full">
<div className="aspect-video w-full overflow-hidden rounded-lg border border-white/10 bg-black">
<div ref={playerContainerRef} className="h-full w-full" />
</div>
</div>
</div>
)}
</Modal>
</div>
);
}
export default TutorialModalButton;
@@ -1,37 +0,0 @@
export type TutorialItem = {
id: string;
title: string;
thumbnailUrl: string;
videoUrl: string;
category?: string;
};
export enum TutorialCategory {
Editor3D = "3D Editor",
Canvas2D = "2D Canvas",
}
export const defaultTutorials: TutorialItem[] = [
{
id: "turn-images-into-objects",
title: "Turn Images into 3D Objects",
thumbnailUrl:
"/resources/images/tutorial-thumbnails/Turn_Images_Into_Objects.jpg",
videoUrl: "https://youtu.be/t8F-sy_zyK4?si=P8lSzuiwSgdRttKa",
category: TutorialCategory.Editor3D,
},
{
id: "2d-editor-basics",
title: "2D 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",
thumbnailUrl: "/resources/images/tutorial-thumbnails/3D_Editor_Basics.jpg",
videoUrl: "https://youtu.be/pM4CxN-isrY?si=XDyG5C1aMIL4KK5O",
category: TutorialCategory.Editor3D,
},
];
@@ -1,19 +0,0 @@
{
"files": [],
"include": [],
"references": [
{
"path": "../button"
},
{
"path": "../modal"
},
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
],
"extends": "../../../tsconfig.base.json"
}
@@ -1,53 +0,0 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"types": [
"node",
"@nx/react/typings/cssmodule.d.ts",
"@nx/react/typings/image.d.ts",
"vite/client"
],
"rootDir": "src",
"jsx": "react-jsx",
"tsBuildInfoFile": "dist/tsconfig.lib.tsbuildinfo"
},
"exclude": [
"out-tsc",
"dist",
"**/*.spec.ts",
"**/*.test.ts",
"**/*.spec.tsx",
"**/*.test.tsx",
"**/*.spec.js",
"**/*.test.js",
"**/*.spec.jsx",
"**/*.test.jsx",
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx"
],
"include": [
"src/**/*.js",
"src/**/*.jsx",
"src/**/*.ts",
"src/**/*.tsx"
],
"references": [
{
"path": "../button/tsconfig.lib.json"
},
{
"path": "../modal/tsconfig.lib.json"
}
]
}
@@ -1,34 +0,0 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./out-tsc/vitest",
"types": [
"vitest/globals",
"vitest/importMeta",
"vite/client",
"node",
"vitest"
],
"jsx": "react-jsx"
},
"include": [
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx",
"src/**/*.d.ts"
],
"references": [
{
"path": "./tsconfig.lib.json"
}
]
}
@@ -1,49 +0,0 @@
/// <reference types='vitest' />
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import dts from 'vite-plugin-dts';
import * as path from 'path';
export default defineConfig(() => ({
root: __dirname,
cacheDir: '../../../node_modules/.vite/libs/components/tutorial-modal',
plugins: [react(), dts({ entryRoot: 'src', tsconfigPath: path.join(__dirname, 'tsconfig.lib.json') })],
// Uncomment this if you are using workers.
// worker: {
// plugins: [ nxViteTsPaths() ],
// },
// Configuration for building your library.
// See: https://vitejs.dev/guide/build.html#library-mode
build: {
outDir: './dist',
emptyOutDir: true,
reportCompressedSize: true,
commonjsOptions: {
transformMixedEsModules: true,
},
lib: {
// Could also be a dictionary or array of multiple entry points.
entry: 'src/index.ts',
name: '@storyteller/ui-tutorial-modal',
fileName: 'index',
// Change this to the formats you want to support.
// Don't forget to update your package.json as well.
formats: ['es' as const]
},
rollupOptions: {
// External packages that should not be bundled into your library.
external: ['react','react-dom','react/jsx-runtime']
},
},
test: {
watch: false,
globals: true,
environment: 'jsdom',
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
reporters: ['default'],
coverage: {
reportsDirectory: './test-output/vitest/coverage',
provider: 'v8' as const,
}
},
}));
@@ -62,28 +62,4 @@ export const VIDEO_MODELS: VideoModel[] = [
startFrame: true,
endFrame: false,
}),
new VideoModel({
id: "veo_3",
tauriId: "veo_3",
fullName: "Google Veo 3",
category: "video",
creator: ModelCreator.Google,
selectorName: "Google Veo 3",
selectorDescription: "Slow, high-quality model",
selectorBadges: ["2 min."],
startFrame: true,
endFrame: false,
}),
new VideoModel({
id: "veo_3_fast",
tauriId: "veo_3_fast",
fullName: "Google Veo 3 Fast",
category: "video",
creator: ModelCreator.Google,
selectorName: "Google Veo 3 Fast",
selectorDescription: "High-quality model (faster)",
selectorBadges: ["2 min."],
startFrame: true,
endFrame: false,
}),
];
@@ -1,6 +1,6 @@
import { SubscriptionPlanDetails } from "./subscription-plan-details.js";
export const FREE_PLAN: SubscriptionPlanDetails = {
export const FREE_PLAN : SubscriptionPlanDetails = {
slug: "free",
name: "Free",
isPaidPlan: false,
@@ -9,14 +9,11 @@ export const FREE_PLAN: SubscriptionPlanDetails = {
features: [
{ text: "Free daily generations", included: true },
{ text: "Limited access to ArtCraft tools", included: true },
{ text: "~15 Midjourney images", included: true },
{ text: "~10 GPT-Image-1 images", included: true },
{ text: "~1 minute Kling video", included: true },
],
colorScheme: "dark" as const,
};
export const SUBSCRIPTION_PLANS: SubscriptionPlanDetails[] = [
export const SUBSCRIPTION_PLANS : SubscriptionPlanDetails[] = [
FREE_PLAN,
{
slug: "artcraft_basic",
@@ -27,10 +24,10 @@ export const SUBSCRIPTION_PLANS: SubscriptionPlanDetails[] = [
originalMonthlyPrice: 10,
originalYearlyPrice: 120,
features: [
{ text: "~1,000 Flux images", included: true },
{ text: "~300 Midjourney images", included: true },
{ text: "~100 GPT-Image-1 images", included: true },
{ text: "~10 minutes Kling video", included: true },
{ text: "~1,010 Flux images", included: true },
{ text: "~36,000 real-time images", included: true },
{ text: "~180 enhanced images", included: true },
{ text: "~6 training jobs", included: true },
{ text: "Commercial license", included: true },
],
colorScheme: "green" as const,
@@ -44,10 +41,10 @@ export const SUBSCRIPTION_PLANS: SubscriptionPlanDetails[] = [
originalMonthlyPrice: 35,
originalYearlyPrice: 420,
features: [
{ text: "~5,000 Flux images", included: true },
{ text: "~1,000 Midjourney images", included: true },
{ text: "~300 GPT-Image-1 images", included: true },
{ text: "~30 minutes Kling video", included: true },
{ text: "~5,048 Flux images", included: true },
{ text: "~180,000 real-time images", included: true },
{ text: "~900 enhanced images", included: true },
{ text: "~30 training jobs", included: true },
{ text: "Commercial license", included: true },
],
colorScheme: "purple" as const,
@@ -61,15 +58,16 @@ export const SUBSCRIPTION_PLANS: SubscriptionPlanDetails[] = [
originalMonthlyPrice: 60,
originalYearlyPrice: 720,
features: [
{ text: "~15,000 Flux images", included: true },
{ text: "~3,000 Midjourney images", included: true },
{ text: "~500 GPT-Image-1 images", included: true },
{ text: "~60 minutes Kling video", included: true },
{ text: "~15,142 Flux images", included: true },
{ text: "~540,000 real-time images", included: true },
{ text: "~2,700 enhanced images", included: true },
{ text: "~90 training jobs", included: true },
{ text: "Commercial license", included: true },
],
colorScheme: "orange" as const,
},
];
]
export const SUBSCRIPTION_PLANS_BY_SLUG: Map<string, SubscriptionPlanDetails> =
new Map(SUBSCRIPTION_PLANS.map((plan) => [plan.slug, plan]));
export const SUBSCRIPTION_PLANS_BY_SLUG : Map<string, SubscriptionPlanDetails> = new Map(
SUBSCRIPTION_PLANS.map((plan) => [plan.slug, plan])
);
+380 -119
View File
File diff suppressed because it is too large Load Diff
-3
View File
@@ -167,9 +167,6 @@
},
{
"path": "./libs/state/subscription"
},
{
"path": "./libs/components/tutorial-modal"
}
]
}