mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-04 05:20:58 -05:00
Compare commits
43
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8dc547547 | ||
|
|
7b5abf82c4 | ||
|
|
f49dd31f7e | ||
|
|
55c9da44d2 | ||
|
|
9c24dbd446 | ||
|
|
adf9b07744 | ||
|
|
24559a3554 | ||
|
|
5a26aed728 | ||
|
|
95773c1c23 | ||
|
|
2d01073434 | ||
|
|
1c62092d94 | ||
|
|
5e22e17dfb | ||
|
|
611da775a9 | ||
|
|
6fc3f73579 | ||
|
|
562cb99c51 | ||
|
|
67d280df84 | ||
|
|
8ccc8927a9 | ||
|
|
a21e7331cc | ||
|
|
0f0f8eb39c | ||
|
|
b64ed411b1 | ||
|
|
a155b419e1 | ||
|
|
e6177005df | ||
|
|
1c78e17565 | ||
|
|
0769bfad28 | ||
|
|
183946a943 | ||
|
|
d43ee0f6c9 | ||
|
|
eec59c218d | ||
|
|
5c1e72f8f4 | ||
|
|
d91243b1f5 | ||
|
|
83cecad280 | ||
|
|
06313f54a5 | ||
|
|
ef3ca88054 | ||
|
|
cfcf7ab5aa | ||
|
|
4cc215495a | ||
|
|
f1d14ab73f | ||
|
|
5cb8f5b93a | ||
|
|
9decf8bf88 | ||
|
|
63a7f99c53 | ||
|
|
9dd16f2e80 | ||
|
|
abe2d2a093 | ||
|
|
552d5553c0 | ||
|
|
0d33358ead | ||
|
|
908ff43be3 |
+12
-1
@@ -17,7 +17,15 @@ pub struct AppPreferencesPayload {
|
||||
|
||||
/// Play sounds on events.
|
||||
pub play_sounds: bool,
|
||||
|
||||
|
||||
/// Key pointing to file; defined in the frontend code.
|
||||
/// Defined for enqueue since image enqueue can be async
|
||||
pub enqueue_success_sound: Option<String>,
|
||||
|
||||
/// Key pointing to file; defined in the frontend code.
|
||||
/// Defined for enqueue since image enqueue can be async
|
||||
pub enqueue_failure_sound: Option<String>,
|
||||
|
||||
/// Key pointing to file; defined in the frontend code.
|
||||
pub generation_success_sound: Option<String>,
|
||||
|
||||
@@ -25,6 +33,7 @@ pub struct AppPreferencesPayload {
|
||||
pub generation_failure_sound: Option<String>,
|
||||
|
||||
/// Key pointing to file; defined in the frontend code.
|
||||
#[deprecated]
|
||||
pub generation_enqueue_sound: Option<String>,
|
||||
}
|
||||
|
||||
@@ -52,6 +61,8 @@ async fn get_prefs(app_prefs: &AppPreferencesManager) -> AnyhowResult<AppPrefere
|
||||
Ok(AppPreferencesPayload {
|
||||
preferred_download_directory: prefs.preferred_download_directory,
|
||||
play_sounds: prefs.play_sounds,
|
||||
enqueue_success_sound: prefs.enqueue_success_sound,
|
||||
enqueue_failure_sound: prefs.enqueue_failure_sound,
|
||||
generation_success_sound: prefs.generation_success_sound,
|
||||
generation_failure_sound: prefs.generation_failure_sound,
|
||||
generation_enqueue_sound: prefs.generation_enqueue_sound,
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
use serde::Serialize;
|
||||
use std::error::Error;
|
||||
use std::str::FromStr;
|
||||
|
||||
/// Easy to use Result<T, E> type.
|
||||
pub type CommandResult<SuccessPayload, ErrType, ErrPayload> =
|
||||
Result<CommandSuccessResponseWrapper<SuccessPayload>, CommandErrorResponseWrapper<ErrType, ErrPayload>>;
|
||||
|
||||
/// No inner payloads for this type. Just strings as messages.
|
||||
pub type SimpleCommandResult = CommandResult<(), (), ()>;
|
||||
|
||||
/// Statuses for successful commands.
|
||||
#[derive(Serialize, Clone, Debug)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CommandSuccessStatus {
|
||||
Success, // 200
|
||||
}
|
||||
|
||||
/// Statuses for errorful commands.
|
||||
#[derive(Serialize, Clone, Debug)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CommandErrorStatus {
|
||||
BadRequest, // 400
|
||||
Unauthorized, // 401
|
||||
TooManyRequests, // 429
|
||||
ServerError, // 500
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
pub struct CommandSuccessResponseWrapper<T: Serialize> {
|
||||
/// Both "success" and "error" types have a `status` field for the frontend.
|
||||
pub status: CommandSuccessStatus,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub success_message: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub payload: Option<T>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
pub struct CommandErrorResponseWrapper<ErrType: Serialize, ErrPayload: Serialize> {
|
||||
/// Both "success" and "error" types have a `status` field for the frontend.
|
||||
pub status: CommandErrorStatus,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error_message: Option<String>,
|
||||
|
||||
/// Represents a "type" of error, preferably an enum without payloads.
|
||||
/// Preferably this serializes to a string.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error_type: Option<ErrType>,
|
||||
|
||||
/// Represents a full error details object or payload.
|
||||
/// This could be a structure, message, or whatever.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error_details: Option<ErrPayload>,
|
||||
}
|
||||
|
||||
|
||||
impl CommandSuccessResponseWrapper<()> {
|
||||
pub fn empty() -> Self {
|
||||
CommandSuccessResponseWrapper {
|
||||
status: CommandSuccessStatus::Success,
|
||||
success_message: None,
|
||||
payload: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// NB: This is a hack because of `()` (a.k.a. the "unit" type) and `Serialize` trait coherence and overlap.
|
||||
/// We cannot currently implement a trait for `()` and generic `Serialize` because `()` itself implements
|
||||
/// `Serialize`. Rust's `"specialization"`, which would let us account for this, is an unstable, nightly-only
|
||||
/// feature. So instead we manually mark all types other than `()` with `SerializeMarker` as a hack to
|
||||
/// distinguish the two.
|
||||
trait SerializeMarker : Serialize {}
|
||||
|
||||
// NB: () is Serialize! So we can't broadly implement just `T: Serialize`.
|
||||
impl From<()> for CommandSuccessResponseWrapper<()> {
|
||||
fn from(_: ()) -> Self {
|
||||
CommandSuccessResponseWrapper {
|
||||
status: CommandSuccessStatus::Success,
|
||||
success_message: None,
|
||||
payload: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// NB: Note the `"+ SerializeMarker"` bound here. This is a hack to distinguish between `()` and other types
|
||||
/// for which we will manually mark as `SerializeMarker`.
|
||||
impl <T> From<T> for CommandSuccessResponseWrapper<T> where T: Serialize + SerializeMarker {
|
||||
fn from(val: T) -> Self {
|
||||
CommandSuccessResponseWrapper {
|
||||
status: CommandSuccessStatus::Success,
|
||||
success_message: None,
|
||||
payload: Some(val),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for CommandSuccessResponseWrapper<String> {
|
||||
fn from(msg: &str) -> Self {
|
||||
CommandSuccessResponseWrapper {
|
||||
status: CommandSuccessStatus::Success,
|
||||
success_message: Some(msg.to_string()),
|
||||
payload: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for CommandErrorResponseWrapper<(), ()> {
|
||||
fn from(value: &str) -> Self {
|
||||
CommandErrorResponseWrapper {
|
||||
status: CommandErrorStatus::BadRequest, // NB: Default to 500 error.
|
||||
error_message: Some(value.to_string()),
|
||||
error_type: None,
|
||||
error_details: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_empty_tuple_serialize() {
|
||||
let response = super::CommandSuccessResponseWrapper::<()>::from(());
|
||||
let serialized = serde_json::to_string(&response).unwrap();
|
||||
// NB: This tests that the blanket "Serialize" impl doesn't produce a "null payload" field.
|
||||
assert_eq!(serialized, r#"{"status":"success"}"#);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod app_preferences;
|
||||
pub mod command_response_wrapper;
|
||||
pub mod flip_image;
|
||||
pub mod platform_info_command;
|
||||
pub mod sora;
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
use crate::commands::app_preferences::get_app_preferences_command::AppPreferencesPayload;
|
||||
use crate::state::app_preferences::app_preferences_manager::AppPreferencesManager;
|
||||
use crate::state::data_dir::app_data_root::AppDataRoot;
|
||||
use crate::state::sora::sora_credential_manager::SoraCredentialManager;
|
||||
use crate::windows::sora_login_window::open_sora_login_window::open_sora_login_window;
|
||||
use errors::AnyhowResult;
|
||||
use log::{error, info};
|
||||
use once_cell::sync::Lazy;
|
||||
use openai_sora_client::creds::sora_credential_set::SoraCredentialSet;
|
||||
use openai_sora_client::recipes::maybe_upgrade_or_renew_session::maybe_upgrade_or_renew_session;
|
||||
use openai_sora_client::requests::list_media::list_media::list_media;
|
||||
use serde_derive::Serialize;
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct CheckSoraSessionCommand {
|
||||
pub state: SoraSessionState,
|
||||
pub maybe_account_email: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SoraSessionState{
|
||||
#[serde(rename = "not_set_up")]
|
||||
NotSetUp,
|
||||
#[serde(rename = "expired_or_error")]
|
||||
ExpiredOrError,
|
||||
#[serde(rename = "valid")]
|
||||
Valid,
|
||||
}
|
||||
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn check_sora_session_command(
|
||||
app: AppHandle,
|
||||
sora_creds_manager: State<'_, SoraCredentialManager>,
|
||||
) -> Result<CheckSoraSessionCommand, CheckSoraSessionCommand> {
|
||||
info!("check_sora_session_command called");
|
||||
|
||||
match do_check(&sora_creds_manager).await {
|
||||
Ok(state) => Ok(state),
|
||||
Err(err) => {
|
||||
error!("Error checking session state: {:?}", err);
|
||||
Err(CheckSoraSessionCommand {
|
||||
state: SoraSessionState::ExpiredOrError,
|
||||
maybe_account_email: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn do_check(
|
||||
sora_creds_manager: &SoraCredentialManager,
|
||||
) -> AnyhowResult<CheckSoraSessionCommand> {
|
||||
|
||||
let mut creds = match sora_creds_manager.get_credentials()? {
|
||||
Some(creds) => creds,
|
||||
None => {
|
||||
return Ok(CheckSoraSessionCommand {
|
||||
state: SoraSessionState::NotSetUp,
|
||||
maybe_account_email: None,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let upgraded = maybe_upgrade_or_renew_session(&mut creds).await?;
|
||||
|
||||
list_media(&creds).await?;
|
||||
|
||||
if upgraded {
|
||||
info!("Saving upgraded session to manager and disk...");
|
||||
sora_creds_manager.set_credentials(&creds)?;
|
||||
sora_creds_manager.persist_all_to_disk()?;
|
||||
}
|
||||
|
||||
let maybe_account_email = creds
|
||||
.jwt_bearer_token
|
||||
.map(|token| token.jwt_claims().email.clone());
|
||||
|
||||
Ok(CheckSoraSessionCommand {
|
||||
state: SoraSessionState::Valid,
|
||||
maybe_account_email,
|
||||
})
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
pub mod check_sora_session_command;
|
||||
pub mod open_sora_login_command;
|
||||
pub mod sora_image_generation_command;
|
||||
pub mod sora_image_remix_command;
|
||||
pub mod sora_logout_command;
|
||||
|
||||
@@ -1,31 +1,21 @@
|
||||
use crate::threads::sora_session_login_thread::{LOGIN_WINDOW_NAME, SORA_LOGIN_URL};
|
||||
use crate::utils::clear_all_webview_cookies::clear_all_webview_cookies;
|
||||
use crate::state::app_preferences::app_preferences_manager::AppPreferencesManager;
|
||||
use crate::state::data_dir::app_data_root::AppDataRoot;
|
||||
use crate::state::sora::sora_credential_manager::SoraCredentialManager;
|
||||
use crate::windows::sora_login_window::open_sora_login_window::open_sora_login_window;
|
||||
use errors::AnyhowResult;
|
||||
use log::{error, info};
|
||||
use once_cell::sync::Lazy;
|
||||
use std::time::Duration;
|
||||
use tauri::{AppHandle, Manager, Url, WebviewUrl, WebviewWindowBuilder, WindowBuilder};
|
||||
use tokio::time::sleep_until;
|
||||
|
||||
pub const START_URL_STR: &str = "https://storyteller.ai/";
|
||||
|
||||
pub static START_URL: Lazy<Url> = Lazy::new(|| {
|
||||
Url::parse(START_URL_STR).expect("URL should parse")
|
||||
});
|
||||
|
||||
pub const SORA_HOMEPAGE_URL_STR: &str = "https://sora.com/";
|
||||
|
||||
pub static SORA_HOMEPAGE_URL : Lazy<Url> = Lazy::new(|| {
|
||||
Url::parse(SORA_HOMEPAGE_URL_STR).expect("URL should parse")
|
||||
});
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn open_sora_login_command(
|
||||
app: AppHandle,
|
||||
app_data_root: State<'_, AppDataRoot>,
|
||||
sora_creds_manager: State<'_, SoraCredentialManager>,
|
||||
) -> Result<String, String> {
|
||||
info!("open_login_command called");
|
||||
|
||||
do_open_login(&app)
|
||||
do_open_login(&app, &app_data_root, &sora_creds_manager)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
error!("Error opening login: {:?}", err);
|
||||
@@ -35,37 +25,14 @@ pub async fn open_sora_login_command(
|
||||
Ok("result".to_string())
|
||||
}
|
||||
|
||||
async fn do_open_login(app: &AppHandle) -> AnyhowResult<()> {
|
||||
async fn do_open_login(
|
||||
app: &AppHandle,
|
||||
app_data_root: &AppDataRoot,
|
||||
sora_creds_manager: &SoraCredentialManager,
|
||||
) -> AnyhowResult<()> {
|
||||
info!("Building login window...");
|
||||
|
||||
let url = WebviewUrl::External(START_URL.clone());
|
||||
|
||||
let window = WebviewWindowBuilder::new(app, LOGIN_WINDOW_NAME, url)
|
||||
//.user_agent(openai_sora_client::credentials::USER_AGENT)
|
||||
.always_on_top(true)
|
||||
.title("Login to OpenAI")
|
||||
.center()
|
||||
.resizable(true)
|
||||
.visible(true)
|
||||
.closable(true)
|
||||
.focused(true)
|
||||
.devtools(true)
|
||||
.build()?;
|
||||
|
||||
for (name, webview) in window.webviews() {
|
||||
if name != LOGIN_WINDOW_NAME {
|
||||
continue;
|
||||
}
|
||||
|
||||
clear_all_webview_cookies(&webview)?;
|
||||
|
||||
// NB: We're starting to get Cloudflare protection screens. Let's try to avoid.
|
||||
webview.navigate(SORA_HOMEPAGE_URL.clone())?;
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
webview.navigate(SORA_LOGIN_URL.clone())?;
|
||||
break;
|
||||
}
|
||||
open_sora_login_window(app, app_data_root, sora_creds_manager).await?;
|
||||
|
||||
info!("Done.");
|
||||
Ok(())
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
use crate::commands::command_response_wrapper::{CommandErrorResponseWrapper, CommandErrorStatus, CommandResult};
|
||||
use crate::events::sendable_event_trait::SendableEvent;
|
||||
use crate::events::sora::sora_image_enqueue_failure_event::SoraImageEnqueueFailureEvent;
|
||||
use crate::events::sora::sora_image_enqueue_success_event::SoraImageEnqueueSuccessEvent;
|
||||
use crate::state::data_dir::app_data_root::AppDataRoot;
|
||||
use crate::state::data_dir::trait_data_subdir::DataSubdir;
|
||||
use crate::state::sora::read_sora_credentials_from_disk::read_sora_credentials_from_disk;
|
||||
@@ -8,7 +12,7 @@ use crate::utils::get_url_file_extension::get_url_file_extension;
|
||||
use crate::utils::simple_http_download::simple_http_download;
|
||||
use base64::prelude::BASE64_STANDARD;
|
||||
use base64::Engine;
|
||||
use errors::AnyhowResult;
|
||||
use errors::{AnyhowError, AnyhowResult};
|
||||
use image::codecs::png::{CompressionType, FilterType, PngEncoder};
|
||||
use image::{DynamicImage, ImageReader};
|
||||
use log::{debug, error, info};
|
||||
@@ -27,17 +31,10 @@ use std::io::Cursor;
|
||||
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 openai_sora_client::sora_error::SoraError;
|
||||
use storyteller_client::api_error::ApiError;
|
||||
use storyteller_client::api_error::ApiError::InternalServerError;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use crate::threads::sora_task_polling_thread::SoraImageGenerationComplete;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct SoraImageEnqueueFailure {
|
||||
// TODO: Reason.
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct SoraImageEnqueueSuccess {
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SoraImageRemixCommand {
|
||||
@@ -56,6 +53,20 @@ pub struct SoraImageRemixCommand {
|
||||
pub maybe_number_of_samples: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SoraImageRemixErrorType {
|
||||
/// Generic server error
|
||||
ServerError,
|
||||
/// The user is sending too many requests
|
||||
TooManyConcurrentTasks,
|
||||
/// The user needs to create a Sora account
|
||||
SoraUsernameNotYetCreated,
|
||||
/// The Sora service is having problems. Try again soon.
|
||||
SoraIsHavingProblems,
|
||||
}
|
||||
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn sora_image_remix_command(
|
||||
app: AppHandle,
|
||||
@@ -63,7 +74,7 @@ pub async fn sora_image_remix_command(
|
||||
app_data_root: State<'_, AppDataRoot>,
|
||||
sora_creds_manager: State<'_, SoraCredentialManager>,
|
||||
sora_task_queue: State<'_, SoraTaskQueue>,
|
||||
) -> Result<String, String> {
|
||||
) -> CommandResult<(), SoraImageRemixErrorType, ()> {
|
||||
|
||||
info!("image_generation_command called; scene media token: {:?}, additional images: {:?}",
|
||||
request.snapshot_media_token, request.maybe_additional_images);
|
||||
@@ -72,23 +83,83 @@ pub async fn sora_image_remix_command(
|
||||
|
||||
let result = generate_image(request, &app_data_root, &sora_creds_manager, &sora_task_queue).await;
|
||||
|
||||
if let Err(err) = result {
|
||||
error!("error: {:?}", err);
|
||||
match result {
|
||||
Err(err) => {
|
||||
error!("error: {:?}", err);
|
||||
|
||||
let result = app.emit("sora-image-enqueue-failure", SoraImageEnqueueFailure{});
|
||||
if let Err(err) = result {
|
||||
error!("Failed to emit event: {:?}", err);
|
||||
let event = SoraImageEnqueueFailureEvent {};
|
||||
let result = event.send(&app);
|
||||
|
||||
if let Err(err) = result {
|
||||
error!("Failed to emit event: {:?}", err);
|
||||
}
|
||||
|
||||
let mut status = CommandErrorStatus::ServerError;
|
||||
let mut error_type = SoraImageRemixErrorType::ServerError;
|
||||
let mut error_message = "A server error occurred.";
|
||||
|
||||
match (err) {
|
||||
InnerError::SoraError(SoraError::TooManyConcurrentTasks) => {
|
||||
error_type = SoraImageRemixErrorType::TooManyConcurrentTasks;
|
||||
status = CommandErrorStatus::ServerError;
|
||||
error_message = "You already have work in progress. Please wait.";
|
||||
},
|
||||
InnerError::SoraError(SoraError::SoraUsernameNotYetCreated) => {
|
||||
error_type = SoraImageRemixErrorType::SoraUsernameNotYetCreated;
|
||||
status = CommandErrorStatus::BadRequest;
|
||||
error_message = "You need to create a username on Sora.com.";
|
||||
},
|
||||
InnerError::SoraError(SoraError::BadGateway(_) | SoraError::CloudFlareTimeout(_)) => {
|
||||
error_type = SoraImageRemixErrorType::SoraIsHavingProblems;
|
||||
status = CommandErrorStatus::ServerError;
|
||||
error_message = "Sora is having problems. Please wait a moment before retrying.";
|
||||
}
|
||||
_ => {},
|
||||
}
|
||||
|
||||
Err(CommandErrorResponseWrapper {
|
||||
status,
|
||||
error_message: Some(error_message.to_string()),
|
||||
error_type: Some(error_type),
|
||||
error_details: None,
|
||||
})
|
||||
}
|
||||
|
||||
return Err("there was an error".to_string());
|
||||
}
|
||||
Ok(_) => {
|
||||
let event = SoraImageEnqueueSuccessEvent {};
|
||||
let result = event.send(&app);
|
||||
|
||||
let result = app.emit("sora-image-enqueue-success", SoraImageEnqueueSuccess{});
|
||||
if let Err(err) = result {
|
||||
error!("Failed to emit event: {:?}", err);
|
||||
}
|
||||
if let Err(err) = result {
|
||||
error!("Failed to emit event: {:?}", err);
|
||||
}
|
||||
|
||||
Ok("success".to_string())
|
||||
Ok(().into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum InnerError {
|
||||
SoraError(SoraError),
|
||||
AnyhowError(AnyhowError),
|
||||
StorytellerApiError(ApiError),
|
||||
}
|
||||
|
||||
impl From<AnyhowError> for InnerError {
|
||||
fn from(value: AnyhowError) -> Self {
|
||||
Self::AnyhowError(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SoraError> for InnerError {
|
||||
fn from(value: SoraError) -> Self {
|
||||
Self::SoraError(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ApiError> for InnerError {
|
||||
fn from(value: ApiError) -> Self {
|
||||
Self::StorytellerApiError(value)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn generate_image(
|
||||
@@ -96,7 +167,7 @@ pub async fn generate_image(
|
||||
app_data_root: &AppDataRoot,
|
||||
sora_creds_manager: &SoraCredentialManager,
|
||||
sora_task_queue: &SoraTaskQueue,
|
||||
) -> AnyhowResult<()> {
|
||||
) -> Result<(), InnerError> {
|
||||
|
||||
let response = get_media_file(&ApiHost::Storyteller, &request.snapshot_media_token).await?;
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
use crate::commands::app_preferences::get_app_preferences_command::AppPreferencesPayload;
|
||||
use crate::commands::command_response_wrapper::{CommandResult, CommandSuccessResponseWrapper, SimpleCommandResult};
|
||||
use crate::state::app_preferences::app_preferences_manager::AppPreferencesManager;
|
||||
use crate::state::data_dir::app_data_root::AppDataRoot;
|
||||
use crate::state::sora::sora_credential_manager::SoraCredentialManager;
|
||||
use crate::windows::sora_login_window::open_sora_login_window::open_sora_login_window;
|
||||
use errors::{AnyhowError, AnyhowResult};
|
||||
use log::{error, info};
|
||||
use once_cell::sync::Lazy;
|
||||
use openai_sora_client::creds::sora_credential_set::SoraCredentialSet;
|
||||
use openai_sora_client::recipes::maybe_upgrade_or_renew_session::maybe_upgrade_or_renew_session;
|
||||
use openai_sora_client::requests::list_media::list_media::list_media;
|
||||
use serde_derive::Serialize;
|
||||
use std::fmt::format;
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn sora_logout_command(
|
||||
sora_creds_manager: State<'_, SoraCredentialManager>,
|
||||
) -> SimpleCommandResult {
|
||||
info!("sora_logout_command called");
|
||||
|
||||
if let Err(err) = sora_creds_manager.purge_credentials_from_disk() {
|
||||
error!("Error purging credentials from disk: {:?}", err);
|
||||
return Err("error purging credentials from disk".into());
|
||||
}
|
||||
|
||||
if let Err(err) = sora_creds_manager.clear_credentials() {
|
||||
error!("Error clearing credentials from memory: {:?}", err);
|
||||
return Err("error clearing credentials from memory".into());
|
||||
}
|
||||
|
||||
Ok(().into())
|
||||
}
|
||||
@@ -1 +1,4 @@
|
||||
pub mod notification_event;
|
||||
pub mod notification_event;
|
||||
pub mod sendable_event_error;
|
||||
pub mod sendable_event_trait;
|
||||
pub mod sora;
|
||||
@@ -0,0 +1,31 @@
|
||||
use std::error::Error;
|
||||
use std::fmt::{Display, Formatter};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum SendableEventError {
|
||||
TauriError(tauri::Error),
|
||||
AnyhowError(anyhow::Error),
|
||||
}
|
||||
|
||||
impl Error for SendableEventError {}
|
||||
|
||||
impl Display for SendableEventError {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::TauriError(err) => write!(f, "SendableEventError::TauriError: {}", err),
|
||||
Self::AnyhowError(err) => write!(f, "SendableEventError::AnyhowError: {}", err),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<anyhow::Error> for SendableEventError {
|
||||
fn from(err: anyhow::Error) -> Self {
|
||||
SendableEventError::AnyhowError(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<tauri::Error> for SendableEventError {
|
||||
fn from(err: tauri::Error) -> Self {
|
||||
SendableEventError::TauriError(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
use log::info;
|
||||
use crate::events::sendable_event_error::SendableEventError;
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
/// Implement this on a Serializable object to be able to send it as an event to the frontend.
|
||||
pub trait SendableEvent : Serialize {
|
||||
/// This is the name of the event that the frontend subscribes to.
|
||||
const FRONTEND_EVENT_NAME: &'static str;
|
||||
|
||||
/// Default implementation of send().
|
||||
/// This serializes and sends the event to the frontend.
|
||||
fn send(&self, app: &AppHandle) -> Result<(), SendableEventError> {
|
||||
info!("Sending event to frontend: {}", Self::FRONTEND_EVENT_NAME);
|
||||
let result = app.emit(Self::FRONTEND_EVENT_NAME, self);
|
||||
if let Err(err) = result {
|
||||
return Err(SendableEventError::from(err));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod sora_image_enqueue_failure_event;
|
||||
pub mod sora_image_enqueue_success_event;
|
||||
pub mod sora_image_generation_complete_event;
|
||||
pub mod sora_image_generation_failed_event;
|
||||
pub mod sora_login_success_event;
|
||||
@@ -0,0 +1,11 @@
|
||||
use crate::events::sendable_event_trait::SendableEvent;
|
||||
use serde_derive::Serialize;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct SoraImageEnqueueFailureEvent {
|
||||
// TODO: Reason.
|
||||
}
|
||||
|
||||
impl SendableEvent for SoraImageEnqueueFailureEvent {
|
||||
const FRONTEND_EVENT_NAME: &'static str = "sora-image-enqueue-failure";
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
use crate::events::sendable_event_trait::SendableEvent;
|
||||
use serde_derive::Serialize;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct SoraImageEnqueueSuccessEvent {
|
||||
// TODO: Token, etc.
|
||||
}
|
||||
|
||||
impl SendableEvent for SoraImageEnqueueSuccessEvent {
|
||||
const FRONTEND_EVENT_NAME: &'static str = "sora-image-enqueue-success";
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
use serde_derive::Serialize;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use crate::events::sendable_event_trait::SendableEvent;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct SoraImageGenerationCompleteEvent {
|
||||
pub media_file_token: MediaFileToken,
|
||||
}
|
||||
|
||||
impl SendableEvent for SoraImageGenerationCompleteEvent {
|
||||
const FRONTEND_EVENT_NAME: &'static str = "sora-image-generation-complete";
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
use serde_derive::Serialize;
|
||||
use crate::events::sendable_event_trait::SendableEvent;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct SoraImageGenerationFailedEvent {
|
||||
pub prompt: String,
|
||||
}
|
||||
|
||||
impl SendableEvent for SoraImageGenerationFailedEvent {
|
||||
const FRONTEND_EVENT_NAME: &'static str = "sora-image-generation-failed";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
use crate::events::sendable_event_trait::SendableEvent;
|
||||
use serde_derive::Serialize;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct SoraLoginSuccessEvent {
|
||||
// TODO: Reason.
|
||||
}
|
||||
|
||||
impl SendableEvent for SoraLoginSuccessEvent {
|
||||
const FRONTEND_EVENT_NAME: &'static str = "sora-login-success";
|
||||
}
|
||||
@@ -3,30 +3,33 @@ pub mod events;
|
||||
pub mod state;
|
||||
pub mod threads;
|
||||
pub mod utils;
|
||||
pub mod windows;
|
||||
|
||||
use tauri::Manager;
|
||||
|
||||
use crate::commands::flip_image::flip_image;
|
||||
use crate::commands::app_preferences::get_app_preferences_command::get_app_preferences_command;
|
||||
use crate::commands::app_preferences::update_app_preference_command::update_app_preferences_command;
|
||||
use crate::commands::flip_image::flip_image;
|
||||
use crate::commands::platform_info_command::platform_info_command;
|
||||
use crate::commands::sora::check_sora_session_command::check_sora_session_command;
|
||||
use crate::commands::sora::open_sora_login_command::open_sora_login_command;
|
||||
use crate::commands::sora::sora_image_generation_command::sora_image_generation_command;
|
||||
use crate::commands::sora::sora_image_remix_command::sora_image_remix_command;
|
||||
use crate::commands::sora::sora_logout_command::sora_logout_command;
|
||||
use crate::state::app_preferences::app_preferences_manager::load_app_preferences_or_default;
|
||||
use crate::state::data_dir::app_data_root::AppDataRoot;
|
||||
use crate::state::main_window_position::MainWindowPosition;
|
||||
use crate::state::main_window_size::MainWindowSize;
|
||||
use crate::state::sora::sora_credential_manager::SoraCredentialManager;
|
||||
use crate::state::sora::sora_task_queue::SoraTaskQueue;
|
||||
use crate::state::storyteller::storyteller_credential_manager::StorytellerCredentialManager;
|
||||
use crate::threads::discord_presence_thread::discord_presence_thread;
|
||||
use crate::threads::main_window_thread::main_window_thread::main_window_thread;
|
||||
use crate::threads::sora_session_login_thread::sora_session_login_thread;
|
||||
use crate::threads::sora_task_polling_thread::sora_task_polling_thread;
|
||||
use crate::utils::webview_unsafe::webview_unsafe_for_app;
|
||||
|
||||
use crate::state::app_preferences::app_preferences_manager::load_app_preferences_or_default;
|
||||
use tauri_plugin_http;
|
||||
use tauri_plugin_dialog;
|
||||
use tauri_plugin_http;
|
||||
use tauri_plugin_log::Target;
|
||||
use tauri_plugin_log::TargetKind;
|
||||
|
||||
@@ -108,7 +111,20 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
|
||||
tauri::async_runtime::spawn(sora_session_login_thread(app_2, app_data_root_2, sora_creds_manager_2));
|
||||
match MainWindowPosition::from_filesystem_configs(&app_data_root_3) {
|
||||
Ok(None) => {}
|
||||
Ok(Some(pos)) => {
|
||||
println!("Moving window to: {:?}", pos);
|
||||
let result = pos.apply_to_main_window(&app);
|
||||
if let Err(err) = result {
|
||||
eprintln!("Could not set window position: {:?}", err);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("Failed to read window position from disk: {:?}", err);
|
||||
}
|
||||
}
|
||||
|
||||
tauri::async_runtime::spawn(main_window_thread(app_3, app_data_root_3, storyteller_creds_manager_2));
|
||||
tauri::async_runtime::spawn(sora_task_polling_thread(app_4, app_data_root_4, sora_creds_manager_3, storyteller_creds_manager_3, sora_task_queue_2));
|
||||
tauri::async_runtime::spawn(discord_presence_thread());
|
||||
@@ -120,13 +136,15 @@ pub fn run() {
|
||||
.manage(sora_creds_manager)
|
||||
.manage(sora_task_queue)
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
check_sora_session_command,
|
||||
flip_image,
|
||||
get_app_preferences_command,
|
||||
update_app_preferences_command,
|
||||
open_sora_login_command,
|
||||
platform_info_command,
|
||||
sora_image_generation_command,
|
||||
sora_image_remix_command,
|
||||
sora_logout_command,
|
||||
update_app_preferences_command,
|
||||
])
|
||||
.run(tauri::generate_context!("tauri.conf.json"))
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -9,6 +9,14 @@ pub struct AppPreferences {
|
||||
|
||||
/// Play sounds on events.
|
||||
pub play_sounds: bool,
|
||||
|
||||
/// Key pointing to file; defined in the frontend code.
|
||||
/// Defined for enqueue since image enqueue can be async
|
||||
pub enqueue_success_sound: Option<String>,
|
||||
|
||||
/// Key pointing to file; defined in the frontend code.
|
||||
/// Defined for enqueue since image enqueue can be async
|
||||
pub enqueue_failure_sound: Option<String>,
|
||||
|
||||
/// Key pointing to file; defined in the frontend code.
|
||||
pub generation_success_sound: Option<String>,
|
||||
@@ -17,6 +25,7 @@ pub struct AppPreferences {
|
||||
pub generation_failure_sound: Option<String>,
|
||||
|
||||
/// Key pointing to file; defined in the frontend code.
|
||||
#[deprecated]
|
||||
pub generation_enqueue_sound: Option<String>,
|
||||
}
|
||||
|
||||
@@ -26,7 +35,9 @@ impl Default for AppPreferences {
|
||||
preferred_download_directory: PreferredDownloadDirectory::System(SystemDownloadDirectory::Downloads),
|
||||
play_sounds: true,
|
||||
// NB: These are defined in the frontend.
|
||||
generation_success_sound: Some("flower".to_string()),
|
||||
enqueue_success_sound: Some("done".to_string()),
|
||||
enqueue_failure_sound: Some("spike_throw".to_string()),
|
||||
generation_success_sound: Some("special_flower".to_string()),
|
||||
generation_failure_sound: Some("crumble".to_string()),
|
||||
generation_enqueue_sound: Some("done".to_string()),
|
||||
}
|
||||
|
||||
@@ -136,6 +136,10 @@ impl AppDataRoot {
|
||||
pub fn get_window_size_config_file(&self) -> PathBuf {
|
||||
self.path.join("window_size.json")
|
||||
}
|
||||
|
||||
pub fn get_window_position_config_file(&self) -> PathBuf {
|
||||
self.path.join("window_position.json")
|
||||
}
|
||||
}
|
||||
|
||||
// eg. /home/bob/artcraft, /Users/bob/artcraft, or C:\Users\Alice\artcraft
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
use crate::state::data_dir::app_data_root::AppDataRoot;
|
||||
use crate::windows::main_window::constants::MAIN_WINDOW_NAME;
|
||||
use errors::AnyhowResult;
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
use tauri::{AppHandle, Manager, PhysicalPosition, Window};
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq)]
|
||||
pub struct MainWindowPosition {
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
}
|
||||
|
||||
impl MainWindowPosition {
|
||||
pub fn new(x: i32, y: i32) -> Self {
|
||||
Self { x, y }
|
||||
}
|
||||
|
||||
pub fn from_main_window(app: &AppHandle) -> AnyhowResult<Self> {
|
||||
let windows = app.windows();
|
||||
windows.get(MAIN_WINDOW_NAME)
|
||||
.map(|window| Self::from_window(window))
|
||||
.unwrap_or_else(|| Err(anyhow::anyhow!("Main window not found")))
|
||||
}
|
||||
|
||||
pub fn from_window(window: &Window) -> AnyhowResult<Self> {
|
||||
let position = window.outer_position()?;
|
||||
Ok(Self {
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn from_filesystem_configs(app_data_root: &AppDataRoot) -> AnyhowResult<Option<Self>> {
|
||||
let filename = app_data_root.get_window_position_config_file();
|
||||
if !filename.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let contents = std::fs::read_to_string(filename)?;
|
||||
let pos : MainWindowPosition = serde_json::from_str(&contents)?;
|
||||
Ok(Some(pos))
|
||||
}
|
||||
|
||||
pub fn apply_to_main_window(&self, app: &AppHandle) -> AnyhowResult<()> {
|
||||
let windows = app.windows();
|
||||
let window = windows.get(MAIN_WINDOW_NAME)
|
||||
.ok_or_else(|| anyhow::anyhow!("Main window not found"))?;
|
||||
window.set_position(PhysicalPosition::new(self.x, self.y))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn persist_to_filesystem(&self, app_data_root: &AppDataRoot) -> AnyhowResult<()> {
|
||||
let filename = app_data_root.get_window_position_config_file();
|
||||
let json = serde_json::to_string(self)?;
|
||||
let mut file = OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(filename)?;
|
||||
file.write_all(json.as_bytes())?;
|
||||
file.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn to_physical_position(&self) -> PhysicalPosition<i32> {
|
||||
PhysicalPosition::new(self.x, self.y)
|
||||
}
|
||||
|
||||
pub fn matches_physical_position(&self, pos: &PhysicalPosition<i32>) -> bool {
|
||||
self.x == pos.x && self.y == pos.y
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
use crate::state::data_dir::app_data_root::AppDataRoot;
|
||||
use crate::utils::best_window_size_heuristic::best_window_size_heuristic;
|
||||
use crate::windows::main_window::constants::MAIN_WINDOW_NAME;
|
||||
use errors::AnyhowResult;
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
use tauri::{AppHandle, Manager, PhysicalSize, Window};
|
||||
|
||||
const MAIN_WINDOW_NAME: &str = "main";
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq)]
|
||||
pub struct MainWindowSize {
|
||||
pub width: u32,
|
||||
@@ -26,7 +26,7 @@ impl MainWindowSize {
|
||||
}
|
||||
|
||||
pub fn from_window(window: &Window) -> AnyhowResult<Self> {
|
||||
let size = window.inner_size()?;
|
||||
let size = best_window_size_heuristic(window)?;
|
||||
Ok(Self {
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
|
||||
@@ -2,6 +2,7 @@ pub mod app_preferences;
|
||||
pub mod app_startup_time;
|
||||
pub mod data_dir;
|
||||
pub mod expanduser;
|
||||
pub mod main_window_position;
|
||||
pub mod main_window_size;
|
||||
pub mod os_platform;
|
||||
pub mod sora;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use std::{fs, io};
|
||||
use crate::state::data_dir::app_data_root::AppDataRoot;
|
||||
use crate::state::sora::read_sora_credentials_from_disk::read_sora_credentials_from_disk;
|
||||
use crate::state::sora::sora_credential_holder::SoraCredentialHolder;
|
||||
@@ -9,6 +10,7 @@ use openai_sora_client::creds::sora_sentinel::SoraSentinel;
|
||||
use openai_sora_client::requests::sentinel_refresh::generate::token::generate_token;
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use openai_sora_client::creds::sora_cookies::SoraCookies;
|
||||
use openai_sora_client::creds::sora_jwt_bearer_token::SoraJwtBearerToken;
|
||||
|
||||
@@ -45,6 +47,31 @@ impl SoraCredentialManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// This is meant to be infallible.
|
||||
pub fn purge_credentials_from_disk(&self) -> Result<(), io::Error> {
|
||||
let creds_dir = self.app_data_root.credentials_dir();
|
||||
|
||||
remove_file_if_exists(creds_dir.get_sora_cookie_file_path())?;
|
||||
remove_file_if_exists(creds_dir.get_sora_bearer_token_file_path())?;
|
||||
remove_file_if_exists(creds_dir.get_sora_sentinel_file_path())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// This is meant to be infallible.
|
||||
pub fn try_purge_credentials_from_disk(&self) {
|
||||
let creds_dir = self.app_data_root.credentials_dir();
|
||||
if let Err(err) = fs::remove_file(creds_dir.get_sora_cookie_file_path()) {
|
||||
error!("Failed to remove cookie file: {:?}", err);
|
||||
}
|
||||
if let Err(err) = fs::remove_file(creds_dir.get_sora_bearer_token_file_path()) {
|
||||
error!("Failed to remove bearer token file: {:?}", err);
|
||||
}
|
||||
if let Err(err) = fs::remove_file(creds_dir.get_sora_sentinel_file_path()) {
|
||||
error!("Failed to remove sentinel file: {:?}", err);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_credentials(&self) -> AnyhowResult<Option<SoraCredentialSet>> {
|
||||
self.holder.get_credentials()
|
||||
}
|
||||
@@ -155,3 +182,11 @@ fn persist_sentinel_to_disk(sentinel: &SoraSentinel, app_data_root: &AppDataRoot
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_file_if_exists<P: AsRef<Path>>(path: P) -> Result<(), io::Error> {
|
||||
let p = path.as_ref();
|
||||
if p.exists() && p.is_file() {
|
||||
fs::remove_file(p)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use anyhow::anyhow;
|
||||
use discord_rich_presence::{DiscordIpc, DiscordIpcClient};
|
||||
use errors::AnyhowResult;
|
||||
use log::info;
|
||||
use log::{debug, info};
|
||||
|
||||
/// Our Discord App ID. Not sure if this is a secret.
|
||||
const DISCORD_APP_ID : &str = "1366596912593113138";
|
||||
@@ -15,13 +15,13 @@ pub async fn discord_presence_thread() -> ! {
|
||||
}
|
||||
|
||||
async fn discord_main_loop(mut client: DiscordIpcClient) -> AnyhowResult<()> {
|
||||
info!("Connecting Discord IPC client");
|
||||
info!("(Re)-connecting Discord IPC client...");
|
||||
|
||||
client.connect()
|
||||
.map_err(|err| anyhow!("Error connecting to Discord IPC: {:?}", err))?;
|
||||
|
||||
loop {
|
||||
info!("Notifying discord...");
|
||||
debug!("Notifying discord presence API...");
|
||||
|
||||
let assets = discord_rich_presence::activity::Assets::new()
|
||||
.large_image("https://storyteller.ai/android-chrome-512x512.png")
|
||||
@@ -37,6 +37,6 @@ async fn discord_main_loop(mut client: DiscordIpcClient) -> AnyhowResult<()> {
|
||||
.assets(assets))
|
||||
.map_err(|err| anyhow!("Error setting Discord activity: {:?}", err))?;
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(30_000)).await;
|
||||
tokio::time::sleep(std::time::Duration::from_millis(60_000)).await;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,15 @@
|
||||
use crate::state::app_startup_time::AppStartupTime;
|
||||
use crate::state::data_dir::app_data_root::AppDataRoot;
|
||||
use crate::state::main_window_position::MainWindowPosition;
|
||||
use crate::state::main_window_size::MainWindowSize;
|
||||
use crate::state::storyteller::storyteller_credential_manager::StorytellerCredentialManager;
|
||||
use crate::threads::main_window_thread::persist_storyteller_cookies_task::persist_storyteller_cookies_task;
|
||||
use crate::threads::main_window_thread::persist_window_position_task::persist_window_position_task;
|
||||
use crate::threads::main_window_thread::persist_window_resize_task::persist_window_resize_task;
|
||||
use errors::AnyhowResult;
|
||||
use log::{error, info};
|
||||
use memory_store::clone_slot::CloneSlot;
|
||||
use tauri::{AppHandle, Manager, Webview, Window};
|
||||
use crate::state::app_startup_time::AppStartupTime;
|
||||
|
||||
const MAIN_WINDOW_NAME : &str = "main";
|
||||
|
||||
@@ -18,6 +20,7 @@ pub async fn main_window_thread(
|
||||
) -> ! {
|
||||
// TODO: Move these into some kind of dependency injection framework
|
||||
let window_size_slot: CloneSlot<MainWindowSize> = CloneSlot::empty();
|
||||
let window_pos_slot: CloneSlot<MainWindowPosition> = CloneSlot::empty();
|
||||
let app_startup_time = AppStartupTime::new();
|
||||
|
||||
loop {
|
||||
@@ -29,6 +32,7 @@ pub async fn main_window_thread(
|
||||
&app_data_root,
|
||||
&storyteller_creds_manager,
|
||||
&window_size_slot,
|
||||
&window_pos_slot,
|
||||
&app_startup_time,
|
||||
).await;
|
||||
if let Err(err) = result {
|
||||
@@ -46,10 +50,12 @@ pub async fn handle_main_window(
|
||||
app_data_root: &AppDataRoot,
|
||||
storyteller_creds_manager: &StorytellerCredentialManager,
|
||||
window_size_slot: &CloneSlot<MainWindowSize>,
|
||||
window_pos_slot: &CloneSlot<MainWindowPosition>,
|
||||
app_startup_time: &AppStartupTime,
|
||||
) -> AnyhowResult<()> {
|
||||
loop {
|
||||
log_errors(persist_window_resize_task(window, app_data_root, window_size_slot).await);
|
||||
log_errors(persist_window_position_task(window, app_data_root, window_pos_slot).await);
|
||||
log_errors(persist_storyteller_cookies_task(app, storyteller_creds_manager, app_startup_time).await);
|
||||
tokio::time::sleep(std::time::Duration::from_millis(1_000)).await;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod main_window_thread;
|
||||
pub mod persist_storyteller_cookies_task;
|
||||
pub mod persist_window_position_task;
|
||||
pub mod persist_window_resize_task;
|
||||
-1
@@ -1,7 +1,6 @@
|
||||
use crate::state::app_startup_time::AppStartupTime;
|
||||
use crate::state::data_dir::app_data_root::AppDataRoot;
|
||||
use crate::state::storyteller::storyteller_credential_manager::StorytellerCredentialManager;
|
||||
use crate::threads::sora_session_login_thread::LOGIN_WINDOW_NAME;
|
||||
use anyhow::anyhow;
|
||||
use chrono::TimeDelta;
|
||||
use errors::AnyhowResult;
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
use crate::state::data_dir::app_data_root::AppDataRoot;
|
||||
use crate::state::main_window_position::MainWindowPosition;
|
||||
use crate::state::main_window_size::MainWindowSize;
|
||||
use crate::utils::best_window_size_heuristic::best_window_size_heuristic;
|
||||
use errors::AnyhowResult;
|
||||
use log::info;
|
||||
use memory_store::clone_slot::CloneSlot;
|
||||
use tauri::Window;
|
||||
|
||||
pub async fn persist_window_position_task(
|
||||
window: &Window,
|
||||
app_data_root: &AppDataRoot,
|
||||
window_pos_slot: &CloneSlot<MainWindowPosition>,
|
||||
) -> AnyhowResult<()> {
|
||||
|
||||
let current_window_position = MainWindowPosition::from_window(window)?;
|
||||
let current_physical_pos = window.outer_position()?;
|
||||
|
||||
let mut save_size_to_disk = true;
|
||||
|
||||
if let Ok(Some(old_size)) = window_pos_slot.get_clone() {
|
||||
if old_size.matches_physical_position(¤t_physical_pos) {
|
||||
save_size_to_disk = false;
|
||||
}
|
||||
}
|
||||
|
||||
if save_size_to_disk {
|
||||
info!("Saving window position configs to disk...");
|
||||
current_window_position.persist_to_filesystem(app_data_root)?;
|
||||
window_pos_slot.set_clone(¤t_window_position)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
+2
-1
@@ -1,5 +1,6 @@
|
||||
use crate::state::data_dir::app_data_root::AppDataRoot;
|
||||
use crate::state::main_window_size::MainWindowSize;
|
||||
use crate::utils::best_window_size_heuristic::best_window_size_heuristic;
|
||||
use errors::AnyhowResult;
|
||||
use log::info;
|
||||
use memory_store::clone_slot::CloneSlot;
|
||||
@@ -12,7 +13,7 @@ pub async fn persist_window_resize_task(
|
||||
) -> AnyhowResult<()> {
|
||||
|
||||
let current_window_size = MainWindowSize::from_window(window)?;
|
||||
let current_physical_size = window.inner_size()?;
|
||||
let current_physical_size = best_window_size_heuristic(window)?;
|
||||
|
||||
let mut save_size_to_disk = true;
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
pub mod discord_presence_thread;
|
||||
pub mod main_window_thread;
|
||||
pub mod sora_session_login_thread;
|
||||
pub mod sora_task_polling_thread;
|
||||
@@ -1,178 +0,0 @@
|
||||
use crate::state::data_dir::app_data_root::AppDataRoot;
|
||||
use crate::state::sora::sora_credential_holder::SoraCredentialHolder;
|
||||
use crate::state::sora::sora_credential_manager::SoraCredentialManager;
|
||||
use crate::utils::cookies::sora_webview_cookies::get_all_sora_cookies_as_string;
|
||||
use crate::utils::sora::initialize_sora_jwt_bearer_token::initialize_sora_jwt_bearer_token;
|
||||
use anyhow::anyhow;
|
||||
use chrono::{DateTime, NaiveDateTime, TimeDelta};
|
||||
use errors::AnyhowResult;
|
||||
use log::{error, info, warn};
|
||||
use once_cell::sync::Lazy;
|
||||
use openai_sora_client::creds::sora_credential_set::SoraCredentialSet;
|
||||
use openai_sora_client::recipes::maybe_upgrade_or_renew_session::maybe_upgrade_or_renew_session;
|
||||
use openai_sora_client::utils::has_session_cookie::{has_session_cookie, SessionCookiePresence};
|
||||
use reqwest::Url;
|
||||
use std::fs;
|
||||
use std::fs::{read_to_string, OpenOptions};
|
||||
use std::io::Write;
|
||||
use std::ops::Sub;
|
||||
use tauri::{AppHandle, Manager, Webview};
|
||||
|
||||
pub const LOGIN_WINDOW_NAME: &str = "login_window";
|
||||
|
||||
pub const SORA_LOGIN_URL_STR: &str = "https://sora.com/auth/login?callback_path=/";
|
||||
|
||||
pub static SORA_LOGIN_URL: Lazy<Url> = Lazy::new(|| {
|
||||
Url::parse(SORA_LOGIN_URL_STR).expect("URL should parse")
|
||||
});
|
||||
|
||||
pub const SORA_ROOT_URL_STR: &str = "https://sora.com/";
|
||||
|
||||
pub static SORA_ROOT_URL: Lazy<Url> = Lazy::new(|| {
|
||||
Url::parse(SORA_ROOT_URL_STR).expect("URL should parse")
|
||||
});
|
||||
|
||||
pub async fn sora_session_login_thread(
|
||||
app: AppHandle,
|
||||
app_data_root: AppDataRoot,
|
||||
sora_creds_manager: SoraCredentialManager
|
||||
) -> ! {
|
||||
loop {
|
||||
for (window_name, webview) in app.webviews() {
|
||||
if window_name == LOGIN_WINDOW_NAME {
|
||||
let result = check_login_window(
|
||||
&webview,
|
||||
&app_data_root,
|
||||
&sora_creds_manager,
|
||||
).await;
|
||||
if let Err(err) = result {
|
||||
error!("Error checking login window: {:?}", err);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(2_000)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn check_login_window(
|
||||
webview: &Webview,
|
||||
app_data_root: &AppDataRoot,
|
||||
sora_credential_manager: &SoraCredentialManager,
|
||||
) -> AnyhowResult<()> {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
clear_browsing_data_on_test_domain(webview)?;
|
||||
//keep_on_task(webview)?;
|
||||
extract_cookies_to_file(webview, app_data_root, sora_credential_manager).await?;
|
||||
//initialize_sora_jwt_bearer_token(app_data_root).await?; // TODO: This only runs once. We need better management.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn keep_on_task(webview: &Webview) -> AnyhowResult<()> {
|
||||
let url = webview.url()?;
|
||||
let hostname= url.host()
|
||||
.ok_or(anyhow!("no host in url"))?
|
||||
.to_string();
|
||||
match hostname.as_str() {
|
||||
"auth.openai.com" => {},
|
||||
"openai.com" => {},
|
||||
"sora.com" => {},
|
||||
// Third party SSO
|
||||
"accounts.google.com" => {},
|
||||
"accounts.youtube.com" => {},
|
||||
"login.live.com" => {},
|
||||
"appleid.apple.com" => {},
|
||||
_ => {
|
||||
info!("Non login hostname: {}", hostname);
|
||||
webview.navigate(SORA_LOGIN_URL.clone())?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// This is just so we have a way to clear browsing data.
|
||||
fn clear_browsing_data_on_test_domain(webview: &Webview) -> AnyhowResult<()> {
|
||||
let url = webview.url()?;
|
||||
let hostname= url.host()
|
||||
.ok_or(anyhow!("no host in url"))?
|
||||
.to_string();
|
||||
match hostname.as_str() {
|
||||
"storyteller.ai" => {
|
||||
info!("Clearing all browsing data...");
|
||||
webview.clear_all_browsing_data()?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// TODO(bt,2025-04-07): Heuristic to detect when logged in. Only write when logged in.
|
||||
async fn extract_cookies_to_file(
|
||||
webview: &Webview,
|
||||
app_data_root: &AppDataRoot,
|
||||
sora_credential_manager: &SoraCredentialManager,
|
||||
) -> AnyhowResult<()> {
|
||||
let new_cookies = get_all_sora_cookies_as_string(webview)?.trim().to_string();
|
||||
|
||||
if new_cookies.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let session_cookie_presence = has_session_cookie(&new_cookies)
|
||||
.unwrap_or_else(|err| {
|
||||
error!("Failed to check for session cookie: {:?}", err);
|
||||
SessionCookiePresence::MaybePresent
|
||||
});
|
||||
|
||||
info!("Session cookies are: {:?}", session_cookie_presence);
|
||||
|
||||
let (should_write_cookies, should_upgrade_session) = match session_cookie_presence {
|
||||
SessionCookiePresence::Present => (true, true),
|
||||
SessionCookiePresence::MaybePresent => (true, false),
|
||||
SessionCookiePresence::Absent => (false, false),
|
||||
};
|
||||
|
||||
if !should_write_cookies {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// TODO(bt): Race conditions ahead.
|
||||
|
||||
sora_credential_manager.clear_credentials()?;
|
||||
|
||||
let _r = fs::remove_file(app_data_root.get_sora_cookie_file_path());
|
||||
let _r = fs::remove_file(app_data_root.get_sora_bearer_token_file_path());
|
||||
let _r = fs::remove_file(app_data_root.get_sora_sentinel_file_path());
|
||||
|
||||
let mut new_credentials =
|
||||
SoraCredentialSet::initialize_with_just_cookies_str(&new_cookies);
|
||||
|
||||
if should_upgrade_session {
|
||||
let _upgraded = maybe_upgrade_or_renew_session(&mut new_credentials).await?;
|
||||
}
|
||||
|
||||
sora_credential_manager.set_credentials(&new_credentials)?;
|
||||
sora_credential_manager.persist_all_to_disk()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn read_sora_cookies_from_disk(app_data_root: &AppDataRoot) -> Option<String> {
|
||||
let cookie_file = app_data_root.get_sora_cookie_file_path();
|
||||
if !cookie_file.exists() {
|
||||
return None;
|
||||
}
|
||||
|
||||
match read_to_string(cookie_file) {
|
||||
Ok(contents) => Some(contents.trim().to_string()),
|
||||
Err(err) => {
|
||||
warn!("Failed to read cookie file: {:?}", err);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
use crate::events::sendable_event_trait::SendableEvent;
|
||||
use crate::events::sora::sora_image_generation_complete_event::SoraImageGenerationCompleteEvent;
|
||||
use crate::events::sora::sora_image_generation_failed_event::SoraImageGenerationFailedEvent;
|
||||
use crate::state::data_dir::app_data_root::AppDataRoot;
|
||||
use crate::state::data_dir::trait_data_subdir::DataSubdir;
|
||||
use crate::state::sora::sora_credential_manager::SoraCredentialManager;
|
||||
@@ -18,16 +21,6 @@ use tauri::{AppHandle, Emitter};
|
||||
use tempdir::TempDir;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct SoraImageGenerationComplete {
|
||||
pub media_file_token: MediaFileToken,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct SoraImageGenerationFailed {
|
||||
pub prompt: String,
|
||||
}
|
||||
|
||||
pub async fn sora_task_polling_thread(
|
||||
app_handle: AppHandle,
|
||||
app_data_root: AppDataRoot,
|
||||
@@ -102,9 +95,10 @@ async fn polling_loop(
|
||||
|
||||
for task in failed_tasks.iter() {
|
||||
if sora_task_queue.contains_key(&task.id)? {
|
||||
app_handle.emit("sora-image-generation-failed", SoraImageGenerationFailed {
|
||||
let event = SoraImageGenerationFailedEvent {
|
||||
prompt: task.prompt.clone(),
|
||||
})?;
|
||||
};
|
||||
event.send(&app_handle)?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,9 +127,11 @@ async fn polling_loop(
|
||||
|
||||
info!("Uploaded to API backend: {:?}", result.media_file_token);
|
||||
|
||||
app_handle.emit("sora-image-generation-complete", SoraImageGenerationComplete {
|
||||
media_file_token: result.media_file_token
|
||||
})?;
|
||||
let event = SoraImageGenerationCompleteEvent {
|
||||
media_file_token: result.media_file_token,
|
||||
};
|
||||
|
||||
event.send(&app_handle)?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,7 +142,7 @@ async fn polling_loop(
|
||||
|
||||
sora_task_queue.remove_list(&succeeded_task_ids)?;
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(2_000)).await;
|
||||
tokio::time::sleep(std::time::Duration::from_millis(6_000)).await;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
use tauri::{PhysicalSize, Window};
|
||||
use errors::AnyhowResult;
|
||||
|
||||
// A typical height difference when dev tools are open:
|
||||
// Window inner size = PhysicalSize { width: 3704, height: 732 }
|
||||
// Window outer size = PhysicalSize { width: 3704, height: 1476 }
|
||||
const HEIGHT_DIFFERENCE_THRESHOLD: u32 = 300;
|
||||
|
||||
/// Get the best window size
|
||||
pub fn best_window_size_heuristic(window: &Window) -> AnyhowResult<PhysicalSize<u32>> {
|
||||
// NB: Ordinarily we'd use inner size, as the outer size includes the window decorations.
|
||||
// However, dev tools contract the inner size, and in the event they're open, we should
|
||||
// prefer the outer size.
|
||||
let inner = window.inner_size()?;
|
||||
let outer = window.outer_size()?;
|
||||
|
||||
let height_delta = outer.height.saturating_sub(inner.height);
|
||||
if height_delta > HEIGHT_DIFFERENCE_THRESHOLD {
|
||||
// Dev tools are open, use the outer size
|
||||
Ok(outer)
|
||||
} else {
|
||||
// Dev tools are not open, use the inner size
|
||||
Ok(inner)
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
pub mod sora_webview_cookies;
|
||||
@@ -1,26 +0,0 @@
|
||||
use once_cell::sync::Lazy;
|
||||
use reqwest::Url;
|
||||
use tauri::Webview;
|
||||
use tauri::webview::Cookie;
|
||||
use errors::AnyhowResult;
|
||||
|
||||
const SORA_ROOT_COOKIE_URL_STR: &str = "https://sora.com/";
|
||||
|
||||
static SORA_ROOT_COOKIE_URL: Lazy<Url> = Lazy::new(|| {
|
||||
Url::parse(SORA_ROOT_COOKIE_URL_STR).expect("URL should parse")
|
||||
});
|
||||
|
||||
pub fn get_all_sora_cookies(webview: &Webview) -> AnyhowResult<Vec<Cookie>> {
|
||||
let cookies = webview.cookies_for_url(SORA_ROOT_COOKIE_URL.clone())?;
|
||||
Ok(cookies)
|
||||
}
|
||||
|
||||
pub fn get_all_sora_cookies_as_string(webview: &Webview) -> AnyhowResult<String> {
|
||||
let cookies = get_all_sora_cookies(webview)?;
|
||||
let cookie_string = cookies
|
||||
.iter()
|
||||
.map(|cookie| format!("{}={}", cookie.name(), cookie.value()))
|
||||
.collect::<Vec<String>>()
|
||||
.join("; ");
|
||||
Ok(cookie_string)
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
pub mod best_window_size_heuristic;
|
||||
pub mod clear_all_webview_cookies;
|
||||
pub mod cookies;
|
||||
pub mod get_url_file_extension;
|
||||
pub mod image;
|
||||
pub mod large_file_transfer;
|
||||
pub mod object_store;
|
||||
pub mod simple_http_download;
|
||||
pub mod sora;
|
||||
pub mod webview_unsafe;
|
||||
pub mod webview_unsafe;
|
||||
@@ -0,0 +1,4 @@
|
||||
|
||||
/// The name of the main application window.
|
||||
/// This is the Artcraft application UX itself.
|
||||
pub const MAIN_WINDOW_NAME : &str = "main";
|
||||
@@ -0,0 +1 @@
|
||||
pub mod constants;
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod sora_login_window;
|
||||
pub mod main_window;
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
use anyhow::anyhow;
|
||||
use log::info;
|
||||
use tauri::Webview;
|
||||
use errors::AnyhowResult;
|
||||
|
||||
// This is just so we have a way to clear browsing data while debugging / building.
|
||||
pub fn clear_browsing_data_on_test_domain(webview: &Webview) -> AnyhowResult<()> {
|
||||
let url = webview.url()?;
|
||||
let hostname= url.host()
|
||||
.ok_or(anyhow!("no host in url"))?
|
||||
.to_string();
|
||||
match hostname.as_str() {
|
||||
"storyteller.ai" => {
|
||||
info!("Clearing all browsing data...");
|
||||
webview.clear_all_browsing_data()?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
use errors::AnyhowResult;
|
||||
use once_cell::sync::Lazy;
|
||||
use reqwest::Url;
|
||||
use tauri::webview::Cookie;
|
||||
use tauri::WebviewWindow;
|
||||
|
||||
const CHAT_GPT_ROOT_COOKIE_URL_STR: &str = "https://chatgpt.com";
|
||||
|
||||
static CHAT_GPT_ROOT_COOKIE_URL: Lazy<Url> = Lazy::new(|| {
|
||||
Url::parse(CHAT_GPT_ROOT_COOKIE_URL_STR).expect("URL should parse")
|
||||
});
|
||||
|
||||
pub fn extract_sora_webview_cookies(webview: &WebviewWindow) -> AnyhowResult<String> {
|
||||
// NB(bt): Cookies were originally on sora.com, but now they're located on
|
||||
// the sora.chatgpt.com / *.chatgpt.com domains.
|
||||
let cookies = get_all_chatgpt_cookies(webview)?;
|
||||
let cookie_string = cookies
|
||||
.iter()
|
||||
.map(|cookie| format!("{}={}", cookie.name(), cookie.value()))
|
||||
.collect::<Vec<String>>()
|
||||
.join("; ");
|
||||
Ok(cookie_string)
|
||||
}
|
||||
|
||||
fn get_all_chatgpt_cookies(webview: &WebviewWindow) -> AnyhowResult<Vec<Cookie>> {
|
||||
let cookies = webview.cookies_for_url(CHAT_GPT_ROOT_COOKIE_URL.clone())?;
|
||||
Ok(cookies)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
use crate::windows::sora_login_window::open_sora_login_window::SORA_LOGIN_URL;
|
||||
use anyhow::anyhow;
|
||||
use errors::AnyhowResult;
|
||||
use log::info;
|
||||
use tauri::Webview;
|
||||
|
||||
// NB: Probably best not to use this.
|
||||
pub fn keep_on_login_domains(webview: &Webview) -> AnyhowResult<()> {
|
||||
let url = webview.url()?;
|
||||
let hostname= url.host()
|
||||
.ok_or(anyhow!("no host in url"))?
|
||||
.to_string();
|
||||
match hostname.as_str() {
|
||||
"auth.openai.com" => {},
|
||||
"openai.com" => {},
|
||||
"sora.com" => {},
|
||||
// Third party SSO
|
||||
"accounts.google.com" => {},
|
||||
"accounts.youtube.com" => {},
|
||||
"login.live.com" => {},
|
||||
"appleid.apple.com" => {},
|
||||
_ => {
|
||||
info!("Non login hostname: {}", hostname);
|
||||
webview.navigate(SORA_LOGIN_URL.clone())?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod clear_browsing_on_test_domain;
|
||||
pub mod extract_sora_webview_cookies;
|
||||
pub mod keep_on_login_domains;
|
||||
pub mod open_sora_login_window;
|
||||
pub mod sora_login_thread;
|
||||
@@ -0,0 +1,84 @@
|
||||
use crate::state::data_dir::app_data_root::AppDataRoot;
|
||||
use crate::state::sora::sora_credential_manager::SoraCredentialManager;
|
||||
use crate::utils::clear_all_webview_cookies::clear_all_webview_cookies;
|
||||
use crate::windows::sora_login_window::sora_login_thread::sora_login_thread;
|
||||
use anyhow::anyhow;
|
||||
use errors::AnyhowResult;
|
||||
use once_cell::sync::Lazy;
|
||||
use reqwest::Url;
|
||||
use std::time::Duration;
|
||||
use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder};
|
||||
|
||||
/// Name of the window
|
||||
pub const LOGIN_WINDOW_NAME: &str = "login_window";
|
||||
|
||||
pub const START_URL_STR: &str = "https://google.com";
|
||||
|
||||
pub static START_URL: Lazy<Url> = Lazy::new(|| {
|
||||
Url::parse(START_URL_STR).expect("URL should parse")
|
||||
});
|
||||
|
||||
pub const SORA_HOMEPAGE_URL_STR: &str = "https://sora.com/";
|
||||
|
||||
pub static SORA_HOMEPAGE_URL : Lazy<Url> = Lazy::new(|| {
|
||||
Url::parse(SORA_HOMEPAGE_URL_STR).expect("URL should parse")
|
||||
});
|
||||
|
||||
pub const SORA_LOGIN_URL_STR: &str = "https://chatgpt.com/auth/login?next=%2Fsora%2F";
|
||||
|
||||
pub static SORA_LOGIN_URL: Lazy<Url> = Lazy::new(|| {
|
||||
Url::parse(SORA_LOGIN_URL_STR).expect("URL should parse")
|
||||
});
|
||||
|
||||
//pub const SORA_ROOT_URL_STR: &str = "https://sora.com/";
|
||||
//
|
||||
//pub static SORA_ROOT_URL: Lazy<Url> = Lazy::new(|| {
|
||||
// Url::parse(SORA_ROOT_URL_STR).expect("URL should parse")
|
||||
//});
|
||||
|
||||
pub async fn open_sora_login_window(
|
||||
app: &AppHandle,
|
||||
app_data_root: &AppDataRoot,
|
||||
sora_creds_manager: &SoraCredentialManager,
|
||||
) -> AnyhowResult<()> {
|
||||
if app.get_window(LOGIN_WINDOW_NAME).is_some() {
|
||||
return Err(anyhow!("Login window already open"));
|
||||
}
|
||||
|
||||
let url = WebviewUrl::External(START_URL.clone());
|
||||
|
||||
let window = WebviewWindowBuilder::new(app, LOGIN_WINDOW_NAME, url)
|
||||
//.user_agent(openai_sora_client::credentials::USER_AGENT)
|
||||
.always_on_top(true)
|
||||
.title("Login to OpenAI's Sora")
|
||||
.center()
|
||||
.resizable(true)
|
||||
.visible(true)
|
||||
.closable(true)
|
||||
.min_inner_size(200.0, 800.0)
|
||||
.focused(true)
|
||||
.devtools(true)
|
||||
.build()?;
|
||||
|
||||
let webview = window.get_webview(LOGIN_WINDOW_NAME)
|
||||
.ok_or_else(|| anyhow!("no webview found"))?;
|
||||
|
||||
clear_all_webview_cookies(&webview)?;
|
||||
|
||||
webview.navigate(SORA_HOMEPAGE_URL.clone())?;
|
||||
|
||||
// NB: We're starting to get Cloudflare protection screens. Let's try to avoid.
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
webview.navigate(SORA_LOGIN_URL.clone())?;
|
||||
|
||||
let app_handle = app.clone();
|
||||
let app_data_root = app_data_root.clone();
|
||||
let sora_creds_manager = sora_creds_manager.clone();
|
||||
|
||||
let _ = tauri::async_runtime::spawn(async move {
|
||||
sora_login_thread(app_handle, app_data_root, sora_creds_manager).await;
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
use crate::events::sendable_event_trait::SendableEvent;
|
||||
use crate::events::sora::sora_login_success_event::SoraLoginSuccessEvent;
|
||||
use crate::state::data_dir::app_data_root::AppDataRoot;
|
||||
use crate::state::sora::sora_credential_holder::SoraCredentialHolder;
|
||||
use crate::state::sora::sora_credential_manager::SoraCredentialManager;
|
||||
use crate::utils::sora::initialize_sora_jwt_bearer_token::initialize_sora_jwt_bearer_token;
|
||||
use crate::windows::sora_login_window::extract_sora_webview_cookies::extract_sora_webview_cookies;
|
||||
use crate::windows::sora_login_window::open_sora_login_window::LOGIN_WINDOW_NAME;
|
||||
use anyhow::anyhow;
|
||||
use chrono::{DateTime, NaiveDateTime, TimeDelta};
|
||||
use errors::AnyhowResult;
|
||||
use log::{error, info, warn};
|
||||
use once_cell::sync::Lazy;
|
||||
use openai_sora_client::creds::sora_credential_set::SoraCredentialSet;
|
||||
use openai_sora_client::recipes::maybe_upgrade_or_renew_session::maybe_upgrade_or_renew_session;
|
||||
use openai_sora_client::utils::has_session_cookie::{has_session_cookie, SessionCookiePresence};
|
||||
use reqwest::Url;
|
||||
use std::fs;
|
||||
use std::fs::{read_to_string, OpenOptions};
|
||||
use std::io::Write;
|
||||
use std::ops::Sub;
|
||||
use tauri::{AppHandle, Manager, Webview, WebviewWindow};
|
||||
|
||||
pub async fn sora_login_thread(
|
||||
app: AppHandle,
|
||||
app_data_root: AppDataRoot,
|
||||
sora_creds_manager: SoraCredentialManager
|
||||
) {
|
||||
loop {
|
||||
let login_webview_window = match app.get_webview_window(LOGIN_WINDOW_NAME) {
|
||||
Some(webview) => webview,
|
||||
None => {
|
||||
info!("Exit sora login thread.");
|
||||
return; // NB: Only exit if we don't have the webview.
|
||||
}
|
||||
};
|
||||
|
||||
let result = check_login_window(
|
||||
&app,
|
||||
&login_webview_window,
|
||||
&app_data_root,
|
||||
&sora_creds_manager,
|
||||
).await;
|
||||
|
||||
match result {
|
||||
Err(err) => {
|
||||
error!("Error checking login window: {:?}", err);
|
||||
}
|
||||
Ok(false) => {} // Continue iteration and try again...
|
||||
Ok(true) => {
|
||||
info!("Successfully saved cookies from login window. Closing.");
|
||||
if let Err(err) = login_webview_window.close() {
|
||||
error!("Error closing login window: {:?}", err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(2_000)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if we can exit.
|
||||
async fn check_login_window(
|
||||
app_handle: &AppHandle,
|
||||
webview_window: &WebviewWindow,
|
||||
app_data_root: &AppDataRoot,
|
||||
sora_credential_manager: &SoraCredentialManager,
|
||||
) -> AnyhowResult<bool> {
|
||||
|
||||
/* Login flow looks like this:
|
||||
|
||||
1. Start: https://sora.chatgpt.com/
|
||||
2. Login Start: https://chatgpt.com/auth/login?next=%2Fsora%2F [this is where we start]
|
||||
3. Login Continue: https://auth.openai.com/log-in
|
||||
4. SSO / Google Login (etc): https://accounts.google.com/v3/signin/challenge/pwd?[query]
|
||||
5. Done / Landing: https://sora.chatgpt.com/explore
|
||||
*/
|
||||
|
||||
/*
|
||||
- Do not use credential manager until the end (we don't load old cookies!)
|
||||
- Check if we're on the correct domain, if not exit? (or inverse, that we're not in login flow)
|
||||
*/
|
||||
|
||||
let hostname = get_hostname(webview_window)?;
|
||||
|
||||
let mut maybe_at_destination = false;
|
||||
|
||||
match hostname.as_str() {
|
||||
"sora.com" | // Old destination
|
||||
"sora.chatgpt.com" // New destination
|
||||
=> {
|
||||
maybe_at_destination = true;
|
||||
}
|
||||
// chatgpt.com/auth is also an auth domain
|
||||
"auth.openai.com" |
|
||||
"accounts.google.com" |
|
||||
"accounts.youtube.com" |
|
||||
"login.live.com" |
|
||||
"appleid.apple.com"
|
||||
=> {
|
||||
// NB: We're in auth flow.
|
||||
info!("Sora login webview is in auth flow; hostname `{}`.", hostname);
|
||||
return Ok(false)
|
||||
}
|
||||
_ => {}, // We just don't know...
|
||||
}
|
||||
|
||||
let webview_cookies = extract_sora_webview_cookies(webview_window)?.trim().to_string();
|
||||
|
||||
let session_cookie_presence = has_session_cookie(&webview_cookies)
|
||||
.unwrap_or_else(|err| {
|
||||
error!("Failed to check for session cookie: {:?}", err);
|
||||
SessionCookiePresence::MaybePresent
|
||||
});
|
||||
|
||||
info!("Sora login webview is at hostname `{}`; cookie status: {:?}.", hostname, session_cookie_presence);
|
||||
|
||||
match session_cookie_presence {
|
||||
SessionCookiePresence::Absent => {
|
||||
info!("Session cookies are absent.");
|
||||
return Ok(false);
|
||||
}
|
||||
_ => {},
|
||||
}
|
||||
|
||||
let mut new_credentials =
|
||||
SoraCredentialSet::initialize_with_just_cookies_str(&webview_cookies);
|
||||
|
||||
let _upgraded = maybe_upgrade_or_renew_session(&mut new_credentials).await?;
|
||||
|
||||
// TODO(bt): Race conditions ahead.
|
||||
|
||||
sora_credential_manager.clear_credentials()?;
|
||||
sora_credential_manager.try_purge_credentials_from_disk();
|
||||
|
||||
sora_credential_manager.set_credentials(&new_credentials)?;
|
||||
sora_credential_manager.persist_all_to_disk()?;
|
||||
|
||||
// NB: Event sent to the frontend for the login flow. We shouldn't rely on just this
|
||||
// alone as it could be brittle if the events aren't caught.
|
||||
let event = SoraLoginSuccessEvent {};
|
||||
|
||||
if let Err(err) = event.send(app_handle) {
|
||||
error!("Error sending Sora login success event: {:?}", err);
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn get_hostname(webview: &WebviewWindow) -> AnyhowResult<String> {
|
||||
let url = webview.url()?;
|
||||
let url_hostname= url.host()
|
||||
.ok_or(anyhow!("no host in url"))?
|
||||
.to_string();
|
||||
Ok(url_hostname)
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
{"default":{"identifier":"default","description":"enables the default permissions","local":true,"windows":["main"],"permissions":["core:default","log:default","upload:allow-download","upload:default",{"identifier":"http:default","allow":[{"url":"https://*.storyteller.ai"},{"url":"https://*.fakeyou.com"}],"deny":[{"url":"https://private.tauri.app"}]}]}}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+11
-8
@@ -7,7 +7,7 @@ use crate::creds::sora_jwt_bearer_token::SoraJwtBearerToken;
|
||||
use crate::creds::sora_sentinel::SoraSentinel;
|
||||
use crate::requests::bearer::generate::generate_bearer_with_cookie;
|
||||
use crate::requests::image_gen::common::{ImageSize, NumImages, SoraImageGenResponse};
|
||||
use crate::requests::image_gen::image_gen_http_request;
|
||||
use crate::requests::image_gen::{image_gen_http_request, SoraImageGenError};
|
||||
use crate::requests::image_gen::sora_image_gen_remix::{sora_image_gen_remix, SoraImageGenRemixRequest};
|
||||
use crate::requests::sentinel_refresh::generate::token::generate_token;
|
||||
use crate::sora_error::SoraError;
|
||||
@@ -43,29 +43,32 @@ pub async fn image_remix_with_session_auto_renew(request: ImageRemixAutoRenewReq
|
||||
|
||||
match err {
|
||||
// We'll fail these requests...
|
||||
image_gen_http_request::SoraError::TooManyConcurrentTasks(_) => {
|
||||
SoraImageGenError::TooManyConcurrentTasks(_) => {
|
||||
return Err(SoraError::TooManyConcurrentTasks);
|
||||
}
|
||||
image_gen_http_request::SoraError::GenericError(err) => {
|
||||
SoraImageGenError::GenericError(err) => {
|
||||
return Err(SoraError::OtherBadStatus(anyhow!("image remix failed with GenericError: {:?}", err)))
|
||||
}
|
||||
image_gen_http_request::SoraError::NetworkError(err) => {
|
||||
SoraImageGenError::NetworkError(err) => {
|
||||
// TODO: The underlying type should be a reqwest::Error.
|
||||
return Err(SoraError::OtherBadStatus(anyhow!("network error: {:?}", err)))
|
||||
}
|
||||
SoraImageGenError::UsernameRequired(_err) => {
|
||||
return Err(SoraError::SoraUsernameNotYetCreated)
|
||||
}
|
||||
|
||||
// We'll retry these requests...
|
||||
|
||||
image_gen_http_request::SoraError::SentinelBlock(err) => {
|
||||
SoraImageGenError::SentinelBlock(err) => {
|
||||
warn!("Image generation failed due to sentinel block error: {:?}", err);
|
||||
refresh_sentinel = true;
|
||||
}
|
||||
image_gen_http_request::SoraError::TokenExpired(err) => {
|
||||
SoraImageGenError::TokenExpired(err) => {
|
||||
warn!("Image generation failed due to token expired error: {:?}", err);
|
||||
refresh_sentinel = true; // TODO: Not sure what this error is, actually.
|
||||
refresh_jwt = true;
|
||||
}
|
||||
image_gen_http_request::SoraError::InvalidJwt(err) => {
|
||||
SoraImageGenError::InvalidJwt(err) => {
|
||||
warn!("Image generation failed due to invalid jwt error: {:?}", err);
|
||||
refresh_jwt = true;
|
||||
}
|
||||
@@ -128,7 +131,7 @@ pub async fn image_remix_with_session_auto_renew(request: ImageRemixAutoRenewReq
|
||||
match result {
|
||||
Ok(response) => Ok((response, Some(new_creds))),
|
||||
Err(err) => match err {
|
||||
image_gen_http_request::SoraError::TooManyConcurrentTasks(err) => {
|
||||
image_gen_http_request::SoraImageGenError::TooManyConcurrentTasks(err) => {
|
||||
Err(SoraError::TooManyConcurrentTasks)
|
||||
}
|
||||
_ => {
|
||||
|
||||
+4
-5
@@ -39,11 +39,10 @@ pub async fn list_sora_task_status_with_session_auto_renew(
|
||||
new_creds = Some(updated_creds);
|
||||
}
|
||||
|
||||
let legacy_creds = new_creds.as_ref()
|
||||
.unwrap_or_else(|| &args.credentials)
|
||||
.to_legacy_credentials()?;
|
||||
let upgraded_creds = new_creds.as_ref()
|
||||
.unwrap_or_else(|| &args.credentials);
|
||||
|
||||
let result = get_image_gen_status(&request, &legacy_creds).await;
|
||||
let result = get_image_gen_status(&request, &upgraded_creds).await;
|
||||
|
||||
let err = match result {
|
||||
Ok(response) => return Ok((response, new_creds)),
|
||||
@@ -56,7 +55,7 @@ pub async fn list_sora_task_status_with_session_auto_renew(
|
||||
|
||||
info!("Retrying image upload with new credentials...");
|
||||
|
||||
let result = get_image_gen_status(&request, &legacy_creds).await?;
|
||||
let result = get_image_gen_status(&request, &upgraded_creds).await?;
|
||||
|
||||
Ok((result, Some(new_creds)))
|
||||
}
|
||||
|
||||
+2
-1
@@ -7,13 +7,14 @@ use chrono::{DateTime, TimeDelta, Utc};
|
||||
use errors::AnyhowResult;
|
||||
use log::info;
|
||||
use std::ops::Sub;
|
||||
use crate::sora_error::SoraError;
|
||||
|
||||
const EXPIRATION_DEADLINE : TimeDelta = TimeDelta::hours(12);
|
||||
|
||||
/// Call this at session startup to generate the tokens for the first time.
|
||||
/// Call this periodically to refresh the JWT as it nears its expiration date.
|
||||
/// This will not refresh the sentinel token once it's been generated - we can use other flows to accomplish that.
|
||||
pub async fn maybe_upgrade_or_renew_session(sora_credentials: &mut SoraCredentialSet) -> AnyhowResult<bool> {
|
||||
pub async fn maybe_upgrade_or_renew_session(sora_credentials: &mut SoraCredentialSet) -> Result<bool, SoraError> {
|
||||
let mut refresh_jwt = !sora_credentials.jwt_bearer_token.is_some();
|
||||
let refresh_sentinel = !sora_credentials.sora_sentinel.is_some();
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ use crate::credentials::SoraCredentials;
|
||||
use crate::requests::image_gen::image_gen_status::TaskStatus;
|
||||
use crate::requests::image_gen::image_gen_status::{get_image_gen_status, StatusRequest, TaskResponse, VideoGenStatusResponse};
|
||||
use errors::AnyhowResult;
|
||||
use crate::creds::sora_credential_set::SoraCredentialSet;
|
||||
|
||||
pub async fn wait_for_image_gen_status(task_id: &String, credentials: &SoraCredentials, retry_limit: Option<u32>) -> AnyhowResult<TaskResponse> {
|
||||
let status_request = StatusRequest {
|
||||
@@ -10,11 +11,13 @@ pub async fn wait_for_image_gen_status(task_id: &String, credentials: &SoraCrede
|
||||
// before: Some(task_id.clone()),
|
||||
};
|
||||
let retry_limit = retry_limit.unwrap_or(10);
|
||||
|
||||
let new_creds = SoraCredentialSet::from_legacy_credentials(&credentials)?;
|
||||
|
||||
for _ in 0..retry_limit {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(10)).await;
|
||||
|
||||
let status_response = get_image_gen_status(&status_request, credentials).await?;
|
||||
let status_response = get_image_gen_status(&status_request, &new_creds).await?;
|
||||
|
||||
match find_task_response_by_id(&status_response, task_id.clone()) {
|
||||
Some(task_response) => match TaskStatus::from_str(&task_response.status) {
|
||||
|
||||
File diff suppressed because one or more lines are too long
+26
-16
@@ -1,3 +1,4 @@
|
||||
use log::warn;
|
||||
use crate::creds::credential_migration::CredentialMigrationRef;
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
@@ -9,7 +10,7 @@ const SORA_IMAGE_GEN_URL: &str = "https://sora.com/backend/video_gen";
|
||||
const USER_AGENT : &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36";
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum SoraError {
|
||||
pub enum SoraImageGenError {
|
||||
#[error("Sora token expired: {0}")]
|
||||
TokenExpired(String),
|
||||
|
||||
@@ -27,6 +28,9 @@ pub enum SoraError {
|
||||
|
||||
#[error("Network error: {0}")]
|
||||
NetworkError(String),
|
||||
|
||||
#[error("Sora username required: {0}")]
|
||||
UsernameRequired(String),
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
@@ -127,6 +131,7 @@ pub enum SoraErrorCode {
|
||||
InvalidJwt,
|
||||
TokenExpired,
|
||||
TooManyConcurrentTasks,
|
||||
UsernameRequired,
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
@@ -147,7 +152,7 @@ impl std::fmt::Display for RawSoraErrorInner {
|
||||
}
|
||||
|
||||
/// Don't expose the internal request implementation as there are only a few "correct" ways to call the API.
|
||||
pub (crate) async fn image_gen_http_request(sora_request: RawSoraImageGenRequest, credentials: CredentialMigrationRef<'_>) -> Result<RawSoraResponse, SoraError> {
|
||||
pub (crate) async fn image_gen_http_request(sora_request: RawSoraImageGenRequest, credentials: CredentialMigrationRef<'_>) -> Result<RawSoraResponse, SoraImageGenError> {
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let mut cookie;
|
||||
@@ -161,18 +166,18 @@ pub (crate) async fn image_gen_http_request(sora_request: RawSoraImageGenRequest
|
||||
// TODO(bt,2025-04-23): We're using a Sora payload error in place of application state error. Surface this differently.
|
||||
sentinel = creds.sentinel.as_ref()
|
||||
.map(|sentinel| sentinel.to_string())
|
||||
.ok_or(SoraError::SentinelBlock("Sentinel is required for image generation.".to_string()))?;
|
||||
.ok_or(SoraImageGenError::SentinelBlock("Sentinel is required for image generation.".to_string()))?;
|
||||
}
|
||||
CredentialMigrationRef::New(creds) => {
|
||||
cookie = creds.cookies.to_string();
|
||||
// TODO(bt,2025-04-23): We're using a Sora payload error in place of application state error. Surface this differently.
|
||||
authorization_header = creds.jwt_bearer_token.as_ref()
|
||||
.ok_or(SoraError::InvalidJwt("JWT bearer is required for image generation".to_string()))?
|
||||
.ok_or(SoraImageGenError::InvalidJwt("JWT bearer is required for image generation".to_string()))?
|
||||
.to_authorization_header_value();
|
||||
// TODO(bt,2025-04-23): We're using a Sora payload error in place of application state error. Surface this differently.
|
||||
sentinel = creds.sora_sentinel.as_ref()
|
||||
.map(|sentinel| sentinel.get_sentinel().to_string())
|
||||
.ok_or(SoraError::SentinelBlock("Sentinel is required for image generation.".to_string()))?;
|
||||
.ok_or(SoraImageGenError::SentinelBlock("Sentinel is required for image generation.".to_string()))?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,47 +189,52 @@ pub (crate) async fn image_gen_http_request(sora_request: RawSoraImageGenRequest
|
||||
.header("OpenAI-Sentinel-Token", &sentinel);
|
||||
|
||||
let http_request = http_request.json(&sora_request).build()
|
||||
.map_err(|e| SoraError::NetworkError(e.to_string()))?;
|
||||
.map_err(|e| SoraImageGenError::NetworkError(e.to_string()))?;
|
||||
|
||||
let response = client.execute(http_request)
|
||||
.await
|
||||
.map_err(|e| SoraError::NetworkError(e.to_string()))?;
|
||||
.map_err(|e| SoraImageGenError::NetworkError(e.to_string()))?;
|
||||
|
||||
let status = response.status();
|
||||
|
||||
let response_body = &response.text().await
|
||||
.map_err(|e| SoraError::NetworkError(e.to_string()))?;
|
||||
.map_err(|e| SoraImageGenError::NetworkError(e.to_string()))?;
|
||||
|
||||
if status != reqwest::StatusCode::OK {
|
||||
warn!("Sora image generation failure. Raw response: {:?}", response_body);
|
||||
|
||||
let error_response: RawSoraErrorResponse = serde_json::from_str(response_body)
|
||||
.map_err(|e| SoraError::GenericError(format!("Failed to parse error response: {}", e)))?;
|
||||
.map_err(|e| SoraImageGenError::GenericError(format!("Failed to parse error response: {}", e)))?;
|
||||
|
||||
// Check for specific error codes
|
||||
if let Some(code) = &error_response.error.code {
|
||||
match code {
|
||||
SoraErrorCode::TokenExpired => {
|
||||
return Err(SoraError::TokenExpired(error_response.error.message));
|
||||
return Err(SoraImageGenError::TokenExpired(error_response.error.message));
|
||||
}
|
||||
SoraErrorCode::SentinelBlock => {
|
||||
return Err(SoraError::SentinelBlock(error_response.error.message));
|
||||
return Err(SoraImageGenError::SentinelBlock(error_response.error.message));
|
||||
}
|
||||
SoraErrorCode::TooManyConcurrentTasks => {
|
||||
return Err(SoraError::TooManyConcurrentTasks(error_response.error.message));
|
||||
return Err(SoraImageGenError::TooManyConcurrentTasks(error_response.error.message));
|
||||
}
|
||||
SoraErrorCode::Unknown(_) => {
|
||||
return Err(SoraError::GenericError(error_response.error.message));
|
||||
return Err(SoraImageGenError::GenericError(error_response.error.message));
|
||||
}
|
||||
SoraErrorCode::InvalidJwt => {
|
||||
return Err(SoraError::InvalidJwt(error_response.error.message));
|
||||
return Err(SoraImageGenError::InvalidJwt(error_response.error.message));
|
||||
}
|
||||
SoraErrorCode::UsernameRequired => {
|
||||
return Err(SoraImageGenError::UsernameRequired(error_response.error.message));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Err(SoraError::GenericError(error_response.error.message));
|
||||
return Err(SoraImageGenError::GenericError(error_response.error.message));
|
||||
}
|
||||
|
||||
let response = serde_json::from_str(response_body)
|
||||
.map_err(|e| SoraError::GenericError(format!("Failed to parse success response: {}", e)))?;
|
||||
.map_err(|e| SoraImageGenError::GenericError(format!("Failed to parse success response: {}", e)))?;
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
+28
-8
@@ -6,7 +6,9 @@ use reqwest::Url;
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use std::fmt::{Debug, Display};
|
||||
use std::{fs::File, io::Write, path::Path};
|
||||
use log::debug;
|
||||
use log::{debug, warn};
|
||||
use crate::creds::sora_credential_set::SoraCredentialSet;
|
||||
use crate::creds::sora_jwt_bearer_token::SoraJwtBearerToken;
|
||||
|
||||
const SORA_STATUS_URL: &str = "https://sora.com/backend/video_gen";
|
||||
|
||||
@@ -171,7 +173,7 @@ pub struct InpaintItem {
|
||||
pub r#type: String,
|
||||
pub preset_id: Option<String>,
|
||||
pub generation_id: Option<String>,
|
||||
pub upload_media_id: String,
|
||||
pub upload_media_id: Option<String>,
|
||||
pub frame_index: i32,
|
||||
pub source_start_frame: i32,
|
||||
}
|
||||
@@ -199,7 +201,16 @@ impl StatusRequest {
|
||||
}
|
||||
|
||||
/// Gets the status of image generation tasks from Sora API
|
||||
pub async fn get_image_gen_status(status_request: &StatusRequest, credentials: &SoraCredentials) -> Result<VideoGenStatusResponse, SoraError> {
|
||||
pub async fn get_image_gen_status(status_request: &StatusRequest, credentials: &SoraCredentialSet) -> Result<VideoGenStatusResponse, SoraError> {
|
||||
|
||||
let bearer_header = match credentials.jwt_bearer_token.as_ref() {
|
||||
Some(bearer) => bearer.to_authorization_header_value(),
|
||||
None => {
|
||||
warn!("No JWT bearer for getting image gen status!");
|
||||
return Err(SoraError::NoBearerTokenAvailable)
|
||||
},
|
||||
};
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let mut url = reqwest::Url::parse(SORA_STATUS_URL)
|
||||
@@ -216,8 +227,8 @@ pub async fn get_image_gen_status(status_request: &StatusRequest, credentials: &
|
||||
|
||||
let http_request = client.get(url)
|
||||
.header("User-Agent", USER_AGENT)
|
||||
.header("Cookie", &credentials.cookie)
|
||||
.header("Authorization", credentials.authorization_header_value())
|
||||
.header("Cookie", credentials.cookies.as_str())
|
||||
.header("Authorization", bearer_header)
|
||||
.header("Content-Type", "application/json");
|
||||
|
||||
let http_request = http_request.build()?;
|
||||
@@ -235,9 +246,15 @@ pub async fn get_image_gen_status(status_request: &StatusRequest, credentials: &
|
||||
return Err(SoraError::AnyhowError(anyhow::anyhow!("The status request failed; status = {:?}, message = {:?}", status, response_body)));
|
||||
}
|
||||
|
||||
let response: VideoGenStatusResponse = serde_json::from_str(response_body)?;
|
||||
let maybe_response = serde_json::from_str::<VideoGenStatusResponse>(response_body);
|
||||
|
||||
Ok(response)
|
||||
match maybe_response {
|
||||
Ok(response) => Ok(response),
|
||||
Err(err) => {
|
||||
warn!("Failed to parse status response: {:?}", err);
|
||||
Err(SoraError::JsonErrorWithBody(err, response_body.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn save_generations_to_dir(generations: &[Generation], dir: &str) -> AnyhowResult<()> {
|
||||
@@ -275,6 +292,7 @@ mod tests {
|
||||
use errors::AnyhowResult;
|
||||
use std::fs::read_to_string;
|
||||
use testing::test_file_path::test_file_path;
|
||||
use crate::creds::sora_credential_set::SoraCredentialSet;
|
||||
|
||||
#[ignore]
|
||||
#[tokio::test]
|
||||
@@ -297,10 +315,12 @@ mod tests {
|
||||
let bearer = bearer.trim().to_string();
|
||||
|
||||
let creds = SoraCredentials { bearer_token: bearer, cookie, sentinel: Some(sentinel) };
|
||||
let new_creds = SoraCredentialSet::from_legacy_credentials(&creds)?;
|
||||
|
||||
// Get the task status for a specific task
|
||||
// let response = get_image_gen_status(&StatusRequest::new(None, Some("task_01jr9yvpfyetx9r7qvvx38scna".to_string())), &creds).await?;
|
||||
|
||||
let response = get_image_gen_status(&StatusRequest::new(Some(50), None), &creds).await?;
|
||||
let response = get_image_gen_status(&StatusRequest::new(Some(50), None), &new_creds).await?;
|
||||
println!("task_id: {}", response.task_responses[0].id);
|
||||
|
||||
let task_id = response.task_responses[0].id.clone();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
pub (crate) mod image_gen_http_request;
|
||||
pub use image_gen_http_request::SoraError;
|
||||
pub use image_gen_http_request::SoraImageGenError;
|
||||
pub mod common;
|
||||
pub mod sora_image_gen_remix;
|
||||
pub mod sora_image_gen_simple;
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
use log::info;
|
||||
use crate::creds::credential_migration::CredentialMigrationRef;
|
||||
use crate::requests::image_gen::common::{ImageSize, NumImages, SoraImageGenResponse};
|
||||
use crate::requests::image_gen::image_gen_http_request::{image_gen_http_request, InpaintItem, InpaintItemType, OperationType, RawSoraImageGenRequest, SoraError, VideoGenType};
|
||||
use crate::requests::image_gen::image_gen_http_request::{image_gen_http_request, InpaintItem, InpaintItemType, OperationType, RawSoraImageGenRequest, SoraImageGenError, VideoGenType};
|
||||
|
||||
pub struct SoraImageGenRemixRequest<'a> {
|
||||
pub prompt: String,
|
||||
@@ -13,7 +13,7 @@ pub struct SoraImageGenRemixRequest<'a> {
|
||||
|
||||
/// The "remix" commands let you supply additional images as context.
|
||||
/// Sora "media tokens" of previously uploaded images must be supplied.
|
||||
pub async fn sora_image_gen_remix(request: SoraImageGenRemixRequest<'_>) -> Result<SoraImageGenResponse, SoraError> {
|
||||
pub async fn sora_image_gen_remix(request: SoraImageGenRemixRequest<'_>) -> Result<SoraImageGenResponse, SoraImageGenError> {
|
||||
let args = RawSoraImageGenRequest {
|
||||
r#type: VideoGenType::ImageGen,
|
||||
operation: OperationType::Remix,
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
pub mod list_media;
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod bearer;
|
||||
pub mod image_gen;
|
||||
pub mod job;
|
||||
pub mod list_media;
|
||||
pub mod sentinel_refresh;
|
||||
pub mod upload;
|
||||
@@ -26,10 +26,27 @@ pub enum SoraError {
|
||||
/// }
|
||||
UnauthorizedCookieOrBearerExpired,
|
||||
|
||||
/// Failed to generate a bearer token despite the `200 OK` response.
|
||||
/// This has been generated by sending old/expired session cookies or empty cookies.
|
||||
FailedToGenerateBearer,
|
||||
|
||||
/// Sora requires users to set up an account username before they can generate images.
|
||||
/// This requires the user to set up that username.
|
||||
SoraUsernameNotYetCreated,
|
||||
|
||||
/// Something is wrong with the JWT bearer token.
|
||||
/// This error originates on our end as we try to parse the JWT.
|
||||
LocalJwtClaimsParseError(String),
|
||||
|
||||
/// Sora is having issues.
|
||||
/// Typically served from Cloudflare
|
||||
/// We preserve the message for debugging.
|
||||
BadGateway(String),
|
||||
|
||||
/// HTTP 524 (Cloudflare-specific)
|
||||
/// Cloudflare formed a TCP connection to Sora, but no payload was delivered before timeout
|
||||
/// We preserve the message for debugging
|
||||
CloudFlareTimeout(String),
|
||||
|
||||
/// Another error occurred.
|
||||
OtherBadStatus(anyhow::Error),
|
||||
@@ -45,6 +62,10 @@ pub enum SoraError {
|
||||
|
||||
/// serde_json::Error, likely from JSON deserialization schema mismatch.
|
||||
JsonError(serde_json::Error),
|
||||
|
||||
/// serde_json::Error, likely from JSON deserialization schema mismatch.
|
||||
/// Includes the original body.
|
||||
JsonErrorWithBody(serde_json::Error, String),
|
||||
}
|
||||
|
||||
impl Error for SoraError {}
|
||||
@@ -55,6 +76,15 @@ impl Display for SoraError {
|
||||
Self::NoBearerTokenAvailable => {
|
||||
write!(f, "Unauthorized: no bearer token set")
|
||||
}
|
||||
Self::FailedToGenerateBearer => {
|
||||
write!(f, "Failed to generate bearer. Probably invalid session.")
|
||||
}
|
||||
Self::SoraUsernameNotYetCreated => {
|
||||
write!(f, "Sora username not created. Please set up a username.")
|
||||
}
|
||||
Self::LocalJwtClaimsParseError(message) => {
|
||||
write!(f, "Local JWT claims parse error: {}", message)
|
||||
}
|
||||
Self::TooManyConcurrentTasks => {
|
||||
write!(f, "Too many concurrent tasks. Please wait.")
|
||||
}
|
||||
@@ -64,6 +94,9 @@ impl Display for SoraError {
|
||||
Self::BadGateway(message) => {
|
||||
write!(f, "Bad Gateway; Sora is likely having issues: {:?}", message)
|
||||
}
|
||||
Self::CloudFlareTimeout(message) => {
|
||||
write!(f, "Cloudflare Timeout (524); Sora is likely having issues: {:?}", message)
|
||||
}
|
||||
Self::OtherBadStatus(err) => {
|
||||
write!(f, "Other error: {}", err)
|
||||
}
|
||||
@@ -79,6 +112,9 @@ impl Display for SoraError {
|
||||
Self::JsonError(err) => {
|
||||
write!(f, "serde_json error: {}", err)
|
||||
}
|
||||
Self::JsonErrorWithBody(err, message) => {
|
||||
write!(f, "serde_json error: {:?} body: {:?}", err, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,33 @@ pub async fn classify_general_http_error(response: reqwest::Response) -> SoraErr
|
||||
Ok(text) => text,
|
||||
Err(err) => return SoraError::ReqwestError(err),
|
||||
};
|
||||
|
||||
/*
|
||||
|
||||
Old cookie -
|
||||
|
||||
Message: {
|
||||
"error": {
|
||||
"message": "Your authentication token has expired. Please try signing in again.",
|
||||
"type": "invalid_request_error",
|
||||
"param": null,
|
||||
"code": "token_expired"
|
||||
}
|
||||
}
|
||||
|
||||
No cookie -
|
||||
|
||||
Message: {
|
||||
"error": {
|
||||
"message": "Your authentication token has expired. Please try signing in again.",
|
||||
"type": "invalid_request_error",
|
||||
"param": null,
|
||||
"code": "token_expired"
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
// TODO: I *think* this is just the bearer token, not the cookie.
|
||||
let cookie_expired =
|
||||
message.contains("Your authentication token has expired. Please try signing in again.")
|
||||
|| message.contains("token_expired");
|
||||
@@ -17,8 +43,18 @@ pub async fn classify_general_http_error(response: reqwest::Response) -> SoraErr
|
||||
return SoraError::UnauthorizedCookieOrBearerExpired
|
||||
}
|
||||
|
||||
if status.as_u16() == 502 {
|
||||
return SoraError::BadGateway(message);
|
||||
let status_code = status.as_u16();
|
||||
|
||||
match status_code {
|
||||
502 => {
|
||||
return SoraError::BadGateway(message);
|
||||
}
|
||||
524 => {
|
||||
if message.contains("cloudflare") || message.contains("Cloudflare") {
|
||||
return SoraError::CloudFlareTimeout(message);
|
||||
}
|
||||
}
|
||||
_ => {}, // Fall-through
|
||||
}
|
||||
|
||||
SoraError::OtherBadStatus(anyhow::anyhow!("Upload failed with status {}: {}", status, message))
|
||||
|
||||
@@ -4,7 +4,8 @@ use cookie::Cookie;
|
||||
use once_cell::sync::Lazy;
|
||||
use errors::AnyhowResult;
|
||||
|
||||
pub const SESSION_COOKIE_NAME : &str = "__Secure-authjs.session-token";
|
||||
pub const SESSION_COOKIE_NAME_OLD : &str = "__Secure-authjs.session-token";
|
||||
pub const SESSION_COOKIE_NAME_NEW : &str = "__Secure-next-auth.session-token";
|
||||
|
||||
/// Cookies Sora.com mints that are not the session cookie. We can ignore these.
|
||||
/// This list is very likely to change over time.
|
||||
@@ -51,7 +52,9 @@ pub fn has_session_cookie(cookies_header: &str) -> AnyhowResult<SessionCookiePre
|
||||
for cookie in cookies {
|
||||
let cookie= cookie?;
|
||||
let cookie_name = cookie.name();
|
||||
if cookie_name == SESSION_COOKIE_NAME {
|
||||
if cookie_name == SESSION_COOKIE_NAME_NEW {
|
||||
return Ok(SessionCookiePresence::Present);
|
||||
} else if cookie_name == SESSION_COOKIE_NAME_NEW {
|
||||
return Ok(SessionCookiePresence::Present);
|
||||
} else if !IRRELEVANT_COOKIE_NAMES.contains(cookie_name) {
|
||||
unknown_cookie_count += 1;
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
//! Adapted from 'jwt' crate
|
||||
|
||||
use crate::sora_error::SoraError;
|
||||
use anyhow::anyhow;
|
||||
use base64::Engine;
|
||||
use base64::prelude::BASE64_URL_SAFE_NO_PAD;
|
||||
use base64::Engine;
|
||||
use chrono::{DateTime, Utc};
|
||||
use errors::AnyhowResult;
|
||||
use log::warn;
|
||||
use serde_json::Value;
|
||||
use errors::AnyhowResult;
|
||||
|
||||
const SEPARATOR: &str = ".";
|
||||
|
||||
@@ -19,48 +20,53 @@ pub struct SoraJwtClaims {
|
||||
pub email_verified: bool,
|
||||
}
|
||||
|
||||
pub fn lightweight_sora_jwt_parse(token: &str) -> AnyhowResult<SoraJwtClaims> {
|
||||
pub fn lightweight_sora_jwt_parse(token: &str) -> Result<SoraJwtClaims, SoraError> {
|
||||
let [_header_str, claims_str, _signature_str] = split_components(token)?;
|
||||
|
||||
let claims = decode_base64(claims_str)?;
|
||||
let claims : serde_json::Map<String, Value> = serde_json::from_str(&claims)?;
|
||||
let claims = decode_base64(claims_str)
|
||||
.map_err(|err| SoraError::LocalJwtClaimsParseError(
|
||||
format!("failure to parse claims base64: {:?}", err)))?;
|
||||
|
||||
let claims : serde_json::Map<String, Value> = serde_json::from_str(&claims)
|
||||
.map_err(|err| SoraError::LocalJwtClaimsParseError(
|
||||
format!("failure to parse claims json: {:?}", err)))?;
|
||||
|
||||
let iat = claims.get("iat")
|
||||
.map(|val| val.as_number())
|
||||
.flatten()
|
||||
.ok_or(anyhow!("no iat claim"))?;
|
||||
.ok_or_else(|| jwt_error("no iat claim"))?;
|
||||
|
||||
let iat = iat.as_i64()
|
||||
.ok_or(anyhow!("iat is not a number"))?;
|
||||
.ok_or_else(|| jwt_error("iat is not a number"))?;
|
||||
|
||||
let iat = DateTime::from_timestamp(iat, 0)
|
||||
.ok_or(anyhow!("iat is not a valid timestamp"))?;
|
||||
.ok_or_else(|| jwt_error("iat is not a valid timestamp"))?;
|
||||
|
||||
let exp = claims.get("exp")
|
||||
.map(|val| val.as_number())
|
||||
.flatten()
|
||||
.ok_or(anyhow!("no exp claim"))?;
|
||||
.ok_or_else(|| jwt_error("no exp claim"))?;
|
||||
|
||||
let exp = exp.as_i64()
|
||||
.ok_or(anyhow!("exp is not a number"))?;
|
||||
.ok_or_else(|| jwt_error("exp is not a number"))?;
|
||||
|
||||
let exp = DateTime::from_timestamp(exp, 0)
|
||||
.ok_or(anyhow!("iat is not a valid timestamp"))?;
|
||||
.ok_or_else(|| jwt_error("iat is not a valid timestamp"))?;
|
||||
|
||||
let user_id = claims.get("https://api.openai.com/auth")
|
||||
.map(|val| val.get("user_id"))
|
||||
.flatten()
|
||||
.map(|val| val.as_str())
|
||||
.flatten()
|
||||
.ok_or(anyhow!("no user_id claim"))?;
|
||||
.ok_or_else(|| jwt_error("no user_id claim"))?;
|
||||
|
||||
let profile = claims.get("https://api.openai.com/profile")
|
||||
.ok_or(anyhow!("no user_id claim"))?;
|
||||
.ok_or_else(|| jwt_error("no user_id claim"))?;
|
||||
|
||||
let email = profile.get("email")
|
||||
.map(|val| val.as_str())
|
||||
.flatten()
|
||||
.ok_or(anyhow!("no email claim"))?;
|
||||
.ok_or_else(|| jwt_error("no email claim"))?;
|
||||
|
||||
let email_verified = profile.get("email_verified")
|
||||
.map(|val| val.as_bool())
|
||||
@@ -79,14 +85,19 @@ pub fn lightweight_sora_jwt_parse(token: &str) -> AnyhowResult<SoraJwtClaims> {
|
||||
})
|
||||
}
|
||||
|
||||
fn split_components(token: &str) -> AnyhowResult<[&str; 3]> {
|
||||
fn split_components(token: &str) -> Result<[&str; 3], SoraError> {
|
||||
let mut components = token.split(SEPARATOR);
|
||||
let header = components.next().ok_or(anyhow!("no header component"))?;
|
||||
let claims = components.next().ok_or(anyhow!("no claims component"))?;
|
||||
let signature = components.next().ok_or(anyhow!("no signature component"))?;
|
||||
let header = components.next()
|
||||
.ok_or_else(|| jwt_error("no header component"))?;
|
||||
|
||||
let claims = components.next()
|
||||
.ok_or_else(|| jwt_error("no claims component"))?;
|
||||
|
||||
let signature = components.next()
|
||||
.ok_or_else(|| jwt_error("no signature component"))?;
|
||||
|
||||
if components.next().is_some() {
|
||||
return Err(anyhow!("too many components"));
|
||||
return Err(jwt_error("too many components"));
|
||||
}
|
||||
|
||||
Ok([header, claims, signature])
|
||||
@@ -98,6 +109,10 @@ fn decode_base64(raw: &str) -> AnyhowResult<String> {
|
||||
Ok(json_str)
|
||||
}
|
||||
|
||||
fn jwt_error(reason: &str) -> SoraError {
|
||||
SoraError::LocalJwtClaimsParseError(reason.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::utils::lightweight_sora_jwt_parse::lightweight_sora_jwt_parse;
|
||||
|
||||
+5
-5
@@ -15,7 +15,7 @@ use log::warn;
|
||||
use log::{debug, error};
|
||||
use mysql_queries::queries::media_files::get::get_media_file::get_media_file;
|
||||
use openai_sora_client::credentials::SoraCredentials;
|
||||
use openai_sora_client::requests::image_gen::SoraError;
|
||||
use openai_sora_client::requests::image_gen::SoraImageGenError;
|
||||
use openai_sora_client::requests::sentinel_refresh::generate::token::generate_token;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
@@ -326,7 +326,7 @@ pub async fn enqueue_studio_image_generation_handler(http_request: HttpRequest,
|
||||
|
||||
debug!("Sora image gen response: {:?}", response);
|
||||
|
||||
if let Err(SoraError::SentinelBlock(msg)) = &response {
|
||||
if let Err(SoraImageGenError::SentinelBlock(msg)) = &response {
|
||||
error!("Sora sentinel block, attempting refresh: {}", msg);
|
||||
|
||||
match generate_token().await {
|
||||
@@ -382,16 +382,16 @@ pub async fn enqueue_studio_image_generation_handler(http_request: HttpRequest,
|
||||
|
||||
let response = response.map_err(|err| {
|
||||
match err {
|
||||
SoraError::TokenExpired(msg) => {
|
||||
SoraImageGenError::TokenExpired(msg) => {
|
||||
error!("Sora token expired, needs refresh: {}", msg);
|
||||
// TODO: Implement token refresh logic here
|
||||
EnqueueImageGenRequestError::ServerError
|
||||
},
|
||||
SoraError::SentinelBlock(msg) => {
|
||||
SoraImageGenError::SentinelBlock(msg) => {
|
||||
error!("Sora sentinel block still occurring after refresh: {}", msg);
|
||||
EnqueueImageGenRequestError::ServerError
|
||||
},
|
||||
SoraError::TooManyConcurrentTasks(msg) => {
|
||||
SoraImageGenError::TooManyConcurrentTasks(msg) => {
|
||||
error!("Sora too many concurrent tasks: {}", msg);
|
||||
EnqueueImageGenRequestError::TooManyConcurrentTasks
|
||||
},
|
||||
|
||||
@@ -320,7 +320,7 @@ export const PromptBox = () => {
|
||||
if (!prompt.trim()) return;
|
||||
try {
|
||||
console.log(
|
||||
"Enqueuing with prompt:",
|
||||
"(3) Enqueuing with prompt:",
|
||||
prompt,
|
||||
"and reference images:",
|
||||
referenceImages,
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+64
-62
@@ -1,12 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
TransitionDialogue,
|
||||
LoadingDots,
|
||||
Label,
|
||||
CloseButton,
|
||||
} from "~/components";
|
||||
|
||||
import { LoadingDots } from "~/components";
|
||||
import { Modal } from "@storyteller/ui-modal";
|
||||
import { Label } from "@storyteller/ui-label";
|
||||
import { UploadAssetError } from "../UploadModal/UploadAssetError";
|
||||
import { UploadSuccess } from "../UploadModal/UploadSuccess";
|
||||
import { UploadFiles3D } from "./UploadFiles3D";
|
||||
@@ -19,18 +14,21 @@ import {
|
||||
CHARACTER_MIXAMO_FILE_TYPE,
|
||||
IMAGEPLANE_FILE_TYPE,
|
||||
} from "~/enums";
|
||||
import { assetModalVisible } from "~/pages/PageEnigma/signals";
|
||||
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
onSuccess: (category: FilterEngineCategories) => void;
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
titleIcon: IconDefinition;
|
||||
options?: {
|
||||
fileSubtypes?: { [key: string]: string }[];
|
||||
hasLength?: boolean;
|
||||
hasThumbnailUpload?: boolean;
|
||||
};
|
||||
preselectedCategory?: FilterEngineCategories;
|
||||
isSelectVisible: boolean;
|
||||
}
|
||||
|
||||
// Map categories to their allowed file types
|
||||
@@ -43,16 +41,6 @@ const categoryFileTypes: Partial<Record<FilterEngineCategories, string[]>> = {
|
||||
[FilterEngineCategories.LOCATION]: Object.values(OBJECT_FILE_TYPE),
|
||||
[FilterEngineCategories.CREATURE]: Object.values(OBJECT_FILE_TYPE),
|
||||
};
|
||||
|
||||
// Map engine categories to asset modal tabs
|
||||
const categoryToAssetTab: Partial<Record<FilterEngineCategories, string>> = {
|
||||
[FilterEngineCategories.OBJECT]: "objects",
|
||||
[FilterEngineCategories.CHARACTER]: "character",
|
||||
[FilterEngineCategories.IMAGE_PLANE]: "image-planes",
|
||||
[FilterEngineCategories.LOCATION]: "sets",
|
||||
[FilterEngineCategories.CREATURE]: "creatures",
|
||||
};
|
||||
|
||||
// Excluded category options for the upload modal
|
||||
const categoryOptions = Object.entries(FilterEngineCategories)
|
||||
.filter(
|
||||
@@ -82,12 +70,19 @@ export function UploadModal3D({
|
||||
onClose,
|
||||
onSuccess,
|
||||
title,
|
||||
titleIcon,
|
||||
options,
|
||||
}: Props) {
|
||||
preselectedCategory,
|
||||
isSelectVisible,
|
||||
}: Props & { preselectedCategory?: FilterEngineCategories }) {
|
||||
const [uploaderState, setUploaderState] =
|
||||
useState<UploaderState>(initialUploaderState);
|
||||
const [selectedCategory, setSelectedCategory] =
|
||||
useState<FilterEngineCategories>(FilterEngineCategories.OBJECT);
|
||||
useState<FilterEngineCategories>(
|
||||
preselectedCategory !== undefined
|
||||
? preselectedCategory
|
||||
: FilterEngineCategories.OBJECT,
|
||||
);
|
||||
|
||||
const updateUploaderState = (newState: UploaderState) => {
|
||||
setUploaderState(newState);
|
||||
@@ -103,30 +98,51 @@ export function UploadModal3D({
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (preselectedCategory !== undefined) {
|
||||
setSelectedCategory(preselectedCategory);
|
||||
}
|
||||
}, [preselectedCategory]);
|
||||
|
||||
useEffect(() => {
|
||||
console.log("Preselected category:", preselectedCategory);
|
||||
console.log("Initial selected category:", selectedCategory);
|
||||
}, [preselectedCategory, selectedCategory]);
|
||||
|
||||
useEffect(() => {
|
||||
if (uploaderState.status === UploaderStates.success) {
|
||||
onSuccess(selectedCategory);
|
||||
onClose();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [uploaderState.status]);
|
||||
|
||||
const UploaderModalContent = () => {
|
||||
switch (uploaderState.status) {
|
||||
case UploaderStates.ready:
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex w-full flex-col">
|
||||
<Label>Category</Label>
|
||||
<Select
|
||||
id="category-select"
|
||||
options={categoryOptions}
|
||||
value={selectedCategory}
|
||||
onChange={(value) => {
|
||||
if (
|
||||
typeof value === "string" &&
|
||||
Object.values(FilterEngineCategories).includes(
|
||||
value as FilterEngineCategories,
|
||||
)
|
||||
) {
|
||||
setSelectedCategory(value as FilterEngineCategories);
|
||||
}
|
||||
}}
|
||||
placeholder="Select a category"
|
||||
/>
|
||||
</div>
|
||||
{isSelectVisible && (
|
||||
<div className="flex w-full flex-col">
|
||||
<Label className="mb-1">Category</Label>
|
||||
<Select
|
||||
id="category-select"
|
||||
options={categoryOptions}
|
||||
value={selectedCategory}
|
||||
onChange={(value) => {
|
||||
if (
|
||||
typeof value === "string" &&
|
||||
Object.values(FilterEngineCategories).includes(
|
||||
value as FilterEngineCategories,
|
||||
)
|
||||
) {
|
||||
setSelectedCategory(value as FilterEngineCategories);
|
||||
}
|
||||
}}
|
||||
placeholder="Select a category"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<UploadFiles3D
|
||||
title={title}
|
||||
engineCategory={selectedCategory}
|
||||
@@ -146,31 +162,17 @@ export function UploadModal3D({
|
||||
<div className="w-100 text-center opacity-50">Uploading...</div>
|
||||
</>
|
||||
);
|
||||
case UploaderStates.success:
|
||||
case UploaderStates.success: {
|
||||
return (
|
||||
<UploadSuccess
|
||||
title="3D model"
|
||||
onOk={() => {
|
||||
onSuccess();
|
||||
onClose();
|
||||
// Store the selected category and tab in sessionStorage
|
||||
const assetTab = categoryToAssetTab[selectedCategory];
|
||||
if (assetTab) {
|
||||
sessionStorage.setItem(
|
||||
"lastUploadedCategory",
|
||||
selectedCategory,
|
||||
);
|
||||
sessionStorage.setItem("lastUploadedTab", assetTab);
|
||||
// Reopen the asset modal
|
||||
assetModalVisible.value = true;
|
||||
} else {
|
||||
// Clear any existing stored values if we don't have a valid tab
|
||||
sessionStorage.removeItem("lastUploadedCategory");
|
||||
sessionStorage.removeItem("lastUploadedTab");
|
||||
}
|
||||
onSuccess(selectedCategory);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case UploaderStates.assetError:
|
||||
return (
|
||||
<UploadAssetError
|
||||
@@ -199,15 +201,15 @@ export function UploadModal3D({
|
||||
};
|
||||
|
||||
return (
|
||||
<TransitionDialogue
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
titleIcon={titleIcon}
|
||||
title={title}
|
||||
className="max-w-xl"
|
||||
showClose={false}
|
||||
showClose={true}
|
||||
>
|
||||
<CloseButton className="absolute right-4 top-4" onClick={onClose} />
|
||||
<UploaderModalContent />
|
||||
</TransitionDialogue>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useSignals } from "@preact/signals-react/runtime";
|
||||
|
||||
import { AUTH_STATUS } from "~/enums";
|
||||
import { authentication } from "~/signals";
|
||||
|
||||
import { ButtonLink } from "~/components";
|
||||
import ProfileDropdown from "./ProfileDropdown";
|
||||
|
||||
|
||||
@@ -17,11 +17,14 @@ import { downloadFileFromUrl } from "@storyteller/api";
|
||||
import { TabSelector, TabItem } from "@storyteller/ui-tab-selector";
|
||||
import { Signal } from "@preact/signals-react";
|
||||
import { useSignals } from "@preact/signals-react/runtime";
|
||||
import { setLogoutStates } from "~/signals/authentication/utilities";
|
||||
|
||||
function isEditorPath(path: string) {
|
||||
if (path === "/") return true;
|
||||
if (path === "/idealenigma/") return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
pageName: string;
|
||||
appTabIdSignal: Signal<string>;
|
||||
@@ -29,8 +32,8 @@ interface Props {
|
||||
}
|
||||
|
||||
const appTabs: TabItem[] = [
|
||||
{ id: "2D", label: "2D" },
|
||||
{ id: "3D", label: "3D" },
|
||||
{ id: "2D", label: "2D" },
|
||||
];
|
||||
|
||||
export const TopBar = ({ pageName, appTabIdSignal, setAppTabId }: Props) => {
|
||||
@@ -65,23 +68,13 @@ export const TopBar = ({ pageName, appTabIdSignal, setAppTabId }: Props) => {
|
||||
Back to Editor
|
||||
</ButtonLink>
|
||||
)}
|
||||
<TabSelector
|
||||
{/* <TabSelector
|
||||
tabs={appTabs}
|
||||
activeTab={appTabIdSignal.value}
|
||||
disabled={false}
|
||||
onTabChange={(tabId: string) => setAppTabId(tabId)}
|
||||
className="w-fit"
|
||||
/>
|
||||
{/* <Button
|
||||
variant="secondary"
|
||||
icon={faChevronRight}
|
||||
iconClassName="text-xs"
|
||||
iconFlip={true}
|
||||
onClick={handleClick}
|
||||
className="bg-transparent p-0 text-sm text-white/80 hover:bg-transparent hover:text-white hover:underline hover:underline-offset-2"
|
||||
>
|
||||
Go to 2D Canvas
|
||||
</Button> */}
|
||||
/> */}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center gap-2 font-medium">
|
||||
@@ -118,6 +111,7 @@ export const TopBar = ({ pageName, appTabIdSignal, setAppTabId }: Props) => {
|
||||
<SettingsModal
|
||||
isOpen={isSettingsModalOpen}
|
||||
onClose={() => setIsSettingsModalOpen(false)}
|
||||
globalAccountLogoutCallback={() => setLogoutStates()}
|
||||
/>
|
||||
|
||||
<GalleryModal
|
||||
|
||||
@@ -66,8 +66,8 @@ export class Configs {
|
||||
break;
|
||||
}
|
||||
|
||||
console.debug(`Build environment type: ${environment}`);
|
||||
console.debug(`Deploy context: ${this.deployContext}`);
|
||||
console.debug(`Upload API: ${this.uploadApiVideo}`);
|
||||
//console.debug(`Build environment type: ${environment}`);
|
||||
//console.debug(`Deploy context: ${this.deployContext}`);
|
||||
//console.debug(`Upload API: ${this.uploadApiVideo}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
|
||||
// This is used to do things such as disable job polling in the dev console.
|
||||
|
||||
export const SetDevFlag = (flagName: string, value: any) => {
|
||||
if (!!!(window as any)._dev_flags) {
|
||||
(window as any)._dev_flags = {};
|
||||
}
|
||||
(window as any)._dev_flags[flagName] = value;
|
||||
}
|
||||
|
||||
export const GetDevFlag = (flagName: string) => {
|
||||
if (!!(window as any)._dev_flags && flagName in (window as any)._dev_flags) {
|
||||
return (window as any)._dev_flags[flagName];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const DisableJobPolling = () => {
|
||||
SetDevFlag("disable_job_polling", true);
|
||||
}
|
||||
|
||||
export const EnableJobPolling = () => {
|
||||
SetDevFlag("disable_job_polling", false);
|
||||
}
|
||||
|
||||
export const IsJobPollingDisabled = () : boolean => {
|
||||
return GetDevFlag("disable_job_polling") === true;
|
||||
}
|
||||
|
||||
(window as any).setDevFlag = SetDevFlag;
|
||||
(window as any).getDevFlag = GetDevFlag;
|
||||
|
||||
(window as any).disableJobPolling = DisableJobPolling;
|
||||
(window as any).enableJobPolling = EnableJobPolling;
|
||||
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { ToastTypes } from "~/enums";
|
||||
import { addToast, setJobs } from "~/signals";
|
||||
import { JobsApi } from "~/Classes/ApiManager/JobsApi";
|
||||
import { IsJobPollingDisabled } from "~/flags/DevConsoleFlags";
|
||||
|
||||
export async function PollRecentJobs() {
|
||||
const jobsApi = new JobsApi();
|
||||
|
||||
if (IsJobPollingDisabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await jobsApi.ListRecentJobs();
|
||||
if (response.success && response.data) {
|
||||
setJobs(response.data);
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { MouseEventHandler, useState, useEffect } from "react";
|
||||
import { Transition } from "@headlessui/react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { uiAccess } from "./signals/uiAccess";
|
||||
import { dispatchUiEvents } from "./signals/uiEvents";
|
||||
|
||||
import { ToolbarNode } from "./components/features/ToolbarNode";
|
||||
import { ToolbarNodeButtonNames } from "./components/features/ToolbarNode/enums";
|
||||
import { transitionTimingStyles } from "./components/styles";
|
||||
import { useSignals } from "@preact/signals-react/runtime";
|
||||
|
||||
export const ContextualToolbarNode = () => {
|
||||
const [winSize, setWinSize] = useState<{ width: number; height: number }>({
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
});
|
||||
const { isShowing, position, ...rest } = uiAccess.toolbarNode.signal.value;
|
||||
const buttonsProps = Object.values(ToolbarNodeButtonNames).reduce(
|
||||
(acc, buttonName) => {
|
||||
acc[buttonName] = {
|
||||
onClick: dispatchUiEvents.toolbarNode[buttonName],
|
||||
disabled:
|
||||
uiAccess.toolbarNode.signal.value.buttonStates[buttonName].disabled,
|
||||
hidden:
|
||||
uiAccess.toolbarNode.signal.value.buttonStates[buttonName].hidden,
|
||||
active:
|
||||
uiAccess.toolbarNode.signal.value.buttonStates[buttonName].active,
|
||||
};
|
||||
return acc;
|
||||
},
|
||||
{} as {
|
||||
[key in ToolbarNodeButtonNames]: {
|
||||
onClick: MouseEventHandler<HTMLButtonElement>;
|
||||
disabled: boolean;
|
||||
hidden: boolean;
|
||||
active: boolean;
|
||||
};
|
||||
},
|
||||
);
|
||||
useEffect(() => {
|
||||
function resizeHandler() {
|
||||
setWinSize({
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
});
|
||||
}
|
||||
window.addEventListener("resize", resizeHandler);
|
||||
return () => {
|
||||
window.removeEventListener("resize", resizeHandler);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleColorChange = (color: string) => {
|
||||
// Don't do anything if no node is known to be bound
|
||||
if (uiAccess.toolbarNode.signal.value.knodeIds.length < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update the UI component
|
||||
uiAccess.toolbarNode.setColor(color);
|
||||
|
||||
// Signal the engine with the node ID (just grab the first -and expected only- node)
|
||||
const nodeID = uiAccess.toolbarNode.signal.value.knodeIds[0];
|
||||
dispatchUiEvents.toolbarNode.setSelectedColor({ kNodeId: nodeID, color });
|
||||
}
|
||||
|
||||
useSignals();
|
||||
const color = uiAccess.toolbarNode.signal.value.color;
|
||||
|
||||
const boundedTopPosition = Math.min(position.y, winSize.height - 164);
|
||||
return (
|
||||
<Transition
|
||||
as="div"
|
||||
show={isShowing}
|
||||
className={twMerge(
|
||||
transitionTimingStyles,
|
||||
"fixed -translate-x-1/2 translate-y-5 data-[closed]:opacity-0",
|
||||
)}
|
||||
style={{
|
||||
top: boundedTopPosition,
|
||||
left: position.x,
|
||||
}}
|
||||
>
|
||||
<ToolbarNode
|
||||
{...rest}
|
||||
buttonsProps={buttonsProps}
|
||||
locked={uiAccess.toolbarNode.signal.value.locked}
|
||||
onLockClicked={(e) => {
|
||||
dispatchUiEvents.toolbarNode.lock(e);
|
||||
}}
|
||||
color={color}
|
||||
onColorChange={handleColorChange}
|
||||
/>
|
||||
</Transition>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,738 @@
|
||||
import Konva from "konva";
|
||||
|
||||
import { uiAccess, uiEvents } from "./signals";
|
||||
import { ImageNode, ShapeNode, ShapeType, TextNode, PaintNode, PreviewCopyNode } from "./KonvaApp/Nodes";
|
||||
import { UndoStackManager } from "./KonvaApp/UndoRedo";
|
||||
import { CommandManager, MatteBox, SceneManager } from "./KonvaApp/EngineUtitlities";
|
||||
import {
|
||||
NodeIsolator,
|
||||
NodesManager,
|
||||
NodesTranslationEventDetails,
|
||||
NodeTransformationEventDetails,
|
||||
NodeTransformer,
|
||||
SelectionManager,
|
||||
SelectionManagerEvents,
|
||||
SelectorSquare,
|
||||
} from "./KonvaApp/NodesManagers";
|
||||
import { EngineOptions, TextNodeData, VideoNodeData } from "./KonvaApp/types";
|
||||
|
||||
import { AppModes, VideoResolutions } from "./KonvaApp/constants";
|
||||
import { ToolbarMainButtonNames } from "./components/features/ToolbarMain/enum";
|
||||
|
||||
import { ToolbarNodeButtonNames } from "./components/features/ToolbarNode/enums";
|
||||
import { NavigateFunction } from "react-router-dom";
|
||||
|
||||
import { RealTimeDrawEngine } from "./KonvaApp/RenderingPrimitives/RealTimeDrawEngine";
|
||||
import { NodeColor } from "./signals/uiEvents/toolbarNode";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
import { DecodeBase64ToImage } from "./utilities/DecodeBase64ToImage";
|
||||
import { EncodeImageBitmapToBase64 } from "./utilities/EncodeImageBitmapToBase64";
|
||||
|
||||
|
||||
|
||||
export interface RenderingOptions {
|
||||
artstyle: string;
|
||||
positivePrompt: string;
|
||||
negativePrompt: string;
|
||||
cinematic: boolean;
|
||||
enginePreProcessing: boolean;
|
||||
faceDetail: boolean;
|
||||
lipSync: boolean;
|
||||
upscale: boolean;
|
||||
styleStrength: number;
|
||||
}
|
||||
|
||||
export class Engine {
|
||||
private navigateRef: NavigateFunction;
|
||||
private appMode: AppModes = AppModes.INIT;
|
||||
private boardCanvasRef: HTMLDivElement;
|
||||
private stage: Konva.Stage;
|
||||
private bgLayer: Konva.Layer;
|
||||
private mediaLayer: Konva.Layer;
|
||||
private nodeIsolationLayer: Konva.Layer;
|
||||
private uiLayer: Konva.Layer;
|
||||
private previewLayer: Konva.Layer;
|
||||
private offScreenCanvas: OffscreenCanvas;
|
||||
public realTimeDrawEngine: RealTimeDrawEngine;
|
||||
private nodesManager: NodesManager;
|
||||
private nodeIsolator: NodeIsolator;
|
||||
private nodeTransformer: NodeTransformer;
|
||||
private selectionManager: SelectionManager;
|
||||
private selectorSquare: SelectorSquare;
|
||||
private loadingVideosProvider: LoadingVideosProvider;
|
||||
private matteBox: MatteBox;
|
||||
private backgroundNode: ShapeNode | null = null;
|
||||
|
||||
private sceneManager: SceneManager;
|
||||
public undoStackManager: UndoStackManager;
|
||||
public commandManager: CommandManager;
|
||||
|
||||
public segmentationButtonCanBePressed: boolean = true;
|
||||
// signal reference
|
||||
constructor(boardCanvasRef: HTMLDivElement, options: EngineOptions) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.log("Engine Created");
|
||||
}
|
||||
this.navigateRef = options.navigate;
|
||||
this.appMode = AppModes.SELECT;
|
||||
|
||||
this.boardCanvasRef = boardCanvasRef;
|
||||
this.stage = new Konva.Stage({
|
||||
container: this.boardCanvasRef,
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
});
|
||||
this.bgLayer = new Konva.Layer();
|
||||
|
||||
this.mediaLayer = new Konva.Layer();
|
||||
this.previewLayer = new Konva.Layer();
|
||||
|
||||
this.nodeIsolationLayer = new Konva.Layer();
|
||||
this.uiLayer = new Konva.Layer();
|
||||
|
||||
this.stage.add(this.bgLayer);
|
||||
this.stage.add(this.previewLayer);
|
||||
this.stage.add(this.mediaLayer);
|
||||
this.stage.add(this.nodeIsolationLayer);
|
||||
this.stage.add(this.uiLayer);
|
||||
|
||||
// Konva Transformer
|
||||
this.nodeTransformer = new NodeTransformer();
|
||||
this.uiLayer.add(this.nodeTransformer.getKonvaNode());
|
||||
// Loading Placeholders
|
||||
|
||||
// Node Isolator
|
||||
this.nodeIsolator = new NodeIsolator({
|
||||
mediaLayerRef: this.mediaLayer,
|
||||
nodeIsolationLayerRef: this.nodeIsolationLayer,
|
||||
});
|
||||
|
||||
// core layer for all the work done.
|
||||
this.offScreenCanvas = new OffscreenCanvas(0, 0);
|
||||
|
||||
// Collection of all Nodes
|
||||
this.nodesManager = new NodesManager();
|
||||
// Partial Collection of selected Nodes
|
||||
this.selectionManager = new SelectionManager({
|
||||
nodeTransformerRef: this.nodeTransformer,
|
||||
mediaLayerRef: this.mediaLayer,
|
||||
});
|
||||
|
||||
this.realTimeDrawEngine = new RealTimeDrawEngine({
|
||||
width: VideoResolutions.SQUARE_1024.width,
|
||||
height: VideoResolutions.SQUARE_1024.height,
|
||||
mediaLayerRef: this.mediaLayer,
|
||||
offScreenCanvas: this.offScreenCanvas,
|
||||
onDraw: async (canvas, lineBounds) => {
|
||||
await this.addPaintNode(canvas, lineBounds);
|
||||
},
|
||||
onPreviewCopy: async (image) => {
|
||||
await this.addPreviewCopy(image);
|
||||
},
|
||||
});
|
||||
|
||||
// Selector Square to select Nodes
|
||||
this.selectorSquare = new SelectorSquare({
|
||||
captureCanvasRef: this.realTimeDrawEngine.captureCanvas,
|
||||
mediaLayerRef: this.mediaLayer,
|
||||
nodesManagerRef: this.nodesManager,
|
||||
selectionManagerRef: this.selectionManager,
|
||||
stageRef: this.stage,
|
||||
});
|
||||
this.uiLayer.add(this.selectorSquare.getKonvaNode());
|
||||
|
||||
//Collection of commands for undo-redo
|
||||
this.undoStackManager = new UndoStackManager(() => {
|
||||
//this.realTimeDrawEngine.render();
|
||||
});
|
||||
this.commandManager = new CommandManager({
|
||||
mediaLayerRef: this.mediaLayer,
|
||||
nodesManagerRef: this.nodesManager,
|
||||
nodeTransformerRef: this.nodeTransformer,
|
||||
selectionManagerRef: this.selectionManager,
|
||||
renderEngineRef: this.realTimeDrawEngine,
|
||||
undoStackManagerRef: this.undoStackManager,
|
||||
});
|
||||
|
||||
// set up secene manager
|
||||
this.sceneManager = new SceneManager({
|
||||
navigateRef: this.navigateRef,
|
||||
loadingVideosProviderRef: this.loadingVideosProvider,
|
||||
mediaLayerRef: this.mediaLayer,
|
||||
nodesManagerRef: this.nodesManager,
|
||||
selectionManagerRef: this.selectionManager,
|
||||
renderEngineRef: this.realTimeDrawEngine,
|
||||
});
|
||||
|
||||
this.matteBox = new MatteBox({
|
||||
boardCanvasSize: {
|
||||
width: this.boardCanvasRef.clientWidth,
|
||||
height: this.boardCanvasRef.clientHeight,
|
||||
},
|
||||
captureCanvasSize: this.realTimeDrawEngine.captureCanvas.getSize(),
|
||||
uiLayerRef: this.uiLayer,
|
||||
});
|
||||
|
||||
// some of the managers has events
|
||||
// hence, lastly, setup these events
|
||||
this.setupEventSystem();
|
||||
this.setAppMode(this.appMode);
|
||||
}
|
||||
|
||||
private setAppMode(newAppMode: AppModes) {
|
||||
this.appMode = newAppMode;
|
||||
switch (this.appMode) {
|
||||
case AppModes.PAINT: {
|
||||
console.log("APPMODE: PAINT");
|
||||
this.selectorSquare.disable(); // this breaks the paint if you use it in the wrong spot
|
||||
this.selectionManager.disable(); // this prevents selection.
|
||||
this.realTimeDrawEngine.enablePaintMode();
|
||||
|
||||
uiAccess.toolbarMain.enable();
|
||||
uiAccess.toolbarMain.changeButtonState(ToolbarMainButtonNames.SELECT, {
|
||||
active: false,
|
||||
});
|
||||
uiAccess.toolbarMain.changeButtonState(ToolbarMainButtonNames.PAINT, {
|
||||
active: true,
|
||||
});
|
||||
this.matteBox.disable();
|
||||
return;
|
||||
}
|
||||
case AppModes.SELECT: {
|
||||
console.log("APPMODE: SELECT");
|
||||
this.realTimeDrawEngine.disablePaintMode();
|
||||
|
||||
this.selectorSquare.enable();
|
||||
this.selectionManager.enable();
|
||||
uiAccess.toolbarMain.enable();
|
||||
uiAccess.toolbarMain.changeButtonState(ToolbarMainButtonNames.SELECT, {
|
||||
active: true,
|
||||
});
|
||||
uiAccess.toolbarMain.changeButtonState(ToolbarMainButtonNames.PAINT, {
|
||||
active: false,
|
||||
});
|
||||
this.matteBox.disable();
|
||||
document.body.style.cursor = "default";
|
||||
return;
|
||||
}
|
||||
case AppModes.PREVIEW: {
|
||||
console.log("APPMODE: RENDER");
|
||||
this.selectorSquare.disable();
|
||||
this.selectionManager.disable();
|
||||
uiAccess.toolbarMain.disable();
|
||||
uiAccess.toolbarMain.changeButtonState(ToolbarMainButtonNames.SELECT, {
|
||||
active: false,
|
||||
});
|
||||
this.matteBox.enable();
|
||||
document.body.style.cursor = "not-allowed";
|
||||
return;
|
||||
}
|
||||
case AppModes.RENDERING: {
|
||||
console.log("APPMODE: RENDER");
|
||||
this.selectorSquare.disable();
|
||||
this.selectionManager.disable();
|
||||
uiAccess.toolbarMain.disable();
|
||||
uiAccess.toolbarMain.changeButtonState(ToolbarMainButtonNames.SELECT, {
|
||||
active: false,
|
||||
});
|
||||
this.matteBox.enable(true);
|
||||
document.body.style.cursor = "wait";
|
||||
return;
|
||||
}
|
||||
case AppModes.INIT:
|
||||
default: {
|
||||
console.log("APPMODE: INIT");
|
||||
this.selectorSquare.disable();
|
||||
uiAccess.toolbarMain.disable();
|
||||
this.selectionManager.disable();
|
||||
uiAccess.toolbarMain.changeButtonState(ToolbarMainButtonNames.SELECT, {
|
||||
active: false,
|
||||
});
|
||||
document.body.style.cursor = "wait";
|
||||
this.matteBox.disable();
|
||||
}
|
||||
}
|
||||
}
|
||||
private setupEventSystem() {
|
||||
// Listen to changes in container size
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
this.onBoardCanvasResize();
|
||||
});
|
||||
resizeObserver.observe(this.boardCanvasRef);
|
||||
this.onBoardCanvasResize();
|
||||
|
||||
// Listen to Nodes
|
||||
this.selectionManager.eventTarget.addEventListener(
|
||||
SelectionManagerEvents.NODES_TRANSLATIONS,
|
||||
((event: CustomEvent<NodesTranslationEventDetails>) => {
|
||||
//console.log("Event: SelectionManager -> Engine", event);
|
||||
this.commandManager.translateNodes(event.detail);
|
||||
}) as EventListener,
|
||||
);
|
||||
this.selectionManager.eventTarget.addEventListener(
|
||||
SelectionManagerEvents.NODES_TRANSFORMATION,
|
||||
((event: CustomEvent<NodeTransformationEventDetails>) => {
|
||||
//console.log("Event: SelectionManager -> Engine", event);
|
||||
this.commandManager.transformNodes(event.detail);
|
||||
}) as EventListener,
|
||||
);
|
||||
|
||||
// Listen to Tooolbar Node
|
||||
uiEvents.toolbarNode.lock.onClick(() => {
|
||||
this.commandManager.toggleLockNodes();
|
||||
});
|
||||
uiEvents.toolbarNode.DOWNLOAD.onClick(() => {
|
||||
const nodes = this.selectionManager.getSelectedNodes();
|
||||
if (nodes.size > 1) {
|
||||
uiAccess.dialogError.show({
|
||||
title: "Error: Download Node Content",
|
||||
message:
|
||||
"Please do not select more than 1 item for the Download Node Content feature, you can only download 1 item at a time",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const node = nodes.values().next().value;
|
||||
try {
|
||||
if (node instanceof VideoNode && node.currentUrl) {
|
||||
downloadURI(node.currentUrl, `Download Video Node-${node.kNode.id}`);
|
||||
} else {
|
||||
throw new Error();
|
||||
}
|
||||
} catch {
|
||||
uiAccess.dialogError.show({
|
||||
title: "Error: Download Node Content",
|
||||
message: "This item does not have content for download.",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
uiEvents.toolbarNode.DELETE.onClick(() =>
|
||||
this.commandManager.deleteNodes(),
|
||||
);
|
||||
uiEvents.toolbarNode.MOVE_LAYER_DOWN.onClick(() =>
|
||||
this.commandManager.moveNodesDown(),
|
||||
);
|
||||
uiEvents.toolbarNode.MOVE_LAYER_UP.onClick(() =>
|
||||
this.commandManager.moveNodesUp(),
|
||||
);
|
||||
uiEvents.toolbarNode.REMOVE_BACKGROUND.onClick(() =>
|
||||
this.removeBackground(),
|
||||
);
|
||||
|
||||
// Listen to Toolbar Main
|
||||
uiEvents.toolbarMain.UNDO.onClick(() => {
|
||||
this.undoStackManager.undo();
|
||||
});
|
||||
uiEvents.toolbarMain.REDO.onClick(() => {
|
||||
this.undoStackManager.redo();
|
||||
//this.realTimeDrawEngine.render();
|
||||
});
|
||||
|
||||
uiEvents.toolbarMain.SAVE.onClick(async (/*event*/) => {
|
||||
//await this.realTimeDrawEngine.saveOutput(); // This works.
|
||||
await invoke("open_login_command");
|
||||
});
|
||||
|
||||
uiEvents.toolbarMain.GENERATE.onClick(async (/*event*/) => {
|
||||
console.log("generate clicked");
|
||||
await this.realTimeDrawEngine.generateImage();
|
||||
});
|
||||
|
||||
uiEvents.toolbarMain.SELECT.onClick(() => {
|
||||
console.log("Toolbar Main >> Select");
|
||||
this.setAppMode(AppModes.SELECT);
|
||||
});
|
||||
|
||||
uiEvents.toolbarMain.PAINT.onClick(() => {
|
||||
console.log("Toolbar Main >> Paint");
|
||||
this.setAppMode(AppModes.PAINT);
|
||||
});
|
||||
|
||||
uiEvents.toolbarMain.ERASER.onClick(() => {
|
||||
console.log("Toolbar Main >> Eraser");
|
||||
});
|
||||
|
||||
uiEvents.toolbarMain.PREVIEW.onClick(async () => {
|
||||
console.log("Toolbar Main >> Preview");
|
||||
this.setAppMode(AppModes.PREVIEW);
|
||||
await this.handlePreview();
|
||||
this.setAppMode(AppModes.SELECT);
|
||||
});
|
||||
uiEvents.toolbarMain.DOWNLOAD.onClick(async () => {
|
||||
console.log("Toolbar Main >> Render Download");
|
||||
try {
|
||||
this.setAppMode(AppModes.RENDERING);
|
||||
} catch (error) {
|
||||
// throw error to retry
|
||||
uiAccess.dialogError.show({
|
||||
title: "Generation Error",
|
||||
message: error?.toString() || "Unknown Error",
|
||||
});
|
||||
this.setAppMode(AppModes.SELECT);
|
||||
}
|
||||
});
|
||||
// Listen to other toolbars
|
||||
// VideoExtraction Toolbar
|
||||
|
||||
|
||||
// Listen to other requests coming from the UI
|
||||
uiEvents.onGetStagedImage((image) => {
|
||||
this.addImage(image);
|
||||
});
|
||||
|
||||
uiEvents.onAddTextToEngine((textdata) => {
|
||||
this.addText(textdata);
|
||||
});
|
||||
|
||||
let renderTimeout: NodeJS.Timeout;
|
||||
|
||||
uiEvents.promptEvents.onPromptStrengthChanged(async (strength) => {
|
||||
this.realTimeDrawEngine.currentStrength = strength / 100.0;
|
||||
clearTimeout(renderTimeout);
|
||||
renderTimeout = setTimeout(async () => {
|
||||
//await this.realTimeDrawEngine.render();
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
uiEvents.promptEvents.onPromptTextChanged(async (prompt) => {
|
||||
this.realTimeDrawEngine.currentPrompt = prompt;
|
||||
clearTimeout(renderTimeout);
|
||||
renderTimeout = setTimeout(async () => {
|
||||
//await this.realTimeDrawEngine.render();
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
uiEvents.onAddShapeToEngine((shapeData) => {
|
||||
switch (shapeData.shape) {
|
||||
case "circle":
|
||||
this.addShape(ShapeType.CIRCLE, 100);
|
||||
break;
|
||||
case "square":
|
||||
this.addShape(ShapeType.SQUARE, 100);
|
||||
break;
|
||||
case "triangle":
|
||||
this.addShape(ShapeType.TRIANGLE, 100);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
uiEvents.toolbarMain.onBgColorChanged((data) => {
|
||||
this.realTimeDrawEngine.updateBackground(data);
|
||||
});
|
||||
|
||||
uiEvents.toolbarNode.color.onConfirmChanged((nodeColor) => {
|
||||
console.log("Color change event triggered with:", nodeColor);
|
||||
if (!nodeColor) {
|
||||
console.warn("nodeColor is undefined or null");
|
||||
return;
|
||||
}
|
||||
if (!nodeColor.kNodeId) {
|
||||
console.warn("nodeColor.kNodeId is undefined or null");
|
||||
return;
|
||||
}
|
||||
this.changeNodeColor(nodeColor);
|
||||
});
|
||||
uiEvents.toolbarMain.onPaintColorChanged((color) => {
|
||||
this.realTimeDrawEngine.paintColor = color;
|
||||
});
|
||||
|
||||
uiEvents.toolbarMain.onEraseBrushSizeChanged((size: number) => {
|
||||
this.realTimeDrawEngine.paintBrushSize = size;
|
||||
});
|
||||
}
|
||||
|
||||
removeBackground() {
|
||||
const selectedNodeSet = this.selectionManager.getSelectedNodes();
|
||||
|
||||
if (selectedNodeSet.size !== 1) {
|
||||
return; // Can only remove background from a single image at a time.
|
||||
}
|
||||
|
||||
const [selectedNode] = selectedNodeSet;
|
||||
|
||||
const removeBgInvocation = async (
|
||||
selectedNode: ImageNode,
|
||||
callback: (
|
||||
existingImageNode: ImageNode,
|
||||
createdImage: ImageBitmap,
|
||||
) => void,
|
||||
) => {
|
||||
const image: HTMLImageElement = selectedNode.kNode.image();
|
||||
const base64Bitmap = await EncodeImageBitmapToBase64(image);
|
||||
const base64BitmapResponse = await invoke("remove_background", {
|
||||
image: base64Bitmap,
|
||||
});
|
||||
|
||||
const createdImage = await DecodeBase64ToImage(
|
||||
base64BitmapResponse as string,
|
||||
);
|
||||
|
||||
callback(selectedNode, createdImage);
|
||||
};
|
||||
|
||||
const _promise = removeBgInvocation(
|
||||
selectedNode,
|
||||
(selectedNode: ImageNode, createdImage: ImageBitmap) => {
|
||||
if (!this.nodesManager.hasNode(selectedNode)) {
|
||||
return;
|
||||
}
|
||||
this.commandManager.deleteSpecificNodes([selectedNode]);
|
||||
this.addImageFromImageBitmap(createdImage);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
disableAllButtons() {
|
||||
const buttonNames = Object.values(ToolbarNodeButtonNames);
|
||||
for (const name of buttonNames) {
|
||||
uiAccess.toolbarNode.changeButtonState(name, { disabled: true });
|
||||
}
|
||||
}
|
||||
|
||||
async enableAllButtons() {
|
||||
const buttonNames = Object.values(ToolbarNodeButtonNames);
|
||||
for (const name of buttonNames) {
|
||||
await uiAccess.toolbarNode.changeButtonState(name, { disabled: false });
|
||||
}
|
||||
}
|
||||
|
||||
sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
private onBoardCanvasResize() {
|
||||
console.log("onBoardCanvasResize Event called");
|
||||
// Print window dimensions for debugging
|
||||
console.log("Window dimensions:", {
|
||||
innerWidth: window.innerWidth,
|
||||
innerHeight: window.innerHeight,
|
||||
outerWidth: window.outerWidth,
|
||||
outerHeight: window.outerHeight,
|
||||
});
|
||||
|
||||
this.realTimeDrawEngine.updateCaptureCanvas(
|
||||
window.innerWidth,
|
||||
window.innerHeight,
|
||||
);
|
||||
|
||||
this.matteBox.updateSize({
|
||||
boardCanvasSize: {
|
||||
width: this.boardCanvasRef.offsetWidth,
|
||||
height: this.boardCanvasRef.offsetHeight,
|
||||
},
|
||||
});
|
||||
this.uiLayer.draw();
|
||||
this.stage.width(this.boardCanvasRef.offsetWidth);
|
||||
this.stage.height(this.boardCanvasRef.offsetHeight);
|
||||
this.stage.draw(); // Redraw the canvas
|
||||
}
|
||||
|
||||
// Sandbox is quickly a way to test your idea.
|
||||
public async sandbox() {}
|
||||
|
||||
public onMessage(event: MessageEvent) {
|
||||
console.log("Message From Shared Worker");
|
||||
console.log(event);
|
||||
}
|
||||
|
||||
public initializeStage(sceneToken?: string) {
|
||||
// load canvas that was originaly saved TODO Save manager for resharing.
|
||||
uiAccess.toolbarNode.hide();
|
||||
uiAccess.loadingBar.hide();
|
||||
// load the scene if there's a scenetoken
|
||||
if (sceneToken) {
|
||||
this.sceneManager.loadScene(sceneToken);
|
||||
}
|
||||
this.setupStage();
|
||||
// this.populateWithDebugItems();
|
||||
}
|
||||
public isInitialized() {
|
||||
return this.stage !== null;
|
||||
}
|
||||
|
||||
public async setupStage() {
|
||||
// Frame rate inicator
|
||||
// const textNode = new Konva.Text({
|
||||
// x: 10,
|
||||
// y: 80,
|
||||
// text: "",
|
||||
// fontSize: 18,
|
||||
// fontFamily: "Source Sans 3",
|
||||
// fill: "white",
|
||||
// });
|
||||
// const anim = new Konva.Animation((frame) => {
|
||||
// if (frame && import.meta.env.DEV) {
|
||||
// const timeDiff = frame.timeDiff;
|
||||
// const frameRate = frame.frameRate;
|
||||
// textNode.setText(
|
||||
// `FrameTime: ${timeDiff.toFixed(0)} ms\nFrameRate: ${frameRate.toFixed(0)} fps`,
|
||||
// );
|
||||
// }
|
||||
// }, this.mediaLayer);
|
||||
|
||||
// anim.start();
|
||||
//this.uiLayer.add(textNode);
|
||||
this.addKeyboardShortcuts();
|
||||
}
|
||||
|
||||
private changeNodeColor(nodeColor: NodeColor) {
|
||||
console.log("Changing node color:", nodeColor);
|
||||
// Find the node that is selected with the id
|
||||
const idSelector = "#" + nodeColor.kNodeId;
|
||||
const selectedNode = this.mediaLayer.findOne(idSelector);
|
||||
console.log("Selected node:", selectedNode);
|
||||
console.log("ID selector:", idSelector);
|
||||
console.log("Media layer:", this.mediaLayer);
|
||||
// TODO: Change the color of the node
|
||||
// Turns out shapes are image nodes... how do we change the image's fill??
|
||||
if (selectedNode) {
|
||||
// Get the shape node from the real time drawing engine's image nodes
|
||||
const shapeNode = this.realTimeDrawEngine.findImageNodeById(
|
||||
nodeColor.kNodeId,
|
||||
);
|
||||
if (shapeNode && shapeNode instanceof ShapeNode) {
|
||||
// Create new shape node with updated color
|
||||
const newShapeNode = new ShapeNode({
|
||||
canvasPosition: this.realTimeDrawEngine.captureCanvas.position(),
|
||||
canvasSize: this.realTimeDrawEngine.captureCanvas.size(),
|
||||
shapeType: shapeNode.shapeType,
|
||||
size: shapeNode.kNode.size(),
|
||||
color: nodeColor.color,
|
||||
mediaLayerRef: this.mediaLayer,
|
||||
selectionManagerRef: this.selectionManager,
|
||||
loaded: async () => {
|
||||
//this.realTimeDrawEngine.render();
|
||||
},
|
||||
});
|
||||
newShapeNode.kNode.position(shapeNode.kNode.position());
|
||||
newShapeNode.kNode.zIndex(shapeNode.kNode.zIndex());
|
||||
|
||||
// Remove old node and add new one
|
||||
shapeNode.kNode.destroy();
|
||||
this.commandManager.createNode(newShapeNode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public addText(textNodeData: TextNodeData) {
|
||||
const textNode = new TextNode({
|
||||
textNodeData: textNodeData,
|
||||
mediaLayerRef: this.mediaLayer,
|
||||
selectionManagerRef: this.selectionManager,
|
||||
canvasPosition: this.realTimeDrawEngine.captureCanvas.position(),
|
||||
canvasSize: this.realTimeDrawEngine.captureCanvas.size(),
|
||||
});
|
||||
this.commandManager.createNode(textNode);
|
||||
}
|
||||
|
||||
public addShape(type: ShapeType, size: number, color?: string) {
|
||||
const shapeNode = new ShapeNode({
|
||||
canvasPosition: this.realTimeDrawEngine.captureCanvas.position(),
|
||||
canvasSize: this.realTimeDrawEngine.captureCanvas.size(),
|
||||
shapeType: type,
|
||||
size: { width: size, height: size },
|
||||
color: color,
|
||||
mediaLayerRef: this.mediaLayer,
|
||||
selectionManagerRef: this.selectionManager,
|
||||
loaded: async () => {
|
||||
//await this.realTimeDrawEngine.render();
|
||||
this.setAppMode(AppModes.SELECT);
|
||||
},
|
||||
});
|
||||
|
||||
this.commandManager.createNode(shapeNode);
|
||||
}
|
||||
public addPreviewCopy(image: Image.Konva) {
|
||||
// Start of Selection
|
||||
const copyNode = new PreviewCopyNode({
|
||||
image: image,
|
||||
mediaLayerRef: this.mediaLayer,
|
||||
selectionManagerRef: this.selectionManager,
|
||||
loaded: async () => {
|
||||
//this.realTimeDrawEngine.render();
|
||||
this.setAppMode(AppModes.SELECT);
|
||||
},
|
||||
});
|
||||
this.commandManager.createNode(copyNode);
|
||||
}
|
||||
public addImage(imageFile: File) {
|
||||
const imageNode = new ImageNode({
|
||||
mediaLayerRef: this.mediaLayer,
|
||||
canvasPosition: this.realTimeDrawEngine.captureCanvas.position(),
|
||||
canvasSize: this.realTimeDrawEngine.captureCanvas.size(),
|
||||
imageFile: imageFile,
|
||||
selectionManagerRef: this.selectionManager,
|
||||
loaded: async () => {
|
||||
//await this.realTimeDrawEngine.render();
|
||||
this.setAppMode(AppModes.SELECT);
|
||||
},
|
||||
});
|
||||
|
||||
this.commandManager.createNode(imageNode);
|
||||
}
|
||||
|
||||
public addImageFromImageBitmap(imageBitmap: ImageBitmap) {
|
||||
const imageNode = new ImageNode({
|
||||
mediaLayerRef: this.mediaLayer,
|
||||
canvasPosition: this.realTimeDrawEngine.captureCanvas.position(),
|
||||
canvasSize: this.realTimeDrawEngine.captureCanvas.size(),
|
||||
imageBitmap: imageBitmap,
|
||||
selectionManagerRef: this.selectionManager,
|
||||
loaded: async () => {
|
||||
//await this.realTimeDrawEngine.render();
|
||||
this.setAppMode(AppModes.SELECT);
|
||||
},
|
||||
});
|
||||
|
||||
this.commandManager.createNode(imageNode);
|
||||
}
|
||||
|
||||
public addPaintNode(
|
||||
canvas: HTMLCanvasElement,
|
||||
lineBounds: {
|
||||
width: number;
|
||||
height: number;
|
||||
x: number;
|
||||
y: number;
|
||||
},
|
||||
) {
|
||||
const node = new PaintNode({
|
||||
canvasElement: canvas,
|
||||
lineBounds: lineBounds,
|
||||
mediaLayerRef: this.mediaLayer,
|
||||
selectionManagerRef: this.selectionManager,
|
||||
loaded: async () => {
|
||||
//await this.realTimeDrawEngine.render();
|
||||
},
|
||||
});
|
||||
this.commandManager.createNode(node);
|
||||
}
|
||||
|
||||
// Events for Undo and Redo
|
||||
private addKeyboardShortcuts() {
|
||||
window.addEventListener("keydown", (event) => {
|
||||
if (event.ctrlKey && event.key === "z") {
|
||||
this.undoStackManager.undo();
|
||||
} else if (
|
||||
(event.ctrlKey && event.key === "y") ||
|
||||
(event.ctrlKey && event.shiftKey && event.key === "Z")
|
||||
) {
|
||||
this.undoStackManager.redo();
|
||||
} else if (event.key === "Delete") {
|
||||
this.commandManager.deleteNodes();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Move to utilities?
|
||||
function downloadURI(uri: string, name: string) {
|
||||
const link = document.createElement("a");
|
||||
link.download = name;
|
||||
link.target = "_blank";
|
||||
link.href = uri;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import React, { createContext, useContext, useState } from "react";
|
||||
import { JobContextType } from "@storyteller/common";
|
||||
const JobContext = createContext<JobContextType | undefined>(undefined);
|
||||
|
||||
export const JobProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
const [jobTokens, setJobTokens] = useState<string[]>([]);
|
||||
|
||||
const addJobToken = (token: string) => {
|
||||
setJobTokens((prevTokens) => [...prevTokens, token]);
|
||||
};
|
||||
|
||||
const removeJobToken = (token: string) => {
|
||||
setJobTokens((prevTokens) => prevTokens.filter((t) => t !== token));
|
||||
};
|
||||
|
||||
const clearJobTokens = () => {
|
||||
setJobTokens([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<JobContext.Provider
|
||||
value={{ jobTokens, addJobToken, removeJobToken, clearJobTokens }}
|
||||
>
|
||||
{children}
|
||||
</JobContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useJobContext = () => {
|
||||
const context = useContext(JobContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useJobContext must be used within a JobProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -0,0 +1,371 @@
|
||||
import Konva from "konva";
|
||||
|
||||
import { uiAccess } from "../signals";
|
||||
import { CommandManager, MatteBox } from "./EngineUtitlities";
|
||||
import { ImageNode } from "./Nodes";
|
||||
import { PaintNode } from "./Nodes/PaintNode";
|
||||
import {
|
||||
NodesManager,
|
||||
NodesTranslationEventDetails,
|
||||
NodeTransformationEventDetails,
|
||||
NodeTransformer,
|
||||
SelectionManager,
|
||||
SelectionManagerEvents,
|
||||
SelectorSquare,
|
||||
} from "./NodesManagers";
|
||||
import { UndoStackManager } from "./UndoRedo";
|
||||
|
||||
import { ToolbarMainButtonNames } from "../components/features/ToolbarMain/enum";
|
||||
import { EditModes, VideoResolutions } from "./constants";
|
||||
|
||||
import { ToolbarNodeButtonNames } from "../components/features/ToolbarNode/enums";
|
||||
|
||||
|
||||
export interface RenderingOptions {
|
||||
artstyle: string;
|
||||
positivePrompt: string;
|
||||
negativePrompt: string;
|
||||
cinematic: boolean;
|
||||
enginePreProcessing: boolean;
|
||||
faceDetail: boolean;
|
||||
lipSync: boolean;
|
||||
upscale: boolean;
|
||||
styleStrength: number;
|
||||
}
|
||||
|
||||
export class EditEngine {
|
||||
private editMode: EditModes = EditModes.SELECT;
|
||||
private boardCanvasRef: HTMLDivElement;
|
||||
private stage: Konva.Stage;
|
||||
|
||||
private mediaLayer: Konva.Layer;
|
||||
private nodeIsolationLayer: Konva.Layer;
|
||||
private uiLayer: Konva.Layer;
|
||||
private offScreenCanvas: OffscreenCanvas;
|
||||
private realTimeDrawEngine: RealTimeDrawEngine;
|
||||
private nodesManager: NodesManager;
|
||||
private nodeTransformer: NodeTransformer;
|
||||
private selectionManager: SelectionManager;
|
||||
private selectorSquare: SelectorSquare;
|
||||
private matteBox: MatteBox;
|
||||
|
||||
private undoStackManager: UndoStackManager;
|
||||
private commandManager: CommandManager;
|
||||
|
||||
public segmentationButtonCanBePressed: boolean = true;
|
||||
// signal reference
|
||||
constructor(boardCanvasRef: HTMLDivElement) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.log("Engine Created");
|
||||
}
|
||||
this.editMode = EditModes.SELECT;
|
||||
|
||||
this.boardCanvasRef = boardCanvasRef;
|
||||
|
||||
this.stage = new Konva.Stage({
|
||||
container: this.boardCanvasRef,
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
});
|
||||
//this.bgLayer = new Konva.Layer();
|
||||
|
||||
this.mediaLayer = new Konva.Layer();
|
||||
|
||||
this.nodeIsolationLayer = new Konva.Layer();
|
||||
this.uiLayer = new Konva.Layer();
|
||||
|
||||
//this.stage.add(this.bgLayer);
|
||||
this.stage.add(this.mediaLayer);
|
||||
this.stage.add(this.nodeIsolationLayer);
|
||||
this.stage.add(this.uiLayer);
|
||||
|
||||
// Konva Transformer
|
||||
this.nodeTransformer = new NodeTransformer();
|
||||
this.uiLayer.add(this.nodeTransformer.getKonvaNode());
|
||||
|
||||
// core layer for all the work done.
|
||||
this.offScreenCanvas = new OffscreenCanvas(0, 0);
|
||||
|
||||
// Collection of all Nodes
|
||||
this.nodesManager = new NodesManager();
|
||||
// Partial Collection of selected Nodes
|
||||
this.selectionManager = new SelectionManager({
|
||||
nodeTransformerRef: this.nodeTransformer,
|
||||
mediaLayerRef: this.mediaLayer,
|
||||
});
|
||||
|
||||
this.realTimeDrawEngine = new RealTimeDrawEngine({
|
||||
width: VideoResolutions.SQUARE_1024.width,
|
||||
height: VideoResolutions.SQUARE_1024.height,
|
||||
mediaLayerRef: this.mediaLayer,
|
||||
offScreenCanvas: this.offScreenCanvas,
|
||||
onDraw: async (canvas, lineBounds) => {
|
||||
await this.addPaintNode(canvas, lineBounds);
|
||||
},
|
||||
});
|
||||
|
||||
// Selector Square to select Nodes
|
||||
this.selectorSquare = new SelectorSquare({
|
||||
captureCanvasRef: this.realTimeDrawEngine.captureCanvas,
|
||||
mediaLayerRef: this.mediaLayer,
|
||||
nodesManagerRef: this.nodesManager,
|
||||
selectionManagerRef: this.selectionManager,
|
||||
stageRef: this.stage,
|
||||
});
|
||||
this.uiLayer.add(this.selectorSquare.getKonvaNode());
|
||||
|
||||
//Collection of commands for undo-redo
|
||||
this.undoStackManager = new UndoStackManager(() => {
|
||||
this.realTimeDrawEngine.render();
|
||||
});
|
||||
this.commandManager = new CommandManager({
|
||||
mediaLayerRef: this.mediaLayer,
|
||||
nodesManagerRef: this.nodesManager,
|
||||
nodeTransformerRef: this.nodeTransformer,
|
||||
selectionManagerRef: this.selectionManager,
|
||||
renderEngineRef: this.realTimeDrawEngine,
|
||||
undoStackManagerRef: this.undoStackManager,
|
||||
});
|
||||
|
||||
this.matteBox = new MatteBox({
|
||||
boardCanvasSize: {
|
||||
width: this.boardCanvasRef.clientWidth,
|
||||
height: this.boardCanvasRef.clientHeight,
|
||||
},
|
||||
captureCanvasSize: this.realTimeDrawEngine.captureCanvas.getSize(),
|
||||
uiLayerRef: this.uiLayer,
|
||||
});
|
||||
|
||||
// some of the managers has events
|
||||
// hence, lastly, setup these events
|
||||
this.setupEventSystem();
|
||||
this.setEditMode(this.editMode);
|
||||
}
|
||||
|
||||
private setEditMode(newEditMode: EditModes) {
|
||||
this.editMode = newEditMode;
|
||||
switch (this.editMode) {
|
||||
case EditModes.SELECT:
|
||||
default: {
|
||||
console.log("EDITMODE: SELECT");
|
||||
this.realTimeDrawEngine.disablePaintMode();
|
||||
|
||||
this.selectorSquare.enable();
|
||||
this.selectionManager.enable();
|
||||
uiAccess.toolbarMain.enable();
|
||||
uiAccess.toolbarMain.changeButtonState(ToolbarMainButtonNames.SELECT, {
|
||||
active: true,
|
||||
});
|
||||
uiAccess.toolbarMain.changeButtonState(ToolbarMainButtonNames.PAINT, {
|
||||
active: false,
|
||||
});
|
||||
this.matteBox.disable();
|
||||
document.body.style.cursor = "default";
|
||||
return;
|
||||
}
|
||||
case EditModes.EDIT: {
|
||||
console.log("EDITMODE: EDIT");
|
||||
this.realTimeDrawEngine.enablePaintMode();
|
||||
this.selectorSquare.disable();
|
||||
this.selectionManager.disable();
|
||||
}
|
||||
}
|
||||
}
|
||||
private setupEventSystem() {
|
||||
// Listen to changes in container size
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
this.onBoardCanvasResize();
|
||||
});
|
||||
resizeObserver.observe(this.boardCanvasRef);
|
||||
this.onBoardCanvasResize();
|
||||
|
||||
// Listen to Nodes
|
||||
this.selectionManager.eventTarget.addEventListener(
|
||||
SelectionManagerEvents.NODES_TRANSLATIONS,
|
||||
((event: CustomEvent<NodesTranslationEventDetails>) => {
|
||||
//console.log("Event: SelectionManager -> Engine", event);
|
||||
this.commandManager.translateNodes(event.detail);
|
||||
}) as EventListener,
|
||||
);
|
||||
this.selectionManager.eventTarget.addEventListener(
|
||||
SelectionManagerEvents.NODES_TRANSFORMATION,
|
||||
((event: CustomEvent<NodeTransformationEventDetails>) => {
|
||||
//console.log("Event: SelectionManager -> Engine", event);
|
||||
this.commandManager.transformNodes(event.detail);
|
||||
}) as EventListener,
|
||||
);
|
||||
|
||||
// Listen to Toolbar Main
|
||||
// uiEvents.toolbarMain.UNDO.onClick(() => {
|
||||
// this.undoStackManager.undo();
|
||||
// });
|
||||
// uiEvents.toolbarMain.REDO.onClick(() => {
|
||||
// this.undoStackManager.redo();
|
||||
// this.realTimeDrawEngine.render();
|
||||
// });
|
||||
|
||||
// uiEvents.toolbarMain.SAVE.onClick(async (/*event*/) => {
|
||||
// await this.realTimeDrawEngine.saveOutput();
|
||||
// });
|
||||
|
||||
// uiEvents.toolbarMain.SELECT.onClick(() => {
|
||||
// console.log("Toolbar Main >> Select");
|
||||
// this.setAppMode(AppModes.SELECT);
|
||||
// });
|
||||
|
||||
// uiEvents.toolbarMain.PAINT.onClick(() => {
|
||||
// console.log("Toolbar Main >> Paint");
|
||||
// this.setAppMode(AppModes.PAINT);
|
||||
// });
|
||||
}
|
||||
|
||||
disableAllButtons() {
|
||||
const buttonNames = Object.values(ToolbarNodeButtonNames);
|
||||
for (const name of buttonNames) {
|
||||
uiAccess.toolbarNode.changeButtonState(name, { disabled: true });
|
||||
}
|
||||
}
|
||||
|
||||
async enableAllButtons() {
|
||||
const buttonNames = Object.values(ToolbarNodeButtonNames);
|
||||
for (const name of buttonNames) {
|
||||
await uiAccess.toolbarNode.changeButtonState(name, { disabled: false });
|
||||
}
|
||||
}
|
||||
|
||||
sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
private onBoardCanvasResize() {
|
||||
this.realTimeDrawEngine.updateCaptureCanvas(0, 0);
|
||||
console.log("onBoardCanvasResize Event called");
|
||||
console.log("Board Canvas values below");
|
||||
console.log(this.boardCanvasRef.offsetWidth);
|
||||
console.log(this.boardCanvasRef.offsetHeight);
|
||||
|
||||
this.matteBox.updateSize({
|
||||
boardCanvasSize: {
|
||||
width: this.boardCanvasRef.offsetWidth,
|
||||
height: this.boardCanvasRef.offsetHeight,
|
||||
},
|
||||
});
|
||||
|
||||
this.uiLayer.draw();
|
||||
this.stage.width(this.boardCanvasRef.offsetWidth);
|
||||
this.stage.height(this.boardCanvasRef.offsetHeight);
|
||||
this.stage.draw(); // Redraw the canvas
|
||||
}
|
||||
|
||||
// Sandbox is quickly a way to test your idea.
|
||||
public async sandbox() {}
|
||||
|
||||
public onMessage(event: MessageEvent) {
|
||||
console.log("Message From Shared Worker");
|
||||
console.log(event);
|
||||
}
|
||||
|
||||
public initializeStage() {
|
||||
// load canvas that was originaly saved TODO Save manager for resharing.
|
||||
uiAccess.toolbarNode.hide();
|
||||
uiAccess.loadingBar.hide();
|
||||
this.setupStage();
|
||||
}
|
||||
|
||||
public isInitialized() {
|
||||
return this.stage !== null;
|
||||
}
|
||||
|
||||
public async setupStage() {
|
||||
// Frame rate inicator
|
||||
// const textNode = new Konva.Text({
|
||||
// x: 10,
|
||||
// y: 80,
|
||||
// text: "",
|
||||
// fontSize: 18,
|
||||
// fontFamily: "Source Sans 3",
|
||||
// fill: "white",
|
||||
// });
|
||||
// const anim = new Konva.Animation((frame) => {
|
||||
// if (frame && import.meta.env.DEV) {
|
||||
// const timeDiff = frame.timeDiff;
|
||||
// const frameRate = frame.frameRate;
|
||||
// textNode.setText(
|
||||
// `FrameTime: ${timeDiff.toFixed(0)} ms\nFrameRate: ${frameRate.toFixed(0)} fps`,
|
||||
// );
|
||||
// }
|
||||
// }, this.mediaLayer);
|
||||
|
||||
// anim.start();
|
||||
// this.uiLayer.add(textNode);
|
||||
this.addKeyboardShortcuts();
|
||||
}
|
||||
|
||||
public addImage(imageFile: File) {
|
||||
const imageNode = new ImageNode({
|
||||
mediaLayerRef: this.mediaLayer,
|
||||
canvasPosition: this.realTimeDrawEngine.captureCanvas.position(),
|
||||
canvasSize: this.realTimeDrawEngine.captureCanvas.size(),
|
||||
imageFile: imageFile,
|
||||
selectionManagerRef: this.selectionManager,
|
||||
loaded: async () => {
|
||||
await this.realTimeDrawEngine.render();
|
||||
this.setEditMode(EditModes.SELECT);
|
||||
},
|
||||
});
|
||||
|
||||
this.commandManager.createNode(imageNode);
|
||||
}
|
||||
|
||||
public addImageFromImageBitmap(imageBitmap: ImageBitmap) {
|
||||
const imageNode = new ImageNode({
|
||||
mediaLayerRef: this.mediaLayer,
|
||||
canvasPosition: this.realTimeDrawEngine.captureCanvas.position(),
|
||||
canvasSize: this.realTimeDrawEngine.captureCanvas.size(),
|
||||
imageBitmap: imageBitmap,
|
||||
selectionManagerRef: this.selectionManager,
|
||||
loaded: async () => {
|
||||
await this.realTimeDrawEngine.render();
|
||||
this.setEditMode(EditModes.SELECT);
|
||||
},
|
||||
});
|
||||
|
||||
this.commandManager.createNode(imageNode);
|
||||
}
|
||||
|
||||
public addPaintNode(
|
||||
canvas: HTMLCanvasElement,
|
||||
lineBounds: {
|
||||
width: number;
|
||||
height: number;
|
||||
x: number;
|
||||
y: number;
|
||||
},
|
||||
) {
|
||||
var node = new PaintNode({
|
||||
canvasElement: canvas,
|
||||
lineBounds: lineBounds,
|
||||
mediaLayerRef: this.mediaLayer,
|
||||
selectionManagerRef: this.selectionManager,
|
||||
loaded: async () => {
|
||||
await this.realTimeDrawEngine.render();
|
||||
},
|
||||
});
|
||||
this.commandManager.createNode(node);
|
||||
}
|
||||
// Events for Undo and Redo
|
||||
private addKeyboardShortcuts() {
|
||||
window.addEventListener("keydown", (event) => {
|
||||
if (event.ctrlKey && event.key === "z") {
|
||||
this.undoStackManager.undo();
|
||||
} else if (
|
||||
(event.ctrlKey && event.key === "y") ||
|
||||
(event.ctrlKey && event.shiftKey && event.key === "Z")
|
||||
) {
|
||||
this.undoStackManager.redo();
|
||||
} else if (event.key === "Delete") {
|
||||
this.commandManager.deleteNodes();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const ensureBase64Prefix = (base64String: string) => {
|
||||
return base64String.startsWith("data:")
|
||||
? base64String
|
||||
: `data:image/png;base64,${base64String}`;
|
||||
}
|
||||
|
||||
export { ensureBase64Prefix };
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
import Konva from "konva";
|
||||
|
||||
import { MediaNode, Position, RGBColor, Transformation } from "../types";
|
||||
import {
|
||||
|
||||
CreateCommand,
|
||||
DeleteCommand,
|
||||
LockNodesCommand,
|
||||
MoveLayerDown,
|
||||
MoveLayerUp,
|
||||
|
||||
TransformCommand,
|
||||
TranslateCommand,
|
||||
UndoStackManager,
|
||||
UnlockNodesCommand,
|
||||
|
||||
} from "../UndoRedo";
|
||||
import {
|
||||
NodesManager,
|
||||
NodeTransformer,
|
||||
SelectionManager,
|
||||
} from "../NodesManagers";
|
||||
import { RealTimeDrawEngine } from "../RenderingPrimitives/RealTimeDrawEngine";
|
||||
|
||||
interface EngineReferences {
|
||||
mediaLayerRef: Konva.Layer;
|
||||
nodesManagerRef: NodesManager;
|
||||
nodeTransformerRef: NodeTransformer;
|
||||
selectionManagerRef: SelectionManager;
|
||||
renderEngineRef: RealTimeDrawEngine;
|
||||
undoStackManagerRef: UndoStackManager;
|
||||
}
|
||||
|
||||
export class CommandManager {
|
||||
private mediaLayerRef: Konva.Layer;
|
||||
private nodesManagerRef: NodesManager;
|
||||
private nodeTransformerRef: NodeTransformer;
|
||||
private selectionManagerRef: SelectionManager;
|
||||
private renderEngineRef: RealTimeDrawEngine;
|
||||
private undoStackManagerRef: UndoStackManager;
|
||||
|
||||
constructor(engineRefs: EngineReferences) {
|
||||
this.mediaLayerRef = engineRefs.mediaLayerRef;
|
||||
this.nodesManagerRef = engineRefs.nodesManagerRef;
|
||||
this.nodeTransformerRef = engineRefs.nodeTransformerRef;
|
||||
this.selectionManagerRef = engineRefs.selectionManagerRef;
|
||||
this.renderEngineRef = engineRefs.renderEngineRef;
|
||||
this.undoStackManagerRef = engineRefs.undoStackManagerRef;
|
||||
}
|
||||
|
||||
createNode(node: MediaNode) {
|
||||
const command = new CreateCommand({
|
||||
nodes: new Set<MediaNode>([node]),
|
||||
mediaLayerRef: this.mediaLayerRef,
|
||||
nodesManagerRef: this.nodesManagerRef,
|
||||
nodeTransformerRef: this.nodeTransformerRef,
|
||||
selectionManagerRef: this.selectionManagerRef,
|
||||
renderEngineRef: this.renderEngineRef,
|
||||
});
|
||||
this.undoStackManagerRef.executeCommand(command);
|
||||
|
||||
// Set the kNode's manual id to same as kNode's internal _id
|
||||
// This helps identify the kNode in the Konva layer with find
|
||||
node.kNode.id(node.kNode._id.toString());
|
||||
//this.renderEngineRef.render();
|
||||
}
|
||||
deleteNodes() {
|
||||
const nodes = this.selectionManagerRef.getSelectedNodes();
|
||||
const command = new DeleteCommand({
|
||||
nodes: nodes,
|
||||
mediaLayerRef: this.mediaLayerRef,
|
||||
nodesManagerRef: this.nodesManagerRef,
|
||||
nodeTransformerRef: this.nodeTransformerRef,
|
||||
selectionManagerRef: this.selectionManagerRef,
|
||||
renderEngineRef: this.renderEngineRef,
|
||||
});
|
||||
this.undoStackManagerRef.executeCommand(command);
|
||||
//this.renderEngineRef.render();
|
||||
}
|
||||
|
||||
deleteSpecificNodes(nodes: MediaNode[]) {
|
||||
const command = new DeleteCommand({
|
||||
nodes: nodes,
|
||||
mediaLayerRef: this.mediaLayerRef,
|
||||
nodesManagerRef: this.nodesManagerRef,
|
||||
nodeTransformerRef: this.nodeTransformerRef,
|
||||
selectionManagerRef: this.selectionManagerRef,
|
||||
renderEngineRef: this.renderEngineRef,
|
||||
});
|
||||
this.undoStackManagerRef.executeCommand(command);
|
||||
//this.renderEngineRef.render();
|
||||
}
|
||||
|
||||
toggleLockNodes() {
|
||||
const nodes = this.selectionManagerRef.getSelectedNodes();
|
||||
const node = nodes.values().next().value;
|
||||
if (!node) {
|
||||
console.log("Node Not Found for Locking");
|
||||
return;
|
||||
}
|
||||
if (node.isLocked()) {
|
||||
const command = new UnlockNodesCommand({
|
||||
nodes: this.selectionManagerRef.getSelectedNodes(),
|
||||
});
|
||||
this.undoStackManagerRef.executeCommand(command);
|
||||
} else {
|
||||
const command = new LockNodesCommand({
|
||||
nodes: this.selectionManagerRef.getSelectedNodes(),
|
||||
});
|
||||
this.undoStackManagerRef.executeCommand(command);
|
||||
}
|
||||
}
|
||||
moveNodesUp() {
|
||||
const command = new MoveLayerUp({
|
||||
nodes: this.selectionManagerRef.getSelectedNodes(),
|
||||
nodesManagerRef: this.nodesManagerRef,
|
||||
mediaLayerRef: this.mediaLayerRef,
|
||||
});
|
||||
this.undoStackManagerRef.executeCommand(command);
|
||||
//this.renderEngineRef.render();
|
||||
}
|
||||
moveNodesDown() {
|
||||
const command = new MoveLayerDown({
|
||||
nodes: this.selectionManagerRef.getSelectedNodes(),
|
||||
nodesManagerRef: this.nodesManagerRef,
|
||||
mediaLayerRef: this.mediaLayerRef,
|
||||
});
|
||||
this.undoStackManagerRef.executeCommand(command);
|
||||
//this.renderEngineRef.render();
|
||||
}
|
||||
translateNodes(props: {
|
||||
nodes: Set<MediaNode>;
|
||||
initialPositions: Map<MediaNode, Position>;
|
||||
finalPositions: Map<MediaNode, Position>;
|
||||
}) {
|
||||
const command = new TranslateCommand({
|
||||
...props,
|
||||
layerRef: this.mediaLayerRef,
|
||||
});
|
||||
this.undoStackManagerRef.pushCommand(command);
|
||||
//this.renderEngineRef.render();
|
||||
}
|
||||
transformNodes(props: {
|
||||
nodes: Set<MediaNode>;
|
||||
initialTransformations: Map<MediaNode, Transformation[]>;
|
||||
finalTransformations: Map<MediaNode, Transformation[]>;
|
||||
}) {
|
||||
const command = new TransformCommand({
|
||||
...props,
|
||||
layerRef: this.mediaLayerRef,
|
||||
});
|
||||
this.undoStackManagerRef.pushCommand(command);
|
||||
//this.renderEngineRef.render();
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import Konva from "konva";
|
||||
import { Size } from "../types";
|
||||
import { Colors } from "../constants";
|
||||
import { uiAccess } from "../../signals/uiAccess";
|
||||
|
||||
export class MatteBox {
|
||||
private matteBoxPoly: Konva.Line;
|
||||
private lightBoxRect: Konva.Rect;
|
||||
private uiLayerRef: Konva.Layer;
|
||||
private boardCanvasSize: Size;
|
||||
private captureCanvasSize: Size;
|
||||
|
||||
constructor({
|
||||
boardCanvasSize,
|
||||
captureCanvasSize,
|
||||
uiLayerRef,
|
||||
}: {
|
||||
boardCanvasSize: Size;
|
||||
captureCanvasSize: Size;
|
||||
uiLayerRef: Konva.Layer;
|
||||
}) {
|
||||
this.uiLayerRef = uiLayerRef;
|
||||
this.boardCanvasSize = boardCanvasSize;
|
||||
this.captureCanvasSize = captureCanvasSize;
|
||||
this.matteBoxPoly = new Konva.Line({
|
||||
points: getMatteBoxPolygonPoints({
|
||||
boardCanvasSize: this.boardCanvasSize,
|
||||
captureCanvasSize: this.captureCanvasSize,
|
||||
}),
|
||||
fill: Colors.translucentBlack,
|
||||
closed: true,
|
||||
});
|
||||
this.lightBoxRect = new Konva.Rect({
|
||||
size: captureCanvasSize,
|
||||
position: {
|
||||
x: (boardCanvasSize.width - captureCanvasSize.width) / 2,
|
||||
y: (boardCanvasSize.height - captureCanvasSize.height) / 2,
|
||||
},
|
||||
fill: Colors.transparent,
|
||||
// fill: "rgba(100,0,0,0.3)",
|
||||
});
|
||||
}
|
||||
public enable(showMagicBox?: boolean) {
|
||||
this.uiLayerRef.add(this.matteBoxPoly);
|
||||
this.uiLayerRef.add(this.lightBoxRect);
|
||||
if (showMagicBox) {
|
||||
uiAccess.magicBox.show();
|
||||
}
|
||||
}
|
||||
public disable() {
|
||||
this.matteBoxPoly.remove();
|
||||
this.lightBoxRect.remove();
|
||||
if (uiAccess.magicBox.isShowing()) {
|
||||
uiAccess.magicBox.hide();
|
||||
}
|
||||
}
|
||||
public updateSize({
|
||||
boardCanvasSize,
|
||||
captureCanvasSize,
|
||||
}: {
|
||||
boardCanvasSize?: Size;
|
||||
captureCanvasSize?: Size;
|
||||
}) {
|
||||
if (boardCanvasSize) {
|
||||
this.boardCanvasSize = boardCanvasSize;
|
||||
}
|
||||
if (captureCanvasSize) {
|
||||
this.captureCanvasSize = captureCanvasSize;
|
||||
}
|
||||
this.matteBoxPoly.points(
|
||||
getMatteBoxPolygonPoints({
|
||||
boardCanvasSize: this.boardCanvasSize,
|
||||
captureCanvasSize: this.captureCanvasSize,
|
||||
}),
|
||||
);
|
||||
this.lightBoxRect.setAttrs({
|
||||
size: this.captureCanvasSize,
|
||||
position: {
|
||||
x: (this.boardCanvasSize.width - this.captureCanvasSize.width) / 2,
|
||||
y: (this.boardCanvasSize.height - this.captureCanvasSize.height) / 2,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function getMatteBoxPolygonPoints({
|
||||
boardCanvasSize: board,
|
||||
captureCanvasSize: capture,
|
||||
}: {
|
||||
boardCanvasSize: Size;
|
||||
captureCanvasSize: Size;
|
||||
}) {
|
||||
return [
|
||||
//top left
|
||||
0,
|
||||
0,
|
||||
// top right
|
||||
board.width,
|
||||
0,
|
||||
//bottom right
|
||||
board.width,
|
||||
board.height,
|
||||
//bottom middle
|
||||
board.width / 2,
|
||||
board.height,
|
||||
//canvas bottom middle,
|
||||
board.width / 2,
|
||||
(board.height - capture.height) / 2 + capture.height,
|
||||
//canvas bottom right,
|
||||
(board.width - capture.width) / 2 + capture.width,
|
||||
(board.height - capture.height) / 2 + capture.height,
|
||||
//canvas top right,
|
||||
(board.width - capture.width) / 2 + capture.width,
|
||||
(board.height - capture.height) / 2,
|
||||
//canvas top left,
|
||||
(board.width - capture.width) / 2,
|
||||
(board.height - capture.height) / 2,
|
||||
//canvas bottom left,
|
||||
(board.width - capture.width) / 2,
|
||||
(board.height - capture.height) / 2 + capture.height,
|
||||
//canvas bottom middle,
|
||||
board.width / 2,
|
||||
(board.height - capture.height) / 2 + capture.height,
|
||||
//bottom middle
|
||||
board.width / 2,
|
||||
board.height,
|
||||
//bottom left
|
||||
0,
|
||||
board.height,
|
||||
//back to top left
|
||||
0,
|
||||
0,
|
||||
];
|
||||
}
|
||||
+230
@@ -0,0 +1,230 @@
|
||||
import Konva from "konva";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
// TODO: USE API STORTELLE
|
||||
import { MediaFilesApi, MediaUploadApi } from "~/Classes/ApiManager";
|
||||
import { NodesManager, SelectionManager } from "../NodesManagers";
|
||||
import {
|
||||
ImageNodeData,
|
||||
NodeData,
|
||||
TextNodeData,
|
||||
TransformationData,
|
||||
VideoNodeData,
|
||||
} from "../types";
|
||||
import { uiAccess } from "../../signals/uiAccess";
|
||||
import { NavigateFunction } from "react-router-dom";
|
||||
import { NodeType } from "../Nodes/constants";
|
||||
import { ImageNode, TextNode } from "../Nodes";
|
||||
import { RealTimeDrawEngine as RenderEngine } from "../RenderingPrimitives/RealTimeDrawEngine";
|
||||
|
||||
|
||||
export class SceneManager {
|
||||
private navigateRef: NavigateFunction;
|
||||
|
||||
|
||||
private mediaLayerRef: Konva.Layer;
|
||||
private nodesManagerRef: NodesManager;
|
||||
private selectionManagerRef: SelectionManager;
|
||||
private renderEngineRef: RenderEngine;
|
||||
private currentSceneToken: string | undefined;
|
||||
|
||||
constructor({
|
||||
navigateRef,
|
||||
|
||||
mediaLayerRef,
|
||||
nodesManagerRef,
|
||||
selectionManagerRef,
|
||||
renderEngineRef,
|
||||
}: {
|
||||
navigateRef: NavigateFunction;
|
||||
|
||||
mediaLayerRef: Konva.Layer;
|
||||
nodesManagerRef: NodesManager;
|
||||
selectionManagerRef: SelectionManager;
|
||||
renderEngineRef: RenderEngine;
|
||||
}) {
|
||||
this.navigateRef = navigateRef;
|
||||
|
||||
this.mediaLayerRef = mediaLayerRef;
|
||||
this.nodesManagerRef = nodesManagerRef;
|
||||
this.selectionManagerRef = selectionManagerRef;
|
||||
this.renderEngineRef = renderEngineRef;
|
||||
}
|
||||
|
||||
public async saveScene() {
|
||||
const mediaUploadApi = new MediaUploadApi();
|
||||
const timestamp = new Date();
|
||||
const sceneTitle =
|
||||
"board_" + timestamp.toISOString().substring(0, 19).replace(/\D/g, "");
|
||||
|
||||
const saveJson = this.extractAllNodesData();
|
||||
console.log("saveJson", saveJson);
|
||||
const file = new File([saveJson], `${sceneTitle}`, {
|
||||
type: "application/json",
|
||||
});
|
||||
if (!this.currentSceneToken) {
|
||||
const uploadResponse = await mediaUploadApi.UploadNewScene({
|
||||
blob: file,
|
||||
fileName: file.name,
|
||||
uuid: uuidv4(),
|
||||
});
|
||||
console.log(uploadResponse);
|
||||
if (!uploadResponse.success || !uploadResponse.data) {
|
||||
uiAccess.dialogError.show({
|
||||
title: "Save New Scene Error",
|
||||
message: uploadResponse.errorMessage,
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.currentSceneToken = uploadResponse.data;
|
||||
this.navigateRef(`/${this.currentSceneToken}`);
|
||||
} else {
|
||||
const uploadResponse = await mediaUploadApi.UploadSavedScene({
|
||||
blob: file,
|
||||
fileName: file.name,
|
||||
uuid: uuidv4(),
|
||||
mediaToken: this.currentSceneToken,
|
||||
});
|
||||
console.log(uploadResponse);
|
||||
if (!uploadResponse.success || !uploadResponse.data) {
|
||||
uiAccess.dialogError.show({
|
||||
title: "Save Scene Error",
|
||||
message: uploadResponse.errorMessage,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async loadScene(mediaFileToken: string) {
|
||||
const mediaFileApi = new MediaFilesApi();
|
||||
const getSceneResponse = await mediaFileApi.GetMediaFileByToken({
|
||||
mediaFileToken: mediaFileToken,
|
||||
});
|
||||
if (!getSceneResponse.success || !getSceneResponse.data) {
|
||||
uiAccess.dialogError.show({
|
||||
title: "Load Scene Error",
|
||||
message: getSceneResponse.errorMessage,
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.currentSceneToken = mediaFileToken;
|
||||
// console.log(getSceneResponse.data);
|
||||
// console.log(getSceneResponse.data.public_bucket_path);
|
||||
const fileResponse = await fetch(getSceneResponse.data.public_bucket_url);
|
||||
if (!fileResponse.ok) {
|
||||
uiAccess.dialogError.show({
|
||||
title: "Load Scene Error",
|
||||
message: "Failed to download Scene File",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const fileBlob = await fileResponse.blob();
|
||||
|
||||
const loadJson: NodeData[] = await new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
const readerError = () => {
|
||||
uiAccess.dialogError.show({
|
||||
title: "Load Scene Error",
|
||||
message: "Failed to read Scene File",
|
||||
});
|
||||
this.navigateRef("/");
|
||||
console.log("debug file Response", fileResponse);
|
||||
console.log("debug file Blob", fileBlob);
|
||||
reject();
|
||||
};
|
||||
reader.onloadend = () => {
|
||||
try {
|
||||
resolve(JSON.parse(reader.result as string));
|
||||
} catch {
|
||||
readerError();
|
||||
}
|
||||
};
|
||||
reader.onerror = () => {
|
||||
readerError();
|
||||
};
|
||||
reader.readAsText(fileBlob);
|
||||
});
|
||||
|
||||
this.rebuildScene(loadJson);
|
||||
}
|
||||
|
||||
private extractAllNodesData() {
|
||||
const nodesData: NodeData[] = [];
|
||||
this.nodesManagerRef.getAllNodes().forEach((node) => {
|
||||
const nodeData = node.getNodeData(
|
||||
this.renderEngineRef.captureCanvas.position(),
|
||||
);
|
||||
if (nodeData !== null) {
|
||||
nodesData.push(nodeData);
|
||||
}
|
||||
});
|
||||
nodesData.sort((a, b) => a.transform.zIndex - b.transform.zIndex);
|
||||
return JSON.stringify(nodesData);
|
||||
}
|
||||
|
||||
private rebuildScene(nodesData: NodeData[]) {
|
||||
// console.log(nodesData);
|
||||
nodesData.forEach((nodeDatum) => {
|
||||
if (nodeDatum.type === NodeType.IMAGE && nodeDatum.imageNodeData) {
|
||||
this.addImage(nodeDatum.imageNodeData, nodeDatum.transform);
|
||||
} else if (nodeDatum.type === NodeType.VIDEO && nodeDatum.videoNodeData) {
|
||||
this.addVideo(nodeDatum.videoNodeData, nodeDatum.transform);
|
||||
} else if (nodeDatum.type === NodeType.TEXT && nodeDatum.textNodeData) {
|
||||
this.addText({
|
||||
textNodeData: nodeDatum.textNodeData,
|
||||
transform: nodeDatum.transform,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private addImage(
|
||||
imageNodeData: ImageNodeData,
|
||||
transform: TransformationData,
|
||||
) {
|
||||
const imageNode = new ImageNode({
|
||||
mediaFileUrl: imageNodeData.mediaFileUrl,
|
||||
mediaLayerRef: this.mediaLayerRef,
|
||||
canvasPosition: this.renderEngineRef.captureCanvas.position(),
|
||||
canvasSize: this.renderEngineRef.captureCanvas.size(),
|
||||
transform: transform,
|
||||
selectionManagerRef: this.selectionManagerRef,
|
||||
});
|
||||
this.nodesManagerRef.saveNode(imageNode);
|
||||
this.renderEngineRef.addNodes(imageNode);
|
||||
}
|
||||
private addVideo(
|
||||
videoNodeData: VideoNodeData,
|
||||
transform: TransformationData,
|
||||
) {
|
||||
const videoNode = new VideoNode({
|
||||
loadingVideosProviderRef: this.loadingVideosProviderRef,
|
||||
mediaLayerRef: this.mediaLayerRef,
|
||||
selectionManagerRef: this.selectionManagerRef,
|
||||
canvasPosition: this.renderEngineRef.captureCanvas.position(),
|
||||
canvasSize: this.renderEngineRef.captureCanvas.size(),
|
||||
videoNodeData: videoNodeData,
|
||||
transform: transform,
|
||||
});
|
||||
this.nodesManagerRef.saveNode(videoNode);
|
||||
this.renderEngineRef.addNodes(videoNode);
|
||||
}
|
||||
private addText({
|
||||
textNodeData,
|
||||
transform,
|
||||
}: {
|
||||
textNodeData: TextNodeData;
|
||||
transform: TransformationData;
|
||||
}) {
|
||||
const textNode = new TextNode({
|
||||
textNodeData: textNodeData,
|
||||
transform: transform,
|
||||
mediaLayerRef: this.mediaLayerRef,
|
||||
canvasPosition: this.renderEngineRef.captureCanvas.position(),
|
||||
canvasSize: this.renderEngineRef.captureCanvas.size(),
|
||||
selectionManagerRef: this.selectionManagerRef,
|
||||
});
|
||||
this.nodesManagerRef.saveNode(textNode);
|
||||
this.renderEngineRef.addNodes(textNode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { CommandManager } from "./CommandManager";
|
||||
export { MatteBox } from "./MatteBox";
|
||||
export { SceneManager } from "./SceneManager";
|
||||
@@ -0,0 +1,46 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import {
|
||||
cancelGeneration,
|
||||
finishGeneration,
|
||||
onStartGeneration,
|
||||
} from "~/signals/generation/generationSignals";
|
||||
|
||||
export class GenerationEngine {
|
||||
private isGenerating = false;
|
||||
|
||||
constructor() {
|
||||
console.log("GenerationEngine constructor");
|
||||
this.attachEventListeners();
|
||||
}
|
||||
|
||||
private attachEventListeners() {
|
||||
onStartGeneration((prompt) => {
|
||||
this.invokeGeneration(prompt);
|
||||
});
|
||||
}
|
||||
|
||||
private async invokeGeneration(prompt: string) {
|
||||
if (this.isGenerating) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isGenerating = true;
|
||||
console.log("Invoking generation for prompt:", prompt);
|
||||
|
||||
try {
|
||||
const base64BitmapResponse = await invoke("text_to_image", {
|
||||
prompt: prompt,
|
||||
});
|
||||
|
||||
console.log("Generation finished with response:", base64BitmapResponse);
|
||||
|
||||
const base64string = base64BitmapResponse as string;
|
||||
finishGeneration(base64string);
|
||||
} catch (error) {
|
||||
console.error("Error during image processing:", error);
|
||||
cancelGeneration();
|
||||
} finally {
|
||||
this.isGenerating = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import Konva from "konva";
|
||||
|
||||
import { SelectionManager } from "../NodesManagers";
|
||||
import { highlightStrokeWidth, primaryOrange } from "./constants";
|
||||
import { NodeData, Position } from "../types";
|
||||
import { RealTimeDrawEngine } from "../RenderingPrimitives/RealTimeDrawEngine";
|
||||
import { MouseEventHandler } from "react";
|
||||
import { KonvaEventObject } from "konva/lib/Node";
|
||||
|
||||
export type SupportedNode = Konva.Image | Konva.Group | Konva.Shape;
|
||||
|
||||
export abstract class BaseNode {
|
||||
protected selectionManagerRef: SelectionManager;
|
||||
public kNode: SupportedNode;
|
||||
protected mediaLayerRef: Konva.Layer;
|
||||
|
||||
// Internal State members
|
||||
// do not modify internal
|
||||
protected isSelecting: boolean = false;
|
||||
// This locks interaction when the render engine is rendering
|
||||
protected isProcessing: boolean = false;
|
||||
protected _isLocked: boolean = false;
|
||||
protected _areBaseEventsListening: boolean = false;
|
||||
protected _isKDragEventListening: boolean = false;
|
||||
protected _isKTranformEventListening: boolean = false;
|
||||
|
||||
abstract getNodeData(captureCanvasPosition: Position): NodeData | null;
|
||||
|
||||
constructor({
|
||||
selectionManagerRef,
|
||||
mediaLayerRef,
|
||||
kNode,
|
||||
}: {
|
||||
selectionManagerRef: SelectionManager;
|
||||
mediaLayerRef: Konva.Layer;
|
||||
kNode: SupportedNode;
|
||||
}) {
|
||||
// console.log("Node constructed");
|
||||
// this.uuid = uuidv4();
|
||||
this.mediaLayerRef = mediaLayerRef;
|
||||
this.selectionManagerRef = selectionManagerRef;
|
||||
this.kNode = kNode;
|
||||
}
|
||||
|
||||
protected delete() {
|
||||
// Do any other clean up and delete the konva node.
|
||||
this.kNode.destroy();
|
||||
}
|
||||
public async setProcessing(val: boolean) {
|
||||
this.isProcessing = val;
|
||||
}
|
||||
public isKEventRef() {
|
||||
return this._isKDragEventListening && this._isKTranformEventListening;
|
||||
}
|
||||
public setIsKEventRef(flag: boolean) {
|
||||
this._isKDragEventListening = flag;
|
||||
this._isKTranformEventListening = flag;
|
||||
this.removeListenToBaseKNodeTransformations();
|
||||
this.removeListenToBaseKNodeDrags();
|
||||
if (flag) {
|
||||
this.listenToBaseKNodeTransformations();
|
||||
this.listenToBaseKNodeDrags();
|
||||
}
|
||||
}
|
||||
public highlight() {
|
||||
// console.log("Highlight", this.kNode._id);
|
||||
if (this.kNode instanceof Konva.Image || this.kNode instanceof Konva.Shape) {
|
||||
this.kNode.stroke(primaryOrange);
|
||||
this.kNode.strokeWidth(highlightStrokeWidth);
|
||||
return;
|
||||
}
|
||||
if (this.kNode instanceof Konva.Group) {
|
||||
const wrapperRect = this.kNode.findOne(".wrapper") as Konva.Rect;
|
||||
console.log("Wrapper rect:", wrapperRect);
|
||||
if (wrapperRect != null) {
|
||||
wrapperRect.stroke(primaryOrange);
|
||||
wrapperRect.strokeWidth(highlightStrokeWidth);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn(
|
||||
"DEV: BaseNode Highlight Error, kNode of BaseNode should be override by MediaNodes that extends kNode",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public unhighlight() {
|
||||
// console.log("unHighlight", this.kNode._id);
|
||||
if (this.kNode instanceof Konva.Image || this.kNode instanceof Konva.Shape) {
|
||||
this.kNode.strokeWidth(0);
|
||||
return;
|
||||
}
|
||||
if (this.kNode instanceof Konva.Group) {
|
||||
const wrapperRect = this.kNode.findOne(".wrapper") as Konva.Rect;
|
||||
console.log("Wrapper rect:", wrapperRect);
|
||||
if (wrapperRect != null) {
|
||||
// Issue investigate
|
||||
wrapperRect.strokeWidth(0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn(
|
||||
"DEV: BaseNode UnHighlight Error, kNode of BaseNode should be override by MediaNodes that extends kNode",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public moveLayerUp() {
|
||||
this.kNode.moveUp();
|
||||
}
|
||||
public moveLayerDown() {
|
||||
this.kNode.moveDown();
|
||||
}
|
||||
public isLocked() {
|
||||
return this._isLocked;
|
||||
}
|
||||
public lock() {
|
||||
this._isLocked = true;
|
||||
this.kNode.setDraggable(false);
|
||||
this.selectionManagerRef.updateNodeTransformer();
|
||||
if (this.isKEventRef()) {
|
||||
this.selectionManagerRef.updateContextComponents();
|
||||
}
|
||||
}
|
||||
public unlock() {
|
||||
this._isLocked = false;
|
||||
this.kNode.setDraggable(true);
|
||||
this.selectionManagerRef.updateNodeTransformer();
|
||||
if (this.isKEventRef()) {
|
||||
this.selectionManagerRef.updateContextComponents();
|
||||
}
|
||||
}
|
||||
|
||||
public listenToBaseKNode() {
|
||||
if (this._areBaseEventsListening) {
|
||||
return;
|
||||
}
|
||||
this._areBaseEventsListening = true;
|
||||
const handleSelect = (isMultiSelect: boolean) => {
|
||||
// console.log("handle select");
|
||||
if (!isMultiSelect) {
|
||||
// clear selection if not multislect
|
||||
console.log("No Shift >> no multiselect");
|
||||
this.selectionManagerRef.clearSelection();
|
||||
}
|
||||
if (this.selectionManagerRef.isNodeSelected(this)) {
|
||||
this.selectionManagerRef.deselectNode(this);
|
||||
return;
|
||||
}
|
||||
if (this.isSelecting) {
|
||||
this.selectionManagerRef.selectNode(this);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const handleRightClick = (e: KonvaEventObject<MouseEvent>) => {
|
||||
this.selectionManagerRef.clearSelection();
|
||||
this.selectionManagerRef.selectNode(this, undefined, {
|
||||
show: true,
|
||||
at: { x: e.evt.x, y: e.evt.y }
|
||||
});
|
||||
}
|
||||
|
||||
this.kNode.on("contextmenu", (e) => {
|
||||
console.log("CONTEXT MENU", e);
|
||||
e.evt.preventDefault();
|
||||
handleRightClick(e);
|
||||
});
|
||||
|
||||
this.kNode.on("mousedown", (e) => {
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/UIEvent/which
|
||||
// 3: Right mouse button
|
||||
// If right click, let context event handle it
|
||||
if (e.evt.button === 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Selection of Node
|
||||
if (!this.selectionManagerRef.isNodeSelected(this)) {
|
||||
this.isSelecting = true;
|
||||
// send shift key to check for multiselect
|
||||
handleSelect(e.evt.shiftKey);
|
||||
}
|
||||
});
|
||||
|
||||
this.kNode.on("mouseup", (e) => {
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/UIEvent/which
|
||||
// 3: Right mouse button
|
||||
// If right click, let context event handle it
|
||||
if (e.evt.button === 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
// just coming out of dragging or selecting mode
|
||||
// no need to handle select
|
||||
if (this.selectionManagerRef.isDragging() || this.isSelecting) {
|
||||
this.isSelecting = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// checking for multiselect, in multiselect deselection is possible
|
||||
handleSelect(e.evt.shiftKey);
|
||||
});
|
||||
}
|
||||
public removeListenToBaseKNode() {
|
||||
this._areBaseEventsListening = false;
|
||||
this.kNode.removeEventListener("mousedown mouseup");
|
||||
}
|
||||
private handleDragStart = () => {
|
||||
console.log("Drag start", this.kNode._id);
|
||||
if (this.isProcessing) {
|
||||
return;
|
||||
}
|
||||
this.selectionManagerRef.dragStart();
|
||||
};
|
||||
|
||||
private handleDragEnd = () => {
|
||||
this.selectionManagerRef.dragEnd();
|
||||
};
|
||||
|
||||
public listenToBaseKNodeDrags() {
|
||||
this.kNode.on("dragstart", this.handleDragStart);
|
||||
this.kNode.on("dragend", this.handleDragEnd);
|
||||
}
|
||||
|
||||
public removeListenToBaseKNodeDrags() {
|
||||
this.kNode.off("dragstart", this.handleDragStart);
|
||||
this.kNode.off("dragend", this.handleDragEnd);
|
||||
}
|
||||
public listenToBaseKNodeTransformations() {
|
||||
this.kNode.on("transformstart", (event) => {
|
||||
console.log("transformstart", event.target._id);
|
||||
this.selectionManagerRef.transformStart();
|
||||
});
|
||||
this.kNode.on("transform", () => {
|
||||
this.kNode.setAttrs({
|
||||
width: this.kNode.width() * this.kNode.scaleX(),
|
||||
height: this.kNode.height() * this.kNode.scaleY(),
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
});
|
||||
});
|
||||
this.kNode.on("transformend", (event) => {
|
||||
console.log("transformend", event.target._id);
|
||||
this.selectionManagerRef.transformEnd();
|
||||
});
|
||||
}
|
||||
public removeListenToBaseKNodeTransformations() {
|
||||
this.kNode.removeEventListener("transformend");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import Konva from "konva";
|
||||
import { MediaFilesApi, MediaUploadApi } from "~/Classes/ApiManager";
|
||||
|
||||
import { SelectionManager } from "../NodesManagers";
|
||||
import { Position, Size, NodeData, TransformationData } from "../types";
|
||||
|
||||
import { NetworkedNode, UploadStatus } from "./NetworkedNode";
|
||||
import { NodeType, transparent } from "./constants";
|
||||
import { NodeUtilities } from "./NodeUtilities";
|
||||
|
||||
interface ImageNodeContructor {
|
||||
canvasPosition: Position;
|
||||
canvasSize: Size;
|
||||
imageFile?: File;
|
||||
imageBitmap?: ImageBitmap;
|
||||
mediaFileToken?: string;
|
||||
mediaFileUrl?: string;
|
||||
transform?: TransformationData;
|
||||
mediaLayerRef: Konva.Layer;
|
||||
selectionManagerRef: SelectionManager;
|
||||
loaded: () => Promise<void>;
|
||||
}
|
||||
|
||||
export class ImageNode extends NetworkedNode {
|
||||
public kNode: Konva.Image;
|
||||
public imageSize?: Size;
|
||||
private loaded!: () => Promise<void>;
|
||||
constructor({
|
||||
canvasPosition,
|
||||
canvasSize,
|
||||
imageFile,
|
||||
mediaFileToken,
|
||||
mediaFileUrl,
|
||||
transform: existingTransform,
|
||||
mediaLayerRef,
|
||||
selectionManagerRef,
|
||||
loaded,
|
||||
imageBitmap,
|
||||
}: ImageNodeContructor) {
|
||||
// kNodes need to be created first to guaruntee
|
||||
// that it is not undefined in parent's context
|
||||
const transform = NodeUtilities.getInitialTransform({
|
||||
existingTransform,
|
||||
canvasPosition,
|
||||
canvasSize,
|
||||
});
|
||||
const kNode = new Konva.Image({
|
||||
image: undefined, // to do replace with placeholder
|
||||
...transform,
|
||||
draggable: true,
|
||||
strokeScaleEnabled: false,
|
||||
});
|
||||
super({
|
||||
selectionManagerRef: selectionManagerRef,
|
||||
mediaLayerRef: mediaLayerRef,
|
||||
kNode: kNode,
|
||||
localFile: imageFile,
|
||||
});
|
||||
this.kNode = kNode;
|
||||
this.loaded = loaded;
|
||||
// this.imageSize = minNodeSize;
|
||||
this.mediaLayerRef.add(this.kNode);
|
||||
|
||||
if (imageFile) {
|
||||
this.loadImageFromFile({
|
||||
imageFile: imageFile,
|
||||
maxSize: canvasSize,
|
||||
refPosition: canvasPosition,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (mediaFileUrl && transform) {
|
||||
this.mediaFileToken = mediaFileToken;
|
||||
this.mediaFileUrl = mediaFileUrl;
|
||||
this.loadImageFromUrl(mediaFileUrl);
|
||||
return;
|
||||
}
|
||||
if (imageBitmap) {
|
||||
this.loadImageFromImageBitmap({
|
||||
imageBitmap: imageBitmap,
|
||||
maxSize: canvasSize,
|
||||
refPosition: canvasPosition,
|
||||
});
|
||||
return;
|
||||
}
|
||||
console.log("image node creation is fucked");
|
||||
}
|
||||
private loadImageFromFile({
|
||||
imageFile,
|
||||
maxSize,
|
||||
refPosition,
|
||||
}: {
|
||||
imageFile: File;
|
||||
maxSize: Size;
|
||||
refPosition: Position;
|
||||
}) {
|
||||
const imageComponent = new Image();
|
||||
imageComponent.crossOrigin = "anonymous";
|
||||
// this.setProgress({ progress: 100, status: UploadStatus.SUCCESS });
|
||||
|
||||
imageComponent.onload = () => {
|
||||
//this.setProgress({ progress: 0, status: UploadStatus.FILE_STAGED });
|
||||
this.imageSize = {
|
||||
width: imageComponent.width,
|
||||
height: imageComponent.height,
|
||||
};
|
||||
const adjustedSize = NodeUtilities.adjustNodeSizeToCanvas({
|
||||
componentSize: this.imageSize,
|
||||
maxSize: maxSize,
|
||||
});
|
||||
const centerPosition = NodeUtilities.positionNodeOnCanvasCenter({
|
||||
canvasOffset: refPosition,
|
||||
componentSize: adjustedSize,
|
||||
maxSize: maxSize,
|
||||
});
|
||||
this.kNode.image(imageComponent);
|
||||
this.kNode.setSize(adjustedSize);
|
||||
this.kNode.setPosition(centerPosition);
|
||||
this.kNode.moveToTop();
|
||||
this.kNode.fill(transparent);
|
||||
this.listenToBaseKNode();
|
||||
this.mediaLayerRef.draw();
|
||||
//this.uploadImage(imageFile);
|
||||
this.loaded();
|
||||
};
|
||||
imageComponent.onerror = () => {
|
||||
//this.setProgress({ progress: 0, status: UploadStatus.ERROR_ON_FILE });
|
||||
};
|
||||
imageComponent.src = URL.createObjectURL(imageFile);
|
||||
}
|
||||
private async loadImageFromUrl(mediaFileUrl: string) {
|
||||
//this.setProgress({ progress: 75, status: UploadStatus.LOADING });
|
||||
|
||||
const newImage = new Image();
|
||||
newImage.crossOrigin = "anonymous";
|
||||
newImage.onerror = () => {
|
||||
//this.setProgress({ progress: 90, status: UploadStatus.ERROR_ON_LOAD });
|
||||
};
|
||||
newImage.onload = () => {
|
||||
// console.log("network image", newImage);
|
||||
this.kNode.image(newImage);
|
||||
this.kNode.draw();
|
||||
//this.setProgress({ progress: 100, status: UploadStatus.SUCCESS });
|
||||
};
|
||||
newImage.src = mediaFileUrl;
|
||||
this.listenToBaseKNode();
|
||||
}
|
||||
|
||||
private loadImageFromImageBitmap({
|
||||
imageBitmap,
|
||||
maxSize,
|
||||
refPosition,
|
||||
}: {
|
||||
imageBitmap: ImageBitmap;
|
||||
maxSize: Size;
|
||||
refPosition: Position;
|
||||
}) {
|
||||
this.imageSize = {
|
||||
width: imageBitmap.width,
|
||||
height: imageBitmap.height,
|
||||
};
|
||||
const adjustedSize = NodeUtilities.adjustNodeSizeToCanvas({
|
||||
componentSize: this.imageSize,
|
||||
maxSize: maxSize, // TODO
|
||||
});
|
||||
const centerPosition = NodeUtilities.positionNodeOnCanvasCenter({
|
||||
canvasOffset: refPosition, // TODO
|
||||
componentSize: adjustedSize, // TODO
|
||||
maxSize: maxSize, // TODO
|
||||
});
|
||||
this.kNode.image(imageBitmap);
|
||||
this.kNode.setSize(adjustedSize);
|
||||
this.kNode.setPosition(centerPosition);
|
||||
this.kNode.moveToTop();
|
||||
this.kNode.fill(transparent);
|
||||
this.listenToBaseKNode();
|
||||
this.mediaLayerRef.draw();
|
||||
//this.uploadImage(imageFile);
|
||||
this.loaded();
|
||||
this.listenToBaseKNode();
|
||||
}
|
||||
|
||||
private async uploadImage(imageFile: File) {
|
||||
//this.setProgress({ progress: 10, status: UploadStatus.UPLOADING });
|
||||
|
||||
const mediaUploadApi = new MediaUploadApi();
|
||||
const uploadResponse = await mediaUploadApi.UploadImage({
|
||||
blob: imageFile,
|
||||
uuid: this.uuidGenerate(),
|
||||
fileName: imageFile.name,
|
||||
});
|
||||
// console.log(uploadResponse);
|
||||
if (!uploadResponse.success || !uploadResponse.data) {
|
||||
this.setStatus(UploadStatus.ERROR_ON_UPLOAD, uploadResponse.errorMessage);
|
||||
return;
|
||||
}
|
||||
this.mediaFileToken = uploadResponse.data;
|
||||
this.retreiveImage(this.mediaFileToken);
|
||||
}
|
||||
|
||||
private async retreiveImage(mediaFileToken: string) {
|
||||
//this.setProgress({ progress: 50, status: UploadStatus.RETREIVING });
|
||||
const mediaFileApi = new MediaFilesApi();
|
||||
const mediaFileResponse = await mediaFileApi.GetMediaFileByToken({
|
||||
mediaFileToken: mediaFileToken,
|
||||
});
|
||||
// console.log(mediaFileResponse);
|
||||
if (!mediaFileResponse.success || !mediaFileResponse.data) {
|
||||
this.setStatus(
|
||||
UploadStatus.ERROR_ON_RETREIVE,
|
||||
mediaFileResponse.errorMessage,
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.mediaFileUrl = mediaFileResponse.data.media_links.cdn_url;
|
||||
this.loadImageFromUrl(this.mediaFileUrl);
|
||||
}
|
||||
|
||||
public async retry() {
|
||||
if (this.mediaFileUrl) {
|
||||
this.loadImageFromUrl(this.mediaFileUrl);
|
||||
return;
|
||||
}
|
||||
if (this.mediaFileToken) {
|
||||
this.retreiveImage(this.mediaFileToken);
|
||||
return;
|
||||
}
|
||||
if (this.localFile) {
|
||||
this.uploadImage(this.localFile);
|
||||
return;
|
||||
}
|
||||
console.warn("Image Node has no data to recontruct itself!");
|
||||
}
|
||||
public getNodeData(canvasPostion: Position) {
|
||||
if (!this.mediaFileUrl) {
|
||||
console.error("Image Node can not be saved");
|
||||
return null;
|
||||
}
|
||||
const data: NodeData = {
|
||||
type: NodeType.IMAGE,
|
||||
transform: {
|
||||
position: {
|
||||
x: this.kNode.position().x - canvasPostion.x,
|
||||
y: this.kNode.position().y - canvasPostion.y,
|
||||
},
|
||||
size: this.kNode.size(),
|
||||
rotation: this.kNode.rotation(),
|
||||
scale: {
|
||||
x: this.kNode.scaleX(),
|
||||
y: this.kNode.scaleY(),
|
||||
},
|
||||
zIndex: this.kNode.getZIndex(),
|
||||
},
|
||||
imageNodeData: {
|
||||
mediaFileUrl: this.mediaFileUrl,
|
||||
mediaFileToken: this.mediaFileToken,
|
||||
},
|
||||
};
|
||||
return data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import Konva from "konva";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { SelectionManager } from "../NodesManagers";
|
||||
import { BaseNode, SupportedNode } from "./BaseNode";
|
||||
import { Size } from "../types";
|
||||
import { LoadingVideosProvider } from "../EngineUtitlities/LoadingVideosProvider";
|
||||
import { NodeProgressEventDetail } from "../types/events";
|
||||
|
||||
export enum UploadStatus {
|
||||
INIT = "init",
|
||||
FILE_STAGED = "file_staged",
|
||||
ERROR_ON_FILE = "error_on_file",
|
||||
UPLOADING = "uploading",
|
||||
ERROR_ON_UPLOAD = "error_on_upload",
|
||||
RETREIVING = "retreiving",
|
||||
ERROR_ON_RETREIVE = "error_on_retreive",
|
||||
LOADING = "loading",
|
||||
ERROR_ON_LOAD = "error_on_load",
|
||||
SUCCESS = "success",
|
||||
}
|
||||
|
||||
export abstract class NetworkedNode extends BaseNode {
|
||||
public kNode: SupportedNode;
|
||||
|
||||
// members to deal with loading progress
|
||||
public didFinishLoading: boolean = false;
|
||||
protected _progress: number = 0;
|
||||
protected _progressMessage?: string;
|
||||
protected progressStatus: string = UploadStatus.INIT;
|
||||
public progressEvent: EventTarget;
|
||||
|
||||
// members to deal with the file carried
|
||||
protected localFile?: File;
|
||||
protected mediaFileToken?: string;
|
||||
protected mediaFileUrl?: string;
|
||||
public mediaFileSize?: Size;
|
||||
|
||||
// loading screen
|
||||
protected loadingVideosProviderRef?: LoadingVideosProvider;
|
||||
|
||||
// error and retry handling
|
||||
public errorMessage?: string;
|
||||
abstract retry(): void;
|
||||
|
||||
constructor({
|
||||
kNode,
|
||||
selectionManagerRef,
|
||||
mediaLayerRef,
|
||||
localFile,
|
||||
loadingVideosProviderRef,
|
||||
}: {
|
||||
kNode: SupportedNode;
|
||||
selectionManagerRef: SelectionManager;
|
||||
mediaLayerRef: Konva.Layer;
|
||||
loadingVideosProviderRef?: LoadingVideosProvider;
|
||||
localFile?: File;
|
||||
}) {
|
||||
super({
|
||||
kNode,
|
||||
selectionManagerRef,
|
||||
mediaLayerRef,
|
||||
});
|
||||
this.kNode = kNode;
|
||||
this.loadingVideosProviderRef = loadingVideosProviderRef;
|
||||
this.didFinishLoading = false;
|
||||
this.progressEvent = new EventTarget();
|
||||
this.localFile = localFile;
|
||||
}
|
||||
public progress() {
|
||||
return this._progress;
|
||||
}
|
||||
public progressMessage() {
|
||||
return this._progressMessage;
|
||||
}
|
||||
public status() {
|
||||
return this.progressStatus;
|
||||
}
|
||||
public isError() {
|
||||
const errorStatues: string[] = [
|
||||
UploadStatus.ERROR_ON_FILE,
|
||||
UploadStatus.ERROR_ON_UPLOAD,
|
||||
UploadStatus.ERROR_ON_RETREIVE,
|
||||
UploadStatus.ERROR_ON_LOAD,
|
||||
];
|
||||
return errorStatues.includes(this.progressStatus);
|
||||
}
|
||||
|
||||
protected uuidGenerate() {
|
||||
return uuidv4();
|
||||
}
|
||||
protected setStatus(newStatus: string, message?: string) {
|
||||
this.progressStatus = newStatus;
|
||||
this.errorMessage = message;
|
||||
this.selectionManagerRef.updateContextComponents();
|
||||
}
|
||||
protected setProgress<S>({
|
||||
progress,
|
||||
name,
|
||||
status,
|
||||
message,
|
||||
}: {
|
||||
progress: number;
|
||||
name?: string;
|
||||
status?: S;
|
||||
message?: string;
|
||||
}) {
|
||||
this._progress = progress;
|
||||
if (message) {
|
||||
this._progressMessage = message;
|
||||
}
|
||||
if (status && typeof status === "string") {
|
||||
this.progressStatus = status;
|
||||
}
|
||||
|
||||
if (this._progress === 100) {
|
||||
this.didFinishLoading = true;
|
||||
}
|
||||
// TODO: change to use
|
||||
this.progressEvent.dispatchEvent(
|
||||
new CustomEvent<NodeProgressEventDetail<S>>(
|
||||
name ? name : "GenericNodeProgress",
|
||||
{
|
||||
detail: {
|
||||
node: this,
|
||||
progress: this._progress,
|
||||
message: message,
|
||||
status: status,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
this.selectionManagerRef.updateContextComponents();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import Konva from "konva";
|
||||
import { Position, Size, TransformationData } from "../types";
|
||||
import { minNodeSize, transparent } from "./constants";
|
||||
|
||||
export const NodeUtilities = {
|
||||
adjustNodeSizeToCanvas,
|
||||
downloadOffscreenCanvas,
|
||||
getInitialTransform,
|
||||
isAssetUrlAvailable,
|
||||
positionNodeOnCanvasCenter,
|
||||
printKNodeAttrs,
|
||||
urlToBlob,
|
||||
};
|
||||
function downloadOffscreenCanvas(canvas: OffscreenCanvas) {
|
||||
canvas.convertToBlob().then((blob) => {
|
||||
if (blob) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "offscreen-canvas-image.png"; // Specify the file name
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url); // Clean up
|
||||
}
|
||||
});
|
||||
}
|
||||
function getInitialTransform({
|
||||
existingTransform,
|
||||
mediaFileSize,
|
||||
canvasPosition,
|
||||
canvasSize,
|
||||
}: {
|
||||
existingTransform?: TransformationData;
|
||||
mediaFileSize?: Size;
|
||||
canvasPosition: Position;
|
||||
canvasSize: Size;
|
||||
}) {
|
||||
return existingTransform
|
||||
? {
|
||||
...existingTransform,
|
||||
position: {
|
||||
x: existingTransform.position.x + canvasPosition.x,
|
||||
y: existingTransform.position.y + canvasPosition.y,
|
||||
},
|
||||
fill: transparent,
|
||||
}
|
||||
: {
|
||||
position: positionNodeOnCanvasCenter({
|
||||
canvasOffset: canvasPosition,
|
||||
componentSize: mediaFileSize ?? minNodeSize,
|
||||
maxSize: canvasSize,
|
||||
}),
|
||||
size: mediaFileSize ?? minNodeSize,
|
||||
fill: "gray",
|
||||
};
|
||||
}
|
||||
|
||||
function adjustNodeSizeToCanvas({
|
||||
componentSize,
|
||||
maxSize,
|
||||
}: {
|
||||
componentSize: Size;
|
||||
maxSize: Size;
|
||||
}) {
|
||||
const adjustedSize = {
|
||||
width: componentSize.width,
|
||||
height: componentSize.height,
|
||||
};
|
||||
if (adjustedSize.width > maxSize.width) {
|
||||
const scaleDown = maxSize.width / adjustedSize.width;
|
||||
adjustedSize.width = adjustedSize.width * scaleDown;
|
||||
adjustedSize.height = adjustedSize.height * scaleDown;
|
||||
}
|
||||
if (adjustedSize.height > maxSize.height) {
|
||||
const scaleDownAgain = maxSize.height / adjustedSize.height;
|
||||
adjustedSize.width = adjustedSize.width * scaleDownAgain;
|
||||
adjustedSize.height = adjustedSize.height * scaleDownAgain;
|
||||
}
|
||||
return adjustedSize;
|
||||
}
|
||||
|
||||
function positionNodeOnCanvasCenter({
|
||||
canvasOffset,
|
||||
componentSize,
|
||||
maxSize,
|
||||
}: {
|
||||
canvasOffset: Position;
|
||||
componentSize: Size;
|
||||
maxSize: Size;
|
||||
}) {
|
||||
return {
|
||||
x: canvasOffset.x + maxSize.width / 2 - componentSize.width / 2,
|
||||
y: canvasOffset.y + maxSize.height / 2 - componentSize.height / 2,
|
||||
};
|
||||
}
|
||||
|
||||
function printKNodeAttrs(kNode: Konva.Node) {
|
||||
console.log({
|
||||
position: kNode.position(),
|
||||
size: kNode.size(),
|
||||
scale: kNode.scale(),
|
||||
rotation: kNode.rotation(),
|
||||
});
|
||||
}
|
||||
async function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
// Function to check if the URL returns a 200 status
|
||||
async function checkUrl(url: string): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
return response.status === 200;
|
||||
} catch (error) {
|
||||
console.error("Error fetching URL:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// wait using a while loop to display the result.
|
||||
async function isAssetUrlAvailable({
|
||||
url,
|
||||
sleepDurationMs = 500,
|
||||
totalRetries,
|
||||
}: {
|
||||
url: string;
|
||||
sleepDurationMs?: number;
|
||||
totalRetries?: number;
|
||||
}): Promise<boolean> {
|
||||
let retryCount = 0;
|
||||
let isAvailable = false;
|
||||
const totalRetriesReached = () => {
|
||||
if (!totalRetries) {
|
||||
return false;
|
||||
}
|
||||
return retryCount >= totalRetries;
|
||||
};
|
||||
while (!isAvailable && !totalRetriesReached()) {
|
||||
isAvailable = await checkUrl(url);
|
||||
if (!isAvailable) {
|
||||
console.log("Asset at Url not available yet, retrying...");
|
||||
retryCount = retryCount + 1;
|
||||
await sleep(sleepDurationMs);
|
||||
} else if (import.meta.env.DEV) {
|
||||
console.log("Preview Image is available:", url);
|
||||
}
|
||||
}
|
||||
return isAvailable;
|
||||
}
|
||||
|
||||
async function urlToBlob(url: string): Promise<Blob> {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch resource: ${response.statusText}`);
|
||||
}
|
||||
const blob = await response.blob();
|
||||
return blob;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import Konva from "konva";
|
||||
import { SelectionManager } from "../NodesManagers";
|
||||
import { Position, NodeData } from "../types";
|
||||
import { NodeType } from "./constants";
|
||||
|
||||
import { NetworkedNode } from "./NetworkedNode";
|
||||
|
||||
interface PaintNodeConstructor {
|
||||
canvasElement: HTMLCanvasElement;
|
||||
lineBounds: {
|
||||
width: number;
|
||||
height: number;
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
mediaLayerRef: Konva.Layer;
|
||||
selectionManagerRef: SelectionManager;
|
||||
|
||||
loaded: () => Promise<void>;
|
||||
}
|
||||
|
||||
export class PaintNode extends NetworkedNode {
|
||||
retry(): void {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
constructor({
|
||||
canvasElement,
|
||||
lineBounds,
|
||||
mediaLayerRef,
|
||||
selectionManagerRef,
|
||||
loaded,
|
||||
}: PaintNodeConstructor) {
|
||||
const cl = canvasElement;
|
||||
|
||||
// Convert circle to image
|
||||
const dataURL = cl.toDataURL();
|
||||
const imageObj = new Image();
|
||||
imageObj.src = dataURL;
|
||||
|
||||
const paintShape = new Konva.Image({
|
||||
x: lineBounds.x,
|
||||
y: lineBounds.y,
|
||||
width: lineBounds.width,
|
||||
height: lineBounds.height,
|
||||
image: imageObj,
|
||||
listening: false, // Disable event listening to allow captureCanvas to receive events
|
||||
draggable: true,
|
||||
// stroke: '#000000',
|
||||
// strokeWidth: 2,
|
||||
// dash: [5, 5], // Creates dotted outline THIS IS FOR DEBUG
|
||||
globalCompositeOperation: "source-over",
|
||||
fill: "transparent",
|
||||
});
|
||||
|
||||
imageObj.onload = () => {
|
||||
this.mediaLayerRef.draw();
|
||||
loaded();
|
||||
};
|
||||
|
||||
super({
|
||||
selectionManagerRef: selectionManagerRef,
|
||||
mediaLayerRef: mediaLayerRef,
|
||||
kNode: paintShape,
|
||||
});
|
||||
this.kNode.moveToTop();
|
||||
this.mediaLayerRef.add(this.kNode);
|
||||
this.listenToBaseKNode();
|
||||
this.mediaLayerRef.draw();
|
||||
}
|
||||
|
||||
public getNodeData(canvasPosition: Position) {
|
||||
const data: NodeData = {
|
||||
type: NodeType.SHAPE,
|
||||
transform: {
|
||||
position: {
|
||||
x: this.kNode.position().x - canvasPosition.x,
|
||||
y: this.kNode.position().y - canvasPosition.y,
|
||||
},
|
||||
size: this.kNode.size(),
|
||||
rotation: this.kNode.rotation(),
|
||||
scale: {
|
||||
x: this.kNode.scaleX(),
|
||||
y: this.kNode.scaleY(),
|
||||
},
|
||||
zIndex: this.kNode.getZIndex(),
|
||||
},
|
||||
};
|
||||
return data;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user