mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-20 17:02:53 -04:00
fix tauri for omni apis (#1941)
This commit is contained in:
@@ -37,6 +37,7 @@ pub async fn filter_bad_response(response: reqwest::Response) -> Result<reqwest:
|
||||
filter_cloudflare_errors(status_code, &response_body)?;
|
||||
|
||||
match status_code {
|
||||
STATUS_400_BAD_REQUEST => Err(ApiError::InvalidRequest(response_body)),
|
||||
STATUS_401_UNAUTHORIZED => Err(ApiError::Unauthorized(response_body)),
|
||||
STATUS_402_PAYMENT_REQUIRED => Err(ApiError::PaymentRequired(response_body)),
|
||||
STATUS_403_FORBIDDEN => Err(ApiError::Forbidden(response_body)),
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
pub (crate) mod basic_json_delete_request;
|
||||
pub (crate) mod basic_json_get_request;
|
||||
pub (crate) mod basic_json_post_request;
|
||||
pub mod basic_json_post_request;
|
||||
pub (crate) mod basic_query_string_post_request;
|
||||
pub (crate) mod filter_bad_response;
|
||||
pub (crate) mod http_get_anonymous;
|
||||
pub mod api_host;
|
||||
pub mod constants;
|
||||
pub mod status_codes;
|
||||
pub mod status_codes;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
pub const STATUS_400_BAD_REQUEST: u16 = 400;
|
||||
|
||||
pub const STATUS_401_UNAUTHORIZED : u16 = 401u16;
|
||||
|
||||
pub const STATUS_402_PAYMENT_REQUIRED : u16 = 402u16;
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
use crate::core::commands::generate::omni::{self, Modality, OmniRequest, OmniResult};
|
||||
use crate::core::commands::generate::omni::dispatch::local_error;
|
||||
use tauri::AppHandle;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn estimate_audio_cost_command(request: OmniRequest, app: AppHandle) -> OmniResult {
|
||||
if !request.uses_artcraft() {
|
||||
return Err(local_error("No direct provider adapter is configured for this modality."));
|
||||
}
|
||||
omni::estimate(request, Modality::Audio, &app).await
|
||||
}
|
||||
+11
-1
@@ -1,3 +1,6 @@
|
||||
use crate::core::commands::generate::omni::{self, Modality, OmniRequest, OmniResult};
|
||||
use crate::core::commands::generate::omni::dispatch::{adapt_legacy_response, decode_native};
|
||||
use tauri::{AppHandle, Manager};
|
||||
use crate::core::commands::response::failure_response_wrapper::{CommandErrorResponseWrapper, CommandErrorStatus};
|
||||
use crate::core::commands::response::shorthand::ResponseOrError;
|
||||
use crate::core::commands::response::success_response_wrapper::SerializeMarker;
|
||||
@@ -13,7 +16,14 @@ use tauri::State;
|
||||
impl SerializeMarker for EstimateImageCostResponse {}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn estimate_image_cost_command(
|
||||
pub async fn estimate_image_cost_command(request: OmniRequest, app: AppHandle) -> OmniResult {
|
||||
if request.uses_artcraft() && !request.uses_legacy_image_endpoint() {
|
||||
return omni::estimate(request, Modality::Image, &app).await;
|
||||
}
|
||||
adapt_legacy_response(estimate_image_cost_native(decode_native(request)?, app.state()).await)
|
||||
}
|
||||
|
||||
async fn estimate_image_cost_native(
|
||||
request: EstimateImageCostRequest,
|
||||
app_env_configs: State<'_, AppEnvConfigs>,
|
||||
) -> ResponseOrError<EstimateImageCostResponse, EstimateImageCostError> {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
use crate::core::commands::generate::omni::{self, Modality, OmniRequest, OmniResult};
|
||||
use crate::core::commands::generate::omni::dispatch::local_error;
|
||||
use tauri::AppHandle;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn estimate_mesh_cost_command(request: OmniRequest, app: AppHandle) -> OmniResult {
|
||||
if !request.uses_artcraft() {
|
||||
return Err(local_error("No direct provider adapter is configured for this modality."));
|
||||
}
|
||||
omni::estimate(request, Modality::Mesh, &app).await
|
||||
}
|
||||
+11
-1
@@ -1,3 +1,6 @@
|
||||
use crate::core::commands::generate::omni::{self, Modality, OmniRequest, OmniResult};
|
||||
use crate::core::commands::generate::omni::dispatch::{adapt_legacy_response, decode_native};
|
||||
use tauri::{AppHandle, Manager};
|
||||
use crate::core::commands::response::failure_response_wrapper::{CommandErrorResponseWrapper, CommandErrorStatus};
|
||||
use crate::core::commands::response::shorthand::ResponseOrError;
|
||||
use crate::core::commands::response::success_response_wrapper::SerializeMarker;
|
||||
@@ -13,7 +16,14 @@ use tauri::State;
|
||||
impl SerializeMarker for EstimateSplatCostResponse {}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn estimate_splat_cost_command(
|
||||
pub async fn estimate_splat_cost_command(request: OmniRequest, app: AppHandle) -> OmniResult {
|
||||
if request.uses_artcraft() {
|
||||
return omni::estimate(request, Modality::Splat, &app).await;
|
||||
}
|
||||
adapt_legacy_response(estimate_splat_cost_native(decode_native(request)?, app.state()).await)
|
||||
}
|
||||
|
||||
async fn estimate_splat_cost_native(
|
||||
request: EstimateSplatCostRequest,
|
||||
app_env_configs: State<'_, AppEnvConfigs>,
|
||||
) -> ResponseOrError<EstimateSplatCostResponse, EstimateSplatCostError> {
|
||||
|
||||
+11
-1
@@ -1,3 +1,6 @@
|
||||
use crate::core::commands::generate::omni::{self, Modality, OmniRequest, OmniResult};
|
||||
use crate::core::commands::generate::omni::dispatch::{adapt_legacy_response, decode_native};
|
||||
use tauri::{AppHandle, Manager};
|
||||
use crate::core::commands::response::failure_response_wrapper::{CommandErrorResponseWrapper, CommandErrorStatus};
|
||||
use crate::core::commands::response::shorthand::ResponseOrError;
|
||||
use crate::core::commands::response::success_response_wrapper::SerializeMarker;
|
||||
@@ -14,7 +17,14 @@ use tauri::State;
|
||||
impl SerializeMarker for EstimateVideoCostResponse {}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn estimate_video_cost_command(
|
||||
pub async fn estimate_video_cost_command(request: OmniRequest, app: AppHandle) -> OmniResult {
|
||||
if request.uses_artcraft() {
|
||||
return omni::estimate(request, Modality::Video, &app).await;
|
||||
}
|
||||
adapt_legacy_response(estimate_video_cost_native(decode_native(request)?, app.state()).await)
|
||||
}
|
||||
|
||||
async fn estimate_video_cost_native(
|
||||
request: EstimateVideoCostRequest,
|
||||
app_env_configs: State<'_, AppEnvConfigs>,
|
||||
) -> ResponseOrError<EstimateVideoCostResponse, EstimateVideoCostError> {
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
pub mod estimate_image_cost_command;
|
||||
pub mod estimate_splat_cost_command;
|
||||
pub mod estimate_video_cost_command;
|
||||
|
||||
pub mod estimate_mesh_cost_command;
|
||||
pub mod estimate_audio_cost_command;
|
||||
|
||||
+1
@@ -24,6 +24,7 @@ use worldlabs_consumer_client::recipes::upload_image_and_create_world_with_retry
|
||||
|
||||
pub(super) const MAX_IMAGES: usize = 10;
|
||||
|
||||
#[deprecated(note = "Legacy direct-provider handler; desktop generation now uses ArtCraft.")]
|
||||
pub async fn handle_worldlabs_marble(
|
||||
app: &AppHandle,
|
||||
app_data_root: &AppDataRoot,
|
||||
|
||||
@@ -26,6 +26,7 @@ impl TaskEnqueueSuccess{
|
||||
TaskType::ImageGeneration => GenerationAction::GenerateImage,
|
||||
TaskType::GaussianGeneration => GenerationAction::GenerateGaussian,
|
||||
TaskType::VideoGeneration => GenerationAction::GenerateVideo,
|
||||
TaskType::AudioGeneration => GenerationAction::GenerateAudio,
|
||||
TaskType::BackgroundRemoval => GenerationAction::RemoveBackground,
|
||||
TaskType::ObjectGeneration => GenerationAction::ImageTo3d,
|
||||
TaskType::ImageInpaintEdit => GenerationAction::ImageInpaintEdit,
|
||||
@@ -69,8 +70,9 @@ impl TaskEnqueueSuccess{
|
||||
frontend_subscriber_payload: Option<&str>,
|
||||
) -> Result<TaskId, SqliteTasksError> {
|
||||
// TODO: Move this mapping elsewhere, or remove the other models.
|
||||
let model_type = match self.model {
|
||||
let model_type = match &self.model {
|
||||
None => None,
|
||||
Some(GenerationModel::Unknown(model)) => Some(TaskModelType::Unknown(model.clone())),
|
||||
Some(GenerationModel::Flux1Dev) => Some(TaskModelType::Flux1Dev),
|
||||
Some(GenerationModel::FluxDevJuggernaut) => Some(TaskModelType::FluxDevJuggernaut),
|
||||
Some(GenerationModel::Flux1Schnell) => Some(TaskModelType::Flux1Schnell),
|
||||
@@ -146,4 +148,4 @@ impl TaskEnqueueSuccess{
|
||||
frontend_subscriber_payload,
|
||||
}).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
use crate::core::commands::generate::omni::{self, Modality, OmniRequest, OmniResult};
|
||||
use crate::core::commands::generate::omni::dispatch::local_error;
|
||||
use tauri::AppHandle;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn generate_audio_command(request: OmniRequest, app: AppHandle) -> OmniResult {
|
||||
if !request.uses_artcraft() {
|
||||
return Err(local_error("No direct provider adapter is configured for this modality."));
|
||||
}
|
||||
omni::generate(request, Modality::Audio, &app).await
|
||||
}
|
||||
+14
-1
@@ -1,3 +1,6 @@
|
||||
use crate::core::commands::generate::omni::{self, Modality, OmniRequest, OmniResult};
|
||||
use crate::core::commands::generate::omni::dispatch::{adapt_legacy_response, decode_native};
|
||||
use tauri::Manager;
|
||||
use crate::core::commands::enqueue::common::notify_frontend_of_errors::notify_frontend_of_errors;
|
||||
use crate::core::commands::enqueue::generate_error::{GenerateError, MissingCredentialsReason};
|
||||
use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
|
||||
@@ -20,7 +23,17 @@ use log::{error, info};
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn generate_image_command(
|
||||
pub async fn generate_image_command(request: OmniRequest, app: AppHandle) -> OmniResult {
|
||||
if request.uses_artcraft() && !request.uses_legacy_image_endpoint() {
|
||||
return omni::generate(request, Modality::Image, &app).await;
|
||||
}
|
||||
adapt_legacy_response(generate_image_native(
|
||||
decode_native(request)?, app.clone(),
|
||||
app.state(), app.state(), app.state(), app.state(),
|
||||
).await)
|
||||
}
|
||||
|
||||
async fn generate_image_native(
|
||||
request: TauriGenerateImageRequest,
|
||||
app: AppHandle,
|
||||
app_env_configs: State<'_, AppEnvConfigs>,
|
||||
|
||||
+1
@@ -22,6 +22,7 @@ use tauri::AppHandle;
|
||||
|
||||
const SORA_SIMPLE_IMAGE_GEN_TIMEOUT : Duration = Duration::from_millis(1000 * 30); // 30 seconds
|
||||
|
||||
#[deprecated(note = "Legacy direct-provider handler; desktop generation now uses ArtCraft.")]
|
||||
pub async fn handle_gpt_image_1_sora_text_to_image(
|
||||
request: &EnqueueTextToImageRequest,
|
||||
app: &AppHandle,
|
||||
|
||||
+1
@@ -6,6 +6,7 @@ use crate::services::sora::state::sora_credential_manager::SoraCredentialManager
|
||||
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
|
||||
use tauri::AppHandle;
|
||||
|
||||
#[deprecated(note = "Legacy direct-provider handler; desktop generation now uses ArtCraft.")]
|
||||
pub async fn handle_text_to_image_sora(
|
||||
request: &EnqueueTextToImageRequest,
|
||||
app: &AppHandle,
|
||||
|
||||
+2
-2
@@ -64,7 +64,7 @@ pub async fn parse_semantic_media_files(
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
async fn resolve_image_field(
|
||||
pub(crate) async fn resolve_image_field(
|
||||
field_name: &str,
|
||||
maybe_token: Option<&MediaFileToken>,
|
||||
maybe_raw_bytes: Option<&[u8]>,
|
||||
@@ -95,7 +95,7 @@ async fn resolve_image_field(
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn resolve_mask_field(
|
||||
pub(crate) async fn resolve_mask_field(
|
||||
maybe_token: Option<&MediaFileToken>,
|
||||
maybe_raw_bytes: Option<&[u8]>,
|
||||
creds: &StorytellerCredentialSet,
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
use crate::core::commands::generate::omni::{self, Modality, OmniRequest, OmniResult};
|
||||
use crate::core::commands::generate::omni::dispatch::local_error;
|
||||
use tauri::AppHandle;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn generate_mesh_command(request: OmniRequest, app: AppHandle) -> OmniResult {
|
||||
if !request.uses_artcraft() {
|
||||
return Err(local_error("No direct provider adapter is configured for this modality."));
|
||||
}
|
||||
omni::generate(request, Modality::Mesh, &app).await
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
use crate::core::commands::enqueue::image_to_gaussian::enqueue_image_to_gaussian_command::enqueue_image_to_gaussian_command;
|
||||
use crate::core::commands::generate::omni::{self, Modality, OmniRequest, OmniResult};
|
||||
use crate::core::commands::generate::omni::dispatch::{adapt_legacy_response, decode_native};
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn generate_splat_command(mut request: OmniRequest, app: AppHandle) -> OmniResult {
|
||||
if request.uses_artcraft() {
|
||||
return omni::generate(request, Modality::Splat, &app).await;
|
||||
}
|
||||
if let Some(images) = request.fields.remove("reference_image_media_tokens") {
|
||||
request.fields.insert("image_media_tokens".into(), images);
|
||||
}
|
||||
adapt_legacy_response(enqueue_image_to_gaussian_command(decode_native(request)?, app.clone(), app.state(), app.state(), app.state(), app.state(), app.state(), app.state()).await)
|
||||
}
|
||||
+14
-1
@@ -1,3 +1,6 @@
|
||||
use crate::core::commands::generate::omni::{self, Modality, OmniRequest, OmniResult};
|
||||
use crate::core::commands::generate::omni::dispatch::{adapt_legacy_response, decode_native};
|
||||
use tauri::Manager;
|
||||
use crate::core::commands::enqueue::common::notify_frontend_of_errors::notify_frontend_of_errors;
|
||||
use crate::core::commands::enqueue::generate_error::{BadInputReason, GenerateError, MissingCredentialsReason};
|
||||
use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
|
||||
@@ -28,7 +31,17 @@ use log::{error, info, warn};
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn generate_video_command(
|
||||
pub async fn generate_video_command(request: OmniRequest, app: AppHandle) -> OmniResult {
|
||||
if request.uses_artcraft() {
|
||||
return omni::generate(request, Modality::Video, &app).await;
|
||||
}
|
||||
adapt_legacy_response(generate_video_native(
|
||||
decode_native(request)?, app.clone(),
|
||||
app.state(), app.state(), app.state(), app.state(), app.state(), app.state(), app.state(), app.state(), app.state(),
|
||||
).await)
|
||||
}
|
||||
|
||||
async fn generate_video_native(
|
||||
mut request: TauriGenerateVideoRequest,
|
||||
app: AppHandle,
|
||||
app_env_configs: State<'_, AppEnvConfigs>,
|
||||
|
||||
+1
@@ -18,6 +18,7 @@ use openai_sora_client::requests::generate_sora2_video::generate_sora2_video::{G
|
||||
use tauri::AppHandle;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
#[deprecated(note = "Legacy direct-provider handler; desktop generation now uses ArtCraft.")]
|
||||
pub async fn handle_sora_sora2(
|
||||
request: &TauriGenerateVideoRequest,
|
||||
app: &AppHandle,
|
||||
|
||||
@@ -2,3 +2,8 @@ pub mod generate_image;
|
||||
pub mod generate_video;
|
||||
pub mod common;
|
||||
pub mod models;
|
||||
pub mod omni;
|
||||
|
||||
pub mod generate_mesh_command;
|
||||
pub mod generate_splat_command;
|
||||
pub mod generate_audio_command;
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
use super::request::{Modality, OmniRequest};
|
||||
use crate::core::commands::enqueue::common::notify_frontend_of_errors::notify_frontend_of_errors;
|
||||
use crate::core::commands::enqueue::generate_error::{GenerateError, MissingCredentialsReason};
|
||||
use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
|
||||
use crate::core::commands::generate::generate_image::utils::parse_semantic_media_files::{resolve_image_field, resolve_mask_field};
|
||||
use crate::core::commands::response::failure_response_wrapper::{CommandErrorResponseWrapper, CommandErrorStatus};
|
||||
use crate::core::commands::response::shorthand::Response;
|
||||
use crate::core::commands::response::success_response_wrapper::SerializeMarker;
|
||||
use crate::core::events::basic_sendable_event_trait::BasicSendableEvent;
|
||||
use crate::core::events::functional_events::credits_balance_changed_event::CreditsBalanceChangedEvent;
|
||||
use crate::core::events::generation_events::common::GenerationModel;
|
||||
use crate::core::events::generation_events::generation_enqueue_success_event::GenerationEnqueueSuccessEvent;
|
||||
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
|
||||
use crate::core::state::artcraft_usage_tracker::artcraft_usage_tracker::ArtcraftUsageTracker;
|
||||
use crate::core::state::artcraft_usage_tracker::artcraft_usage_type::{ArtcraftUsagePage, ArtcraftUsageType};
|
||||
use crate::core::state::task_database::TaskDatabase;
|
||||
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
|
||||
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
|
||||
use artcraft_client::error::api_error::ApiError;
|
||||
use artcraft_client::error::storyteller_error::StorytellerError;
|
||||
use artcraft_client::utils::api_host::ApiHost;
|
||||
use artcraft_client::utils::basic_json_post_request::basic_json_post_request;
|
||||
use enums::common::generation_provider::GenerationProvider;
|
||||
use log::error;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
use tauri::{AppHandle, Manager};
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use uuid_utils::uuid::generate_random_uuid;
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct OmniResponse(pub Value);
|
||||
|
||||
impl SerializeMarker for OmniResponse {}
|
||||
|
||||
pub type OmniResult = Response<OmniResponse, String, ()>;
|
||||
|
||||
pub async fn generate(request: OmniRequest, modality: Modality, app: &AppHandle) -> OmniResult {
|
||||
let configs = app.state::<AppEnvConfigs>();
|
||||
let manager = app.state::<StorytellerCredentialManager>();
|
||||
let maybe_creds = manager.get_credentials().map_err(local_error)?;
|
||||
let creds = match maybe_creds {
|
||||
Some(creds) => creds,
|
||||
None => {
|
||||
notify_frontend_of_errors(app, &GenerateError::MissingCredentials(MissingCredentialsReason::NeedsStorytellerCredentials)).await;
|
||||
return Err(command_error(CommandErrorStatus::Unauthorized, "needs_storyteller_credentials", "You need to be logged into ArtCraft.".into()));
|
||||
},
|
||||
};
|
||||
let mut fields = request.api_fields(modality);
|
||||
if modality == Modality::Image {
|
||||
resolve_image_inputs(&mut fields, Some(&creds), &configs.storyteller_host).await?;
|
||||
}
|
||||
fields.remove("estimate_only");
|
||||
if fields.get("idempotency_token").is_none_or(Value::is_null) {
|
||||
fields.insert("idempotency_token".into(), Value::String(generate_random_uuid()));
|
||||
}
|
||||
let model = fields.get("model").and_then(Value::as_str).map(str::to_owned);
|
||||
let route = format!("/v1/omni_gen/generate/{}", modality.as_str());
|
||||
let response: OmniResponse = match basic_json_post_request(&configs.storyteller_host, &route, Some(&creds), fields).await {
|
||||
Ok(response) => response,
|
||||
Err(err) => {
|
||||
let error = api_error(&err);
|
||||
notify_frontend_of_errors(app, &GenerateError::from(err)).await;
|
||||
return Err(error);
|
||||
},
|
||||
};
|
||||
|
||||
// A batch may create several jobs. Track every job so history and completion
|
||||
// events survive restart, including when a model was unknown at build time.
|
||||
let job_tokens = job_tokens(&response.0);
|
||||
if job_tokens.is_empty() {
|
||||
return Err(local_error("The API accepted generation but returned no job tokens."));
|
||||
}
|
||||
record_usage(app, &request, modality, job_tokens.len());
|
||||
let database = app.state::<TaskDatabase>();
|
||||
for job_token in job_tokens {
|
||||
let success = TaskEnqueueSuccess { task_type: modality.task_type(), model: model.clone().map(GenerationModel::Unknown), provider: GenerationProvider::Artcraft, provider_job_id: Some(job_token), maybe_queue_status_url: None, maybe_queue_response_url: None, maybe_prompt_token: None };
|
||||
if let Err(err) = success.insert_into_task_database_with_frontend_payload(&database, request.frontend_caller, request.frontend_subscriber_id.as_deref(), request.frontend_subscriber_payload.as_deref()).await {
|
||||
// Generation has already been accepted. Do not encourage duplicate retries.
|
||||
error!("Could not persist accepted Omni generation: {:?}", err);
|
||||
}
|
||||
GenerationEnqueueSuccessEvent { action: success.to_frontend_event_action(), service: success.to_frontend_event_service(), model: success.model }.send_infallible(app);
|
||||
}
|
||||
CreditsBalanceChangedEvent {}.send_infallible(app);
|
||||
Ok(response.into())
|
||||
}
|
||||
|
||||
pub async fn estimate(request: OmniRequest, modality: Modality, app: &AppHandle) -> OmniResult {
|
||||
let configs = app.state::<AppEnvConfigs>();
|
||||
let manager = app.state::<StorytellerCredentialManager>();
|
||||
let creds = manager.get_credentials().map_err(local_error)?;
|
||||
let mut fields = request.api_fields(modality);
|
||||
if modality == Modality::Image {
|
||||
// Pricing only needs reference presence, never upload a canvas on each keystroke.
|
||||
resolve_image_inputs(&mut fields, None, &configs.storyteller_host).await?;
|
||||
}
|
||||
let route = format!("/v1/omni_gen/cost/{}", modality.as_str());
|
||||
let response: OmniResponse = basic_json_post_request(&configs.storyteller_host, &route, creds.as_ref(), fields).await.map_err(|err| api_error(&err))?;
|
||||
Ok(response.into())
|
||||
}
|
||||
|
||||
pub fn adapt_legacy_response<T: Serialize, E: Serialize, P: Serialize>(response: Response<T, E, P>) -> OmniResult {
|
||||
match response {
|
||||
Ok(response) => Ok(OmniResponse(serde_json::to_value(response.payload).map_err(local_error)?).into()),
|
||||
Err(err) => Err(CommandErrorResponseWrapper { status: err.status, error_message: err.error_message.or_else(|| serde_json::to_value(err.error_details).ok().and_then(|value| value.get("error_message").and_then(Value::as_str).map(str::to_owned))), error_type: err.error_type.and_then(|value| serde_json::to_value(value).ok()).and_then(|value| value.as_str().map(str::to_owned)), error_details: None }),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn decode_native<T: serde::de::DeserializeOwned>(request: OmniRequest) -> Result<T, CommandErrorResponseWrapper<String, ()>> {
|
||||
serde_json::from_value(serde_json::to_value(request).map_err(local_error)?).map_err(|err| command_error(CommandErrorStatus::BadRequest, "bad_input", err.to_string()))
|
||||
}
|
||||
|
||||
pub fn local_error(err: impl std::fmt::Display) -> CommandErrorResponseWrapper<String, ()> {
|
||||
command_error(CommandErrorStatus::ServerError, "server_error", err.to_string())
|
||||
}
|
||||
|
||||
fn api_error(err: &StorytellerError) -> CommandErrorResponseWrapper<String, ()> {
|
||||
match err {
|
||||
StorytellerError::Api(ApiError::InvalidRequest(body)) => command_error(CommandErrorStatus::BadRequest, "bad_input", api_message(body)),
|
||||
StorytellerError::Api(ApiError::Unauthorized(body) | ApiError::Forbidden(body)) => command_error(CommandErrorStatus::Unauthorized, "unauthorized", api_message(body)),
|
||||
StorytellerError::Api(ApiError::TooManyRequests(body)) => command_error(CommandErrorStatus::TooManyRequests, "too_many_requests", api_message(body)),
|
||||
StorytellerError::Api(ApiError::PaymentRequired(body)) => command_error(CommandErrorStatus::BadRequest, "billing_issue", api_message(body)),
|
||||
_ => local_error(err),
|
||||
}
|
||||
}
|
||||
|
||||
fn api_message(body: &str) -> String {
|
||||
serde_json::from_str::<Value>(body).ok().and_then(|value| value.get("error_message").or_else(|| value.get("message")).and_then(Value::as_str).map(str::to_owned)).unwrap_or_else(|| body.to_owned())
|
||||
}
|
||||
|
||||
fn command_error(status: CommandErrorStatus, kind: &str, message: String) -> CommandErrorResponseWrapper<String, ()> {
|
||||
CommandErrorResponseWrapper { status, error_type: Some(kind.into()), error_message: Some(message), error_details: None }
|
||||
}
|
||||
|
||||
async fn resolve_image_inputs(fields: &mut Map<String, Value>, maybe_creds: Option<&StorytellerCredentialSet>, host: &ApiHost) -> Result<(), CommandErrorResponseWrapper<String, ()>> {
|
||||
let mut refs = Vec::new();
|
||||
for name in ["canvas_image", "scene_image", "inpainting_mask_image"] {
|
||||
let token_key = format!("{}_media_token", name);
|
||||
let bytes_key = format!("{}_raw_bytes", name);
|
||||
let token: Option<MediaFileToken> = serde_json::from_value(fields.remove(&token_key).unwrap_or(Value::Null)).map_err(local_error)?;
|
||||
let bytes: Option<Vec<u8>> = serde_json::from_value(fields.remove(&bytes_key).unwrap_or(Value::Null)).map_err(local_error)?;
|
||||
let resolved = if let Some(creds) = maybe_creds { if name == "inpainting_mask_image" { resolve_mask_field(token.as_ref(), bytes.as_deref(), creds, host).await } else { resolve_image_field(name, token.as_ref(), bytes.as_deref(), creds, host).await }.map_err(|err| local_error(format!("{:?}", err)))?.map(|token| token.to_string()) } else { token.map(|token| token.to_string()).or_else(|| bytes.map(|_| "mf_estimate_reference".into())) };
|
||||
if let Some(token) = resolved {
|
||||
if name == "inpainting_mask_image" {
|
||||
fields.insert(token_key, Value::String(token));
|
||||
} else {
|
||||
refs.push(Value::String(token));
|
||||
}
|
||||
}
|
||||
}
|
||||
if !refs.is_empty() {
|
||||
if let Some(Value::Array(existing)) = fields.remove("image_media_tokens") {
|
||||
refs.extend(existing);
|
||||
}
|
||||
fields.insert("image_media_tokens".into(), Value::Array(refs));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn job_tokens(response: &Value) -> Vec<String> {
|
||||
let mut result = Vec::new();
|
||||
if let Some(primary) = response.get("inference_job_token").and_then(Value::as_str) {
|
||||
result.push(primary.to_owned());
|
||||
}
|
||||
if let Some(tokens) = response.get("all_job_tokens").and_then(Value::as_array) {
|
||||
for token in tokens.iter().filter_map(Value::as_str) {
|
||||
if !result.iter().any(|existing| existing == token) {
|
||||
result.push(token.to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn record_usage(app: &AppHandle, request: &OmniRequest, modality: Modality, count: usize) {
|
||||
let tracker = app.state::<ArtcraftUsageTracker>();
|
||||
let count = u16::try_from(count).unwrap_or(u16::MAX);
|
||||
let has_media = request.fields.iter().any(|(key, value)| {
|
||||
key.ends_with("media_token") && value.is_string()
|
||||
|| key.ends_with("media_tokens") && value.as_array().is_some_and(|values| !values.is_empty())
|
||||
});
|
||||
let usage = if has_media { ArtcraftUsageType::ImageToResult } else { ArtcraftUsageType::TextToResult };
|
||||
let result = match modality {
|
||||
Modality::Video => tracker.record_video_generation(count, usage, ArtcraftUsagePage::VideoPage),
|
||||
Modality::Mesh => tracker.record_object_generation(count, usage, ArtcraftUsagePage::ObjectPage),
|
||||
Modality::Splat => tracker.record_object_generation(count, usage, ArtcraftUsagePage::OtherPage),
|
||||
_ => Ok(()),
|
||||
};
|
||||
if let Err(err) = result {
|
||||
error!("Could not record generation usage: {:?}", err);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn invalid_options_return_the_api_message() {
|
||||
let err = api_error(&StorytellerError::Api(ApiError::InvalidRequest(r#"{"success":false,"error_message":"Duration must be between 4 and 30 seconds."}"#.into())));
|
||||
assert!(matches!(err.status, CommandErrorStatus::BadRequest));
|
||||
assert_eq!(err.error_message.as_deref(), Some("Duration must be between 4 and 30 seconds."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_tracks_each_job_once() {
|
||||
assert_eq!(job_tokens(&json!({"inference_job_token":"job_1", "all_job_tokens":["job_1", "job_2"]})), vec!["job_1", "job_2"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod request;
|
||||
pub mod dispatch;
|
||||
|
||||
pub use dispatch::{generate, estimate, OmniResponse, OmniResult};
|
||||
pub use request::{OmniRequest, Modality};
|
||||
@@ -0,0 +1,187 @@
|
||||
use enums::common::generation_provider::GenerationProvider;
|
||||
use enums::tauri::tasks::task_type::TaskType;
|
||||
use enums::tauri::ux::tauri_command_caller::TauriCommandCaller;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
/// Desktop metadata is local. Everything else is an API-owned field, including
|
||||
/// model IDs and option strings introduced after this version of the app.
|
||||
#[derive(Clone, Deserialize, Serialize)]
|
||||
pub struct OmniRequest {
|
||||
pub provider: Option<GenerationProvider>,
|
||||
pub frontend_caller: Option<TauriCommandCaller>,
|
||||
pub frontend_subscriber_id: Option<String>,
|
||||
pub frontend_subscriber_payload: Option<String>,
|
||||
#[serde(flatten)]
|
||||
pub fields: Map<String, Value>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Modality {
|
||||
Image,
|
||||
Video,
|
||||
Mesh,
|
||||
Splat,
|
||||
Audio,
|
||||
}
|
||||
|
||||
impl OmniRequest {
|
||||
pub fn uses_artcraft(&self) -> bool {
|
||||
// Native Grok is deliberately disabled, including calls from old UI state.
|
||||
self.model().is_some_and(|model| model.starts_with("grok_")) || matches!(self.provider, None | Some(GenerationProvider::Artcraft | GenerationProvider::Grok))
|
||||
}
|
||||
|
||||
pub fn model(&self) -> Option<&str> {
|
||||
self.fields.get("model").and_then(Value::as_str)
|
||||
}
|
||||
|
||||
pub fn uses_legacy_image_endpoint(&self) -> bool {
|
||||
// These desktop editor integrations still have dedicated request shapes.
|
||||
// Catalog models, including any future ID, use Omni by default.
|
||||
matches!(self.model(), Some("recraft_3" | "flux_pro_kontext_max" | "flux_dev_juggernaut" | "flux_pro_1" | "midjourney"))
|
||||
}
|
||||
|
||||
pub fn api_fields(&self, modality: Modality) -> Map<String, Value> {
|
||||
let mut fields = self.fields.clone();
|
||||
if let Some(model) = self.model() {
|
||||
fields.insert("model".into(), Value::String(canonical_model_id(model).into()));
|
||||
}
|
||||
match modality {
|
||||
Modality::Image => rename_field(&mut fields, "batch_size", "image_batch_count"),
|
||||
Modality::Video => {
|
||||
rename_field(&mut fields, "image_media_token", "start_frame_image_media_token");
|
||||
fields.remove("sora_orientation");
|
||||
fields.remove("grok_aspect_ratio");
|
||||
},
|
||||
Modality::Splat => rename_field(&mut fields, "image_media_tokens", "reference_image_media_tokens"),
|
||||
Modality::Mesh => {
|
||||
if let Some(token) = fields.remove("image_media_token").filter(|v| !v.is_null()) {
|
||||
fields.entry("reference_image_media_tokens").or_insert(Value::Array(vec![token]));
|
||||
}
|
||||
},
|
||||
Modality::Audio => {},
|
||||
}
|
||||
// These belong to the old pricing endpoint, not Omni generation or pricing.
|
||||
fields.remove("generation_mode");
|
||||
fields
|
||||
}
|
||||
}
|
||||
|
||||
impl Modality {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Image => "image",
|
||||
Self::Video => "video",
|
||||
Self::Mesh => "mesh",
|
||||
Self::Splat => "splat",
|
||||
Self::Audio => "audio",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn task_type(self) -> TaskType {
|
||||
match self {
|
||||
Self::Image => TaskType::ImageGeneration,
|
||||
Self::Video => TaskType::VideoGeneration,
|
||||
Self::Mesh => TaskType::ObjectGeneration,
|
||||
Self::Splat => TaskType::GaussianGeneration,
|
||||
Self::Audio => TaskType::AudioGeneration,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn rename_field(fields: &mut Map<String, Value>, old: &str, new: &str) {
|
||||
if let Some(value) = fields.remove(old).filter(|v| !v.is_null()) {
|
||||
if fields.get(new).is_none_or(Value::is_null) {
|
||||
fields.insert(new.into(), value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Compatibility for persisted desktop settings and existing editor callers.
|
||||
// Unknown IDs pass through unchanged; this is not a model allowlist.
|
||||
fn canonical_model_id(model: &str) -> &str {
|
||||
match model {
|
||||
"flux_pro_11" => "flux_pro_1p1",
|
||||
"flux_pro_11_ultra" => "flux_pro_1p1_ultra",
|
||||
"gemini_25_flash" => "nano_banana",
|
||||
"grok_image" => "grok_imagine_image",
|
||||
"grok_video" => "grok_imagine_video",
|
||||
"kling_1.6_pro" => "kling_1p6_pro",
|
||||
"kling_2.1_pro" => "kling_2p1_pro",
|
||||
"kling_2.1_master" => "kling_2p1_master",
|
||||
"seedance_1.0_lite" => "seedance_1p0_lite",
|
||||
"hunyuan_3d_2" | "hunyuan_3d_2_0" => "hunyuan_3d_2p0",
|
||||
"hunyuan_3d_2_1" => "hunyuan_3d_2p1",
|
||||
"world_labs_marble" | "worldlabs_marble" => "marble_0p1_plus",
|
||||
_ => model,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn future_model_and_options_survive_without_desktop_metadata() {
|
||||
let request: OmniRequest = serde_json::from_value(json!({
|
||||
"model": "future_video_v9", "resolution": "eight_k", "bitrate": "ultra",
|
||||
"duration_seconds": 30, "generate_audio": false, "new_option": {"value": 1},
|
||||
"estimate_only": {"total_input_video_duration_millis": 7250},
|
||||
"frontend_subscriber_id": "local-only"
|
||||
}))
|
||||
.unwrap();
|
||||
let fields = request.api_fields(Modality::Video);
|
||||
assert_eq!(fields["model"], "future_video_v9");
|
||||
assert_eq!(fields["resolution"], "eight_k");
|
||||
assert_eq!(fields["bitrate"], "ultra");
|
||||
assert_eq!(fields["duration_seconds"], 30);
|
||||
assert_eq!(fields["generate_audio"], false);
|
||||
assert_eq!(fields["new_option"]["value"], 1);
|
||||
assert_eq!(fields["estimate_only"]["total_input_video_duration_millis"], 7250);
|
||||
assert!(!fields.contains_key("frontend_subscriber_id"));
|
||||
assert!(!fields.contains_key("provider"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grok_uses_artcraft_even_with_a_saved_native_provider() {
|
||||
let request: OmniRequest = serde_json::from_value(json!({
|
||||
"model": "grok_video", "provider": "grok"
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(request.uses_artcraft());
|
||||
assert_eq!(request.api_fields(Modality::Video)["model"], "grok_imagine_video");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_mesh_and_splat_inputs_use_omni_field_names() {
|
||||
let mesh: OmniRequest = serde_json::from_value(json!({
|
||||
"model": "hunyuan_3d_2_0", "image_media_token": "mf_mesh_input"
|
||||
}))
|
||||
.unwrap();
|
||||
let fields = mesh.api_fields(Modality::Mesh);
|
||||
assert_eq!(fields["model"], "hunyuan_3d_2p0");
|
||||
assert_eq!(fields["reference_image_media_tokens"], json!(["mf_mesh_input"]));
|
||||
assert!(!fields.contains_key("image_media_token"));
|
||||
|
||||
let splat: OmniRequest = serde_json::from_value(json!({
|
||||
"model": "world_labs_marble", "image_media_tokens": ["mf_splat_input"]
|
||||
}))
|
||||
.unwrap();
|
||||
let fields = splat.api_fields(Modality::Splat);
|
||||
assert_eq!(fields["model"], "marble_0p1_plus");
|
||||
assert_eq!(fields["reference_image_media_tokens"], json!(["mf_splat_input"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn future_model_ids_survive_storage_and_event_serialization() {
|
||||
use crate::core::events::generation_events::common::GenerationModel;
|
||||
use enums::tauri::tasks::task_model_type::TaskModelType;
|
||||
|
||||
let stored = TaskModelType::from_str("future_model_v99").unwrap();
|
||||
let event = GenerationModel::Unknown(stored.to_str().to_owned());
|
||||
assert_eq!(serde_json::to_value(event).unwrap(), json!("future_model_v99"));
|
||||
// Keep existing event strings compatible with old history.
|
||||
assert_eq!(serde_json::to_value(GenerationModel::FluxPro11).unwrap(), json!("flux_pro_1.1"));
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ pub enum GenerationServiceProvider {
|
||||
Runway,
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone, Serialize)]
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum GenerationModel {
|
||||
#[serde(rename = "flux_1_dev")]
|
||||
@@ -143,6 +143,10 @@ pub enum GenerationModel {
|
||||
Veo3p1,
|
||||
#[serde(rename = "veo_3p1_fast")]
|
||||
Veo3p1Fast,
|
||||
|
||||
/// An API model ID that does not need a compiled desktop variant.
|
||||
#[serde(untagged)]
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone, Serialize)]
|
||||
@@ -150,6 +154,7 @@ pub enum GenerationModel {
|
||||
pub enum GenerationAction {
|
||||
GenerateImage,
|
||||
GenerateVideo,
|
||||
GenerateAudio,
|
||||
RemoveBackground,
|
||||
#[serde(rename = "image_to_3d")]
|
||||
ImageTo3d,
|
||||
|
||||
+5
-1
@@ -55,7 +55,7 @@ pub async fn notify_frontend_of_completion(
|
||||
) {
|
||||
// Fire the generic generation-complete event (for the task queue UI).
|
||||
let generation_action = task_type_to_generation_action(task.task_type);
|
||||
let generation_model = task.model_type.and_then(task_model_type_to_generation_model);
|
||||
let generation_model = task.model_type.clone().and_then(task_model_type_to_generation_model);
|
||||
let generation_service = provider_to_generation_service(task.provider);
|
||||
|
||||
let complete_event = GenerationCompleteEvent {
|
||||
@@ -67,6 +67,8 @@ pub async fn notify_frontend_of_completion(
|
||||
|
||||
// Fire the typed frontend notification (for the specific page/component that initiated the job).
|
||||
let result = match task.task_type {
|
||||
// Audio pages consume the common completion event and refresh their feed.
|
||||
TaskType::AudioGeneration => Ok(()),
|
||||
TaskType::ImageGeneration => {
|
||||
notify_image_generation(app, api_host, maybe_creds, task, completion).await
|
||||
}
|
||||
@@ -259,6 +261,7 @@ fn task_type_to_generation_action(task_type: TaskType) -> GenerationAction {
|
||||
match task_type {
|
||||
TaskType::ImageGeneration => GenerationAction::GenerateImage,
|
||||
TaskType::VideoGeneration => GenerationAction::GenerateVideo,
|
||||
TaskType::AudioGeneration => GenerationAction::GenerateAudio,
|
||||
TaskType::BackgroundRemoval => GenerationAction::RemoveBackground,
|
||||
TaskType::ObjectGeneration => GenerationAction::ImageTo3d,
|
||||
TaskType::GaussianGeneration => GenerationAction::GenerateGaussian,
|
||||
@@ -287,6 +290,7 @@ fn provider_to_generation_service(provider: GenerationProvider) -> GenerationSer
|
||||
|
||||
fn task_model_type_to_generation_model(model: TaskModelType) -> Option<GenerationModel> {
|
||||
match model {
|
||||
TaskModelType::Unknown(model) => Some(GenerationModel::Unknown(model)),
|
||||
TaskModelType::Flux1Dev => Some(GenerationModel::Flux1Dev),
|
||||
TaskModelType::Flux1Schnell => Some(GenerationModel::Flux1Schnell),
|
||||
TaskModelType::FluxPro1 => Some(GenerationModel::FluxPro1),
|
||||
|
||||
+1
@@ -43,6 +43,7 @@ fn task_type_to_generation_action(task_type: TaskType) -> GenerationAction {
|
||||
match task_type {
|
||||
TaskType::ImageGeneration => GenerationAction::GenerateImage,
|
||||
TaskType::VideoGeneration => GenerationAction::GenerateVideo,
|
||||
TaskType::AudioGeneration => GenerationAction::GenerateAudio,
|
||||
TaskType::BackgroundRemoval => GenerationAction::RemoveBackground,
|
||||
TaskType::ObjectGeneration => GenerationAction::ImageTo3d,
|
||||
TaskType::GaussianGeneration => GenerationAction::GenerateGaussian,
|
||||
|
||||
@@ -5,6 +5,7 @@ pub fn to_generation_action(task_type: TaskType) -> GenerationAction {
|
||||
match task_type {
|
||||
TaskType::ImageGeneration => GenerationAction::GenerateImage,
|
||||
TaskType::VideoGeneration => GenerationAction::GenerateVideo,
|
||||
TaskType::AudioGeneration => GenerationAction::GenerateAudio,
|
||||
TaskType::BackgroundRemoval => GenerationAction::RemoveBackground,
|
||||
TaskType::ObjectGeneration => GenerationAction::ImageTo3d,
|
||||
TaskType::GaussianGeneration => GenerationAction::GenerateGaussian,
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
use crate::core::commands::cost_estimate::estimate_audio_cost_command::estimate_audio_cost_command;
|
||||
use crate::core::commands::cost_estimate::estimate_mesh_cost_command::estimate_mesh_cost_command;
|
||||
use crate::core::commands::generate::generate_splat_command::generate_splat_command;
|
||||
use crate::core::commands::generate::generate_audio_command::generate_audio_command;
|
||||
use crate::core::commands::generate::generate_mesh_command::generate_mesh_command;
|
||||
pub mod core;
|
||||
pub mod services;
|
||||
pub mod version;
|
||||
@@ -221,11 +226,16 @@ pub fn run() {
|
||||
estimate_image_cost_command,
|
||||
estimate_splat_cost_command,
|
||||
estimate_video_cost_command,
|
||||
estimate_audio_cost_command,
|
||||
estimate_mesh_cost_command,
|
||||
list_image_models_command,
|
||||
list_video_models_command,
|
||||
flip_image,
|
||||
generate_image_command,
|
||||
generate_video_command,
|
||||
generate_splat_command,
|
||||
generate_audio_command,
|
||||
generate_mesh_command,
|
||||
get_app_info_command,
|
||||
get_app_preferences_command,
|
||||
get_provider_order_command,
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
//! Deprecated direct Sora integration. Retained for legacy handlers and task polling.
|
||||
//! New desktop generations use ArtCraft; account management is no longer exposed in the frontend.
|
||||
|
||||
pub mod commands;
|
||||
pub mod events;
|
||||
pub mod state;
|
||||
|
||||
+1
@@ -38,6 +38,7 @@ use artcraft_client::endpoints::prompts::create_prompt::create_prompt;
|
||||
use tauri::AppHandle;
|
||||
use tempdir::TempDir;
|
||||
|
||||
#[deprecated(note = "Retained to poll legacy direct-provider tasks; new generations use ArtCraft.")]
|
||||
pub async fn sora_task_polling_thread(
|
||||
app_handle: AppHandle,
|
||||
app_env_configs: AppEnvConfigs,
|
||||
|
||||
+2
@@ -37,6 +37,8 @@ pub async fn maybe_handle_frontend_caller_notification(
|
||||
};
|
||||
|
||||
match task.task_type {
|
||||
// Audio uses the session-job feed and the common task completion event.
|
||||
TaskType::AudioGeneration => {},
|
||||
TaskType::ImageGeneration => {
|
||||
let _r = handle_image_generation(
|
||||
app,
|
||||
|
||||
+2
-1
@@ -1,3 +1,4 @@
|
||||
use crate::core::events::generation_events::common::GenerationModel;
|
||||
use crate::core::events::basic_sendable_event_trait::BasicSendableEvent;
|
||||
use crate::core::events::generation_events::generation_failed_event::GenerationFailedEvent;
|
||||
use crate::core::state::task_database::TaskDatabase;
|
||||
@@ -42,7 +43,7 @@ pub async fn handle_failed_job(
|
||||
let event = GenerationFailedEvent {
|
||||
action,
|
||||
service,
|
||||
model: None,
|
||||
model: task.model_type.as_ref().map(|model| GenerationModel::Unknown(model.to_str().to_owned())),
|
||||
reason: None,
|
||||
};
|
||||
|
||||
|
||||
+4
-2
@@ -1,5 +1,5 @@
|
||||
use crate::core::events::basic_sendable_event_trait::BasicSendableEvent;
|
||||
use crate::core::events::generation_events::common::GenerationAction;
|
||||
use crate::core::events::generation_events::common::{GenerationAction, GenerationModel};
|
||||
use crate::core::events::generation_events::generation_complete_event::GenerationCompleteEvent;
|
||||
use crate::core::events::sendable_event_trait::SendableEvent;
|
||||
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
|
||||
@@ -59,7 +59,7 @@ pub async fn handle_successful_job(
|
||||
let event = GenerationCompleteEvent {
|
||||
action: Some(action),
|
||||
service,
|
||||
model: None, // TODO
|
||||
model: task.model_type.as_ref().map(|model| GenerationModel::Unknown(model.to_str().to_owned())),
|
||||
};
|
||||
|
||||
if let Err(err) = event.send(app_handle) {
|
||||
@@ -139,6 +139,8 @@ fn get_media_file_class(job: &ListSessionJobsItem) -> Option<TaskMediaFileClass>
|
||||
InferenceCategory::BackgroundRemoval => return Some(TaskMediaFileClass::Image),
|
||||
InferenceCategory::ImageGeneration => return Some(TaskMediaFileClass::Image),
|
||||
InferenceCategory::VideoGeneration => return Some(TaskMediaFileClass::Video),
|
||||
InferenceCategory::AudioGeneration | InferenceCategory::TextToSpeech => return Some(TaskMediaFileClass::Audio),
|
||||
InferenceCategory::SplatGeneration => return Some(TaskMediaFileClass::Dimensional),
|
||||
InferenceCategory::ObjectGeneration => return Some(TaskMediaFileClass::Dimensional),
|
||||
_ => {}, // Fall-through
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
//! Deprecated direct WorldLabs integration. Retained for legacy handlers and task polling.
|
||||
//! New desktop generations use ArtCraft; account management is no longer exposed in the frontend.
|
||||
|
||||
pub mod commands;
|
||||
pub mod state;
|
||||
pub mod threads;
|
||||
|
||||
+1
@@ -61,6 +61,7 @@ use worldlabs_consumer_client::credentials::world_labs_bearer_token::WorldLabsBe
|
||||
use worldlabs_consumer_client::credentials::world_labs_cookies::WorldLabsCookies;
|
||||
use worldlabs_consumer_client::credentials::worldlabs_refresh_token::WorldLabsRefreshToken;
|
||||
|
||||
#[deprecated(note = "Retained to poll legacy direct-provider tasks; new generations use ArtCraft.")]
|
||||
pub async fn worldlabs_marble_task_polling(
|
||||
app_handle: AppHandle,
|
||||
app_env_configs: AppEnvConfigs,
|
||||
|
||||
@@ -32,7 +32,7 @@ pub async fn create_task(
|
||||
let task_id_temp = task_id.as_str();
|
||||
let status_temp = args.status.to_str();
|
||||
let task_type_temp = args.task_type.to_str();
|
||||
let model_type_temp = args.model_type.map(|s| s.to_str());
|
||||
let model_type_temp = args.model_type.as_ref().map(|s| s.to_str());
|
||||
let provider_temp = args.provider.to_string();
|
||||
let prompt_token_temp = args.prompt_token.map(|t| t.as_str());
|
||||
let frontend_caller_temp = args.frontend_caller.map(|s| s.to_str());
|
||||
|
||||
@@ -8,7 +8,7 @@ use strum::EnumIter;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
#[cfg_attr(test, derive(EnumIter, EnumCount))]
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize, ToSchema)]
|
||||
#[derive(Clone, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TaskModelType {
|
||||
// Image models
|
||||
@@ -127,6 +127,11 @@ pub enum TaskModelType {
|
||||
WorldlabsMarble0p1Mini,
|
||||
#[serde(rename = "marble_0p1_plus")]
|
||||
WorldlabsMarble0p1Plus,
|
||||
|
||||
/// Preserve catalog IDs introduced after this desktop build.
|
||||
#[serde(untagged)]
|
||||
#[cfg_attr(test, strum(disabled))]
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
impl_enum_display_and_debug_using_to_str!(TaskModelType);
|
||||
@@ -136,8 +141,9 @@ impl_enum_display_and_debug_using_to_str!(TaskModelType);
|
||||
// NB: We can derive `sqlx::Type` instead of using `impl_mysql_enum_coders`
|
||||
|
||||
impl TaskModelType {
|
||||
pub fn to_str(&self) -> &'static str {
|
||||
pub fn to_str(&self) -> &str {
|
||||
match self {
|
||||
Self::Unknown(value) => value,
|
||||
// Image models
|
||||
Self::Flux1Dev => "flux_1_dev",
|
||||
Self::Flux1Schnell => "flux_1_schnell",
|
||||
@@ -257,7 +263,7 @@ impl TaskModelType {
|
||||
"worldlabs_marble" => Ok(Self::WorldlabsMarble),
|
||||
"marble_0p1_mini" => Ok(Self::WorldlabsMarble0p1Mini),
|
||||
"marble_0p1_plus" => Ok(Self::WorldlabsMarble0p1Plus),
|
||||
_ => Err(EnumError::CouldNotConvertFromString(value.to_string())),
|
||||
_ => Ok(Self::Unknown(value.to_owned())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -330,7 +336,6 @@ impl TaskModelType {
|
||||
mod tests {
|
||||
use crate::tauri::tasks::task_model_type::TaskModelType;
|
||||
use crate::test_helpers::assert_serialization;
|
||||
use crate::error::enum_error::EnumError;
|
||||
|
||||
mod explicit_checks {
|
||||
use super::*;
|
||||
@@ -513,14 +518,11 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_str_err() {
|
||||
let result = TaskModelType::from_str("asdf");
|
||||
assert!(result.is_err());
|
||||
if let Err(EnumError::CouldNotConvertFromString(value)) = result {
|
||||
assert_eq!(value, "asdf");
|
||||
} else {
|
||||
panic!("Expected EnumError::CouldNotConvertFromString");
|
||||
}
|
||||
fn unknown_catalog_id_round_trips() {
|
||||
let model = TaskModelType::from_str("future_video_v9").unwrap();
|
||||
assert_eq!(model.to_str(), "future_video_v9");
|
||||
assert_eq!(serde_json::to_string(&model).unwrap(), "\"future_video_v9\"");
|
||||
assert_eq!(serde_json::from_str::<TaskModelType>("\"future_video_v9\"").unwrap(), model);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -14,6 +14,7 @@ pub enum TaskType {
|
||||
ImageGeneration,
|
||||
ImageInpaintEdit,
|
||||
VideoGeneration,
|
||||
AudioGeneration,
|
||||
ObjectGeneration,
|
||||
GaussianGeneration,
|
||||
BackgroundRemoval,
|
||||
@@ -31,6 +32,7 @@ impl TaskType {
|
||||
Self::ImageGeneration => "image_generation",
|
||||
Self::ImageInpaintEdit => "image_inpaint_edit",
|
||||
Self::VideoGeneration => "video_generation",
|
||||
Self::AudioGeneration => "audio_generation",
|
||||
Self::ObjectGeneration => "object_generation",
|
||||
Self::GaussianGeneration => "gaussian_generation",
|
||||
Self::BackgroundRemoval => "background_removal",
|
||||
@@ -42,6 +44,7 @@ impl TaskType {
|
||||
"image_generation" => Ok(Self::ImageGeneration),
|
||||
"image_inpaint_edit" => Ok(Self::ImageInpaintEdit),
|
||||
"video_generation" => Ok(Self::VideoGeneration),
|
||||
"audio_generation" => Ok(Self::AudioGeneration),
|
||||
"object_generation" => Ok(Self::ObjectGeneration),
|
||||
"gaussian_generation" => Ok(Self::GaussianGeneration),
|
||||
"background_removal" => Ok(Self::BackgroundRemoval),
|
||||
@@ -56,6 +59,7 @@ impl TaskType {
|
||||
Self::ImageGeneration,
|
||||
Self::ImageInpaintEdit,
|
||||
Self::VideoGeneration,
|
||||
Self::AudioGeneration,
|
||||
Self::ObjectGeneration,
|
||||
Self::GaussianGeneration,
|
||||
Self::BackgroundRemoval,
|
||||
@@ -116,10 +120,11 @@ mod tests {
|
||||
#[test]
|
||||
fn all_variants() {
|
||||
let mut variants = TaskType::all_variants();
|
||||
assert_eq!(variants.len(), 6);
|
||||
assert_eq!(variants.len(), 7);
|
||||
assert_eq!(variants.pop_first(), Some(TaskType::ImageGeneration));
|
||||
assert_eq!(variants.pop_first(), Some(TaskType::ImageInpaintEdit));
|
||||
assert_eq!(variants.pop_first(), Some(TaskType::VideoGeneration));
|
||||
assert_eq!(variants.pop_first(), Some(TaskType::AudioGeneration));
|
||||
assert_eq!(variants.pop_first(), Some(TaskType::ObjectGeneration));
|
||||
assert_eq!(variants.pop_first(), Some(TaskType::GaussianGeneration));
|
||||
assert_eq!(variants.pop_first(), Some(TaskType::BackgroundRemoval));
|
||||
|
||||
@@ -11,9 +11,10 @@ import { useImageTo3DWorldStore } from "../../pages/PageImageTo3DWorld/ImageTo3D
|
||||
import { MediaUploadApi, downloadFileFromUrl } from "@storyteller/api";
|
||||
import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal";
|
||||
import {
|
||||
EnqueueImageTo3dObject,
|
||||
GenerateMesh,
|
||||
EnqueueImageTo3dObjectModel,
|
||||
EnqueueImageToGaussian,
|
||||
GenerateSplat,
|
||||
commandErrorMessage,
|
||||
} from "@storyteller/tauri-api";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
@@ -415,18 +416,15 @@ export const ImageTo3DExperience = ({
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
const result = await EnqueueImageToGaussian({
|
||||
image_media_tokens: readyTokens,
|
||||
await GenerateSplat({
|
||||
reference_image_media_tokens: readyTokens,
|
||||
prompt: worldPrompt.trim() || undefined,
|
||||
model: selectedWorldModel ?? SPLAT_MODELS[0],
|
||||
model: (selectedWorldModel ?? SPLAT_MODELS[0]).tauriId,
|
||||
provider: selectedWorldProvider,
|
||||
frontend_caller: "mini_app",
|
||||
frontend_subscriber_id: subscriberId,
|
||||
});
|
||||
|
||||
if ("error_type" in result) {
|
||||
throw new Error(result.error_message || result.error_type);
|
||||
}
|
||||
} else {
|
||||
const snapshotPrompt = prompt.trim();
|
||||
const snapshotPreview = uploadedPreview || undefined;
|
||||
@@ -452,24 +450,21 @@ export const ImageTo3DExperience = ({
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
const result = await EnqueueImageTo3dObject({
|
||||
image_media_token: uploadedMediaToken || undefined,
|
||||
model: selectedObjectModelId,
|
||||
await GenerateMesh({
|
||||
prompt: snapshotPrompt || undefined,
|
||||
reference_image_media_tokens: activeMode === "image" && uploadedMediaToken ? [uploadedMediaToken] : undefined,
|
||||
model: selectedObjectModel?.tauriId ?? selectedObjectModelId,
|
||||
frontend_caller: "mini_app",
|
||||
frontend_subscriber_id: subscriberId,
|
||||
});
|
||||
|
||||
if ("error_type" in result) {
|
||||
throw new Error(result.error_message || result.error_type);
|
||||
}
|
||||
|
||||
if (activeMode === "text") {
|
||||
setPrompt("");
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "An unexpected error occurred";
|
||||
const errorMessage = commandErrorMessage(error, "An unexpected error occurred");
|
||||
toast.error(`Failed to generate 3D model: ${errorMessage}`);
|
||||
if (variant === "world") {
|
||||
worldFailGeneration(subscriberId);
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
promptDownloadLocationIfNeeded,
|
||||
} from "@storyteller/api";
|
||||
import type { Prompts } from "@storyteller/api";
|
||||
import { DownloadUrl } from "@storyteller/tauri-api";
|
||||
import { DownloadUrl, useModelsStore } from "@storyteller/tauri-api";
|
||||
import {
|
||||
RefImage,
|
||||
RefVideo,
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
VIDEO_MODELS_BY_ID,
|
||||
CommonAspectRatio,
|
||||
CommonResolution,
|
||||
videoResolutionValue,
|
||||
} from "@storyteller/model-list";
|
||||
import {
|
||||
galleryModalLightboxVisible,
|
||||
@@ -249,7 +250,8 @@ export function applyRecreateFromPromptData(data: {
|
||||
|
||||
// Set model first so the UI syncs sizeOptions / durationOptions
|
||||
const videoModel = promptData.maybe_model_type
|
||||
? VIDEO_MODELS_BY_ID.get(promptData.maybe_model_type)
|
||||
? useModelsStore.getState().videoModels.find((model) => model.tauriId === promptData.maybe_model_type || model.id === promptData.maybe_model_type)
|
||||
?? VIDEO_MODELS_BY_ID.get(promptData.maybe_model_type)
|
||||
: undefined;
|
||||
if (videoModel) {
|
||||
modelStore.setSelectedModel(ModelPage.ImageToVideo, videoModel);
|
||||
@@ -281,16 +283,9 @@ export function applyRecreateFromPromptData(data: {
|
||||
|
||||
// Map API resolution (like "one_k") → video store format (like "1080p")
|
||||
if (promptData.maybe_resolution && videoModel?.resolutionOptions) {
|
||||
const resolutionMap: Record<string, string> = {
|
||||
one_k: "1080p",
|
||||
two_k: "2k",
|
||||
three_k: "3k",
|
||||
four_k: "4k",
|
||||
};
|
||||
const mapped = resolutionMap[promptData.maybe_resolution];
|
||||
if (mapped && videoModel.resolutionOptions.includes(mapped)) {
|
||||
videoStore.setResolution(mapped);
|
||||
}
|
||||
const value = videoResolutionValue(promptData.maybe_resolution);
|
||||
const option = videoModel.resolutionOptions.find((option) => videoResolutionValue(option) === value);
|
||||
videoStore.setResolution(option ?? value);
|
||||
}
|
||||
|
||||
videoStore.setInputMode(inputMode);
|
||||
@@ -315,7 +310,8 @@ export function applyRecreateFromPromptData(data: {
|
||||
}
|
||||
|
||||
if (promptData.maybe_model_type) {
|
||||
const model = IMAGE_MODELS_BY_ID.get(promptData.maybe_model_type);
|
||||
const model = useModelsStore.getState().imageModels.find((model) => model.tauriId === promptData.maybe_model_type || model.id === promptData.maybe_model_type)
|
||||
?? IMAGE_MODELS_BY_ID.get(promptData.maybe_model_type);
|
||||
if (model) modelStore.setSelectedModel(ModelPage.TextToImage, model);
|
||||
}
|
||||
useTabStore.getState().setActiveTab("IMAGE");
|
||||
|
||||
+2
-1
@@ -1,3 +1,4 @@
|
||||
import { findDesktopModel } from "@storyteller/tauri-api";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { listen, UnlistenFn } from "@tauri-apps/api/event";
|
||||
import { GetTaskQueue, MarkTaskAsDismissed } from "@storyteller/tauri-api";
|
||||
@@ -270,7 +271,7 @@ function getTaskMediaType(t: TaskQueueItem): "image" | "video" | "other" {
|
||||
|
||||
function getTaskModelLabel(t: TaskQueueItem): string {
|
||||
const modelType = t.model_type ? String(t.model_type) : "";
|
||||
const modelDisplay = modelType ? getModelDisplayName(modelType) : undefined;
|
||||
const modelDisplay = modelType ? findDesktopModel(modelType)?.fullName ?? getModelDisplayName(modelType) : undefined;
|
||||
const provider = t.provider
|
||||
? getProviderDisplayName(String(t.provider).toLowerCase())
|
||||
: undefined;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { findDesktopModel } from "@storyteller/tauri-api";
|
||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
import { PopoverMenu } from "@storyteller/ui-popover";
|
||||
import { BombIcon, BrushIcon, CheckIcon, CircleAlertIcon, CopyIcon, ListChecksIcon, LoaderCircleIcon, Trash2Icon, TriangleAlertIcon, XIcon } from "lucide-react";
|
||||
@@ -648,6 +649,8 @@ export const TaskQueue = () => {
|
||||
kind = "Image to 3D";
|
||||
} else if (is3DModel) {
|
||||
kind = "3D Model";
|
||||
} else if (taskTypeStr.includes("audio")) {
|
||||
kind = "Audio";
|
||||
} else if (taskTypeStr.includes("video")) {
|
||||
kind = "Video";
|
||||
} else if (taskTypeStr.includes("image")) {
|
||||
@@ -655,7 +658,7 @@ export const TaskQueue = () => {
|
||||
}
|
||||
|
||||
const modelDisplay = t.model_type
|
||||
? getModelDisplayName(String(t.model_type))
|
||||
? findDesktopModel(String(t.model_type))?.fullName ?? getModelDisplayName(String(t.model_type))
|
||||
: undefined;
|
||||
|
||||
const title = kind || "Task";
|
||||
|
||||
@@ -598,7 +598,8 @@ export const TopBar = ({ pageName }: Props) => {
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={SettingsIcon}
|
||||
className="h-[34px] w-[34px]"
|
||||
iconClassName="h-5 w-5 shrink-0"
|
||||
className="h-[34px] w-[34px] p-0"
|
||||
onClick={() => {
|
||||
setSettingsSection("general");
|
||||
setIsSettingsModalOpen(true);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Button } from "@storyteller/ui-button";
|
||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
import { ImagesIcon, UploadIcon } from "lucide-react";
|
||||
import { UploadModalImage } from "@storyteller/ui-upload-modal";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
@@ -17,7 +18,8 @@ export const UploadImagesButton = ({ className }: Props) => {
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={UploadIcon}
|
||||
className={className || "h-[38px] w-[38px]"}
|
||||
iconClassName="h-5 w-5 shrink-0"
|
||||
className={twMerge("h-[38px] w-[38px] p-0", className)}
|
||||
onClick={() => setIsOpen(true)}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { GenerateAudio, EstimateAudioCost } from "@storyteller/tauri-api";
|
||||
import type { OmniGenAudioRequest } from "@storyteller/api";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { PromptBoxAudio, usePromptAudioStore } from "@storyteller/ui-promptbox";
|
||||
import {
|
||||
@@ -25,6 +27,9 @@ import { useDesktopUsername } from "~/components/generation-feed/useDesktopUsern
|
||||
import { DesktopCreatePageShell } from "~/components/generation-feed/DesktopCreatePageShell";
|
||||
import { DesktopGenerationGallery } from "~/components/generation-feed/DesktopGenerationGallery";
|
||||
|
||||
const generateAudio = async (request: OmniGenAudioRequest) => (await GenerateAudio(request)).payload;
|
||||
const estimateAudio = async (request: OmniGenAudioRequest) => (await EstimateAudioCost(request)).payload;
|
||||
|
||||
const AUDIO_FILTER = [FilterMediaClasses.AUDIO];
|
||||
|
||||
const CreateAudio = () => {
|
||||
@@ -51,7 +56,7 @@ const CreateAudio = () => {
|
||||
sampleRateHz: selectedModel?.sample_rate_hz_options?.length
|
||||
? (selectedModel.sample_rate_hz_default ?? undefined)
|
||||
: undefined,
|
||||
});
|
||||
}, estimateAudio);
|
||||
|
||||
// Track the promptbox height so the feed can pad past it.
|
||||
useEffect(() => {
|
||||
@@ -64,9 +69,8 @@ const CreateAudio = () => {
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
// The merged generation feed. Audio enqueues over HTTP (no Tauri task
|
||||
// queue), so in-progress/failed come from the shared jobs poller and the
|
||||
// completed history from the library — exactly like the webapp.
|
||||
// Tauri enqueues and persists audio jobs; the shared session poller and
|
||||
// library provide the same generation feed used by the webapp.
|
||||
const username = useDesktopUsername();
|
||||
const feed = useGenerationJobs({ mediaType: "audio", enabled: !!username });
|
||||
const gallery = useGalleryData({
|
||||
@@ -167,6 +171,7 @@ const CreateAudio = () => {
|
||||
<div className="fixed bottom-4 left-1/2 z-20 w-full max-w-5xl -translate-x-1/2 px-2 sm:px-4">
|
||||
<div ref={promptContentRef}>
|
||||
<PromptBoxAudio
|
||||
generateAudio={generateAudio}
|
||||
models={models}
|
||||
uploadAudio={UploadAudioMedia}
|
||||
uploadImage={UploadImageMedia}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
import { sceneOutputAdapter } from "./sceneOutputAdapter";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
fetch: vi.fn(),
|
||||
image: { value: null as any },
|
||||
mediaId: { value: null as string | null },
|
||||
visible: { value: false },
|
||||
previous: { value: null },
|
||||
next: { value: null },
|
||||
}));
|
||||
|
||||
// Exercise the real multipart API client, replacing only the network boundary.
|
||||
vi.mock("@storyteller/tauri-utils", () => ({ FetchProxy: mocks.fetch }));
|
||||
vi.mock("@storyteller/api", async () => ({
|
||||
...await import("../../../../../../libs/api/src/lib/MediaUploadApi"),
|
||||
...await import("../../../../../../libs/api/src/lib/MediaFilesApi"),
|
||||
...await import("../../../../../../libs/api/src/lib/enums/EIntermediateFile"),
|
||||
}));
|
||||
vi.mock("@storyteller/ui-gallery-modal", () => ({
|
||||
galleryModalLightboxImage: mocks.image,
|
||||
galleryModalLightboxMediaId: mocks.mediaId,
|
||||
galleryModalLightboxVisible: mocks.visible,
|
||||
galleryModalLightboxNavPrev: mocks.previous,
|
||||
galleryModalLightboxNavNext: mocks.next,
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.fetch.mockReset();
|
||||
mocks.image.value = null;
|
||||
mocks.mediaId.value = null;
|
||||
mocks.visible.value = false;
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ kind: "video" as const, fileName: "scene-recording.mp4", mime: "video/mp4", endpoint: "new_video" },
|
||||
{ kind: "image" as const, fileName: "scene-capture.png", mime: "image/png", endpoint: "image" },
|
||||
])("uploads a $kind as a library output through authenticated multipart HTTP", async ({ kind, fileName, mime, endpoint }) => {
|
||||
mocks.fetch.mockResolvedValue(new Response(JSON.stringify({
|
||||
success: true, media_file_token: "mf_scene_output",
|
||||
})));
|
||||
const blob = new Blob(["rendered scene"], { type: mime });
|
||||
const token = await sceneOutputAdapter.uploadMedia({ kind, blob, fileName });
|
||||
expect(token).toBe("mf_scene_output");
|
||||
const [url, request] = mocks.fetch.mock.calls[0];
|
||||
expect(url).toMatch(new RegExp(`/v1/media_files/upload/${endpoint}$`));
|
||||
expect(request.method).toBe("POST");
|
||||
expect(request.credentials).toBe("include");
|
||||
const form = request.body as FormData;
|
||||
expect(form.get("is_intermediate_system_file")).toBe("false");
|
||||
expect(form.get("uuid_idempotency_token")).toBeTruthy();
|
||||
expect(form.get("maybe_title")).toBe(fileName);
|
||||
const file = form.get("file") as File;
|
||||
expect(file.name).toBe(fileName);
|
||||
expect(file.type).toBe(mime);
|
||||
expect(file.size).toBe(blob.size);
|
||||
});
|
||||
|
||||
it("surfaces an API rejection instead of treating it as an uploaded recording", async () => {
|
||||
mocks.fetch.mockResolvedValue(new Response(JSON.stringify({
|
||||
success: false, BadInput: "Video upload rejected",
|
||||
}), { status: 400 }));
|
||||
await expect(sceneOutputAdapter.uploadMedia({
|
||||
kind: "video", blob: new Blob(["video"]), fileName: "recording.mp4",
|
||||
})).rejects.toThrow("Video upload rejected");
|
||||
expect(mocks.visible.value).toBe(false);
|
||||
});
|
||||
|
||||
it("opens the uploaded video in the desktop lightbox using its CDN URL", async () => {
|
||||
mocks.fetch.mockResolvedValue(new Response(JSON.stringify({
|
||||
success: true,
|
||||
media_file: {
|
||||
token: "mf_scene_output",
|
||||
maybe_title: "My recording",
|
||||
created_at: "2026-09-11T12:00:00Z",
|
||||
media_links: { cdn_url: "https://cdn.example/recording.mp4" },
|
||||
},
|
||||
})));
|
||||
await sceneOutputAdapter.openMediaLightbox("mf_scene_output", "video");
|
||||
expect(mocks.fetch.mock.calls[0][0]).toMatch(/\/v1\/media_files\/file\/mf_scene_output$/);
|
||||
expect(mocks.image.value).toMatchObject({
|
||||
id: "mf_scene_output", label: "My recording", mediaClass: "video",
|
||||
fullImage: "https://cdn.example/recording.mp4",
|
||||
});
|
||||
expect(mocks.mediaId.value).toBe("mf_scene_output");
|
||||
expect(mocks.visible.value).toBe(true);
|
||||
});
|
||||
|
||||
it("reports a failed preview lookup so the local recording can be retained", async () => {
|
||||
mocks.fetch.mockResolvedValue(new Response(JSON.stringify({ success: false }), { status: 503 }));
|
||||
await expect(sceneOutputAdapter.openMediaLightbox("mf_scene_output", "video"))
|
||||
.rejects.toThrow("Upload succeeded, but the preview could not be loaded");
|
||||
expect(mocks.visible.value).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { EIntermediateFile, MediaFilesApi, MediaUploadApi } from "@storyteller/api";
|
||||
import type { PageSceneAdapter } from "@storyteller/ui-pagescene";
|
||||
import {
|
||||
type GalleryItem,
|
||||
galleryModalLightboxImage,
|
||||
galleryModalLightboxMediaId,
|
||||
galleryModalLightboxNavNext,
|
||||
galleryModalLightboxNavPrev,
|
||||
galleryModalLightboxVisible,
|
||||
} from "@storyteller/ui-gallery-modal";
|
||||
|
||||
export const sceneOutputAdapter: Pick<
|
||||
PageSceneAdapter,
|
||||
"uploadMedia" | "openMediaLightbox"
|
||||
> = {
|
||||
async uploadMedia({ kind, blob, fileName, title }) {
|
||||
const api = new MediaUploadApi();
|
||||
const request = {
|
||||
uuid: crypto.randomUUID(),
|
||||
blob,
|
||||
fileName,
|
||||
maybe_title: title ?? fileName,
|
||||
// Captures and recordings are library outputs, not temporary inputs.
|
||||
is_intermediate_system_file: EIntermediateFile.false,
|
||||
};
|
||||
const response = kind === "video"
|
||||
? await api.UploadNewVideo(request)
|
||||
: await api.UploadImage(request);
|
||||
if (!response.success || !response.data) {
|
||||
throw new Error(response.errorMessage || "Could not upload the recording or capture.");
|
||||
}
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async openMediaLightbox(token, kind) {
|
||||
const response = await new MediaFilesApi().GetMediaFileByToken({
|
||||
mediaFileToken: token,
|
||||
});
|
||||
const file = response.data;
|
||||
if (!response.success || !file?.media_links?.cdn_url) {
|
||||
throw new Error("Upload succeeded, but the preview could not be loaded. Retry to open it.");
|
||||
}
|
||||
const item: GalleryItem = {
|
||||
id: token,
|
||||
label: file.maybe_title || (kind === "video" ? "Scene recording" : "Scene capture"),
|
||||
thumbnail: null,
|
||||
thumbnailUrlTemplate: file.media_links.thumbnail_template,
|
||||
fullImage: file.media_links.cdn_url,
|
||||
createdAt: file.created_at,
|
||||
mediaClass: kind,
|
||||
isUpload: true,
|
||||
};
|
||||
galleryModalLightboxNavPrev.value = null;
|
||||
galleryModalLightboxNavNext.value = null;
|
||||
galleryModalLightboxMediaId.value = token;
|
||||
galleryModalLightboxImage.value = item;
|
||||
galleryModalLightboxVisible.value = true;
|
||||
},
|
||||
};
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
pageWidth,
|
||||
signalScene,
|
||||
} from "~/signals";
|
||||
import { sceneOutputAdapter } from "./sceneOutputAdapter";
|
||||
|
||||
const apiHost = () =>
|
||||
StorytellerApiHostStore.getInstance().getApiSchemeAndHost();
|
||||
@@ -167,6 +168,7 @@ export const useTauriPageSceneAdapter = (
|
||||
const { initialSceneToken, cacheJsonString, onSceneSerialized } = options;
|
||||
return useMemo<PageSceneAdapter>(
|
||||
() => ({
|
||||
...sceneOutputAdapter,
|
||||
enqueueGeneration: async (req) => {
|
||||
const request: GenerateImageRequest = {
|
||||
model: req.model,
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
include: ["app/src/**/*.spec.{ts,tsx}"],
|
||||
},
|
||||
});
|
||||
@@ -2,6 +2,7 @@
|
||||
// In the future, we should use code gen (protobufs or similar) to keep the two sides in sync.
|
||||
|
||||
export enum TaskType {
|
||||
AudioGeneration = "audio_generation",
|
||||
ImageGeneration = "image_generation",
|
||||
ImageInpaintEdit = "image_inpaint_edit",
|
||||
VideoGeneration = "video_generation",
|
||||
|
||||
@@ -584,7 +584,11 @@ export function LightboxModal({
|
||||
controls
|
||||
loop={true}
|
||||
autoPlay={true}
|
||||
playsInline
|
||||
className="h-full w-full object-contain"
|
||||
// Give WebKit a dedicated compositing layer for paused frames,
|
||||
// independent of the modal's transform/opacity animation.
|
||||
style={{ transform: "translateZ(0)" }}
|
||||
onLoadedData={(e) => {
|
||||
setMediaLoaded(true);
|
||||
const el = e.currentTarget;
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { act, cleanup, renderHook } from "@testing-library/react";
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
import { GenerationProvider } from "@storyteller/api-enums";
|
||||
import { ModelPage } from "./model-pages";
|
||||
import {
|
||||
getSelectedProviderForModel,
|
||||
useClassyModelSelectorStore,
|
||||
useSelectedProviderForModel,
|
||||
} from "./classy-model-selector-store";
|
||||
|
||||
const RETIRED_SELECTIONS = [
|
||||
{ page: ModelPage.TextToImage, modelId: "gpt_image_1", provider: GenerationProvider.Sora },
|
||||
{ page: ModelPage.ImageToVideo, modelId: "sora_2", provider: GenerationProvider.Sora },
|
||||
{ page: ModelPage.ImageTo3DWorld, modelId: "marble_0p1_plus", provider: GenerationProvider.WorldLabs },
|
||||
];
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
useClassyModelSelectorStore.setState({ selectedModels: {}, selectedProviders: {} });
|
||||
});
|
||||
|
||||
it.each(RETIRED_SELECTIONS)(
|
||||
"uses ArtCraft for stale $provider selections of $modelId in generation and pricing",
|
||||
({ page, modelId, provider }) => {
|
||||
useClassyModelSelectorStore.setState({
|
||||
selectedProviders: { [page]: { [modelId]: provider } },
|
||||
});
|
||||
const { result } = renderHook(() => useSelectedProviderForModel(page, modelId));
|
||||
expect(result.current).toBe(GenerationProvider.Artcraft);
|
||||
expect(getSelectedProviderForModel(page, modelId)).toBe(GenerationProvider.Artcraft);
|
||||
|
||||
act(() => useClassyModelSelectorStore.getState().setSelectedProvider(page, modelId, provider));
|
||||
expect(useClassyModelSelectorStore.getState().selectedProviders[page]?.[modelId])
|
||||
.toBe(GenerationProvider.Artcraft);
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves other provider selections and missing defaults", () => {
|
||||
const page = ModelPage.TextToImage;
|
||||
const modelId = "midjourney";
|
||||
const { result } = renderHook(() => useSelectedProviderForModel(page, modelId));
|
||||
expect(result.current).toBeUndefined();
|
||||
act(() => useClassyModelSelectorStore.getState().setSelectedProvider(page, modelId, GenerationProvider.Midjourney));
|
||||
expect(result.current).toBe(GenerationProvider.Midjourney);
|
||||
expect(getSelectedProviderForModel(page, modelId)).toBe(GenerationProvider.Midjourney);
|
||||
});
|
||||
@@ -31,7 +31,7 @@ export const useClassyModelSelectorStore = create<ClassyModelSelectorState>(
|
||||
...state.selectedProviders,
|
||||
[page]: {
|
||||
...(state.selectedProviders[page] ?? {}),
|
||||
[modelId]: provider,
|
||||
[modelId]: resolveSelectedProvider(provider)!,
|
||||
},
|
||||
},
|
||||
})),
|
||||
@@ -77,7 +77,7 @@ export const getSelectedProviderForModel = (
|
||||
const { selectedProviders } = useClassyModelSelectorStore.getState();
|
||||
const byPage = selectedProviders[page];
|
||||
if (!byPage) return undefined;
|
||||
return byPage[modelId];
|
||||
return resolveSelectedProvider(byPage[modelId]);
|
||||
};
|
||||
|
||||
// Reactive hooks for UI subscriptions
|
||||
@@ -110,5 +110,19 @@ export const useSelectedProviderForModel = (
|
||||
modelId: string | undefined
|
||||
): GenerationProvider | undefined =>
|
||||
useClassyModelSelectorStore((s) =>
|
||||
modelId ? s.selectedProviders[page]?.[modelId] : undefined
|
||||
modelId ? resolveSelectedProvider(s.selectedProviders[page]?.[modelId]) : undefined
|
||||
);
|
||||
|
||||
function resolveSelectedProvider(
|
||||
provider: GenerationProvider | undefined,
|
||||
): GenerationProvider | undefined {
|
||||
// Retired direct accounts must not reach generation or pricing requests,
|
||||
// including when an older selection is still in state.
|
||||
if (
|
||||
provider === GenerationProvider.Sora ||
|
||||
provider === GenerationProvider.WorldLabs
|
||||
) {
|
||||
return GenerationProvider.Artcraft;
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
|
||||
@@ -80,10 +80,10 @@ function ProviderTooltipContent({
|
||||
useClassyModelSelectorStore();
|
||||
const selectedProvider = useSelectedProviderForModel(page, modelId);
|
||||
|
||||
// Initialize provider for this model if missing
|
||||
// Reset selections that are no longer offered for this model.
|
||||
useEffect(() => {
|
||||
if (!modelId) return;
|
||||
if (!selectedProvider && allowedProviders.length > 0) {
|
||||
if ((!selectedProvider || !allowedProviders.includes(selectedProvider)) && allowedProviders.length > 0) {
|
||||
setSelectedProvider(page, modelId, allowedProviders[0]);
|
||||
}
|
||||
}, [page, modelId, selectedProvider, allowedProviders, setSelectedProvider]);
|
||||
@@ -173,13 +173,13 @@ export function ClassyModelSelector({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [items, selectedModels, page]);
|
||||
|
||||
// Initialize a default provider for each model so we can render icons even when not selected
|
||||
// Initialize defaults and replace providers removed from the catalog.
|
||||
useEffect(() => {
|
||||
for (const item of items) {
|
||||
const modelId = item.model?.id;
|
||||
if (!modelId) continue;
|
||||
if (selectedProvidersByModel[modelId]) continue;
|
||||
const allowed = item.model?.getProviders() || DEFAULT_PROVIDER_OPTIONS;
|
||||
if (allowed.includes(selectedProvidersByModel[modelId])) continue;
|
||||
if (allowed.length > 0) {
|
||||
setSelectedProvider(page, modelId, allowed[0]);
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ interface ProviderSelectorProps {
|
||||
const DEFAULT_PROVIDER_OPTIONS: GenerationProvider[] = [
|
||||
GenerationProvider.Artcraft,
|
||||
GenerationProvider.Fal,
|
||||
GenerationProvider.Sora,
|
||||
];
|
||||
|
||||
export function ProviderSelector({
|
||||
@@ -40,14 +39,16 @@ export function ProviderSelector({
|
||||
const modelId = model?.id;
|
||||
const allowedProviders: GenerationProvider[] = useMemo(() => {
|
||||
if (!modelId) return DEFAULT_PROVIDER_OPTIONS;
|
||||
return providersByModel?.[modelId] ?? DEFAULT_PROVIDER_OPTIONS;
|
||||
}, [providersByModel, modelId]);
|
||||
const providers = (providersByModel?.[modelId] ?? model?.getProviders() ?? DEFAULT_PROVIDER_OPTIONS)
|
||||
.filter((provider) => provider !== GenerationProvider.Sora && provider !== GenerationProvider.WorldLabs);
|
||||
return providers.length > 0 ? providers : [GenerationProvider.Artcraft];
|
||||
}, [providersByModel, modelId, model]);
|
||||
|
||||
const selectedProvider = useSelectedProviderForModel(page, modelId);
|
||||
|
||||
useEffect(() => {
|
||||
if (!modelId) return;
|
||||
if (!selectedProvider && allowedProviders.length > 0) {
|
||||
if ((!selectedProvider || !allowedProviders.includes(selectedProvider)) && allowedProviders.length > 0) {
|
||||
setSelectedProvider(page, modelId, allowedProviders[0]);
|
||||
}
|
||||
}, [page, modelId, selectedProvider, allowedProviders, setSelectedProvider]);
|
||||
|
||||
@@ -52,7 +52,7 @@ import { ControlsTopButtons } from "./comps/ControlsTopButtons";
|
||||
import { EditorCanvas } from "./comps/EngineCanvases";
|
||||
import { FocalLengthDisplay } from "./comps/FocalLengthDisplay/FocalLengthDisplay";
|
||||
import { PerfStatsOverlay } from "./comps/PerfStatsOverlay";
|
||||
import { EntranceDebugPanel } from "./comps/EntranceDebugPanel";
|
||||
// import { EntranceDebugPanel } from "./comps/EntranceDebugPanel";
|
||||
import { Outliner } from "./comps/Outliner";
|
||||
import { PoseModeSelector } from "./comps/PoseModeSelector";
|
||||
import { ExitCameraView } from "./comps/ExitCameraView";
|
||||
@@ -477,7 +477,8 @@ export const Stage3DBody = ({
|
||||
<Cheatsheet surface="pagescene" visible={cheatsheetVisible} />
|
||||
|
||||
<PerfStatsOverlay />
|
||||
{import.meta.env.DEV && <EntranceDebugPanel />}
|
||||
{/* Local animation tuning only; keep disabled in committed code. */}
|
||||
{/* {import.meta.env.DEV && <EntranceDebugPanel />} */}
|
||||
<FocalLengthDisplay />
|
||||
<PoseModeSelector />
|
||||
<RenderOverlay />
|
||||
|
||||
@@ -263,8 +263,8 @@ export interface PageSceneAdapter {
|
||||
|
||||
// Persist a produced still/video to the user's media library and return its
|
||||
// media token. Images auto-upload after Capture; videos upload on demand
|
||||
// (they're large). Host: MediaUploadApi via uploadByKind.
|
||||
uploadMedia?(args: {
|
||||
// (they're large). Required so hosts cannot silently omit recording uploads.
|
||||
uploadMedia(args: {
|
||||
kind: "image" | "video";
|
||||
blob: Blob;
|
||||
fileName: string;
|
||||
@@ -275,7 +275,7 @@ export interface PageSceneAdapter {
|
||||
// offers every destination for the media type (Edit-on-Canvas, Make-Video,
|
||||
// Recreate, Share, Download). Host resolves the CDN URL from the token and
|
||||
// renders <Lightbox>. Replaces bespoke per-destination handoffs.
|
||||
openMediaLightbox?(token: string, kind: "image" | "video"): void;
|
||||
openMediaLightbox(token: string, kind: "image" | "video"): void | Promise<void>;
|
||||
|
||||
// Auth/logout — the SettingsModal in Controls3D needs a logout
|
||||
// callback. Tauri host: setLogoutStates. Web host: web auth flow.
|
||||
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { CompletionModal } from "./CompletionModal";
|
||||
import { EngineContext } from "../../contexts/EngineContext";
|
||||
import { usePageSceneStore, type ProducedArtifact } from "../../PageSceneStore";
|
||||
import type Editor from "../../engine/editor";
|
||||
|
||||
const mocks = vi.hoisted(() => ({ revoke: vi.fn() }));
|
||||
|
||||
vi.mock("../../contexts/EngineContext", async () => {
|
||||
const { createContext } = await import("react");
|
||||
return { EngineContext: createContext(null) };
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("URL", { revokeObjectURL: mocks.revoke });
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
usePageSceneStore.setState({ producedArtifact: null });
|
||||
mocks.revoke.mockClear();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("uploads only on click and retains the video until its lightbox is ready", async () => {
|
||||
const artifact = setArtifact("video");
|
||||
const uploadMedia = vi.fn().mockResolvedValue("mf_video");
|
||||
let finishPreview!: () => void;
|
||||
const openMediaLightbox = vi.fn().mockReturnValue(new Promise<void>((resolve) => {
|
||||
finishPreview = resolve;
|
||||
}));
|
||||
renderModal({ uploadMedia, openMediaLightbox });
|
||||
expect(uploadMedia).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Upload & continue" }));
|
||||
await waitFor(() => expect(openMediaLightbox).toHaveBeenCalledWith("mf_video", "video"));
|
||||
expect(uploadMedia).toHaveBeenCalledWith({
|
||||
kind: "video", blob: artifact.blob, fileName: artifact.fileName,
|
||||
});
|
||||
expect(usePageSceneStore.getState().producedArtifact).toBe(artifact);
|
||||
expect(mocks.revoke).not.toHaveBeenCalled();
|
||||
expect((screen.getByRole("button", { name: "Discard" }) as HTMLButtonElement).disabled).toBe(true);
|
||||
await act(async () => finishPreview());
|
||||
expect(usePageSceneStore.getState().producedArtifact).toBeNull();
|
||||
expect(mocks.revoke).toHaveBeenCalledWith(artifact.objectUrl);
|
||||
});
|
||||
|
||||
it("shows missing upload support instead of silently ignoring the click", async () => {
|
||||
const artifact = setArtifact("video");
|
||||
renderModal({});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Upload & continue" }));
|
||||
expect((await screen.findByRole("alert")).textContent).toContain("Uploading is unavailable");
|
||||
expect(usePageSceneStore.getState().producedArtifact).toBe(artifact);
|
||||
expect(console.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps a failed upload available and retries it", async () => {
|
||||
const artifact = setArtifact("video");
|
||||
const uploadMedia = vi.fn()
|
||||
.mockRejectedValueOnce(new Error("Connection lost"))
|
||||
.mockResolvedValueOnce("mf_video");
|
||||
const openMediaLightbox = vi.fn();
|
||||
renderModal({ uploadMedia, openMediaLightbox });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Upload & continue" }));
|
||||
expect((await screen.findByRole("alert")).textContent).toContain("Connection lost");
|
||||
expect(usePageSceneStore.getState().producedArtifact).toBe(artifact);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||
await waitFor(() => expect(usePageSceneStore.getState().producedArtifact).toBeNull());
|
||||
expect(uploadMedia).toHaveBeenCalledTimes(2);
|
||||
expect(openMediaLightbox).toHaveBeenCalledWith("mf_video", "video");
|
||||
});
|
||||
|
||||
it("retries preview failures without uploading the recording twice", async () => {
|
||||
setArtifact("video");
|
||||
const uploadMedia = vi.fn().mockResolvedValue("mf_video");
|
||||
const openMediaLightbox = vi.fn()
|
||||
.mockRejectedValueOnce(new Error("Preview unavailable"))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
renderModal({ uploadMedia, openMediaLightbox });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Upload & continue" }));
|
||||
await screen.findByRole("alert");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||
await waitFor(() => expect(usePageSceneStore.getState().producedArtifact).toBeNull());
|
||||
expect(uploadMedia).toHaveBeenCalledTimes(1);
|
||||
expect(openMediaLightbox).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("still uploads captured images automatically", async () => {
|
||||
const artifact = setArtifact("image");
|
||||
const uploadMedia = vi.fn().mockResolvedValue("mf_image");
|
||||
const openMediaLightbox = vi.fn();
|
||||
renderModal({ uploadMedia, openMediaLightbox });
|
||||
await waitFor(() => expect(usePageSceneStore.getState().producedArtifact).toBeNull());
|
||||
expect(uploadMedia).toHaveBeenCalledWith({
|
||||
kind: "image", blob: artifact.blob, fileName: artifact.fileName,
|
||||
});
|
||||
expect(openMediaLightbox).toHaveBeenCalledWith("mf_image", "image");
|
||||
});
|
||||
|
||||
function renderModal(adapter: Partial<Editor["adapter"]>) {
|
||||
return render(
|
||||
<EngineContext.Provider value={{ adapter } as Editor}>
|
||||
<CompletionModal />
|
||||
</EngineContext.Provider>,
|
||||
);
|
||||
}
|
||||
|
||||
function setArtifact(kind: "image" | "video"): ProducedArtifact {
|
||||
const artifact: ProducedArtifact = {
|
||||
kind,
|
||||
blob: new Blob(["rendered scene"], { type: kind === "video" ? "video/mp4" : "image/png" }),
|
||||
objectUrl: "blob:scene-output",
|
||||
fileName: kind === "video" ? "recording.mp4" : "capture.png",
|
||||
mimeType: kind === "video" ? "video/mp4" : "image/png",
|
||||
aspectRatio: usePageSceneStore.getState().cameraAspectRatio,
|
||||
};
|
||||
usePageSceneStore.getState().setProducedArtifact(artifact);
|
||||
return artifact;
|
||||
}
|
||||
+36
-12
@@ -1,7 +1,7 @@
|
||||
import { useContext, useEffect, useRef, useState } from "react";
|
||||
import { CloudUploadIcon, LoaderCircleIcon, RotateCwIcon, TrashIcon, TriangleAlertIcon } from "lucide-react";
|
||||
import { EngineContext } from "../../contexts/EngineContext";
|
||||
import { usePageSceneStore } from "../../PageSceneStore";
|
||||
import { usePageSceneStore, type ProducedArtifact } from "../../PageSceneStore";
|
||||
|
||||
// Output modal shown immediately after Capture/Record — it previews the local
|
||||
// artifact (object URL, works offline), then uploads to the gallery and shows
|
||||
@@ -17,25 +17,47 @@ export const CompletionModal = () => {
|
||||
(s) => s.clearProducedArtifact,
|
||||
);
|
||||
const [status, setStatus] = useState<UploadStatus>("idle");
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const autoStartedRef = useRef(false);
|
||||
const inFlightRef = useRef(false);
|
||||
const uploadedRef = useRef<{ artifact: ProducedArtifact; token: string } | null>(null);
|
||||
|
||||
const isVideo = artifact?.kind === "video";
|
||||
|
||||
const upload = async () => {
|
||||
if (!editor?.adapter.uploadMedia || !artifact) return;
|
||||
if (!artifact || inFlightRef.current) return;
|
||||
inFlightRef.current = true;
|
||||
setStatus("uploading");
|
||||
setErrorMessage("");
|
||||
try {
|
||||
const token = await editor.adapter.uploadMedia({
|
||||
kind: artifact.kind,
|
||||
blob: artifact.blob,
|
||||
fileName: artifact.fileName,
|
||||
});
|
||||
if (!editor?.adapter.uploadMedia || !editor.adapter.openMediaLightbox) {
|
||||
throw new Error("Uploading is unavailable in this editor.");
|
||||
}
|
||||
// A failed preview lookup must not upload the same large video again.
|
||||
let token = uploadedRef.current?.artifact === artifact
|
||||
? uploadedRef.current.token
|
||||
: undefined;
|
||||
if (!token) {
|
||||
token = await editor.adapter.uploadMedia({
|
||||
kind: artifact.kind,
|
||||
blob: artifact.blob,
|
||||
fileName: artifact.fileName,
|
||||
});
|
||||
uploadedRef.current = { artifact, token };
|
||||
}
|
||||
if (usePageSceneStore.getState().producedArtifact !== artifact) return;
|
||||
// Hand off to the app Lightbox (destinations for this kind). It shows via
|
||||
// the uploaded CDN URL, so revoking the local object URL now is safe.
|
||||
editor.adapter.openMediaLightbox?.(token, artifact.kind);
|
||||
clearProducedArtifact();
|
||||
} catch {
|
||||
await editor.adapter.openMediaLightbox(token, artifact.kind);
|
||||
if (usePageSceneStore.getState().producedArtifact === artifact) {
|
||||
clearProducedArtifact();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Scene output upload or preview failed:", error);
|
||||
setErrorMessage(error instanceof Error ? error.message : "Upload failed. Please retry.");
|
||||
setStatus("error");
|
||||
} finally {
|
||||
inFlightRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -47,7 +69,9 @@ export const CompletionModal = () => {
|
||||
}
|
||||
if (!artifact) {
|
||||
autoStartedRef.current = false;
|
||||
uploadedRef.current = null;
|
||||
setStatus("idle");
|
||||
setErrorMessage("");
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [artifact]);
|
||||
@@ -91,11 +115,11 @@ export const CompletionModal = () => {
|
||||
</div>
|
||||
)}
|
||||
{status === "error" && (
|
||||
<div className="flex items-center gap-2 text-sm text-red">
|
||||
<div role="alert" className="flex items-center gap-2 text-sm text-red">
|
||||
<TriangleAlertIcon
|
||||
|
||||
className="h-4 w-4" />
|
||||
Upload failed — check your connection and retry.
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
+2
-6
@@ -2,10 +2,6 @@ import { CommonAspectRatio } from "@storyteller/api-enums";
|
||||
|
||||
// TODO(bt): This shouldn't exist. We need to standardize types throughout the frontend.
|
||||
|
||||
const VALID_COMMON_ASPECT_RATIOS = new Set<string>(
|
||||
Object.values(CommonAspectRatio),
|
||||
);
|
||||
|
||||
/**
|
||||
* Convert an aspect ratio to a CommonAspectRatio for the image cost estimate API.
|
||||
*
|
||||
@@ -18,8 +14,8 @@ const VALID_COMMON_ASPECT_RATIOS = new Set<string>(
|
||||
export function imageAspectRatioToCommonAspectRatio(
|
||||
newStyleAspectRatio: string | undefined,
|
||||
legacyAspectRatio?: string,
|
||||
): CommonAspectRatio | null {
|
||||
if (newStyleAspectRatio && VALID_COMMON_ASPECT_RATIOS.has(newStyleAspectRatio)) {
|
||||
): string | null {
|
||||
if (newStyleAspectRatio) {
|
||||
return newStyleAspectRatio as CommonAspectRatio;
|
||||
}
|
||||
if (legacyAspectRatio) {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { CommonQuality } from "@storyteller/api-enums";
|
||||
*/
|
||||
export function stringToCommonQuality(
|
||||
quality: string | undefined,
|
||||
): CommonQuality | null {
|
||||
): string | null {
|
||||
switch (quality) {
|
||||
case "high":
|
||||
return CommonQuality.High;
|
||||
@@ -16,6 +16,6 @@ export function stringToCommonQuality(
|
||||
case "low":
|
||||
return CommonQuality.Low;
|
||||
default:
|
||||
return null;
|
||||
return quality ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
+4
-2
@@ -12,7 +12,7 @@ import { CommonVideoResolution } from "@storyteller/api-enums";
|
||||
*/
|
||||
export function stringToCommonVideoResolution(
|
||||
resolution: string | undefined,
|
||||
): CommonVideoResolution | null {
|
||||
): string | null {
|
||||
switch (resolution) {
|
||||
case "half_k":
|
||||
return CommonVideoResolution.HalfK;
|
||||
@@ -30,13 +30,15 @@ export function stringToCommonVideoResolution(
|
||||
return CommonVideoResolution.TenEightyP;
|
||||
case "two_k":
|
||||
case "2k":
|
||||
case "2K":
|
||||
return CommonVideoResolution.TwoK;
|
||||
case "three_k":
|
||||
return CommonVideoResolution.ThreeK;
|
||||
case "four_k":
|
||||
case "4k":
|
||||
case "4K":
|
||||
return CommonVideoResolution.FourK;
|
||||
default:
|
||||
return null;
|
||||
return resolution ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
+2
-11
@@ -1,22 +1,13 @@
|
||||
import { CommonAspectRatio } from "@storyteller/api-enums";
|
||||
import { SizeOption } from "@storyteller/model-list";
|
||||
|
||||
// TODO(bt): This shouldn't exist. We need to standardize types throughout the frontend.
|
||||
|
||||
const VALID_COMMON_ASPECT_RATIOS = new Set<string>(
|
||||
Object.values(CommonAspectRatio),
|
||||
);
|
||||
|
||||
export function videoAspectRatioToCommonAspectRatio(
|
||||
textLabel: string | null,
|
||||
sizeOptions: SizeOption[] | undefined,
|
||||
): CommonAspectRatio | null {
|
||||
): string | null {
|
||||
if (!textLabel || !sizeOptions) return null;
|
||||
const option = sizeOptions.find((o) => o.textLabel === textLabel);
|
||||
if (!option) return null;
|
||||
const { tauriValue } = option;
|
||||
if (VALID_COMMON_ASPECT_RATIOS.has(tauriValue)) {
|
||||
return tauriValue as CommonAspectRatio;
|
||||
}
|
||||
return null;
|
||||
return option.tauriValue;
|
||||
}
|
||||
|
||||
@@ -38,5 +38,6 @@ export const TAB_TO_MODEL_PAGE: Record<string, ModelPage> = {
|
||||
"3D": ModelPage.Stage3D,
|
||||
EDIT: ModelPage.ImageEditor,
|
||||
IMAGE_TO_3D_WORLD: ModelPage.ImageTo3DWorld,
|
||||
IMAGE_TO_3D_OBJECT: ModelPage.ImageTo3DObject,
|
||||
ANGLES: ModelPage.Angles,
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useMeshCostEstimate } from "./useMeshCostEstimate";
|
||||
import { Modal } from "@storyteller/ui-modal";
|
||||
import { useMemo } from "react";
|
||||
import { CoinsIcon, LoaderIcon } from "lucide-react";
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
useImageEditorPageModelList,
|
||||
useAnglesPageModelList,
|
||||
IMAGE_TO_3D_WORLD_PAGE_MODEL_LIST,
|
||||
IMAGE_TO_3D_OBJECT_PAGE_MODEL_LIST,
|
||||
} from "@storyteller/ui-model-selector";
|
||||
import {
|
||||
usePrompt2DStore,
|
||||
@@ -102,6 +104,8 @@ export function CostBreakdownModal({ activeTabId }: CostBreakdownModalProps) {
|
||||
return modelsFromList(imageEditorList);
|
||||
case ModelPage.ImageTo3DWorld:
|
||||
return modelsFromList(IMAGE_TO_3D_WORLD_PAGE_MODEL_LIST);
|
||||
case ModelPage.ImageTo3DObject:
|
||||
return modelsFromList(IMAGE_TO_3D_OBJECT_PAGE_MODEL_LIST);
|
||||
case ModelPage.Angles:
|
||||
return modelsFromList(anglesList);
|
||||
default:
|
||||
@@ -139,8 +143,9 @@ export function CostBreakdownModal({ activeTabId }: CostBreakdownModalProps) {
|
||||
selectedModel,
|
||||
selectedProvider,
|
||||
);
|
||||
const { isLoading: isMeshEstimateLoading } = useMeshCostEstimate(activePage, selectedModel, selectedProvider);
|
||||
const isEstimateLoading =
|
||||
isVideoEstimateLoading || isImageEstimateLoading || isSplatEstimateLoading;
|
||||
isVideoEstimateLoading || isImageEstimateLoading || isSplatEstimateLoading || isMeshEstimateLoading;
|
||||
|
||||
// Get generation settings from the appropriate stores based on active page
|
||||
const prompt2D = usePrompt2DStore();
|
||||
@@ -213,6 +218,7 @@ export function CostBreakdownModal({ activeTabId }: CostBreakdownModalProps) {
|
||||
ModelPage.ImageEditor,
|
||||
ModelPage.ImageToVideo,
|
||||
ModelPage.ImageTo3DWorld,
|
||||
ModelPage.ImageTo3DObject,
|
||||
ModelPage.Angles,
|
||||
]);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { ModelPage } from "@storyteller/ui-model-selector";
|
||||
import { Model } from "@storyteller/model-list";
|
||||
import { Model, ImageModel } from "@storyteller/model-list";
|
||||
import { GenerationProvider } from "@storyteller/api-enums";
|
||||
import {
|
||||
usePromptImageStore,
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
} from "@storyteller/tauri-api";
|
||||
import { useCostBreakdownModalStore } from "./cost-breakdown-modal-store";
|
||||
import {
|
||||
imageModelToCommonImageModel,
|
||||
imageAspectRatioToCommonAspectRatio,
|
||||
stringToCommonQuality,
|
||||
stringToCommonVideoResolution,
|
||||
@@ -40,9 +39,7 @@ export function useImageCostEstimate(
|
||||
|
||||
// TextToImage store
|
||||
const imageAspectRatio = usePromptImageStore((s) => s.commonAspectRatio);
|
||||
const imageLegacyAspectRatio = usePromptImageStore((s) => s.aspectRatio);
|
||||
const imageResolution = usePromptImageStore((s) => s.commonResolution);
|
||||
const imageLegacyResolution = usePromptImageStore((s) => s.resolution);
|
||||
const imageReferenceImages = usePromptImageStore((s) => s.referenceImages);
|
||||
const imageGenerationCount = usePromptImageStore((s) => s.generationCount);
|
||||
const imageQuality = usePromptImageStore((s) => s.commonQuality);
|
||||
@@ -64,15 +61,11 @@ export function useImageCostEstimate(
|
||||
|
||||
useEffect(() => {
|
||||
if (!IMAGE_PAGES.has(activePage) || !selectedModel) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const commonModel = imageModelToCommonImageModel(selectedModel.tauriId);
|
||||
if (!commonModel) {
|
||||
setEstimatedCreditsForPage(activePage, null);
|
||||
return;
|
||||
}
|
||||
|
||||
const imageModel = selectedModel as ImageModel;
|
||||
let aspectRatioStr: string | undefined;
|
||||
let legacyAspectRatioStr: string | undefined;
|
||||
let resolutionStr: string | undefined;
|
||||
@@ -82,28 +75,29 @@ export function useImageCostEstimate(
|
||||
|
||||
switch (activePage) {
|
||||
case ModelPage.TextToImage:
|
||||
aspectRatioStr = imageAspectRatio;
|
||||
legacyAspectRatioStr = imageLegacyAspectRatio;
|
||||
resolutionStr = imageResolution ?? imageLegacyResolution;
|
||||
qualityStr = imageQuality;
|
||||
referenceImageCount = imageReferenceImages.length;
|
||||
// Match GenerateImage exactly; omitted values use the API defaults.
|
||||
aspectRatioStr = imageModel.supportsNewAspectRatio() ? imageAspectRatio : undefined;
|
||||
resolutionStr = imageModel.supportsNewResolution() ? imageResolution : undefined;
|
||||
qualityStr = imageModel.supportsQuality() ? imageQuality ?? imageModel.defaultQuality : undefined;
|
||||
referenceImageCount = imageModel.canUseImagePrompt
|
||||
? imageReferenceImages.filter((image) => image.mediaToken.length > 0).length : 0;
|
||||
generationCount = imageGenerationCount;
|
||||
break;
|
||||
case ModelPage.Canvas2D:
|
||||
legacyAspectRatioStr = prompt2DAspectRatio;
|
||||
resolutionStr = prompt2DResolution;
|
||||
referenceImageCount = prompt2DReferenceImages.length;
|
||||
referenceImageCount = prompt2DReferenceImages.length + 1;
|
||||
generationCount = prompt2DGenerationCount;
|
||||
break;
|
||||
case ModelPage.Stage3D:
|
||||
resolutionStr = prompt3DResolution;
|
||||
referenceImageCount = prompt3DReferenceImages.length;
|
||||
referenceImageCount = prompt3DReferenceImages.length + 1;
|
||||
generationCount = 1;
|
||||
break;
|
||||
case ModelPage.ImageEditor:
|
||||
legacyAspectRatioStr = editAspectRatio;
|
||||
resolutionStr = editResolution;
|
||||
referenceImageCount = editReferenceImages.length;
|
||||
referenceImageCount = editReferenceImages.length + 1;
|
||||
generationCount = 1;
|
||||
break;
|
||||
case ModelPage.Angles:
|
||||
@@ -128,10 +122,14 @@ export function useImageCostEstimate(
|
||||
(selectedProvider as GenerationProvider | null | undefined) ??
|
||||
GenerationProvider.Artcraft;
|
||||
|
||||
let cancelled = false;
|
||||
setIsLoading(true);
|
||||
setEstimatedCreditsForPage(activePage, null);
|
||||
|
||||
EstimateImageCost({
|
||||
model: commonModel,
|
||||
model: selectedModel.tauriId,
|
||||
image_media_tokens: referenceImageCount ? Array.from({ length: referenceImageCount }, (_, i) => `mf_estimate_${i}`) : undefined,
|
||||
image_batch_count: generationCount,
|
||||
provider,
|
||||
generation_mode: generationMode,
|
||||
aspect_ratio: commonAspectRatio ?? undefined,
|
||||
@@ -139,31 +137,27 @@ export function useImageCostEstimate(
|
||||
quality: commonQuality ?? undefined,
|
||||
})
|
||||
.then((result) => {
|
||||
if (cancelled) return;
|
||||
if (isEstimateImageCostSuccess(result)) {
|
||||
const creditsPerGeneration = result.payload.cost_in_credits ?? null;
|
||||
const totalCredits =
|
||||
creditsPerGeneration != null
|
||||
? creditsPerGeneration * generationCount
|
||||
: null;
|
||||
setEstimatedCreditsForPage(activePage, totalCredits);
|
||||
setEstimatedCreditsForPage(activePage, result.payload.cost_in_credits ?? null);
|
||||
} else {
|
||||
setEstimatedCreditsForPage(activePage, null);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
setEstimatedCreditsForPage(activePage, null);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
if (!cancelled) setIsLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [
|
||||
activePage,
|
||||
selectedModel?.id,
|
||||
selectedModel,
|
||||
selectedProvider,
|
||||
imageAspectRatio,
|
||||
imageLegacyAspectRatio,
|
||||
imageResolution,
|
||||
imageLegacyResolution,
|
||||
imageReferenceImages.length,
|
||||
imageGenerationCount,
|
||||
imageQuality,
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { ModelPage } from "@storyteller/ui-model-selector";
|
||||
import type { Model } from "@storyteller/model-list";
|
||||
import { GenerationProvider } from "@storyteller/api-enums";
|
||||
import { EstimateMeshCost } from "@storyteller/tauri-api";
|
||||
import { useCostBreakdownModalStore } from "./cost-breakdown-modal-store";
|
||||
|
||||
export function useMeshCostEstimate(
|
||||
activePage: ModelPage,
|
||||
selectedModel: Model | null | undefined,
|
||||
selectedProvider: string | null | undefined,
|
||||
): { isLoading: boolean } {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const setCredits = useCostBreakdownModalStore(
|
||||
(s) => s.setEstimatedCreditsForPage,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (activePage !== ModelPage.ImageTo3DObject || !selectedModel) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setIsLoading(true);
|
||||
setCredits(activePage, null);
|
||||
EstimateMeshCost({
|
||||
model: selectedModel.tauriId,
|
||||
provider:
|
||||
(selectedProvider as GenerationProvider) ?? GenerationProvider.Artcraft,
|
||||
reference_image_media_tokens: ["mf_estimate_reference"],
|
||||
})
|
||||
.then((result) => {
|
||||
if (!cancelled)
|
||||
setCredits(activePage, result.payload.cost_in_credits ?? null);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setCredits(activePage, null);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setIsLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [activePage, selectedModel, selectedProvider, setCredits]);
|
||||
|
||||
return { isLoading };
|
||||
}
|
||||
@@ -23,6 +23,7 @@ export function useSplatCostEstimate(
|
||||
|
||||
useEffect(() => {
|
||||
if (!SPLAT_PAGES.has(activePage) || !selectedModel) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -36,14 +37,18 @@ export function useSplatCostEstimate(
|
||||
(selectedProvider as GenerationProvider | null | undefined) ??
|
||||
GenerationProvider.Artcraft;
|
||||
|
||||
let cancelled = false;
|
||||
setIsLoading(true);
|
||||
setEstimatedCreditsForPage(activePage, null);
|
||||
|
||||
EstimateSplatCost({
|
||||
model: commonModel,
|
||||
provider,
|
||||
has_reference_image: true,
|
||||
reference_image_media_tokens: ["mf_estimate_reference"],
|
||||
})
|
||||
.then((result) => {
|
||||
if (cancelled) return;
|
||||
if (isEstimateSplatCostSuccess(result)) {
|
||||
setEstimatedCreditsForPage(
|
||||
activePage,
|
||||
@@ -54,11 +59,13 @@ export function useSplatCostEstimate(
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
setEstimatedCreditsForPage(activePage, null);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
if (!cancelled) setIsLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [activePage, selectedModel?.id, selectedProvider]);
|
||||
|
||||
return { isLoading };
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
import { ModelPage } from "@storyteller/ui-model-selector";
|
||||
import { VideoModel } from "@storyteller/model-list";
|
||||
import { useVideoCostEstimate } from "./useVideoCostEstimate";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
estimate: vi.fn(),
|
||||
setCredits: vi.fn(),
|
||||
state: {
|
||||
duration: 30,
|
||||
aspectRatio: "16:9",
|
||||
resolution: "4K",
|
||||
inputMode: "reference",
|
||||
referenceImages: [{ mediaToken: "mf_image" }],
|
||||
endFrameImage: undefined,
|
||||
referenceVideos: [{ mediaToken: "mf_video", duration: 7.25 }],
|
||||
referenceAudios: [{ mediaToken: "mf_audio", duration: 3.5 }],
|
||||
bitrate: "high",
|
||||
generationCount: 1,
|
||||
generateWithSound: true,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@storyteller/ui-promptbox", () => ({
|
||||
usePromptVideoStore: (select: any) => select(mocks.state),
|
||||
}));
|
||||
vi.mock("@storyteller/ui-model-selector", () => ({
|
||||
ModelPage: { ImageToVideo: "image_to_video" },
|
||||
}));
|
||||
vi.mock("@storyteller/tauri-api", () => ({
|
||||
EstimateVideoCost: mocks.estimate,
|
||||
isEstimateVideoCostSuccess: (result: any) => result.status === "success",
|
||||
}));
|
||||
vi.mock("./cost-breakdown-modal-store", () => ({
|
||||
useCostBreakdownModalStore: (select: any) =>
|
||||
select({ setEstimatedCreditsForPage: mocks.setCredits }),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.estimate.mockReset();
|
||||
mocks.setCredits.mockReset();
|
||||
mocks.estimate.mockResolvedValue({
|
||||
status: "success",
|
||||
payload: { cost_in_credits: 42 },
|
||||
});
|
||||
});
|
||||
|
||||
it("quotes a future model with the chosen bitrate and reference durations in milliseconds", async () => {
|
||||
renderHook(() =>
|
||||
useVideoCostEstimate(ModelPage.ImageToVideo, model, "artcraft"),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(mocks.setCredits).toHaveBeenLastCalledWith(
|
||||
ModelPage.ImageToVideo,
|
||||
42,
|
||||
),
|
||||
);
|
||||
expect(mocks.estimate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
model: "future_video",
|
||||
resolution: "four_k",
|
||||
bitrate: "high",
|
||||
duration_seconds: 30,
|
||||
reference_video_media_tokens: ["mf_video"],
|
||||
reference_audio_media_tokens: ["mf_audio"],
|
||||
estimate_only: {
|
||||
total_input_video_duration_millis: 7250,
|
||||
total_input_audio_duration_millis: 3500,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not overwrite a newer model's quote with a slow earlier response", async () => {
|
||||
let finishOld: (result: unknown) => void = () => {};
|
||||
mocks.estimate.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finishOld = resolve;
|
||||
}),
|
||||
);
|
||||
const hook = renderHook(
|
||||
({ selected }) =>
|
||||
useVideoCostEstimate(ModelPage.ImageToVideo, selected, "artcraft"),
|
||||
{ initialProps: { selected: model } },
|
||||
);
|
||||
hook.rerender({
|
||||
selected: { ...model, id: "new_model", tauriId: "new_model" } as VideoModel,
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(mocks.setCredits).toHaveBeenLastCalledWith(
|
||||
ModelPage.ImageToVideo,
|
||||
42,
|
||||
),
|
||||
);
|
||||
await act(async () =>
|
||||
finishOld({ status: "success", payload: { cost_in_credits: 999 } }),
|
||||
);
|
||||
expect(mocks.setCredits).toHaveBeenLastCalledWith(ModelPage.ImageToVideo, 42);
|
||||
});
|
||||
|
||||
const model = {
|
||||
id: "future_video",
|
||||
tauriId: "future_video",
|
||||
minDuration: 4,
|
||||
maxDuration: 30,
|
||||
durationOptions: undefined,
|
||||
defaultDuration: 5,
|
||||
supportsReferenceMode: true,
|
||||
resolutionOptions: ["4K"],
|
||||
bitrateOptions: ["normal", "high"],
|
||||
defaultBitrate: "normal",
|
||||
generateWithSound: true,
|
||||
sizeOptions: [{ tauriValue: "wide_sixteen_by_nine", textLabel: "16:9" }],
|
||||
} as VideoModel;
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { ModelPage } from "@storyteller/ui-model-selector";
|
||||
import { Model, VideoModel } from "@storyteller/model-list";
|
||||
import { Model, VideoModel, resolveVideoDuration, videoResolutionValue } from "@storyteller/model-list";
|
||||
import { GenerationProvider } from "@storyteller/api-enums";
|
||||
import { usePromptVideoStore } from "@storyteller/ui-promptbox";
|
||||
import {
|
||||
@@ -9,10 +9,8 @@ import {
|
||||
} from "@storyteller/tauri-api";
|
||||
import { useCostBreakdownModalStore } from "./cost-breakdown-modal-store";
|
||||
import {
|
||||
videoModelToCommonVideoModel,
|
||||
videoAspectRatioToCommonAspectRatio,
|
||||
videoStoreToGenerationMode,
|
||||
stringToCommonVideoResolution,
|
||||
} from "./convert/index.js";
|
||||
|
||||
export function useVideoCostEstimate(
|
||||
@@ -31,25 +29,25 @@ export function useVideoCostEstimate(
|
||||
const inputMode = usePromptVideoStore((s) => s.inputMode);
|
||||
const referenceImages = usePromptVideoStore((s) => s.referenceImages);
|
||||
const endFrameImage = usePromptVideoStore((s) => s.endFrameImage);
|
||||
const referenceVideos = usePromptVideoStore((s) => s.referenceVideos);
|
||||
const referenceAudios = usePromptVideoStore((s) => s.referenceAudios);
|
||||
const bitrate = usePromptVideoStore((s) => s.bitrate);
|
||||
const generationCount = usePromptVideoStore((s) => s.generationCount);
|
||||
const generateWithSound = usePromptVideoStore((s) => s.generateWithSound);
|
||||
|
||||
useEffect(() => {
|
||||
if (activePage !== ModelPage.ImageToVideo || !selectedModel) {
|
||||
return;
|
||||
}
|
||||
|
||||
const commonModel = videoModelToCommonVideoModel(selectedModel.tauriId);
|
||||
if (!commonModel) {
|
||||
setEstimatedCreditsForPage(ModelPage.ImageToVideo, null);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const videoModel = selectedModel as VideoModel;
|
||||
const commonAspectRatio = videoAspectRatioToCommonAspectRatio(
|
||||
aspectRatio,
|
||||
videoModel.sizeOptions,
|
||||
);
|
||||
const commonResolution = stringToCommonVideoResolution(resolution);
|
||||
const commonAspectRatio = videoModel.supportsCommonAspectRatio
|
||||
? videoAspectRatioToCommonAspectRatio(aspectRatio, videoModel.sizeOptions)
|
||||
?? videoModel.defaultAspectRatio ?? videoModel.sizeOptions[0]?.tauriValue
|
||||
: undefined;
|
||||
const commonResolution = videoModel.resolutionOptions?.length ? videoResolutionValue(resolution) : undefined;
|
||||
const isReferenceMode = inputMode === "reference" && !!videoModel.supportsReferenceMode;
|
||||
const generationMode = videoStoreToGenerationMode(
|
||||
inputMode,
|
||||
referenceImages,
|
||||
@@ -61,41 +59,64 @@ export function useVideoCostEstimate(
|
||||
(selectedProvider as GenerationProvider | null | undefined) ??
|
||||
GenerationProvider.Artcraft;
|
||||
|
||||
let cancelled = false;
|
||||
setIsLoading(true);
|
||||
setEstimatedCreditsForPage(ModelPage.ImageToVideo, null);
|
||||
|
||||
EstimateVideoCost({
|
||||
model: commonModel,
|
||||
model: videoModel.tauriId,
|
||||
provider,
|
||||
generation_mode: generationMode,
|
||||
aspect_ratio: commonAspectRatio ?? undefined,
|
||||
resolution: commonResolution ?? undefined,
|
||||
duration_seconds: duration ?? undefined,
|
||||
generate_audio: generateWithSound,
|
||||
duration_seconds: resolveVideoDuration(videoModel, duration, isReferenceMode) ?? undefined,
|
||||
generate_audio: videoModel.generateWithSound ? generateWithSound : undefined,
|
||||
bitrate: videoModel.bitrateOptions?.length ? bitrate ?? videoModel.defaultBitrate ?? videoModel.bitrateOptions[0] : undefined,
|
||||
start_frame_image_media_token: !isReferenceMode ? referenceImages[0]?.mediaToken : undefined,
|
||||
end_frame_image_media_token: !isReferenceMode ? endFrameImage?.mediaToken : undefined,
|
||||
reference_image_media_tokens: isReferenceMode ? referenceImages.map((image) => image.mediaToken) : undefined,
|
||||
reference_video_media_tokens: isReferenceMode ? referenceVideos.map((video) => video.mediaToken) : undefined,
|
||||
reference_audio_media_tokens: isReferenceMode ? referenceAudios.map((audio) => audio.mediaToken) : undefined,
|
||||
estimate_only: isReferenceMode ? {
|
||||
total_input_video_duration_millis: referenceVideos.length
|
||||
? Math.round(referenceVideos.reduce((sum, video) => sum + video.duration, 0) * 1000) : undefined,
|
||||
total_input_audio_duration_millis: referenceAudios.length
|
||||
? Math.round(referenceAudios.reduce((sum, audio) => sum + audio.duration, 0) * 1000) : undefined,
|
||||
} : undefined,
|
||||
})
|
||||
.then((result) => {
|
||||
if (cancelled) return;
|
||||
if (isEstimateVideoCostSuccess(result)) {
|
||||
const credits = result.payload.cost_in_credits ?? null;
|
||||
const unitCredits = result.payload.cost_in_credits;
|
||||
const count = videoModel.id === "seedance_2p0" ? generationCount : 1;
|
||||
const credits = unitCredits == null ? null : unitCredits * count;
|
||||
setEstimatedCreditsForPage(ModelPage.ImageToVideo, credits);
|
||||
} else {
|
||||
setEstimatedCreditsForPage(ModelPage.ImageToVideo, null);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
setEstimatedCreditsForPage(ModelPage.ImageToVideo, null);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
if (!cancelled) setIsLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [
|
||||
activePage,
|
||||
selectedModel?.id,
|
||||
selectedModel,
|
||||
selectedProvider,
|
||||
duration,
|
||||
aspectRatio,
|
||||
resolution,
|
||||
inputMode,
|
||||
referenceImages.length,
|
||||
!!endFrameImage,
|
||||
referenceImages,
|
||||
referenceVideos,
|
||||
referenceAudios,
|
||||
bitrate,
|
||||
generationCount,
|
||||
endFrameImage,
|
||||
generateWithSound,
|
||||
]);
|
||||
|
||||
|
||||
@@ -446,33 +446,9 @@ export const PromptBox3D = ({
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to show Sora login reminder and wait for login
|
||||
// const handleSoraLoginReminder = async () => {
|
||||
// return new Promise<void>((resolve) => {
|
||||
// showActionReminder({
|
||||
// reminderType: "soraLogin",
|
||||
// onPrimaryAction: async () => {
|
||||
// await invoke("open_sora_login_command");
|
||||
// await waitForSoraLogin();
|
||||
// toast.success("Logged in to Sora!");
|
||||
// resolve();
|
||||
// },
|
||||
// });
|
||||
// });
|
||||
// };
|
||||
|
||||
const handleTauriEnqueue = async () => {
|
||||
if (!prompt.trim()) return;
|
||||
|
||||
// NB(bt): This needs to move to an error handler.
|
||||
// // Check if the Sora session is valid
|
||||
// const soraSession = await CheckSoraSession();
|
||||
// if (soraSession.state !== SoraSessionState.Valid) {
|
||||
// setIsEnqueueing(false);
|
||||
// await handleSoraLoginReminder();
|
||||
// return;
|
||||
// }
|
||||
|
||||
setIsEnqueueing(true);
|
||||
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
import { GenerateButton, ToggleButton } from "@storyteller/ui-button";
|
||||
import { ChevronDownIcon, ChevronUpIcon, MicIcon, MicOffIcon, RepeatIcon } from "lucide-react";
|
||||
import { DynamicIcon } from "@storyteller/icons";
|
||||
import type { OmniGenAudioModelDetails, UploadMediaFn } from "@storyteller/api";
|
||||
import type { OmniGenAudioModelDetails, OmniGenAudioRequest, OmniGenAudioGenerateResponse, UploadMediaFn } from "@storyteller/api";
|
||||
import {
|
||||
enqueueAudioGeneration,
|
||||
AUDIO_MODELS_REQUIRING_AUDIO_REF,
|
||||
@@ -69,6 +69,7 @@ interface PromptBoxAudioProps {
|
||||
// Audio models from GET /v1/omni_gen/models/audio (useOmniGenAudioModels).
|
||||
models: OmniGenAudioModelDetails[];
|
||||
uploadAudio?: UploadMediaFn;
|
||||
generateAudio?: (request: OmniGenAudioRequest) => Promise<OmniGenAudioGenerateResponse>;
|
||||
uploadImage?: UploadMediaFn;
|
||||
// Fired after a successful enqueue with every created job token (one
|
||||
// request can create several Suno clips).
|
||||
@@ -77,6 +78,7 @@ interface PromptBoxAudioProps {
|
||||
}
|
||||
|
||||
export const PromptBoxAudio = ({
|
||||
generateAudio,
|
||||
models,
|
||||
uploadAudio,
|
||||
uploadImage,
|
||||
@@ -400,7 +402,7 @@ export const PromptBoxAudio = ({
|
||||
pitch,
|
||||
};
|
||||
|
||||
const result = await enqueueAudioGeneration(selectedModel, settings);
|
||||
const result = await enqueueAudioGeneration(selectedModel, settings, generateAudio);
|
||||
|
||||
if (!result.success) {
|
||||
if (result.errorCode === 402) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { toast } from "@storyteller/ui-toaster";
|
||||
import { PopoverMenu, PopoverItem } from "@storyteller/ui-popover";
|
||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
import { GenerateIconButton } from "@storyteller/ui-button";
|
||||
import { GenerateImage, GenerateImageRequest } from "@storyteller/tauri-api";
|
||||
import { GenerateImage, GenerateImageRequest, commandErrorMessage } from "@storyteller/tauri-api";
|
||||
import { ChevronDownIcon, ChevronUpIcon, MaximizeIcon } from "lucide-react";
|
||||
import { DynamicIcon } from "@storyteller/icons";
|
||||
import { ImageModel } from "@storyteller/model-list";
|
||||
@@ -429,7 +429,7 @@ export const PromptBoxImage = ({
|
||||
await onEnqueuePressed?.(prompt, generationCount, subscriberId);
|
||||
} catch (err) {
|
||||
console.error("PromptBoxImage - enqueue failed", err);
|
||||
toast.error("Failed to start image generation. Please try again.");
|
||||
toast.error(commandErrorMessage(err, "Failed to start image generation. Please try again."));
|
||||
} finally {
|
||||
setIsEnqueueing(false);
|
||||
}
|
||||
|
||||
@@ -5,12 +5,14 @@ import { PopoverMenu, PopoverItem } from "@storyteller/ui-popover";
|
||||
import { SliderV2 } from "@storyteller/ui-sliderv2";
|
||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
import { ToggleButton, GenerateIconButton } from "@storyteller/ui-button";
|
||||
import { GenerateVideo, GenerateVideoRequest } from "@storyteller/tauri-api";
|
||||
import { GenerateVideo, GenerateVideoRequest, commandErrorMessage } from "@storyteller/tauri-api";
|
||||
import { AudioLinesIcon, ChevronDownIcon, ChevronUpIcon, ClockIcon, InfoIcon } from "lucide-react";
|
||||
import { DynamicIcon } from "@storyteller/icons";
|
||||
import { arrayMove } from "@dnd-kit/sortable";
|
||||
import {
|
||||
CommonResolution,
|
||||
resolveVideoDuration,
|
||||
videoDurationRange,
|
||||
videoResolutionValue,
|
||||
effectivePromptMaxLength,
|
||||
SizeIconOption,
|
||||
SizeOption,
|
||||
@@ -67,27 +69,6 @@ type GROK_ASPECT_RATIO = "landscape" | "portrait" | "square";
|
||||
|
||||
const EMPTY_CHARACTERS: StoredCharacter[] = [];
|
||||
|
||||
// The video store keeps resolution as a legacy display string ("480p" / "720p"
|
||||
// / "1080p") taken from the model's `resolutionOptions`. The generate request,
|
||||
// however, needs the `CommonResolution` enum. Map here so the user's resolution
|
||||
// choice is actually sent: omitting it makes the backend fall back to the
|
||||
// model's default resolution (and bill for it), which is what made the charge
|
||||
// disagree with the cost preview — the preview reads the same store value and
|
||||
// converts it correctly. Snake_case forms are accepted too for robustness.
|
||||
const RESOLUTION_STRING_TO_COMMON: Record<string, CommonResolution> = {
|
||||
"480p": CommonResolution.FourEightyP,
|
||||
"720p": CommonResolution.SevenTwentyP,
|
||||
"1080p": CommonResolution.TenEightyP,
|
||||
half_k: CommonResolution.HalfK,
|
||||
one_k: CommonResolution.OneK,
|
||||
two_k: CommonResolution.TwoK,
|
||||
three_k: CommonResolution.ThreeK,
|
||||
four_k: CommonResolution.FourK,
|
||||
four_eighty_p: CommonResolution.FourEightyP,
|
||||
seven_twenty_p: CommonResolution.SevenTwentyP,
|
||||
ten_eighty_p: CommonResolution.TenEightyP,
|
||||
};
|
||||
|
||||
const DEFAULT_RESOLUTIONS: SizeOption[] = [
|
||||
{
|
||||
tauriValue: "720p",
|
||||
@@ -160,6 +141,8 @@ export const PromptBoxVideo = ({
|
||||
const aspectRatio = usePromptVideoStore((s) => s.aspectRatio);
|
||||
const setAspectRatio = usePromptVideoStore((s) => s.setAspectRatio);
|
||||
const duration = usePromptVideoStore((s) => s.duration);
|
||||
const bitrate = usePromptVideoStore((s) => s.bitrate);
|
||||
const setBitrate = usePromptVideoStore((s) => s.setBitrate);
|
||||
const setDuration = usePromptVideoStore((s) => s.setDuration);
|
||||
const inputMode = usePromptVideoStore((s) => s.inputMode);
|
||||
const setInputMode = usePromptVideoStore((s) => s.setInputMode);
|
||||
@@ -360,22 +343,31 @@ export const PromptBoxVideo = ({
|
||||
);
|
||||
};
|
||||
|
||||
// Sync duration with model default when switching models.
|
||||
// Read duration from the store directly to avoid stale closure issues
|
||||
// when the model and duration are updated together (e.g. during recreate).
|
||||
// Follow the web app's range and sparse-option behavior, including the
|
||||
// lower duration cap some models apply in image-reference mode.
|
||||
useEffect(() => {
|
||||
const currentDuration = usePromptVideoStore.getState().duration;
|
||||
if (selectedModel?.durationOptions && selectedModel.defaultDuration) {
|
||||
if (
|
||||
currentDuration === null ||
|
||||
!selectedModel.durationOptions.includes(currentDuration)
|
||||
) {
|
||||
setDuration(selectedModel.defaultDuration);
|
||||
}
|
||||
} else if (currentDuration !== null) {
|
||||
setDuration(null);
|
||||
const current = usePromptVideoStore.getState().duration;
|
||||
const next = selectedModel
|
||||
? resolveVideoDuration(selectedModel, current, inputMode === "reference")
|
||||
: null;
|
||||
if (next !== current) setDuration(next);
|
||||
}, [selectedModel, inputMode, setDuration]);
|
||||
|
||||
useEffect(() => {
|
||||
const current = usePromptVideoStore.getState().bitrate;
|
||||
const options = selectedModel?.bitrateOptions;
|
||||
if (!current || !options?.includes(current)) {
|
||||
setBitrate(selectedModel?.defaultBitrate ?? options?.[0] ?? null);
|
||||
}
|
||||
}, [selectedModel]);
|
||||
}, [selectedModel, setBitrate]);
|
||||
|
||||
useEffect(() => {
|
||||
const current = usePromptVideoStore.getState().aspectRatio;
|
||||
if (selectedModel?.sizeOptions.some((option) => option.textLabel === current)) return;
|
||||
const option = selectedModel?.sizeOptions.find((option) => option.tauriValue === selectedModel.defaultAspectRatio)
|
||||
?? selectedModel?.sizeOptions[0];
|
||||
if (option) setAspectRatio(option.textLabel);
|
||||
}, [selectedModel, setAspectRatio]);
|
||||
|
||||
// Sync resolution with model default when switching models.
|
||||
// Read from store directly to avoid stale closure (same as duration above).
|
||||
@@ -414,26 +406,14 @@ export const PromptBoxVideo = ({
|
||||
}
|
||||
}, [selectedModel]);
|
||||
|
||||
const durationRange = selectedModel?.durationOptions?.length
|
||||
? {
|
||||
min: selectedModel.durationOptions[0]!,
|
||||
max: selectedModel.durationOptions[
|
||||
selectedModel.durationOptions.length - 1
|
||||
]!,
|
||||
}
|
||||
const durationRange = selectedModel
|
||||
? videoDurationRange(selectedModel, inputMode === "reference")
|
||||
: null;
|
||||
const effectiveDuration = duration ?? selectedModel?.defaultDuration ?? 5;
|
||||
const [localDuration, setLocalDuration] = useState(effectiveDuration);
|
||||
const durationTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
useEffect(() => {
|
||||
clearTimeout(durationTimerRef.current);
|
||||
setLocalDuration(effectiveDuration);
|
||||
return () => clearTimeout(durationTimerRef.current);
|
||||
}, [effectiveDuration]);
|
||||
const handleDurationSlide = (v: number) => {
|
||||
setLocalDuration(v);
|
||||
clearTimeout(durationTimerRef.current);
|
||||
durationTimerRef.current = setTimeout(() => setDuration(v), 300);
|
||||
const effectiveDuration = selectedModel
|
||||
? resolveVideoDuration(selectedModel, duration, inputMode === "reference") ?? selectedModel.defaultDuration ?? 5
|
||||
: 5;
|
||||
const handleDurationSlide = (value: number) => {
|
||||
if (selectedModel) setDuration(resolveVideoDuration(selectedModel, value, inputMode === "reference"));
|
||||
};
|
||||
|
||||
const resolutionPickerOptions: PopoverItem[] | null =
|
||||
@@ -1159,19 +1139,18 @@ export const PromptBoxVideo = ({
|
||||
}
|
||||
|
||||
// Pass duration if model supports it
|
||||
if (selectedModel.durationOptions && duration !== null) {
|
||||
request.duration_seconds = duration;
|
||||
if (durationRange || selectedModel.defaultDuration != null) {
|
||||
request.duration_seconds = effectiveDuration;
|
||||
}
|
||||
if (selectedModel.bitrateOptions?.length) {
|
||||
request.bitrate = bitrate ?? selectedModel.defaultBitrate ?? selectedModel.bitrateOptions[0];
|
||||
}
|
||||
|
||||
// Pass the chosen resolution when the model exposes a resolution picker.
|
||||
// Guarded on `resolutionOptions` so a stale store value (left over from a
|
||||
// model that did support resolution) isn't sent for one that doesn't.
|
||||
if (selectedModel.resolutionOptions?.length) {
|
||||
const mappedResolution =
|
||||
RESOLUTION_STRING_TO_COMMON[resolution as string];
|
||||
if (mappedResolution) {
|
||||
request.resolution = mappedResolution;
|
||||
}
|
||||
request.resolution = videoResolutionValue(resolution);
|
||||
}
|
||||
|
||||
switch (selectedModel?.tauriId) {
|
||||
@@ -1195,7 +1174,8 @@ export const PromptBoxVideo = ({
|
||||
request.aspect_ratio =
|
||||
selectedOption.tauriValue as typeof request.aspect_ratio;
|
||||
} else {
|
||||
const maybeDefault = selectedModel.sizeOptions[0];
|
||||
const maybeDefault = selectedModel.sizeOptions.find((option) => option.tauriValue === selectedModel.defaultAspectRatio)
|
||||
?? selectedModel.sizeOptions[0];
|
||||
if (!!maybeDefault) {
|
||||
request.aspect_ratio =
|
||||
maybeDefault.tauriValue as typeof request.aspect_ratio;
|
||||
@@ -1224,14 +1204,14 @@ export const PromptBoxVideo = ({
|
||||
enqueuePromises.push(GenerateVideo(buildRequest(subscriberId)));
|
||||
}
|
||||
|
||||
try {
|
||||
await Promise.all(enqueuePromises);
|
||||
} catch (err) {
|
||||
console.error("PromptBoxVideo - enqueue failed", err);
|
||||
toast.error("Failed to start video generation. Please try again.");
|
||||
const outcomes = await Promise.allSettled(enqueuePromises);
|
||||
const acceptedIds = subscriberIds.filter((_, index) => outcomes[index].status === "fulfilled");
|
||||
const failure = outcomes.find((outcome) => outcome.status === "rejected");
|
||||
if (failure?.status === "rejected") {
|
||||
console.error("PromptBoxVideo - enqueue failed", failure.reason);
|
||||
toast.error(commandErrorMessage(failure.reason, "Failed to start video generation. Please try again."));
|
||||
}
|
||||
|
||||
onEnqueuePressed?.(prompt, subscriberIds);
|
||||
if (acceptedIds.length) onEnqueuePressed?.(prompt, acceptedIds);
|
||||
|
||||
setIsEnqueueing(false);
|
||||
};
|
||||
@@ -1459,7 +1439,22 @@ export const PromptBoxVideo = ({
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{durationRange && (
|
||||
{!!selectedModel?.bitrateOptions?.length && (
|
||||
<Tooltip content="Bitrate" position="top" className="z-50">
|
||||
<PopoverMenu
|
||||
panelTitle="Bitrate"
|
||||
mode="toggle"
|
||||
items={selectedModel.bitrateOptions.map((value) => ({
|
||||
label: value === "normal" ? "Normal" : value === "high" ? "High" : value,
|
||||
action: value,
|
||||
selected: value === bitrate,
|
||||
}))}
|
||||
onSelect={(item) => setBitrate(item.action ?? item.label)}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{durationRange && durationRange.max > durationRange.min && (
|
||||
<Tooltip content="Duration" position="top" className="z-50">
|
||||
<PopoverMenu
|
||||
mode="default"
|
||||
@@ -1475,7 +1470,7 @@ export const PromptBoxVideo = ({
|
||||
<SliderV2
|
||||
min={durationRange.min}
|
||||
max={durationRange.max}
|
||||
value={localDuration}
|
||||
value={effectiveDuration}
|
||||
onChange={handleDurationSlide}
|
||||
step={1}
|
||||
suffix="s"
|
||||
@@ -1483,7 +1478,7 @@ export const PromptBoxVideo = ({
|
||||
/>
|
||||
</div>
|
||||
<span className="min-w-6 shrink-0 text-sm font-medium tabular-nums text-base-fg">
|
||||
{localDuration}s
|
||||
{effectiveDuration}s
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1.5 flex justify-between px-0.5 text-[11px] text-base-fg/40">
|
||||
|
||||
@@ -50,7 +50,7 @@ export const AspectRatioPicker = ({
|
||||
useAspectRatio === CommonAspectRatio.Auto4k;
|
||||
|
||||
const handleSelectAdapter = (item: PopoverItem) => {
|
||||
const ratio = popOverLabelToAspectRatio(item.label, model);
|
||||
const ratio = (item.action ?? item.label) as CommonAspectRatio;
|
||||
handleCommonAspectRatioSelect(ratio);
|
||||
};
|
||||
|
||||
@@ -68,6 +68,7 @@ export const AspectRatioPicker = ({
|
||||
model.aspectRatios?.forEach((ratio: CommonAspectRatio) => {
|
||||
aspectRatioList.push({
|
||||
label: getAspectRatioTextLabel(ratio),
|
||||
action: ratio,
|
||||
selected: useAspectRatio === ratio,
|
||||
icon: isAutoAspectRatio(ratio) ? (
|
||||
<AutoIcon />
|
||||
@@ -152,56 +153,6 @@ const getAspectRatioTextLabel = (aspectRatio: CommonAspectRatio): string => {
|
||||
return "Tall";
|
||||
|
||||
default:
|
||||
console.error("Unknown aspect ratio:", aspectRatio);
|
||||
return "Square"; // Fail open-ish
|
||||
return aspectRatio;
|
||||
}
|
||||
};
|
||||
|
||||
// Note: We only need this to deal with turning PopOverItems back into typesafe aspect ratios
|
||||
const popOverLabelToAspectRatio = (
|
||||
label: string,
|
||||
model: ImageModel,
|
||||
): CommonAspectRatio => {
|
||||
switch (label) {
|
||||
case "Auto":
|
||||
return CommonAspectRatio.Auto;
|
||||
case "Square":
|
||||
return CommonAspectRatio.Square;
|
||||
case "Wide":
|
||||
return CommonAspectRatio.Wide;
|
||||
case "Tall":
|
||||
return CommonAspectRatio.Tall;
|
||||
case "5:4 (Wide)":
|
||||
return CommonAspectRatio.WideFiveByFour;
|
||||
case "4:3 (Wide)":
|
||||
return CommonAspectRatio.WideFourByThree;
|
||||
case "3:2 (Wide)":
|
||||
return CommonAspectRatio.WideThreeByTwo;
|
||||
case "16:9 (Wide)":
|
||||
return CommonAspectRatio.WideSixteenByNine;
|
||||
case "21:9 (Wide)":
|
||||
return CommonAspectRatio.WideTwentyOneByNine;
|
||||
case "4:5 (Tall)":
|
||||
return CommonAspectRatio.TallFourByFive;
|
||||
case "3:4 (Tall)":
|
||||
return CommonAspectRatio.TallThreeByFour;
|
||||
case "2:3 (Tall)":
|
||||
return CommonAspectRatio.TallTwoByThree;
|
||||
case "9:16 (Tall)":
|
||||
return CommonAspectRatio.TallNineBySixteen;
|
||||
case "9:21 (Tall)":
|
||||
return CommonAspectRatio.TallNineByTwentyOne;
|
||||
case "Auto (2K)":
|
||||
return CommonAspectRatio.Auto2k;
|
||||
case "Auto (3K)":
|
||||
return CommonAspectRatio.Auto3k;
|
||||
case "Auto (4K)":
|
||||
return CommonAspectRatio.Auto4k;
|
||||
case "Square (HD)":
|
||||
return CommonAspectRatio.SquareHd;
|
||||
|
||||
}
|
||||
console.error("Unknown aspect ratio label:", label, "for model:", model.id);
|
||||
// If we can't find it, return the model's default aspect ratio or Square as fallback
|
||||
return model.defaultAspectRatio || CommonAspectRatio.Square;
|
||||
};
|
||||
|
||||
@@ -15,10 +15,6 @@ const QUALITY_LABELS: Record<CommonQuality, string> = {
|
||||
[CommonQuality.Low]: "Low",
|
||||
};
|
||||
|
||||
const LABEL_TO_QUALITY: Record<string, CommonQuality> = Object.fromEntries(
|
||||
Object.entries(QUALITY_LABELS).map(([k, v]) => [v, k as CommonQuality]),
|
||||
);
|
||||
|
||||
/**
|
||||
* Stateless picker for image generation "quality" (used by OpenAI image
|
||||
* models — gpt_image_1, gpt_image_1p5, gpt_image_2). Models that don't
|
||||
@@ -32,12 +28,13 @@ export const QualityPicker = ({
|
||||
const useQuality = currentQuality ?? model.defaultQuality ?? undefined;
|
||||
|
||||
const handleSelectAdapter = (item: PopoverItem) => {
|
||||
const quality = LABEL_TO_QUALITY[item.label];
|
||||
const quality = (item.action ?? item.label) as CommonQuality;
|
||||
if (quality) handleCommonQualitySelect(quality);
|
||||
};
|
||||
|
||||
const qualityList: PopoverItem[] = model.qualityOptions.map((q) => ({
|
||||
label: QUALITY_LABELS[q] ?? q,
|
||||
action: q,
|
||||
selected: useQuality === q,
|
||||
}));
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ export const ResolutionPicker = ({
|
||||
};
|
||||
|
||||
const handleSelectAdapter = (item: PopoverItem) => {
|
||||
const resolution = popOverLabelToResolution(item.label, model);
|
||||
const resolution = (item.action ?? item.label) as CommonResolution;
|
||||
handleCommonResolutionSelect(resolution);
|
||||
};
|
||||
|
||||
@@ -51,8 +51,8 @@ export const ResolutionPicker = ({
|
||||
model.resolutions?.forEach((resolution: CommonResolution) => {
|
||||
resolutionList.push({
|
||||
label: getResolutionTextLabel(resolution),
|
||||
action: resolution,
|
||||
selected: useResolution === resolution,
|
||||
description: `foo ${resolution}`,
|
||||
icon: (
|
||||
<DynamicIcon
|
||||
icon={getResolutionIcon(resolution)}
|
||||
@@ -125,35 +125,6 @@ const getResolutionTextLabel = (resolution: CommonResolution): string => {
|
||||
case CommonResolution.FourK:
|
||||
return "4K";
|
||||
default:
|
||||
console.error("Unknown resolution:", resolution);
|
||||
return "1K"; // Fail open-ish
|
||||
return resolution;
|
||||
}
|
||||
};
|
||||
|
||||
// Note: We only need this to deal with turning PopOverItems back into typesafe aspect ratios
|
||||
const popOverLabelToResolution = (
|
||||
label: string,
|
||||
model: ImageModel,
|
||||
): CommonResolution => {
|
||||
switch (label) {
|
||||
case "0.5K":
|
||||
return CommonResolution.HalfK;
|
||||
case "480p":
|
||||
return CommonResolution.FourEightyP;
|
||||
case "720p":
|
||||
return CommonResolution.SevenTwentyP;
|
||||
case "1K":
|
||||
return CommonResolution.OneK;
|
||||
case "1080p":
|
||||
return CommonResolution.TenEightyP;
|
||||
case "2K":
|
||||
return CommonResolution.TwoK;
|
||||
case "3K":
|
||||
return CommonResolution.ThreeK;
|
||||
case "4K":
|
||||
return CommonResolution.FourK;
|
||||
}
|
||||
console.error("Unknown resolution label:", label, "for model:", model.id);
|
||||
// If we can't find it, return the model's default resolution or 1K as fallback
|
||||
return model.defaultResolution || CommonResolution.OneK;
|
||||
};
|
||||
|
||||
@@ -164,6 +164,7 @@ interface PromptVideoStore {
|
||||
referenceAudios: RefAudio[];
|
||||
generateWithSound: boolean;
|
||||
duration: number | null;
|
||||
bitrate: string | null;
|
||||
inputMode: VideoInputMode;
|
||||
generationCount: number;
|
||||
setPrompt: (prompt: string) => void;
|
||||
@@ -176,6 +177,7 @@ interface PromptVideoStore {
|
||||
setReferenceAudios: (audios: RefAudio[]) => void;
|
||||
setGenerateWithSound: (value: boolean) => void;
|
||||
setDuration: (duration: number | null) => void;
|
||||
setBitrate: (bitrate: string | null) => void;
|
||||
setInputMode: (mode: VideoInputMode) => void;
|
||||
setGenerationCount: (count: number) => void;
|
||||
}
|
||||
@@ -191,6 +193,7 @@ export const usePromptVideoStore = create<PromptVideoStore>()((set) => ({
|
||||
referenceAudios: [],
|
||||
generateWithSound: true,
|
||||
duration: null,
|
||||
bitrate: null,
|
||||
inputMode: "reference",
|
||||
generationCount: 1,
|
||||
setPrompt: (prompt) => set({ prompt }),
|
||||
@@ -203,6 +206,7 @@ export const usePromptVideoStore = create<PromptVideoStore>()((set) => ({
|
||||
setReferenceAudios: (referenceAudios) => set({ referenceAudios }),
|
||||
setGenerateWithSound: (generateWithSound) => set({ generateWithSound }),
|
||||
setDuration: (duration) => set({ duration }),
|
||||
setBitrate: (bitrate) => set({ bitrate }),
|
||||
setInputMode: (inputMode) => set({ inputMode }),
|
||||
setGenerationCount: (generationCount) => set({ generationCount }),
|
||||
}));
|
||||
|
||||
+41
-8
@@ -1,12 +1,45 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { GenerationProvider } from "@storyteller/common";
|
||||
import ProviderSetupModal from "./provider-setup-modal";
|
||||
|
||||
import FrontendProviderSetupModal from './provider-setup-modal';
|
||||
const mocks = vi.hoisted(() => ({
|
||||
onLogin: (_event: { provider: GenerationProvider }) => {},
|
||||
invoke: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
describe('FrontendProviderSetupModal', () => {
|
||||
|
||||
it('should render successfully', () => {
|
||||
const { baseElement } = render(<FrontendProviderSetupModal />);
|
||||
expect(baseElement).toBeTruthy();
|
||||
vi.mock("@storyteller/tauri-events", () => ({
|
||||
useShowProviderLoginModalEvent: (callback: typeof mocks.onLogin) => {
|
||||
mocks.onLogin = callback;
|
||||
},
|
||||
}));
|
||||
vi.mock("@tauri-apps/api/core", () => ({ invoke: mocks.invoke }));
|
||||
vi.mock("@storyteller/ui-modal", () => ({
|
||||
Modal: ({ isOpen, children }: { isOpen: boolean; children: React.ReactNode }) =>
|
||||
isOpen ? <div role="dialog">{children}</div> : null,
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
mocks.invoke.mockClear();
|
||||
});
|
||||
|
||||
it.each([GenerationProvider.Sora, GenerationProvider.WorldLabs])(
|
||||
"ignores login events from the retired %s integration",
|
||||
async (provider) => {
|
||||
render(<ProviderSetupModal />);
|
||||
await act(async () => mocks.onLogin({ provider }));
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
expect(mocks.invoke).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("still opens login for a supported direct provider", async () => {
|
||||
render(<ProviderSetupModal />);
|
||||
await act(async () => mocks.onLogin({ provider: GenerationProvider.Midjourney }));
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Set up Midjourney" }));
|
||||
});
|
||||
|
||||
expect(mocks.invoke).toHaveBeenCalledWith("midjourney_open_login_command");
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -15,6 +15,13 @@ export function ProviderSetupModal({
|
||||
const [provider, setProvider] = useState<GenerationProvider>(GenerationProvider.Artcraft);
|
||||
|
||||
useShowProviderLoginModalEvent(async (event) => {
|
||||
// Retained native handlers can still emit these events for old tasks.
|
||||
if (
|
||||
event.provider === GenerationProvider.Sora ||
|
||||
event.provider === GenerationProvider.WorldLabs
|
||||
) {
|
||||
return;
|
||||
}
|
||||
console.log("Show provider login modal event received from Tauri:", event);
|
||||
setProvider(event.provider);
|
||||
setShowModal(true);
|
||||
@@ -28,7 +35,6 @@ export function ProviderSetupModal({
|
||||
let modalDescription;
|
||||
switch (provider) {
|
||||
case GenerationProvider.Grok:
|
||||
case GenerationProvider.Sora:
|
||||
modalDescription = `You can add your ${serviceProviderName} account to ArtCraft by simply logging in. Use can then use it directly within Artcraft. You can add all of your AI accounts to Artcraft to use them all in one place and build the ultimate AI art tool.`;
|
||||
break;
|
||||
default:
|
||||
@@ -40,18 +46,12 @@ export function ProviderSetupModal({
|
||||
|
||||
const buttonOnClick = async () => {
|
||||
switch (provider) {
|
||||
case GenerationProvider.WorldLabs:
|
||||
await invoke("worldlabs_open_login_command");
|
||||
break;
|
||||
case GenerationProvider.Grok:
|
||||
await invoke("grok_open_login_command");
|
||||
break;
|
||||
case GenerationProvider.Midjourney:
|
||||
await invoke("midjourney_open_login_command");
|
||||
break;
|
||||
case GenerationProvider.Sora:
|
||||
await invoke("open_sora_login_command"); // TODO: Rename in Tauri
|
||||
break;
|
||||
case GenerationProvider.Fal:
|
||||
break; // TODO: None yet.
|
||||
default:
|
||||
@@ -119,10 +119,6 @@ function getServiceProviderName(provider: GenerationProvider) : string {
|
||||
return "Fal";
|
||||
case GenerationProvider.Midjourney:
|
||||
return "Midjourney";
|
||||
case GenerationProvider.Sora:
|
||||
return "Sora";
|
||||
case GenerationProvider.WorldLabs:
|
||||
return "WorldLabs";
|
||||
case GenerationProvider.Higgsfield:
|
||||
return "Higgsfield";
|
||||
case GenerationProvider.Krea:
|
||||
|
||||
+7
-100
@@ -1,8 +1,6 @@
|
||||
import { SoraAccountBlock } from "./SoraAccountBlock";
|
||||
import { ArtcraftAccountBlock } from "./ArtcraftAccountBlock";
|
||||
import { MidjourneyAccountBlock } from "./MidjourneyAccountBlock";
|
||||
import { GrokAccountBlock } from "./GrokAccountBlock";
|
||||
import { WorldLabsAccountBlock } from "./WorldLabsAccountBlock";
|
||||
|
||||
interface AccountSettingsPaneProps {
|
||||
globalAccountLogoutCallback: () => void;
|
||||
@@ -12,103 +10,12 @@ export const AccountSettingsPane = ({
|
||||
globalAccountLogoutCallback,
|
||||
}: AccountSettingsPaneProps) => {
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-4 text-base-fg">
|
||||
{/*
|
||||
<div className="flex justify-between">
|
||||
<span>ArtCraft Account:</span>
|
||||
<span className="text-white/80">{accountInfo.username}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>ArtCraft Subscription:</span>
|
||||
<span className="text-white/80">{accountInfo.subscription}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Credits Remaining:</span>
|
||||
<span className="text-white/80">{accountInfo.credits}</span>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md p-4 text-sm dark:bg-white/5">
|
||||
<p className="text-white/60">
|
||||
Note: You can optionally log into other accounts and use your
|
||||
credits at those providers. Some features are only available via 3rd
|
||||
party accounts, such as OpenAI / Sora GPT 4.0 Images. For other
|
||||
features, ArtCraft credits are consumed unless you log into your
|
||||
third party account.
|
||||
</p>
|
||||
</div>
|
||||
*/}
|
||||
|
||||
<ArtcraftAccountBlock
|
||||
globalAccountLogoutCallback={globalAccountLogoutCallback}
|
||||
/>
|
||||
<GrokAccountBlock />
|
||||
<MidjourneyAccountBlock />
|
||||
<SoraAccountBlock />
|
||||
<WorldLabsAccountBlock />
|
||||
{/*
|
||||
<OpenAIApiKeyBlock/>
|
||||
<hr />
|
||||
|
||||
<h2>Coming Soon...</h2>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<span>Google / Veo Account:</span>
|
||||
<Button variant="secondary" disabled className="h-[30px]">
|
||||
Connect
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<span>Midjourney Account:</span>
|
||||
<Button variant="secondary" disabled className="h-[30px]">
|
||||
Connect
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="kling-api-key" className="mb-2 block">
|
||||
Kling API Key (optional)
|
||||
</label>
|
||||
<Input
|
||||
id="kling-api-key"
|
||||
type="password"
|
||||
value={klingApiKey}
|
||||
onChange={(e) => setKlingApiKey((e.target as any).value)}
|
||||
placeholder="Enter API Key"
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="pal-api-key" className="mb-2 block">
|
||||
Fal API Key (optional)
|
||||
</label>
|
||||
<Input
|
||||
id="pal-api-key"
|
||||
type="password"
|
||||
value={palApiKey}
|
||||
onChange={(e) => setPalApiKey((e.target as any).value)}
|
||||
placeholder="Enter API Key"
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="pal-api-key" className="mb-2 block">
|
||||
Replicate API Key (optional)
|
||||
</label>
|
||||
<Input
|
||||
id="pal-api-key"
|
||||
type="password"
|
||||
value={palApiKey}
|
||||
onChange={(e) => setPalApiKey((e.target as any).value)}
|
||||
placeholder="Enter API Key"
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
*/}
|
||||
</div>
|
||||
</>
|
||||
<div className="space-y-4 text-base-fg">
|
||||
<ArtcraftAccountBlock
|
||||
globalAccountLogoutCallback={globalAccountLogoutCallback}
|
||||
/>
|
||||
<GrokAccountBlock />
|
||||
<MidjourneyAccountBlock />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
-184
@@ -1,184 +0,0 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
// ── Types matching the Rust ProviderCredentialKey enum ──
|
||||
|
||||
type ProviderCredentialKey =
|
||||
| "fal_api_key"
|
||||
| "replicate_api_key"
|
||||
| "grok_web_login"
|
||||
| "higgsfield_web_login"
|
||||
| "midjourney_login"
|
||||
| "runway_web_login";
|
||||
|
||||
interface ProviderCredentialDetails {
|
||||
maybe_key_start: string | null;
|
||||
maybe_full_key: string | null;
|
||||
maybe_email_address: string | null;
|
||||
maybe_username: string | null;
|
||||
}
|
||||
|
||||
interface ProviderListEntry {
|
||||
provider_credential: ProviderCredentialKey;
|
||||
credential_type: string;
|
||||
has_credentials: boolean;
|
||||
maybe_details: ProviderCredentialDetails | null;
|
||||
}
|
||||
|
||||
interface ProviderListResponse {
|
||||
providers: ProviderListEntry[];
|
||||
}
|
||||
|
||||
// ── API key input row ──
|
||||
|
||||
interface ApiKeyRowProps {
|
||||
label: string;
|
||||
credentialKey: ProviderCredentialKey;
|
||||
initialRedactedValue: string;
|
||||
initialFullKey: string;
|
||||
}
|
||||
|
||||
const ApiKeyRow = ({
|
||||
label,
|
||||
credentialKey,
|
||||
initialRedactedValue,
|
||||
initialFullKey,
|
||||
}: ApiKeyRowProps) => {
|
||||
// The actual key value (editable).
|
||||
const [fullKey, setFullKey] = useState(initialFullKey);
|
||||
// The redacted display value.
|
||||
const [redacted, setRedacted] = useState(initialRedactedValue);
|
||||
// Whether the input is focused (show real value vs redacted).
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
// Track if user changed something since last save.
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value;
|
||||
setFullKey(newValue);
|
||||
setIsDirty(true);
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
setIsFocused(true);
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
setIsFocused(false);
|
||||
|
||||
if (!isDirty) return;
|
||||
setIsDirty(false);
|
||||
|
||||
const trimmed = fullKey.trim();
|
||||
|
||||
if (trimmed === "") {
|
||||
invoke("provider_clear_command", {
|
||||
request: { provider_credential: credentialKey },
|
||||
}).then(() => {
|
||||
setFullKey("");
|
||||
setRedacted("");
|
||||
console.log(`[ProviderCredentials] Cleared ${credentialKey}`);
|
||||
}).catch((e) => {
|
||||
console.error(`[ProviderCredentials] Error clearing ${credentialKey}:`, e);
|
||||
});
|
||||
} else {
|
||||
invoke("provider_set_api_key_command", {
|
||||
request: { provider_credential: credentialKey, api_key: trimmed },
|
||||
}).then(() => {
|
||||
setFullKey(trimmed);
|
||||
setRedacted(
|
||||
trimmed.length > 6
|
||||
? trimmed.substring(0, 6) + "********"
|
||||
: "********"
|
||||
);
|
||||
console.log(`[ProviderCredentials] Saved ${credentialKey}`);
|
||||
}).catch((e) => {
|
||||
console.error(`[ProviderCredentials] Error saving ${credentialKey}:`, e);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
(e.target as HTMLInputElement).blur();
|
||||
}
|
||||
};
|
||||
|
||||
const hasKey = isFocused ? fullKey.length > 0 : redacted.length > 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="mb-1 block text-sm">{label}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={isFocused ? fullKey : (redacted || "")}
|
||||
onChange={handleChange}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Enter API Key"
|
||||
className="h-10 w-full rounded-lg px-3 py-2.5 outline-none bg-ui-panel text-base-fg placeholder-base-fg/50 border border-ui-panel-border transition-all duration-150 ease-in-out hover:border-primary/60 focus:border-primary"
|
||||
readOnly={!isFocused}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Main block ──
|
||||
|
||||
export const ProviderCredentialsGroupBlock = () => {
|
||||
const [providers, setProviders] = useState<ProviderListEntry[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchProviders = async () => {
|
||||
try {
|
||||
const result = (await invoke("provider_list_command")) as {
|
||||
payload: ProviderListResponse;
|
||||
};
|
||||
setProviders(result.payload.providers);
|
||||
} catch (e) {
|
||||
console.error("[ProviderCredentials] Error fetching provider list:", e);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
fetchProviders();
|
||||
}, []);
|
||||
|
||||
const findProvider = (key: ProviderCredentialKey) =>
|
||||
providers.find((p) => p.provider_credential === key);
|
||||
|
||||
if (isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const falProvider = findProvider("fal_api_key");
|
||||
const replicateProvider = findProvider("replicate_api_key");
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-base-fg/60">API Keys</h3>
|
||||
<ApiKeyRow
|
||||
label="FAL API Key (optional)"
|
||||
credentialKey="fal_api_key"
|
||||
initialRedactedValue={
|
||||
falProvider?.maybe_details?.maybe_key_start ?? ""
|
||||
}
|
||||
initialFullKey={
|
||||
falProvider?.maybe_details?.maybe_full_key ?? ""
|
||||
}
|
||||
/>
|
||||
<ApiKeyRow
|
||||
label="Replicate API Key (optional)"
|
||||
credentialKey="replicate_api_key"
|
||||
initialRedactedValue={
|
||||
replicateProvider?.maybe_details?.maybe_key_start ?? ""
|
||||
}
|
||||
initialFullKey={
|
||||
replicateProvider?.maybe_details?.maybe_full_key ?? ""
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
-91
@@ -1,91 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { LoaderCircleIcon } from "lucide-react";
|
||||
import {
|
||||
SoraGetCredentialInfo,
|
||||
LogoutSoraSession,
|
||||
SoraGetCredentialInfoSuccess,
|
||||
} from "@storyteller/tauri-api";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { RefreshAccountStateEvent, useRefreshAccountStateEvent } from "@storyteller/tauri-events";
|
||||
|
||||
export const SoraAccountBlock = () => {
|
||||
const [soraSession, setSoraSession] = useState<SoraGetCredentialInfoSuccess| undefined>(undefined);
|
||||
const [isCheckingSoraSession, setIsCheckingSoraSession] = useState(false);
|
||||
|
||||
const fetchSession = async () => {
|
||||
setIsCheckingSoraSession(true);
|
||||
try {
|
||||
const result = await SoraGetCredentialInfo();
|
||||
setSoraSession(result);
|
||||
} catch (e) {
|
||||
console.error("Error fetching Sora session", e);
|
||||
setSoraSession(undefined);
|
||||
} finally {
|
||||
setIsCheckingSoraSession(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchSession();
|
||||
}, []);
|
||||
|
||||
useRefreshAccountStateEvent(async (event: RefreshAccountStateEvent) => {
|
||||
fetchSession();
|
||||
});
|
||||
|
||||
const clearState = async() => {
|
||||
try {
|
||||
await LogoutSoraSession();
|
||||
//await invoke("sora_clear_credentials_command"); // TODO: Wrong command name
|
||||
} catch (e) {
|
||||
console.error("Error clearing Sora credentials", e);
|
||||
}
|
||||
}
|
||||
|
||||
const openLogin = async() => {
|
||||
try {
|
||||
await invoke("open_sora_login_command");
|
||||
} catch (e) {
|
||||
console.error("Error opening Sora login", e);
|
||||
}
|
||||
}
|
||||
|
||||
const handleSoraButton = async () => {
|
||||
if (soraSession?.payload?.can_clear_state) {
|
||||
await clearState();
|
||||
setSoraSession(undefined);
|
||||
} else {
|
||||
await openLogin();
|
||||
}
|
||||
};
|
||||
|
||||
return(
|
||||
<div className="flex justify-between items-center">
|
||||
<span>Sora (OpenAI) Account:</span>
|
||||
<pre>{soraSession?.payload?.maybe_email || "Not logged in"}</pre>
|
||||
<Button
|
||||
variant={
|
||||
soraSession?.payload?.can_clear_state && !isCheckingSoraSession
|
||||
? "destructive"
|
||||
: soraSession?.payload?.can_clear_state
|
||||
? "primary"
|
||||
: "secondary"
|
||||
}
|
||||
className="h-[30px]"
|
||||
onClick={handleSoraButton}
|
||||
disabled={isCheckingSoraSession}
|
||||
>
|
||||
{isCheckingSoraSession ? (
|
||||
<LoaderCircleIcon
|
||||
|
||||
className="animate-spin text-sm" />
|
||||
) : soraSession?.payload?.can_clear_state ? (
|
||||
"Disconnect"
|
||||
) : (
|
||||
"Connect"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
-89
@@ -1,89 +0,0 @@
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useEffect, useState } from "react";
|
||||
import { LoaderCircleIcon } from "lucide-react";
|
||||
import { useRefreshAccountStateEvent } from "@storyteller/tauri-events";
|
||||
import { RefreshAccountStateEvent } from "@storyteller/tauri-events";
|
||||
import { WorldLabsGetCredentialInfo, WorldLabsGetCredentialInfoSuccess } from "@storyteller/tauri-api";
|
||||
|
||||
export const WorldLabsAccountBlock = () => {
|
||||
const [worldlabsSession, setWorldlabsSession] = useState<WorldLabsGetCredentialInfoSuccess| undefined>(undefined);
|
||||
const [isCheckingWorldlabsSession, setIsCheckingWorldlabsSession] = useState(false);
|
||||
|
||||
const fetchSession = async () => {
|
||||
setIsCheckingWorldlabsSession(true);
|
||||
try {
|
||||
const result = await WorldLabsGetCredentialInfo();
|
||||
setWorldlabsSession(result);
|
||||
} catch (e) {
|
||||
console.error("Error fetching WorldLabs session", e);
|
||||
setWorldlabsSession(undefined);
|
||||
} finally {
|
||||
setIsCheckingWorldlabsSession(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchSession();
|
||||
}, []);
|
||||
|
||||
useRefreshAccountStateEvent(async (event: RefreshAccountStateEvent) => {
|
||||
fetchSession();
|
||||
});
|
||||
|
||||
const clearState = async() => {
|
||||
try {
|
||||
await invoke("worldlabs_clear_credentials_command");
|
||||
} catch (e) {
|
||||
console.error("Error clearing WorldLabs credentials", e);
|
||||
}
|
||||
}
|
||||
|
||||
const openLogin = async() => {
|
||||
try {
|
||||
await invoke("worldlabs_open_login_command");
|
||||
} catch (e) {
|
||||
console.error("Error opening WorldLabs login", e);
|
||||
}
|
||||
}
|
||||
|
||||
const handleWorldlabsButton = async () => {
|
||||
if (worldlabsSession?.payload?.can_clear_state) {
|
||||
await clearState();
|
||||
setWorldlabsSession(undefined);
|
||||
} else {
|
||||
await openLogin();
|
||||
}
|
||||
};
|
||||
|
||||
console.log("worldlabs session", worldlabsSession);
|
||||
|
||||
return(
|
||||
<div className="flex justify-between items-center">
|
||||
<span>WorldLabs Account:</span>
|
||||
<pre>{worldlabsSession?.payload?.maybe_email || "Not logged in"}</pre>
|
||||
<Button
|
||||
variant={
|
||||
worldlabsSession?.payload?.can_clear_state && !isCheckingWorldlabsSession
|
||||
? "destructive"
|
||||
: worldlabsSession?.payload?.can_clear_state
|
||||
? "primary"
|
||||
: "secondary"
|
||||
}
|
||||
className="h-[30px]"
|
||||
onClick={handleWorldlabsButton}
|
||||
disabled={isCheckingWorldlabsSession}
|
||||
>
|
||||
{isCheckingWorldlabsSession ? (
|
||||
<LoaderCircleIcon
|
||||
|
||||
className="animate-spin text-sm" />
|
||||
) : worldlabsSession?.payload?.can_clear_state ? (
|
||||
"Disconnect"
|
||||
) : (
|
||||
"Connect"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { ProviderCredentialsGroupBlock } from "./AccountSettings/ProviderCredentialsGroupBlock";
|
||||
|
||||
export const ApiSettingsPane = () => {
|
||||
return (
|
||||
<div className="space-y-4 text-base-fg">
|
||||
<ProviderCredentialsGroupBlock />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Modal } from "@storyteller/ui-modal";
|
||||
import { useEffect, useState } from "react";
|
||||
import { CreditCardIcon, FlaskConicalIcon, InfoIcon, KeyIcon, KeyboardIcon, PaletteIcon, SettingsIcon, UserIcon, Volume2Icon } from "lucide-react";
|
||||
import { CreditCardIcon, FlaskConicalIcon, InfoIcon, KeyboardIcon, PaletteIcon, SettingsIcon, UserIcon, Volume2Icon } from "lucide-react";
|
||||
import { DynamicIcon } from "@storyteller/icons";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { KeybindsSettings } from "@storyteller/keybinds";
|
||||
@@ -12,7 +12,6 @@ import { ProviderPrioritySettingsPane } from "./panes/ProviderPrioritySettingsPa
|
||||
import { ExperimentalSettingsPane } from "./panes/ExperimentalSettingsPane";
|
||||
import { gtagEvent } from "@storyteller/google-analytics";
|
||||
import { BillingSettingsPane } from "./panes/BillingSettingsPane";
|
||||
import { ApiSettingsPane } from "./panes/ApiSettingsPane";
|
||||
import { AppearanceSettingsPane } from "./panes/AppearanceSettingsPane";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { useExperimentalStore } from "./experimental-store";
|
||||
@@ -31,7 +30,6 @@ type SettingsSection =
|
||||
| "appearance"
|
||||
| "keybinds"
|
||||
| "accounts"
|
||||
| "api_keys"
|
||||
| "alerts"
|
||||
| "about"
|
||||
| "provider_priority"
|
||||
@@ -70,7 +68,6 @@ export const SettingsModal = ({
|
||||
{ id: "general" as const, label: "General", icon: SettingsIcon },
|
||||
|
||||
{ id: "accounts" as const, label: "Accounts", icon: UserIcon },
|
||||
{ id: "api_keys" as const, label: "API Keys", icon: KeyIcon },
|
||||
{ id: "billing" as const, label: "Plan & Credits", icon: CreditCardIcon },
|
||||
|
||||
/* {
|
||||
@@ -105,8 +102,6 @@ export const SettingsModal = ({
|
||||
globalAccountLogoutCallback={globalAccountLogoutCallback}
|
||||
/>
|
||||
);
|
||||
case "api_keys":
|
||||
return <ApiSettingsPane />;
|
||||
case "about":
|
||||
return <AboutSettingsPane />;
|
||||
case "provider_priority":
|
||||
|
||||
@@ -25,3 +25,5 @@ export * from "./lib/lists/SplatModels.js";
|
||||
export * from "./lib/classes/Object3DModel.js";
|
||||
export * from "./lib/lists/Object3DModels.js";
|
||||
export * from "./lib/loader/buildModelsFromListing.js";
|
||||
|
||||
export * from "./lib/classes/videoOptions.js";
|
||||
|
||||
@@ -36,6 +36,13 @@ export class VideoModel extends Model {
|
||||
|
||||
// Default duration in seconds
|
||||
readonly defaultDuration?: number;
|
||||
readonly minDuration?: number;
|
||||
readonly maxDuration?: number;
|
||||
readonly maxDurationWithImageReferences?: number;
|
||||
readonly bitrateOptions?: string[];
|
||||
readonly defaultBitrate?: string;
|
||||
readonly defaultAspectRatio?: string;
|
||||
|
||||
|
||||
// Whether the model supports multi-image reference mode
|
||||
readonly supportsReferenceMode?: boolean;
|
||||
@@ -90,6 +97,13 @@ export class VideoModel extends Model {
|
||||
providers?: GenerationProvider[];
|
||||
durationOptions?: number[];
|
||||
defaultDuration?: number;
|
||||
minDuration?: number;
|
||||
maxDuration?: number;
|
||||
maxDurationWithImageReferences?: number;
|
||||
bitrateOptions?: string[];
|
||||
defaultBitrate?: string;
|
||||
defaultAspectRatio?: string;
|
||||
|
||||
supportsReferenceMode?: boolean;
|
||||
maxReferenceImages?: number;
|
||||
maxReferenceVideos?: number;
|
||||
@@ -111,6 +125,13 @@ export class VideoModel extends Model {
|
||||
this.generateWithSound = args.generateWithSound || false;
|
||||
this.durationOptions = args.durationOptions;
|
||||
this.defaultDuration = args.defaultDuration;
|
||||
this.minDuration = args.minDuration;
|
||||
this.maxDuration = args.maxDuration;
|
||||
this.maxDurationWithImageReferences = args.maxDurationWithImageReferences;
|
||||
this.bitrateOptions = args.bitrateOptions;
|
||||
this.defaultBitrate = args.defaultBitrate;
|
||||
this.defaultAspectRatio = args.defaultAspectRatio;
|
||||
|
||||
this.supportsReferenceMode = args.supportsReferenceMode;
|
||||
this.maxReferenceImages = args.maxReferenceImages;
|
||||
this.maxReferenceVideos = args.maxReferenceVideos;
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { VideoModel } from "./VideoModel.js";
|
||||
|
||||
/** Normalize old display values while preserving future API values. */
|
||||
export function videoResolutionValue(value: string): string {
|
||||
const legacy: Record<string, string> = {
|
||||
"480p": "four_eighty_p",
|
||||
"720p": "seven_twenty_p",
|
||||
"1080p": "ten_eighty_p",
|
||||
"1k": "one_k",
|
||||
"2k": "two_k",
|
||||
"2K": "two_k",
|
||||
"3k": "three_k",
|
||||
"4k": "four_k",
|
||||
"4K": "four_k",
|
||||
};
|
||||
return legacy[value] ?? value;
|
||||
}
|
||||
|
||||
export function videoDurationRange(
|
||||
model: VideoModel,
|
||||
referenceMode: boolean,
|
||||
): { min: number; max: number } | null {
|
||||
const max = referenceMode
|
||||
? (model.maxDurationWithImageReferences ?? model.maxDuration)
|
||||
: model.maxDuration;
|
||||
if (model.minDuration != null && max != null) {
|
||||
return { min: model.minDuration, max };
|
||||
}
|
||||
const options = model.durationOptions;
|
||||
return options?.length
|
||||
? { min: Math.min(...options), max: Math.max(...options) }
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Match the web prompt box: clamp ranges, snap sparse lists, retain defaults. */
|
||||
export function resolveVideoDuration(
|
||||
model: VideoModel,
|
||||
current: number | null,
|
||||
referenceMode: boolean,
|
||||
): number | null {
|
||||
const value = current ?? model.defaultDuration;
|
||||
if (value == null) return null;
|
||||
const range = videoDurationRange(model, referenceMode);
|
||||
if (range && model.minDuration != null) {
|
||||
return Math.min(Math.max(value, range.min), range.max);
|
||||
}
|
||||
if (model.durationOptions?.length) {
|
||||
return model.durationOptions.reduce((best, option) => {
|
||||
const distance = Math.abs(option - value);
|
||||
const bestDistance = Math.abs(best - value);
|
||||
return distance < bestDistance ||
|
||||
(distance === bestDistance && option > best)
|
||||
? option
|
||||
: best;
|
||||
});
|
||||
}
|
||||
return model.defaultDuration ?? null;
|
||||
}
|
||||
@@ -15,7 +15,7 @@ export const IMAGE_MODELS: ImageModel[] = [
|
||||
fullName: "Grok",
|
||||
category: "image",
|
||||
creator: ModelCreator.Grok,
|
||||
providers: [GenerationProvider.Grok],
|
||||
providers: [GenerationProvider.Artcraft],
|
||||
selectorName: "Grok",
|
||||
selectorDescription: "Fast af",
|
||||
selectorBadges: ["10 sec."],
|
||||
@@ -299,7 +299,7 @@ export const IMAGE_MODELS: ImageModel[] = [
|
||||
fullName: "GPT Image 1 (GPT-4o)",
|
||||
category: "image",
|
||||
creator: ModelCreator.OpenAi,
|
||||
providers: [GenerationProvider.Artcraft, GenerationProvider.Sora],
|
||||
providers: [GenerationProvider.Artcraft],
|
||||
selectorName: "GPT Image 1 (GPT-4o)",
|
||||
selectorDescription: "Slow, but super smart",
|
||||
selectorBadges: ["60 sec."],
|
||||
|
||||
@@ -14,10 +14,7 @@ export const SPLAT_MODELS : SplatModel [] = [
|
||||
selectorName: "Marble Mini",
|
||||
selectorDescription: "Fast generation, good for quick drafts",
|
||||
selectorBadges: ["~30 sec."],
|
||||
providers: [
|
||||
GenerationProvider.Artcraft,
|
||||
GenerationProvider.WorldLabs,
|
||||
],
|
||||
providers: [GenerationProvider.Artcraft],
|
||||
progressBarTime: 45000,
|
||||
}),
|
||||
new SplatModel({
|
||||
@@ -29,10 +26,7 @@ export const SPLAT_MODELS : SplatModel [] = [
|
||||
selectorName: "Marble Plus",
|
||||
selectorDescription: "High quality, best for final renders",
|
||||
selectorBadges: ["~5 min."],
|
||||
providers: [
|
||||
GenerationProvider.Artcraft,
|
||||
GenerationProvider.WorldLabs,
|
||||
],
|
||||
providers: [GenerationProvider.Artcraft],
|
||||
progressBarTime: 300000,
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -10,7 +10,7 @@ export const VIDEO_MODELS: VideoModel[] = [
|
||||
fullName: "Grok Video",
|
||||
category: "video",
|
||||
creator: ModelCreator.Grok,
|
||||
providers: [GenerationProvider.Grok],
|
||||
providers: [GenerationProvider.Artcraft],
|
||||
selectorName: "Grok Video",
|
||||
selectorDescription: "Fastest video model",
|
||||
selectorBadges: ["20 sec."],
|
||||
@@ -466,7 +466,7 @@ export const VIDEO_MODELS: VideoModel[] = [
|
||||
fullName: "Sora 2",
|
||||
category: "video",
|
||||
creator: ModelCreator.OpenAi,
|
||||
providers: [GenerationProvider.Artcraft, GenerationProvider.Sora],
|
||||
providers: [GenerationProvider.Artcraft],
|
||||
selectorName: "Sora 2",
|
||||
selectorDescription: "Smart video model",
|
||||
selectorBadges: ["2 min."],
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { GenerationProvider } from "@storyteller/api-enums";
|
||||
import {
|
||||
buildImageModelsFromListing,
|
||||
buildVideoModelsFromListing,
|
||||
} from "./buildModelsFromListing.js";
|
||||
import {
|
||||
resolveVideoDuration,
|
||||
videoDurationRange,
|
||||
videoResolutionValue,
|
||||
} from "../classes/videoOptions.js";
|
||||
import { IMAGE_MODELS } from "../lists/ImageModels.js";
|
||||
import { VIDEO_MODELS } from "../lists/VideoModels.js";
|
||||
import { SPLAT_MODELS } from "../lists/SplatModels.js";
|
||||
|
||||
describe("desktop catalog", () => {
|
||||
it("offers enabled models without compiled overlay or provider entries", () => {
|
||||
const ids = [
|
||||
"seedance_2p5",
|
||||
"seedance_2p5_u",
|
||||
"seedance_2p5_preview",
|
||||
"seedance_2p0_mini",
|
||||
"flux_3",
|
||||
"minimax_h3",
|
||||
"vidu_q3",
|
||||
"future_video_v99",
|
||||
];
|
||||
const models = buildVideoModelsFromListing(
|
||||
[],
|
||||
ids.map((model) => ({ model })),
|
||||
);
|
||||
expect(models.map((model) => model.tauriId)).toEqual(ids);
|
||||
});
|
||||
|
||||
it("retains offered disabled entries and excludes the MiniMax admin variants", () => {
|
||||
const ids = ["sora_2", "minimax_h3_turbo", "minimax_h3_ultra"];
|
||||
const models = buildVideoModelsFromListing(
|
||||
[],
|
||||
ids.map((model) => ({ model, is_disabled: true })),
|
||||
ids,
|
||||
);
|
||||
expect(models.map((model) => model.tauriId)).toEqual(["sora_2"]);
|
||||
});
|
||||
|
||||
it("preserves future image options and leaves editor eligibility unchanged", () => {
|
||||
const [model] = buildImageModelsFromListing(
|
||||
[],
|
||||
[
|
||||
{
|
||||
model: "seedream_5p0_pro",
|
||||
image_refs_supported: true,
|
||||
resolution_options: ["eight_k"],
|
||||
resolution_default: "eight_k",
|
||||
aspect_ratio_options: ["future_aspect"],
|
||||
quality_options: ["ultra"],
|
||||
},
|
||||
],
|
||||
);
|
||||
expect(model.resolutions).toEqual(["eight_k"]);
|
||||
expect(model.aspectRatios).toEqual(["future_aspect"]);
|
||||
expect(model.qualityOptions).toEqual(["ultra"]);
|
||||
expect(model.canUseImagePrompt).toBe(true);
|
||||
expect(model.canEditImages).toBe(false);
|
||||
});
|
||||
|
||||
it("routes the generic Grok overlays through ArtCraft", () => {
|
||||
for (const model of [...IMAGE_MODELS, ...VIDEO_MODELS].filter((model) =>
|
||||
model.tauriId.startsWith("grok_"),
|
||||
)) {
|
||||
expect(model.getProviders()).toEqual([GenerationProvider.Artcraft]);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps Sora and Marble models available through ArtCraft after hydration", () => {
|
||||
const images = buildImageModelsFromListing(IMAGE_MODELS, [
|
||||
{ model: "gpt_image_1", image_refs_supported: true },
|
||||
]);
|
||||
const videos = buildVideoModelsFromListing(VIDEO_MODELS, [
|
||||
{ model: "sora_2", duration_seconds_options: [4, 8, 12] },
|
||||
]);
|
||||
expect(images.find((model) => model.tauriId === "gpt_image_1")?.getProviders())
|
||||
.toEqual([GenerationProvider.Artcraft]);
|
||||
expect(videos.find((model) => model.tauriId === "sora_2")?.getProviders())
|
||||
.toEqual([GenerationProvider.Artcraft]);
|
||||
expect(SPLAT_MODELS.map((model) => model.tauriId)).toEqual([
|
||||
"marble_0p1_mini", "marble_0p1_plus",
|
||||
]);
|
||||
for (const model of [...images, ...videos, ...SPLAT_MODELS]) {
|
||||
expect(model.getProviders()).not.toContain(GenerationProvider.Sora);
|
||||
expect(model.getProviders()).not.toContain(GenerationProvider.WorldLabs);
|
||||
}
|
||||
for (const model of SPLAT_MODELS) {
|
||||
expect(model.getProviders()).toEqual([GenerationProvider.Artcraft]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("API video options", () => {
|
||||
it("uses range durations, reference-mode caps, bitrate, and future resolutions", () => {
|
||||
const [model] = buildVideoModelsFromListing(
|
||||
[],
|
||||
[
|
||||
{
|
||||
model: "future_video",
|
||||
duration_seconds_min: 4,
|
||||
duration_seconds_max: 30,
|
||||
duration_seconds_max_with_image_references: 10,
|
||||
duration_seconds_default: 5,
|
||||
bitrate_options: ["normal", "high"],
|
||||
bitrate_default: "normal",
|
||||
resolution_options: ["four_k", "eight_k"],
|
||||
},
|
||||
],
|
||||
);
|
||||
expect(videoDurationRange(model, false)).toEqual({ min: 4, max: 30 });
|
||||
expect(resolveVideoDuration(model, 30, false)).toBe(30);
|
||||
expect(resolveVideoDuration(model, 30, true)).toBe(10);
|
||||
expect(model.bitrateOptions).toEqual(["normal", "high"]);
|
||||
expect(model.resolutionOptions?.map(videoResolutionValue)).toEqual([
|
||||
"four_k",
|
||||
"eight_k",
|
||||
]);
|
||||
});
|
||||
|
||||
it("snaps sparse duration lists to the nearest option, preferring longer ties", () => {
|
||||
const [model] = buildVideoModelsFromListing(
|
||||
[],
|
||||
[{ model: "sparse", duration_seconds_options: [12, 4, 8] }],
|
||||
);
|
||||
expect(videoDurationRange(model, false)).toEqual({ min: 4, max: 12 });
|
||||
expect(resolveVideoDuration(model, 6, false)).toBe(8);
|
||||
expect(resolveVideoDuration(model, 30, false)).toBe(12);
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,9 @@ import { CommonResolution } from "../classes/properties/CommonResolution.js";
|
||||
import { CommonQuality } from "../classes/properties/CommonQuality.js";
|
||||
import { MODEL_ID_PREFIX_CREATORS } from "../classes/metadata/ModelCreatorIconForId.js";
|
||||
|
||||
// Admin testing variants are deliberately unavailable in desktop.
|
||||
const ADMIN_ONLY_MODELS = new Set(["minimax_h3_turbo", "minimax_h3_ultra"]);
|
||||
|
||||
// ── Structural shapes of the backend listing (subset we read) ──────────────
|
||||
|
||||
export interface ListingModelBase {
|
||||
@@ -67,6 +70,11 @@ export interface ListingVideoModel extends ListingModelBase {
|
||||
audio_references_max_total_duration_seconds?: number | null;
|
||||
show_generate_with_sound_toggle?: boolean | null;
|
||||
duration_seconds_options?: number[] | null;
|
||||
duration_seconds_min?: number | null;
|
||||
duration_seconds_max?: number | null;
|
||||
duration_seconds_max_with_image_references?: number | null;
|
||||
bitrate_options?: string[] | null;
|
||||
bitrate_default?: string | null;
|
||||
duration_seconds_default?: number | null;
|
||||
}
|
||||
|
||||
@@ -90,9 +98,7 @@ export const buildVideoModelsFromListing = (
|
||||
// 1. OFFERED by a provider in the response's `providers[]` (the backend's
|
||||
// publish switch — `models[]` details also contain disabled entries and
|
||||
// internal variants that must NOT surface), or
|
||||
// 2. an ENABLED `models[]` detail that the frontend knows (overlay entry) —
|
||||
// covers desktop-native models like grok video that no backend provider
|
||||
// offers yet, or
|
||||
// 2. any ENABLED `models[]` detail, including models added after this build, or
|
||||
// 3. a frontend-only overlay model the backend has never heard of
|
||||
// (switch_x, inpaint models, …).
|
||||
// `models[]` details are the capability source either way (the enabled entry
|
||||
@@ -123,13 +129,12 @@ const build = <T extends { tauriId: string }, L extends ListingModelBase>(
|
||||
// Backend details order drives ordering.
|
||||
for (const m of listing) {
|
||||
const tauriId = m.model;
|
||||
if (seenTauriIds.has(tauriId)) continue;
|
||||
if (seenTauriIds.has(tauriId) || ADMIN_ONLY_MODELS.has(tauriId)) continue;
|
||||
|
||||
const overlayEntry = overlayByTauriId.get(tauriId);
|
||||
const detail = detailsByTauriId.get(tauriId) ?? m;
|
||||
const enabled = detail.is_disabled !== true;
|
||||
const show =
|
||||
offeredTauriIds.has(tauriId) || (enabled && overlayEntry !== undefined);
|
||||
const show = offeredTauriIds.has(tauriId) || enabled;
|
||||
if (!show) continue;
|
||||
|
||||
seenTauriIds.add(tauriId);
|
||||
@@ -138,7 +143,7 @@ const build = <T extends { tauriId: string }, L extends ListingModelBase>(
|
||||
// Offered models with no detail entry at all (unusual, but the publish
|
||||
// switch wins): surface minimally.
|
||||
for (const tauriId of offeredModelIds) {
|
||||
if (seenTauriIds.has(tauriId)) continue;
|
||||
if (seenTauriIds.has(tauriId) || ADMIN_ONLY_MODELS.has(tauriId)) continue;
|
||||
seenTauriIds.add(tauriId);
|
||||
result.push(
|
||||
merge({ model: tauriId } as L, tauriId, overlayByTauriId.get(tauriId)),
|
||||
@@ -146,7 +151,7 @@ const build = <T extends { tauriId: string }, L extends ListingModelBase>(
|
||||
}
|
||||
// Append frontend-only models the backend has never heard of.
|
||||
for (const m of overlay) {
|
||||
if (!knownTauriIds.has(m.tauriId) && !seenTauriIds.has(m.tauriId)) {
|
||||
if (!ADMIN_ONLY_MODELS.has(m.tauriId) && !knownTauriIds.has(m.tauriId) && !seenTauriIds.has(m.tauriId)) {
|
||||
result.push(m);
|
||||
}
|
||||
}
|
||||
@@ -160,9 +165,9 @@ const mergedImageModel = (
|
||||
tauriId: string,
|
||||
o: ImageModel | undefined,
|
||||
): ImageModel => {
|
||||
const aspectRatios = knownValues(m.aspect_ratio_options, COMMON_ASPECT_RATIO_VALUES);
|
||||
const resolutions = knownValues(m.resolution_options, COMMON_RESOLUTION_VALUES);
|
||||
const qualityOptions = knownValues(m.quality_options, COMMON_QUALITY_VALUES);
|
||||
const aspectRatios = m.aspect_ratio_options ?? [];
|
||||
const resolutions = m.resolution_options ?? [];
|
||||
const qualityOptions = m.quality_options ?? [];
|
||||
const fullName = m.full_name ?? o?.fullName ?? m.model;
|
||||
|
||||
return new ImageModel({
|
||||
@@ -203,23 +208,17 @@ const mergedImageModel = (
|
||||
aspectRatios:
|
||||
aspectRatios.length > 0 ? (aspectRatios as CommonAspectRatio[]) : o?.aspectRatios,
|
||||
defaultAspectRatio:
|
||||
(knownValue(m.aspect_ratio_default, COMMON_ASPECT_RATIO_VALUES) as
|
||||
| CommonAspectRatio
|
||||
| undefined) ?? o?.defaultAspectRatio,
|
||||
(m.aspect_ratio_default as CommonAspectRatio | undefined) ?? o?.defaultAspectRatio,
|
||||
canChangeResolution:
|
||||
resolutions.length > 0 || (o?.canChangeResolution ?? false),
|
||||
resolutions:
|
||||
resolutions.length > 0 ? (resolutions as CommonResolution[]) : o?.resolutions,
|
||||
defaultResolution:
|
||||
(knownValue(m.resolution_default, COMMON_RESOLUTION_VALUES) as
|
||||
| CommonResolution
|
||||
| undefined) ?? o?.defaultResolution,
|
||||
(m.resolution_default as CommonResolution | undefined) ?? o?.defaultResolution,
|
||||
qualityOptions:
|
||||
qualityOptions.length > 0 ? (qualityOptions as CommonQuality[]) : o?.qualityOptions,
|
||||
defaultQuality:
|
||||
(knownValue(m.default_quality, COMMON_QUALITY_VALUES) as
|
||||
| CommonQuality
|
||||
| undefined) ?? o?.defaultQuality,
|
||||
(m.default_quality as CommonQuality | undefined) ?? o?.defaultQuality,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -228,7 +227,7 @@ const mergedVideoModel = (
|
||||
tauriId: string,
|
||||
o: VideoModel | undefined,
|
||||
): VideoModel => {
|
||||
const aspectRatios = knownValues(m.aspect_ratio_options, COMMON_ASPECT_RATIO_VALUES);
|
||||
const aspectRatios = m.aspect_ratio_options ?? [];
|
||||
const fullName = m.full_name ?? o?.fullName ?? m.model;
|
||||
|
||||
return new VideoModel({
|
||||
@@ -262,6 +261,12 @@ const mergedVideoModel = (
|
||||
generateWithSound: m.show_generate_with_sound_toggle ?? o?.generateWithSound,
|
||||
durationOptions: m.duration_seconds_options ?? o?.durationOptions,
|
||||
defaultDuration: m.duration_seconds_default ?? o?.defaultDuration,
|
||||
minDuration: m.duration_seconds_min ?? o?.minDuration,
|
||||
maxDuration: m.duration_seconds_max ?? o?.maxDuration,
|
||||
maxDurationWithImageReferences: m.duration_seconds_max_with_image_references ?? o?.maxDurationWithImageReferences,
|
||||
bitrateOptions: m.bitrate_options ?? o?.bitrateOptions,
|
||||
defaultBitrate: m.bitrate_default ?? o?.defaultBitrate,
|
||||
defaultAspectRatio: m.aspect_ratio_default ?? o?.defaultAspectRatio,
|
||||
supportsReferenceMode: m.image_references_supported ?? o?.supportsReferenceMode,
|
||||
maxReferenceImages: m.image_references_max ?? o?.maxReferenceImages,
|
||||
maxReferenceVideos: m.video_references_max ?? o?.maxReferenceVideos,
|
||||
@@ -288,18 +293,6 @@ const mergedVideoModel = (
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
const COMMON_ASPECT_RATIO_VALUES: Set<string> = new Set(Object.values(CommonAspectRatio));
|
||||
const COMMON_RESOLUTION_VALUES: Set<string> = new Set(Object.values(CommonResolution));
|
||||
const COMMON_QUALITY_VALUES: Set<string> = new Set(Object.values(CommonQuality));
|
||||
|
||||
const knownValues = (values: string[] | null | undefined, known: Set<string>): string[] =>
|
||||
(values ?? []).filter((v) => known.has(v));
|
||||
|
||||
const knownValue = (
|
||||
value: string | null | undefined,
|
||||
known: Set<string>,
|
||||
): string | undefined => (value != null && known.has(value) ? value : undefined);
|
||||
|
||||
/**
|
||||
* Derive a video size picker option from a CommonAspectRatio value.
|
||||
* `tauriValue` is the CommonAspectRatio serde string sent in the request.
|
||||
@@ -348,6 +341,10 @@ const sizeOptionForAspectRatio = (value: string): SizeOption => {
|
||||
*/
|
||||
const resolutionLabel = (value: string): string => {
|
||||
switch (value) {
|
||||
case "two_k":
|
||||
return "2K";
|
||||
case "four_k":
|
||||
return "4K";
|
||||
case "four_eighty_p":
|
||||
return "480p";
|
||||
case "seven_twenty_p":
|
||||
|
||||
@@ -3,11 +3,13 @@ import { OmniGenApi } from "@storyteller/api";
|
||||
import type {
|
||||
OmniGenAudioModelDetails,
|
||||
OmniGenAudioRequest,
|
||||
OmniGenAudioGenerateResponse,
|
||||
OmniGenAudioCostResponse,
|
||||
OmniGenMusicalKey,
|
||||
} from "@storyteller/api";
|
||||
|
||||
// Audio request building + enqueue + cost estimation, shared by the webapp
|
||||
// create-audio page and the desktop PromptBoxAudio (both enqueue over HTTP).
|
||||
// create-audio page and desktop. Desktop injects its Tauri transport.
|
||||
|
||||
// Server rule (validate_audio_request): these models require exactly one
|
||||
// audio reference (the remix/sample source). The models API has no
|
||||
@@ -95,6 +97,7 @@ export function buildAudioRequest(
|
||||
export async function enqueueAudioGeneration(
|
||||
model: OmniGenAudioModelDetails,
|
||||
settings: AudioGenerationSettings,
|
||||
generate: (body: OmniGenAudioRequest) => Promise<OmniGenAudioGenerateResponse> = generateAudioOverHttp,
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
// One request can create several jobs (Suno-style multi-clip) — callers
|
||||
@@ -106,8 +109,7 @@ export async function enqueueAudioGeneration(
|
||||
const body = buildAudioRequest(model, settings);
|
||||
|
||||
try {
|
||||
const api = new OmniGenApi();
|
||||
const response = await api.generateAudio(body);
|
||||
const response = await generate(body);
|
||||
if (response.success && response.inference_job_token) {
|
||||
const jobTokens = response.all_job_tokens?.length
|
||||
? response.all_job_tokens
|
||||
@@ -119,7 +121,7 @@ export async function enqueueAudioGeneration(
|
||||
return {
|
||||
success: false,
|
||||
jobTokens: [],
|
||||
error: err.message ?? "Request failed",
|
||||
error: err.error_message ?? err.message ?? "Request failed",
|
||||
errorCode: parseAudioHttpStatusCode(err),
|
||||
};
|
||||
}
|
||||
@@ -143,18 +145,23 @@ export interface AudioCostParams {
|
||||
sampleRateHz?: number | null;
|
||||
}
|
||||
|
||||
export function useAudioCostEstimate(params: AudioCostParams): number | null {
|
||||
const generateAudioOverHttp = (body: OmniGenAudioRequest) => new OmniGenApi().generateAudio(body);
|
||||
const estimateAudioOverHttp = (body: OmniGenAudioRequest) => new OmniGenApi().estimateAudioCost(body);
|
||||
|
||||
export function useAudioCostEstimate(
|
||||
params: AudioCostParams,
|
||||
estimate: (body: OmniGenAudioRequest) => Promise<OmniGenAudioCostResponse> = estimateAudioOverHttp,
|
||||
): number | null {
|
||||
const [credits, setCredits] = useState<number | null>(null);
|
||||
const abortRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const id = ++abortRef.current;
|
||||
if (!params.model) {
|
||||
setCredits(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const id = ++abortRef.current;
|
||||
|
||||
const body: OmniGenAudioRequest = {
|
||||
model: params.model,
|
||||
audio_media_tokens: params.audioReferenceCount
|
||||
@@ -164,8 +171,7 @@ export function useAudioCostEstimate(params: AudioCostParams): number | null {
|
||||
sample_rate_hz: params.sampleRateHz ?? null,
|
||||
};
|
||||
|
||||
const api = new OmniGenApi();
|
||||
api.estimateAudioCost(body).then(
|
||||
estimate(body).then(
|
||||
(response) => {
|
||||
if (id !== abortRef.current) return;
|
||||
if (response.success && response.cost_in_credits != null) {
|
||||
@@ -179,7 +185,9 @@ export function useAudioCostEstimate(params: AudioCostParams): number | null {
|
||||
setCredits(null);
|
||||
},
|
||||
);
|
||||
return () => { abortRef.current++; };
|
||||
}, [
|
||||
estimate,
|
||||
params.model,
|
||||
params.audioReferenceCount,
|
||||
params.hasImageReference,
|
||||
|
||||
@@ -49,3 +49,7 @@ export * from "./lib/cost_estimate/EstimateImageCost.js";
|
||||
export * from "./lib/cost_estimate/EstimateSplatCost.js";
|
||||
export * from "./lib/cost_estimate/EstimateVideoCost.js";
|
||||
export * from "./lib/models/modelsStore.js";
|
||||
|
||||
export * from "./lib/common/commandErrorMessage.js";
|
||||
|
||||
export * from "./lib/generate/GenerateOmni.js";
|
||||
|
||||
@@ -21,7 +21,7 @@ export interface TaskQueueItem {
|
||||
id: string;
|
||||
task_status: TaskStatus;
|
||||
task_type: TaskType;
|
||||
model_type?: TaskModelType;
|
||||
model_type?: TaskModelType | string;
|
||||
provider?: GenerationProvider;
|
||||
provider_job_id?: string;
|
||||
completed_item?: TaskQueueCompletedItem;
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
export function commandErrorMessage(error: unknown, fallback: string): string {
|
||||
if (error && typeof error === "object") {
|
||||
const command = error as {
|
||||
error_message?: string;
|
||||
error_details?: { error_message?: string };
|
||||
message?: string;
|
||||
};
|
||||
return (
|
||||
command.error_message ??
|
||||
command.error_details?.error_message ??
|
||||
command.message ??
|
||||
fallback
|
||||
);
|
||||
}
|
||||
return typeof error === "string" ? error : fallback;
|
||||
}
|
||||
@@ -1,22 +1,20 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { CommandResult, CommandSuccessStatus } from "../common/CommandStatus";
|
||||
import {
|
||||
CommonImageModel,
|
||||
CommonAspectRatio,
|
||||
CommonQuality,
|
||||
CommonVideoResolution,
|
||||
ImageGenerationMode,
|
||||
GenerationProvider,
|
||||
} from "@storyteller/api-enums";
|
||||
|
||||
export interface EstimateImageCostRequest {
|
||||
model: CommonImageModel;
|
||||
provider: GenerationProvider;
|
||||
generation_mode: ImageGenerationMode;
|
||||
aspect_ratio?: CommonAspectRatio;
|
||||
resolution?: CommonVideoResolution;
|
||||
quality?: CommonQuality;
|
||||
[key: string]: unknown;
|
||||
model: string;
|
||||
provider?: GenerationProvider;
|
||||
generation_mode?: ImageGenerationMode;
|
||||
aspect_ratio?: string;
|
||||
resolution?: string;
|
||||
quality?: string;
|
||||
image_batch_count?: number;
|
||||
image_media_tokens?: string[];
|
||||
}
|
||||
|
||||
export interface EstimateImageCostPayload {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user