Compare commits

...
Author SHA1 Message Date
Brandon Thomas 77383df886 fix names 2026-07-01 23:16:58 -04:00
Brandon Thomas 5ca570e8af populate model listing 2026-07-01 19:13:34 -04:00
Brandon Thomas e561889fc0 switch grok provider 2026-07-01 18:29:10 -04:00
Brandon Thomas 42610e6684 update frontend and router so models work 2026-07-01 18:17:34 -04:00
Brandon Thomas 5cd71a0db6 adjust frontend for omni models 2026-07-01 16:32:16 -04:00
41 changed files with 1235 additions and 328 deletions
@@ -1,3 +1,4 @@
pub mod tauri_image_model_to_common_model_type;
pub mod tauri_image_model_to_enums_model;
pub mod tauri_image_model_to_generation_model;
pub mod tauri_image_model_to_router_model;
pub mod tauri_image_model_to_router_model;
@@ -0,0 +1,34 @@
use enums::common::generation::common_model_type::CommonModelType;
use crate::core::commands::generate::generate_image::tauri_image_model::TauriImageModel;
/// Map TauriImageModel to the legacy CommonModelType (enums crate).
pub fn tauri_image_model_to_common_model_type(model: TauriImageModel) -> CommonModelType {
match model {
TauriImageModel::Flux1Dev => CommonModelType::Flux1Dev,
TauriImageModel::Flux1Schnell => CommonModelType::Flux1Schnell,
TauriImageModel::FluxPro11 => CommonModelType::FluxPro11,
TauriImageModel::FluxPro11Ultra => CommonModelType::FluxPro11Ultra,
TauriImageModel::GrokImage => CommonModelType::GrokImage,
TauriImageModel::Recraft3 => CommonModelType::Recraft3,
TauriImageModel::GptImage1 => CommonModelType::GptImage1,
TauriImageModel::GptImage1p5 => CommonModelType::GptImage1p5,
TauriImageModel::GptImage2 => CommonModelType::GptImage2,
TauriImageModel::Gemini25Flash => CommonModelType::NanoBanana,
TauriImageModel::NanoBanana => CommonModelType::NanoBanana,
TauriImageModel::NanoBanana2 => CommonModelType::NanoBanana2,
TauriImageModel::NanoBananaPro => CommonModelType::NanoBananaPro,
TauriImageModel::Seedream4 => CommonModelType::Seedream4,
TauriImageModel::Seedream4p5 => CommonModelType::Seedream4p5,
TauriImageModel::Seedream5Lite => CommonModelType::Seedream5Lite,
TauriImageModel::Midjourney => CommonModelType::Midjourney,
TauriImageModel::Midjourney7 => CommonModelType::Midjourney7,
TauriImageModel::Midjourney7Niji => CommonModelType::Midjourney7Niji,
TauriImageModel::Midjourney8 => CommonModelType::Midjourney8,
TauriImageModel::FluxProKontextMax => CommonModelType::FluxProKontextMax,
TauriImageModel::QwenEdit2511Angles => CommonModelType::QwenEdit2511Angles,
TauriImageModel::Flux2LoraAngles => CommonModelType::Flux2LoraAngles,
TauriImageModel::FluxDevJuggernaut => CommonModelType::FluxDevJuggernaut,
TauriImageModel::FluxPro1 => CommonModelType::FluxPro1,
}
}
@@ -23,10 +23,13 @@ pub fn tauri_image_model_to_enums_model(model: TauriImageModel) -> Option<Common
TauriImageModel::Seedream5Lite => Some(CommonImageModel::Seedream5Lite),
TauriImageModel::QwenEdit2511Angles => Some(CommonImageModel::QwenEdit2511Angles),
TauriImageModel::Flux2LoraAngles => Some(CommonImageModel::Flux2LoraAngles),
TauriImageModel::Midjourney7 => Some(CommonImageModel::Midjourney7),
TauriImageModel::Midjourney7Niji => Some(CommonImageModel::Midjourney7Niji),
TauriImageModel::Midjourney8 => Some(CommonImageModel::Midjourney8),
// Not accounted for yet
TauriImageModel::GrokImage => None,
TauriImageModel::Recraft3 => None,
TauriImageModel::Midjourney => None,
TauriImageModel::Midjourney => None, // NB: Generic Midjourney is served by the native Midjourney provider.
TauriImageModel::FluxProKontextMax => None,
TauriImageModel::FluxDevJuggernaut => None,
}
@@ -20,7 +20,10 @@ pub fn tauri_image_model_to_generation_model(model: TauriImageModel) -> Generati
TauriImageModel::Seedream4 => GenerationModel::Seedream4,
TauriImageModel::Seedream4p5 => GenerationModel::Seedream4p5,
TauriImageModel::Seedream5Lite => GenerationModel::Seedream5Lite,
TauriImageModel::Midjourney => GenerationModel::Flux1Dev, // Fallback
TauriImageModel::Midjourney => GenerationModel::Midjourney,
TauriImageModel::Midjourney7 => GenerationModel::Midjourney7,
TauriImageModel::Midjourney7Niji => GenerationModel::Midjourney7Niji,
TauriImageModel::Midjourney8 => GenerationModel::Midjourney8,
TauriImageModel::FluxProKontextMax => GenerationModel::FluxProKontextMax,
TauriImageModel::QwenEdit2511Angles => GenerationModel::QwenEdit2511Angles,
TauriImageModel::Flux2LoraAngles => GenerationModel::Flux2LoraAngles,
@@ -23,10 +23,13 @@ pub fn tauri_image_model_to_router_model(model: TauriImageModel) -> Option<Route
TauriImageModel::Seedream5Lite => Some(RouterImageModel::Seedream5Lite), // Text-to-Image
TauriImageModel::QwenEdit2511Angles => Some(RouterImageModel::QwenEdit2511Angles),
TauriImageModel::Flux2LoraAngles => Some(RouterImageModel::Flux2LoraAngles),
TauriImageModel::Midjourney7 => Some(RouterImageModel::Midjourney7),
TauriImageModel::Midjourney7Niji => Some(RouterImageModel::Midjourney7Niji),
TauriImageModel::Midjourney8 => Some(RouterImageModel::Midjourney8),
// Not accounted for yet
TauriImageModel::GrokImage => None,
TauriImageModel::Recraft3 => None,
TauriImageModel::Midjourney => None,
TauriImageModel::Midjourney => None, // NB: Generic Midjourney is served by the native Midjourney provider.
TauriImageModel::FluxProKontextMax => None,
TauriImageModel::FluxDevJuggernaut => None,
}
@@ -1 +1,2 @@
pub mod image;
pub mod image;
pub mod video;
@@ -0,0 +1,2 @@
pub mod tauri_video_model_to_generation_model;
pub mod tauri_video_model_to_router_model;
@@ -0,0 +1,31 @@
use crate::core::commands::generate::generate_video::request::TauriVideoModel;
use crate::core::events::generation_events::common::GenerationModel;
/// Map TauriVideoModel to the GenerationModel used in events and the task database.
/// NB: GenerationModel serializations are stored in the tasks database — they keep
/// their legacy spellings for compatibility.
pub fn tauri_video_model_to_generation_model(model: TauriVideoModel) -> GenerationModel {
match model {
TauriVideoModel::GrokVideo => GenerationModel::GrokVideo,
TauriVideoModel::GrokImagineVideo1p5 => GenerationModel::GrokImagineVideo1p5,
TauriVideoModel::HappyHorse1p0 => GenerationModel::HappyHorse1p0,
TauriVideoModel::Kling16Pro => GenerationModel::Kling1_6,
TauriVideoModel::Kling21Master => GenerationModel::Kling21Master,
TauriVideoModel::Kling21Pro => GenerationModel::Kling21Pro,
TauriVideoModel::Kling2p5TurboPro => GenerationModel::Kling2p5TurboPro,
TauriVideoModel::Kling2p6Pro => GenerationModel::Kling2p6Pro,
TauriVideoModel::Kling3p0Pro => GenerationModel::Kling3p0Pro,
TauriVideoModel::Kling3p0Standard => GenerationModel::Kling3p0Standard,
TauriVideoModel::Seedance10Lite => GenerationModel::Seedance10Lite,
TauriVideoModel::Seedance1p5Pro => GenerationModel::Seedance1p5Pro,
TauriVideoModel::Seedance2p0 => GenerationModel::Seedance2p0,
TauriVideoModel::Seedance2p0Fast => GenerationModel::Seedance2p0Fast,
TauriVideoModel::Sora2 => GenerationModel::Sora2,
TauriVideoModel::Sora2Pro => GenerationModel::Sora2Pro,
TauriVideoModel::Veo2 => GenerationModel::Veo2,
TauriVideoModel::Veo3 => GenerationModel::Veo3,
TauriVideoModel::Veo3Fast => GenerationModel::Veo3Fast,
TauriVideoModel::Veo3p1 => GenerationModel::Veo3p1,
TauriVideoModel::Veo3p1Fast => GenerationModel::Veo3p1Fast,
}
}
@@ -0,0 +1,30 @@
use artcraft_router::api::router_video_model::RouterVideoModel;
use crate::core::commands::generate::generate_video::request::TauriVideoModel;
/// Map TauriVideoModel to the artcraft_router's RouterVideoModel.
pub fn tauri_video_model_to_router_model(model: TauriVideoModel) -> RouterVideoModel {
match model {
TauriVideoModel::GrokVideo => RouterVideoModel::GrokImagineVideo,
TauriVideoModel::GrokImagineVideo1p5 => RouterVideoModel::GrokImagineVideo1p5,
TauriVideoModel::HappyHorse1p0 => RouterVideoModel::HappyHorse1p0,
TauriVideoModel::Kling16Pro => RouterVideoModel::Kling16Pro,
TauriVideoModel::Kling21Master => RouterVideoModel::Kling21Master,
TauriVideoModel::Kling21Pro => RouterVideoModel::Kling21Pro,
TauriVideoModel::Kling2p5TurboPro => RouterVideoModel::Kling2p5TurboPro,
TauriVideoModel::Kling2p6Pro => RouterVideoModel::Kling2p6Pro,
TauriVideoModel::Kling3p0Pro => RouterVideoModel::Kling3p0Pro,
TauriVideoModel::Kling3p0Standard => RouterVideoModel::Kling3p0Standard,
TauriVideoModel::Seedance10Lite => RouterVideoModel::Seedance10Lite,
TauriVideoModel::Seedance1p5Pro => RouterVideoModel::Seedance1p5Pro,
TauriVideoModel::Seedance2p0 => RouterVideoModel::Seedance2p0,
TauriVideoModel::Seedance2p0Fast => RouterVideoModel::Seedance2p0Fast,
TauriVideoModel::Sora2 => RouterVideoModel::Sora2,
TauriVideoModel::Sora2Pro => RouterVideoModel::Sora2Pro,
TauriVideoModel::Veo2 => RouterVideoModel::Veo2,
TauriVideoModel::Veo3 => RouterVideoModel::Veo3,
TauriVideoModel::Veo3Fast => RouterVideoModel::Veo3Fast,
TauriVideoModel::Veo3p1 => RouterVideoModel::Veo3p1,
TauriVideoModel::Veo3p1Fast => RouterVideoModel::Veo3p1Fast,
}
}
@@ -85,6 +85,7 @@ impl TaskEnqueueSuccess{
Some(GenerationModel::GrokImage) => Some(TaskModelType::GrokImage),
Some(GenerationModel::Recraft3) => Some(TaskModelType::Recraft3),
Some(GenerationModel::GrokVideo) => Some(TaskModelType::GrokVideo),
Some(GenerationModel::GrokImagineVideo1p5) => Some(TaskModelType::GrokImagineVideo1p5),
Some(GenerationModel::Kling21Pro) => Some(TaskModelType::Kling21Pro),
Some(GenerationModel::Kling21Master) => Some(TaskModelType::Kling21Master),
Some(GenerationModel::Kling2p5TurboPro) => Some(TaskModelType::Kling2p5TurboPro),
@@ -110,6 +111,9 @@ impl TaskEnqueueSuccess{
Some(GenerationModel::WorldlabsMarble0p1Mini) => Some(TaskModelType::WorldlabsMarble0p1Mini),
Some(GenerationModel::WorldlabsMarble0p1Plus) => Some(TaskModelType::WorldlabsMarble0p1Plus),
Some(GenerationModel::Midjourney) => Some(TaskModelType::Midjourney), // NB: This is a generic Midjourney model, version unknown.
Some(GenerationModel::Midjourney7) => Some(TaskModelType::Midjourney7),
Some(GenerationModel::Midjourney7Niji) => Some(TaskModelType::Midjourney7Niji),
Some(GenerationModel::Midjourney8) => Some(TaskModelType::Midjourney8),
// TODO: These seem wrong -
Some(GenerationModel::Kling1_6) => Some(TaskModelType::Kling16Pro), // NB: `VideoModel::Kling16Pro`.
@@ -1,10 +1,14 @@
use enums::common::generation::common_model_type::CommonModelType;
use serde_derive::Deserialize;
/// Unified image model enum covering text-to-image, image edit, and inpainting.
///
/// This is used in the Tauri command bridge.
/// Don't change the serializations without coordinating with the frontend.
///
/// MIGRATION (2026-07): we're moving the frontend to send storyteller-web omni
/// identifiers (`CommonImageModel` serde strings). Variants whose legacy Tauri
/// id differs carry the omni id as a `#[serde(alias)]` so BOTH deserialize.
/// Once the frontend is 100% omni ids, the legacy renames can be dropped.
#[derive(Deserialize, Debug, Copy, Clone)]
#[serde(rename_all = "snake_case")]
pub enum TauriImageModel {
@@ -14,11 +18,11 @@ pub enum TauriImageModel {
Flux1Dev,
#[serde(rename = "flux_1_schnell")]
Flux1Schnell,
#[serde(rename = "flux_pro_11")]
#[serde(rename = "flux_pro_11", alias = "flux_pro_1p1")]
FluxPro11,
#[serde(rename = "flux_pro_11_ultra")]
#[serde(rename = "flux_pro_11_ultra", alias = "flux_pro_1p1_ultra")]
FluxPro11Ultra,
#[serde(rename = "grok_image")]
#[serde(rename = "grok_image", alias = "grok_imagine_image")]
GrokImage,
#[serde(rename = "recraft_3")]
Recraft3,
@@ -42,9 +46,19 @@ pub enum TauriImageModel {
Seedream4p5,
#[serde(rename = "seedream_5_lite")]
Seedream5Lite,
// Generic Midjourney experience, served via the native Midjourney provider.
#[serde(rename = "midjourney")]
Midjourney,
// Versioned Midjourney models, served by storyteller-web via the
// Artcraft provider (dispatched through artcraft_router).
#[serde(rename = "midjourney_7")]
Midjourney7,
#[serde(rename = "midjourney_7_niji")]
Midjourney7Niji,
#[serde(rename = "midjourney_8")]
Midjourney8,
// Image edit models
#[serde(rename = "flux_pro_kontext_max")]
@@ -61,32 +75,3 @@ pub enum TauriImageModel {
#[serde(rename = "flux_pro_1")]
FluxPro1,
}
impl TauriImageModel {
pub fn to_common_model_type(&self) -> CommonModelType {
match self {
Self::Flux1Dev => CommonModelType::Flux1Dev,
Self::Flux1Schnell => CommonModelType::Flux1Schnell,
Self::FluxPro11 => CommonModelType::FluxPro11,
Self::FluxPro11Ultra => CommonModelType::FluxPro11Ultra,
Self::GrokImage => CommonModelType::GrokImage,
Self::Recraft3 => CommonModelType::Recraft3,
Self::GptImage1 => CommonModelType::GptImage1,
Self::GptImage1p5 => CommonModelType::GptImage1p5,
Self::GptImage2 => CommonModelType::GptImage2,
Self::Gemini25Flash => CommonModelType::NanoBanana,
Self::NanoBanana => CommonModelType::NanoBanana,
Self::NanoBanana2 => CommonModelType::NanoBanana2,
Self::NanoBananaPro => CommonModelType::NanoBananaPro,
Self::Seedream4 => CommonModelType::Seedream4,
Self::Seedream4p5 => CommonModelType::Seedream4p5,
Self::Seedream5Lite => CommonModelType::Seedream5Lite,
Self::Midjourney => CommonModelType::Midjourney,
Self::FluxProKontextMax => CommonModelType::FluxProKontextMax,
Self::QwenEdit2511Angles => CommonModelType::QwenEdit2511Angles,
Self::Flux2LoraAngles => CommonModelType::Flux2LoraAngles,
Self::FluxDevJuggernaut => CommonModelType::FluxDevJuggernaut,
Self::FluxPro1 => CommonModelType::FluxPro1,
}
}
}
@@ -1,13 +1,12 @@
use crate::core::api_adapters::models::video::tauri_video_model_to_generation_model::tauri_video_model_to_generation_model;
use crate::core::api_adapters::models::video::tauri_video_model_to_router_model::tauri_video_model_to_router_model;
use crate::core::commands::enqueue::generate_error::GenerateError;
use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
use crate::core::commands::generate::generate_video::artcraft::get_storyteller_creds_or_error::get_storyteller_creds_or_error;
use crate::core::commands::generate::generate_video::artcraft::handle_artcraft_video_via_router::handle_artcraft_video_via_router;
use crate::core::commands::generate::generate_video::request::{TauriGenerateVideoRequest, TauriVideoModel};
use crate::core::events::generation_events::common::GenerationModel;
use crate::core::commands::generate::generate_video::request::TauriGenerateVideoRequest;
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use anyhow::anyhow;
use artcraft_router::api::router_video_model::RouterVideoModel;
use tauri::AppHandle;
pub async fn handle_video_artcraft(
@@ -19,30 +18,13 @@ pub async fn handle_video_artcraft(
let creds = get_storyteller_creds_or_error(app, storyteller_creds_manager)?;
let (router_model, generation_model) = match request.model {
let model = match request.model {
None => return Err(GenerateError::no_model_specified()),
Some(TauriVideoModel::HappyHorse1p0) => (RouterVideoModel::HappyHorse1p0, GenerationModel::HappyHorse1p0),
Some(TauriVideoModel::Kling16Pro) => (RouterVideoModel::Kling16Pro, GenerationModel::Kling1_6),
Some(TauriVideoModel::Kling21Master) => (RouterVideoModel::Kling21Master, GenerationModel::Kling21Master),
Some(TauriVideoModel::Kling21Pro) => (RouterVideoModel::Kling21Pro, GenerationModel::Kling21Pro),
Some(TauriVideoModel::Kling2p5TurboPro) => (RouterVideoModel::Kling2p5TurboPro, GenerationModel::Kling2p5TurboPro),
Some(TauriVideoModel::Kling2p6Pro) => (RouterVideoModel::Kling2p6Pro, GenerationModel::Kling2p6Pro),
Some(TauriVideoModel::Kling3p0Pro) => (RouterVideoModel::Kling3p0Pro, GenerationModel::Kling3p0Pro),
Some(TauriVideoModel::Kling3p0Standard) => (RouterVideoModel::Kling3p0Standard, GenerationModel::Kling3p0Standard),
Some(TauriVideoModel::Seedance10Lite) => (RouterVideoModel::Seedance10Lite, GenerationModel::Seedance10Lite),
Some(TauriVideoModel::Seedance1p5Pro) => (RouterVideoModel::Seedance1p5Pro, GenerationModel::Seedance1p5Pro),
Some(TauriVideoModel::Seedance2p0) => (RouterVideoModel::Seedance2p0, GenerationModel::Seedance2p0),
Some(TauriVideoModel::Seedance2p0Fast) => (RouterVideoModel::Seedance2p0Fast, GenerationModel::Seedance2p0Fast),
Some(TauriVideoModel::Sora2) => (RouterVideoModel::Sora2, GenerationModel::Sora2),
Some(TauriVideoModel::Sora2Pro) => (RouterVideoModel::Sora2Pro, GenerationModel::Sora2Pro),
Some(TauriVideoModel::Veo2) => (RouterVideoModel::Veo2, GenerationModel::Veo2),
Some(TauriVideoModel::Veo3) => (RouterVideoModel::Veo3, GenerationModel::Veo3),
Some(TauriVideoModel::Veo3Fast) => (RouterVideoModel::Veo3Fast, GenerationModel::Veo3Fast),
Some(TauriVideoModel::Veo3p1) => (RouterVideoModel::Veo3p1, GenerationModel::Veo3p1),
Some(TauriVideoModel::Veo3p1Fast) => (RouterVideoModel::Veo3p1Fast, GenerationModel::Veo3p1Fast),
Some(other) => return Err(GenerateError::AnyhowError(
anyhow!("wrong logic: another branch should handle this: {:?}", other))),
Some(model) => model,
};
let router_model = tauri_video_model_to_router_model(model);
let generation_model = tauri_video_model_to_generation_model(model);
handle_artcraft_video_via_router(request, app_env_configs, &creds, router_model, generation_model).await
}
@@ -150,6 +150,10 @@ async fn handle_request(
let provider = match (model, request.provider) {
(TauriVideoModel::GrokVideo, _) => GenerationProvider::Grok,
// NB: Grok Imagine 1.5 is served via Artcraft (storyteller-web / artcraft_router)
// for now — force it regardless of the requested provider. In the future we
// may serve it natively via the Grok provider.
(TauriVideoModel::GrokImagineVideo1p5, _) => GenerationProvider::Artcraft,
_ => request.provider.unwrap_or(GenerationProvider::Artcraft),
};
@@ -1,7 +1,7 @@
use crate::core::commands::enqueue::generate_error::{BadInputReason, GenerateError};
use crate::core::commands::deprecated::image_edit::enqueue_edit_image_command::EnqueueEditImageCommand;
use crate::core::commands::deprecated::image_inpaint::enqueue_image_inpaint_command::EnqueueInpaintImageCommand;
use crate::core::commands::generate::generate_video::request::{TauriGenerateVideoRequest, GrokAspectRatio, SoraOrientation};
use crate::core::commands::generate::generate_video::request::{TauriGenerateVideoRequest, TauriVideoModel, GrokAspectRatio, SoraOrientation};
use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
use crate::core::events::functional_events::show_provider_login_modal_event::ShowProviderLoginModalEvent;
use crate::core::events::generation_events::common::GenerationModel;
@@ -91,9 +91,16 @@ pub async fn handle_grok_video(
}
};
// NB: Grok's consumer API doesn't take a version selector; this only affects
// which model is recorded for the task queue / events.
let generation_model = match request.model {
Some(TauriVideoModel::GrokImagineVideo1p5) => GenerationModel::GrokImagineVideo1p5,
_ => GenerationModel::GrokVideo,
};
Ok(TaskEnqueueSuccess {
provider: GenerationProvider::Grok,
model: Some(GenerationModel::GrokVideo),
model: Some(generation_model),
provider_job_id: Some(post_id.to_string()),
task_type: TaskType::VideoGeneration,
maybe_queue_status_url: None,
@@ -10,19 +10,27 @@ use crate::core::commands::response::success_response_wrapper::SerializeMarker;
/// This is used in the Tauri command bridge.
/// Don't change the serializations without coordinating with the frontend.
///
/// MIGRATION (2026-07): we're moving the frontend to send storyteller-web omni
/// identifiers (`CommonVideoModel` serde strings). Variants whose legacy Tauri
/// id differs carry the omni id as a `#[serde(alias)]` so BOTH deserialize.
/// Once the frontend is 100% omni ids, the legacy renames can be dropped.
#[derive(Deserialize, Debug, Copy, Clone)]
#[serde(rename_all = "snake_case")]
pub enum TauriVideoModel {
#[serde(rename = "grok_video")]
#[serde(rename = "grok_video", alias = "grok_imagine_video")]
GrokVideo,
#[serde(rename = "kling_1.6_pro")]
#[serde(rename = "grok_imagine_video_1p5")]
GrokImagineVideo1p5,
#[serde(rename = "kling_1.6_pro", alias = "kling_1p6_pro")]
Kling16Pro,
#[serde(rename = "kling_2.1_pro")]
#[serde(rename = "kling_2.1_pro", alias = "kling_2p1_pro")]
Kling21Pro,
#[serde(rename = "kling_2.1_master")]
#[serde(rename = "kling_2.1_master", alias = "kling_2p1_master")]
Kling21Master,
#[serde(rename = "kling_2p5_turbo_pro")]
@@ -40,7 +48,7 @@ pub enum TauriVideoModel {
#[serde(rename = "happy_horse_1p0")]
HappyHorse1p0,
#[serde(rename = "seedance_1.0_lite")]
#[serde(rename = "seedance_1.0_lite", alias = "seedance_1p0_lite")]
Seedance10Lite,
#[serde(rename = "seedance_1p5_pro")]
@@ -74,10 +74,18 @@ pub enum GenerationModel {
// Generic Midjourney model, version unknown.
#[serde(rename = "midjourney")]
Midjourney,
#[serde(rename = "midjourney_7")]
Midjourney7,
#[serde(rename = "midjourney_7_niji")]
Midjourney7Niji,
#[serde(rename = "midjourney_8")]
Midjourney8,
// Generic Grok video model, version unknown.
#[serde(rename = "grok_video")]
GrokVideo,
#[serde(rename = "grok_imagine_video_1p5")]
GrokImagineVideo1p5,
// TODO: Should be Kling16Pro
#[serde(rename = "kling_1.6")]
Kling1_6,
@@ -299,7 +299,11 @@ fn task_model_type_to_generation_model(model: TaskModelType) -> Option<Generatio
TaskModelType::QwenEdit2511Angles => Some(GenerationModel::QwenEdit2511Angles),
TaskModelType::GrokImage => Some(GenerationModel::GrokImage),
TaskModelType::Recraft3 => Some(GenerationModel::Recraft3),
TaskModelType::Midjourney7 => Some(GenerationModel::Midjourney7),
TaskModelType::Midjourney7Niji => Some(GenerationModel::Midjourney7Niji),
TaskModelType::Midjourney8 => Some(GenerationModel::Midjourney8),
TaskModelType::GrokVideo => Some(GenerationModel::GrokVideo),
TaskModelType::GrokImagineVideo1p5 => Some(GenerationModel::GrokImagineVideo1p5),
TaskModelType::Kling16Pro => Some(GenerationModel::Kling1_6),
TaskModelType::Kling21Pro => Some(GenerationModel::Kling21Pro),
TaskModelType::Kling21Master => Some(GenerationModel::Kling21Master),
@@ -59,10 +59,18 @@ pub enum TaskModelType {
// Generic Midjourney model, version unknown.
#[serde(rename = "midjourney")]
Midjourney,
#[serde(rename = "midjourney_7")]
Midjourney7,
#[serde(rename = "midjourney_7_niji")]
Midjourney7Niji,
#[serde(rename = "midjourney_8")]
Midjourney8,
// Video models
#[serde(rename = "grok_video")]
GrokVideo, // Video version unspecified/unknown
#[serde(rename = "grok_imagine_video_1p5")]
GrokImagineVideo1p5,
#[serde(rename = "kling_1.6_pro")]
Kling16Pro,
#[serde(rename = "kling_2.1_pro")]
@@ -149,8 +157,12 @@ impl TaskModelType {
Self::GrokImage => "grok_image",
Self::Recraft3 => "recraft_3",
Self::Midjourney => "midjourney",
Self::Midjourney7 => "midjourney_7",
Self::Midjourney7Niji => "midjourney_7_niji",
Self::Midjourney8 => "midjourney_8",
// Video models
Self::GrokVideo => "grok_video",
Self::GrokImagineVideo1p5 => "grok_imagine_video_1p5",
Self::Kling16Pro => "kling_1.6_pro",
Self::Kling21Pro => "kling_2.1_pro",
Self::Kling21Master => "kling_2.1_master",
@@ -205,8 +217,12 @@ impl TaskModelType {
"grok_image" => Ok(Self::GrokImage),
"recraft_3" => Ok(Self::Recraft3),
"midjourney" => Ok(Self::Midjourney),
"midjourney_7" => Ok(Self::Midjourney7),
"midjourney_7_niji" => Ok(Self::Midjourney7Niji),
"midjourney_8" => Ok(Self::Midjourney8),
// Video models
"grok_video" => Ok(Self::GrokVideo),
"grok_imagine_video_1p5" => Ok(Self::GrokImagineVideo1p5),
"kling_1.6_pro" => Ok(Self::Kling16Pro),
"kling_2.1_pro" => Ok(Self::Kling21Pro),
"kling_2.1_master" => Ok(Self::Kling21Master),
@@ -264,8 +280,12 @@ impl TaskModelType {
Self::GrokImage,
Self::Recraft3,
Self::Midjourney,
Self::Midjourney7,
Self::Midjourney7Niji,
Self::Midjourney8,
// Video models
Self::GrokVideo,
Self::GrokImagineVideo1p5,
Self::Kling16Pro,
Self::Kling21Pro,
Self::Kling21Master,
@@ -331,7 +351,11 @@ mod tests {
assert_serialization(TaskModelType::Recraft3, "recraft_3");
assert_serialization(TaskModelType::Midjourney, "midjourney");
// Video models
assert_serialization(TaskModelType::Midjourney7, "midjourney_7");
assert_serialization(TaskModelType::Midjourney7Niji, "midjourney_7_niji");
assert_serialization(TaskModelType::Midjourney8, "midjourney_8");
assert_serialization(TaskModelType::GrokVideo, "grok_video");
assert_serialization(TaskModelType::GrokImagineVideo1p5, "grok_imagine_video_1p5");
assert_serialization(TaskModelType::Kling16Pro, "kling_1.6_pro");
assert_serialization(TaskModelType::Kling21Pro, "kling_2.1_pro");
assert_serialization(TaskModelType::Kling21Master, "kling_2.1_master");
@@ -387,6 +411,7 @@ mod tests {
assert_eq!(TaskModelType::Midjourney.to_str(), "midjourney");
// Video models
assert_eq!(TaskModelType::GrokVideo.to_str(), "grok_video");
assert_eq!(TaskModelType::GrokImagineVideo1p5.to_str(), "grok_imagine_video_1p5");
assert_eq!(TaskModelType::Kling16Pro.to_str(), "kling_1.6_pro");
assert_eq!(TaskModelType::Kling21Pro.to_str(), "kling_2.1_pro");
assert_eq!(TaskModelType::Kling21Master.to_str(), "kling_2.1_master");
@@ -442,6 +467,7 @@ mod tests {
assert_eq!(TaskModelType::from_str("midjourney").unwrap(), TaskModelType::Midjourney);
// Video models
assert_eq!(TaskModelType::from_str("grok_video").unwrap(), TaskModelType::GrokVideo);
assert_eq!(TaskModelType::from_str("grok_imagine_video_1p5").unwrap(), TaskModelType::GrokImagineVideo1p5);
assert_eq!(TaskModelType::from_str("kling_1.6_pro").unwrap(), TaskModelType::Kling16Pro);
assert_eq!(TaskModelType::from_str("kling_2.1_pro").unwrap(), TaskModelType::Kling21Pro);
assert_eq!(TaskModelType::from_str("kling_2.1_master").unwrap(), TaskModelType::Kling21Master);
@@ -484,7 +510,7 @@ mod tests {
#[test]
fn all_variants() {
let mut variants = TaskModelType::all_variants();
assert_eq!(variants.len(), 48);
assert_eq!(variants.len(), 52);
// Image models
assert_eq!(variants.pop_first(), Some(TaskModelType::Flux1Dev));
assert_eq!(variants.pop_first(), Some(TaskModelType::Flux1Schnell));
@@ -508,8 +534,12 @@ mod tests {
assert_eq!(variants.pop_first(), Some(TaskModelType::GrokImage));
assert_eq!(variants.pop_first(), Some(TaskModelType::Recraft3));
assert_eq!(variants.pop_first(), Some(TaskModelType::Midjourney));
assert_eq!(variants.pop_first(), Some(TaskModelType::Midjourney7));
assert_eq!(variants.pop_first(), Some(TaskModelType::Midjourney7Niji));
assert_eq!(variants.pop_first(), Some(TaskModelType::Midjourney8));
// Video models
assert_eq!(variants.pop_first(), Some(TaskModelType::GrokVideo));
assert_eq!(variants.pop_first(), Some(TaskModelType::GrokImagineVideo1p5));
assert_eq!(variants.pop_first(), Some(TaskModelType::Kling16Pro));
assert_eq!(variants.pop_first(), Some(TaskModelType::Kling21Pro));
assert_eq!(variants.pop_first(), Some(TaskModelType::Kling21Master));
+8
View File
@@ -14,6 +14,7 @@ import { pageHeight, pageWidth, persistLogin } from "~/signals";
import { SyncStorytellerApiConfig } from "./api/SyncStorytellerApiConfig";
import { posthog } from "posthog-js";
import { SoundManager } from "@storyteller/soundboard";
import { useModelsStore } from "@storyteller/tauri-api";
config.autoAddCss = false; /* eslint-disable import/first */
@@ -89,6 +90,13 @@ const GlobalSettingsManager = ({ env }: { env: Record<string, string> }) => {
SoundManager.install();
}, []);
// Reconcile the model dropdowns against the backend omni listing once on boot.
// The store is already seeded with the static overlay, so a failure here is a
// no-op (the UI keeps the overlay models).
useEffect(() => {
void useModelsStore.getState().loadModelsFromBackend();
}, []);
return null;
};
@@ -37,7 +37,7 @@ import { SliderV2 } from "@storyteller/ui-sliderv2";
import { GenerateImage } from "@storyteller/tauri-api";
import {
ClassyModelSelector,
ANGLES_PAGE_MODEL_LIST,
useAnglesPageModelList,
ModelPage,
useSelectedModel,
useSelectedProviderForModel,
@@ -58,6 +58,7 @@ const ANGLES_MODEL_PAGE = ModelPage.Angles;
// ─── Main Angles Component ─────────────────────────────────────────────────────
export const Angles = () => {
const anglesModelList = useAnglesPageModelList();
const [isGalleryModalOpen, setIsGalleryModalOpen] = useState(false);
const [selectedGalleryImages, setSelectedGalleryImages] = useState<string[]>(
[],
@@ -733,7 +734,7 @@ export const Angles = () => {
<ClassyModelSelector
variant="embedded"
items={ANGLES_PAGE_MODEL_LIST}
items={anglesModelList}
page={ANGLES_MODEL_PAGE}
/>
<GenerateButton
@@ -4,7 +4,7 @@ import { PromptBoxImage } from "@storyteller/ui-promptbox";
import { UploadImageMedia, FilterMediaClasses } from "@storyteller/api";
import BackgroundGallery from "./BackgroundGallery";
import {
TEXT_TO_IMAGE_PAGE_MODEL_LIST,
useTextToImagePageModelList,
ModelPage,
ClassyModelSelector,
useSelectedImageModel,
@@ -44,6 +44,7 @@ interface TextToImageProps {
}
const TextToImage = ({ imageMediaId, imageUrl }: TextToImageProps) => {
const textToImageModelList = useTextToImagePageModelList();
const startBatch = useTextToImageStore((s) => s.startBatch);
const promptContentRef = useRef<HTMLDivElement>(null);
const [promptHeight, setPromptHeight] = useState<number>(138);
@@ -187,7 +188,7 @@ const TextToImage = ({ imageMediaId, imageUrl }: TextToImageProps) => {
modelSelector={
<ClassyModelSelector
variant="embedded"
items={TEXT_TO_IMAGE_PAGE_MODEL_LIST}
items={textToImageModelList}
page={PAGE_ID}
/>
}
@@ -13,7 +13,7 @@ import {
import BackgroundGallery from "./BackgroundGallery";
import {
ClassyModelSelector,
IMAGE_TO_VIDEO_PAGE_MODEL_LIST,
useImageToVideoPageModelList,
ModelPage,
useSelectedVideoModel,
useSelectedProviderForModel,
@@ -56,6 +56,7 @@ interface ImageToVideoProps {
}
const ImageToVideo = ({ imageMediaId, imageUrl }: ImageToVideoProps) => {
const imageToVideoModelList = useImageToVideoPageModelList();
const startBatch = useImageToVideoStore((s) => s.startBatch);
const completeBatch = useImageToVideoStore((s) => s.completeBatch);
const promptContentRef = useRef<HTMLDivElement>(null);
@@ -215,7 +216,7 @@ const ImageToVideo = ({ imageMediaId, imageUrl }: ImageToVideoProps) => {
modelSelector={
<ClassyModelSelector
variant="embedded"
items={IMAGE_TO_VIDEO_PAGE_MODEL_LIST}
items={imageToVideoModelList}
page={PAGE_ID}
/>
}
File diff suppressed because one or more lines are too long
@@ -44,6 +44,11 @@ interface ClassyModelSelectorProps {
showProviderSelection?: boolean;
}
// Model instances are rebuilt when the backend listing hydrates, so compare by
// tauriId rather than object identity.
const isSameModel = (a: Model | undefined, b: Model | undefined): boolean =>
a !== undefined && b !== undefined && a.tauriId === b.tauriId;
const DEFAULT_PROVIDER_OPTIONS: GenerationProvider[] = [GenerationProvider.Artcraft];
function ProviderTooltipContent({
@@ -143,6 +148,20 @@ export function ClassyModelSelector({
}
}, []);
// The backend listing hydrates asynchronously and rebuilds the model
// instances with API capabilities. Swap a stale selected instance for the
// fresh one so capability-driven UI (keyframes, references, pickers)
// reflects the API data without needing a manual re-select.
useEffect(() => {
const selected = selectedModels[page];
if (!selected) return;
const fresh = itemModels.find((m) => m.tauriId === selected.tauriId);
if (fresh !== undefined && fresh !== selected) {
setSelectedModel(page, fresh);
}
// 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
useEffect(() => {
for (const item of items) {
@@ -175,7 +194,7 @@ export function ClassyModelSelector({
return {
...item,
selected: item.model === selectedModel,
selected: isSameModel(item.model as Model | undefined, selectedModel),
hoverTooltip: hasMultipleProviders
? (close: () => void) => (
<ProviderTooltipContent
@@ -190,7 +209,7 @@ export function ClassyModelSelector({
: undefined,
tooltipDelayMs: providerTooltipDelayMs,
trailing:
item.model !== selectedModel && hasMultipleProviders
!isSameModel(item.model as Model | undefined, selectedModel) && hasMultipleProviders
? (() => {
const prov = modelId
? selectedProvidersByModel[modelId]
@@ -206,7 +225,7 @@ export function ClassyModelSelector({
})()
: undefined,
selectedRight:
item.model === selectedModel &&
isSameModel(item.model as Model | undefined, selectedModel) &&
selectedProvider &&
hasMultipleProviders ? (
<div className="mr-1 rounded-md p-1.5 bg-primary/60 group-hover:bg-primary/80 transition-colors">
@@ -265,7 +284,7 @@ export function ClassyModelSelector({
{...popoverProps}
buttonClassName="rounded-xl bg-ui-controls/90 hover:bg-ui-controls text-left shadow-sm px-3 py-1 gap-3 border border-ui-controls-border"
renderTrigger={(selectedItem) => {
const modelTitle = selectedItem?.label ?? "";
const modelTitle = selectedItem?.label ?? selectedModel?.selectorName ?? "";
const providerIcon = selectedProvider
? getProviderIcon(selectedProvider)
: null;
@@ -6,35 +6,37 @@ import {
} from "@storyteller/model-list";
import { ModelPage } from "./model-pages";
const DEFAULT_MODEL_ID_FOR_PAGE: Partial<Record<ModelPage, string>> = {
[ModelPage.TextToImage]: "nano_banana_pro",
[ModelPage.ImageToVideo]: "seedance_2p0",
[ModelPage.Canvas2D]: "gpt_image_1p5",
[ModelPage.Stage3D]: "gpt_image_1p5",
[ModelPage.ImageEditor]: "nano_banana_pro",
[ModelPage.ImageTo3DWorld]: "marble_0p1_mini",
[ModelPage.Angles]: "flux_2_lora_angles",
};
export const defaultModelForPage = (
models: Model[],
page: ModelPage,
): Model => {
let imageModel: Model | undefined;
const defaultId = DEFAULT_MODEL_ID_FOR_PAGE[page];
switch (page) {
case ModelPage.TextToImage:
imageModel = IMAGE_MODELS_BY_ID.get("nano_banana_pro");
break;
case ModelPage.ImageToVideo:
imageModel = VIDEO_MODELS_BY_ID.get("seedance_2p0");
break;
case ModelPage.Canvas2D:
imageModel = IMAGE_MODELS_BY_ID.get("gpt_image_1p5");
break;
case ModelPage.Stage3D:
imageModel = IMAGE_MODELS_BY_ID.get("gpt_image_1p5");
break;
case ModelPage.ImageEditor:
imageModel = IMAGE_MODELS_BY_ID.get("nano_banana_pro");
break;
case ModelPage.ImageTo3DWorld:
imageModel = SPLAT_MODELS_BY_ID.get("marble_0p1_mini");
break;
case ModelPage.Angles:
imageModel = IMAGE_MODELS_BY_ID.get("flux_2_lora_angles");
break;
if (defaultId) {
// Prefer the instance from the caller's (backend-hydrated) list — the
// static map instances are presentation-only fallbacks without the API's
// capability data.
const fromList = models.find(
(m) => m.id === defaultId || m.tauriId === defaultId,
);
if (fromList) return fromList;
const fromStaticMaps =
IMAGE_MODELS_BY_ID.get(defaultId) ??
VIDEO_MODELS_BY_ID.get(defaultId) ??
SPLAT_MODELS_BY_ID.get(defaultId);
if (fromStaticMaps) return fromStaticMaps;
}
return imageModel || models[0];
return models[0];
};
@@ -1,24 +1,25 @@
import { useMemo } from "react";
import type { PopoverItem } from "@storyteller/ui-popover";
import { faCube, faFilm, faImage } from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
getCreatorIcon,
Model,
ImageModel,
VideoModel,
VIDEO_MODELS,
IMAGE_MODELS,
SPLAT_MODELS,
OBJECT_3D_MODELS,
} from "@storyteller/model-list";
import { ModelTag } from "@storyteller/model-list";
import { useImageModels, useVideoModels } from "@storyteller/tauri-api";
export type ModelList = Omit<PopoverItem, "selected">[];
const withIcon = (creatorIcon: any, fallback: any) => creatorIcon || fallback;
const buildItems = (
models: Model[],
fallbackIcon: any
) =>
const buildItems = (models: Model[], fallbackIcon: any) =>
models.map((model: Model) => ({
label: model.selectorName,
icon: withIcon(getCreatorIcon(model.creator), fallbackIcon),
@@ -27,113 +28,137 @@ const buildItems = (
model: model,
}));
const sortedBySelectorName = <T extends Model>(models: T[]): T[] => {
// De-dupe while preserving instances, then sort by selector name.
const list = Array.from(new Set(models));
list.sort((a, b) => a.selectorName?.localeCompare(b.selectorName));
return list;
};
const imageIcon = <FontAwesomeIcon icon={faImage} className="h-4 w-4" />;
const filmIcon = <FontAwesomeIcon icon={faFilm} className="h-4 w-4" />;
const cubeIcon = <FontAwesomeIcon icon={faCube} className="h-4 w-4" />;
/**
* IN-PROGRESS MIGRATION (messy for now)
* We're gradually going to phase out ModelList, ModelConfig, etc.
* We won't index by name, but rather id, or simply will always have full
* access to the object directly.
* Per-page model subsetting. These pages show a slice of the full model set
* based on model CAPABILITY FLAGS (canTextToImage, canEditImages, canEditAngles,
* the InstructiveEdit tag, etc.). Those flags do not exist in the storyteller-web
* omni backend, so this subsetting is maintained here in the frontend overlay.
*
* Each builder is a pure function over a model list, so it can run against either
* the static overlay (the `*_PAGE_MODEL_LIST` constants below) or the live,
* backend-reconciled list from `useModelsStore` (the `use*PageModelList` hooks).
*/
export const TEXT_TO_IMAGE_PAGE_MODEL_LIST: ModelList =
export const buildTextToImagePageList = (imageModels: ImageModel[]): ModelList =>
buildItems(
(function (): Model[] {
const set: Set<Model> = new Set();
IMAGE_MODELS
.filter((model) => model.canTextToImage)
.forEach((m) => set.add(m));
const list = Array.from(set);
list.sort((a, b) => a.selectorName?.localeCompare(b.selectorName));
return list;
})(),
<FontAwesomeIcon icon={faImage} className="h-4 w-4" />
sortedBySelectorName(imageModels.filter((m) => m.canTextToImage)),
imageIcon,
);
export const buildCanvas2dPageList = (imageModels: ImageModel[]): ModelList =>
buildItems(
sortedBySelectorName(
imageModels.filter(
(m) => m.canEditImages || m.tags?.includes(ModelTag.InstructiveEdit),
),
),
imageIcon,
);
export const buildStage3dPageList = (imageModels: ImageModel[]): ModelList =>
buildItems(
sortedBySelectorName(
imageModels.filter((m) => m.tags?.includes(ModelTag.InstructiveEdit)),
),
imageIcon,
);
export const buildImageEditorPageList = (imageModels: ImageModel[]): ModelList =>
buildItems(
sortedBySelectorName(imageModels.filter((m) => m.canEditImages)),
imageIcon,
);
export const buildAnglesPageList = (imageModels: ImageModel[]): ModelList =>
buildItems(
sortedBySelectorName(imageModels.filter((m) => m.canEditAngles)),
imageIcon,
);
export const buildImageToVideoPageList = (videoModels: VideoModel[]): ModelList =>
buildItems(
// SwitchX is a VFX/background-change model with its own page; keep it
// out of the general video selector.
sortedBySelectorName(videoModels.filter((m) => m.id !== "switch_x")),
filmIcon,
);
// ---------------------------------------------------------------------------
// Backward-compatible static constants (computed from the frontend overlay).
// Prefer the `use*PageModelList` hooks in React components so the dropdowns
// reflect the backend-reconciled model set.
// ---------------------------------------------------------------------------
export const TEXT_TO_IMAGE_PAGE_MODEL_LIST: ModelList =
buildTextToImagePageList(IMAGE_MODELS);
export const CANVAS_2D_PAGE_MODEL_LIST: ModelList =
buildItems(
(function (): Model[] {
const set: Set<Model> = new Set();
IMAGE_MODELS
.filter((m) => m.canEditImages || m.tags?.includes(ModelTag.InstructiveEdit))
.forEach((m) => set.add(m));
const list = Array.from(set);
list.sort((a, b) => a.selectorName?.localeCompare(b.selectorName));
return list;
})(),
<FontAwesomeIcon icon={faImage} className="h-4 w-4" />
);
buildCanvas2dPageList(IMAGE_MODELS);
export const STAGE_3D_PAGE_MODEL_LIST: ModelList =
buildItems(
(function (): Model[] {
const set: Set<Model> = new Set();
IMAGE_MODELS
.filter((m) => m.tags?.includes(ModelTag.InstructiveEdit))
.forEach((m) => set.add(m));
const list = Array.from(set);
list.sort((a, b) => a.selectorName?.localeCompare(b.selectorName));
return list;
})(),
<FontAwesomeIcon icon={faImage} className="h-4 w-4" />
);
buildStage3dPageList(IMAGE_MODELS);
export const IMAGE_EDITOR_PAGE_MODEL_LIST: ModelList =
buildItems(
//[
// ALL_MODELS_BY_ID.get("flux_pro_inpaint")!,
// ALL_MODELS_BY_ID.get("flux_dev_juggernaut_inpaint")!,
// ALL_MODELS_BY_ID.get("flux_pro_kontext_max")!,
//],
(function (): Model[] {
const set: Set<Model> = new Set();
//set.add(IMAGE_MODELS_BY_ID.get("gpt_image_1")!); // Place gpt_image_1 first.
IMAGE_MODELS
.filter((m) => m.canEditImages)
.forEach((m) => set.add(m));
const list = Array.from(set);
list.sort((a, b) => a.selectorName?.localeCompare(b.selectorName));
return list;
})(),
<FontAwesomeIcon icon={faImage} className="h-4 w-4" />
);
buildImageEditorPageList(IMAGE_MODELS);
export const IMAGE_TO_VIDEO_PAGE_MODEL_LIST: ModelList =
buildItems(
(function (): Model[] {
const set: Set<Model> = new Set();
// SwitchX is a VFX/background-change model with its own page; keep it
// out of the general video selector.
VIDEO_MODELS.filter((m) => m.id !== "switch_x").forEach((m) =>
set.add(m),
);
const list = Array.from(set);
list.sort((a, b) => a.selectorName?.localeCompare(b.selectorName));
return list;
})(),
<FontAwesomeIcon icon={faFilm} className="h-4 w-4" />
);
buildImageToVideoPageList(VIDEO_MODELS);
export const ANGLES_PAGE_MODEL_LIST: ModelList =
buildItems(
(function (): Model[] {
const set: Set<Model> = new Set();
IMAGE_MODELS
.filter((m) => m.canEditAngles)
.forEach((m) => set.add(m));
const list = Array.from(set);
list.sort((a, b) => a.selectorName?.localeCompare(b.selectorName));
return list;
})(),
<FontAwesomeIcon icon={faImage} className="h-4 w-4" />
);
buildAnglesPageList(IMAGE_MODELS);
export const IMAGE_TO_3D_WORLD_PAGE_MODEL_LIST: ModelList =
buildItems(
SPLAT_MODELS as Model[],
<FontAwesomeIcon icon={faCube} className="h-4 w-4" />
);
export const IMAGE_TO_3D_WORLD_PAGE_MODEL_LIST: ModelList = buildItems(
SPLAT_MODELS as Model[],
cubeIcon,
);
export const IMAGE_TO_3D_OBJECT_PAGE_MODEL_LIST: ModelList =
buildItems(
OBJECT_3D_MODELS as Model[],
<FontAwesomeIcon icon={faCube} className="h-4 w-4" />
);
export const IMAGE_TO_3D_OBJECT_PAGE_MODEL_LIST: ModelList = buildItems(
OBJECT_3D_MODELS as Model[],
cubeIcon,
);
// ---------------------------------------------------------------------------
// Live, backend-reconciled hooks. Use these in React components; they fall back
// to the overlay until `loadModelsFromBackend()` completes at app startup.
// ---------------------------------------------------------------------------
export const useTextToImagePageModelList = (): ModelList => {
const imageModels = useImageModels();
return useMemo(() => buildTextToImagePageList(imageModels), [imageModels]);
};
export const useCanvas2dPageModelList = (): ModelList => {
const imageModels = useImageModels();
return useMemo(() => buildCanvas2dPageList(imageModels), [imageModels]);
};
export const useStage3dPageModelList = (): ModelList => {
const imageModels = useImageModels();
return useMemo(() => buildStage3dPageList(imageModels), [imageModels]);
};
export const useImageEditorPageModelList = (): ModelList => {
const imageModels = useImageModels();
return useMemo(() => buildImageEditorPageList(imageModels), [imageModels]);
};
export const useAnglesPageModelList = (): ModelList => {
const imageModels = useImageModels();
return useMemo(() => buildAnglesPageList(imageModels), [imageModels]);
};
export const useImageToVideoPageModelList = (): ModelList => {
const videoModels = useVideoModels();
return useMemo(() => buildImageToVideoPageList(videoModels), [videoModels]);
};
@@ -24,7 +24,7 @@ import { ContextMenuContainer } from "./components/ui/ContextMenu";
import InpaintToolBar from "./components/ui/InpaintToolBar";
import { ImageModel } from "@storyteller/model-list";
import {
CANVAS_2D_PAGE_MODEL_LIST,
useCanvas2dPageModelList,
ClassyModelSelector,
ModelPage,
useSelectedImageModel,
@@ -293,6 +293,7 @@ const PageDraw = ({
fillParentHeight = false,
showBottomRightControls = true,
}: PageDrawProps) => {
const canvas2dModelList = useCanvas2dPageModelList();
const canvasWidth = useRef<number>(1024);
const canvasHeight = useRef<number>(1024);
const [isSelecting, setIsSelecting] = useState<boolean>(false);
@@ -1215,7 +1216,7 @@ const PageDraw = ({
modelSelector={
<ClassyModelSelector
variant="embedded"
items={CANVAS_2D_PAGE_MODEL_LIST}
items={canvas2dModelList}
page={PAGE_ID}
showProviderSelection={false}
/>
@@ -20,7 +20,7 @@ import {
removeImageDropListener,
} from "@storyteller/ui-gallery-modal";
import {
STAGE_3D_PAGE_MODEL_LIST,
useStage3dPageModelList,
ModelPage,
defaultModelForPage,
useClassyModelSelectorStore,
@@ -109,6 +109,7 @@ export const Stage3DBody = ({
topBarStartSlot,
topBarEndSlot,
}: Stage3DBodyProps = {}) => {
const stage3dModelList = useStage3dPageModelList();
const camAspect = usePageSceneStore((s) => s.cameraAspectRatio);
const outlinerShowing = usePageSceneStore((s) => s.outlinerShowing);
const editorLoader = usePageSceneStore((s) => s.editorLoader);
@@ -165,20 +166,25 @@ export const Stage3DBody = ({
useEffect(() => {
if (modelSelectorPlacement !== "prompt-box") return;
if (selectedImageModel) return;
const models = STAGE_3D_PAGE_MODEL_LIST.map((i) => i.model).filter(
const models = stage3dModelList.map((i) => i.model).filter(
(m): m is NonNullable<typeof m> => m !== undefined,
);
const def = defaultModelForPage(models, PAGE_ID);
if (def) setSelectedModel(PAGE_ID, def);
}, [modelSelectorPlacement, selectedImageModel, setSelectedModel]);
}, [
modelSelectorPlacement,
selectedImageModel,
setSelectedModel,
stage3dModelList,
]);
const inlineModelItems: PopoverItem[] = useMemo(
() =>
STAGE_3D_PAGE_MODEL_LIST.map((item) => ({
stage3dModelList.map((item) => ({
...item,
selected: item.model === selectedImageModel,
})),
[selectedImageModel],
[selectedImageModel, stage3dModelList],
);
const handleInlineModelSelect = useCallback(
(item: PopoverItem) => {
@@ -188,9 +194,9 @@ export const Stage3DBody = ({
);
const selectedModelIcon = useMemo(
() =>
STAGE_3D_PAGE_MODEL_LIST.find((i) => i.model === selectedImageModel)
stage3dModelList.find((i) => i.model === selectedImageModel)
?.icon,
[selectedImageModel],
[selectedImageModel, stage3dModelList],
);
const inlineModelSelector =
modelSelectorPlacement === "prompt-box" ? (
@@ -536,7 +542,7 @@ export const Stage3DBody = ({
{modelSelectorPlacement === "bottom-left" && (
<div className="absolute bottom-6 left-6 z-20 flex items-center gap-3">
<ClassyModelSelector
items={STAGE_3D_PAGE_MODEL_LIST}
items={stage3dModelList}
page={PAGE_ID}
panelTitle="Select Model"
panelClassName="min-w-[300px]"
@@ -1,41 +1,26 @@
import { CommonImageModel } from "@storyteller/api-enums";
// TODO(bt): This shouldn't exist. We need to standardize types throughout the frontend.
// MIGRATION (2026-07): model `tauriId`s are now the storyteller-web omni
// identifiers, which are exactly the CommonImageModel values — so the common
// case is a direct pass-through. The switch below only remains to translate
// LEGACY tauri ids (old bookmarks/state); do not add new cases.
const COMMON_IMAGE_MODEL_VALUES: Set<string> = new Set(
Object.values(CommonImageModel),
);
export function imageModelToCommonImageModel(
tauriId: string,
): CommonImageModel | null {
if (COMMON_IMAGE_MODEL_VALUES.has(tauriId)) {
return tauriId as CommonImageModel;
}
switch (tauriId) {
case "flux_1_dev":
return CommonImageModel.Flux1Dev;
case "flux_1_schnell":
return CommonImageModel.Flux1Schnell;
case "flux_pro_11":
return CommonImageModel.FluxPro11;
case "flux_pro_11_ultra":
return CommonImageModel.FluxPro11Ultra;
case "gpt_image_1":
return CommonImageModel.GptImage1;
case "gpt_image_1p5":
return CommonImageModel.GptImage1p5;
case "gpt_image_2":
return CommonImageModel.GptImage2;
case "nano_banana":
return CommonImageModel.NanaBanana;
case "nano_banana_2":
return CommonImageModel.NanaBanana2;
case "nano_banana_pro":
return CommonImageModel.NanaBananaPro;
case "seedream_4":
return CommonImageModel.Seedream4;
case "seedream_4p5":
return CommonImageModel.Seedream4p5;
case "seedream_5_lite":
return CommonImageModel.Seedream5Lite;
case "qwen_edit_2511_angles":
return CommonImageModel.QwenEdit2511Angles;
case "flux_2_lora_angles":
return CommonImageModel.Flux2LoraAngles;
default:
return null;
}
@@ -1,49 +1,34 @@
import { CommonVideoModel } from "@storyteller/api-enums";
// TODO(bt): This shouldn't exist. We need to standardize types throughout the frontend.
// MIGRATION (2026-07): model `tauriId`s are now the storyteller-web omni
// identifiers, which are exactly the CommonVideoModel values — so the common
// case is a direct pass-through. The switch below only remains to translate
// LEGACY tauri ids (old bookmarks/state); do not add new cases.
const COMMON_VIDEO_MODEL_VALUES: Set<string> = new Set(
Object.values(CommonVideoModel),
);
export function videoModelToCommonVideoModel(
tauriId: string,
): CommonVideoModel | null {
if (COMMON_VIDEO_MODEL_VALUES.has(tauriId)) {
return tauriId as CommonVideoModel;
}
switch (tauriId) {
case "grok_video":
return CommonVideoModel.GrokVideo;
case "kling_1.6_pro":
return CommonVideoModel.Kling16Pro;
case "kling_2.1_pro":
return CommonVideoModel.Kling21Pro;
case "kling_2.1_master":
return CommonVideoModel.Kling21Master;
case "kling_2p5_turbo_pro":
return CommonVideoModel.Kling2p5TurboPro;
case "kling_2p6_pro":
return CommonVideoModel.Kling2p6Pro;
case "kling_3p0_standard":
return CommonVideoModel.Kling3p0Standard;
case "kling_3p0_pro":
return CommonVideoModel.Kling3p0Pro;
case "seedance_1.0_lite":
return CommonVideoModel.Seedance10Lite;
case "seedance_1p5_pro":
return CommonVideoModel.Seedance1p5Pro;
case "seedance_2p0":
return CommonVideoModel.Seedance2p0;
case "seedance_2p0_fast":
return CommonVideoModel.Seedance2p0Fast;
case "sora_2":
return CommonVideoModel.Sora2;
case "sora_2_pro":
return CommonVideoModel.Sora2Pro;
case "veo_2":
return CommonVideoModel.Veo2;
case "veo_3":
return CommonVideoModel.Veo3;
case "veo_3_fast":
return CommonVideoModel.Veo3Fast;
case "veo_3p1":
return CommonVideoModel.Veo3p1;
case "veo_3p1_fast":
return CommonVideoModel.Veo3p1Fast;
case "grok_imagine_video":
// The frontend id for the generic Grok video experience; the (stale)
// CommonVideoModel enum still spells it "grok_video".
return CommonVideoModel.GrokVideo;
default:
return null;
}
@@ -12,13 +12,13 @@ import {
useSelectedModel,
useSelectedProviderForModel,
defaultModelForPage,
TEXT_TO_IMAGE_PAGE_MODEL_LIST,
IMAGE_TO_VIDEO_PAGE_MODEL_LIST,
CANVAS_2D_PAGE_MODEL_LIST,
STAGE_3D_PAGE_MODEL_LIST,
IMAGE_EDITOR_PAGE_MODEL_LIST,
useTextToImagePageModelList,
useImageToVideoPageModelList,
useCanvas2dPageModelList,
useStage3dPageModelList,
useImageEditorPageModelList,
useAnglesPageModelList,
IMAGE_TO_3D_WORLD_PAGE_MODEL_LIST,
ANGLES_PAGE_MODEL_LIST,
} from "@storyteller/ui-model-selector";
import {
usePrompt2DStore,
@@ -49,41 +49,11 @@ const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
worldlabs: "World Labs",
};
// Get models list for a page
const getModelsForPage = (page: ModelPage | null): Model[] => {
switch (page) {
case ModelPage.TextToImage:
return TEXT_TO_IMAGE_PAGE_MODEL_LIST.map((item) => item.model).filter(
(m): m is Model => m !== undefined,
);
case ModelPage.ImageToVideo:
return IMAGE_TO_VIDEO_PAGE_MODEL_LIST.map((item) => item.model).filter(
(m): m is Model => m !== undefined,
);
case ModelPage.Canvas2D:
return CANVAS_2D_PAGE_MODEL_LIST.map((item) => item.model).filter(
(m): m is Model => m !== undefined,
);
case ModelPage.Stage3D:
return STAGE_3D_PAGE_MODEL_LIST.map((item) => item.model).filter(
(m): m is Model => m !== undefined,
);
case ModelPage.ImageEditor:
return IMAGE_EDITOR_PAGE_MODEL_LIST.map((item) => item.model).filter(
(m): m is Model => m !== undefined,
);
case ModelPage.ImageTo3DWorld:
return IMAGE_TO_3D_WORLD_PAGE_MODEL_LIST.map((item) => item.model).filter(
(m): m is Model => m !== undefined,
);
case ModelPage.Angles:
return ANGLES_PAGE_MODEL_LIST.map((item) => item.model).filter(
(m): m is Model => m !== undefined,
);
default:
return [];
}
};
// Extract the concrete `Model` instances out of a page's selectable item list.
const modelsFromList = (
list: { model?: Model }[],
): Model[] =>
list.map((item) => item.model).filter((m): m is Model => m !== undefined);
export interface CostBreakdownModalProps {
/** The current active tab ID from the app (e.g. "IMAGE", "VIDEO", "2D", "3D") */
@@ -106,11 +76,47 @@ export function CostBreakdownModal({ activeTabId }: CostBreakdownModalProps) {
return TAB_TO_MODEL_PAGE[activeTabId] ?? ModelPage.TextToImage;
}, [activeTabId]);
// Backend-reconciled model lists per page (fall back to the overlay until the
// startup fetch completes).
const textToImageList = useTextToImagePageModelList();
const imageToVideoList = useImageToVideoPageModelList();
const canvas2dList = useCanvas2dPageModelList();
const stage3dList = useStage3dPageModelList();
const imageEditorList = useImageEditorPageModelList();
const anglesList = useAnglesPageModelList();
// Get the selected model for the active page
const selectedModelFromStore = useSelectedModel(activePage);
// If no model selected in store, use the default for this page
const modelsForPage = getModelsForPage(activePage);
const modelsForPage = useMemo<Model[]>(() => {
switch (activePage) {
case ModelPage.TextToImage:
return modelsFromList(textToImageList);
case ModelPage.ImageToVideo:
return modelsFromList(imageToVideoList);
case ModelPage.Canvas2D:
return modelsFromList(canvas2dList);
case ModelPage.Stage3D:
return modelsFromList(stage3dList);
case ModelPage.ImageEditor:
return modelsFromList(imageEditorList);
case ModelPage.ImageTo3DWorld:
return modelsFromList(IMAGE_TO_3D_WORLD_PAGE_MODEL_LIST);
case ModelPage.Angles:
return modelsFromList(anglesList);
default:
return [];
}
}, [
activePage,
textToImageList,
imageToVideoList,
canvas2dList,
stage3dList,
imageEditorList,
anglesList,
]);
const selectedModel =
selectedModelFromStore ?? defaultModelForPage(modelsForPage, activePage);
@@ -273,8 +279,10 @@ export function CostBreakdownModal({ activeTabId }: CostBreakdownModalProps) {
const MODELS_WITH_COST_DATA = new Set([
"flux_1_dev",
"flux_1_schnell",
"flux_pro_11",
"flux_pro_11_ultra",
"flux_pro_11", // Legacy id
"flux_pro_1p1",
"flux_pro_11_ultra", // Legacy id
"flux_pro_1p1_ultra",
"gpt_image_1p5",
"gpt_image_2",
"nano_banana",
@@ -826,7 +826,8 @@ export const PromptBoxVideo = ({
}
switch (selectedModel?.tauriId) {
case "grok_video":
case "grok_video": // Legacy id
case "grok_imagine_video":
request.grok_aspect_ratio = getGrokAspectRatio();
break;
+2 -2
View File
@@ -22,5 +22,5 @@ export * from "./lib/classes/SplatModel.js";
export * from "./lib/lists/SplatModels.js";
export * from "./lib/classes/Object3DModel.js";
export * from "./lib/lists/Object3DModels.js";
export * from "./lib/loader/modelReconciliation.js";
export * from "./lib/loader/buildModelsFromListing.js";
@@ -11,7 +11,7 @@ import { CommonQuality } from "../classes/properties/CommonQuality.js";
export const IMAGE_MODELS: ImageModel[] = [
new ImageModel({
id: "grok_image",
tauriId: "grok_image",
tauriId: "grok_imagine_image",
fullName: "Grok",
category: "image",
creator: ModelCreator.Grok,
@@ -58,7 +58,7 @@ export const IMAGE_MODELS: ImageModel[] = [
}),
new ImageModel({
id: "flux_pro_1_1_ultra",
tauriId: "flux_pro_11_ultra",
tauriId: "flux_pro_1p1_ultra",
fullName: "Flux Pro 1.1 Ultra",
category: "image",
creator: ModelCreator.BlackForestLabs,
@@ -87,7 +87,7 @@ export const IMAGE_MODELS: ImageModel[] = [
}),
new ImageModel({
id: "flux_pro_1_1",
tauriId: "flux_pro_11",
tauriId: "flux_pro_1p1",
fullName: "Flux Pro 1.1",
category: "image",
creator: ModelCreator.BlackForestLabs,
@@ -6,7 +6,7 @@ import { SizeIconOption } from "../classes/metadata/SizeOption.js";
export const VIDEO_MODELS: VideoModel[] = [
new VideoModel({
id: "grok_video",
tauriId: "grok_video",
tauriId: "grok_imagine_video",
fullName: "Grok Video",
category: "video",
creator: ModelCreator.Grok,
@@ -43,7 +43,9 @@ export const VIDEO_MODELS: VideoModel[] = [
fullName: "Grok Imagine 1.5",
category: "video",
creator: ModelCreator.Grok,
providers: [GenerationProvider.Grok],
// Served via Artcraft (storyteller-web / artcraft_router) for now, even
// though the model is made by xAI. See generate_video_command.rs.
providers: [GenerationProvider.Artcraft],
selectorName: "Grok Imagine 1.5",
selectorDescription: "Image-to-video by xAI",
selectorBadges: ["Preview"],
@@ -75,7 +77,7 @@ export const VIDEO_MODELS: VideoModel[] = [
}),
new VideoModel({
id: "kling_1_6_pro",
tauriId: "kling_1.6_pro",
tauriId: "kling_1p6_pro",
fullName: "Kling 1.6 Pro",
category: "video",
creator: ModelCreator.Kling,
@@ -91,7 +93,7 @@ export const VIDEO_MODELS: VideoModel[] = [
}),
new VideoModel({
id: "kling_2_1_pro",
tauriId: "kling_2.1_pro",
tauriId: "kling_2p1_pro",
fullName: "Kling 2.1 Pro",
category: "video",
creator: ModelCreator.Kling,
@@ -107,7 +109,7 @@ export const VIDEO_MODELS: VideoModel[] = [
}),
new VideoModel({
id: "kling_2_1_master",
tauriId: "kling_2.1_master",
tauriId: "kling_2p1_master",
fullName: "Kling 2.1 Master",
category: "video",
creator: ModelCreator.Kling,
@@ -274,7 +276,7 @@ export const VIDEO_MODELS: VideoModel[] = [
}),
new VideoModel({
id: "seedance_1_0_lite",
tauriId: "seedance_1.0_lite",
tauriId: "seedance_1p0_lite",
fullName: "Seedance 1.0 Lite",
category: "video",
creator: ModelCreator.Bytedance,
@@ -0,0 +1,396 @@
// Builds the picker model lists FROM the backend omni listing (the Tauri
// command response). Membership + order come from the backend, and every
// capability the API expresses is mapped 1:1 — the static overlay lists
// (`IMAGE_MODELS` / `VIDEO_MODELS`) only contribute what the API does not
// return:
// - presentation (selectorName/Description/Badges, tags, progressBarTime)
// - page-subsetting flags (canTextToImage, canEditImages, canEditAngles, …)
// - desktop-native provider knowledge (`providers`)
// - models the backend registry doesn't know (switch_x, inpaint models, …)
//
// API capability fields ALWAYS win over the overlay's copies, so the overlay's
// capability data is dead at runtime for backend-known models and can be
// deleted from the lists.
//
// Pure module (no tauri-api import). The store in `@storyteller/tauri-api`
// feeds it the command payload's `models[]`; the DTO is accepted structurally.
import { ImageModel } from "../classes/ImageModel.js";
import { VideoModel } from "../classes/VideoModel.js";
import { ModelCreator } from "../classes/metadata/ModelCreator.js";
import { SizeIconOption, SizeOption } from "../classes/metadata/SizeOption.js";
import { CommonAspectRatio } from "../classes/properties/CommonAspectRatio.js";
import { CommonResolution } from "../classes/properties/CommonResolution.js";
import { CommonQuality } from "../classes/properties/CommonQuality.js";
import {
BACKEND_TO_TAURI_IMAGE_ID,
BACKEND_TO_TAURI_VIDEO_ID,
modelCreatorFromBackend,
} from "./modelReconciliation.js";
// ── Structural shapes of the backend listing (subset we read) ──────────────
export interface ListingModelBase {
model: string;
model_creator?: string | null;
full_name?: string | null;
text_prompt_supported?: boolean | null;
text_prompt_max_length?: number | null;
batch_size_max?: number | null;
batch_size_default?: number | null;
batch_size_options?: number[] | null;
aspect_ratio_options?: string[] | null;
aspect_ratio_default?: string | null;
resolution_options?: string[] | null;
resolution_default?: string | null;
quality_options?: string[] | null;
default_quality?: string | null;
is_disabled?: boolean | null;
}
export interface ListingImageModel extends ListingModelBase {
image_refs_supported?: boolean | null;
image_refs_max?: number | null;
}
export interface ListingVideoModel extends ListingModelBase {
extra_info?: string | null;
extra_info_short?: string | null;
text_to_video_supported?: boolean | null;
starting_keyframe_supported?: boolean | null;
starting_keyframe_required?: boolean | null;
ending_keyframe_supported?: boolean | null;
image_references_supported?: boolean | null;
image_references_max?: number | null;
video_references_max?: number | null;
video_references_max_total_duration_seconds?: number | null;
audio_references_max?: number | null;
audio_references_max_total_duration_seconds?: number | null;
show_generate_with_sound_toggle?: boolean | null;
duration_seconds_options?: number[] | null;
duration_seconds_default?: number | null;
}
// ── Public builders ────────────────────────────────────────────────────────
export const buildImageModelsFromListing = (
overlay: ImageModel[],
listing: ListingImageModel[],
offeredModelIds: string[] = [],
): ImageModel[] =>
build(overlay, listing, offeredModelIds, BACKEND_TO_TAURI_IMAGE_ID, mergedImageModel);
export const buildVideoModelsFromListing = (
overlay: VideoModel[],
listing: ListingVideoModel[],
offeredModelIds: string[] = [],
): VideoModel[] =>
build(overlay, listing, offeredModelIds, BACKEND_TO_TAURI_VIDEO_ID, mergedVideoModel);
// ── Core assembly ──────────────────────────────────────────────────────────
// Membership: a model appears in the picker when it is
// 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
// 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
// wins when a model is detailed twice).
const build = <T extends { tauriId: string }, L extends ListingModelBase>(
overlay: T[],
listing: L[],
offeredModelIds: string[],
alias: Record<string, string>,
merge: (m: L, tauriId: string, overlayEntry: T | undefined) => T,
): T[] => {
const toTauriId = (backendId: string) => alias[backendId] ?? backendId;
const overlayByTauriId = new Map(overlay.map((m) => [m.tauriId, m]));
const knownTauriIds = new Set(listing.map((m) => toTauriId(m.model)));
const offeredTauriIds = new Set(offeredModelIds.map(toTauriId));
// Capability lookup — prefer the enabled detail entry when duplicated.
const detailsByTauriId = new Map<string, L>();
for (const m of listing) {
const tauriId = toTauriId(m.model);
const existing = detailsByTauriId.get(tauriId);
if (existing === undefined || existing.is_disabled === true) {
detailsByTauriId.set(tauriId, m);
}
}
const result: T[] = [];
const seenTauriIds = new Set<string>();
// Backend details order drives ordering.
for (const m of listing) {
const tauriId = toTauriId(m.model);
if (seenTauriIds.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);
if (!show) continue;
seenTauriIds.add(tauriId);
result.push(merge(detail, tauriId, overlayEntry));
}
// Offered models with no detail entry at all (unusual, but the publish
// switch wins): surface minimally.
for (const backendId of offeredModelIds) {
const tauriId = toTauriId(backendId);
if (seenTauriIds.has(tauriId)) continue;
seenTauriIds.add(tauriId);
result.push(
merge({ model: backendId } as L, tauriId, overlayByTauriId.get(tauriId)),
);
}
// Append frontend-only models the backend has never heard of.
for (const m of overlay) {
if (!knownTauriIds.has(m.tauriId) && !seenTauriIds.has(m.tauriId)) {
result.push(m);
}
}
return result;
};
// ── 1:1 merge constructors (API capability > overlay; presentation ← overlay)
const mergedImageModel = (
m: ListingImageModel,
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 fullName = m.full_name ?? o?.fullName ?? m.model;
return new ImageModel({
// Identity. Keep the overlay's `id` (BY_ID lookups / history) when known.
id: o?.id ?? tauriId,
tauriId,
fullName,
category: "image",
creator: creatorFor(m.model_creator, m.model, o?.creator),
// Presentation — the omni image API has no display fields yet.
selectorName: o?.selectorName ?? fullName,
selectorDescription: o?.selectorDescription ?? "",
selectorBadges: o?.selectorBadges ?? [],
tags: o?.tags ?? [],
progressBarTime: o?.progressBarTime,
// Page-subsetting flags — frontend-owned concepts.
canTextToImage: o ? o.canTextToImage : m.text_prompt_supported !== false,
canEditImages: o?.canEditImages ?? false,
usesInpaintingMask: o?.usesInpaintingMask ?? false,
editingIsInpainting: o?.editingIsInpainting ?? false,
canEditAngles: o?.canEditAngles ?? false,
// Desktop-native provider knowledge.
providers: o?.getProviders(),
// Capabilities — served by the API, overlay only as a transitional
// fallback where the backend config leaves a field unset.
maxPromptLength: m.text_prompt_max_length ?? o?.maxPromptLength,
maxGenerationCount: m.batch_size_max ?? o?.maxGenerationCount ?? 4,
defaultGenerationCount: m.batch_size_default ?? o?.defaultGenerationCount ?? 1,
predefinedGenerationCounts: m.batch_size_options ?? o?.predefinedGenerationCounts,
canUseImagePrompt: m.image_refs_supported ?? o?.canUseImagePrompt ?? false,
maxImagePromptCount: m.image_refs_max ?? o?.maxImagePromptCount ?? 1,
canChangeAspectRatio:
aspectRatios.length > 0 || (o?.canChangeAspectRatio ?? false),
aspectRatios:
aspectRatios.length > 0 ? (aspectRatios as CommonAspectRatio[]) : o?.aspectRatios,
defaultAspectRatio:
(knownValue(m.aspect_ratio_default, COMMON_ASPECT_RATIO_VALUES) 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,
qualityOptions:
qualityOptions.length > 0 ? (qualityOptions as CommonQuality[]) : o?.qualityOptions,
defaultQuality:
(knownValue(m.default_quality, COMMON_QUALITY_VALUES) as
| CommonQuality
| undefined) ?? o?.defaultQuality,
});
};
const mergedVideoModel = (
m: ListingVideoModel,
tauriId: string,
o: VideoModel | undefined,
): VideoModel => {
const aspectRatios = knownValues(m.aspect_ratio_options, COMMON_ASPECT_RATIO_VALUES);
const fullName = m.full_name ?? o?.fullName ?? m.model;
return new VideoModel({
// Identity. Keep the overlay's `id` (BY_ID lookups / history) when known.
id: o?.id ?? tauriId,
tauriId,
fullName,
category: "video",
creator: creatorFor(m.model_creator, m.model, o?.creator),
// Presentation. The video API DOES have descriptions (extra_info_short /
// extra_info) — prefer them so backend copy reaches the picker.
selectorName: o?.selectorName ?? fullName,
selectorDescription:
m.extra_info_short ?? m.extra_info ?? o?.selectorDescription ?? "",
selectorBadges: o?.selectorBadges ?? [],
tags: o?.tags ?? [],
progressBarTime: o?.progressBarTime,
supportsSystemPrompt: o?.supportsSystemPrompt,
// Desktop-native provider knowledge.
providers: o?.getProviders(),
// Capabilities — served by the API, overlay only as a transitional
// fallback where the backend config leaves a field unset.
maxPromptLength: m.text_prompt_max_length ?? o?.maxPromptLength,
startFrame: m.starting_keyframe_supported ?? o?.startFrame ?? false,
endFrame: m.ending_keyframe_supported ?? o?.endFrame ?? false,
requiresImage: m.starting_keyframe_required ?? o?.requiresImage ?? false,
textToVideoSupported: m.text_to_video_supported ?? o?.textToVideoSupported,
generateWithSound: m.show_generate_with_sound_toggle ?? o?.generateWithSound,
durationOptions: m.duration_seconds_options ?? o?.durationOptions,
defaultDuration: m.duration_seconds_default ?? o?.defaultDuration,
supportsReferenceMode: m.image_references_supported ?? o?.supportsReferenceMode,
maxReferenceImages: m.image_references_max ?? o?.maxReferenceImages,
maxReferenceVideos: m.video_references_max ?? o?.maxReferenceVideos,
maxVideoRefDuration:
m.video_references_max_total_duration_seconds ?? o?.maxVideoRefDuration,
maxReferenceAudios: m.audio_references_max ?? o?.maxReferenceAudios,
maxAudioRefDuration:
m.audio_references_max_total_duration_seconds ?? o?.maxAudioRefDuration,
resolutionOptions:
m.resolution_options?.map(resolutionLabel) ?? o?.resolutionOptions,
defaultResolution: m.resolution_default
? resolutionLabel(m.resolution_default)
: o?.defaultResolution,
// Aspect handling: API aspect ratios drive the modern picker; legacy
// models (grok/sora native size UI) keep their overlay sizeOptions.
sizeOptions:
aspectRatios.length > 0
? aspectRatios.map(sizeOptionForAspectRatio)
: o?.sizeOptions,
supportsCommonAspectRatio:
aspectRatios.length > 0 || (o?.supportsCommonAspectRatio ?? false),
});
};
// ── 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.
*/
const sizeOptionForAspectRatio = (value: string): SizeOption => {
switch (value) {
case "square":
return { tauriValue: value, textLabel: "1:1", icon: SizeIconOption.Square };
case "square_hd":
return { tauriValue: value, textLabel: "1:1 HD", icon: SizeIconOption.Square };
case "wide_sixteen_by_nine":
return { tauriValue: value, textLabel: "16:9", icon: SizeIconOption.Landscape16x9 };
case "tall_nine_by_sixteen":
return { tauriValue: value, textLabel: "9:16", icon: SizeIconOption.Portrait9x16 };
case "wide_four_by_three":
return { tauriValue: value, textLabel: "4:3", icon: SizeIconOption.Standard4x3 };
case "tall_three_by_four":
return { tauriValue: value, textLabel: "3:4", icon: SizeIconOption.Portrait3x4 };
case "wide_three_by_two":
return { tauriValue: value, textLabel: "3:2", icon: SizeIconOption.Landscape };
case "tall_two_by_three":
return { tauriValue: value, textLabel: "2:3", icon: SizeIconOption.Portrait };
case "wide_five_by_four":
return { tauriValue: value, textLabel: "5:4", icon: SizeIconOption.Landscape };
case "tall_four_by_five":
return { tauriValue: value, textLabel: "4:5", icon: SizeIconOption.Portrait };
case "wide_twenty_one_by_nine":
return { tauriValue: value, textLabel: "21:9", icon: SizeIconOption.Landscape16x9 };
case "tall_nine_by_twenty_one":
return { tauriValue: value, textLabel: "9:21", icon: SizeIconOption.Portrait9x16 };
case "wide":
return { tauriValue: value, textLabel: "Landscape", icon: SizeIconOption.Landscape };
case "tall":
return { tauriValue: value, textLabel: "Portrait", icon: SizeIconOption.Portrait };
case "auto":
return { tauriValue: value, textLabel: "Auto", icon: SizeIconOption.Square };
default:
return { tauriValue: value, textLabel: value, icon: SizeIconOption.Square };
}
};
/**
* CommonResolution serde value -> the UI label form the video promptbox uses.
* (PromptBoxVideo's RESOLUTION_STRING_TO_COMMON accepts both forms; labels
* display better.)
*/
const resolutionLabel = (value: string): string => {
switch (value) {
case "four_eighty_p":
return "480p";
case "seven_twenty_p":
return "720p";
case "ten_eighty_p":
return "1080p";
default:
return value; // Raw enum value; still request-mappable.
}
};
// Guess a creator from the API's `model_creator`, then the overlay, then the
// model-id prefix, then ArtCraft.
const CREATOR_BY_PREFIX: Array<[string, ModelCreator]> = [
["flux", ModelCreator.BlackForestLabs],
["nano_banana", ModelCreator.Google],
["gpt_image", ModelCreator.OpenAi],
["seedream", ModelCreator.Bytedance],
["seedance", ModelCreator.Bytedance],
["kling", ModelCreator.Kling],
["sora", ModelCreator.OpenAi],
["veo", ModelCreator.Google],
["grok", ModelCreator.Grok],
["happy_horse", ModelCreator.Alibaba],
["midjourney", ModelCreator.Midjourney],
["qwen", ModelCreator.Alibaba],
];
const creatorFor = (
raw: string | null | undefined,
modelId: string,
overlayCreator: ModelCreator | undefined,
): ModelCreator => {
const mapped = modelCreatorFromBackend(raw ?? undefined);
if (mapped) return mapped;
if (overlayCreator) return overlayCreator;
for (const [prefix, creator] of CREATOR_BY_PREFIX) {
if (modelId.startsWith(prefix)) return creator;
}
return ModelCreator.ArtCraft;
};
@@ -0,0 +1,96 @@
// Reconciles the frontend model OVERLAY (the hand-maintained `IMAGE_MODELS` /
// `VIDEO_MODELS`, which carry all the UI/product metadata + page-capability flags)
// against the backend omni listing (`ListImageModels` / `ListVideoModels`).
//
// The backend is authoritative for WHICH models are offered; the overlay is
// authoritative for the metadata the backend doesn't have (selectorName, badges,
// tags, canTextToImage/canEditImages/canEditAngles, sizeOptions, durations, and
// the frontend-only models switch_x / inpaint / angle / kontext).
//
// This module is intentionally PURE (no tauri-api import) so `model-list` stays
// free of a circular dependency. The async loader that calls the Tauri commands
// lives in `tauri-api` and feeds `knownBackendIds` / `offeredBackendIds` here.
import { ImageModel } from "../classes/ImageModel.js";
import { VideoModel } from "../classes/VideoModel.js";
import { ModelCreator } from "../classes/metadata/ModelCreator.js";
/**
* Backend omni model id (the `CommonImageModel` serde string) -> frontend `tauriId`.
*
* MIGRATION (2026-07): RETIRED. The overlay `tauriId`s now use the
* storyteller-web omni identifiers directly, and the Tauri generate commands
* accept them, so no aliasing remains. Do not add entries — fix ids at the
* source instead. (Kept exported until the reconcile helpers are deleted.)
* See external/reports/2026-07/storyteller-web-omni-vs-tauri-model-differences.md.
*/
export const BACKEND_TO_TAURI_IMAGE_ID: Record<string, string> = {};
export const BACKEND_TO_TAURI_VIDEO_ID: Record<string, string> = {};
/** Backend `model_creator` snake_case string -> frontend `ModelCreator` enum. */
export const modelCreatorFromBackend = (raw?: string): ModelCreator | undefined => {
switch (raw) {
case "alibaba": return ModelCreator.Alibaba;
case "artcraft": return ModelCreator.ArtCraft;
case "black_forest_labs": return ModelCreator.BlackForestLabs;
case "bytedance": return ModelCreator.Bytedance;
case "fal": return ModelCreator.Fal;
case "google": return ModelCreator.Google;
case "grok": return ModelCreator.Grok;
case "hailuo": return ModelCreator.Hailuo;
case "higgsfield": return ModelCreator.Higgsfield;
case "kling": return ModelCreator.Kling;
case "krea": return ModelCreator.Krea;
case "midjourney": return ModelCreator.Midjourney;
case "open_ai": return ModelCreator.OpenAi;
case "open_art": return ModelCreator.OpenArt;
case "recraft": return ModelCreator.Recraft;
case "replicate": return ModelCreator.Replicate;
case "runway": return ModelCreator.Runway;
case "stability": return ModelCreator.Stability;
case "tencent": return ModelCreator.Tencent;
case "tensor_art": return ModelCreator.TensorArt;
case "vidu": return ModelCreator.Vidu;
case "world_labs": return ModelCreator.WorldLabs;
default: return undefined;
}
};
const toTauriIds = (backendIds: string[], alias: Record<string, string>): Set<string> =>
new Set(backendIds.map((id) => alias[id] ?? id));
/**
* Membership is BACKEND-DRIVEN, mirroring the web apps' `omni-gen-hooks` (which
* render `res.models.filter((m) => m.is_disabled !== true)`):
*
* - Keep an overlay model when the backend returns it ENABLED.
* - Drop it when the backend KNOWS the model but has it disabled (`is_disabled`).
* - Keep it when the backend has never heard of it — a frontend-only model like
* `switch_x` / inpaint / angles that has no omni counterpart.
*
* The overlay only supplies the UI metadata the backend lacks (selectorName,
* badges, tags, capability flags); the backend response decides what appears.
*/
const reconcile = <T extends { tauriId: string }>(
overlay: T[],
knownBackendIds: string[],
enabledBackendIds: string[],
alias: Record<string, string>,
): T[] => {
const known = toTauriIds(knownBackendIds, alias);
const enabled = toTauriIds(enabledBackendIds, alias);
return overlay.filter((m) => enabled.has(m.tauriId) || !known.has(m.tauriId));
};
export const reconcileImageModels = (
overlay: ImageModel[],
knownBackendIds: string[],
enabledBackendIds: string[],
): ImageModel[] => reconcile(overlay, knownBackendIds, enabledBackendIds, BACKEND_TO_TAURI_IMAGE_ID);
export const reconcileVideoModels = (
overlay: VideoModel[],
knownBackendIds: string[],
enabledBackendIds: string[],
): VideoModel[] => reconcile(overlay, knownBackendIds, enabledBackendIds, BACKEND_TO_TAURI_VIDEO_ID);
+4 -1
View File
@@ -16,6 +16,8 @@ export * from "./lib/enqueue/EnqueueImageToVideo.js";
export * from "./lib/enqueue/EnqueueTextToImage.js";
export * from "./lib/generate/GenerateImage.js";
export * from "./lib/generate/GenerateVideo.js";
export * from "./lib/generate/models/image/ListImageModels.js";
export * from "./lib/generate/models/video/ListVideoModels.js";
export * from "./lib/events/BasicEventWrapper.js";
export * from "./lib/events/CanvasBgRemovedEvent.js";
export * from "./lib/fal/FalBackgroundRemoval.js";
@@ -45,4 +47,5 @@ export * from "./lib/util/LoadWithoutCors.js";
export * from "./lib/worldlabs/WorldLabsGetCredentialInfo.js";
export * from "./lib/cost_estimate/EstimateImageCost.js";
export * from "./lib/cost_estimate/EstimateSplatCost.js";
export * from "./lib/cost_estimate/EstimateVideoCost.js";
export * from "./lib/cost_estimate/EstimateVideoCost.js";
export * from "./lib/models/modelsStore.js";
@@ -0,0 +1,63 @@
import { invoke } from "@tauri-apps/api/core";
import { CommandResult } from "../../../common/CommandStatus";
// NB: enum-valued fields are typed as plain strings on purpose. The backend
// enums carry an `Unknown(String)` catch-all, so a newer server may return a
// value this build doesn't know; keeping them as strings keeps us forward-compatible.
export interface ListImageModelsSuccess extends CommandResult {
payload: ListImageModelsPayload;
}
export interface ListImageModelsPayload {
success: boolean;
models: OmniGenImageModelDetails[];
providers: OmniGenImageModelProviderDetails[];
}
export interface OmniGenImageModelProviderDetails {
provider: string;
models: OmniGenImageProviderModelDetails[];
}
export interface OmniGenImageProviderModelDetails {
model: string;
overrides?: OmniGenImageModelDetails;
}
export interface OmniGenImageModelDetails {
model: string;
model_creator?: string;
full_name?: string;
text_prompt_supported?: boolean;
text_prompt_max_length?: number;
negative_text_prompt_supported?: boolean;
negative_text_prompt_max_length?: number;
image_refs_supported?: boolean;
image_refs_max?: number;
has_fixed_editing_aspect_ratio?: boolean;
aspect_ratio_options?: string[];
aspect_ratio_default?: string;
aspect_ratio_default_when_editing?: string;
resolution_options?: string[];
resolution_default?: string;
quality_options?: string[];
default_quality?: string;
batch_size_min?: number;
batch_size_max?: number;
batch_size_options?: number[];
batch_size_default?: number;
is_disabled?: boolean;
}
/**
* List available image models from the Tauri backend
* (`list_image_models_command` — retried + cached 60s in the Rust layer).
*/
export const ListImageModels = async (): Promise<ListImageModelsSuccess> => {
try {
return (await invoke("list_image_models_command")) as ListImageModelsSuccess;
} catch (error) {
throw error;
}
};
@@ -0,0 +1,82 @@
import { invoke } from "@tauri-apps/api/core";
import { CommandResult } from "../../../common/CommandStatus";
// NB: enum-valued fields are typed as plain strings for forward-compatibility
// (the backend enums have an `Unknown(String)` catch-all).
export interface ListVideoModelsSuccess extends CommandResult {
payload: ListVideoModelsPayload;
}
export interface ListVideoModelsPayload {
success: boolean;
models: OmniGenVideoModelDetails[];
providers: OmniGenVideoModelProviderDetails[];
}
export interface OmniGenVideoModelProviderDetails {
provider: string;
models: OmniGenVideoProviderModelDetails[];
}
export interface OmniGenVideoProviderModelDetails {
model: string;
overrides?: OmniGenVideoModelDetails;
}
export interface OmniGenVideoModelDetails {
model: string;
model_creator?: string;
full_name?: string;
extra_info?: string;
extra_info_short?: string;
text_to_video_supported?: boolean;
text_prompt_supported?: boolean;
text_prompt_max_length?: number;
negative_text_prompt_supported?: boolean;
negative_text_prompt_max_length?: number;
starting_keyframe_supported?: boolean;
starting_keyframe_required?: boolean;
ending_keyframe_supported?: boolean;
image_references_supported?: boolean;
image_references_max?: number;
video_references_supported?: boolean;
video_references_max?: number;
video_references_max_total_duration_seconds?: number;
audio_references_supported?: boolean;
audio_references_max?: number;
audio_references_max_total_duration_seconds?: number;
character_references_supported?: boolean;
character_references_max?: number;
show_generate_with_sound_toggle?: boolean;
aspect_ratio_options?: string[];
aspect_ratio_default?: string;
resolution_options?: string[];
resolution_default?: string;
bitrate_options?: string[];
bitrate_default?: string;
quality_options?: string[];
default_quality?: string;
duration_seconds_min?: number;
duration_seconds_max?: number;
duration_seconds_max_with_image_references?: number;
duration_seconds_options?: number[];
duration_seconds_default?: number;
batch_size_min?: number;
batch_size_max?: number;
batch_size_options?: number[];
batch_size_default?: number;
is_disabled?: boolean;
}
/**
* List available video models from the Tauri backend
* (`list_video_models_command` — retried + cached 60s in the Rust layer).
*/
export const ListVideoModels = async (): Promise<ListVideoModelsSuccess> => {
try {
return (await invoke("list_video_models_command")) as ListVideoModelsSuccess;
} catch (error) {
throw error;
}
};
@@ -0,0 +1,81 @@
// Shared model store: the single source of truth for the model dropdowns.
//
// Seeded synchronously from the frontend OVERLAY (`IMAGE_MODELS` / `VIDEO_MODELS`
// in `@storyteller/model-list`) so the UI is never empty. On app boot,
// `loadModelsFromBackend()` fetches the authoritative omni listing via the Tauri
// commands and rebuilds the lists FROM that response (membership + order come
// from the backend; the overlay only enriches UI metadata; backend models with
// no overlay entry are built minimally so NEW models appear). If the fetch fails
// the store keeps the overlay.
import { create } from "zustand";
import {
ImageModel,
VideoModel,
IMAGE_MODELS,
VIDEO_MODELS,
buildImageModelsFromListing,
buildVideoModelsFromListing,
} from "@storyteller/model-list";
import { ListImageModels } from "../generate/models/image/ListImageModels.js";
import { ListVideoModels } from "../generate/models/video/ListVideoModels.js";
export interface ModelsStoreState {
imageModels: ImageModel[];
videoModels: VideoModel[];
// True once a backend reconciliation has completed at least once.
loaded: boolean;
isLoading: boolean;
loadModelsFromBackend: () => Promise<void>;
}
export const useModelsStore = create<ModelsStoreState>((set, get) => ({
imageModels: IMAGE_MODELS,
videoModels: VIDEO_MODELS,
loaded: false,
isLoading: false,
loadModelsFromBackend: async () => {
if (get().isLoading) return;
console.log("[models] loadModelsFromBackend() — calling Tauri commands…");
set({ isLoading: true });
const [imageResult, videoResult] = await Promise.allSettled([
ListImageModels(),
ListVideoModels(),
]);
const next: Partial<ModelsStoreState> = { isLoading: false, loaded: true };
if (imageResult.status === "fulfilled") {
const { models, providers } = imageResult.value.payload;
const offered = providers.flatMap((p) => p.models.map((pm) => pm.model));
const built = buildImageModelsFromListing(IMAGE_MODELS, models, offered);
next.imageModels = built;
console.log(
`[models] image: backend returned ${models.length} → ${built.length} models shown`,
built.map((m) => m.tauriId),
);
} else {
console.error("[models] failed to load image models from backend:", imageResult.reason);
}
if (videoResult.status === "fulfilled") {
const { models, providers } = videoResult.value.payload;
const offered = providers.flatMap((p) => p.models.map((pm) => pm.model));
const built = buildVideoModelsFromListing(VIDEO_MODELS, models, offered);
next.videoModels = built;
console.log(
`[models] video: backend returned ${models.length} → ${built.length} models shown`,
built.map((m) => m.tauriId),
);
} else {
console.error("[models] failed to load video models from backend:", videoResult.reason);
}
set(next);
},
}));
// Hook selectors for React consumers.
export const useImageModels = (): ImageModel[] => useModelsStore((s) => s.imageModels);
export const useVideoModels = (): VideoModel[] => useModelsStore((s) => s.videoModels);