Compare commits

...
Author SHA1 Message Date
bflatastic 50a1d3801f copy paste bug fixes 2025-08-08 20:27:57 +07:00
Brandon Thomas 0d67b81f5f mark intermediate files 2025-08-08 01:59:24 -04:00
Brandon Thomas 24addb0a49 track seconds elapsed since server startup 2025-08-08 01:26:40 -04:00
Brandon Thomas 041cfc5460 background removal with event plumbing (#755) 2025-08-08 01:07:56 -04:00
Brandon Thomas 693b8c36be cleanup 2025-08-03 17:03:06 -04:00
Brandon Thomas 8c2e37cad7 wire up flux inpainting (#753) 2025-08-03 16:16:50 -04:00
68 changed files with 1414 additions and 258 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"
}
Generated
+1
View File
@@ -663,6 +663,7 @@ dependencies = [
"futures 0.3.31",
"idempotency",
"image",
"images",
"indexmap 2.9.0",
"log",
"memory_store",
@@ -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')),
@@ -1,4 +1,5 @@
pub mod get_media_file;
pub mod upload_image_media_file_from_bytes;
pub mod upload_image_media_file_from_file;
pub mod upload_new_engine_asset_from_file;
pub mod upload_video_media_file_from_file;
@@ -0,0 +1,138 @@
use crate::credentials::storyteller_credential_set::StorytellerCredentialSet;
use crate::error::api_error::ApiError;
use crate::error::client_error::ClientError;
use crate::error::storyteller_error::StorytellerError;
use crate::shared_response_types::media_file_cover_image_details::MediaFileCoverImageDetails;
use crate::shared_response_types::media_links::MediaLinks;
use crate::shared_response_types::simple_entity_stats::SimpleEntityStats;
use crate::shared_response_types::user_details_light::UserDetailsLight;
use crate::utils::api_host::ApiHost;
use crate::utils::constants::{APPLICATION_JSON, USER_AGENT};
use crate::utils::filter_bad_response::filter_bad_response;
use crate::utils::http_get_anonymous::http_get_anonymous;
use chrono::{DateTime, Utc};
use enums::by_table::media_files::media_file_animation_type::MediaFileAnimationType;
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_engine_category::MediaFileEngineCategory;
use enums::by_table::media_files::media_file_type::MediaFileType;
use enums::by_table::model_weights::weights_category::WeightsCategory;
use enums::common::visibility::Visibility;
use enums::no_table::style_transfer::style_transfer_name::StyleTransferName;
use enums_public::by_table::model_weights::public_weights_types::PublicWeightsType;
use idempotency::uuid::generate_random_uuid;
use log::debug;
use reqwest::multipart::{Form, Part};
use reqwest::Client;
use serde_derive::{Deserialize, Serialize};
use std::fs::File;
use std::io::Read;
use std::path::Path;
use tokens::tokens::batch_generations::BatchGenerationToken;
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::model_weights::ModelWeightToken;
use tokens::tokens::prompts::PromptToken;
use uuid::uuid;
pub struct UploadImageBytesArgs<'a> {
pub api_host: &'a ApiHost,
pub maybe_creds: Option<&'a StorytellerCredentialSet>,
// NB: Bytes need to be owned for the request.
pub image_bytes: Vec<u8>,
pub image_type: ImageType,
/// If true, we should hide the image from the user's gallery.
pub is_intermediate_system_file: bool,
}
#[derive(Clone, Copy, Debug)]
pub enum ImageType {
Png,
Jpeg,
Gif,
}
impl ImageType {
pub fn mime_type(&self) -> &str {
match self {
ImageType::Png => "image/png",
ImageType::Jpeg => "image/jpeg",
ImageType::Gif => "image/gif",
}
}
pub fn file_extension(&self) -> &str {
match self {
ImageType::Png => "png",
ImageType::Jpeg => "jpg",
ImageType::Gif => "gif",
}
}
}
/// Upload an image media file from a file.
/// NB: File type is not checked, so caller needs to enforce.
/// NB: We need owned bytes for the request.
pub async fn upload_image_media_file_from_bytes(
args: UploadImageBytesArgs<'_>
) -> Result<UploadImageMediaFileSuccessResponse, StorytellerError> {
let url = get_route(args.api_host);
debug!("Requesting {:?}", &url);
let client = Client::builder()
.gzip(true)
.build()
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
let part = Part::bytes(args.image_bytes)
.file_name(format!("image.{}", args.image_type.file_extension()))
.mime_str(args.image_type.mime_type())
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
let mut form = Form::new()
.text("uuid_idempotency_token", generate_random_uuid())
.part("file", part);
if args.is_intermediate_system_file {
form = form.text("is_intermediate_system_file", "true");
}
let mut request_builder = client.post(url)
.header("User-Agent", USER_AGENT)
.header("Accept", APPLICATION_JSON);
if let Some(creds) = args.maybe_creds {
if let Some(header) = &creds.maybe_as_cookie_header() {
request_builder = request_builder.header("Cookie", header);
}
}
let response = request_builder
.multipart(form)
.send()
.await
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
let response = filter_bad_response(response).await?;
let response_body = &response.text().await
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
let media_file = serde_json::from_str(&response_body)
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
Ok(media_file)
}
fn get_route(api_host: &ApiHost) -> String {
let api_hostname_and_scheme = api_host.to_api_hostname_and_scheme();
format!("{}/v1/media_files/upload/image", api_hostname_and_scheme)
}
// TODO(bt,2025-04-22): Share API definitions between client and server in common crate.
#[derive(Deserialize, Debug)]
pub struct UploadImageMediaFileSuccessResponse {
pub success: bool,
pub media_file_token: MediaFileToken,
}
@@ -33,14 +33,24 @@ use tokens::tokens::model_weights::ModelWeightToken;
use tokens::tokens::prompts::PromptToken;
use uuid::uuid;
pub struct UploadImageFromFileArgs<'a, P: AsRef<Path>> {
pub api_host: &'a ApiHost,
pub maybe_creds: Option<&'a StorytellerCredentialSet>,
// NB: Path needs to be owned for the request.
pub path: P,
/// If true, we should hide the image from the user's gallery.
pub is_intermediate_system_file: bool,
}
/// Upload an image media file from a file.
pub async fn upload_image_media_file_from_file<P: AsRef<Path>>(
api_host: &ApiHost,
maybe_creds: Option<&StorytellerCredentialSet>,
path: P,
args: UploadImageFromFileArgs<'_, P>,
) -> Result<UploadImageMediaFileSuccessResponse, StorytellerError> {
let url = get_route(api_host);
let url = get_route(args.api_host);
debug!("Requesting {:?}", &url);
@@ -51,17 +61,21 @@ pub async fn upload_image_media_file_from_file<P: AsRef<Path>>(
.build()
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
let form = Form::new()
let mut form = Form::new()
.text("uuid_idempotency_token", generate_random_uuid())
.file("file", path)
.file("file", args.path)
.await
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
if args.is_intermediate_system_file {
form = form.text("is_intermediate_system_file", "true");
}
let mut request_builder = client.post(url)
.header("User-Agent", USER_AGENT)
.header("Accept", APPLICATION_JSON);
if let Some(creds) = maybe_creds {
if let Some(creds) = args.maybe_creds {
if let Some(header) = &creds.maybe_as_cookie_header() {
request_builder = request_builder.header("Cookie", header);
}
@@ -1,7 +1,7 @@
use crate::credentials::storyteller_credential_set::StorytellerCredentialSet;
use crate::error::client_error::ClientError;
use crate::error::storyteller_error::StorytellerError;
use crate::media_files::upload_image_media_file_from_file::upload_image_media_file_from_file;
use crate::media_files::upload_image_media_file_from_file::{upload_image_media_file_from_file, UploadImageFromFileArgs};
use crate::media_files::upload_new_engine_asset_from_file::upload_new_engine_asset_from_file;
use crate::media_files::upload_video_media_file_from_file::upload_video_media_file_from_file;
use crate::utils::api_host::ApiHost;
@@ -49,7 +49,13 @@ pub async fn upload_media_file_from_file<P: AsRef<Path>>(
}
}
Some(FileExtension::Png) | Some(FileExtension::Jpg) | Some(FileExtension::Gif) | Some(FileExtension::Webp) => {
match upload_image_media_file_from_file(api_host, maybe_creds, path).await {
let result = upload_image_media_file_from_file(UploadImageFromFileArgs {
api_host,
maybe_creds,
path,
is_intermediate_system_file: false,
}).await;
match result {
Ok(result) => Ok(UploadMediaFileSuccessResponse {
success: result.success,
media_file_token: result.media_file_token,
+1
View File
@@ -33,6 +33,7 @@ errors = { workspace = true }
fal_client = { workspace = true }
filesys = { workspace = true }
idempotency = { workspace = true }
images = { workspace = true }
memory_store = { workspace = true }
mimetypes = { workspace = true }
openai_sora_client = { workspace = true }
@@ -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 {
@@ -39,7 +39,7 @@ use storyteller_client::generate::image::generate_flux_1_schnell_text_to_image::
use storyteller_client::generate::image::generate_flux_pro_11_text_to_image::generate_flux_pro_11_text_to_image;
use storyteller_client::generate::image::generate_flux_pro_11_ultra_text_to_image::generate_flux_pro_11_ultra_text_to_image;
use storyteller_client::generate::image::remove_image_background::remove_image_background;
use storyteller_client::media_files::upload_image_media_file_from_file::upload_image_media_file_from_file;
use storyteller_client::media_files::upload_image_media_file_from_file::{upload_image_media_file_from_file, UploadImageFromFileArgs};
use tauri::AppHandle;
use tokens::tokens::media_files::MediaFileToken;
@@ -123,9 +123,13 @@ async fn upload_image_from_base64_bytes(
info!("Uploading image media file from temp file: {:?}", temp_file.path());
let result =
upload_image_media_file_from_file(&app_env_configs.storyteller_host, Some(&creds), temp_file)
.await
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.
}).await
.map_err(|err| {
error!("Failed to upload image media file: {:?}", err);
InternalBgRemovalError::StorytellerError(err)
@@ -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};
@@ -60,17 +61,16 @@ pub struct EnqueueInpaintImageCommand {
pub model: Option<ImageInpaintModel>,
/// REQUIRED (Option<T> is just for error messages).
/// Images to use for the image edit.
/// The first image is typically a 2D canvas or 3D stage, but doesn't have to be.
/// There must be at least one image.
/// The source image to edit.
pub image_media_token: Option<MediaFileToken>,
// TODO: Allow for bytes.
/// REQUIRED (Option<T> is just for error messages).
/// Images to use for the image edit.
/// The first image is typically a 2D canvas or 3D stage, but doesn't have to be.
/// There must be at least one image.
pub mask_media_token: Option<MediaFileToken>,
/// REQUIRED: Supply this *XOR* `mask_image_raw_bytes`.
/// The mask to focus the edit (already uploaded).
pub mask_image_media_token: Option<MediaFileToken>,
/// REQUIRED: Supply this *XOR* `mask_image_media_token`.
/// The mask to focus the edit (raw bytes).
pub mask_image_raw_bytes: Option<Vec<u8>>,
/// REQUIRED.
/// The user's image generation prompt.
@@ -78,6 +78,24 @@ pub struct EnqueueInpaintImageCommand {
/// Number of images to generate.
pub image_count: Option<u32>,
/// 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)]
@@ -95,20 +113,17 @@ pub enum EnqueueInpaintImageErrorType {
/// No mask image was supplied.
NoMaskImageSpecified,
/// Too many mask images were supplied.
MultipleMaskImagesSpecified,
/// Bad mask image was supplied.
BadMaskImage,
/// Generic bad request error
BadRequest,
/// Generic server error
ServerError,
/// The user is sending too many requests
TooManyConcurrentTasks,
/// User is not logged into Sora!
SoraLoginRequired,
/// The user needs to create a Sora account
SoraUsernameNotYetCreated,
/// The Sora service is having problems. Try again soon.
SoraIsHavingProblems,
}
#[derive(Serialize)]
@@ -132,8 +147,8 @@ pub async fn enqueue_image_inpaint_command(
sora_task_queue: State<'_, SoraTaskQueue>,
) -> ResponseOrErrorType<EnqueueImageInpaintSuccessResponse, EnqueueInpaintImageErrorType> {
info!("enqueue_image_inpaint_command called; full request: {:?}",
&request);
info!("enqueue_image_inpaint_command called; model: {:?}, image_media_token: {:?}, prompt: {:?}",
&request.model, &request.image_media_token, &request.prompt);
let result = handle_request(
&request,
@@ -217,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 {
@@ -17,7 +17,16 @@ pub enum InternalImageInpaintError {
/// A mask image was not provided
NoMaskImageSpecified,
/// Both mask image media token and raw bytes were supplied.
MaskMediaTokenAndBytesSupplied,
/// Mask image has an unknown MIME type.
CouldNotDetermineMaskMimeType,
/// Could not encode the mask image to PNG.
CouldNotEncodeMask,
/// No storyteller credentials are set.
NeedsStorytellerCredentials,
@@ -76,7 +85,22 @@ impl InternalImageInpaintError {
InternalImageInpaintError::NoMaskImageSpecified => {
status = CommandErrorStatus::BadRequest;
error_type = EnqueueInpaintImageErrorType::NoMaskImageSpecified;
error_message = "No source image was provided".to_string();
error_message = "No mask image was provided".to_string();
}
InternalImageInpaintError::MaskMediaTokenAndBytesSupplied => {
status = CommandErrorStatus::BadRequest;
error_type = EnqueueInpaintImageErrorType::MultipleMaskImagesSpecified;
error_message = "multiple mask images provided".to_string();
}
InternalImageInpaintError::CouldNotDetermineMaskMimeType => {
status = CommandErrorStatus::BadRequest;
error_type = EnqueueInpaintImageErrorType::BadMaskImage;
error_message = "bad mask image (mime)".to_string();
}
InternalImageInpaintError::CouldNotEncodeMask => {
status = CommandErrorStatus::BadRequest;
error_type = EnqueueInpaintImageErrorType::BadMaskImage;
error_message = "bad mask image (encode)".to_string();
}
InternalImageInpaintError::InvalidNumberOfRequestedImages { min, max, requested } => {
status = CommandErrorStatus::BadRequest;
@@ -1,5 +1,7 @@
use crate::core::commands::enqueue::image::enqueue_text_to_image_command::EnqueueTextToImageRequest;
use crate::core::commands::enqueue::image::internal_image_error::InternalImageError;
use crate::core::commands::enqueue::image_bg_removal::enqueue_image_bg_removal_command::EnqueueImageBgRemovalCommand;
use crate::core::commands::enqueue::image_bg_removal::errors::InternalBgRemovalError;
use crate::core::commands::enqueue::image_edit::enqueue_contextual_edit_image_command::{EditImageQuality, EditImageSize, EnqueueContextualEditImageCommand};
use crate::core::commands::enqueue::image_inpaint::enqueue_image_inpaint_command::EnqueueInpaintImageCommand;
use crate::core::commands::enqueue::image_inpaint::errors::InternalImageInpaintError;
@@ -12,6 +14,7 @@ use crate::core::model::image_models::ImageModel;
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::core::state::provider_priority::ProviderPriorityStore;
use crate::core::utils::save_base64_image_to_temp_dir::save_base64_image_to_temp_dir;
use crate::services::fal::state::fal_credential_manager::FalCredentialManager;
use crate::services::fal::state::fal_task_queue::FalTaskQueue;
use crate::services::sora::state::sora_credential_manager::SoraCredentialManager;
@@ -29,14 +32,23 @@ use enums::tauri::tasks::task_type::TaskType;
use fal_client::requests::queue::image_gen::enqueue_flux_pro_11_ultra_text_to_image::{enqueue_flux_pro_11_ultra_text_to_image, FluxPro11UltraTextToImageArgs};
use fal_client::requests::webhook::image::enqueue_gpt_image_1_edit_image_webhook::GptEditImageQuality;
use idempotency::uuid::generate_random_uuid;
use images::encoding::image_bytes_to_png_bytes::image_bytes_to_png_bytes;
use images::encoding::image_bytes_to_png_bytes_with_dimensions::image_bytes_to_png_bytes_with_dimensions;
use images::mask_images::normalize_image_bytes_to_flux_mask::normalize_image_bytes_to_flux_mask;
use log::{error, info};
use mimetypes::mimetype_info::mimetype_info::MimetypeInfo;
use storyteller_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use storyteller_client::generate::image::edit::gpt_image_1_edit_image::gpt_image_1_edit_image;
use storyteller_client::generate::image::generate_flux_1_dev_text_to_image::generate_flux_1_dev_text_to_image;
use storyteller_client::generate::image::generate_flux_1_schnell_text_to_image::generate_flux_1_schnell_text_to_image;
use storyteller_client::generate::image::generate_flux_pro_11_text_to_image::generate_flux_pro_11_text_to_image;
use storyteller_client::generate::image::generate_flux_pro_11_ultra_text_to_image::generate_flux_pro_11_ultra_text_to_image;
use storyteller_client::generate::image::inpaint::flux_pro_1_inpaint_image::flux_pro_1_inpaint_image;
use storyteller_client::media_files::get_media_file::get_media_file;
use storyteller_client::media_files::upload_image_media_file_from_bytes::{upload_image_media_file_from_bytes, ImageType, UploadImageBytesArgs};
use storyteller_client::media_files::upload_image_media_file_from_file::upload_image_media_file_from_file;
use tauri::AppHandle;
use tokens::tokens::media_files::MediaFileToken;
pub async fn handle_flux_pro_1_inpaint_artcraft(
request: &EnqueueInpaintImageCommand,
@@ -53,10 +65,31 @@ pub async fn handle_flux_pro_1_inpaint_artcraft(
},
};
let image_media_token = match &request.image_media_token {
Some(token) => token.clone(),
None => {
return Err(InternalImageInpaintError::NoSourceImageSpecified);
},
};
//let image_media_file = get_media_file(
// &app_env_configs.storyteller_host,
// &image_media_token,
//).await.map_err(|err| {
// error!("Failed to get media file for image: {:?}", err);
// InternalImageInpaintError::StorytellerError(err)
//})?;
let mask_media_token = get_mask(
request,
app_env_configs,
&creds,
).await?;
info!("Calling Artcraft flux pro 1 inpaint ...");
let uuid_idempotency_token = generate_random_uuid();
let num_images = match request.image_count {
None => None,
Some(1) => Some(FluxPro1InpaintImageNumImages::One),
@@ -71,20 +104,6 @@ pub async fn handle_flux_pro_1_inpaint_artcraft(
});
},
};
let image_media_token = match &request.image_media_token {
Some(token) => token.clone(),
None => {
return Err(InternalImageInpaintError::NoSourceImageSpecified);
},
};
let mask_media_token = match &request.mask_media_token {
Some(token) => token.clone(),
None => {
return Err(InternalImageInpaintError::NoMaskImageSpecified);
},
};
let request = FluxPro1InpaintImageRequest {
uuid_idempotency_token,
@@ -120,3 +139,43 @@ pub async fn handle_flux_pro_1_inpaint_artcraft(
task_type: TaskType::ImageGeneration,
})
}
async fn get_mask(
request: &EnqueueInpaintImageCommand,
app_env_configs: &AppEnvConfigs,
storyteller_creds: &StorytellerCredentialSet,
) -> Result<MediaFileToken, InternalImageInpaintError> {
if request.mask_image_media_token.is_some() && request.mask_image_raw_bytes.is_some() {
return Err(InternalImageInpaintError::MaskMediaTokenAndBytesSupplied);
}
if let Some(token) = request.mask_image_media_token.as_ref() {
return Ok(token.clone());
};
let image_bytes = request.mask_image_raw_bytes.as_ref()
.ok_or(InternalImageInpaintError::NoMaskImageSpecified)?;
let image_bytes = normalize_image_bytes_to_flux_mask(image_bytes)
.map_err(|err| {
error!("Failed to convert image bytes to png: {:?}", err);
InternalImageInpaintError::CouldNotEncodeMask
})?;
info!("Uploading image media file from bytes...");
let result = upload_image_media_file_from_bytes(UploadImageBytesArgs {
api_host: &app_env_configs.storyteller_host,
maybe_creds: Some(&storyteller_creds),
image_bytes: image_bytes.0,
image_type: ImageType::Png,
is_intermediate_system_file: true,
}).await
.map_err(|err| {
error!("Failed to upload image media file: {:?}", err);
InternalImageInpaintError::StorytellerError(err)
})?;
Ok(result.media_file_token)
}
@@ -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;
+2
View File
@@ -7,6 +7,7 @@ use crate::core::commands::app_preferences::get_app_preferences_command::get_app
use crate::core::commands::app_preferences::update_app_preference_command::update_app_preferences_command;
use crate::core::commands::enqueue::image::enqueue_text_to_image_command::enqueue_text_to_image_command;
use crate::core::commands::enqueue::image_bg_removal::enqueue_image_bg_removal_command::enqueue_image_bg_removal_command;
use crate::core::commands::enqueue::image_inpaint::enqueue_image_inpaint_command::enqueue_image_inpaint_command;
use crate::core::commands::enqueue::image_edit::enqueue_contextual_edit_image_command::enqueue_contextual_edit_image_command;
use crate::core::commands::enqueue::object::enqueue_image_to_3d_object_command::enqueue_image_to_3d_object_command;
use crate::core::commands::enqueue::video::enqueue_image_to_video_command::enqueue_image_to_video_command;
@@ -144,6 +145,7 @@ pub fn run() {
check_sora_session_command,
enqueue_contextual_edit_image_command,
enqueue_image_bg_removal_command,
enqueue_image_inpaint_command,
enqueue_image_to_3d_object_command,
enqueue_image_to_video_command,
enqueue_text_to_image_command,
@@ -29,7 +29,7 @@ use reqwest::Url;
use serde_derive::{Deserialize, Serialize};
use storyteller_client::error::storyteller_error::StorytellerError;
use storyteller_client::media_files::get_media_file::{get_media_file, GetMediaFileSuccessResponse};
use storyteller_client::media_files::upload_image_media_file_from_file::upload_image_media_file_from_file;
use storyteller_client::media_files::upload_image_media_file_from_file::{upload_image_media_file_from_file, UploadImageFromFileArgs};
use storyteller_client::utils::api_host::ApiHost;
use tauri::{AppHandle, Manager, State};
use tokens::tokens::media_files::MediaFileToken;
@@ -256,11 +256,12 @@ pub async fn remove_background(
info!("Uploading image media file...");
let upload_result = upload_image_media_file_from_file(
&app_env_configs.storyteller_host,
Some(&creds),
result_filename,
).await?;
let upload_result = upload_image_media_file_from_file(UploadImageFromFileArgs {
api_host: &app_env_configs.storyteller_host,
maybe_creds: Some(&creds),
path: result_filename,
is_intermediate_system_file: true, // NB: Probably not essential to keep this.
}).await?;
// TODO: Don't re-request to simply build MediaLinks (or CDN URL). Get those from the upload API in one turn.
info!("Re-requesting media file...");
@@ -17,7 +17,7 @@ use reqwest::Url;
use std::fs::File;
use std::io::Write;
use std::path::PathBuf;
use storyteller_client::media_files::upload_image_media_file_from_file::upload_image_media_file_from_file;
use storyteller_client::media_files::upload_image_media_file_from_file::{upload_image_media_file_from_file, UploadImageFromFileArgs};
use tauri::AppHandle;
use tempdir::TempDir;
@@ -134,12 +134,14 @@ async fn polling_loop(
let download_path = download_generation(generation, &app_data_root).await?;
info!("Uploading to backend...");
let result = upload_image_media_file_from_file(
&app_env_configs.storyteller_host,
Some(&creds),
download_path
).await?;
let result = upload_image_media_file_from_file(UploadImageFromFileArgs {
api_host: &app_env_configs.storyteller_host,
maybe_creds: Some(&creds),
path: download_path,
is_intermediate_system_file: false,
}).await?;
info!("Uploaded to API backend: {:?}", result.media_file_token);
let event = GenerationCompleteEvent {
@@ -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);
@@ -0,0 +1,18 @@
use crate::error::ImagesError;
use image::{ImageFormat, ImageReader};
use std::io::Cursor;
pub fn image_bytes_to_png_bytes(
arbitrary_image_bytes: &[u8],
) -> Result<Vec<u8>, ImagesError> {
let reader = ImageReader::new(Cursor::new(arbitrary_image_bytes));
let image = reader
.with_guessed_format()? // NB: Can raise IoError.
.decode()?;
let mut output_bytes: Vec<u8> = Vec::new();
image.write_to(&mut Cursor::new(&mut output_bytes), ImageFormat::Png)?;
Ok(output_bytes)
}
@@ -0,0 +1,29 @@
use std::io::Cursor;
use image::{ImageFormat, ImageReader};
use crate::error::ImagesError;
pub struct PngBytesWithDimensions {
pub png_bytes: Vec<u8>,
pub width: u32,
pub height: u32,
}
pub fn image_bytes_to_png_bytes_with_dimensions(
arbitrary_image_bytes: &[u8],
) -> Result<PngBytesWithDimensions, ImagesError> {
let reader = ImageReader::new(Cursor::new(arbitrary_image_bytes));
let image = reader
.with_guessed_format()? // NB: Can raise IoError.
.decode()?;
let mut output_bytes: Vec<u8> = Vec::new();
image.write_to(&mut Cursor::new(&mut output_bytes), ImageFormat::Png)?;
Ok(PngBytesWithDimensions {
png_bytes: output_bytes,
width: image.width(),
height: image.height(),
})
}
@@ -1 +1,3 @@
pub mod image_bytes_to_png_bytes;
pub mod image_bytes_to_png_bytes_with_dimensions;
pub mod webp_bytes_to_png_bytes;
+1
View File
@@ -26,6 +26,7 @@ pub use image;
pub mod encoding;
pub mod error;
pub mod image_info;
pub mod mask_images;
pub mod resize_image_file_preserving_aspect;
pub mod resize_preserving_aspect;
@@ -0,0 +1 @@
pub mod normalize_image_bytes_to_flux_mask;
@@ -0,0 +1,40 @@
use crate::error::ImagesError;
use image::{GenericImage, GenericImageView, ImageFormat, ImageReader, Rgba};
use std::io::Cursor;
pub struct PngBytes(pub Vec<u8>);
/// Normalize arbitrary image bytes to a PNG format image.
/// Make sure the bitmap is in the correct mask format.
pub fn normalize_image_bytes_to_flux_mask(
arbitrary_image_bytes: &[u8],
) -> Result<PngBytes, ImagesError> {
let reader = ImageReader::new(Cursor::new(arbitrary_image_bytes));
let mut image = reader
.with_guessed_format()? // NB: Can raise IoError.
.decode()?;
let (width, height) = image.dimensions();
for y in 0..height {
for x in 0..width {
let rgba_pixel = image.get_pixel(x, y);
if rgba_pixel[3] == 0 {
// Alpha pixel is transparent
// Set to black. Mask ignore region.
image.put_pixel(x, y, Rgba([0, 0, 0, 255]));
} else {
// Set to white. Mask interest region.
image.put_pixel(x, y, Rgba([255, 255, 255, 255]));
}
}
}
let mut output_bytes: Vec<u8> = Vec::new();
image.write_to(&mut Cursor::new(&mut output_bytes), ImageFormat::Png)?;
Ok(PngBytes(output_bytes))
}
@@ -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);
// }
//}
}
}
@@ -4,7 +4,8 @@ use std::sync::Arc;
use actix_web::error::ResponseError;
use actix_web::http::StatusCode;
use actix_web::{web, HttpRequest, HttpResponse};
use chrono::NaiveDateTime;
use actix_web::web::Json;
use chrono::{DateTime, NaiveDateTime, Utc};
use log::error;
use http_server_common::response::serialize_as_json_error::serialize_as_json_error;
@@ -22,6 +23,8 @@ pub struct HealthCheckResponse {
pub unhealthy_check_consecutive_count: Option<u64>,
pub server_build_sha: String,
pub server_hostname: String,
pub startup_time: DateTime<Utc>,
pub seconds_since_startup: u64,
}
@@ -65,6 +68,11 @@ pub async fn get_health_check_handler(
let is_healthy = health_check_status.is_healthy;
let now = Utc::now();
let seconds_since_startup = now.signed_duration_since(server_state.startup_time)
.num_seconds()
.unsigned_abs();
let response = HealthCheckResponse {
success: true,
is_healthy: health_check_status.is_healthy,
@@ -73,6 +81,8 @@ pub async fn get_health_check_handler(
unhealthy_check_consecutive_count: health_check_status.unhealthy_check_consecutive_count,
server_build_sha: server_state.server_info.build_sha.clone(),
server_hostname: server_state.hostname.clone(),
startup_time: server_state.startup_time,
seconds_since_startup,
};
let body = serde_json::to_string(&response)
@@ -25,6 +25,7 @@ use actix_multipart::form::MultipartFormConfig;
use actix_web::middleware::{DefaultHeaders, Logger};
use actix_web::{middleware, web, App, HttpServer};
use anyhow::anyhow;
use chrono::Utc;
use elasticsearch::http::transport::Transport;
use elasticsearch::Elasticsearch;
use log::info;
@@ -392,6 +393,8 @@ async fn main() -> AnyhowResult<()> {
let openai_api_key= easyenv::get_env_string_required("OPENAI_API_KEY")?;
let startup_time = Utc::now();
let server_state = ServerState {
env_config: EnvConfig {
num_workers,
@@ -409,6 +412,7 @@ async fn main() -> AnyhowResult<()> {
client: stripe_client,
},
hostname: server_hostname,
startup_time,
server_environment_old: server_environment,
server_environment: server_environment_typed,
flags: service_feature_flags,
@@ -1,3 +1,4 @@
use chrono::{DateTime, Utc};
use elasticsearch::Elasticsearch;
use r2d2_redis::{r2d2, RedisConnectionManager};
use sqlx::MySqlPool;
@@ -51,6 +52,9 @@ pub struct ServerState {
pub hostname: String,
/// When the server starts.
pub startup_time: DateTime<Utc>,
#[deprecated(note = "Use `server_environment` instead")]
/// Knowing if we're in production will allow us to turn off development-only functionalities.
pub server_environment_old: ServerEnvironment,
@@ -1,3 +1,4 @@
export const normalizeCanvas = (canvas: HTMLCanvasElement, width: number, height: number): HTMLCanvasElement => {
const newCanvas = document.createElement("canvas");
newCanvas.width = width;
@@ -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) => {
@@ -0,0 +1,16 @@
export const EncodeImageBitmapToArray = async (imageBitmap: ImageBitmap): Promise<Uint8Array> => {
const canvas = new OffscreenCanvas(imageBitmap.width, imageBitmap.height);
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error("Failed to get 2D context");
}
ctx.drawImage(imageBitmap, 0, 0);
const blob = await canvas.convertToBlob({ type: 'image/png' });
const arrayBuffer = await blob.arrayBuffer();
return new Uint8Array(arrayBuffer);
}
@@ -8,7 +8,8 @@ import Konva from "konva"; // just for types
import { setCanvasRenderBitmap } from "../../signals/canvasRenderBitmap"
import {
FalBackgroundRemoval,
EnqueueImageInpaint,
EnqueueImageInpaintModel,
} from "@storyteller/tauri-api";
import { ContextMenuContainer } from "../PageDraw/components/ui/ContextMenu";
import SideToolbar from "../PageDraw/components/ui/SideToolbar";
@@ -188,7 +189,7 @@ const PageEdit = () => {
}
// Create a function to use the left layer ref and download the bitmap from it
const downloadLeftPanelBitmap = () => {
const downloadLeftPanelBitmap = async(): Promise<Uint8Array> => {
if (!stageRef.current || !leftPanelRef.current || !rectRef.current) {
console.error("Stage or left panel ref is not available");
return;
@@ -206,24 +207,63 @@ const PageEdit = () => {
pixelRatio: 1 / stageRef.current.scaleX(),
});
// Using the pixelRatio scaling may result in off-by-one rounding errors,
// So we re-fit the image to a canvas of precise size.
const fittedCanvas = normalizeCanvas(layerCrop, rect.width(), rect.height());
// // Using the pixelRatio scaling may result in off-by-one rounding errors,
// // So we re-fit the image to a canvas of precise size.
// const fittedCanvas = normalizeCanvas(layerCrop, rect.width(), rect.height());
// const downloadCallback = (blob: Blob | null) => {
// if (!blob) {
// console.error("Failed to create blob from canvas");
// return;
// }
// const url = URL.createObjectURL(blob);
// const link = document.createElement("a");
// link.href = url;
// link.download = "artcraft_snapshot.png";
// link.click();
// console.log('downloadCallback', url);
// }
// fittedCanvas.toBlob(downloadCallback, "image/png", 1.0);
const downloadCallback = (blob: Blob | null) => {
if (!blob) {
console.error("Failed to create blob from canvas");
return;
const normalizeCanvas2 = (canvas: HTMLCanvasElement, width: number, height: number): OffscreenCanvas => {
const newCanvas = new OffscreenCanvas(width, height);
const ctx = newCanvas.getContext("2d");
if (!ctx) {
throw new Error("Failed to get canvas context");
}
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "artcraft_snapshot.png";
link.click();
ctx.imageSmoothingEnabled = true;
ctx.drawImage(canvas, 0, 0, width, height);
return newCanvas;
}
fittedCanvas.toBlob(downloadCallback, "image/png", 1.0);
const fittedCanvas2 = normalizeCanvas2(layerCrop, rect.width(), rect.height());
const blob = await fittedCanvas2.convertToBlob({ type: 'image/png' });
const arrayBuffer = await blob.arrayBuffer();
return new Uint8Array(arrayBuffer);
};
const handleGenerate = async (prompt: string) => {
const editedImageToken = store.baseImageInfo?.mediaToken;
if (!editedImageToken) {
console.error("Base image is not available");
return;
}
// TODO: Call inference API here
let arrayBuffer = await downloadLeftPanelBitmap();
const response = await EnqueueImageInpaint({
model: EnqueueImageInpaintModel.FluxPro1,
image_media_token: editedImageToken,
mask_image_raw_bytes: arrayBuffer,
prompt: prompt,
image_count: 1,
});
};
// Display image selector on launch, otherwise hide it
@@ -262,7 +302,7 @@ const PageEdit = () => {
<PromptEditor
onModeChange={(mode: string) => { store.setActiveTool(mode as ActiveEditTool) }}
selectedMode={store.activeTool}
onGenerateClick={downloadLeftPanelBitmap}
onGenerateClick={handleGenerate}
/>
</div>
<div className="relative z-0">
@@ -3,6 +3,7 @@ import { PromptBoxEdit, PromptBoxEditProps } from "@storyteller/ui-promptbox";
// Set this value on when enqueue is pressed nasty global variable.
import { JobProvider } from "~/pages/PageDraw/JobContext";
const PromptEditor: React.FC<PromptBoxEditProps> = ({
onModeChange,
selectedMode,
@@ -329,18 +329,23 @@ export class MouseControls {
}
if ((event.ctrlKey || event.metaKey) && !this.isProcessing) {
if (event.key === "Z" || event.key === "z") {
const keyLower = event.key.toLowerCase();
if (keyLower === "z") {
// redo
this.isProcessing = true;
await this.sceneManager?.redo();
this.isProcessing = false;
} else if (event.key === "c") {
// redo
} else if (keyLower === "c") {
// Copy
event.preventDefault();
event.stopPropagation();
this.isProcessing = true;
await this.sceneManager?.copy();
this.isProcessing = false;
} else if (event.key === "v") {
// redo
} else if (keyLower === "v") {
// Paste
event.preventDefault();
event.stopPropagation();
this.isProcessing = true;
await this.sceneManager?.paste();
this.isProcessing = false;
@@ -406,7 +411,7 @@ export class MouseControls {
// If the mouse is clicked but controls also locked, move the camera with the mouse
const camera = this.lockControls.getObject();
const _euler = new Euler(0, 0, 0, 'YXZ');
const _euler = new Euler(0, 0, 0, "YXZ");
const _MOUSE_SENSITIVITY = 0.002;
const pointerSpeed = 1.0;
const _PI_2 = Math.PI / 2;
@@ -418,7 +423,10 @@ export class MouseControls {
_euler.y -= event.movementX * _MOUSE_SENSITIVITY * pointerSpeed;
_euler.x -= event.movementY * _MOUSE_SENSITIVITY * pointerSpeed;
_euler.x = Math.max(_PI_2 - maxPolarAngle, Math.min(_PI_2 - minPolarAngle, _euler.x));
_euler.x = Math.max(
_PI_2 - maxPolarAngle,
Math.min(_PI_2 - minPolarAngle, _euler.x),
);
camera.quaternion.setFromEuler(_euler);
} else if (this.lockControls) {
@@ -452,7 +460,6 @@ export class MouseControls {
} else {
this.handleMousePointerLock();
}
}
// When the mouse clicks the screen.
@@ -8,6 +8,11 @@ import {
import Scene from "./scene";
import { MouseControls } from "./keybinds_controls";
import { ClipGroup } from "~/enums";
import Queue from "~/pages/PageEnigma/Queue/Queue";
import { QueueNames } from "~/pages/PageEnigma/Queue/QueueNames";
import { fromEngineActions } from "~/pages/PageEnigma/Queue/fromEngineActions";
import { MediaItem } from "~/pages/PageEnigma/models";
import { AssetType } from "~/enums";
import {
CommandInputTypes,
CreationSceneItem,
@@ -146,8 +151,7 @@ export class SceneManager implements SceneManagerAPI {
if (media_token.includes("SKY::")) {
const token = media_token.replace("SKY::", "");
this.scene.updateSkybox(token);
}
else if (media_token !== "Parim") {
} else if (media_token !== "Parim") {
return await this.scene.loadObject(
media_token,
name,
@@ -265,11 +269,9 @@ export class SceneManager implements SceneManagerAPI {
}
public async copy() {
// TODO MAKE BETTER FIX: Temp disables copy and paste of characters.
const object = this.mouse_controls.selected?.at(0)
if (object !== undefined) {
if (this.is_character(object.uuid) === false)
this.copiedObject = object;
const object = this.mouse_controls.selected?.at(0);
if (object !== undefined && object.name !== "::CAM::") {
this.copiedObject = object;
}
}
@@ -283,6 +285,7 @@ export class SceneManager implements SceneManagerAPI {
const media_id = userdata["media_id"];
const color = userdata["color"];
const name = this.copiedObject.name;
const wasCharacter: boolean = !!this.copiedObject.userData.isCharacter;
const obj = await this.create(media_id, name, position);
this.scene.setColor(obj.uuid, color);
@@ -290,8 +293,40 @@ export class SceneManager implements SceneManagerAPI {
obj.rotation.copy(rotation);
obj.scale.copy(scale);
// Preserve character stuff on pasted object
if (wasCharacter) {
obj.userData.isCharacter = true;
}
this.mouse_controls.selectObject(obj);
this.updateOutliner();
if (wasCharacter) {
Queue.publish({
queueName: QueueNames.FROM_ENGINE,
action: fromEngineActions.UPDATE_CHARACTER_ID,
data: {
version: 1,
type: AssetType.CHARACTER,
media_id: media_id,
object_uuid: obj.uuid,
name: name,
} as MediaItem,
});
} else {
Queue.publish({
queueName: QueueNames.FROM_ENGINE,
action: fromEngineActions.ADD_OBJECT,
data: {
version: 1,
type: AssetType.OBJECT,
media_id: media_id,
object_uuid: obj.uuid,
name: name,
} as MediaItem,
});
}
await this.copy();
await this.add_creation_undostack(obj);
}
@@ -3,6 +3,8 @@ import { useSceneStore } from '../PageDraw/stores/SceneState';
export type TabId = '2D' | '3D' | 'VIDEO' | 'IMAGE' | 'EDIT';
const DEFAULT_TAB : TabId = '2D';
interface TabState {
// Current active tab
activeTabId: TabId;
@@ -18,7 +20,7 @@ interface TabState {
}
export const useTabStore = create<TabState>((set, get) => ({
activeTabId: 'EDIT',
activeTabId: DEFAULT_TAB,
tabData: {},
setActiveTab: async (newTabId) => {
@@ -895,8 +895,6 @@ export const Modal = ({
>
{/* Animated Modal Content */}
{transitions((styles, item) => {
// Debug logging - remove this after testing
console.log("Modal animation styles:", styles);
return item ? (
<Dialog.Content
forceMount
@@ -40,7 +40,6 @@ import {
// SoraSessionState,
// waitForSoraLogin,
EnqueueContextualEditImage,
EnqueueContextualEditImageModel,
EnqueueContextualEditImageSize,
} from "@storyteller/tauri-api";
// import { showActionReminder } from "@storyteller/ui-action-reminder-modal";
@@ -390,6 +389,7 @@ export const PromptBox2D = ({
toast.error("Failed to enqueue image generation. Please try again.");
}
};
const handleEnqueue = async () => {
console.log("Pressing Enqueue");
if (onEnqueuePressed) {
@@ -17,7 +17,7 @@ import { useEffect, useRef, useState } from "react";
export interface PromptBoxEditProps {
onModeChange?: (mode: string) => void;
selectedMode?: string;
onGenerateClick: () => void;
onGenerateClick: (prompt: string) => void;
isDisabled?: boolean;
}
@@ -132,7 +132,7 @@ export const PromptBoxEdit = ({
<Button
className="flex items-center border-none bg-primary px-3 text-sm text-white disabled:cursor-not-allowed disabled:opacity-50"
icon={!isDisabled ? faSparkles : undefined}
onClick={onGenerateClick}
onClick={() => onGenerateClick(prompt)}
disabled={isDisabled || !prompt.trim()}
>
{isDisabled ? (
+9 -5
View File
@@ -1,8 +1,12 @@
export * from "./lib/common/CommandStatus.js";
export * from "./lib/enqueue/EnqueueContextualEditImage.js";
export * from "./lib/enqueue/EnqueueImageBgRemovalCommand.js";
export * from "./lib/enqueue/EnqueueImageInpaint.js";
export * from "./lib/enqueue/EnqueueImageTo3dObject.js";
export * from "./lib/enqueue/EnqueueTextToImage.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";
@@ -10,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";
@@ -18,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,95 @@
import { invoke } from "@tauri-apps/api/core";
import { CommandResult } from "../common/CommandStatus";
import { ModelInfo } from "@storyteller/model-list";
export interface EnqueueImageInpaintRequest {
// The model to use.
model?: ModelInfo | EnqueueImageInpaintModel;
// The image we're editing.
image_media_token?: string;
// The mask to focus the edit (raw bytes).
mask_image_raw_bytes?: Uint8Array;
// The user's image generation prompt.
prompt?: string;
// Number of images to generate.
image_count?: number;
}
// Shape of request sent to Tauri
// (We do some transformations from the public-facing request object.)
interface RawEnqueueImageInpaintRequest {
model?: EnqueueImageInpaintModel | string;
image_media_token?: string;
mask_image_raw_bytes?: Uint8Array;
prompt?: string;
image_count?: number;
}
export enum EnqueueImageInpaintErrorType {
ModelNotSpecified = "model_not_specified",
NoProviderAvailable = "no_provider_available",
BadRequest = "bad_request",
ServerError = "server_error",
TooManyConcurrentTasks = "too_many_concurrent_tasks",
SoraLoginRequired = "sora_login_required",
SoraUsernameNotYetCreated = "sora_username_not_yet_created",
SoraIsHavingProblems = "sora_is_having_problems",
}
export enum EnqueueImageInpaintModel {
FluxPro1 = "flux_pro_1",
}
export interface EnqueueImageInpaintError extends CommandResult {
error_type: EnqueueImageInpaintErrorType;
error_message?: string;
}
export interface EnqueueImageInpaintPayload {
}
export interface EnqueueImageInpaintSuccess extends CommandResult {
payload: EnqueueImageInpaintPayload;
}
export type EnqueueImageInpaintResult = EnqueueImageInpaintSuccess | EnqueueImageInpaintError;
export const EnqueueImageInpaint = async (request: EnqueueImageInpaintRequest) : Promise<EnqueueImageInpaintResult> => {
let modelName = undefined;
if (!!request.model) {
if (typeof request.model === "string") {
modelName = request.model;
} else {
modelName = request.model.tauri_id;
}
}
let mutableRequest : RawEnqueueImageInpaintRequest = {
model: modelName,
prompt: request.prompt,
};
if (!!request.image_media_token) {
mutableRequest.image_media_token = request.image_media_token;
}
if (!!request.mask_image_raw_bytes) {
mutableRequest.mask_image_raw_bytes = request.mask_image_raw_bytes;
}
if (!!request.image_count) {
mutableRequest.image_count = request.image_count;
}
const result = await invoke("enqueue_image_inpaint_command", {
request: mutableRequest,
});
return (result as EnqueueImageInpaintResult);
}
@@ -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());
};
}, []);
}