Compare commits

...
Author SHA1 Message Date
Brandon Thomas 16f93cba52 route requests based on configured service 2025-06-27 21:10:23 -04:00
Brandon Thomas 045aefcd13 set and get provider priority 2025-06-27 20:52:20 -04:00
Brandon Thomas 5f6bee5a87 update priority 2025-06-27 20:32:45 -04:00
Brandon Thomas 068ff6ac10 get/set provider priority 2025-06-27 19:47:44 -04:00
Brandon Thomas 2b5bedf131 provider preference model 2025-06-27 18:43:47 -04:00
ArEnScandCrossPr0duct 3d06f89477 Feature/aspect ratio (#682)
Co-authored-by: CrossPr0duct <alicextensor@gmail.com>
2025-06-26 14:09:52 -04:00
CrossPr0duct d007cb81d5 Fix Drag and Drop Image, I had thought this change was already in. 2025-06-25 16:40:41 -04:00
CrossPr0duct 221d8bb95a the two magic undocumented values that will actually have this work without a bunch of code. 2025-06-25 15:52:33 -04:00
Brandon Thomas 760639d071 video model plumbing (#693) 2025-06-25 14:59:11 -04:00
Brandon Thomas 4e68169faf cookie debugging 2025-06-24 22:28:48 -04:00
Brandon Thomas 8090cdb526 call api endpoints from frontend (#692) 2025-06-24 15:41:02 -04:00
bflatastic ada4c77093 modal closable with esc key (for floating panels that's draggable and background interactable) 2025-06-24 08:22:00 +07:00
bflatastic 7bb14e1b17 topbar minor padding fix 2025-06-24 08:11:25 +07:00
bflatastic f9c8343165 library asset downloaded toast 2025-06-24 08:06:30 +07:00
Tanish Manku 53eb46d2b6 Add 500ms of timeout on tab switching (#691) 2025-06-23 13:03:57 -07:00
bflatastic f5a3ff67b3 small updates to router priority pane 2025-06-23 22:04:55 +07:00
BFlat 6b262d9fcb model router priority drag and drop reordering and login fixes (#690)
Model router priority tab in settings:
- drag and drop to update
- function calls on order change

Login refactor:
- fixed login (opens modal on log out)
- fixes bug where login modal opens even before login state is checked
(annoying)
- uses zustand states now
2025-06-21 02:22:32 +07:00
ArEnScandCrossPr0duct 744df6bcb8 Fixed issue with image background removal and drag and drop. (#689)
Co-authored-by: CrossPr0duct <alicextensor@gmail.com>
2025-06-20 12:55:26 -04:00
bflatastic 9a5ab77d3c don't return profile dropdown 2025-06-20 23:07:18 +07:00
Brandon Thomas 3e42ccc5e5 add artcraft/storyteller host to app info for debugging (#688) 2025-06-20 08:36:47 -04:00
BFlat 81bf7d712c feature/pricing modal (#684)
- pricing modal with tiers: free, plus, pro and max
- set up and ready to integrate with stripe
- small bug fix with the library modal buttons being squished

- upgrade button in TopBar is commented out for now
2025-06-20 12:36:37 +07:00
ArEnScandCrossPr0duct e9fe8bdf0d Fix/login (#686)
Moved Login to top level.
Minor bug fix later., logout after login in and exiting, log out and the button
won't trigger the modal

---------

Co-authored-by: CrossPr0duct <alicextensor@gmail.com>
2025-06-20 01:07:18 -04:00
Brandon Thomas 627fd6beeb roll back Cargo.lock 2025-06-20 00:46:06 -04:00
Brandon Thomas 77788a74e9 fix imports 2025-06-19 21:30:22 -04:00
Brandon Thomas 691f27eb23 misc: refactor client, move endpoints, more models, local testing, etc. etc. (#683) 2025-06-19 20:41:46 -04:00
Brandon Thomas 9008e1f4f4 logging for fal webhook handler 2025-06-19 16:04:38 -04:00
ArEnScandCrossPr0duct c11894692c Background removal race condition fixed. (#681)
Co-authored-by: CrossPr0duct <alicextensor@gmail.com>
2025-06-19 13:22:25 -04:00
Kasisnu e7d8613d09 tweaks to the action 2025-06-19 17:01:19 +05:30
Kasisnu 55832bdfc2 basic tauri/macos action 2025-06-19 16:52:32 +05:30
Brandon Thomas 49f18d7616 remaining tauri -> backend -> fal endpoints (#679) 2025-06-19 06:43:44 -04:00
152 changed files with 3489 additions and 594 deletions
+107
View File
@@ -0,0 +1,107 @@
name: "Artcraft Publish"
on:
push:
branches:
- release
- artcraft-release
# This workflow creates Universal macOS builds with signing certificate support.
# On each push to the release branches it will create or update a GitHub release, build your app, and upload the artifacts to the release.
jobs:
publish-artcraft:
permissions:
contents: write
strategy:
fail-fast: false
matrix:
include:
# Universal macOS builds (arm64 and x86_64)
- platform: 'macos-latest'
args: '--target universal-apple-darwin'
# Uncomment below for additional platforms
# - platform: 'ubuntu-22.04'
# args: ''
# - platform: 'windows-latest'
# args: ''
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@v4
- name: setup node
uses: actions/setup-node@v4
with:
node-version: lts/*
- name: install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
- name: install dependencies (ubuntu only)
if: matrix.platform == 'ubuntu-22.04'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
- name: install frontend dependencies
run: |
cd ./crates/desktop/artcraft
yarn install
- name: import Apple Developer Certificate
if: matrix.platform == 'macos-latest'
# Prevents keychain from locking automatically for 3600 seconds.
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
echo $APPLE_CERTIFICATE | base64 --decode > certificate.p12
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security default-keychain -s build.keychain
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security set-keychain-settings -t 3600 -u build.keychain
security import certificate.p12 -k build.keychain -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain
security find-identity -v -p codesigning build.keychain
- name: verify certificate
if: matrix.platform == 'macos-latest'
run: |
echo "Available certificates:"
security find-identity -v -p codesigning build.keychain
# Try multiple certificate patterns
CERT_INFO=$(security find-identity -v -p codesigning build.keychain | grep -E "(Developer ID Application|Apple Development|Storyteller Developer)" | head -1)
if [ -z "$CERT_INFO" ]; then
echo "No matching certificate found. Using first available certificate."
CERT_INFO=$(security find-identity -v -p codesigning build.keychain | grep -v "Policy" | head -1)
fi
CERT_ID=$(echo "$CERT_INFO" | awk -F'"' '{print $2}')
echo "CERT_ID=$CERT_ID" >> $GITHUB_ENV
echo "Using certificate: $CERT_ID"
- name: build and publish
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ env.CERT_ID }}
with:
projectPath: ./crates/desktop/artcraft
tagName: artcraft-v__VERSION__ # the action automatically replaces __VERSION__ with the app version.
releaseName: "Artcraft v__VERSION__"
releaseBody: "See the assets to download this version and install."
releaseDraft: true
prerelease: false
args: ${{ matrix.args }}
@@ -16,6 +16,9 @@
"allow": [
{
"url": "https://*"
},
{
"url": "http://localhost:12345"
}
]
},
@@ -1,12 +1,21 @@
use crate::core::commands::enqueue::image::handle_image_artcraft::handle_image_artcraft;
use crate::core::commands::enqueue::image::handle_image_fal::handle_image_fal;
use crate::core::commands::enqueue::image::handle_image_sora::handle_image_sora;
use crate::core::commands::enqueue::image::internal_image_error::InternalImageError;
use crate::core::commands::enqueue::image::success_event::SuccessEvent;
use crate::core::commands::enqueue::object::handle_object_artcraft::handle_object_artcraft;
use crate::core::commands::response::failure_response_wrapper::{CommandErrorResponseWrapper, CommandErrorStatus};
use crate::core::commands::response::shorthand::Response;
use crate::core::commands::response::success_response_wrapper::SerializeMarker;
use crate::core::events::basic_sendable_event_trait::BasicSendableEvent;
use crate::core::events::generation_events::common::{GenerationAction, GenerationServiceProvider};
use crate::core::events::generation_events::generation_enqueue_success_event::GenerationEnqueueSuccessEvent;
use crate::core::events::sendable_event_trait::SendableEvent;
use crate::core::model::image_models::ImageModel;
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::core::state::data_dir::trait_data_subdir::DataSubdir;
use crate::core::state::provider_priority::{Provider, ProviderPriorityStore};
use crate::core::utils::download_media_file_to_temp_dir::download_media_file_to_temp_dir;
use crate::core::utils::get_url_file_extension::get_url_file_extension;
use crate::core::utils::save_base64_image_to_temp_dir::save_base64_image_to_temp_dir;
@@ -55,8 +64,6 @@ use storyteller_client::media_files::upload_image_media_file_from_file::upload_i
use storyteller_client::utils::api_host::ApiHost;
use tauri::{AppHandle, Emitter, Manager, State};
use tempfile::NamedTempFile;
use crate::core::commands::enqueue::image::handle_image_artcraft::handle_image_artcraft;
use crate::core::commands::enqueue::object::handle_object_artcraft::handle_object_artcraft;
#[derive(Deserialize)]
pub struct EnqueueTextToImageRequest {
@@ -64,19 +71,9 @@ pub struct EnqueueTextToImageRequest {
pub prompt: Option<String>,
/// The model to use.
pub model: Option<EnqueueTextToImageModel>,
pub model: Option<ImageModel>,
}
#[derive(Deserialize, Debug, Copy, Clone)]
#[serde(rename_all = "snake_case")]
pub enum EnqueueTextToImageModel {
#[serde(rename = "flux_pro_ultra")]
FluxProUltra,
#[serde(rename = "gpt_image_1")]
GptImage1,
#[serde(rename = "recraft_3")]
Recraft3,
}
#[derive(Serialize)]
pub struct EnqueueTextToImageSuccessResponse {
@@ -89,6 +86,8 @@ impl SerializeMarker for EnqueueTextToImageSuccessResponse {}
pub enum EnqueueTextToImageErrorType {
/// Caller didn't specify a model
ModelNotSpecified,
/// No model available for image generation
NoProviderAvailable,
/// Generic server error
ServerError,
/// No Fal API key available
@@ -105,6 +104,8 @@ pub async fn enqueue_text_to_image_command(
app: AppHandle,
request: EnqueueTextToImageRequest,
app_data_root: State<'_, AppDataRoot>,
app_env_configs: State<'_, AppEnvConfigs>,
provider_priority_store: State<'_, ProviderPriorityStore>,
fal_creds_manager: State<'_, FalCredentialManager>,
storyteller_creds_manager: State<'_, StorytellerCredentialManager>,
fal_task_queue: State<'_, FalTaskQueue>,
@@ -118,8 +119,10 @@ pub async fn enqueue_text_to_image_command(
&app,
request,
&app_data_root,
&provider_priority_store,
&fal_creds_manager,
&storyteller_creds_manager,
&app_env_configs,
&fal_task_queue,
&sora_creds_manager,
&sora_task_queue,
@@ -139,6 +142,11 @@ pub async fn enqueue_text_to_image_command(
error_type = EnqueueTextToImageErrorType::ModelNotSpecified;
error_message = "No model specified for image generation";
}
InternalImageError::NoProviderAvailable => {
status = CommandErrorStatus::ServerError;
error_type = EnqueueTextToImageErrorType::NoProviderAvailable;
error_message = "No configured provider available for image generation";
}
InternalImageError::NeedsFalApiKey => {
status = CommandErrorStatus::Unauthorized;
error_type = EnqueueTextToImageErrorType::NeedsFalApiKey;
@@ -154,7 +162,17 @@ pub async fn enqueue_text_to_image_command(
error_details: None,
})
}
Ok(()) => {
Ok(event) => {
let event = GenerationEnqueueSuccessEvent {
action: GenerationAction::GenerateImage,
service: event.service_provider,
model: Some(event.tauri_event_model()),
};
if let Err(err) = event.send(&app) {
error!("Failed to emit event: {:?}", err); // Fail open.
}
Ok(EnqueueTextToImageSuccessResponse {}.into())
}
}
@@ -165,31 +183,47 @@ pub async fn handle_request(
app: &AppHandle,
request: EnqueueTextToImageRequest,
app_data_root: &AppDataRoot,
provider_priority_store: &ProviderPriorityStore,
fal_creds_manager: &FalCredentialManager,
storyteller_creds_manager: &StorytellerCredentialManager,
app_env_configs: &AppEnvConfigs,
fal_task_queue: &FalTaskQueue,
sora_creds_manager: &SoraCredentialManager,
sora_task_queue: &SoraTaskQueue,
) -> Result<(), InternalImageError> {
) -> Result<SuccessEvent, InternalImageError> {
match request.model {
None => {
return Err(InternalImageError::NoModelSpecified);
}
Some(EnqueueTextToImageModel::GptImage1) => {
Some(ImageModel::GptImage1) => {
handle_image_sora(&app, request, sora_creds_manager, sora_task_queue).await?;
return Ok(());
return Ok(SuccessEvent {
service_provider: GenerationServiceProvider::Sora,
model: ImageModel::GptImage1,
});
}
_ => {
// Fall-through
}
};
if fal_creds_manager.has_apparent_api_token()? {
handle_image_fal(&app, request, fal_creds_manager, fal_task_queue).await?;
} else {
handle_image_artcraft(request, &app, app_data_root, storyteller_creds_manager).await?;
let priority = provider_priority_store.get_priority()?;
for provider in priority.iter() {
match provider {
Provider::Sora => {} // Fallthrough
Provider::Artcraft => {
return handle_image_artcraft(
request, &app, app_env_configs, app_data_root, storyteller_creds_manager).await;
}
Provider::Fal => {
if fal_creds_manager.has_apparent_api_token()? {
return handle_image_fal(&app, request, fal_creds_manager, fal_task_queue).await;
}
}
}
}
Ok(())
Err(InternalImageError::NoProviderAvailable)
}
@@ -1,9 +1,12 @@
use crate::core::commands::enqueue::image::enqueue_text_to_image_command::{EnqueueTextToImageModel, EnqueueTextToImageRequest};
use crate::core::commands::enqueue::image::enqueue_text_to_image_command::EnqueueTextToImageRequest;
use crate::core::commands::enqueue::image::internal_image_error::InternalImageError;
use crate::core::commands::enqueue::image::success_event::SuccessEvent;
use crate::core::events::basic_sendable_event_trait::BasicSendableEvent;
use crate::core::events::generation_events::common::{GenerationAction, GenerationModel, GenerationServiceProvider};
use crate::core::events::generation_events::generation_enqueue_failure_event::GenerationEnqueueFailureEvent;
use crate::core::events::generation_events::generation_enqueue_success_event::GenerationEnqueueSuccessEvent;
use crate::core::model::image_models::ImageModel;
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::core::utils::download_media_file_to_temp_dir::download_media_file_to_temp_dir;
use crate::core::utils::save_base64_image_to_temp_dir::save_base64_image_to_temp_dir;
@@ -13,8 +16,11 @@ use crate::services::sora::state::sora_credential_manager::SoraCredentialManager
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use anyhow::anyhow;
use artcraft_api_defs::generate::image::generate_flux_1_dev_text_to_image::GenerateFlux1DevTextToImageRequest;
use artcraft_api_defs::generate::image::generate_flux_1_schnell_text_to_image::GenerateFlux1SchnellTextToImageRequest;
use artcraft_api_defs::generate::image::generate_flux_pro_11_text_to_image::GenerateFluxPro11TextToImageRequest;
use artcraft_api_defs::generate::image::generate_flux_pro_11_ultra_text_to_image::GenerateFluxPro11UltraTextToImageRequest;
use artcraft_api_defs::generate::object::generate_hunyuan_2_image_to_3d::GenerateHunyuan2ImageTo3dRequest;
use artcraft_api_defs::generate::object::generate_hunyuan_2_0_image_to_3d::GenerateHunyuan20ImageTo3dRequest;
use artcraft_api_defs::generate::video::generate_kling_1_6_pro_image_to_video::GenerateKling16ProImageToVideoRequest;
use fal_client::creds::fal_api_key::FalApiKey;
use fal_client::requests::queue::enqueue_hunyuan2_image_to_3d::{enqueue_hunyuan2_image_to_3d, Hunyuan2Args};
@@ -22,8 +28,11 @@ use fal_client::requests::queue::image_gen::enqueue_flux_pro_11_ultra_text_to_im
use fal_client::requests::queue::image_gen::enqueue_recraft3_text_to_image::{enqueue_recraft3_text_to_image, Recraft3TextToImageArgs};
use idempotency::uuid::generate_random_uuid;
use log::{error, info, warn};
use storyteller_client::generate::image::generate_flux_1_dev_text_to_image::generate_flux_1_dev_text_to_image;
use storyteller_client::generate::image::generate_flux_1_schnell_text_to_image::generate_flux_1_schnell_text_to_image;
use storyteller_client::generate::image::generate_flux_pro_11_text_to_image::generate_flux_pro_11_text_to_image;
use storyteller_client::generate::image::generate_flux_pro_11_ultra_text_to_image::generate_flux_pro_11_ultra_text_to_image;
use storyteller_client::generate::object::generate_hunyuan2_image_to_3d::generate_hunyuan2_image_to_3d;
use storyteller_client::generate::object::generate_hunyuan_3d_2_0_image_to_3d::generate_hunyuan3d_2_0_image_to_3d;
use storyteller_client::generate::video::generate_kling_16_pro_image_to_video::generate_kling_16_pro_image_to_video;
use storyteller_client::utils::api_host::ApiHost;
use tauri::{AppHandle, State};
@@ -31,9 +40,10 @@ use tauri::{AppHandle, State};
pub async fn handle_image_artcraft(
request: EnqueueTextToImageRequest,
app: &AppHandle,
app_env_configs: &AppEnvConfigs,
app_data_root: &AppDataRoot,
storyteller_creds_manager: &StorytellerCredentialManager,
) -> Result<(), InternalImageError> {
) -> Result<SuccessEvent, InternalImageError> {
let creds = match storyteller_creds_manager.get_credentials()? {
Some(creds) => creds,
@@ -54,21 +64,96 @@ pub async fn handle_image_artcraft(
let uuid_idempotency_token = generate_random_uuid();
let mut selected_model = None;
let mut selected_model;
let job_token = match request.model {
None => {
return Err(InternalImageError::NoModelSpecified);
}
Some(EnqueueTextToImageModel::GptImage1) => {
Some(ImageModel::GptImage1) => {
return Err(InternalImageError::AnyhowError(anyhow!("wrong logic: artcraft is handling sora images")));
}
Some(EnqueueTextToImageModel::Recraft3) => {
Some(ImageModel::Recraft3) => {
return Err(InternalImageError::AnyhowError(anyhow!("not yet implemented in Artcraft")));
}
Some(EnqueueTextToImageModel::FluxProUltra) => {
info!("enqueue Flux Pro Ultra");
selected_model = Some(GenerationModel::FluxPro11Ultra);
Some(ImageModel::Flux1Dev) => {
info!("enqueue Flux 1 Dev");
selected_model = ImageModel::Flux1Dev;
let request = GenerateFlux1DevTextToImageRequest {
uuid_idempotency_token,
prompt: request.prompt,
aspect_ratio: None,
num_images: None,
};
let result = generate_flux_1_dev_text_to_image(
&app_env_configs.storyteller_host,
Some(&creds),
request,
).await;
match result {
Ok(enqueued) => {
info!("Successfully enqueued Artcraft Flux 1 Dev text to image generation");
enqueued.inference_job_token
}
Err(err) => {
error!("Failed to use Artcraft Flux 1 Dev text to image generation: {:?}", err);
return Err(InternalImageError::StorytellerError(err));
}
}
}
Some(ImageModel::Flux1Schnell) => {
info!("enqueue Flux 1 Schnell");
selected_model = ImageModel::Flux1Schnell;
let request = GenerateFlux1SchnellTextToImageRequest {
uuid_idempotency_token,
prompt: request.prompt,
aspect_ratio: None,
num_images: None,
};
let result = generate_flux_1_schnell_text_to_image(
&app_env_configs.storyteller_host,
Some(&creds),
request,
).await;
match result {
Ok(enqueued) => {
info!("Successfully enqueued Artcraft Flux 1 Schnell text to image generation");
enqueued.inference_job_token
}
Err(err) => {
error!("Failed to use Artcraft Flux 1 Schnell text to image generation: {:?}", err);
return Err(InternalImageError::StorytellerError(err));
}
}
}
Some(ImageModel::FluxPro11) => {
info!("enqueue Flux Pro 1.1");
selected_model = ImageModel::FluxPro11;
let request = GenerateFluxPro11TextToImageRequest {
uuid_idempotency_token,
prompt: request.prompt,
aspect_ratio: None,
num_images: None,
};
let result = generate_flux_pro_11_text_to_image(
&app_env_configs.storyteller_host,
Some(&creds),
request,
).await;
match result {
Ok(enqueued) => {
info!("Successfully enqueued Artcraft Flux Pro 1.1 text to image generation");
enqueued.inference_job_token
}
Err(err) => {
error!("Failed to use Artcraft Flux Pro 1.1 text to image generation: {:?}", err);
return Err(InternalImageError::StorytellerError(err));
}
}
}
Some(ImageModel::FluxPro11Ultra) => {
info!("enqueue Flux Pro 1.1 Ultra");
selected_model = ImageModel::FluxPro11Ultra;
let request = GenerateFluxPro11UltraTextToImageRequest {
uuid_idempotency_token,
prompt: request.prompt,
@@ -76,22 +161,25 @@ pub async fn handle_image_artcraft(
num_images: None,
};
let result = generate_flux_pro_11_ultra_text_to_image(
&ApiHost::Storyteller,
&app_env_configs.storyteller_host,
Some(&creds),
request,
).await;
match result {
Ok(enqueued) => {
info!("Successfully enqueued Artcraft flux pro ultra text to image generation");
info!("Successfully enqueued Artcraft Flux Pro 1.1 Ultra text to image generation");
enqueued.inference_job_token
}
Err(err) => {
error!("Failed to use Artcraft flux pro ultra text to image generation: {:?}", err);
error!("Failed to use Artcraft Flux Pro 1.1 Ultra text to image generation: {:?}", err);
return Err(InternalImageError::StorytellerError(err));
}
}
}
};
Ok(())
Ok(SuccessEvent {
service_provider: GenerationServiceProvider::Artcraft,
model:selected_model,
})
}
@@ -1,9 +1,11 @@
use crate::core::commands::enqueue::image::enqueue_text_to_image_command::{EnqueueTextToImageModel, EnqueueTextToImageRequest};
use crate::core::commands::enqueue::image::enqueue_text_to_image_command::EnqueueTextToImageRequest;
use crate::core::commands::enqueue::image::internal_image_error::InternalImageError;
use crate::core::commands::enqueue::image::success_event::SuccessEvent;
use crate::core::events::basic_sendable_event_trait::BasicSendableEvent;
use crate::core::events::generation_events::common::{GenerationAction, GenerationServiceProvider};
use crate::core::events::generation_events::generation_enqueue_failure_event::GenerationEnqueueFailureEvent;
use crate::core::events::generation_events::generation_enqueue_success_event::GenerationEnqueueSuccessEvent;
use crate::core::model::image_models::ImageModel;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::services::fal::state::fal_credential_manager::FalCredentialManager;
use crate::services::fal::state::fal_task_queue::FalTaskQueue;
@@ -22,7 +24,7 @@ pub async fn handle_image_fal(
request: EnqueueTextToImageRequest,
fal_creds_manager: &FalCredentialManager,
fal_task_queue: &FalTaskQueue,
) -> Result<(), InternalImageError> {
) -> Result<SuccessEvent, InternalImageError> {
let api_key = match fal_creds_manager.get_key()? {
Some(key) => key,
@@ -47,21 +49,32 @@ pub async fn handle_image_fal(
let prompt = request.prompt.as_deref().unwrap_or("");
let model;
let result = match request.model {
None => {
return Err(InternalImageError::NoModelSpecified);
}
Some(EnqueueTextToImageModel::GptImage1) => {
Some(ImageModel::GptImage1) => {
return Err(InternalImageError::AnyhowError(anyhow!("wrong logic: fal is handling sora images")));
}
Some(EnqueueTextToImageModel::FluxProUltra) => {
info!("enqueue Flux Pro Ultra text-to-image with prompt: {}", prompt);
Some(
ImageModel::Flux1Dev |
ImageModel::Flux1Schnell |
ImageModel::FluxPro11
) => {
return Err(InternalImageError::AnyhowError(anyhow!("not yet implemented: {:?}", request.model)));
}
Some(ImageModel::FluxPro11Ultra) => {
model = ImageModel::FluxPro11Ultra;
info!("enqueue Flux Pro 1.1 Ultra text-to-image with prompt: {}", prompt);
enqueue_flux_pro_11_ultra_text_to_image(FluxPro11UltraTextToImageArgs {
prompt,
api_key: &api_key,
}).await
}
Some(EnqueueTextToImageModel::Recraft3) => {
Some(ImageModel::Recraft3) => {
model = ImageModel::Recraft3;
info!("enqueue Recraft v3 text-to-image with prompt: {}", prompt);
enqueue_recraft3_text_to_image(Recraft3TextToImageArgs {
prompt,
@@ -74,15 +87,15 @@ pub async fn handle_image_fal(
Ok(enqueued) => {
info!("Successfully enqueued text to image");
let event = GenerationEnqueueSuccessEvent {
action: GenerationAction::GenerateImage,
service: GenerationServiceProvider::Fal,
model: None,
};
//let event = GenerationEnqueueSuccessEvent {
// action: GenerationAction::GenerateImage,
// service: GenerationServiceProvider::Fal,
// model: None,
//};
if let Err(err) = event.send(app) {
error!("Failed to emit event: {:?}", err); // Fail open.
}
//if let Err(err) = event.send(app) {
// error!("Failed to emit event: {:?}", err); // Fail open.
//}
if let Err(err) = fal_task_queue.insert(&enqueued) {
error!("Failed to enqueue task: {:?}", err);
@@ -107,5 +120,8 @@ pub async fn handle_image_fal(
}
}
Ok(())
Ok(SuccessEvent {
service_provider: GenerationServiceProvider::Fal,
model,
})
}
@@ -1,4 +1,4 @@
use crate::core::commands::enqueue::image::enqueue_text_to_image_command::{EnqueueTextToImageModel, EnqueueTextToImageRequest};
use crate::core::commands::enqueue::image::enqueue_text_to_image_command::{EnqueueTextToImageRequest};
use crate::core::commands::enqueue::image::internal_image_error::InternalImageError;
use crate::core::events::basic_sendable_event_trait::BasicSendableEvent;
use crate::core::events::generation_events::common::{GenerationAction, GenerationServiceProvider};
@@ -90,15 +90,5 @@ pub async fn handle_image_sora(
sora_task_queue.insert(&response.task_id)?;
let event = GenerationEnqueueSuccessEvent {
action: GenerationAction::GenerateImage,
service: GenerationServiceProvider::Sora,
model: None,
};
if let Err(err) = event.send(app) {
error!("Failed to emit event: {:?}", err); // Fail open.
}
Ok(())
}
@@ -8,6 +8,7 @@ use storyteller_client::error::storyteller_error::StorytellerError;
#[derive(Debug)]
pub enum InternalImageError {
NoModelSpecified,
NoProviderAvailable,
NeedsFalApiKey,
NeedsSoraCredentials,
NeedsStorytellerCredentials,
@@ -2,4 +2,5 @@ pub mod enqueue_text_to_image_command;
pub mod handle_image_artcraft;
pub mod handle_image_fal;
pub mod handle_image_sora;
pub mod internal_image_error;
pub mod internal_image_error;
mod success_event;
@@ -0,0 +1,20 @@
use crate::core::events::generation_events::common::{GenerationModel, GenerationServiceProvider};
use crate::core::model::image_models::ImageModel;
pub struct SuccessEvent {
pub service_provider: GenerationServiceProvider,
pub model: ImageModel,
}
impl SuccessEvent {
pub fn tauri_event_model(&self) -> GenerationModel {
match self.model {
ImageModel::Flux1Dev => GenerationModel::Flux1Dev,
ImageModel::Flux1Schnell => GenerationModel::Flux1Schnell,
ImageModel::FluxPro11 => GenerationModel::FluxPro11,
ImageModel::FluxPro11Ultra => GenerationModel::FluxPro11Ultra,
ImageModel::GptImage1 => GenerationModel::GptImage1,
ImageModel::Recraft3 => GenerationModel::Recraft3,
}
}
}
@@ -56,6 +56,9 @@ use storyteller_client::utils::api_host::ApiHost;
use tauri::{AppHandle, Emitter, Manager, State};
use tempfile::NamedTempFile;
use tokens::tokens::media_files::MediaFileToken;
use crate::core::commands::enqueue::image::enqueue_text_to_image_command::EnqueueTextToImageErrorType;
use crate::core::commands::enqueue::image::internal_image_error::InternalImageError;
use crate::core::state::provider_priority::{Provider, ProviderPriorityStore};
#[derive(Deserialize)]
pub struct EnqueueImageTo3dObjectRequest {
@@ -70,8 +73,13 @@ pub struct EnqueueImageTo3dObjectRequest {
#[derive(Deserialize, Debug, Copy, Clone)]
#[serde(rename_all = "snake_case")]
pub enum EnqueueImageTo3dObjectModel {
#[deprecated(note="Use `hunyuan_3d_2_0` instead")]
#[serde(rename = "hunyuan_3d_2")]
Hunyuan3d2,
#[serde(rename = "hunyuan_3d_2_0")]
Hunyuan3d2_0,
#[serde(rename = "hunyuan_3d_2_1")]
Hunyuan3d2_1,
}
#[derive(Serialize)]
@@ -85,6 +93,8 @@ impl SerializeMarker for EnqueueImageTo3dObjectSuccessResponse {}
pub enum EnqueueImageTo3dObjectErrorType {
/// Caller didn't specify a model
ModelNotSpecified,
/// No model available for object generation
NoProviderAvailable,
/// Generic server error
ServerError,
/// No Fal API key available
@@ -100,6 +110,7 @@ pub async fn enqueue_image_to_3d_object_command(
app: AppHandle,
request: EnqueueImageTo3dObjectRequest,
app_data_root: State<'_, AppDataRoot>,
provider_priority_store: State<'_, ProviderPriorityStore>,
fal_creds_manager: State<'_, FalCredentialManager>,
fal_task_queue: State<'_, FalTaskQueue>,
storyteller_creds_manager: State<'_, StorytellerCredentialManager>,
@@ -112,6 +123,7 @@ pub async fn enqueue_image_to_3d_object_command(
&app,
request,
&app_data_root,
&provider_priority_store,
&fal_creds_manager,
&storyteller_creds_manager,
&fal_task_queue,
@@ -129,7 +141,12 @@ pub async fn enqueue_image_to_3d_object_command(
InternalObjectError::NoModelSpecified => {
status = CommandErrorStatus::BadRequest;
error_type = EnqueueImageTo3dObjectErrorType::ModelNotSpecified;
error_message = "No model specified for image generation";
error_message = "No model specified for object generation";
}
InternalObjectError::NoProviderAvailable => {
status = CommandErrorStatus::ServerError;
error_type = EnqueueImageTo3dObjectErrorType::NoProviderAvailable;
error_message = "No configured provider available for object generation";
}
InternalObjectError::NeedsFalApiKey => {
status = CommandErrorStatus::Unauthorized;
@@ -162,16 +179,29 @@ pub async fn handle_request(
app: &AppHandle,
request: EnqueueImageTo3dObjectRequest,
app_data_root: &AppDataRoot,
provider_priority_store: &ProviderPriorityStore,
fal_creds_manager: &FalCredentialManager,
storyteller_creds_manager: &StorytellerCredentialManager,
fal_task_queue: &FalTaskQueue,
) -> Result<(), InternalObjectError> {
if fal_creds_manager.has_apparent_api_token()? {
handle_object_fal(&app, app_data_root, request, fal_creds_manager, fal_task_queue).await?;
} else {
handle_object_artcraft(request, &app, app_data_root, storyteller_creds_manager).await?;
let priority = provider_priority_store.get_priority()?;
for provider in priority.iter() {
match provider {
Provider::Sora => {} // Fallthrough
Provider::Artcraft => {
return Ok(handle_object_artcraft(
request, &app, app_data_root, storyteller_creds_manager).await?);
}
Provider::Fal => {
if fal_creds_manager.has_apparent_api_token()? {
return Ok(handle_object_fal(
&app, app_data_root, request, fal_creds_manager, fal_task_queue).await?);
}
}
}
}
Ok(())
Err(InternalObjectError::NoProviderAvailable)
}
@@ -1,4 +1,3 @@
use crate::core::commands::enqueue::image::enqueue_text_to_image_command::{EnqueueTextToImageModel, EnqueueTextToImageRequest};
use crate::core::commands::enqueue::object::enqueue_image_to_3d_object_command::{EnqueueImageTo3dObjectModel, EnqueueImageTo3dObjectRequest};
use crate::core::commands::enqueue::object::internal_object_error::InternalObjectError;
use crate::core::events::basic_sendable_event_trait::BasicSendableEvent;
@@ -14,14 +13,16 @@ use crate::services::sora::state::sora_credential_manager::SoraCredentialManager
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use anyhow::anyhow;
use artcraft_api_defs::generate::object::generate_hunyuan_2_image_to_3d::GenerateHunyuan2ImageTo3dRequest;
use artcraft_api_defs::generate::object::generate_hunyuan_2_0_image_to_3d::GenerateHunyuan20ImageTo3dRequest;
use artcraft_api_defs::generate::object::generate_hunyuan_2_1_image_to_3d::GenerateHunyuan21ImageTo3dRequest;
use fal_client::creds::fal_api_key::FalApiKey;
use fal_client::requests::queue::enqueue_hunyuan2_image_to_3d::{enqueue_hunyuan2_image_to_3d, Hunyuan2Args};
use fal_client::requests::queue::image_gen::enqueue_flux_pro_11_ultra_text_to_image::{enqueue_flux_pro_11_ultra_text_to_image, FluxPro11UltraTextToImageArgs};
use fal_client::requests::queue::image_gen::enqueue_recraft3_text_to_image::{enqueue_recraft3_text_to_image, Recraft3TextToImageArgs};
use idempotency::uuid::generate_random_uuid;
use log::{error, info, warn};
use storyteller_client::generate::object::generate_hunyuan2_image_to_3d::generate_hunyuan2_image_to_3d;
use storyteller_client::generate::object::generate_hunyuan_3d_2_0_image_to_3d::generate_hunyuan3d_2_0_image_to_3d;
use storyteller_client::generate::object::generate_hunyuan_3d_2_1_image_to_3d::generate_hunyuan3d_2_1_image_to_3d;
use storyteller_client::utils::api_host::ApiHost;
use tauri::{AppHandle, State};
@@ -63,13 +64,16 @@ pub async fn handle_object_artcraft(
None => {
return Err(InternalObjectError::NoModelSpecified);
}
Some(EnqueueImageTo3dObjectModel::Hunyuan3d2) => {
info!("enqueue Hunyuan 3D 2.0");
let request = GenerateHunyuan2ImageTo3dRequest {
Some(
EnqueueImageTo3dObjectModel::Hunyuan3d2 |
EnqueueImageTo3dObjectModel::Hunyuan3d2_0
) => {
info!("enqueue Artcraft Hunyuan 3D 2.0");
let request = GenerateHunyuan20ImageTo3dRequest {
uuid_idempotency_token,
media_file_token: request.image_media_token,
};
let result = generate_hunyuan2_image_to_3d(
let result = generate_hunyuan3d_2_0_image_to_3d(
&ApiHost::Storyteller,
Some(&creds),
request,
@@ -85,6 +89,28 @@ pub async fn handle_object_artcraft(
}
}
}
Some(EnqueueImageTo3dObjectModel::Hunyuan3d2_1) => {
info!("enqueue Artcraft Hunyuan 3D 2.1");
let request = GenerateHunyuan21ImageTo3dRequest {
uuid_idempotency_token,
media_file_token: request.image_media_token,
};
let result = generate_hunyuan3d_2_1_image_to_3d(
&ApiHost::Storyteller,
Some(&creds),
request,
).await;
match result {
Ok(enqueued) => {
info!("Successfully enqueued Artcraft Hunyuan 3D 2.1");
enqueued.inference_job_token
}
Err(err) => {
error!("Failed to use Artcraft Hunyuan 3D 2.1: {:?}", err);
return Err(InternalObjectError::StorytellerError(err));
}
}
}
};
info!("Successfully enqueued 3D generation");
@@ -1,4 +1,3 @@
use crate::core::commands::enqueue::image::enqueue_text_to_image_command::{EnqueueTextToImageModel, EnqueueTextToImageRequest};
use crate::core::commands::enqueue::object::enqueue_image_to_3d_object_command::{EnqueueImageTo3dObjectModel, EnqueueImageTo3dObjectRequest};
use crate::core::commands::enqueue::object::internal_object_error::InternalObjectError;
use crate::core::events::basic_sendable_event_trait::BasicSendableEvent;
@@ -67,13 +66,19 @@ pub async fn handle_object_fal(
None => {
return Err(InternalObjectError::NoModelSpecified);
}
Some(EnqueueImageTo3dObjectModel::Hunyuan3d2) => {
Some(
EnqueueImageTo3dObjectModel::Hunyuan3d2_0 |
EnqueueImageTo3dObjectModel::Hunyuan3d2
) => {
info!("enqueue Hunyuan 3D 2.0");
enqueue_hunyuan2_image_to_3d(Hunyuan2Args {
image_path: filename,
api_key: &api_key,
}).await
}
_ => {
return Err(InternalObjectError::AnyhowError(anyhow!("Wrong model specified: {:?}", request.model)));
}
};
match result {
@@ -7,6 +7,7 @@ use storyteller_client::error::storyteller_error::StorytellerError;
#[derive(Debug)]
pub enum InternalObjectError {
NoModelSpecified,
NoProviderAvailable,
NeedsFalApiKey,
NeedsSoraCredentials,
NeedsStorytellerCredentials,
@@ -1,12 +1,10 @@
use crate::core::commands::enqueue::object::handle_object_artcraft::handle_object_artcraft;
use crate::core::commands::enqueue::object::handle_object_fal::handle_object_fal;
use crate::core::commands::enqueue::video::handle_video_artcraft::handle_video_artcraft;
use crate::core::commands::enqueue::video::handle_video_fal::handle_video_fal;
use crate::core::commands::enqueue::video::internal_video_error::InternalVideoError;
use crate::core::commands::response::failure_response_wrapper::{CommandErrorResponseWrapper, CommandErrorStatus};
use crate::core::commands::response::shorthand::Response;
use crate::core::commands::response::success_response_wrapper::SerializeMarker;
use crate::core::events::sendable_event_trait::SendableEvent;
use crate::core::model::video_models::VideoModel;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::core::state::data_dir::trait_data_subdir::DataSubdir;
use crate::core::utils::download_media_file_to_temp_dir::download_media_file_to_temp_dir;
@@ -58,6 +56,9 @@ use storyteller_client::utils::api_host::ApiHost;
use tauri::{AppHandle, Emitter, Manager, State};
use tempfile::NamedTempFile;
use tokens::tokens::media_files::MediaFileToken;
use crate::core::commands::enqueue::object::enqueue_image_to_3d_object_command::EnqueueImageTo3dObjectErrorType;
use crate::core::commands::enqueue::object::internal_object_error::InternalObjectError;
use crate::core::state::provider_priority::{Provider, ProviderPriorityStore};
#[derive(Deserialize)]
pub struct EnqueueImageToVideoRequest {
@@ -66,14 +67,7 @@ pub struct EnqueueImageToVideoRequest {
pub image_media_token: Option<MediaFileToken>,
/// The model to use.
pub model: Option<EnqueueImageToVideoModel>,
}
#[derive(Deserialize, Debug, Copy, Clone)]
#[serde(rename_all = "snake_case")]
pub enum EnqueueImageToVideoModel {
#[serde(rename = "kling1_6")]
Kling16,
pub model: Option<VideoModel>,
}
#[derive(Serialize)]
@@ -87,6 +81,8 @@ impl SerializeMarker for EnqueueImageToVideoSuccessResponse {}
pub enum EnqueueImageToVideoErrorType {
/// Caller didn't specify a model
ModelNotSpecified,
/// No model available for video generation
NoProviderAvailable,
/// Generic server error
ServerError,
/// No Fal API key available
@@ -102,6 +98,7 @@ pub async fn enqueue_image_to_video_command(
app: AppHandle,
request: EnqueueImageToVideoRequest,
app_data_root: State<'_, AppDataRoot>,
provider_priority_store: State<'_, ProviderPriorityStore>,
fal_creds_manager: State<'_, FalCredentialManager>,
fal_task_queue: State<'_, FalTaskQueue>,
storyteller_creds_manager: State<'_, StorytellerCredentialManager>,
@@ -114,6 +111,7 @@ pub async fn enqueue_image_to_video_command(
&app,
request,
&app_data_root,
&provider_priority_store,
&fal_creds_manager,
&storyteller_creds_manager,
&fal_task_queue,
@@ -131,7 +129,12 @@ pub async fn enqueue_image_to_video_command(
InternalVideoError::NoModelSpecified => {
status = CommandErrorStatus::BadRequest;
error_type = EnqueueImageToVideoErrorType::ModelNotSpecified;
error_message = "No model specified for image generation";
error_message = "No model specified for video generation";
}
InternalVideoError::NoProviderAvailable => {
status = CommandErrorStatus::ServerError;
error_type = EnqueueImageToVideoErrorType::NoProviderAvailable;
error_message = "No configured provider available for video generation";
}
InternalVideoError::NeedsFalApiKey => {
status = CommandErrorStatus::Unauthorized;
@@ -164,16 +167,29 @@ pub async fn handle_request(
app: &AppHandle,
request: EnqueueImageToVideoRequest,
app_data_root: &AppDataRoot,
provider_priority_store: &ProviderPriorityStore,
fal_creds_manager: &FalCredentialManager,
storyteller_creds_manager: &StorytellerCredentialManager,
fal_task_queue: &FalTaskQueue,
) -> Result<(), InternalVideoError> {
if fal_creds_manager.has_apparent_api_token()? {
handle_video_fal(&app, app_data_root, request, fal_creds_manager, fal_task_queue).await?;
} else {
handle_video_artcraft(request, &app, app_data_root, storyteller_creds_manager).await?;
let priority = provider_priority_store.get_priority()?;
for provider in priority.iter() {
match provider {
Provider::Sora => {} // Fallthrough
Provider::Artcraft => {
return Ok(handle_video_artcraft(
request, &app, app_data_root, storyteller_creds_manager).await?);
}
Provider::Fal => {
if fal_creds_manager.has_apparent_api_token()? {
return Ok(handle_video_fal(
&app, app_data_root, request, fal_creds_manager, fal_task_queue).await?);
}
}
}
}
Ok(())
Err(InternalVideoError::NoProviderAvailable)
}
@@ -1,11 +1,11 @@
use crate::core::commands::enqueue::image::enqueue_text_to_image_command::{EnqueueTextToImageModel, EnqueueTextToImageRequest};
use crate::core::commands::enqueue::object::enqueue_image_to_3d_object_command::{EnqueueImageTo3dObjectModel, EnqueueImageTo3dObjectRequest};
use crate::core::commands::enqueue::video::enqueue_image_to_video_command::{EnqueueImageToVideoModel, EnqueueImageToVideoRequest};
use crate::core::commands::enqueue::video::enqueue_image_to_video_command::EnqueueImageToVideoRequest;
use crate::core::commands::enqueue::video::internal_video_error::InternalVideoError;
use crate::core::events::basic_sendable_event_trait::BasicSendableEvent;
use crate::core::events::generation_events::common::{GenerationAction, GenerationModel, GenerationServiceProvider};
use crate::core::events::generation_events::generation_enqueue_failure_event::GenerationEnqueueFailureEvent;
use crate::core::events::generation_events::generation_enqueue_success_event::GenerationEnqueueSuccessEvent;
use crate::core::model::video_models::VideoModel;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::core::utils::download_media_file_to_temp_dir::download_media_file_to_temp_dir;
use crate::core::utils::save_base64_image_to_temp_dir::save_base64_image_to_temp_dir;
@@ -15,17 +15,26 @@ use crate::services::sora::state::sora_credential_manager::SoraCredentialManager
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use anyhow::anyhow;
use artcraft_api_defs::generate::object::generate_hunyuan_2_image_to_3d::GenerateHunyuan2ImageTo3dRequest;
use artcraft_api_defs::generate::object::generate_hunyuan_2_0_image_to_3d::GenerateHunyuan20ImageTo3dRequest;
use artcraft_api_defs::generate::video::generate_kling_1_6_pro_image_to_video::{GenerateKling16ProAspectRatio, GenerateKling16ProImageToVideoRequest};
use artcraft_api_defs::generate::video::generate_kling_2_1_master_image_to_video::{GenerateKling21MasterAspectRatio, GenerateKling21MasterImageToVideoRequest};
use artcraft_api_defs::generate::video::generate_kling_2_1_pro_image_to_video::{GenerateKling21ProAspectRatio, GenerateKling21ProImageToVideoRequest};
use artcraft_api_defs::generate::video::generate_seedance_1_0_lite_image_to_video::GenerateSeedance10LiteImageToVideoRequest;
use artcraft_api_defs::generate::video::generate_veo_2_image_to_video::{GenerateVeo2AspectRatio, GenerateVeo2ImageToVideoRequest};
use fal_client::creds::fal_api_key::FalApiKey;
use fal_client::requests::queue::enqueue_hunyuan2_image_to_3d::{enqueue_hunyuan2_image_to_3d, Hunyuan2Args};
use fal_client::requests::queue::image_gen::enqueue_flux_pro_11_ultra_text_to_image::{enqueue_flux_pro_11_ultra_text_to_image, FluxPro11UltraTextToImageArgs};
use fal_client::requests::queue::image_gen::enqueue_recraft3_text_to_image::{enqueue_recraft3_text_to_image, Recraft3TextToImageArgs};
use fal_client::requests::queue::video_gen::enqueue_kling_16_pro_image_to_video::Kling16ProAspectRatio;
use fal_client::requests::webhook::video::enqueue_veo_2_image_to_video_webhook::Veo2AspectRatio;
use idempotency::uuid::generate_random_uuid;
use log::{error, info, warn};
use storyteller_client::generate::object::generate_hunyuan2_image_to_3d::generate_hunyuan2_image_to_3d;
use storyteller_client::generate::object::generate_hunyuan_3d_2_0_image_to_3d::generate_hunyuan3d_2_0_image_to_3d;
use storyteller_client::generate::video::generate_kling_16_pro_image_to_video::generate_kling_16_pro_image_to_video;
use storyteller_client::generate::video::generate_kling_21_master_image_to_video::generate_kling_21_master_image_to_video;
use storyteller_client::generate::video::generate_kling_21_pro_image_to_video::generate_kling_21_pro_image_to_video;
use storyteller_client::generate::video::generate_seedance_1_0_lite_image_to_video::generate_seedance_1_0_lite_image_to_video;
use storyteller_client::generate::video::generate_veo_2_image_to_video::generate_veo_2_image_to_video;
use storyteller_client::utils::api_host::ApiHost;
use tauri::{AppHandle, State};
@@ -69,8 +78,8 @@ pub async fn handle_video_artcraft(
None => {
return Err(InternalVideoError::NoModelSpecified);
}
Some(EnqueueImageToVideoModel::Kling16) => {
info!("enqueue Kling 1.6");
Some(VideoModel::Kling16Pro) => {
info!("enqueue Kling 1.6 Pro with Artcraft API");
selected_model = Some(GenerationModel::Kling1_6);
let request = GenerateKling16ProImageToVideoRequest {
uuid_idempotency_token,
@@ -95,6 +104,110 @@ pub async fn handle_video_artcraft(
}
}
}
Some(VideoModel::Kling21Master) => {
info!("enqueue Kling 2.1 Master with Artcraft API");
selected_model = Some(GenerationModel::Kling21Master);
let request = GenerateKling21MasterImageToVideoRequest {
uuid_idempotency_token,
media_file_token: request.image_media_token,
aspect_ratio: Some(GenerateKling21MasterAspectRatio::WideSixteenNine),
prompt: None,
duration: None,
};
let result = generate_kling_21_master_image_to_video(
&ApiHost::Storyteller,
Some(&creds),
request,
).await;
match result {
Ok(enqueued) => {
info!("Successfully enqueued Artcraft Kling 2.1 Master video generation");
enqueued.inference_job_token
}
Err(err) => {
error!("Failed to use Artcraft Kling 2.1 Master video generation: {:?}", err);
return Err(InternalVideoError::StorytellerError(err));
}
}
}
Some(VideoModel::Kling21Pro) => {
info!("enqueue Kling 2.1 Pro with Artcraft API");
selected_model = Some(GenerationModel::Kling21Pro);
let request = GenerateKling21ProImageToVideoRequest {
uuid_idempotency_token,
media_file_token: request.image_media_token,
aspect_ratio: Some(GenerateKling21ProAspectRatio::WideSixteenNine),
prompt: None,
duration: None,
};
let result = generate_kling_21_pro_image_to_video(
&ApiHost::Storyteller,
Some(&creds),
request,
).await;
match result {
Ok(enqueued) => {
info!("Successfully enqueued Artcraft Kling 2.1 Pro video generation");
enqueued.inference_job_token
}
Err(err) => {
error!("Failed to use Artcraft Kling 2.1 Pro video generation: {:?}", err);
return Err(InternalVideoError::StorytellerError(err));
}
}
}
Some(VideoModel::Seedance10Lite) => {
info!("enqueue Seedance 1.0 Lite with Artcraft API");
selected_model = Some(GenerationModel::Seedance10Lite);
let request = GenerateSeedance10LiteImageToVideoRequest {
uuid_idempotency_token,
media_file_token: request.image_media_token,
prompt: None,
resolution: None,
duration: None,
};
let result = generate_seedance_1_0_lite_image_to_video(
&ApiHost::Storyteller,
Some(&creds),
request,
).await;
match result {
Ok(enqueued) => {
info!("Successfully enqueued Artcraft Seedance 1.0 Lite video generation");
enqueued.inference_job_token
}
Err(err) => {
error!("Failed to use Artcraft Seedance 1.0 Lite video generation: {:?}", err);
return Err(InternalVideoError::StorytellerError(err));
}
}
}
Some(VideoModel::Veo2) => {
info!("enqueue Veo 2 with Artcraft API");
selected_model = Some(GenerationModel::Veo2);
let request = GenerateVeo2ImageToVideoRequest {
uuid_idempotency_token,
media_file_token: request.image_media_token,
aspect_ratio: Some(GenerateVeo2AspectRatio::WideSixteenNine),
prompt: None,
duration: None,
};
let result = generate_veo_2_image_to_video(
&ApiHost::Storyteller,
Some(&creds),
request,
).await;
match result {
Ok(enqueued) => {
info!("Successfully enqueued Artcraft Veo 2 video generation");
enqueued.inference_job_token
}
Err(err) => {
error!("Failed to use Artcraft Veo 2 video generation: {:?}", err);
return Err(InternalVideoError::StorytellerError(err));
}
}
}
};
info!("Successfully enqueued video generation");
@@ -1,10 +1,10 @@
use crate::core::commands::enqueue::image::enqueue_text_to_image_command::{EnqueueTextToImageModel, EnqueueTextToImageRequest};
use crate::core::commands::enqueue::video::enqueue_image_to_video_command::{EnqueueImageToVideoModel, EnqueueImageToVideoRequest};
use crate::core::commands::enqueue::video::enqueue_image_to_video_command::EnqueueImageToVideoRequest;
use crate::core::commands::enqueue::video::internal_video_error::InternalVideoError;
use crate::core::events::basic_sendable_event_trait::BasicSendableEvent;
use crate::core::events::generation_events::common::{GenerationAction, GenerationModel, GenerationServiceProvider};
use crate::core::events::generation_events::generation_enqueue_failure_event::GenerationEnqueueFailureEvent;
use crate::core::events::generation_events::generation_enqueue_success_event::GenerationEnqueueSuccessEvent;
use crate::core::model::video_models::VideoModel;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::core::utils::download_media_file_to_temp_dir::download_media_file_to_temp_dir;
use crate::core::utils::save_base64_image_to_temp_dir::save_base64_image_to_temp_dir;
@@ -60,8 +60,6 @@ pub async fn handle_video_fal(
return Err(InternalVideoError::AnyhowError(anyhow!("No image media token provided")));
}
info!("Calling FAL image to 3d ...");
let filename = temp_download.path().to_path_buf();
let mut selected_model = None;
@@ -70,8 +68,8 @@ pub async fn handle_video_fal(
None => {
return Err(InternalVideoError::NoModelSpecified);
}
Some(EnqueueImageToVideoModel::Kling16) => {
info!("enqueue Kling 1.6 image to video");
Some(VideoModel::Kling16Pro) => {
info!("enqueue Kling 1.6 image to video with Kling API");
selected_model = Some(GenerationModel::Kling1_6);
enqueue_kling_16_pro_image_to_video(Kling16ProArgs {
image_path: filename,
@@ -81,6 +79,9 @@ pub async fn handle_video_fal(
prompt: "",
}).await
}
_ => {
return Err(InternalVideoError::AnyhowError(anyhow!("Unsupported model: {:?}", request.model)));
}
};
match result {
@@ -7,6 +7,7 @@ use storyteller_client::error::storyteller_error::StorytellerError;
#[derive(Debug)]
pub enum InternalVideoError {
NoModelSpecified,
NoProviderAvailable,
NeedsFalApiKey,
NeedsSoraCredentials,
NeedsStorytellerCredentials,
@@ -1,5 +1,6 @@
use crate::core::commands::response::shorthand::InfallibleResponse;
use crate::core::commands::response::success_response_wrapper::SerializeMarker;
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::core::state::os_platform::OsPlatform;
use crate::services::fal::commands::fal_background_removal_command::FalBackgroundRemovalSuccessResponse;
use base64::prelude::BASE64_STANDARD;
@@ -10,31 +11,38 @@ use image::ImageReader;
use log::info;
use serde_derive::Serialize;
use std::io::Cursor;
use tauri::State;
#[derive(Debug, Serialize)]
pub struct BuildInfoResponse {
pub struct AppInfoResponse {
pub build_timestamp: DateTime<Utc>,
pub git_commit_id: Option<String>,
pub git_commit_short_id: Option<String>,
pub git_commit_timestamp: Option<DateTime<Utc>>,
pub storyteller_host: String,
}
impl SerializeMarker for BuildInfoResponse {}
impl SerializeMarker for AppInfoResponse {}
#[tauri::command]
pub fn get_build_info_command() -> InfallibleResponse<BuildInfoResponse> {
info!("get_build_info_command called...");
pub fn get_app_info_command(
app_env_configs: State<'_, AppEnvConfigs>,
) -> InfallibleResponse<AppInfoResponse> {
info!("get_app_info_command called...");
let build_timestamp = build_metadata::build_timestamp();
let git_commit_id = build_metadata::git_commit_id();
let git_commit_short_id = build_metadata::git_commit_short_id();
let git_commit_timestamp = build_metadata::git_commit_timestamp();
BuildInfoResponse {
let storyteller_host = app_env_configs.storyteller_host.to_api_hostname();
AppInfoResponse {
build_timestamp,
git_commit_id: git_commit_id.map(|s| s.to_string()),
git_commit_short_id: git_commit_short_id.map(|s| s.to_string()),
git_commit_timestamp,
storyteller_host,
}.into()
}
@@ -1,6 +1,7 @@
pub mod app_preferences;
pub mod enqueue;
pub mod flip_image;
pub mod get_build_info_command;
pub mod get_app_info_command;
pub mod platform_info_command;
pub mod providers;
pub mod response;
@@ -0,0 +1,33 @@
use crate::core::commands::response::shorthand::ResponseOrErrorMessage;
use crate::core::commands::response::success_response_wrapper::SerializeMarker;
use crate::core::state::app_preferences::app_preferences_manager::AppPreferencesManager;
use crate::core::state::app_preferences::preferred_download_directory::PreferredDownloadDirectory;
use crate::core::state::provider_priority::{Provider, ProviderPriorityStore};
use errors::AnyhowResult;
use log::{error, info};
use serde_derive::Serialize;
use tauri::{AppHandle, State};
#[derive(Serialize)]
pub struct GetProviderOrderResponse {
pub providers: Vec<Provider>,
}
impl SerializeMarker for GetProviderOrderResponse{}
#[tauri::command]
pub async fn get_provider_order_command(
provider_priority_store: State<'_, ProviderPriorityStore>,
) -> ResponseOrErrorMessage<GetProviderOrderResponse> {
info!("get_provider_order_command called");
let providers = provider_priority_store.get_priority()
.map_err(|err| {
error!("Failed to get provider order: {:?}", err);
"Failed to retrieve provider order"
})?;
Ok(GetProviderOrderResponse {
providers,
}.into())
}
@@ -0,0 +1,2 @@
pub mod get_provider_order_command;
pub mod set_provider_order_command;
@@ -0,0 +1,39 @@
use crate::core::commands::response::shorthand::{ResponseOrErrorMessage, SuccessOrErrorMessage};
use crate::core::commands::response::success_response_wrapper::SerializeMarker;
use crate::core::model::image_models::ImageModel;
use crate::core::state::app_preferences::app_preferences_manager::AppPreferencesManager;
use crate::core::state::app_preferences::preferred_download_directory::PreferredDownloadDirectory;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::core::state::provider_priority::{Provider, ProviderPriorityStore};
use errors::AnyhowResult;
use log::{error, info};
use serde_derive::{Deserialize, Serialize};
use tauri::{AppHandle, State};
#[derive(Deserialize)]
pub struct SetProviderOrderRequest {
pub providers: Vec<Provider>,
}
#[tauri::command]
pub async fn set_provider_order_command(
request: SetProviderOrderRequest,
provider_priority_store: State<'_, ProviderPriorityStore>,
app_data_root: State<'_, AppDataRoot>,
) -> SuccessOrErrorMessage {
info!("set_provider_order_command called");
provider_priority_store.set_priority(&request.providers)
.map_err(|err| {
error!("Failed to set provider order: {:?}", err);
"Failed to set provider order"
})?;
provider_priority_store.persist_to_filesystem(&app_data_root)
.map_err(|err| {
error!("Failed to persist provider order to filesystem: {:?}", err);
"Failed to persist provider order"
})?;
Ok(().into())
}
@@ -30,3 +30,19 @@ pub type Response<SuccessPayload, ErrType, ErrPayload> =
/// No inner payloads for this type. Just strings as messages.
pub type SimpleResponse = Response<(), (), ()>;
/// Either an empty success payload or error message.
pub type SuccessOrErrorMessage =
Result<CommandSuccessResponseWrapper<()>, CommandErrorResponseWrapper<(), ()>>;
/// Either a success or error message.
pub type ResponseOrErrorMessage<SuccessPayload> =
Result<CommandSuccessResponseWrapper<SuccessPayload>, CommandErrorResponseWrapper<(), ()>>;
/// Either a success or error type.
pub type ResponseOrErrorType<SuccessPayload, ErrType> =
Result<CommandSuccessResponseWrapper<SuccessPayload>, CommandErrorResponseWrapper<ErrType, ()>>;
/// Either a success or error payload.
pub type ResponseOrError<SuccessPayload, ErrPayload> =
Result<CommandSuccessResponseWrapper<SuccessPayload>, CommandErrorResponseWrapper<(), ErrPayload>>;
@@ -11,13 +11,32 @@ pub enum GenerationServiceProvider {
#[derive(Debug, Copy, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum GenerationModel {
#[serde(rename = "flux_1_dev")]
Flux1Dev,
#[serde(rename = "flux_1_schnell")]
Flux1Schnell,
#[serde(rename = "flux_pro_1.1")]
FluxPro11,
#[serde(rename = "flux_pro_1.1_ultra")]
FluxPro11Ultra,
#[serde(rename = "gpt_image_1")]
GptImage1,
#[serde(rename = "kling_1.6")]
Kling1_6,
#[serde(rename = "kling_2.0")]
Kling2_0,
#[serde(rename = "kling_2.1_master")]
Kling21Master,
#[serde(rename = "kling_2.1_pro")]
Kling21Pro,
#[serde(rename = "recraft_3")]
Recraft3,
#[serde(rename = "sora")]
Sora,
#[serde(rename = "seedance_1.0_lite")]
Seedance10Lite,
#[serde(rename = "veo_2")]
Veo2,
}
#[derive(Debug, Copy, Clone, Serialize)]
+1
View File
@@ -1,6 +1,7 @@
pub mod artcraft_error;
pub mod commands;
pub mod events;
pub mod model;
pub mod state;
pub mod threads;
pub mod utils;
@@ -0,0 +1,20 @@
use serde_derive::Deserialize;
/// This is used in the Tauri command bridge.
/// Don't change the serializations without coordinating with the frontend.
#[derive(Deserialize, Debug, Copy, Clone)]
#[serde(rename_all = "snake_case")]
pub enum ImageModel {
#[serde(rename = "flux_1_dev")]
Flux1Dev,
#[serde(rename = "flux_1_schnell")]
Flux1Schnell,
#[serde(rename = "flux_pro_11")]
FluxPro11,
#[serde(rename = "flux_pro_11_ultra")]
FluxPro11Ultra,
#[serde(rename = "gpt_image_1")]
GptImage1,
#[serde(rename = "recraft_3")]
Recraft3,
}
@@ -0,0 +1,2 @@
pub mod image_models;
pub mod video_models;
@@ -0,0 +1,22 @@
use serde_derive::Deserialize;
/// This is used in the Tauri command bridge.
/// Don't change the serializations without coordinating with the frontend.
#[derive(Deserialize, Debug, Copy, Clone)]
#[serde(rename_all = "snake_case")]
pub enum VideoModel {
#[serde(rename = "kling_1.6_pro")]
Kling16Pro,
#[serde(rename = "kling_2.1_pro")]
Kling21Pro,
#[serde(rename = "kling_2.1_master")]
Kling21Master,
#[serde(rename = "seedance_1.0_lite")]
Seedance10Lite,
#[serde(rename = "veo_2")]
Veo2,
}
@@ -0,0 +1,38 @@
use log::info;
use errors::AnyhowResult;
use storyteller_client::utils::api_host::ApiHost;
use crate::core::state::app_env_configs::app_env_configs_serializeable::{AppEnvConfigsSerializable, StorytellerApiHost};
use crate::core::state::data_dir::app_data_root::AppDataRoot;
#[derive(Clone)]
pub struct AppEnvConfigs {
pub storyteller_host: ApiHost,
}
impl AppEnvConfigs {
pub fn load_from_filesystem(root: &AppDataRoot) -> AnyhowResult<Self> {
println!("Loading app environmental configs from filesystem...");
let input = AppEnvConfigsSerializable::load_from_filesystem(root)?;
let storyteller = input.as_ref()
.map(|i| i.storyteller_host)
.flatten();
let storyteller_api = match storyteller {
Some(StorytellerApiHost::Localhost) => ApiHost::Localhost {
port: input.as_ref()
.and_then(|i| i.storyteller_port)
.unwrap_or(12345)
},
Some(StorytellerApiHost::Production) => ApiHost::Storyteller,
_ => ApiHost::Storyteller,
};
println!("Using storyteller API host: {:?}", storyteller_api);
Ok(Self {
storyteller_host: storyteller_api,
})
}
}
@@ -0,0 +1,34 @@
use serde_derive::{Deserialize, Serialize};
use errors::AnyhowResult;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
const CURRENT_VERSION: &str = "1";
#[derive(Debug, Copy, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StorytellerApiHost {
Production,
Localhost,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct AppEnvConfigsSerializable {
/// Versioning string.
pub version: Option<String>,
pub storyteller_port: Option<u32>,
pub storyteller_host: Option<StorytellerApiHost>,
}
impl AppEnvConfigsSerializable {
pub fn load_from_filesystem(app_data_root: &AppDataRoot) -> AnyhowResult<Option<Self>> {
let filename = app_data_root.settings_dir().get_app_env_configs_path();
if !filename.exists() {
return Ok(None);
}
let contents = std::fs::read_to_string(filename)?;
let data: Self = serde_json::from_str(&contents)?;
Ok(Some(data))
}
}
@@ -0,0 +1,2 @@
pub mod app_env_configs;
pub mod app_env_configs_serializeable;
@@ -31,7 +31,15 @@ impl AppSettingsDir {
pub fn get_app_preferences_path(&self) -> PathBuf {
self.path.join("app_preferences.json")
}
pub fn get_app_env_configs_path(&self) -> PathBuf {
self.path.join("env_configs.json")
}
pub fn get_provider_preferences_path(&self) -> PathBuf {
self.path.join("provider_preferences.json")
}
pub fn write_app_preferences(&self, prefs: &AppPreferences) -> AnyhowResult<()> {
let serializable = prefs.to_serializable();
@@ -1,7 +1,9 @@
pub mod app_env_configs;
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 os_platform;
pub mod provider_priority;
@@ -0,0 +1,162 @@
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use errors::AnyhowResult;
use serde_derive::{Deserialize, Serialize};
use std::collections::HashSet;
use std::fs::OpenOptions;
use std::io::Write;
use std::sync::{Arc, RwLock};
#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Provider {
Artcraft,
Fal,
Sora,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct ProviderPriority {
/// Providers may occur zero or one time.
/// They are ordered by priority, highest to lowest.
provider_priority: Vec<Provider>,
}
#[derive(Clone)]
pub struct ProviderPriorityStore {
provider_priority: Arc<RwLock<ProviderPriority>>,
}
impl ProviderPriority {
pub fn default() -> Self {
Self {
provider_priority: vec![
Provider::Artcraft,
Provider::Sora,
Provider::Fal,
],
}
}
pub fn set_priority(&mut self, ordered_list: &[Provider]) -> AnyhowResult<()> {
let set : HashSet<&Provider> = HashSet::from_iter(ordered_list.iter());
if set.len() != ordered_list.len() {
return Err(anyhow::anyhow!("Duplicate providers in the ordered list"));
}
self.provider_priority = ordered_list.to_vec();
Ok(())
}
pub fn get_priority(&self) -> &[Provider] {
&self.provider_priority
}
pub fn from_filesystem_configs(app_data_root: &AppDataRoot) -> AnyhowResult<Option<Self>> {
let filename = app_data_root.settings_dir().get_provider_preferences_path();
if !filename.exists() {
return Ok(None);
}
let contents = std::fs::read_to_string(filename)?;
let providers : Self = serde_json::from_str(&contents)?;
Ok(Some(providers))
}
pub fn persist_to_filesystem(&self, app_data_root: &AppDataRoot) -> AnyhowResult<()> {
let filename = app_data_root.settings_dir().get_provider_preferences_path();
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(())
}
}
impl ProviderPriorityStore {
pub fn default() -> Self {
Self {
provider_priority: Arc::new(RwLock::new(ProviderPriority::default())),
}
}
pub fn set_priority(&self, ordered_list: &[Provider]) -> AnyhowResult<()> {
match self.provider_priority.write() {
Err(err) => {
Err(anyhow::anyhow!("Failed to acquire write lock on provider priority: {}", err))
}
Ok(mut providers) => {
providers.set_priority(ordered_list)?;
Ok(())
}
}
}
pub fn get_priority(&self) -> AnyhowResult<Vec<Provider>> {
match self.provider_priority.read() {
Err(err) => {
Err(anyhow::anyhow!("Failed to acquire read lock on provider priority: {}", err))
}
Ok(providers) => {
Ok(providers.get_priority().to_vec())
}
}
}
pub fn from_filesystem_configs(app_data_root: &AppDataRoot) -> AnyhowResult<Option<Self>> {
match ProviderPriority::from_filesystem_configs(app_data_root) {
Err(err) => {
Err(anyhow::anyhow!("Failed to read provider preferences from filesystem: {}", err))
}
Ok(None) => Ok(None),
Ok(Some(providers)) => {
Ok(Some(Self {
provider_priority: Arc::new(RwLock::new(providers)),
}))
}
}
}
pub fn persist_to_filesystem(&self, app_data_root: &AppDataRoot) -> AnyhowResult<()> {
match self.provider_priority.read() {
Err(err) => {
Err(anyhow::anyhow!("Failed to acquire read lock on provider priority: {}", err))
}
Ok(providers) => {
providers.persist_to_filesystem(app_data_root)?;
Ok(())
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_priority() {
let priority = ProviderPriority::default();
assert_eq!(priority.get_priority(), &[Provider::Artcraft, Provider::Sora, Provider::Fal]);
}
#[test]
fn test_set_priority() -> AnyhowResult<()> {
let mut priority = ProviderPriority::default();
priority.set_priority(&[Provider::Fal, Provider::Artcraft])?;
assert_eq!(priority.get_priority(), &[Provider::Fal, Provider::Artcraft]);
Ok(())
}
#[test]
fn test_set_priority_with_duplicates() {
let mut priority = ProviderPriority::default();
let result = priority.set_priority(&[Provider::Artcraft, Provider::Artcraft]);
assert!(result.is_err());
}
}
@@ -2,12 +2,14 @@ use crate::core::state::app_startup_time::AppStartupTime;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::core::state::main_window_position::MainWindowPosition;
use crate::core::state::main_window_size::MainWindowSize;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use crate::core::threads::main_window_thread::persist_storyteller_cookies_task::persist_storyteller_cookies_task;
use crate::core::threads::main_window_thread::persist_window_position_task::persist_window_position_task;
use crate::core::threads::main_window_thread::persist_window_resize_task::persist_window_resize_task;
use crate::core::utils::clear_all_webview_cookies::clear_all_webview_cookies;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use anyhow::anyhow;
use errors::AnyhowResult;
use log::{error, info};
use log::{error, info, warn};
use memory_store::clone_slot::CloneSlot;
use tauri::{AppHandle, Manager, Webview, Window};
@@ -31,6 +33,8 @@ pub async fn main_window_thread(
.unwrap_or_else(|| "unknown".to_string()));
info!("build timestamp: {:?}", build_metadata::build_timestamp()
.to_string());
// debug_try_clear_all_webview_data(&app);
loop {
for (window_name, window) in app.windows() {
@@ -75,3 +79,15 @@ pub fn log_errors<T>(result: AnyhowResult<T>) {
error!("Error persisting window size: {:?}", err);
}
}
fn debug_try_clear_all_webview_data(app: &AppHandle) {
warn!("[!!!] THIS IS ONLY FOR DEBUGGING PURPOSES: Attempting to clear webview data...");
for (name, webview) in app.webviews() {
if let Err(err) = webview.clear_all_browsing_data() {
error!("Failed to clear cookies for '{}' window webview: {:?}", name, err);
} else {
warn!("Successfully cleared cookies for '{}' window webview", name);
}
}
}
+32 -4
View File
@@ -9,12 +9,16 @@ use crate::core::commands::enqueue::image::enqueue_text_to_image_command::enqueu
use crate::core::commands::enqueue::object::enqueue_image_to_3d_object_command::enqueue_image_to_3d_object_command;
use crate::core::commands::enqueue::video::enqueue_image_to_video_command::enqueue_image_to_video_command;
use crate::core::commands::flip_image::flip_image;
use crate::core::commands::get_build_info_command::get_build_info_command;
use crate::core::commands::get_app_info_command::get_app_info_command;
use crate::core::commands::platform_info_command::platform_info_command;
use crate::core::commands::providers::get_provider_order_command::get_provider_order_command;
use crate::core::commands::providers::set_provider_order_command::set_provider_order_command;
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::core::state::app_preferences::app_preferences_manager::load_app_preferences_or_default;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::core::state::main_window_position::MainWindowPosition;
use crate::core::state::main_window_size::MainWindowSize;
use crate::core::state::provider_priority::ProviderPriorityStore;
use crate::core::threads::discord_presence_thread::discord_presence_thread;
use crate::core::threads::main_window_thread::main_window_thread::main_window_thread;
use crate::core::utils::webview_unsafe::webview_unsafe_for_app;
@@ -52,8 +56,10 @@ pub fn run() {
println!("Loading app preferences...");
let app_preferences = load_app_preferences_or_default(&app_data_root);
println!("Attempting to read existing artcraft credentials...");
let storyteller_creds_manager = StorytellerCredentialManager::initialize_from_disk_infallible(&app_data_root);
// NB: tauri-plugin-http stores the credentials on disk, so we can defer to that for now.
// println!("Attempting to read existing artcraft credentials...");
// let storyteller_creds_manager = StorytellerCredentialManager::initialize_from_disk_infallible(&app_data_root);
let storyteller_creds_manager = StorytellerCredentialManager::initialize_empty(&app_data_root);
let storyteller_creds_manager_2 = storyteller_creds_manager.clone();
let storyteller_creds_manager_3 = storyteller_creds_manager.clone();
@@ -71,6 +77,24 @@ pub fn run() {
let fal_task_queue = FalTaskQueue::new();
let fal_task_queue_2 = fal_task_queue.clone();
let app_env_configs = AppEnvConfigs::load_from_filesystem(&app_data_root)
.expect("AppEnvConfigs should be loaded from disk");
let provider_priority = match ProviderPriorityStore::from_filesystem_configs(&app_data_root) {
Ok(Some(priority)) => {
println!("Loaded provider priority from disk: {:?}", priority.get_priority());
priority
}
Ok(None) => {
println!("No provider priority found on disk, using default.");
ProviderPriorityStore::default()
}
Err(err) => {
eprintln!("Failed to read provider priority from disk: {:?}", err);
ProviderPriorityStore::default()
}
};
println!("Initializing backend runtime...");
@@ -155,9 +179,11 @@ pub fn run() {
Ok(())
})
.manage(app_data_root)
.manage(app_env_configs)
.manage(app_preferences)
.manage(fal_creds_manager)
.manage(fal_task_queue)
.manage(provider_priority)
.manage(sora_creds_manager)
.manage(sora_task_queue)
.manage(storyteller_creds_manager_3)
@@ -170,9 +196,11 @@ pub fn run() {
fal_hunyuan_image_to_3d_command,
fal_kling_image_to_video_command,
flip_image,
get_app_info_command,
get_app_preferences_command,
get_build_info_command,
get_fal_api_key_command,
get_provider_order_command,
set_provider_order_command,
open_sora_login_command,
platform_info_command,
set_fal_api_key_command,
@@ -2,4 +2,6 @@ pub mod fal_background_removal_command;
pub mod fal_hunyuan_image_to_3d_command;
pub mod fal_kling_image_to_video_command;
pub mod get_fal_api_key_command;
pub mod set_fal_api_key_command;
pub mod set_fal_api_key_command;
// pub mod get_open_ai_api_key_command;
// pub mod set_open_ai_api_key_command;
@@ -1,5 +1,5 @@
use crate::services::fal::state::read_fal_api_key_from_disk::read_fal_api_key_from_disk;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::services::fal::state::read_fal_api_key_from_disk::read_fal_api_key_from_disk;
use anyhow::anyhow;
use errors::AnyhowResult;
use fal_client::creds::fal_api_key::FalApiKey;
@@ -42,6 +42,9 @@ impl FalCredentialManager {
}
pub fn set_key(&self, key: &FalApiKey) -> AnyhowResult<()> {
if key.0.trim().is_empty() {
return Err(anyhow!("FAL API key cannot be empty")); // TODO: Also handle upstream.
}
self.key.set_clone(key)
}
@@ -20,6 +20,13 @@ pub struct StorytellerCredentialManager {
impl StorytellerCredentialManager {
pub fn initialize_empty(app_data_root: &AppDataRoot) -> Self {
Self {
holder: StorytellerCredentialHolder::new(),
app_data_root: app_data_root.clone(),
}
}
pub fn initialize_from_disk_infallible(app_data_root: &AppDataRoot) -> Self {
let holder = StorytellerCredentialHolder::new();
@@ -21,7 +21,10 @@ pub enum ApiError {
TooManyRequests(String),
/// 500. An internal server error occurred.
InternalServerError(String),
InternalServerError {
body: String,
backend_hostname: Option<String>,
},
/// A deserialization error with the response.
DeserializationError(serde_json::Error),
@@ -53,7 +56,8 @@ impl Display for ApiError {
ApiError::Forbidden(msg) => write!(f, "Forbidden: {}", msg),
ApiError::NotFound(msg) => write!(f, "Not found: {}", msg),
ApiError::TooManyRequests(msg) => write!(f, "Too many requests: {}", msg),
ApiError::InternalServerError(msg) => write!(f, "Internal server error: {}", msg),
ApiError::InternalServerError {body, backend_hostname} =>
write!(f, "Internal Server Error; backend hostname: {:?} ; body: {}; ", backend_hostname, body),
// Server response handling errors
ApiError::DeserializationError(error) => write!(f, "Deserialization error: {}", error),
// Network errors
@@ -0,0 +1,19 @@
use crate::credentials::storyteller_credential_set::StorytellerCredentialSet;
use crate::error::storyteller_error::StorytellerError;
use crate::utils::api_host::ApiHost;
use crate::utils::basic_json_post_request::basic_json_post_request;
use artcraft_api_defs::generate::image::generate_flux_1_dev_text_to_image::{GenerateFlux1DevTextToImageRequest, GenerateFlux1DevTextToImageResponse, GENERATE_FLUX_1_DEV_TEXT_TO_IMAGE_PATH};
pub async fn generate_flux_1_dev_text_to_image(
api_host: &ApiHost,
maybe_creds: Option<&StorytellerCredentialSet>,
request: GenerateFlux1DevTextToImageRequest,
) -> Result<GenerateFlux1DevTextToImageResponse, StorytellerError> {
Ok(basic_json_post_request(
api_host,
GENERATE_FLUX_1_DEV_TEXT_TO_IMAGE_PATH,
maybe_creds,
request,
).await?)
}
@@ -0,0 +1,19 @@
use crate::credentials::storyteller_credential_set::StorytellerCredentialSet;
use crate::error::storyteller_error::StorytellerError;
use crate::utils::api_host::ApiHost;
use crate::utils::basic_json_post_request::basic_json_post_request;
use artcraft_api_defs::generate::image::generate_flux_1_schnell_text_to_image::{GenerateFlux1SchnellTextToImageRequest, GenerateFlux1SchnellTextToImageResponse, GENERATE_FLUX_1_SCHNELL_TEXT_TO_IMAGE_PATH};
pub async fn generate_flux_1_schnell_text_to_image(
api_host: &ApiHost,
maybe_creds: Option<&StorytellerCredentialSet>,
request: GenerateFlux1SchnellTextToImageRequest,
) -> Result<GenerateFlux1SchnellTextToImageResponse, StorytellerError> {
Ok(basic_json_post_request(
api_host,
GENERATE_FLUX_1_SCHNELL_TEXT_TO_IMAGE_PATH,
maybe_creds,
request,
).await?)
}
@@ -0,0 +1,19 @@
use crate::credentials::storyteller_credential_set::StorytellerCredentialSet;
use crate::error::storyteller_error::StorytellerError;
use crate::utils::api_host::ApiHost;
use crate::utils::basic_json_post_request::basic_json_post_request;
use artcraft_api_defs::generate::image::generate_flux_pro_11_text_to_image::{GenerateFluxPro11TextToImageRequest, GenerateFluxPro11TextToImageResponse, GENERATE_FLUX_PRO_11_TEXT_TO_IMAGE_PATH};
pub async fn generate_flux_pro_11_text_to_image(
api_host: &ApiHost,
maybe_creds: Option<&StorytellerCredentialSet>,
request: GenerateFluxPro11TextToImageRequest,
) -> Result<GenerateFluxPro11TextToImageResponse, StorytellerError> {
Ok(basic_json_post_request(
api_host,
GENERATE_FLUX_PRO_11_TEXT_TO_IMAGE_PATH,
maybe_creds,
request,
).await?)
}
@@ -1,68 +1,18 @@
use crate::credentials::storyteller_credential_set::StorytellerCredentialSet;
use crate::error::api_error::ApiError;
use crate::error::client_error::ClientError;
use crate::error::storyteller_error::StorytellerError;
use crate::utils::api_host::ApiHost;
use crate::utils::constants::{APPLICATION_JSON, USER_AGENT};
use crate::utils::filter_bad_response::filter_bad_response;
use crate::utils::basic_json_post_request::basic_json_post_request;
use artcraft_api_defs::generate::image::generate_flux_pro_11_ultra_text_to_image::{GenerateFluxPro11UltraTextToImageRequest, GenerateFluxPro11UltraTextToImageResponse, GENERATE_FLUX_PRO_11_ULTRA_TEXT_TO_IMAGE_PATH};
use log::{debug, info};
use reqwest::Client;
pub async fn generate_flux_pro_11_ultra_text_to_image(
api_host: &ApiHost,
maybe_creds: Option<&StorytellerCredentialSet>,
request: GenerateFluxPro11UltraTextToImageRequest,
) -> Result<GenerateFluxPro11UltraTextToImageResponse, StorytellerError> {
let url = get_route(api_host);
info!("Requesting {:?}", &url);
let client = Client::builder()
.gzip(true)
.build()
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
let mut request_builder = client.post(url)
.header("User-Agent", USER_AGENT)
.header("Accept", APPLICATION_JSON)
.header("Content-Type", APPLICATION_JSON);
if let Some(creds) = maybe_creds {
if let Some(header) = &creds.maybe_as_cookie_header() {
request_builder = request_builder.header("Cookie", header);
}
}
let request_body = serde_json::to_string(&request)
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
debug!("Request body: {:?}", request_body);
let response = request_builder
.body(request_body)
.send()
.await
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
let response = filter_bad_response(response)
.await
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
let response_body = &response.text()
.await
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
debug!("Response body: {:?}", response_body);
let response = serde_json::from_str(&response_body)
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
Ok(response)
}
fn get_route(api_host: &ApiHost) -> String {
let api_hostname = api_host.to_api_hostname();
format!("https://{}{}", api_hostname, GENERATE_FLUX_PRO_11_ULTRA_TEXT_TO_IMAGE_PATH)
Ok(basic_json_post_request(
api_host,
GENERATE_FLUX_PRO_11_ULTRA_TEXT_TO_IMAGE_PATH,
maybe_creds,
request,
).await?)
}
@@ -1 +1,4 @@
pub mod generate_flux_1_dev_text_to_image;
pub mod generate_flux_1_schnell_text_to_image;
pub mod generate_flux_pro_11_text_to_image;
pub mod generate_flux_pro_11_ultra_text_to_image;
@@ -0,0 +1,18 @@
use crate::credentials::storyteller_credential_set::StorytellerCredentialSet;
use crate::error::storyteller_error::StorytellerError;
use crate::utils::api_host::ApiHost;
use crate::utils::basic_json_post_request::basic_json_post_request;
use artcraft_api_defs::generate::object::generate_hunyuan_2_0_image_to_3d::{GenerateHunyuan20ImageTo3dRequest, GenerateHunyuan20ImageTo3dResponse, GENERATE_HUNYUAN_2_0_IMAGE_TO_3D_URL_PATH};
pub async fn generate_hunyuan3d_2_0_image_to_3d(
api_host: &ApiHost,
maybe_creds: Option<&StorytellerCredentialSet>,
request: GenerateHunyuan20ImageTo3dRequest,
) -> Result<GenerateHunyuan20ImageTo3dResponse, StorytellerError> {
Ok(basic_json_post_request(
api_host,
GENERATE_HUNYUAN_2_0_IMAGE_TO_3D_URL_PATH,
maybe_creds,
request,
).await?)
}
@@ -0,0 +1,18 @@
use crate::credentials::storyteller_credential_set::StorytellerCredentialSet;
use crate::error::storyteller_error::StorytellerError;
use crate::utils::api_host::ApiHost;
use crate::utils::basic_json_post_request::basic_json_post_request;
use artcraft_api_defs::generate::object::generate_hunyuan_2_1_image_to_3d::{GenerateHunyuan21ImageTo3dRequest, GenerateHunyuan21ImageTo3dResponse, GENERATE_HUNYUAN_2_1_IMAGE_TO_3D_URL_PATH};
pub async fn generate_hunyuan3d_2_1_image_to_3d(
api_host: &ApiHost,
maybe_creds: Option<&StorytellerCredentialSet>,
request: GenerateHunyuan21ImageTo3dRequest,
) -> Result<GenerateHunyuan21ImageTo3dResponse, StorytellerError> {
Ok(basic_json_post_request(
api_host,
GENERATE_HUNYUAN_2_1_IMAGE_TO_3D_URL_PATH,
maybe_creds,
request,
).await?)
}
@@ -1 +1,2 @@
pub mod generate_hunyuan2_image_to_3d;
pub mod generate_hunyuan_3d_2_0_image_to_3d;
pub mod generate_hunyuan_3d_2_1_image_to_3d;
@@ -1,68 +1,18 @@
use crate::credentials::storyteller_credential_set::StorytellerCredentialSet;
use crate::error::api_error::ApiError;
use crate::error::client_error::ClientError;
use crate::error::storyteller_error::StorytellerError;
use crate::utils::api_host::ApiHost;
use crate::utils::constants::{APPLICATION_JSON, USER_AGENT};
use crate::utils::filter_bad_response::filter_bad_response;
use crate::utils::basic_json_post_request::basic_json_post_request;
use artcraft_api_defs::generate::video::generate_kling_1_6_pro_image_to_video::{GenerateKling16ProImageToVideoRequest, GenerateKling16ProImageToVideoResponse, GENERATE_KLING_1_6_PRO_IMAGE_TO_VIDEO_URL_PATH};
use log::{debug, info};
use reqwest::Client;
pub async fn generate_kling_16_pro_image_to_video(
api_host: &ApiHost,
maybe_creds: Option<&StorytellerCredentialSet>,
request: GenerateKling16ProImageToVideoRequest,
) -> Result<GenerateKling16ProImageToVideoResponse, StorytellerError> {
let url = get_route(api_host);
info!("Requesting {:?}", &url);
let client = Client::builder()
.gzip(true)
.build()
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
let mut request_builder = client.post(url)
.header("User-Agent", USER_AGENT)
.header("Accept", APPLICATION_JSON)
.header("Content-Type", APPLICATION_JSON);
if let Some(creds) = maybe_creds {
if let Some(header) = &creds.maybe_as_cookie_header() {
request_builder = request_builder.header("Cookie", header);
}
}
let request_body = serde_json::to_string(&request)
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
debug!("Request body: {:?}", request_body);
let response = request_builder
.body(request_body)
.send()
.await
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
let response = filter_bad_response(response)
.await
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
let response_body = &response.text()
.await
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
debug!("Response body: {:?}", response_body);
let response = serde_json::from_str(&response_body)
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
Ok(response)
}
fn get_route(api_host: &ApiHost) -> String {
let api_hostname = api_host.to_api_hostname();
format!("https://{}{}", api_hostname, GENERATE_KLING_1_6_PRO_IMAGE_TO_VIDEO_URL_PATH)
Ok(basic_json_post_request(
api_host,
GENERATE_KLING_1_6_PRO_IMAGE_TO_VIDEO_URL_PATH,
maybe_creds,
request,
).await?)
}
@@ -0,0 +1,18 @@
use crate::credentials::storyteller_credential_set::StorytellerCredentialSet;
use crate::error::storyteller_error::StorytellerError;
use crate::utils::api_host::ApiHost;
use crate::utils::basic_json_post_request::basic_json_post_request;
use artcraft_api_defs::generate::video::generate_kling_2_1_master_image_to_video::{GenerateKling21MasterImageToVideoRequest, GenerateKling21MasterImageToVideoResponse, GENERATE_KLING_2_1_MASTER_IMAGE_TO_VIDEO_URL_PATH};
pub async fn generate_kling_21_master_image_to_video(
api_host: &ApiHost,
maybe_creds: Option<&StorytellerCredentialSet>,
request: GenerateKling21MasterImageToVideoRequest,
) -> Result<GenerateKling21MasterImageToVideoResponse, StorytellerError> {
Ok(basic_json_post_request(
api_host,
GENERATE_KLING_2_1_MASTER_IMAGE_TO_VIDEO_URL_PATH,
maybe_creds,
request,
).await?)
}
@@ -0,0 +1,18 @@
use crate::credentials::storyteller_credential_set::StorytellerCredentialSet;
use crate::error::storyteller_error::StorytellerError;
use crate::utils::api_host::ApiHost;
use crate::utils::basic_json_post_request::basic_json_post_request;
use artcraft_api_defs::generate::video::generate_kling_2_1_pro_image_to_video::{GenerateKling21ProImageToVideoRequest, GenerateKling21ProImageToVideoResponse, GENERATE_KLING_2_1_PRO_IMAGE_TO_VIDEO_URL_PATH};
pub async fn generate_kling_21_pro_image_to_video(
api_host: &ApiHost,
maybe_creds: Option<&StorytellerCredentialSet>,
request: GenerateKling21ProImageToVideoRequest,
) -> Result<GenerateKling21ProImageToVideoResponse, StorytellerError> {
Ok(basic_json_post_request(
api_host,
GENERATE_KLING_2_1_PRO_IMAGE_TO_VIDEO_URL_PATH,
maybe_creds,
request,
).await?)
}
@@ -0,0 +1,18 @@
use crate::credentials::storyteller_credential_set::StorytellerCredentialSet;
use crate::error::storyteller_error::StorytellerError;
use crate::utils::api_host::ApiHost;
use crate::utils::basic_json_post_request::basic_json_post_request;
use artcraft_api_defs::generate::video::generate_seedance_1_0_lite_image_to_video::{GenerateSeedance10LiteImageToVideoRequest, GenerateSeedance10LiteImageToVideoResponse, GENERATE_SEEDANCE_1_0_LITE_IMAGE_TO_VIDEO_URL_PATH};
pub async fn generate_seedance_1_0_lite_image_to_video(
api_host: &ApiHost,
maybe_creds: Option<&StorytellerCredentialSet>,
request: GenerateSeedance10LiteImageToVideoRequest,
) -> Result<GenerateSeedance10LiteImageToVideoResponse, StorytellerError> {
Ok(basic_json_post_request(
api_host,
GENERATE_SEEDANCE_1_0_LITE_IMAGE_TO_VIDEO_URL_PATH,
maybe_creds,
request,
).await?)
}
@@ -0,0 +1,18 @@
use crate::credentials::storyteller_credential_set::StorytellerCredentialSet;
use crate::error::storyteller_error::StorytellerError;
use crate::utils::api_host::ApiHost;
use crate::utils::basic_json_post_request::basic_json_post_request;
use artcraft_api_defs::generate::video::generate_veo_2_image_to_video::{GenerateVeo2ImageToVideoRequest, GenerateVeo2ImageToVideoResponse, GENERATE_VEO_2_IMAGE_TO_VIDEO_URL_PATH};
pub async fn generate_veo_2_image_to_video(
api_host: &ApiHost,
maybe_creds: Option<&StorytellerCredentialSet>,
request: GenerateVeo2ImageToVideoRequest,
) -> Result<GenerateVeo2ImageToVideoResponse, StorytellerError> {
Ok(basic_json_post_request(
api_host,
GENERATE_VEO_2_IMAGE_TO_VIDEO_URL_PATH,
maybe_creds,
request,
).await?)
}
@@ -1 +1,5 @@
pub mod generate_kling_16_pro_image_to_video;
pub mod generate_kling_16_pro_image_to_video;
pub mod generate_kling_21_master_image_to_video;
pub mod generate_kling_21_pro_image_to_video;
pub mod generate_seedance_1_0_lite_image_to_video;
pub mod generate_veo_2_image_to_video;
@@ -1,5 +1,5 @@
#[derive(Clone)]
#[derive(Clone, Debug)]
pub enum ApiHost {
Storyteller,
FakeYou,
@@ -14,4 +14,12 @@ impl ApiHost {
ApiHost::Localhost { port } => format!("localhost:{}", port),
}
}
pub fn to_api_hostname_and_scheme(&self) -> String {
match self {
ApiHost::Storyteller => "https://api.storyteller.ai".to_string(),
ApiHost::FakeYou => "https://api.fakeyou.com".to_string(),
ApiHost::Localhost { port } => format!("http://localhost:{}", port),
}
}
}
@@ -5,17 +5,20 @@ use crate::error::storyteller_error::StorytellerError;
use crate::utils::api_host::ApiHost;
use crate::utils::constants::{APPLICATION_JSON, USER_AGENT};
use crate::utils::filter_bad_response::filter_bad_response;
use artcraft_api_defs::generate::object::generate_hunyuan_2_image_to_3d::{GenerateHunyuan2ImageTo3dRequest, GenerateHunyuan2ImageTo3dResponse, GENERATE_HUNYUAN_2_IMAGE_TO_3D_URL_PATH};
use artcraft_api_defs::generate::image::generate_flux_1_schnell_text_to_image::{GenerateFlux1SchnellTextToImageRequest, GenerateFlux1SchnellTextToImageResponse, GENERATE_FLUX_1_SCHNELL_TEXT_TO_IMAGE_PATH};
use log::{debug, info};
use reqwest::Client;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
pub async fn generate_hunyuan2_image_to_3d(
pub async fn basic_json_post_request<Req: Serialize, Res: DeserializeOwned>(
api_host: &ApiHost,
route_path: &str,
maybe_creds: Option<&StorytellerCredentialSet>,
request: GenerateHunyuan2ImageTo3dRequest,
) -> Result<GenerateHunyuan2ImageTo3dResponse, StorytellerError> {
request: Req,
) -> Result<Res, StorytellerError> {
let url = get_route(api_host);
let url = get_route(api_host, route_path);
info!("Requesting {:?}", &url);
@@ -34,12 +37,12 @@ pub async fn generate_hunyuan2_image_to_3d(
request_builder = request_builder.header("Cookie", header);
}
}
let request_body = serde_json::to_string(&request)
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
debug!("Request body: {:?}", request_body);
let response = request_builder
.body(request_body)
.send()
@@ -49,20 +52,20 @@ pub async fn generate_hunyuan2_image_to_3d(
let response = filter_bad_response(response)
.await
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
let response_body = &response.text()
.await
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
debug!("Response body: {:?}", response_body);
let response = serde_json::from_str(&response_body)
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
Ok(response)
}
fn get_route(api_host: &ApiHost) -> String {
let api_hostname = api_host.to_api_hostname();
format!("https://{}{}", api_hostname, GENERATE_HUNYUAN_2_IMAGE_TO_3D_URL_PATH)
fn get_route(api_host: &ApiHost, route_path: &str) -> String {
let api_hostname = api_host.to_api_hostname_and_scheme();
format!("{}{}", api_hostname, route_path)
}
@@ -1,6 +1,7 @@
use crate::error::api_error::ApiError;
use crate::utils::status_codes::*;
use anyhow::anyhow;
use log::error;
/// Pass request errors to this method for standard error handling behavior.
/// This function needs to take temporary ownership, but will return it to the caller.
@@ -13,6 +14,15 @@ pub async fn filter_bad_response(response: reqwest::Response) -> Result<reqwest:
let status = response.status();
if !status.is_success() {
// The host in the fleet that handled the request.
let backend_hostname= response.headers()
.get("x-backend-hostname")
.map(|header| header.to_str().unwrap_or_else(|err| {
error!("Failed to parse x-backend-hostname header: {:?}", err);
"unknown"
}))
.map(|s| s.to_string());
let response_body = match response.text().await {
Ok(text) => text.to_string(),
Err(err) => format!("Could not read response body: {:?}", err),
@@ -23,7 +33,7 @@ pub async fn filter_bad_response(response: reqwest::Response) -> Result<reqwest:
STATUS_403_FORBIDDEN => Err(ApiError::Forbidden(response_body)),
STATUS_404_NOT_FOUND => Err(ApiError::NotFound(response_body)),
STATUS_429_TOO_MANY_REQUESTS => Err(ApiError::TooManyRequests(response_body)),
STATUS_500_INTERNAL_SERVER_ERROR => Err(ApiError::InternalServerError(response_body)),
STATUS_500_INTERNAL_SERVER_ERROR => Err(ApiError::InternalServerError { body: response_body, backend_hostname }),
_ => Err(ApiError::Other(anyhow!("Bad status code: {}; message: {:?}", status, response_body))),
}
}
@@ -1,4 +1,5 @@
pub mod api_host;
pub mod basic_json_post_request;
pub mod constants;
pub mod filter_bad_response;
pub mod http_get_anonymous;
@@ -3,10 +3,10 @@ use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::media_files::MediaFileToken;
use utoipa::ToSchema;
pub const GENERATE_HUNYUAN_2_IMAGE_TO_3D_URL_PATH: &str = "/v1/generate/object/hunyuan_2_image_to_3d";
pub const GENERATE_HUNYUAN_2_0_IMAGE_TO_3D_URL_PATH: &str = "/v1/generate/object/hunyuan_2.0_image_to_3d";
#[derive(Serialize, Deserialize, ToSchema)]
pub struct GenerateHunyuan2ImageTo3dRequest {
pub struct GenerateHunyuan20ImageTo3dRequest {
/// Idempotency token to prevent duplicate requests.
pub uuid_idempotency_token: String,
@@ -16,7 +16,7 @@ pub struct GenerateHunyuan2ImageTo3dRequest {
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct GenerateHunyuan2ImageTo3dResponse {
pub struct GenerateHunyuan20ImageTo3dResponse {
pub success: bool,
pub inference_job_token: InferenceJobToken,
}
@@ -1,2 +1,2 @@
pub mod generate_hunyuan_21_image_to_3d;
pub mod generate_hunyuan_2_image_to_3d;
pub mod generate_hunyuan_2_0_image_to_3d;
pub mod generate_hunyuan_2_1_image_to_3d;
@@ -18,10 +18,10 @@ use artcraft_api_defs::generate::image::generate_flux_pro_11_ultra_text_to_image
use artcraft_api_defs::generate::image::generate_flux_pro_11_ultra_text_to_image::GenerateFluxPro11UltraTextToImageResponse;
use artcraft_api_defs::generate::image::remove_image_background::RemoveImageBackgroundRequest;
use artcraft_api_defs::generate::image::remove_image_background::RemoveImageBackgroundResponse;
use artcraft_api_defs::generate::object::generate_hunyuan_21_image_to_3d::GenerateHunyuan21ImageTo3dRequest;
use artcraft_api_defs::generate::object::generate_hunyuan_21_image_to_3d::GenerateHunyuan21ImageTo3dResponse;
use artcraft_api_defs::generate::object::generate_hunyuan_2_image_to_3d::GenerateHunyuan2ImageTo3dRequest;
use artcraft_api_defs::generate::object::generate_hunyuan_2_image_to_3d::GenerateHunyuan2ImageTo3dResponse;
use artcraft_api_defs::generate::object::generate_hunyuan_2_1_image_to_3d::GenerateHunyuan21ImageTo3dRequest;
use artcraft_api_defs::generate::object::generate_hunyuan_2_1_image_to_3d::GenerateHunyuan21ImageTo3dResponse;
use artcraft_api_defs::generate::object::generate_hunyuan_2_0_image_to_3d::GenerateHunyuan20ImageTo3dRequest;
use artcraft_api_defs::generate::object::generate_hunyuan_2_0_image_to_3d::GenerateHunyuan20ImageTo3dResponse;
use artcraft_api_defs::generate::video::generate_kling_1_6_pro_image_to_video::GenerateKling16ProAspectRatio;
use artcraft_api_defs::generate::video::generate_kling_1_6_pro_image_to_video::GenerateKling16ProDuration;
use artcraft_api_defs::generate::video::generate_kling_1_6_pro_image_to_video::GenerateKling16ProImageToVideoRequest;
@@ -527,8 +527,8 @@ use crate::http_server::web_utils::response_success_helpers::*;
GenerateFluxPro11UltraTextToImageResponse,
GenerateHunyuan21ImageTo3dRequest,
GenerateHunyuan21ImageTo3dResponse,
GenerateHunyuan2ImageTo3dRequest,
GenerateHunyuan2ImageTo3dResponse,
GenerateHunyuan20ImageTo3dRequest,
GenerateHunyuan20ImageTo3dResponse,
GenerateKling16ProAspectRatio,
GenerateKling16ProDuration,
GenerateKling16ProImageToVideoRequest,
@@ -16,8 +16,8 @@ use actix_web::http::StatusCode;
use actix_web::web::Json;
use actix_web::web::Path;
use actix_web::{web, HttpRequest, HttpResponse};
use artcraft_api_defs::generate::object::generate_hunyuan_2_image_to_3d::GenerateHunyuan2ImageTo3dRequest;
use artcraft_api_defs::generate::object::generate_hunyuan_2_image_to_3d::GenerateHunyuan2ImageTo3dResponse;
use artcraft_api_defs::generate::object::generate_hunyuan_2_0_image_to_3d::GenerateHunyuan20ImageTo3dRequest;
use artcraft_api_defs::generate::object::generate_hunyuan_2_0_image_to_3d::GenerateHunyuan20ImageTo3dResponse;
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
use enums::common::visibility::Visibility;
use fal_client::requests::webhook::object::enqueue_hunyuan_3d_2_image_to_3d_webhook::enqueue_hunyuan_3d_2_image_to_3d_webhook;
@@ -38,7 +38,7 @@ use utoipa::ToSchema;
#[utoipa::path(
post,
tag = "Generate Objects",
path = "/v1/generate/object/hunyuan_2_image_to_3d",
path = "/v1/generate/object/hunyuan_2.0_image_to_3d",
responses(
(status = 200, description = "Success", body = GenerateHunyuan2ImageTo3dResponse),
),
@@ -46,11 +46,11 @@ use utoipa::ToSchema;
("request" = GenerateHunyuan2ImageTo3dRequest, description = "Payload for Request"),
)
)]
pub async fn generate_hunyuan_2_image_to_3d_handler(
pub async fn generate_hunyuan_2_0_image_to_3d_handler(
http_request: HttpRequest,
request: Json<GenerateHunyuan2ImageTo3dRequest>,
request: Json<GenerateHunyuan20ImageTo3dRequest>,
server_state: web::Data<Arc<ServerState>>
) -> Result<Json<GenerateHunyuan2ImageTo3dResponse>, CommonWebError> {
) -> Result<Json<GenerateHunyuan20ImageTo3dResponse>, CommonWebError> {
let maybe_user_session = server_state
.session_checker
.maybe_get_user_session(&http_request, &server_state.mysql_pool)
@@ -173,7 +173,7 @@ pub async fn generate_hunyuan_2_image_to_3d_handler(
}
};
Ok(Json(GenerateHunyuan2ImageTo3dResponse {
Ok(Json(GenerateHunyuan20ImageTo3dResponse {
success: true,
inference_job_token: job_token,
}))
@@ -16,8 +16,8 @@ use actix_web::http::StatusCode;
use actix_web::web::Json;
use actix_web::web::Path;
use actix_web::{web, HttpRequest, HttpResponse};
use artcraft_api_defs::generate::object::generate_hunyuan_21_image_to_3d::GenerateHunyuan21ImageTo3dRequest;
use artcraft_api_defs::generate::object::generate_hunyuan_21_image_to_3d::GenerateHunyuan21ImageTo3dResponse;
use artcraft_api_defs::generate::object::generate_hunyuan_2_1_image_to_3d::GenerateHunyuan21ImageTo3dRequest;
use artcraft_api_defs::generate::object::generate_hunyuan_2_1_image_to_3d::GenerateHunyuan21ImageTo3dResponse;
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
use enums::common::visibility::Visibility;
use fal_client::requests::webhook::object::enqueue_hunyuan_3d_21_image_to_3d_webhook::enqueue_hunyuan_3d_2_1_image_to_3d_webhook;
@@ -46,7 +46,7 @@ use utoipa::ToSchema;
("request" = GenerateHunyuan21ImageTo3dRequest, description = "Payload for Request"),
)
)]
pub async fn generate_hunyuan_21_image_to_3d_handler(
pub async fn generate_hunyuan_2_1_image_to_3d_handler(
http_request: HttpRequest,
request: Json<GenerateHunyuan21ImageTo3dRequest>,
server_state: web::Data<Arc<ServerState>>
@@ -1,2 +1,2 @@
pub mod generate_hunyuan_21_image_to_3d_handler;
pub mod generate_hunyuan_2_image_to_3d_handler;
pub mod generate_hunyuan_2_1_image_to_3d_handler;
pub mod generate_hunyuan_2_0_image_to_3d_handler;
@@ -2,6 +2,7 @@ use std::fmt;
use std::sync::Arc;
use crate::http_server::endpoints::webhooks::handle_image_payload::handle_image_payload;
use crate::http_server::endpoints::webhooks::handle_images_payload::handle_images_payload;
use crate::http_server::endpoints::webhooks::handle_model_mesh_payload::handle_model_mesh_payload;
use crate::http_server::endpoints::webhooks::handle_video_payload::handle_video_payload;
use crate::state::server_state::ServerState;
@@ -18,7 +19,6 @@ use mysql_queries::queries::generic_inference::fal::get_inference_job_by_fal_id:
use mysql_queries::queries::generic_inference::fal::mark_fal_generic_inference_job_successfully_done::mark_fal_generic_inference_job_successfully_done;
use serde_json::Value;
use utoipa::ToSchema;
// 1. tauri --> hit endpoint to enqueue
//
// 2. webhook
@@ -140,17 +140,24 @@ pub async fn fal_webhook_handler(
return Err(FalWebhookError::ServerError);
}
};
let mut maybe_media_token = None;
info!("Fal webhook job record: {:?}", job);
let mut maybe_media_token = None;
if let Some(payload_obj) = payload.as_object() {
if payload_obj.contains_key("image") {
info!("Handling image payload for job: {:?}", job.job_token);
let token = handle_image_payload(payload_obj, &job, &server_state).await?;
maybe_media_token = Some(token);
} else if payload_obj.contains_key("images") {
maybe_media_token = handle_images_payload(payload_obj, &job, &server_state).await?;
} else if payload_obj.contains_key("video") {
info!("Handling video payload for job: {:?}", job.job_token);
let token = handle_video_payload(payload_obj, &job, &server_state).await?;
maybe_media_token = Some(token);
} else if payload_obj.contains_key("model_mesh") {
info!("Handling model_mesh payload for job: {:?}", job.job_token);
let token = handle_model_mesh_payload(payload_obj, &job, &server_state).await?;
maybe_media_token = Some(token);
}
@@ -167,6 +174,8 @@ pub async fn fal_webhook_handler(
warn!("Error marking job as successfully done: {:?}", err);
FalWebhookError::ServerError
})?;
} else {
warn!("No media token found in payload for job: {:?}", job.job_token);
}
Ok(SimpleGenericJsonSuccess::wrapped(true))
@@ -0,0 +1,124 @@
use crate::http_server::endpoints::media_files::upload::upload_error::MediaFileUploadError;
use crate::state::server_state::ServerState;
use crate::util::http_download_url_to_bytes::http_download_url_to_bytes;
use crate::util::http_download_url_to_tempfile::http_download_url_to_tempfile;
use actix_web::web;
use anyhow::anyhow;
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory;
use enums::by_table::media_files::media_file_origin_product_category::MediaFileOriginProductCategory;
use enums::by_table::media_files::media_file_type::MediaFileType;
use errors::AnyhowResult;
use hashing::sha256::sha256_hash_bytes::sha256_hash_bytes;
use images::image_info::image_info::ImageInfo;
use log::{info, warn};
use mimetypes::mimetype_info::mimetype_info::MimetypeInfo;
use mysql_queries::queries::generic_inference::fal::get_inference_job_by_fal_id::FalJobDetails;
use mysql_queries::queries::media_files::create::insert_builder::media_file_insert_builder::MediaFileInsertBuilder;
use serde_json::{Map, Value};
use std::sync::Arc;
use tokens::tokens::media_files::MediaFileToken;
const PREFIX : Option<&str> = Some("artcraft_");
#[derive(Deserialize, Debug)]
pub struct FalWebhookImage {
pub content_type: Option<String>,
pub file_name: Option<String>,
pub file_size: Option<usize>,
pub height: Option<usize>,
pub width: Option<usize>,
pub url: Option<String>,
}
pub async fn handle_images_payload(
payload: &Map<String, Value>,
job: &FalJobDetails,
server_state: &ServerState,
) -> AnyhowResult<Option<MediaFileToken>> {
let images_value = payload.get("images")
.ok_or_else(|| anyhow!("no `images` key in payload"))?;
info!("Fal Images Payload: {:?}", images_value);
let images: Vec<FalWebhookImage> = serde_json::from_value(images_value.clone())?;
let mut maybe_media_token = None;
for image in images {
let media_token = upload_image(job, server_state, &image).await?;
if maybe_media_token.is_none() {
maybe_media_token = Some(media_token); // Set the first media token
}
}
Ok(maybe_media_token)
}
pub async fn upload_image(
job: &FalJobDetails,
server_state: &ServerState,
image: &FalWebhookImage,
) -> AnyhowResult<MediaFileToken> {
let image_url = image.url
.as_deref()
.ok_or_else(|| anyhow!("no `url` in image payload"))?;
//let mime_type = image.content_type
// .as_deref()
// .ok_or_else(|| anyhow!("no `content_type` in image payload"))?;
let file_bytes = http_download_url_to_bytes(image_url)
.await
.map_err(|e| anyhow!("Failed to download image: {:?}", e))?;
let mimetype_info = MimetypeInfo::get_for_bytes(&file_bytes)
.ok_or_else(|| anyhow!("Failed to get mimetype info"))?;
let mime_type = mimetype_info.mime_type();
let media_file_type = MediaFileType::try_from_mime_type(mime_type)
.ok_or_else(|| anyhow!("Unsupported media file type: {}", mime_type))?;
let extension_with_period = mimetype_info.file_extension()
.map(|ext| ext.extension_with_period())
.ok_or_else(|| anyhow!("Failed to get file extension from mimetype info"))?;
let file_size_bytes = file_bytes.len();
let file_hash = sha256_hash_bytes(&file_bytes)?;
let image_info = ImageInfo::decode_image_from_bytes(&file_bytes)?;
let public_upload_path = MediaFileBucketPath::generate_new(PREFIX, Some(&extension_with_period));
info!("Uploading media to bucket path: {}", public_upload_path.get_full_object_path_str());
server_state.public_bucket_client.upload_file_with_content_type_process(
public_upload_path.get_full_object_path_str(),
file_bytes.as_ref(),
&mime_type)
.await?;
let media_token = MediaFileInsertBuilder::new()
.maybe_creator_user(job.maybe_creator_user_token.as_ref())
.maybe_creator_anonymous_visitor(job.maybe_creator_anonymous_visitor_token.as_ref())
.creator_ip_address(&job.creator_ip_address)
.public_bucket_directory_hash(&public_upload_path)
.media_file_class(MediaFileClass::Image)
.media_file_type(media_file_type)
.media_file_origin_category(MediaFileOriginCategory::Inference)
//.media_file_origin_product_category(MediaFileOriginProductCategory::Unknown)
.mime_type(mime_type)
.file_size_bytes(file_size_bytes as u64)
.frame_width(image_info.width())
.frame_height(image_info.height())
.checksum_sha2(&file_hash)
.insert_pool(&server_state.mysql_pool)
.await?;
info!("Image media file uploaded with token: {}", media_token);
Ok(media_token)
}
@@ -1,4 +1,5 @@
pub mod fal_webhook_handler;
pub mod handle_image_payload;
pub mod handle_images_payload;
pub mod handle_model_mesh_payload;
pub mod handle_video_payload;
@@ -3,8 +3,8 @@ use crate::http_server::endpoints::generate::image::generate_flux_1_schnell_text
use crate::http_server::endpoints::generate::image::generate_flux_pro_11_text_to_image_handler::generate_flux_pro_11_text_to_image_handler;
use crate::http_server::endpoints::generate::image::generate_flux_pro_11_ultra_text_to_image_handler::generate_flux_pro_11_ultra_text_to_image_handler;
use crate::http_server::endpoints::generate::image::remove_image_background_handler::remove_image_background_handler;
use crate::http_server::endpoints::generate::object::generate_hunyuan_21_image_to_3d_handler::generate_hunyuan_21_image_to_3d_handler;
use crate::http_server::endpoints::generate::object::generate_hunyuan_2_image_to_3d_handler::generate_hunyuan_2_image_to_3d_handler;
use crate::http_server::endpoints::generate::object::generate_hunyuan_2_0_image_to_3d_handler::generate_hunyuan_2_0_image_to_3d_handler;
use crate::http_server::endpoints::generate::object::generate_hunyuan_2_1_image_to_3d_handler::generate_hunyuan_2_1_image_to_3d_handler;
use crate::http_server::endpoints::generate::video::generate_kling_1_6_pro_video_handler::generate_kling_1_6_pro_video_handler;
use crate::http_server::endpoints::generate::video::generate_kling_2_1_master_video_handler::generate_kling_2_1_master_video_handler;
use crate::http_server::endpoints::generate::video::generate_kling_2_1_pro_video_handler::generate_kling_2_1_pro_video_handler;
@@ -72,12 +72,12 @@ where
)
)
.service(web::scope("/object")
.service(web::resource("/hunyuan_2_image_to_3d")
.route(web::post().to(generate_hunyuan_2_image_to_3d_handler))
.service(web::resource("/hunyuan_2.0_image_to_3d")
.route(web::post().to(generate_hunyuan_2_0_image_to_3d_handler))
.route(web::head().to(|| HttpResponse::Ok()))
)
.service(web::resource("/hunyuan_2.1_image_to_3d")
.route(web::post().to(generate_hunyuan_21_image_to_3d_handler))
.route(web::post().to(generate_hunyuan_2_1_image_to_3d_handler))
.route(web::head().to(|| HttpResponse::Ok()))
)
)
+4
View File
@@ -41,6 +41,10 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
std::fs::create_dir_all(&cache_dir)?;
let path = cache_dir.join(COOKIES_FILENAME);
// NB(bt): Let's always log this.
println!("Tauri plugin http: using cookie store at {}", path.display());
let file = File::options()
.create(true)
.append(true)
@@ -2,19 +2,14 @@ import { useSignals } from "@preact/signals-react/runtime";
import { AUTH_STATUS } from "~/enums";
import { authentication } from "~/signals";
import { Button } from "@storyteller/ui-button";
import ProfileDropdown from "./ProfileDropdown";
import { LoginModal } from "@storyteller/ui-login-modal";
import { useState } from "react";
import {
disableHotkeyInput,
DomLevels,
enableHotkeyInput,
} from "~/pages/PageEnigma/signals";
// import ProfileDropdown from "./ProfileDropdown";
export const AuthButtons = () => {
export const AuthButtons = ({
loginSignUpPressed,
}: {
loginSignUpPressed: () => void;
}) => {
useSignals();
const [showLoginModal, setShowLoginModal] = useState(false);
const [isSignUp, setIsSignUp] = useState(false);
const { status, userInfo } = authentication;
@@ -22,7 +17,7 @@ export const AuthButtons = () => {
console.log(status.value);
if (status.value === AUTH_STATUS.LOGGED_IN) {
console.log("SHOWING DROPDOWN");
return <ProfileDropdown />;
return null;
} else {
return (
<>
@@ -31,34 +26,12 @@ export const AuthButtons = () => {
<Button
className="h-[38px]"
onClick={() => {
setIsSignUp(true);
setShowLoginModal(true);
loginSignUpPressed();
}}
>
Login / Sign Up
</Button>
</div>
{showLoginModal && (
<LoginModal
onClose={() => setShowLoginModal(false)}
isSignUp={isSignUp}
videoSrc2D="/resources/videos/artcraft-canvas-demo.mp4"
videoSrc3D="/resources/videos/artcraft-3d-demo.mp4"
onArtCraftAuthSuccess={(userInfo_value) => {
userInfo.value = userInfo_value;
status.value = AUTH_STATUS.LOGGED_IN;
setShowLoginModal(false);
}}
onOpenChange={(isOpen: boolean) => {
if (isOpen) {
disableHotkeyInput(DomLevels.DIALOGUE);
} else {
enableHotkeyInput(DomLevels.DIALOGUE);
}
}}
/>
)}
</>
);
}
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useRef, useState } from "react";
import {
faGear,
faImages,
@@ -6,6 +6,7 @@ import {
faFilm,
faPaintbrush,
faImage,
// faGem,
} from "@fortawesome/pro-solid-svg-icons";
import { Button } from "@storyteller/ui-button";
import { AuthButtons } from "./AuthButtons";
@@ -18,7 +19,7 @@ import {
} from "@storyteller/ui-gallery-modal";
import { SettingsModal } from "@storyteller/ui-settings-modal";
import { Tooltip } from "@storyteller/ui-tooltip";
import { downloadFileFromUrl } from "@storyteller/api";
import { downloadFileFromUrl, FilterMediaClasses } from "@storyteller/api";
import {
MenuIconSelector,
MenuIconItem,
@@ -34,10 +35,15 @@ import {
is3DSceneLoaded,
set3DPageMounted,
} from "~/pages/PageEnigma/Editor/editor";
// import { usePricingModalStore } from "@storyteller/ui-pricing-modal"; - Uncomment for pricing modal - BFlat
import toast from "react-hot-toast";
interface Props {
pageName: string;
loginSignUpPressed: () => void;
}
const SWITCHER_THROTTLE_TIME = 500; // milliseconds
const appMenuTabs: MenuIconItem[] = [
{
id: "2D",
@@ -70,7 +76,7 @@ const appMenuTabs: MenuIconItem[] = [
export const topNavMediaId = signal<string>("");
export const topNavMediaUrl = signal<string>("");
export const TopBar = ({ pageName }: Props) => {
export const TopBar = ({ pageName, loginSignUpPressed }: Props) => {
useSignals();
const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false);
@@ -84,14 +90,33 @@ export const TopBar = ({ pageName }: Props) => {
const is3DSceneReady = is3DSceneLoaded.value;
const is3DEditorReady = is3DEditorInitialized.value;
const [disableSwitcher, setDisableSwitcher] = useState(false);
const switcherThrottle = useRef(false);
const disableTabSwitcher = () => {
return (
useTabStore.getState().activeTabId === "3D" &&
!is3DEditorReady &&
!is3DSceneReady
disableSwitcher ||
(useTabStore.getState().activeTabId === "3D" &&
!is3DEditorReady &&
!is3DSceneReady)
);
};
const downloadFile = async (url: string, mediaClass?: string) => {
try {
await downloadFileFromUrl(url);
if (mediaClass === FilterMediaClasses.DIMENSIONAL) {
toast.success(`Downloaded 3D model`);
} else {
toast.success(`Downloaded ${mediaClass}`);
}
} catch (error) {
toast.error("Failed to download file");
}
};
// const { toggleModal } = usePricingModalStore(); - Uncomment for pricing modal - BFlat
return (
<>
<header className="fixed left-0 top-0 z-[60] w-full border-b border-white/5 bg-ui-background">
@@ -113,6 +138,13 @@ export const TopBar = ({ pageName }: Props) => {
activeMenu={tabStore.activeTabId}
disabled={disableTabSwitcher()}
onMenuChange={(tabId) => {
// Prevent a second input if the switcher is throttled.
if (switcherThrottle.current) {
return;
}
switcherThrottle.current = true;
setDisableSwitcher(true);
// Disable 3d engine to prevent memory leak.
if (tabId === "3D") {
set3DPageMounted(true);
@@ -120,6 +152,12 @@ export const TopBar = ({ pageName }: Props) => {
set3DPageMounted(false);
}
useTabStore.getState().setActiveTab(tabId);
setTimeout(() => {
// Clear the throttle
switcherThrottle.current = false;
// Trigger a new re-render (important)
setDisableSwitcher(false);
}, SWITCHER_THROTTLE_TIME);
}}
className="w-fit"
/>
@@ -139,8 +177,17 @@ export const TopBar = ({ pageName }: Props) => {
)}
</div>
<div className="flex justify-end gap-3.5">
<div className="flex justify-end gap-3.5 pr-2">
<div className="flex gap-2">
{/* - Uncomment for pricing modal - BFlat */}
{/* <Button
variant="primary"
icon={faGem}
onClick={toggleModal}
className="shadow-md shadow-primary-500/50 transition-all duration-300 hover:shadow-md hover:shadow-primary-500/75"
>
Upgrade Now
</Button> */}
<Tooltip content="Settings" position="bottom" delay={300}>
<Button
variant="secondary"
@@ -159,9 +206,7 @@ export const TopBar = ({ pageName }: Props) => {
<Activity />
</div>
<div className="flex justify-end gap-2">
<AuthButtons />
</div>
<AuthButtons loginSignUpPressed={loginSignUpPressed} />
</div>
</nav>
</header>
@@ -172,7 +217,7 @@ export const TopBar = ({ pageName }: Props) => {
globalAccountLogoutCallback={() => setLogoutStates()}
/>
<GalleryModal mode="view" onDownloadClicked={downloadFileFromUrl} />
<GalleryModal mode="view" onDownloadClicked={downloadFile} />
</>
);
};
@@ -6,7 +6,7 @@ import "./App.css";
import PromptEditor from "./PromptEditor/PromptEditor";
import SideToolbar from "./components/ui/SideToolbar";
// Import the Zustand store
import { useSceneStore } from "./stores/SceneState";
import { AspectRatioType, useSceneStore } from "./stores/SceneState";
import { useUndoRedoHotkeys } from "./hooks/useUndoRedoHotkeys";
import { useDeleteHotkeys } from "./hooks/useDeleteHotkeys";
import { useCopyPasteHotkeys } from "./hooks/useCopyPasteHotkeys"; // Import the hook
@@ -196,6 +196,26 @@ const PageDraw = () => {
}
};
const onFitPressed = async () => {
// Get the stage and its container dimensions
const stage = stageRef.current;
// Get container dimensions
const containerWidth = stage.container().offsetWidth;
const containerHeight = stage.container().offsetHeight;
// Get canvas dimensions from store aspect ratio
const canvasW = store.getAspectRatioDimensions().width;
const canvasH = store.getAspectRatioDimensions().height;
// Calculate position to center canvas in container
stage.position({
x: (containerWidth - canvasW) / 2,
y: (containerHeight - canvasH) / 2
});
}
return (
<>
<div
@@ -219,7 +239,23 @@ const PageDraw = () => {
}}
onAspectRatioChange={(ratio: string) => {
console.log("Aspect ratio:", ratio);
// Handle aspect ratio changes here
// Convert ratio string to AspectRatioType enum
const ratioToType = (ratio: string): AspectRatioType => {
switch (ratio) {
case "2:3":
return AspectRatioType.PORTRAIT;
case "3:2":
return AspectRatioType.LANDSCAPE;
case "1:1":
return AspectRatioType.SQUARE;
default:
return AspectRatioType.NONE;
}
};
const aspectRatioType = ratioToType(ratio);
store.setAspectRatioType(aspectRatioType);
onFitPressed()
}}
onEnqueuePressed={onEnqueuedPressed}
/>
@@ -413,6 +449,12 @@ const PageDraw = () => {
showIconsInList
triggerLabel="Model"
/>
<button
className="bg-transparent p-2 text-lg text-white hover:text-white hover:bg-white/20 rounded transition"
onClick={onFitPressed}
>
Fit
</button>
</div>
</>
);
@@ -49,6 +49,9 @@ export const PaintSurface = ({
stageRef,
transformerRefs,
}: MiraiProps) => {
// switch off to be preview panel mode.
const singlePaneMode = true
const store = useSceneStore(); // Use store directly
const imageRef = React.useRef<HTMLImageElement>(null);
const [snapshotImage, setSnapshotImage] = useState<HTMLImageElement | null>(
@@ -66,7 +69,7 @@ export const PaintSurface = ({
const [rightPanelHeight, setRightPanelHeight] = useState(1024);
/* 1️⃣ Track SplitPane percent so we can re-measure */
const [leftPct, setLeftPct] = useState(50);
const [leftPct, setLeftPct] = useState(singlePaneMode ? 100:50);
const [isDrawing, setIsDrawing] = useState(false);
const [lastPoint, setLastPoint] = useState<{ x: number; y: number } | null>(
null,
@@ -131,17 +134,17 @@ export const PaintSurface = ({
y: number;
}): { x: number; y: number } => {
return {
x: Math.max(0, Math.min(point.x, leftPanelWidth)),
y: Math.max(0, Math.min(point.y, leftPanelHeight)),
x: Math.max(0, Math.min(point.x, store.getAspectRatioDimensions().width)), //leftPanelWidth)),
y: Math.max(0, Math.min(point.y, store.getAspectRatioDimensions().height))//leftPanelHeight)),
};
};
const isWithinLeftPanel = (point: { x: number; y: number }): boolean => {
return (
point.x >= 0 &&
point.x <= leftPanelWidth &&
point.x <= store.getAspectRatioDimensions().width && //leftPanelWidth &&
point.y >= 0 &&
point.y <= leftPanelHeight
point.y <= store.getAspectRatioDimensions().height // leftPanelHeight
);
};
@@ -194,7 +197,8 @@ export const PaintSurface = ({
stroke: activeTool === "draw" ? brushColor : fillColor || "#ffffff",
strokeWidth: brushSize / stage.scaleX(),
draggable: true,
opacity: opacity
opacity: opacity,
locked: false,
};
store.selectNode(null);
store.addLineNode(newLineNode, false); // Don't save state when starting line
@@ -659,9 +663,10 @@ export const PaintSurface = ({
"bottom-right",
]}
rotateEnabled={true}
keepRatio={false}
keepRatio={true}
centeredScaling={true}
padding={5}
shiftBehavior="inverted"
ignoreStroke={true}
onTransformEnd={(e: Konva.KonvaEventObject<Event>) => {
// Typed event
@@ -975,10 +980,11 @@ export const PaintSurface = ({
}
}, [selectedNodeIds]);
return (
<SplitPane
singlePaneMode={true}
initialPercent={50}
singlePaneMode={singlePaneMode}
initialPercent={singlePaneMode?100 : 50}
onChange={setLeftPct}
left={
<div className="flex h-full w-full items-center justify-center overflow-hidden">
@@ -989,7 +995,7 @@ export const PaintSurface = ({
scaleX={1} // Initial scale, controlled by wheel/zoom
scaleY={1} // Initial scale, controlled by wheel/zoom
style={{
display: "block",
// display: "block",
background: "transparent", // Or use fillColor if stage background is desired directly
}}
x={stagePosition.x} // Set the x position
@@ -1006,14 +1012,14 @@ export const PaintSurface = ({
<Layer
ref={leftPanelRef}
clipFunc={(ctx) => {
ctx.rect(0, 0, leftPanelWidth, leftPanelHeight);
ctx.rect(0, 0, store.getAspectRatioDimensions().width, store.getAspectRatioDimensions().height); // leftPanelWidth, leftPanelHeight);
}}
>
<Rect
x={0}
y={0}
width={leftPanelWidth}
height={leftPanelHeight}
width={store.getAspectRatioDimensions().width}
height={store.getAspectRatioDimensions().height}
fill={fillColor}
listening={false}
zIndex={-1}
@@ -12,6 +12,7 @@ import { getCanvasRenderBitmap } from "../../../signals/canvasRenderBitmap"
const PromptEditor: React.FC<PromptEditorProps> = ({
onImageStyleChange,
onEnqueuePressed,
onAspectRatioChange,
}) => {
const [images, setImages] = useState<ImageStyle[]>([]);
@@ -40,6 +41,7 @@ const PromptEditor: React.FC<PromptEditorProps> = ({
EncodeImageBitmapToBase64={EncodeImageBitmapToBase64}
useJobContext={useJobContext}
onEnqueuePressed={onEnqueuePressed}
onAspectRatioChange={onAspectRatioChange}
/>
</JobProvider>
@@ -21,6 +21,13 @@ export type LineNode = {
locked?: boolean; // Add locked property
};
// Add this enum at the top of the file with other types
export enum AspectRatioType {
PORTRAIT = '2:3', // 683 x 1024
LANDSCAPE = '3:2', // 1024 x 683
SQUARE = '1:1', // 1024 x 1024
NONE = 'none' // No aspect ratio constraint
}
// Logic to remove background from image nodes would go here
const convertFileToBase64 = (file: File): Promise<string> => {
@@ -133,10 +140,17 @@ interface SceneState {
sendToBack: (nodeIds: string[]) => void;
bringForward: (nodeIds: string[]) => void;
sendBackward: (nodeIds: string[]) => void;
removeBackground: (nodeIds: string[], operation: (success: boolean,base64_image:string, message: string) => Promise<{ success: boolean; file?: File }>) => Promise<void>;
removeBackground: (nodeIds: string[], operation: (success: boolean, base64_image: string, message: string) => Promise<{ success: boolean; file?: File }>) => Promise<void>;
// Add new lock action
toggleLock: (nodeIds: string[]) => void;
// Add minimal aspect ratio property
aspectRatioType: AspectRatioType;
// Add aspect ratio action
setAspectRatioType: (type: AspectRatioType) => void;
getAspectRatioDimensions: () => { width: number; height: number };
}
const generateId = (): string => {
@@ -182,6 +196,9 @@ export const useSceneStore = create<SceneState>((set, get) => ({
cursorPosition: null,
cursorVisible: false,
// Add initial aspect ratio state
aspectRatioType: AspectRatioType.NONE,
// Actions
addNode: (node: Node) => {
set((state) => {
@@ -614,34 +631,21 @@ export const useSceneStore = create<SceneState>((set, get) => ({
},
// Add method for URL-based images
createImageFromUrl: (x: number, y: number, url: string, width?: number, height?: number) => {
// Similar to createImageFromFile but handles URL
const img = new Image();
img.crossOrigin = "Anonymous"; // Handle CORS if necessary
img.onload = () => {
const aspectRatio = img.width / img.height;
const newWidth = width || (height ? height * aspectRatio : 200); // Default width or calculate from height
const newHeight = height || (width ? width / aspectRatio : newWidth / aspectRatio); // Default height or calculate from width
createImageFromUrl: async (x: number, y: number, url: string, width?: number, height?: number) => {
try {
// Fetch the image and convert to blob
const response = await fetch(url);
const blob = await response.blob();
const node = new Node({
id: generateId(),
type: 'image',
x,
y,
width: newWidth,
height: newHeight,
fill: 'transparent',
imageElement: img, // Use the loaded image object
draggable: true,
imageUrl: url // Store the URL as the source
});
get().addNode(node);
};
img.onerror = () => {
console.error("Error loading image from URL:", url);
// Optionally handle the error, e.g., by creating a placeholder or notifying the user
};
img.src = url;
// Create a File from the blob
const filename = url.split('/').pop() || 'image.png';
const file = new File([blob], filename, { type: blob.type });
// Use existing createImage function
get().createImageFromFile(x, y, file, width, height);
} catch (error) {
console.error("Error loading image from URL:", url, error);
}
},
// Update the createImage method to handle both URLs and Files
@@ -866,6 +870,7 @@ export const useSceneStore = create<SceneState>((set, get) => ({
brushColor: state.brushColor,
brushSize: state.brushSize,
fillColor: state.fillColor,
aspectRatioType: state.aspectRatioType,
version: "1.0"
};
@@ -924,7 +929,8 @@ export const useSceneStore = create<SceneState>((set, get) => ({
selectedNodeIds: [],
brushColor: sceneData.brushColor || '#000000',
brushSize: sceneData.brushSize || 5,
fillColor: sceneData.fillColor || 'white'
fillColor: sceneData.fillColor || 'white',
aspectRatioType: sceneData.aspectRatioType || AspectRatioType.NONE
});
isRestoring = false;
@@ -1162,58 +1168,46 @@ export const useSceneStore = create<SceneState>((set, get) => ({
});
get().saveState();
},
removeBackground: async (nodeIds: string[], operation: (success: boolean, base64_image:string,message: string) => Promise<{ success: boolean; file?: File }>) => {
removeBackground: async (nodeIds: string[], operation: (success: boolean, base64_image: string, message: string) => Promise<{ success: boolean; file?: File }>) => {
const hasImageNodes = nodeIds.some(id => {
const node = get().nodes.find(n => n.id === id);
return node ? node.type === 'image': false;
const node = get().nodes.find(n => n.id === id);
return node ? node.type === 'image' : false;
});
let callbackSuccess = false
if (hasImageNodes) {
console.log("Selected nodes include image types.");
const firstNode = get().nodes.find(node => nodeIds.includes(node.id) && node.type === 'image');
if (firstNode) {
const imageFile = firstNode.imageFile; // Assuming the file is stored in the node
if (imageFile !== undefined) {
try {
const base64Image = await convertFileToBase64(imageFile);
console.log("Base64 Image:", base64Image);
// Additional logic to handle the base64 image can be added here
const { success, file } = await operation(true, base64Image, "Removing Background."); // Indicate success in removing background
callbackSuccess = success;
if (callbackSuccess) {
// save the state of the image.
set((state: SceneState) => ({
nodes: state.nodes.map((node: Node) => {
if (node.id === firstNode.id) {
// Create an object URL for immediate preview if needed
console.log("Updated Node!")
const updatedImageUrl: string | undefined = file ? URL.createObjectURL(file) : node.imageUrl;
return new Node({
...node,
imageFile: file,
imageUrl: updatedImageUrl,
});
}
return node;
}),
}));
const firstNode = get().nodes.find(node => nodeIds.includes(node.id) && node.type === 'image');
if (firstNode && firstNode.imageFile) {
try {
const base64Image = await convertFileToBase64(firstNode.imageFile);
const { success, file } = await operation(true, base64Image, "Removing Background.");
if (success && file) {
// Create a new node instance
const updatedNode = new Node({
...firstNode,
imageFile: file
});
// Load the new image
await updatedNode.setImageFromFile(file);
// Update the store with the fully loaded node
set((state: SceneState) => ({
nodes: state.nodes.map((node: Node) => {
if (node.id === firstNode.id) {
return updatedNode;
}
} catch (error) {
console.error("Error converting file to base64:", error);
}
}
console.log("Image File:", imageFile);
} else {
console.warn("No image node found or first node is not an image.");
return node;
}),
}));
get().saveState();
}
} catch (error) {
console.error("Error updating image:", error);
}
} else {
console.log("No selected nodes are of image type.");
operation(false,"","Did not select a Images"); // Indicate failure to remove background
}
}
},
// Add toggleLock action
@@ -1247,5 +1241,25 @@ export const useSceneStore = create<SceneState>((set, get) => ({
};
});
get().saveState();
}
},
// Add aspect ratio actions
setAspectRatioType: (type: AspectRatioType) => {
set({ aspectRatioType: type });
get().saveState();
},
getAspectRatioDimensions: () => {
const { aspectRatioType } = get();
switch (aspectRatioType) {
case AspectRatioType.PORTRAIT:
return { width: 683, height: 1024 };
case AspectRatioType.LANDSCAPE:
return { width: 1024, height: 683 };
case AspectRatioType.SQUARE:
return { width: 1024, height: 1024 };
default:
return { width: 1024, height: 683 }; // Default to landscape
}
},
}));
@@ -10,6 +10,7 @@ import { authentication, pageHeight, pageWidth } from "~/signals";
import { PoseModeSelector } from "./comps/PoseModeSelector";
import ImageToVideo from "../PageVideo/ImageToVideo";
import TextToImage from "../PageImage/TextToImage";
import {
timelineHeight,
sidePanelWidth,
@@ -72,12 +73,20 @@ import {
// videoGenerationModels,
// useModelSelectorStore,
} from "@storyteller/ui-model-selector";
import { LoginModal } from "@storyteller/ui-login-modal";
import { LoginModal, useLoginModalStore } from "@storyteller/ui-login-modal";
import PageDraw from "../PageDraw/PageDraw";
import { useTabStore } from "../Stores/TabState";
export const PageEditor = () => {
useSignals();
const { triggerRecheck } = useLoginModalStore();
const { status } = authentication;
useEffect(() => {
if (status.value === AUTH_STATUS.LOGGED_OUT) {
triggerRecheck();
}
}, [status.value, triggerRecheck]);
const tabStore = useTabStore();
const handleOverlayClick = (event: React.MouseEvent<HTMLDivElement>) => {
@@ -408,7 +417,28 @@ export const PageEditor = () => {
return (
<div className="w-screen">
<TopBar pageName="Edit Scene" />
<TopBar
loginSignUpPressed={() => {
console.log("PRESSED");
triggerRecheck();
}}
pageName="Edit Scene"
/>
<LoginModal
videoSrc2D="/resources/videos/artcraft-canvas-demo.mp4"
videoSrc3D="/resources/videos/artcraft-3d-demo.mp4"
onOpenChange={(isOpen: boolean) => {
if (isOpen) {
disableHotkeyInput(DomLevels.DIALOGUE);
} else {
enableHotkeyInput(DomLevels.DIALOGUE);
}
}}
onArtCraftAuthSuccess={(userInfo: any) => {
authentication.status.value = AUTH_STATUS.LOGGED_IN;
authentication.userInfo.value = userInfo;
}}
/>
{tabStore.activeTabId == "3D" && (
<div>
<OnboardingHelper />
@@ -527,7 +557,7 @@ export const PageEditor = () => {
)}
{tabStore.activeTabId == "IMAGE" && (
<div>
<ImageToVideo
<TextToImage
imageMediaId={topNavMediaId.value}
imageUrl={topNavMediaUrl.value}
/>
@@ -535,30 +565,12 @@ export const PageEditor = () => {
)}
{tabStore.activeTabId == "VIDEO" && (
<div>
<TextToImage
<ImageToVideo
imageMediaId={topNavMediaId.value}
imageUrl={topNavMediaUrl.value}
/>
</div>
)}
<LoginModal
onClose={() => {}}
videoSrc2D="/resources/videos/artcraft-canvas-demo.mp4"
videoSrc3D="/resources/videos/artcraft-3d-demo.mp4"
openAiLogo="/resources/images/openai-logo.png"
onOpenChange={(isOpen: boolean) => {
if (isOpen) {
disableHotkeyInput(DomLevels.DIALOGUE);
} else {
enableHotkeyInput(DomLevels.DIALOGUE);
}
}}
onArtCraftAuthSuccess={(userInfo: any) => {
authentication.status.value = AUTH_STATUS.LOGGED_IN;
authentication.userInfo.value = userInfo;
}}
/>
</div>
);
};
@@ -27,6 +27,7 @@ import { useGenerationCompleteEvent } from "./TauriEvents/generations/useGenerat
import { PageEditor } from "~/pages/PageEnigma/PageEditor";
import { GalleryDragComponent } from "@storyteller/ui-gallery-modal";
import { LoadingDots } from "@storyteller/ui-loading";
import { PricingModal } from "@storyteller/ui-pricing-modal";
import {
isActionReminderOpen,
@@ -132,6 +133,8 @@ export const PageEnigma = ({ sceneToken }: { sceneToken?: string }) => {
openAiLogo={currentReminderModalProps.openAiLogo}
/>
)}
<PricingModal />
</EngineProvider>
);
};
@@ -1,15 +0,0 @@
import { useSignals } from "@preact/signals-react/runtime";
import { PageEditor } from "~/pages/PageEnigma/PageEditor";
// TODO: Remove these file
import { currentPage } from "~/signals";
import { Pages } from "~/pages/PageEnigma/constants/page";
export const PageEnigmaComponent = () => {
useSignals();
return (
<>
<PageEditor />
</>
);
};
@@ -13,14 +13,19 @@ interface TextToImageProps {
imageMediaId?: string;
imageUrl?: string;
}
import { IMAGE_MODELS_BY_LABEL } from "@storyteller/model-list";
import { ModelInfo } from "@storyteller/model-list";
const TextToImage = ({ imageMediaId, imageUrl }: TextToImageProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const { selectedModels } = useModelSelectorStore();
const selectedModel =
selectedModels[ModelCategory.TextToImage] ||
imageGenerationModels[0]?.label;
const selectedModelInfo : ModelInfo | undefined = IMAGE_MODELS_BY_LABEL[selectedModel];
const jobContext: JobContextType = {
jobTokens: [],
addJobToken: () => {},
@@ -48,6 +53,7 @@ const TextToImage = ({ imageMediaId, imageUrl }: TextToImageProps) => {
return jobContext;
}}
model={selectedModel}
modelInfo={selectedModelInfo}
imageMediaId={imageMediaId}
url={imageUrl ?? undefined}
onEnqueuePressed={async () => {
@@ -8,6 +8,8 @@ import {
useModelSelectorStore,
videoGenerationModels,
} from "@storyteller/ui-model-selector";
import { VIDEO_MODELS_BY_LABEL } from "@storyteller/model-list";
import { ModelInfo } from "@storyteller/model-list";
interface ImageToVideoProps {
imageMediaId?: string;
@@ -17,10 +19,13 @@ interface ImageToVideoProps {
const ImageToVideo = ({ imageMediaId, imageUrl }: ImageToVideoProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const { selectedModels } = useModelSelectorStore();
const selectedModel =
selectedModels[ModelCategory.ImageToVideo] ||
videoGenerationModels[0]?.label;
const selectedModelInfo : ModelInfo | undefined = VIDEO_MODELS_BY_LABEL[selectedModel];
const jobContext: JobContextType = {
jobTokens: [],
addJobToken: () => {},
@@ -49,6 +54,7 @@ const ImageToVideo = ({ imageMediaId, imageUrl }: ImageToVideoProps) => {
return jobContext;
}}
model={selectedModel}
modelInfo={selectedModelInfo}
imageMediaId={imageMediaId}
url={imageUrl ?? undefined}
onEnqueuePressed={async () => {
@@ -29,6 +29,12 @@ export default {
white: colors.white,
gray: colors.gray,
black: colors.black,
blue: colors.blue,
green: colors.green,
yellow: colors.yellow,
purple: colors.purple,
pink: colors.pink,
orange: colors.orange,
primary: {
DEFAULT: "#2d81ff",
"50": "#eef6ff",
+8 -2
View File
@@ -65,10 +65,10 @@
"path": "../../libs/components/button-dropdown"
},
{
"path": "../../libs/tauri-api"
"path": "../../libs/components/action-reminder-modal"
},
{
"path": "../../libs/components/action-reminder-modal"
"path": "../../libs/components/pricing-modal"
},
{
"path": "../../libs/components/toaster"
@@ -82,6 +82,9 @@
{
"path": "../../libs/components/promptbox"
},
{
"path": "../../libs/tauri-api"
},
{
"path": "../../libs/components/popover"
},
@@ -106,6 +109,9 @@
{
"path": "../../libs/components/gravatar"
},
{
"path": "../../libs/components/login-modal"
},
{
"path": "../../libs/components/select"
},
+5 -1
View File
@@ -98,6 +98,7 @@ export class UsersApi extends ApiManager {
};
try {
console.log("calling auth fetch")
const response = await this.authFetch<
{ username_or_email: string; password: string },
{
@@ -110,7 +111,8 @@ export class UsersApi extends ApiManager {
method: "POST",
body: body,
});
console.log(response)
console.log("Auth fetch response!!! above")
return {
success: response.success,
data: response.success
@@ -198,3 +200,5 @@ export class UsersApi extends ApiManager {
}
}
}
(window as any).UsersApi = new UsersApi();
+2
View File
@@ -1,4 +1,6 @@
export enum API_TARGETS {
// NB: Comment this out for quick testing in development
//BASE_API = "http://localhost:12345",
BASE_API = "https://api.storyteller.ai",
GOOGLE_API = "https://storage.googleapis.com",
FUNNEL_API = "https://style.storyteller.ai",
+1 -2
View File
@@ -1,3 +1,2 @@
export * from './lib/BuildEnv';
export * from './lib/BuildEnvType';
export * from './lib/build-env';
+27 -29
View File
@@ -1,4 +1,4 @@
/// <reference types='vitest' />
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import dts from 'vite-plugin-dts';
@@ -7,7 +7,7 @@ import * as path from 'path';
export default defineConfig(() => ({
root: __dirname,
cacheDir: '../../node_modules/.vite/libs/build-env',
plugins: [react(), dts({ entryRoot: 'src', tsconfigPath: path.join(__dirname, 'tsconfig.lib.json') })],
plugins: [react(), dts({ entryRoot: 'src', tsconfigPath: path.join(__dirname, 'tsconfig.lib.json') }), ],
// Uncomment this if you are using workers.
// worker: {
// plugins: [ nxViteTsPaths() ],
@@ -15,35 +15,33 @@ export default defineConfig(() => ({
// Configuration for building your library.
// See: https://vitejs.dev/guide/build.html#library-mode
build: {
outDir: './dist',
emptyOutDir: true,
reportCompressedSize: true,
commonjsOptions: {
transformMixedEsModules: true,
},
lib: {
// Could also be a dictionary or array of multiple entry points.
entry: 'src/index.ts',
name: '@frontend/build-env',
fileName: 'index',
// Change this to the formats you want to support.
// Don't forget to update your package.json as well.
formats: ['es' as const]
},
rollupOptions: {
// External packages that should not be bundled into your library.
external: ['react','react-dom','react/jsx-runtime']
'emptyOutDir': true,
'transformMixedEsModules': true,
'entry': 'src/index.ts',
'name': '@frontend/build-env',
'fileName': 'index',
'formats': ['es' as const],
'external': ['react','react-dom','react/jsx-runtime'],
'lib': {
'entry': "src/index.ts",
'name': "@frontend/build-env",
'fileName': "index",
'formats': ['es' as const],
},
'rollupOptions': {"external":["'react'","'react-dom'","'react/jsx-runtime'"]},
'outDir': "./dist",
'reportCompressedSize': true,
'commonjsOptions': {"transformMixedEsModules":true},
},
test: {
watch: false,
globals: true,
environment: 'jsdom',
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
reporters: ['default'],
coverage: {
reportsDirectory: './test-output/vitest/coverage',
provider: 'v8' as const,
}
'watch': false,
'globals': true,
'environment': "jsdom",
'include': ["src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"],
'reporters': ["default"],
'coverage': {
'reportsDirectory': './test-output/vitest/coverage',
'provider': 'v8' as const,
}
},
}));
@@ -2,6 +2,15 @@
"files": [],
"include": [],
"references": [
{
"path": "../../tauri-api"
},
{
"path": "../../api"
},
{
"path": "../image-input"
},
{
"path": "../gallery-modal"
},
@@ -43,6 +43,15 @@
"src/**/*.tsx"
],
"references": [
{
"path": "../../tauri-api/tsconfig.lib.json"
},
{
"path": "../../api/tsconfig.lib.json"
},
{
"path": "../image-input/tsconfig.lib.json"
},
{
"path": "../gallery-modal/tsconfig.lib.json"
},
@@ -62,7 +62,7 @@ interface GalleryModalProps {
onSelectItem?: (id: string) => void;
maxSelections?: number;
onUseSelected?: (selectedItems: GalleryItem[]) => void;
onDownloadClicked?: (url: string) => Promise<void>;
onDownloadClicked?: (url: string, mediaClass?: string) => Promise<void>;
onAddToSceneClicked?: (
url: string,
media_id: string | undefined
@@ -376,7 +376,7 @@ export const GalleryModal = React.memo(
)}
<div className="flex h-full flex-col">
<div className="border-b border-white/10 p-4 py-3">
<div className="grid grid-cols-2 items-center">
<div className="flex justify-between items-center">
<div className="flex items-center gap-4">
<h2 className="text-xl font-semibold">
{mode === "select" ? "Select Images" : "My Library"}
@@ -2,6 +2,9 @@
"files": [],
"include": [],
"references": [
{
"path": "../button"
},
{
"path": "./tsconfig.lib.json"
},
@@ -41,5 +41,10 @@
"src/**/*.jsx",
"src/**/*.ts",
"src/**/*.tsx"
],
"references": [
{
"path": "../button/tsconfig.lib.json"
}
]
}
@@ -21,7 +21,7 @@ interface LightboxModalProps {
additionalInfo?: React.ReactNode;
downloadUrl?: string;
mediaId?: string;
onDownloadClicked?: (url: string) => Promise<void>;
onDownloadClicked?: (url: string, mediaClass?: string) => Promise<void>;
onAddToSceneClicked?: (
url: string,
media_id: string | undefined
@@ -164,7 +164,7 @@ export function LightboxModal({
icon={faDownToLine}
onClick={async (e) => {
e.stopPropagation();
await onDownloadClicked(downloadUrl);
await onDownloadClicked(downloadUrl, mediaClass);
}}
>
Download
@@ -2,6 +2,9 @@
"files": [],
"include": [],
"references": [
{
"path": "../loading-spinner"
},
{
"path": "../../tauri-api"
},
@@ -9,7 +12,7 @@
"path": "../button"
},
{
"path": "../close-button"
"path": "../modal"
},
{
"path": "./tsconfig.lib.json"
@@ -43,6 +43,9 @@
"src/**/*.tsx"
],
"references": [
{
"path": "../loading-spinner/tsconfig.lib.json"
},
{
"path": "../../tauri-api/tsconfig.lib.json"
},
@@ -50,7 +53,7 @@
"path": "../button/tsconfig.lib.json"
},
{
"path": "../close-button/tsconfig.lib.json"
"path": "../modal/tsconfig.lib.json"
}
]
}
@@ -1,2 +1,2 @@
export * from './lib/login-modal';
export * from "./lib/login-modal";
export * from "./lib/useLoginModalStore";
@@ -1,26 +1,20 @@
import { Button } from "@storyteller/ui-button";
import { Transition, TransitionChild } from "@headlessui/react";
import { createPortal } from "react-dom";
import { useState, useEffect, useRef } from "react";
import {
faArrowRight,
faRightToBracket,
} from "@fortawesome/pro-solid-svg-icons";
import { faArrowRight } from "@fortawesome/pro-solid-svg-icons";
import { faDiscord } from "@fortawesome/free-brands-svg-icons";
import { IsDesktopApp } from "@storyteller/tauri-utils";
import {
CheckSoraSession,
SoraSessionState,
useSoraLoginListener,
} from "@storyteller/tauri-api";
import { invoke } from "@tauri-apps/api/core";
import { ArtCraftSignUp } from "./artcraft-signup";
import { UsersApi } from "@storyteller/api";
import { useLoginModalStore } from "./useLoginModalStore";
interface LoginModalProps {
onClose: () => void;
onClose?: () => void;
videoSrc2D?: string;
videoSrc3D?: string;
openAiLogo?: string;
onOpenChange?: (isOpen: boolean) => void;
onArtCraftAuthSuccess?: (userInfo: any) => void;
isSignUp?: boolean;
@@ -30,14 +24,13 @@ export function LoginModal({
onClose,
videoSrc2D,
videoSrc3D,
openAiLogo,
onOpenChange,
onArtCraftAuthSuccess,
isSignUp: initialIsSignUp = true,
}: LoginModalProps) {
const { isOpen, recheckTrigger, closeModal } = useLoginModalStore();
const [step, setStep] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const [isLoggedInArtCraft, setIsLoggedInArtCraft] = useState(false);
const [isSignUp, setIsSignUp] = useState(initialIsSignUp);
const [errorMessage, setErrorMessage] = useState("");
@@ -67,26 +60,35 @@ export function LoginModal({
setStep(4); // Always go to the final step after Sora login
});
// Check session on component mount
// Check session on component mount and when recheckTrigger changes
useEffect(() => {
checkArtCraftLogin().then((loggedIn) => {
if (loggedIn) {
setIsLoggedInArtCraft(true);
setIsOpen(false);
closeModal();
} else {
setIsOpen(true);
setStep(1); // Always start at step 1
// Reset all modal state to initial values
setStep(1);
setIsLoading(false);
setIsSignUp(initialIsSignUp);
setErrorMessage("");
setShowDiscord(false);
setShowSuccess(false);
setIsLoggedInArtCraft(false);
const { openModal } = useLoginModalStore.getState();
openModal();
}
});
}, []);
}, [recheckTrigger, closeModal, initialIsSignUp]);
useEffect(() => {
if (onOpenChange) onOpenChange(isOpen);
}, [isOpen, onOpenChange]);
const handleClose = () => {
setIsOpen(false);
onClose();
closeModal();
onClose?.();
};
const handleNext = async () => {
@@ -155,7 +157,8 @@ export function LoginModal({
Join Our Community
</h2>
<p className="text-white/70 mb-6 text-center px-24">
Connect with other creators, share your work, and get the latest updates in our Discord community.
Connect with other creators, share your work, and get the latest
updates in our Discord community.
</p>
<div className="flex gap-4">
<Button
@@ -240,15 +243,15 @@ export function LoginModal({
try {
let signupResponse, loginResponse;
if (isSignUp) {
console.log("Sign up!")
console.log("Sign up!");
signupResponse = await usersApi.Signup({
username,
email,
password,
passwordConfirmation,
});
console.log(signupResponse)
console.log(signupResponse);
if (!signupResponse.success) {
setErrorMessage(
signupResponse.errorMessage ||
@@ -257,17 +260,21 @@ export function LoginModal({
setIsLoading(false);
return;
}
console.log("Path 1");
loginResponse = await usersApi.Login({
usernameOrEmail: username || email,
password,
});
console.log(loginResponse);
} else {
console.log("Path 2");
loginResponse = await usersApi.Login({
usernameOrEmail: username || email,
password,
});
console.log(loginResponse);
}
if (!loginResponse.success) {
setErrorMessage(
loginResponse.errorMessage ||
@@ -278,12 +285,12 @@ export function LoginModal({
}
setIsLoggedInArtCraft(true);
console.log("ARTCRAFT LOGIN SUCCeSS")
console.log("ARTCRAFT LOGIN SUCCeSS");
if (onArtCraftAuthSuccess) {
const session = await usersApi.GetSession();
const userInfo = session.data?.user;
console.log("USERINFO")
console.log(userInfo)
console.log("USERINFO");
console.log(userInfo);
if (userInfo) onArtCraftAuthSuccess(userInfo);
}
setShowDiscord(true); // Show Discord step after successful login
@@ -315,11 +322,7 @@ export function LoginModal({
return (
<div className="flex items-end justify-center gap-2.5 mt-10 grow">
{step === 2 && (
<Button
variant="secondary"
onClick={handleBack}
disabled={isLoading}
>
<Button variant="secondary" onClick={handleBack} disabled={isLoading}>
Back
</Button>
)}
@@ -336,7 +339,7 @@ export function LoginModal({
);
};
return createPortal(
return (
<Transition appear show={isOpen}>
<div className="fixed inset-0 z-[100]">
<TransitionChild
@@ -378,8 +381,7 @@ export function LoginModal({
</TransitionChild>
</div>
</div>
</Transition>,
document.body
</Transition>
);
}
@@ -0,0 +1,18 @@
import { create } from "zustand";
interface LoginModalStore {
isOpen: boolean;
recheckTrigger: number;
openModal: () => void;
closeModal: () => void;
triggerRecheck: () => void;
}
export const useLoginModalStore = create<LoginModalStore>((set) => ({
isOpen: false,
recheckTrigger: 0,
openModal: () => set({ isOpen: true }),
closeModal: () => set({ isOpen: false }),
triggerRecheck: () =>
set((state) => ({ recheckTrigger: state.recheckTrigger + 1 })),
}));
@@ -25,6 +25,7 @@ import { DomLevels } from "@storyteller/common";
declare global {
interface Window {
__inertStripperInstalled?: boolean;
__modalEscListenerInstalled?: boolean;
}
}
@@ -58,6 +59,56 @@ if (!window.__inertStripperInstalled) {
});
}
// ---------------------------------------------------------------------------
// Modal ESC handling & stack tracking
// If several modals are stacked, Esc will dismiss the one visually on top (highest z-index).
// ---------------------------------------------------------------------------
interface ModalRegistryEntry {
id: number;
zIndex: number;
onClose: () => void;
closeOnEsc: boolean;
}
const modalRegistry: ModalRegistryEntry[] = [];
const registerModal = (entry: ModalRegistryEntry) => {
modalRegistry.push(entry);
};
const unregisterModal = (id: number) => {
const idx = modalRegistry.findIndex((m) => m.id === id);
if (idx !== -1) modalRegistry.splice(idx, 1);
};
const updateModal = (
id: number,
data: Partial<{ zIndex: number; onClose: () => void; closeOnEsc: boolean }>
) => {
const entry = modalRegistry.find((m) => m.id === id);
if (entry) Object.assign(entry, data);
};
const getTopEscapableModal = (): ModalRegistryEntry | undefined => {
return modalRegistry
.filter((m) => m.closeOnEsc)
.sort((a, b) => b.zIndex - a.zIndex)[0];
};
// Install one global ESC listener (once per page load)
if (typeof window !== "undefined" && !window.__modalEscListenerInstalled) {
window.__modalEscListenerInstalled = true;
window.addEventListener("keydown", (e: KeyboardEvent) => {
if (e.key !== "Escape" && e.key !== "Esc") return;
const top = getTopEscapableModal();
if (top) {
e.preventDefault();
top.onClose();
}
});
}
// Simple global z-index tracker for stacked modals
let modalZCounter = 70;
@@ -176,6 +227,7 @@ export const Modal = ({
resizable = false,
initialPosition,
closeOnOutsideClick = true,
closeOnEsc = true,
allowBackgroundInteraction = false,
expandable = false,
}: {
@@ -203,6 +255,10 @@ export const Modal = ({
* If false, clicking the backdrop will NOT close the modal
*/
closeOnOutsideClick?: boolean;
/**
* If true, pressing the Escape key will close the modal
*/
closeOnEsc?: boolean;
/**
* If true, allow interacting with background (removes pointer events from backdrop)
*/
@@ -747,6 +803,31 @@ export const Modal = ({
};
}, [allowBackgroundInteraction]);
// Register this modal in the global registry for ESC handling
const idRef = useRef<number>(Math.floor(Math.random() * 1e9));
useEffect(() => {
if (!isOpen) return;
registerModal({
id: idRef.current,
zIndex,
onClose,
closeOnEsc,
});
return () => {
unregisterModal(idRef.current);
};
// We intentionally want to run this only on mount/unmount when isOpen true
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen]);
// Keep registry entry updated when props change
useEffect(() => {
if (!isOpen) return;
updateModal(idRef.current, { zIndex, onClose, closeOnEsc });
}, [isOpen, zIndex, onClose, closeOnEsc]);
return (
<Transition appear show={isOpen} as={Fragment}>
<Dialog
@@ -1,49 +1,93 @@
import type { PopoverItem } from "@storyteller/ui-popover";
import { faClock, faFilm, faImage } from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { IMAGE_MODELS } from "@storyteller/model-list";
export type ModelList = Omit<PopoverItem, "selected">[];
export const allModels = {
video: [
{
label: "Kling 2.0",
label: "Kling 1.6 Pro",
icon: <FontAwesomeIcon icon={faFilm} className="h-4 w-4" />,
description: "Good quality model",
badges: [{ label: "2 min.", icon: <FontAwesomeIcon icon={faClock} /> }],
},
{
label: "Kling 2.1 Pro",
icon: <FontAwesomeIcon icon={faFilm} className="h-4 w-4" />,
description: "High quality model",
badges: [{ label: "2 min.", icon: <FontAwesomeIcon icon={faClock} /> }],
},
{
label: "Runway",
label: "Kling 2.1 Master",
icon: <FontAwesomeIcon icon={faFilm} className="h-4 w-4" />,
description: "Master quality model ($$)",
badges: [{ label: "2 min.", icon: <FontAwesomeIcon icon={faClock} /> }],
},
{
label: "Seedance 1.0 Lite",
icon: <FontAwesomeIcon icon={faFilm} className="h-4 w-4" />,
description: "Fast and high-quality model",
badges: [{ label: "2 min.", icon: <FontAwesomeIcon icon={faClock} /> }],
},
{
label: "Google Veo 2",
icon: <FontAwesomeIcon icon={faFilm} className="h-4 w-4" />,
description: "Fast and high-quality model",
badges: [{ label: "2 min.", icon: <FontAwesomeIcon icon={faClock} /> }],
},
],
image: [
{
label: "Flux Pro 1.1 Ultra",
icon: <FontAwesomeIcon icon={faImage} className="h-4 w-4" />,
description: "High quality model",
badges: [{ label: "15 sec.", icon: <FontAwesomeIcon icon={faClock} /> }],
modelInfo: IMAGE_MODELS.flux_pro_1_1_ultra,
},
{
label: "Flux Pro 1.1",
icon: <FontAwesomeIcon icon={faImage} className="h-4 w-4" />,
description: "High quality model",
badges: [{ label: "15 sec.", icon: <FontAwesomeIcon icon={faClock} /> }],
modelInfo: IMAGE_MODELS.flux_pro_1_1,
},
{
label: "Flux 1 Dev",
icon: <FontAwesomeIcon icon={faImage} className="h-4 w-4" />,
description: "High quality model",
badges: [{ label: "15 sec.", icon: <FontAwesomeIcon icon={faClock} /> }],
modelInfo: IMAGE_MODELS.flux_1_dev,
},
{
label: "Flux 1 Schnell",
icon: <FontAwesomeIcon icon={faImage} className="h-4 w-4" />,
description: "High quality model",
badges: [{ label: "15 sec.", icon: <FontAwesomeIcon icon={faClock} /> }],
modelInfo: IMAGE_MODELS.flux_1_schnell,
},
{
label: "GPT Image 1 (GPT-4o)",
icon: <FontAwesomeIcon icon={faImage} className="h-4 w-4" />,
description: "Slow, ultra instructive model",
badges: [{ label: "45 sec.", icon: <FontAwesomeIcon icon={faClock} /> }],
modelInfo: IMAGE_MODELS.gpt_image_1,
},
{
label: "Flux Pro Ultra",
icon: <FontAwesomeIcon icon={faImage} className="h-4 w-4" />,
description: "High quality model",
badges: [{ label: "15 sec.", icon: <FontAwesomeIcon icon={faClock} /> }],
},
{
label: "Recraft 3",
icon: <FontAwesomeIcon icon={faImage} className="h-4 w-4" />,
description: "Fast and high-quality model",
badges: [{ label: "15 sec.", icon: <FontAwesomeIcon icon={faClock} /> }],
},
{
label: "Flux.1 Kontext",
icon: <FontAwesomeIcon icon={faImage} className="h-4 w-4" />,
description: "New model with advanced context",
badges: [{ label: "25 sec.", icon: <FontAwesomeIcon icon={faClock} /> }],
},
//{
// id: "todo",
// label: "Recraft 3",
// icon: <FontAwesomeIcon icon={faImage} className="h-4 w-4" />,
// description: "Fast and high-quality model",
// badges: [{ label: "15 sec.", icon: <FontAwesomeIcon icon={faClock} /> }],
//},
//{
// id: "todo",
// label: "Flux.1 Kontext",
// icon: <FontAwesomeIcon icon={faImage} className="h-4 w-4" />,
// description: "New model with advanced context",
// badges: [{ label: "25 sec.", icon: <FontAwesomeIcon icon={faClock} /> }],
//},
],
};

Some files were not shown because too many files have changed in this diff Show More