Compare commits

...
Author SHA1 Message Date
Brandon Thomas c9156a7ec6 notes 2025-08-08 01:04:19 -04:00
Brandon Thomas 00a9fa9d3c cleanup 2025-08-08 00:28:58 -04:00
Brandon Thomas 204d8377d5 background removal works !! 2025-08-07 23:56:20 -04:00
Brandon Thomas 480cd43e15 fix plumbing, begin to enqueue 2025-08-07 22:28:44 -04:00
Brandon Thomas c69f9c781c more plumbing 2025-08-07 22:16:19 -04:00
Brandon Thomas 324a11bcbe command caller 2025-08-07 22:02:42 -04:00
Brandon Thomas d5030f96ba enums, migration change 2025-08-07 21:56:14 -04:00
Brandon Thomas 0ea24480db add field to table, allow re-running migrations for tasks 2025-08-07 20:39:47 -04:00
Brandon Thomas 3d3a8d9a66 send event for background removal 2025-08-07 20:03:28 -04:00
Brandon Thomas 5d8aac6992 command wrapper code 2025-08-07 12:05:56 -04:00
Brandon Thomas 95a6ae4746 fields for frontend events 2025-08-07 10:28:39 -04:00
Brandon Thomas b2fe3052dc schema notes 2025-08-07 09:28:33 -04:00
38 changed files with 759 additions and 160 deletions
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n INSERT INTO tasks (\n id,\n task_status,\n task_type,\n model_type,\n provider,\n provider_job_id,\n frontend_caller,\n frontend_subscriber_id,\n frontend_subscriber_payload\n )\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 9
},
"nullable": []
},
"hash": "01a0bf33e8331c19dc73f064c54eaa863bec158ef7713450a06c75e246f5f08c"
}
@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "\n INSERT INTO tasks (\n id,\n task_status,\n task_type,\n model_type,\n provider,\n provider_job_id,\n frontend_subscriber_id,\n frontend_subscriber_payload\n )\n VALUES (?, ?, ?, ?, ?, ?, ?, ?)\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 8
},
"nullable": []
},
"hash": "22b91a21cbb88abc9bc17b338cf2f9418e08e414667d44476623eb141ab17b87"
}
@@ -4,17 +4,43 @@
-- tasks_v2.sqlite - added model_type (nullable)
CREATE TABLE tasks (
-- Task auto-incrementing primary key.
-- We don't use this.
id TEXT NOT NULL PRIMARY KEY,
-- TaskStatus enum
-- This is how the job system manages job states.
-- e.g. pending, started, complete_success, dead, etc.
task_status TEXT NOT NULL,
-- TaskType enum
-- e.g. image_generation, video_generation, etc.
task_type TEXT NOT NULL,
-- TaskModelType enum
-- e.g. flux_1_dev, veo_2, etc.
model_type TEXT,
-- GenerationProvider enum
-- Together with `provider_job_id`, this creates a foreign key lookup to the job.
-- e.g. 'artcraft', 'fal', 'sora', etc.
provider TEXT,
-- The primary key for the job in the provider's system.
provider_job_id TEXT,
-- OPTIONAL.
-- Tell the job system which caller initiated the task.
-- e.g. 'canvas'
frontend_caller TEXT,
-- OPTIONAL.
-- An arbitrary opaque identifier that frontend can set.
frontend_subscriber_id TEXT,
-- OPTIONAL.
-- An opaque JSON payload set by the frontend subscriber that
-- will be re-emitted back to the frontend.
frontend_subscriber_payload TEXT,
created_at INTEGER NOT NULL DEFAULT (unixepoch('now')),
@@ -43,6 +43,7 @@ use storyteller_client::error::storyteller_error::StorytellerError;
use storyteller_client::media_files::get_media_file::get_media_file;
use storyteller_client::utils::api_host::ApiHost;
use tauri::{AppHandle, Manager, State};
use enums::tauri::ux::tauri_command_caller::TauriCommandCaller;
use tokens::tokens::media_files::MediaFileToken;
#[derive(Deserialize, Debug)]
@@ -55,6 +56,21 @@ pub struct EnqueueImageBgRemovalCommand {
/// 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)]
@@ -169,7 +185,12 @@ pub async fn handle_request(
).await?;
let result = success_event
.insert_into_task_database(task_database)
.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 {
@@ -30,6 +30,7 @@ use crate::services::storyteller::state::storyteller_credential_manager::Storyte
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 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};
@@ -80,6 +81,21 @@ pub struct EnqueueInpaintImageCommand {
/// If true, force the dimensions of the source image and mask image to match.
pub require_matching_dimensions: Option<bool>,
/// 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)]
@@ -216,7 +232,12 @@ pub async fn handle_request(
};
let result = success_event
.insert_into_task_database(task_database)
.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 {
@@ -4,6 +4,7 @@ use enums::common::generation_provider::GenerationProvider;
use enums::tauri::tasks::task_model_type::TaskModelType;
use enums::tauri::tasks::task_status::TaskStatus;
use enums::tauri::tasks::task_type::TaskType;
use enums::tauri::ux::tauri_command_caller::TauriCommandCaller;
use sqlite_tasks::error::SqliteTasksError;
use sqlite_tasks::queries::create_task::{create_task, CreateTaskArgs};
use tokens::tokens::sqlite::tasks::TaskId;
@@ -35,6 +36,22 @@ impl TaskEnqueueSuccess{
}
pub async fn insert_into_task_database(&self, task_database: &TaskDatabase) -> Result<TaskId, SqliteTasksError> {
self.insert_into_task_database_with_frontend_payload(
task_database,
None,
None,
None,
).await
}
// TODO: This belongs somewhere else, not as a method of an event struct.
pub async fn insert_into_task_database_with_frontend_payload(
&self,
task_database: &TaskDatabase,
frontend_caller: Option<TauriCommandCaller>,
frontend_subscriber_id: Option<&str>,
frontend_subscriber_payload: Option<&str>,
) -> Result<TaskId, SqliteTasksError> {
// TODO: Move this mapping elsewhere, or remove the other models.
let model_type = match self.model {
None => None,
@@ -63,23 +80,12 @@ impl TaskEnqueueSuccess{
db: task_database.get_connection(),
status: TaskStatus::Pending,
task_type: self.task_type,
model_type: model_type,
model_type,
provider: self.provider,
provider_job_id: self.provider_job_id.as_deref(),
frontend_subscriber_id: None,
frontend_subscriber_payload: None,
frontend_caller,
frontend_subscriber_id,
frontend_subscriber_payload,
}).await
}
// pub fn tauri_event_model(&self) -> GenerationModel {
// match self.model {
// ImageModel::Flux1Dev => GenerationModel::Flux1Dev,
// ImageModel::Flux1Schnell => GenerationModel::Flux1Schnell,
// ImageModel::FluxPro11 => GenerationModel::FluxPro11,
// ImageModel::FluxPro11Ultra => GenerationModel::FluxPro11Ultra,
// ImageModel::GptImage1 => GenerationModel::GptImage1,
// ImageModel::Recraft3 => GenerationModel::Recraft3,
// }
// }
}
@@ -1,4 +1,5 @@
use crate::core::events::sendable_event_error::SendableEventError;
use enums::tauri::ux::tauri_event_name::TauriEventName;
use log::info;
use serde::Serialize;
use std::fmt::Debug;
@@ -29,7 +30,7 @@ struct BasicEventWrapper<T: Serialize + Debug> {
pub trait BasicSendableEvent : Clone + Debug + Serialize {
/// This is the name of the event that the frontend subscribes to.
const FRONTEND_EVENT_NAME: &'static str;
const FRONTEND_EVENT_NAME: TauriEventName;
/// This is the status of the event: Success, Failure, etc.
const EVENT_STATUS: BasicEventStatus;
@@ -41,8 +42,8 @@ pub trait BasicSendableEvent : Clone + Debug + Serialize {
status: Self::EVENT_STATUS,
data: self.clone(),
};
info!("Sending event to frontend: {} ; payload = {:?}", Self::FRONTEND_EVENT_NAME, wrapped);
let result = app.emit(Self::FRONTEND_EVENT_NAME, wrapped);
info!("Sending event to frontend: {} ; payload = {:?}", Self::FRONTEND_EVENT_NAME.to_str(), wrapped);
let result = app.emit(Self::FRONTEND_EVENT_NAME.to_str(), wrapped);
if let Err(err) = result {
return Err(SendableEventError::from(err));
}
@@ -0,0 +1,19 @@
use crate::core::events::basic_sendable_event_trait::{BasicEventStatus, BasicSendableEvent};
use enums::tauri::ux::tauri_event_name::TauriEventName;
use serde_derive::Serialize;
use tokens::tokens::media_files::MediaFileToken;
use url::Url;
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "snake_case")]
pub struct CanvasBackgroundRemovalCompleteEvent {
pub media_token: MediaFileToken,
pub image_cdn_url: Url,
pub maybe_frontend_subscriber_id: Option<String>,
pub maybe_frontend_subscriber_payload: Option<String>,
}
impl BasicSendableEvent for CanvasBackgroundRemovalCompleteEvent {
const FRONTEND_EVENT_NAME: TauriEventName = TauriEventName::CanvasBgRemovedEvent;
const EVENT_STATUS: BasicEventStatus = BasicEventStatus::Success;
}
@@ -0,0 +1 @@
pub mod canvas_background_removal_complete_event;
@@ -1,5 +1,6 @@
use crate::core::events::basic_sendable_event_trait::{BasicEventStatus, BasicSendableEvent};
use crate::core::events::generation_events::common::{GenerationAction, GenerationModel, GenerationServiceProvider};
use enums::tauri::ux::tauri_event_name::TauriEventName;
use serde_derive::Serialize;
#[derive(Clone, Debug, Serialize)]
@@ -11,6 +12,6 @@ pub struct GenerationCompleteEvent {
}
impl BasicSendableEvent for GenerationCompleteEvent {
const FRONTEND_EVENT_NAME: &'static str = "generation-complete-event";
const FRONTEND_EVENT_NAME: TauriEventName = TauriEventName::GenerationCompleteEvent;
const EVENT_STATUS: BasicEventStatus = BasicEventStatus::Success;
}
@@ -1,5 +1,6 @@
use crate::core::events::basic_sendable_event_trait::{BasicEventStatus, BasicSendableEvent};
use crate::core::events::generation_events::common::{GenerationAction, GenerationModel, GenerationServiceProvider};
use enums::tauri::ux::tauri_event_name::TauriEventName;
use serde_derive::Serialize;
#[derive(Clone, Debug, Serialize)]
@@ -14,7 +15,7 @@ pub struct GenerationEnqueueFailureEvent {
}
impl BasicSendableEvent for GenerationEnqueueFailureEvent {
const FRONTEND_EVENT_NAME: &'static str = "generation-enqueue-failure-event";
const FRONTEND_EVENT_NAME: TauriEventName = TauriEventName::GenerationEnqueueFailureEvent;
const EVENT_STATUS: BasicEventStatus = BasicEventStatus::Failure;
}
@@ -1,5 +1,6 @@
use crate::core::events::basic_sendable_event_trait::{BasicEventStatus, BasicSendableEvent};
use crate::core::events::generation_events::common::{GenerationAction, GenerationModel, GenerationServiceProvider};
use enums::tauri::ux::tauri_event_name::TauriEventName;
use serde_derive::Serialize;
#[derive(Clone, Debug, Serialize)]
@@ -11,6 +12,6 @@ pub struct GenerationEnqueueSuccessEvent {
}
impl BasicSendableEvent for GenerationEnqueueSuccessEvent {
const FRONTEND_EVENT_NAME: &'static str = "generation-enqueue-success-event";
const FRONTEND_EVENT_NAME: TauriEventName = TauriEventName::GenerationEnqueueSuccessEvent;
const EVENT_STATUS: BasicEventStatus = BasicEventStatus::Success;
}
@@ -1,5 +1,6 @@
use crate::core::events::basic_sendable_event_trait::{BasicEventStatus, BasicSendableEvent};
use crate::core::events::generation_events::common::{GenerationAction, GenerationModel, GenerationServiceProvider};
use enums::tauri::ux::tauri_event_name::TauriEventName;
use serde_derive::Serialize;
#[derive(Clone, Debug, Serialize)]
@@ -14,6 +15,6 @@ pub struct GenerationFailedEvent {
}
impl BasicSendableEvent for GenerationFailedEvent {
const FRONTEND_EVENT_NAME: &'static str = "generation-failed-event";
const FRONTEND_EVENT_NAME: TauriEventName = TauriEventName::GenerationFailedEvent;
const EVENT_STATUS: BasicEventStatus = BasicEventStatus::Failure;
}
@@ -1,4 +1,5 @@
pub mod basic_sendable_event_trait;
pub mod generation_events;
pub mod sendable_event_error;
pub mod sendable_event_trait;
pub mod sendable_event_trait;
pub mod functional_events;
@@ -1,3 +1,4 @@
use crate::core::artcraft_error::ArtcraftError;
use crate::core::commands::response::failure_response_wrapper::{CommandErrorResponseWrapper, CommandErrorStatus};
use crate::core::commands::response::shorthand::Response;
@@ -0,0 +1,48 @@
use crate::core::events::basic_sendable_event_trait::BasicSendableEvent;
use crate::core::events::functional_events::canvas_background_removal_complete_event::CanvasBackgroundRemovalCompleteEvent;
use crate::core::events::generation_events::generation_complete_event::GenerationCompleteEvent;
use artcraft_api_defs::jobs::list_session_jobs::ListSessionJobsItem;
use enums::tauri::tasks::task_type::TaskType;
use enums::tauri::ux::tauri_command_caller::TauriCommandCaller;
use errors::AnyhowResult;
use log::error;
use sqlite_tasks::queries::list_tasks_by_provider_and_tokens::{list_tasks_by_provider_and_tokens, ListTasksArgs, Task};
use tokens::tokens::media_files::MediaFileToken;
pub async fn maybe_send_background_removal_complete_event(
app: &tauri::AppHandle,
task: &Task,
job: &ListSessionJobsItem,
) -> AnyhowResult<()> {
match task.task_type {
TaskType::BackgroundRemoval => {} // NB: Fall-through
_ => return Ok(()),
}
match task.frontend_caller {
Some(TauriCommandCaller::Canvas) => {} // NB: Fall-through
_ => return Ok(()),
}
let result = match job.maybe_result {
Some(ref res) => res,
None => {
error!("Job result is None for task: {:?}", task);
return Ok(()); // No result, nothing to do
},
};
let event = CanvasBackgroundRemovalCompleteEvent {
media_token: MediaFileToken::new_from_str(&result.entity_token),
image_cdn_url: result.media_links.cdn_url.clone(),
maybe_frontend_subscriber_id: task.frontend_subscriber_id.clone(),
maybe_frontend_subscriber_payload: task.frontend_subscriber_payload.clone(),
};
if let Err(err) = event.send(&app) {
error!("Failed to send CanvasBackgroundRemovalCompleteEvent: {:?}", err); // Fail open
}
Ok(())
}
@@ -0,0 +1 @@
pub mod maybe_send_background_removal_complete_event;
@@ -1 +1,2 @@
pub mod events;
pub mod storyteller_task_polling_thread;
@@ -12,10 +12,12 @@ use crate::core::utils::enum_conversion::task_type::to_generation_action;
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 crate::services::storyteller::threads::events::maybe_send_background_removal_complete_event::maybe_send_background_removal_complete_event;
use anyhow::anyhow;
use enums::common::generation_provider::GenerationProvider;
use enums::common::job_status_plus::JobStatusPlus;
use enums::tauri::tasks::task_status::TaskStatus;
use enums::tauri::tasks::task_type::TaskType;
use errors::AnyhowResult;
use log::{error, info};
use reqwest::Url;
@@ -137,7 +139,17 @@ async fn polling_loop(
if !updated {
continue; // If anything breaks with queries, don't spam events.
}
let result = maybe_send_background_removal_complete_event(
&app_handle,
&task,
&job,
).await;
if let Err(err) = result {
error!("Failed to send background removal complete event: {:?}", err);
}
let service = to_generation_service_provider(task.provider);
let action = to_generation_action(task.task_type);
@@ -1,4 +1,6 @@
use errors::AnyhowResult;
use log::{error, info};
use sqlx::migrate::MigrateError;
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode};
use sqlx::{Pool, Sqlite, SqlitePool};
use std::path::Path;
@@ -10,19 +12,20 @@ pub struct TaskDbConnection {
impl TaskDbConnection {
pub async fn connect_and_migrate<P: AsRef<Path>>(database_file: P) -> AnyhowResult<Self> {
let connection_options = SqliteConnectOptions::new()
.filename(database_file)
.create_if_missing(true)
.journal_mode(SqliteJournalMode::Wal);
match run_migrations(&database_file).await {
Ok(pool) => return Ok(Self { pool }),
Err(err) => {
error!("Error running SQLite migrations: {:?}", err);
}
}
let pool = SqlitePool::connect_with(connection_options).await?;
info!("Deleting and recreating SQLite database at {:?}", database_file.as_ref());
// Run migrations regardless of whether the database is new, SQLx will track which migrations
// have been run.
// The migrations text get compiled into the binary, so no worries about build inclusion.
// Since the task database is being treated as ephemeral, we can always run migrations without
// worrying about previous state if we simply blow away old versions of the schema.
sqlx::migrate!("../../../../_sql/artcraft_migrations").run(&pool).await?;
if let Err(err) = std::fs::remove_file(&database_file) {
error!("Error deleting SQLite database file: {:?}", err);
}
let pool = run_migrations(database_file).await?;
Ok(Self { pool })
}
@@ -31,3 +34,21 @@ impl TaskDbConnection {
&self.pool
}
}
async fn run_migrations<P: AsRef<Path>>(database_file: P) -> Result<SqlitePool, MigrateError> {
let connection_options = SqliteConnectOptions::new()
.filename(database_file)
.create_if_missing(true)
.journal_mode(SqliteJournalMode::Wal);
let pool = SqlitePool::connect_with(connection_options).await?;
// Run migrations regardless of whether the database is new, SQLx will track which migrations
// have been run.
// The migrations text get compiled into the binary, so no worries about build inclusion.
// Since the task database is being treated as ephemeral, we can always run migrations without
// worrying about previous state if we simply blow away old versions of the schema.
sqlx::migrate!("../../../../_sql/artcraft_migrations").run(&pool).await?;
Ok(pool)
}
@@ -4,6 +4,7 @@ use enums::common::generation_provider::GenerationProvider;
use enums::tauri::tasks::task_model_type::TaskModelType;
use enums::tauri::tasks::task_status::TaskStatus;
use enums::tauri::tasks::task_type::TaskType;
use enums::tauri::ux::tauri_command_caller::TauriCommandCaller;
use tokens::tokens::sqlite::tasks::TaskId;
pub struct CreateTaskArgs<'a> {
@@ -13,6 +14,7 @@ pub struct CreateTaskArgs<'a> {
pub model_type: Option<TaskModelType>,
pub provider: GenerationProvider,
pub provider_job_id: Option<&'a str>,
pub frontend_caller: Option<TauriCommandCaller>,
pub frontend_subscriber_id: Option<&'a str>,
pub frontend_subscriber_payload: Option<&'a str>,
}
@@ -28,6 +30,7 @@ pub async fn create_task(
let task_type_temp = args.task_type.to_str();
let model_type_temp = args.model_type.map(|s| s.to_str());
let provider_temp = args.provider.to_string();
let frontend_caller_temp = args.frontend_caller.map(|s| s.to_str());
let query = sqlx::query!(r#"
INSERT INTO tasks (
@@ -37,10 +40,11 @@ pub async fn create_task(
model_type,
provider,
provider_job_id,
frontend_caller,
frontend_subscriber_id,
frontend_subscriber_payload
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
"#,
task_id_temp,
status_temp,
@@ -48,6 +52,7 @@ pub async fn create_task(
model_type_temp,
provider_temp,
args.provider_job_id,
frontend_caller_temp,
args.frontend_subscriber_id,
args.frontend_subscriber_payload
);
@@ -4,6 +4,7 @@ use enums::common::generation_provider::GenerationProvider;
use enums::tauri::tasks::task_model_type::TaskModelType;
use enums::tauri::tasks::task_status::TaskStatus;
use enums::tauri::tasks::task_type::TaskType;
use enums::tauri::ux::tauri_command_caller::TauriCommandCaller;
use sqlx::{QueryBuilder, Sqlite};
use tokens::tokens::sqlite::tasks::TaskId;
@@ -22,9 +23,10 @@ pub struct Task {
pub id: TaskId,
pub status: TaskStatus,
pub task_type: TaskType,
pub model_type: TaskModelType,
pub model_type: Option<TaskModelType>,
pub provider: GenerationProvider,
pub provider_job_id: Option<String>,
pub frontend_caller: Option<TauriCommandCaller>,
pub frontend_subscriber_id: Option<String>,
pub frontend_subscriber_payload: Option<String>,
}
@@ -35,9 +37,10 @@ struct RawTask {
id: String,
task_status: String,
task_type: String,
model_type: String,
model_type: Option<String>,
provider: String,
provider_job_id: Option<String>,
frontend_caller: Option<String>,
frontend_subscriber_id: Option<String>,
frontend_subscriber_payload: Option<String>,
}
@@ -54,6 +57,7 @@ pub async fn list_tasks_by_provider_and_tokens(
model_type,
provider,
provider_job_id,
frontend_caller,
frontend_subscriber_id,
frontend_subscriber_payload
FROM tasks
@@ -91,11 +95,17 @@ pub async fn list_tasks_by_provider_and_tokens(
.map_err(|err| SqliteTasksError::TaskParseError(err))?,
task_type: TaskType::from_str(&task.task_type)
.map_err(|err| SqliteTasksError::TaskParseError(err))?,
model_type: TaskModelType::from_str(&task.model_type)
.map_err(|err| SqliteTasksError::TaskParseError(err))?,
model_type: task.model_type
.map(|model| TaskModelType::from_str(&model)
.map_err(|err| SqliteTasksError::TaskParseError(err)))
.transpose()?,
provider: GenerationProvider::from_str(&task.provider)
.map_err(|err| SqliteTasksError::TaskParseError(err))?,
provider_job_id: task.provider_job_id,
frontend_caller: task.frontend_caller
.map(|caller| TauriCommandCaller::from_str(&caller)
.map_err(|err| SqliteTasksError::TaskParseError(err)))
.transpose()?,
frontend_subscriber_id: task.frontend_subscriber_id,
frontend_subscriber_payload: task.frontend_subscriber_payload,
});
@@ -37,7 +37,7 @@ impl GenerationProvider {
"artcraft" => Ok(Self::Artcraft),
"fal" => Ok(Self::Fal),
"sora" => Ok(Self::Sora),
_ => Err(format!("invalid task_state: {:?}", job_status)),
_ => Err(format!("invalid generation_provider: {:?}", job_status)),
}
}
@@ -108,7 +108,7 @@ impl ModelType {
"hunyuan_3d_2p0" => Ok(Self::Hunyuan3d2_0),
"hunyuan_3d_2p1" => Ok(Self::Hunyuan3d2_1),
_ => Err(format!("invalid task_state: {:?}", job_status)),
_ => Err(format!("invalid model_type: {:?}", job_status)),
}
}
+2 -1
View File
@@ -1 +1,2 @@
pub mod tasks;
pub mod tasks;
pub mod ux;
@@ -98,7 +98,7 @@ impl TaskModelType {
// 3D Object generation models
"hunyuan_3d_2.0" => Ok(Self::Hunyuan3d2_0),
"hunyuan_3d_2.1" => Ok(Self::Hunyuan3d2_1),
_ => Err(format!("invalid task_state: {:?}", job_status)),
_ => Err(format!("invalid task_model_type: {:?}", job_status)),
}
}
@@ -59,7 +59,7 @@ impl TaskStatus {
"cancelled_by_user" => Ok(Self::CancelledByUser),
"cancelled_by_provider" => Ok(Self::CancelledByProvider),
"cancelled_by_us" => Ok(Self::CancelledByUs),
_ => Err(format!("invalid task_state: {:?}", job_status)),
_ => Err(format!("invalid task_status: {:?}", job_status)),
}
}
@@ -38,7 +38,7 @@ impl TaskType {
"video_generation" => Ok(Self::VideoGeneration),
"object_generation" => Ok(Self::ObjectGeneration),
"background_removal" => Ok(Self::BackgroundRemoval),
_ => Err(format!("invalid task_state: {:?}", job_status)),
_ => Err(format!("invalid task_type: {:?}", job_status)),
}
}
@@ -0,0 +1,2 @@
pub mod tauri_command_caller;
pub mod tauri_event_name;
@@ -0,0 +1,109 @@
use std::collections::BTreeSet;
#[cfg(test)]
use strum::EnumCount;
#[cfg(test)]
use strum::EnumIter;
use utoipa::ToSchema;
/// Defines the names of the Tauri-sent events that the frontend subscribes to.
/// These event names are also stored in the database, so keep them short-ish.
#[cfg_attr(test, derive(EnumIter, EnumCount))]
#[derive(Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum TauriCommandCaller {
/// The 2D canvas
Canvas,
}
impl_enum_display_and_debug_using_to_str!(TauriCommandCaller);
impl_mysql_enum_coders!(TauriCommandCaller);
impl_mysql_from_row!(TauriCommandCaller);
// NB: We can derive `sqlx::Type` instead of using `impl_mysql_enum_coders`
impl TauriCommandCaller {
pub fn to_str(&self) -> &'static str {
match self {
Self::Canvas => "canvas",
}
}
pub fn from_str(job_status: &str) -> Result<Self, String> {
match job_status {
"canvas" => Ok(Self::Canvas),
_ => Err(format!("invalid tauri_command_caller: {:?}", job_status)),
}
}
pub fn all_variants() -> BTreeSet<Self> {
// NB: BTreeSet is sorted
// NB: BTreeSet::from() isn't const, but not worth using LazyStatic, etc.
BTreeSet::from([
Self::Canvas,
])
}
}
#[cfg(test)]
mod tests {
use crate::test_helpers::assert_serialization;
use crate::tauri::ux::tauri_command_caller::TauriCommandCaller;
mod explicit_checks {
use super::*;
#[test]
fn test_serialization() {
assert_serialization(TauriCommandCaller::Canvas, "canvas");
}
#[test]
fn to_str() {
assert_eq!(TauriCommandCaller::Canvas.to_str(), "canvas");
}
#[test]
fn from_str() {
assert_eq!(TauriCommandCaller::from_str("canvas").unwrap(), TauriCommandCaller::Canvas);
}
#[test]
fn all_variants() {
let mut variants = TauriCommandCaller::all_variants();
assert_eq!(variants.len(), 1);
assert_eq!(variants.pop_first(), Some(TauriCommandCaller::Canvas));
assert_eq!(variants.pop_first(), None);
}
}
mod mechanical_checks {
use super::*;
use strum::IntoEnumIterator;
#[test]
fn variant_length() {
assert_eq!(TauriCommandCaller::all_variants().len(), TauriCommandCaller::iter().len());
}
#[test]
fn round_trip() {
for variant in TauriCommandCaller::all_variants() {
// Test to_str(), from_str(), Display, and Debug.
assert_eq!(variant, TauriCommandCaller::from_str(variant.to_str()).unwrap());
assert_eq!(variant, TauriCommandCaller::from_str(&format!("{}", variant)).unwrap());
assert_eq!(variant, TauriCommandCaller::from_str(&format!("{:?}", variant)).unwrap());
}
}
//#[test]
//fn serialized_length_ok_for_database() {
// const MAX_LENGTH : usize = 16;
// for variant in TauriCommandCaller::all_variants() {
// let serialized = variant.to_str();
// assert!(serialized.len() > 0, "variant {:?} is too short", variant);
// assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long", variant);
// }
//}
}
}
@@ -0,0 +1,168 @@
use std::collections::BTreeSet;
#[cfg(test)]
use strum::EnumCount;
#[cfg(test)]
use strum::EnumIter;
use utoipa::ToSchema;
/// Defines the names of the Tauri-sent events that the frontend subscribes to.
/// These event names are also stored in the database, so keep them short-ish.
///
/// NB: Events should end in "_event" so they're easy to grep for in Javascript.
#[cfg_attr(test, derive(EnumIter, EnumCount))]
#[derive(Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum TauriEventName {
// TODO: Get rid of kebab case.
/// General purpose event:
/// Generation enqueued
#[serde(rename = "generation-enqueue-success-event")]
GenerationEnqueueSuccessEvent,
// TODO: Get rid of kebab case.
/// General purpose event:
/// Generation failed to enqueue
#[serde(rename = "generation-enqueue-failure-event")]
GenerationEnqueueFailureEvent,
// TODO: Get rid of kebab case.
/// General purpose event:
/// Generation completed successfully
#[serde(rename = "generation-complete-event")]
GenerationCompleteEvent,
// TODO: Get rid of kebab case.
/// General purpose event:
/// Generation failed
#[serde(rename = "generation-failed-event")]
GenerationFailedEvent,
/// Special event:
/// Background removal complete
#[serde(rename = "canvas_bg_removed_event")]
CanvasBgRemovedEvent,
}
impl_enum_display_and_debug_using_to_str!(TauriEventName);
impl_mysql_enum_coders!(TauriEventName);
impl_mysql_from_row!(TauriEventName);
// NB: We can derive `sqlx::Type` instead of using `impl_mysql_enum_coders`
impl TauriEventName {
pub fn to_str(&self) -> &'static str {
match self {
Self::GenerationEnqueueSuccessEvent => "generation-enqueue-success-event",
Self::GenerationEnqueueFailureEvent => "generation-enqueue-failure-event",
Self::GenerationCompleteEvent => "generation-complete-event",
Self::GenerationFailedEvent => "generation-failed-event",
Self::CanvasBgRemovedEvent => "canvas_bg_removed_event",
}
}
pub fn from_str(job_status: &str) -> Result<Self, String> {
match job_status {
"generation-enqueue-success-event" => Ok(Self::GenerationEnqueueSuccessEvent),
"generation-enqueue-failure-event" => Ok(Self::GenerationEnqueueFailureEvent),
"generation-complete-event" => Ok(Self::GenerationCompleteEvent),
"generation-failed-event" => Ok(Self::GenerationFailedEvent),
"canvas_bg_removed_event" => Ok(Self::CanvasBgRemovedEvent),
_ => Err(format!("invalid tauri_event_name: {:?}", job_status)),
}
}
pub fn all_variants() -> BTreeSet<Self> {
// NB: BTreeSet is sorted
// NB: BTreeSet::from() isn't const, but not worth using LazyStatic, etc.
BTreeSet::from([
Self::GenerationEnqueueSuccessEvent,
Self::GenerationEnqueueFailureEvent,
Self::GenerationCompleteEvent,
Self::GenerationFailedEvent,
Self::CanvasBgRemovedEvent,
])
}
}
#[cfg(test)]
mod tests {
use crate::tauri::ux::tauri_event_name::TauriEventName;
use crate::test_helpers::assert_serialization;
mod explicit_checks {
use super::*;
#[test]
fn test_serialization() {
assert_serialization(TauriEventName::GenerationEnqueueSuccessEvent, "generation-enqueue-success-event");
assert_serialization(TauriEventName::GenerationEnqueueFailureEvent, "generation-enqueue-failure-event");
assert_serialization(TauriEventName::GenerationCompleteEvent, "generation-complete-event");
assert_serialization(TauriEventName::GenerationFailedEvent, "generation-failed-event");
assert_serialization(TauriEventName::CanvasBgRemovedEvent, "canvas_bg_removed_event");
}
#[test]
fn to_str() {
assert_eq!(TauriEventName::GenerationEnqueueSuccessEvent.to_str(), "generation-enqueue-success-event");
assert_eq!(TauriEventName::GenerationEnqueueFailureEvent.to_str(), "generation-enqueue-failure-event");
assert_eq!(TauriEventName::GenerationCompleteEvent.to_str(), "generation-complete-event");
assert_eq!(TauriEventName::GenerationFailedEvent.to_str(), "generation-failed-event");
assert_eq!(TauriEventName::CanvasBgRemovedEvent.to_str(), "canvas_bg_removed_event");
}
#[test]
fn from_str() {
assert_eq!(TauriEventName::from_str("generation-enqueue-success-event").unwrap(), TauriEventName::GenerationEnqueueSuccessEvent);
assert_eq!(TauriEventName::from_str("generation-enqueue-failure-event").unwrap(), TauriEventName::GenerationEnqueueFailureEvent);
assert_eq!(TauriEventName::from_str("generation-complete-event").unwrap(), TauriEventName::GenerationCompleteEvent);
assert_eq!(TauriEventName::from_str("generation-failed-event").unwrap(), TauriEventName::GenerationFailedEvent);
assert_eq!(TauriEventName::from_str("canvas_bg_removed_event").unwrap(), TauriEventName::CanvasBgRemovedEvent);
}
#[test]
fn all_variants() {
let mut variants = TauriEventName::all_variants();
assert_eq!(variants.len(), 5);
assert_eq!(variants.pop_first(), Some(TauriEventName::GenerationEnqueueSuccessEvent));
assert_eq!(variants.pop_first(), Some(TauriEventName::GenerationEnqueueFailureEvent));
assert_eq!(variants.pop_first(), Some(TauriEventName::GenerationCompleteEvent));
assert_eq!(variants.pop_first(), Some(TauriEventName::GenerationFailedEvent));
assert_eq!(variants.pop_first(), Some(TauriEventName::CanvasBgRemovedEvent));
assert_eq!(variants.pop_first(), None);
}
}
mod mechanical_checks {
use super::*;
use strum::IntoEnumIterator;
#[test]
fn variant_length() {
assert_eq!(TauriEventName::all_variants().len(), TauriEventName::iter().len());
}
#[test]
fn round_trip() {
for variant in TauriEventName::all_variants() {
// Test to_str(), from_str(), Display, and Debug.
assert_eq!(variant, TauriEventName::from_str(variant.to_str()).unwrap());
assert_eq!(variant, TauriEventName::from_str(&format!("{}", variant)).unwrap());
assert_eq!(variant, TauriEventName::from_str(&format!("{:?}", variant)).unwrap());
}
}
//#[test]
//fn serialized_length_ok_for_database() {
// const MAX_LENGTH : usize = 16;
// for variant in TauriEventName::all_variants() {
// let serialized = variant.to_str();
// assert!(serialized.len() > 0, "variant {:?} is too short", variant);
// assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long", variant);
// }
//}
}
}
@@ -15,8 +15,11 @@ import { setCanvasRenderBitmap } from "../../signals/canvasRenderBitmap";
import { captureStageImageBitmap } from "./hooks/useUpdateSnapshot";
import { ContextMenuContainer } from "./components/ui/ContextMenu";
import { FalBackgroundRemoval } from "@storyteller/tauri-api";
import { EnqueueImageBgRemoval } from "@storyteller/tauri-api";
import { getCreatorIcon, IMAGE_MODELS_BY_LABEL, ModelCreator, ModelInfo } from "@storyteller/model-list";
import { instructiveImageEditModels, ModelCategory, ModelSelector, useModelSelectorStore } from "@storyteller/ui-model-selector";
import { useCanvasBgRemovedEvent } from "@storyteller/tauri-api";
export const DecodeBase64ToImage = async (
base64String: string,
@@ -66,6 +69,27 @@ const PageDraw = () => {
onPaste: store.pasteItems,
});
useCanvasBgRemovedEvent(async (event) => {
console.log("Canvas bg removed event received:", event);
const nodeId = event.maybe_frontend_subscriber_id;
if (!nodeId) {
console.error("No node ID received from background removal");
return;
}
// const base64String = response.payload?.base64_bytes as string;
// const binaryString = atob(base64String);
// const bytes = Uint8Array.from(binaryString, (c) => c.charCodeAt(0));
// const blob = new Blob([bytes], { type: "image/png" });
// const file = new File([blob], "generated_image.png", {
// type: blob.type,
// });
store.finishRemoveBackground(
nodeId,
event.media_token,
event.image_cdn_url
);
});
// Listen for gallery drag and drop events
useEffect(() => {
const handleGallery2DDrop = (event: CustomEvent) => {
@@ -370,46 +394,7 @@ const PageDraw = () => {
store.toggleLock(store.selectedNodeIds);
break;
case "REMOVE_BACKGROUND":
await store.removeBackground(
store.selectedNodeIds,
async (
success: boolean,
image_base64: string,
message: string,
) => {
if (!success) {
console.error(message);
return { success: false };
}
try {
const response = await FalBackgroundRemoval({
base64_image: image_base64,
});
if (
response.status !== "success" ||
!("payload" in response)
) {
console.error("Failed to remove background", response);
return { success: false };
}
const base64String = response.payload
?.base64_bytes as string;
const binaryString = atob(base64String);
const bytes = Uint8Array.from(binaryString, (c) =>
c.charCodeAt(0),
);
const blob = new Blob([bytes], { type: "image/png" });
const file = new File([blob], "generated_image.png", {
type: blob.type,
});
return { success: true, file };
} catch (error) {
console.error("Failed to remove background", error);
return { success: false };
}
},
);
await store.beginRemoveBackground(store.selectedNodeIds);
break;
case "BRING_TO_FRONT":
store.bringToFront(store.selectedNodeIds);
@@ -1,5 +1,6 @@
import { create } from "zustand";
import { Node, NodeType } from "../Node";
import { EnqueueImageBgRemoval } from "libs/tauri-api/src/lib/enqueue/EnqueueImageBgRemovalCommand";
// Add LineNode type
export type LineNode = {
@@ -231,13 +232,17 @@ interface SceneState {
sendToBack: (nodeIds: string[]) => void;
bringForward: (nodeIds: string[]) => void;
sendBackward: (nodeIds: string[]) => void;
removeBackground: (
// Start background removal (enqueues task with Tauri)
beginRemoveBackground: (
nodeIds: string[],
operation: (
success: boolean,
base64_image: string,
message: string,
) => Promise<{ success: boolean; file?: File }>,
) => Promise<void>;
// Finish background removal (handoff back from a Tauri-sent event)
finishRemoveBackground: (
nodeId: string,
mediaToken: string,
imageCdnUrl: string,
) => Promise<void>;
// Add new lock action
@@ -1432,61 +1437,71 @@ export const useSceneStore = create<SceneState>((set, get) => ({
});
get().saveState();
},
removeBackground: async (
beginRemoveBackground: async (
nodeIds: string[],
operation: (
success: boolean,
base64_image: string,
message: string,
) => Promise<{ success: boolean; file?: File }>,
) => {
const hasImageNodes = nodeIds.some((id) => {
const node = get().nodes.find((n) => n.id === id);
return node ? node.type === "image" : false;
});
if (hasImageNodes) {
const firstNode = get().nodes.find(
(node) => nodeIds.includes(node.id) && node.type === "image",
);
if (firstNode && firstNode.imageFile) {
try {
const base64Image = await convertFileToBase64(firstNode.imageFile);
const { success, file } = await operation(
true,
base64Image,
"Removing Background.",
);
if (success && file) {
// Create a new node instance
const updatedNode = new Node({
...firstNode,
imageFile: file,
});
// Load the new image
await updatedNode.setImageFromFile(file);
// Update the store with the fully loaded node
set((state: SceneState) => ({
nodes: state.nodes.map((node: Node) => {
if (node.id === firstNode.id) {
return updatedNode;
}
return node;
}),
}));
get().saveState();
if (!hasImageNodes) {
return;
}
const firstNode = get().nodes.find(
(node) => nodeIds.includes(node.id) && node.type === "image",
);
if (!firstNode || !firstNode.imageFile) {
return;
}
try {
const base64Image = await convertFileToBase64(firstNode.imageFile);
// NB: This handler is async, so we subscribe (externally) and
// wait for the bg removal to complete.
const _response = await EnqueueImageBgRemoval({
base64_image: base64Image,
frontend_caller: "canvas",
frontend_subscriber_id: firstNode.id,
});
} catch (error) {
console.error("Error starting background removal:", error);
}
},
finishRemoveBackground: async (
nodeId: string,
mediaToken: string,
imageCdnUrl: string,
) => {
if (!nodeId || !mediaToken || !imageCdnUrl) {
return;
}
const firstNode = get().nodes.find(
(node) => node.id === nodeId && node.type === "image",
);
if (!firstNode || !firstNode.imageFile) {
return;
}
try {
// Create a new node instance from the old node
const updatedNode = new Node({
...firstNode,
imageUrl: imageCdnUrl,
});
// Load the new image
await updatedNode.setImageFromUrl(imageCdnUrl);
// Update the store with the fully loaded node
set((state: SceneState) => ({
nodes: state.nodes.map((node: Node) => {
if (node.id === firstNode.id) {
return updatedNode;
}
} catch (error) {
console.error("Error updating image:", error);
}
}
return node;
}),
}));
get().saveState();
} catch (error) {
console.error("Error completing background removal:", error);
}
},
// Add toggleLock action
toggleLock: (nodeIds: string[]) => {
set((state) => {
@@ -10,7 +10,6 @@ import { setCanvasRenderBitmap } from "../../signals/canvasRenderBitmap"
import {
EnqueueImageInpaint,
EnqueueImageInpaintModel,
FalBackgroundRemoval,
} from "@storyteller/tauri-api";
import { ContextMenuContainer } from "../PageDraw/components/ui/ContextMenu";
import SideToolbar from "../PageDraw/components/ui/SideToolbar";
+10 -7
View File
@@ -1,9 +1,12 @@
export * from "./lib/common/CommandStatus.js";
export * from "./lib/enqueue/EnqueueContextualEditImage.js";
export * from "./lib/enqueue/EnqueueImageTo3dObject.js";
export * from "./lib/enqueue/EnqueueTextToImage.js";
export * from "./lib/enqueue/EnqueueImageToVideo.js";
export * from "./lib/enqueue/EnqueueImageBgRemovalCommand.js";
export * from "./lib/enqueue/EnqueueImageInpaint.js";
export * from "./lib/enqueue/EnqueueImageTo3dObject.js";
export * from "./lib/enqueue/EnqueueImageToVideo.js";
export * from "./lib/enqueue/EnqueueTextToImage.js";
export * from "./lib/events/BasicEventWrapper.js";
export * from "./lib/events/CanvasBgRemovedEvent.js";
export * from "./lib/fal/FalBackgroundRemoval.js";
export * from "./lib/fal/FalHunyuanImageTo3d.js";
export * from "./lib/fal/FalKlingImageToVideo.js";
@@ -11,6 +14,9 @@ export * from "./lib/fal/GetFalApiKey.js";
export * from "./lib/fal/SetFalApiKey.js";
export * from "./lib/openai/GetOpenAIApiKey.js";
export * from "./lib/openai/SetOpenAIApiKey.js";
export * from "./lib/providers/GetProviderOrder.js";
export * from "./lib/providers/Provider.js";
export * from "./lib/providers/SetProviderOrder.js";
export * from "./lib/settings/GetAppPreferences.js";
export * from "./lib/settings/UpdateAppPreference.js";
export * from "./lib/sora/CheckSoraSession.js";
@@ -19,7 +25,4 @@ export * from "./lib/sora/SoraImageRemix.js";
export * from "./lib/sora/useSoraLoginListener.js";
export * from "./lib/sora/waitForSoraLogin.js";
export * from "./lib/system/GetAppInfo.js";
export * from "./lib/providers/SetProviderOrder.js";
export * from "./lib/providers/Provider.js";
export * from "./lib/providers/GetProviderOrder.js";
export * from "./lib/util/LoadWithoutCors.js";
export * from "./lib/util/LoadWithoutCors.js";
@@ -0,0 +1,71 @@
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);
}
@@ -0,0 +1,10 @@
export enum BasicEventStatus {
Success = "success",
Failure = "failure",
};
export interface BasicEventWrapper<T> {
status: BasicEventStatus,
data: T,
};
@@ -0,0 +1,37 @@
import { listen, UnlistenFn } from '@tauri-apps/api/event';
import { BasicEventWrapper } from './BasicEventWrapper';
import { useEffect } from 'react';
const EVENT_NAME : string = 'canvas_bg_removed_event';
export interface CanvasBgRemovedEvent {
media_token: string,
image_cdn_url: string,
maybe_frontend_subscriber_id?: string,
maybe_frontend_subscriber_payload?: string,
}
export const useCanvasBgRemovedEvent = (asyncCallback: (event: CanvasBgRemovedEvent) => Promise<void>) => {
useEffect(() => {
let isUnmounted = false;
let unlisten: Promise<UnlistenFn>;
const setup = async () => {
unlisten = listen<BasicEventWrapper<CanvasBgRemovedEvent>>(EVENT_NAME, async (wrappedEvent) => {
await asyncCallback(wrappedEvent.payload.data);
});
if (isUnmounted) {
unlisten.then(f => f()); // Unsubscribe if unmounted early.
}
};
setup();
return () => {
isUnmounted = true;
unlisten.then(f => f());
};
}, []);
}