Compare commits

...
Author SHA1 Message Date
Tanish Manku a13dc6acfd Do only one render pass 2025-07-02 13:43:33 -07: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
BFlat 10603f7c86 feature/lightbox drag (#677)
- draggable and resizable lightbox

Bug found: if lightbox is open, gallery modal cannot open the filter
dropdown until lightbox is closed.
2025-06-19 00:41:21 +07:00
5e8c524f84 Feature/login fix (#678)
Co-authored-by: CrossPr0duct <alicextensor@gmail.com>
Co-authored-by: bflatastic <bombayapirat@gmail.com>
2025-06-18 10:59:43 -04:00
145 changed files with 3739 additions and 916 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,10 +1,14 @@
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::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::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::utils::download_media_file_to_temp_dir::download_media_file_to_temp_dir;
@@ -55,8 +59,10 @@ 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;
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_success_event::GenerationEnqueueSuccessEvent;
#[derive(Deserialize)]
pub struct EnqueueTextToImageRequest {
@@ -64,19 +70,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 {
@@ -105,6 +101,7 @@ pub async fn enqueue_text_to_image_command(
app: AppHandle,
request: EnqueueTextToImageRequest,
app_data_root: State<'_, AppDataRoot>,
app_env_configs: State<'_, AppEnvConfigs>,
fal_creds_manager: State<'_, FalCredentialManager>,
storyteller_creds_manager: State<'_, StorytellerCredentialManager>,
fal_task_queue: State<'_, FalTaskQueue>,
@@ -120,6 +117,7 @@ pub async fn enqueue_text_to_image_command(
&app_data_root,
&fal_creds_manager,
&storyteller_creds_manager,
&app_env_configs,
&fal_task_queue,
&sora_creds_manager,
&sora_task_queue,
@@ -154,7 +152,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())
}
}
@@ -167,18 +175,22 @@ pub async fn handle_request(
app_data_root: &AppDataRoot,
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
@@ -186,10 +198,8 @@ pub async fn handle_request(
};
if fal_creds_manager.has_apparent_api_token()? {
handle_image_fal(&app, request, fal_creds_manager, fal_task_queue).await?;
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?;
handle_image_artcraft(request, &app, app_env_configs, app_data_root, storyteller_creds_manager).await
}
Ok(())
}
@@ -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(())
}
@@ -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,
}
}
}
@@ -70,8 +70,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)]
@@ -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 {
@@ -1,5 +1,3 @@
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;
@@ -7,6 +5,7 @@ use crate::core::commands::response::failure_response_wrapper::{CommandErrorResp
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;
@@ -66,14 +65,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)]
@@ -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 {
@@ -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,6 @@
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 response;
@@ -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,11 @@ 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 write_app_preferences(&self, prefs: &AppPreferences) -> AnyhowResult<()> {
let serializable = prefs.to_serializable();
@@ -1,3 +1,4 @@
pub mod app_env_configs;
pub mod app_preferences;
pub mod app_startup_time;
pub mod data_dir;
@@ -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);
}
}
}
+11 -4
View File
@@ -9,8 +9,9 @@ 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::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;
@@ -52,8 +53,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 +74,9 @@ 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");
println!("Initializing backend runtime...");
@@ -155,6 +161,7 @@ pub fn run() {
Ok(())
})
.manage(app_data_root)
.manage(app_env_configs)
.manage(app_preferences)
.manage(fal_creds_manager)
.manage(fal_task_queue)
@@ -170,8 +177,8 @@ 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,
open_sora_login_command,
platform_info_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;
@@ -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)
@@ -1,38 +1,38 @@
import { useSignals } from "@preact/signals-react/runtime";
import { AUTH_STATUS } from "~/enums";
import { authentication } from "~/signals";
//import { ButtonLink } from "@storyteller/ui-button-link";
import ProfileDropdown from "./ProfileDropdown";
import { Button } from "@storyteller/ui-button";
// import ProfileDropdown from "./ProfileDropdown";
export const AuthButtons = () => {
export const AuthButtons = ({
loginSignUpPressed,
}: {
loginSignUpPressed: () => void;
}) => {
useSignals();
const { status } = authentication;
const { status, userInfo } = authentication;
console.log("Current State:");
console.log(status.value);
if (status.value === AUTH_STATUS.LOGGED_IN) {
return <ProfileDropdown />;
console.log("SHOWING DROPDOWN");
return null;
} else {
return (
<div className="flex items-center gap-3.5">
<span className="text-white/20">|</span>
<div className="flex items-center gap-2">
{/* <ButtonLink
to="/login"
variant="secondary"
reloadDocument={true}
className="h-[38px]" // TODO(bt,2025-04-19): Once we have in-page routing, get rid of this.
<>
<div className="flex items-center gap-3.5">
<span className="text-white/20">|</span>
<Button
className="h-[38px]"
onClick={() => {
loginSignUpPressed();
}}
>
Login
</ButtonLink>
<ButtonLink
to="/signup"
reloadDocument={true}
className="h-[38px]" // TODO(bt,2025-04-19): Once we have in-page routing, get rid of this.
>
Sign Up
</ButtonLink> */}
Login / Sign Up
</Button>
</div>
</div>
</>
);
}
};
@@ -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} />
</>
);
};
@@ -689,7 +689,7 @@ export const PaintSurface = ({
scaleY: finalScaleY,
offsetX: finalOffsetX,
offsetY: finalOffsetY,
});
}, true);
} else {
store.updateNode(
nodeId,
@@ -7,6 +7,15 @@ interface DeleteHotkeysProps {
export const useDeleteHotkeys = ({ onDelete, target = document }: DeleteHotkeysProps): void => {
const handleKeyDown = useCallback((event: KeyboardEvent) => {
// Check if the event target is an input field or contentEditable element
const target = event.target as HTMLElement;
if (target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.isContentEditable ||
target.getAttribute('role') === 'textbox') {
return; // Don't prevent default for input fields
}
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
const isDelete = event.key === 'Delete' ||
event.key === 'Backspace' ||
@@ -133,7 +133,7 @@ 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;
@@ -614,34 +614,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().createImage(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
@@ -1162,58 +1149,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
@@ -1216,10 +1216,10 @@ class Editor {
: this.render_camera_aspect_ratio === meraAspectRatio.VERTICAL_9_16
? 576
: this.render_camera_aspect_ratio ===
meraAspectRatio.HORIZONTAL_3_2
meraAspectRatio.HORIZONTAL_3_2
? 900
: this.render_camera_aspect_ratio ===
meraAspectRatio.VERTICAL_2_3
meraAspectRatio.VERTICAL_2_3
? 600
: 1000;
const height =
@@ -1228,10 +1228,10 @@ class Editor {
: this.render_camera_aspect_ratio === meraAspectRatio.VERTICAL_9_16
? 1024
: this.render_camera_aspect_ratio ===
meraAspectRatio.HORIZONTAL_3_2
meraAspectRatio.HORIZONTAL_3_2
? 600
: this.render_camera_aspect_ratio ===
meraAspectRatio.VERTICAL_2_3
meraAspectRatio.VERTICAL_2_3
? 900
: 1000;
@@ -1323,7 +1323,7 @@ class Editor {
) {
this.composer.render();
//this.rawRenderer.render(this.activeScene.scene, this.render_camera!);
this.render_composer.render();
// this.render_composer.render();
} else if (this.renderer && this.render_camera && !this.rendering) {
this.renderer.setSize(this.render_width, this.render_height);
this.renderer.render(this.activeScene.scene, this.render_camera);
@@ -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"
},
@@ -12,6 +12,7 @@ import React, {
} from "react";
import {
FilterMediaClasses,
FilterMediaType,
GalleryModalApi,
UsersApi,
} from "@storyteller/api";
@@ -61,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
@@ -218,7 +219,8 @@ export const GalleryModal = React.memo(
include_user_uploads:
activeFilter === "uploaded" ||
activeFilter === "all" ||
activeFilter === "3d",
activeFilter === "3d" ||
activeFilter === "image",
user_uploads_only: activeFilter === "uploaded",
page_index: reset ? 0 : pageIndex,
page_size: pageSize,
@@ -227,26 +229,30 @@ export const GalleryModal = React.memo(
if (response.success && response.data) {
const thumbnail_size = 250;
const newItems = response.data.map((item: any) => ({
id: item.token,
label: item.maybe_title || "Image Generation",
thumbnail:
item.media_class === "video"
? item.media_links.maybe_video_previews.still
: item.media_class === "dimensional"
? item.cover_image?.maybe_cover_image_public_bucket_url
: item.media_links.maybe_thumbnail_template?.replace(
"{WIDTH}",
thumbnail_size.toString()
),
fullImage: item.media_links.cdn_url,
createdAt: item.created_at,
mediaClass:
item.media_class ||
(item.filter_media_classes
? item.filter_media_classes[0]
: "image"),
}));
const newItems = response.data
.filter(
(item: any) => item.media_type !== FilterMediaType.SCENE_JSON
)
.map((item: any) => ({
id: item.token,
label: item.maybe_title || "Image Generation",
thumbnail:
item.media_class === "video"
? item.media_links.maybe_video_previews.still
: item.media_class === "dimensional"
? item.cover_image?.maybe_cover_image_public_bucket_url
: item.media_links.maybe_thumbnail_template?.replace(
"{WIDTH}",
thumbnail_size.toString()
),
fullImage: item.media_links.cdn_url,
createdAt: item.created_at,
mediaClass:
item.media_class ||
(item.filter_media_classes
? item.filter_media_classes[0]
: "image"),
}));
setAllItems((prev) => (reset ? newItems : [...prev, ...newItems]));
// Pagination logic
const current = response.pagination?.current ?? 0;
@@ -355,13 +361,13 @@ export const GalleryModal = React.memo(
}}
className={twMerge(
"h-[620px] max-w-4xl",
mode === "view" && "h-[640px] max-w-4xl"
mode === "view" && "h-[640px] w-[56rem]"
)}
childPadding={false}
showClose={false}
draggable={mode === "view"}
allowBackgroundInteraction={mode === "view" ? true : false}
closeOnOutsideClick={false}
closeOnOutsideClick={mode === "view" ? false : true}
>
{mode === "view" && (
<Modal.DragHandle>
@@ -370,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"}
@@ -526,10 +532,23 @@ export const GalleryModal = React.memo(
{Object.entries(groupItemsByDate(allItems)).map(
([date, dateItems]) => {
const filteredItems = dateItems.filter((item) => {
if (activeFilter === "3d")
if ((item as any).mediaType === "scene_json")
return false;
if (activeFilter === "3d") {
return item.mediaClass === "dimensional";
if (activeFilter === "all")
return item.mediaClass !== "audio";
}
if (activeFilter === "image") {
return item.mediaClass === "image";
}
if (activeFilter === "video") {
return item.mediaClass === "video";
}
if (activeFilter === "all") {
return (
item.mediaClass !== "audio" &&
(item as any).mediaType !== "scene_json"
);
}
return true;
});
if (filteredItems.length === 0) return null;
@@ -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"
}
]
}
@@ -1,14 +1,13 @@
import { CloseButton } from "@storyteller/ui-close-button";
import { Modal } from "@storyteller/ui-modal";
import { Button } from "@storyteller/ui-button";
import { Transition, TransitionChild } from "@headlessui/react";
import { createPortal } from "react-dom";
import dayjs from "dayjs";
import { faDownToLine } from "@fortawesome/pro-solid-svg-icons";
import {
FalHunyuanImageTo3d,
EnqueueImageTo3dObject,
EnqueueImageTo3dObjectModel,
} from "@storyteller/tauri-api";
import { LoadingSpinner } from "@storyteller/ui-loading-spinner";
import React from "react";
interface LightboxModalProps {
isOpen: boolean;
@@ -22,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
@@ -46,156 +45,145 @@ export function LightboxModal({
onAddToSceneClicked,
mediaClass,
}: LightboxModalProps) {
// NB(bt,2025-06-14): We add ?cors=1 to the image url to prevent caching "sec-fetch-mode: no-cors" from
// NB(bt,2025-06-14): We add ?cors=1 to the image url to prevent caching "sec-fetch-mode: no-cors" from
// the <image> tag request from being cached. If we then drag it into the canvas after it's been cached,
// it won't be able to load in cors mode and will show blank in the canvas and 3D engine. This is a really
// stupid hack around this behavior.
const imageTagImageUrl = imageUrl ? imageUrl + "?cors=1" : "";
return createPortal(
<Transition appear show={isOpen}>
<div className="fixed inset-0 z-[100]">
{/* backdrop */}
<TransitionChild
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div
className="fixed inset-0 cursor-pointer bg-black/60"
onClick={onClose}
/>
</TransitionChild>
{/* dialog container */}
<div
className="fixed inset-0 flex items-center justify-center p-4"
onClick={onClose}
>
<TransitionChild
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<div
className="relative h-[90vh] w-[80vw] rounded-xl bg-[#2C2C2C]"
onClick={(e) => e.stopPropagation()}
>
{/* close */}
<CloseButton
onClick={onClose}
className="absolute right-4 top-4 z-10"
/>
const [mediaLoaded, setMediaLoaded] = React.useState<boolean>(false);
{/* content grid */}
<div className="grid h-full grid-cols-3 gap-6">
{/* image panel */}
<div className="col-span-2 flex h-full items-center justify-center overflow-hidden rounded-l-xl bg-black/40">
{!imageUrl ? (
<div className="flex h-full w-full items-center justify-center bg-gray-800">
<span className="text-white/60">Image not available</span>
</div>
) : mediaClass === "video" ? (
<video controls className="h-full w-full object-contain">
<source src={imageUrl} type="video/mp4" />
Your browser does not support the video tag.
</video>
) : (
<img
src={imageTagImageUrl}
alt={imageAlt}
className="h-full w-full object-contain"
onError={onImageError}
/>
)}
</div>
// Reset when imageUrl changes
React.useEffect(() => {
setMediaLoaded(false);
}, [imageUrl]);
{/* info + actions */}
<div className="flex h-full flex-col py-5 pe-5">
<div className="flex-1 space-y-4">
<div className="text-xl font-medium">
{title || "Image Generation"}
</div>
{createdAt && (
<div className="text-sm text-white/60">
Created:{" "}
{dayjs(createdAt).format("MMM D, YYYY HH:mm:ss")}
</div>
)}
{additionalInfo}
</div>
return (
<Modal
isOpen={isOpen}
onClose={onClose}
className="rounded-xl bg-[#2C2C2C] h-[80vh] w-[60vw] max-w-screen"
draggable
allowBackgroundInteraction={true}
showClose={true}
closeOnOutsideClick={false}
resizable={true}
backdropClassName="pointer-events-none hidden"
>
{/* Invisible drag handle strip at the very top for moving */}
<Modal.DragHandle>
<div className="absolute left-0 top-0 z-20 h-12 w-full cursor-move rounded-t-xl" />
</Modal.DragHandle>
{/* buttons with spacing */}
{(onAddToSceneClicked && downloadUrl) || downloadUrl ? (
<div className="mt-15 mb-15 flex justify-end gap-2">
<Button
onClick={async (e) => {
//let _result = await FalHunyuanImageTo3d({
// image_media_token: mediaId,
// //base64_image: downloadUrl,
//});
let _result = await EnqueueImageTo3dObject({
image_media_token: mediaId,
model: EnqueueImageTo3dObjectModel.Hunyuan3d2,
});
//e.stopPropagation();
//await onAddToSceneClicked(downloadUrl, mediaId);
//onClose(); // close the lightbox
//onCloseGallery(); // close the gallery
}}
>
3D
</Button>
{onAddToSceneClicked && downloadUrl && (
<Button
onClick={async (e) => {
e.stopPropagation();
await onAddToSceneClicked(downloadUrl, mediaId);
onClose(); // close the lightbox
onCloseGallery(); // close the gallery
}}
>
Add to Current Scene
</Button>
)}
{downloadUrl &&
(onDownloadClicked ? (
<Button
icon={faDownToLine}
onClick={async (e) => {
e.stopPropagation();
await onDownloadClicked(downloadUrl);
}}
>
Download
</Button>
) : (
<a
href={downloadUrl}
download
onClick={(e) => e.stopPropagation()}
className="no-underline"
>
<Button icon={faDownToLine}>Download</Button>
</a>
))}
</div>
) : null}
</div>
</div>
{/* content grid */}
<div className="grid h-full grid-cols-3 gap-6">
{/* image panel */}
<div className="col-span-2 relative flex h-full items-center justify-center overflow-hidden rounded-l-xl bg-[#1A1A1A]">
{!imageUrl ? (
<div className="flex h-full w-full items-center justify-center bg-gray-800">
<span className="text-white/60">Image not available</span>
</div>
</TransitionChild>
) : mediaClass === "video" ? (
<video
controls
className="h-full w-full object-contain"
onLoadedData={() => setMediaLoaded(true)}
>
<source src={imageUrl} type="video/mp4" />
Your browser does not support the video tag.
</video>
) : (
<img
src={imageTagImageUrl}
alt={imageAlt}
className="h-full w-full object-contain"
onError={onImageError}
onLoad={() => setMediaLoaded(true)}
/>
)}
{!mediaLoaded && imageUrl && (
<div className="absolute inset-0 bg-[#1A1A1A] flex items-center justify-center">
<LoadingSpinner className="h-12 w-12 text-white" />
</div>
)}
</div>
{/* info + actions */}
<div className="flex h-full flex-col py-5 pe-5">
<div className="flex-1 space-y-4">
<div className="text-xl font-medium">
{title || "Image Generation"}
</div>
{createdAt && (
<div className="text-sm text-white/60">
Created: {dayjs(createdAt).format("MMM D, YYYY HH:mm:ss")}
</div>
)}
{additionalInfo}
</div>
{/* buttons with spacing */}
{(onAddToSceneClicked && downloadUrl) || downloadUrl ? (
<div className="mt-15 mb-15 flex justify-end gap-2">
<Button
onClick={async (e) => {
//let _result = await FalHunyuanImageTo3d({
// image_media_token: mediaId,
// //base64_image: downloadUrl,
//});
let result = await EnqueueImageTo3dObject({
image_media_token: mediaId,
model: EnqueueImageTo3dObjectModel.Hunyuan3d2,
});
//e.stopPropagation();
//await onAddToSceneClicked(downloadUrl, mediaId);
//onClose(); // close the lightbox
//onCloseGallery(); // close the gallery
}}
>
3D
</Button>
{onAddToSceneClicked && downloadUrl && (
<Button
onClick={async (e) => {
e.stopPropagation();
await onAddToSceneClicked(downloadUrl, mediaId);
onClose(); // close the lightbox
onCloseGallery(); // close the gallery
}}
>
Add to Current Scene
</Button>
)}
{downloadUrl &&
(onDownloadClicked ? (
<Button
icon={faDownToLine}
onClick={async (e) => {
e.stopPropagation();
await onDownloadClicked(downloadUrl, mediaClass);
}}
>
Download
</Button>
) : (
<a
href={downloadUrl}
download
onClick={(e) => e.stopPropagation()}
className="no-underline"
>
<Button icon={faDownToLine}>Download</Button>
</a>
))}
</div>
) : null}
</div>
</div>
</Transition>,
document.body
</Modal>
);
}
@@ -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";
@@ -69,6 +69,14 @@ export const ArtCraftSignUp = ({
}
};
const handleInputFocus = () => {
};
const handleInputBlur = () => {
};
return (
<div className="flex flex-col items-center justify-center h-full">
<h2 className="text-3xl font-bold mb-3 text-center">
@@ -88,6 +96,8 @@ export const ArtCraftSignUp = ({
placeholder="Username"
required
autoComplete="off"
onFocus={handleInputFocus}
onBlur={handleInputBlur}
/>
<Input
label="Email"
@@ -98,6 +108,8 @@ export const ArtCraftSignUp = ({
required={isSignUp}
style={isSignUp ? {} : { display: "none" }}
autoComplete="off"
onFocus={handleInputFocus}
onBlur={handleInputBlur}
/>
</>
) : (
@@ -108,6 +120,8 @@ export const ArtCraftSignUp = ({
placeholder="Email or Username"
required
autoComplete="off"
onFocus={handleInputFocus}
onBlur={handleInputBlur}
/>
)}
@@ -119,6 +133,8 @@ export const ArtCraftSignUp = ({
placeholder="Password"
required
autoComplete="off"
onFocus={handleInputFocus}
onBlur={handleInputBlur}
/>
{isSignUp && (
<Input
@@ -129,6 +145,8 @@ export const ArtCraftSignUp = ({
placeholder="Confirm Password"
required
autoComplete="off"
onFocus={handleInputFocus}
onBlur={handleInputBlur}
/>
)}
<button type="submit" className="hidden" />
@@ -1,66 +1,46 @@
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;
}
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(true);
const [isSignUp, setIsSignUp] = useState(initialIsSignUp);
const [errorMessage, setErrorMessage] = useState("");
const artCraftFormRef = useRef<HTMLFormElement>(null);
const [initialArtCraftLoginStatusForUI, setInitialArtCraftLoginStatusForUI] =
useState<boolean | null>(null);
const [initialSoraSessionStatusForUI, setInitialSoraSessionStatusForUI] =
useState<boolean | null>(null);
const [showDiscord, setShowDiscord] = useState(false);
const [showSuccess, setShowSuccess] = useState(false);
// Determine total steps and current step for UI
let uiTotalSteps = 4;
let uiCurrentStep = step;
if (initialSoraSessionStatusForUI) {
// If Sora session exists, remove the Sora step (step 3)
uiTotalSteps = 3;
if (step === 1) uiCurrentStep = 1;
else if (step === 2) uiCurrentStep = 2;
else if (step === 4) uiCurrentStep = 3;
} else if (initialArtCraftLoginStatusForUI) {
// If ArtCraft is logged in but not Sora, keep 4 steps
uiTotalSteps = 4;
if (step === 1) uiCurrentStep = 1;
else if (step === 3) uiCurrentStep = 2;
else if (step === 4) uiCurrentStep = 3;
}
// Now we have 3 steps: Welcome, Sign up/Login, and Success
const uiTotalSteps = 3;
const uiCurrentStep = showSuccess ? 3 : showDiscord ? 2 : step;
const initSession = async () => {
const result = await CheckSoraSession();
@@ -80,73 +60,39 @@ 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(() => {
// Run both checks in parallel
Promise.all([initSession(), checkArtCraftLogin()]).then(
([soraSessionExists, loggedIn]) => {
setInitialArtCraftLoginStatusForUI(loggedIn || false); // Set initial status for UI step counting
setInitialSoraSessionStatusForUI(soraSessionExists || false); // Set initial Sora status for UI step counting
checkArtCraftLogin().then((loggedIn) => {
if (loggedIn) {
setIsLoggedInArtCraft(true);
closeModal();
} else {
// Reset all modal state to initial values
setStep(1);
setIsLoading(false);
setIsSignUp(initialIsSignUp);
setErrorMessage("");
setShowDiscord(false);
setShowSuccess(false);
setIsLoggedInArtCraft(false);
if (soraSessionExists && loggedIn) {
setIsLoggedInArtCraft(true);
setIsOpen(false); // Close the modal if fully logged in
return;
} else if (soraSessionExists && !loggedIn) {
// Sora session exists, but not logged in to ArtCraft
setIsOpen(true);
setStep(2);
setIsLoggedInArtCraft(false);
} else if (!soraSessionExists && loggedIn) {
// No Sora session, but logged in to ArtCraft
setIsOpen(true);
setStep(3);
setIsLoggedInArtCraft(true);
} else if (!soraSessionExists) {
// No Sora session, start at step 1
setIsOpen(true);
setStep(1);
setIsLoggedInArtCraft(false); // Ensure this is also set
}
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 () => {
if (step === 3) {
setIsLoading(true);
try {
if (IsDesktopApp()) {
await invoke("open_sora_login_command");
await new Promise((resolve) => setTimeout(resolve, 3000));
const result = await CheckSoraSession();
const sessionExists = result.state === SoraSessionState.Valid;
if (sessionExists) {
setStep(4); // Always go to the final step after Sora login
}
} else {
alert("Please open the desktop app to login");
await new Promise((resolve) => setTimeout(resolve, 3000));
const sessionExists = true;
if (sessionExists) {
setStep(4); // Always go to the final step after Sora login
}
}
} catch (error) {
console.error("Login failed:", error);
} finally {
setIsLoading(false);
}
} else if (step === 2) {
if (step === 2) {
// Trigger the form submit in ArtCraftSignUp
if (artCraftFormRef.current) {
artCraftFormRef.current.requestSubmit();
@@ -174,12 +120,69 @@ export function LoginModal({
</div>
);
const handleDiscordJoin = () => {
window.open("https://discord.gg/75svZP2Vje", "_blank");
setShowDiscord(false);
setShowSuccess(true);
};
// Step content rendering
const renderStepContent = () => {
if (step === 2 && isLoggedInArtCraft) {
setStep(3);
return null;
if (showSuccess) {
return (
<div className="flex flex-col items-center justify-center h-full">
<h2 className="text-3xl font-bold mb-2 text-center">
Thank you for signing in!
</h2>
<p className="text-white/70 mb-6 text-center">
You're all set to start creating amazing content.
</p>
<Button
variant="primary"
onClick={handleClose}
icon={faArrowRight}
iconFlip={true}
className="text-md"
>
Get Started
</Button>
</div>
);
}
if (showDiscord) {
return (
<div className="flex flex-col items-center justify-center h-full">
<h2 className="text-3xl font-bold mb-2 text-center">
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.
</p>
<div className="flex gap-4">
<Button
variant="secondary"
onClick={() => {
setShowDiscord(false);
setShowSuccess(true);
}}
>
Skip for now
</Button>
<Button
variant="primary"
onClick={handleDiscordJoin}
icon={faDiscord}
className="text-md bg-[#5865F2] hover:bg-[#6a76ff]"
>
Join Discord
</Button>
</div>
</div>
);
}
switch (step) {
case 1:
return (
@@ -240,12 +243,15 @@ export function LoginModal({
try {
let signupResponse, loginResponse;
if (isSignUp) {
console.log("Sign up!");
signupResponse = await usersApi.Signup({
username,
email,
password,
passwordConfirmation,
});
console.log(signupResponse);
if (!signupResponse.success) {
setErrorMessage(
signupResponse.errorMessage ||
@@ -254,45 +260,40 @@ export function LoginModal({
setIsLoading(false);
return;
}
console.log("Path 1");
loginResponse = await usersApi.Login({
usernameOrEmail: username || email,
password,
});
if (!loginResponse.success) {
setErrorMessage(
loginResponse.errorMessage ||
"Login after signup failed, please try again. "
);
setIsLoading(false);
return;
}
console.log(loginResponse);
} else {
console.log("Path 2");
loginResponse = await usersApi.Login({
usernameOrEmail: username || email,
password,
});
if (!loginResponse.success) {
setErrorMessage(
loginResponse.errorMessage ||
"Login failed, please try again."
);
setIsLoading(false);
return;
}
console.log(loginResponse);
}
if (!loginResponse.success) {
setErrorMessage(
loginResponse.errorMessage ||
"Login failed, please try again."
);
setIsLoading(false);
return;
}
setIsLoggedInArtCraft(true);
console.log("ARTCRAFT LOGIN SUCCeSS");
if (onArtCraftAuthSuccess) {
const session = await usersApi.GetSession();
const userInfo = session.data?.user;
console.log("USERINFO");
console.log(userInfo);
if (userInfo) onArtCraftAuthSuccess(userInfo);
}
// Check for Sora session and skip to step 4 if present
const soraSessionExists = await initSession();
if (soraSessionExists) {
setStep(4);
} else {
setStep(3);
}
setShowDiscord(true); // Show Discord step after successful login
} catch (e) {
console.error(e);
setErrorMessage(
@@ -308,54 +309,37 @@ export function LoginModal({
errorMessage={errorMessage}
/>
);
case 3:
return (
<div className="flex flex-col items-center justify-center h-full">
<h2 className="text-3xl font-bold mb-2 text-center">
Login with your OpenAI account
</h2>
<p className="text-white/70 mb-6 text-center px-16">
We're the application layer for images and video. To provide you
with <b>100% free service</b>, we use your existing AI accounts.
To start off, you'll need to add your OpenAI Sora account. If you
have a ChatGPT subscription, you can log in with Sora.
</p>
<img
src={openAiLogo}
alt="OpenAI Logo"
className="w-72 h-72 mx-auto grow"
/>
</div>
);
case 4:
return (
<div className="flex flex-col items-center justify-center h-full">
<h2 className="text-3xl font-bold mb-2 text-center">
Thank you for signing in!
</h2>
<p className="text-white/70 mb-6 text-center px-24">
You're all set to start creating amazing content. Join our Discord
community to connect with other creators, share your work, and get
the latest updates.
</p>
<Button
variant="secondary"
onClick={() =>
window.open("https://discord.gg/75svZP2Vje", "_blank")
}
icon={faDiscord}
className="text-md bg-[#5865F2] hover:bg-[#6a76ff] rounded-xl"
>
Join our Discord
</Button>
</div>
);
default:
return null;
}
};
return createPortal(
const renderFooterButtons = () => {
if (showSuccess || showDiscord) {
return null; // No footer buttons needed in success or discord states
}
return (
<div className="flex items-end justify-center gap-2.5 mt-10 grow">
{step === 2 && (
<Button variant="secondary" onClick={handleBack} disabled={isLoading}>
Back
</Button>
)}
<Button
icon={faArrowRight}
iconFlip={true}
onClick={handleNext}
loading={isLoading}
disabled={isLoading}
>
{step === 1 ? "Continue" : isSignUp ? "Sign up" : "Login"}
</Button>
</div>
);
};
return (
<Transition appear show={isOpen}>
<div className="fixed inset-0 z-[100]">
<TransitionChild
@@ -382,65 +366,22 @@ export function LoginModal({
onClick={(e) => e.stopPropagation()}
>
<div className="flex flex-col gap-4 p-8 h-full">
<span className="text-sm text-center opacity-60 font-medium">
Step {uiCurrentStep} of {uiTotalSteps}
</span>
{renderProgress()}
{!showSuccess && (
<>
<span className="text-sm text-center opacity-60 font-medium">
Step {uiCurrentStep} of {uiTotalSteps}
</span>
{renderProgress()}
</>
)}
{renderStepContent()}
<div className="flex items-end justify-center gap-2.5 mt-10 grow">
{step === 2 ? (
<Button
variant="secondary"
onClick={handleBack}
disabled={isLoading}
>
Back
</Button>
) : null}
{step === 3 && (
<Button
variant="secondary"
onClick={handleClose}
disabled={isLoading}
>
Skip for now
</Button>
)}
{step === 4 ? (
<Button
icon={faArrowRight}
iconFlip={true}
onClick={handleClose}
>
Start Creating Now
</Button>
) : (
<>
<Button
icon={step === 3 ? faRightToBracket : faArrowRight}
iconFlip={step !== 3}
onClick={handleNext}
loading={isLoading}
disabled={isLoading}
>
{step === 1
? "Continue"
: step === 2
? isSignUp
? "Sign up"
: "Login"
: "Login with OpenAI"}
</Button>
</>
)}
</div>
{renderFooterButtons()}
</div>
</div>
</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 })),
}));
@@ -4,4 +4,4 @@
* e.g.
* .container {
* }
*/
*/
+452 -17
View File
@@ -18,6 +18,100 @@ import {
} from "@fortawesome/pro-solid-svg-icons";
import { DomLevels } from "@storyteller/common";
// ---------------------------------------------------------------------------
// GLOBAL inert / aria-hidden stripper – applies once per page load
// Overrides the inert attribute set by Headless UI to allow background interaction / stacked modals
// ---------------------------------------------------------------------------
declare global {
interface Window {
__inertStripperInstalled?: boolean;
__modalEscListenerInstalled?: boolean;
}
}
if (!window.__inertStripperInstalled) {
window.__inertStripperInstalled = true;
const strip = (el: Element) => {
if (el.hasAttribute("inert")) el.removeAttribute("inert");
// @ts-ignore – some browsers expose .inert as a property
if ((el as any).inert) (el as any).inert = false;
if (el.getAttribute("aria-hidden") === "true")
el.removeAttribute("aria-hidden");
};
// 1. Clean anything that already exists
document.querySelectorAll("[inert]").forEach(strip);
// 2. Keep document clean forever
const inertObserver = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.type === "attributes" && m.attributeName === "inert") {
strip(m.target as Element);
}
}
});
inertObserver.observe(document.documentElement, {
attributes: true,
subtree: true,
attributeFilter: ["inert"],
});
}
// ---------------------------------------------------------------------------
// 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;
const DialogBackdrop = ({
className,
onClose,
@@ -120,8 +214,8 @@ export const Modal = ({
titleIcon,
onTitleIconClick,
onClose,
disableHotkeyInput = () => {},
enableHotkeyInput = () => {},
disableHotkeyInput = () => {},
className,
backdropClassName,
width,
@@ -130,8 +224,10 @@ export const Modal = ({
titleIconClassName,
showClose = true,
draggable = false,
resizable = false,
initialPosition,
closeOnOutsideClick = true,
closeOnEsc = true,
allowBackgroundInteraction = false,
expandable = false,
}: {
@@ -148,6 +244,7 @@ export const Modal = ({
childPadding?: boolean;
showClose?: boolean;
draggable?: boolean;
resizable?: boolean;
disableHotkeyInput?: (level: number) => void;
enableHotkeyInput?: (level: number) => void;
/**
@@ -158,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)
*/
@@ -172,11 +273,13 @@ export const Modal = ({
null
);
const [dragging, setDragging] = useState(false);
const [resizing, setResizing] = useState(false);
const dragStart = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
const mouseStart = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
const modalRef = useRef<HTMLDivElement>(null);
const positionRef = useRef<{ x: number; y: number } | null>(null);
const lastPositionRef = useRef<{ x: number; y: number } | null>(null);
const [zIndex, setZIndex] = useState<number>(() => ++modalZCounter);
// Expanded state
const [expanded, setExpanded] = useState(false);
@@ -197,7 +300,7 @@ export const Modal = ({
modalRef.current.style.top = "0px";
modalRef.current.style.margin = "0";
modalRef.current.style.position = "fixed";
modalRef.current.style.zIndex = "70";
modalRef.current.style.zIndex = String(zIndex);
}
} else {
// Restore last non-expanded position
@@ -210,12 +313,12 @@ export const Modal = ({
modalRef.current.style.top = lastNonExpandedPosition.current.y + "px";
modalRef.current.style.margin = "0";
modalRef.current.style.position = "fixed";
modalRef.current.style.zIndex = "70";
modalRef.current.style.zIndex = String(zIndex);
}
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [expanded]);
}, [expanded, zIndex]);
const toggleExpanded = () => setExpanded((v) => !v);
// Reset position when modal is closed or opened
@@ -237,7 +340,7 @@ export const Modal = ({
modalRef.current.style.top = lastPositionRef.current.y + "px";
modalRef.current.style.margin = "0";
modalRef.current.style.position = "fixed";
modalRef.current.style.zIndex = "70";
modalRef.current.style.zIndex = String(zIndex);
}
} else if (initialPosition) {
setPosition({ ...initialPosition });
@@ -247,11 +350,11 @@ export const Modal = ({
modalRef.current.style.top = initialPosition.y + "px";
modalRef.current.style.margin = "0";
modalRef.current.style.position = "fixed";
modalRef.current.style.zIndex = "70";
modalRef.current.style.zIndex = String(zIndex);
}
}
}
}, [isOpen]);
}, [isOpen, zIndex]);
// Handle mouse move and up events
useEffect(() => {
@@ -284,7 +387,7 @@ export const Modal = ({
modalRef.current.style.top = positionRef.current.y + "px";
modalRef.current.style.margin = "0";
modalRef.current.style.position = "fixed";
modalRef.current.style.zIndex = "70";
modalRef.current.style.zIndex = String(zIndex);
}
});
}
@@ -301,20 +404,24 @@ export const Modal = ({
window.removeEventListener("mouseup", handleMouseUp);
if (animationFrame) cancelAnimationFrame(animationFrame);
};
}, [dragging]);
}, [dragging, zIndex]);
// Center modal if not being dragged
const getModalStyle = (): React.CSSProperties => {
if (!draggable || !position) {
// If allowBackgroundInteraction, set pointerEvents: 'auto' for modal
return allowBackgroundInteraction ? { pointerEvents: "auto" } : {};
return {
...(size ? { width: size.width, height: size.height } : {}),
...(allowBackgroundInteraction ? { pointerEvents: "auto" } : {}),
};
}
return {
position: "fixed",
left: position.x,
top: position.y,
margin: 0,
zIndex: 70,
zIndex,
...(size ? { width: size.width, height: size.height } : {}),
...(allowBackgroundInteraction ? { pointerEvents: "auto" } : {}),
};
};
@@ -352,7 +459,7 @@ export const Modal = ({
modalRef.current.style.top = y + "px";
modalRef.current.style.margin = "0";
modalRef.current.style.position = "fixed";
modalRef.current.style.zIndex = "70";
modalRef.current.style.zIndex = String(zIndex);
}
}
};
@@ -407,12 +514,326 @@ export const Modal = ({
: children;
}
/**
* ------------------ RESIZING LOGIC --------------------
*/
const resizeDirRef = useRef<string | null>(null);
const resizeStart = useRef<{
mouseX: number;
mouseY: number;
width: number;
height: number;
x: number;
y: number;
} | null>(null);
const handleResizeStart = (e: React.MouseEvent, dir: string) => {
if (!modalRef.current) return;
e.preventDefault();
e.stopPropagation();
if (expanded) return; // don't allow resize when expanded
const rect = modalRef.current.getBoundingClientRect();
resizeDirRef.current = dir;
resizeStart.current = {
mouseX: e.clientX,
mouseY: e.clientY,
width: rect.width,
height: rect.height,
x: rect.left,
y: rect.top,
};
setResizing(true);
};
// Handle resizing mouse move / up
useEffect(() => {
if (!resizing) return;
let animationFrame: number | null = null;
const handleMouseMove = (e: MouseEvent) => {
if (!modalRef.current || !resizeStart.current || !resizeDirRef.current)
return;
const dx = e.clientX - resizeStart.current.mouseX;
const dy = e.clientY - resizeStart.current.mouseY;
let newWidth = resizeStart.current.width;
let newHeight = resizeStart.current.height;
let newX = resizeStart.current.x;
let newY = resizeStart.current.y;
const dir = resizeDirRef.current;
const minWidth = 320;
const minHeight = 240;
if (dir.includes("right")) {
newWidth = resizeStart.current.width + dx;
}
if (dir.includes("left")) {
newWidth = resizeStart.current.width - dx;
newX = resizeStart.current.x + dx;
}
if (dir.includes("bottom")) {
newHeight = resizeStart.current.height + dy;
}
if (dir.includes("top")) {
newHeight = resizeStart.current.height - dy;
newY = resizeStart.current.y + dy;
}
newWidth = Math.max(minWidth, newWidth);
newHeight = Math.max(minHeight, newHeight);
positionRef.current = { x: newX, y: newY };
sizeRef.current = { width: newWidth, height: newHeight };
if (animationFrame) cancelAnimationFrame(animationFrame);
animationFrame = requestAnimationFrame(() => {
if (modalRef.current && positionRef.current) {
modalRef.current.style.width = newWidth + "px";
modalRef.current.style.height = newHeight + "px";
modalRef.current.style.left = positionRef.current.x + "px";
modalRef.current.style.top = positionRef.current.y + "px";
modalRef.current.style.margin = "0";
modalRef.current.style.position = "fixed";
modalRef.current.style.zIndex = String(zIndex);
}
});
};
const handleMouseUp = () => {
setResizing(false);
if (positionRef.current) setPosition({ ...positionRef.current });
if (sizeRef.current) setSize({ ...sizeRef.current });
};
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleMouseUp);
return () => {
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp);
if (animationFrame) cancelAnimationFrame(animationFrame);
};
}, [resizing, zIndex]);
// Render resize handles if resizable and not expanded
const renderResizeHandles = () => {
if (!resizable || expanded) return null;
const baseClass = "absolute z-[75] bg-transparent select-none";
const handleSize = 5; // px
const sideThickness = 2; // for edge handles
const handles = [
{ dir: "top", className: `top-0 left-0 w-full h-${sideThickness}` },
{ dir: "bottom", className: `bottom-0 left-0 w-full h-${sideThickness}` },
{ dir: "left", className: `left-0 top-0 h-full w-${sideThickness}` },
{ dir: "right", className: `right-0 top-0 h-full w-${sideThickness}` },
{
dir: "top-left",
className: `top-0 left-0 w-${handleSize} h-${handleSize}`,
},
{
dir: "top-right",
className: `top-0 right-0 w-${handleSize} h-${handleSize}`,
},
{
dir: "bottom-left",
className: `bottom-0 left-0 w-${handleSize} h-${handleSize}`,
},
{
dir: "bottom-right",
className: `bottom-0 right-0 w-${handleSize} h-${handleSize}`,
},
];
const cursorMap: Record<string, string> = {
top: "n-resize",
bottom: "s-resize",
left: "w-resize",
right: "e-resize",
"top-left": "nw-resize",
"top-right": "ne-resize",
"bottom-left": "sw-resize",
"bottom-right": "se-resize",
};
return handles.map((h) => (
<div
key={h.dir}
className={`${baseClass} ${h.className}`}
style={{ cursor: cursorMap[h.dir] }}
onMouseDown={(e) => handleResizeStart(e, h.dir)}
/>
));
};
// Size (width & height) state for resizable modal
const [size, setSize] = useState<{ width: number; height: number } | null>(
null
);
const sizeRef = useRef<{ width: number; height: number } | null>(null);
const lastSizeRef = useRef<{ width: number; height: number } | null>(null);
// Capture initial size on first open
useEffect(() => {
if (isOpen && !size && modalRef.current) {
const { width, height } = modalRef.current.getBoundingClientRect();
setSize({ width, height });
sizeRef.current = { width, height };
}
}, [isOpen, size]);
// Persist size across close / reopen
useEffect(() => {
if (!isOpen) {
if (sizeRef.current) lastSizeRef.current = { ...sizeRef.current };
} else {
if (lastSizeRef.current && modalRef.current) {
const { width, height } = lastSizeRef.current;
modalRef.current.style.width = width + "px";
modalRef.current.style.height = height + "px";
setSize({ width, height });
sizeRef.current = { width, height };
}
}
}, [isOpen]);
// Bring to front when user interacts with modal (mouse down anywhere inside)
useEffect(() => {
const handleBringToFront = () => {
if (modalRef.current) {
if (zIndex < modalZCounter) {
modalZCounter += 1;
setZIndex(modalZCounter);
modalRef.current.style.zIndex = String(modalZCounter);
}
}
};
const node = modalRef.current;
if (node) {
node.addEventListener("mousedown", handleBringToFront);
}
return () => {
if (node) node.removeEventListener("mousedown", handleBringToFront);
};
}, [zIndex]);
// If background interaction is allowed, ensure this modal (and its ancestors)
// never get the "inert" attribute Headless-UI uses to lock background dialogs.
useEffect(() => {
if (!allowBackgroundInteraction) return;
const node = modalRef.current as HTMLElement | null;
if (!node) return;
const stripInert = (el: HTMLElement | null) => {
if (!el) return;
if (el.hasAttribute("inert")) el.removeAttribute("inert");
// Also clear the property for browsers implementing it as mutable prop
// @ts-ignore
if ((el as any).inert) (el as any).inert = false;
if (el.getAttribute("aria-hidden") === "true") {
el.removeAttribute("aria-hidden");
}
};
// Remove inert from this modal and all its ancestors (Headless UI sets it on the modal container)
let cur: HTMLElement | null = node;
while (cur) {
stripInert(cur);
cur = cur.parentElement as HTMLElement | null;
}
// MutationObserver to keep stripping inert from THIS modal element if reapplied
const observer = new MutationObserver((mutations) => {
mutations.forEach((m) => {
if (
m.type === "attributes" &&
m.attributeName === "inert" &&
m.target instanceof HTMLElement
) {
const target = m.target as HTMLElement;
// Only touch if the target is this modal or one of its ancestors
if (target === node || node.contains(target)) {
stripInert(target);
}
}
});
});
observer.observe(node, {
attributes: true,
subtree: false,
attributeFilter: ["inert"],
});
// Additionally, Headless UI adds inert to previous portal containers that are
// siblings of the one just created. We strip inert from ANY portal container
// so long as background interaction is requested.
const stripInertFromPortals = () => {
const portals = document.querySelectorAll(
"[data-headlessui-portal][inert]"
);
portals.forEach((el) => el.removeAttribute("inert"));
};
stripInertFromPortals();
const globalObserver = new MutationObserver((mutList) => {
mutList.forEach((m) => {
if (
m.type === "attributes" &&
m.attributeName === "inert" &&
(m.target as HTMLElement).hasAttribute("inert")
) {
const target = m.target as HTMLElement;
if (target.hasAttribute("data-headlessui-portal")) {
stripInert(target as HTMLElement);
}
}
});
});
globalObserver.observe(document.body, {
attributes: true,
subtree: true,
attributeFilter: ["inert"],
});
return () => {
observer.disconnect();
globalObserver.disconnect();
};
}, [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
as="div"
className="relative z-[70]"
onClose={onClose}
onClose={closeOnOutsideClick ? onClose : () => null}
static={allowBackgroundInteraction}
>
<div
@@ -424,7 +845,10 @@ export const Modal = ({
{/* Backdrop always rendered first in stacking context */}
{!allowBackgroundInteraction && (
<DialogBackdrop
className={backdropClassName}
className={twMerge(
allowBackgroundInteraction ? "pointer-events-none" : "",
backdropClassName
)}
onClose={onClose}
closeOnOutsideClick={closeOnOutsideClick}
disableHotkeyInput={disableHotkeyInput}
@@ -438,7 +862,14 @@ export const Modal = ({
/>
)}
<ModalExpandContext.Provider value={{ expanded, toggleExpanded }}>
<div className="flex min-h-full items-center justify-center p-4 text-center">
<div
className="flex min-h-full items-center justify-center p-4 text-center"
style={
allowBackgroundInteraction
? { pointerEvents: "none" }
: undefined
}
>
<TransitionChild
as="div"
enter="ease-out duration-200"
@@ -451,7 +882,9 @@ export const Modal = ({
"w-full max-w-lg transform rounded-xl relative border border-ui-panel-border bg-[#2C2C2C] text-left align-middle shadow-2xl z-[70]",
childPadding && !expanded ? "p-4" : "",
className,
dragging && !expanded ? "!transition-none" : "transition-all",
(dragging || resizing) && !expanded
? "!transition-none"
: "transition-all",
expanded &&
"w-screen h-screen max-w-screen max-h-screen rounded-none"
)}
@@ -495,9 +928,11 @@ export const Modal = ({
</DialogTitle>
)}
<div className={`h-full`.trim()}>{enhancedChildren}</div>
{/* resize handles inside panel so clicks don't count as outside */}
{renderResizeHandles()}
</DialogPanel>
{showClose && (
<div className="absolute top-0 right-0 p-2.5">
<div className="absolute top-0 right-0 m-2.5 z-[80]">
<CloseButton onClick={onClose} />
</div>
)}
@@ -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} /> }],
//},
],
};
@@ -5,6 +5,9 @@
{
"path": "../popover"
},
{
"path": "../../model-list"
},
{
"path": "./tsconfig.lib.json"
},
@@ -45,6 +45,9 @@
"references": [
{
"path": "../popover/tsconfig.lib.json"
},
{
"path": "../../model-list/tsconfig.lib.json"
}
]
}
@@ -13,6 +13,7 @@ import {
faChevronUp,
faCircleCheck,
} from "@fortawesome/pro-solid-svg-icons";
import { ModelInfo } from "@storyteller/model-list";
export interface PopoverItem {
label: string;
@@ -25,6 +26,7 @@ export interface PopoverItem {
label: string;
icon?: ReactNode;
}>;
modelInfo?: ModelInfo;
}
interface PopoverMenuProps {
@@ -5,6 +5,9 @@
{
"path": "../button"
},
{
"path": "../../model-list"
},
{
"path": "./tsconfig.lib.json"
},
@@ -45,6 +45,9 @@
"references": [
{
"path": "../button/tsconfig.lib.json"
},
{
"path": "../../model-list/tsconfig.lib.json"
}
]
}
@@ -0,0 +1,12 @@
{
"presets": [
[
"@nx/react/babel",
{
"runtime": "automatic",
"useBuiltIns": "usage"
}
]
],
"plugins": []
}
@@ -0,0 +1,7 @@
# @storyteller/ui-pricing-modal
This library was generated with [Nx](https://nx.dev).
## Running unit tests
Run `nx test @storyteller/ui-pricing-modal` to execute the unit tests via [Vitest](https://vitest.dev/).

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