mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-23 01:59:31 -04:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0a24b913d | ||
|
|
1bc275293b | ||
|
|
4521088453 | ||
|
|
0bdf79dfe4 | ||
|
|
770b361b94 | ||
|
|
32282d628f | ||
|
|
6294d4b71c | ||
|
|
ac3ed99615 | ||
|
|
4722bc3fec | ||
|
|
84a32d1c36 |
Generated
+1
@@ -663,6 +663,7 @@ dependencies = [
|
||||
"futures 0.3.31",
|
||||
"idempotency",
|
||||
"image",
|
||||
"images",
|
||||
"indexmap 2.9.0",
|
||||
"log",
|
||||
"memory_store",
|
||||
|
||||
@@ -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;
|
||||
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
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;
|
||||
|
||||
/// Upload an image media file from a file.
|
||||
/// NB: We need owned bytes for the request.
|
||||
pub async fn upload_image_media_file_from_bytes(
|
||||
api_host: &ApiHost,
|
||||
maybe_creds: Option<&StorytellerCredentialSet>,
|
||||
bytes: Vec<u8>,
|
||||
) -> Result<UploadImageMediaFileSuccessResponse, StorytellerError> {
|
||||
|
||||
let url = get_route(api_host);
|
||||
|
||||
debug!("Requesting {:?}", &url);
|
||||
|
||||
let client = Client::builder()
|
||||
.gzip(true)
|
||||
.build()
|
||||
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
|
||||
|
||||
let part = Part::bytes(bytes)
|
||||
.file_name("image.png")
|
||||
.mime_str("image/png")
|
||||
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
|
||||
|
||||
let form = Form::new()
|
||||
.text("uuid_idempotency_token", generate_random_uuid())
|
||||
.part("file", part);
|
||||
|
||||
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(header) = &creds.maybe_as_cookie_header() {
|
||||
request_builder = request_builder.header("Cookie", header);
|
||||
}
|
||||
}
|
||||
|
||||
let response = request_builder
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
|
||||
|
||||
let response = filter_bad_response(response).await?;
|
||||
let response_body = &response.text().await
|
||||
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
|
||||
|
||||
let media_file = serde_json::from_str(&response_body)
|
||||
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
|
||||
|
||||
Ok(media_file)
|
||||
}
|
||||
|
||||
fn get_route(api_host: &ApiHost) -> String {
|
||||
let api_hostname_and_scheme = api_host.to_api_hostname_and_scheme();
|
||||
format!("{}/v1/media_files/upload/image", api_hostname_and_scheme)
|
||||
}
|
||||
|
||||
// TODO(bt,2025-04-22): Share API definitions between client and server in common crate.
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct UploadImageMediaFileSuccessResponse {
|
||||
pub success: bool,
|
||||
pub media_file_token: MediaFileToken,
|
||||
}
|
||||
@@ -33,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 }
|
||||
|
||||
+19
-20
@@ -60,17 +60,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 +77,9 @@ 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>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
@@ -95,20 +97,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 +131,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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
+72
-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;
|
||||
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,41 @@ 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(
|
||||
&app_env_configs.storyteller_host,
|
||||
Some(&storyteller_creds),
|
||||
image_bytes.0
|
||||
).await
|
||||
.map_err(|err| {
|
||||
error!("Failed to upload image media file: {:?}", err);
|
||||
InternalImageInpaintError::StorytellerError(err)
|
||||
})?;
|
||||
|
||||
Ok(result.media_file_token)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
|
||||
export const normalizeCanvas = (canvas: HTMLCanvasElement, width: number, height: number): HTMLCanvasElement => {
|
||||
const newCanvas = document.createElement("canvas");
|
||||
newCanvas.width = width;
|
||||
|
||||
@@ -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,6 +8,8 @@ import Konva from "konva"; // just for types
|
||||
|
||||
import { setCanvasRenderBitmap } from "../../signals/canvasRenderBitmap"
|
||||
import {
|
||||
EnqueueImageInpaint,
|
||||
EnqueueImageInpaintModel,
|
||||
FalBackgroundRemoval,
|
||||
} from "@storyteller/tauri-api";
|
||||
import { ContextMenuContainer } from "../PageDraw/components/ui/ContextMenu";
|
||||
@@ -188,7 +190,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 +208,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 +303,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,
|
||||
|
||||
@@ -3,6 +3,8 @@ import { useSceneStore } from '../PageDraw/stores/SceneState';
|
||||
|
||||
export type TabId = '2D' | '3D' | 'VIDEO' | 'IMAGE' | 'EDIT';
|
||||
|
||||
const DEFAULT_TAB : TabId = 'EDIT';
|
||||
|
||||
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
|
||||
|
||||
@@ -42,6 +42,8 @@ import {
|
||||
EnqueueContextualEditImage,
|
||||
EnqueueContextualEditImageModel,
|
||||
EnqueueContextualEditImageSize,
|
||||
EnqueueImageInpaint,
|
||||
EnqueueImageInpaintModel,
|
||||
} from "@storyteller/tauri-api";
|
||||
// import { showActionReminder } from "@storyteller/ui-action-reminder-modal";
|
||||
// import { invoke } from "@tauri-apps/api/core";
|
||||
@@ -333,6 +335,41 @@ export const PromptBox2D = ({
|
||||
});
|
||||
|
||||
console.log("generateResponse", generateResponse);
|
||||
|
||||
// NB: Tanish, this is an example request to inpainting
|
||||
// NB: Tanish, this is an example request to inpainting
|
||||
// NB: Tanish, this is an example request to inpainting
|
||||
// NB: Tanish, this is an example request to inpainting
|
||||
|
||||
const EncodeImageBitmapToArray = async (imageBitmap: ImageBitmap): Promise<Uint8Array> => {
|
||||
const canvas = new OffscreenCanvas(imageBitmap.width, imageBitmap.height);
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.drawImage(imageBitmap, 0, 0);
|
||||
|
||||
// Convert canvas to PNG Blob
|
||||
const blob = await canvas.convertToBlob({ type: 'image/png' });
|
||||
|
||||
// Read Blob as ArrayBuffer
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
|
||||
// Convert to Uint8Array
|
||||
return new Uint8Array(arrayBuffer);
|
||||
}
|
||||
|
||||
const uint8Array = await EncodeImageBitmapToArray(image);
|
||||
|
||||
const generateResponse2 = await EnqueueImageInpaint({
|
||||
model: EnqueueImageInpaintModel.FluxPro1,
|
||||
image_media_token: "m_9y8hy50582mwxpm8hxmh4n4wjeh5cr",
|
||||
mask_image_raw_bytes: uint8Array,
|
||||
prompt: prompt,
|
||||
image_count: 1,
|
||||
});
|
||||
|
||||
// NB: Tanish, end example.
|
||||
// NB: Tanish, end example.
|
||||
// NB: Tanish, end example.
|
||||
// NB: Tanish, end example.
|
||||
};
|
||||
|
||||
const handleWebEnqueue = async () => {
|
||||
@@ -390,6 +427,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 ? (
|
||||
|
||||
@@ -3,6 +3,7 @@ export * from "./lib/enqueue/EnqueueContextualEditImage.js";
|
||||
export * from "./lib/enqueue/EnqueueImageTo3dObject.js";
|
||||
export * from "./lib/enqueue/EnqueueTextToImage.js";
|
||||
export * from "./lib/enqueue/EnqueueImageToVideo.js";
|
||||
export * from "./lib/enqueue/EnqueueImageInpaint.js";
|
||||
export * from "./lib/fal/FalBackgroundRemoval.js";
|
||||
export * from "./lib/fal/FalHunyuanImageTo3d.js";
|
||||
export * from "./lib/fal/FalKlingImageToVideo.js";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user