Compare commits

...
100 changed files with 1634 additions and 465 deletions
+1 -1
View File
@@ -64,7 +64,7 @@ jobs:
npm --version
# Remove mismatched @nx packages and reinstall with correct versions
npm install --save-dev @nx/remix@20.7.2 @nx/js@20.7.2 @nx/react@20.7.2 @nx/vite@20.7.2 @nx/eslint@20.7.2
npm install --save-dev @nx/remix@21.1.1 @nx/js@21.1.1 @nx/react@21.1.1 @nx/vite@21.1.1 @nx/eslint@21.1.1
echo "=== Nx versions after adjustment ==="
npm list --depth=0 | grep nx
@@ -51,7 +51,7 @@ jobs:
npm --version
# Remove mismatched @nx packages and reinstall with correct versions
npm install --save-dev @nx/remix@20.7.2 @nx/js@20.7.2 @nx/react@20.7.2 @nx/vite@20.7.2 @nx/eslint@20.7.2
npm install --save-dev @nx/remix@21.1.1 @nx/js@21.1.1 @nx/react@21.1.1 @nx/vite@21.1.1 @nx/eslint@21.1.1
echo "=== Nx versions after adjustment ==="
npm list --depth=0 | findstr nx
@@ -74,7 +74,7 @@ jobs:
# Update Tauri config to skip the build (since we already built)
cd ${{ github.workspace }}/crates/desktop/artcraft
Copy-Item tauri.conf.json tauri.conf.json.bak
# Set beforeBuildCommand to empty using PowerShell
$config = Get-Content tauri.conf.json | ConvertFrom-Json
$config.build.beforeBuildCommand = ""
@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "\n INSERT INTO tasks (\n id,\n task_status,\n task_type,\n provider,\n provider_job_id,\n frontend_subscriber_id,\n frontend_subscriber_payload\n )\n VALUES (?, ?, ?, ?, ?, ?, ?)\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 7
},
"nullable": []
},
"hash": "0848cd9ddf8f61f2dd4d57f0c58b66a60a985acaa1ee5753f30f5e6e107e080e"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n INSERT INTO tasks (\n id,\n task_status,\n task_type,\n model_type,\n provider,\n provider_job_id,\n frontend_subscriber_id,\n frontend_subscriber_payload\n )\n VALUES (?, ?, ?, ?, ?, ?, ?, ?)\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 8
},
"nullable": []
},
"hash": "22b91a21cbb88abc9bc17b338cf2f9418e08e414667d44476623eb141ab17b87"
}
Generated
+9 -1
View File
@@ -1918,6 +1918,13 @@ dependencies = [
"bitflags 1.3.2",
]
[[package]]
name = "cloudflare_errors"
version = "0.0.1"
dependencies = [
"workspace-build-acceleration",
]
[[package]]
name = "coarsetime"
version = "0.1.34"
@@ -6841,6 +6848,7 @@ dependencies = [
"anyhow",
"base64 0.22.1",
"chrono",
"cloudflare_errors",
"cookie 0.18.1",
"errors",
"idempotency",
@@ -9634,7 +9642,6 @@ dependencies = [
"sqlx",
"tempfile",
"tokens",
"tokio",
"workspace-build-acceleration",
]
@@ -9992,6 +9999,7 @@ dependencies = [
"artcraft_api_defs",
"backoff",
"chrono",
"cloudflare_errors",
"cookie 0.18.1",
"enums",
"enums_public",
+2
View File
@@ -40,6 +40,7 @@ members = [
"crates/lib/caching/memory_caching",
"crates/lib/caching/memory_store",
"crates/lib/caching/redis_caching",
"crates/lib/cloudflare_errors",
"crates/lib/concurrency",
"crates/lib/cookies",
"crates/lib/data/collections",
@@ -216,6 +217,7 @@ url = { version = "2.5.4", features = ["serde"] }
artcraft_api_defs = { path = "crates/schema/public/api/artcraft_api_defs" }
build_metadata = { path = "crates/lib/build_metadata" }
cloud_storage = { path = "crates/api_clients/cloud_storage" }
cloudflare_errors = { path = "crates/lib/cloudflare_errors" }
crockford = { path = "crates/lib/data/crockford" }
enums = { path = "crates/schema/public/enums" }
enums_public = { path = "crates/schema/public/enums_public" }
@@ -1,4 +1,7 @@
-- noinspection SqlDialectInspectionForFile
-- Migration history:
-- tasks_v1.sqlite - initial version
-- tasks_v2.sqlite - added model_type (nullable)
CREATE TABLE tasks (
id TEXT NOT NULL PRIMARY KEY,
@@ -6,6 +9,8 @@ CREATE TABLE tasks (
task_status TEXT NOT NULL,
task_type TEXT NOT NULL,
model_type TEXT,
provider TEXT,
provider_job_id TEXT,
@@ -37,10 +37,10 @@ pub async fn enqueue_veo_2_image_to_video_webhook<U: IntoUrl, V: IntoUrl>(
) -> Result<WebhookResponse, FalErrorPlus> {
let duration = match args.duration {
Veo2Duration::Default => None,
Veo2Duration::FiveSeconds => Some("5".to_string()),
Veo2Duration::SixSeconds => Some("6".to_string()),
Veo2Duration::SevenSeconds => Some("7".to_string()),
Veo2Duration::EightSeconds => Some("8".to_string()),
Veo2Duration::FiveSeconds => Some("5s".to_string()),
Veo2Duration::SixSeconds => Some("6s".to_string()),
Veo2Duration::SevenSeconds => Some("7s".to_string()),
Veo2Duration::EightSeconds => Some("8s".to_string()),
};
let aspect_ratio = match args.aspect_ratio {
@@ -23,6 +23,7 @@ idempotency.workspace = true
anyhow.workspace = true
base64.workspace = true
chrono.workspace = true
cloudflare_errors.workspace = true
cookie.workspace = true
log.workspace = true
once_cell.workspace = true
@@ -1,3 +1,4 @@
use cloudflare_errors::cloudflare_error::CloudflareError;
use std::error::Error;
use std::fmt::{Display, Formatter};
use std::io;
@@ -42,15 +43,9 @@ pub enum SoraError {
/// Typically served from Cloudflare
/// We preserve the message for debugging.
BadGateway(String),
/// HTTP 403 + Cloudflare Challenge Interstitial
/// Cloudflare decided to block the request and present a challenge.
CloudFlareChallenge,
/// HTTP 524 (Cloudflare-specific)
/// Cloudflare formed a TCP connection to Sora, but no payload was delivered before timeout
/// We preserve the message for debugging
CloudFlareTimeout(String),
/// Specific Cloudflare errors.
CloudflareError(CloudflareError),
/// Another error occurred.
OtherBadStatus(anyhow::Error),
@@ -72,6 +67,17 @@ pub enum SoraError {
JsonErrorWithBody(serde_json::Error, String),
}
impl SoraError {
pub fn is_sora_having_downtime_issues(&self) -> bool {
match self {
Self::BadGateway(_) => true,
Self::CloudflareError(CloudflareError::GatewayTimeout504) => true,
Self::CloudflareError(CloudflareError::TimeoutOccurred524) => true,
_ => false,
}
}
}
impl Error for SoraError {}
impl Display for SoraError {
@@ -98,11 +104,8 @@ impl Display for SoraError {
Self::BadGateway(message) => {
write!(f, "Bad Gateway; Sora is likely having issues: {:?}", message)
}
Self::CloudFlareChallenge => {
write!(f, "Cloudflare Challenge (403); Cloudflare wants to verify the request with a CAPTCHA challenge.")
}
Self::CloudFlareTimeout(message) => {
write!(f, "Cloudflare Timeout (524); Sora is likely having issues: {:?}", message)
Self::CloudflareError(error) => {
write!(f, "Cloudflare error: {}", error)
}
Self::OtherBadStatus(err) => {
write!(f, "Other error: {}", err)
@@ -149,3 +152,9 @@ impl From<serde_json::Error> for SoraError {
Self::JsonError(err)
}
}
impl From<CloudflareError> for SoraError {
fn from(err: CloudflareError) -> Self {
Self::CloudflareError(err)
}
}
@@ -1,6 +1,6 @@
use crate::sora_error::SoraError;
use cloudflare_errors::filter_cloudflare_errors::filter_cloudflare_errors;
use reqwest::StatusCode;
/*
Old cookie -
@@ -50,32 +50,15 @@ pub async fn classify_general_http_status_code_and_body(status: StatusCode, resp
}
let status_code = status.as_u16();
if let Err(err) = filter_cloudflare_errors(status_code, &response_body) {
return SoraError::CloudflareError(err);
}
match status_code {
403 => {
if message.contains("challenge-platform")
|| message.contains("challenge-error-text")
|| message.contains("cType: 'managed'")
|| message.contains("Just a moment...") {
return SoraError::CloudFlareChallenge;
}
}
502 => {
return SoraError::BadGateway(message);
}
504 => {
if message.contains("cloudflare")
|| message.contains("Cloudflare") {
return SoraError::CloudFlareTimeout(message);
}
}
524 => {
// NB: 524 is a cloudflare specific gateway timeout error with slightly different semantics than 504.
if message.contains("cloudflare")
|| message.contains("Cloudflare") {
return SoraError::CloudFlareTimeout(message);
}
}
_ => {}, // Fall-through
}
@@ -24,12 +24,13 @@ workspace-build-acceleration.workspace = true
# Internal
artcraft_api_defs.workspace = true
cloudflare_errors.workspace = true
enums.workspace = true
enums_public.workspace = true
errors.workspace = true
idempotency.workspace = true
tokens.workspace = true
mimetypes.workspace = true
tokens.workspace = true
# External
anyhow.workspace = true
@@ -1,7 +1,8 @@
use cloudflare_errors::cloudflare_error::CloudflareError;
use errors::AnyhowError;
use std::error::Error;
use std::fmt::{Display, Formatter};
use std::io;
use errors::AnyhowError;
#[derive(Debug)]
pub enum ApiError {
@@ -26,6 +27,9 @@ pub enum ApiError {
backend_hostname: Option<String>,
},
/// Cloudflare errors.
CloudflareError(CloudflareError),
/// A deserialization error with the response.
DeserializationError(serde_json::Error),
@@ -63,6 +67,8 @@ impl Display for ApiError {
// Network errors
ApiError::Timeout(msg) => write!(f, "Timeout: {}", msg),
ApiError::NetworkError(msg) => write!(f, "Network error: {}", msg),
// Cloudflare errors
ApiError::CloudflareError(error) => write!(f, "Cloudflare Error: {}", error),
// I/O errors
ApiError::IoError(error) => write!(f, "IO error: {}", error),
// Other
@@ -95,3 +101,9 @@ impl From<io::Error> for ApiError {
ApiError::IoError(error)
}
}
impl From<CloudflareError> for ApiError {
fn from(error: CloudflareError) -> Self {
ApiError::CloudflareError(error)
}
}
@@ -1,6 +1,7 @@
use crate::error::api_error::ApiError;
use crate::utils::status_codes::*;
use anyhow::anyhow;
use cloudflare_errors::filter_cloudflare_errors::filter_cloudflare_errors;
use log::error;
/// Pass request errors to this method for standard error handling behavior.
@@ -13,30 +14,34 @@ use log::error;
pub async fn filter_bad_response(response: reqwest::Response) -> Result<reqwest::Response, ApiError> {
let status = response.status();
if !status.is_success() {
// The host in the fleet that handled the request.
let backend_hostname= response.headers()
.get("x-backend-hostname")
.map(|header| header.to_str().unwrap_or_else(|err| {
error!("Failed to parse x-backend-hostname header: {:?}", err);
"unknown"
}))
.map(|s| s.to_string());
let response_body = match response.text().await {
Ok(text) => text.to_string(),
Err(err) => format!("Could not read response body: {:?}", err),
};
return match status.as_u16() {
STATUS_401_UNAUTHORIZED => Err(ApiError::Unauthorized(response_body)),
STATUS_403_FORBIDDEN => Err(ApiError::Forbidden(response_body)),
STATUS_404_NOT_FOUND => Err(ApiError::NotFound(response_body)),
STATUS_429_TOO_MANY_REQUESTS => Err(ApiError::TooManyRequests(response_body)),
STATUS_500_INTERNAL_SERVER_ERROR => Err(ApiError::InternalServerError { body: response_body, backend_hostname }),
_ => Err(ApiError::Other(anyhow!("Bad status code: {}; message: {:?}", status, response_body))),
}
if status.is_success() {
return Ok(response);
}
Ok(response)
}
// The host in the fleet that handled the request.
let backend_hostname= response.headers()
.get("x-backend-hostname")
.map(|header| header.to_str().unwrap_or_else(|err| {
error!("Failed to parse x-backend-hostname header: {:?}", err);
"unknown"
}))
.map(|s| s.to_string());
let response_body = match response.text().await {
Ok(text) => text.to_string(),
Err(err) => format!("Could not read response body: {:?}", err),
};
let status_code = status.as_u16();
filter_cloudflare_errors(status_code, &response_body)?;
match status_code {
STATUS_401_UNAUTHORIZED => Err(ApiError::Unauthorized(response_body)),
STATUS_403_FORBIDDEN => Err(ApiError::Forbidden(response_body)),
STATUS_404_NOT_FOUND => Err(ApiError::NotFound(response_body)),
STATUS_429_TOO_MANY_REQUESTS => Err(ApiError::TooManyRequests(response_body)),
STATUS_500_INTERNAL_SERVER_ERROR => Err(ApiError::InternalServerError { body: response_body, backend_hostname }),
_ => Err(ApiError::Other(anyhow!("Bad status code: {}; message: {:?}", status, response_body))),
}
}
@@ -1,6 +1,7 @@
use crate::core::events::generation_events::common::{GenerationAction, GenerationModel, GenerationServiceProvider};
use crate::core::state::task_database::TaskDatabase;
use enums::common::generation_provider::GenerationProvider;
use enums::tauri::tasks::task_model_type::TaskModelType;
use enums::tauri::tasks::task_status::TaskStatus;
use enums::tauri::tasks::task_type::TaskType;
use sqlite_tasks::error::SqliteTasksError;
@@ -34,10 +35,33 @@ impl TaskEnqueueSuccess{
}
pub async fn insert_into_task_database(&self, task_database: &TaskDatabase) -> Result<TaskId, SqliteTasksError> {
// TODO: Move this mapping elsewhere, or remove the other models.
let model_type = match self.model {
None => None,
Some(GenerationModel::Flux1Dev) => Some(TaskModelType::Flux1Dev),
Some(GenerationModel::Flux1Schnell) => Some(TaskModelType::Flux1Schnell),
Some(GenerationModel::FluxPro11) => Some(TaskModelType::FluxPro11),
Some(GenerationModel::FluxPro11Ultra) => Some(TaskModelType::FluxPro11Ultra),
Some(GenerationModel::GptImage1) => Some(TaskModelType::GptImage1),
Some(GenerationModel::Recraft3) => Some(TaskModelType::Recraft3),
Some(GenerationModel::Kling21Pro) => Some(TaskModelType::Kling21Pro),
Some(GenerationModel::Kling21Master) => Some(TaskModelType::Kling21Master),
Some(GenerationModel::Seedance10Lite) => Some(TaskModelType::Seedance10Lite),
Some(GenerationModel::Veo2) => Some(TaskModelType::Veo2),
Some(GenerationModel::Hunyuan3d2_0) => Some(TaskModelType::Hunyuan3d2_0),
Some(GenerationModel::Hunyuan3d2_1) => Some(TaskModelType::Hunyuan3d2_1),
// TODO: These seem wrong -
Some(GenerationModel::Kling1_6) => Some(TaskModelType::Kling16Pro), // NB: `VideoModel::Kling16Pro`.
Some(GenerationModel::Kling2_0) => None, // TODO: unused elsewhere?
Some(GenerationModel::Sora) => None, // TODO: unused elsewhere?
};
create_task(CreateTaskArgs {
db: task_database.get_connection(),
status: TaskStatus::Pending,
task_type: self.task_type,
model_type: model_type,
provider: self.provider,
provider_job_id: self.provider_job_id.as_deref(),
frontend_subscriber_id: None,
@@ -25,8 +25,11 @@ pub enum GenerationModel {
Hunyuan3d2_0,
#[serde(rename = "hunyuan_3d_2_1")]
Hunyuan3d2_1,
// TODO: Should be Kling16Pro
#[serde(rename = "kling_1.6")]
Kling1_6,
#[serde(rename = "kling_2.0")]
Kling2_0,
#[serde(rename = "kling_2.1_master")]
@@ -1,7 +1,18 @@
use crate::core::lifecycle::startup::bootstrap_task_database::bootstrap_task_database;
use crate::core::lifecycle::startup::spawn_storytoller_task_polling_thread::spawn_storyteller_task_polling_thread;
use crate::core::lifecycle::startup::tasks::bootstrap_task_database::bootstrap_task_database;
use crate::core::lifecycle::startup::tasks::load_provider_priority_state::load_provider_priority_state;
use crate::core::lifecycle::startup::tasks::set_app_log_level::set_app_log_level;
use crate::core::lifecycle::startup::tasks::initially_size_and_position_windows::initially_size_and_position_windows;
use crate::core::lifecycle::startup::tasks::spawn_discord_presence_thread::spawn_discord_presence_thread;
use crate::core::lifecycle::startup::tasks::spawn_fal_task_polling_thread::spawn_fal_task_polling_thread;
use crate::core::lifecycle::startup::tasks::spawn_main_window_thread::spawn_main_window_thread;
use crate::core::lifecycle::startup::tasks::spawn_sora_task_polling_thread::spawn_sora_task_polling_thread;
use crate::core::lifecycle::startup::tasks::spawn_storytoller_task_polling_thread::spawn_storyteller_task_polling_thread;
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::services::fal::state::fal_credential_manager::FalCredentialManager;
use crate::services::fal::state::fal_task_queue::FalTaskQueue;
use crate::services::sora::state::sora_credential_manager::SoraCredentialManager;
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use errors::AnyhowResult;
use tauri::{AppHandle, Manager};
@@ -11,8 +22,30 @@ pub async fn handle_tauri_startup(
root: AppDataRoot,
app_env_configs: AppEnvConfigs,
storyteller_creds_manager: StorytellerCredentialManager,
sora_credential_manager: SoraCredentialManager,
sora_task_queue: SoraTaskQueue,
fal_credential_manager: FalCredentialManager,
fal_task_queue: FalTaskQueue,
) -> AnyhowResult<()> {
let task_database = bootstrap_task_database(&app, &root).await?;
set_app_log_level(
&app,
&root,
)?;
let task_database =
bootstrap_task_database(&app, &root).await?;
load_provider_priority_state(
&app,
&root,
)?;
spawn_main_window_thread(
&app,
&root,
&storyteller_creds_manager,
)?;
spawn_storyteller_task_polling_thread(
&app,
@@ -21,5 +54,27 @@ pub async fn handle_tauri_startup(
&storyteller_creds_manager,
)?;
spawn_sora_task_polling_thread(
&app,
&root,
&app_env_configs,
&sora_credential_manager,
&storyteller_creds_manager,
&sora_task_queue,
)?;
spawn_fal_task_polling_thread(
&app,
&root,
&app_env_configs,
&fal_credential_manager,
&fal_task_queue,
&storyteller_creds_manager,
)?;
spawn_discord_presence_thread()?;
initially_size_and_position_windows(&app, &root);
Ok(())
}
@@ -1,3 +1,2 @@
pub mod bootstrap_task_database;
pub mod handle_tauri_startup;
mod spawn_storytoller_task_polling_thread;
mod tasks;
@@ -0,0 +1,42 @@
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::core::state::window::main_window_position::MainWindowPosition;
use crate::core::state::window::main_window_size::MainWindowSize;
use crate::core::utils::window::position_main_window::position_main_window;
use crate::core::utils::window::resize_main_window::resize_main_window;
use tauri::AppHandle;
pub fn initially_size_and_position_windows(
app: &AppHandle,
root: &AppDataRoot,
) {
println!("Sizing and positioning window...");
match MainWindowSize::from_filesystem_configs(&root) {
Ok(None) => {}
Ok(Some(size)) => {
println!("Resizing window to: {:?}", size);
let result = resize_main_window(app, &size);
if let Err(err) = result {
eprintln!("Could not set window size: {:?}", err);
}
}
Err(err) => {
eprintln!("Failed to read window size from disk: {:?}", err);
}
}
match MainWindowPosition::from_filesystem_configs(&root) {
Ok(None) => {}
Ok(Some(pos)) => {
println!("Moving window to: {:?}", pos);
let result = position_main_window(app, &pos);
if let Err(err) = result {
eprintln!("Could not set window position: {:?}", err);
}
}
Err(err) => {
eprintln!("Failed to read window position from disk: {:?}", err);
}
}
}
@@ -0,0 +1,34 @@
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::core::state::provider_priority::ProviderPriorityStore;
use anyhow::anyhow;
use errors::AnyhowResult;
use tauri::{AppHandle, Manager};
pub fn load_provider_priority_state(
app: &AppHandle,
root: &AppDataRoot,
) -> AnyhowResult<()> {
let provider_priority = match ProviderPriorityStore::from_filesystem_configs(root) {
Ok(Some(priority)) => {
println!("Loaded provider priority from disk: {:?}", priority.get_priority());
priority
}
Ok(None) => {
println!("No provider priority found on disk, using default.");
ProviderPriorityStore::default()
}
Err(err) => {
eprintln!("Failed to read provider priority from disk: {:?}", err);
ProviderPriorityStore::default()
}
};
let success = app.manage(provider_priority);
if !success {
eprintln!("Failed to manage provider priority state.");
return Err(anyhow!("Failed to manage provider priority state."));
}
Ok(())
}
@@ -0,0 +1,9 @@
pub (super) mod bootstrap_task_database;
pub (super) mod set_app_log_level;
pub (super) mod initially_size_and_position_windows;
pub (super) mod spawn_discord_presence_thread;
pub (super) mod spawn_fal_task_polling_thread;
pub (super) mod spawn_main_window_thread;
pub (super) mod spawn_sora_task_polling_thread;
pub (super) mod spawn_storytoller_task_polling_thread;
pub (super) mod load_provider_priority_state;
@@ -0,0 +1,73 @@
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use errors::AnyhowResult;
use log::LevelFilter;
use std::env;
use std::env::VarError;
use tauri::AppHandle;
use tauri_plugin_log::{Target, TargetKind};
const RUST_LOG: &str = "RUST_LOG";
pub fn set_app_log_level(
app: &AppHandle,
root: &AppDataRoot,
) -> AnyhowResult<()> {
//print_env_var(RUST_LOG);
// NB: The semantics of this do not match `RUST_LOG` exactly as it's not set per-package.
let maybe_level = env::var(RUST_LOG)
.map(|value| value.trim().to_ascii_lowercase());
let mut log_level = LevelFilter::Info;
match maybe_level {
Ok(level) => {
println!("The `RUST_LOG` environment variable was set to: `{}`.", level);
if level.contains("debug") {
log_level = LevelFilter::Debug;
} else if level.contains("info") {
log_level = LevelFilter::Info;
} else if level.contains("warn") {
log_level = LevelFilter::Warn;
} else if level.contains("error") {
log_level = LevelFilter::Error;
} else if level.contains("off") {
log_level = LevelFilter::Off;
}
}
Err(VarError::NotPresent) => {
// NB: This demonstrably tests stderr output in the default case.
eprintln!("No `RUST_LOG` environment variable found.");
}
Err(VarError::NotUnicode(_)) => {
// NB: This demonstrably tests stderr output in the default case.
eprintln!("Could not parse `RUST_LOG` environment variable; not unicode.");
}
}
println!("Setting app log level to: {:?}", log_level);
app.plugin(
tauri_plugin_log::Builder::default()
.level(log_level)
.targets(vec![
Target::new(TargetKind::Stdout),
Target::new(TargetKind::LogDir {
file_name: Some(root.log_file_name_str().to_string())
}),
])
.build(),
)?;
Ok(())
}
// pub fn print_env_var(var_name: &str) {
// match env::var(var_name) {
// Ok(value) => println!("Environment variable `{}` = {}", var_name, value),
// Err(VarError::NotPresent) => println!("Environment variable `{}` is not set.", var_name),
// Err(VarError::NotUnicode(_)) => println!("Environment variable `{}` is not a valid Unicode string", var_name),
// }
// }
@@ -0,0 +1,10 @@
use crate::core::threads::discord_presence_thread::discord_presence_thread;
use errors::AnyhowResult;
pub fn spawn_discord_presence_thread(
) -> AnyhowResult<()> {
tauri::async_runtime::spawn(discord_presence_thread());
Ok(())
}
@@ -0,0 +1,29 @@
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::services::fal::state::fal_credential_manager::FalCredentialManager;
use crate::services::fal::state::fal_task_queue::FalTaskQueue;
use crate::services::fal::threads::fal_task_polling_thread::fal_task_polling_thread;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use errors::AnyhowResult;
use tauri::{AppHandle, Manager};
pub fn spawn_fal_task_polling_thread(
app: &AppHandle,
root: &AppDataRoot,
app_env_configs: &AppEnvConfigs,
fal_credential_manager: &FalCredentialManager,
fal_task_queue: &FalTaskQueue,
storyteller_creds_manager: &StorytellerCredentialManager,
) -> AnyhowResult<()> {
tauri::async_runtime::spawn(fal_task_polling_thread(
app.clone(),
app_env_configs.clone(),
root.clone(),
fal_credential_manager.clone(),
fal_task_queue.clone(),
storyteller_creds_manager.clone(),
));
Ok(())
}
@@ -0,0 +1,20 @@
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::core::threads::main_window_thread::main_window_thread::main_window_thread;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use errors::AnyhowResult;
use tauri::{AppHandle, Manager};
pub fn spawn_main_window_thread(
app: &AppHandle,
root: &AppDataRoot,
storyteller_creds_manager: &StorytellerCredentialManager,
) -> AnyhowResult<()> {
tauri::async_runtime::spawn(main_window_thread(
app.clone(),
root.clone(),
storyteller_creds_manager.clone(),
));
Ok(())
}
@@ -0,0 +1,29 @@
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::services::sora::state::sora_credential_manager::SoraCredentialManager;
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
use crate::services::sora::threads::sora_task_polling_thread::sora_task_polling_thread;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use errors::AnyhowResult;
use tauri::{AppHandle, Manager};
pub fn spawn_sora_task_polling_thread(
app: &AppHandle,
root: &AppDataRoot,
app_env_configs: &AppEnvConfigs,
sora_credential_manager: &SoraCredentialManager,
storyteller_creds_manager: &StorytellerCredentialManager,
sora_task_queue: &SoraTaskQueue,
) -> AnyhowResult<()> {
tauri::async_runtime::spawn(sora_task_polling_thread(
app.clone(),
app_env_configs.clone(),
root.clone(),
sora_credential_manager.clone(),
storyteller_creds_manager.clone(),
sora_task_queue.clone(),
));
Ok(())
}
@@ -22,7 +22,7 @@ impl DataSubdir for AppStateDir{
impl AppStateDir {
pub fn get_tasks_sqlite_database_path(&self) -> PathBuf {
self.path.join("tasks_v1.sqlite")
self.path.join("tasks_v2.sqlite")
}
pub fn get_window_size_config_file(&self) -> PathBuf {
@@ -3,8 +3,7 @@ pub mod app_preferences;
pub mod app_startup_time;
pub mod data_dir;
pub mod expanduser;
pub mod main_window_position;
pub mod main_window_size;
pub mod os_platform;
pub mod provider_priority;
pub mod task_database;
pub mod task_database;
pub mod window;
@@ -42,14 +42,6 @@ impl MainWindowPosition {
Ok(Some(pos))
}
pub fn apply_to_main_window(&self, app: &AppHandle) -> AnyhowResult<()> {
let windows = app.windows();
let window = windows.get(MAIN_WINDOW_NAME)
.ok_or_else(|| anyhow::anyhow!("Main window not found"))?;
window.set_position(PhysicalPosition::new(self.x, self.y))?;
Ok(())
}
pub fn persist_to_filesystem(&self, app_data_root: &AppDataRoot) -> AnyhowResult<()> {
let filename = app_data_root.get_window_position_config_file();
let json = serde_json::to_string(self)?;
@@ -1,12 +1,13 @@
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::core::utils::best_window_size_heuristic::best_window_size_heuristic;
use crate::core::windows::main_window::constants::MAIN_WINDOW_NAME;
use crate::core::utils::window::get_window_size_heuristic::get_window_size_heuristic;
use crate::core::windows::main_window::constants::{MAIN_WINDOW_MIN_HEIGHT, MAIN_WINDOW_MIN_WIDTH, MAIN_WINDOW_NAME};
use errors::AnyhowResult;
use serde_derive::{Deserialize, Serialize};
use std::fs::OpenOptions;
use std::io::Write;
use tauri::{AppHandle, Manager, PhysicalSize, Window};
#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq)]
pub struct MainWindowSize {
pub width: u32,
@@ -26,7 +27,7 @@ impl MainWindowSize {
}
pub fn from_window(window: &Window) -> AnyhowResult<Self> {
let size = best_window_size_heuristic(window)?;
let size = get_window_size_heuristic(window)?;
Ok(Self {
width: size.width,
height: size.height,
@@ -43,14 +44,6 @@ impl MainWindowSize {
Ok(Some(size))
}
pub fn apply_to_main_window(&self, app: &AppHandle) -> AnyhowResult<()> {
let windows = app.windows();
let window = windows.get(MAIN_WINDOW_NAME)
.ok_or_else(|| anyhow::anyhow!("Main window not found"))?;
window.set_size(PhysicalSize::new(self.width, self.height))?;
Ok(())
}
pub fn persist_to_filesystem(&self, app_data_root: &AppDataRoot) -> AnyhowResult<()> {
let filename = app_data_root.get_window_size_config_file();
let json = serde_json::to_string(self)?;
@@ -64,6 +57,21 @@ impl MainWindowSize {
Ok(())
}
/// Window size is not wide enough to persist or apply.
pub fn is_not_wide_enough(&self) -> bool {
self.width < MAIN_WINDOW_MIN_WIDTH
}
/// Window size is not tall enough to persist or apply.
pub fn is_not_tall_enough(&self) -> bool {
self.height < MAIN_WINDOW_MIN_HEIGHT
}
/// Window size is not big enough (tall or wide) to persist or apply.
pub fn is_not_big_enough(&self) -> bool {
self.is_not_wide_enough() || self.is_not_tall_enough()
}
pub fn to_physical_size(&self) -> PhysicalSize<u32> {
PhysicalSize::new(self.width, self.height)
}
@@ -0,0 +1,2 @@
pub mod main_window_position;
pub mod main_window_size;
@@ -1,10 +0,0 @@
pub mod frontend_subscriber_id;
pub mod frontend_subscriber_payload;
pub mod task_provider;
pub mod task;
pub mod task_id;
pub mod task_pool;
pub mod task_type;
mod task_provider_job_id;
mod task_state;
pub mod task_database;
@@ -1,42 +0,0 @@
use multi_index_map::MultiIndexMap;
use crate::core::tasks::task_id::TaskId;
use crate::core::tasks::task_provider::TaskProvider;
use crate::core::tasks::task_provider_job_id::TaskProviderJobId;
use crate::core::tasks::task_state::TaskState;
use crate::core::tasks::task_type::TaskType;
/// The provider's native identifier for the task.
pub type ProviderTaskId = String;
#[derive(MultiIndexMap, Debug, Clone)]
#[multi_index_derive(Debug)]
#[multi_index_hash(rustc_hash::FxBuildHasher)]
pub struct Task {
#[multi_index(hashed_unique)]
pub id: TaskId,
#[multi_index(hashed_non_unique)]
pub task_state: TaskState,
pub task_type: Option<TaskType>,
#[multi_index(hashed_non_unique)]
pub provider: Option<TaskProvider>,
/// Identifier used by the provider to track the job.
#[multi_index(hashed_unique)]
pub provider_job_id: Option<TaskProviderJobId>,
/// If the frontend is subscribing, it might have an identifier
pub frontend_subscriber_id: Option<String>,
/// If the frontend is subscribing, it might have a payload.
/// This is opaque, but could be for instance base64-encoded JSON.
pub frontend_subscriber_payload: Option<String>,
// TODO: Submitted and completion datetimes
// TODO: Progress
// TODO: Completion media file, completion batch token
}
@@ -1,21 +0,0 @@
pub struct TaskDatabase {
pub pool: Pool<Sqlite>,
}
impl Database {
pub async fn new(app_handle: &AppHandle) -> Result<Self> {
let app_dir = app_handle
.path_resolver()
.app_data_dir()
.expect("failed to get app dir");
// Ensure the app directory exists
fs::create_dir_all(&app_dir)?;
let db_path = app_dir.join("grid_search.db");
// Set the DATABASE_URL environment variable to point to this SQLite file
env::set_var("DATABASE_URL", format!("sqlite://{}", db_path.display()));
}
}
@@ -1,25 +0,0 @@
use idempotency::uuid::generate_random_uuid;
/// Our identifier for tasks.
/// These are ephemeral (for now), but may be peristed later on.
#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)]
pub struct TaskId(String);
impl TaskId {
pub fn generate() -> Self {
let task_id = format!("tauri_task_{}", generate_random_uuid());
Self(task_id)
}
pub fn from_str(task_id: &str) -> Self {
Self(task_id.to_string())
}
pub fn to_string(&self) -> String {
self.0.clone()
}
pub fn as_str(&self) -> &str {
self.0.as_str()
}
}
@@ -1,7 +0,0 @@
/// Hand-rolling this instead of SQLite for now.
/// Not sure if SQLite will create crazy build issues.
/// Can replace with SQLite later, perhaps.
pub struct TaskPool {
}
@@ -1,8 +0,0 @@
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)]
pub enum TaskProvider {
Artcraft,
Fal,
Sora,
}
@@ -1,22 +0,0 @@
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)]
pub struct TaskProviderJobId(String);
impl TaskProviderJobId {
pub fn from_str(job_id: &str) -> Self {
Self(job_id.to_string())
}
pub fn from_artcraft_job_id(job_id: InferenceJobToken) -> Self {
Self(job_id.to_string())
}
pub fn to_string(&self) -> String {
self.0.clone()
}
pub fn as_str(&self) -> &str {
self.0.as_str()
}
}
@@ -1,11 +0,0 @@
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)]
pub enum TaskState {
Submitted,
InProgress,
// Terminal states
Complete,
Failed,
}
@@ -1,7 +0,0 @@
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)]
pub enum TaskType {
BackgroundRemoval,
ImageGeneration,
VideoGeneration,
}
@@ -1,7 +1,7 @@
use crate::core::state::app_startup_time::AppStartupTime;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::core::state::main_window_position::MainWindowPosition;
use crate::core::state::main_window_size::MainWindowSize;
use crate::core::state::window::main_window_position::MainWindowPosition;
use crate::core::state::window::main_window_size::MainWindowSize;
use crate::core::threads::main_window_thread::persist_storyteller_cookies_task::persist_storyteller_cookies_task;
use crate::core::threads::main_window_thread::persist_window_position_task::persist_window_position_task;
use crate::core::threads::main_window_thread::persist_window_resize_task::persist_window_resize_task;
@@ -1,5 +1,5 @@
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::core::state::main_window_position::MainWindowPosition;
use crate::core::state::window::main_window_position::MainWindowPosition;
use errors::AnyhowResult;
use log::info;
use memory_store::clone_slot::CloneSlot;
@@ -1,6 +1,6 @@
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::core::state::main_window_size::MainWindowSize;
use crate::core::utils::best_window_size_heuristic::best_window_size_heuristic;
use crate::core::state::window::main_window_size::MainWindowSize;
use crate::core::utils::window::get_window_size_heuristic::get_window_size_heuristic;
use errors::AnyhowResult;
use log::info;
use memory_store::clone_slot::CloneSlot;
@@ -13,7 +13,7 @@ pub async fn persist_window_resize_task(
) -> AnyhowResult<()> {
let current_window_size = MainWindowSize::from_window(window)?;
let current_physical_size = best_window_size_heuristic(window)?;
let current_physical_size = get_window_size_heuristic(window)?;
let mut save_size_to_disk = true;
@@ -22,6 +22,10 @@ pub async fn persist_window_resize_task(
save_size_to_disk = false;
}
}
if current_window_size.is_not_big_enough() {
save_size_to_disk = false;
}
if save_size_to_disk {
info!("Saving window size configs to disk...");
@@ -1,4 +1,3 @@
pub mod best_window_size_heuristic;
pub mod clear_all_webview_cookies;
pub mod download_media_file_to_temp_dir;
pub mod download_url_to_temp_dir;
@@ -10,4 +9,4 @@ pub mod object_store;
pub mod save_base64_image_to_temp_dir;
pub mod simple_http_download;
pub mod simple_http_download_to_tempfile;
pub mod webview_unsafe;
pub mod window;
@@ -1,38 +0,0 @@
use errors::AnyhowResult;
use tauri::{AppHandle, Manager};
const MAIN_WINDOW_NAME: &str = "main";
pub fn webview_unsafe_for_app(app: &AppHandle) -> AnyhowResult<()> {
let windows = app.windows();
let maybe_window = windows.get(MAIN_WINDOW_NAME);
/*if let Some(window) = maybe_window {
let webview = window.get_webview(MAIN_WINDOW_NAME);
if let Some(webview) = webview {
webview_unsafe(&webview);
}
} else {
return Err(anyhow::anyhow!("Main window not found"));
}*/
Ok(())
}
/*pub fn webview_unsafe(webview: &Webview) {
let r = webview.with_webview(|webview| {
webview.controller();
#[cfg(target_os = "macos")]
unsafe {
let view: &objc2_web_kit::WKWebView = &*webview.inner().cast();
let controller: &objc2_web_kit::WKUserContentController = &*webview.controller().cast();
let window: &objc2_app_kit::NSWindow = &*webview.ns_window().cast();
//view.setPageZoom(40.);
//controller.removeAllUserScripts();
let bg_color = objc2_app_kit::NSColor::colorWithDeviceRed_green_blue_alpha(1.0, 0.2, 0.4, 1.);
window.setBackgroundColor(Some(&bg_color));
}
});
}*/
@@ -4,18 +4,18 @@ use tauri::{PhysicalSize, Window};
// A typical height difference when dev tools are open:
// Window inner size = PhysicalSize { width: 3704, height: 732 }
// Window outer size = PhysicalSize { width: 3704, height: 1476 }
const HEIGHT_DIFFERENCE_THRESHOLD: u32 = 300;
const DEV_TOOLS_HEIGHT_DIFFERENCE_THRESHOLD: u32 = 300;
/// Get the best window size
pub fn best_window_size_heuristic(window: &Window) -> AnyhowResult<PhysicalSize<u32>> {
pub fn get_window_size_heuristic(window: &Window) -> AnyhowResult<PhysicalSize<u32>> {
// NB: Ordinarily we'd use inner size, as the outer size includes the window decorations.
// However, dev tools contract the inner size, and in the event they're open, we should
// prefer the outer size.
let inner = window.inner_size()?;
let outer = window.outer_size()?;
let height_delta = outer.height.saturating_sub(inner.height);
if height_delta > HEIGHT_DIFFERENCE_THRESHOLD {
if height_delta > DEV_TOOLS_HEIGHT_DIFFERENCE_THRESHOLD {
// Dev tools are open, use the outer size
Ok(outer)
} else {
@@ -0,0 +1,3 @@
pub mod get_window_size_heuristic;
pub mod position_main_window;
pub mod resize_main_window;
@@ -0,0 +1,65 @@
use crate::core::state::window::main_window_position::MainWindowPosition;
use crate::core::windows::main_window::constants::MAIN_WINDOW_NAME;
use errors::AnyhowResult;
use tauri::{AppHandle, Manager, PhysicalPosition};
/// Try to position the main window at startup.
/// If it doesn't meet certain placement heuristics, abandon manual placement and let the OS
/// decide where to place the app.
pub fn position_main_window(
app: &AppHandle,
position: &MainWindowPosition,
) -> AnyhowResult<()> {
// NB: This non-negative position heuristic is good for MacBook (single screen).
// Not sure how this fares on Windows, Linux, or multiscreen setups.
// On Mac, this prevents the windows from being positioned off to the top-left,
// as (0,0) is the top left directly under the top menu bar.
if position.x < 0 {
// TODO(bt,2025-07-16): just in the case of x/width, allow slight placement off
// screen as long as the app is wide enough.
return Err(anyhow::anyhow!("X position must be non-negative"));
} else if position.y < 0 {
return Err(anyhow::anyhow!("Y position must be non-negative"));
}
let windows = app.windows();
let window = windows.get(MAIN_WINDOW_NAME)
.ok_or_else(|| anyhow::anyhow!("Main window not found"))?;
if let Ok(Some(monitor)) = window.current_monitor() {
// Reports the size of the monitor, e.g. PhysicalSize { width: 4112, height: 2658 }
// This is not impacted by `scale_factor`, even if that's a multiple like "2".
// i.e., positioning the app at `monitor.size().width/2` and `monitor.size().height/2`
// will place the **top left corner of the app** in roughly the center of the monitor.
let monitor_size = monitor.size();
println!("Current monitor - size: {:?}", monitor_size);
println!("Current monitor - scale factor: {:?}", monitor.scale_factor());
println!("Current monitor - position: {:?}", monitor.position());
let width_padding = monitor_size.width.div_ceil(6);
let height_padding = monitor_size.height.div_ceil(6);
let max_width = monitor_size.width.saturating_sub(width_padding);
let max_height = monitor_size.height.saturating_sub(height_padding);
let max_width = i32::try_from(max_width)?;
let max_height = i32::try_from(max_height)?;
// Prevent the window from being positioned off the screen to the bottom, right, and bottom right.
// This works on MacBook (single screen), but should be tested on Windows, Linux, and multiscreen setups.
if position.x > max_width {
return Err(anyhow::anyhow!("X position must be less than or equal to {} (monitor width {})",
max_width, monitor_size.width));
} else if position.y > max_height {
return Err(anyhow::anyhow!("Y position must be less than or equal to {} (monitor height {})",
max_height, monitor_size.height));
}
}
window.set_position(PhysicalPosition::new(position.x, position.y))?;
Ok(())
}
@@ -0,0 +1,63 @@
use crate::core::state::window::main_window_size::MainWindowSize;
use crate::core::windows::main_window::constants::{MAIN_WINDOW_MIN_HEIGHT, MAIN_WINDOW_MIN_WIDTH, MAIN_WINDOW_NAME};
use tauri::{AppHandle, Manager, PhysicalSize};
/// Try to resize the main window at startup.
/// If it doesn't meet certain sizing heuristics, abandon manual resize and let the OS
/// decide how to size the app.
pub fn resize_main_window(
app: &AppHandle,
size: &MainWindowSize,
) -> errors::AnyhowResult<()> {
let mut resize_width = size.width;
let mut resize_height = size.height;
let mut resize_was_corrected = false;
if resize_width < MAIN_WINDOW_MIN_WIDTH {
resize_width = MAIN_WINDOW_MIN_WIDTH;
resize_was_corrected = true;
}
if resize_height < MAIN_WINDOW_MIN_HEIGHT {
resize_height = MAIN_WINDOW_MIN_HEIGHT;
resize_was_corrected = true;
}
let windows = app.windows();
let window = windows.get(MAIN_WINDOW_NAME)
.ok_or_else(|| anyhow::anyhow!("Main window not found"))?;
if let Ok(Some(monitor)) = window.current_monitor() {
let scale_factor = monitor.scale_factor();
if scale_factor > 1.0 {
let min_width = MAIN_WINDOW_MIN_WIDTH as f64 * scale_factor;
let min_height = MAIN_WINDOW_MIN_HEIGHT as f64 * scale_factor;
// Is this safe? https://users.rust-lang.org/t/convert-an-f64-to-an-i32/50991/4
let min_width = min_width as u32;
let min_height = min_height as u32;
if resize_width < min_width {
resize_width = min_width;
resize_was_corrected = true;
}
if resize_height < min_height {
resize_height = min_height;
resize_was_corrected = true;
}
}
}
if resize_was_corrected {
println!("Resizing window to: {}x{} (corrected from {}x{})",
resize_width, resize_height, size.width, size.height);
} else {
println!("Resizing window to: {}x{} (no correction needed)", resize_width, resize_height);
}
window.set_size(PhysicalSize::new(resize_width, resize_height))?;
Ok(())
}
@@ -2,3 +2,11 @@
/// The name of the main application window.
/// This is the Artcraft application UX itself.
pub const MAIN_WINDOW_NAME : &str = "main";
// TODO: handle pixel density, eg. retina displays
/// This is the minimum width to resize the main window in an automated fashion
pub const MAIN_WINDOW_MIN_WIDTH : u32 = 800;
// TODO: handle pixel density, eg. retina displays
/// This is the minimum height to resize the main window in an automated fashion
pub const MAIN_WINDOW_MIN_HEIGHT : u32 = 600;
+16 -82
View File
@@ -19,12 +19,9 @@ use crate::core::lifecycle::startup::handle_tauri_startup::handle_tauri_startup;
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::core::state::app_preferences::app_preferences_manager::load_app_preferences_or_default;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::core::state::main_window_position::MainWindowPosition;
use crate::core::state::main_window_size::MainWindowSize;
use crate::core::state::provider_priority::ProviderPriorityStore;
use crate::core::threads::discord_presence_thread::discord_presence_thread;
use crate::core::threads::main_window_thread::main_window_thread::main_window_thread;
use crate::core::utils::webview_unsafe::webview_unsafe_for_app;
use crate::services::fal::commands::fal_background_removal_command::fal_background_removal_command;
use crate::services::fal::commands::fal_hunyuan_image_to_3d_command::fal_hunyuan_image_to_3d_command;
use crate::services::fal::commands::fal_kling_image_to_video_command::fal_kling_image_to_video_command;
@@ -87,36 +84,12 @@ pub fn run() {
let app_env_configs_2 = app_env_configs.clone();
let provider_priority = match ProviderPriorityStore::from_filesystem_configs(&app_data_root) {
Ok(Some(priority)) => {
println!("Loaded provider priority from disk: {:?}", priority.get_priority());
priority
}
Ok(None) => {
println!("No provider priority found on disk, using default.");
ProviderPriorityStore::default()
}
Err(err) => {
eprintln!("Failed to read provider priority from disk: {:?}", err);
ProviderPriorityStore::default()
}
};
println!("Initializing backend runtime...");
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_upload::init())
.plugin(tauri_plugin_log::Builder::new()
.level(log::LevelFilter::Info)
.targets(vec![
Target::new(TargetKind::Stdout),
Target::new(TargetKind::LogDir {
file_name: Some(app_data_root.log_file_name_str().to_string())
}),
])
.build())
.setup(move |app| {
// TODO(bt): This is broken on windows
// log_environment_details();
@@ -133,66 +106,28 @@ pub fn run() {
let root = app_data_root_2.clone();
let env_config = app_env_configs_2.clone();
let storyteller_creds = storyteller_creds_manager_2.clone();
let sora_creds = sora_creds_manager_2.clone();
let sora_tasks = sora_task_queue_2.clone();
let fal_creds = fal_creds_manager_2.clone();
let fal_tasks = fal_task_queue_2.clone();
tauri::async_runtime::block_on(async move {
if let Err(err) = handle_tauri_startup(handle, root, env_config, storyteller_creds).await {
let result = handle_tauri_startup(
handle,
root,
env_config,
storyteller_creds,
sora_creds,
sora_tasks,
fal_creds,
fal_tasks,
).await;
if let Err(err) = result {
error!("Failed to handle Tauri startup: {:?}", err);
panic!("Failed to handle Tauri startup: {:?}", err);
}
});
let result = webview_unsafe_for_app(&app);
if let Err(err) = result {
eprintln!("Error setting webview unsafe: {:?}", err);
}
// TODO(bt): Clean this up. We can just clone at the callsite. Also clean initialization
let app_2 = app.clone();
let app_3 = app.clone();
let app_4 = app.clone();
let app_5 = app.clone();
let app_env_configs_3 = app_env_configs_2.clone();
let app_data_root_3 = app_data_root_2.clone();
let app_data_root_4 = app_data_root_2.clone();
let app_data_root_5 = app_data_root_2.clone();
let sora_creds_manager_3 = sora_creds_manager_2.clone();
let storyteller_creds_manager_3 = storyteller_creds_manager.clone();
let storyteller_creds_manager_4 = storyteller_creds_manager.clone();
let fal_creds_manager_3 = fal_creds_manager_2.clone();
let fal_task_queue_3 = fal_task_queue_2.clone();
match MainWindowSize::from_filesystem_configs(&app_data_root_3) {
Ok(None) => {}
Ok(Some(size)) => {
println!("Resizing window to: {:?}", size);
let result = size.apply_to_main_window(&app);
if let Err(err) = result {
eprintln!("Could not set window size: {:?}", err);
}
}
Err(err) => {
eprintln!("Failed to read window size from disk: {:?}", err);
}
}
match MainWindowPosition::from_filesystem_configs(&app_data_root_3) {
Ok(None) => {}
Ok(Some(pos)) => {
println!("Moving window to: {:?}", pos);
let result = pos.apply_to_main_window(&app);
if let Err(err) = result {
eprintln!("Could not set window position: {:?}", err);
}
}
Err(err) => {
eprintln!("Failed to read window position from disk: {:?}", err);
}
}
tauri::async_runtime::spawn(main_window_thread(app_3, app_data_root_3, storyteller_creds_manager_2));
tauri::async_runtime::spawn(sora_task_polling_thread(app_4, app_env_configs_2, app_data_root_4, sora_creds_manager_3, storyteller_creds_manager_3, sora_task_queue_2));
tauri::async_runtime::spawn(discord_presence_thread());
tauri::async_runtime::spawn(fal_task_polling_thread(app_5, app_env_configs_3, app_data_root_5, fal_creds_manager_3, fal_task_queue_3, storyteller_creds_manager_4));
Ok(())
})
@@ -201,7 +136,6 @@ pub fn run() {
.manage(app_preferences)
.manage(fal_creds_manager)
.manage(fal_task_queue)
.manage(provider_priority)
.manage(sora_creds_manager)
.manage(sora_task_queue)
.manage(storyteller_creds_manager_3)
@@ -145,10 +145,12 @@ pub async fn sora_image_remix_command(
status = CommandErrorStatus::BadRequest;
error_message = "Your Sora username is not yet created. Please visit the Sora.com website to create it first.";
},
InnerError::SoraError(SoraError::BadGateway(_) | SoraError::CloudFlareTimeout(_)) => {
error_type = SoraImageRemixErrorType::SoraIsHavingProblems;
status = CommandErrorStatus::ServerError;
error_message = "Sora is having problems. Please try again later.";
InnerError::SoraError(err) => {
if err.is_sora_having_downtime_issues() {
error_type = SoraImageRemixErrorType::SoraIsHavingProblems;
status = CommandErrorStatus::ServerError;
error_message = "Sora is having problems. Please try again later.";
}
}
_ => {},
}
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "cloudflare_errors"
edition = "2024"
version = "0.0.1"
publish = false
[package.metadata.cargo-udeps.ignore]
normal = ["workspace-build-acceleration"]
[lib]
name = "cloudflare_errors"
path = "src/lib.rs"
[dependencies]
# cargo-hakari workspace hack (faster compile times)
workspace-build-acceleration.workspace = true
# Internal
# (None)
# External
# (None)
[dev-dependencies]
# (None)
@@ -0,0 +1,32 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
#[derive(Debug)]
pub enum CloudflareError {
/// Cloudflare wants to verify the request with a CAPTCHA challenge.
ChallengeInterstitial403,
/// Cloudflare could not form a connection to the backend server.
GatewayTimeout504,
/// Cloudflare formed a TCP connection to the backend server, but no payload was delivered before timeout
TimeoutOccurred524,
}
impl Error for CloudflareError {}
impl Display for CloudflareError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::ChallengeInterstitial403 => {
write!(f, "Cloudflare Challenge Interstitial (403); Cloudflare wants to verify the request with a CAPTCHA challenge.")
}
Self::GatewayTimeout504 => {
write!(f, "Cloudflare Gateway Timeout (504); This is likely a backend server issue.")
}
Self::TimeoutOccurred524 => {
write!(f, "Cloudflare Timeout (524); This is likely a backend server issue. Cloudflare connected, but did not receive a response from the server in time.")
}
}
}
}
@@ -0,0 +1,44 @@
use crate::cloudflare_error::CloudflareError;
/// This only filters out Cloudflare errors. Any other errors will be returned as `Ok(())`.
pub fn filter_cloudflare_errors(status_code: u16, body: &str) -> Result<(), CloudflareError> {
if status_code >= 200 && status_code < 300 {
return Ok(());
}
match status_code {
403 => {
if body.contains("challenge-platform")
|| body.contains("challenge-error-text")
|| body.contains("cType: 'managed'")
|| body.contains("Just a moment...") {
return Err(CloudflareError::ChallengeInterstitial403);
}
}
_ => {}, // Fall-through
}
// TODO: This is a really bad heuristic.
let is_cloudflare = body.contains("cloudflare")
|| body.contains("Cloudflare");
// let is_cloudflare = body.contains("cloudflare.com")
// || body.contains("Cloudflare Ray ID");
if is_cloudflare {
match status_code {
504 => return Err(CloudflareError::GatewayTimeout504),
524 => return Err(CloudflareError::TimeoutOccurred524),
_ => {}, // Fall-through
}
if body.contains("errorcode_504")
|| body.contains("Gateway time-out")
|| body.contains("Error code 504")
{
return Err(CloudflareError::GatewayTimeout504);
}
}
Ok(())
}
+22
View File
@@ -0,0 +1,22 @@
//! cloudflare_errors
//!
//! Classify common cloudflare errors so clients can handle appropriately.
//!
// Never allow these
#![forbid(private_bounds)]
#![forbid(private_interfaces)]
#![forbid(unused_must_use)] // NB: It's unsafe to not close/check some things
// Okay to toggle
#![forbid(unreachable_patterns)]
#![forbid(unused_imports)]
#![forbid(unused_mut)]
#![forbid(unused_variables)]
// Always allow
#![allow(dead_code)]
#![allow(non_snake_case)]
pub mod cloudflare_error;
pub mod filter_cloudflare_errors;
@@ -38,9 +38,6 @@ serde.workspace = true
serde_derive.workspace = true
serde_json.workspace = true
# For the bootstrap tool (TODO: Move this)
tokio = { version = "1.42", features = ["rt", "rt-multi-thread", "fs"] }
[dev-dependencies]
# serial_test = "3.0.0"
@@ -1,6 +1,7 @@
use crate::connection::TaskDbConnection;
use crate::error::SqliteTasksError;
use enums::common::generation_provider::GenerationProvider;
use enums::tauri::tasks::task_model_type::TaskModelType;
use enums::tauri::tasks::task_status::TaskStatus;
use enums::tauri::tasks::task_type::TaskType;
use tokens::tokens::sqlite::tasks::TaskId;
@@ -9,6 +10,7 @@ pub struct CreateTaskArgs<'a> {
pub db: &'a TaskDbConnection,
pub status: TaskStatus,
pub task_type: TaskType,
pub model_type: Option<TaskModelType>,
pub provider: GenerationProvider,
pub provider_job_id: Option<&'a str>,
pub frontend_subscriber_id: Option<&'a str>,
@@ -24,6 +26,7 @@ pub async fn create_task(
let task_id_temp = task_id.as_str();
let status_temp = args.status.to_str();
let task_type_temp = args.task_type.to_str();
let model_type_temp = args.model_type.map(|s| s.to_str());
let provider_temp = args.provider.to_string();
let query = sqlx::query!(r#"
@@ -31,16 +34,18 @@ pub async fn create_task(
id,
task_status,
task_type,
model_type,
provider,
provider_job_id,
frontend_subscriber_id,
frontend_subscriber_payload
)
VALUES (?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
"#,
task_id_temp,
status_temp,
task_type_temp,
model_type_temp,
provider_temp,
args.provider_job_id,
args.frontend_subscriber_id,
@@ -1,6 +1,7 @@
use crate::connection::TaskDbConnection;
use crate::error::SqliteTasksError;
use enums::common::generation_provider::GenerationProvider;
use enums::tauri::tasks::task_model_type::TaskModelType;
use enums::tauri::tasks::task_status::TaskStatus;
use enums::tauri::tasks::task_type::TaskType;
use sqlx::{QueryBuilder, Sqlite};
@@ -21,6 +22,7 @@ pub struct Task {
pub id: TaskId,
pub status: TaskStatus,
pub task_type: TaskType,
pub model_type: TaskModelType,
pub provider: GenerationProvider,
pub provider_job_id: Option<String>,
pub frontend_subscriber_id: Option<String>,
@@ -33,6 +35,7 @@ struct RawTask {
id: String,
task_status: String,
task_type: String,
model_type: String,
provider: String,
provider_job_id: Option<String>,
frontend_subscriber_id: Option<String>,
@@ -48,6 +51,7 @@ pub async fn list_tasks_by_provider_and_tokens(
id,
task_status,
task_type,
model_type,
provider,
provider_job_id,
frontend_subscriber_id,
@@ -87,6 +91,8 @@ pub async fn list_tasks_by_provider_and_tokens(
.map_err(|err| SqliteTasksError::TaskParseError(err))?,
task_type: TaskType::from_str(&task.task_type)
.map_err(|err| SqliteTasksError::TaskParseError(err))?,
model_type: TaskModelType::from_str(&task.model_type)
.map_err(|err| SqliteTasksError::TaskParseError(err))?,
provider: GenerationProvider::from_str(&task.provider)
.map_err(|err| SqliteTasksError::TaskParseError(err))?,
provider_job_id: task.provider_job_id,
@@ -1,2 +1,3 @@
pub mod task_model_type;
pub mod task_status;
pub mod task_type;
@@ -0,0 +1,241 @@
use std::collections::BTreeSet;
#[cfg(test)]
use strum::EnumCount;
#[cfg(test)]
use strum::EnumIter;
use utoipa::ToSchema;
#[cfg_attr(test, derive(EnumIter, EnumCount))]
#[derive(Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum TaskModelType {
// Image models
#[serde(rename = "flux_1_dev")]
Flux1Dev,
#[serde(rename = "flux_1_schnell")]
Flux1Schnell,
#[serde(rename = "flux_pro_1.1")]
FluxPro11,
#[serde(rename = "flux_pro_1.1_ultra")]
FluxPro11Ultra,
#[serde(rename = "gpt_image_1")]
GptImage1,
#[serde(rename = "recraft_3")]
Recraft3,
// Video models
#[serde(rename = "kling_1.6_pro")]
Kling16Pro,
#[serde(rename = "kling_2.1_pro")]
Kling21Pro,
#[serde(rename = "kling_2.1_master")]
Kling21Master,
#[serde(rename = "seedance_1.0_lite")]
Seedance10Lite,
#[serde(rename = "veo_2")]
Veo2,
// 3D Object generation models
#[serde(rename = "hunyuan_3d_2.0")]
Hunyuan3d2_0,
#[serde(rename = "hunyuan_3d_2.1")]
Hunyuan3d2_1,
}
impl_enum_display_and_debug_using_to_str!(TaskModelType);
//impl_mysql_enum_coders!(TaskModelType);
//impl_mysql_from_row!(TaskModelType);
// NB: We can derive `sqlx::Type` instead of using `impl_mysql_enum_coders`
impl TaskModelType {
pub fn to_str(&self) -> &'static str {
match self {
// Image models
Self::Flux1Dev => "flux_1_dev",
Self::Flux1Schnell => "flux_1_schnell",
Self::FluxPro11 => "flux_pro_1.1",
Self::FluxPro11Ultra => "flux_pro_1.1_ultra",
Self::GptImage1 => "gpt_image_1",
Self::Recraft3 => "recraft_3",
// Video models
Self::Kling16Pro => "kling_1.6_pro",
Self::Kling21Pro => "kling_2.1_pro",
Self::Kling21Master => "kling_2.1_master",
Self::Seedance10Lite => "seedance_1.0_lite",
Self::Veo2 => "veo_2",
// 3D Object generation models
Self::Hunyuan3d2_0 => "hunyuan_3d_2.0",
Self::Hunyuan3d2_1 => "hunyuan_3d_2.1",
}
}
pub fn from_str(job_status: &str) -> Result<Self, String> {
match job_status {
// Image models
"flux_1_dev" => Ok(Self::Flux1Dev),
"flux_1_schnell" => Ok(Self::Flux1Schnell),
"flux_pro_1.1" => Ok(Self::FluxPro11),
"flux_pro_1.1_ultra" => Ok(Self::FluxPro11Ultra),
"gpt_image_1" => Ok(Self::GptImage1),
"recraft_3" => Ok(Self::Recraft3),
// Video models
"kling_1.6_pro" => Ok(Self::Kling16Pro),
"kling_2.1_pro" => Ok(Self::Kling21Pro),
"kling_2.1_master" => Ok(Self::Kling21Master),
"seedance_1.0_lite" => Ok(Self::Seedance10Lite),
"veo_2" => Ok(Self::Veo2),
// 3D Object generation models
"hunyuan_3d_2.0" => Ok(Self::Hunyuan3d2_0),
"hunyuan_3d_2.1" => Ok(Self::Hunyuan3d2_1),
_ => Err(format!("invalid task_state: {:?}", job_status)),
}
}
pub fn all_variants() -> BTreeSet<Self> {
// NB: BTreeSet is sorted
// NB: BTreeSet::from() isn't const, but not worth using LazyStatic, etc.
BTreeSet::from([
// Image models
Self::Flux1Dev,
Self::Flux1Schnell,
Self::FluxPro11,
Self::FluxPro11Ultra,
Self::GptImage1,
Self::Recraft3,
// Video models
Self::Kling16Pro,
Self::Kling21Pro,
Self::Kling21Master,
Self::Seedance10Lite,
Self::Veo2,
// 3D Object generation models
Self::Hunyuan3d2_0,
Self::Hunyuan3d2_1,
])
}
}
#[cfg(test)]
mod tests {
use crate::test_helpers::assert_serialization;
use crate::tauri::tasks::task_model_type::TaskModelType;
mod explicit_checks {
use super::*;
#[test]
fn test_serialization() {
// Image models
assert_serialization(TaskModelType::Flux1Dev, "flux_1_dev");
assert_serialization(TaskModelType::Flux1Schnell, "flux_1_schnell");
assert_serialization(TaskModelType::FluxPro11, "flux_pro_1.1");
assert_serialization(TaskModelType::FluxPro11Ultra, "flux_pro_1.1_ultra");
assert_serialization(TaskModelType::GptImage1, "gpt_image_1");
assert_serialization(TaskModelType::Recraft3, "recraft_3");
// Video models
assert_serialization(TaskModelType::Kling16Pro, "kling_1.6_pro");
assert_serialization(TaskModelType::Kling21Pro, "kling_2.1_pro");
assert_serialization(TaskModelType::Kling21Master, "kling_2.1_master");
assert_serialization(TaskModelType::Seedance10Lite, "seedance_1.0_lite");
assert_serialization(TaskModelType::Veo2, "veo_2");
// 3D Object generation models
assert_serialization(TaskModelType::Hunyuan3d2_0, "hunyuan_3d_2.0");
assert_serialization(TaskModelType::Hunyuan3d2_1, "hunyuan_3d_2.1");
}
#[test]
fn to_str() {
// Image models
assert_eq!(TaskModelType::Flux1Dev.to_str(), "flux_1_dev");
assert_eq!(TaskModelType::Flux1Schnell.to_str(), "flux_1_schnell");
assert_eq!(TaskModelType::FluxPro11.to_str(), "flux_pro_1.1");
assert_eq!(TaskModelType::FluxPro11Ultra.to_str(), "flux_pro_1.1_ultra");
assert_eq!(TaskModelType::GptImage1.to_str(), "gpt_image_1");
assert_eq!(TaskModelType::Recraft3.to_str(), "recraft_3");
// Video models
assert_eq!(TaskModelType::Kling16Pro.to_str(), "kling_1.6_pro");
assert_eq!(TaskModelType::Kling21Pro.to_str(), "kling_2.1_pro");
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");
// 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");
}
#[test]
fn from_str() {
// Image models
assert_eq!(TaskModelType::from_str("flux_1_dev").unwrap(), TaskModelType::Flux1Dev);
assert_eq!(TaskModelType::from_str("flux_1_schnell").unwrap(), TaskModelType::Flux1Schnell);
assert_eq!(TaskModelType::from_str("flux_pro_1.1").unwrap(), TaskModelType::FluxPro11);
assert_eq!(TaskModelType::from_str("flux_pro_1.1_ultra").unwrap(), TaskModelType::FluxPro11Ultra);
assert_eq!(TaskModelType::from_str("gpt_image_1").unwrap(), TaskModelType::GptImage1);
assert_eq!(TaskModelType::from_str("recraft_3").unwrap(), TaskModelType::Recraft3);
// Video models
assert_eq!(TaskModelType::from_str("kling_1.6_pro").unwrap(), TaskModelType::Kling16Pro);
assert_eq!(TaskModelType::from_str("kling_2.1_pro").unwrap(), TaskModelType::Kling21Pro);
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);
// 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);
}
#[test]
fn all_variants() {
let mut variants = TaskModelType::all_variants();
assert_eq!(variants.len(), 13);
// Image models
assert_eq!(variants.pop_first(), Some(TaskModelType::Flux1Dev));
assert_eq!(variants.pop_first(), Some(TaskModelType::Flux1Schnell));
assert_eq!(variants.pop_first(), Some(TaskModelType::FluxPro11));
assert_eq!(variants.pop_first(), Some(TaskModelType::FluxPro11Ultra));
assert_eq!(variants.pop_first(), Some(TaskModelType::GptImage1));
assert_eq!(variants.pop_first(), Some(TaskModelType::Recraft3));
// Video models
assert_eq!(variants.pop_first(), Some(TaskModelType::Kling16Pro));
assert_eq!(variants.pop_first(), Some(TaskModelType::Kling21Pro));
assert_eq!(variants.pop_first(), Some(TaskModelType::Kling21Master));
assert_eq!(variants.pop_first(), Some(TaskModelType::Seedance10Lite));
assert_eq!(variants.pop_first(), Some(TaskModelType::Veo2));
// 3D Object generation models
assert_eq!(variants.pop_first(), Some(TaskModelType::Hunyuan3d2_0));
assert_eq!(variants.pop_first(), Some(TaskModelType::Hunyuan3d2_1));
assert_eq!(variants.pop_first(), None);
}
}
mod mechanical_checks {
use super::*;
#[test]
fn variant_length() {
use strum::IntoEnumIterator;
assert_eq!(TaskModelType::all_variants().len(), TaskModelType::iter().len());
}
#[test]
fn round_trip() {
for variant in TaskModelType::all_variants() {
// Test to_str(), from_str(), Display, and Debug.
assert_eq!(variant, TaskModelType::from_str(variant.to_str()).unwrap());
assert_eq!(variant, TaskModelType::from_str(&format!("{}", variant)).unwrap());
assert_eq!(variant, TaskModelType::from_str(&format!("{:?}", variant)).unwrap());
}
}
#[test]
fn serialized_length_ok_for_database() {
const MAX_LENGTH : usize = 24;
for variant in TaskModelType::all_variants() {
let serialized = variant.to_str();
assert!(serialized.len() > 0, "variant {:?} is too short", variant);
assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long", variant);
}
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 463 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Flux</title><path d="M0 20.683L12.01 2.5 24 20.683h-2.233L12.009 5.878 3.471 18.806h12.122l1.239 1.877H0z"></path><path d="M8.069 16.724l2.073-3.115 2.074 3.115H8.069zM18.24 20.683l-5.668-8.707h2.177l5.686 8.707h-2.196zM19.74 11.676l2.13-3.19 2.13 3.19h-4.26z"></path></svg>

After

Width:  |  Height:  |  Size: 438 B

@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Kling</title><path clip-rule="evenodd" d="M5.493 21.234c-1.112-1.451-1.109-4.263-.081-7.459l-4.557-2.63a1.683 1.683 0 01-.85-1.304 1.505 1.505 0 01.08-.622 13.18 13.18 0 011.037-2.255c3.476-6.02 10.916-8.23 16.619-4.938.46.266.82.67 1.081 1.184.785 1.545.685 4.096-.234 6.954l4.557 2.631c.339.196.596.492.736.832a1.53 1.53 0 01.034 1.093 13.146 13.146 0 01-1.037 2.255c-3.476 6.02-10.916 8.23-16.619 4.938a2.6 2.6 0 01-.766-.68zm11.096-6.615c-2.073 3.591-5.808 5.316-8.343 3.852-1.267-.731-1.994-2.122-2.145-3.778-.095-1.035.036-2.173.4-3.32.217-.684.517-1.37.902-2.039l.008-.014c2.073-3.59 5.808-5.315 8.343-3.852.633.366 1.13.895 1.49 1.54.986 1.772.922 4.415-.285 6.914-.111.23-.232.457-.362.683l-.008.014z"></path></svg>

After

Width:  |  Height:  |  Size: 888 B

@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Midjourney</title><path d="M22.369 17.676c-1.387 1.259-3.17 2.378-5.332 3.417.044.03.086.057.13.083l.018.01.019.012c.216.123.42.184.641.184.222 0 .426-.061.642-.184l.018-.011.019-.011c.14-.084.266-.178.492-.366l.178-.148c.279-.232.426-.342.625-.456.304-.174.612-.266.949-.266.337 0 .645.092.949.266l.023.014c.188.109.334.219.602.442l.178.148c.221.184.346.278.483.36l.028.017.018.01c.21.12.407.181.62.185h.022a.31.31 0 110 .618c-.337 0-.645-.092-.95-.266a3.137 3.137 0 01-.09-.054l-.022-.014-.022-.013-.02-.014a5.356 5.356 0 01-.49-.377l-.159-.132a3.836 3.836 0 00-.483-.36l-.027-.017-.019-.01a1.256 1.256 0 00-.641-.185c-.222 0-.426.061-.641.184l-.02.011-.018.011c-.14.084-.266.178-.492.366l-.158.132a5.125 5.125 0 01-.51.39l-.022.014-.022.014-.09.054a1.868 1.868 0 01-.95.266c-.337 0-.644-.092-.949-.266a3.137 3.137 0 01-.09-.054l-.022-.014-.022-.013-.026-.017a4.881 4.881 0 01-.425-.325.308.308 0 01-.12-.1l-.098-.081a3.836 3.836 0 00-.483-.36l-.027-.017-.019-.01a1.256 1.256 0 00-.641-.185c-.222 0-.426.061-.642.184l-.018.011-.019.011c-.14.084-.266.178-.492.366l-.158.132a5.125 5.125 0 01-.51.39l-.023.014-.022.014-.09.054A1.868 1.868 0 0112 22c-.337 0-.645-.092-.949-.266a3.137 3.137 0 01-.09-.054l-.022-.014-.022-.013-.021-.014a5.356 5.356 0 01-.49-.377l-.158-.132a3.836 3.836 0 00-.483-.36l-.028-.017-.018-.01a1.256 1.256 0 00-.642-.185c-.221 0-.425.061-.641.184l-.019.011-.018.011c-.141.084-.266.178-.492.366l-.158.132a5.125 5.125 0 01-.511.39l-.022.014-.022.014-.09.054a1.868 1.868 0 01-.986.264c-.746-.09-1.319-.38-1.89-.866l-.035-.03c-.047-.041-.118-.106-.192-.174l-.196-.181-.107-.1-.011-.01a1.531 1.531 0 00-.336-.253.313.313 0 00-.095-.03h-.005c-.119.022-.238.059-.361.11a.308.308 0 01-.077.061l-.008.005a.309.309 0 01-.126.034 5.66 5.66 0 00-.774.518l-.416.324-.055.043a6.542 6.542 0 01-.324.236c-.305.207-.552.315-.8.315a.31.31 0 01-.01-.618h.01c.09 0 .235-.062.438-.198l.04-.027c.077-.054.163-.117.27-.199l.385-.301.06-.047c.268-.206.506-.373.73-.505l-.633-1.21a.309.309 0 01.254-.451l20.287-1.305a.309.309 0 01.228.537zm-1.118.14L2.369 19.03l.423.809c.128-.045.256-.078.388-.1a.31.31 0 01.052-.005c.132 0 .26.032.386.093.153.073.294.179.483.35l.016.015.092.086.144.134.097.089c.065.06.125.114.16.144.485.418.948.658 1.554.736h.011a1.25 1.25 0 00.6-.172l.021-.011.019-.011.018-.011c.141-.084.266-.178.492-.366l.178-.148c.279-.232.426-.342.625-.456.305-.174.612-.266.95-.266.336 0 .644.092.948.266l.023.014c.188.109.335.219.603.442l.177.148c.222.184.346.278.484.36l.027.017.019.01c.215.124.42.185.641.185.222 0 .426-.061.641-.184l.019-.011.018-.011c.141-.084.267-.178.493-.366l.177-.148c.28-.232.427-.342.626-.456.304-.174.612-.266.949-.266.337 0 .644.092.949.266l.025.015c.187.109.334.22.603.443 1.867-.878 3.448-1.811 4.73-2.832l.02-.016zM3.653 2.026C6.073 3.06 8.69 4.941 10.8 7.258c2.46 2.7 4.109 5.828 4.637 9.149a.31.31 0 01-.421.335c-2.348-.945-4.54-1.258-6.59-1.02-1.739.2-3.337.792-4.816 1.703-.294.182-.62-.182-.405-.454 1.856-2.355 2.581-4.99 2.343-7.794-.195-2.292-1.031-4.61-2.284-6.709a.31.31 0 01.388-.442zM10.04 4.45c1.778.543 3.892 2.102 5.782 4.243 1.984 2.248 3.552 4.934 4.347 7.582a.31.31 0 01-.401.38l-.022-.01-.386-.154a10.594 10.594 0 00-.291-.112l-.016-.006c-.68-.247-1.199-.291-1.944-.101a.31.31 0 01-.375-.218C15.378 11.123 13.073 7.276 9.775 5c-.291-.201-.072-.653.266-.55zM4.273 2.996l.008.015c1.028 1.94 1.708 4.031 1.885 6.113.213 2.513-.31 4.906-1.673 7.092l-.02.031.003-.001c1.198-.581 2.47-.969 3.825-1.132l.055-.006c1.981-.23 4.083.029 6.309.837l.066.025-.007-.039c-.593-2.95-2.108-5.737-4.31-8.179l-.07-.078c-1.785-1.96-3.944-3.6-6.014-4.65l-.057-.028zm7.92 3.238l.048.048c2.237 2.295 3.885 5.431 4.974 9.191l.038.132.022-.004c.71-.133 1.284-.063 1.963.18l.027.01.066.024.046.018-.025-.073c-.811-2.307-2.208-4.62-3.936-6.594l-.058-.065c-1.02-1.155-2.103-2.132-3.15-2.856l-.015-.011z"></path></svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OpenAI</title><path d="M21.55 10.004a5.416 5.416 0 00-.478-4.501c-1.217-2.09-3.662-3.166-6.05-2.66A5.59 5.59 0 0010.831 1C8.39.995 6.224 2.546 5.473 4.838A5.553 5.553 0 001.76 7.496a5.487 5.487 0 00.691 6.5 5.416 5.416 0 00.477 4.502c1.217 2.09 3.662 3.165 6.05 2.66A5.586 5.586 0 0013.168 23c2.443.006 4.61-1.546 5.361-3.84a5.553 5.553 0 003.715-2.66 5.488 5.488 0 00-.693-6.497v.001zm-8.381 11.558a4.199 4.199 0 01-2.675-.954c.034-.018.093-.05.132-.074l4.44-2.53a.71.71 0 00.364-.623v-6.176l1.877 1.069c.02.01.033.029.036.05v5.115c-.003 2.274-1.87 4.118-4.174 4.123zM4.192 17.78a4.059 4.059 0 01-.498-2.763c.032.02.09.055.131.078l4.44 2.53c.225.13.504.13.73 0l5.42-3.088v2.138a.068.068 0 01-.027.057L9.9 19.288c-1.999 1.136-4.552.46-5.707-1.51h-.001zM3.023 8.216A4.15 4.15 0 015.198 6.41l-.002.151v5.06a.711.711 0 00.364.624l5.42 3.087-1.876 1.07a.067.067 0 01-.063.005l-4.489-2.559c-1.995-1.14-2.679-3.658-1.53-5.63h.001zm15.417 3.54l-5.42-3.088L14.896 7.6a.067.067 0 01.063-.006l4.489 2.557c1.998 1.14 2.683 3.662 1.529 5.633a4.163 4.163 0 01-2.174 1.807V12.38a.71.71 0 00-.363-.623zm1.867-2.773a6.04 6.04 0 00-.132-.078l-4.44-2.53a.731.731 0 00-.729 0l-5.42 3.088V7.325a.068.068 0 01.027-.057L14.1 4.713c2-1.137 4.555-.46 5.707 1.513.487.833.664 1.809.499 2.757h.001zm-11.741 3.81l-1.877-1.068a.065.065 0 01-.036-.051V6.559c.001-2.277 1.873-4.122 4.181-4.12.976 0 1.92.338 2.671.954-.034.018-.092.05-.131.073l-4.44 2.53a.71.71 0 00-.365.623l-.003 6.173v.002zm1.02-2.168L12 9.25l2.414 1.375v2.75L12 14.75l-2.415-1.375v-2.75z"></path></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -0,0 +1,142 @@
import { ReactNode } from "react";
import { twMerge } from "tailwind-merge";
interface BadgeInfo {
id: string;
label?: string | ReactNode;
svg?: ReactNode | null;
}
interface ModelBadgeGridProps {
className?: string;
highlight?: string;
rowOffsets?: number[];
}
const baseBadgeClasses =
"rounded-2xl px-6 py-3 text-2xl font-normal flex-shrink-0 bg-white/10 text-white/90 text-center h-[58px] flex items-center justify-center gap-1.5";
const highlightClasses =
"bg-primary/30 text-white font-semibold shadow-lg border-2 border-primary/60";
const rows: BadgeInfo[][] = [
[
{ id: "empty1", svg: null },
{ id: "empty2", svg: null },
{ id: "empty3", svg: null },
{
id: "flux",
label: "Flux Pro 1.1 Ultra",
svg: (
<img
src="/model-logos/flux.svg"
alt="Flux logo"
className="w-full h-full invert"
/>
),
},
{ id: "empty4", svg: null },
{ id: "empty6", svg: null },
{ id: "empty7", svg: null },
{ id: "empty8", svg: null },
],
[
{ id: "empty9", svg: null },
{
id: "midjourney",
label: "Midjourney",
svg: (
<img
src="/model-logos/midjourney.svg"
alt="Midjourney logo"
className="w-full h-full invert"
/>
),
},
{
id: "gpt-image-1",
label: "GPT-Image 1",
svg: (
<img
src="/model-logos/openai.svg"
alt="OpenAI logo"
className="w-full h-full invert"
/>
),
},
{ id: "empty10", svg: null },
{ id: "empty11", svg: null },
{ id: "empty12", svg: null },
{ id: "empty13", svg: null },
{ id: "empty14", svg: null },
],
[
{ id: "empty15", svg: null },
{ id: "empty16", svg: null },
{ id: "empty17", svg: null },
{
id: "kling",
label: "Kling 2.1 Pro",
svg: (
<img
src="/model-logos/kling.svg"
alt="Kling AI logo"
className="w-full h-full invert"
/>
),
},
{ id: "empty18", svg: null },
{ id: "empty19", svg: null },
{ id: "empty20", svg: null },
{ id: "empty21", svg: null },
],
];
export default function ModelBadgeGrid({
className = "",
highlight,
rowOffsets = [],
}: ModelBadgeGridProps) {
return (
<div
className={`select-none relative z-10 h-full overflow-hidden ${className}`}
>
{/* Gradient fade overlays */}
<div className="absolute left-0 top-0 w-32 xl:w-96 h-full bg-gradient-to-r from-[#28282C] to-transparent z-10 pointer-events-none" />
<div className="absolute right-0 top-0 w-32 xl:w-96 h-full bg-gradient-to-l from-[#28282C] to-transparent z-10 pointer-events-none" />
<div className="flex flex-col gap-5 h-full -mx-[280px] xl:-mx-8">
{rows.map((row, rowIdx) => {
const offset = rowOffsets[rowIdx] ?? 0;
return (
<div
key={rowIdx}
className="flex gap-5 whitespace-nowrap"
style={{ marginLeft: offset }}
>
{row.map(({ id, label, svg }, idx) => {
const isHighlight = highlight && highlight.toLowerCase() === id;
return (
<div
key={idx}
className={twMerge(
baseBadgeClasses,
label ? "" : "min-w-[180px]",
isHighlight ? highlightClasses : ""
)}
>
{svg ? (
<span className="mr-2 w-6 h-6 inline-flex ">{svg}</span>
) : null}
{label}
</div>
);
})}
</div>
);
})}
</div>
</div>
);
}
@@ -3,16 +3,25 @@ import { motion } from "framer-motion";
import { Button } from "@storyteller/ui-button";
import { isMobile } from "react-device-detect";
import { faWindows, faApple } from "@fortawesome/free-brands-svg-icons";
import { faVolumeMute, faVolumeHigh } from "@fortawesome/pro-solid-svg-icons";
import {
faVolumeMute,
faVolumeHigh,
faFilm,
faPaintBrush,
faCamera,
} from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import Footer from "../../components/footer";
import { useState, useRef, useEffect } from "react";
import ModelBadgeGrid from "../../components/model-badge-grid";
//const MAC_LINK = "https://pub-3b58c874772a4e04b9c291815224128c.r2.dev/mac/ArtCraft_0.0.1_universal_2025.06.04.dmg";
const MAC_LINK = "https://pub-3b58c874772a4e04b9c291815224128c.r2.dev/mac/ArtCraft_0.0.1_universal_2025.07.13.dmg";
//const MAC_LINK = "https://pub-3b58c874772a4e04b9c291815224128c.r2.dev/mac/ArtCraft_0.0.1_universal_2025.07.13.dmg";
const MAC_LINK = "https://pub-3b58c874772a4e04b9c291815224128c.r2.dev/mac/ArtCraft_0.0.1_universal_2025.07.17.dmg";
//const WINDOWS_LINK = "https://pub-3b58c874772a4e04b9c291815224128c.r2.dev/windows/ArtCraft_0.0.1_x64-setup_2025.06.04.exe";
const WINDOWS_LINK = "https://pub-3b58c874772a4e04b9c291815224128c.r2.dev/windows/ArtCraft_0.0.1_x64-setup_2025.07.13.exe";
//const WINDOWS_LINK = "https://pub-3b58c874772a4e04b9c291815224128c.r2.dev/windows/ArtCraft_0.0.1_x64-setup_2025.07.13.exe";
const WINDOWS_LINK = "https://pub-3b58c874772a4e04b9c291815224128c.r2.dev/windows/ArtCraft_0.0.1_x64-setup_2025.07.17.exe";
const fadeUpVariants = {
hidden: { opacity: 0, y: 20 },
@@ -48,7 +57,7 @@ const Landing = () => {
useEffect(() => {
if (videoRef.current) {
videoRef.current.muted = isMuted;
videoRef.current.volume = 0.14;
videoRef.current.volume = 0.1;
}
}, [isMuted]);
@@ -82,18 +91,21 @@ const Landing = () => {
<span className="text-white">
Controllable AI
<br />
for Artists
for{" "}
<span className="relative after:absolute after:bottom-0 after:left-0 after:w-full after:h-[6px] md:after:h-[11px] after:bg-primary/80 after:mb-0 after:xl:mb-2">
Artists
</span>
</span>
</motion.h1>
{/* Subtitle */}
<motion.p
className="text-base sm:text-xl md:text-2xl lg:text-2xl text-white/70 mb-8 sm:mb-12 max-w-2xl mx-auto font-medium drop-shadow-[0_2px_12px_rgba(80,80,255,0.10)]"
className="text-base mt-4 sm:text-xl md:text-2xl lg:text-2xl text-white/70 mb-8 sm:mb-12 max-w-2xl mx-auto font-medium drop-shadow-[0_2px_12px_rgba(80,80,255,0.10)]"
initial="hidden"
animate="visible"
variants={fadeUpVariants}
>
Text prompting sucks. Bring your true vision to life with
Text prompting sucks. Bring your true vision to life with
unparalleled control and precision.
</motion.p>
@@ -182,6 +194,229 @@ const Landing = () => {
</main>
<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 */}
<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-[120px] z-0 pointer-events-none" />
<motion.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"
initial="hidden"
whileInView="visible"
viewport={{ once: true }}
variants={fadeUpVariants}
>
<motion.div className="relative" variants={fadeUpVariants}>
<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-[4.5rem] !leading-tight text-shadow-lg">
<span
className="text-primary"
style={{ textShadow: "0 0 15px var(--color-primary)" }}
>
Five Reasons{" "}
</span>
Why
<br />
ArtCraft is the
<span
className="text-primary"
style={{ textShadow: "0 0 15px var(--color-primary)" }}
>
{" "}
Best Tool
</span>
</h1>
</motion.div>
</motion.div>
</div>
{/* Bento Box Grid Section */}
<div className="overflow-visible pb-10 sm:pb-20 md:pb-28 lg:pb-32 relative px-4">
{/* Gradient Orbs for Section */}
<div className="absolute right-[-150px] top-[-100px] w-[500px] h-[500px] rounded-full bg-gradient-to-br from-[#00AABA] via-blue-500 to-blue-700 opacity-10 blur-[110px] z-0 pointer-events-none" />
{/* <div className="absolute right-[-200px] bottom-[-100px] w-[600px] h-[600px] rounded-full bg-gradient-to-br from-blue-700 via-[#00AABA] to-pink-500 opacity-8 blur-[120px] z-0 pointer-events-none" /> */}
<motion.div
className="mx-auto max-w-[88rem] md:px-6 lg:px-8"
initial="hidden"
whileInView="visible"
viewport={{ once: true }}
variants={fadeUpVariants}
>
{/* Bento Grid Container */}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-12 gap-6 sm:gap-8">
{/* Reason #1 - Large Box */}
<motion.div
className="xl:col-span-6 bg-[#28282C] rounded-2xl sm:rounded-3xl p-6 lg:p-8 shadow-2xl transition-all duration-300 group"
variants={fadeUpVariants}
>
<div className="flex xl:flex-col gap-4 lg:gap-8 h-full flex-col-reverse">
<div className="grow h-40">
<img
src="/images/2d-3d.png"
alt="2D and 3D"
className="w-full h-full object-cover rounded-2xl"
/>
</div>
<div className="flex flex-col justify-between">
<div>
<h3 className="font-bold text-xl sm:text-2xl lg:text-3xl mb-3 sm:mb-4 leading-tight">
Text Prompting Sucks
</h3>
<p className="text-white/80 text-sm sm:text-base lg:text-lg leading-relaxed">
<span className="text-primary-400 font-semibold">
Our tool lets you design visually, like Figma.
</span>{" "}
Draw on a canvas or work in a 3D space as if you're
playing a video game.
</p>
</div>
</div>
</div>
</motion.div>
{/* Reason #2 - Medium Box */}
<motion.div
className="xl:col-span-6 bg-[#28282C] rounded-2xl sm:rounded-3xl p-6 lg:p-8 pb-0 lg:pb-0 shadow-2xl transition-all duration-300 group"
variants={fadeUpVariants}
>
<div className="relative flex flex-col h-full">
<h3 className="font-bold text-xl sm:text-2xl lg:text-3xl mb-3 sm:mb-4 leading-tight ">
Desktop App
</h3>
<p className="text-white/80 text-sm sm:text-base mb-4 sm:mb-6 lg:text-lg leading-relaxed">
<span className="text-primary-400 font-semibold">
No more hunting for the hundredth tab.
</span>{" "}
Works on Windows, Mac, and soon Linux and Tablets. First class
experience for real artists.
</p>
<div className="h-20 md:h-24 lg:h-36 xl:h-36 bg-white/[3%] border-[5px] border-white/[2%] rounded-t-2xl relative mt-12 lg:mt-16 xl:mt-24 select-none">
<div className="absolute -top-20 left-1/2 -translate-x-1/2 flex gap-9 items-center justify-center drop-shadow-2xl z-20 scale-50 lg:scale-75 xl:scale-100">
<img
src="/images/windows-logo.png"
alt="Windows Logo"
draggable={false}
className="h-32 rotate-6"
/>
<img
src="/images/apple-logo.png"
alt="Windows Logo"
draggable={false}
className="h-36 -rotate-6"
/>
<img
src="/images/linux-logo.png"
alt="Windows Logo"
draggable={false}
className="h-36 rotate-6"
/>
</div>
</div>
<div className="absolute left-0 bottom-0 w-full h-28 bg-gradient-to-t from-[#28282C] to-transparent z-10 pointer-events-none" />
</div>
</motion.div>
{/* Reason #3 - Medium Box */}
<motion.div
className="xl:col-span-4 bg-[#28282C] rounded-2xl sm:rounded-3xl p-6 lg:p-8 shadow-2xl transition-all duration-300 group"
variants={fadeUpVariants}
>
<div className="flex flex-col h-full">
<h3 className="font-bold text-xl sm:text-2xl lg:text-3xl mb-3 sm:mb-4 leading-tight">
It's Open Source
</h3>
<p className="text-white/80 text-sm sm:text-base lg:text-lg mb-4 sm:mb-6 leading-relaxed flex-1">
We'll be releasing the code behind our desktop app, server,
models, and infrastructure as{" "}
<span className="text-primary-400 font-semibold">
open source!
</span>
</p>
<div className="flex justify-center items-center h-full p-4 lg:p-6 select-none">
<img
src="/images/github-logo.png"
alt="GitHub Logo"
className="h-20 md:h-32 lg:h-36"
draggable={false}
/>
</div>
</div>
</motion.div>
{/* Reason #4 - Large Box */}
<motion.div
className="xl:col-span-8 bg-[#28282C] rounded-2xl sm:rounded-3xl shadow-2xl transition-all duration-300 group overflow-hidden"
variants={fadeUpVariants}
>
<div className="lg:flex-1 flex flex-col justify-between">
<div className="p-6 lg:p-8">
<h3 className="font-bold text-xl sm:text-2xl lg:text-3xl mb-3 sm:mb-4 leading-tight">
Use Every Model
</h3>
<p className="text-white/80 text-sm sm:text-base lg:text-lg leading-relaxed">
You'll be able to use{" "}
<span className="text-primary-400 font-semibold">
EVERY image and video model
</span>{" "}
all in one place. Log in with your existing subscriptions.
</p>
</div>
<ModelBadgeGrid
highlight="gpt-image-1"
rowOffsets={[-70, -90, -160]}
className="mt-3"
/>
</div>
</motion.div>
{/* Reason #5 - Full Width Box */}
<motion.div
className="xl:col-span-12 md:col-span-2 bg-[#28282C] rounded-2xl sm:rounded-3xl p-6 lg:p-8 shadow-2xl transition-all duration-300 group"
variants={fadeUpVariants}
>
<div className="flex flex-col lg:flex-row gap-4 lg:gap-8 items-center">
<div className="lg:flex-1">
<h3 className="font-bold text-xl sm:text-2xl lg:text-3xl mb-3 sm:mb-4 leading-tight">
Created by Artists and Filmmakers
</h3>
<p className="text-white/80 text-sm sm:text-base lg:text-lg leading-relaxed">
<span className="text-primary-400 font-semibold">
The other leading platforms were created by the Google ad
team, crypto bros, and other non-artists.
</span>{" "}
<br />
Not us. We're one of you.
</p>
</div>
<div className="flex justify-center items-center h-24 lg:h-28">
{/* Film icon */}
<div className="w-16 h-16 lg:w-20 lg:h-20 bg-pink-900 rounded-full flex items-center justify-center border-2 border-pink-600 shadow-lg z-10">
<FontAwesomeIcon
icon={faFilm}
className="text-white text-xl lg:text-2xl"
/>
</div>
{/* Paint brush icon - center, most prominent */}
<div className="w-20 h-20 lg:w-24 lg:h-24 bg-emerald-600 rounded-full flex items-center justify-center border-2 border-emerald-400 shadow-lg -ml-2 z-30">
<FontAwesomeIcon
icon={faPaintBrush}
className="text-white text-2xl lg:text-3xl"
/>
</div>
{/* Camera icon */}
<div className="w-16 h-16 lg:w-20 lg:h-20 bg-purple-900 rounded-full flex items-center justify-center border-2 border-purple-600 shadow-lg -ml-2 z-20">
<FontAwesomeIcon
icon={faCamera}
className="text-white text-xl lg:text-2xl"
/>
</div>
</div>
</div>
</motion.div>
</div>
</motion.div>
</div>
<div className="relative flex overflow-visible xl:items-center xl:pt-0 px-2 sm:px-4 md:px-0">
{/* 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-[120px] z-0 pointer-events-none" />
<motion.div
@@ -214,7 +449,7 @@ const Landing = () => {
</motion.div>
</div>
<div className="relative z-10 mx-auto w-full max-w-[1200px] px-2 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">
<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-[140px] z-0 pointer-events-none" />
<motion.div
@@ -247,7 +482,35 @@ const Landing = () => {
</motion.div>
</div>
<div className="overflow-visible py-10 sm:py-20 md:py-28 lg:py-32 relative px-4">
<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 */}
<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-[120px] z-0 pointer-events-none" />
<motion.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"
initial="hidden"
whileInView="visible"
viewport={{ once: true }}
variants={fadeUpVariants}
>
<motion.div className="relative" variants={fadeUpVariants}>
<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-[4.5rem] !leading-none text-shadow-lg">
<span
className="text-white leading-tight"
style={{ textShadow: "0 0 15px var(--color-primary)" }}
>
We're Pulling You{" "}
<span className="text-primary">
Out
<br />
of Prompting
</span>
</span>
</h1>
</motion.div>
</motion.div>
</div>
<div className="overflow-visible pb-10 sm:pb-20 md:pb-28 lg:pb-32 relative px-4">
{/* Gradient Orb for Section */}
<div className="absolute left-[-150px] bottom-[-100px] w-[500px] h-[500px] rounded-full bg-gradient-to-br from-[#00AABA] via-blue-500 to-blue-700 opacity-10 blur-[110px] z-0 pointer-events-none" />
<motion.div
@@ -263,7 +526,7 @@ const Landing = () => {
variants={fadeUpVariants}
>
<div className="max-w-2xl lg:mx-0 mb-10 md:mb-16">
<h2 className="relative font-bold text-4xl md:text-5xl lg:text-6xl !leading-tight">
<h2 className="relative font-bold text-4xl md:text-5xl !leading-tight">
Our tool lets you design, control, and achieve{" "}
<span className="text-primary">perfect consistency</span>
</h2>
@@ -272,7 +535,7 @@ const Landing = () => {
</motion.div>
<div className="sm:px-2 sm:mx-auto lg:px-0">
<div className="relative isolate overflow-hidden bg-indigo-500 sm:mx-auto sm:max-w-2xl sm:rounded-3xl sm:pr-0 lg:mx-0 lg:max-w-none">
<div className="mx-auto max-w-full sm:max-w-2xl sm:mx-0 sm:max-w-none p-2 sm:p-4 bg-white/10 backdrop-blur-md rounded-2xl sm:rounded-3xl overflow-hidden shadow-2xl">
<div className="mx-auto max-w-full sm:mx-0 sm:max-w-none p-2 sm:p-4 bg-white/10 backdrop-blur-md rounded-2xl sm:rounded-3xl overflow-hidden shadow-2xl">
<video
src="/videos/artcraft-3d-demo.mp4"
autoPlay
@@ -304,7 +567,7 @@ const Landing = () => {
className="relative isolate overflow-hidden bg-indigo-500 sm:mx-auto sm:max-w-2xl sm:rounded-3xl sm:pr-0 lg:mx-0 lg:max-w-none"
variants={fadeUpVariants}
>
<div className="mx-auto max-w-full sm:max-w-2xl sm:mx-0 sm:max-w-none p-2 sm:p-4 bg-white/10 backdrop-blur-md rounded-2xl sm:rounded-3xl overflow-hidden shadow-2xl">
<div className="mx-auto max-w-full sm:mx-0 sm:max-w-none p-2 sm:p-4 bg-white/10 backdrop-blur-md rounded-2xl sm:rounded-3xl overflow-hidden shadow-2xl">
<video
src="/videos/artcraft-canvas-demo.mp4"
autoPlay
@@ -321,9 +584,9 @@ const Landing = () => {
variants={fadeUpVariants}
>
<div className=" max-w-2xl lg:mx-0 mb-10 md:mb-16">
<h2 className="relative font-bold text-4xl md:text-5xl lg:text-6xl xl:text-[3.8rem] !leading-tight">
<h2 className="relative font-bold text-4xl md:text-5xl !leading-tight">
<span className="text-primary">Patented</span> Brain to Art
Interface lets you Sketch and Composite
Interface lets you Sketch and Composite
</h2>
</div>
<DiscordButton />
@@ -332,7 +595,7 @@ const Landing = () => {
</motion.div>
</div>
<div className="relative z-10 mx-auto w-full max-w-screen px-2 sm:px-4 py-10 sm:py-20 md:py-32 sm:px-8 lg:px-32 overflow-visible px-4">
<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 */}
<div className="absolute left-[-250px] top-[-150px] w-[700px] h-[700px] rounded-full bg-gradient-to-br from-[#00AABA] via-blue-500 to-blue-700 opacity-[0.07] blur-[150px] z-0 pointer-events-none" />
{/* Videos Section */}
@@ -377,7 +640,7 @@ const Landing = () => {
</motion.div>
</div>
<div className="relative z-10 mx-auto w-full max-w-[1400px] px-2 sm:px-4 pt-10 sm:pt-20 md:pt-32 pb-12 sm:pb-24 md:pb-40 lg:pb-56 sm:px-8 lg:px-32 overflow-visible px-4">
<div className="relative z-10 mx-auto w-full max-w-[1400px] pt-10 sm:pt-20 md:pt-32 pb-12 sm:pb-24 md:pb-40 lg:pb-56 sm:px-8 lg:px-32 overflow-visible px-4">
{/* Gradient Orb for Section */}
<div className="absolute right-[-300px] bottom-[-100px] w-[800px] h-[800px] rounded-full bg-gradient-to-br from-blue-700 via-[#00AABA] to-pink-500 opacity-10 blur-[160px] z-0 pointer-events-none" />
{/* Discord CTA Section */}
@@ -1,6 +1,6 @@
const { createGlobPatternsForDependencies } = require("@nx/react/tailwind");
const { join } = require("path");
import colors from "tailwindcss/colors";
import colors, { teal } from "tailwindcss/colors";
/** @type {import('tailwindcss').Config} */
module.exports = {
@@ -24,6 +24,12 @@ module.exports = {
gray: colors.gray,
black: colors.black,
blue: colors.blue,
orange: colors.orange,
green: colors.green,
emerald: colors.emerald,
teal: colors.teal,
yellow: colors.yellow,
pink: colors.pink,
purple: colors.purple,
primary: {
DEFAULT: "#2d81ff",
@@ -6,8 +6,8 @@ import { Label } from "@storyteller/ui-label";
import { FileUploader } from "@storyteller/ui-file-uploader";
import { loadPreviewOnCanvas, snapshotCanvasAsThumbnail } from "./utilities";
import { upload3DObjects } from "./utilities/upload3DObjects";
import { UploaderState } from "~/models";
import { FilterEngineCategories, MediaFileAnimationType } from "~/enums";
import { UploaderState } from "../../../models";
import { FilterEngineCategories, MediaFileAnimationType } from "../../../enums";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCube } from "@fortawesome/pro-solid-svg-icons";
@@ -136,10 +136,9 @@ export const UploadFiles3D = ({
</div>
<FileUploader
title="Upload 3D Model"
fileTypes={fileTypes}
file={assetFile.value}
setFile={(file: File | null) => {
file={assetFile.value ?? undefined}
handleChange={(file: File) => {
setAssetFile({
value: file,
});
@@ -4,7 +4,7 @@ import { Modal } from "@storyteller/ui-modal";
import { Label } from "@storyteller/ui-label";
import { UploadAssetError, UploadSuccess } from "@storyteller/ui-upload-modal";
import { UploadFiles3D } from "./UploadFiles3D";
import { initialUploaderState, UploaderState } from "~/models";
import { initialUploaderState, UploaderState } from "../../../models";
import { Select } from "@storyteller/ui-select";
import {
FilterEngineCategories,
@@ -12,7 +12,7 @@ import {
OBJECT_FILE_TYPE,
CHARACTER_MIXAMO_FILE_TYPE,
IMAGEPLANE_FILE_TYPE,
} from "~/enums";
} from "../../../enums";
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
interface Props {
@@ -51,6 +51,7 @@ const categoryOptions = Object.entries(FilterEngineCategories)
"set_dressing",
"expression",
"animation",
"image_plane",
"scene",
].includes(value),
)
@@ -14,7 +14,20 @@ export interface JobMaybeResult {
entity_type: string;
maybe_public_bucket_media_path: string;
maybe_successfully_completed_at: string;
media_links?: JobMediaLinks;
}
export interface JobMediaLinks {
cdn_url: string;
maybe_thumbnail_template?: string;
maybe_video_previews?: JobMediaLinksVideoPreviews;
}
export interface JobMediaLinksVideoPreviews {
still: string;
animated: string;
}
export interface JobRequest {
inference_category: string;
maybe_model_title: string;
@@ -66,7 +66,7 @@ import {
removeImageDropListener,
} from "@storyteller/ui-gallery-modal";
import {
imageGenerationModels,
instructiveImageEditModels,
ModelCategory,
// ModelCategory,
ModelSelector,
@@ -536,7 +536,7 @@ export const PageEditor = () => {
<div className="absolute bottom-6 left-6 z-20 flex items-center gap-2">
<ModelSelector
items={imageGenerationModels}
items={instructiveImageEditModels}
category={ModelCategory.Editor3D}
panelTitle="Select Model"
panelClassName="min-w-[280px]"
@@ -4,6 +4,8 @@ import {
faMagicWandSparkles,
faPlus,
faUpRightAndDownLeftFromCenter,
faCube,
faArrowUpFromBracket,
} from "@fortawesome/pro-solid-svg-icons";
import { ButtonIconSelect } from "@storyteller/ui-button-icon-select";
import { Button } from "@storyteller/ui-button";
@@ -34,6 +36,7 @@ import { outlinerState } from "../../signals/outliner/outliner";
import { twMerge } from "tailwind-merge";
// eslint-disable-next-line import/no-unresolved
import { setLogoutStates } from "~/signals/authentication/utilities";
import { UploadModal3D } from "../../../../components/reusable/UploadModal3D/UploadModal3D";
export const Controls3D = () => {
useSignals();
@@ -41,6 +44,7 @@ export const Controls3D = () => {
const [showEmptySceneTooltip, setShowEmptySceneTooltip] = useState(false);
// Action reminder is now handled through signals
const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false);
const [upload3DIsShowing, setUpload3DIsShowing] = useState(false);
// Track processed 3D models by their media token to prevent duplicates
const processedModelsRef = useRef<Record<string, boolean>>({});
@@ -165,6 +169,10 @@ export const Controls3D = () => {
}
};
const handleOpenUpload3DModal = () => {
setUpload3DIsShowing(true);
};
const openSettingsModal = () => {
setIsSettingsModalOpen(true);
};
@@ -207,7 +215,7 @@ export const Controls3D = () => {
<div className="flex justify-center">
<div className="glass rounded-b-xl p-1.5 pr-2 text-white shadow-md">
<div className="flex items-center justify-center gap-2.5">
<div className="flex items-center gap-1">
<div className="flex items-center gap-1.5">
<div className="relative">
{showEmptySceneTooltip && (
<div className="absolute -bottom-14 left-1/2 -translate-x-1/2 transform whitespace-nowrap">
@@ -237,6 +245,19 @@ export const Controls3D = () => {
/>
</Tooltip>
</div>
<Tooltip
content="Upload 3D asset"
position="bottom"
delay={300}
closeOnClick
>
<Button
icon={faArrowUpFromBracket}
className="text-md h-9 w-9 rounded-[10px] bg-white/15 transition-colors hover:bg-white/25"
variant="secondary"
onClick={handleOpenUpload3DModal}
/>
</Tooltip>
<Tooltip
content="Create 3D model from image"
position="bottom"
@@ -272,6 +293,15 @@ export const Controls3D = () => {
globalAccountLogoutCallback={() => setLogoutStates()}
initialSection="accounts"
/>
<UploadModal3D
isOpen={upload3DIsShowing}
onClose={() => setUpload3DIsShowing(false)}
onSuccess={() => setUpload3DIsShowing(false)}
title="Upload 3D Asset"
titleIcon={faCube}
isSelectVisible={true}
/>
</>
);
};
@@ -10,11 +10,13 @@ import { JobsApi } from "~/Classes/ApiManager/JobsApi";
import { toast } from "@storyteller/ui-toaster";
import { ActiveJob } from "~/pages/PageEnigma/models";
import { IsJobPollingDisabled } from "~/flags/DevConsoleFlags";
// TODO ensure we de-dupe all this extra code.
interface CompletedItem {
token: string;
maybe_title: string;
public_bucket_path: string;
public_bucket_path: string; // TODO: STOP USING THIS.
maybe_media_links_thumbnail?: string;
updated_at: string;
}
@@ -140,6 +142,12 @@ export function Activity() {
maybe_title: job.request?.maybe_model_title || "Image Generation",
public_bucket_path:
job.maybe_result?.maybe_public_bucket_media_path || "",
maybe_media_links_thumbnail: ((job) => {
// Try the video thumbnail first, then the regular thumbnail.
const maybeVideoThumb = job.maybe_result?.media_links?.maybe_video_previews?.animated;
const maybeGenericThumb = (job.maybe_result?.media_links?.maybe_thumbnail_template || "").replace("{WIDTH}", "500");
return maybeVideoThumb || maybeGenericThumb;
})(job),
updated_at: job.updated_at || new Date().toISOString(),
}));
@@ -12,6 +12,7 @@ interface Props {
updated_at: string;
public_bucket_path: string;
maybe_style_name?: string;
maybe_media_links_thumbnail?: string;
};
}
@@ -21,9 +22,13 @@ export function CompletedCard({ job }: Props) {
const cdnOrigin = GetCdnOrigin();
const mediaPath = job?.public_bucket_path || "";
const downloadLink = `${cdnOrigin}${mediaPath}`;
const imageUrl = downloadLink;
//const imageUrl = downloadLink;
const fullImageUrl = downloadLink;
// NB: Try to use the thumbnail instead of the link to the full asset.
// Passing around full asset URLs and putting them into image tags is causing CORS errors if those assets are not images.
const thumbnailUrl = job?.maybe_media_links_thumbnail;
if (!job) return null;
return (
@@ -36,11 +41,11 @@ export function CompletedCard({ job }: Props) {
<div className="flex aspect-square h-14 w-14 justify-center overflow-hidden rounded-lg border border-[#A9A9A9]/50 bg-black/60">
<img
src={
loadError ? "/resources/images/movie-placeholder.png" : imageUrl
loadError ? "/resources/images/movie-placeholder.png" : thumbnailUrl
}
className="h-full w-full object-cover"
alt={job.maybe_title ?? "unknown"}
crossOrigin="anonymous"
//crossOrigin="anonymous"
onError={() => setLoadError(true)}
loading="lazy"
/>
+12 -9
View File
@@ -28,6 +28,9 @@
"noEmit": true
},
"references": [
{
"path": "../../libs/model-list"
},
{
"path": "../../libs/components/pagination"
},
@@ -73,6 +76,9 @@
{
"path": "../../libs/components/toaster"
},
{
"path": "../../libs/components/login-modal"
},
{
"path": "../../libs/components/model-selector"
},
@@ -82,9 +88,6 @@
{
"path": "../../libs/components/promptbox"
},
{
"path": "../../libs/tauri-api"
},
{
"path": "../../libs/components/popover"
},
@@ -94,9 +97,6 @@
{
"path": "../../libs/components/menu-icon-selector"
},
{
"path": "../../libs/api"
},
{
"path": "../../libs/components/tooltip"
},
@@ -109,9 +109,6 @@
{
"path": "../../libs/components/gravatar"
},
{
"path": "../../libs/components/login-modal"
},
{
"path": "../../libs/components/select"
},
@@ -139,8 +136,14 @@
{
"path": "../../libs/components/button"
},
{
"path": "../../libs/tauri-api"
},
{
"path": "../../libs/tauri-utils"
},
{
"path": "../../libs/api"
}
]
}
@@ -110,6 +110,7 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
</div>
) : (
<img
data-gallery-draggable-1="true"
ref={imgRef}
src={item.thumbnail}
alt={item.label}
@@ -177,6 +178,7 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
</div>
) : (
<img
data-gallery-draggable-2="true"
ref={imgRef}
src={item.thumbnail}
alt={item.label}
@@ -153,6 +153,7 @@ export const GalleryModal = React.memo(
const handleImageError = useCallback(
(url: string) => {
console.error(`Failed to load gallery modal image: ${url}`);
failedImageUrls.add(url);
},
[failedImageUrls]
@@ -234,7 +235,7 @@ export const GalleryModal = React.memo(
label: item.maybe_title || "Image Generation",
thumbnail:
item.media_class === "video"
? item.media_links.maybe_video_previews.still
? item.media_links.maybe_video_previews.animated
: item.media_class === "dimensional"
? item.cover_image?.maybe_cover_image_public_bucket_url
: item.media_links.maybe_thumbnail_template?.replace(
@@ -86,6 +86,8 @@ export function LightboxModal({
) : mediaClass === "video" ? (
<video
controls
loop={true}
autoPlay={true}
className="h-full w-full object-contain"
onLoadedData={() => setMediaLoaded(true)}
>
@@ -94,6 +96,7 @@ export function LightboxModal({
</video>
) : (
<img
data-lightbox-modal="true"
src={imageTagImageUrl}
alt={imageAlt}
className="h-full w-full object-contain"
@@ -134,7 +137,7 @@ export function LightboxModal({
//});
let result = await EnqueueImageTo3dObject({
image_media_token: mediaId,
model: EnqueueImageTo3dObjectModel.Hunyuan3d2,
model: EnqueueImageTo3dObjectModel.Hunyuan3d2_0,
});
//e.stopPropagation();
//await onAddToSceneClicked(downloadUrl, mediaId);
@@ -8,9 +8,6 @@
{
"path": "../../tauri-api"
},
{
"path": "../../tauri-utils"
},
{
"path": "../button"
},
@@ -49,9 +49,6 @@
{
"path": "../../tauri-api/tsconfig.lib.json"
},
{
"path": "../../tauri-utils/tsconfig.lib.json"
},
{
"path": "../button/tsconfig.lib.json"
},
@@ -684,6 +684,23 @@ export const Modal = ({
};
}, [zIndex]);
// Block propagation of keyboard events to elements outside the modal so global hot-keys (T / R / G shortcuts in the 3-D editor) don't fire while a modal is open
useEffect(() => {
if (!isOpen || allowBackgroundInteraction) return;
const stopKey = (e: KeyboardEvent) => {
// Exclude ESC key
if (e.key === "Escape" || e.key === "Esc") return;
e.stopPropagation();
};
window.addEventListener("keydown", stopKey, true);
return () => {
window.removeEventListener("keydown", stopKey, true);
};
}, [isOpen, allowBackgroundInteraction]);
// If background interaction is allowed, ensure this modal (and its ancestors)
// never get the "inert" attribute Headless-UI uses to lock background dialogs.
useEffect(() => {
@@ -89,7 +89,25 @@ export const allModels = {
// badges: [{ label: "25 sec.", icon: <FontAwesomeIcon icon={faClock} /> }],
//},
],
// These models are used for 2D and 3D editing and scene building.
instructiveImageEdits: [
{
label: "GPT-4o",
icon: <FontAwesomeIcon icon={faImage} className="h-4 w-4" />,
description: "High quality model",
badges: [{ label: "2 min.", icon: <FontAwesomeIcon icon={faClock} /> }],
modelInfo: IMAGE_MODELS.gpt_image_1,
},
{
label: "FLUX.1 Kontext",
icon: <FontAwesomeIcon icon={faImage} className="h-4 w-4" />,
description: "Fast and high-quality model",
badges: [{ label: "20 sec.", icon: <FontAwesomeIcon icon={faClock} /> }],
modelInfo: IMAGE_MODELS.gpt_image_1, // TODO: Wrong metadata!
},
],
};
export const videoGenerationModels: ModelList = allModels.video;
export const imageGenerationModels: ModelList = allModels.image;
export const instructiveImageEditModels: ModelList = allModels.instructiveImageEdits;
@@ -2,6 +2,9 @@
"files": [],
"include": [],
"references": [
{
"path": "../tab-selector"
},
{
"path": "../button"
},
@@ -43,6 +43,9 @@
"src/**/*.tsx"
],
"references": [
{
"path": "../tab-selector/tsconfig.lib.json"
},
{
"path": "../button/tsconfig.lib.json"
},
@@ -2,14 +2,17 @@
"files": [],
"include": [],
"references": [
{
"path": "../../model-list"
},
{
"path": "../camera-settings-modal"
},
{
"path": "../action-reminder-modal"
"path": "../../soundboard"
},
{
"path": "../../soundboard"
"path": "../action-reminder-modal"
},
{
"path": "../../tauri-api"
@@ -43,14 +43,17 @@
"src/**/*.tsx"
],
"references": [
{
"path": "../../model-list/tsconfig.lib.json"
},
{
"path": "../camera-settings-modal/tsconfig.lib.json"
},
{
"path": "../action-reminder-modal/tsconfig.lib.json"
"path": "../../soundboard/tsconfig.lib.json"
},
{
"path": "../../soundboard/tsconfig.lib.json"
"path": "../action-reminder-modal/tsconfig.lib.json"
},
{
"path": "../../tauri-api/tsconfig.lib.json"
@@ -5,9 +5,6 @@
{
"path": "../modal"
},
{
"path": "../label"
},
{
"path": "../switch"
},
@@ -18,7 +15,7 @@
"path": "../../soundboard"
},
{
"path": "../../tauri-api"
"path": "../login-modal"
},
{
"path": "../../api"
@@ -29,6 +26,12 @@
{
"path": "../button"
},
{
"path": "../label"
},
{
"path": "../../tauri-api"
},
{
"path": "./tsconfig.lib.json"
},
@@ -46,9 +46,6 @@
{
"path": "../modal/tsconfig.lib.json"
},
{
"path": "../label/tsconfig.lib.json"
},
{
"path": "../switch/tsconfig.lib.json"
},
@@ -59,7 +56,7 @@
"path": "../../soundboard/tsconfig.lib.json"
},
{
"path": "../../tauri-api/tsconfig.lib.json"
"path": "../login-modal/tsconfig.lib.json"
},
{
"path": "../../api/tsconfig.lib.json"
@@ -69,6 +66,12 @@
},
{
"path": "../button/tsconfig.lib.json"
},
{
"path": "../label/tsconfig.lib.json"
},
{
"path": "../../tauri-api/tsconfig.lib.json"
}
]
}
@@ -21,6 +21,8 @@ export interface EnqueueImageTo3dObjectRequest {
export enum EnqueueImageTo3dObjectModel {
Hunyuan3d2 = "hunyuan_3d_2",
Hunyuan3d2_0 = "hunyuan_3d_2_0",
Hunyuan3d2_1 = "hunyuan_3d_2_1",
}
export interface EnqueueImageTo3dObjectError extends CommandResult {
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
# This works on Linux and MacOS to launch the frontend dev server
root_dir=$(pwd)
frontend_path="${root_dir}/frontend"
echo "Running Artcraft Website in Dev Mode..."
echo ""
# Kill any process running on port 5741, which will block startup
if lsof -i tcp:4200 &>/dev/null; then
lsof -i tcp:4200 -t | xargs kill -9
echo "Killed process running on port 4200"
else
echo "No process running on port 4200"
fi
pushd "${frontend_path}" || exit
npm install --verbose
export VITE_ENVIRONMENT_TYPE="production"
nx dev artcraft-website
popd || exit