This commit is contained in:
Hanashi 2026-08-04 02:05:25 -04:00
parent ae8c50a0c7
commit 775841e6c7
44 changed files with 17 additions and 2091 deletions

View File

@ -1,125 +0,0 @@
use crate::commands::generate::generate_error::{BadInputReason, GenerateError, MissingCredentialsReason, ProviderFailureReason};
use crate::commands::background_removal::enqueue_image_bg_removal_command::EnqueueImageBgRemovalCommand;
use crate::commands::generate::task_enqueue_success::TaskEnqueueSuccess;
use crate::events::basic_sendable_event_trait::BasicSendableEvent;
use crate::events::generation_events::common::{GenerationAction, GenerationServiceProvider};
use crate::events::generation_events::generation_enqueue_failure_event::GenerationEnqueueFailureEvent;
use crate::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::state::data_dir::app_data_root::AppDataRoot;
use crate::state::provider_priority::ProviderPriorityStore;
use crate::utils::save_base64_image_to_temp_dir::save_base64_image_to_temp_dir;
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 anyhow::anyhow;
use artcraft_client::api_defs::generate::image::bg_removal::remove_image_background::RemoveImageBackgroundRequest;
use enums::common::generation_provider::GenerationProvider;
use enums::tauri::tasks::task_type::TaskType;
use uuid_utils::uuid::generate_random_uuid;
use log::{error, info};
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use artcraft_client::endpoints::generate::image::bg_removal::remove_image_background::remove_image_background;
use artcraft_client::endpoints::media_files::upload_image_media_file_from_file::{upload_image_media_file_from_file, UploadImageFromFileArgs};
use tauri::AppHandle;
use tokens::tokens::media_files::MediaFileToken;
pub async fn handle_generic_bg_removal_artcraft(
request: &EnqueueImageBgRemovalCommand,
app: &AppHandle,
app_data_root: &AppDataRoot,
app_env_configs: &AppEnvConfigs,
storyteller_creds_manager: &StorytellerCredentialManager,
) -> Result<TaskEnqueueSuccess, GenerateError> {
let creds = match storyteller_creds_manager.get_credentials()? {
Some(creds) => creds,
None => {
return Err(GenerateError::MissingCredentials(MissingCredentialsReason::NeedsStorytellerCredentials));
},
};
info!("Calling Artcraft bg removal ...");
let uuid_idempotency_token = generate_random_uuid();
let image_media_token = match &request.image_media_token {
Some(image_media_token) => image_media_token.clone(),
None => {
upload_image_from_base64_bytes(&request, &app_data_root, &app_env_configs, &creds).await?
}
};
let request = RemoveImageBackgroundRequest {
uuid_idempotency_token,
media_file_token: Some(image_media_token),
};
let result = remove_image_background(
&app_env_configs.storyteller_host,
Some(&creds),
request,
).await;
let success_result = match result {
Ok(enqueued) => {
// TODO(bt,2025-07-05): Enqueue job token?
info!("Successfully enqueued Artcraft background removal. Job token: {}",
enqueued.inference_job_token);
enqueued
}
Err(err) => {
error!("Failed to use Artcraft background removal: {:?}", err);
return Err(GenerateError::ProviderFailure(ProviderFailureReason::StorytellerError(err)));
}
};
Ok(TaskEnqueueSuccess {
provider: GenerationProvider::Artcraft,
task_type: TaskType::BackgroundRemoval,
provider_job_id: Some(success_result.inference_job_token.to_string()),
model: None,
maybe_queue_status_url: None,
maybe_prompt_token: None,
maybe_queue_response_url: None,
})
}
async fn upload_image_from_base64_bytes(
request: &EnqueueImageBgRemovalCommand,
app_data_root: &AppDataRoot,
app_env_configs: &AppEnvConfigs,
creds: &StorytellerCredentialSet
) -> Result<MediaFileToken, GenerateError> {
let temp_file;
if let Some(base64_bytes) = &request.base64_image {
info!("Saving base64 image to temp dir ...");
temp_file = save_base64_image_to_temp_dir(&app_data_root, base64_bytes)
.await
.map_err(|err| {
error!("Failed to save base64 image to temp dir: {:?}", err);
GenerateError::BadInput(BadInputReason::Base64DecodeError)
})?;
} else {
return Err(GenerateError::BadInput(BadInputReason::RequiredSourceImageNotProvided));
};
info!("Uploading image media file from temp file: {:?}", temp_file.path());
let result =
upload_image_media_file_from_file(UploadImageFromFileArgs {
api_host: &app_env_configs.storyteller_host,
maybe_creds: Some(&creds),
path: temp_file,
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.
maybe_generation_provider: None,
}).await
.map_err(|err| {
error!("Failed to upload image media file: {:?}", err);
GenerateError::ProviderFailure(ProviderFailureReason::StorytellerError(err))
})?;
Ok(result.media_file_token)
}

View File

@ -1 +0,0 @@
pub mod handle_generic_bg_removal_artcraft;

View File

@ -1,230 +0,0 @@
use crate::commands::generate::common::notify_frontend_of_errors::notify_frontend_of_errors;
use crate::commands::generate::generate_error::{BadInputReason, GenerateError};
use crate::commands::background_removal::artcraft::handle_generic_bg_removal_artcraft::handle_generic_bg_removal_artcraft;
use crate::commands::generate::task_enqueue_success::TaskEnqueueSuccess;
use crate::commands::response::failure_response_wrapper::{CommandErrorResponseWrapper, CommandErrorStatus};
use crate::commands::response::shorthand::{Response, ResponseOrErrorType};
use crate::commands::response::success_response_wrapper::SerializeMarker;
use crate::events::basic_sendable_event_trait::BasicSendableEvent;
use crate::events::functional_events::credits_balance_changed_event::CreditsBalanceChangedEvent;
use crate::events::generation_events::common::{GenerationAction, GenerationServiceProvider};
use crate::events::generation_events::generation_enqueue_failure_event::GenerationEnqueueFailureEvent;
use crate::events::generation_events::generation_enqueue_success_event::GenerationEnqueueSuccessEvent;
use crate::events::sendable_event_trait::SendableEvent;
use crate::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::state::artcraft_usage_tracker::artcraft_usage_tracker::ArtcraftUsageTracker;
use crate::state::artcraft_usage_tracker::artcraft_usage_type::{ArtcraftUsagePage, ArtcraftUsageType};
use crate::state::data_dir::app_data_root::AppDataRoot;
use crate::state::data_dir::subdirectory::trait_data_subdir::DataSubdir;
use crate::state::provider_priority::{Provider, ProviderPriorityStore};
use crate::state::task_database::TaskDatabase;
use crate::utils::get_url_file_extension::get_url_file_extension;
use crate::utils::simple_http_download::simple_http_download;
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 enums::tauri::ux::tauri_command_caller::TauriCommandCaller;
use errors::AnyhowError;
use log::{error, info, warn};
use openai_sora_client::recipes::image_remix_with_session_auto_renew::{image_remix_with_session_auto_renew, ImageRemixAutoRenewRequest};
use openai_sora_client::recipes::image_upload_from_file_with_session_auto_renew::{image_upload_from_file_with_session_auto_renew, ImageUploadFromFileAutoRenewRequest};
use openai_sora_client::recipes::maybe_upgrade_or_renew_session::maybe_upgrade_or_renew_session;
use openai_sora_client::requests::image_gen::common::{ImageSize, NumImages};
use serde_derive::{Deserialize, Serialize};
use sqlite_database::queries::create_task::{create_task, CreateTaskArgs};
use std::time::Duration;
use artcraft_client::endpoints::media_files::get_media_file::get_media_file;
use artcraft_client::error::storyteller_error::StorytellerError;
use artcraft_client::utils::api_host::ApiHost;
use tauri::{AppHandle, Manager, State};
use tokens::tokens::media_files::MediaFileToken;
#[derive(Deserialize, Debug)]
pub struct EnqueueImageBgRemovalCommand {
// /// The bg removal model to use (optional).
// pub model: Option<TODO>,
/// Image media file; the image to remove the background from.
pub image_media_token: Option<MediaFileToken>,
/// Base64-encoded image
pub base64_image: Option<String>,
/// OPTIONAL.
/// Name of the frontend caller.
/// We'll use this to selectively trigger events.
pub frontend_caller: Option<TauriCommandCaller>,
/// OPTIONAL.
/// A frontend-defined identifier that we'll send back to the frontend
/// as a Tauri event on task completion.
pub frontend_subscriber_id: Option<String>,
/// OPTIONAL.
/// A frontend-defined payload that we'll send back to the frontend
/// as a Tauri event on task completion.
pub frontend_subscriber_payload: Option<String>,
}
#[derive(Serialize, Debug)]
#[serde(rename_all = "snake_case")]
pub enum EnqueueImageBgRemovalErrorType {
/// No model available for image generation
NoProviderAvailable,
/// No image was provided for background removal
MissingImage,
/// Bad base64 image data
Base64DecodeError,
/// Generic bad request error
BadRequest,
/// Generic server error
ServerError,
}
#[derive(Serialize)]
pub struct EnqueueImageBgRemovalSuccessResponse {
}
impl SerializeMarker for EnqueueImageBgRemovalSuccessResponse {}
#[tauri::command]
pub async fn enqueue_image_bg_removal_command(
app: AppHandle,
request: EnqueueImageBgRemovalCommand,
app_data_root: State<'_, AppDataRoot>,
app_env_configs: State<'_, AppEnvConfigs>,
artcraft_usage_tracker: State<'_, ArtcraftUsageTracker>,
provider_priority_store: State<'_, ProviderPriorityStore>,
task_database: State<'_, TaskDatabase>,
storyteller_creds_manager: State<'_, StorytellerCredentialManager>,
) -> ResponseOrErrorType<EnqueueImageBgRemovalSuccessResponse, EnqueueImageBgRemovalErrorType> {
info!("enqueue_image_bg_removal_command called");
let result = handle_request(
&request,
&app,
&app_data_root,
&app_env_configs,
&artcraft_usage_tracker,
&provider_priority_store,
&task_database,
&storyteller_creds_manager,
).await;
match result {
Err(err) => {
error!("Error removing background: {:?}", err);
notify_frontend_of_errors(&app, &err).await;
// TODO: Derive from err. Make service provider optional.
let event = GenerationEnqueueFailureEvent {
action: GenerationAction::RemoveBackground,
service: GenerationServiceProvider::Artcraft, // FIXME: This is wrong.
model: None,
reason: None,
};
if let Err(err) = event.send(&app) {
error!("Failed to emit event: {:?}", err); // Fail open.
}
Err(error_to_tauri_response(err))
}
Ok(event) => {
let event = GenerationEnqueueSuccessEvent {
action: event.to_frontend_event_action(),
service: event.to_frontend_event_service(),
model: None, // FIXME: This isn't right, though we probably don't care for simple bg removal.
};
if let Err(err) = event.send(&app) {
error!("Failed to emit event: {:?}", err); // Fail open.
}
CreditsBalanceChangedEvent{}.send_infallible(&app);
Ok(EnqueueImageBgRemovalSuccessResponse {}.into())
}
}
}
pub async fn handle_request(
request: &EnqueueImageBgRemovalCommand,
app: &AppHandle,
app_data_root: &AppDataRoot,
app_env_configs: &AppEnvConfigs,
artcraft_usage_tracker: &ArtcraftUsageTracker,
provider_priority_store: &ProviderPriorityStore,
task_database: &TaskDatabase,
storyteller_creds_manager: &StorytellerCredentialManager,
) -> Result<TaskEnqueueSuccess, GenerateError> {
// TODO(bt,2025-07-07): Other model/provider routing...
let success_event = handle_generic_bg_removal_artcraft(
request,
app,
app_data_root,
app_env_configs,
storyteller_creds_manager,
).await?;
let result = success_event
.insert_into_task_database_with_frontend_payload(
task_database,
request.frontend_caller,
request.frontend_subscriber_id.as_deref(),
request.frontend_subscriber_payload.as_deref()
)
.await;
if let Err(err) = result {
error!("Failed to create task in database: {:?}", err);
// NB: Fail open, but find a way to flag this.
}
if let Err(err) = artcraft_usage_tracker.record_image_generation(1, ArtcraftUsageType::ImageToResult, ArtcraftUsagePage::EditPage) {
// NB: Fail open.
warn!("Failed to report usage: {:?}", err);
}
Ok(success_event)
}
fn error_to_tauri_response(error: GenerateError) -> CommandErrorResponseWrapper<EnqueueImageBgRemovalErrorType, ()> {
let mut status = CommandErrorStatus::ServerError;
let mut error_type = EnqueueImageBgRemovalErrorType::ServerError;
let mut error_message = "A server error occurred. Please try again. If it continues, please tell our staff about the problem.".to_string();
match error {
GenerateError::NoProviderAvailable => {
status = CommandErrorStatus::ServerError;
error_type = EnqueueImageBgRemovalErrorType::NoProviderAvailable;
error_message = "No configured provider available for background removal".to_string();
}
GenerateError::BadInput(BadInputReason::RequiredSourceImageNotProvided) => {
status = CommandErrorStatus::BadRequest;
error_type = EnqueueImageBgRemovalErrorType::MissingImage;
error_message = "No image provided for background removal".to_string();
}
GenerateError::BadInput(BadInputReason::Base64DecodeError) => {
status = CommandErrorStatus::BadRequest;
error_type = EnqueueImageBgRemovalErrorType::Base64DecodeError;
error_message = "Failed to decode base64 image data".to_string();
}
_ => {}, // Other cases fall through.
}
CommandErrorResponseWrapper {
status,
error_message: Some(error_message.to_string()),
error_type: Some(error_type),
error_details: None,
}
}

View File

@ -1,2 +0,0 @@
pub mod enqueue_image_bg_removal_command;
pub (super) mod artcraft;

View File

@ -1,36 +0,0 @@
use base64::prelude::BASE64_STANDARD;
use base64::Engine;
use image::codecs::png::{CompressionType, FilterType, PngEncoder};
use image::ImageReader;
use std::io::Cursor;
#[tauri::command]
pub fn flip_image(image: &str) -> Result<String, String> {
println!("infer_image called; processing image...");
let bytes = BASE64_STANDARD.decode(image)
.map_err(|err| format!("Base64 decode error: {}", err))?;
let image = ImageReader::new(Cursor::new(bytes))
.with_guessed_format()
.map_err(|err| format!("Image format error: {}", err))?
.decode()
.map_err(|err| format!("Image decode error: {}", err))?;
let rotated_image = image.rotate180();
let mut buffer = Vec::new(); // TODO: Preallocate, better data structure
let mut writer = Cursor::new(&mut buffer);
let encoder = PngEncoder::new_with_quality
(&mut writer, CompressionType::Fast, FilterType::Adaptive);
rotated_image.write_with_encoder(encoder)
.map_err(|err| format!("Image encoding error: {}", err))?;
let encoded = BASE64_STANDARD.encode(&buffer);
println!("Encoded: {:?}", encoded.split_at(10).0);
Ok(encoded)
}

View File

@ -1,80 +0,0 @@
use crate::commands::response::shorthand::ResponseOrErrorMessage;
use crate::commands::response::success_response_wrapper::SerializeMarker;
use crate::events::basic_sendable_event_trait::BasicSendableEvent;
use crate::events::functional_events::media_file_deleted_event::MediaFileDeletedEvent;
use crate::events::generation_events::generation_enqueue_success_event::GenerationEnqueueSuccessEvent;
use crate::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::state::data_dir::app_data_root::AppDataRoot;
use crate::state::provider_priority::{Provider, ProviderPriorityStore};
use crate::state::task_database::TaskDatabase;
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use errors::AnyhowResult;
use log::{error, info, warn};
use serde_derive::{Deserialize, Serialize};
use artcraft_client::endpoints::media_files::delete_media_file::delete_media_file;
use tauri::{AppHandle, State};
use tokens::tokens::media_files::MediaFileToken;
#[derive(Deserialize)]
pub struct MediaFileDeleteRequest {
/// REQUIRED.
/// Media file to delete. The user must own the file.
pub media_file_token: MediaFileToken,
}
#[derive(Serialize)]
pub struct MediaFileDeleteSuccessResponse {
pub success: bool,
}
impl SerializeMarker for MediaFileDeleteSuccessResponse {}
#[tauri::command]
pub async fn media_file_delete_command(
app: AppHandle,
request: MediaFileDeleteRequest,
app_env_configs: State<'_, AppEnvConfigs>,
storyteller_creds_manager: State<'_, StorytellerCredentialManager>,
) -> ResponseOrErrorMessage<MediaFileDeleteSuccessResponse> {
info!("media_file_delete_command called");
let result = handle_request(
request,
&app,
&app_env_configs,
&storyteller_creds_manager,
).await;
if let Err(err) = result {
error!("media_file_delete_command failed: {:?}", err);
return Err("delete failed".into())
}
Ok(MediaFileDeleteSuccessResponse {
success: true,
}.into())
}
pub async fn handle_request(
request: MediaFileDeleteRequest,
app: &AppHandle,
app_env_configs: &AppEnvConfigs,
storyteller_creds_manager: &StorytellerCredentialManager,
) -> AnyhowResult<()> {
let creds = storyteller_creds_manager.get_credentials()?;
let _result = delete_media_file(
&app_env_configs.storyteller_host,
creds.as_ref(),
&request.media_file_token
).await?;
MediaFileDeletedEvent {
media_file_token: request.media_file_token,
}.send_infallible(app);
Ok(())
}

View File

@ -1 +0,0 @@
pub mod media_file_delete_command;

View File

@ -1,15 +1,7 @@
pub mod app_preferences;
pub mod app_state;
pub mod background_removal;
pub mod cost_estimate;
pub mod credentials;
pub mod download;
pub mod flip_image;
pub mod generate;
pub mod get_app_info_command;
pub mod load_without_cors_command;
pub mod media_files;
pub mod platform_info_command;
pub mod providers;
pub mod response;
pub mod service;
pub mod task_queue;

View File

@ -1,30 +0,0 @@
use crate::commands::response::shorthand::ResponseOrErrorMessage;
use crate::commands::response::success_response_wrapper::SerializeMarker;
use crate::state::provider_priority::{Provider, ProviderPriorityStore};
use log::{error, info};
use serde_derive::Serialize;
use tauri::State;
#[derive(Serialize)]
pub struct GetProviderOrderResponse {
pub providers: Vec<Provider>,
}
impl SerializeMarker for GetProviderOrderResponse{}
#[tauri::command]
pub async fn get_provider_order_command(
provider_priority_store: State<'_, ProviderPriorityStore>,
) -> ResponseOrErrorMessage<GetProviderOrderResponse> {
info!("get_provider_order_command called");
let providers = provider_priority_store.get_priority()
.map_err(|err| {
error!("Failed to get provider order: {:?}", err);
"Failed to retrieve provider order"
})?;
Ok(GetProviderOrderResponse {
providers,
}.into())
}

View File

@ -1,2 +0,0 @@
pub mod get_provider_order_command;
pub mod set_provider_order_command;

View File

@ -1,34 +0,0 @@
use crate::commands::response::shorthand::SuccessOrErrorMessage;
use crate::state::data_dir::app_data_root::AppDataRoot;
use crate::state::provider_priority::{Provider, ProviderPriorityStore};
use log::{error, info};
use serde_derive::Deserialize;
use tauri::State;
#[derive(Deserialize)]
pub struct SetProviderOrderRequest {
pub providers: Vec<Provider>,
}
#[tauri::command]
pub async fn set_provider_order_command(
request: SetProviderOrderRequest,
provider_priority_store: State<'_, ProviderPriorityStore>,
app_data_root: State<'_, AppDataRoot>,
) -> SuccessOrErrorMessage {
info!("set_provider_order_command called");
provider_priority_store.set_priority(&request.providers)
.map_err(|err| {
error!("Failed to set provider order: {:?}", err);
"Failed to set provider order"
})?;
provider_priority_store.persist_to_filesystem(&app_data_root)
.map_err(|err| {
error!("Failed to persist provider order to filesystem: {:?}", err);
"Failed to persist provider order"
})?;
Ok(().into())
}

View File

@ -1,4 +0,0 @@
pub mod deprecated;
pub mod provider_list_command;
pub mod provider_set_api_key_command;
pub mod provider_clear_command;

View File

@ -1,33 +0,0 @@
use crate::commands::response::shorthand::ResponseOrErrorMessage;
use crate::commands::response::success_response_wrapper::SerializeMarker;
use crate::providers::credentials::provider_credential_key::ProviderCredentialKey;
use crate::providers::credentials::provider_credential_loading_cache::ProviderCredentialLoadingCache;
use log::{error, info};
use serde_derive::{Deserialize, Serialize};
use tauri::State;
#[derive(Deserialize)]
pub struct ProviderClearRequest {
pub provider_credential: ProviderCredentialKey,
}
#[derive(Serialize)]
pub struct ProviderClearResponse {}
impl SerializeMarker for ProviderClearResponse {}
#[tauri::command]
pub async fn provider_clear_command(
request: ProviderClearRequest,
credential_cache: State<'_, ProviderCredentialLoadingCache>,
) -> ResponseOrErrorMessage<ProviderClearResponse> {
info!("provider_clear_command called for key: {:?}", request.provider_credential);
credential_cache.delete_credentials(request.provider_credential)
.map_err(|err| {
error!("Failed to clear provider credential: {:?}", err);
"Failed to clear provider credential"
})?;
Ok(ProviderClearResponse {}.into())
}

View File

@ -1,106 +0,0 @@
use crate::commands::response::shorthand::ResponseOrErrorMessage;
use crate::commands::response::success_response_wrapper::SerializeMarker;
use crate::providers::credentials::payload::provider_credential_payload::ProviderCredentialPayload;
use crate::providers::credentials::provider_credential_key::ProviderCredentialKey;
use crate::providers::credentials::provider_credential_loading_cache::ProviderCredentialLoadingCache;
use crate::providers::credentials::provider_credential_type::ProviderCredentialType;
use log::{info, warn};
use serde_derive::Serialize;
use tauri::State;
/// All known credential keys. Add new ones here as providers are added.
const ALL_KEYS: &[ProviderCredentialKey] = &[
ProviderCredentialKey::FalApiKey,
ProviderCredentialKey::ReplicateApiKey,
ProviderCredentialKey::GrokWebLogin,
ProviderCredentialKey::HiggsfieldWebLogin,
ProviderCredentialKey::MidjourneyLogin,
ProviderCredentialKey::RunwayWebLogin,
];
const REDACTED_KEY_VISIBLE_CHARS: usize = 6;
#[derive(Serialize)]
pub struct ProviderListResponse {
pub providers: Vec<ProviderListEntry>,
}
impl SerializeMarker for ProviderListResponse {}
#[derive(Serialize)]
pub struct ProviderListEntry {
pub provider_credential: ProviderCredentialKey,
pub credential_type: ProviderCredentialType,
pub has_credentials: bool,
pub maybe_details: Option<ProviderCredentialDetails>,
}
#[derive(Serialize)]
pub struct ProviderCredentialDetails {
/// For API keys: the first few characters followed by asterisks.
pub maybe_key_start: Option<String>,
/// For API keys: the full key value.
pub maybe_full_key: Option<String>,
/// For web logins: the email address if available.
pub maybe_email_address: Option<String>,
/// For web logins: the username if available.
pub maybe_username: Option<String>,
}
#[tauri::command]
pub async fn provider_list_command(
credential_cache: State<'_, ProviderCredentialLoadingCache>,
) -> ResponseOrErrorMessage<ProviderListResponse> {
info!("provider_list_command called");
let mut providers = Vec::with_capacity(ALL_KEYS.len());
for &key in ALL_KEYS {
let maybe_payload = match credential_cache.get_credentials(key) {
Ok(payload) => payload,
Err(err) => {
warn!("Error checking credential for {:?}: {:?}", key, err);
None
}
};
let has_credentials = maybe_payload.is_some();
let maybe_details = maybe_payload.map(|payload| match payload {
ProviderCredentialPayload::ApiKey(data) => {
ProviderCredentialDetails {
maybe_key_start: Some(redact_key(data.as_str())),
maybe_full_key: Some(data.as_str().to_string()),
maybe_email_address: None,
maybe_username: None,
}
}
ProviderCredentialPayload::WebLogin(data) => {
ProviderCredentialDetails {
maybe_key_start: None,
maybe_full_key: None,
maybe_email_address: data.email_address,
maybe_username: data.username,
}
}
});
providers.push(ProviderListEntry {
provider_credential: key,
credential_type: key.get_type(),
has_credentials,
maybe_details,
});
}
Ok(ProviderListResponse { providers }.into())
}
fn redact_key(key: &str) -> String {
if key.len() <= REDACTED_KEY_VISIBLE_CHARS {
"********".to_string()
} else {
let visible = &key[..REDACTED_KEY_VISIBLE_CHARS];
format!("{}********", visible)
}
}

View File

@ -1,39 +0,0 @@
use crate::commands::response::shorthand::ResponseOrErrorMessage;
use crate::commands::response::success_response_wrapper::SerializeMarker;
use crate::providers::credentials::payload::api_key::ApiKeyData;
use crate::providers::credentials::payload::provider_credential_payload::ProviderCredentialPayload;
use crate::providers::credentials::provider_credential_key::ProviderCredentialKey;
use crate::providers::credentials::provider_credential_loading_cache::ProviderCredentialLoadingCache;
use log::{error, info};
use serde_derive::{Deserialize, Serialize};
use tauri::State;
#[derive(Deserialize)]
pub struct ProviderSetApiKeyRequest {
pub provider_credential: ProviderCredentialKey,
pub api_key: String,
}
#[derive(Serialize)]
pub struct ProviderSetApiKeyResponse {}
impl SerializeMarker for ProviderSetApiKeyResponse {}
#[tauri::command]
pub async fn provider_set_api_key_command(
request: ProviderSetApiKeyRequest,
credential_cache: State<'_, ProviderCredentialLoadingCache>,
) -> ResponseOrErrorMessage<ProviderSetApiKeyResponse> {
info!("provider_set_api_key_command called for provider: {:?}", request.provider_credential);
let api_key_data = ApiKeyData::from_str(&request.api_key);
let payload = ProviderCredentialPayload::ApiKey(api_key_data);
credential_cache.save_credentials(request.provider_credential, payload)
.map_err(|err| {
error!("Failed to save API key: {:?}", err);
"Failed to save API key"
})?;
Ok(ProviderSetApiKeyResponse {}.into())
}

View File

@ -0,0 +1,5 @@
pub mod app_preferences;
pub mod app_state;
pub mod get_app_info_command;
pub mod load_without_cors_command;
pub mod platform_info_command;

View File

@ -23,8 +23,8 @@ use crate::commands::credentials::edit_api_credential_command::edit_api_credenti
use crate::commands::credentials::edit_web_credential_command::edit_web_credential_command;
use crate::commands::credentials::list_credentials_command::list_credentials_command;
use crate::commands::credentials::open_web_login_command::open_web_login_command;
use crate::commands::app_preferences::get_app_preferences_command::get_app_preferences_command;
use crate::commands::app_preferences::update_app_preference_command::update_app_preferences_command;
use crate::commands::service::app_preferences::get_app_preferences_command::get_app_preferences_command;
use crate::commands::service::app_preferences::update_app_preference_command::update_app_preferences_command;
use crate::commands::cost_estimate::estimate_audio_cost_command::estimate_audio_cost_command;
use crate::commands::cost_estimate::estimate_image_cost_command::estimate_image_cost_command;
use crate::commands::cost_estimate::estimate_mesh_cost_command::estimate_mesh_cost_command;
@ -35,22 +35,14 @@ use crate::commands::generate::models::video::list_video_models_command::list_vi
use crate::commands::download::download_directory_reveal_command::download_directory_reveal_command;
use crate::commands::download::download_media_file_command::download_media_file_command;
use crate::commands::download::download_url_command::download_url_command;
use crate::commands::background_removal::enqueue_image_bg_removal_command::enqueue_image_bg_removal_command;
use crate::commands::generate::generate_audio::generate_audio_command::generate_audio_command;
use crate::commands::generate::generate_image::generate_image_command::generate_image_command;
use crate::commands::generate::generate_mesh::generate_mesh_command::generate_mesh_command;
use crate::commands::generate::generate_splat::generate_splat_command::generate_splat_command;
use crate::commands::generate::generate_video::generate_video_command::generate_video_command;
use crate::commands::flip_image::flip_image;
use crate::commands::get_app_info_command::get_app_info_command;
use crate::commands::load_without_cors_command::load_without_cors_command;
use crate::commands::media_files::media_file_delete_command::media_file_delete_command;
use crate::commands::platform_info_command::platform_info_command;
use crate::commands::providers::deprecated::get_provider_order_command::get_provider_order_command;
use crate::commands::providers::deprecated::set_provider_order_command::set_provider_order_command;
use crate::commands::providers::provider_clear_command::provider_clear_command;
use crate::commands::providers::provider_list_command::provider_list_command;
use crate::commands::providers::provider_set_api_key_command::provider_set_api_key_command;
use crate::commands::service::get_app_info_command::get_app_info_command;
use crate::commands::service::load_without_cors_command::load_without_cors_command;
use crate::commands::service::platform_info_command::platform_info_command;
use crate::commands::task_queue::get_task_queue_command::get_task_queue_command;
use crate::commands::task_queue::mark_task_as_dismissed_command::mark_task_as_dismissed_command;
use crate::commands::task_queue::tasks_nuke_all_command::tasks_nuke_all_command;
@ -221,7 +213,6 @@ pub fn run() {
download_directory_reveal_command,
download_media_file_command,
download_url_command,
enqueue_image_bg_removal_command,
estimate_audio_cost_command,
estimate_image_cost_command,
estimate_mesh_cost_command,
@ -229,7 +220,6 @@ pub fn run() {
estimate_video_cost_command,
list_image_models_command,
list_video_models_command,
flip_image,
generate_audio_command,
generate_image_command,
generate_mesh_command,
@ -237,16 +227,10 @@ pub fn run() {
generate_video_command,
get_app_info_command,
get_app_preferences_command,
get_provider_order_command,
get_task_queue_command,
provider_clear_command,
provider_list_command,
provider_set_api_key_command,
load_without_cors_command,
mark_task_as_dismissed_command,
media_file_delete_command,
platform_info_command,
set_provider_order_command,
tasks_nuke_all_command,
update_app_preferences_command,
]);

View File

@ -1,23 +0,0 @@
import { MediaFileDelete } from "@storyteller/tauri-api";
import { listen } from "@tauri-apps/api/event";
export const galleryModalDeleteMedia = (id: string) => MediaFileDelete(id);
export const galleryModalSubscribeToMediaEvents = (handlers: {
onGenerationComplete: () => void;
onMediaDeleted: (mediaId: string) => void;
}) => {
const unlistenGen = listen("generation-complete-event", () => {
handlers.onGenerationComplete();
});
const unlistenDel = listen<any>("media_file_deleted_event", (event) => {
const token: string | undefined =
event?.payload?.data?.media_file_token ??
event?.payload?.media_file_token;
if (token) handlers.onMediaDeleted(token);
});
return () => {
unlistenGen.then((fn) => fn());
unlistenDel.then((fn) => fn());
};
};

View File

@ -19,7 +19,6 @@ export type AppId =
| "IMAGE_WATERMARK_REMOVAL"
| "IMAGE_TO_3D_OBJECT"
| "IMAGE_TO_3D_WORLD"
| "REMOVE_BACKGROUND"
| "ANGLES"
| "STORYBOARD"
| "BACKGROUND_CHANGE"
@ -78,7 +77,6 @@ export const goToApp = (action?: string) => {
"IMAGE_WATERMARK_REMOVAL",
"IMAGE_TO_3D_OBJECT",
"IMAGE_TO_3D_WORLD",
"REMOVE_BACKGROUND",
"ANGLES",
"STORYBOARD",
"BACKGROUND_CHANGE",

View File

@ -47,7 +47,6 @@ import { VideoWatermarkRemover } from "./PageVideoWatermarkRemover";
import { ImageWatermarkRemover } from "./PageImageWatermarkRemover";
import { ImageTo3DObject } from "./PageImageTo3DObject";
import { ImageTo3DWorld } from "./PageImageTo3DWorld";
import { RemoveBackground } from "./PageRemoveBackground";
import { Angles } from "./PageAngles";
import { Storyboard } from "./PageStoryboard";
import { PageBackgroundChange } from "./PageBackgroundChange";
@ -211,12 +210,6 @@ const TabBody = () => {
<ImageTo3DWorld />
</div>
);
case "REMOVE_BACKGROUND":
return (
<div>
<RemoveBackground />
</div>
);
case "ANGLES":
return (
<div>

View File

@ -1,769 +0,0 @@
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faWandMagicSparkles,
faImages,
faPlus,
faEye,
faDownload,
faUpload,
} from "@fortawesome/pro-solid-svg-icons";
import { Button } from "@storyteller/ui-button";
import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal";
import { downloadFileFromUrl } from "@storyteller/api";
import toast from "react-hot-toast";
import { v4 as uuidv4 } from "uuid";
import { UploadEntryCard } from "../../components/media/UploadEntryCard";
import {
useRemoveBackgroundStore,
ProcessedImage,
} from "./RemoveBackgroundStore";
import {
EnqueueImageBgRemoval,
useCanvasBgRemovedEvent,
} from "@storyteller/tauri-api";
import { PopoverMenu, PopoverItem } from "@storyteller/ui-popover";
import { twMerge } from "tailwind-merge";
import { LoadingSpinner } from "@storyteller/ui-loading-spinner";
const convertFileToBase64 = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => {
if (reader.result) {
resolve(reader.result as string);
} else {
reject(new Error("Failed to convert file to base64."));
}
};
reader.onerror = () => reject(new Error("Error reading file."));
reader.readAsDataURL(file);
});
};
export const RemoveBackground = () => {
const [isGalleryModalOpen, setIsGalleryModalOpen] = useState(false);
const [selectedGalleryImages, setSelectedGalleryImages] = useState<string[]>(
[],
);
const [windowSize, setWindowSize] = useState({
width: window.innerWidth,
height: window.innerHeight,
});
const [isHoldingCompare, setIsHoldingCompare] = useState(false);
const [animationComplete, setAnimationComplete] = useState(true);
const [isLoadingImage, setIsLoadingImage] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const clipLayerRef = useRef<HTMLDivElement>(null);
const progressBarRef = useRef<HTMLDivElement>(null);
const revealProgressRef = useRef(100);
const animationFrameRef = useRef<number | null>(null);
const processingExternalUrlRef = useRef<string | null>(null);
const store = useRemoveBackgroundStore();
const {
images,
activeImageId,
isProcessing,
currentOriginalUrl,
pendingJobId,
imageDimensions,
pendingExternalUrl,
setIsProcessing,
setCurrentOriginalUrl,
setPendingJobId,
setImageDimensions,
setPendingExternalUrl,
addImage,
setActiveImage,
getActiveImage,
} = store;
const activeImage = getActiveImage();
const pendingJobIdRef = useRef(pendingJobId);
const currentOriginalUrlRef = useRef(currentOriginalUrl);
useEffect(() => {
pendingJobIdRef.current = pendingJobId;
}, [pendingJobId]);
useEffect(() => {
currentOriginalUrlRef.current = currentOriginalUrl;
}, [currentOriginalUrl]);
useEffect(() => {
const handleResize = () => {
setWindowSize({ width: window.innerWidth, height: window.innerHeight });
};
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
useEffect(() => {
const processExternalUrl = async () => {
if (!pendingExternalUrl) return;
if (processingExternalUrlRef.current === pendingExternalUrl) return;
processingExternalUrlRef.current = pendingExternalUrl;
const imageUrl = pendingExternalUrl;
setPendingExternalUrl(null);
setIsLoadingImage(true);
try {
const response = await fetch(imageUrl);
const blob = await response.blob();
const file = new File([blob], "external-image.png", {
type: blob.type,
});
const base64Image = await convertFileToBase64(file);
await new Promise<void>((resolve, reject) => {
const img = new Image();
img.onload = () => {
setImageDimensions({
width: img.naturalWidth,
height: img.naturalHeight,
});
resolve();
};
img.onerror = () => reject(new Error("Failed to load image"));
img.src = imageUrl;
});
setActiveImage(null);
setCurrentOriginalUrl(imageUrl);
setIsLoadingImage(false);
setIsProcessing(true);
const jobId = uuidv4();
setPendingJobId(jobId);
await EnqueueImageBgRemoval({
base64_image: base64Image,
frontend_caller: "mini_app",
frontend_subscriber_id: jobId,
});
} catch (error) {
console.error("Error processing external image:", error);
toast.error("Failed to process image");
setIsLoadingImage(false);
setIsProcessing(false);
setPendingJobId(null);
} finally {
processingExternalUrlRef.current = null;
}
};
processExternalUrl();
}, [
pendingExternalUrl,
setPendingExternalUrl,
setActiveImage,
setCurrentOriginalUrl,
setIsProcessing,
setPendingJobId,
setImageDimensions,
]);
const addMenuItems: PopoverItem[] = useMemo(
() => [
{
label: "Upload Image",
selected: false,
icon: <FontAwesomeIcon icon={faUpload} className="h-4 w-4" />,
action: "upload",
},
{
label: "Choose from Library",
selected: false,
icon: <FontAwesomeIcon icon={faImages} className="h-4 w-4" />,
action: "library",
},
],
[],
);
const handleAddMenuSelect = useCallback((item: PopoverItem) => {
if (item.action === "upload") {
fileInputRef.current?.click();
} else if (item.action === "library") {
setIsGalleryModalOpen(true);
}
}, []);
const startRevealAnimation = useCallback(() => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
}
revealProgressRef.current = 0;
setAnimationComplete(false);
const duration = 800;
const startTime = performance.now();
const animate = (currentTime: number) => {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
const eased = 1 - Math.pow(1 - progress, 3);
const progressPercent = eased * 100;
revealProgressRef.current = progressPercent;
if (clipLayerRef.current) {
clipLayerRef.current.style.clipPath = `inset(0 0 0 ${progressPercent}%)`;
}
if (progressBarRef.current) {
progressBarRef.current.style.left = `${progressPercent}%`;
progressBarRef.current.style.display =
progressPercent < 100 ? "block" : "none";
}
if (progress < 1) {
animationFrameRef.current = requestAnimationFrame(animate);
} else {
setAnimationComplete(true);
animationFrameRef.current = null;
}
};
requestAnimationFrame(() => {
requestAnimationFrame(() => {
setTimeout(() => {
animationFrameRef.current = requestAnimationFrame(animate);
}, 50);
});
});
}, []);
useEffect(() => {
return () => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
}
};
}, []);
useCanvasBgRemovedEvent(async (event) => {
if (event.maybe_frontend_subscriber_id !== pendingJobIdRef.current) return;
const newImage: ProcessedImage = {
id: uuidv4(),
originalUrl: currentOriginalUrlRef.current,
processedUrl: event.image_cdn_url,
timestamp: Date.now(),
};
const loadImage = (src: string): Promise<HTMLImageElement> => {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error(`Failed to load: ${src}`));
img.src = src;
});
};
try {
const [originalImg] = await Promise.all([
loadImage(currentOriginalUrlRef.current),
loadImage(event.image_cdn_url),
]);
addImage(newImage);
setIsProcessing(false);
setPendingJobId(null);
toast.success("Saved to Library");
setImageDimensions({
width: originalImg.naturalWidth,
height: originalImg.naturalHeight,
});
startRevealAnimation();
} catch (error) {
console.error("Error loading images:", error);
addImage(newImage);
setIsProcessing(false);
setPendingJobId(null);
toast.success("Saved to Library");
}
});
const resetAnimationState = useCallback(() => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
revealProgressRef.current = 0;
setAnimationComplete(true);
setIsHoldingCompare(false);
}, []);
const handleLocalImageSelect = useCallback(
async (files: FileList) => {
const file = files[0];
if (!file || !file.type.startsWith("image/")) return;
setIsLoadingImage(true);
try {
const base64Image = await convertFileToBase64(file);
const objectUrl = URL.createObjectURL(file);
await new Promise<void>((resolve, reject) => {
const img = new Image();
img.onload = () => {
setImageDimensions({
width: img.naturalWidth,
height: img.naturalHeight,
});
resolve();
};
img.onerror = () => reject(new Error("Failed to load image"));
img.src = objectUrl;
});
setActiveImage(null);
resetAnimationState();
setCurrentOriginalUrl(objectUrl);
setIsLoadingImage(false);
setIsProcessing(true);
const jobId = uuidv4();
setPendingJobId(jobId);
await EnqueueImageBgRemoval({
base64_image: base64Image,
frontend_caller: "mini_app",
frontend_subscriber_id: jobId,
});
} catch (error) {
console.error("Error processing image:", error);
toast.error("Failed to process image");
setIsLoadingImage(false);
setIsProcessing(false);
setPendingJobId(null);
}
},
[
setActiveImage,
resetAnimationState,
setCurrentOriginalUrl,
setIsProcessing,
setPendingJobId,
setImageDimensions,
],
);
const handleImageSelect = useCallback((id: string) => {
setSelectedGalleryImages((prev) => {
if (prev.includes(id)) return prev.filter((x) => x !== id);
return [id];
});
}, []);
const handleGallerySelect = useCallback(
async (selectedItems: GalleryItem[]) => {
const item = selectedItems[0];
if (!item || !item.fullImage) {
toast.error("No image selected");
return;
}
const imageUrl = item.fullImage;
setIsGalleryModalOpen(false);
setSelectedGalleryImages([]);
setIsLoadingImage(true);
try {
const response = await fetch(imageUrl);
const blob = await response.blob();
const file = new File([blob], "library-image.png", { type: blob.type });
const base64Image = await convertFileToBase64(file);
await new Promise<void>((resolve, reject) => {
const img = new Image();
img.onload = () => {
setImageDimensions({
width: img.naturalWidth,
height: img.naturalHeight,
});
resolve();
};
img.onerror = () => reject(new Error("Failed to load image"));
img.src = imageUrl;
});
setActiveImage(null);
resetAnimationState();
setCurrentOriginalUrl(imageUrl);
setIsLoadingImage(false);
setIsProcessing(true);
const jobId = uuidv4();
setPendingJobId(jobId);
await EnqueueImageBgRemoval({
base64_image: base64Image,
frontend_caller: "mini_app",
frontend_subscriber_id: jobId,
});
} catch (error) {
console.error("Error processing gallery image:", error);
toast.error("Failed to process image");
setIsLoadingImage(false);
setIsProcessing(false);
setPendingJobId(null);
}
},
[
setActiveImage,
resetAnimationState,
setCurrentOriginalUrl,
setIsProcessing,
setPendingJobId,
setImageDimensions,
],
);
const handleDownload = useCallback(async () => {
const currentActiveImage = getActiveImage();
if (!currentActiveImage) {
toast.error("No image to download");
return;
}
try {
await downloadFileFromUrl(currentActiveImage.processedUrl);
toast.success("Image saved to Downloads folder");
} catch (error) {
console.error("Download failed:", error);
toast.error("Failed to download image");
}
}, [getActiveImage]);
const handleCompareMouseDown = useCallback(() => {
setIsHoldingCompare(true);
if (clipLayerRef.current) {
clipLayerRef.current.style.clipPath = "inset(0 0 0 0)";
}
if (progressBarRef.current) {
progressBarRef.current.style.display = "none";
}
}, []);
const handleCompareMouseUp = useCallback(() => {
setIsHoldingCompare(false);
if (clipLayerRef.current) {
clipLayerRef.current.style.clipPath = `inset(0 0 0 ${revealProgressRef.current}%)`;
}
if (progressBarRef.current && revealProgressRef.current < 100) {
progressBarRef.current.style.display = "block";
}
}, []);
useEffect(() => {
if (isHoldingCompare) {
document.addEventListener("mouseup", handleCompareMouseUp);
document.addEventListener("mouseleave", handleCompareMouseUp);
}
return () => {
document.removeEventListener("mouseup", handleCompareMouseUp);
document.removeEventListener("mouseleave", handleCompareMouseUp);
};
}, [isHoldingCompare, handleCompareMouseUp]);
const handleThumbnailClick = useCallback(
(img: ProcessedImage) => {
setActiveImage(img.id);
revealProgressRef.current = 100;
setAnimationComplete(true);
if (clipLayerRef.current) {
clipLayerRef.current.style.clipPath = "inset(0 0 0 100%)";
}
if (progressBarRef.current) {
progressBarRef.current.style.display = "none";
}
const loadImg = new Image();
loadImg.onload = () => {
setImageDimensions({
width: loadImg.naturalWidth,
height: loadImg.naturalHeight,
});
};
loadImg.src = img.originalUrl;
},
[setActiveImage, setImageDimensions],
);
const hasImages = images.length > 0;
const showUploadScreen = !hasImages && !isProcessing;
const imageContainerStyle = useMemo(() => {
if (!imageDimensions) {
return { width: "600px", height: "450px" };
}
const horizontalPadding = 128 + 32;
const verticalPadding = 128 + 150;
const availableWidth = windowSize.width - horizontalPadding;
const availableHeight = windowSize.height - 56 - verticalPadding;
const maxWidth = Math.min(availableWidth, 1400);
const maxHeight = Math.max(availableHeight, 200);
const imageAspect = imageDimensions.width / imageDimensions.height;
let width = maxWidth;
let height = width / imageAspect;
if (height > maxHeight) {
height = maxHeight;
width = height * imageAspect;
}
width = Math.max(width, 200);
height = Math.max(height, 150);
return { width: `${width}px`, height: `${height}px` };
}, [imageDimensions, windowSize.width, windowSize.height]);
const handleFileInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
handleLocalImageSelect(e.target.files);
e.target.value = "";
}
},
[handleLocalImageSelect],
);
const handleOpenGallery = useCallback(() => {
setIsGalleryModalOpen(true);
}, []);
const handleCloseGallery = useCallback(() => {
setIsGalleryModalOpen(false);
setSelectedGalleryImages([]);
}, []);
return (
<>
<div className="bg-ui-panel-gradient flex h-[calc(100vh-56px)] w-full overflow-hidden bg-ui-panel text-base-fg">
<div className="flex flex-1 items-center justify-center overflow-y-auto p-16">
<main className="flex h-full w-full flex-col items-center justify-center">
{showUploadScreen ? (
<div className="w-full max-w-5xl">
<div className="relative aspect-video overflow-hidden rounded-2xl border border-ui-panel-border bg-ui-background shadow-lg">
<UploadEntryCard
icon={faWandMagicSparkles}
title="Remove Background"
description="Instantly remove backgrounds from your images with AI-powered precision."
accentBackgroundClass="bg-violet-500/40"
accentBorderClass="border-violet-400/30"
accept="image/*"
onFilesSelected={handleLocalImageSelect}
primaryLabel="Select Image"
secondaryLabel="Pick from Library"
secondaryIcon={faImages}
onSecondaryClick={handleOpenGallery}
disabled={isLoadingImage}
/>
{isLoadingImage && (
<div className="bg-ui-panel/80 absolute inset-0 flex items-center justify-center backdrop-blur-sm">
<LoadingSpinner className="h-12 w-12" />
</div>
)}
</div>
</div>
) : (
<div className="flex h-full w-full max-w-[1400px] flex-col items-center">
{(activeImage || isProcessing) && (
<div className="flex shrink-0 gap-3 pb-4">
<Button
variant="action"
icon={faEye}
onMouseDown={handleCompareMouseDown}
disabled={!activeImage || isProcessing}
className={twMerge(
"border-ui-controls-border select-none border-2 px-6 py-2.5 text-sm font-semibold transition-all",
isHoldingCompare
? "border-primary bg-primary/20"
: "border-ui-controls-border",
(!activeImage || isProcessing) &&
"cursor-not-allowed opacity-50",
)}
>
{isHoldingCompare
? "Showing Original"
: "Hold to Compare"}
</Button>
<Button
variant="primary"
icon={faDownload}
onClick={handleDownload}
disabled={!activeImage || isProcessing}
className={twMerge(
"select-none border-2 border-primary px-6 py-2.5 text-sm font-semibold transition-all",
(!activeImage || isProcessing) &&
"cursor-not-allowed opacity-50",
)}
>
Download
</Button>
</div>
)}
<div className="flex flex-1 items-center justify-center">
<div
className="relative overflow-hidden rounded-2xl border border-ui-panel-border shadow-xl"
style={imageContainerStyle}
>
{isProcessing && (
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center bg-black/60 backdrop-blur-sm">
{currentOriginalUrl && (
<img
src={currentOriginalUrl}
alt="Processing"
className="absolute inset-0 h-full w-full object-contain opacity-30"
/>
)}
<div className="relative z-10 flex flex-col items-center gap-4">
<div className="relative">
<div className="h-16 w-16 animate-spin rounded-full border-4 border-primary-500/30 border-t-primary-500" />
<FontAwesomeIcon
icon={faWandMagicSparkles}
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-2xl text-primary-400"
/>
</div>
<span className="text-lg font-semibold text-white">
Removing Background...
</span>
</div>
</div>
)}
{activeImage && (
<>
<div
className="absolute inset-0"
style={{
backgroundImage: `
linear-gradient(45deg, #1a1a1a 25%, transparent 25%),
linear-gradient(-45deg, #1a1a1a 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #1a1a1a 75%),
linear-gradient(-45deg, transparent 75%, #1a1a1a 75%)
`,
backgroundSize: "16px 16px",
backgroundPosition:
"0 0, 0 8px, 8px -8px, -8px 0px",
backgroundColor: "#2a2a2a",
}}
/>
<img
src={activeImage.processedUrl}
alt="Background Removed"
className="absolute inset-0 h-full w-full object-contain"
/>
<div
ref={clipLayerRef}
className={twMerge(
"absolute inset-0",
animationComplete &&
"transition-[clip-path] duration-300",
)}
style={{
clipPath: `inset(0 0 0 ${revealProgressRef.current}%)`,
}}
>
<img
src={activeImage.originalUrl}
alt="Original"
className="absolute inset-0 h-full w-full object-contain"
/>
</div>
<div
ref={progressBarRef}
className="absolute bottom-0 top-0 w-1 bg-primary-500 shadow-lg shadow-primary-500/50"
style={{
left: `${revealProgressRef.current}%`,
transform: "translateX(-50%)",
display:
animationComplete ||
revealProgressRef.current >= 100
? "none"
: "block",
}}
/>
</>
)}
</div>
</div>
<div className="mt-auto flex shrink-0 items-center gap-3 rounded-xl border border-ui-panel-border bg-ui-background p-2">
<input
type="file"
ref={fileInputRef}
className="hidden"
accept="image/*"
onChange={handleFileInputChange}
/>
<PopoverMenu
items={addMenuItems}
onSelect={handleAddMenuSelect}
mode="button"
position="top"
showIconsInList
buttonClassName={twMerge(
"h-14 w-14 border-2 border-dashed border-ui-panel-border bg-ui-controls/50",
isProcessing && "cursor-not-allowed opacity-50",
)}
triggerIcon={
<FontAwesomeIcon icon={faPlus} className="text-xl" />
}
/>
{images.map((img) => (
<button
key={img.id}
onClick={() => handleThumbnailClick(img)}
className={twMerge(
"relative h-14 w-14 overflow-hidden rounded-lg border-2 transition-all",
img.id === activeImageId
? "border-primary ring-2 ring-primary/30"
: "border-transparent hover:border-primary/50",
)}
>
<img
src={img.processedUrl}
alt="Processed"
className="h-full w-full object-cover"
/>
</button>
))}
</div>
</div>
)}
</main>
</div>
</div>
<GalleryModal
isOpen={isGalleryModalOpen}
onClose={handleCloseGallery}
mode="select"
selectedItemIds={selectedGalleryImages}
onSelectItem={handleImageSelect}
maxSelections={1}
onUseSelected={handleGallerySelect}
onDownloadClicked={downloadFileFromUrl}
forceFilter="image"
/>
</>
);
};

View File

@ -1,93 +0,0 @@
import { create } from "zustand";
export interface ProcessedImage {
id: string;
originalUrl: string;
processedUrl: string;
timestamp: number;
}
export interface ImageDimensions {
width: number;
height: number;
}
interface RemoveBackgroundState {
images: ProcessedImage[];
activeImageId: string | null;
isProcessing: boolean;
currentOriginalUrl: string;
pendingJobId: string | null;
imageDimensions: ImageDimensions | null;
pendingExternalUrl: string | null;
addImage: (image: ProcessedImage) => void;
setActiveImage: (id: string | null) => void;
getActiveImage: () => ProcessedImage | null;
setIsProcessing: (value: boolean) => void;
setCurrentOriginalUrl: (url: string) => void;
setPendingJobId: (id: string | null) => void;
setImageDimensions: (dimensions: ImageDimensions | null) => void;
setPendingExternalUrl: (url: string | null) => void;
clearAll: () => void;
}
export const useRemoveBackgroundStore = create<RemoveBackgroundState>(
(set, get) => ({
images: [],
activeImageId: null,
isProcessing: false,
currentOriginalUrl: "",
pendingJobId: null,
imageDimensions: null,
pendingExternalUrl: null,
addImage: (image) => {
set((state) => ({
images: [...state.images, image],
activeImageId: image.id,
}));
},
setActiveImage: (id) => {
set({ activeImageId: id });
},
getActiveImage: () => {
const state = get();
return state.images.find((img) => img.id === state.activeImageId) ?? null;
},
setIsProcessing: (value) => {
set({ isProcessing: value });
},
setCurrentOriginalUrl: (url) => {
set({ currentOriginalUrl: url });
},
setPendingJobId: (id) => {
set({ pendingJobId: id });
},
setImageDimensions: (dimensions) => {
set({ imageDimensions: dimensions });
},
setPendingExternalUrl: (url) => {
set({ pendingExternalUrl: url });
},
clearAll: () => {
set({
images: [],
activeImageId: null,
isProcessing: false,
currentOriginalUrl: "",
pendingJobId: null,
imageDimensions: null,
pendingExternalUrl: null,
});
},
}),
);

View File

@ -1,3 +0,0 @@
export { RemoveBackground } from "./RemoveBackground";
export { useRemoveBackgroundStore } from "./RemoveBackgroundStore";

View File

@ -10,7 +10,6 @@ export type TabId =
| "IMAGE_WATERMARK_REMOVAL"
| "IMAGE_TO_3D_OBJECT"
| "IMAGE_TO_3D_WORLD"
| "REMOVE_BACKGROUND"
| "ANGLES"
| "STORYBOARD"
| "BACKGROUND_CHANGE"

View File

@ -14,12 +14,10 @@ import {
faPause,
faPencil,
faPlay,
faTrashCan,
faVideo,
faWandMagicSparkles,
faArrowRotateRight,
} from "@fortawesome/pro-solid-svg-icons";
import { MediaFileDelete } from "@storyteller/tauri-api";
import { LoadingSpinner } from "@storyteller/ui-loading-spinner";
import { Viewer3D } from "@storyteller/ui-viewer-3d";
import { WaveformAudioPlayer } from "@storyteller/ui-audio-player";
@ -41,8 +39,6 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCopy, faLink, faCheck } from "@fortawesome/pro-solid-svg-icons";
import { twMerge } from "tailwind-merge";
import {
showActionReminder,
isActionReminderOpen,
} from "@storyteller/ui-action-reminder-modal";
import {
getCreatorIconPathForModelId,
@ -408,33 +404,6 @@ export function LightboxModal({
[emblaMainApi, emblaThumbsApi],
);
const onDeleteClicked = useCallback(
(mediaToken: string) => {
showActionReminder({
reminderType: "default",
title: "Delete this media?",
message: (
<p className="text-sm text-white/70">
This will permanently remove the media from your library. This
action cannot be undone.
</p>
),
primaryActionText: "Delete",
secondaryActionText: "Cancel",
primaryActionBtnClassName: "bg-red text-white hover:bg-red/90",
onPrimaryAction: async () => {
try {
await MediaFileDelete(mediaToken);
} finally {
isActionReminderOpen.value = false;
onClose();
}
},
});
},
[onClose],
);
const onSelect = useCallback(() => {
if (!emblaMainApi || !emblaThumbsApi) return;
const index = emblaMainApi.selectedScrollSnap();
@ -1256,20 +1225,6 @@ export function LightboxModal({
</Button>
)}
{selectedMediaToken && onDeleteClicked && (
<Button
icon={faTrashCan}
className="w-full py-1.5 text-[13px]"
variant="destructive"
onClick={async (e) => {
e.stopPropagation();
gtagEvent("delete_media_clicked");
await onDeleteClicked(selectedMediaToken);
}}
>
Delete
</Button>
)}
</div>
)}
</div>

View File

@ -23,18 +23,15 @@
{
"path": "../../analytics/google-analytics"
},
{
"path": "../audio-player"
},
{
"path": "../viewer-3d"
},
{
"path": "../loading-spinner"
},
{
"path": "../audio-player"
},
{
"path": "../../tauri-api"
},
{
"path": "../button"
},

View File

@ -64,18 +64,15 @@
{
"path": "../../analytics/google-analytics/tsconfig.lib.json"
},
{
"path": "../audio-player/tsconfig.lib.json"
},
{
"path": "../viewer-3d/tsconfig.lib.json"
},
{
"path": "../loading-spinner/tsconfig.lib.json"
},
{
"path": "../audio-player/tsconfig.lib.json"
},
{
"path": "../../tauri-api/tsconfig.lib.json"
},
{
"path": "../button/tsconfig.lib.json"
},

View File

@ -1,233 +0,0 @@
import { useEffect, useState, ReactNode } from "react";
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faGripVertical,
faSpinnerThird,
} from "@fortawesome/pro-solid-svg-icons";
import {
SetProviderOrder,
Provider,
GetProviderOrder,
} from "@storyteller/tauri-api";
import { getCreatorIcon, ModelCreator } from "@storyteller/model-list";
import { IsDesktopApp } from "@storyteller/tauri-utils";
interface ProviderItem {
id: Provider;
name: string;
}
interface SortableItemProps {
id: string;
name: string;
icon: ReactNode;
isUpdating?: boolean;
}
const SortableItem = ({
id,
name,
icon,
isUpdating = false,
}: SortableItemProps) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
className={`
flex items-center justify-between rounded-lg p-3 transition-colors duration-200 border border-ui-controls-border bg-ui-controls/60 text-base-fg
${
isUpdating
? "opacity-60 cursor-not-allowed"
: "cursor-move hover:bg-ui-controls/80"
}
${isDragging ? "opacity-70 shadow-lg" : ""}
`}
>
<div className="flex items-center gap-3">
<span className="inline-flex items-center justify-center h-4 w-4">
{icon}
</span>
<span className="font-medium">{name}</span>
</div>
<div className="flex items-center">
<FontAwesomeIcon
icon={faGripVertical}
className="text-base-fg/40 text-sm"
/>
</div>
</div>
);
};
const ProviderItemMap = {
[Provider.ArtCraft]: { id: Provider.ArtCraft, name: "ArtCraft" },
[Provider.Fal]: { id: Provider.Fal, name: "Fal" },
[Provider.Sora]: { id: Provider.Sora, name: "Sora / ChatGPT" },
};
const PROVIDER_TO_CREATOR: Partial<Record<Provider, ModelCreator>> = {
[Provider.ArtCraft]: ModelCreator.ArtCraft,
[Provider.Fal]: ModelCreator.Fal,
[Provider.Sora]: ModelCreator.OpenAi,
};
const getProviderIcon = (provider: Provider): ReactNode => {
const creator = PROVIDER_TO_CREATOR[provider];
if (creator) return getCreatorIcon(creator, "h-4 w-4 icon-auto-contrast");
return (
<img
src={
IsDesktopApp()
? "/resources/images/services/generic.svg"
: "/images/services/generic.svg"
}
alt="generic logo"
className="h-4 w-4 icon-auto-contrast"
/>
);
};
export const ProviderPrioritySettingsPane = () => {
const [items, setItems] = useState<ProviderItem[]>([]);
const [isUpdating, setIsUpdating] = useState(false);
useEffect(() => {
const fetchData = async () => {
const providers = await GetProviderOrder();
let items: ProviderItem[] = [];
// Add providers from backend (in order)
for (let provider of providers.payload.providers) {
items.push(ProviderItemMap[provider]);
}
// Add providers not in backend (in order)
for (const [key, value] of Object.entries(ProviderItemMap)) {
if (!providers.payload.providers.includes(key as Provider)) {
items.push(value);
}
}
setItems(items);
};
fetchData();
}, []);
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const updateProviderPriorityOnBackend = async (newOrder: ProviderItem[]) => {
try {
setIsUpdating(true);
console.log("Updating provider priority on backend:", newOrder);
let ordering = newOrder.map((item) => item.id);
console.log("Provider order:", ordering);
await SetProviderOrder({ providers: ordering });
console.log("Provider priority updated successfully");
} catch (error) {
console.error("Failed to update provider priority:", error);
} finally {
setIsUpdating(false);
}
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (active.id !== over?.id) {
setItems((prevItems) => {
const oldIndex = prevItems.findIndex((item) => item.id === active.id);
const newIndex = prevItems.findIndex((item) => item.id === over?.id);
const newOrder = arrayMove(prevItems, oldIndex, newIndex);
// Send update to backend
updateProviderPriorityOnBackend(newOrder);
return newOrder;
});
}
};
return (
<div className="space-y-4">
<div>
<p className="text-sm text-base-fg/70 mb-4">
Drag and drop to reorder model provider priority. Higher items will be
tried first. You can use this to control favorite services and
spending.
</p>
</div>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext items={items} strategy={verticalListSortingStrategy}>
<div className="space-y-2">
{items.map((item) => (
<SortableItem
key={item.id}
id={item.id}
name={item.name}
icon={getProviderIcon(item.id)}
isUpdating={isUpdating}
/>
))}
</div>
</SortableContext>
</DndContext>
{isUpdating && (
<div className="text-xs rounded-full animate-pulse mt-4 flex items-center gap-2 text-base-fg/70">
<FontAwesomeIcon icon={faSpinnerThird} className="animate-spin" />
Updating...
</div>
)}
</div>
);
};

View File

@ -5,7 +5,6 @@ import { MiscSettingsPane } from "./panes/MiscSettingsPane";
import { AudioSettingsPane } from "./panes/AudioSettingsPane";
import { AccountSettingsPane } from "./panes/AccountSettings/AccountSettingsPane";
import { AboutSettingsPane } from "./panes/AboutSettingsPane";
import { ProviderPrioritySettingsPane } from "./panes/ProviderPrioritySettingsPane";
import { ExperimentalSettingsPane } from "./panes/ExperimentalSettingsPane";
import { gtagEvent } from "@storyteller/google-analytics";
import { BillingSettingsPane } from "./panes/BillingSettingsPane";
@ -20,7 +19,6 @@ export type SettingsSection =
| "accounts"
| "alerts"
| "about"
| "provider_priority"
| "billing"
| "experimental";
@ -85,8 +83,6 @@ export const SettingsContent = ({
);
case "about":
return <AboutSettingsPane />;
case "provider_priority":
return <ProviderPrioritySettingsPane />;
case "billing":
return <BillingSettingsPane />;
case "experimental":

View File

@ -2,9 +2,6 @@
"files": [],
"include": [],
"references": [
{
"path": "../../tauri-utils"
},
{
"path": "../../api"
},

View File

@ -43,9 +43,6 @@
"src/**/*.tsx"
],
"references": [
{
"path": "../../tauri-utils/tsconfig.lib.json"
},
{
"path": "../../api/tsconfig.lib.json"
},

View File

@ -7,7 +7,6 @@ export * from "./lib/common/CommandStatus.js";
export * from "./lib/download/DownloadDirectoryReveal.js";
export * from "./lib/download/DownloadUrl.js";
export * from "./lib/enqueue/EnqueueEditImage.js";
export * from "./lib/enqueue/EnqueueImageBgRemovalCommand.js";
export * from "./lib/enqueue/EnqueueImageInpaint.js";
export * from "./lib/enqueue/EnqueueImageToVideo.js";
export * from "./lib/enqueue/EnqueueTextToImage.js";
@ -26,14 +25,11 @@ export * from "./lib/fal/FalKlingImageToVideo.js";
export * from "./lib/fal/GetFalApiKey.js";
export * from "./lib/fal/SetFalApiKey.js";
export * from "./lib/grok/GrokGetCredentialInfo.js";
export * from "./lib/media_files/MediaFileDeleteCommand.js";
export * from "./lib/midjourney/MidjourneyGetCredentialInfo.js";
export * from "./lib/openai/GetOpenAIApiKey.js";
export * from "./lib/openai/SetOpenAIApiKey.js";
export * from "./lib/opener/OpenUrl.js";
export * from "./lib/providers/GetProviderOrder.js";
export * from "./lib/providers/Provider.js";
export * from "./lib/providers/SetProviderOrder.js";
export * from "./lib/settings/GetAppPreferences.js";
export * from "./lib/settings/UpdateAppPreference.js";
export * from "./lib/sora/CheckSoraSession.js";

View File

@ -1,71 +0,0 @@
import { invoke } from "@tauri-apps/api/core";
import { CommandResult } from "../common/CommandStatus";
export interface EnqueueImageBgRemovalRequest {
image_media_token?: string;
base64_image?: string;
// TODO: Actual enum.
frontend_caller?: string;
// We'll use the node id here.
frontend_subscriber_id?: string;
}
interface RawEnqueueImageBgRemovalRequest {
image_media_token?: string;
base64_image?: string;
frontend_caller?: string;
frontend_subscriber_id?: string;
}
export enum EnqueueImageBgRemovalErrorType {
/// Caller didn't specify a model
ModelNotSpecified = "model_not_specified",
/// Generic server error
ServerError = "server_error",
/// No Fal API key available
NeedsFalApiKey = "needs_fal_api_key",
/// Fal had an API error
FalError = "fal_error",
}
export interface EnqueueImageBgRemovalError extends CommandResult {
error_type: EnqueueImageBgRemovalErrorType;
error_message?: string;
}
export interface EnqueueImageBgRemovalPayload {
}
export interface EnqueueImageBgRemovalSuccess extends CommandResult {
payload: EnqueueImageBgRemovalPayload;
}
export type EnqueueImageBgRemovalResult = EnqueueImageBgRemovalSuccess | EnqueueImageBgRemovalError;
export const EnqueueImageBgRemoval = async (request: EnqueueImageBgRemovalRequest) : Promise<EnqueueImageBgRemovalResult> => {
let mutableRequest : RawEnqueueImageBgRemovalRequest = {};
if (!!request.image_media_token) {
mutableRequest.image_media_token = request.image_media_token;
}
if (!!request.base64_image) {
mutableRequest.base64_image = request.base64_image;
}
if (!!request.frontend_caller) {
mutableRequest.frontend_caller = request.frontend_caller;
}
if (!!request.frontend_subscriber_id) {
mutableRequest.frontend_subscriber_id = request.frontend_subscriber_id;
}
const result = await invoke("enqueue_image_bg_removal_command", {
request: mutableRequest,
});
return (result as EnqueueImageBgRemovalResult);
}

View File

@ -1,26 +0,0 @@
import { invoke } from "@tauri-apps/api/core";
import { CommandResult } from "../common/CommandStatus";
export interface MediaFileDeleteError extends CommandResult {
error_message?: string;
}
export interface MediaFileDeletePayload {
success: boolean;
}
export interface MediaFileDeleteSuccess extends CommandResult {
payload: MediaFileDeletePayload;
}
export type MediaFileDeleteResult = MediaFileDeleteSuccess | MediaFileDeleteError;
export const MediaFileDelete = async (media_file_token: string) : Promise<MediaFileDeleteResult> => {
const result = await invoke("media_file_delete_command", {
request: {
media_file_token: media_file_token,
}
});
return (result as MediaFileDeleteResult);
}

View File

@ -1,22 +0,0 @@
import { invoke } from "@tauri-apps/api/core";
import { CommandResult } from "../common/CommandStatus";
import { Provider } from "./Provider";
export interface GetProviderOrderSuccess extends CommandResult {
payload: GetProviderOrderPayload;
}
export interface GetProviderOrderPayload {
providers: Provider[];
}
// Returns the Success and Error variants directly.
// Throws on Network/Tauri errors.
export const GetProviderOrder = async () : Promise<GetProviderOrderSuccess> => {
try {
return await invoke("get_provider_order_command") as GetProviderOrderSuccess;
} catch (error) {
// NB: Endpoint should be infalliable
throw error;
}
}

View File

@ -1,17 +0,0 @@
import { invoke } from "@tauri-apps/api/core";
import { CommandResult } from "../common/CommandStatus";
import { Provider } from "./Provider";
export interface SetProviderOrderRequest {
providers: Provider[];
}
export const SetProviderOrder = async (request: SetProviderOrderRequest) : Promise<CommandResult> => {
const result = await invoke("set_provider_order_command", {
request: {
providers: request.providers,
}
});
return (result as CommandResult);
}