Compare commits

..
Author SHA1 Message Date
Brandon Thomas de0776009b more query refactoring, media file class 2025-10-15 02:45:49 -04:00
Brandon Thomas 11f8885bf6 error for non-loading images 2025-10-15 01:39:35 -04:00
Brandon Thomas 0e2926b179 populate video thumbnail template 2025-10-15 01:22:10 -04:00
Brandon Thomas 671e02edcb refactor fields 2025-10-15 01:00:28 -04:00
Brandon Thomas 10f8649089 frontend cleanup 2025-10-15 00:35:32 -04:00
Brandon Thomas 3aeea6d2b2 report cdn url 2025-10-15 00:25:44 -04:00
Brandon Thomas abccceb5bd add full cdn url too 2025-10-15 00:09:51 -04:00
Brandon Thomas 9add7ee1cd work in progress on task queue api 2025-10-14 23:51:20 -04:00
BFlat d551e48a23 sound toggle for veo models (#877)
- veo models if selected now has a sound toggle button which defaults to
ON/true (still needs backend link)
2025-10-14 23:30:26 +07:00
BFlat 5cb608fad6 lightbox updates (#876)
- colors will match selected theme now
- buttons relayout and added "turn into video" button for images that
will take you to the Image to Video page
- reference image matches imapge prompt row component now - opens full
preview in a separate resizable modal
2025-10-14 23:15:32 +07:00
BFlat d5ae28ca4c task queue dropdown and modal (#852)
task queue implementation
2025-10-14 00:11:40 +07:00
Brandon Thomas 8313558795 debugging for inference-job loop 2025-10-13 11:03:50 -04:00
Brandon Thomas 95173e032f Remove FAL from desktop app (for now) - build speed issue (#875)
Fal is causing really inflated build slowdowns on account of the library
using so much Serde/proc macros. We really need to rethink FAL client
workings before adding it.
2025-10-10 19:31:44 -04:00
Brandon Thomas b0f6551623 fix job builds (#873) 2025-10-10 19:00:22 -04:00
106 changed files with 1818 additions and 2750 deletions
@@ -1,6 +1,6 @@
{
"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 ",
"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 on_complete_primary_media_file_token,\n on_complete_primary_media_file_class,\n on_complete_batch_token,\n on_complete_primary_media_file_cdn_url,\n on_complete_primary_media_file_thumbnail_url_template,\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": [
{
@@ -49,18 +49,43 @@
"type_info": "Text"
},
{
"name": "created_at: DateTime<Utc>",
"name": "on_complete_primary_media_file_token",
"ordinal": 9,
"type_info": "Text"
},
{
"name": "on_complete_primary_media_file_class",
"ordinal": 10,
"type_info": "Text"
},
{
"name": "on_complete_batch_token",
"ordinal": 11,
"type_info": "Text"
},
{
"name": "on_complete_primary_media_file_cdn_url",
"ordinal": 12,
"type_info": "Text"
},
{
"name": "on_complete_primary_media_file_thumbnail_url_template",
"ordinal": 13,
"type_info": "Text"
},
{
"name": "created_at: DateTime<Utc>",
"ordinal": 14,
"type_info": "Int64"
},
{
"name": "updated_at: DateTime<Utc>",
"ordinal": 10,
"ordinal": 15,
"type_info": "Int64"
},
{
"name": "completed_at: DateTime<Utc>",
"ordinal": 11,
"ordinal": 16,
"type_info": "Int64"
}
],
@@ -77,10 +102,15 @@
true,
true,
true,
true,
true,
true,
true,
true,
false,
false,
true
]
},
"hash": "a142dc3fdf5b6e741c268d8f2e9996a28c214f5835681297d58a9431da8de939"
"hash": "47f94c127abc20683b25c257aed3c2190b9bd29eb829d61392f08e10fd986767"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n UPDATE tasks\n SET\n task_status = ?,\n on_complete_batch_token = ?,\n on_complete_primary_media_file_token = ?,\n on_complete_primary_media_file_class = ?,\n on_complete_primary_media_file_cdn_url = ?,\n on_complete_primary_media_file_thumbnail_url_template = ?\n WHERE id = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 7
},
"nullable": []
},
"hash": "50a2449b87abed7df3302a74ecd0e8ab87940077a247636680cb09b2fe488321"
}
Generated
-3
View File
@@ -660,7 +660,6 @@ dependencies = [
"discord-rich-presence",
"enums",
"errors",
"fal_client",
"filesys",
"futures 0.3.31",
"idempotency",
@@ -5573,7 +5572,6 @@ dependencies = [
"mimetypes",
"mysql_queries",
"once_cell",
"openai_sora_client",
"opentelemetry",
"opentelemetry-otlp",
"opentelemetry_sdk",
@@ -5586,7 +5584,6 @@ dependencies = [
"serde",
"serde_derive",
"serde_json",
"shared_service_components",
"sqlx",
"subprocess",
"subprocess_common",
@@ -10,6 +10,13 @@
-- tasks_v2.sqlite - added model_type (nullable)
-- tasks_v3.sqlite - added frontend_caller, comments
-- tasks_v4.sqlite - added is_dismissed_by_user
-- tasks_v5.sqlite - added five fields:
-- on_complete_batch_token,
-- on_complete_primary_media_file_token,
-- on_complete_primary_media_file_class,
-- on_complete_primary_media_file_cdn_url,
-- on_complete_primary_media_file_thumbnail_url_template,
--
CREATE TABLE tasks (
-- Task auto-incrementing primary key.
@@ -54,6 +61,29 @@ CREATE TABLE tasks (
-- Whether the user has dismissed the task from view.
is_dismissed_by_user INTEGER NOT NULL DEFAULT 0,
-- OPTIONAL.
-- When the generation completes, the batch token (if any).
-- This will point to a collection of multiple files.
on_complete_batch_token TEXT,
-- OPTIONAL.
-- When the generation completes, the media token of the "primary" media file.
-- We'll probably always generate homogenous types, but just in case this is the first or primary item in a batch.
on_complete_primary_media_file_token TEXT,
-- OPTIONAL.
-- When the generation completes, the file type of the primary media file.
-- We'll probably always generate homogenous types, but just in case this is the first or primary item in a batch.
on_complete_primary_media_file_class TEXT,
-- OPTIONAL.
-- The URL to the media file.
on_complete_primary_media_file_cdn_url TEXT,
-- OPTIONAL.
-- The URL template for the thumbnail of the primary media file.
on_complete_primary_media_file_thumbnail_url_template TEXT,
created_at INTEGER NOT NULL DEFAULT (unixepoch('now')),
updated_at INTEGER NOT NULL DEFAULT (unixepoch('now')),
completed_at INTEGER DEFAULT NULL
-1
View File
@@ -31,7 +31,6 @@ build_metadata.workspace = true
cookie_store.workspace = true
enums.workspace = true
errors.workspace = true
fal_client.workspace = true
filesys.workspace = true
idempotency.workspace = true
images.workspace = true
@@ -1,10 +1,10 @@
use crate::core::artcraft_error::ArtcraftError;
use base64::DecodeError;
use errors::AnyhowError;
use fal_client::error::fal_error_plus::FalErrorPlus;
use midjourney_client::error::midjourney_error::MidjourneyError;
use openai_sora_client::error::sora_error::SoraError;
use storyteller_client::error::storyteller_error::StorytellerError;
//use fal_client::error::fal_error_plus::FalErrorPlus;
#[derive(Debug)]
pub enum GenerateError {
@@ -14,6 +14,9 @@ pub enum GenerateError {
/// We couldn't find a provider to dispatch the request to.
NoProviderAvailable,
/// We pulled out Fal (for now) - it's impacting build speeds. We'll add it in the future.
FalNoLongerSupported,
/// There was a billing, credits, or payments issue.
BillingIssue(BillingIssueReason),
@@ -73,7 +76,7 @@ pub enum BillingProvider {
#[derive(Debug)]
pub enum ProviderFailureReason {
Fal(FalErrorPlus),
//Fal(FalErrorPlus),
MidjourneyError(MidjourneyError),
/// NB: The midjourney client doesn't categorize all errors, so we have to do so on our end.
MidjourneyJobEnqueueFailed,
@@ -141,11 +144,11 @@ impl From<ArtcraftError> for GenerateError {
}
}
impl From<FalErrorPlus> for GenerateError {
fn from(value: FalErrorPlus) -> Self {
Self::ProviderFailure(ProviderFailureReason::Fal(value))
}
}
//impl From<FalErrorPlus> for GenerateError {
// fn from(value: FalErrorPlus) -> Self {
// Self::ProviderFailure(ProviderFailureReason::Fal(value))
// }
//}
impl From<MidjourneyError> for GenerateError {
fn from(value: MidjourneyError) -> Self {
@@ -7,6 +7,7 @@ use crate::core::commands::response::failure_response_wrapper::{CommandErrorResp
use crate::core::commands::response::shorthand::{Response, ResponseOrErrorType};
use crate::core::commands::response::success_response_wrapper::SerializeMarker;
use crate::core::events::basic_sendable_event_trait::BasicSendableEvent;
use crate::core::events::functional_events::credits_balance_changed_event::CreditsBalanceChangedEvent;
use crate::core::events::generation_events::common::{GenerationAction, GenerationServiceProvider};
use crate::core::events::generation_events::generation_enqueue_failure_event::GenerationEnqueueFailureEvent;
use crate::core::events::generation_events::generation_enqueue_success_event::GenerationEnqueueSuccessEvent;
@@ -18,8 +19,6 @@ use crate::core::state::provider_priority::{Provider, ProviderPriorityStore};
use crate::core::state::task_database::TaskDatabase;
use crate::core::utils::get_url_file_extension::get_url_file_extension;
use crate::core::utils::simple_http_download::simple_http_download;
use crate::services::fal::state::fal_credential_manager::FalCredentialManager;
use crate::services::fal::state::fal_task_queue::FalTaskQueue;
use crate::services::sora::state::sora_credential_manager::SoraCredentialManager;
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
@@ -35,12 +34,11 @@ use openai_sora_client::requests::image_gen::common::{ImageSize, NumImages};
use serde_derive::{Deserialize, Serialize};
use sqlite_tasks::queries::create_task::{create_task, CreateTaskArgs};
use std::time::Duration;
use storyteller_client::error::storyteller_error::StorytellerError;
use storyteller_client::endpoints::media_files::get_media_file::get_media_file;
use storyteller_client::error::storyteller_error::StorytellerError;
use storyteller_client::utils::api_host::ApiHost;
use tauri::{AppHandle, Manager, State};
use tokens::tokens::media_files::MediaFileToken;
use crate::core::events::functional_events::credits_balance_changed_event::CreditsBalanceChangedEvent;
#[derive(Deserialize, Debug)]
pub struct EnqueueImageBgRemovalCommand {
@@ -103,8 +101,6 @@ pub async fn enqueue_image_bg_removal_command(
provider_priority_store: State<'_, ProviderPriorityStore>,
task_database: State<'_, TaskDatabase>,
storyteller_creds_manager: State<'_, StorytellerCredentialManager>,
fal_creds_manager: State<'_, FalCredentialManager>,
fal_task_queue: State<'_, FalTaskQueue>,
) -> ResponseOrErrorType<EnqueueImageBgRemovalSuccessResponse, EnqueueImageBgRemovalErrorType> {
info!("enqueue_image_bg_removal_command called");
@@ -117,8 +113,6 @@ pub async fn enqueue_image_bg_removal_command(
&provider_priority_store,
&task_database,
&storyteller_creds_manager,
&fal_creds_manager,
&fal_task_queue,
).await;
match result {
@@ -167,8 +161,6 @@ pub async fn handle_request(
provider_priority_store: &ProviderPriorityStore,
task_database: &TaskDatabase,
storyteller_creds_manager: &StorytellerCredentialManager,
fal_creds_manager: &FalCredentialManager,
fal_task_queue: &FalTaskQueue,
) -> Result<TaskEnqueueSuccess, GenerateError> {
// TODO(bt,2025-07-07): Other model/provider routing...
@@ -180,8 +172,6 @@ pub async fn handle_request(
app_env_configs,
provider_priority_store,
storyteller_creds_manager,
fal_creds_manager,
fal_task_queue,
).await?;
let result = success_event
@@ -9,8 +9,6 @@ use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
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::services::fal::state::fal_credential_manager::FalCredentialManager;
use crate::services::fal::state::fal_task_queue::FalTaskQueue;
use crate::services::sora::state::sora_credential_manager::SoraCredentialManager;
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
@@ -24,8 +22,6 @@ pub async fn handle_generic_bg_removal(
app_env_configs: &AppEnvConfigs,
provider_priority_store: &ProviderPriorityStore,
storyteller_creds_manager: &StorytellerCredentialManager,
fal_creds_manager: &FalCredentialManager,
fal_task_queue: &FalTaskQueue,
) -> Result<TaskEnqueueSuccess, GenerateError> {
let priority = provider_priority_store.get_priority()?;
@@ -57,8 +53,6 @@ pub async fn handle_generic_bg_removal(
app,
app_data_root,
app_env_configs,
fal_creds_manager,
fal_task_queue,
).await;
}
}
@@ -9,8 +9,6 @@ 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::ProviderPriorityStore;
use crate::core::utils::save_base64_image_to_temp_dir::save_base64_image_to_temp_dir;
use crate::services::fal::state::fal_credential_manager::FalCredentialManager;
use crate::services::fal::state::fal_task_queue::FalTaskQueue;
use crate::services::sora::state::sora_credential_manager::SoraCredentialManager;
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
@@ -23,7 +21,6 @@ use artcraft_api_defs::generate::image::generate_flux_pro_11_ultra_text_to_image
use artcraft_api_defs::generate::image::remove_image_background::RemoveImageBackgroundRequest;
use enums::common::generation_provider::GenerationProvider;
use enums::tauri::tasks::task_type::TaskType;
use fal_client::requests::queue::image_gen::enqueue_flux_pro_11_ultra_text_to_image::{enqueue_flux_pro_11_ultra_text_to_image, FluxPro11UltraTextToImageArgs};
use idempotency::uuid::generate_random_uuid;
use log::{error, info};
use storyteller_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
@@ -3,8 +3,6 @@ use crate::core::commands::enqueue::image_bg_removal::enqueue_image_bg_removal_c
use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::services::fal::state::fal_credential_manager::FalCredentialManager;
use crate::services::fal::state::fal_task_queue::FalTaskQueue;
use tauri::AppHandle;
pub async fn handle_generic_bg_removal_fal(
@@ -12,8 +10,6 @@ pub async fn handle_generic_bg_removal_fal(
app: &AppHandle,
app_data_root: &AppDataRoot,
app_env_configs: &AppEnvConfigs,
fal_creds_manager: &FalCredentialManager,
fal_task_queue: &FalTaskQueue,
) -> Result<TaskEnqueueSuccess, GenerateError> {
Err(GenerateError::NotYetImplemented("TODO: Implement fal background removal handling".to_string()))
}
@@ -20,8 +20,6 @@ use crate::core::state::provider_priority::ProviderPriorityStore;
use crate::core::state::task_database::TaskDatabase;
use crate::core::utils::get_url_file_extension::get_url_file_extension;
use crate::core::utils::simple_http_download::simple_http_download;
use crate::services::fal::state::fal_credential_manager::FalCredentialManager;
use crate::services::fal::state::fal_task_queue::FalTaskQueue;
use crate::services::sora::state::sora_credential_manager::SoraCredentialManager;
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
@@ -169,8 +167,6 @@ pub async fn enqueue_contextual_edit_image_command(
provider_priority_store: State<'_, ProviderPriorityStore>,
task_database: State<'_, TaskDatabase>,
storyteller_creds_manager: State<'_, StorytellerCredentialManager>,
fal_creds_manager: State<'_, FalCredentialManager>,
fal_task_queue: State<'_, FalTaskQueue>,
sora_creds_manager: State<'_, SoraCredentialManager>,
sora_task_queue: State<'_, SoraTaskQueue>,
) -> ResponseOrErrorType<EnqueueContextualEditImageSuccessResponse, EnqueueContextualEditImageErrorType> {
@@ -186,8 +182,6 @@ pub async fn enqueue_contextual_edit_image_command(
&provider_priority_store,
&task_database,
&storyteller_creds_manager,
&fal_creds_manager,
&fal_task_queue,
&sora_creds_manager,
&sora_task_queue,
).await;
@@ -238,8 +232,6 @@ pub async fn handle_request(
provider_priority_store: &ProviderPriorityStore,
task_database: &TaskDatabase,
storyteller_creds_manager: &StorytellerCredentialManager,
fal_creds_manager: &FalCredentialManager,
fal_task_queue: &FalTaskQueue,
sora_creds_manager: &SoraCredentialManager,
sora_task_queue: &SoraTaskQueue,
) -> Result<TaskEnqueueSuccess, GenerateError> {
@@ -255,8 +247,6 @@ pub async fn handle_request(
app_env_configs,
provider_priority_store,
storyteller_creds_manager,
fal_creds_manager,
fal_task_queue,
sora_creds_manager,
sora_task_queue,
).await?
@@ -269,8 +259,6 @@ pub async fn handle_request(
app_env_configs,
provider_priority_store,
storyteller_creds_manager,
fal_creds_manager,
fal_task_queue,
sora_creds_manager,
sora_task_queue,
).await?
@@ -283,8 +271,6 @@ pub async fn handle_request(
app_env_configs,
provider_priority_store,
storyteller_creds_manager,
fal_creds_manager,
fal_task_queue,
sora_creds_manager,
sora_task_queue,
).await?
@@ -7,8 +7,6 @@ use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
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::services::fal::state::fal_credential_manager::FalCredentialManager;
use crate::services::fal::state::fal_task_queue::FalTaskQueue;
use crate::services::sora::state::sora_credential_manager::SoraCredentialManager;
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
@@ -22,8 +20,6 @@ pub async fn handle_flux_kontext_edit(
app_env_configs: &AppEnvConfigs,
provider_priority_store: &ProviderPriorityStore,
storyteller_creds_manager: &StorytellerCredentialManager,
fal_creds_manager: &FalCredentialManager,
fal_task_queue: &FalTaskQueue,
sora_creds_manager: &SoraCredentialManager,
sora_task_queue: &SoraTaskQueue,
) -> Result<TaskEnqueueSuccess, GenerateError> {
@@ -7,8 +7,6 @@ use crate::core::events::generation_events::generation_enqueue_failure_event::Ge
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::ProviderPriorityStore;
use crate::services::fal::state::fal_credential_manager::FalCredentialManager;
use crate::services::fal::state::fal_task_queue::FalTaskQueue;
use crate::services::sora::state::sora_credential_manager::SoraCredentialManager;
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
@@ -21,8 +19,6 @@ use artcraft_api_defs::generate::image::generate_flux_pro_11_text_to_image::Gene
use artcraft_api_defs::generate::image::generate_flux_pro_11_ultra_text_to_image::GenerateFluxPro11UltraTextToImageRequest;
use enums::common::generation_provider::GenerationProvider;
use enums::tauri::tasks::task_type::TaskType;
use fal_client::requests::queue::image_gen::enqueue_flux_pro_11_ultra_text_to_image::{enqueue_flux_pro_11_ultra_text_to_image, FluxPro11UltraTextToImageArgs};
use fal_client::requests::webhook::image::edit::enqueue_flux_pro_kontext_max_edit_webhook::FluxProKontextMaxNumImages;
use idempotency::uuid::generate_random_uuid;
use log::{error, info};
use storyteller_client::endpoints::generate::image::edit::flux_pro_kontext_max_edit_image::flux_pro_kontext_max_edit_image;
@@ -7,8 +7,6 @@ use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
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::services::fal::state::fal_credential_manager::FalCredentialManager;
use crate::services::fal::state::fal_task_queue::FalTaskQueue;
use crate::services::sora::state::sora_credential_manager::SoraCredentialManager;
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
@@ -22,8 +20,6 @@ pub async fn handle_gemini_25_flash_edit(
app_env_configs: &AppEnvConfigs,
provider_priority_store: &ProviderPriorityStore,
storyteller_creds_manager: &StorytellerCredentialManager,
fal_creds_manager: &FalCredentialManager,
fal_task_queue: &FalTaskQueue,
sora_creds_manager: &SoraCredentialManager,
sora_task_queue: &SoraTaskQueue,
) -> Result<TaskEnqueueSuccess, GenerateError> {
@@ -7,8 +7,6 @@ use crate::core::events::generation_events::generation_enqueue_failure_event::Ge
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::ProviderPriorityStore;
use crate::services::fal::state::fal_credential_manager::FalCredentialManager;
use crate::services::fal::state::fal_task_queue::FalTaskQueue;
use crate::services::sora::state::sora_credential_manager::SoraCredentialManager;
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
@@ -6,8 +6,6 @@ use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
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::services::fal::state::fal_credential_manager::FalCredentialManager;
use crate::services::fal::state::fal_task_queue::FalTaskQueue;
use crate::services::sora::state::sora_credential_manager::SoraCredentialManager;
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
@@ -23,8 +21,6 @@ pub async fn handle_gpt_image_1_edit(
app_env_configs: &AppEnvConfigs,
provider_priority_store: &ProviderPriorityStore,
storyteller_creds_manager: &StorytellerCredentialManager,
fal_creds_manager: &FalCredentialManager,
fal_task_queue: &FalTaskQueue,
sora_creds_manager: &SoraCredentialManager,
sora_task_queue: &SoraTaskQueue,
) -> Result<TaskEnqueueSuccess, GenerateError> {
@@ -8,8 +8,6 @@ use crate::core::events::generation_events::generation_enqueue_failure_event::Ge
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::ProviderPriorityStore;
use crate::services::fal::state::fal_credential_manager::FalCredentialManager;
use crate::services::fal::state::fal_task_queue::FalTaskQueue;
use crate::services::sora::state::sora_credential_manager::SoraCredentialManager;
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
@@ -21,8 +19,6 @@ use artcraft_api_defs::generate::image::generate_flux_pro_11_text_to_image::Gene
use artcraft_api_defs::generate::image::generate_flux_pro_11_ultra_text_to_image::GenerateFluxPro11UltraTextToImageRequest;
use enums::common::generation_provider::GenerationProvider;
use enums::tauri::tasks::task_type::TaskType;
use fal_client::requests::queue::image_gen::enqueue_flux_pro_11_ultra_text_to_image::{enqueue_flux_pro_11_ultra_text_to_image, FluxPro11UltraTextToImageArgs};
use fal_client::requests::webhook::image::edit::enqueue_gpt_image_1_edit_image_webhook::GptEditImageQuality;
use idempotency::uuid::generate_random_uuid;
use log::{error, info};
use storyteller_client::endpoints::generate::image::edit::gpt_image_1_edit_image::gpt_image_1_edit_image;
@@ -14,8 +14,6 @@ use crate::core::state::data_dir::trait_data_subdir::DataSubdir;
use crate::core::state::provider_priority::ProviderPriorityStore;
use crate::core::utils::get_url_file_extension::get_url_file_extension;
use crate::core::utils::simple_http_download::simple_http_download;
use crate::services::fal::state::fal_credential_manager::FalCredentialManager;
use crate::services::fal::state::fal_task_queue::FalTaskQueue;
use crate::services::sora::state::sora_credential_manager::SoraCredentialManager;
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
@@ -27,7 +25,6 @@ use artcraft_api_defs::generate::image::generate_flux_pro_11_ultra_text_to_image
use enums::common::generation_provider::GenerationProvider;
use enums::tauri::tasks::task_type::TaskType;
use errors::AnyhowResult;
use fal_client::requests::queue::image_gen::enqueue_flux_pro_11_ultra_text_to_image::{enqueue_flux_pro_11_ultra_text_to_image, FluxPro11UltraTextToImageArgs};
use idempotency::uuid::generate_random_uuid;
use log::{error, info, warn};
use openai_sora_client::recipes::image_remix_with_session_auto_renew::{image_remix_with_session_auto_renew, ImageRemixAutoRenewRequest};
@@ -22,8 +22,6 @@ use crate::core::state::provider_priority::ProviderPriorityStore;
use crate::core::state::task_database::TaskDatabase;
use crate::core::utils::get_url_file_extension::get_url_file_extension;
use crate::core::utils::simple_http_download::simple_http_download;
use crate::services::fal::state::fal_credential_manager::FalCredentialManager;
use crate::services::fal::state::fal_task_queue::FalTaskQueue;
use crate::services::sora::state::sora_credential_manager::SoraCredentialManager;
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
@@ -40,8 +38,8 @@ use openai_sora_client::requests::image_gen::common::{ImageSize, NumImages};
use serde_derive::{Deserialize, Serialize};
use sqlite_tasks::queries::create_task::{create_task, CreateTaskArgs};
use std::time::Duration;
use storyteller_client::error::storyteller_error::StorytellerError;
use storyteller_client::endpoints::media_files::get_media_file::get_media_file;
use storyteller_client::error::storyteller_error::StorytellerError;
use storyteller_client::utils::api_host::ApiHost;
use tauri::{AppHandle, Manager, State};
use tokens::tokens::media_files::MediaFileToken;
@@ -158,8 +156,6 @@ pub async fn enqueue_image_inpaint_command(
provider_priority_store: State<'_, ProviderPriorityStore>,
task_database: State<'_, TaskDatabase>,
storyteller_creds_manager: State<'_, StorytellerCredentialManager>,
fal_creds_manager: State<'_, FalCredentialManager>,
fal_task_queue: State<'_, FalTaskQueue>,
sora_creds_manager: State<'_, SoraCredentialManager>,
sora_task_queue: State<'_, SoraTaskQueue>,
) -> ResponseOrErrorType<EnqueueImageInpaintSuccessResponse, EnqueueInpaintImageErrorType> {
@@ -175,8 +171,6 @@ pub async fn enqueue_image_inpaint_command(
&provider_priority_store,
&task_database,
&storyteller_creds_manager,
&fal_creds_manager,
&fal_task_queue,
&sora_creds_manager,
&sora_task_queue,
).await;
@@ -227,8 +221,6 @@ pub async fn handle_request(
provider_priority_store: &ProviderPriorityStore,
task_database: &TaskDatabase,
storyteller_creds_manager: &StorytellerCredentialManager,
fal_creds_manager: &FalCredentialManager,
fal_task_queue: &FalTaskQueue,
sora_creds_manager: &SoraCredentialManager,
sora_task_queue: &SoraTaskQueue,
) -> Result<TaskEnqueueSuccess, GenerateError> {
@@ -244,8 +236,6 @@ pub async fn handle_request(
app_env_configs,
provider_priority_store,
storyteller_creds_manager,
fal_creds_manager,
fal_task_queue,
sora_creds_manager,
sora_task_queue,
).await?
@@ -258,8 +248,6 @@ pub async fn handle_request(
app_env_configs,
provider_priority_store,
storyteller_creds_manager,
fal_creds_manager,
fal_task_queue,
sora_creds_manager,
sora_task_queue,
).await?
@@ -273,8 +261,6 @@ pub async fn handle_request(
app_env_configs,
provider_priority_store,
storyteller_creds_manager,
fal_creds_manager,
fal_task_queue,
sora_creds_manager,
sora_task_queue,
).await?
@@ -288,8 +274,6 @@ pub async fn handle_request(
app_env_configs,
provider_priority_store,
storyteller_creds_manager,
fal_creds_manager,
fal_task_queue,
sora_creds_manager,
sora_task_queue,
).await?
@@ -8,8 +8,6 @@ use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
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::services::fal::state::fal_credential_manager::FalCredentialManager;
use crate::services::fal::state::fal_task_queue::FalTaskQueue;
use crate::services::sora::state::sora_credential_manager::SoraCredentialManager;
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
@@ -23,8 +21,6 @@ pub async fn handle_flux_dev_juggernaut_inpaint(
app_env_configs: &AppEnvConfigs,
provider_priority_store: &ProviderPriorityStore,
storyteller_creds_manager: &StorytellerCredentialManager,
fal_creds_manager: &FalCredentialManager,
fal_task_queue: &FalTaskQueue,
sora_creds_manager: &SoraCredentialManager,
sora_task_queue: &SoraTaskQueue,
) -> Result<TaskEnqueueSuccess, GenerateError> {
@@ -10,8 +10,6 @@ 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::ProviderPriorityStore;
use crate::core::utils::save_base64_image_to_temp_dir::save_base64_image_to_temp_dir;
use crate::services::fal::state::fal_credential_manager::FalCredentialManager;
use crate::services::fal::state::fal_task_queue::FalTaskQueue;
use crate::services::sora::state::sora_credential_manager::SoraCredentialManager;
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
@@ -24,7 +22,6 @@ use artcraft_api_defs::generate::image::generate_flux_pro_11_ultra_text_to_image
use artcraft_api_defs::generate::image::inpaint::flux_dev_juggernaut_inpaint_image::{FluxDevJuggernautInpaintImageNumImages, FluxDevJuggernautInpaintImageRequest};
use enums::common::generation_provider::GenerationProvider;
use enums::tauri::tasks::task_type::TaskType;
use fal_client::requests::queue::image_gen::enqueue_flux_pro_11_ultra_text_to_image::{enqueue_flux_pro_11_ultra_text_to_image, FluxPro11UltraTextToImageArgs};
use idempotency::uuid::generate_random_uuid;
use images::encoding::image_bytes_to_png_bytes::image_bytes_to_png_bytes;
use images::encoding::image_bytes_to_png_bytes_with_dimensions::image_bytes_to_png_bytes_with_dimensions;
@@ -8,8 +8,6 @@ use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
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::services::fal::state::fal_credential_manager::FalCredentialManager;
use crate::services::fal::state::fal_task_queue::FalTaskQueue;
use crate::services::sora::state::sora_credential_manager::SoraCredentialManager;
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
@@ -23,8 +21,6 @@ pub async fn handle_flux_pro_1_inpaint(
app_env_configs: &AppEnvConfigs,
provider_priority_store: &ProviderPriorityStore,
storyteller_creds_manager: &StorytellerCredentialManager,
fal_creds_manager: &FalCredentialManager,
fal_task_queue: &FalTaskQueue,
sora_creds_manager: &SoraCredentialManager,
sora_task_queue: &SoraTaskQueue,
) -> Result<TaskEnqueueSuccess, GenerateError> {
@@ -10,8 +10,6 @@ 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::ProviderPriorityStore;
use crate::core::utils::save_base64_image_to_temp_dir::save_base64_image_to_temp_dir;
use crate::services::fal::state::fal_credential_manager::FalCredentialManager;
use crate::services::fal::state::fal_task_queue::FalTaskQueue;
use crate::services::sora::state::sora_credential_manager::SoraCredentialManager;
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
@@ -24,7 +22,6 @@ use artcraft_api_defs::generate::image::generate_flux_pro_11_ultra_text_to_image
use artcraft_api_defs::generate::image::inpaint::flux_pro_1_inpaint_image::{FluxPro1InpaintImageNumImages, FluxPro1InpaintImageRequest};
use enums::common::generation_provider::GenerationProvider;
use enums::tauri::tasks::task_type::TaskType;
use fal_client::requests::queue::image_gen::enqueue_flux_pro_11_ultra_text_to_image::{enqueue_flux_pro_11_ultra_text_to_image, FluxPro11UltraTextToImageArgs};
use idempotency::uuid::generate_random_uuid;
use images::encoding::image_bytes_to_png_bytes::image_bytes_to_png_bytes;
use images::encoding::image_bytes_to_png_bytes_with_dimensions::image_bytes_to_png_bytes_with_dimensions;
@@ -6,8 +6,6 @@ use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
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::services::fal::state::fal_credential_manager::FalCredentialManager;
use crate::services::fal::state::fal_task_queue::FalTaskQueue;
use crate::services::sora::state::sora_credential_manager::SoraCredentialManager;
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
@@ -21,8 +19,6 @@ pub async fn handle_flux_pro_kontext_inpaint(
app_env_configs: &AppEnvConfigs,
provider_priority_store: &ProviderPriorityStore,
storyteller_creds_manager: &StorytellerCredentialManager,
fal_creds_manager: &FalCredentialManager,
fal_task_queue: &FalTaskQueue,
sora_creds_manager: &SoraCredentialManager,
sora_task_queue: &SoraTaskQueue,
) -> Result<TaskEnqueueSuccess, GenerateError> {
@@ -10,8 +10,6 @@ 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::ProviderPriorityStore;
use crate::core::utils::save_base64_image_to_temp_dir::save_base64_image_to_temp_dir;
use crate::services::fal::state::fal_credential_manager::FalCredentialManager;
use crate::services::fal::state::fal_task_queue::FalTaskQueue;
use crate::services::sora::state::sora_credential_manager::SoraCredentialManager;
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
@@ -25,7 +23,6 @@ use artcraft_api_defs::generate::image::generate_flux_pro_11_ultra_text_to_image
use artcraft_api_defs::generate::image::inpaint::flux_pro_1_inpaint_image::{FluxPro1InpaintImageNumImages, FluxPro1InpaintImageRequest};
use enums::common::generation_provider::GenerationProvider;
use enums::tauri::tasks::task_type::TaskType;
use fal_client::requests::queue::image_gen::enqueue_flux_pro_11_ultra_text_to_image::{enqueue_flux_pro_11_ultra_text_to_image, FluxPro11UltraTextToImageArgs};
use idempotency::uuid::generate_random_uuid;
use images::encoding::image_bytes_to_png_bytes::image_bytes_to_png_bytes;
use images::encoding::image_bytes_to_png_bytes_with_dimensions::image_bytes_to_png_bytes_with_dimensions;
@@ -7,8 +7,6 @@ use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
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::services::fal::state::fal_credential_manager::FalCredentialManager;
use crate::services::fal::state::fal_task_queue::FalTaskQueue;
use crate::services::sora::state::sora_credential_manager::SoraCredentialManager;
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
@@ -22,8 +20,6 @@ pub async fn handle_gemini_25_flash_inpaint(
app_env_configs: &AppEnvConfigs,
provider_priority_store: &ProviderPriorityStore,
storyteller_creds_manager: &StorytellerCredentialManager,
fal_creds_manager: &FalCredentialManager,
fal_task_queue: &FalTaskQueue,
sora_creds_manager: &SoraCredentialManager,
sora_task_queue: &SoraTaskQueue,
) -> Result<TaskEnqueueSuccess, GenerateError> {
@@ -10,8 +10,6 @@ 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::ProviderPriorityStore;
use crate::core::utils::save_base64_image_to_temp_dir::save_base64_image_to_temp_dir;
use crate::services::fal::state::fal_credential_manager::FalCredentialManager;
use crate::services::fal::state::fal_task_queue::FalTaskQueue;
use crate::services::sora::state::sora_credential_manager::SoraCredentialManager;
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
@@ -26,7 +24,6 @@ use artcraft_api_defs::generate::image::generate_flux_pro_11_ultra_text_to_image
use artcraft_api_defs::generate::image::inpaint::flux_pro_1_inpaint_image::{FluxPro1InpaintImageNumImages, FluxPro1InpaintImageRequest};
use enums::common::generation_provider::GenerationProvider;
use enums::tauri::tasks::task_type::TaskType;
use fal_client::requests::queue::image_gen::enqueue_flux_pro_11_ultra_text_to_image::{enqueue_flux_pro_11_ultra_text_to_image, FluxPro11UltraTextToImageArgs};
use idempotency::uuid::generate_random_uuid;
use images::encoding::image_bytes_to_png_bytes::image_bytes_to_png_bytes;
use images::encoding::image_bytes_to_png_bytes_with_dimensions::image_bytes_to_png_bytes_with_dimensions;
@@ -12,11 +12,8 @@ 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 fal_client::requests::queue::image_gen::enqueue_flux_pro_11_ultra_text_to_image::{enqueue_flux_pro_11_ultra_text_to_image, FluxPro11UltraTextToImageArgs};
use log::{error, info, warn};
use serde_derive::{Deserialize, Serialize};
use tauri::{AppHandle, State};
@@ -75,8 +72,6 @@ pub async fn enqueue_image_to_3d_object_command(
app_data_root: State<'_, AppDataRoot>,
provider_priority_store: State<'_, ProviderPriorityStore>,
task_database: State<'_, TaskDatabase>,
fal_creds_manager: State<'_, FalCredentialManager>,
fal_task_queue: State<'_, FalTaskQueue>,
storyteller_creds_manager: State<'_, StorytellerCredentialManager>,
sora_task_queue: State<'_, SoraTaskQueue>,
) -> Response<EnqueueImageTo3dObjectSuccessResponse, EnqueueImageTo3dObjectErrorType, ()> {
@@ -90,9 +85,7 @@ pub async fn enqueue_image_to_3d_object_command(
&app_data_root,
&provider_priority_store,
&task_database,
&fal_creds_manager,
&storyteller_creds_manager,
&fal_task_queue,
).await;
match result {
@@ -162,9 +155,7 @@ pub async fn handle_request(
app_data_root: &AppDataRoot,
provider_priority_store: &ProviderPriorityStore,
task_database: &TaskDatabase,
fal_creds_manager: &FalCredentialManager,
storyteller_creds_manager: &StorytellerCredentialManager,
fal_task_queue: &FalTaskQueue,
) -> Result<TaskEnqueueSuccess, GenerateError> {
let result = handle_object(
@@ -173,9 +164,7 @@ pub async fn handle_request(
&app_env_configs,
&app_data_root,
&provider_priority_store,
&fal_creds_manager,
&storyteller_creds_manager,
&fal_task_queue,
).await;
let success_event = match result {
@@ -1,3 +1,4 @@
use crate::core::commands::enqueue::generate_error::GenerateError;
use crate::core::commands::enqueue::image_to_object::enqueue_image_to_3d_object_command::EnqueueImageTo3dObjectRequest;
use crate::core::commands::enqueue::image_to_object::generic::handle_object_artcraft::handle_object_artcraft;
use crate::core::commands::enqueue::image_to_object::generic::handle_object_fal::handle_object_fal;
@@ -5,11 +6,8 @@ use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
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::services::fal::state::fal_credential_manager::FalCredentialManager;
use crate::services::fal::state::fal_task_queue::FalTaskQueue;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use tauri::AppHandle;
use crate::core::commands::enqueue::generate_error::GenerateError;
pub async fn handle_object(
request: EnqueueImageTo3dObjectRequest,
@@ -17,15 +15,14 @@ pub async fn handle_object(
app_env_configs: &AppEnvConfigs,
app_data_root: &AppDataRoot,
provider_priority_store: &ProviderPriorityStore,
fal_creds_manager: &FalCredentialManager,
storyteller_creds_manager: &StorytellerCredentialManager,
fal_task_queue: &FalTaskQueue,
) -> Result<TaskEnqueueSuccess, GenerateError> {
let priority = provider_priority_store.get_priority()?;
for provider in priority.iter() {
match provider {
Provider::Fal => {} // Fallthrough
Provider::Sora => {} // Fallthrough
Provider::Artcraft => {
return Ok(handle_object_artcraft(
@@ -36,18 +33,6 @@ pub async fn handle_object(
storyteller_creds_manager
).await?);
}
Provider::Fal => {
if fal_creds_manager.has_apparent_api_token()? {
return Ok(handle_object_fal(
&app,
app_env_configs,
app_data_root,
request,
fal_creds_manager,
fal_task_queue
).await?);
}
}
}
}
@@ -12,7 +12,6 @@ use artcraft_api_defs::generate::object::generate_hunyuan_2_0_image_to_3d::Gener
use artcraft_api_defs::generate::object::generate_hunyuan_2_1_image_to_3d::GenerateHunyuan21ImageTo3dRequest;
use enums::common::generation_provider::GenerationProvider;
use enums::tauri::tasks::task_type::TaskType;
use fal_client::requests::queue::image_gen::enqueue_flux_pro_11_ultra_text_to_image::{enqueue_flux_pro_11_ultra_text_to_image, FluxPro11UltraTextToImageArgs};
use idempotency::uuid::generate_random_uuid;
use log::{error, info};
use storyteller_client::endpoints::generate::object::generate_hunyuan_3d_2_0_image_to_3d::generate_hunyuan3d_2_0_image_to_3d;
@@ -1,21 +1,8 @@
use crate::core::commands::enqueue::generate_error::GenerateError;
use crate::core::commands::enqueue::image_to_object::enqueue_image_to_3d_object_command::{EnqueueImageTo3dObjectModel, EnqueueImageTo3dObjectRequest};
use crate::core::commands::enqueue::image_to_object::enqueue_image_to_3d_object_command::EnqueueImageTo3dObjectRequest;
use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
use crate::core::events::basic_sendable_event_trait::BasicSendableEvent;
use crate::core::events::generation_events::common::{GenerationAction, GenerationModel, GenerationServiceProvider};
use crate::core::events::generation_events::generation_enqueue_failure_event::GenerationEnqueueFailureEvent;
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::utils::download_media_file_to_temp_dir::download_media_file_to_temp_dir;
use crate::services::fal::state::fal_credential_manager::FalCredentialManager;
use crate::services::fal::state::fal_task_queue::FalTaskQueue;
use anyhow::anyhow;
use enums::common::generation_provider::GenerationProvider;
use enums::tauri::tasks::task_type::TaskType;
use fal_client::requests::queue::enqueue_hunyuan2_image_to_3d::{enqueue_hunyuan2_image_to_3d, Hunyuan2Args};
use fal_client::requests::queue::image_gen::enqueue_flux_pro_11_ultra_text_to_image::{enqueue_flux_pro_11_ultra_text_to_image, FluxPro11UltraTextToImageArgs};
use log::{error, info};
use tauri::AppHandle;
pub async fn handle_object_fal(
@@ -23,114 +10,6 @@ pub async fn handle_object_fal(
app_env_configs: &AppEnvConfigs,
app_data_root: &AppDataRoot,
request: EnqueueImageTo3dObjectRequest,
fal_creds_manager: &FalCredentialManager,
fal_task_queue: &FalTaskQueue,
) -> Result<TaskEnqueueSuccess, GenerateError> {
let api_key = match fal_creds_manager.get_key()? {
Some(key) => key,
None => {
error!("No FAL API key is set. Can't perform action.");
let event =
GenerationEnqueueFailureEvent::no_fal_api_key(GenerationAction::ImageTo3d);
if let Err(err) = event.send(&app) {
error!("Failed to emit event: {:?}", err); // Fail open.
}
return Err(GenerateError::needs_fal_api_key());
},
};
//let api_key = fal_creds_manager.get_key_required()
// .map_err(|err| {
// error!("EnqueueTextToImage FAL api key required: {:?}", err);
// InternalObjectError::NeedsFalApiKey
// })?;
let mut temp_download;
if let Some(media_token) = request.image_media_token {
temp_download = download_media_file_to_temp_dir(
app_env_configs,
app_data_root,
&media_token
).await?;
} else {
return Err(GenerateError::required_source_image_not_provided());
}
info!("Calling FAL image to 3d ...");
let filename = temp_download.path().to_path_buf();
let mut used_model = None;
let result = match request.model {
None => {
return Err(GenerateError::no_model_specified());
}
Some(
EnqueueImageTo3dObjectModel::Hunyuan3d2_0 |
EnqueueImageTo3dObjectModel::Hunyuan3d2
) => {
used_model = Some(GenerationModel::Hunyuan3d2_0);
info!("enqueue Hunyuan 3D 2.0");
enqueue_hunyuan2_image_to_3d(Hunyuan2Args {
image_path: filename,
api_key: &api_key,
}).await
}
_ => {
return Err(GenerateError::AnyhowError(anyhow!("Wrong model specified: {:?}", request.model)));
}
};
let mut job_id = None;
match result {
Ok(enqueued) => {
info!("Successfully enqueued text to image");
job_id = Some(enqueued.request_id.to_string());
let event = GenerationEnqueueSuccessEvent {
action: GenerationAction::ImageTo3d,
service: GenerationServiceProvider::Fal,
model: None,
};
if let Err(err) = event.send(app) {
error!("Failed to emit event: {:?}", err); // Fail open.
}
if let Err(err) = fal_task_queue.insert(&enqueued) {
error!("Failed to enqueue task: {:?}", err);
return Err(GenerateError::AnyhowError(anyhow!("Failed to enqueue task: {:?}", err)));
}
}
Err(err) => {
error!("Failed to enqueue image to 3d: {:?}", err);
let event = GenerationEnqueueFailureEvent {
action: GenerationAction::ImageTo3d,
service: GenerationServiceProvider::Fal,
model: None,
reason: None,
};
if let Err(err) = event.send(app) {
error!("Failed to emit event: {:?}", err); // Fail open.
}
return Err(GenerateError::from(err));
}
}
Ok(TaskEnqueueSuccess {
task_type: TaskType::ObjectGeneration,
model: used_model,
provider: GenerationProvider::Fal,
provider_job_id: job_id,
})
Err(GenerateError::FalNoLongerSupported)
}
@@ -13,12 +13,9 @@ 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_credential_manager::SoraCredentialManager;
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use fal_client::requests::queue::image_gen::enqueue_flux_pro_11_ultra_text_to_image::{enqueue_flux_pro_11_ultra_text_to_image, FluxPro11UltraTextToImageArgs};
use log::{error, info, warn};
use serde_derive::{Deserialize, Serialize};
use tauri::{AppHandle, State};
@@ -118,8 +115,6 @@ pub async fn enqueue_image_to_video_command(
app_data_root: State<'_, AppDataRoot>,
provider_priority_store: State<'_, ProviderPriorityStore>,
task_database: State<'_, TaskDatabase>,
fal_creds_manager: State<'_, FalCredentialManager>,
fal_task_queue: State<'_, FalTaskQueue>,
storyteller_creds_manager: State<'_, StorytellerCredentialManager>,
sora_task_queue: State<'_, SoraTaskQueue>,
sora_creds_manager: State<'_, SoraCredentialManager>,
@@ -134,10 +129,8 @@ pub async fn enqueue_image_to_video_command(
&app_data_root,
&provider_priority_store,
&task_database,
&fal_creds_manager,
&sora_creds_manager,
&storyteller_creds_manager,
&fal_task_queue,
).await;
match result {
@@ -207,10 +200,8 @@ pub async fn handle_request(
app_data_root: &AppDataRoot,
provider_priority_store: &ProviderPriorityStore,
task_database: &TaskDatabase,
fal_creds_manager: &FalCredentialManager,
sora_creds_manager: &SoraCredentialManager,
storyteller_creds_manager: &StorytellerCredentialManager,
fal_task_queue: &FalTaskQueue,
) -> Result<TaskEnqueueSuccess, GenerateError> {
let result = match request.model {
@@ -231,9 +222,7 @@ pub async fn handle_request(
app_env_configs,
app_data_root,
provider_priority_store,
fal_creds_manager,
storyteller_creds_manager,
fal_task_queue,
).await
}
};
@@ -1,13 +1,10 @@
use crate::core::commands::enqueue::generate_error::GenerateError;
use crate::core::commands::enqueue::image_to_video::enqueue_image_to_video_command::EnqueueImageToVideoRequest;
use crate::core::commands::enqueue::image_to_video::generic::handle_video_artcraft::handle_video_artcraft;
use crate::core::commands::enqueue::image_to_video::generic::handle_video_fal::handle_video_fal;
use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
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::services::fal::state::fal_credential_manager::FalCredentialManager;
use crate::services::fal::state::fal_task_queue::FalTaskQueue;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use tauri::AppHandle;
@@ -17,15 +14,14 @@ pub async fn handle_video(
app_env_configs: &AppEnvConfigs,
app_data_root: &AppDataRoot,
provider_priority_store: &ProviderPriorityStore,
fal_creds_manager: &FalCredentialManager,
storyteller_creds_manager: &StorytellerCredentialManager,
fal_task_queue: &FalTaskQueue,
) -> Result<TaskEnqueueSuccess, GenerateError> {
let priority = provider_priority_store.get_priority()?;
for provider in priority.iter() {
match provider {
Provider::Fal => {} // Fallthrough
Provider::Sora => {} // Fallthrough
Provider::Artcraft => {
return Ok(handle_video_artcraft(
@@ -36,18 +32,6 @@ pub async fn handle_video(
storyteller_creds_manager
).await?);
}
Provider::Fal => {
if fal_creds_manager.has_apparent_api_token()? {
return Ok(handle_video_fal(
&app,
app_env_configs,
app_data_root,
request,
fal_creds_manager,
fal_task_queue
).await?);
}
}
}
}
@@ -18,9 +18,6 @@ use artcraft_api_defs::generate::video::generate_veo_3_fast_image_to_video::Gene
use artcraft_api_defs::generate::video::generate_veo_3_image_to_video::{GenerateVeo3AspectRatio, GenerateVeo3ImageToVideoRequest};
use enums::common::generation_provider::GenerationProvider;
use enums::tauri::tasks::task_type::TaskType;
use fal_client::requests::queue::image_gen::enqueue_flux_pro_11_ultra_text_to_image::{enqueue_flux_pro_11_ultra_text_to_image, FluxPro11UltraTextToImageArgs};
use fal_client::requests::queue::video_gen::enqueue_kling_16_pro_image_to_video::Kling16ProAspectRatio;
use fal_client::requests::webhook::video::enqueue_veo_2_image_to_video_webhook::Veo2AspectRatio;
use idempotency::uuid::generate_random_uuid;
use log::{error, info};
use storyteller_client::endpoints::generate::video::generate_kling_16_pro_image_to_video::generate_kling_16_pro_image_to_video;
@@ -1,21 +1,8 @@
use crate::core::commands::enqueue::generate_error::{BadInputReason, GenerateError};
use crate::core::commands::enqueue::image_to_video::enqueue_image_to_video_command::{EnqueueImageToVideoRequest, VideoModel};
use crate::core::commands::enqueue::generate_error::GenerateError;
use crate::core::commands::enqueue::image_to_video::enqueue_image_to_video_command::EnqueueImageToVideoRequest;
use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
use crate::core::events::basic_sendable_event_trait::BasicSendableEvent;
use crate::core::events::generation_events::common::{GenerationAction, GenerationModel, GenerationServiceProvider};
use crate::core::events::generation_events::generation_enqueue_failure_event::GenerationEnqueueFailureEvent;
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::utils::download_media_file_to_temp_dir::download_media_file_to_temp_dir;
use crate::services::fal::state::fal_credential_manager::FalCredentialManager;
use crate::services::fal::state::fal_task_queue::FalTaskQueue;
use anyhow::anyhow;
use enums::common::generation_provider::GenerationProvider;
use enums::tauri::tasks::task_type::TaskType;
use fal_client::requests::queue::image_gen::enqueue_flux_pro_11_ultra_text_to_image::{enqueue_flux_pro_11_ultra_text_to_image, FluxPro11UltraTextToImageArgs};
use fal_client::requests::queue::video_gen::enqueue_kling_16_pro_image_to_video::{enqueue_kling_16_pro_image_to_video, Kling16ProArgs, Kling16ProAspectRatio, Kling16ProDuration};
use log::{error, info};
use tauri::AppHandle;
pub async fn handle_video_fal(
@@ -23,102 +10,6 @@ pub async fn handle_video_fal(
app_env_configs: &AppEnvConfigs,
app_data_root: &AppDataRoot,
request: EnqueueImageToVideoRequest,
fal_creds_manager: &FalCredentialManager,
fal_task_queue: &FalTaskQueue,
) -> Result<TaskEnqueueSuccess, GenerateError> {
let api_key = match fal_creds_manager.get_key()? {
Some(key) => key,
None => {
error!("No FAL API key is set. Can't perform action.");
let event =
GenerationEnqueueFailureEvent::no_fal_api_key(GenerationAction::GenerateVideo);
if let Err(err) = event.send(&app) {
error!("Failed to emit event: {:?}", err); // Fail open.
}
return Err(GenerateError::needs_fal_api_key());
},
};
//let api_key = fal_creds_manager.get_key_required()
// .map_err(|err| {
// error!("EnqueueTextToImage FAL api key required: {:?}", err);
// InternalVideoError::NeedsFalApiKey
// })?;
let mut temp_download;
if let Some(media_token) = request.image_media_token {
temp_download = download_media_file_to_temp_dir(
app_env_configs,
app_data_root,
&media_token
).await?;
} else {
return Err(GenerateError::BadInput(BadInputReason::RequiredSourceImageNotProvided));
}
let filename = temp_download.path().to_path_buf();
let mut selected_model = None;
let result = match request.model {
None => {
return Err(GenerateError::no_model_specified());
}
Some(VideoModel::Kling16Pro) => {
info!("enqueue Kling 1.6 image to video with Kling API");
selected_model = Some(GenerationModel::Kling1_6);
enqueue_kling_16_pro_image_to_video(Kling16ProArgs {
image_path: filename,
api_key: &api_key,
duration: Kling16ProDuration::Default,
aspect_ratio: Kling16ProAspectRatio::WideSixteenNine,
prompt: request.prompt.as_deref().unwrap_or(""),
}).await
}
_ => {
return Err(GenerateError::AnyhowError(anyhow!("Unsupported model: {:?}", request.model)));
}
};
let mut job_id = None;
match result {
Ok(enqueued) => {
info!("Successfully enqueued Kling 1.6 image to video");
job_id = Some(enqueued.request_id.to_string());
if let Err(err) = fal_task_queue.insert(&enqueued) {
error!("Failed to enqueue task: {:?}", err);
return Err(GenerateError::AnyhowError(anyhow!("Failed to enqueue task: {:?}", err)));
}
}
Err(err) => {
error!("Failed to enqueue image to video: {:?}", err);
let event = GenerationEnqueueFailureEvent {
action: GenerationAction::GenerateVideo,
service: GenerationServiceProvider::Fal,
model: selected_model,
reason: None,
};
if let Err(err) = event.send(app) {
error!("Failed to emit event: {:?}", err); // Fail open.
}
return Err(GenerateError::from(err));
}
}
Ok(TaskEnqueueSuccess {
task_type: TaskType::VideoGeneration,
model: selected_model,
provider: GenerationProvider::Fal,
provider_job_id: job_id,
})
Err(GenerateError::FalNoLongerSupported)
}
@@ -7,8 +7,6 @@ use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
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::services::fal::state::fal_credential_manager::FalCredentialManager;
use crate::services::fal::state::fal_task_queue::FalTaskQueue;
use crate::services::sora::state::sora_credential_manager::SoraCredentialManager;
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
@@ -19,8 +19,6 @@ 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::midjourney::state::midjourney_credential_manager::MidjourneyCredentialManager;
use crate::services::sora::state::sora_credential_manager::SoraCredentialManager;
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
@@ -29,7 +27,6 @@ use enums::common::generation_provider::GenerationProvider;
use enums::tauri::tasks::task_status::TaskStatus;
use enums::tauri::tasks::task_type::TaskType;
use enums::tauri::ux::tauri_command_caller::TauriCommandCaller;
use fal_client::requests::queue::image_gen::enqueue_flux_pro_11_ultra_text_to_image::{enqueue_flux_pro_11_ultra_text_to_image, FluxPro11UltraTextToImageArgs};
use log::{error, info, warn};
use serde::{Deserialize, Serialize};
use sqlite_tasks::queries::create_task::{create_task, CreateTaskArgs};
@@ -146,10 +143,8 @@ pub async fn enqueue_text_to_image_command(
app_env_configs: State<'_, AppEnvConfigs>,
provider_priority_store: State<'_, ProviderPriorityStore>,
task_database: State<'_, TaskDatabase>,
fal_creds_manager: State<'_, FalCredentialManager>,
mj_creds_manager: State<'_, MidjourneyCredentialManager>,
storyteller_creds_manager: State<'_, StorytellerCredentialManager>,
fal_task_queue: State<'_, FalTaskQueue>,
sora_creds_manager: State<'_, SoraCredentialManager>,
sora_task_queue: State<'_, SoraTaskQueue>,
) -> Response<EnqueueTextToImageSuccessResponse, EnqueueTextToImageErrorType, ()> {
@@ -162,11 +157,9 @@ pub async fn enqueue_text_to_image_command(
&app_data_root,
&provider_priority_store,
&task_database,
&fal_creds_manager,
&mj_creds_manager,
&storyteller_creds_manager,
&app_env_configs,
&fal_task_queue,
&sora_creds_manager,
&sora_task_queue,
).await;
@@ -232,11 +225,9 @@ pub async fn handle_request(
app_data_root: &AppDataRoot,
provider_priority_store: &ProviderPriorityStore,
task_database: &TaskDatabase,
fal_creds_manager: &FalCredentialManager,
mj_creds_manager: &MidjourneyCredentialManager,
storyteller_creds_manager: &StorytellerCredentialManager,
app_env_configs: &AppEnvConfigs,
fal_task_queue: &FalTaskQueue,
sora_creds_manager: &SoraCredentialManager,
sora_task_queue: &SoraTaskQueue,
) -> Result<TaskEnqueueSuccess, GenerateError> {
@@ -246,10 +237,8 @@ pub async fn handle_request(
&app,
&app_data_root,
&provider_priority_store,
&fal_creds_manager,
&storyteller_creds_manager,
&app_env_configs,
&fal_task_queue,
&mj_creds_manager,
&sora_creds_manager,
&sora_task_queue,
@@ -282,10 +271,8 @@ pub async fn dispatch_request(
app: &AppHandle,
app_data_root: &AppDataRoot,
provider_priority_store: &ProviderPriorityStore,
fal_creds_manager: &FalCredentialManager,
storyteller_creds_manager: &StorytellerCredentialManager,
app_env_configs: &AppEnvConfigs,
fal_task_queue: &FalTaskQueue,
mj_creds_manager: &MidjourneyCredentialManager,
sora_creds_manager: &SoraCredentialManager,
sora_task_queue: &SoraTaskQueue,
@@ -336,16 +323,12 @@ pub async fn dispatch_request(
for provider in priority.iter() {
match provider {
Provider::Fal => {} // Fallthrough
Provider::Sora => {} // Fallthrough
Provider::Artcraft => {
return handle_image_artcraft(
request, &app, app_env_configs, app_data_root, storyteller_creds_manager).await;
}
Provider::Fal => {
if fal_creds_manager.has_apparent_api_token()? {
return handle_image_fal(&app, request, fal_creds_manager, fal_task_queue).await;
}
}
}
}
@@ -17,7 +17,6 @@ use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use enums::common::generation_provider::GenerationProvider;
use enums::tauri::tasks::task_type::TaskType;
use fal_client::requests::queue::image_gen::enqueue_flux_pro_11_ultra_text_to_image::{enqueue_flux_pro_11_ultra_text_to_image, FluxPro11UltraTextToImageArgs};
use log::{error, info};
use openai_sora_client::recipes::maybe_upgrade_or_renew_session::maybe_upgrade_or_renew_session;
use openai_sora_client::recipes::simple_image_gen_with_session_auto_renew::{simple_image_gen_with_session_auto_renew, SimpleImageGenAutoRenewRequest};
@@ -15,7 +15,6 @@ use artcraft_api_defs::generate::image::edit::gemini_25_flash_edit_image::{Gemin
use artcraft_api_defs::generate::image::generate_gpt_image_1_text_to_image::{GenerateGptImage1TextToImageImageQuality, GenerateGptImage1TextToImageImageSize, GenerateGptImage1TextToImageNumImages, GenerateGptImage1TextToImageRequest};
use enums::common::generation_provider::GenerationProvider;
use enums::tauri::tasks::task_type::TaskType;
use fal_client::requests::queue::image_gen::enqueue_flux_pro_11_ultra_text_to_image::{enqueue_flux_pro_11_ultra_text_to_image, FluxPro11UltraTextToImageArgs};
use idempotency::uuid::generate_random_uuid;
use log::{error, info};
use openai_sora_client::recipes::maybe_upgrade_or_renew_session::maybe_upgrade_or_renew_session;
@@ -14,7 +14,6 @@ use artcraft_api_defs::generate::image::generate_flux_pro_11_text_to_image::{Gen
use artcraft_api_defs::generate::image::generate_flux_pro_11_ultra_text_to_image::{GenerateFluxPro11UltraTextToImageAspectRatio, GenerateFluxPro11UltraTextToImageNumImages, GenerateFluxPro11UltraTextToImageRequest};
use enums::common::generation_provider::GenerationProvider;
use enums::tauri::tasks::task_type::TaskType;
use fal_client::requests::queue::image_gen::enqueue_flux_pro_11_ultra_text_to_image::{enqueue_flux_pro_11_ultra_text_to_image, FluxPro11UltraTextToImageArgs};
use idempotency::uuid::generate_random_uuid;
use log::{error, info};
use storyteller_client::endpoints::generate::image::generate_flux_1_dev_text_to_image::generate_flux_1_dev_text_to_image;
@@ -1,130 +1,11 @@
use crate::core::commands::enqueue::generate_error::{BadInputReason, GenerateError};
use crate::core::commands::enqueue::generate_error::GenerateError;
use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
use crate::core::commands::enqueue::text_to_image::enqueue_text_to_image_command::{EnqueueTextToImageRequest, ImageModel};
use crate::core::events::basic_sendable_event_trait::BasicSendableEvent;
use crate::core::events::generation_events::common::{GenerationAction, GenerationModel, GenerationServiceProvider};
use crate::core::events::generation_events::generation_enqueue_failure_event::GenerationEnqueueFailureEvent;
use crate::services::fal::state::fal_credential_manager::FalCredentialManager;
use crate::services::fal::state::fal_task_queue::FalTaskQueue;
use anyhow::anyhow;
use enums::common::generation_provider::GenerationProvider;
use enums::tauri::tasks::task_type::TaskType;
use fal_client::requests::queue::image_gen::enqueue_flux_pro_11_ultra_text_to_image::{enqueue_flux_pro_11_ultra_text_to_image, FluxPro11UltraTextToImageArgs};
use fal_client::requests::queue::image_gen::enqueue_recraft3_text_to_image::{enqueue_recraft3_text_to_image, Recraft3TextToImageArgs};
use log::{error, info};
use crate::core::commands::enqueue::text_to_image::enqueue_text_to_image_command::EnqueueTextToImageRequest;
use tauri::AppHandle;
pub async fn handle_image_fal(
app: &AppHandle,
request: &EnqueueTextToImageRequest,
fal_creds_manager: &FalCredentialManager,
fal_task_queue: &FalTaskQueue,
) -> Result<TaskEnqueueSuccess, GenerateError> {
let api_key = match fal_creds_manager.get_key()? {
Some(key) => key,
None => {
error!("No FAL API key is set. Can't perform action.");
let event =
GenerationEnqueueFailureEvent::no_fal_api_key(GenerationAction::GenerateImage);
if let Err(err) = event.send(&app) {
error!("Failed to emit event: {:?}", err); // Fail open.
}
return Err(GenerateError::needs_fal_api_key());
},
};
let api_key = fal_creds_manager.get_key_required()
.map_err(|err| {
error!("EnqueueTextToImage FAL api key required: {:?}", err);
GenerateError::needs_fal_api_key()
})?;
let prompt = request.prompt.as_deref().unwrap_or("");
let model;
let result = match request.model {
None => {
return Err(GenerateError::BadInput(BadInputReason::NoModelSpecified));
}
Some(
ImageModel::GptImage1 |
ImageModel::Midjourney
) => {
return Err(GenerateError::AnyhowError(anyhow!("wrong logic: another branch should handle this: {:?}", request.model)));
}
Some(
ImageModel::Gemini25Flash |
ImageModel::Flux1Dev |
ImageModel::Flux1Schnell |
ImageModel::FluxPro11
) => {
return Err(GenerateError::NotYetImplemented(format!("not yet implemented: {:?}", request.model)));
}
Some(ImageModel::FluxPro11Ultra) => {
model = GenerationModel::FluxPro11Ultra;
info!("enqueue Flux Pro 1.1 Ultra text-to-image with prompt: {}", prompt);
enqueue_flux_pro_11_ultra_text_to_image(FluxPro11UltraTextToImageArgs {
prompt,
api_key: &api_key,
}).await
}
Some(ImageModel::Recraft3) => {
model = GenerationModel::Recraft3;
info!("enqueue Recraft v3 text-to-image with prompt: {}", prompt);
enqueue_recraft3_text_to_image(Recraft3TextToImageArgs {
prompt,
api_key: &api_key,
}).await
}
};
let success_result = match result {
Ok(enqueued) => {
info!("Successfully enqueued text to image");
//let event = GenerationEnqueueSuccessEvent {
// action: GenerationAction::GenerateImage,
// service: GenerationServiceProvider::Fal,
// model: None,
//};
//if let Err(err) = event.send(app) {
// error!("Failed to emit event: {:?}", err); // Fail open.
//}
if let Err(err) = fal_task_queue.insert(&enqueued) {
error!("Failed to enqueue task: {:?}", err);
return Err(GenerateError::AnyhowError(anyhow!("Failed to enqueue task: {:?}", err)));
}
enqueued
}
Err(err) => {
error!("Failed to enqueue text to image: {:?}", err);
let event = GenerationEnqueueFailureEvent {
action: GenerationAction::GenerateImage,
service: GenerationServiceProvider::Fal,
model: None,
reason: None,
};
if let Err(err) = event.send(app) {
error!("Failed to emit event: {:?}", err); // Fail open.
}
return Err(GenerateError::from(err));
}
};
Ok(TaskEnqueueSuccess {
provider: GenerationProvider::Fal,
task_type: TaskType::ImageGeneration,
model: Some(model),
provider_job_id: Some(success_result.request_id.to_string()),
})
Err(GenerateError::FalNoLongerSupported)
}
@@ -16,7 +16,6 @@ use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use enums::common::generation_provider::GenerationProvider;
use enums::tauri::tasks::task_type::TaskType;
use fal_client::requests::queue::image_gen::enqueue_flux_pro_11_ultra_text_to_image::{enqueue_flux_pro_11_ultra_text_to_image, FluxPro11UltraTextToImageArgs};
use log::{error, info};
use openai_sora_client::recipes::maybe_upgrade_or_renew_session::maybe_upgrade_or_renew_session;
use openai_sora_client::recipes::simple_image_gen_with_session_auto_renew::{simple_image_gen_with_session_auto_renew, SimpleImageGenAutoRenewRequest};
@@ -14,7 +14,6 @@ use crate::services::storyteller::state::storyteller_credential_manager::Storyte
use artcraft_api_defs::generate::image::generate_gpt_image_1_text_to_image::{GenerateGptImage1TextToImageImageQuality, GenerateGptImage1TextToImageImageSize, GenerateGptImage1TextToImageNumImages, GenerateGptImage1TextToImageRequest};
use enums::common::generation_provider::GenerationProvider;
use enums::tauri::tasks::task_type::TaskType;
use fal_client::requests::queue::image_gen::enqueue_flux_pro_11_ultra_text_to_image::{enqueue_flux_pro_11_ultra_text_to_image, FluxPro11UltraTextToImageArgs};
use idempotency::uuid::generate_random_uuid;
use log::{error, info};
use openai_sora_client::recipes::maybe_upgrade_or_renew_session::maybe_upgrade_or_renew_session;
@@ -10,7 +10,6 @@ use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
use enums::common::generation_provider::GenerationProvider;
use enums::tauri::tasks::task_type::TaskType;
use errors::AnyhowResult;
use fal_client::requests::queue::image_gen::enqueue_flux_pro_11_ultra_text_to_image::{enqueue_flux_pro_11_ultra_text_to_image, FluxPro11UltraTextToImageArgs};
use log::{error, info};
use openai_sora_client::recipes::maybe_upgrade_or_renew_session::maybe_upgrade_or_renew_session;
use openai_sora_client::recipes::simple_image_gen_with_session_auto_renew::{simple_image_gen_with_session_auto_renew, SimpleImageGenAutoRenewRequest};
@@ -18,7 +18,6 @@ use crate::services::storyteller::state::storyteller_credential_manager::Storyte
use artcraft_api_defs::generate::image::generate_gpt_image_1_text_to_image::{GenerateGptImage1TextToImageImageQuality, GenerateGptImage1TextToImageImageSize, GenerateGptImage1TextToImageNumImages, GenerateGptImage1TextToImageRequest};
use enums::common::generation_provider::GenerationProvider;
use enums::tauri::tasks::task_type::TaskType;
use fal_client::requests::queue::image_gen::enqueue_flux_pro_11_ultra_text_to_image::{enqueue_flux_pro_11_ultra_text_to_image, FluxPro11UltraTextToImageArgs};
use idempotency::uuid::generate_random_uuid;
use log::{error, info};
use midjourney_client::client::midjourney_hostname::MidjourneyHostname;
@@ -5,8 +5,6 @@ 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 errors::AnyhowResult;
@@ -5,8 +5,6 @@ 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};
@@ -20,6 +18,8 @@ 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 enums::tauri::tasks::task_media_file_class::TaskMediaFileClass;
use tokens::tokens::batch_generations::BatchGenerationToken;
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::sqlite::tasks::TaskId;
@@ -36,11 +36,33 @@ pub struct TaskQueueItem {
pub model_type: Option<TaskModelType>,
pub provider: Option<GenerationProvider>,
pub provider_job_id: Option<String>,
pub completed_item: Option<CompletedItemData>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub completed_at: Option<DateTime<Utc>>,
}
#[derive(Serialize)]
pub struct CompletedItemData {
pub primary_media_file: MediaFileData,
/// The type of file(s) generated.
pub media_file_class: Option<TaskMediaFileClass>,
/// If generated in a batch, we probably have a batch token we can query.
pub maybe_batch_token: Option<BatchGenerationToken>,
}
#[derive(Serialize)]
pub struct MediaFileData {
pub token: MediaFileToken,
pub cdn_url: String,
pub maybe_thumbnail_url_template: Option<String>,
// NB: The frontend wants this.
pub created_at: DateTime<Utc>,
}
impl SerializeMarker for GetTaskQueueCommandResponse {}
#[tauri::command]
@@ -76,17 +98,45 @@ pub async fn handle_request(
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())
let mut transformed_tasks = Vec::with_capacity(tasks.tasks.len());
for task in tasks.tasks.into_iter() {
let mut completed_item = None;
if task.status == TaskStatus::CompleteSuccess {
let token_and_url = task.on_complete_primary_media_file_token
.zip(task.on_complete_primary_media_file_cdn_url);
if let Some((primary_media_file_token, media_file_url)) = token_and_url{
completed_item = Some(CompletedItemData {
primary_media_file: MediaFileData {
token: primary_media_file_token,
cdn_url: media_file_url.clone(),
maybe_thumbnail_url_template: task.on_complete_primary_media_file_thumbnail_url_template.clone(),
// NB: This isn't the exact completion date. Also, fallback to now if missing.
created_at: task.completed_at.unwrap_or_else(Utc::now),
},
media_file_class: task.on_complete_primary_media_file_class,
maybe_batch_token: task.on_complete_batch_token,
});
} else {
warn!("Task {} is marked complete but has no primary media file token or URL.", task.id);
}
}
transformed_tasks.push(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,
completed_item,
})
}
Ok(transformed_tasks)
}
@@ -5,8 +5,6 @@ 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};
@@ -3,15 +3,12 @@ use crate::core::lifecycle::startup::tasks::initially_size_and_position_windows:
use crate::core::lifecycle::startup::tasks::load_provider_priority_state::load_provider_priority_state;
use crate::core::lifecycle::startup::tasks::set_app_log_level::set_app_log_level;
use crate::core::lifecycle::startup::tasks::spawn_discord_presence_thread::spawn_discord_presence_thread;
use crate::core::lifecycle::startup::tasks::spawn_fal_task_polling_thread::spawn_fal_task_polling_thread;
use crate::core::lifecycle::startup::tasks::spawn_main_window_thread::spawn_main_window_thread;
use crate::core::lifecycle::startup::tasks::spawn_sora_task_polling_thread::spawn_sora_task_polling_thread;
use crate::core::lifecycle::startup::tasks::spawn_storyteller_threads::spawn_storyteller_threads;
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::core::state::artcraft_platform_info::ArtcraftPlatformInfo;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::services::fal::state::fal_credential_manager::FalCredentialManager;
use crate::services::fal::state::fal_task_queue::FalTaskQueue;
use crate::services::midjourney::state::midjourney_credential_manager::MidjourneyCredentialManager;
use crate::services::midjourney::threads::midjourney_long_polling_thread::midjourney_long_polling_thread;
use crate::services::sora::state::sora_credential_manager::SoraCredentialManager;
@@ -28,8 +25,6 @@ pub async fn handle_tauri_startup(
storyteller_creds_manager: StorytellerCredentialManager,
sora_credential_manager: SoraCredentialManager,
sora_task_queue: SoraTaskQueue,
fal_credential_manager: FalCredentialManager,
fal_task_queue: FalTaskQueue,
mj_creds_manager: MidjourneyCredentialManager,
) -> AnyhowResult<()> {
@@ -70,15 +65,6 @@ pub async fn handle_tauri_startup(
&sora_task_queue,
)?;
spawn_fal_task_polling_thread(
&app,
&root,
&app_env_configs,
&fal_credential_manager,
&fal_task_queue,
&storyteller_creds_manager,
)?;
tauri::async_runtime::spawn(midjourney_long_polling_thread(
app.clone(),
app_env_configs.clone(),
@@ -2,7 +2,6 @@ pub (super) mod bootstrap_task_database;
pub (super) mod set_app_log_level;
pub (super) mod initially_size_and_position_windows;
pub (super) mod spawn_discord_presence_thread;
pub (super) mod spawn_fal_task_polling_thread;
pub (super) mod spawn_main_window_thread;
pub (super) mod spawn_sora_task_polling_thread;
pub (super) mod spawn_storyteller_threads;
@@ -1,29 +0,0 @@
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::services::fal::state::fal_credential_manager::FalCredentialManager;
use crate::services::fal::state::fal_task_queue::FalTaskQueue;
use crate::services::fal::threads::fal_task_polling_thread::fal_task_polling_thread;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use errors::AnyhowResult;
use tauri::{AppHandle, Manager};
pub fn spawn_fal_task_polling_thread(
app: &AppHandle,
root: &AppDataRoot,
app_env_configs: &AppEnvConfigs,
fal_credential_manager: &FalCredentialManager,
fal_task_queue: &FalTaskQueue,
storyteller_creds_manager: &StorytellerCredentialManager,
) -> AnyhowResult<()> {
tauri::async_runtime::spawn(fal_task_polling_thread(
app.clone(),
app_env_configs.clone(),
root.clone(),
fal_credential_manager.clone(),
fal_task_queue.clone(),
storyteller_creds_manager.clone(),
));
Ok(())
}
@@ -7,4 +7,4 @@
/// 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;
pub const TASK_DATABASE_VERSION: u32 = 5;
-26
View File
@@ -29,14 +29,6 @@ use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::core::state::provider_priority::ProviderPriorityStore;
use crate::core::threads::discord_presence_thread::discord_presence_thread;
use crate::core::threads::main_window_thread::main_window_thread::main_window_thread;
use crate::services::fal::commands::fal_background_removal_command::fal_background_removal_command;
use crate::services::fal::commands::fal_hunyuan_image_to_3d_command::fal_hunyuan_image_to_3d_command;
use crate::services::fal::commands::fal_kling_image_to_video_command::fal_kling_image_to_video_command;
use crate::services::fal::commands::get_fal_api_key_command::get_fal_api_key_command;
use crate::services::fal::commands::set_fal_api_key_command::set_fal_api_key_command;
use crate::services::fal::state::fal_credential_manager::FalCredentialManager;
use crate::services::fal::state::fal_task_queue::FalTaskQueue;
use crate::services::fal::threads::fal_task_polling_thread::fal_task_polling_thread;
use crate::services::midjourney::commands::midjourney_clear_credentials_command::midjourney_clear_credentials_command;
use crate::services::midjourney::commands::midjourney_get_credential_info_command::midjourney_get_credential_info_command;
use crate::services::midjourney::commands::midjourney_open_login_command::midjourney_open_login_command;
@@ -93,17 +85,10 @@ pub fn run() {
let sora_creds_manager = SoraCredentialManager::initialize_from_disk_infallible(&app_data_root);
let sora_creds_manager_2 = sora_creds_manager.clone();
println!("Attempting to read existing fal credentials...");
let fal_creds_manager = FalCredentialManager::initialize_from_disk_infallible(&app_data_root);
let fal_creds_manager_2 = fal_creds_manager.clone();
// Other state
let sora_task_queue = SoraTaskQueue::new();
let sora_task_queue_2 = sora_task_queue.clone();
let fal_task_queue = FalTaskQueue::new();
let fal_task_queue_2 = fal_task_queue.clone();
let app_env_configs = AppEnvConfigs::load_from_filesystem(&app_data_root)
.expect("AppEnvConfigs should be loaded from disk");
@@ -136,8 +121,6 @@ pub fn run() {
let storyteller_creds = storyteller_creds_manager_2.clone();
let sora_creds = sora_creds_manager_2.clone();
let sora_tasks = sora_task_queue_2.clone();
let fal_creds = fal_creds_manager_2.clone();
let fal_tasks = fal_task_queue_2.clone();
tauri::async_runtime::block_on(async move {
let result = setup_main_window(&app).await;
@@ -150,8 +133,6 @@ pub fn run() {
storyteller_creds,
sora_creds,
sora_tasks,
fal_creds,
fal_tasks,
midjourney_creds_manager_2,
).await;
@@ -167,8 +148,6 @@ pub fn run() {
.manage(app_env_configs)
.manage(app_preferences)
.manage(artcraft_platform_info)
.manage(fal_creds_manager)
.manage(fal_task_queue)
.manage(midjourney_creds_manager)
.manage(sora_creds_manager)
.manage(sora_task_queue)
@@ -184,13 +163,9 @@ pub fn run() {
enqueue_image_to_3d_object_command,
enqueue_image_to_video_command,
enqueue_text_to_image_command,
fal_background_removal_command,
fal_hunyuan_image_to_3d_command,
fal_kling_image_to_video_command,
flip_image,
get_app_info_command,
get_app_preferences_command,
get_fal_api_key_command,
get_provider_order_command,
get_task_queue_command,
load_without_cors_command,
@@ -201,7 +176,6 @@ pub fn run() {
midjourney_open_login_command,
open_sora_login_command,
platform_info_command,
set_fal_api_key_command,
set_provider_order_command,
sora_get_credential_info_command,
sora_logout_command,
@@ -1,279 +0,0 @@
use crate::core::artcraft_error::ArtcraftError;
use crate::core::commands::response::failure_response_wrapper::{CommandErrorResponseWrapper, CommandErrorStatus};
use crate::core::commands::response::shorthand::Response;
use crate::core::commands::response::success_response_wrapper::SerializeMarker;
use crate::core::events::basic_sendable_event_trait::BasicSendableEvent;
use crate::core::events::generation_events::common::{GenerationAction, GenerationServiceProvider};
use crate::core::events::generation_events::generation_enqueue_failure_event::GenerationEnqueueFailureEvent;
use crate::core::events::generation_events::generation_enqueue_success_event::GenerationEnqueueSuccessEvent;
use crate::core::events::sendable_event_trait::SendableEvent;
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::core::state::data_dir::trait_data_subdir::DataSubdir;
use crate::core::utils::download_media_file_to_temp_dir::download_media_file_to_temp_dir;
use crate::core::utils::get_url_file_extension::get_url_file_extension;
use crate::core::utils::save_base64_image_to_temp_dir::save_base64_image_to_temp_dir;
use crate::core::utils::simple_http_download_to_tempfile::simple_http_download_to_tempfile;
use crate::services::fal::state::fal_credential_manager::FalCredentialManager;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use anyhow::anyhow;
use base64::prelude::BASE64_STANDARD;
use base64::{DecodeError, Engine};
use errors::AnyhowError;
use fal_client::error::fal_error_plus::FalErrorPlus;
use fal_client::requests::queue::remove_background_rembg::remove_background_rembg;
use filesys::file_read_bytes::file_read_bytes;
use image::{EncodableLayout, ImageReader};
use log::{error, info, warn};
use reqwest::Url;
use serde_derive::{Deserialize, Serialize};
use storyteller_client::error::storyteller_error::StorytellerError;
use storyteller_client::endpoints::media_files::get_media_file::{get_media_file, GetMediaFileSuccessResponse};
use storyteller_client::endpoints::media_files::upload_image_media_file_from_file::{upload_image_media_file_from_file, UploadImageFromFileArgs};
use storyteller_client::utils::api_host::ApiHost;
use tauri::{AppHandle, Manager, State};
use tokens::tokens::media_files::MediaFileToken;
#[derive(Deserialize)]
pub struct FalBackgroundRemovalRequest {
/// Image media file; the image to remove the background from.
pub image_media_token: Option<MediaFileToken>,
/// Base64-encoded image
pub base64_image: Option<String>,
}
#[derive(Serialize)]
pub struct FalBackgroundRemovalSuccessResponse {
/// Result media token
pub media_token: MediaFileToken,
/// Result URL
pub cdn_url: Url,
/// Base64 bytes
pub base64_bytes: String,
}
impl SerializeMarker for FalBackgroundRemovalSuccessResponse {}
#[derive(Serialize, Debug)]
#[serde(rename_all = "snake_case")]
pub enum BackgroundRemovalErrorType {
/// Generic server error
ServerError,
/// No Fal API key available
NeedsFalApiKey,
}
#[tauri::command]
pub async fn fal_background_removal_command(
app: AppHandle,
request: FalBackgroundRemovalRequest,
app_env_configs: State<'_, AppEnvConfigs>,
app_data_root: State<'_, AppDataRoot>,
fal_creds_manager: State<'_, FalCredentialManager>,
storyteller_creds_manager: State<'_, StorytellerCredentialManager>,
) -> Response<FalBackgroundRemovalSuccessResponse, BackgroundRemovalErrorType, ()> {
info!("fal_background_removal_command called; image media token: {:?}", request.image_media_token);
let has_credentials = fal_creds_manager
.has_apparent_api_token()
.unwrap_or(true); // NB: Failures would be lock issues
if !has_credentials {
warn!("No API key found");
let event =
GenerationEnqueueFailureEvent::no_fal_api_key(GenerationAction::RemoveBackground);
if let Err(err) = event.send(&app) {
error!("Failed to emit event: {:?}", err); // Fail open.
}
return Err(CommandErrorResponseWrapper {
status: CommandErrorStatus::Unauthorized,
error_message: Some("You need to set a FAL api key".to_string()),
error_type: Some(BackgroundRemovalErrorType::NeedsFalApiKey),
error_details: None,
});
}
let event = GenerationEnqueueSuccessEvent {
action: GenerationAction::RemoveBackground,
service: GenerationServiceProvider::Fal,
model: None,
};
if let Err(err) = event.send(&app) {
error!("Failed to emit event: {:?}", err); // Fail open.
}
let result = remove_background(
request,
&app_env_configs,
&app_data_root,
&fal_creds_manager,
&storyteller_creds_manager
).await;
match result {
Err(err) => {
error!("error: {:?}", err);
let event = GenerationEnqueueFailureEvent {
action: GenerationAction::RemoveBackground,
service: GenerationServiceProvider::Fal,
model: None,
reason: None,
};
if let Err(err) = event.send(&app) {
error!("Failed to emit event: {:?}", err); // Fail open.
}
let mut status = CommandErrorStatus::ServerError;
let mut error_type = BackgroundRemovalErrorType::ServerError;
let mut error_message = "A server error occurred. Please try again. If it continues, please tell our staff about the problem.";
Err(CommandErrorResponseWrapper {
status,
error_message: Some(error_message.to_string()),
error_type: Some(error_type),
error_details: None,
})
}
Ok(result) => {
Ok(FalBackgroundRemovalSuccessResponse {
media_token: result.0.media_file.token,
cdn_url: result.0.media_file.media_links.cdn_url,
base64_bytes: result.1,
}.into())
}
}
}
#[derive(Debug)]
enum InnerError {
FalError(FalErrorPlus),
AnyhowError(AnyhowError),
StorytellerError(StorytellerError),
DecodeError(DecodeError),
IoError(std::io::Error),
ArtcraftError(ArtcraftError),
}
impl From<AnyhowError> for InnerError {
fn from(value: AnyhowError) -> Self {
Self::AnyhowError(value)
}
}
impl From<FalErrorPlus> for InnerError {
fn from(value: FalErrorPlus) -> Self {
Self::FalError(value)
}
}
impl From<StorytellerError> for InnerError {
fn from(value: StorytellerError) -> Self {
Self::StorytellerError(value)
}
}
impl From<DecodeError> for InnerError {
fn from(value: DecodeError) -> Self {
Self::DecodeError(value)
}
}
impl From<std::io::Error> for InnerError {
fn from(value: std::io::Error) -> Self {
Self::IoError(value)
}
}
impl From<ArtcraftError> for InnerError {
fn from(value: ArtcraftError) -> Self {
match value {
ArtcraftError::AnyhowError(e) => Self::AnyhowError(e),
ArtcraftError::DecodeError(e) => Self::DecodeError(e),
ArtcraftError::IoError(e) => Self::IoError(e),
_ => Self::ArtcraftError(value),
}
}
}
pub async fn remove_background(
request: FalBackgroundRemovalRequest,
app_env_configs: &AppEnvConfigs,
app_data_root: &AppDataRoot,
fal_creds_manager: &FalCredentialManager,
storyteller_creds_manager: &StorytellerCredentialManager,
) -> Result<(GetMediaFileSuccessResponse, String), InnerError> {
let api_key = fal_creds_manager.get_key_required()?;
let creds = storyteller_creds_manager.get_credentials_required()?;
let mut temp_download;
if let Some(media_token) = request.image_media_token {
info!("From media token: {:?}", &media_token);
temp_download = download_media_file_to_temp_dir(
app_env_configs,
app_data_root,
&media_token
).await?;
} else if let Some(base64_bytes) = request.base64_image {
info!("From base64 bytes...");
temp_download = save_base64_image_to_temp_dir(&app_data_root, &base64_bytes).await?;
} else {
return Err(InnerError::AnyhowError(anyhow!("No image media token or base64 image provided")));
}
info!("Calling FAL image background removal...");
let filename = temp_download.path().to_path_buf();
let result = remove_background_rembg(filename, &api_key).await?;
info!("Unpacking FAL result...");
let extension_with_dot = get_url_file_extension(&result.image_url)
.map(|ext| format!(".{}", ext))
.unwrap_or_else(|| ".png".to_string());
let mut result_file = app_data_root.temp_dir().new_named_temp_file_with_extension(&extension_with_dot)?;
info!("Downloading file result file...");
simple_http_download_to_tempfile(&result.image_url, &mut result_file).await?;
let result_filename = result_file.path().to_path_buf();
// NB: We couldn't pass the existing file handle as I think the file handle pointer is already at the EOF
let bytes = file_read_bytes(&result_filename)?;
let base64_bytes = BASE64_STANDARD.encode(bytes.as_bytes());
info!("Uploading image media file...");
let upload_result = upload_image_media_file_from_file(UploadImageFromFileArgs {
api_host: &app_env_configs.storyteller_host,
maybe_creds: Some(&creds),
path: result_filename,
is_intermediate_system_file: true, // NB: Probably not essential to keep this.
maybe_prompt_token: None, // NB: Not used for bg removal.
maybe_batch_token: None, // NB: Not used for bg removal.
}).await?;
// TODO: Don't re-request to simply build MediaLinks (or CDN URL). Get those from the upload API in one turn.
info!("Re-requesting media file...");
let response = get_media_file(
&app_env_configs.storyteller_host,
&upload_result.media_file_token,
).await?;
info!("Uploaded media file: {:?}", response.media_file.token);
Ok((response, base64_bytes))
}
@@ -1,215 +0,0 @@
use crate::core::artcraft_error::ArtcraftError;
use crate::core::commands::response::failure_response_wrapper::{CommandErrorResponseWrapper, CommandErrorStatus};
use crate::core::commands::response::shorthand::Response;
use crate::core::events::basic_sendable_event_trait::BasicSendableEvent;
use crate::core::events::generation_events::common::{GenerationAction, GenerationServiceProvider};
use crate::core::events::generation_events::generation_enqueue_failure_event::GenerationEnqueueFailureEvent;
use crate::core::events::generation_events::generation_enqueue_success_event::GenerationEnqueueSuccessEvent;
use crate::core::events::sendable_event_trait::SendableEvent;
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::core::state::data_dir::trait_data_subdir::DataSubdir;
use crate::core::utils::download_media_file_to_temp_dir::download_media_file_to_temp_dir;
use crate::core::utils::save_base64_image_to_temp_dir::save_base64_image_to_temp_dir;
use crate::services::fal::state::fal_credential_manager::FalCredentialManager;
use crate::services::fal::state::fal_task_queue::FalTaskQueue;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use anyhow::anyhow;
use base64::DecodeError;
use errors::AnyhowError;
use fal_client::error::fal_error_plus::FalErrorPlus;
use fal_client::requests::queue::enqueue_hunyuan2_image_to_3d::{enqueue_hunyuan2_image_to_3d, Hunyuan2Args};
use log::{error, info, warn};
use serde_derive::{Deserialize, Serialize};
use storyteller_client::error::storyteller_error::StorytellerError;
use tauri::{AppHandle, Manager, State};
use tokens::tokens::media_files::MediaFileToken;
#[derive(Deserialize)]
pub struct FalHunyuanImageTo3dRequest {
/// Image media file; the image to remove the background from.
pub image_media_token: Option<MediaFileToken>,
/// Base64-encoded image
pub base64_image: Option<String>,
}
#[derive(Serialize, Debug)]
#[serde(rename_all = "snake_case")]
pub enum HunyuanErrorType {
/// Generic server error
ServerError,
/// No Fal API key available
NeedsFalApiKey,
}
#[tauri::command]
pub async fn fal_hunyuan_image_to_3d_command(
app: AppHandle,
request: FalHunyuanImageTo3dRequest,
app_env_configs: State<'_, AppEnvConfigs>,
app_data_root: State<'_, AppDataRoot>,
fal_creds_manager: State<'_, FalCredentialManager>,
storyteller_creds_manager: State<'_, StorytellerCredentialManager>,
fal_task_queue: State<'_, FalTaskQueue>,
) -> Response<(), HunyuanErrorType, ()> {
info!("fal_hunyuan_image_to_3d_command called; image media token: {:?}", request.image_media_token);
let has_credentials = fal_creds_manager
.has_apparent_api_token()
.unwrap_or(true); // NB: Failures would be lock issues
if !has_credentials {
warn!("No API key found");
return Err(CommandErrorResponseWrapper {
status: CommandErrorStatus::Unauthorized,
error_message: Some("You need to set a FAL api key".to_string()),
error_type: Some(HunyuanErrorType::NeedsFalApiKey),
error_details: None,
});
}
let result = image_to_3d(
request,
&app_env_configs,
&app_data_root,
&fal_creds_manager,
&storyteller_creds_manager,
&fal_task_queue,
).await;
if let Err(err) = result {
error!("error: {:?}", err);
let event = GenerationEnqueueFailureEvent {
action: GenerationAction::ImageTo3d,
service: GenerationServiceProvider::Fal,
model: None,
reason: None,
};
if let Err(err) = event.send(&app) {
error!("Failed to emit event: {:?}", err); // Fail open.
}
let mut status = CommandErrorStatus::ServerError;
let mut error_type = HunyuanErrorType::ServerError;
let mut error_message = "A server error occurred. Please try again. If it continues, please tell our staff about the problem.";
return Err(CommandErrorResponseWrapper {
status,
error_message: Some(error_message.to_string()),
error_type: Some(error_type),
error_details: None,
})
}
let event = GenerationEnqueueSuccessEvent {
action: GenerationAction::ImageTo3d,
service: GenerationServiceProvider::Fal,
model: None,
};
if let Err(err) = event.send(&app) {
error!("Failed to emit event: {:?}", err); // Fail open.
}
Ok(().into())
}
#[derive(Debug)]
enum InnerError {
FalError(FalErrorPlus),
AnyhowError(AnyhowError),
StorytellerError(StorytellerError),
DecodeError(DecodeError),
IoError(std::io::Error),
ArtcraftError(ArtcraftError),
}
impl From<AnyhowError> for InnerError {
fn from(value: AnyhowError) -> Self {
Self::AnyhowError(value)
}
}
impl From<FalErrorPlus> for InnerError {
fn from(value: FalErrorPlus) -> Self {
Self::FalError(value)
}
}
impl From<StorytellerError> for InnerError {
fn from(value: StorytellerError) -> Self {
Self::StorytellerError(value)
}
}
impl From<DecodeError> for InnerError {
fn from(value: DecodeError) -> Self {
Self::DecodeError(value)
}
}
impl From<std::io::Error> for InnerError {
fn from(value: std::io::Error) -> Self {
Self::IoError(value)
}
}
impl From<ArtcraftError> for InnerError {
fn from(value: ArtcraftError) -> Self {
match value {
ArtcraftError::AnyhowError(e) => Self::AnyhowError(e),
ArtcraftError::DecodeError(e) => Self::DecodeError(e),
ArtcraftError::IoError(e) => Self::IoError(e),
ArtcraftError::StorytellerError(e) => Self::StorytellerError(e),
_ => Self::ArtcraftError(value),
}
}
}
pub async fn image_to_3d(
request: FalHunyuanImageTo3dRequest,
app_env_configs: &AppEnvConfigs,
app_data_root: &AppDataRoot,
fal_creds_manager: &FalCredentialManager,
storyteller_creds_manager: &StorytellerCredentialManager,
fal_task_queue: &FalTaskQueue,
) -> Result<(), InnerError> {
let api_key = fal_creds_manager.get_key_required()?;
let mut temp_download;
if let Some(media_token) = request.image_media_token {
temp_download = download_media_file_to_temp_dir(
app_env_configs,
app_data_root,
&media_token
).await?;
} else if let Some(base64_bytes) = request.base64_image {
temp_download = save_base64_image_to_temp_dir(&app_data_root, &base64_bytes).await?;
} else {
return Err(InnerError::AnyhowError(anyhow!("No image media token or base64 image provided")));
}
info!("Calling FAL image to 3d ...");
let filename = temp_download.path().to_path_buf();
let enqueued = enqueue_hunyuan2_image_to_3d(Hunyuan2Args {
image_path: filename,
api_key: &api_key,
}).await?;
if let Err(err) = fal_task_queue.insert(&enqueued) {
error!("Failed to enqueue task: {:?}", err);
return Err(InnerError::AnyhowError(anyhow!("Failed to enqueue task: {:?}", err)));
}
Ok(())
}
@@ -1,224 +0,0 @@
use crate::core::artcraft_error::ArtcraftError;
use crate::core::commands::response::failure_response_wrapper::{CommandErrorResponseWrapper, CommandErrorStatus};
use crate::core::commands::response::shorthand::Response;
use crate::core::events::basic_sendable_event_trait::BasicSendableEvent;
use crate::core::events::generation_events::common::{GenerationAction, GenerationServiceProvider};
use crate::core::events::generation_events::generation_enqueue_failure_event::GenerationEnqueueFailureEvent;
use crate::core::events::generation_events::generation_enqueue_success_event::GenerationEnqueueSuccessEvent;
use crate::core::events::sendable_event_trait::SendableEvent;
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::core::state::data_dir::trait_data_subdir::DataSubdir;
use crate::core::utils::download_media_file_to_temp_dir::download_media_file_to_temp_dir;
use crate::core::utils::save_base64_image_to_temp_dir::save_base64_image_to_temp_dir;
use crate::services::fal::state::fal_credential_manager::FalCredentialManager;
use crate::services::fal::state::fal_task_queue::FalTaskQueue;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use anyhow::anyhow;
use base64::DecodeError;
use errors::AnyhowError;
use fal_client::error::fal_error_plus::FalErrorPlus;
use fal_client::requests::queue::video_gen::enqueue_kling_16_pro_image_to_video::{enqueue_kling_16_pro_image_to_video, Kling16ProArgs, Kling16ProAspectRatio, Kling16ProDuration};
use log::{error, info, warn};
use serde_derive::{Deserialize, Serialize};
use storyteller_client::error::storyteller_error::StorytellerError;
use tauri::{AppHandle, Manager, State};
use tokens::tokens::media_files::MediaFileToken;
#[derive(Deserialize)]
pub struct FalKlingImageToVideoRequest {
/// Image media file; the image to remove the background from.
/// This must be supplied **OR** the `base64_image`.
pub image_media_token: Option<MediaFileToken>,
/// Base64-encoded image
/// This must be supplied **OR** the `image_media_token`.
pub base64_image: Option<String>,
/// Optional: Text prompt.
pub prompt: Option<String>,
}
#[derive(Serialize, Debug)]
#[serde(rename_all = "snake_case")]
pub enum KlingImageToVideoErrorType {
/// Generic server error
ServerError,
/// No Fal API key available
NeedsFalApiKey,
}
#[tauri::command]
pub async fn fal_kling_image_to_video_command(
app: AppHandle,
request: FalKlingImageToVideoRequest,
app_env_configs: State<'_, AppEnvConfigs>,
app_data_root: State<'_, AppDataRoot>,
fal_creds_manager: State<'_, FalCredentialManager>,
storyteller_creds_manager: State<'_, StorytellerCredentialManager>,
fal_task_queue: State<'_, FalTaskQueue>,
) -> Response<(), KlingImageToVideoErrorType, ()> {
info!("fal_kling_image_to_video_command called; image media token: {:?}", request.image_media_token);
let has_credentials = fal_creds_manager
.has_apparent_api_token()
.unwrap_or(true); // NB: Failures would be lock issues
if !has_credentials {
warn!("No API key found");
return Err(CommandErrorResponseWrapper {
status: CommandErrorStatus::Unauthorized,
error_message: Some("You need to set a FAL api key".to_string()),
error_type: Some(KlingImageToVideoErrorType::NeedsFalApiKey),
error_details: None,
});
}
let result = image_to_video(
request,
&app_env_configs,
&app_data_root,
&fal_creds_manager,
&storyteller_creds_manager,
&fal_task_queue,
).await;
if let Err(err) = result {
error!("error: {:?}", err);
let event = GenerationEnqueueFailureEvent {
action: GenerationAction::GenerateVideo,
service: GenerationServiceProvider::Fal,
model: None,
reason: None,
};
if let Err(err) = event.send(&app) {
error!("Failed to emit event: {:?}", err); // Fail open.
}
let mut status = CommandErrorStatus::ServerError;
let mut error_type = KlingImageToVideoErrorType::ServerError;
let mut error_message = "A server error occurred. Please try again. If it continues, please tell our staff about the problem.";
return Err(CommandErrorResponseWrapper {
status,
error_message: Some(error_message.to_string()),
error_type: Some(error_type),
error_details: None,
})
}
let event = GenerationEnqueueSuccessEvent {
action: GenerationAction::GenerateVideo,
service: GenerationServiceProvider::Fal,
model: None,
};
if let Err(err) = event.send(&app) {
error!("Failed to emit event: {:?}", err); // Fail open.
}
Ok(().into())
}
#[derive(Debug)]
enum InnerError {
FalError(FalErrorPlus),
AnyhowError(AnyhowError),
StorytellerError(StorytellerError),
DecodeError(DecodeError),
IoError(std::io::Error),
ArtcraftError(ArtcraftError),
}
impl From<AnyhowError> for InnerError {
fn from(value: AnyhowError) -> Self {
Self::AnyhowError(value)
}
}
impl From<FalErrorPlus> for InnerError {
fn from(value: FalErrorPlus) -> Self {
Self::FalError(value)
}
}
impl From<StorytellerError> for InnerError {
fn from(value: StorytellerError) -> Self {
Self::StorytellerError(value)
}
}
impl From<DecodeError> for InnerError {
fn from(value: DecodeError) -> Self {
Self::DecodeError(value)
}
}
impl From<std::io::Error> for InnerError {
fn from(value: std::io::Error) -> Self {
Self::IoError(value)
}
}
impl From<ArtcraftError> for InnerError {
fn from(value: ArtcraftError) -> Self {
match value {
ArtcraftError::AnyhowError(e) => Self::AnyhowError(e),
ArtcraftError::DecodeError(e) => Self::DecodeError(e),
ArtcraftError::IoError(e) => Self::IoError(e),
ArtcraftError::StorytellerError(e) => Self::StorytellerError(e),
_ => Self::ArtcraftError(value),
}
}
}
pub async fn image_to_video(
request: FalKlingImageToVideoRequest,
app_env_configs: &AppEnvConfigs,
app_data_root: &AppDataRoot,
fal_creds_manager: &FalCredentialManager,
storyteller_creds_manager: &StorytellerCredentialManager,
fal_task_queue: &FalTaskQueue,
) -> Result<(), InnerError> {
let api_key = fal_creds_manager.get_key_required()?;
let creds = storyteller_creds_manager.get_credentials_required()?;
let mut temp_download;
if let Some(media_token) = request.image_media_token {
temp_download = download_media_file_to_temp_dir(
app_env_configs,
app_data_root,
&media_token
).await?;
} else if let Some(base64_bytes) = request.base64_image {
temp_download = save_base64_image_to_temp_dir(&app_data_root, &base64_bytes).await?;
} else {
return Err(InnerError::AnyhowError(anyhow!("No image media token or base64 image provided")));
}
info!("Calling FAL image to video ...");
let filename = temp_download.path().to_path_buf();
let enqueued = enqueue_kling_16_pro_image_to_video(Kling16ProArgs {
image_path: filename,
api_key: &api_key,
duration: Kling16ProDuration::Default,
aspect_ratio: Kling16ProAspectRatio::WideSixteenNine,
prompt: request.prompt.as_deref().unwrap_or(""),
}).await?;
if let Err(err) = fal_task_queue.insert(&enqueued) {
error!("Failed to enqueue task: {:?}", err);
return Err(InnerError::AnyhowError(anyhow!("Failed to enqueue task: {:?}", err)));
}
Ok(())
}
@@ -1,40 +0,0 @@
use crate::core::commands::response::shorthand::Response;
use crate::core::commands::response::success_response_wrapper::SerializeMarker;
use crate::services::fal::state::fal_credential_manager::FalCredentialManager;
use errors::AnyhowResult;
use log::error;
use serde_derive::Serialize;
use tauri::State;
#[derive(Serialize)]
pub struct GetFalApiKeyResponse {
pub key: Option<String>,
}
impl SerializeMarker for GetFalApiKeyResponse {}
#[tauri::command]
pub async fn get_fal_api_key_command(
creds_manager: State<'_, FalCredentialManager>,
) -> Response<GetFalApiKeyResponse, (), ()> {
let key = get_key(&creds_manager)
.await
.map_err(|err| {
error!("Error getting API key: {:?}", err);
"error getting key"
})?;
Ok(key.into())
}
async fn get_key(
creds_manager: &FalCredentialManager,
) -> AnyhowResult<GetFalApiKeyResponse> {
let maybe_key = creds_manager.get_key()?
.map(|key| key.0)
.map(|key| key.trim().to_string())
.filter(|key| !key.is_empty());
Ok(GetFalApiKeyResponse { key: maybe_key })
}
@@ -1,7 +0,0 @@
pub mod fal_background_removal_command;
pub mod fal_hunyuan_image_to_3d_command;
pub mod fal_kling_image_to_video_command;
pub mod get_fal_api_key_command;
pub mod set_fal_api_key_command;
// pub mod get_open_ai_api_key_command;
// pub mod set_open_ai_api_key_command;
@@ -1,54 +0,0 @@
use crate::core::commands::response::shorthand::Response;
use crate::services::fal::state::fal_credential_manager::FalCredentialManager;
use errors::AnyhowResult;
use fal_client::creds::fal_api_key::FalApiKey;
use log::{error, info};
use serde_derive::Deserialize;
use tauri::State;
/// For now, we'll only update a single preference at a time.
#[derive(Deserialize)]
pub struct SetFalApiKeyRequest{
pub key: Option<String>,
}
#[tauri::command]
pub async fn set_fal_api_key_command(
request: SetFalApiKeyRequest,
creds_manager: State<'_, FalCredentialManager>,
) -> Response<(), (), ()> {
info!("update_app_preferences_command called");
set_key(request, &creds_manager)
.await
.map_err(|err| {
error!("Error getting app preferences: {:?}", err);
"error setting key"
})?;
Ok(().into())
}
async fn set_key(
request: SetFalApiKeyRequest,
creds_manager: &FalCredentialManager,
) -> AnyhowResult<()> {
let key = request.key
.map(|key| key.trim().to_string())
.filter(|key| !key.is_empty())
.map(|key| FalApiKey::from_str(&key));
match key {
None => {
creds_manager.clear_key()?;
creds_manager.purge_api_key_from_disk()?;
}
Some(key) => {
creds_manager.set_key(&key)?;
creds_manager.persist_to_disk()?;
}
}
Ok(())
}
@@ -1,3 +0,0 @@
pub mod commands;
pub mod state;
pub mod threads;
@@ -1,100 +0,0 @@
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::services::fal::state::read_fal_api_key_from_disk::read_fal_api_key_from_disk;
use anyhow::anyhow;
use errors::AnyhowResult;
use fal_client::creds::fal_api_key::FalApiKey;
use memory_store::clone_slot::CloneSlot;
use std::fs::OpenOptions;
use std::io::Write;
use std::{fs, io};
#[derive(Clone)]
pub struct FalCredentialManager {
key: CloneSlot<FalApiKey>,
app_data_root: AppDataRoot,
}
impl FalCredentialManager {
pub fn new(app_data_root: &AppDataRoot) -> Self {
Self {
key: CloneSlot::empty(),
app_data_root: app_data_root.clone(),
}
}
pub fn initialize_from_disk_infallible(app_data_root: &AppDataRoot) -> Self {
let mut holder = CloneSlot::empty();
match read_fal_api_key_from_disk(app_data_root) {
Err(err) => {
log::warn!("Failed to read FAL API key from disk: {:?}", err);
}
Ok(api_key) => {
holder.set_clone(&api_key).expect("Failed to set FAL API key");
}
}
Self {
key: holder,
app_data_root: app_data_root.clone(),
}
}
pub fn set_key(&self, key: &FalApiKey) -> AnyhowResult<()> {
if key.0.trim().is_empty() {
return Err(anyhow!("FAL API key cannot be empty")); // TODO: Also handle upstream.
}
self.key.set_clone(key)
}
pub fn get_key(&self) -> AnyhowResult<Option<FalApiKey>> {
self.key.get_clone()
}
pub fn get_key_required(&self) -> AnyhowResult<FalApiKey> {
self.key.get_clone_required()
}
pub fn clear_key(&self) -> AnyhowResult<()> {
self.key.clear()
}
/// Does not check the token for validity
pub fn has_apparent_api_token(&self) -> AnyhowResult<bool> {
self.key.is_some()
}
pub fn purge_api_key_from_disk(&self) -> Result<(), io::Error> {
let filename = self.app_data_root.credentials_dir().get_fal_api_key_file_path();
if filename.exists() && filename.is_file() {
fs::remove_file(filename)?;
}
Ok(())
}
pub fn persist_to_disk(&self) -> AnyhowResult<()> {
let key = match self.get_key() {
Ok(Some(key)) => key,
Ok(None) => {
self.purge_api_key_from_disk()?;
return Ok(());
},
Err(err) => {
return Err(anyhow!("Failed to get FAL key: {:?}", err));
}
};
let filename = self.app_data_root.credentials_dir().get_fal_api_key_file_path();
let mut file = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(filename)?;
file.write_all(key.0.as_bytes())?;
file.flush()?;
Ok(())
}
}
@@ -1,37 +0,0 @@
use crate::core::events::generation_events::common::GenerationAction;
use fal_client::model::fal_endpoint::FalEndpoint;
/// Adapter of generation types/categories for FAL
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FalGenerationType {
TextToImage,
ImageToImage,
TextToVideo,
ImageToVideo,
ImageTo3d,
ImageBackgroundRemoval,
}
impl FalGenerationType {
pub fn from_fal_endpoint(endpoint: &FalEndpoint) -> Option<Self> {
match endpoint {
FalEndpoint::FluxProUltraTextToImage => Some(Self::TextToImage),
FalEndpoint::Hunyuan3d2Base => Some(Self::ImageTo3d),
FalEndpoint::Kling16ImageToVideo => Some(Self::ImageToVideo),
FalEndpoint::Minimax01ImageToVideo => Some(Self::ImageToVideo),
FalEndpoint::RecraftV3TextToImage => Some(Self::TextToImage),
FalEndpoint::Other(_) => None,
}
}
pub fn to_event_generation_action(&self) -> GenerationAction {
match self {
Self::TextToImage => GenerationAction::GenerateImage,
Self::ImageToImage => GenerationAction::GenerateImage,
Self::TextToVideo => GenerationAction::GenerateVideo,
Self::ImageToVideo => GenerationAction::GenerateVideo,
Self::ImageTo3d => GenerationAction::ImageTo3d,
Self::ImageBackgroundRemoval => GenerationAction::RemoveBackground,
}
}
}
@@ -1,167 +0,0 @@
use crate::services::fal::state::fal_generation_type::FalGenerationType;
use chrono::{DateTime, Utc};
use errors::AnyhowResult;
use fal_client::model::enqueued_request::EnqueuedRequest;
use fal_client::model::fal_request_id::FalRequestId;
use indexmap::IndexMap;
use std::collections::HashSet;
use std::sync::{Arc, RwLock};
#[derive(Clone)]
pub struct FalTaskDetails {
pub enqueue_time: DateTime<Utc>,
pub enqueued_request: EnqueuedRequest,
pub generation_type: Option<FalGenerationType>,
}
impl FalTaskDetails {
pub fn for_request(request: EnqueuedRequest) -> Self {
Self {
enqueue_time: Utc::now(),
generation_type: FalGenerationType::from_fal_endpoint(&request.fal_endpoint),
enqueued_request: request,
}
}
}
#[derive(Clone)]
pub struct FalTaskQueue {
// Insertion-order map of task IDs to task details
queue: Arc<RwLock<IndexMap<FalRequestId, FalTaskDetails>>>
}
impl FalTaskQueue {
pub fn new() -> Self {
Self {
queue: Arc::new(RwLock::new(IndexMap::new()))
}
}
/// Determine if queue is empty
/// This is an O(1) operation.
pub fn is_empty(&self) -> AnyhowResult<bool> {
match self.queue.read() {
Err(err) => {
Err(anyhow::anyhow!("Failed to acquire read lock: {:?}", err))
}
Ok(lock) => Ok(lock.is_empty())
}
}
/// Get length of set
/// This is an O(1) operation.
pub fn len(&self) -> AnyhowResult<usize> {
match self.queue.read() {
Err(err) => {
Err(anyhow::anyhow!("Failed to acquire read lock: {:?}", err))
}
Ok(lock) => Ok(lock.len())
}
}
/// Get the task at an index.
/// This is an O(1) operation.
pub fn get_index(&self, i: usize) -> AnyhowResult<Option<FalTaskDetails>> {
match self.queue.read() {
Err(err) => {
Err(anyhow::anyhow!("Failed to acquire read lock: {:?}", err))
}
Ok(lock) => {
if i >= lock.len() {
Ok(None)
} else {
match lock.get_index(i) {
None => Ok(None),
Some((_key, value)) => {
Ok(Some(value.clone()))
}
}
}
}
}
}
/// Determine if the key is in the queue
/// This is an O(1) operation.
pub fn contains_key(&self, request_id: &FalRequestId) -> AnyhowResult<bool> {
match self.queue.read() {
Err(err) => {
Err(anyhow::anyhow!("Failed to acquire read lock: {:?}", err))
}
Ok(lock) => Ok(lock.contains_key(request_id))
}
}
/// Get the first enqueued task
/// This is an O(1) operation.
pub fn first(&self) -> AnyhowResult<Option<FalTaskDetails>> {
match self.queue.read() {
Err(err) => {
Err(anyhow::anyhow!("Failed to acquire read lock: {:?}", err))
}
Ok(lock) => match lock.first() {
None => Ok(None),
Some((request_id, task_details)) => Ok(Some(task_details.clone()))
}
}
}
/// Get the last enqueued task
/// This is an O(1) operation.
pub fn last(&self) -> AnyhowResult<Option<FalTaskDetails>> {
match self.queue.read() {
Err(err) => {
Err(anyhow::anyhow!("Failed to acquire read lock: {:?}", err))
}
Ok(lock) => match lock.last() {
None => Ok(None),
Some((request_id, task_details)) => Ok(Some(task_details.clone()))
}
}
}
/// Insert a new task. If a task already exists, we update its value.
/// This is an O(1) operation.
pub fn insert(&self, request: &EnqueuedRequest) -> AnyhowResult<Option<FalTaskDetails>> {
match self.queue.write() {
Err(err) => {
Err(anyhow::anyhow!("Failed to acquire write lock: {:?}", err))
}
Ok(mut lock) => {
// NB: Probably fine to compute TaskDetails here for now. We may eventually expose this if we include additional info.
let task_details = FalTaskDetails::for_request(request.clone());
Ok(lock.insert(request.request_id.clone(), task_details))
}
}
}
/// Remove a task by ID.
/// This is an O(n) operation.
pub fn remove(&self, request_id: &FalRequestId) -> AnyhowResult<Option<FalTaskDetails>> {
match self.queue.write() {
Err(err) => {
Err(anyhow::anyhow!("Failed to acquire write lock: {:?}", err))
}
Ok(mut lock) => {
Ok(lock.shift_remove(request_id))
}
}
}
/// Remove a list of tasks by ID.
/// This is an O(n) operation.
pub fn remove_list(&self, request_ids: &[&FalRequestId]) -> AnyhowResult<()> {
match self.queue.write() {
Err(err) => {
Err(anyhow::anyhow!("Failed to acquire write lock: {:?}", err))
}
Ok(mut lock) => {
// TODO: Cleaner way to do this?
let mut set : HashSet<&FalRequestId, _> = HashSet::with_capacity(request_ids.len());
set.extend(request_ids);
lock.retain(|k, v| !set.contains(k));
Ok(())
}
}
}
}
@@ -1,4 +0,0 @@
pub mod fal_credential_manager;
pub mod fal_task_queue;
pub mod read_fal_api_key_from_disk;
mod fal_generation_type;
@@ -1,19 +0,0 @@
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use anyhow::anyhow;
use errors::AnyhowResult;
use fal_client::creds::fal_api_key::FalApiKey;
use std::fs::read_to_string;
pub fn read_fal_api_key_from_disk(app_data_root: &AppDataRoot) -> AnyhowResult<FalApiKey> {
let api_key_file = app_data_root.credentials_dir().get_fal_api_key_file_path();
if !api_key_file.exists() {
return Err(anyhow!("API key file does not exist: {:?}", api_key_file));
}
let value = read_to_string(api_key_file)?
.trim()
.to_string();
Ok(FalApiKey::new(value))
}
@@ -1,227 +0,0 @@
use crate::core::events::basic_sendable_event_trait::BasicSendableEvent;
use crate::core::events::generation_events::common::GenerationServiceProvider;
use crate::core::events::generation_events::generation_complete_event::GenerationCompleteEvent;
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::core::utils::download_url_to_temp_dir::download_url_to_temp_dir;
use crate::services::fal::state::fal_credential_manager::FalCredentialManager;
use crate::services::fal::state::fal_task_queue::FalTaskQueue;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use errors::AnyhowResult;
use fal_client::export::queue::Status;
use fal_client::model::fal_request_id::FalRequestId;
use fal_client::utils::queue_status_checker::QueueStatusChecker;
use log::{error, info, warn};
use storyteller_client::recipes::upload_media_file_from_file::upload_media_file_from_file;
use storyteller_client::utils::api_host::ApiHost;
use tauri::AppHandle;
use tokens::tokens::media_files::MediaFileToken;
pub async fn fal_task_polling_thread(
app_handle: AppHandle,
app_env_configs: AppEnvConfigs,
app_data_root: AppDataRoot,
fal_creds_manager: FalCredentialManager,
fal_task_queue: FalTaskQueue,
storyteller_creds_manager: StorytellerCredentialManager,
) -> ! {
loop {
let res = polling_loop(
&app_handle,
&app_env_configs,
&fal_creds_manager,
&fal_task_queue,
&storyteller_creds_manager,
&app_data_root,
).await;
if let Err(err) = res {
error!("An error occurred: {:?}", err);
}
tokio::time::sleep(std::time::Duration::from_millis(30_000)).await;
}
}
async fn polling_loop(
app_handle: &AppHandle,
app_env_configs: &AppEnvConfigs,
fal_creds_manager: &FalCredentialManager,
fal_task_queue: &FalTaskQueue,
storyteller_creds_manager: &StorytellerCredentialManager,
app_data_root: &AppDataRoot,
) -> AnyhowResult<()> {
loop {
if fal_task_queue.is_empty()? {
// No need to poll if we don't have pending tasks.
tokio::time::sleep(std::time::Duration::from_millis(1_000)).await;
continue;
}
info!("Task queue has {} pending tasks.", fal_task_queue.len()?);
let key = fal_creds_manager.get_key_required()?;
let storyteller_creds = storyteller_creds_manager.get_credentials_required()?;
let queue_status_checker = QueueStatusChecker::new(key);
let mut succeeded_tasks = Vec::new();
//let mut failed_tasks = Vec::new(); // TODO
let len = fal_task_queue.len()?;
for i in 0..len {
let task = match fal_task_queue.get_index(i)? {
Some(task) => task,
None => {
warn!("Failed to get task ID at index {}", i);
break;
}
};
let task_id = task.enqueued_request.request_id.clone();
info!("Checking task status for task ID: {:?}", &task_id);
let result = queue_status_checker.check_status(&task.enqueued_request).await;
let url = match result {
Err(err) => {
warn!("Error checking job status: {:?}", err);
tokio::time::sleep(std::time::Duration::from_millis(2_000)).await;
// TODO: Some failures will be permanent job failures and the jobs will need to be removed.
continue;
}
Ok(status) => {
info!("Status: {:?}", status);
match status.status {
Status::Completed => {
let result = queue_status_checker.get_download_url(&task.enqueued_request).await;
let url = match result {
Ok(url) => url,
Err(err) => {
warn!("Failed to get download url: {:?}", err);
// TODO: Some failures will be permanent job failures.
continue;
}
};
url
}
_ => {
continue;
},
}
}
};
// TODO: Clean up
let download_file = download_url_to_temp_dir(&url, &app_data_root).await?;
let result = upload_media_file_from_file(
&app_env_configs.storyteller_host,
Some(&storyteller_creds),
download_file,
None, // TODO: Should probably save prompts.
).await;
match result {
Ok(success) => {
info!("Uploaded to API backend: {:?}", success.media_file_token);
let event = GenerationCompleteEvent {
//media_file_token: result.media_file_token,
action: task.generation_type
.map(|typ| typ.to_event_generation_action()),
service: GenerationServiceProvider::Fal,
model: None,
};
if let Err(err) = event.send(&app_handle) {
error!("Failed to send GenerationCompleteEvent: {:?}", err); // Fail open
}
succeeded_tasks.push(task_id);
}
Err(err) => {
warn!("Failed to upload media file: {:?}", err);
continue;
}
}
}
// // TODO: The cursoring logic likely needs to improve.
// for task in response.task_responses {
// let status = TaskStatus::from_str(&task.status);
//
// match status {
// TaskStatus::Succeeded => {
// succeeded_tasks.push(task);
// }
// TaskStatus::Failed => {
// failed_tasks.push(task);
// }
// TaskStatus::Queued => {}
// TaskStatus::Running => {}
// TaskStatus::Unknown(_) => {}
// }
// }
//
// for task in failed_tasks.iter() {
// if fal_task_queue.contains_key(&task.id)? {
// let event = SoraImageGenerationFailedEvent {
// prompt: task.prompt.clone(),
// };
// event.send(&app_handle)?;
// }
// }
//
// let failed_task_ids: Vec<&TaskId> = failed_tasks
// .iter()
// .map(|task| &task.id)
// .collect();
//
// fal_task_queue.remove_list(&failed_task_ids)?;
//
// let creds = storyteller_creds_manager.get_credentials_required()?;
// let api_host = ApiHost::Storyteller;
//
// for task in succeeded_tasks.iter() {
// if !fal_task_queue.contains_key(&task.id)? {
// continue;
// }
// info!("Task succeeded: {:?}", task.id);
//
// for (i, generation) in task.generations.iter().enumerate() {
// info!("Downloading generated file...");
// let download_path = download_generation(generation, &app_data_root).await?;
//
// info!("Uploading to backend...");
// let result = upload_image_media_file_from_file(&api_host, Some(&creds), download_path).await?;
//
// info!("Uploaded to API backend: {:?}", result.media_file_token);
//
// let event = SoraImageGenerationCompleteEvent {
// media_file_token: result.media_file_token,
// };
//
// event.send(&app_handle)?;
// }
// }
//
let succeeded_task_ids : Vec<&FalRequestId> = succeeded_tasks
.iter()
.map(|task| task)
.collect::<Vec<_>>();
fal_task_queue.remove_list(&succeeded_task_ids)?;
tokio::time::sleep(std::time::Duration::from_millis(1_000)).await;
}
}
@@ -1 +0,0 @@
pub mod fal_task_polling_thread;
@@ -1,12 +1,10 @@
use crate::core::commands::response::shorthand::{Response, SimpleResponse};
use crate::services::fal::state::fal_credential_manager::FalCredentialManager;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::services::midjourney::state::midjourney_credential_manager::MidjourneyCredentialManager;
use errors::AnyhowResult;
use fal_client::creds::fal_api_key::FalApiKey;
use log::{error, info};
use serde_derive::Deserialize;
use tauri::State;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
#[tauri::command]
pub async fn midjourney_clear_credentials_command(
@@ -1,6 +1,5 @@
use crate::core::commands::response::shorthand::{Response, ResponseOrErrorMessage};
use crate::core::commands::response::success_response_wrapper::SerializeMarker;
use crate::services::fal::state::fal_credential_manager::FalCredentialManager;
use crate::services::midjourney::state::midjourney_credential_manager::MidjourneyCredentialManager;
use errors::AnyhowResult;
use log::{error, info};
@@ -1,4 +1,3 @@
pub mod fal;
pub mod midjourney;
pub mod sora;
pub mod storyteller;
@@ -1,6 +1,5 @@
use crate::core::commands::response::shorthand::{Response, ResponseOrErrorMessage};
use crate::core::commands::response::success_response_wrapper::SerializeMarker;
use crate::services::fal::state::fal_credential_manager::FalCredentialManager;
use crate::services::midjourney::state::midjourney_credential_manager::MidjourneyCredentialManager;
use crate::services::sora::state::sora_credential_manager::SoraCredentialManager;
use errors::AnyhowResult;
@@ -17,26 +17,30 @@ use crate::services::storyteller::threads::events::maybe_handle_text_to_image_co
use crate::services::storyteller::threads::events::maybe_send_background_removal_complete_event::maybe_send_background_removal_complete_event;
use anyhow::anyhow;
use artcraft_api_defs::jobs::list_session_jobs::ListSessionJobsItem;
use enums::by_table::generic_inference_jobs::inference_category::InferenceCategory;
use enums::common::generation_provider::GenerationProvider;
use enums::common::job_status_plus::JobStatusPlus;
use enums::tauri::tasks::task_media_file_class::TaskMediaFileClass;
use enums::tauri::tasks::task_status::TaskStatus;
use enums::tauri::tasks::task_type::TaskType;
use errors::AnyhowResult;
use log::{error, info};
use reqwest::Url;
use sqlite_tasks::queries::list_tasks_by_provider_and_tokens::{list_tasks_by_provider_and_tokens, ListTasksArgs, Task};
use sqlite_tasks::queries::update_successful_task_status_with_metadata::{update_successful_task_status_with_metadata, UpdateSuccessfulTaskArgs};
use sqlite_tasks::queries::update_task_status::{update_task_status, UpdateTaskArgs};
use std::collections::HashMap;
use std::fs::File;
use std::io::Write;
use std::path::PathBuf;
use storyteller_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use storyteller_client::error::api_error::ApiError;
use storyteller_client::error::storyteller_error::StorytellerError;
use storyteller_client::endpoints::jobs::list_session_jobs::{list_session_jobs, States};
use storyteller_client::endpoints::media_files::upload_image_media_file_from_file::upload_image_media_file_from_file;
use storyteller_client::error::api_error::ApiError;
use storyteller_client::error::storyteller_error::StorytellerError;
use tauri::AppHandle;
use tempdir::TempDir;
use tokens::tokens::media_files::MediaFileToken;
pub async fn storyteller_task_polling_thread(
app_handle: AppHandle,
@@ -134,10 +138,23 @@ async fn polling_loop(
_ => {} // Fall-through.
}
let updated = update_task_status(UpdateTaskArgs {
let maybe_primary_media_file_token = job.maybe_result
.as_ref()
.map(|result| MediaFileToken::new_from_str(&result.entity_token));
let updated = update_successful_task_status_with_metadata(UpdateSuccessfulTaskArgs {
db: task_database.get_connection(),
task_id: &task.id,
status: TaskStatus::CompleteSuccess,
maybe_batch_token: job.maybe_result
.as_ref()
.map(|result| result.maybe_batch_token.as_ref())
.flatten(),
maybe_primary_media_file_token: maybe_primary_media_file_token.as_ref(),
maybe_primary_media_file_class: get_media_file_class(&job),
maybe_primary_media_file_thumbnail_url_template: get_thumbnail_template(&job),
maybe_primary_media_file_cdn_url: job.maybe_result
.as_ref()
.map(|result| result.media_links.cdn_url.as_str()),
}).await?;
if !updated {
@@ -212,3 +229,60 @@ async fn send_additional_events(
error!("Failed to send background removal complete event: {:?}", err);
}
}
fn get_thumbnail_template<'a>(job: &'a ListSessionJobsItem) -> Option<&'a str> {
let links = match job.maybe_result.as_ref() {
None => return None,
Some(result) => &result.media_links,
};
// NB: We only populate video previews for video tasks.
if let Some(video) = links.maybe_video_previews.as_ref() {
return Some(&video.animated_thumbnail_template);
}
// Last option - use the image thumbnail template if it exists.
links.maybe_thumbnail_template.as_deref()
}
fn get_media_file_class<'a>(job: &'a ListSessionJobsItem) -> Option<TaskMediaFileClass> {
match job.request.inference_category {
InferenceCategory::BackgroundRemoval => return Some(TaskMediaFileClass::Image),
InferenceCategory::ImageGeneration => return Some(TaskMediaFileClass::Image),
InferenceCategory::VideoGeneration => return Some(TaskMediaFileClass::Video),
InferenceCategory::ObjectGeneration => return Some(TaskMediaFileClass::Dimensional),
_ => {}, // Fall-through
}
let result = match job.maybe_result.as_ref() {
None => return None,
Some(result) => result,
};
let url = result.media_links.cdn_url.as_str();
if url.ends_with("jpg")
|| url.ends_with("jpeg")
|| url.ends_with("png")
{
return Some(TaskMediaFileClass::Image);
}
if url.ends_with("mp4")
|| url.ends_with("webm")
{
return Some(TaskMediaFileClass::Video);
}
if url.ends_with("glb") {
return Some(TaskMediaFileClass::Dimensional);
}
if url.ends_with("wav")
|| url.ends_with("mp3")
{
return Some(TaskMediaFileClass::Audio);
}
None
}
@@ -2,10 +2,13 @@ use crate::connection::TaskDbConnection;
use crate::error::SqliteTasksError;
use chrono::{DateTime, Utc};
use enums::common::generation_provider::GenerationProvider;
use enums::tauri::tasks::task_media_file_class::TaskMediaFileClass;
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::batch_generations::BatchGenerationToken;
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::sqlite::tasks::TaskId;
pub struct TaskList {
@@ -22,6 +25,11 @@ pub struct TaskItem {
pub frontend_caller: Option<TauriCommandCaller>,
pub frontend_subscriber_id: Option<String>,
pub frontend_subscriber_payload: Option<String>,
pub on_complete_primary_media_file_token: Option<MediaFileToken>,
pub on_complete_primary_media_file_class: Option<TaskMediaFileClass>,
pub on_complete_batch_token: Option<BatchGenerationToken>,
pub on_complete_primary_media_file_cdn_url: Option<String>,
pub on_complete_primary_media_file_thumbnail_url_template: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub completed_at: Option<DateTime<Utc>>,
@@ -43,6 +51,11 @@ pub async fn list_tasks_for_frontend(
frontend_caller,
frontend_subscriber_id,
frontend_subscriber_payload,
on_complete_primary_media_file_token,
on_complete_primary_media_file_class,
on_complete_batch_token,
on_complete_primary_media_file_cdn_url,
on_complete_primary_media_file_thumbnail_url_template,
created_at as "created_at: DateTime<Utc>",
updated_at as "updated_at: DateTime<Utc>",
completed_at as "completed_at: DateTime<Utc>"
@@ -72,6 +85,13 @@ pub async fn list_tasks_for_frontend(
.transpose()?,
frontend_subscriber_id: raw.frontend_subscriber_id,
frontend_subscriber_payload: raw.frontend_subscriber_payload,
on_complete_primary_media_file_token: raw.on_complete_primary_media_file_token.map(|t| MediaFileToken::new_from_str(&t)),
on_complete_primary_media_file_class: raw.on_complete_primary_media_file_class
.map(|c| TaskMediaFileClass::from_str(&c))
.transpose()?,
on_complete_batch_token: raw.on_complete_batch_token.map(|t| BatchGenerationToken::new_from_str(&t)),
on_complete_primary_media_file_cdn_url: raw.on_complete_primary_media_file_cdn_url,
on_complete_primary_media_file_thumbnail_url_template: raw.on_complete_primary_media_file_thumbnail_url_template,
created_at: raw.created_at,
updated_at: raw.updated_at,
completed_at: raw.completed_at,
@@ -93,6 +113,11 @@ struct TaskItemRaw {
frontend_caller: Option<String>,
frontend_subscriber_id: Option<String>,
frontend_subscriber_payload: Option<String>,
on_complete_primary_media_file_token: Option<String>,
on_complete_primary_media_file_class: Option<String>,
on_complete_batch_token: Option<String>,
on_complete_primary_media_file_cdn_url: Option<String>,
on_complete_primary_media_file_thumbnail_url_template: Option<String>,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
completed_at: Option<DateTime<Utc>>,
@@ -3,4 +3,5 @@ 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_successful_task_status_with_metadata;
pub mod update_task_status;
@@ -0,0 +1,59 @@
use crate::connection::TaskDbConnection;
use crate::error::SqliteTasksError;
use enums::tauri::tasks::task_media_file_class::TaskMediaFileClass;
use enums::tauri::tasks::task_status::TaskStatus;
use tokens::tokens::batch_generations::BatchGenerationToken;
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::sqlite::tasks::TaskId;
const SUCCESSFUL_STATUS : &str = TaskStatus::CompleteSuccess.to_str();
pub struct UpdateSuccessfulTaskArgs<'a> {
pub db: &'a TaskDbConnection,
pub task_id: &'a TaskId,
pub maybe_batch_token: Option<&'a BatchGenerationToken>,
pub maybe_primary_media_file_token: Option<&'a MediaFileToken>,
pub maybe_primary_media_file_class: Option<TaskMediaFileClass>,
pub maybe_primary_media_file_cdn_url: Option<&'a str>,
pub maybe_primary_media_file_thumbnail_url_template: Option<&'a str>,
}
/// Returns true if rows were updated.
pub async fn update_successful_task_status_with_metadata(
args: UpdateSuccessfulTaskArgs<'_>,
) -> Result<bool, SqliteTasksError> {
// TODO(bt,2025-07-12): Fix this. The sqlx mysql queries never required temporaries
let task_id_temp = args.task_id.as_str();
let maybe_batch_token_temp = args.maybe_batch_token.map(|t| t.as_str());
let maybe_primary_media_token_temp = args.maybe_primary_media_file_token.map(|t| t.as_str());
let maybe_primary_media_class = args.maybe_primary_media_file_class.map(|c| c.to_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
task_status = ?,
on_complete_batch_token = ?,
on_complete_primary_media_file_token = ?,
on_complete_primary_media_file_class = ?,
on_complete_primary_media_file_cdn_url = ?,
on_complete_primary_media_file_thumbnail_url_template = ?
WHERE id = ?
"#,
SUCCESSFUL_STATUS,
maybe_batch_token_temp,
maybe_primary_media_token_temp,
maybe_primary_media_class,
args.maybe_primary_media_file_cdn_url,
args.maybe_primary_media_file_thumbnail_url_template,
task_id_temp,
);
// info!("query: {:?}", query.sql());
let res = query.execute(args.db.get_pool()).await?;
let rows_updated = res.rows_affected() > 0;
Ok(rows_updated)
}
@@ -1,3 +1,4 @@
pub mod task_media_file_class;
pub mod task_model_type;
pub mod task_status;
pub mod task_type;
@@ -0,0 +1,140 @@
use std::collections::BTreeSet;
use crate::error::enum_error::EnumError;
#[cfg(test)]
use strum::EnumCount;
#[cfg(test)]
use strum::EnumIter;
use utoipa::ToSchema;
#[cfg_attr(test, derive(EnumIter, EnumCount))]
#[derive(Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum TaskMediaFileClass {
/// Audio files: wav, mp3, etc.
Audio,
/// Image files: png, jpeg, etc.
Image,
/// Video files: mp4, etc.
Video,
/// 3D engine data: glb, gltf, etc.
Dimensional,
}
impl_enum_display_and_debug_using_to_str!(TaskMediaFileClass);
//impl_mysql_enum_coders!(TaskMediaFileType);
//impl_mysql_from_row!(TaskMediaFileType);
// NB: We can derive `sqlx::Type` instead of using `impl_mysql_enum_coders`
impl TaskMediaFileClass {
pub fn to_str(&self) -> &'static str {
match self {
Self::Audio => "audio",
Self::Image => "image",
Self::Video => "video",
Self::Dimensional => "dimensional",
}
}
pub fn from_str(value: &str) -> Result<Self, EnumError> {
match value {
"audio" => Ok(Self::Audio),
"image" => Ok(Self::Image),
"video" => Ok(Self::Video),
"dimensional" => Ok(Self::Dimensional),
_ => Err(EnumError::CouldNotConvertFromString(value.to_string())),
}
}
pub fn all_variants() -> BTreeSet<Self> {
// NB: BTreeSet is sorted
// NB: BTreeSet::from() isn't const, but not worth using LazyStatic, etc.
BTreeSet::from([
Self::Audio,
Self::Image,
Self::Video,
Self::Dimensional,
])
}
}
#[cfg(test)]
mod tests {
use crate::tauri::tasks::task_media_file_class::TaskMediaFileClass;
use crate::test_helpers::assert_serialization;
mod explicit_checks {
use super::*;
use crate::error::enum_error::EnumError;
#[test]
fn test_serialization() {
assert_serialization(TaskMediaFileClass::Audio, "audio");
assert_serialization(TaskMediaFileClass::Image, "image");
assert_serialization(TaskMediaFileClass::Video, "video");
assert_serialization(TaskMediaFileClass::Dimensional, "dimensional");
}
#[test]
fn to_str() {
assert_eq!(TaskMediaFileClass::Audio.to_str(), "audio");
assert_eq!(TaskMediaFileClass::Image.to_str(), "image");
assert_eq!(TaskMediaFileClass::Video.to_str(), "video");
assert_eq!(TaskMediaFileClass::Dimensional.to_str(), "dimensional");
}
#[test]
fn from_str() {
assert_eq!(TaskMediaFileClass::from_str("audio").unwrap(), TaskMediaFileClass::Audio);
assert_eq!(TaskMediaFileClass::from_str("image").unwrap(), TaskMediaFileClass::Image);
assert_eq!(TaskMediaFileClass::from_str("video").unwrap(), TaskMediaFileClass::Video);
assert_eq!(TaskMediaFileClass::from_str("dimensional").unwrap(), TaskMediaFileClass::Dimensional);
}
#[test]
fn from_str_err() {
let result = TaskMediaFileClass::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 = TaskMediaFileClass::all_variants();
assert_eq!(variants.len(), 4);
assert_eq!(variants.pop_first(), Some(TaskMediaFileClass::Audio));
assert_eq!(variants.pop_first(), Some(TaskMediaFileClass::Image));
assert_eq!(variants.pop_first(), Some(TaskMediaFileClass::Video));
assert_eq!(variants.pop_first(), Some(TaskMediaFileClass::Dimensional));
assert_eq!(variants.pop_first(), None);
}
}
mod mechanical_checks {
use super::*;
#[test]
fn variant_length() {
use strum::IntoEnumIterator;
assert_eq!(TaskMediaFileClass::all_variants().len(), TaskMediaFileClass::iter().len());
}
#[test]
fn round_trip() {
for variant in TaskMediaFileClass::all_variants() {
// Test to_str(), from_str(), Display, and Debug.
assert_eq!(variant, TaskMediaFileClass::from_str(variant.to_str()).unwrap());
assert_eq!(variant, TaskMediaFileClass::from_str(&format!("{}", variant)).unwrap());
assert_eq!(variant, TaskMediaFileClass::from_str(&format!("{:?}", variant)).unwrap());
}
}
}
}
@@ -35,7 +35,7 @@ impl Default for TaskStatus {
}
impl TaskStatus {
pub fn to_str(&self) -> &'static str {
pub const fn to_str(&self) -> &'static str {
match self {
Self::Pending => "pending",
Self::Started => "started",
@@ -42,11 +42,9 @@ memory_caching = { path = "../../../lib/caching/memory_caching" }
migration = { path = "../../../lib/migration" }
mimetypes = { path = "../../../lib/files/mimetypes" }
mysql_queries = { path = "../../../schema/database/mysql_queries" }
openai_sora_client = { path = "../../../api_clients/openai_sora_client" }
primitives.workspace = true
redis_common = { path = "../../../schema/database/redis_common" }
redis_schema = { path = "../../../schema/database/redis_schema" }
shared_service_components = { workspace = true }
subprocess_common = { path = "../../../lib/subprocess_common" }
thumbnail_generator = { path = "../../../api_clients/thumbnail_generator"}
tokens.workspace = true
@@ -35,6 +35,8 @@ pub async fn main_loop(job_dependencies: JobDependencies) {
let mut sort_by_priority_count = 0;
while !job_dependencies.job.system.application_shutdown.get() {
info!("Job loop iteration starting...");
if let Some(pause_file) = job_dependencies.fs.maybe_pause_file.as_deref() {
while file_exists(pause_file) {
warn!("Pause file exists. Pausing until deleted: {:?}", pause_file);
@@ -53,6 +55,8 @@ pub async fn main_loop(job_dependencies: JobDependencies) {
let batch_query_start_time = Instant::now();
info!("Querying jobs...");
let maybe_available_jobs = list_available_generic_inference_jobs(ListAvailableGenericInferenceJobArgs {
num_records: job_dependencies.job.system.job_batch_size,
is_debug_worker: false, // TODO
@@ -117,6 +121,8 @@ pub async fn main_loop(job_dependencies: JobDependencies) {
error_timeout_millis = START_TIMEOUT_MILLIS; // reset
info!("Job loop batch processing complete. Sleeping {} ms", job_dependencies.job.system.job_batch_wait_millis);
std::thread::sleep(Duration::from_millis(job_dependencies.job.system.job_batch_wait_millis));
}
@@ -192,6 +198,8 @@ async fn process_job_batch(job_dependencies: &JobDependencies, jobs: Vec<Availab
job_duration.as_millis() as u64,
&job_duration_instrumentation_attributes);
}
info!("Process job batch loop ended.");
Ok(())
}
@@ -1,4 +1,3 @@
use crate::job::job_types::image_generation::v2::process_single_sora_job::process_single_sora_job;
use std::time::Instant;
use anyhow::anyhow;
@@ -21,7 +20,6 @@ use crate::job::job_types::f5_tts::process_single_f5_tts_job::process_single_f5_
use crate::job::job_types::format_conversion::process_single_format_conversion_job::process_single_format_conversion_job;
use crate::job::job_types::gpt_sovits::process_single_gpt_sovits_job::process_single_gpt_sovits_job;
use crate::job::job_types::image_generation::process_single_ig_job::process_single_ig_job;
use crate::job::job_types::image_generation::v2::process_single_sora_job;
use crate::job::job_types::lipsync::process_single_lipsync_job::process_single_lipsync_job;
use crate::job::job_types::mocap::process_single_mc_job::process_single_mc_job;
use crate::job::job_types::render_engine_scene::process_render_engine_scene::process_single_render_engine_scene_job;
@@ -1,3 +1,2 @@
pub mod process_single_ig_job;
pub mod sd;
pub mod v2;
pub mod sd;
@@ -1,14 +1,13 @@
use log::info;
use enums::by_table::generic_inference_jobs::inference_job_type::InferenceJobType;
use enums::by_table::generic_inference_jobs::inference_model_type::InferenceModelType;
use errors::anyhow;
use log::info;
use mysql_queries::queries::generic_inference::job::list_available_generic_inference_jobs::AvailableInferenceJob;
use crate::job::job_loop::job_success_result::JobSuccessResult;
use crate::job::job_loop::process_single_job_error::ProcessSingleJobError;
use crate::job::job_types::image_generation::sd::process_job::process_job_selection;
use crate::job::job_types::image_generation::sd::process_job::StableDiffusionProcessArgs;
use crate::job::job_types::image_generation::v2::process_single_sora_job::{process_single_sora_job, ImageGenerationProcessArgs};
use crate::state::job_dependencies::JobDependencies;
pub async fn process_single_ig_job(job_dependencies: &JobDependencies, job: &AvailableInferenceJob) -> Result<JobSuccessResult, ProcessSingleJobError> {
@@ -26,9 +25,7 @@ pub async fn process_single_ig_job(job_dependencies: &JobDependencies, job: &Ava
match job.job_type {
InferenceJobType::ImageGenApi => {
info!("Processing image job as ImageGenApi");
let args = ImageGenerationProcessArgs { job_dependencies, job };
process_single_sora_job(&args).await
Err(ProcessSingleJobError::Other(anyhow!("ImageGenAPI (Sora) jobs are no longer supported")))
},
_ => {
info!("Processing image job as **NOT** ImageGenApi. Type: {:?}", job.job_type);
@@ -1 +0,0 @@
pub mod process_single_sora_job;
@@ -1,287 +0,0 @@
use std::time::Instant;
use anyhow::anyhow;
use bucket_paths::legacy::remote_file_manager_paths::media_descriptor::MediaImagePngDescriptor;
use enums::by_table::generic_inference_jobs::inference_result_type::InferenceResultType;
use enums::by_table::generic_synthetic_ids::id_category::IdCategory;
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory;
use enums::by_table::media_files::media_file_origin_model_type::MediaFileOriginModelType;
use enums::by_table::media_files::media_file_origin_product_category::MediaFileOriginProductCategory;
use enums::by_table::media_files::media_file_type::MediaFileType;
use mysql_queries::payloads::generic_inference_args::generic_inference_args::PolymorphicInferenceArgs;
use mysql_queries::queries::media_files::create::generic_insert::insert_media_file_generic_from_job::{insert_media_file_generic_from_job, InsertFromJobArgs};
use crate::job::job_loop::job_success_result::{JobSuccessResult, ResultEntity};
use crate::job::job_loop::process_single_job_error::ProcessSingleJobError;
use crate::state::job_dependencies::JobDependencies;
use crate::util::extractors::get_polymorphic_args_from_job::get_polymorphic_args_from_job;
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
use cloud_storage::remote_file_manager::remote_cloud_file_manager::RemoteCloudFileClient;
use composite_identifiers::by_table::batch_generations::batch_generation_entity::BatchGenerationEntity;
use errors::AnyhowResult;
use filesys::file_read_bytes::file_read_bytes;
use hashing::sha256::sha256_hash_file::sha256_hash_file;
use log::info;
use mimetypes::mimetype_for_bytes::get_mimetype_for_bytes;
use mysql_queries::queries::batch_generations::insert_batch_generation_records::{insert_batch_generation_records, InsertBatchArgs};
use mysql_queries::queries::generic_inference::job::list_available_generic_inference_jobs::AvailableInferenceJob;
use openai_sora_client::credentials::SoraCredentials;
use openai_sora_client::recipes::wait_for_image_gen_status::wait_for_image_gen_status;
use openai_sora_client::requests::image_gen::sora_job_status::{Generation, TaskStatus};
use r2d2_redis::r2d2::PooledConnection;
use r2d2_redis::redis::Commands;
use r2d2_redis::RedisConnectionManager;
use shared_service_components::sora_redis_credentials::get_sora_credentials_from_redis::get_sora_credentials_from_redis;
use std::collections::HashMap;
use std::fs::File;
use std::io::Write;
use std::path::PathBuf;
use tempdir::TempDir;
use tokens::tokens::batch_generations::BatchGenerationToken;
use url::Url;
const IMAGE_PREFIX : &str = "image_";
const IMAGE_SUFFIX : &str = ".png"; // TODO: Vary based on filetype.
pub struct ImageGenerationProcessArgs<'a> {
pub job_dependencies: &'a JobDependencies,
pub job: &'a AvailableInferenceJob,
}
pub struct ImageGenerationArgs {
pub number_of_samples: u32,
pub sora_task_id: String,
}
pub async fn ig_args_from_job(args: &ImageGenerationProcessArgs<'_>) -> Result<ImageGenerationArgs, ProcessSingleJobError> {
let polymorphic_args = get_polymorphic_args_from_job(&args.job)?;
let job_args = match polymorphic_args {
PolymorphicInferenceArgs::Sg(args) => args,
_ => {
return Err(
ProcessSingleJobError::from_anyhow_error(anyhow!("wrong inner args for job!"))
);
}
};
let number_of_samples = job_args.maybe_number_of_samples.unwrap_or(1);
let sora_task_id = job_args.maybe_sora_task_id.clone().ok_or_else(|| ProcessSingleJobError::from_anyhow_error(anyhow!("Missing Sora Task ID")))?;
Ok(ImageGenerationArgs {
number_of_samples,
sora_task_id,
})
}
pub async fn process_single_sora_job(args: &ImageGenerationProcessArgs<'_>) -> Result<JobSuccessResult, ProcessSingleJobError> {
let job = args.job;
let deps = args.job_dependencies;
let mysql_pool = &deps.db.mysql_pool;
let ig_args = ig_args_from_job(&args).await?;
let _job_progress_reporter = args.job_dependencies.clients.job_progress_reporter
.new_generic_inference(job.inference_job_token.as_str())
.map_err(|e| ProcessSingleJobError::Other(anyhow!(e)))?;
//==================== TEMP DIR ==================== //
let work_temp_dir = format!("temp_image_generation_{}", job.id.0);
//NB: TempDir exists until it goes out of scope, at which point it should delete from filesystem.
let work_temp_dir = args.job_dependencies.fs.scoped_temp_dir_creator_for_work
.new_tempdir(&work_temp_dir)
.map_err(|e| ProcessSingleJobError::from_io_error(e))?;
// thread::sleep(seconds) to check the directory
let inference_start_time = Instant::now();
let redis_pool = deps
.db
.maybe_keepalive_redis_pool
.as_ref()
.ok_or_else(|| ProcessSingleJobError::Other(anyhow!("failed to get redis pool")))?;
let redis = &mut redis_pool
.get()
.map_err(|e| ProcessSingleJobError::Other(anyhow!(e)))?;
let sora_credentials = get_sora_credentials_from_redis(redis)?;
let sora_task_id = ig_args.sora_task_id.clone();
let sora_task_response = wait_for_image_gen_status(
&sora_task_id,
&sora_credentials,
None,
).await.map_err(|e| ProcessSingleJobError::Other(anyhow!(e)))?;
match TaskStatus::from_str(&sora_task_response.status) {
TaskStatus::Succeeded => {} // Success case. Fall-through.
TaskStatus::Failed => {
return Err(ProcessSingleJobError::Other(anyhow!("Task failed: {:?}", sora_task_response)));
}
_ => {
return Err(ProcessSingleJobError::Other(anyhow!("Task not completed yet: {:?}", sora_task_response)));
}
}
let generations = sora_task_response.generations;
if generations.is_empty() {
return Err(ProcessSingleJobError::Other(anyhow!("No generations found in task response")));
}
let inference_duration = Instant::now().duration_since(inference_start_time);
let remote_cloud_file_client = &deps.buckets.public_bucket_client;
let mut entries = vec![];
let batch_token = BatchGenerationToken::generate();
let mut maybe_first_media_file_token = None;
for (i, generation) in generations.iter().enumerate() {
info!("Downloading generation {} of {} from Sora task ID: {}", i + 1, generations.len(), &sora_task_id);
let download_path = download_generation(&generation, &work_temp_dir).await?;
info!("Sora image was downloaded to filesystem path: {:?}", &download_path);
info!("Reading file bytes and metadata...");
let bytes = file_read_bytes(&download_path)?;
let sha256_checksum = sha256_hash_file(&download_path)?;
let mimetype: &str = get_mimetype_for_bytes(&bytes).unwrap_or("application/octet-stream");
let media_file_bucket_path =
MediaFileBucketPath::generate_new(Some(IMAGE_PREFIX), Some(IMAGE_SUFFIX));
remote_cloud_file_client.upload_filename_with_content_type(
media_file_bucket_path.to_full_object_pathbuf(),
&download_path,
mimetype,
).await?;
// TODO(ks: 2025-04-08): This should insert the media file token and maybe additional metadata about the generation
let (media_file_token, _id) = insert_media_file_generic_from_job(InsertFromJobArgs {
pool: mysql_pool,
job,
media_class: MediaFileClass::Image,
media_type: MediaFileType::Png, // TODO(bt,2024-04-30): Verify that these are png images.
origin_category: MediaFileOriginCategory::Inference,
origin_product_category: MediaFileOriginProductCategory::ImageGeneration,
maybe_origin_model_type: Some(MediaFileOriginModelType::StorytellerStudioImageGen),
maybe_origin_model_token: None,
maybe_origin_filename: None, // TODO: Need to grab this
maybe_batch_token: Some(&batch_token),
maybe_prompt_token: None,
maybe_mime_type: Some(mimetype.as_ref()),
file_size_bytes: bytes.len() as u64,
maybe_duration_millis: Some(inference_duration.as_millis() as u64),
maybe_audio_encoding: None,
maybe_video_encoding: None,
maybe_frame_width: Some(1024), // TODO: Need to grab this
maybe_frame_height: Some(1024), // TODO: Need to grab this
checksum_sha2: sha256_checksum.as_str(),
maybe_text_transcript: None,
public_bucket_directory_hash: media_file_bucket_path.get_object_hash(),
maybe_public_bucket_prefix: media_file_bucket_path.get_optional_prefix(),
maybe_public_bucket_extension: media_file_bucket_path.get_optional_extension(),
maybe_extra_media_info: None,
maybe_creator_file_synthetic_id_category: IdCategory::MediaFile,
maybe_creator_category_synthetic_id_category: IdCategory::MediaFile,
maybe_mod_user_token: None,
is_generated_on_prem: false,
generated_by_worker: Some(&args.job_dependencies.job.info.container.hostname),
generated_by_cluster: Some(&args.job_dependencies.job.info.container.cluster_name),
maybe_title: None,
maybe_scene_source_media_file_token: None,
is_intermediate_system_file: false,
})
.await?;
if maybe_first_media_file_token.is_none() {
maybe_first_media_file_token = Some(media_file_token.clone());
}
let batch_generation_entity: BatchGenerationEntity = BatchGenerationEntity::MediaFile(
media_file_token
);
entries.push(batch_generation_entity);
}
// TODO(bt,2024-02-22): This transaction should wrap everything
let mut transaction = mysql_pool.begin()
.await
.map_err(|e| ProcessSingleJobError::from_anyhow_error(anyhow!(e)))?;
let batch_token_result = insert_batch_generation_records(InsertBatchArgs {
entries,
maybe_existing_batch_token: Some(&batch_token),
transaction: &mut transaction,
}).await;
let _batch_token = match batch_token_result {
Ok(v) => { v.to_string() }
Err(_err) => {
return Err(
ProcessSingleJobError::from_anyhow_error(
anyhow!("No batch token? something has failed.")
)
);
}
};
// NB: Don't fail the job if the query fails.
// let prompt_result = insert_prompt(InsertPromptArgs {
// maybe_apriori_prompt_token: Some(&prompt_token),
// prompt_type: PromptType::StableDiffusion,
// maybe_creator_user_token: job.maybe_creator_user_token_typed.as_ref(),
// maybe_positive_prompt: Some(&positive_prompt),
// maybe_negative_prompt: maybe_negative_prompt.as_deref(),
// maybe_other_args: None, // TODO(bt,2024-02-22): Support other arguments
// creator_ip_address: &job.creator_ip_address,
// mysql_executor: &mut *transaction,
// phantom: Default::default(),
// }).await;
// match prompt_result {
// Ok(_token) => {}
// Err(err) => {
// error!("No prompt result token? something has failed: {:?}", err);
// return Err(ProcessSingleJobError::from_anyhow_error(anyhow!("No prompt result token? something has failed.")));
// }
// }
let _r = transaction.commit().await.map_err(|e| ProcessSingleJobError::from_anyhow_error(anyhow!(e)))?;
// TODO(bt,2024-02-12): Return the batch token instead. (We're not ready for that.)
Ok(JobSuccessResult {
inference_duration,
maybe_result_entity: maybe_first_media_file_token.map(|media_file_token| ResultEntity {
entity_type: InferenceResultType::MediaFile,
entity_token: media_file_token.to_string()
})
})
}
async fn download_generation(generation: &Generation, tempdir: &TempDir) -> AnyhowResult<PathBuf> {
let url = Url::parse(&generation.url)?;
let response = reqwest::get(&generation.url).await?;
let image_bytes = response.bytes().await?;
let ext = url.path().split(".").last().unwrap_or("png");
let download_filename = format!("{}.{}", generation.id, ext);
let download_path = tempdir.path().join(download_filename);
let mut file = File::create(&download_path)?;
file.write_all(&image_bytes)?;
Ok(download_path)
}
@@ -0,0 +1,632 @@
import { Tooltip } from "@storyteller/ui-tooltip";
import { PopoverMenu } from "@storyteller/ui-popover";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faListCheck,
faSpinnerThird,
faXmark,
faTrashAlt,
faTasks,
} from "@fortawesome/pro-solid-svg-icons";
import { Modal } from "@storyteller/ui-modal";
import {
galleryModalLightboxMediaId,
galleryModalLightboxVisible,
galleryModalLightboxImage,
} from "@storyteller/ui-gallery-modal";
import type { GalleryItem } from "@storyteller/ui-gallery-modal";
import { useEffect, useMemo, useRef, useState } from "react";
import { GetTaskQueue, MarkTaskAsDismissed } from "@storyteller/tauri-api";
import type { TaskQueueItem } from "@storyteller/tauri-api";
import { listen, UnlistenFn } from "@tauri-apps/api/event";
import {
useSelectedImageModel,
useSelectedVideoModel,
ModelPage,
} from "@storyteller/ui-model-selector";
import { Button } from "@storyteller/ui-button";
import {
getProviderDisplayName,
getModelDisplayName,
} from "@storyteller/model-list";
import { CloseButton } from "@storyteller/ui-close-button";
import { ActionReminderModal } from "@storyteller/ui-action-reminder-modal";
import { TaskMediaFileClass } from "@storyteller/api-enums";
type InProgressTask = {
id: string;
title: string;
subtitle?: string;
progress: number;
updatedAt?: Date;
canDismiss?: boolean;
};
type CompletedTask = {
id: string;
title: string;
subtitle?: string;
thumbnailUrl?: string;
completedAt?: Date;
updatedAt?: Date;
imageUrls?: string[];
mediaTokens?: string[];
mediaFileClass?: TaskMediaFileClass;
};
const InProgressCard = ({
task,
onDismiss,
}: {
task: InProgressTask;
onDismiss?: () => void;
}) => {
return (
<div className="mb-2 rounded-md border border-ui-divider bg-ui-background p-2">
<div className="flex items-center gap-2.5">
<div className="flex h-14 w-14 shrink-0 items-center justify-center overflow-hidden rounded bg-ui-controls">
<FontAwesomeIcon
icon={faSpinnerThird}
className="text-base-fg/60 animate-spin"
size="lg"
/>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between text-sm">
<div className="text-base-fg/90 truncate font-medium">
{task.title}
</div>
<div className="text-base-fg/60 ml-2 shrink-0 text-xs tabular-nums">
{Math.max(0, Math.min(100, Math.round(task.progress)))}%
</div>
</div>
{task.subtitle && (
<div className="text-base-fg mt-0.5 truncate text-xs opacity-60">
{task.subtitle}
</div>
)}
<div className="mt-2 h-1.5 w-full rounded bg-ui-controls">
<div
className="h-1.5 rounded bg-brand-primary-400"
style={{ width: `${Math.max(0, Math.min(100, task.progress))}%` }}
/>
</div>
</div>
{onDismiss && (
<button
className="text-base-fg/60 ml-auto h-6 w-6 rounded-full p-1 hover:bg-ui-controls"
aria-label="Dismiss"
onClick={(e) => {
e.stopPropagation();
onDismiss();
}}
>
<FontAwesomeIcon icon={faXmark} />
</button>
)}
</div>
</div>
);
};
const CompletedCard = ({
task,
onClick,
onDismiss,
}: {
task: CompletedTask;
onClick?: () => void;
onDismiss?: () => void;
}) => {
return (
<div
className="mb-2 flex cursor-pointer items-center gap-2.5 rounded-md border border-ui-divider bg-ui-background p-2 transition-colors hover:bg-ui-controls/40"
onClick={onClick}
role={onClick ? "button" : undefined}
tabIndex={onClick ? 0 : -1}
>
<div className="h-14 w-14 shrink-0 overflow-hidden rounded bg-ui-controls">
{task.thumbnailUrl ? (
<img
src={task.thumbnailUrl}
alt={task.title}
onError={(e) => {
console.log("Failed to load thumbnail", e);
let errorPlaceholder = "/resources/placeholders/placeholder.png";
switch (task.mediaFileClass) {
case TaskMediaFileClass.Video:
errorPlaceholder = "/resources/placeholders/placeholder_play.png";
break;
}
e.currentTarget.src = errorPlaceholder;
}}
className="h-full w-full object-cover"
/>
) : (
<div className="text-base-fg/40 flex h-full w-full items-center justify-center text-[10px]">
Done
</div>
)}
</div>
<div className="min-w-0">
<div className="text-base-fg/90 truncate text-sm font-medium">
{task.title}
</div>
{task.subtitle && (
<div className="text-base-fg mt-0.5 truncate text-xs opacity-60">
{task.subtitle}
</div>
)}
{task.completedAt && (
<div className="text-base-fg text-xs opacity-60">
{task.completedAt.toISOString()}
</div>
)}
</div>
{onDismiss && (
<button
className="text-base-fg/60 ml-auto h-6 w-6 rounded-full p-1 hover:bg-ui-controls"
aria-label="Dismiss"
onClick={(e) => {
e.stopPropagation();
onDismiss();
}}
>
<FontAwesomeIcon icon={faXmark} />
</button>
)}
</div>
);
};
export const TaskQueue = () => {
const [isModalOpen, setModalOpen] = useState(false);
const [inProgress, setInProgress] = useState<InProgressTask[]>([]);
const [completed, setCompleted] = useState<CompletedTask[]>([]);
const [lastReadAt, setLastReadAt] = useState<number>(() => {
const stored = localStorage.getItem("taskQueueLastReadAt");
return stored ? parseInt(stored, 10) : 0;
});
// remove unread state; unread tracking handled via IDs below
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [unreadCompletedIds, setUnreadCompletedIds] = useState<string[]>([]);
const prevCompletedIdsRef = useRef<Set<string>>(new Set());
// confirmation modal state for destructive actions
const [confirmOpen, setConfirmOpen] = useState(false);
const [afterConfirm, setAfterConfirm] = useState<(() => void) | null>(null);
// Use currently selected models for image and video pages to drive fake progress.
const selectedImageModel = useSelectedImageModel(ModelPage.TextToImage);
const selectedVideoModel = useSelectedVideoModel(ModelPage.ImageToVideo);
// Snapshot per-task duration so switching models doesn't affect existing items
const taskDurationRef = useRef<Map<string, number>>(new Map());
useEffect(() => {
let cancelled = false;
const formatTitleParts = (t: TaskQueueItem) => {
const provider = t.provider
? getProviderDisplayName(String(t.provider).toLowerCase())
: undefined;
const taskTypeStr = t.task_type ? String(t.task_type) : "";
const kind = taskTypeStr.includes("video")
? "Video"
: taskTypeStr.includes("image")
? "Image"
: undefined;
const modelDisplay = t.model_type
? getModelDisplayName(String(t.model_type))
: undefined;
const title = kind || "Task";
const subtitle =
modelDisplay && provider
? `${modelDisplay} — ${provider}`
: modelDisplay || provider || undefined;
return { title, subtitle, kind };
};
const load = async () => {
try {
const result = await GetTaskQueue();
if (cancelled) return;
console.log("TaskQueue:GetTaskQueue result", result);
const { tasks } = result;
const now = Date.now();
const inProg = tasks
.filter(
(t) => t.task_status === "pending" || t.task_status === "started",
)
.sort((a, b) => b.updated_at.getTime() - a.updated_at.getTime())
.map((t: TaskQueueItem) => {
const createdMs = t.created_at.getTime();
const taskTypeStr = t.task_type
? String(t.task_type).toLowerCase()
: "";
const isVideo = taskTypeStr.includes("video");
let duration = taskDurationRef.current.get(t.id);
if (!duration) {
duration =
(isVideo
? selectedVideoModel?.progressBarTime
: selectedImageModel?.progressBarTime) ?? 20000;
taskDurationRef.current.set(t.id, duration);
}
const raw = ((now - createdMs) / duration) * 100;
const progress = Math.min(95, Math.max(0, raw));
const parts = formatTitleParts(t);
const canDismiss = now - createdMs > 5 * 60 * 1000; // 5 minutes
return {
id: t.id,
title: `Generating ${parts.kind || "Task"}...`,
subtitle: parts.subtitle,
progress,
updatedAt: t.updated_at,
canDismiss,
};
});
// prune durations for tasks no longer in progress
const inProgIds = new Set(inProg.map((x) => x.id));
for (const id of Array.from(taskDurationRef.current.keys())) {
if (!inProgIds.has(id)) {
taskDurationRef.current.delete(id);
}
}
const done = tasks
.filter((t) => t.task_status === "complete_success")
.sort(
(a, b) =>
(b.completed_at?.getTime() || b.updated_at.getTime()) -
(a.completed_at?.getTime() || a.updated_at.getTime()),
)
.map((t: TaskQueueItem) => ({
id: t.id,
...formatTitleParts(t),
thumbnailUrl: t.completed_item?.primary_media_file?.maybe_thumbnail_url_template ?
t.completed_item?.primary_media_file?.maybe_thumbnail_url_template.replace("{WIDTH}", "250") :
undefined,
imageUrls: t.completed_item?.primary_media_file?.cdn_url ?
[t.completed_item?.primary_media_file?.cdn_url] :
[],
mediaTokens: t.completed_item?.primary_media_file?.token ?
[t.completed_item?.primary_media_file?.token] :
[],
completedAt: t.completed_at,
updatedAt: t.updated_at,
}));
setInProgress(inProg);
setCompleted(done);
// Track newly completed IDs when popover is closed
const newCompletedIdSet = new Set(done.map((d) => d.id));
const newlyCompletedIds: string[] = [];
newCompletedIdSet.forEach((id) => {
if (!prevCompletedIdsRef.current.has(id)) {
newlyCompletedIds.push(id);
}
});
prevCompletedIdsRef.current = newCompletedIdSet;
if (!isPopoverOpen && newlyCompletedIds.length > 0) {
setUnreadCompletedIds((prev) =>
Array.from(new Set([...(prev ?? []), ...newlyCompletedIds])),
);
}
} catch (_) {
// ignore
}
};
load();
const id = setInterval(load, 5000);
let unlisten: Promise<UnlistenFn> | null = null;
(async () => {
// Update immediately when Tauri signals a generation completion
unlisten = listen("generation-complete-event", () => {
if (!cancelled) {
load();
}
});
})();
return () => {
cancelled = true;
clearInterval(id);
if (unlisten) {
unlisten.then((f) => f());
}
};
}, [
lastReadAt,
selectedImageModel?.progressBarTime,
selectedVideoModel?.progressBarTime,
isPopoverOpen,
]);
const hasNothing = useMemo(
() => inProgress.length === 0 && completed.length === 0,
[inProgress.length, completed.length],
);
const inProgressCount = inProgress.length;
const badgeCount = inProgressCount + (unreadCompletedIds?.length ?? 0);
const handleOpenChange = (open: boolean) => {
setIsPopoverOpen(open);
if (open) {
const now = Date.now();
setLastReadAt(now);
localStorage.setItem("taskQueueLastReadAt", String(now));
setUnreadCompletedIds([]);
}
};
const dismissTask = async (id: string) => {
try {
await MarkTaskAsDismissed(id);
setInProgress((prev) => prev.filter((t) => t.id !== id));
setCompleted((prev) => prev.filter((t) => t.id !== id));
setUnreadCompletedIds((prev) => (prev ?? []).filter((x) => x !== id));
taskDurationRef.current.delete(id);
} catch (_) {
// ignore
}
};
const dismissCompleted = async () => {
const ids = completed.map((t) => t.id);
try {
await Promise.all(ids.map((id) => MarkTaskAsDismissed(id)));
} catch (_) {
// ignore
} finally {
setCompleted([]);
setUnreadCompletedIds([]);
}
};
return (
<>
<Tooltip content="Task Queue" position="bottom" closeOnClick={true}>
<div className="relative">
{badgeCount > 0 && (
<div className="absolute -right-1 -top-1 z-20 flex h-[17px] w-[17px] items-center justify-center rounded-full bg-brand-primary-400 text-[13px] font-medium text-white">
{badgeCount}
</div>
)}
<PopoverMenu
mode="default"
buttonClassName="h-[38px] w-[38px] !p-0 relative"
panelClassName="w-[360px] p-2 bg-ui-panel mt-2.5"
position="bottom"
align="end"
triggerIcon={
inProgressCount > 0 ? (
<FontAwesomeIcon
icon={faSpinnerThird}
className="animate-spin"
/>
) : (
<FontAwesomeIcon icon={faListCheck} />
)
}
onOpenChange={handleOpenChange}
>
{(close) => (
<>
<div className="flex max-h-[480px] flex-col">
<div className="max-h-[420px] overflow-y-auto p-1">
{hasNothing ? (
<div className="text-base-fg/60 flex w-full flex-col items-center justify-center p-5">
<div className="flex items-center gap-2.5 text-sm opacity-60">
<FontAwesomeIcon icon={faTasks} /> No tasks yet
</div>
</div>
) : (
<div>
{inProgress.length > 0 && (
<div className="mb-4">
<div className="text-base-fg/50 mb-1 px-1 text-xs uppercase tracking-wide">
In Progress
</div>
{inProgress.map((t) => (
<InProgressCard
key={t.id}
task={t}
onDismiss={
t.canDismiss
? () => dismissTask(t.id)
: undefined
}
/>
))}
</div>
)}
{completed.length > 0 && (
<div>
<div className="text-base-fg/50 mb-1 px-1 text-xs uppercase tracking-wide">
Completed
</div>
{completed.map((t) => (
<CompletedCard
key={t.id}
task={t}
onClick={() => {
const firstMediaToken =
t.mediaTokens?.[0] || t.id;
const item: GalleryItem = {
id: firstMediaToken,
label: t.title,
thumbnail: t.thumbnailUrl || null,
fullImage: t.imageUrls?.[0] || null,
createdAt: (t.completedAt || new Date()).toISOString(),
mediaClass: "image",
} as GalleryItem;
galleryModalLightboxMediaId.value = item.id;
galleryModalLightboxImage.value = {
...item,
imageUrls: t.imageUrls,
mediaTokens: t.mediaTokens,
} as unknown as GalleryItem;
galleryModalLightboxVisible.value = true;
close();
}}
onDismiss={() => dismissTask(t.id)}
/>
))}
</div>
)}
</div>
)}
</div>
<div className="pt-1">
<div className="flex items-center justify-between gap-2">
<Button
className="grow"
variant="secondary"
onClick={() => {
setModalOpen(true);
close();
}}
>
Show all
</Button>
<Tooltip
content="Clear all"
position="bottom"
closeOnClick={true}
>
<Button
className="flex h-9 w-9 items-center justify-center rounded-md bg-red/20 text-white hover:bg-red/40"
aria-label="Clear all"
onClick={() => {
setAfterConfirm(() => () => close());
setConfirmOpen(true);
}}
>
<FontAwesomeIcon icon={faTrashAlt} />
</Button>
</Tooltip>
</div>
</div>
</div>
</>
)}
</PopoverMenu>
</div>
</Tooltip>
<Modal
isOpen={isModalOpen}
onClose={() => setModalOpen(false)}
className="h-[520px] max-w-3xl"
showClose={false}
>
<div className="flex h-full flex-col">
<div className="rounded-t-xl border-ui-panel-border bg-ui-panel">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Task Queue</h2>
<div className="flex items-center gap-2">
<Button
className="flex h-9 items-center justify-center bg-red/20 px-3 text-white hover:bg-red/40"
onClick={() => {
setAfterConfirm(() => null);
setConfirmOpen(true);
}}
>
<FontAwesomeIcon icon={faTrashAlt} className="mr-1" />
Clear all
</Button>
<CloseButton onClick={() => setModalOpen(false)} />
</div>
</div>
</div>
<div className="flex-1 overflow-y-auto p-2">
{hasNothing ? (
<div className="text-base-fg/60 flex w-full flex-col items-center justify-center p-5">
<div className="flex items-center gap-2.5 text-sm opacity-60">
<FontAwesomeIcon icon={faTasks} /> No tasks yet
</div>
</div>
) : (
<div>
{inProgress.length > 0 && (
<div className="mb-4">
<div className="text-base-fg/50 mb-2 px-1 text-xs uppercase tracking-wide">
In Progress
</div>
{inProgress.map((t) => (
<InProgressCard
key={t.id}
task={t}
onDismiss={
t.canDismiss ? () => dismissTask(t.id) : undefined
}
/>
))}
</div>
)}
{completed.length > 0 && (
<div>
<div className="text-base-fg/50 mb-2 px-1 text-xs uppercase tracking-wide">
Completed
</div>
{completed.map((t) => (
<CompletedCard
key={t.id}
task={t}
onClick={() => {
const item: GalleryItem = {
id: t.id,
label: t.title,
thumbnail: t.thumbnailUrl || null,
fullImage: t.imageUrls?.[0] || null,
createdAt: (t.completedAt || new Date()).toISOString(),
mediaClass: t.mediaFileClass,
} as GalleryItem;
galleryModalLightboxMediaId.value = item.id;
galleryModalLightboxImage.value = item as GalleryItem;
galleryModalLightboxVisible.value = true;
setModalOpen(false);
}}
/>
))}
</div>
)}
</div>
)}
</div>
</div>
</Modal>
{/* Confirm clear completed modal */}
<ActionReminderModal
isOpen={confirmOpen}
onClose={() => setConfirmOpen(false)}
title="Clear all tasks?"
message={
<span className="text-sm text-white/80">
This will remove all completed tasks from the task queue.
</span>
}
onPrimaryAction={async () => {
await dismissCompleted();
setConfirmOpen(false);
if (afterConfirm) afterConfirm();
setAfterConfirm(null);
}}
primaryActionText="Clear completed"
secondaryActionText="Cancel"
primaryActionIcon={faTrashAlt}
primaryActionBtnClassName="bg-red hover:bg-red/80"
/>
</>
);
};
export default TaskQueue;
@@ -62,6 +62,7 @@ import { useSubscriptionPlanChangedEvent } from "@storyteller/tauri-events";
import { useCreditsState } from "@storyteller/credits";
import { useSubscriptionState } from "@storyteller/subscription";
import { UploadImagesButton } from "./UploadImagesButton";
import TaskQueue from "./TaskQueue";
interface Props {
pageName: string;
@@ -223,6 +224,22 @@ export const TopBar = ({ pageName, loginSignUpPressed }: Props) => {
}
};
const handleTurnIntoVideoFromGallery = async (
url: string,
mediaId?: string,
) => {
try {
topNavMediaId.value = mediaId || "";
topNavMediaUrl.value = url;
useTabStore.getState().setActiveTab("VIDEO");
galleryModalVisibleViewMode.value = false;
galleryModalVisibleDuringDrag.value = false;
galleryModalLightboxVisible.value = false;
} catch (e) {
// no-op
}
};
const getPageTitle = (): string => {
switch (tabStore.activeTabId) {
case "2D":
@@ -449,7 +466,8 @@ export const TopBar = ({ pageName, loginSignUpPressed }: Props) => {
</span>
</Button>
<Activity />
{/* <Activity /> */}
<TaskQueue />
</div>
<div className="no-drag">
@@ -499,6 +517,7 @@ export const TopBar = ({ pageName, loginSignUpPressed }: Props) => {
mode="view"
onDownloadClicked={downloadFile}
onEditClicked={handleEditFromGallery}
onTurnIntoVideoClicked={handleTurnIntoVideoFromGallery}
/>
<ProviderSetupModal />
@@ -369,7 +369,7 @@ const PageDraw = () => {
input.click();
}}
onDelete={(): void => {
store.RESET();
store.deleteSelectedItems();
}}
activeToolId={store.activeTool}
currentShape={store.currentShape}
@@ -196,7 +196,6 @@ interface SceneState {
// Action for deleting selected items
deleteSelectedItems: () => void;
RESET: () => void;
// Clipboard actions
copySelectedItems: () => void;
@@ -235,8 +234,10 @@ 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,
@@ -272,7 +273,7 @@ const getNextZIndex = (nodes: Node[], lineNodes: LineNode[]): number => {
return allZIndices.length > 0 ? Math.max(...allZIndices) + 1 : 1;
};
export const useSceneStore = create<SceneState>((set, get, store) => ({
export const useSceneStore = create<SceneState>((set, get) => ({
// Initial state
nodes: [],
lineNodes: [],
@@ -288,7 +289,7 @@ export const useSceneStore = create<SceneState>((set, get, store) => ({
fillColor: "white",
currentShape: null,
shapeColor: "#4d79b3",
// Cursor initial state
cursorPosition: null,
cursorVisible: false,
@@ -336,6 +337,7 @@ export const useSceneStore = create<SceneState>((set, get, store) => ({
}
},
updateNode: (
id: string,
updates: Partial<Node>,
@@ -836,10 +838,6 @@ export const useSceneStore = create<SceneState>((set, get, store) => ({
}
},
RESET: () => {
set(store.getInitialState());
},
// Clipboard actions
copySelectedItems: () => {
set((state) => {
@@ -1439,7 +1437,9 @@ export const useSceneStore = create<SceneState>((set, get, store) => ({
});
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, store) => ({
}
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,
@@ -1,3 +1,4 @@
export * from "./task_media_file_class.js";
export * from "./task_model_type.js";
export * from "./task_status.js";
export * from "./task_type.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 TaskMediaFileClass {
Audio = "audio",
Image = "image",
Video = "video",
Dimensional = "dimensional",
}
@@ -94,6 +94,28 @@ export class MediaFilesApi extends ApiManager {
});
}
public async GetMediaFilesByBatchToken({
batchToken,
}: {
batchToken: string;
}): Promise<ApiResponse<MediaFile[]>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/media_files/batch/${batchToken}`;
return await this.get<{
success: boolean;
results: MediaFile[];
pagination?: Pagination;
error_reason?: string;
}>({ endpoint })
.then((response) => ({
success: response.success,
data: (response.results ?? []) as unknown as MediaFile[],
}))
.catch((err) => {
return { success: false, errorMessage: err.message };
});
}
public async GetMediaFileByToken({
mediaFileToken,
}: {
@@ -26,7 +26,7 @@ interface GalleryDraggableItemProps {
activeFilter: string;
selected: boolean;
onClick: () => void;
onImageError: () => void;
onImageError: (e: React.SyntheticEvent<HTMLImageElement>) => void;
disableTooltipAndBadge?: boolean;
imageFit?: "cover" | "contain";
onDeleted?: (id: string) => void;
@@ -47,7 +47,7 @@ export interface GalleryItem {
id: string;
label: string;
thumbnail: string | null;
// Thumbnail template is not a usable URL yet. It has some variables
// Thumbnail template is not a usable URL yet. It has some variables
// like `{WIDTH}` which must be replaced downstream.
thumbnailUrlTemplate?: string;
fullImage?: string | null;
@@ -77,6 +77,10 @@ interface GalleryModalProps {
isOpen?: boolean;
forceFilter?: string;
onEditClicked?: (url: string, media_id?: string) => Promise<void> | void;
onTurnIntoVideoClicked?: (
url: string,
media_id?: string
) => Promise<void> | void;
}
export const GalleryModal = React.memo(
@@ -92,6 +96,7 @@ export const GalleryModal = React.memo(
isOpen,
forceFilter,
onEditClicked,
onTurnIntoVideoClicked,
}: GalleryModalProps) => {
const [loading, setLoading] = useState(false);
// Lightbox state is now handled via signals
@@ -126,6 +131,8 @@ export const GalleryModal = React.memo(
const pageSize = 100;
const imageUrl = lightboxImageSignal.value?.fullImage || "";
const imageUrls: string[] | undefined = (lightboxImageSignal.value as any)
?.imageUrls;
const api = useMemo(() => new GalleryModalApi(), []);
const usersApi = useMemo(() => new UsersApi(), []);
@@ -269,9 +276,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
// 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,
thumbnailUrlTemplate: item.media_links.maybe_thumbnail_template,
fullImage: item.media_links.cdn_url,
createdAt: item.created_at,
mediaClass:
@@ -630,9 +637,11 @@ export const GalleryModal = React.memo(
activeFilter={activeFilter}
selected={selectedItemIds.includes(item.id)}
onClick={() => handleItemClick(item)}
onImageError={() =>
handleImageError(item.thumbnail!)
}
onImageError={(e) => {
// NB: Replace the broken thumbnail with a placeholder.
e.currentTarget.src = "/resources/placeholders/placeholder.png";
handleImageError(item.thumbnail!);
}}
disableTooltipAndBadge={mode === "select"}
imageFit={imageFit}
onDeleted={handleItemDeleted}
@@ -693,16 +702,20 @@ export const GalleryModal = React.memo(
onClose={handleCloseLightbox}
onCloseGallery={() => (galleryModalVisibleViewMode.value = false)}
imageUrl={imageUrl}
// pass multiple images if present
imageUrls={imageUrls}
imageAlt={lightboxImageSignal.value?.label || ""}
onImageError={() => imageUrl && handleImageError(imageUrl)}
title={lightboxImageSignal.value?.label}
createdAt={lightboxImageSignal.value?.createdAt}
downloadUrl={imageUrl}
mediaId={lightboxImageSignal.value?.id}
mediaTokens={(lightboxImageSignal.value as any)?.mediaTokens}
onDownloadClicked={onDownloadClicked}
onAddToSceneClicked={onAddToSceneClicked}
mediaClass={lightboxImageSignal.value?.mediaClass}
onEditClicked={onEditClicked}
onTurnIntoVideoClicked={onTurnIntoVideoClicked}
/>
)}
</>
@@ -1,13 +1,18 @@
import { Modal } from "@storyteller/ui-modal";
import { Button } from "@storyteller/ui-button";
import dayjs from "dayjs";
import { faDownToLine, faPencil } from "@fortawesome/pro-solid-svg-icons";
import {
faCube,
faDownToLine,
faPencil,
faVideo,
} from "@fortawesome/pro-solid-svg-icons";
import {
EnqueueImageTo3dObject,
EnqueueImageTo3dObjectModel,
} from "@storyteller/tauri-api";
import { LoadingSpinner } from "@storyteller/ui-loading-spinner";
import React from "react";
import { useEffect, useState, ReactNode } from "react";
import { gtagEvent } from "@storyteller/google-analytics";
import { MediaFilesApi, PromptsApi } from "@storyteller/api";
import { toast } from "@storyteller/ui-toaster";
@@ -19,18 +24,19 @@ import {
getModelDisplayName,
getProviderDisplayName,
} from "@storyteller/model-list";
import { Tooltip } from "@storyteller/ui-tooltip";
interface LightboxModalProps {
isOpen: boolean;
onClose: () => void;
onCloseGallery: () => void;
imageUrl?: string | null;
imageUrls?: string[];
mediaTokens?: string[];
imageAlt?: string;
onImageError?: () => void;
title?: string;
createdAt?: string;
additionalInfo?: React.ReactNode;
additionalInfo?: ReactNode;
downloadUrl?: string;
mediaId?: string;
onDownloadClicked?: (url: string, mediaClass?: string) => Promise<void>;
@@ -41,6 +47,10 @@ interface LightboxModalProps {
mediaClass?: string;
onPromptCopy?: (prompt: string) => void;
onEditClicked?: (url: string, media_id?: string) => Promise<void> | void;
onTurnIntoVideoClicked?: (
url: string,
media_id?: string
) => Promise<void> | void;
}
export function LightboxModal({
@@ -48,6 +58,8 @@ export function LightboxModal({
onClose,
onCloseGallery,
imageUrl,
imageUrls,
mediaTokens,
imageAlt = "",
onImageError,
title,
@@ -59,23 +71,32 @@ export function LightboxModal({
onAddToSceneClicked,
mediaClass,
onEditClicked,
onTurnIntoVideoClicked,
}: LightboxModalProps) {
// NB(bt,2025-06-14): We add ?cors=1 to the image url to prevent caching "sec-fetch-mode: no-cors" from
// the <image> tag request from being cached. If we then drag it into the canvas after it's been cached,
// it won't be able to load in cors mode and will show blank in the canvas and 3D engine. This is a really
// stupid hack around this behavior.
const imageTagImageUrl = imageUrl ? imageUrl + "?cors=1" : "";
const [selectedBatchIndex, setSelectedBatchIndex] = useState<number | null>(
null
);
const displayUrl =
selectedBatchIndex !== null && imageUrls
? imageUrls[selectedBatchIndex]
: imageUrl || null;
const [refPreviewUrl, setRefPreviewUrl] = useState<string | null>(null);
const imageTagImageUrl = displayUrl ? displayUrl + "?cors=1" : "";
const [mediaLoaded, setMediaLoaded] = React.useState<boolean>(false);
const [prompt, setPrompt] = React.useState<string | null>(null);
const [promptLoading, setPromptLoading] = React.useState<boolean>(false);
const [hasPromptToken, setHasPromptToken] = React.useState<boolean>(false);
const [isPromptHovered, setIsPromptHovered] = React.useState<boolean>(false);
const [generationProvider, setGenerationProvider] = React.useState<
string | null
>(null);
const [modelType, setModelType] = React.useState<string | null>(null);
const [contextImages, setContextImages] = React.useState<Array<{
const [mediaLoaded, setMediaLoaded] = useState<boolean>(false);
const [prompt, setPrompt] = useState<string | null>(null);
const [promptLoading, setPromptLoading] = useState<boolean>(false);
const [hasPromptToken, setHasPromptToken] = useState<boolean>(false);
const [isPromptHovered, setIsPromptHovered] = useState<boolean>(false);
const [generationProvider, setGenerationProvider] = useState<string | null>(
null
);
const [modelType, setModelType] = useState<string | null>(null);
const [contextImages, setContextImages] = useState<Array<{
media_links: {
cdn_url: string;
maybe_thumbnail_template: string;
@@ -85,14 +106,35 @@ export function LightboxModal({
}> | null>(null);
// Reset when imageUrl changes
React.useEffect(() => {
useEffect(() => {
setMediaLoaded(false);
}, [imageUrl]);
}, [imageUrl, selectedBatchIndex]);
// Maintain current media id (updates when selecting from batch)
const [currentMediaId, setCurrentMediaId] = useState<string | undefined>(
mediaId
);
useEffect(() => {
setCurrentMediaId(mediaId);
}, [mediaId]);
// Reset promoted selection whenever a new lightbox item is shown or dialog re-opens
useEffect(() => {
if (isOpen) {
setSelectedBatchIndex(null);
setRefPreviewUrl(null);
}
}, [isOpen]);
useEffect(() => {
// If the upstream lightbox content changes, return to grid state
setSelectedBatchIndex(null);
setRefPreviewUrl(null);
}, [mediaId, imageUrls]);
// Fetch prompt when mediaId changes
React.useEffect(() => {
useEffect(() => {
const fetchPrompt = async () => {
if (!mediaId) {
if (!currentMediaId) {
setPrompt(null);
setHasPromptToken(false);
setGenerationProvider(null);
@@ -105,7 +147,7 @@ export function LightboxModal({
try {
const mediaFilesApi = new MediaFilesApi();
const mediaResponse = await mediaFilesApi.GetMediaFileByToken({
mediaFileToken: mediaId,
mediaFileToken: currentMediaId,
});
if (mediaResponse.success && mediaResponse.data?.maybe_prompt_token) {
@@ -147,333 +189,394 @@ export function LightboxModal({
};
fetchPrompt();
}, [mediaId]);
}, [currentMediaId]);
return (
<Modal
isOpen={isOpen}
onClose={onClose}
className="rounded-xl bg-[#2C2C2C] h-[75vh] w-[60vw] max-w-screen min-w-[35vw] min-h-[40vh] p-4"
draggable
allowBackgroundInteraction={true}
showClose={true}
closeOnOutsideClick={false}
resizable={true}
backdropClassName="pointer-events-none hidden"
expandable={true}
>
{/* Invisible drag handle strip at the very top for moving */}
<Modal.DragHandle>
<div className="absolute left-0 top-0 z-20 h-12 w-full cursor-move rounded-t-xl" />
</Modal.DragHandle>
<>
<Modal
isOpen={isOpen}
onClose={onClose}
className="rounded-xl bg-ui-modal h-[75vh] w-[60vw] max-w-screen min-w-[35vw] min-h-[40vh] p-4"
draggable
allowBackgroundInteraction={true}
showClose={true}
closeOnOutsideClick={false}
resizable={true}
backdropClassName="pointer-events-none hidden"
expandable={true}
>
{/* Invisible drag handle strip at the very top for moving */}
<Modal.DragHandle>
<div className="absolute left-0 top-0 z-20 h-12 w-full cursor-move rounded-t-xl" />
</Modal.DragHandle>
{/* content grid */}
<div className="grid h-full grid-cols-3 gap-6">
{/* image panel */}
<div className="col-span-2 relative flex h-full items-center justify-center overflow-hidden rounded-l-xl bg-[#1A1A1A]">
{!imageUrl ? (
<div className="flex h-full w-full items-center justify-center bg-gray-800">
<span className="text-white/60">Image not available</span>
</div>
) : mediaClass === "video" ? (
<video
controls
loop={true}
autoPlay={true}
className="h-full w-full object-contain"
onLoadedData={() => setMediaLoaded(true)}
>
<source src={imageUrl} type="video/mp4" />
Your browser does not support the video tag.
</video>
) : (
<img
data-lightbox-modal="true"
src={imageTagImageUrl}
alt={imageAlt}
className="h-full w-full object-contain"
onError={onImageError}
onLoad={() => setMediaLoaded(true)}
/>
)}
{!mediaLoaded && imageUrl && (
<div className="absolute inset-0 bg-[#1A1A1A] flex items-center justify-center">
<LoadingSpinner className="h-12 w-12 text-white" />
</div>
)}
</div>
{/* info + actions */}
<div className="flex h-full flex-col">
<div className="flex-1 space-y-5">
{/* <div className="text-xl font-medium">
{title || "Image Generation"}
</div> */}
{createdAt && (
<div className="space-y-1.5">
<div className="text-sm font-medium text-white/90">Created</div>
<div className="text-sm text-white/70">
{dayjs(createdAt).format("MMM D, YYYY")} at{" "}
{dayjs(createdAt).format("hh:mm A")}
</div>
{/* content grid */}
<div className="grid h-full grid-cols-3 gap-6">
{/* image panel */}
<div className="col-span-2 relative flex h-full items-center justify-center overflow-hidden rounded-l-xl bg-black/30">
{!displayUrl ? (
<div className="flex h-full w-full items-center justify-center bg-black/30">
<span className="text-base-fg/60">Image not available</span>
</div>
)}
{hasPromptToken && (
<>
{/* Prompt */}
<div className="relative space-y-1.5">
<div className="text-sm font-medium text-white/90">
Prompt
</div>
) : mediaClass === "video" ? (
<video
controls
loop={true}
autoPlay={true}
className="h-full w-full object-contain"
onLoadedData={() => setMediaLoaded(true)}
>
<source src={displayUrl as string} type="video/mp4" />
Your browser does not support the video tag.
</video>
) : imageUrls &&
imageUrls.length > 1 &&
selectedBatchIndex === null ? (
<div
className="grid w-full h-full p-2 gap-2"
style={{ gridTemplateColumns: "repeat(2, minmax(0, 1fr))" }}
>
{imageUrls.slice(0, 4).map((url, idx) => (
<div
className={twMerge(
"relative text-sm text-white/90 break-words bg-black/20 p-3 rounded-lg cursor-pointer transition-colors duration-100 leading-relaxed",
isPromptHovered && "bg-black/30"
)}
onMouseEnter={() => setIsPromptHovered(true)}
onMouseLeave={() => setIsPromptHovered(false)}
key={idx}
className="relative flex items-center justify-center overflow-hidden rounded-lg bg-black/20 cursor-pointer"
onClick={() => {
if (!prompt) return;
navigator.clipboard.writeText(prompt).catch(() => {});
toast.success("Prompt copied");
setSelectedBatchIndex(idx);
const maybeToken = mediaTokens?.[idx];
if (maybeToken) setCurrentMediaId(maybeToken);
}}
>
{promptLoading ? (
<div className="flex items-center gap-2">
<LoadingSpinner className="h-4 w-4" />
<span className="text-sm text-white/80">
Loading prompt...
</span>
<img
src={url + "?cors=1"}
alt={`Generated ${idx + 1}`}
className="h-full w-full object-contain"
onLoad={() => setMediaLoaded(true)}
onError={onImageError}
/>
</div>
))}
</div>
) : (
<img
data-lightbox-modal="true"
src={imageTagImageUrl}
alt={imageAlt}
className="h-full w-full object-contain"
onError={onImageError}
onLoad={() => setMediaLoaded(true)}
/>
)}
{!mediaLoaded && displayUrl && (
<div className="absolute inset-0 bg-ui-panel flex items-center justify-center">
<LoadingSpinner className="h-12 w-12 text-base-fg" />
</div>
)}
</div>
{/* info + actions */}
<div className="flex h-full flex-col">
<div className="flex-1 space-y-5 text-base-fg">
{/* <div className="text-xl font-medium">
{title || "Image Generation"}
</div> */}
{createdAt && (
<div className="space-y-1.5">
<div className="text-sm font-medium text-base-fg/90">
Created
</div>
<div className="text-sm text-base-fg/70">
{dayjs(createdAt).format("MMM D, YYYY")} at{" "}
{dayjs(createdAt).format("hh:mm A")}
</div>
</div>
)}
{hasPromptToken && (
<>
{/* Prompt */}
<div className="relative space-y-1.5">
<div className="text-sm font-medium text-base-fg/90">
Prompt
</div>
<div
className={twMerge(
"relative text-sm text-base-fg break-words p-3 rounded-lg cursor-pointer transition-colors duration-100 leading-relaxed"
)}
style={{
background: isPromptHovered
? "rgb(var(--st-controls-rgb) / 0.30)"
: "rgb(var(--st-controls-rgb) / 0.20)",
}}
onMouseEnter={() => setIsPromptHovered(true)}
onMouseLeave={() => setIsPromptHovered(false)}
onClick={() => {
if (!prompt) return;
navigator.clipboard.writeText(prompt).catch(() => {});
toast.success("Prompt copied");
}}
>
{promptLoading ? (
<div className="flex items-center gap-2">
<LoadingSpinner className="h-4 w-4" />
<span className="text-sm text-base-fg/80">
Loading prompt...
</span>
</div>
) : (
prompt || (
<span className="text-sm text-base-fg">
No prompt
</span>
)
)}
</div>
{!promptLoading && (
<div
className={twMerge(
"pointer-events-none absolute inset-0 flex items-end justify-end opacity-0 transition-opacity duration-50",
isPromptHovered && "opacity-100"
)}
>
<div
className="flex items-center gap-1 text-xs text-base-fg backdrop-blur-md p-1.5 rounded-tl-lg rounded-br-lg"
style={{
background: "rgb(var(--st-controls-rgb) / 0.80)",
}}
>
<FontAwesomeIcon icon={faCopy} className="h-3 w-3" />
<span>Copy prompt</span>
</div>
</div>
) : (
prompt || (
<span className="text-sm text-white/90">No prompt</span>
)
)}
</div>
{!promptLoading && (
<div
className={twMerge(
"pointer-events-none absolute inset-0 flex items-end justify-end opacity-0 transition-opacity duration-50",
isPromptHovered && "opacity-100"
)}
>
<div className="flex items-center gap-1 text-xs text-white/80 bg-black/80 backdrop-blur-md p-1.5 rounded-tl-lg rounded-br-lg">
<FontAwesomeIcon icon={faCopy} className="h-3 w-3" />
<span>Copy prompt</span>
{/* Context Images */}
{contextImages && contextImages.length > 0 && (
<div className="space-y-1.5">
<div className="text-sm font-medium text-base-fg/90">
Reference Images
</div>
<div className="grid grid-cols-6 gap-2">
{contextImages.map((contextImage, index) => {
const thumbnailUrl = contextImage.media_links
.maybe_thumbnail_template
? contextImage.media_links.maybe_thumbnail_template.replace(
"{WIDTH}",
"128"
)
: contextImage.media_links.cdn_url;
const fullSizeUrl = contextImage.media_links
.maybe_thumbnail_template
? contextImage.media_links.maybe_thumbnail_template.replace(
"{WIDTH}",
"512"
)
: contextImage.media_links.cdn_url;
return (
<div
key={contextImage.media_token}
className="glass relative aspect-square overflow-hidden rounded-lg w-14 border-2 border-white/30 hover:border-white/80 transition-all group cursor-pointer hover:cursor-zoom-in"
onClick={() => setRefPreviewUrl(fullSizeUrl)}
>
<img
src={thumbnailUrl}
alt={`Reference image ${index + 1}`}
className="h-full w-full object-cover"
/>
</div>
);
})}
</div>
</div>
)}
</div>
{/* Context Images */}
{contextImages && contextImages.length > 0 && (
<div className="space-y-1.5">
<div className="text-sm font-medium text-white/90">
Reference Images
</div>
<div className="grid grid-cols-6 gap-2">
{contextImages.map((contextImage, index) => {
const thumbnailUrl = contextImage.media_links
.maybe_thumbnail_template
? contextImage.media_links.maybe_thumbnail_template.replace(
"{WIDTH}",
"128"
)
: contextImage.media_links.cdn_url;
const fullSizeUrl = contextImage.media_links
.maybe_thumbnail_template
? contextImage.media_links.maybe_thumbnail_template.replace(
"{WIDTH}",
"512"
)
: contextImage.media_links.cdn_url;
return (
// Hover preview
<Tooltip
key={contextImage.media_token}
className="bg-black p-1.5"
content={
<div>
<div className="flex flex-col items-center bg-white/10 rounded-lg">
<img
src={fullSizeUrl}
alt={`Reference image ${index + 1} preview`}
className="w-auto h-48 object-cover rounded-lg"
/>
</div>
{contextImage.semantic && (
<div className="mt-2 text-xs text-white/90 text-center max-w-48 px-1">
{contextImage.semantic}
</div>
)}
</div>
}
position="top"
delay={300}
closeOnClick={true}
{/* Generation Details */}
{(generationProvider || modelType) && (
<div className="space-y-1.5">
<div className="text-sm font-medium text-base-fg/90">
Generation Details
</div>
<div className="flex flex-col gap-1.5">
{modelType && (
<div
className="flex items-center justify-between py-2 px-3 rounded-lg border border-ui-panel-border"
style={{
background: "rgb(var(--st-controls-rgb) / 0.20)",
}}
>
<div
className="relative group cursor-pointer"
onClick={() => {
window.open(
contextImage.media_links.cdn_url,
"_blank"
);
}}
>
<div className="relative overflow-hidden rounded-lg border border-white/5 bg-white/10 aspect-square">
<img
src={thumbnailUrl}
alt={`Reference image ${index + 1}`}
className="w-full h-full object-cover transition-transform duration-200 group-hover:scale-105"
/>
</div>
<span className="text-sm text-base-fg/70 font-medium">
Model
</span>
<div className="flex items-center gap-2">
{getModelCreatorIcon(modelType)}
<span className="text-sm text-base-fg rounded">
{getModelDisplayName(modelType)}
</span>
</div>
</Tooltip>
);
})}
</div>
</div>
)}
{/* Generation Details */}
{(generationProvider || modelType) && (
<div className="space-y-1.5">
<div className="text-sm font-medium text-white/90">
Generation Details
</div>
<div className="flex flex-col gap-1.5">
{modelType && (
<div className="flex items-center justify-between py-2 px-3 bg-black/20 rounded-lg border border-white/5">
<span className="text-sm text-white/70 font-medium">
Model
</span>
<div className="flex items-center gap-2">
{getModelCreatorIcon(modelType)}
<span className="text-sm text-white/90 rounded">
{getModelDisplayName(modelType)}
</div>
)}
{generationProvider && (
<div
className="flex items-center justify-between py-2 px-3 rounded-lg border border-ui-panel-border"
style={{
background: "rgb(var(--st-controls-rgb) / 0.20)",
}}
>
<span className="text-sm text-base-fg/70 font-medium">
Provider
</span>
<span className="text-sm text-base-fg rounded">
{getProviderDisplayName(generationProvider)}
</span>
</div>
</div>
)}
</div>
</div>
)}
</>
)}
{additionalInfo}
</div>
{/* buttons with spacing */}
{(onAddToSceneClicked && downloadUrl) || downloadUrl
? (() => {
const visibleButtons = [
onEditClicked && downloadUrl && mediaClass === "image",
onTurnIntoVideoClicked &&
downloadUrl &&
mediaClass === "image",
onAddToSceneClicked && downloadUrl,
mediaClass === "image",
onDownloadClicked && downloadUrl,
].filter(Boolean).length;
const buttonClass =
visibleButtons === 1 ? "w-full col-span-2" : "w-full";
return (
<div className="mt-15 mb-15 grid grid-cols-2 gap-2">
{onEditClicked &&
downloadUrl &&
mediaClass === "image" && (
<Button
className={buttonClass}
icon={faPencil}
onClick={async (e) => {
e.stopPropagation();
gtagEvent("edit_image_clicked");
await onEditClicked(downloadUrl, mediaId);
}}
>
Edit Image
</Button>
)}
{onTurnIntoVideoClicked &&
downloadUrl &&
mediaClass === "image" && (
<Button
className={buttonClass}
icon={faVideo}
onClick={async (e) => {
e.stopPropagation();
gtagEvent("turn_into_video_clicked");
await onTurnIntoVideoClicked(
downloadUrl,
mediaId
);
}}
>
Turn into Video
</Button>
)}
{onAddToSceneClicked && downloadUrl && (
<Button
className={buttonClass}
variant="secondary"
onClick={async (e) => {
e.stopPropagation();
gtagEvent("add_to_scene_clicked");
await onAddToSceneClicked(downloadUrl, mediaId);
onClose();
onCloseGallery();
}}
>
Add to Current Scene
</Button>
)}
{generationProvider && (
<div className="flex items-center justify-between py-2 px-3 bg-black/20 rounded-lg border border-white/5">
<span className="text-sm text-white/70 font-medium">
Provider
</span>
<span className="text-sm text-white/90 rounded">
{getProviderDisplayName(generationProvider)}
</span>
</div>
{mediaClass === "image" && (
<Button
icon={faCube}
className={buttonClass}
variant="secondary"
onClick={async (e) => {
gtagEvent("image_to_3d_clicked");
await EnqueueImageTo3dObject({
image_media_token: mediaId,
model: EnqueueImageTo3dObjectModel.Hunyuan3d2_0,
});
}}
>
Make 3D Model
</Button>
)}
{onDownloadClicked && downloadUrl && (
<Button
className={buttonClass}
icon={faDownToLine}
variant="secondary"
onClick={async (e) => {
e.stopPropagation();
gtagEvent("download_clicked");
await onDownloadClicked(downloadUrl, mediaClass);
}}
>
Download
</Button>
)}
</div>
</div>
)}
</>
)}
{additionalInfo}
);
})()
: null}
</div>
{/* buttons with spacing */}
{(onAddToSceneClicked && downloadUrl) || downloadUrl ? (
<div className="mt-15 mb-15 flex justify-end gap-2">
<Button
onClick={async (e) => {
gtagEvent("image_to_3d_clicked");
//let _result = await FalHunyuanImageTo3d({
// image_media_token: mediaId,
// //base64_image: downloadUrl,
//});
let result = await EnqueueImageTo3dObject({
image_media_token: mediaId,
model: EnqueueImageTo3dObjectModel.Hunyuan3d2_0,
});
//e.stopPropagation();
//await onAddToSceneClicked(downloadUrl, mediaId);
//onClose(); // close the lightbox
//onCloseGallery(); // close the gallery
}}
>
3D
</Button>
{onEditClicked && downloadUrl && (
<Button
icon={faPencil}
onClick={async (e) => {
e.stopPropagation();
gtagEvent("edit_image_clicked");
await onEditClicked(downloadUrl, mediaId);
}}
>
Edit
</Button>
)}
{onAddToSceneClicked && downloadUrl && (
<Button
onClick={async (e) => {
e.stopPropagation();
gtagEvent("add_to_scene_clicked");
await onAddToSceneClicked(downloadUrl, mediaId);
onClose(); // close the lightbox
onCloseGallery(); // close the gallery
}}
>
Add to Current Scene
</Button>
)}
{/* {downloadUrl &&
(onDownloadClicked ? (
<Button
icon={faDownToLine}
onClick={async (e) => {
e.stopPropagation();
gtagEvent("download_clicked");
await onDownloadClicked(downloadUrl, mediaClass);
}}
>
Download
</Button>
) : (
<a
href={downloadUrl}
download
onClick={(e) => {
e.stopPropagation();
gtagEvent("download_clicked");
}}
className="no-underline"
>
<Button icon={faDownToLine}>Download</Button>
</a>
))} */}
{onDownloadClicked && downloadUrl && (
<Button
icon={faDownToLine}
onClick={async (e) => {
e.stopPropagation();
gtagEvent("download_clicked");
await onDownloadClicked(downloadUrl, mediaClass);
}}
>
Download
</Button>
)}
</div>
) : null}
</div>
</div>
</Modal>
</Modal>
{refPreviewUrl && (
<Modal
isOpen={true}
onClose={() => setRefPreviewUrl(null)}
className="rounded-xl bg-ui-modal h-[50vh] w-fit max-w-screen min-w-[35vw] min-h-[40vh] p-4"
draggable
allowBackgroundInteraction={true}
showClose={true}
closeOnOutsideClick={true}
resizable={true}
backdropClassName=""
expandable={true}
>
<Modal.DragHandle>
<div className="absolute left-0 top-0 z-20 h-12 w-full cursor-move rounded-t-xl" />
</Modal.DragHandle>
<div className="relative flex h-full items-center justify-center overflow-hidden rounded-xl bg-black/30">
<img
src={`${refPreviewUrl}?cors=1`}
alt="Reference preview"
className="h-full w-full object-contain"
/>
</div>
</Modal>
)}
</>
);
}
@@ -109,8 +109,8 @@ export const PopoverMenu = ({
const className = twMerge(
"text-sm font-medium rounded-lg px-3 py-2 shadow-sm",
"flex gap-2 items-center justify-center outline-none",
"transition-all duration-100",
"bg-ui-controls/60 px-3 text-base-fg hover:bg-ui-controls/90 border border-ui-controls-border",
"transition-all duration-150",
"bg-ui-controls px-3 text-base-fg hover:bg-ui-controls/80 border border-ui-controls-border",
"active:scale-95 transform",
buttonClassName
);
@@ -6,7 +6,10 @@ import { PopoverMenu, PopoverItem } from "@storyteller/ui-popover";
import { Tooltip } from "@storyteller/ui-tooltip";
import { Button, ToggleButton } from "@storyteller/ui-button";
import { Modal } from "@storyteller/ui-modal";
import { EnqueueImageToVideo, EnqueueImageToVideoRequest } from "@storyteller/tauri-api";
import {
EnqueueImageToVideo,
EnqueueImageToVideoRequest,
} from "@storyteller/tauri-api";
import {
faMessageXmark,
faMessageCheck,
@@ -14,12 +17,17 @@ import {
faSpinnerThird,
faSquare,
faPortrait,
faWaveformLines,
} from "@fortawesome/pro-solid-svg-icons";
import { faRectangle } from "@fortawesome/pro-regular-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { IconProp } from "@fortawesome/fontawesome-svg-core";
import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal";
import { SizeIconOption, SizeOption, VideoModel } from "@storyteller/model-list";
import {
SizeIconOption,
SizeOption,
VideoModel,
} from "@storyteller/model-list";
import { usePromptVideoStore, RefImage } from "./promptStore";
import { gtagEvent } from "@storyteller/google-analytics";
import { ImagePromptRow } from "./ImagePromptRow";
@@ -27,7 +35,7 @@ import type { UploadImageFn } from "./ImagePromptRow";
import { twMerge } from "tailwind-merge";
import { toast } from "@storyteller/ui-toaster";
const DEFAULT_RESOLUTIONS : SizeOption[] = [
const DEFAULT_RESOLUTIONS: SizeOption[] = [
{
tauriValue: "720p",
textLabel: "720p",
@@ -83,6 +91,10 @@ export const PromptBoxVideo = ({
const setPrompt = usePromptVideoStore((s) => s.setPrompt);
const useSystemPrompt = usePromptVideoStore((s) => s.useSystemPrompt);
const setUseSystemPrompt = usePromptVideoStore((s) => s.setUseSystemPrompt);
const generateWithSound = usePromptVideoStore((s) => s.generateWithSound);
const setGenerateWithSound = usePromptVideoStore(
(s) => s.setGenerateWithSound
);
const resolution = usePromptVideoStore((s) => s.resolution);
const setResolution = usePromptVideoStore((s) => s.setResolution);
const [isEnqueueing, setIsEnqueueing] = useState(false);
@@ -109,7 +121,9 @@ export const PromptBoxVideo = ({
if (!!selectedModel?.sizeOptions && selectedModel.sizeOptions.length > 0) {
// When switching to a new model, the existing resolution might not be correct.
// This is a gross and nasty hack to handle the case where the resolution is not found.
const resolutionExists = selectedModel.sizeOptions.some((option) => option.textLabel === resolution);
const resolutionExists = selectedModel.sizeOptions.some(
(option) => option.textLabel === resolution
);
const useFirstOption = !resolutionExists;
resolutionOptions = selectedModel.sizeOptions.map((option, index) => {
@@ -128,12 +142,15 @@ export const PromptBoxVideo = ({
const icon = <FontAwesomeIcon icon={faIcon} className="h-4 w-4" />;
return {
label: option.textLabel,
selected: option.textLabel === resolution || (useFirstOption && index === 0),
selected:
option.textLabel === resolution || (useFirstOption && index === 0),
icon: icon,
};
});
} else {
const resolutionExists = DEFAULT_RESOLUTIONS.some((option) => option.textLabel === resolution);
const resolutionExists = DEFAULT_RESOLUTIONS.some(
(option) => option.textLabel === resolution
);
const useFirstOption = !resolutionExists;
resolutionOptions = DEFAULT_RESOLUTIONS.map((option, index) => {
@@ -152,13 +169,15 @@ export const PromptBoxVideo = ({
const icon = <FontAwesomeIcon icon={faIcon} className="h-4 w-4" />;
return {
label: option.textLabel,
selected: option.textLabel === resolution || (useFirstOption && index === 0),
selected:
option.textLabel === resolution || (useFirstOption && index === 0),
icon: icon,
};
});
}
const [resolutionList, setResolutionList] = useState<PopoverItem[]>(resolutionOptions);
const [resolutionList, setResolutionList] =
useState<PopoverItem[]>(resolutionOptions);
const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -252,7 +271,7 @@ export const PromptBoxVideo = ({
setIsEnqueueing(false);
}, 10000);
let request : EnqueueImageToVideoRequest = {
let request: EnqueueImageToVideoRequest = {
model: selectedModel,
image_media_token: imageMediaToken,
prompt: prompt,
@@ -262,7 +281,16 @@ export const PromptBoxVideo = ({
};
if (selectedModel?.tauriId === "sora_2") {
request.sora_orientation = resolution === "720p" ? "landscape" : "portrait";
request.sora_orientation =
resolution === "720p" ? "landscape" : "portrait";
}
if (selectedModel?.tauriId === "veo_2") {
request.generate_with_sound = !!generateWithSound;
}
if (selectedModel?.tauriId === "veo_3") {
request.generate_with_sound = !!generateWithSound;
}
await EnqueueImageToVideo(request);
@@ -297,9 +325,8 @@ export const PromptBoxVideo = ({
const [isGalleryModalOpen, setIsGalleryModalOpen] = useState(false);
const modelNeedsAnImageButNoneAreSelected =
selectedModel?.requiresImage &&
referenceImages.length === 0;
const modelNeedsAnImageButNoneAreSelected =
selectedModel?.requiresImage && referenceImages.length === 0;
// Hide/clear ending frame if model doesn't support it
useEffect(() => {
@@ -421,6 +448,7 @@ export const PromptBoxVideo = ({
}
/>
</Tooltip>
<Tooltip
content={
useSystemPrompt
@@ -438,6 +466,22 @@ export const PromptBoxVideo = ({
onClick={() => setUseSystemPrompt(!useSystemPrompt)}
/>
</Tooltip>
{selectedModel?.generateWithSound && (
<Tooltip
content={generateWithSound ? "Sound: ON" : "Sound: OFF"}
position="top"
className="z-50"
delay={200}
>
<ToggleButton
isActive={generateWithSound}
icon={faWaveformLines}
activeIcon={faWaveformLines}
onClick={() => setGenerateWithSound(!generateWithSound)}
/>
</Tooltip>
)}
</div>
<div className="flex items-center gap-2">
<Button
@@ -93,11 +93,13 @@ interface PromptVideoStore {
useSystemPrompt: boolean;
referenceImages: RefImage[];
endFrameImage?: RefImage;
generateWithSound: boolean;
setPrompt: (prompt: string) => void;
setResolution: (resolution: Resolution | string) => void;
setUseSystemPrompt: (value: boolean) => void;
setReferenceImages: (images: RefImage[]) => void;
setEndFrameImage: (image?: RefImage) => void;
setGenerateWithSound: (value: boolean) => void;
}
export const usePromptVideoStore = create<PromptVideoStore>()((set) => ({
@@ -106,11 +108,13 @@ export const usePromptVideoStore = create<PromptVideoStore>()((set) => ({
useSystemPrompt: true,
referenceImages: [],
endFrameImage: undefined,
generateWithSound: true,
setPrompt: (prompt) => set({ prompt }),
setResolution: (resolution) => set({ resolution }),
setUseSystemPrompt: (useSystemPrompt) => set({ useSystemPrompt }),
setReferenceImages: (referenceImages) => set({ referenceImages }),
setEndFrameImage: (endFrameImage) => set({ endFrameImage }),
setGenerateWithSound: (generateWithSound) => set({ generateWithSound }),
}));
// ----- Edit Prompt Box Store -----
@@ -48,6 +48,7 @@ export class ImageModel extends Model {
maxImagePromptCount?: number;
canTextToImage?: boolean;
tags?: ModelTag[];
progressBarTime?: number;
}) {
if (args.maxGenerationCount < 1) {
throw new Error("maxGenerationCount must be at least 1");
@@ -43,6 +43,9 @@ export class Model {
// Optional provider preferences; two-to-many via page and providers.
readonly preferredProvidersByPage?: Partial<Record<string, string[]>>;
// Time in milliseconds for the fake progress bar to reach 100% (UI-only)
readonly progressBarTime: number;
protected constructor(args: {
id: string;
tauriId: string;
@@ -54,6 +57,7 @@ export class Model {
selectorBadges: string[];
tags?: ModelTag[];
preferredProvidersByPage?: Partial<Record<string, string[]>>;
progressBarTime?: number;
}) {
this.id = args.id;
this.tauriId = args.tauriId;
@@ -65,6 +69,7 @@ export class Model {
this.selectorBadges = args.selectorBadges;
this.tags = args.tags ?? [];
this.preferredProvidersByPage = args.preferredProvidersByPage;
this.progressBarTime = args.progressBarTime ?? 20000;
}
toLegacyBadges(): { label: string }[] {

Some files were not shown because too many files have changed in this diff Show More