mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-02 15:55:41 -05:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50a1d3801f | ||
|
|
0d67b81f5f | ||
|
|
24addb0a49 | ||
|
|
041cfc5460 | ||
|
|
693b8c36be | ||
|
|
8c2e37cad7 |
+12
@@ -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"
|
||||
}
|
||||
-12
@@ -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
@@ -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;
|
||||
|
||||
+138
@@ -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,
|
||||
}
|
||||
+21
-7
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
|
||||
+22
-1
@@ -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 {
|
||||
|
||||
+8
-4
@@ -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)
|
||||
|
||||
+41
-21
@@ -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;
|
||||
|
||||
+74
-15
@@ -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));
|
||||
}
|
||||
|
||||
+19
@@ -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;
|
||||
+2
-1
@@ -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;
|
||||
}
|
||||
|
||||
+2
-1
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -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;
|
||||
}
|
||||
|
||||
+2
-1
@@ -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;
|
||||
@@ -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 {
|
||||
|
||||
+48
@@ -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;
|
||||
+13
-1
@@ -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;
|
||||
@@ -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
|
||||
);
|
||||
|
||||
+14
-4
@@ -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)),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
// }
|
||||
//}
|
||||
}
|
||||
}
|
||||
+11
-1
@@ -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 ? (
|
||||
|
||||
@@ -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());
|
||||
};
|
||||
|
||||
}, []);
|
||||
}
|
||||
Reference in New Issue
Block a user