Compare commits

...
95 changed files with 2488 additions and 438 deletions
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n UPDATE tasks\n SET is_dismissed_by_user = 1\n WHERE id = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "13d435d8f681b73e4080748dcef0579e246e41049af67e1bd79ffc06aa6ba974"
}
@@ -0,0 +1,86 @@
{
"db_name": "SQLite",
"query": "\n SELECT\n id,\n task_status,\n task_type,\n model_type,\n provider,\n provider_job_id,\n frontend_caller,\n frontend_subscriber_id,\n frontend_subscriber_payload,\n created_at as \"created_at: DateTime<Utc>\",\n updated_at as \"updated_at: DateTime<Utc>\",\n completed_at as \"completed_at: DateTime<Utc>\"\n FROM tasks\n WHERE is_dismissed_by_user == 0\n ",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "task_status",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "task_type",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "model_type",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "provider",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "provider_job_id",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "frontend_caller",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "frontend_subscriber_id",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "frontend_subscriber_payload",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "created_at: DateTime<Utc>",
"ordinal": 9,
"type_info": "Int64"
},
{
"name": "updated_at: DateTime<Utc>",
"ordinal": 10,
"type_info": "Int64"
},
{
"name": "completed_at: DateTime<Utc>",
"ordinal": 11,
"type_info": "Int64"
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
false,
true,
true,
true,
true,
true,
true,
false,
false,
true
]
},
"hash": "a142dc3fdf5b6e741c268d8f2e9996a28c214f5835681297d58a9431da8de939"
}
@@ -0,0 +1,20 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
select
u.display_name,
count(*) as sessions,
a.session_duration_seconds / 60 / 60 as hours,
a.measurement_count,
u.email_address,
a.os_platform,
a.os_version,
u.created_at,
u.maybe_source,
a.last_active_at
from analytics_app_sessions as a
left outer join users as u
on a.user_token = u.token
group by a.user_token
order by hours desc;
@@ -9,6 +9,7 @@
-- tasks_v1.sqlite - initial version
-- tasks_v2.sqlite - added model_type (nullable)
-- tasks_v3.sqlite - added frontend_caller, comments
-- tasks_v4.sqlite - added is_dismissed_by_user
CREATE TABLE tasks (
-- Task auto-incrementing primary key.
@@ -50,6 +51,9 @@ CREATE TABLE tasks (
-- will be re-emitted back to the frontend.
frontend_subscriber_payload TEXT,
-- Whether the user has dismissed the task from view.
is_dismissed_by_user INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL DEFAULT (unixepoch('now')),
updated_at INTEGER NOT NULL DEFAULT (unixepoch('now')),
completed_at INTEGER DEFAULT NULL
@@ -7,4 +7,5 @@ pub mod load_without_cors_command;
pub mod media_files;
pub mod platform_info_command;
pub mod providers;
pub mod response;
pub mod response;
pub mod task_queue;
@@ -0,0 +1,92 @@
use crate::core::commands::response::shorthand::ResponseOrErrorMessage;
use crate::core::commands::response::success_response_wrapper::SerializeMarker;
use crate::core::events::generation_events::generation_enqueue_success_event::GenerationEnqueueSuccessEvent;
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::core::state::provider_priority::{Provider, ProviderPriorityStore};
use crate::core::state::task_database::TaskDatabase;
use crate::services::fal::state::fal_credential_manager::FalCredentialManager;
use crate::services::fal::state::fal_task_queue::FalTaskQueue;
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use chrono::{DateTime, Utc};
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 errors::AnyhowResult;
use log::{error, info, warn};
use serde_derive::{Deserialize, Serialize};
use sqlite_tasks::queries::list_tasks_for_frontend::list_tasks_for_frontend;
use storyteller_client::endpoints::media_files::delete_media_file::delete_media_file;
use tauri::{AppHandle, State};
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::sqlite::tasks::TaskId;
#[derive(Serialize)]
pub struct GetTaskQueueCommandResponse {
tasks: Vec<TaskQueueItem>,
}
#[derive(Serialize)]
pub struct TaskQueueItem {
pub id: TaskId,
pub task_status: TaskStatus,
pub task_type: TaskType,
pub model_type: Option<TaskModelType>,
pub provider: Option<GenerationProvider>,
pub provider_job_id: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub completed_at: Option<DateTime<Utc>>,
}
impl SerializeMarker for GetTaskQueueCommandResponse {}
#[tauri::command]
pub async fn get_task_queue_command(
app: AppHandle,
app_env_configs: State<'_, AppEnvConfigs>,
task_database: State<'_, TaskDatabase>,
) -> ResponseOrErrorMessage<GetTaskQueueCommandResponse> {
info!("get_task_queue_command called");
let result = handle_request(
&task_database,
).await;
let tasks = match result {
Ok(items) => items,
Err(err) => {
error!("get_task_queue_command failed: {:?}", err);
return Err("get_task_queue_command failed".into())
}
};
Ok(GetTaskQueueCommandResponse{
tasks,
}.into())
}
pub async fn handle_request(
task_database: &TaskDatabase,
) -> AnyhowResult<Vec<TaskQueueItem>> {
let tasks = list_tasks_for_frontend(task_database.get_connection())
.await?;
Ok(tasks.tasks.into_iter()
.map(|task| TaskQueueItem {
id: task.id,
task_status: task.status,
task_type: task.task_type,
model_type: task.model_type,
provider: task.provider,
provider_job_id: task.provider_job_id,
created_at: task.created_at,
updated_at: task.updated_at,
completed_at: task.completed_at,
})
.collect())
}
@@ -0,0 +1,70 @@
use crate::core::commands::response::shorthand::ResponseOrErrorMessage;
use crate::core::commands::response::success_response_wrapper::SerializeMarker;
use crate::core::events::generation_events::generation_enqueue_success_event::GenerationEnqueueSuccessEvent;
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::core::state::provider_priority::{Provider, ProviderPriorityStore};
use crate::core::state::task_database::TaskDatabase;
use crate::services::fal::state::fal_credential_manager::FalCredentialManager;
use crate::services::fal::state::fal_task_queue::FalTaskQueue;
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use chrono::{DateTime, Utc};
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 errors::AnyhowResult;
use log::{error, info, warn};
use serde_derive::{Deserialize, Serialize};
use sqlite_tasks::queries::list_tasks_for_frontend::list_tasks_for_frontend;
use sqlite_tasks::queries::mark_task_as_dismissed::mark_task_as_dismissed;
use storyteller_client::endpoints::media_files::delete_media_file::delete_media_file;
use tauri::{AppHandle, State};
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::sqlite::tasks::TaskId;
#[derive(Deserialize)]
pub struct MarkTaskAsDismissedRequest {
task: TaskId,
}
#[derive(Serialize)]
pub struct MarkTaskAsDismissedResponse {
success: bool,
}
impl SerializeMarker for MarkTaskAsDismissedResponse {}
#[tauri::command]
pub async fn mark_task_as_dismissed_command(
request: MarkTaskAsDismissedRequest,
app: AppHandle,
app_env_configs: State<'_, AppEnvConfigs>,
task_database: State<'_, TaskDatabase>,
) -> ResponseOrErrorMessage<MarkTaskAsDismissedResponse> {
info!("mark_task_as_dismissed_command called");
let result = handle_request(
&request.task,
&task_database,
).await;
if let Err(err) = &result {
error!("mark_task_as_dismissed_command failed: {:?}", err);
return Err("mark_task_as_dismissed_command failed".into())
}
Ok(MarkTaskAsDismissedResponse{
success: true,
}.into())
}
pub async fn handle_request(
task_id: &TaskId,
task_database: &TaskDatabase,
) -> AnyhowResult<()> {
let _result = mark_task_as_dismissed(task_database.get_connection(), task_id).await?;
Ok(())
}
@@ -0,0 +1,2 @@
pub mod get_task_queue_command;
pub mod mark_task_as_dismissed_command;
@@ -1,4 +1,5 @@
use crate::core::state::data_dir::trait_data_subdir::DataSubdir;
use crate::core::state::task_database_version::TASK_DATABASE_VERSION;
use std::path::{Path, PathBuf};
#[derive(Clone)]
@@ -24,7 +25,7 @@ impl AppStateDir {
pub fn get_tasks_sqlite_database_path(&self) -> PathBuf {
// NB: This must be incremented on any change to the database schema (including comments).
// The database houses ephemeral content and will be migrated automatically.
self.path.join("tasks_v3.sqlite")
self.path.join(format!("tasks_v{TASK_DATABASE_VERSION}.sqlite"))
}
pub fn get_window_size_config_file(&self) -> PathBuf {
@@ -7,4 +7,5 @@ pub mod expanduser;
pub mod os_platform;
pub mod provider_priority;
pub mod task_database;
pub mod task_database_version;
pub mod window;
@@ -0,0 +1,10 @@
/// Used to bump the task database schema version.
/// Migrations on customers' machines tend to fail on existing databases,
/// even if we instruct that the database file be deleted first.
/// It's easiest to just write to a new file and migrate from scratch.
/// We increment this whenever we make a change to the database schema.
/// This prevents deadlock on startup if the schema has changed from the
/// previous version.
pub const TASK_DATABASE_VERSION: u32 = 4;
+5 -1
View File
@@ -18,6 +18,8 @@ use crate::core::commands::media_files::media_file_delete_command::media_file_de
use crate::core::commands::platform_info_command::platform_info_command;
use crate::core::commands::providers::get_provider_order_command::get_provider_order_command;
use crate::core::commands::providers::set_provider_order_command::set_provider_order_command;
use crate::core::commands::task_queue::get_task_queue_command::get_task_queue_command;
use crate::core::commands::task_queue::mark_task_as_dismissed_command::mark_task_as_dismissed_command;
use crate::core::lifecycle::startup::handle_tauri_startup::handle_tauri_startup;
use crate::core::lifecycle::startup::setup_main_window::setup_main_window;
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
@@ -190,8 +192,10 @@ pub fn run() {
get_app_preferences_command,
get_fal_api_key_command,
get_provider_order_command,
media_file_delete_command,
get_task_queue_command,
load_without_cors_command,
mark_task_as_dismissed_command,
media_file_delete_command,
midjourney_clear_credentials_command,
midjourney_get_credential_info_command,
midjourney_open_login_command,
@@ -1,10 +1,11 @@
use enums::error::enum_error::EnumError;
use std::error::Error;
use std::fmt::{Display, Formatter};
#[derive(Debug)]
pub enum SqliteTasksError {
SqlxError(sqlx::Error),
TaskParseError(String),
EnumError(EnumError),
//TaskNotFound,
//TaskAlreadyExists,
//InvalidTaskStatus,
@@ -17,8 +18,8 @@ impl Error for SqliteTasksError {}
impl Display for SqliteTasksError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
SqliteTasksError::SqlxError(err) => write!(f, "SQLx error: {}", err),
SqliteTasksError::TaskParseError(err) => write!(f, "Task parse error: {}", err),
SqliteTasksError::SqlxError(err) => write!(f, "SQLx error: {:?}", err),
SqliteTasksError::EnumError(err) => write!(f, "Error parsing enum: {:?}", err),
}
}
}
@@ -28,3 +29,9 @@ impl From<sqlx::Error> for SqliteTasksError {
SqliteTasksError::SqlxError(err)
}
}
impl From<EnumError> for SqliteTasksError {
fn from(err: EnumError) -> Self {
SqliteTasksError::EnumError(err)
}
}
@@ -92,20 +92,15 @@ pub async fn list_tasks_by_provider_and_status(
for task in results {
tasks.push(Task {
id: TaskId::new_from_str(&task.id),
status: TaskStatus::from_str(&task.task_status)
.map_err(|err| SqliteTasksError::TaskParseError(err))?,
task_type: TaskType::from_str(&task.task_type)
.map_err(|err| SqliteTasksError::TaskParseError(err))?,
status: TaskStatus::from_str(&task.task_status)?,
task_type: TaskType::from_str(&task.task_type)?,
model_type: task.model_type
.map(|model| TaskModelType::from_str(&model)
.map_err(|err| SqliteTasksError::TaskParseError(err)))
.map(|model| TaskModelType::from_str(&model))
.transpose()?,
provider: GenerationProvider::from_str(&task.provider)
.map_err(|err| SqliteTasksError::TaskParseError(err))?,
provider: GenerationProvider::from_str(&task.provider)?,
provider_job_id: task.provider_job_id,
frontend_caller: task.frontend_caller
.map(|caller| TauriCommandCaller::from_str(&caller)
.map_err(|err| SqliteTasksError::TaskParseError(err)))
.map(|caller| TauriCommandCaller::from_str(&caller))
.transpose()?,
frontend_subscriber_id: task.frontend_subscriber_id,
frontend_subscriber_payload: task.frontend_subscriber_payload,
@@ -91,20 +91,15 @@ pub async fn list_tasks_by_provider_and_tokens(
for task in results {
tasks.push(Task {
id: TaskId::new_from_str(&task.id),
status: TaskStatus::from_str(&task.task_status)
.map_err(|err| SqliteTasksError::TaskParseError(err))?,
task_type: TaskType::from_str(&task.task_type)
.map_err(|err| SqliteTasksError::TaskParseError(err))?,
status: TaskStatus::from_str(&task.task_status)?,
task_type: TaskType::from_str(&task.task_type)?,
model_type: task.model_type
.map(|model| TaskModelType::from_str(&model)
.map_err(|err| SqliteTasksError::TaskParseError(err)))
.map(|model| TaskModelType::from_str(&model))
.transpose()?,
provider: GenerationProvider::from_str(&task.provider)
.map_err(|err| SqliteTasksError::TaskParseError(err))?,
provider: GenerationProvider::from_str(&task.provider)?,
provider_job_id: task.provider_job_id,
frontend_caller: task.frontend_caller
.map(|caller| TauriCommandCaller::from_str(&caller)
.map_err(|err| SqliteTasksError::TaskParseError(err)))
.map(|caller| TauriCommandCaller::from_str(&caller))
.transpose()?,
frontend_subscriber_id: task.frontend_subscriber_id,
frontend_subscriber_payload: task.frontend_subscriber_payload,
@@ -0,0 +1,99 @@
use crate::connection::TaskDbConnection;
use crate::error::SqliteTasksError;
use chrono::{DateTime, Utc};
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 enums::tauri::ux::tauri_command_caller::TauriCommandCaller;
use tokens::tokens::sqlite::tasks::TaskId;
pub struct TaskList {
pub tasks: Vec<TaskItem>,
}
pub struct TaskItem {
pub id: TaskId,
pub status: TaskStatus,
pub task_type: TaskType,
pub model_type: Option<TaskModelType>,
pub provider: Option<GenerationProvider>,
pub provider_job_id: Option<String>,
pub frontend_caller: Option<TauriCommandCaller>,
pub frontend_subscriber_id: Option<String>,
pub frontend_subscriber_payload: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub completed_at: Option<DateTime<Utc>>,
}
pub async fn list_tasks_for_frontend(
db: & TaskDbConnection
) -> Result<TaskList, SqliteTasksError> {
let query = sqlx::query_as!(
TaskItemRaw,
r#"
SELECT
id,
task_status,
task_type,
model_type,
provider,
provider_job_id,
frontend_caller,
frontend_subscriber_id,
frontend_subscriber_payload,
created_at as "created_at: DateTime<Utc>",
updated_at as "updated_at: DateTime<Utc>",
completed_at as "completed_at: DateTime<Utc>"
FROM tasks
WHERE is_dismissed_by_user == 0
"#);
let result = query.fetch_all(db.get_pool())
.await?;
let mut tasks = Vec::with_capacity(result.len());
for raw in result.into_iter() {
tasks.push(TaskItem {
id: TaskId::new(raw.id),
status: TaskStatus::from_str(&raw.task_status)?,
task_type: TaskType::from_str(&raw.task_type)?,
model_type: raw.model_type
.map(|model| TaskModelType::from_str(&model))
.transpose()?,
provider: raw.provider
.map(|provider| GenerationProvider::from_str(&provider))
.transpose()?,
provider_job_id: raw.provider_job_id,
frontend_caller: raw.frontend_caller
.map(|caller| TauriCommandCaller::from_str(&caller))
.transpose()?,
frontend_subscriber_id: raw.frontend_subscriber_id,
frontend_subscriber_payload: raw.frontend_subscriber_payload,
created_at: raw.created_at,
updated_at: raw.updated_at,
completed_at: raw.completed_at,
})
}
Ok(TaskList {
tasks,
})
}
struct TaskItemRaw {
id: String,
task_status: String,
task_type: String,
model_type: Option<String>,
provider: Option<String>,
provider_job_id: Option<String>,
frontend_caller: Option<String>,
frontend_subscriber_id: Option<String>,
frontend_subscriber_payload: Option<String>,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
completed_at: Option<DateTime<Utc>>,
}
@@ -0,0 +1,27 @@
use crate::connection::TaskDbConnection;
use crate::error::SqliteTasksError;
use tokens::tokens::sqlite::tasks::TaskId;
/// Returns true if rows were updated.
pub async fn mark_task_as_dismissed(
db: &TaskDbConnection,
task_id: &TaskId,
) -> Result<bool, SqliteTasksError> {
// TODO(bt,2025-07-12): Fix this. The sqlx mysql queries never required temporaries
let task_id_temp = task_id.as_str();
// TODO(bt,2025-07-15): We can't set a LIMIT without a certain compiler flag for SQLite ?
let query = sqlx::query!(r#"
UPDATE tasks
SET is_dismissed_by_user = 1
WHERE id = ?
"#,
task_id_temp,
);
let res = query.execute(db.get_pool()).await?;
let rows_updated = res.rows_affected() > 0;
Ok(rows_updated)
}
@@ -1,4 +1,6 @@
pub mod create_task;
pub mod list_tasks_by_provider_and_status;
pub mod list_tasks_by_provider_and_tokens;
pub mod list_tasks_for_frontend;
pub mod mark_task_as_dismissed;
pub mod update_task_status;
@@ -1,5 +1,6 @@
use std::collections::BTreeSet;
use crate::error::enum_error::EnumError;
#[cfg(test)]
use strum::EnumCount;
#[cfg(test)]
@@ -22,6 +23,9 @@ impl_enum_display_and_debug_using_to_str!(GenerationProvider);
impl_mysql_enum_coders!(GenerationProvider);
impl_mysql_from_row!(GenerationProvider);
// For Tauri
impl_sqlite_enum_coders!(GenerationProvider);
// NB: We can derive `sqlx::Type` instead of using `impl_mysql_enum_coders`
impl GenerationProvider {
@@ -34,13 +38,13 @@ impl GenerationProvider {
}
}
pub fn from_str(job_status: &str) -> Result<Self, String> {
match job_status {
pub fn from_str(value: &str) -> Result<Self, EnumError> {
match value {
"artcraft" => Ok(Self::Artcraft),
"fal" => Ok(Self::Fal),
"midjourney" => Ok(Self::Midjourney),
"sora" => Ok(Self::Sora),
_ => Err(format!("invalid generation_provider: {:?}", job_status)),
_ => Err(EnumError::CouldNotConvertFromString(value.to_string())),
}
}
@@ -58,8 +62,9 @@ impl GenerationProvider {
#[cfg(test)]
mod tests {
use crate::test_helpers::assert_serialization;
use crate::common::generation_provider::GenerationProvider;
use crate::error::enum_error::EnumError;
use crate::test_helpers::assert_serialization;
mod explicit_checks {
use super::*;
@@ -88,6 +93,17 @@ mod tests {
assert_eq!(GenerationProvider::from_str("sora").unwrap(), GenerationProvider::Sora);
}
#[test]
fn from_str_err() {
let result = GenerationProvider::from_str("asdf");
assert!(result.is_err());
if let Err(EnumError::CouldNotConvertFromString(value)) = result {
assert_eq!(value, "asdf");
} else {
panic!("Expected EnumError::CouldNotConvertFromString");
}
}
#[test]
fn all_variants() {
let mut variants = GenerationProvider::all_variants();
@@ -0,0 +1,18 @@
use std::error::Error;
#[derive(Debug)]
pub enum EnumError {
CouldNotConvertFromString(String),
}
impl Error for EnumError {}
impl std::fmt::Display for EnumError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
EnumError::CouldNotConvertFromString(value) => {
write!(f, "Could not convert from string: {}", value)
}
}
}
}
@@ -0,0 +1 @@
pub mod enum_error;
+1
View File
@@ -33,6 +33,7 @@ mod macros;
pub mod by_table;
pub mod common;
pub mod error;
pub mod no_table;
pub mod tauri;
pub mod traits;
@@ -1,5 +1,6 @@
use std::collections::BTreeSet;
use crate::error::enum_error::EnumError;
#[cfg(test)]
use strum::EnumCount;
#[cfg(test)]
@@ -98,8 +99,8 @@ impl TaskModelType {
}
}
pub fn from_str(job_status: &str) -> Result<Self, String> {
match job_status {
pub fn from_str(value: &str) -> Result<Self, EnumError> {
match value {
// Image models
"flux_1_dev" => Ok(Self::Flux1Dev),
"flux_1_schnell" => Ok(Self::Flux1Schnell),
@@ -124,7 +125,7 @@ impl TaskModelType {
// 3D Object generation models
"hunyuan_3d_2.0" => Ok(Self::Hunyuan3d2_0),
"hunyuan_3d_2.1" => Ok(Self::Hunyuan3d2_1),
_ => Err(format!("invalid task_model_type: {:?}", job_status)),
_ => Err(EnumError::CouldNotConvertFromString(value.to_string())),
}
}
@@ -164,6 +165,7 @@ impl TaskModelType {
mod tests {
use crate::tauri::tasks::task_model_type::TaskModelType;
use crate::test_helpers::assert_serialization;
use crate::error::enum_error::EnumError;
mod explicit_checks {
use super::*;
@@ -252,6 +254,17 @@ mod tests {
assert_eq!(TaskModelType::from_str("hunyuan_3d_2.1").unwrap(), TaskModelType::Hunyuan3d2_1);
}
#[test]
fn from_str_err() {
let result = TaskModelType::from_str("asdf");
assert!(result.is_err());
if let Err(EnumError::CouldNotConvertFromString(value)) = result {
assert_eq!(value, "asdf");
} else {
panic!("Expected EnumError::CouldNotConvertFromString");
}
}
#[test]
fn all_variants() {
let mut variants = TaskModelType::all_variants();
@@ -1,5 +1,6 @@
use std::collections::BTreeSet;
use crate::error::enum_error::EnumError;
#[cfg(test)]
use strum::EnumCount;
#[cfg(test)]
@@ -48,8 +49,8 @@ impl TaskStatus {
}
}
pub fn from_str(job_status: &str) -> Result<Self, String> {
match job_status {
pub fn from_str(value: &str) -> Result<Self, EnumError> {
match value {
"pending" => Ok(Self::Pending),
"started" => Ok(Self::Started),
"complete_success" => Ok(Self::CompleteSuccess),
@@ -59,7 +60,7 @@ impl TaskStatus {
"cancelled_by_user" => Ok(Self::CancelledByUser),
"cancelled_by_provider" => Ok(Self::CancelledByProvider),
"cancelled_by_us" => Ok(Self::CancelledByUs),
_ => Err(format!("invalid task_status: {:?}", job_status)),
_ => Err(EnumError::CouldNotConvertFromString(value.to_string())),
}
}
@@ -82,11 +83,12 @@ impl TaskStatus {
#[cfg(test)]
mod tests {
use crate::test_helpers::assert_serialization;
use crate::tauri::tasks::task_status::TaskStatus;
use crate::test_helpers::assert_serialization;
mod explicit_checks {
use super::*;
use crate::error::enum_error::EnumError;
#[test]
fn test_default() {
@@ -132,6 +134,17 @@ mod tests {
assert_eq!(TaskStatus::from_str("cancelled_by_us").unwrap(), TaskStatus::CancelledByUs);
}
#[test]
fn from_str_err() {
let result = TaskStatus::from_str("asdf");
assert!(result.is_err());
if let Err(EnumError::CouldNotConvertFromString(value)) = result {
assert_eq!(value, "asdf");
} else {
panic!("Expected EnumError::CouldNotConvertFromString");
}
}
#[test]
fn all_variants() {
let mut variants = TaskStatus::all_variants();
@@ -1,5 +1,6 @@
use std::collections::BTreeSet;
use crate::error::enum_error::EnumError;
#[cfg(test)]
use strum::EnumCount;
#[cfg(test)]
@@ -34,14 +35,14 @@ impl TaskType {
}
}
pub fn from_str(job_status: &str) -> Result<Self, String> {
match job_status {
pub fn from_str(value: &str) -> Result<Self, EnumError> {
match value {
"image_generation" => Ok(Self::ImageGeneration),
"image_inpaint_edit" => Ok(Self::ImageInpaintEdit),
"video_generation" => Ok(Self::VideoGeneration),
"object_generation" => Ok(Self::ObjectGeneration),
"background_removal" => Ok(Self::BackgroundRemoval),
_ => Err(format!("invalid task_type: {:?}", job_status)),
_ => Err(EnumError::CouldNotConvertFromString(value.to_string())),
}
}
@@ -60,11 +61,12 @@ impl TaskType {
#[cfg(test)]
mod tests {
use crate::test_helpers::assert_serialization;
use crate::tauri::tasks::task_type::TaskType;
use crate::test_helpers::assert_serialization;
mod explicit_checks {
use super::*;
use crate::error::enum_error::EnumError;
#[test]
fn test_serialization() {
@@ -92,6 +94,17 @@ mod tests {
assert_eq!(TaskType::from_str("object_generation").unwrap(), TaskType::ObjectGeneration);
assert_eq!(TaskType::from_str("background_removal").unwrap(), TaskType::BackgroundRemoval);
}
#[test]
fn from_str_err() {
let result = TaskType::from_str("asdf");
assert!(result.is_err());
if let Err(EnumError::CouldNotConvertFromString(value)) = result {
assert_eq!(value, "asdf");
} else {
panic!("Expected EnumError::CouldNotConvertFromString");
}
}
#[test]
fn all_variants() {
@@ -1,5 +1,6 @@
use std::collections::BTreeSet;
use crate::error::enum_error::EnumError;
#[cfg(test)]
use strum::EnumCount;
#[cfg(test)]
@@ -38,13 +39,13 @@ impl TauriCommandCaller {
}
}
pub fn from_str(job_status: &str) -> Result<Self, String> {
match job_status {
pub fn from_str(value: &str) -> Result<Self, EnumError> {
match value {
"canvas" => Ok(Self::Canvas),
"image_editor" => Ok(Self::ImageEditor),
"text_to_image" => Ok(Self::TextToImage),
"image_to_video" => Ok(Self::ImageToVideo),
_ => Err(format!("invalid tauri_command_caller: {:?}", job_status)),
_ => Err(EnumError::CouldNotConvertFromString(value.to_string())),
}
}
@@ -62,8 +63,9 @@ impl TauriCommandCaller {
#[cfg(test)]
mod tests {
use crate::test_helpers::assert_serialization;
use crate::error::enum_error::EnumError;
use crate::tauri::ux::tauri_command_caller::TauriCommandCaller;
use crate::test_helpers::assert_serialization;
mod explicit_checks {
use super::*;
@@ -92,6 +94,17 @@ mod tests {
assert_eq!(TauriCommandCaller::from_str("image_to_video").unwrap(), TauriCommandCaller::ImageToVideo);
}
#[test]
fn from_str_err() {
let result = TauriCommandCaller::from_str("asdf");
assert!(result.is_err());
if let Err(EnumError::CouldNotConvertFromString(value)) = result {
assert_eq!(value, "asdf");
} else {
panic!("Expected EnumError::CouldNotConvertFromString");
}
}
#[test]
fn all_variants() {
let mut variants = TauriCommandCaller::all_variants();
@@ -1,4 +1,8 @@
/tutorials /index.html 200
/tutorials/ /index.html 200
/faq /index.html 200
/faq/ /index.html 200
/faq/* /index.html 200
@@ -3,6 +3,9 @@ import Download from "../pages/download";
import Navbar from "../components/navbar";
import Landing2 from "../pages/landing2";
import TutorialsPage from "../pages/tutorials";
import TutorialsArticle from "../pages/tutorials/article";
import FaqIndex from "../pages/faq/index";
import FaqArticle from "../pages/faq/article";
export function App() {
return (
@@ -13,6 +16,9 @@ export function App() {
<Route path="/" element={<Landing2 />} />
<Route path="/download" element={<Download />} />
<Route path="/tutorials" element={<TutorialsPage />} />
<Route path="/tutorials/:slug" element={<TutorialsArticle />} />
<Route path="/faq" element={<FaqIndex />} />
<Route path="/faq/:slug" element={<FaqArticle />} />
</Routes>
</div>
);
@@ -3,14 +3,28 @@ import {
faTiktok,
faDiscord,
faYoutube,
faGithub,
faGithubAlt,
} from "@fortawesome/free-brands-svg-icons";
import { SOCIAL_LINKS } from "../../config/links";
import { parseFrontmatter, pathToFilename } from "../../utils/markdown";
import { Link } from "react-router-dom";
const faqFiles = import.meta.glob("../../pages/faq/content/*.md", {
query: "?raw",
import: "default",
eager: true,
});
const tutorialFiles = import.meta.glob("../../pages/tutorials/content/*.md", {
query: "?raw",
import: "default",
eager: true,
});
const navigation = {
main: [
{ name: "Home", href: "/" },
{ name: "Tutorials", href: "/tutorials" },
{ name: "FAQ", href: "/faq" },
{ name: "Download", href: "/download" },
],
social: [
@@ -62,8 +76,8 @@ export default function Example() {
</a>
))}
</nav> */}
<div>
<div className="flex justify-center gap-x-10">
<div className="w-full flex flex-col items-center gap-10">
<div className="flex justify-center gap-x-10 mb-8">
{navigation.social.map((item) => (
<a
key={item.name}
@@ -79,7 +93,58 @@ export default function Example() {
</a>
))}
</div>
<p className="mt-10 text-center text-sm/6 text-gray-400">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6 mb-6 text-sm/6">
<div className="flex flex-col items-center sm:items-start gap-2">
<div className="text-gray-200 mb-1">Pages</div>
<Link to="/" className="text-gray-400 hover:text-white">
Home
</Link>
</div>
<div className="flex flex-col items-center sm:items-start gap-2">
<div className="text-gray-200 mb-1">Tutorials</div>
<Link to="/tutorials" className="text-gray-400 hover:text-white">
All Tutorials
</Link>
{Object.entries(tutorialFiles).map(([path, raw]) => {
const { frontmatter } = parseFrontmatter(raw as string);
const slug = pathToFilename(path);
if (frontmatter.isPublished === "false") return null;
return (
<Link
key={slug}
to={`/tutorials/${slug}`}
className="text-gray-400 hover:text-white"
>
{frontmatter.title || slug}
</Link>
);
})}
</div>
<div className="flex flex-col items-center sm:items-start gap-2">
<div className="text-gray-200 mb-1">FAQ</div>
<Link to="/faq" className="text-gray-400 hover:text-white">
All FAQs
</Link>
{Object.entries(faqFiles).map(([path, raw]) => {
const { frontmatter } = parseFrontmatter(raw as string);
const slug = pathToFilename(path);
if (frontmatter.isPublished === "false") return null;
const title = (frontmatter.title || slug) as string;
const truncated =
title.length > 36 ? title.slice(0, 33) + "…" : title;
return (
<Link
key={slug}
to={`/faq/${slug}`}
className="text-gray-400 hover:text-white"
>
{truncated}
</Link>
);
})}
</div>
</div>
<p className="text-center text-sm/6 text-gray-400">
&copy; 2025 ArtCraft. All rights reserved.
</p>
</div>
@@ -1,37 +1,19 @@
import { Disclosure } from "@headlessui/react";
import { twMerge } from "tailwind-merge";
import { useEffect, useRef, useState } from "react";
import { useLocation } from "react-router-dom";
import { useEffect, useState } from "react";
import { Link, useLocation } from "react-router-dom";
import { DiscordButton } from "../discord-button";
const NAV_ITEMS = [
{ name: "Home", href: "/" },
{ name: "Tutorials", href: "/tutorials" },
{ name: "FAQ", href: "/faq" },
];
export default function Navbar() {
const [scrolled, setScrolled] = useState(false);
const location = useLocation();
const [underlineAnim, setUnderlineAnim] = useState<
Record<string, "in" | "out" | null>
>({});
const outTimersRef = useRef<Record<string, number | undefined>>({});
const handleEnter = (href: string) => {
if (outTimersRef.current[href] !== undefined) {
window.clearTimeout(outTimersRef.current[href]);
delete outTimersRef.current[href];
}
setUnderlineAnim((prev) => ({ ...prev, [href]: "in" }));
};
const handleLeave = (href: string) => {
setUnderlineAnim((prev) => ({ ...prev, [href]: "out" }));
outTimersRef.current[href] = window.setTimeout(() => {
setUnderlineAnim((prev) => ({ ...prev, [href]: null }));
delete outTimersRef.current[href];
}, 260);
};
// Underline hover handled purely via CSS
useEffect(() => {
const handleScroll = () => {
@@ -56,31 +38,33 @@ export default function Navbar() {
<div className="flex h-16 justify-between">
<div className="flex">
<div className="flex shrink-0 items-center">
<a href="/">
<Link to="/">
<img
alt="ArtCraft"
src="/images/artcraft-logo.png"
className="h-7 w-auto"
/>
</a>
</Link>
</div>
<div className="hidden md:ml-10 md:flex md:items-center md:space-x-6">
{NAV_ITEMS.map((item) => {
const isCurrent = location.pathname === item.href;
const anim = underlineAnim[item.href] ?? null;
const isCurrent =
item.href === "/"
? location.pathname === "/"
: location.pathname === item.href ||
location.pathname.startsWith(item.href + "/");
return (
<a
<Link
key={item.name}
href={item.href}
to={item.href}
aria-current={isCurrent ? "page" : undefined}
className={twMerge(
"nav-link",
isCurrent
? "text-white"
: "text-white/60 hover:text-white",
"relative rounded-md text-[15px] font-semibold transition-all"
)}
onMouseEnter={() => !isCurrent && handleEnter(item.href)}
onMouseLeave={() => !isCurrent && handleLeave(item.href)}
>
<span className="relative z-10">{item.name}</span>
<span
@@ -93,17 +77,11 @@ export default function Navbar() {
<span
className={twMerge(
"link-underline block h-full w-full bg-primary/90",
isCurrent
? "visible-line"
: anim === "in"
? "animate-in"
: anim === "out"
? "animate-out"
: "hidden-line"
isCurrent ? "visible-line" : ""
)}
/>
</span>
</a>
</Link>
);
})}
</div>
@@ -154,25 +132,16 @@ export default function Navbar() {
transform-origin: left center;
transform: scaleX(0) translateX(0);
opacity: 0;
transition: transform 220ms ease, opacity 220ms ease;
}
.nav-link:hover .link-underline {
transform: scaleX(1) translateX(0);
opacity: 1;
}
.visible-line {
transform: scaleX(1) translateX(0);
opacity: 1;
}
.hidden-line {
transform: scaleX(0) translateX(0);
opacity: 0;
}
@keyframes underline-in {
0% { transform: scaleX(0) translateX(0); opacity: 0.8; }
100% { transform: scaleX(1) translateX(0); opacity: 1; }
}
@keyframes underline-out {
0% { transform: scaleX(1) translateX(0); opacity: 1; }
100% { transform: scaleX(1) translateX(100%); opacity: 0; }
}
.animate-in { animation: underline-in 220ms ease-out forwards; }
.animate-out { animation: underline-out 220ms ease-in forwards; }
`}</style>
</Disclosure>
);
@@ -0,0 +1,41 @@
import { useEffect } from "react";
type SeoProps = {
title: string;
description?: string;
jsonLd?: object | object[];
};
const Seo = ({ title, description, jsonLd }: SeoProps) => {
useEffect(() => {
document.title = title;
if (description) {
let meta = document.querySelector(
'meta[name="description"]'
) as HTMLMetaElement | null;
if (!meta) {
meta = document.createElement("meta");
meta.setAttribute("name", "description");
document.head.appendChild(meta);
}
meta.setAttribute("content", description);
}
let script: HTMLScriptElement | null = null;
if (jsonLd) {
script = document.createElement("script");
script.type = "application/ld+json";
script.text = JSON.stringify(jsonLd);
document.head.appendChild(script);
}
return () => {
if (script && script.parentNode) script.parentNode.removeChild(script);
};
}, [title, description, jsonLd]);
return null;
};
export default Seo;
@@ -0,0 +1,114 @@
import { useMemo } from "react";
import { Link, useParams } from "react-router-dom";
import Seo from "../../components/seo";
import { parseFrontmatter, markdownToHtml } from "../../utils/markdown";
import { faChevronLeft } from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
const markdownFiles = import.meta.glob("./content/*.md", {
query: "?raw",
import: "default",
eager: true,
});
const FaqArticle = () => {
const { slug } = useParams();
const entries = Object.entries(markdownFiles);
const markdown = (() => {
const byFilename = entries.find(([path]) =>
path.endsWith(`/content/${slug}.md`)
);
if (byFilename) return byFilename[1] as string;
for (const [_, raw] of entries) {
const { frontmatter } = parseFrontmatter(raw as string);
if (
frontmatter.slug &&
frontmatter.slug.trim().toLowerCase() === (slug || "").toLowerCase()
) {
return raw as string;
}
}
return null;
})();
const { frontmatter, body } = useMemo(
() =>
markdown ? parseFrontmatter(markdown) : { frontmatter: {}, body: "" },
[markdown]
);
if (!markdown) {
return (
<div className="relative min-h-screen bg-[#101014] text-white overflow-hidden bg-dots">
<div className="relative z-10 mx-auto w-full max-w-[1200px] px-4 sm:px-8 pt-28 sm:pt-36 pb-12">
<h1 className="text-3xl font-bold">Not found</h1>
<p className="text-white/70">We couldn't find this FAQ article.</p>
</div>
</div>
);
}
const title = `${frontmatter.title || slug} - ArtCraft`;
const description = frontmatter.abstract || "";
const thumbnail = frontmatter.thumbnail || "";
const html = markdownToHtml(body, "/faq/");
const jsonLd = {
"@context": "https://schema.org",
"@type": "Article",
headline: frontmatter.title || slug,
description: description,
articleBody: body,
};
return (
<div className="relative min-h-screen bg-[#101014] text-white overflow-hidden bg-dots">
<Seo title={title} description={description} jsonLd={jsonLd} />
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-0">
<div className="w-[900px] h-[900px] rounded-full bg-gradient-to-br from-blue-700 via-blue-500 to-[#00AABA] opacity-25 blur-[120px]"></div>
</div>
<div className="relative z-10 mx-auto w-full max-w-3xl px-4 sm:px-6 pt-24 sm:pt-32 pb-32">
<div className="mb-6">
<Link
to="/faq"
className="rounded-lg px-4 py-2 text-sm border border-white/10 bg-white/5 hover:bg-white/10 inline-flex items-center gap-2"
>
<FontAwesomeIcon icon={faChevronLeft} />
Back to FAQ
</Link>
</div>
<h1 className="text-4xl font-bold mb-4 !leading-tight">
{frontmatter.title || slug}
</h1>
{description && <p className="text-white/70 mb-8">{description}</p>}
{thumbnail && (
<div className="w-full overflow-hidden rounded-lg border border-white/10 bg-black mb-10">
<img
src={thumbnail}
alt={frontmatter.title || slug}
className="w-full h-auto object-cover"
/>
</div>
)}
<article
className="article-content max-w-none text-white/90 leading-relaxed"
dangerouslySetInnerHTML={{ __html: html }}
/>
<style>{`
.article-content h1 { font-size: 2rem; font-weight: 700; margin: 1.25rem 0; }
.article-content h2 { font-size: 1.5rem; font-weight: 700; margin: 1rem 0; }
.article-content h3 { font-size: 1.25rem; font-weight: 600; margin: 0.75rem 0; }
.article-content h4 { font-size: 1.125rem; font-weight: 600; margin: 0.5rem 0; }
.article-content p { margin: 0.75rem 0; }
.article-content ul { list-style: disc; padding-left: 1.25rem; margin: 0.75rem 0; }
.article-content img { display: block; max-width: 100%; height: auto; border-radius: 0.5rem; }
`}</style>
</div>
</div>
);
};
export default FaqArticle;
@@ -0,0 +1,26 @@
---
title: 2D Canvas — Draw and Generate
seoTitle: 2D Canvas — Sketch, mask, and guide AI generation
abstract: Draw, lay out references, and inpaint precisely to get the image you intended.
isPublished: true
publishedOn: 2025-10-06
slug: 2d-canvas
---
## Draw first, generate second
The 2D Canvas is a lightweight drawing and layout tool that the model respects. Sketch shapes, block the background, and mask areas to protect or change. When you generate, the structure you drew stays intact—only the fidelity improves.
## What it’s great for
- Storyboards, thumbnails, product mockups.
- Replacing elements in a scene without moving the rest.
- Fast “idea to image” iterations.
## Tips
- Use high‑contrast sketches for clearer guidance.
- Mask small areas for better local edits (logos, eyes, hands, reflections).
- Keep prompts short; the canvas already supplies structure.
See also: [Text to Image](../text-to-image) · [Edit Image](../edit-image) · [3D Editor](../3d-editor)
@@ -0,0 +1,27 @@
---
title: 3D Editor — Pose, Compose, and Generate
seoTitle: 3D Editor — Block scenes, pose characters, and render with AI
abstract: Build shots like a game, then render them consistently across angles and takes.
isPublished: true
publishedOn: 2025-10-06
slug: 3d-editor
---
## Compose scenes like a playable set
The 3D Editor lets you place subjects, and cameras in a real‑time scene. Add objects and characters, pose characters, and orbit the camera until the shot feels right. When you generate, the AI keeps your framing and pose intact.
## Why 3D matters
- Consistency across multiple shots and angles.
- Natural camera motion and depth cues.
- Character posing that matches your vision.
## Workflow
1. Choose a preset from ArtCraft presets by clicking the + button above or import images to build the scene.
2. Place the subject, set the camera height and focal length.
3. Add a concise prompt for style and materials.
4. Generate, compare, and tweak until it clicks.
See also: [Text to Image](../text-to-image) · [Image to Video](../image-to-video) · [2D Canvas](../2d-canvas)
@@ -0,0 +1,36 @@
---
title: AI Image Generation on ArtCraft
seoTitle: AI Image Generation on ArtCraft
abstract: Explore ArtCraft's AI generation capabilities: features, workflows, and best practices.
isPublished: true
publishedOn: 2025-10-06
slug: ai-image-generation-on-artcraft
---
## What is AI image generation in ArtCraft?
ArtCraft brings modern AI image generation into a creator-first workflow that runs locally. You compose your idea on a 2D canvas or in a 3D scene, then guide the model to render the final image with consistency and control. The result is a fast loop for exploration, iteration, and polished outputs.
## Core capabilities
- 2D Canvas guidance: sketch layouts, place references, mask areas, and refine with inpainting.
- 3D Scene composition: set camera, lighting, and subject placement for consistent multi-shot renders.
- Structure-preserving generation: keep the arrangement you designed while improving detail and lighting.
- Revisions that respect intent: re-run specific regions to fix details without losing the overall look.
## A simple workflow
- Plan the shot: define focal point, subject scale, angle, and background (2D) or block out a scene (3D).
- Add guidance: references, masks, or prompts that clarify style, mood, and key details.
- Generate and iterate: compare candidates, refine regions, and lock in the final composition.
## Quality and consistency tips
- Favor clear subjects and readable silhouettes; the model follows strong structure best.
- Use concise prompts; describe style, lighting, and subject specifics with minimal filler.
- For multi-image sets, keep camera, distance, and lighting consistent between shots.
- Iterate locally on regions that need corrections (eyes, hands, logos, or textures).
## Responsible use
Only generate content you have the rights to create. Avoid infringing on other artists’ styles or producing deceptive media. When sharing, be transparent about AI involvement and follow relevant community and platform guidelines.
@@ -0,0 +1,26 @@
---
title: Edit Image — Inpaint and Replace
seoTitle: Edit Image — Remove, replace, and restyle without Photoshop
abstract: Paint masks, describe the change, and let AI update only what you selected.
isPublished: true
publishedOn: 2025-10-06
slug: edit-image
---
## Precise edits without leaving ArtCraft
Edit Image lets you remove objects, fix details, or restyle parts of an image. Paint a mask, write a brief instruction, and generate. Only the masked region changes; the rest stays locked.
## Common uses
- Clean plates: remove cables, signage, or passers‑by.
- Product swaps: change colorways, logos, or materials.
- Portrait touch‑ups: hairline fixes, reflections, and lighting tweaks.
## Tips
- Keep masks tight around the area you want to change.
- Use short, clear instructions (e.g., “replace with polished chrome”).
- For multi‑step edits, work from big to small changes.
See also: [2D Canvas](../2d-canvas) · [Text to Image](../text-to-image)
@@ -0,0 +1,31 @@
---
title: Generate AI photos and videos of yourself
seoTitle: Generate AI photos and videos of yourself in ArtCraft
abstract: Create photorealistic images of yourself by combining reference inputs with ArtCraft workflows.
isPublished: true
publishedOn: 2025-10-06
slug: generate-ai-photos-and-videos-of-yourself
---
## Create AI portraits and short videos with strong likeness
With ArtCraft you can generate images and short video clips of yourself while keeping control over pose, lighting, and style. The workflow combines good references with structure-aware generation so the results look like you—and stay consistent across shots.
## What you need
- A handful of clear photos that show your face from multiple angles, plus at least one full-body reference.
- Neutral lighting and no heavy filters. Avoid sunglasses or occlusions for the face.
## Step-by-step
1. Prepare references: choose 4–8 images that cover front, 3/4, and profile views; include one where your outfit and background match the vibe you want.
2. Set the composition: on the 2D canvas, sketch layout and subject scale, or use the 3D scene to place camera and lighting.
3. Guide the model: add a concise prompt describing mood, setting, and attire; use masks to protect areas you don’t want to change.
4. Generate and refine: compare candidates, then fix details with inpainting (eyes, hairlines, hands, accessories).
5. Make it move: for short clips, render a small set of frames from slight camera or pose variations, then export as a looping video.
## Likeness and safety tips
- Keep hair outline and eye region sharp in references; it improves identity consistency.
- Reuse the same camera distance and angle for a set; consistency beats variety for multi-shot galleries.
- Be mindful of where and how you publish. When sharing AI portraits, add context so viewers understand how they were made.
@@ -0,0 +1,27 @@
---
title: Image to Video in ArtCraft
seoTitle: Image to Video — Animate your images with AI video models
abstract: Turn a single image into a short, cinematic clip with controllable motion and style.
isPublished: true
publishedOn: 2025-10-06
slug: image-to-video
---
## From still to motion in minutes
Image to Video converts any image—your Text to Image result or a photo—into a short clip. Choose a video model, set duration and motion, then add a concise prompt to guide style and mood.
## Best practices
- Start from a clean, well‑lit image with a readable subject.
- Keep motion subtle for realism: camera drift, gentle parallax, or small pose changes.
- Use consistent prompts if you create a series of clips.
## Workflow
1. Pick a video model and duration.
2. Adjust motion options (camera, subject, or both).
3. Add a brief prompt for style and atmosphere.
4. Render, review, and export.
See also: [Text to Image](../text-to-image) · [3D Editor](../3d-editor) · [Edit Image](../edit-image)
@@ -0,0 +1,34 @@
---
title: What image and video models does ArtCraft support?
seoTitle: ArtCraft models — Image and Video generation models
abstract: A quick overview of image and video models available in ArtCraft.
isPublished: true
publishedOn: 2025-10-06
slug: models-supported
---
## Image models
- Flux Pro 1.1 Ultra
- Flux Pro 1.1
- Flux 1 Dev
- Flux 1 Schnell
- Midjourney
- GPT Image 1
- Gemini 2.5 Flash (editing)
- Flux Pro Kontext Max (editing)
- Flux Pro Inpaint (editing)
- Flux Dev Juggernaut Inpaint (editing)
## Video models
- Kling 1.6 Pro
- Kling 2.1 Pro
- Kling 2.1 Master
- Seedance 1.0 Lite
- Sora 2
- Google Veo 2
- Google Veo 3
- Google Veo 3 Fast
See also: [Text to Image](../text-to-image) · [Image to Video](../image-to-video)
@@ -0,0 +1,28 @@
---
title: Text to Image in ArtCraft
seoTitle: Text to Image — Prompt and generate with top AI models
abstract: Turn ideas into images with clean prompts, model choices, and consistent structure.
isPublished: true
publishedOn: 2025-10-06
slug: text-to-image
---
## Turn prompts into polished images
Text to Image in ArtCraft lets you move quickly from idea to result. Choose a model, write a clear prompt, and render. If you need more control, sketch a loose layout on the 2D canvas or block a scene in 3D first—generation will respect your composition.
## Tips for great prompts
- Be specific about subject, action, and style (lighting, lens, materials).
- Prefer short, descriptive phrases over long paragraphs.
- Use negative cues sparingly (e.g., “no text, no watermark”).
## Choose the right model
Different models excel at different looks. For photorealism, use a high‑fidelity model. For design and branding, choose a model that handles flat color and typography well. If speed matters, start with a fast model to explore options, then switch to a higher‑quality model for final renders.
## Iterate with structure
If the result is close but not perfect, keep the composition and re‑render small regions with inpainting. This preserves the framing while fixing details like hands, logos, or textures.
See also: [2D Canvas](../2d-canvas) · [3D Editor](../3d-editor) · [Edit Image](../edit-image)
@@ -0,0 +1,74 @@
import { Link } from "react-router-dom";
import Seo from "../../components/seo";
import { parseFrontmatter, pathToFilename } from "../../utils/markdown";
const faqFiles = import.meta.glob("./content/*.md", {
query: "?raw",
import: "default",
eager: true,
});
const FaqIndex = () => {
const title = "Frequently Asked Questions - ArtCraft";
const description =
"Explore ArtCraft FAQs: guides on AI image generation, editing, and workflows.";
const items = Object.entries(faqFiles).map(([path, raw]) => {
const { frontmatter } = parseFrontmatter(raw as string);
const slug = pathToFilename(path);
return {
slug,
title: frontmatter.title || slug,
description: frontmatter.abstract || "",
};
});
const jsonLd = {
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: items.map((item) => ({
"@type": "Question",
name: item.title,
acceptedAnswer: {
"@type": "Answer",
text: item.description,
},
})),
};
return (
<div className="relative min-h-screen bg-[#101014] text-white overflow-hidden bg-dots">
<Seo title={title} description={description} jsonLd={jsonLd} />
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-0">
<div className="w-[900px] h-[900px] rounded-full bg-gradient-to-br from-blue-700 via-blue-500 to-[#00AABA] opacity-25 blur-[120px]"></div>
</div>
<div className="relative z-10 mx-auto w-full max-w-4xl px-4 sm:px-8 pt-28 sm:pt-36 pb-12">
<div className="text-center mb-10">
<h1 className="text-4xl sm:text-6xl font-bold mb-3">
Frequently Asked Questions
</h1>
<p className="text-white/70 text-base sm:text-lg">
Deep-dive guides and answers about ArtCraft.
</p>
</div>
<div className="flex flex-col gap-4">
{items.map((item) => (
<Link
key={item.slug}
to={`/faq/${item.slug}`}
className="block rounded-xl border border-white/10 bg-white/5 hover:bg-white/10 p-5 transition-all"
>
<h2 className="text-xl font-semibold mb-2">{item.title}</h2>
<p className="text-white/70 text-sm">{item.description}</p>
</Link>
))}
</div>
</div>
</div>
);
};
export default FaqIndex;
@@ -2,3 +2,6 @@ export * from "./landing";
export * from "./landing2";
export * from "./download";
export * from "./tutorials";
export { default as TutorialsIndex } from "./tutorials/md-index";
export { default as TutorialsArticle } from "./tutorials/article";
export * from "./faq";
@@ -14,6 +14,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import Footer from "../../components/footer";
import { useState, useRef, useEffect } from "react";
import ModelBadgeGrid from "../../components/model-badge-grid";
import Seo from "../../components/seo";
// Current Mac version (update this when a new version is released)
// If a rollback is necessary, roll back to the last known good date.
@@ -24,7 +25,8 @@ import ModelBadgeGrid from "../../components/model-badge-grid";
//const MAC_VERSION = "2025.08.23"; // NB: The "08.22 (twenty two)" release is broken.
//const MAC_VERSION = "2025.09.23";
//const MAC_VERSION = "2025.09.24"; // Works! Good release.
const MAC_VERSION = "2025.09.27";
//const MAC_VERSION = "2025.09.27";
const MAC_VERSION = "2025.10.04"; // Sora 2
// Current Windows version (update this when a new version is released)
// If a rollback is necessary, roll back to the last known good date.
@@ -35,7 +37,8 @@ const MAC_VERSION = "2025.09.27";
//const WINDOWS_VERSION = "2025.08.22";
//const WINDOWS_VERSION = "2025.09.23";
//const WINDOWS_VERSION = "2025.09.24"; // Works! Good release.
const WINDOWS_VERSION = "2025.09.27";
//const WINDOWS_VERSION = "2025.09.27";
const WINDOWS_VERSION = "2025.10.04"; // Sora 2
// Link to the current production Mac build
const MAC_LINK = `https://pub-3b58c874772a4e04b9c291815224128c.r2.dev/mac/ArtCraft_0.0.1_universal_${MAC_VERSION}.dmg`;
@@ -87,6 +90,10 @@ const Landing = () => {
return (
<div className="relative min-h-screen bg-[#101014] text-white overflow-hidden bg-dots">
<Seo
title="ArtCraft. AI Video and Images. Fast and Open Desktop App."
description="ArtCraft is an Open Desktop app for generating AI Video and Images. You own ArtCraft!"
/>
{/* Hero Section */}
<main className="relative mb-8 md:mb-12 pt-24 sm:pt-24 min-h-[400px] sm:min-h-[600px] md:min-h-[700px] flex items-center justify-center px-2 sm:px-4 md:px-0">
{/* Glowing Gradient Orb Background */}
@@ -100,7 +107,7 @@ const Landing = () => {
</motion.div>
<div className="relative z-10 px-2 sm:px-4 md:px-6 py-8 w-full flex flex-col items-center justify-center">
<div className="max-w-full md:max-w-[1200px] mx-auto text-center flex flex-col items-center">
<div className="w-full max-w-[1200px] mx-auto text-center flex flex-col items-center">
{/* Main Heading */}
<motion.h1
className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl xl:text-8xl font-bold leading-tight mb-4 sm:mb-6 drop-shadow-[0_4px_32px_rgba(80,80,255,0.25)]"
@@ -167,13 +174,13 @@ const Landing = () => {
{/* Glassy Video Mockup */}
<motion.div
className="relative mx-auto mt-4 w-full max-w-full"
className="relative mx-auto mt-4 w-full max-w-[1200px]"
initial="hidden"
animate="visible"
variants={fadeUpVariants}
>
<div
className="relative rounded-[20px] sm:rounded-[32px] overflow-hidden bg-white/10 backdrop-blur-xl shadow-2xl p-2 sm:p-4 aspect-[16/9] w-full max-w-full md:max-w-[1200px] mx-auto transition-transform duration-300"
className="relative rounded-[20px] sm:rounded-[32px] overflow-hidden bg-white/10 backdrop-blur-xl shadow-2xl p-2 sm:p-4 aspect-[16/9] w-full mx-auto transition-transform duration-300"
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
>
@@ -182,7 +189,7 @@ const Landing = () => {
autoPlay
loop
playsInline
className="w-full h-full object-cover rounded-2xl"
className="w-full h-full object-cover rounded-2xl bg-white"
src="https://pub-f7441936e5804042a1ea2bdc92e4dc71.r2.dev/artcraft_commercial.mp4"
>
<source
@@ -723,8 +730,6 @@ const Landing = () => {
</motion.div>
</div>
<div className="pb-8 sm:pb-12"></div>
<Footer />
</div>
);
@@ -0,0 +1,136 @@
import { useMemo } from "react";
import { Link, useParams } from "react-router-dom";
import Seo from "../../components/seo";
import { parseFrontmatter, markdownToHtml } from "../../utils/markdown";
import { faChevronLeft } from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
const tutorialFiles = import.meta.glob("./content/*.md", {
query: "?raw",
import: "default",
eager: true,
});
const TutorialsArticle = () => {
const { slug } = useParams();
const entries = Object.entries(tutorialFiles);
const markdown = (() => {
// 1) filename match
const byFilename = entries.find(([path]) =>
path.endsWith(`/content/${slug}.md`)
);
if (byFilename) return byFilename[1] as string;
// 2) frontmatter slug match
const wanted = (slug || "").toLowerCase();
for (const [_, raw] of entries) {
const { frontmatter } = parseFrontmatter(raw as string);
const fmSlug = (frontmatter.slug || "").trim().toLowerCase();
if (fmSlug && fmSlug === wanted) return raw as string;
const aliasesRaw = (frontmatter.aliases || "").toString();
if (aliasesRaw) {
const candidateList = aliasesRaw
.split(/[\s,]+/)
.map((s) => s.trim().toLowerCase())
.filter(Boolean);
if (candidateList.includes(wanted)) return raw as string;
}
}
return null;
})();
const { frontmatter, body } = useMemo(
() =>
markdown ? parseFrontmatter(markdown) : { frontmatter: {}, body: "" },
[markdown]
);
if (!markdown) {
return (
<div className="relative min-h-screen bg-[#101014] text-white overflow-hidden bg-dots">
<div className="relative z-10 mx-auto w-full max-w-[1200px] px-4 sm:px-8 pt-28 sm:pt-36 pb-12">
<h1 className="text-3xl font-bold">Not found</h1>
<p className="text-white/70">We couldn't find this tutorial.</p>
</div>
</div>
);
}
const title = `${frontmatter.title || slug} - ArtCraft`;
const description = frontmatter.abstract || "";
const toEmbed = (url: string): string => {
if (!url) return url;
if (url.includes("youtu.be/"))
return url.replace("youtu.be/", "www.youtube.com/embed/");
if (url.includes("watch?v=")) return url.replace("watch?v=", "embed/");
return url;
};
const frontmatterVideo =
(frontmatter.videoUrl as string) ||
(frontmatter.youtubeId
? `https://www.youtube.com/embed/${frontmatter.youtubeId}`
: "");
const videoUrl = toEmbed(frontmatterVideo);
const html = markdownToHtml(body);
const jsonLd = {
"@context": "https://schema.org",
"@type": "Article",
headline: frontmatter.title || slug,
description: description,
articleBody: body,
};
return (
<div className="relative min-h-screen bg-[#101014] text-white overflow-hidden bg-dots">
<Seo title={title} description={description} jsonLd={jsonLd} />
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-0">
<div className="w-[900px] h-[900px] rounded-full bg-gradient-to-br from-blue-700 via-blue-500 to-[#00AABA] opacity-25 blur-[120px]"></div>
</div>
<div className="relative z-10 mx-auto w-full max-w-4xl px-4 sm:px-6 pt-24 sm:pt-32 pb-32">
<div className="mb-6">
<Link
to="/tutorials"
className="rounded-lg px-4 py-2 text-sm border border-white/10 bg-white/5 hover:bg-white/10 inline-flex items-center gap-2"
>
<FontAwesomeIcon icon={faChevronLeft} />
Back to Tutorials
</Link>
</div>
<h1 className="text-4xl sm:text-5xl font-bold mb-4 !leading-tight">
{frontmatter.title || slug}
</h1>
{description && <p className="text-white/70 mb-8">{description}</p>}
{videoUrl && (
<div className="aspect-video w-full overflow-hidden rounded-lg border border-white/10 bg-black mb-10">
<iframe
title={frontmatter.title || slug}
src={videoUrl}
className="h-full w-full"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
</div>
)}
<article
className="article-content max-w-none text-white/90 leading-relaxed"
dangerouslySetInnerHTML={{ __html: html }}
/>
<style>{`
.article-content h1 { font-size: 2rem; font-weight: 700; margin: 1.25rem 0; }
.article-content h2 { font-size: 1.5rem; font-weight: 700; margin: 1rem 0; }
.article-content h3 { font-size: 1.25rem; font-weight: 600; margin: 0.75rem 0; }
.article-content h4 { font-size: 1.125rem; font-weight: 600; margin: 0.5rem 0; }
.article-content p { margin: 0.75rem 0; }
.article-content ul { list-style: disc; padding-left: 1.25rem; margin: 0.75rem 0; }
.article-content img { display: block; max-width: 100%; height: auto; border-radius: 0.5rem; }
`}</style>
</div>
</div>
);
};
export default TutorialsArticle;
@@ -0,0 +1,11 @@
---
title: 2D Editor Basics
seoTitle: 2D Editor Basics in ArtCraft
abstract: Learn the canvas tools, arrange elements, and generate from layouts.
isPublished: true
publishedOn: 2025-10-06
thumbnail: /images/tutorial-thumbnails/2D_Editor_Basics.jpg
slug: 2d-editor-basics
category: 2D
videoUrl: https://youtu.be/ZASPqlqUBQc?si=U9yRn6KETNJKaUAo
---
@@ -0,0 +1,12 @@
---
title: 3D Editor Basics
seoTitle: 3D Editor Basics in ArtCraft
abstract: Position camera and objects, compose shots, maintain character consistency.
isPublished: true
publishedOn: 2025-10-06
thumbnail: /images/tutorial-thumbnails/3D_Editor_Basics.jpg
slug: 3d-editor-basics
aliases: 3d-editor-basics
category: 3D
videoUrl: https://youtu.be/pM4CxN-isrY?si=XDyG5C1aMIL4KK5O
---
@@ -0,0 +1,12 @@
---
title: Turn Images into 3D Objects
seoTitle: Turn Images into 3D Objects in ArtCraft
abstract: Import images, convert them to scene elements, compose and render.
isPublished: true
publishedOn: 2025-10-06
thumbnail: /images/tutorial-thumbnails/Turn_Images_Into_Objects.jpg
slug: turn-images-into-objects
aliases: turn-images-into-3d-objects
category: 3D
videoUrl: https://youtu.be/t8F-sy_zyK4?si=P8lSzuiwSgdRttKa
---
@@ -0,0 +1,56 @@
import { Link } from "react-router-dom";
import Seo from "../../components/seo";
import { parseFrontmatter, pathToFilename } from "../../utils/markdown";
const tutorialFiles = import.meta.glob("./content/*.md", {
query: "?raw",
import: "default",
eager: true,
});
const TutorialsIndex = () => {
const title = "Tutorials - ArtCraft";
const description =
"Learn tips, tricks, and workflows for ArtCraft with step-by-step guides.";
return (
<div className="relative min-h-screen bg-[#101014] text-white overflow-hidden bg-dots">
<Seo title={title} description={description} />
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-0">
<div className="w-[900px] h-[900px] rounded-full bg-gradient-to-br from-blue-700 via-blue-500 to-[#00AABA] opacity-25 blur-[120px]"></div>
</div>
<div className="relative z-10 mx-auto w-full max-w-[1200px] px-4 sm:px-8 pt-28 sm:pt-36 pb-12">
<div className="text-center mb-10">
<h1 className="text-4xl sm:text-6xl font-bold mb-3">Tutorials</h1>
<p className="text-white/70 text-base sm:text-lg">
Step-by-step guides to master ArtCraft.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{Object.entries(tutorialFiles).map(([path, raw]) => {
const { frontmatter } = parseFrontmatter(raw as string);
const slug = pathToFilename(path);
if (frontmatter.isPublished === "false") return null;
const title = frontmatter.title || slug;
const description = frontmatter.abstract || "";
return (
<Link
key={slug}
to={`/tutorials/${slug}`}
className="block rounded-xl border border-white/10 bg-white/5 hover:bg-white/10 p-5"
>
<h2 className="text-xl font-semibold mb-2">{title}</h2>
<p className="text-white/70 text-sm">{description}</p>
</Link>
);
})}
</div>
</div>
</div>
);
};
export default TutorialsIndex;
@@ -1,14 +1,17 @@
import { useMemo, useState } from "react";
import Seo from "../../components/seo";
import { Link } from "react-router-dom";
import { twMerge } from "tailwind-merge";
import { defaultTutorials, TutorialItem } from "@storyteller/ui-tutorial-modal";
import { Button } from "@storyteller/ui-button";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faChevronLeft, faPlay } from "@fortawesome/pro-solid-svg-icons";
import { faPlay } from "@fortawesome/pro-solid-svg-icons";
import { parseFrontmatter, pathToFilename } from "../../utils/markdown";
const categoriesFrom = (items: TutorialItem[]): string[] => {
const set = new Set<string>();
for (const t of items) if (t.category) set.add(t.category);
return ["All", ...Array.from(set)];
type MdTutorial = {
slug: string;
title: string;
abstract: string;
category?: string;
thumbnail?: string;
};
const websiteThumb = (url: string): string => {
@@ -20,18 +23,51 @@ const websiteThumb = (url: string): string => {
export const TutorialsPage = () => {
const [activeCategory, setActiveCategory] = useState<string>("All");
const [selected, setSelected] = useState<TutorialItem | null>(null);
const tutorials = defaultTutorials;
const categories = useMemo(() => categoriesFrom(tutorials), [tutorials]);
const mdFiles = import.meta.glob("./content/*.md", {
query: "?raw",
import: "default",
eager: true,
});
const items = useMemo<MdTutorial[]>(() => {
const entries = Object.entries(mdFiles);
const results: MdTutorial[] = [];
for (const [path, raw] of entries) {
const { frontmatter } = parseFrontmatter(raw as string);
const filenameSlug = pathToFilename(path);
const slug = (frontmatter.slug || filenameSlug).trim().toLowerCase();
results.push({
slug,
title: frontmatter.title || filenameSlug,
abstract: frontmatter.abstract || "",
category: frontmatter.category || undefined,
thumbnail: frontmatter.thumbnail || undefined,
});
}
return results;
}, [mdFiles]);
const categories = useMemo(() => {
const set = new Set<string>();
for (const t of items) if (t.category) set.add(t.category);
return ["All", ...Array.from(set)];
}, [items]);
const visible = useMemo(() => {
if (activeCategory === "All") return tutorials;
return tutorials.filter((t) => t.category === activeCategory);
}, [activeCategory, tutorials]);
if (activeCategory === "All") return items;
return items.filter((t) => t.category === activeCategory);
}, [activeCategory, items]);
const pageTitle =
activeCategory === "All"
? "Tutorials - ArtCraft"
: `${activeCategory} Tutorials - ArtCraft`;
const pageDescription = "Learn tips, tricks, and workflows for ArtCraft.";
return (
<div className="relative min-h-screen bg-[#101014] text-white overflow-hidden bg-dots">
<Seo title={pageTitle} description={pageDescription} />
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-0">
<div className="w-[900px] h-[900px] rounded-full bg-gradient-to-br from-blue-700 via-blue-500 to-[#00AABA] opacity-25 blur-[120px]"></div>
</div>
@@ -46,46 +82,48 @@ export const TutorialsPage = () => {
</div>
{/* Categories */}
{!selected && (
<div className="flex flex-wrap gap-3 justify-center mb-8">
{categories.map((cat) => (
<button
key={cat}
type="button"
onClick={() => setActiveCategory(cat)}
className={twMerge(
"px-4 py-2 rounded-xl border",
activeCategory === cat
? "bg-primary/30 border-primary/90"
: "bg-white/5 border-white/10 hover:bg-white/10"
)}
>
{cat}
</button>
))}
</div>
)}
<div className="flex flex-wrap gap-3 justify-center mb-8">
{categories.map((cat) => (
<button
key={cat}
type="button"
onClick={() => setActiveCategory(cat)}
className={twMerge(
"px-4 py-2 rounded-xl border",
activeCategory === cat
? "bg-primary/30 border-primary/90"
: "bg-white/5 border-white/10 hover:bg-white/10"
)}
>
{cat}
</button>
))}
</div>
{/* Content */}
{!selected ? (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{visible.map((item) => (
<button
key={item.id}
type="button"
onClick={() => setSelected(item)}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{visible.map((item) => {
const href = `/tutorials/${item.slug}`;
return (
<Link
key={item.slug}
to={href}
className="group block overflow-hidden rounded-lg border border-white/10 bg-white/5 hover:bg-white/10 text-left"
>
<div className="aspect-video w-full overflow-hidden relative">
<img
src={websiteThumb(item.thumbnailUrl)}
src={
item.thumbnail
? websiteThumb(item.thumbnail)
: "/images/tutorial-thumbnails/2D_Editor_Basics.jpg"
}
alt={item.title}
className="h-full w-full object-cover transition-transform group-hover:scale-[1.02]"
/>
<div className="absolute inset-0 flex items-center justify-center bg-black/10 opacity-0 group-hover:opacity-100 transition-opacity">
<span className="inline-flex items-center gap-2 text-white/90 text-sm font-medium">
<FontAwesomeIcon icon={faPlay} />
Watch
Watch video
</span>
</div>
</div>
@@ -97,40 +135,10 @@ export const TutorialsPage = () => {
</span>
)}
</div>
</button>
))}
</div>
) : (
<div className="flex w-full flex-col">
<div className="mb-3 flex items-center gap-4 md:flex-row flex-col">
<Button
onClick={() => setSelected(null)}
className="w-fit text-base-fg opacity-80 hover:opacity-100 font-medium border-none"
variant="action"
icon={faChevronLeft}
>
Back
</Button>
<div className="text-lg font-bold text-base-fg">
Tutorial: {selected.title}
</div>
</div>
<div className="w-full">
<div className="aspect-video w-full overflow-hidden rounded-lg border border-white/10 bg-black">
<iframe
title={selected.title}
src={selected.videoUrl.replace(
"youtu.be/",
"www.youtube.com/embed/"
)}
className="h-full w-full"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
</div>
</div>
</div>
)}
</Link>
);
})}
</div>
</div>
</div>
);
@@ -0,0 +1,126 @@
export const markdownToHtml = (
markdown: string,
basePath: string = ""
): string => {
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
const htmlParts: string[] = [];
let inList = false;
const closeListIfOpen = () => {
if (inList) {
htmlParts.push("</ul>");
inList = false;
}
};
const isAbsolute = (url: string): boolean => /^(?:[a-z]+:)?\/\//i.test(url);
const normalizeLink = (url: string): string => {
if (!basePath) return url;
if (!url) return url;
if (url.startsWith("#")) return url;
if (url.startsWith("/")) return url;
if (isAbsolute(url)) return url;
if (url.startsWith("../")) return basePath + url.replace(/^\.\.\//, "");
if (url.startsWith("./")) return basePath + url.replace(/^\.\//, "");
return basePath + url;
};
const renderInline = (text: string): string => {
// images: ![alt](url)
let out = text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_m, alt, url) => {
const safeAlt = String(alt || "").replace(/"/g, "&quot;");
const safeUrl = String(url || "");
return `<img src="${safeUrl}" alt="${safeAlt}" />`;
});
// links: [text](url)
out = out.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, label, url) => {
const safeLabel = String(label || "").replace(/"/g, "&quot;");
const safeUrl = normalizeLink(String(url || ""));
return `<a href="${safeUrl}" class="underline hover:opacity-90">${safeLabel}</a>`;
});
return out;
};
for (const raw of lines) {
const line = raw.trimEnd();
const ltrim = line.replace(/^\s+/, "");
// ATX headings: support #..###### with optional space after hashes
const heading = ltrim.match(/^(#{1,6})\s*(.*)$/);
if (heading) {
closeListIfOpen();
const level = Math.min(heading[1].length, 6);
const text = renderInline(heading[2] || "");
htmlParts.push(`<h${level}>${text}</h${level}>`);
continue;
}
// Standalone image line
const imgMatch = line.match(/^!\[([^\]]*)\]\(([^)]+)\)\s*$/);
if (imgMatch) {
closeListIfOpen();
const alt = String(imgMatch[1] || "").replace(/"/g, "&quot;");
const url = normalizeLink(String(imgMatch[2] || ""));
htmlParts.push(`<p><img src="${url}" alt="${alt}" /></p>`);
continue;
}
if (line.startsWith("- ")) {
if (!inList) {
inList = true;
htmlParts.push("<ul>");
}
htmlParts.push(`<li>${renderInline(line.slice(2))}</li>`);
continue;
}
if (line.trim() === "") {
closeListIfOpen();
htmlParts.push("");
continue;
}
closeListIfOpen();
htmlParts.push(`<p>${renderInline(line)}</p>`);
}
closeListIfOpen();
return htmlParts.join("\n");
};
export type FrontmatterResult = {
frontmatter: Record<string, string>;
body: string;
};
export const parseFrontmatter = (raw: string): FrontmatterResult => {
const text = raw.replace(/\r\n/g, "\n");
if (!text.startsWith("---\n")) {
return { frontmatter: {}, body: text };
}
const end = text.indexOf("\n---\n", 4);
if (end === -1) return { frontmatter: {}, body: text };
const header = text.slice(4, end);
const body = text.slice(end + 5);
const fm: Record<string, string> = {};
for (const line of header.split("\n")) {
const idx = line.indexOf(":");
if (idx === -1) continue;
const key = line.slice(0, idx).trim();
const value = line
.slice(idx + 1)
.trim()
.replace(/^"|^'|"$|'$/g, "");
if (key) fm[key] = value;
}
return { frontmatter: fm, body };
};
export const slugify = (s: string): string =>
s
.toLowerCase()
.replace(/[_\s]+/g, "-")
.replace(/[^a-z0-9-]/g, "")
.replace(/--+/g, "-")
.replace(/^-+|-+$/g, "");
export const pathToFilename = (path: string): string => {
const parts = path.split("/");
const filename = parts[parts.length - 1] || "";
return filename.replace(/\.md$/, "");
};
@@ -37,9 +37,6 @@
"src/components/footer"
],
"references": [
{
"path": "../../libs/components/tutorial-modal/tsconfig.lib.json"
},
{
"path": "../../libs/components/button/tsconfig.lib.json"
}
@@ -2,9 +2,6 @@
"files": [],
"include": [],
"references": [
{
"path": "../../libs/components/tutorial-modal"
},
{
"path": "../../libs/components/button"
},
@@ -16,8 +16,8 @@ import {
CANVAS_2D_PAGE_MODEL_LIST,
ClassyModelSelector,
ModelPage,
ProviderSelector,
PROVIDER_LOOKUP_BY_PAGE,
//ProviderSelector,
//PROVIDER_LOOKUP_BY_PAGE,
useSelectedImageModel,
} from "@storyteller/ui-model-selector";
import { useCanvasBgRemovedEvent } from "@storyteller/tauri-api";
@@ -369,7 +369,7 @@ const PageDraw = () => {
input.click();
}}
onDelete={(): void => {
store.deleteSelectedItems();
store.RESET();
}}
activeToolId={store.activeTool}
currentShape={store.currentShape}
@@ -453,7 +453,7 @@ const PageDraw = () => {
showIconsInList
triggerLabel="Model"
/>
<ProviderSelector
{/*<ProviderSelector
page={PAGE_ID}
model={selectedImageModel}
providersByModel={PROVIDER_LOOKUP_BY_PAGE[PAGE_ID]}
@@ -461,7 +461,7 @@ const PageDraw = () => {
panelClassName="min-w-[220px]"
buttonClassName="bg-transparent p-0 text-lg hover:bg-transparent text-white/80 hover:text-white"
triggerLabel="Provider"
/>
/>*/}
</div>
<div className="absolute bottom-6 right-6 z-20 flex items-center gap-2">
<TutorialModalButton />
@@ -196,6 +196,7 @@ interface SceneState {
// Action for deleting selected items
deleteSelectedItems: () => void;
RESET: () => void;
// Clipboard actions
copySelectedItems: () => void;
@@ -234,10 +235,8 @@ interface SceneState {
sendBackward: (nodeIds: string[]) => void;
// Start background removal (enqueues task with Tauri)
beginRemoveBackground: (
nodeIds: string[],
) => Promise<void>;
beginRemoveBackground: (nodeIds: string[]) => Promise<void>;
// Finish background removal (handoff back from a Tauri-sent event)
finishRemoveBackground: (
nodeId: string,
@@ -273,7 +272,7 @@ const getNextZIndex = (nodes: Node[], lineNodes: LineNode[]): number => {
return allZIndices.length > 0 ? Math.max(...allZIndices) + 1 : 1;
};
export const useSceneStore = create<SceneState>((set, get) => ({
export const useSceneStore = create<SceneState>((set, get, store) => ({
// Initial state
nodes: [],
lineNodes: [],
@@ -289,7 +288,7 @@ export const useSceneStore = create<SceneState>((set, get) => ({
fillColor: "white",
currentShape: null,
shapeColor: "#4d79b3",
// Cursor initial state
cursorPosition: null,
cursorVisible: false,
@@ -337,7 +336,6 @@ export const useSceneStore = create<SceneState>((set, get) => ({
}
},
updateNode: (
id: string,
updates: Partial<Node>,
@@ -838,6 +836,10 @@ export const useSceneStore = create<SceneState>((set, get) => ({
}
},
RESET: () => {
set(store.getInitialState());
},
// Clipboard actions
copySelectedItems: () => {
set((state) => {
@@ -1437,9 +1439,7 @@ export const useSceneStore = create<SceneState>((set, get) => ({
});
get().saveState();
},
beginRemoveBackground: async (
nodeIds: string[],
) => {
beginRemoveBackground: async (nodeIds: string[]) => {
const hasImageNodes = nodeIds.some((id) => {
const node = get().nodes.find((n) => n.id === id);
return node ? node.type === "image" : false;
@@ -1455,7 +1455,7 @@ export const useSceneStore = create<SceneState>((set, get) => ({
}
try {
const base64Image = await convertFileToBase64(firstNode.imageFile);
// NB: This handler is async, so we subscribe (externally) and
// NB: This handler is async, so we subscribe (externally) and
// wait for the bg removal to complete.
const _response = await EnqueueImageBgRemoval({
base64_image: base64Image,
@@ -14,6 +14,13 @@ import { TutorialModalButton } from "@storyteller/ui-tutorial-modal";
export interface BaseSelectorImage {
url: string;
mediaToken: string;
// Link to the thumbnail *template* URL of the image
// This is not a URL itself! It must be converted into a URL by replacing parameters.
thumbnailUrlTemplate?: string;
// Link to the full image URL of the full image asset
fullImageUrl?: string;
}
export type BaseImageSelectorProps = {
@@ -41,13 +48,13 @@ export const BaseImageSelector = ({
setSelectedGalleryImages([]);
};
const handleImageSelect = (id: string) => {
const handleImageSelect = (mediaToken: string) => {
// If already selected, deselect it
// Else if not selected and under max limit, select it
if (selectedGalleryImages.includes(id)) {
if (selectedGalleryImages.includes(mediaToken)) {
setSelectedGalleryImages([]);
} else {
setSelectedGalleryImages([id]);
setSelectedGalleryImages([mediaToken]);
}
};
@@ -56,12 +63,14 @@ export const BaseImageSelector = ({
if (selectedItems.length !== 1) {
return;
}
const item = selectedItems[0];
if (!item.fullImage) return;
if (!item.fullImage) {
return
}
const referenceImage: BaseSelectorImage = {
url: item.fullImage,
mediaToken: item.id,
thumbnailUrlTemplate: item.thumbnailUrlTemplate,
};
sendImageEvent(referenceImage);
};
@@ -92,6 +101,7 @@ export const BaseImageSelector = ({
// Attempt to resolve the CDN URL for the uploaded image token; fallback to data URL
(async () => {
let finalUrl = reader.result as string;
let thumbnailUrlTemplate = undefined;
try {
const api = new MediaFilesApi();
const result = await api.GetMediaFileByToken({
@@ -102,6 +112,12 @@ export const BaseImageSelector = ({
result.data.media_links?.cdn_url ||
result.data.public_bucket_url ||
finalUrl;
// NB(bt,2025-10-09): I think `thumbnail_template` is wrong and that
// `maybe_thumbnail_template` is the correct API field, but upstream
// seems to imply otherwise. Let's simply check both keys for now.
thumbnailUrlTemplate =
result.data.media_links?.thumbnail_template ||
(result.data.media_links as any)?.maybe_thumbnail_template;
}
} catch (e) {
console.warn(
@@ -111,8 +127,10 @@ export const BaseImageSelector = ({
}
const referenceImage: BaseSelectorImage = {
url: finalUrl,
mediaToken,
url: finalUrl,
fullImageUrl: finalUrl,
thumbnailUrlTemplate: thumbnailUrlTemplate,
};
toast.success("Image uploaded successfully!");
@@ -71,6 +71,8 @@ export const HistoryStack = ({
({
url: editedImage.cdn_url,
mediaToken: editedImage.media_token,
thumbnailUrlTemplate: editedImage.maybe_thumbnail_template,
fullImageUrl: editedImage.cdn_url,
}) as BaseSelectorImage,
),
};
@@ -111,6 +113,18 @@ export const HistoryStack = ({
prevPendingCountRef.current = current;
}, [pendingPlaceholders.length]);
const getImageThumbnailSource = (image: BaseSelectorImage) => {
if (!!image.thumbnailUrlTemplate) {
// NB: this is the preferred way to populate the history stack thumbnails
return image.thumbnailUrlTemplate.replace("{WIDTH}", "256");
}
if (image.url.startsWith("data:")) {
return image.url;
}
console.warn("Using older image source for history stack image", image);
return `${image.url}?historystack+${sessionRandBuster.current}`;
};
return (
<div className="h-auto w-20 rounded-lg">
<div className="glass rounded-lg p-1.5">
@@ -202,13 +216,10 @@ export const HistoryStack = ({
onClick={() => handleSelectWithPreload(image)}
>
<img
src={
image.url.startsWith("data:")
? image.url
: `${image.url}?historystack+${sessionRandBuster.current}`
}
src={getImageThumbnailSource(image)}
alt=""
crossOrigin="anonymous"
// NB(bt,2025-10-09): We shouldn't need CORS here.
//crossOrigin="anonymous"
className="absolute inset-0 h-full w-full object-cover"
/>
<div
@@ -16,8 +16,8 @@ import {
useSelectedImageModel,
IMAGE_EDITOR_PAGE_MODEL_LIST,
ModelPage,
ProviderSelector,
PROVIDER_LOOKUP_BY_PAGE,
//ProviderSelector,
//PROVIDER_LOOKUP_BY_PAGE,
} from "@storyteller/ui-model-selector";
import { ImageModel } from "@storyteller/model-list";
import { HistoryStack, ImageBundle } from "./HistoryStack";
@@ -475,7 +475,7 @@ const PageEdit = () => {
showIconsInList
triggerLabel="Model"
/>
<ProviderSelector
{/*<ProviderSelector
page={PAGE_ID}
model={selectedImageModel}
providersByModel={PROVIDER_LOOKUP_BY_PAGE[PAGE_ID]}
@@ -483,7 +483,7 @@ const PageEdit = () => {
panelClassName="min-w-[220px]"
buttonClassName="bg-transparent p-0 text-lg hover:bg-transparent text-base-fg opacity-80 hover:opacity-100"
triggerLabel="Provider"
/>
/>*/}
</div>
<div className="absolute bottom-6 right-6 z-20 flex items-center gap-2">
<TutorialModalButton />
@@ -71,8 +71,8 @@ import {
ModelPage,
useSelectedImageModel,
ClassyModelSelector,
ProviderSelector,
PROVIDER_LOOKUP_BY_PAGE,
//ProviderSelector,
//PROVIDER_LOOKUP_BY_PAGE,
} from "@storyteller/ui-model-selector";
import { LoginModal, useLoginModalStore } from "@storyteller/ui-login-modal";
import PageDraw from "../PageDraw/PageDraw";
@@ -553,7 +553,7 @@ export const PageEditor = () => {
showIconsInList
triggerLabel="Model"
/>
<ProviderSelector
{/*<ProviderSelector
page={PAGE_ID}
model={selectedImageModel}
providersByModel={PROVIDER_LOOKUP_BY_PAGE[PAGE_ID]}
@@ -561,7 +561,7 @@ export const PageEditor = () => {
panelClassName="min-w-[220px]"
buttonClassName="bg-transparent p-0 text-lg hover:bg-transparent text-white/80 hover:text-white"
triggerLabel="Provider"
/>
/>*/}
</div>
<div className="absolute bottom-6 right-6 z-20 flex items-center gap-2">
<TutorialModalButton />
@@ -8,8 +8,8 @@ import {
ModelPage,
ClassyModelSelector,
useSelectedImageModel,
ProviderSelector,
PROVIDER_LOOKUP_BY_PAGE,
//ProviderSelector,
//PROVIDER_LOOKUP_BY_PAGE,
} from "@storyteller/ui-model-selector";
import { ImageModel } from "@storyteller/model-list";
interface TextToImageProps {
@@ -226,7 +226,7 @@ const TextToImage = ({ imageMediaId, imageUrl }: TextToImageProps) => {
showIconsInList
triggerLabel="Model"
/>
<ProviderSelector
{/*<ProviderSelector
page={PAGE_ID}
model={selectedImageModel}
providersByModel={PROVIDER_LOOKUP_BY_PAGE[PAGE_ID]}
@@ -234,7 +234,7 @@ const TextToImage = ({ imageMediaId, imageUrl }: TextToImageProps) => {
panelClassName="min-w-[220px]"
buttonClassName="bg-transparent p-0 text-lg hover:bg-transparent text-white/80 hover:text-white"
triggerLabel="Provider"
/>
/>*/}
</div>
<div className="absolute bottom-6 right-6 z-20 flex items-center gap-2">
<TutorialModalButton />
@@ -7,8 +7,8 @@ import {
IMAGE_TO_VIDEO_PAGE_MODEL_LIST,
ModelPage,
useSelectedVideoModel,
ProviderSelector,
PROVIDER_LOOKUP_BY_PAGE,
//ProviderSelector,
//PROVIDER_LOOKUP_BY_PAGE,
} from "@storyteller/ui-model-selector";
import { VideoModel } from "@storyteller/model-list";
import { animated, useSpring } from "@react-spring/web";
@@ -213,7 +213,7 @@ const ImageToVideo = ({ imageMediaId, imageUrl }: ImageToVideoProps) => {
showIconsInList
triggerLabel="Model"
/>
<ProviderSelector
{/*<ProviderSelector
page={PAGE_ID}
model={selectedVideoModel}
providersByModel={PROVIDER_LOOKUP_BY_PAGE[PAGE_ID]}
@@ -221,7 +221,7 @@ const ImageToVideo = ({ imageMediaId, imageUrl }: ImageToVideoProps) => {
panelClassName="min-w-[220px]"
buttonClassName="bg-transparent p-0 text-lg hover:bg-transparent text-white/80 hover:text-white"
triggerLabel="Provider"
/>
/>*/}
</div>
<div className="absolute bottom-6 right-6 z-20 flex items-center gap-2">
<TutorialModalButton />
+2
View File
@@ -17,6 +17,7 @@
"build": "vite build",
"preview": "vite preview --port 8080",
"dev": "echo 'editor3d' && vite dev",
"production-preview": "echo 'editor3d dev with production preview' && vite build --debug --emptyOutDir --logLevel info && vite preview --port 5173",
"staging-build": "vite build",
"lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint playground/* .",
"typecheck": "tsc",
@@ -64,6 +65,7 @@
"react-dom": "^18.2.0",
"react-drag-drop-files": "^2.3.10",
"react-helmet": "^6.1.0",
"react-hot-toast": "^2.5.2",
"react-image-crop": "^11.0.5",
"react-konva": "^18.2.10",
"react-router": "^6.26.1",
+50
View File
@@ -0,0 +1,50 @@
{
"extends": [
"../../.eslintrc.json"
],
"ignorePatterns": [
"!**/*"
],
"overrides": [
{
"files": [
"*.ts",
"*.tsx",
"*.js",
"*.jsx"
],
"rules": {}
},
{
"files": [
"*.ts",
"*.tsx"
],
"rules": {}
},
{
"files": [
"*.js",
"*.jsx"
],
"rules": {}
},
{
"files": [
"*.json"
],
"parser": "jsonc-eslint-parser",
"rules": {
"@nx/dependency-checks": [
"error",
{
"ignoredFiles": [
"{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}",
"{projectRoot}/vite.config.{js,ts,mjs,mts}"
]
}
]
}
}
]
}
+11
View File
@@ -0,0 +1,11 @@
# api-enums
This library was generated with [Nx](https://nx.dev).
## Building
Run `nx build api-enums` to build the library.
## Running unit tests
Run `nx test api-enums` to execute the unit tests via [Vitest](https://vitest.dev/).
+19
View File
@@ -0,0 +1,19 @@
{
"name": "@storyteller/api-enums",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"development": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
}
},
"dependencies": {}
}
+1
View File
@@ -0,0 +1 @@
export * from './lib/index.js';
@@ -0,0 +1,10 @@
// NOTE: These are defined in Rust (as the source of truth) and duplicated in the frontend.
// In the future, we should use code gen (protobufs or similar) to keep the two sides in sync.
export enum GenerationProvider {
Artcraft = "artcraft",
Fal = "fal",
Midjourney = "midjourney",
Sora = "sora",
}
@@ -0,0 +1 @@
export * from "./generation_provider.js";
+3
View File
@@ -0,0 +1,3 @@
export * from "./common/index.js";
export * from "./tauri/index.js";
@@ -0,0 +1 @@
export * from "./tasks/index.js";
@@ -0,0 +1,3 @@
export * from "./task_model_type.js";
export * from "./task_status.js";
export * from "./task_type.js";
@@ -0,0 +1,33 @@
// NOTE: These are defined in Rust (as the source of truth) and duplicated in the frontend.
// In the future, we should use code gen (protobufs or similar) to keep the two sides in sync.
export enum TaskModelType {
// Image models
Flux1Dev = "flux_1_dev",
Flux1Schnell = "flux_1_schnell",
FluxDevJuggernaut = "flux_dev_juggernaut",
FluxPro1 = "flux_pro_1",
FluxPro11 = "flux_pro_1.1",
FluxPro11Ultra = "flux_pro_1.1_ultra",
FluxProKontextMax = "flux_pro_kontext_max",
Gemini25Flash = "gemini_25_flash",
GptImage1 = "gpt_image_1",
Recraft3 = "recraft_3",
// Generic Midjourney model, version unknown.
Midjourney = "midjourney",
// Video models
Kling16Pro = "kling_1.6_pro",
Kling21Pro = "kling_2.1_pro",
Kling21Master = "kling_2.1_master",
Seedance10Lite = "seedance_1.0_lite",
Sora2 = "sora_2",
Veo2 = "veo_2",
Veo3 = "veo_3",
Veo3Fast = "veo_3_fast",
// 3D Object generation models
Hunyuan3d2_0 = "hunyuan_3d_2.0",
Hunyuan3d2_1 = "hunyuan_3d_2.1",
}
@@ -0,0 +1,14 @@
// NOTE: These are defined in Rust (as the source of truth) and duplicated in the frontend.
// In the future, we should use code gen (protobufs or similar) to keep the two sides in sync.
export enum TaskStatus {
Pending = "pending",
Started = "started",
CompleteSuccess = "complete_success",
CompleteFailure = "complete_failure",
AttemptFailed = "attempt_failed",
Dead = "dead",
CancelledByUser = "cancelled_by_user",
CancelledByProvider = "cancelled_by_provider",
CancelledByUs = "cancelled_by_us",
}
@@ -0,0 +1,10 @@
// NOTE: These are defined in Rust (as the source of truth) and duplicated in the frontend.
// In the future, we should use code gen (protobufs or similar) to keep the two sides in sync.
export enum TaskType {
ImageGeneration = "image_generation",
ImageInpaintEdit = "image_inpaint_edit",
VideoGeneration = "video_generation",
ObjectGeneration = "object_generation",
BackgroundRemoval = "background_removal",
}
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}
+35
View File
@@ -0,0 +1,35 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"baseUrl": ".",
"rootDir": "src",
"outDir": "dist",
"tsBuildInfoFile": "dist/tsconfig.lib.tsbuildinfo",
"emitDeclarationOnly": true,
"module": "nodenext",
"moduleResolution": "nodenext",
"forceConsistentCasingInFileNames": true,
"types": [
"node",
"vite/client"
]
},
"include": [
"src/**/*.ts"
],
"references": [],
"exclude": [
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx"
]
}
@@ -0,0 +1,36 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./out-tsc/vitest",
"types": [
"vitest/globals",
"vitest/importMeta",
"vite/client",
"node",
"vitest"
],
"module": "nodenext",
"moduleResolution": "nodenext",
"forceConsistentCasingInFileNames": true
},
"include": [
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx",
"src/**/*.d.ts"
],
"references": [
{
"path": "./tsconfig.lib.json"
}
]
}
+48
View File
@@ -0,0 +1,48 @@
/// <reference types='vitest' />
import { defineConfig } from 'vite';
import dts from 'vite-plugin-dts';
import * as path from 'path';
export default defineConfig(() => ({
root: __dirname,
cacheDir: '../../node_modules/.vite/libs/api-enums',
plugins: [dts({ entryRoot: 'src', tsconfigPath: path.join(__dirname, 'tsconfig.lib.json') })],
// Uncomment this if you are using workers.
// worker: {
// plugins: [ nxViteTsPaths() ],
// },
// Configuration for building your library.
// See: https://vitejs.dev/guide/build.html#library-mode
build: {
outDir: './dist',
emptyOutDir: true,
reportCompressedSize: true,
commonjsOptions: {
transformMixedEsModules: true,
},
lib: {
// Could also be a dictionary or array of multiple entry points.
entry: 'src/index.ts',
name: '@storyteller/api-enums',
fileName: 'index',
// Change this to the formats you want to support.
// Don't forget to update your package.json as well.
formats: ['es' as const]
},
rollupOptions: {
// External packages that should not be bundled into your library.
external: []
},
},
test: {
watch: false,
globals: true,
environment: 'node',
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
reporters: ['default'],
coverage: {
reportsDirectory: './test-output/vitest/coverage',
provider: 'v8' as const,
}
},
}));
@@ -47,6 +47,9 @@ export interface GalleryItem {
id: string;
label: string;
thumbnail: string | null;
// Thumbnail template is not a usable URL yet. It has some variables
// like `{WIDTH}` which must be replaced downstream.
thumbnailUrlTemplate?: string;
fullImage?: string | null;
createdAt: string;
mediaClass?: string;
@@ -266,6 +269,9 @@ export const GalleryModal = React.memo(
"{WIDTH}",
thumbnail_size.toString()
),
// TODO(bt): Thumbnail template URL may be wrong for videos and other asset types, but I'm
// trying to plumb it through for the inpainting editor, which is just images.
thumbnailUrlTemplate: item.media_links.maybe_thumbnail_template,
fullImage: item.media_links.cdn_url,
createdAt: item.created_at,
mediaClass:
@@ -7,7 +7,13 @@ import * as path from 'path';
export default defineConfig(() => ({
root: __dirname,
cacheDir: '../../../node_modules/.vite/libs/components/login-modal',
plugins: [react(), dts({ entryRoot: 'src', tsconfigPath: path.join(__dirname, 'tsconfig.lib.json') })],
plugins: [
react(),
dts({
entryRoot: 'src',
tsconfigPath: path.join(__dirname, 'tsconfig.lib.json')
})
],
// Uncomment this if you are using workers.
// worker: {
// plugins: [ nxViteTsPaths() ],
@@ -43,8 +43,9 @@ export const TEXT_TO_IMAGE_PAGE_MODEL_LIST : ModelList =
const set : Set<Model> = new Set();
set.add(IMAGE_MODELS_BY_ID.get("flux_pro_1_1")!); // Don't put Midjourney first!
IMAGE_MODELS
.filter((model) => !model.usesInpaintingMask) // We can't use masked inpainting models here
.filter((model) => model.id !== "flux_pro_kontext_max") // NB: Flux Pro Kontext Max requires an input image
.filter((model) => model.canTextToImage)
//.filter((model) => !model.usesInpaintingMask) // We can't use masked inpainting models here
//.filter((model) => model.id !== "flux_pro_kontext_max") // NB: Flux Pro Kontext Max requires an input image
.forEach((m) => set.add(m));
return Array.from(set);
})(),
@@ -5,7 +5,6 @@ import {
JobContextType,
MaybeCanvasRenderBitmapType,
UploaderState,
UploaderStates,
} from "@storyteller/common";
import { PopoverMenu, PopoverItem } from "@storyteller/ui-popover";
import { Tooltip } from "@storyteller/ui-tooltip";
@@ -32,7 +31,7 @@ import {
EnqueueContextualEditImage,
EnqueueContextualEditImageSize,
} from "@storyteller/tauri-api";
import { usePrompt2DStore, RefImage } from "./promptStore";
import { usePrompt2DStore } from "./promptStore";
import { gtagEvent } from "@storyteller/google-analytics";
import { getCapabilitiesForModel } from "@storyteller/model-list";
import { ImageModel } from "@storyteller/model-list";
@@ -39,11 +39,8 @@ import { Signal } from "@preact/signals-react";
import {
CommandSuccessStatus,
EnqueueContextualEditImage,
EnqueueContextualEditImageModel,
EnqueueContextualEditImageSize,
// waitForSoraLogin,
} from "@storyteller/tauri-api";
// import { showActionReminder } from "@storyteller/ui-action-reminder-modal";
import { usePrompt3DStore } from "./promptStore";
import { gtagEvent } from "@storyteller/google-analytics";
import { ImageModel } from "@storyteller/model-list";
@@ -98,7 +95,7 @@ export const PromptBox3D = ({
snapshotCurrentFrame,
}: PromptBox3DProps) => {
useSignals();
const fileInputRef = useRef<HTMLInputElement>(null);
//const fileInputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [content, setContent] = useState<React.ReactNode>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
@@ -235,8 +235,8 @@ export const PromptBoxImage = ({
return;
}
console.log("Selected model:", selectedModel)
console.log("Prompt:", prompt)
console.debug("Selected model:", selectedModel)
console.debug("Prompt:", prompt)
setIsEnqueueing(true);
@@ -274,7 +274,7 @@ export const PromptBoxImage = ({
}
const generateResponse = await EnqueueTextToImage(request);
console.log("PromptBoxImage - generateResponse", generateResponse);
console.debug("PromptBoxImage - generateResponse", generateResponse);
await onEnqueuePressed?.(prompt, generationCount, subscriberId);
} catch (err) {
@@ -7,7 +7,13 @@ import * as path from 'path';
export default defineConfig(() => ({
root: __dirname,
cacheDir: '../../../node_modules/.vite/libs/components/toaster',
plugins: [react(), dts({ entryRoot: 'src', tsconfigPath: path.join(__dirname, 'tsconfig.lib.json') })],
plugins: [
react(),
dts({
entryRoot: 'src',
tsconfigPath: path.join(__dirname, 'tsconfig.lib.json')
})
],
// Uncomment this if you are using workers.
// worker: {
// plugins: [ nxViteTsPaths() ],
@@ -32,7 +38,18 @@ export default defineConfig(() => ({
},
rollupOptions: {
// External packages that should not be bundled into your library.
external: ['react','react-dom','react/jsx-runtime']
external: [
'react',
'react-dom',
'react/jsx-runtime',
'react-hot-toast'
],
output: {
globals: {
'react-hot-toast': 'react-hot-toast',
},
preserveModules: false,
},
},
},
test: {
@@ -27,6 +27,10 @@ export class ImageModel extends Model {
// Maximum number of image prompts that can be attached
readonly maxImagePromptCount: number;
// Whether the model can be used for text-to-image
// If true, it'll be displayed on the text-to-image page.
readonly canTextToImage: boolean;
constructor(args: {
id: string;
tauriId: string;
@@ -42,6 +46,7 @@ export class ImageModel extends Model {
usesInpaintingMask?: boolean;
canUseImagePrompt?: boolean;
maxImagePromptCount?: number;
canTextToImage?: boolean;
tags?: ModelTag[];
}) {
if (args.maxGenerationCount < 1) {
@@ -62,5 +67,6 @@ export class ImageModel extends Model {
this.usesInpaintingMask = args.usesInpaintingMask ?? false;
this.canUseImagePrompt = args.canUseImagePrompt ?? false;
this.maxImagePromptCount = Math.max(0, args.maxImagePromptCount ?? 1);
this.canTextToImage = args.canTextToImage === false ? false : true; // Default to true !
}
}
@@ -10,8 +10,8 @@ export const IMAGE_MODELS: ImageModel[] = [
category: "image",
creator: ModelCreator.Midjourney,
selectorName: "Midjourney",
selectorDescription: "Incredible style and quality",
selectorBadges: ["15 sec."],
selectorDescription: "Stunning style and quality",
selectorBadges: ["45 sec."],
maxGenerationCount: 4,
defaultGenerationCount: 4,
canUseImagePrompt: false,
@@ -24,8 +24,8 @@ export const IMAGE_MODELS: ImageModel[] = [
category: "image",
creator: ModelCreator.BlackForestLabs,
selectorName: "Flux Pro 1.1 Ultra",
selectorDescription: "High quality model",
selectorBadges: ["15 sec."],
selectorDescription: "Higher quality model",
selectorBadges: ["35 sec."],
maxGenerationCount: 4,
defaultGenerationCount: 4,
canUseImagePrompt: true,
@@ -39,7 +39,7 @@ export const IMAGE_MODELS: ImageModel[] = [
creator: ModelCreator.BlackForestLabs,
selectorName: "Flux Pro 1.1",
selectorDescription: "High quality model",
selectorBadges: ["15 sec."],
selectorBadges: ["25 sec."],
maxGenerationCount: 4,
defaultGenerationCount: 4,
canUseImagePrompt: true,
@@ -52,8 +52,8 @@ export const IMAGE_MODELS: ImageModel[] = [
category: "image",
creator: ModelCreator.BlackForestLabs,
selectorName: "Flux 1 Dev",
selectorDescription: "High quality model",
selectorBadges: ["15 sec."],
selectorDescription: "Fast, but lower quality",
selectorBadges: ["10 sec."],
maxGenerationCount: 4,
defaultGenerationCount: 4,
canUseImagePrompt: true,
@@ -66,8 +66,8 @@ export const IMAGE_MODELS: ImageModel[] = [
category: "image",
creator: ModelCreator.BlackForestLabs,
selectorName: "Flux 1 Schnell",
selectorDescription: "High quality model",
selectorBadges: ["15 sec."],
selectorDescription: "Fastest image gen, but lowest quality",
selectorBadges: ["10 sec."],
maxGenerationCount: 4,
defaultGenerationCount: 4,
canUseImagePrompt: true,
@@ -79,14 +79,15 @@ export const IMAGE_MODELS: ImageModel[] = [
fullName: "Gemini 2.5 Flash",
category: "image",
creator: ModelCreator.Google,
selectorName: "Gemini 2.5 Flash",
selectorDescription: "Ultra instructive model",
selectorBadges: ["35 sec."],
selectorName: "Nano Banana",
selectorDescription: "Incredibly instructive editing",
selectorBadges: ["25 sec."],
maxGenerationCount: 4,
defaultGenerationCount: 1,
canEditImages: true,
canUseImagePrompt: true,
maxImagePromptCount: 6,
canTextToImage: false,
tags: [ModelTag.InstructiveEdit],
}),
new ImageModel({
@@ -96,8 +97,8 @@ export const IMAGE_MODELS: ImageModel[] = [
category: "image",
creator: ModelCreator.OpenAi,
selectorName: "GPT Image 1 (GPT-4o)",
selectorDescription: "Slow, ultra instructive model",
selectorBadges: ["45 sec."],
selectorDescription: "Slow, but super smart",
selectorBadges: ["60 sec."],
maxGenerationCount: 1,
defaultGenerationCount: 1,
tags: [ModelTag.InstructiveEdit],
@@ -117,6 +118,7 @@ export const IMAGE_MODELS: ImageModel[] = [
maxGenerationCount: 4,
defaultGenerationCount: 4,
canEditImages: true,
canTextToImage: false,
tags: [ModelTag.InstructiveEdit],
}),
new ImageModel({
@@ -132,6 +134,7 @@ export const IMAGE_MODELS: ImageModel[] = [
defaultGenerationCount: 1, // NB: Fal only allows one image for some reason!
canEditImages: true,
usesInpaintingMask: true,
canTextToImage: false,
}),
new ImageModel({
id: "flux_dev_juggernaut_inpaint",
@@ -140,12 +143,13 @@ export const IMAGE_MODELS: ImageModel[] = [
category: "image",
creator: ModelCreator.BlackForestLabs,
selectorName: "Flux Dev Juggernaut",
selectorDescription: "Fast inpainting",
selectorBadges: ["15 sec."],
selectorDescription: "Fast inpainting, low quality",
selectorBadges: ["10 sec."],
maxGenerationCount: 4,
defaultGenerationCount: 4,
canEditImages: true,
usesInpaintingMask: true,
canTextToImage: false,
}),
];
+2
View File
@@ -20,6 +20,8 @@ export * from "./lib/openai/GetOpenAIApiKey.js";
export * from "./lib/openai/SetOpenAIApiKey.js";
export * from "./lib/providers/GetProviderOrder.js";
export * from "./lib/providers/Provider.js";
export * from "./lib/artcraft/task_queue/GetTaskQueue.js";
export * from "./lib/artcraft/task_queue/MarkTaskAsDismissed.js";
export * from "./lib/providers/SetProviderOrder.js";
export * from "./lib/settings/GetAppPreferences.js";
export * from "./lib/settings/UpdateAppPreference.js";
@@ -0,0 +1,55 @@
import { invoke } from "@tauri-apps/api/core";
import { CommandResult } from "../../common/CommandStatus";
import { TaskStatus, TaskType, TaskModelType, GenerationProvider } from "@storyteller/api-enums";
interface GetTaskQueueResponse {
tasks: TaskQueueItem[],
}
export interface TaskQueueItem {
id: string,
task_status: TaskStatus,
task_type: TaskType,
model_type?: TaskModelType,
provider?: GenerationProvider,
provider_job_id?: string,
created_at: Date,
updated_at: Date,
completed_at?: Date,
}
interface GetTaskQueueSuccess extends CommandResult {
payload: GetTaskQueueResponse;
}
export const GetTaskQueue = async () : Promise<GetTaskQueueResponse> => {
try {
const result = await invoke("get_task_queue_command") as GetTaskQueueSuccess;
const tasks = result?.payload?.tasks || [];
// Convert timestamps to Date objects
const newTasks : TaskQueueItem[] = tasks.map((task) => {
return {
id: task.id,
task_status: task.task_status,
task_type: task.task_type,
model_type: task.model_type,
provider: task.provider,
provider_job_id: task.provider_job_id,
created_at: new Date(task.created_at),
updated_at: new Date(task.updated_at),
completed_at: task.completed_at ? new Date(task.completed_at) : undefined,
}
});
return {
tasks: newTasks,
};
} catch (error) {
throw error;
}
}
// Temporary just for testing -
(window as any).test_get_task_queue = GetTaskQueue;
@@ -0,0 +1,22 @@
import { invoke } from "@tauri-apps/api/core";
import { CommandResult } from "../../common/CommandStatus";
interface MarkTaskAsDismissedSuccess extends CommandResult {
payload: void;
}
export const MarkTaskAsDismissed = async (task_id: string) : Promise<MarkTaskAsDismissedSuccess> => {
try {
let result = await invoke("mark_task_as_dismissed_command", {
request: {
task: task_id
}
}) as MarkTaskAsDismissedSuccess;
return result;
} catch (error) {
throw error;
}
}
// Temporary just for testing -
(window as any).test_mark_task_as_dismissed = MarkTaskAsDismissed;
+3
View File
@@ -10,6 +10,9 @@
},
{
"path": "../model-list"
},
{
"path": "../api-enums"
}
],
"extends": "../../tsconfig.base.json"
@@ -48,6 +48,9 @@
"references": [
{
"path": "../model-list/tsconfig.lib.json"
},
{
"path": "../api-enums/tsconfig.lib.json"
}
]
}
+13 -2
View File
@@ -7,7 +7,13 @@ import * as path from 'path';
export default defineConfig(() => ({
root: __dirname,
cacheDir: '../../node_modules/.vite/libs/tauri-events',
plugins: [react(), dts({ entryRoot: 'src', tsconfigPath: path.join(__dirname, 'tsconfig.lib.json') })],
plugins: [
react(),
dts({
entryRoot: 'src',
tsconfigPath: path.join(__dirname, 'tsconfig.lib.json')
})
],
// Uncomment this if you are using workers.
// worker: {
// plugins: [ nxViteTsPaths() ],
@@ -32,7 +38,12 @@ export default defineConfig(() => ({
},
rollupOptions: {
// External packages that should not be bundled into your library.
external: ['react','react-dom','react/jsx-runtime']
external: [
'react',
'react-dom',
'react/jsx-runtime',
'react-hot-toast',
]
},
},
test: {
+346 -166
View File
File diff suppressed because it is too large Load Diff
+9 -6
View File
@@ -39,6 +39,12 @@
{
"path": "./libs/components/tooltip"
},
{
"path": "./libs/tauri-api"
},
{
"path": "./libs/tauri-events"
},
{
"path": "./libs/tauri-utils"
},
@@ -78,9 +84,6 @@
{
"path": "./libs/common"
},
{
"path": "./libs/tauri-api"
},
{
"path": "./libs/components/login-modal"
},
@@ -150,9 +153,6 @@
{
"path": "./libs/analytics/google-analytics"
},
{
"path": "./libs/tauri-events"
},
{
"path": "./libs/components/provider-setup-modal"
},
@@ -170,6 +170,9 @@
},
{
"path": "./libs/components/tutorial-modal"
},
{
"path": "./libs/api-enums"
}
]
}
+29
View File
@@ -0,0 +1,29 @@
#!/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 Frontend in Production Build + Preview Mode... (It won't hot reload!)"
echo ""
echo "You'll need to launch the Rust dev server as a second script!"
echo ""
# Kill any process running on port 5741, which will block startup
if lsof -i tcp:5173 &>/dev/null; then
lsof -i tcp:5173 -t | xargs kill -9
echo "Killed process running on port 5173"
else
echo "No process running on port 5173"
fi
pushd "${frontend_path}" || exit
npm install --verbose
export VITE_ENVIRONMENT_TYPE="production"
nx production-preview editor3d
popd || exit