mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-21 01:12:40 -04:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d449eb007c | ||
|
|
27891ee526 | ||
|
|
6d6d982ce2 | ||
|
|
335856f1f8 | ||
|
|
d8c92b0095 | ||
|
|
fd71cee894 | ||
|
|
d0375d9467 | ||
|
|
f83b0e52d8 | ||
|
|
a4107d5ec2 | ||
|
|
17473c65a2 | ||
|
|
6c96f21bd7 | ||
|
|
245409de08 | ||
|
|
90d0f15176 | ||
|
|
257db9b892 | ||
|
|
70348a922a | ||
|
|
95e724e4bd | ||
|
|
21ea7d05e2 | ||
|
|
e8305bd112 |
Generated
+10
@@ -688,6 +688,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"url",
|
||||
"web_base64",
|
||||
"workspace-build-acceleration",
|
||||
]
|
||||
|
||||
@@ -3597,6 +3598,7 @@ dependencies = [
|
||||
"fal",
|
||||
"file_to_base64_url",
|
||||
"futures 0.3.31",
|
||||
"log",
|
||||
"reqwest 0.12.15",
|
||||
"serde",
|
||||
"testing",
|
||||
@@ -12210,6 +12212,14 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web_base64"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"workspace-build-acceleration",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web_scrapers"
|
||||
version = "0.0.1"
|
||||
|
||||
@@ -43,6 +43,7 @@ members = [
|
||||
"crates/lib/data/crockford",
|
||||
"crates/lib/data/datetimes",
|
||||
"crates/lib/data/primitives",
|
||||
"crates/lib/data/web_base64",
|
||||
"crates/lib/deprecated/config", # TODO(bt, 2023-02-13): Deprecate or repurpose
|
||||
"crates/lib/deprecated/http_server_common", # TODO(bt, 2023-03-20): Move useful pieces to actix_helpers
|
||||
"crates/lib/dimensions",
|
||||
@@ -228,3 +229,4 @@ shared_service_components = { path = "crates/lib/shared_service_components" }
|
||||
storyteller_client = { path = "crates/lib/api_clients/storyteller_client" }
|
||||
subprocess_common = { path = "crates/lib/subprocess_common" }
|
||||
tokens = { path = "crates/schema/public/tokens" }
|
||||
web_base64= { path = "crates/lib/data/web_base64" }
|
||||
|
||||
@@ -35,6 +35,7 @@ openai_sora_client = { workspace = true }
|
||||
primitives = { workspace = true }
|
||||
storyteller_client = { workspace = true }
|
||||
tokens = { workspace = true }
|
||||
web_base64 = { workspace = true }
|
||||
|
||||
# External
|
||||
anyhow = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
use std::error::Error;
|
||||
use std::fmt::{Display, Formatter};
|
||||
use storyteller_client::error::storyteller_error::StorytellerError;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ArtcraftError {
|
||||
AnyhowError(anyhow::Error),
|
||||
DecodeError(base64::DecodeError),
|
||||
IoError(std::io::Error),
|
||||
StorytellerError(StorytellerError),
|
||||
}
|
||||
|
||||
impl Error for ArtcraftError {}
|
||||
|
||||
impl Display for ArtcraftError {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::AnyhowError(e) => write!(f, "AnyhowError: {:?}", e),
|
||||
Self::DecodeError(e) => write!(f, "DecodeError: {:?}", e),
|
||||
Self::IoError(e) => write!(f, "IoError: {:?}", e),
|
||||
Self::StorytellerError(e) => write!(f, "StorytellerError: {:?}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<anyhow::Error> for ArtcraftError {
|
||||
fn from(value: anyhow::Error) -> Self {
|
||||
Self::AnyhowError(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<base64::DecodeError> for ArtcraftError {
|
||||
fn from(value: base64::DecodeError) -> Self {
|
||||
Self::DecodeError(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for ArtcraftError {
|
||||
fn from(value: std::io::Error) -> Self {
|
||||
Self::IoError(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<StorytellerError> for ArtcraftError {
|
||||
fn from(value: StorytellerError) -> Self {
|
||||
Self::StorytellerError(value)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
use crate::core::artcraft_error::ArtcraftError;
|
||||
use anyhow::anyhow;
|
||||
use base64::DecodeError;
|
||||
use errors::AnyhowError;
|
||||
use fal_client::error::fal_error_plus::FalErrorPlus;
|
||||
@@ -47,3 +49,17 @@ impl From<std::io::Error> for InternalObjectError {
|
||||
Self::IoError(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ArtcraftError> for InternalObjectError {
|
||||
fn from(value: ArtcraftError) -> Self {
|
||||
match value {
|
||||
ArtcraftError::AnyhowError(e) => Self::AnyhowError(e),
|
||||
ArtcraftError::DecodeError(e) => Self::DecodeError(e),
|
||||
ArtcraftError::IoError(e) => Self::IoError(e),
|
||||
ArtcraftError::StorytellerError(e) => Self::StorytellerError(e),
|
||||
_ => {
|
||||
Self::AnyhowError(anyhow!("Other error: {:?}", value))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod artcraft_error;
|
||||
pub mod commands;
|
||||
pub mod events;
|
||||
pub mod state;
|
||||
|
||||
@@ -33,6 +33,7 @@ pub struct AppDataRoot {
|
||||
impl AppDataRoot {
|
||||
pub fn create_default() -> anyhow::Result<Self> {
|
||||
let directory = get_default_data_dir()?;
|
||||
println!("App data directory: {:?}", directory);
|
||||
Self::create_existing(directory)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::core::artcraft_error::ArtcraftError;
|
||||
use crate::core::state::data_dir::app_data_root::AppDataRoot;
|
||||
use crate::core::utils::get_url_file_extension::get_url_file_extension;
|
||||
use crate::core::utils::simple_http_download_to_tempfile::simple_http_download_to_tempfile;
|
||||
use errors::AnyhowResult;
|
||||
use storyteller_client::media_files::get_media_file::get_media_file;
|
||||
use storyteller_client::utils::api_host::ApiHost;
|
||||
use tempfile::NamedTempFile;
|
||||
@@ -9,7 +9,7 @@ use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
// TODO: Better more concrete error handling
|
||||
|
||||
pub async fn download_media_file_to_temp_dir(app_data_root: &AppDataRoot, token: &MediaFileToken) -> AnyhowResult<NamedTempFile> {
|
||||
pub async fn download_media_file_to_temp_dir(app_data_root: &AppDataRoot, token: &MediaFileToken) -> Result<NamedTempFile, ArtcraftError> {
|
||||
let response = get_media_file(&ApiHost::Storyteller, token).await?;
|
||||
|
||||
let media_file_url = &response.media_file.media_links.cdn_url;
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
use crate::core::artcraft_error::ArtcraftError;
|
||||
use crate::core::state::data_dir::app_data_root::AppDataRoot;
|
||||
use base64::prelude::BASE64_STANDARD;
|
||||
use base64::Engine;
|
||||
use errors::AnyhowResult;
|
||||
use mimetypes::mimetype_info::mimetype_info::MimetypeInfo;
|
||||
use std::io::Write;
|
||||
use tempfile::NamedTempFile;
|
||||
use web_base64::web_base64_decode::web_base64_decode;
|
||||
|
||||
// TODO: Retain error types (io/decode)
|
||||
|
||||
pub async fn save_base64_image_to_temp_dir(app_data_root: &AppDataRoot, base64_image: String) -> AnyhowResult<NamedTempFile> {
|
||||
let bytes = BASE64_STANDARD.decode(base64_image)?;
|
||||
pub async fn save_base64_image_to_temp_dir(app_data_root: &AppDataRoot, base64_image: String) -> Result<NamedTempFile, ArtcraftError> {
|
||||
let bytes = web_base64_decode(&base64_image)?;
|
||||
|
||||
let extension = MimetypeInfo::get_for_bytes(&bytes)
|
||||
.map(|info| info.file_extension())
|
||||
|
||||
+22
-4
@@ -1,3 +1,4 @@
|
||||
use crate::core::artcraft_error::ArtcraftError;
|
||||
use crate::core::commands::response::failure_response_wrapper::{CommandErrorResponseWrapper, CommandErrorStatus};
|
||||
use crate::core::commands::response::shorthand::Response;
|
||||
use crate::core::commands::response::success_response_wrapper::SerializeMarker;
|
||||
@@ -49,6 +50,7 @@ use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use storyteller_client::error::api_error::ApiError;
|
||||
use storyteller_client::error::api_error::ApiError::InternalServerError;
|
||||
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::utils::api_host::ApiHost;
|
||||
@@ -176,9 +178,10 @@ pub async fn fal_background_removal_command(
|
||||
enum InnerError {
|
||||
FalError(FalErrorPlus),
|
||||
AnyhowError(AnyhowError),
|
||||
StorytellerApiError(ApiError),
|
||||
StorytellerError(StorytellerError),
|
||||
DecodeError(DecodeError),
|
||||
IoError(std::io::Error),
|
||||
ArtcraftError(ArtcraftError),
|
||||
}
|
||||
|
||||
impl From<AnyhowError> for InnerError {
|
||||
@@ -193,9 +196,9 @@ impl From<FalErrorPlus> for InnerError {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ApiError> for InnerError {
|
||||
fn from(value: ApiError) -> Self {
|
||||
Self::StorytellerApiError(value)
|
||||
impl From<StorytellerError> for InnerError {
|
||||
fn from(value: StorytellerError) -> Self {
|
||||
Self::StorytellerError(value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,6 +214,17 @@ impl From<std::io::Error> for InnerError {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ArtcraftError> for InnerError {
|
||||
fn from(value: ArtcraftError) -> Self {
|
||||
match value {
|
||||
ArtcraftError::AnyhowError(e) => Self::AnyhowError(e),
|
||||
ArtcraftError::DecodeError(e) => Self::DecodeError(e),
|
||||
ArtcraftError::IoError(e) => Self::IoError(e),
|
||||
_ => Self::ArtcraftError(value),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn remove_background(
|
||||
request: FalBackgroundRemovalRequest,
|
||||
app_data_root: &AppDataRoot,
|
||||
@@ -224,9 +238,11 @@ pub async fn remove_background(
|
||||
let mut temp_download;
|
||||
|
||||
if let Some(media_token) = request.image_media_token {
|
||||
info!("From media token: {:?}", &media_token);
|
||||
temp_download = download_media_file_to_temp_dir(&app_data_root, &media_token).await?;
|
||||
|
||||
} else if let Some(base64_bytes) = request.base64_image {
|
||||
info!("From base64 bytes...");
|
||||
temp_download = save_base64_image_to_temp_dir(&app_data_root, base64_bytes).await?;
|
||||
} else {
|
||||
return Err(InnerError::AnyhowError(anyhow!("No image media token or base64 image provided")));
|
||||
@@ -237,6 +253,8 @@ pub async fn remove_background(
|
||||
let filename = temp_download.path().to_path_buf();
|
||||
|
||||
let result = remove_background_rembg(filename, &api_key).await?;
|
||||
|
||||
info!("Unpacking FAL result...");
|
||||
|
||||
let extension_with_dot = get_url_file_extension(&result.image_url)
|
||||
.map(|ext| format!(".{}", ext))
|
||||
|
||||
+20
-4
@@ -1,3 +1,4 @@
|
||||
use crate::core::artcraft_error::ArtcraftError;
|
||||
use crate::core::commands::response::failure_response_wrapper::{CommandErrorResponseWrapper, CommandErrorStatus};
|
||||
use crate::core::commands::response::shorthand::Response;
|
||||
use crate::core::events::basic_sendable_event_trait::BasicSendableEvent;
|
||||
@@ -49,6 +50,7 @@ use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use storyteller_client::error::api_error::ApiError;
|
||||
use storyteller_client::error::api_error::ApiError::InternalServerError;
|
||||
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::utils::api_host::ApiHost;
|
||||
@@ -152,9 +154,10 @@ pub async fn fal_hunyuan_image_to_3d_command(
|
||||
enum InnerError {
|
||||
FalError(FalErrorPlus),
|
||||
AnyhowError(AnyhowError),
|
||||
StorytellerApiError(ApiError),
|
||||
StorytellerError(StorytellerError),
|
||||
DecodeError(DecodeError),
|
||||
IoError(std::io::Error),
|
||||
ArtcraftError(ArtcraftError),
|
||||
}
|
||||
|
||||
impl From<AnyhowError> for InnerError {
|
||||
@@ -169,9 +172,9 @@ impl From<FalErrorPlus> for InnerError {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ApiError> for InnerError {
|
||||
fn from(value: ApiError) -> Self {
|
||||
Self::StorytellerApiError(value)
|
||||
impl From<StorytellerError> for InnerError {
|
||||
fn from(value: StorytellerError) -> Self {
|
||||
Self::StorytellerError(value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,6 +190,19 @@ impl From<std::io::Error> for InnerError {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ArtcraftError> for InnerError {
|
||||
fn from(value: ArtcraftError) -> Self {
|
||||
match value {
|
||||
ArtcraftError::AnyhowError(e) => Self::AnyhowError(e),
|
||||
ArtcraftError::DecodeError(e) => Self::DecodeError(e),
|
||||
ArtcraftError::IoError(e) => Self::IoError(e),
|
||||
ArtcraftError::StorytellerError(e) => Self::StorytellerError(e),
|
||||
_ => Self::ArtcraftError(value),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
pub async fn image_to_3d(
|
||||
request: FalHunyuanImageTo3dRequest,
|
||||
app_data_root: &AppDataRoot,
|
||||
|
||||
+19
-4
@@ -54,7 +54,9 @@ use storyteller_client::media_files::upload_image_media_file_from_file::upload_i
|
||||
use storyteller_client::utils::api_host::ApiHost;
|
||||
use tauri::{AppHandle, Emitter, Manager, State};
|
||||
use tempfile::NamedTempFile;
|
||||
use storyteller_client::error::storyteller_error::StorytellerError;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use crate::core::artcraft_error::ArtcraftError;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct FalKlingImageToVideoRequest {
|
||||
@@ -157,9 +159,10 @@ pub async fn fal_kling_image_to_video_command(
|
||||
enum InnerError {
|
||||
FalError(FalErrorPlus),
|
||||
AnyhowError(AnyhowError),
|
||||
StorytellerApiError(ApiError),
|
||||
StorytellerError(StorytellerError),
|
||||
DecodeError(DecodeError),
|
||||
IoError(std::io::Error),
|
||||
ArtcraftError(ArtcraftError),
|
||||
}
|
||||
|
||||
impl From<AnyhowError> for InnerError {
|
||||
@@ -174,9 +177,9 @@ impl From<FalErrorPlus> for InnerError {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ApiError> for InnerError {
|
||||
fn from(value: ApiError) -> Self {
|
||||
Self::StorytellerApiError(value)
|
||||
impl From<StorytellerError> for InnerError {
|
||||
fn from(value: StorytellerError) -> Self {
|
||||
Self::StorytellerError(value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,6 +195,18 @@ impl From<std::io::Error> for InnerError {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ArtcraftError> for InnerError {
|
||||
fn from(value: ArtcraftError) -> Self {
|
||||
match value {
|
||||
ArtcraftError::AnyhowError(e) => Self::AnyhowError(e),
|
||||
ArtcraftError::DecodeError(e) => Self::DecodeError(e),
|
||||
ArtcraftError::IoError(e) => Self::IoError(e),
|
||||
ArtcraftError::StorytellerError(e) => Self::StorytellerError(e),
|
||||
_ => Self::ArtcraftError(value),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn image_to_video(
|
||||
request: FalKlingImageToVideoRequest,
|
||||
app_data_root: &AppDataRoot,
|
||||
|
||||
@@ -39,6 +39,7 @@ use storyteller_client::error::api_error::ApiError::InternalServerError;
|
||||
use storyteller_client::media_files::get_media_file::get_media_file;
|
||||
use storyteller_client::utils::api_host::ApiHost;
|
||||
use tauri::{AppHandle, Emitter, Manager, State};
|
||||
use storyteller_client::error::storyteller_error::StorytellerError;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
const SORA_IMAGE_UPLOAD_TIMEOUT: Duration = Duration::from_millis(1000 * 30); // 30 seconds
|
||||
@@ -199,7 +200,7 @@ pub async fn sora_image_remix_command(
|
||||
enum InnerError {
|
||||
SoraError(SoraError),
|
||||
AnyhowError(AnyhowError),
|
||||
StorytellerApiError(ApiError),
|
||||
StorytellerError(StorytellerError),
|
||||
}
|
||||
|
||||
impl From<AnyhowError> for InnerError {
|
||||
@@ -214,9 +215,9 @@ impl From<SoraError> for InnerError {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ApiError> for InnerError {
|
||||
fn from(value: ApiError) -> Self {
|
||||
Self::StorytellerApiError(value)
|
||||
impl From<StorytellerError> for InnerError {
|
||||
fn from(value: StorytellerError) -> Self {
|
||||
Self::StorytellerError(value)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ errors = { workspace = true }
|
||||
# External
|
||||
anyhow = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
log = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
|
||||
@@ -22,7 +22,7 @@ pub async fn remove_background_rembg<P: AsRef<Path>>(image_path: P, api_key: &Fa
|
||||
crop_to_bbox: None,
|
||||
sync_mode: None
|
||||
};
|
||||
|
||||
|
||||
let result = rembg(request)
|
||||
.with_api_key(&api_key.0)
|
||||
.queue()
|
||||
@@ -56,6 +56,7 @@ mod tests {
|
||||
use crate::creds::fal_api_key::FalApiKey;
|
||||
use crate::requests::queue::remove_background_rembg::remove_background_rembg;
|
||||
use errors::AnyhowResult;
|
||||
use std::fs::read_to_string;
|
||||
use testing::test_file_path::test_file_path;
|
||||
|
||||
#[tokio::test]
|
||||
@@ -63,9 +64,9 @@ mod tests {
|
||||
async fn test_remove_background_rembg() -> AnyhowResult<()> {
|
||||
let image = test_file_path("test_data/image/juno.jpg")?;
|
||||
|
||||
// TODO: DO NOT COMMIT SECRET !
|
||||
const KEY : &str = "DO NOT COMMIT";
|
||||
let api_key = FalApiKey::from_str(KEY);
|
||||
// XXX: Don't commit secrets!
|
||||
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
|
||||
let api_key = FalApiKey::from_str(&secret);
|
||||
|
||||
let result = remove_background_rembg(image, &api_key).await?;
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::error::api_error::ApiError;
|
||||
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;
|
||||
@@ -23,16 +24,18 @@ use tokens::tokens::model_weights::ModelWeightToken;
|
||||
use tokens::tokens::prompts::PromptToken;
|
||||
|
||||
/// Get details about a media file from our backend
|
||||
pub async fn get_media_file(api_host: &ApiHost, media_file_token: &MediaFileToken) -> Result<GetMediaFileSuccessResponse, ApiError> {
|
||||
pub async fn get_media_file(api_host: &ApiHost, media_file_token: &MediaFileToken) -> Result<GetMediaFileSuccessResponse, StorytellerError> {
|
||||
let url = get_media_file_token_route(api_host, media_file_token);
|
||||
|
||||
debug!("Requesting {:?}", &url);
|
||||
|
||||
let response = http_get_anonymous(url).await?;
|
||||
let response = filter_bad_response(response).await?;
|
||||
let response_body = &response.text().await?;
|
||||
let response_body = &response.text().await
|
||||
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
|
||||
|
||||
let media_file = serde_json::from_str(&response_body)?;
|
||||
let media_file = serde_json::from_str(&response_body)
|
||||
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
|
||||
|
||||
Ok(media_file)
|
||||
}
|
||||
|
||||
+13
-6
@@ -1,5 +1,7 @@
|
||||
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;
|
||||
@@ -36,7 +38,7 @@ pub async fn upload_image_media_file_from_file<P: AsRef<Path>>(
|
||||
api_host: &ApiHost,
|
||||
maybe_creds: Option<&StorytellerCredentialSet>,
|
||||
path: P,
|
||||
) -> Result<UploadImageMediaFileSuccessResponse, ApiError> {
|
||||
) -> Result<UploadImageMediaFileSuccessResponse, StorytellerError> {
|
||||
|
||||
let url = get_route(api_host);
|
||||
|
||||
@@ -46,12 +48,14 @@ pub async fn upload_image_media_file_from_file<P: AsRef<Path>>(
|
||||
|
||||
let client = Client::builder()
|
||||
.gzip(true)
|
||||
.build()?;
|
||||
.build()
|
||||
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
|
||||
|
||||
let form = Form::new()
|
||||
.text("uuid_idempotency_token", generate_random_uuid())
|
||||
.file("file", path)
|
||||
.await?;
|
||||
.await
|
||||
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
|
||||
|
||||
let mut request_builder = client.post(url)
|
||||
.header("User-Agent", USER_AGENT)
|
||||
@@ -66,12 +70,15 @@ pub async fn upload_image_media_file_from_file<P: AsRef<Path>>(
|
||||
let response = request_builder
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await?;
|
||||
.await
|
||||
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
|
||||
|
||||
let response = filter_bad_response(response).await?;
|
||||
let response_body = &response.text().await?;
|
||||
let response_body = &response.text().await
|
||||
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
|
||||
|
||||
let media_file = serde_json::from_str(&response_body)?;
|
||||
let media_file = serde_json::from_str(&response_body)
|
||||
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
|
||||
|
||||
Ok(media_file)
|
||||
}
|
||||
|
||||
+1
-1
@@ -54,7 +54,7 @@ pub async fn upload_media_file_from_file<P: AsRef<Path>>(
|
||||
success: result.success,
|
||||
media_file_token: result.media_file_token,
|
||||
}),
|
||||
Err(err) => Err(StorytellerError::Api(err)),
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
Some(_) => Err(StorytellerError::Client(ClientError::FileTypeNotHandled(path.as_ref().to_path_buf()))),
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
[package]
|
||||
name = "web_base64"
|
||||
edition = "2024"
|
||||
version = "0.0.1"
|
||||
publish = false
|
||||
|
||||
[package.metadata.cargo-udeps.ignore]
|
||||
normal = ["workspace-build-acceleration"]
|
||||
|
||||
[lib]
|
||||
name = "web_base64"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
# Workspace hack (faster compile times)
|
||||
workspace-build-acceleration = { workspace = true }
|
||||
|
||||
# Internal
|
||||
# (None)
|
||||
|
||||
# External
|
||||
base64 = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
# (None)
|
||||
@@ -0,0 +1,5 @@
|
||||
//! Base64 encoding and decoding library that handles data from the web.
|
||||
//! Most of the base64 we receive from the web is of a particular encoding
|
||||
//! type, and we also have to juggle URL prefixes.
|
||||
|
||||
pub mod web_base64_decode;
|
||||
@@ -0,0 +1,14 @@
|
||||
use base64::prelude::BASE64_STANDARD;
|
||||
use base64::Engine;
|
||||
|
||||
pub fn web_base64_decode(base64_string: &str) -> Result<Vec<u8>, base64::DecodeError> {
|
||||
// Remove the data URL prefix if it exists
|
||||
// eg: "data:image/png;base64,..."
|
||||
let base64_data = if base64_string.starts_with("data:") {
|
||||
base64_string.split(',').nth(1).unwrap_or(base64_string)
|
||||
} else {
|
||||
base64_string
|
||||
};
|
||||
|
||||
BASE64_STANDARD.decode(base64_data)
|
||||
}
|
||||
Vendored
+5
-2
@@ -22,11 +22,14 @@ pub enum Status {
|
||||
Completed,
|
||||
}
|
||||
|
||||
// NB(bt, 2025-06-13): Some of these fields have started to become optional.
|
||||
// eg, errors:
|
||||
// "missing field `source`", line: 1, column: 1019
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct RequestLog {
|
||||
pub timestamp: String,
|
||||
pub level: String,
|
||||
pub source: String,
|
||||
pub level: Option<String>, // NB(bt): Now optional
|
||||
pub source: Option<String>, // NB(bt): Now optional
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface NodeProps {
|
||||
offsetX?: number;
|
||||
offsetY?: number;
|
||||
zIndex?: number;
|
||||
locked?: boolean;
|
||||
}
|
||||
|
||||
export class Node {
|
||||
@@ -44,6 +45,7 @@ export class Node {
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
zIndex: number;
|
||||
locked: boolean;
|
||||
|
||||
constructor({
|
||||
id,
|
||||
@@ -65,7 +67,8 @@ export class Node {
|
||||
scaleY = 1,
|
||||
offsetX = 0,
|
||||
offsetY = 0,
|
||||
zIndex = 0
|
||||
zIndex = 0,
|
||||
locked = false
|
||||
}: NodeProps) {
|
||||
this.id = id;
|
||||
this.x = x;
|
||||
@@ -87,6 +90,7 @@ export class Node {
|
||||
this.offsetX = offsetX;
|
||||
this.offsetY = offsetY;
|
||||
this.zIndex = zIndex;
|
||||
this.locked = locked;
|
||||
}
|
||||
|
||||
setPosition(x: number, y: number): void {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { PaintSurface } from "./PaintSurface";
|
||||
// https://github.com/SaladTechnologies/comfyui-api
|
||||
|
||||
import "./App.css";
|
||||
import PromptEditor from "./PromptEditor/PromptEditor";
|
||||
import SideToolbar from "./components/ui/SideToolbar";
|
||||
@@ -17,6 +18,43 @@ import Konva from "konva"; // just for types
|
||||
import { setCanvasRenderBitmap } from "../../signals/canvasRenderBitmap"
|
||||
import { captureStageImageBitmap } from "./hooks/useUpdateSnapshot"
|
||||
import { ContextMenuContainer } from "./components/ui/ContextMenu";
|
||||
import {
|
||||
FalBackgroundRemoval,
|
||||
FalBackgroundRemovalRequest,
|
||||
} from "@storyteller/tauri-api";
|
||||
|
||||
|
||||
/**
|
||||
* This decodes Base64 encoded PNG images into an image element.
|
||||
* @param base64String a standard (not "web safe") base64-encoded string
|
||||
*/
|
||||
export const DecodeBase64ToImage = async (base64String: string) : Promise<ImageBitmap> => {
|
||||
// Create an image element
|
||||
const img = document.createElement("img");
|
||||
|
||||
// Convert base64 to data URL if it doesn't include the prefix
|
||||
const dataUrl = base64String.startsWith("data:")
|
||||
? base64String
|
||||
: `data:image/png;base64,${base64String}`;
|
||||
|
||||
// Create a promise to handle the image loading
|
||||
return new Promise((resolve, reject) => {
|
||||
img.onload = async () => {
|
||||
try {
|
||||
const bitmap = await createImageBitmap(img);
|
||||
resolve(bitmap);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
img.onerror = () => reject(new Error("Failed to load image"));
|
||||
|
||||
// Set the source to trigger loading
|
||||
img.src = dataUrl;
|
||||
});
|
||||
}
|
||||
|
||||
const PageDraw = () => {
|
||||
//useStateSceneLoader();
|
||||
|
||||
@@ -273,7 +311,6 @@ const PageDraw = () => {
|
||||
<div className="relative z-0">
|
||||
<ContextMenuContainer
|
||||
onAction={(e, action) => {
|
||||
|
||||
if (action === "contextMenu") {
|
||||
const hasSelection = store.selectedNodeIds.length > 0;
|
||||
if (hasSelection) {
|
||||
@@ -285,11 +322,40 @@ const PageDraw = () => {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}}
|
||||
onMenuAction={(action) => {
|
||||
|
||||
onMenuAction={async (action) => {
|
||||
switch (action) {
|
||||
case 'LOCK':
|
||||
store.toggleLock(store.selectedNodeIds);
|
||||
break;
|
||||
case 'REMOVE_BACKGROUND':
|
||||
// returns a success if we have selected images only
|
||||
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 };
|
||||
}
|
||||
});
|
||||
break;
|
||||
case 'BRING_TO_FRONT':
|
||||
store.bringToFront(store.selectedNodeIds);
|
||||
break;
|
||||
@@ -302,10 +368,22 @@ const PageDraw = () => {
|
||||
case 'SEND_TO_BACK':
|
||||
store.sendToBack(store.selectedNodeIds);
|
||||
break;
|
||||
case 'DUPLICATE':
|
||||
store.copySelectedItems()
|
||||
store.pasteItems()
|
||||
break;
|
||||
case 'DELETE':
|
||||
store.deleteSelectedItems()
|
||||
break;
|
||||
default:
|
||||
// No action needed for unhandled cases
|
||||
}
|
||||
}}
|
||||
isLocked={store.selectedNodeIds.some(id => {
|
||||
const node = store.nodes.find(n => n.id === id);
|
||||
const lineNode = store.lineNodes.find(n => n.id === id);
|
||||
return (node?.locked || lineNode?.locked) ?? false;
|
||||
})}
|
||||
>
|
||||
<PaintSurface
|
||||
nodes={store.nodes}
|
||||
|
||||
@@ -546,6 +546,25 @@ export const PaintSurface = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// allow selection
|
||||
if (e.evt.button === 2) {
|
||||
const node = store.nodes.find(n => n.id === nodeId);
|
||||
const lineNode = store.lineNodes.find(n => n.id === nodeId);
|
||||
const isLocked = (node?.locked || lineNode?.locked) ?? false;
|
||||
|
||||
// If locked, only allow selection for context menu
|
||||
if (isLocked) {
|
||||
store.selectNode(nodeId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Don't select locked nodes
|
||||
const node = nodes.find(n => n.id === nodeId) || store.lineNodes.find(n => n.id === nodeId);
|
||||
if (node?.locked) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if Ctrl/Cmd key is pressed
|
||||
const isMultiSelect = e.evt.ctrlKey || e.evt.metaKey;
|
||||
|
||||
@@ -762,7 +781,7 @@ export const PaintSurface = ({
|
||||
onMouseLeave={handleNodeMouseLeave}
|
||||
draggable={draggableIfToolsNotActive(
|
||||
activeTool,
|
||||
lineNode.draggable,
|
||||
lineNode.draggable && lineNode.locked == false,
|
||||
)}
|
||||
onDragMove={(e) => handleLineDragMove(e, lineNode.id)}
|
||||
onDragStart={(e) => handleLineDragStart(e, lineNode.id)}
|
||||
@@ -799,7 +818,7 @@ export const PaintSurface = ({
|
||||
offsetX={node.offsetX || 0}
|
||||
offsetY={node.offsetY || 0}
|
||||
zIndex={node.zIndex}
|
||||
draggable={draggableIfToolsNotActive(activeTool, node.draggable)}
|
||||
draggable={draggableIfToolsNotActive(activeTool, node.draggable && node.locked == false)}
|
||||
onMouseDown={(e) => handleNodeMouseDown(e, node.id)}
|
||||
onTap={(e) => handleNodeMouseDown(e, node.id)}
|
||||
onMouseEnter={handleNodeMouseEnter}
|
||||
@@ -831,7 +850,7 @@ export const PaintSurface = ({
|
||||
offsetX={node.offsetX || 0}
|
||||
offsetY={node.offsetY || 0}
|
||||
zIndex={node.zIndex}
|
||||
draggable={draggableIfToolsNotActive(activeTool, node.draggable)}
|
||||
draggable={draggableIfToolsNotActive(activeTool, node.draggable && node.locked == false)}
|
||||
onMouseDown={(e) => handleNodeMouseDown(e, node.id)}
|
||||
onTap={(e) => handleNodeMouseDown(e, node.id)}
|
||||
onMouseEnter={handleNodeMouseEnter}
|
||||
@@ -863,7 +882,7 @@ export const PaintSurface = ({
|
||||
offsetX={node.offsetX || 0}
|
||||
offsetY={node.offsetY || 0}
|
||||
zIndex={node.zIndex}
|
||||
draggable={draggableIfToolsNotActive(activeTool, node.draggable)}
|
||||
draggable={draggableIfToolsNotActive(activeTool, node.draggable && node.locked == false)}
|
||||
onMouseDown={(e) => handleNodeMouseDown(e, node.id)}
|
||||
onTap={(e) => handleNodeMouseDown(e, node.id)}
|
||||
onMouseEnter={handleNodeMouseEnter}
|
||||
@@ -911,7 +930,7 @@ export const PaintSurface = ({
|
||||
scaleY={node.scaleY || 1}
|
||||
offsetX={node.offsetX || 0}
|
||||
offsetY={node.offsetY || 0}
|
||||
draggable={draggableIfToolsNotActive(activeTool, node.draggable)}
|
||||
draggable={draggableIfToolsNotActive(activeTool, node.draggable && node.locked == false)}
|
||||
onMouseDown={(e) => handleNodeMouseDown(e, node.id)}
|
||||
onTap={(e) => handleNodeMouseDown(e, node.id)}
|
||||
onMouseEnter={handleNodeMouseEnter}
|
||||
|
||||
@@ -67,8 +67,9 @@ interface ContextMenuContainerProps {
|
||||
children: React.ReactNode;
|
||||
items?: MenuItem[];
|
||||
onAction?: (e: React.MouseEvent, actionName: string) => boolean | void;
|
||||
onMenuAction?: (action: string) => void; // New prop for menu item actions
|
||||
onMenuAction?: (action: string) => void;
|
||||
className?: string;
|
||||
isLocked?: boolean;
|
||||
}
|
||||
|
||||
export const ContextMenuContainer: React.FC<ContextMenuContainerProps> = ({
|
||||
@@ -76,11 +77,27 @@ export const ContextMenuContainer: React.FC<ContextMenuContainerProps> = ({
|
||||
items = defaultMenuItems,
|
||||
onAction,
|
||||
onMenuAction,
|
||||
className = ''
|
||||
className = '',
|
||||
isLocked = false
|
||||
}) => {
|
||||
const [showMenu, setShowMenu] = React.useState(false);
|
||||
const [position, setPosition] = React.useState<MenuPosition>({ x: 0, y: 0 });
|
||||
|
||||
const filteredItems = React.useMemo(() => {
|
||||
if (isLocked) {
|
||||
return [
|
||||
{
|
||||
icon: <svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z" strokeWidth="2" strokeLinecap="round"/>
|
||||
</svg>,
|
||||
label: 'Unlock',
|
||||
action: 'LOCK'
|
||||
}
|
||||
];
|
||||
}
|
||||
return items;
|
||||
}, [isLocked, items]);
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const newPosition = { x: e.clientX, y: e.clientY };
|
||||
@@ -111,7 +128,7 @@ export const ContextMenuContainer: React.FC<ContextMenuContainerProps> = ({
|
||||
{children}
|
||||
{showMenu && (
|
||||
<ContextMenu
|
||||
items={items}
|
||||
items={filteredItems}
|
||||
onAction={handleMenuAction}
|
||||
position={position}
|
||||
onClose={() => setShowMenu(false)}
|
||||
@@ -174,5 +191,24 @@ const defaultMenuItems: MenuItem[] = [
|
||||
label: 'Duplicate',
|
||||
action: 'DUPLICATE'
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6M9 7h6m-7 0V4a1 1 0 011-1h4a1 1 0 011 1v3"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
label: 'Delete',
|
||||
action: 'DELETE'
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -18,6 +18,29 @@ export type LineNode = {
|
||||
offsetX?: number;
|
||||
offsetY?: number;
|
||||
zIndex: number;
|
||||
locked?: boolean; // Add locked property
|
||||
};
|
||||
|
||||
|
||||
// Logic to remove background from image nodes would go here
|
||||
const convertFileToBase64 = (file: File): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onloadend = () => {
|
||||
if (reader.result) {
|
||||
resolve(reader.result as string);
|
||||
} else {
|
||||
reject(new Error("Failed to convert file to base64."));
|
||||
}
|
||||
};
|
||||
|
||||
reader.onerror = () => {
|
||||
reject(new Error("Error reading file."));
|
||||
};
|
||||
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
};
|
||||
|
||||
export type ActiveTool = 'select' | 'draw' | 'eraser' | 'backgroundColor' | 'shape';
|
||||
@@ -110,6 +133,10 @@ interface SceneState {
|
||||
sendToBack: (nodeIds: string[]) => void;
|
||||
bringForward: (nodeIds: string[]) => void;
|
||||
sendBackward: (nodeIds: string[]) => void;
|
||||
removeBackground: (nodeIds: string[], operation: (success: boolean,base64_image:string, message: string) => Promise<{ success: boolean; file?: File }>) => Promise<void>;
|
||||
|
||||
// Add new lock action
|
||||
toggleLock: (nodeIds: string[]) => void;
|
||||
}
|
||||
|
||||
const generateId = (): string => {
|
||||
@@ -1131,4 +1158,90 @@ export const useSceneStore = create<SceneState>((set, get) => ({
|
||||
});
|
||||
get().saveState();
|
||||
},
|
||||
removeBackground: 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;
|
||||
});
|
||||
let callbackSuccess = false
|
||||
if (hasImageNodes) {
|
||||
console.log("Selected nodes include image types.");
|
||||
|
||||
const firstNode = get().nodes.find(node => nodeIds.includes(node.id) && node.type === 'image');
|
||||
if (firstNode) {
|
||||
const imageFile = firstNode.imageFile; // Assuming the file is stored in the node
|
||||
if (imageFile !== undefined) {
|
||||
try {
|
||||
const base64Image = await convertFileToBase64(imageFile);
|
||||
console.log("Base64 Image:", base64Image);
|
||||
// Additional logic to handle the base64 image can be added here
|
||||
const { success, file } = await operation(true, base64Image, "Removing Background."); // Indicate success in removing background
|
||||
callbackSuccess = success;
|
||||
if (callbackSuccess) {
|
||||
// save the state of the image.
|
||||
set((state: SceneState) => ({
|
||||
nodes: state.nodes.map((node: Node) => {
|
||||
if (node.id === firstNode.id) {
|
||||
// Create an object URL for immediate preview if needed
|
||||
console.log("Updated Node!")
|
||||
const updatedImageUrl: string | undefined = file ? URL.createObjectURL(file) : node.imageUrl;
|
||||
return new Node({
|
||||
...node,
|
||||
imageFile: file,
|
||||
imageUrl: updatedImageUrl,
|
||||
});
|
||||
}
|
||||
return node;
|
||||
}),
|
||||
}));
|
||||
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error converting file to base64:", error);
|
||||
}
|
||||
}
|
||||
console.log("Image File:", imageFile);
|
||||
} else {
|
||||
console.warn("No image node found or first node is not an image.");
|
||||
}
|
||||
} else {
|
||||
console.log("No selected nodes are of image type.");
|
||||
operation(false,"","Did not select a Images"); // Indicate failure to remove background
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
// Add toggleLock action
|
||||
toggleLock: (nodeIds: string[]) => {
|
||||
set((state) => {
|
||||
// Update regular nodes
|
||||
const updatedNodes = state.nodes.map(node => {
|
||||
if (nodeIds.includes(node.id)) {
|
||||
return new Node({
|
||||
...node,
|
||||
locked: !node.locked
|
||||
});
|
||||
}
|
||||
return node;
|
||||
});
|
||||
|
||||
// Update line nodes
|
||||
const updatedLineNodes = state.lineNodes.map(node => {
|
||||
if (nodeIds.includes(node.id)) {
|
||||
return {
|
||||
...node,
|
||||
locked: !node.locked
|
||||
};
|
||||
}
|
||||
return node;
|
||||
});
|
||||
|
||||
return {
|
||||
nodes: updatedNodes,
|
||||
lineNodes: updatedLineNodes
|
||||
};
|
||||
});
|
||||
get().saveState();
|
||||
}
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user