From 08dfb8515ff32ee19638996d2341a55915a2302e Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 18 Sep 2026 16:10:26 -0400 Subject: [PATCH] Added new models and modes: GPT Image 2.5, Wan, Output Formats --- ...mni_gen_video_cost_and_generate_request.rs | 6 + .../omni_gen/models/omni_gen_video_models.rs | 9 + .../video/omni_gen_list_video_models.rs | 4 + .../artcraft_router/src/api/router_quality.rs | 12 +- .../providers/artcraft/build_common.rs | 7 +- .../providers/artcraft/gpt_image_1/cost.rs | 18 +- .../providers/artcraft/gpt_image_1p5/cost.rs | 18 +- .../providers/artcraft/gpt_image_2/cost.rs | 18 +- .../artcraft/gpt_image_2p5_common_cost.rs | 78 ++++++--- .../providers/fal/gpt_image_1/build.rs | 12 +- .../providers/fal/gpt_image_1p5/build.rs | 12 +- .../providers/fal/gpt_image_2/build.rs | 12 +- .../fal/gpt_image_2p5_common/plan.rs | 18 +- .../providers/fal/gpt_image_2p5_flare/cost.rs | 3 + .../fal/gpt_image_2p5_sunburst/cost.rs | 3 + .../providers/kinovi/midjourney_7/build.rs | 8 +- .../kinovi/midjourney_7_niji/build.rs | 8 +- .../providers/kinovi/midjourney_8/build.rs | 8 +- .../providers/artcraft/build_common.rs | 1 + .../grok_imagine_video_1p5/request.rs | 1 + .../api_adapters/quality/common_quality.rs | 7 + .../utils/convert_enums_to_router.rs | 3 + .../generate/models/image/response_types.rs | 22 +++ .../generate/models/video/response_types.rs | 29 ++++ .../core/commands/generate/omni/request.rs | 25 +++ .../inference_model_type.rs | 20 +++ .../common/generation/common_model_type.rs | 24 ++- .../src/common/generation/common_quality.rs | 34 +++- .../common/generation/common_video_model.rs | 16 ++ .../generation/common_video_output_format.rs | 162 ++++++++++++++++++ .../public/enums/src/common/generation/mod.rs | 1 + .../lib/common/generation/CommonImageModel.ts | 7 + .../lib/common/generation/CommonQuality.ts | 3 + .../lib/common/generation/CommonVideoModel.ts | 27 +++ .../src/lib/convert/stringToCommonQuality.ts | 8 +- .../src/lib/useImageCostEstimate.ts | 2 +- .../src/lib/useVideoCostEstimate.spec.tsx | 21 +++ .../src/lib/useVideoCostEstimate.ts | 7 +- .../promptbox/src/lib/PromptBoxImage.tsx | 2 +- .../promptbox/src/lib/PromptBoxVideo.tsx | 61 +++++-- .../src/lib/common/QualityPicker.tsx | 7 +- .../lib/common/VideoGenerationCountPicker.tsx | 17 +- .../promptbox/src/lib/promptStore.ts | 4 + .../model-list/src/lib/classes/ImageModel.ts | 7 + .../model-list/src/lib/classes/VideoModel.ts | 12 ++ .../classes/metadata/ModelCreatorIconForId.ts | 1 + .../src/lib/classes/metadata/ModelFamilies.ts | 2 + .../src/lib/classes/metadata/ModelMapping.ts | 3 + .../lib/classes/properties/CommonQuality.ts | 3 + .../src/lib/classes/videoOptions.ts | 18 ++ .../model-list/src/lib/lists/ImageModels.ts | 26 ++- .../model-list/src/lib/lists/VideoModels.ts | 2 + .../lib/loader/buildModelsFromListing.spec.ts | 92 ++++++++++ .../src/lib/loader/buildModelsFromListing.ts | 6 + .../lib/cost_estimate/EstimateVideoCost.ts | 1 + .../src/lib/generate/GenerateOmni.spec.ts | 20 +++ .../src/lib/generate/GenerateVideo.ts | 1 + .../generate/models/video/ListVideoModels.ts | 2 + 58 files changed, 883 insertions(+), 78 deletions(-) create mode 100644 crates/schema/public/enums/src/common/generation/common_video_output_format.rs diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/cost_and_generate_requests/omni_gen_video_cost_and_generate_request.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/cost_and_generate_requests/omni_gen_video_cost_and_generate_request.rs index 56822ac8e8..505bbd4e17 100644 --- a/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/cost_and_generate_requests/omni_gen_video_cost_and_generate_request.rs +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/cost_and_generate_requests/omni_gen_video_cost_and_generate_request.rs @@ -6,6 +6,7 @@ use enums::common::generation::common_bitrate::CommonBitrate; use enums::common::generation::common_quality::CommonQuality; use enums::common::generation::common_resolution::CommonResolution; use enums::common::generation::common_video_model::CommonVideoModel; +use enums::common::generation::common_video_output_format::CommonVideoOutputFormat; use tokens::tokens::characters::CharacterToken; use tokens::tokens::media_files::MediaFileToken; @@ -55,6 +56,11 @@ pub struct OmniGenVideoCostAndGenerateRequest { /// Not all models support this; models that don't simply ignore it. pub bitrate: Option, + /// Output container for Seedance 2.5 (including Ultra). Other models ignore it. + /// Use "mp4" or "mov" (QuickTime). Omitted or null defaults to MP4. + #[serde(rename = "output_format", skip_serializing_if = "Option::is_none")] + pub maybe_output_format: Option, + /// The quality to use. pub quality: Option, diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/models/omni_gen_video_models.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/models/omni_gen_video_models.rs index 8cbea2f5e0..4492859797 100644 --- a/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/models/omni_gen_video_models.rs +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/models/omni_gen_video_models.rs @@ -2,6 +2,7 @@ use enums::common::generation::common_aspect_ratio::CommonAspectRatio; use enums::common::generation::common_quality::CommonQuality; use enums::common::generation::common_resolution::CommonResolution; use enums::common::generation::common_video_model::CommonVideoModel; +use enums::common::generation::common_video_output_format::CommonVideoOutputFormat; use enums::common::generation::model_creator::ModelCreator; use enums::common::generation_provider::GenerationProvider; use serde_derive::{Deserialize, Serialize}; @@ -156,6 +157,12 @@ pub struct OmniGenVideoModelDetails { #[serde(skip_serializing_if = "Option::is_none")] pub bitrate_default: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub output_format_options: Option>, + + #[serde(skip_serializing_if = "Option::is_none")] + pub output_format_default: Option, #[serde(skip_serializing_if = "Option::is_none")] pub quality_options: Option>, @@ -229,6 +236,8 @@ impl Default for OmniGenVideoModelDetails { resolution_default: None, bitrate_options: None, bitrate_default: None, + output_format_options: None, + output_format_default: None, quality_options: None, default_quality: None, duration_seconds_min: None, diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/models/video/omni_gen_list_video_models.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/models/video/omni_gen_list_video_models.rs index 16073e8bea..97640c54b0 100644 --- a/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/models/video/omni_gen_list_video_models.rs +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/models/video/omni_gen_list_video_models.rs @@ -158,6 +158,10 @@ pub struct OmniGenVideoModelDetails { #[serde(default)] pub bitrate_default: Option, #[serde(default)] + pub output_format_options: Option>, + #[serde(default)] + pub output_format_default: Option, + #[serde(default)] pub quality_options: Option>, #[serde(default)] pub default_quality: Option, diff --git a/crates/api_clients/artcraft/artcraft_router/src/api/router_quality.rs b/crates/api_clients/artcraft/artcraft_router/src/api/router_quality.rs index b195a84bf3..ce2e9a027e 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/api/router_quality.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/api/router_quality.rs @@ -1,10 +1,20 @@ use serde_derive::{Deserialize, Serialize}; /// Common quality levels you can specify when enqueuing a generation. -/// Not every model will use this; models that don't simply ignore it. +/// Not every model will use this; models that don't simply ignore it, and models that only +/// offer a subset of tiers clamp to the nearest tier they support. +/// +/// Serialized names mirror the server's `CommonQuality` so the two convert via serde. #[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum RouterQuality { + /// Let the model choose. Models without a native "auto" use their default tier. + Auto, + /// Highest tier (GPT Image 2.5 `max`). + Max, + /// Between High and Max (GPT Image 2.5 `xhigh`). + #[serde(rename = "xhigh")] + XHigh, High, Medium, Low, diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/build_common.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/build_common.rs index 201a147fce..4a83b5debf 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/build_common.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/build_common.rs @@ -151,8 +151,11 @@ fn to_resolution_enum(r: RouterResolution) -> CommonResolutionEnum { fn to_quality_enum(q: RouterQuality) -> CommonQualityEnum { match q { - RouterQuality::Low => CommonQualityEnum::Low, - RouterQuality::Medium => CommonQualityEnum::Medium, + RouterQuality::Auto => CommonQualityEnum::Auto, + RouterQuality::Max => CommonQualityEnum::Max, + RouterQuality::XHigh => CommonQualityEnum::XHigh, RouterQuality::High => CommonQualityEnum::High, + RouterQuality::Medium => CommonQualityEnum::Medium, + RouterQuality::Low => CommonQualityEnum::Low, } } diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_1/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_1/cost.rs index c67a06a987..e3d9a69df0 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_1/cost.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_1/cost.rs @@ -36,7 +36,7 @@ impl ArtcraftGptImage1CostState { } pub fn estimate_cost(&self) -> ImageGenerationCostEstimate { - let quality = self.quality.unwrap_or(CommonQualityEnum::High); + let quality = clamp_to_supported_quality(self.quality); let is_square = is_square_or_none(self.aspect_ratio); let output_cost_per_image: u64 = match (quality, is_square) { @@ -45,6 +45,8 @@ impl ArtcraftGptImage1CostState { (CommonQualityEnum::Medium, false) => 7, (CommonQualityEnum::High, true) => 17, (CommonQualityEnum::High, false) => 25, + // Clamped away above. + (CommonQualityEnum::Auto | CommonQualityEnum::XHigh | CommonQualityEnum::Max, _) => unreachable!(), }; let output_cost = output_cost_per_image * self.num_images as u64; @@ -63,6 +65,20 @@ impl ArtcraftGptImage1CostState { } } +/// GPT Image 1 only offers low / medium / high. `auto` prices as the default (high) and the +/// higher GPT Image 2.5 tiers clamp down to high, matching the Fal request planner. +fn clamp_to_supported_quality(quality: Option) -> CommonQualityEnum { + match quality { + Some(CommonQualityEnum::Low) => CommonQualityEnum::Low, + Some(CommonQualityEnum::Medium) => CommonQualityEnum::Medium, + Some(CommonQualityEnum::High) + | Some(CommonQualityEnum::XHigh) + | Some(CommonQualityEnum::Max) + | Some(CommonQualityEnum::Auto) + | None => CommonQualityEnum::High, + } +} + fn is_square_or_none(aspect_ratio: Option) -> bool { use CommonAspectRatioEnum as Ar; matches!( diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_1p5/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_1p5/cost.rs index 624167c739..ebb30ec521 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_1p5/cost.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_1p5/cost.rs @@ -37,7 +37,7 @@ impl ArtcraftGptImage1p5CostState { } pub fn estimate_cost(&self) -> ImageGenerationCostEstimate { - let quality = self.quality.unwrap_or(CommonQualityEnum::High); + let quality = clamp_to_supported_quality(self.quality); let size = size_bucket(self.aspect_ratio); let cost_per_image: u64 = match (quality, size) { @@ -50,6 +50,8 @@ impl ArtcraftGptImage1p5CostState { (CommonQualityEnum::High, SizeBucket::Square) => 14, (CommonQualityEnum::High, _) => 20, + // Clamped away above. + (CommonQualityEnum::Auto | CommonQualityEnum::XHigh | CommonQualityEnum::Max, _) => unreachable!(), }; let cost_in_usd_cents = cost_per_image * self.num_images as u64; @@ -65,6 +67,20 @@ impl ArtcraftGptImage1p5CostState { } } +/// GPT Image 1.5 only offers low / medium / high. `auto` prices as the default (high) and the +/// higher GPT Image 2.5 tiers clamp down to high, matching the Fal request planner. +fn clamp_to_supported_quality(quality: Option) -> CommonQualityEnum { + match quality { + Some(CommonQualityEnum::Low) => CommonQualityEnum::Low, + Some(CommonQualityEnum::Medium) => CommonQualityEnum::Medium, + Some(CommonQualityEnum::High) + | Some(CommonQualityEnum::XHigh) + | Some(CommonQualityEnum::Max) + | Some(CommonQualityEnum::Auto) + | None => CommonQualityEnum::High, + } +} + fn size_bucket(aspect_ratio: Option) -> SizeBucket { use CommonAspectRatioEnum as Ar; match aspect_ratio { diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_2/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_2/cost.rs index 6b8c502021..92ff018550 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_2/cost.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_2/cost.rs @@ -42,7 +42,7 @@ impl ArtcraftGptImage2CostState { } pub fn estimate_cost(&self) -> ImageGenerationCostEstimate { - let quality = self.quality.unwrap_or(CommonQualityEnum::High); + let quality = clamp_to_supported_quality(self.quality); let size = size_bucket(self.aspect_ratio); let cost_per_image: u64 = match (quality, size) { @@ -62,6 +62,8 @@ impl ArtcraftGptImage2CostState { | (CommonQualityEnum::High, SizeBucket::Portrait4x3) => 15, (CommonQualityEnum::High, SizeBucket::Landscape16x9) | (CommonQualityEnum::High, SizeBucket::Portrait16x9) => 16, + // Clamped away above. + (CommonQualityEnum::Auto | CommonQualityEnum::XHigh | CommonQualityEnum::Max, _) => unreachable!(), }; let cost_in_usd_cents = cost_per_image * self.num_images as u64; @@ -77,6 +79,20 @@ impl ArtcraftGptImage2CostState { } } +/// GPT Image 2 only offers low / medium / high. `auto` prices as the default (high) and the +/// higher GPT Image 2.5 tiers clamp down to high, matching the Fal request planner. +fn clamp_to_supported_quality(quality: Option) -> CommonQualityEnum { + match quality { + Some(CommonQualityEnum::Low) => CommonQualityEnum::Low, + Some(CommonQualityEnum::Medium) => CommonQualityEnum::Medium, + Some(CommonQualityEnum::High) + | Some(CommonQualityEnum::XHigh) + | Some(CommonQualityEnum::Max) + | Some(CommonQualityEnum::Auto) + | None => CommonQualityEnum::High, + } +} + /// Mirrors v1's `plan_image_size` for GPT Image 2 — see the v1 file /// `plan_generate_image_artcraft_gpt_image_2.rs::plan_image_size`. fn size_bucket(aspect_ratio: Option) -> SizeBucket { diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_2p5_common_cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_2p5_common_cost.rs index bc015b334f..a6cadd5518 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_2p5_common_cost.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/gpt_image_2p5_common_cost.rs @@ -8,25 +8,27 @@ use fal_client::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_image use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate; /// Artcraft price per output image, in hundredths of a US cent, by output dimensions and -/// quality (`[low, medium, high]`). Rows are stored landscape-first; portrait orientations of -/// the same dimensions price identically. Dimensions come from the aspect ratio preset and -/// resolution tier the same way the Fal request builder derives them. -const OUTPUT_IMAGE_PRICE_TABLE: &[(u32, u32, [u64; 3])] = &[ +/// quality (`[low, medium, high, xhigh, max]`). Each cell is the `fal_client` GPT Image 2.5 +/// estimate for that size and tier with a 10% markup, rounded up. Rows are stored +/// landscape-first; portrait orientations of the same dimensions price identically. +/// Dimensions come from the aspect ratio preset and resolution tier the same way the Fal +/// request builder derives them. +const OUTPUT_IMAGE_PRICE_TABLE: &[(u32, u32, [u64; 5])] = &[ // 1:1 - (1024, 1024, [ 65, 152, 582]), - (2048, 2048, [103, 239, 919]), - (2880, 2880, [151, 352, 1357]), + (1024, 1024, [ 65, 152, 582, 1034, 2327]), + (2048, 2048, [103, 239, 919, 1633, 3672]), + (2880, 2880, [151, 352, 1357, 2412, 5426]), // 4:3 - (1024, 768, [ 46, 105, 400]), - (2048, 1536, [ 73, 170, 652]), - (3072, 2304, [120, 279, 1073]), - (3312, 2480, [134, 311, 1194]), + (1024, 768, [ 46, 105, 400, 710, 1597]), + (2048, 1536, [ 73, 170, 652, 1159, 2605]), + (3072, 2304, [120, 279, 1073, 1906, 4287]), + (3312, 2480, [134, 311, 1194, 2122, 4773]), // 16:9 - (1088, 608, [ 32, 75, 285]), - (1920, 1080, [ 49, 114, 436]), - (2048, 1152, [ 53, 121, 467]), - (3072, 1728, [ 87, 204, 783]), - (3840, 2160, [124, 286, 1102]), + (1088, 608, [ 32, 75, 285, 506, 1139]), + (1920, 1080, [ 49, 114, 436, 775, 1743]), + (2048, 1152, [ 53, 121, 467, 830, 1865]), + (3072, 1728, [ 87, 204, 783, 1390, 3127]), + (3840, 2160, [124, 286, 1102, 1957, 4404]), ]; /// Artcraft price per reference image on an edit request, in hundredths of a US cent. @@ -55,7 +57,8 @@ impl ArtcraftGptImage2p5CostInputs { } /// Per-image price rounded up to whole cents, times the batch size. Quality defaults to - /// high. Without an aspect ratio, text-to-image prices as landscape 4:3 and edit as auto, + /// high, and `auto` is priced as high (the documented default the model falls back to). + /// Without an aspect ratio, text-to-image prices as landscape 4:3 and edit as auto, /// matching the Fal defaults the request would fall back to. pub fn estimate_cost(&self) -> ImageGenerationCostEstimate { let quality = self.quality.unwrap_or(CommonQualityEnum::High); @@ -91,11 +94,13 @@ fn output_image_price(quality: CommonQualityEnum, width: u32, height: u32) -> u6 match quality { CommonQualityEnum::Low => prices[0], CommonQualityEnum::Medium => prices[1], - CommonQualityEnum::High => prices[2], + CommonQualityEnum::High | CommonQualityEnum::Auto => prices[2], + CommonQualityEnum::XHigh => prices[3], + CommonQualityEnum::Max => prices[4], } } -fn lookup_price_row(width: u32, height: u32) -> Option<[u64; 3]> { +fn lookup_price_row(width: u32, height: u32) -> Option<[u64; 5]> { let long_edge = width.max(height); let short_edge = width.min(height); OUTPUT_IMAGE_PRICE_TABLE.iter() @@ -176,6 +181,18 @@ mod tests { (Some(Ar::Auto4k), Some(R::OneK), [2, 3, 12]), ]; + // (aspect_ratio, resolution, [xhigh, max]) — cents per image, text-to-image + const TEXT_GRID_UPPER_TIERS: &[(Option, Option, [u64; 2])] = &[ + (None, None, [ 8, 16]), // landscape 4:3 default + (Some(Ar::Square), None, [11, 24]), + (Some(Ar::Square), Some(R::TwoK), [17, 37]), + (Some(Ar::Square), Some(R::FourK), [25, 55]), + (Some(Ar::WideSixteenByNine), None, [ 8, 18]), + (Some(Ar::WideSixteenByNine), Some(R::OneK), [ 6, 12]), + (Some(Ar::TallNineBySixteen), Some(R::FourK), [20, 45]), + (Some(Ar::Auto), None, [20, 45]), + ]; + mod text_to_image_tests { use super::*; @@ -189,11 +206,32 @@ mod tests { } } + #[test] + fn upper_tier_grid() { + for &(aspect_ratio, resolution, expected) in TEXT_GRID_UPPER_TIERS { + for (quality, expected) in [Q::XHigh, Q::Max].into_iter().zip(expected) { + let actual = cents(make_inputs(0, Some(quality), aspect_ratio, resolution, 1)); + assert_eq!(actual, expected, "{aspect_ratio:?} {resolution:?} {quality:?}"); + } + } + } + #[test] fn quality_defaults_to_high() { assert_eq!(cents(make_inputs(0, None, Some(Ar::Square), None, 1)), 6); } + #[test] + fn auto_prices_as_high() { + for &(aspect_ratio, resolution, _) in TEXT_GRID { + assert_eq!( + cents(make_inputs(0, Some(Q::Auto), aspect_ratio, resolution, 1)), + cents(make_inputs(0, Some(Q::High), aspect_ratio, resolution, 1)), + "{aspect_ratio:?} {resolution:?}", + ); + } + } + #[test] fn batch_multiplies_per_image_cents() { for &(aspect_ratio, resolution, _) in TEXT_GRID { @@ -265,7 +303,7 @@ mod tests { #[test] fn prices_rise_with_quality_and_size() { for &(_, _, prices) in OUTPUT_IMAGE_PRICE_TABLE { - assert!(prices[0] < prices[1] && prices[1] < prices[2], "{prices:?}"); + assert!(prices.windows(2).all(|pair| pair[0] < pair[1]), "{prices:?}"); } assert!(output_image_price(Q::High, 1024, 1024) < output_image_price(Q::High, 2048, 2048)); assert!(output_image_price(Q::High, 2048, 2048) < output_image_price(Q::High, 2880, 2880)); diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_1/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_1/build.rs index 34f68a759b..33545e3fde 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_1/build.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_1/build.rs @@ -100,11 +100,17 @@ fn plan_num_images( } } +/// This endpoint only offers low / medium / high. `auto` falls back to Fal's default (`high`) +/// and the higher GPT Image 2.5 tiers (`xhigh`, `max`) clamp down to `high`. fn plan_quality(quality: Option) -> PlannedQuality { match quality { Some(RouterQuality::Low) => PlannedQuality::Low, Some(RouterQuality::Medium) => PlannedQuality::Medium, - Some(RouterQuality::High) | None => PlannedQuality::High, + Some(RouterQuality::High) + | Some(RouterQuality::XHigh) + | Some(RouterQuality::Max) + | Some(RouterQuality::Auto) + | None => PlannedQuality::High, } } @@ -337,6 +343,10 @@ mod tests { (Some(RouterQuality::Low), "Some(Low)"), (Some(RouterQuality::Medium), "Some(Medium)"), (Some(RouterQuality::High), "Some(High)"), + // Tiers this endpoint lacks clamp to high. + (Some(RouterQuality::Auto), "Some(High)"), + (Some(RouterQuality::XHigh), "Some(High)"), + (Some(RouterQuality::Max), "Some(High)"), ]; for (quality, expected) in cases { diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_1p5/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_1p5/build.rs index a6e9e31684..240901de7a 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_1p5/build.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_1p5/build.rs @@ -100,11 +100,17 @@ fn plan_num_images( } } +/// This endpoint only offers low / medium / high. `auto` falls back to Fal's default (`high`) +/// and the higher GPT Image 2.5 tiers (`xhigh`, `max`) clamp down to `high`. fn plan_quality(quality: Option) -> PlannedQuality { match quality { Some(RouterQuality::Low) => PlannedQuality::Low, Some(RouterQuality::Medium) => PlannedQuality::Medium, - Some(RouterQuality::High) | None => PlannedQuality::High, + Some(RouterQuality::High) + | Some(RouterQuality::XHigh) + | Some(RouterQuality::Max) + | Some(RouterQuality::Auto) + | None => PlannedQuality::High, } } @@ -337,6 +343,10 @@ mod tests { (Some(RouterQuality::Low), "Some(Low)"), (Some(RouterQuality::Medium), "Some(Medium)"), (Some(RouterQuality::High), "Some(High)"), + // Tiers this endpoint lacks clamp to high. + (Some(RouterQuality::Auto), "Some(High)"), + (Some(RouterQuality::XHigh), "Some(High)"), + (Some(RouterQuality::Max), "Some(High)"), ]; for (quality, expected) in cases { diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_2/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_2/build.rs index 53b916f9fa..843e365893 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_2/build.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_2/build.rs @@ -106,11 +106,17 @@ fn plan_num_images( } } +/// This endpoint only offers low / medium / high. `auto` falls back to Fal's default (`high`) +/// and the higher GPT Image 2.5 tiers (`xhigh`, `max`) clamp down to `high`. fn plan_quality(quality: Option) -> PlannedQuality { match quality { Some(RouterQuality::Low) => PlannedQuality::Low, Some(RouterQuality::Medium) => PlannedQuality::Medium, - Some(RouterQuality::High) | None => PlannedQuality::High, + Some(RouterQuality::High) + | Some(RouterQuality::XHigh) + | Some(RouterQuality::Max) + | Some(RouterQuality::Auto) + | None => PlannedQuality::High, } } @@ -362,6 +368,10 @@ mod tests { (Some(RouterQuality::Low), "Some(Low)"), (Some(RouterQuality::Medium), "Some(Medium)"), (Some(RouterQuality::High), "Some(High)"), + // Tiers this endpoint lacks clamp to high. + (Some(RouterQuality::Auto), "Some(High)"), + (Some(RouterQuality::XHigh), "Some(High)"), + (Some(RouterQuality::Max), "Some(High)"), ]; for (quality, expected) in cases { diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_2p5_common/plan.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_2p5_common/plan.rs index 56ac929795..95f87c0318 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_2p5_common/plan.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_2p5_common/plan.rs @@ -82,13 +82,16 @@ fn plan_num_images( } } -/// The router only exposes low / medium / high; GPT Image 2.5's `xhigh` and `max` tiers are -/// not reachable through it. Fal's default (`high`) applies when unspecified. +/// GPT Image 2.5 supports every router tier natively. Fal's default (`high`) applies when +/// unspecified. fn plan_quality(quality: Option) -> GptImage2p5Quality { match quality { - Some(RouterQuality::Low) => GptImage2p5Quality::Low, - Some(RouterQuality::Medium) => GptImage2p5Quality::Medium, + Some(RouterQuality::Auto) => GptImage2p5Quality::Auto, + Some(RouterQuality::Max) => GptImage2p5Quality::Max, + Some(RouterQuality::XHigh) => GptImage2p5Quality::XHigh, Some(RouterQuality::High) | None => GptImage2p5Quality::High, + Some(RouterQuality::Medium) => GptImage2p5Quality::Medium, + Some(RouterQuality::Low) => GptImage2p5Quality::Low, } } @@ -220,9 +223,12 @@ mod tests { fn quality_maps_exhaustively_for_text_and_edit() { let cases = [ (None, "Some(High)"), - (Some(RouterQuality::Low), "Some(Low)"), - (Some(RouterQuality::Medium), "Some(Medium)"), + (Some(RouterQuality::Auto), "Some(Auto)"), + (Some(RouterQuality::Max), "Some(Max)"), + (Some(RouterQuality::XHigh), "Some(XHigh)"), (Some(RouterQuality::High), "Some(High)"), + (Some(RouterQuality::Medium), "Some(Medium)"), + (Some(RouterQuality::Low), "Some(Low)"), ]; for (quality, expected) in cases { let text = unwrap_t2i(plan_gpt_image_2p5_params(&GenerateImageRequestBuilder { quality, ..base_builder() })); diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_2p5_flare/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_2p5_flare/cost.rs index 936d6a6602..6cacca642a 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_2p5_flare/cost.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_2p5_flare/cost.rs @@ -53,6 +53,9 @@ mod tests { (Some(Q::Low), Some(Ar::Square), None, 1, 1), (Some(Q::Medium), Some(Ar::Square), None, 1, 2), (Some(Q::High), Some(Ar::Square), None, 1, 6), + (Some(Q::Auto), Some(Ar::Square), None, 1, 6), // auto is priced as high + (Some(Q::XHigh), Some(Ar::Square), None, 1, 10), + (Some(Q::Max), Some(Ar::Square), None, 1, 22), (Some(Q::High), Some(Ar::Square), None, 4, 24), (Some(Q::High), Some(Ar::SquareHd), None, 1, 9), (Some(Q::High), Some(Ar::Square), Some(R::TwoK), 1, 9), diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_2p5_sunburst/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_2p5_sunburst/cost.rs index 7932ab7c38..130dc7b8c8 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_2p5_sunburst/cost.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/fal/gpt_image_2p5_sunburst/cost.rs @@ -53,6 +53,9 @@ mod tests { (Some(Q::Low), Some(Ar::Square), None, 1, 1), (Some(Q::Medium), Some(Ar::Square), None, 1, 2), (Some(Q::High), Some(Ar::Square), None, 1, 6), + (Some(Q::Auto), Some(Ar::Square), None, 1, 6), // auto is priced as high + (Some(Q::XHigh), Some(Ar::Square), None, 1, 10), + (Some(Q::Max), Some(Ar::Square), None, 1, 22), (Some(Q::High), Some(Ar::Square), None, 4, 24), (Some(Q::High), Some(Ar::SquareHd), None, 1, 9), (Some(Q::High), Some(Ar::Square), Some(R::TwoK), 1, 9), diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7/build.rs index 2e271b2165..ca922c7030 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7/build.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7/build.rs @@ -122,10 +122,14 @@ pub(crate) fn plan_quality(quality: Option) -> Option None, + // `auto` has no Midjourney equivalent; leave it to the provider default. + None | Some(RouterQuality::Auto) => None, Some(RouterQuality::Low) => Some(GenerateMidjourneyV7Quality::Quarter), Some(RouterQuality::Medium) => Some(GenerateMidjourneyV7Quality::Half), - Some(RouterQuality::High) => Some(GenerateMidjourneyV7Quality::Full), + // Midjourney tops out at full quality; the higher GPT Image tiers clamp to it. + Some(RouterQuality::High) + | Some(RouterQuality::XHigh) + | Some(RouterQuality::Max) => Some(GenerateMidjourneyV7Quality::Full), } } diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7_niji/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7_niji/build.rs index e32e2cc32b..7b224f3114 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7_niji/build.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7_niji/build.rs @@ -110,10 +110,14 @@ pub(crate) fn plan_aspect_ratio( pub(crate) fn plan_quality(quality: Option) -> Option { match quality { - None => None, + // `auto` has no Midjourney equivalent; leave it to the provider default. + None | Some(RouterQuality::Auto) => None, Some(RouterQuality::Low) => Some(GenerateMidjourneyV7NijiQuality::Quarter), Some(RouterQuality::Medium) => Some(GenerateMidjourneyV7NijiQuality::Half), - Some(RouterQuality::High) => Some(GenerateMidjourneyV7NijiQuality::Full), + // Midjourney tops out at full quality; the higher GPT Image tiers clamp to it. + Some(RouterQuality::High) + | Some(RouterQuality::XHigh) + | Some(RouterQuality::Max) => Some(GenerateMidjourneyV7NijiQuality::Full), } } diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_8/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_8/build.rs index 6274c0d527..786fefeddf 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_8/build.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_8/build.rs @@ -110,10 +110,14 @@ pub(crate) fn plan_aspect_ratio( pub(crate) fn plan_quality(quality: Option) -> Option { match quality { - None => None, + // `auto` has no Midjourney equivalent; leave it to the provider default. + None | Some(RouterQuality::Auto) => None, Some(RouterQuality::Low) => Some(GenerateMidjourneyV8Quality::Quarter), Some(RouterQuality::Medium) => Some(GenerateMidjourneyV8Quality::Half), - Some(RouterQuality::High) => Some(GenerateMidjourneyV8Quality::Full), + // Midjourney tops out at full quality; the higher GPT Image tiers clamp to it. + Some(RouterQuality::High) + | Some(RouterQuality::XHigh) + | Some(RouterQuality::Max) => Some(GenerateMidjourneyV8Quality::Full), } } diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/build_common.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/build_common.rs index 489a6b0241..5d20d2f843 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/build_common.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/build_common.rs @@ -77,6 +77,7 @@ pub fn build_artcraft_omni_video_request( resolution, aspect_ratio, bitrate, + maybe_output_format: None, duration_seconds: duration_seconds.map(|d| d as u16), video_batch_count: Some(batch_count), negative_prompt: None, diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/grok_imagine_video_1p5/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/grok_imagine_video_1p5/request.rs index cb999d871e..71d03ec3cc 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/grok_imagine_video_1p5/request.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/grok_imagine_video_1p5/request.rs @@ -59,6 +59,7 @@ mod tests { resolution: None, aspect_ratio: None, bitrate: None, + maybe_output_format: None, quality: None, duration_seconds: Some(5), video_batch_count: Some(1), diff --git a/crates/desktop/artcraft/src/core/api_adapters/quality/common_quality.rs b/crates/desktop/artcraft/src/core/api_adapters/quality/common_quality.rs index a28d9522d6..60e5e3c787 100644 --- a/crates/desktop/artcraft/src/core/api_adapters/quality/common_quality.rs +++ b/crates/desktop/artcraft/src/core/api_adapters/quality/common_quality.rs @@ -5,6 +5,10 @@ use artcraft_router::api::router_quality::RouterQuality; #[derive(Copy, Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum CommonQuality { + Auto, + Max, + #[serde(rename = "xhigh")] + XHigh, High, Medium, Low, @@ -13,6 +17,9 @@ pub enum CommonQuality { impl CommonQuality { pub fn to_artcraft_router_type(&self) -> RouterQuality { match self { + CommonQuality::Auto => RouterQuality::Auto, + CommonQuality::Max => RouterQuality::Max, + CommonQuality::XHigh => RouterQuality::XHigh, CommonQuality::High => RouterQuality::High, CommonQuality::Medium => RouterQuality::Medium, CommonQuality::Low => RouterQuality::Low, diff --git a/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft_router/utils/convert_enums_to_router.rs b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft_router/utils/convert_enums_to_router.rs index 33c23040fa..cc82eaddc4 100644 --- a/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft_router/utils/convert_enums_to_router.rs +++ b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft_router/utils/convert_enums_to_router.rs @@ -30,6 +30,9 @@ pub fn convert_aspect_ratio(value: EnumsAspectRatio) -> RouterAspectRatio { pub fn convert_quality(value: EnumsQuality) -> RouterQuality { match value { + EnumsQuality::Auto => RouterQuality::Auto, + EnumsQuality::Max => RouterQuality::Max, + EnumsQuality::XHigh => RouterQuality::XHigh, EnumsQuality::High => RouterQuality::High, EnumsQuality::Medium => RouterQuality::Medium, EnumsQuality::Low => RouterQuality::Low, diff --git a/crates/desktop/artcraft/src/core/commands/generate/models/image/response_types.rs b/crates/desktop/artcraft/src/core/commands/generate/models/image/response_types.rs index dfd7ac7321..e0fa4f6410 100644 --- a/crates/desktop/artcraft/src/core/commands/generate/models/image/response_types.rs +++ b/crates/desktop/artcraft/src/core/commands/generate/models/image/response_types.rs @@ -552,3 +552,25 @@ impl From for ListImageModelsModelDetails { } } } + +#[cfg(test)] +mod catalog_parity_tests { + use super::*; + use serde_json::json; + + #[test] + fn gpt_image_2p5_quality_tiers_survive_the_desktop_listing_boundary() { + for model in ["gpt_image_2p5_flare", "gpt_image_2p5_sunburst"] { + let source = json!({ + "model": model, + "quality_options": ["auto", "max", "xhigh", "high", "medium", "low", "future_quality"], + "default_quality": "high", + "resolution_options": ["one_k", "two_k", "three_k", "four_k"], + "resolution_default": "one_k" + }); + let client: OmniGenImageModelDetails = serde_json::from_value(source.clone()).unwrap(); + let desktop = serde_json::to_value(ListImageModelsModelDetails::from(client)).unwrap(); + assert_eq!(desktop, source); + } + } +} diff --git a/crates/desktop/artcraft/src/core/commands/generate/models/video/response_types.rs b/crates/desktop/artcraft/src/core/commands/generate/models/video/response_types.rs index 755c5e60eb..18a3cf4f9c 100644 --- a/crates/desktop/artcraft/src/core/commands/generate/models/video/response_types.rs +++ b/crates/desktop/artcraft/src/core/commands/generate/models/video/response_types.rs @@ -597,6 +597,10 @@ pub struct ListVideoModelsModelDetails { #[serde(skip_serializing_if = "Option::is_none")] pub bitrate_default: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub output_format_options: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub output_format_default: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub quality_options: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub default_quality: Option, @@ -655,6 +659,8 @@ impl From for ListVideoModelsModelDetails { resolution_default: v.resolution_default.map(Into::into), bitrate_options: v.bitrate_options.map(|items| items.into_iter().map(Into::into).collect()), bitrate_default: v.bitrate_default.map(Into::into), + output_format_options: v.output_format_options, + output_format_default: v.output_format_default, quality_options: v.quality_options.map(|items| items.into_iter().map(Into::into).collect()), default_quality: v.default_quality.map(Into::into), duration_seconds_min: v.duration_seconds_min, @@ -670,3 +676,26 @@ impl From for ListVideoModelsModelDetails { } } } + +#[cfg(test)] +mod catalog_parity_tests { + use super::*; + use serde_json::json; + + #[test] + fn wan_and_output_formats_survive_the_desktop_listing_boundary() { + for model in ["wan_3p0", "wan_3p0_prime", "seedance_2p5", "seedance_2p5_u", "future_video"] { + let source = json!({ + "model": model, + "output_format_options": ["mp4", "mov", "future_container"], + "output_format_default": "mp4", + "batch_size_options": [1, 2, 3, 4], + "duration_seconds_max": 30, + "video_references_max": 5 + }); + let client: OmniGenVideoModelDetails = serde_json::from_value(source.clone()).unwrap(); + let desktop = serde_json::to_value(ListVideoModelsModelDetails::from(client)).unwrap(); + assert_eq!(desktop, source); + } + } +} diff --git a/crates/desktop/artcraft/src/core/commands/generate/omni/request.rs b/crates/desktop/artcraft/src/core/commands/generate/omni/request.rs index c5411c2fab..0c13e7b76c 100644 --- a/crates/desktop/artcraft/src/core/commands/generate/omni/request.rs +++ b/crates/desktop/artcraft/src/core/commands/generate/omni/request.rs @@ -123,6 +123,31 @@ mod tests { use super::*; use serde_json::json; + #[test] + fn catalog_parity_new_models_and_options_reach_the_api_unchanged() { + for model in ["gpt_image_2p5_flare", "gpt_image_2p5_sunburst"] { + for quality in ["auto", "max", "xhigh", "high", "medium", "low"] { + let source = json!({"model": model, "quality": quality, "resolution": "four_k"}); + let request: OmniRequest = serde_json::from_value(source.clone()).unwrap(); + assert!(request.uses_artcraft()); + assert!(!request.uses_legacy_image_endpoint()); + assert_eq!(Value::Object(request.api_fields(Modality::Image)), source); + } + } + for model in ["wan_3p0", "wan_3p0_prime", "seedance_2p5", "seedance_2p5_u"] { + let source = json!({ + "model": model, "duration_seconds": 15, "resolution": "ten_eighty_p", + "output_format": "mov", "generate_audio": true, + "reference_image_media_tokens": ["mf_image"], + "reference_video_media_tokens": ["mf_video"], + "reference_audio_media_tokens": ["mf_audio"] + }); + let request: OmniRequest = serde_json::from_value(source.clone()).unwrap(); + assert!(request.uses_artcraft()); + assert_eq!(Value::Object(request.api_fields(Modality::Video)), source); + } + } + #[test] fn future_model_and_options_survive_without_desktop_metadata() { let request: OmniRequest = serde_json::from_value(json!({ diff --git a/crates/schema/public/enums/src/by_table/generic_inference_jobs/inference_model_type.rs b/crates/schema/public/enums/src/by_table/generic_inference_jobs/inference_model_type.rs index f6f55c73fb..6a0e3bcbb4 100644 --- a/crates/schema/public/enums/src/by_table/generic_inference_jobs/inference_model_type.rs +++ b/crates/schema/public/enums/src/by_table/generic_inference_jobs/inference_model_type.rs @@ -192,6 +192,12 @@ pub enum InferenceModelType { Kling3p0Pro, #[serde(rename = "happy_horse_1p0")] HappyHorse1p0, + + #[serde(rename = "wan_3p0")] + Wan3p0, + + #[serde(rename = "wan_3p0_prime")] + Wan3p0Prime, #[serde(rename = "minimax_h3")] MinimaxH3, #[serde(rename = "minimax_h3_turbo")] @@ -394,6 +400,8 @@ impl InferenceModelType { Self::Kling3p0Standard => "kling_3p0_standard", Self::Kling3p0Pro => "kling_3p0_pro", Self::HappyHorse1p0 => "happy_horse_1p0", + Self::Wan3p0 => "wan_3p0", + Self::Wan3p0Prime => "wan_3p0_prime", Self::MinimaxH3 => "minimax_h3", Self::MinimaxH3Turbo => "minimax_h3_turbo", Self::MinimaxH3Ultra => "minimax_h3_ultra", @@ -534,6 +542,8 @@ impl InferenceModelType { "kling_3p0_standard" => Ok(Self::Kling3p0Standard), "kling_3p0_pro" => Ok(Self::Kling3p0Pro), "happy_horse_1p0" => Ok(Self::HappyHorse1p0), + "wan_3p0" => Ok(Self::Wan3p0), + "wan_3p0_prime" => Ok(Self::Wan3p0Prime), "minimax_h3" => Ok(Self::MinimaxH3), "minimax_h3_turbo" => Ok(Self::MinimaxH3Turbo), "minimax_h3_ultra" => Ok(Self::MinimaxH3Ultra), @@ -678,6 +688,8 @@ impl InferenceModelType { Self::Kling3p0Standard, Self::Kling3p0Pro, Self::HappyHorse1p0, + Self::Wan3p0, + Self::Wan3p0Prime, Self::MinimaxH3, Self::MinimaxH3Turbo, Self::MinimaxH3Ultra, @@ -805,6 +817,8 @@ impl InferenceModelType { CommonModelType::Kling3p0Standard => Self::Kling3p0Standard, CommonModelType::Kling3p0Pro => Self::Kling3p0Pro, CommonModelType::HappyHorse1p0 => Self::HappyHorse1p0, + CommonModelType::Wan3p0 => Self::Wan3p0, + CommonModelType::Wan3p0Prime => Self::Wan3p0Prime, CommonModelType::MinimaxH3 => Self::MinimaxH3, CommonModelType::MinimaxH3Turbo => Self::MinimaxH3Turbo, CommonModelType::MinimaxH3Ultra => Self::MinimaxH3Ultra, @@ -954,6 +968,8 @@ mod tests { assert_serialization(InferenceModelType::Kling3p0Standard, "kling_3p0_standard"); assert_serialization(InferenceModelType::Kling3p0Pro, "kling_3p0_pro"); assert_serialization(InferenceModelType::HappyHorse1p0, "happy_horse_1p0"); + assert_serialization(InferenceModelType::Wan3p0, "wan_3p0"); + assert_serialization(InferenceModelType::Wan3p0Prime, "wan_3p0_prime"); assert_serialization(InferenceModelType::MinimaxH3, "minimax_h3"); assert_serialization(InferenceModelType::MinimaxH3Turbo, "minimax_h3_turbo"); assert_serialization(InferenceModelType::MinimaxH3Ultra, "minimax_h3_ultra"); @@ -1090,6 +1106,8 @@ mod tests { assert_eq!(InferenceModelType::Kling3p0Standard.to_str(), "kling_3p0_standard"); assert_eq!(InferenceModelType::Kling3p0Pro.to_str(), "kling_3p0_pro"); assert_eq!(InferenceModelType::HappyHorse1p0.to_str(), "happy_horse_1p0"); + assert_eq!(InferenceModelType::Wan3p0.to_str(), "wan_3p0"); + assert_eq!(InferenceModelType::Wan3p0Prime.to_str(), "wan_3p0_prime"); assert_eq!(InferenceModelType::MinimaxH3.to_str(), "minimax_h3"); assert_eq!(InferenceModelType::MinimaxH3Turbo.to_str(), "minimax_h3_turbo"); assert_eq!(InferenceModelType::MinimaxH3Ultra.to_str(), "minimax_h3_ultra"); @@ -1228,6 +1246,8 @@ mod tests { assert_eq!(InferenceModelType::from_str("kling_3p0_standard").unwrap(), InferenceModelType::Kling3p0Standard); assert_eq!(InferenceModelType::from_str("kling_3p0_pro").unwrap(), InferenceModelType::Kling3p0Pro); assert_eq!(InferenceModelType::from_str("happy_horse_1p0").unwrap(), InferenceModelType::HappyHorse1p0); + assert_eq!(InferenceModelType::from_str("wan_3p0").unwrap(), InferenceModelType::Wan3p0); + assert_eq!(InferenceModelType::from_str("wan_3p0_prime").unwrap(), InferenceModelType::Wan3p0Prime); assert_eq!(InferenceModelType::from_str("minimax_h3").unwrap(), InferenceModelType::MinimaxH3); assert_eq!(InferenceModelType::from_str("minimax_h3_turbo").unwrap(), InferenceModelType::MinimaxH3Turbo); assert_eq!(InferenceModelType::from_str("minimax_h3_ultra").unwrap(), InferenceModelType::MinimaxH3Ultra); diff --git a/crates/schema/public/enums/src/common/generation/common_model_type.rs b/crates/schema/public/enums/src/common/generation/common_model_type.rs index 22ea6e6cfa..858edfecca 100644 --- a/crates/schema/public/enums/src/common/generation/common_model_type.rs +++ b/crates/schema/public/enums/src/common/generation/common_model_type.rs @@ -163,6 +163,12 @@ pub enum CommonModelType { Kling3p0Pro, #[serde(rename = "happy_horse_1p0")] HappyHorse1p0, + + #[serde(rename = "wan_3p0")] + Wan3p0, + + #[serde(rename = "wan_3p0_prime")] + Wan3p0Prime, #[serde(rename = "minimax_h3")] MinimaxH3, #[serde(rename = "minimax_h3_turbo")] @@ -349,6 +355,8 @@ impl CommonModelType { Self::Kling3p0Standard => "kling_3p0_standard", Self::Kling3p0Pro => "kling_3p0_pro", Self::HappyHorse1p0 => "happy_horse_1p0", + Self::Wan3p0 => "wan_3p0", + Self::Wan3p0Prime => "wan_3p0_prime", Self::MinimaxH3 => "minimax_h3", Self::MinimaxH3Turbo => "minimax_h3_turbo", Self::MinimaxH3Ultra => "minimax_h3_ultra", @@ -472,6 +480,8 @@ impl CommonModelType { "kling_3p0_standard" => Ok(Self::Kling3p0Standard), "kling_3p0_pro" => Ok(Self::Kling3p0Pro), "happy_horse_1p0" => Ok(Self::HappyHorse1p0), + "wan_3p0" => Ok(Self::Wan3p0), + "wan_3p0_prime" => Ok(Self::Wan3p0Prime), "minimax_h3" => Ok(Self::MinimaxH3), "minimax_h3_turbo" => Ok(Self::MinimaxH3Turbo), "minimax_h3_ultra" => Ok(Self::MinimaxH3Ultra), @@ -599,6 +609,8 @@ impl CommonModelType { Self::Kling3p0Standard, Self::Kling3p0Pro, Self::HappyHorse1p0, + Self::Wan3p0, + Self::Wan3p0Prime, Self::MinimaxH3, Self::MinimaxH3Turbo, Self::MinimaxH3Ultra, @@ -723,6 +735,8 @@ impl CommonModelType { Self::Kling3p0Standard => CommonModelClass::Video, Self::Kling3p0Pro => CommonModelClass::Video, Self::HappyHorse1p0 => CommonModelClass::Video, + Self::Wan3p0 => CommonModelClass::Video, + Self::Wan3p0Prime => CommonModelClass::Video, Self::MinimaxH3 => CommonModelClass::Video, Self::MinimaxH3Turbo => CommonModelClass::Video, Self::MinimaxH3Ultra => CommonModelClass::Video, @@ -856,6 +870,8 @@ mod tests { assert_serialization(CommonModelType::Kling3p0Standard, "kling_3p0_standard"); assert_serialization(CommonModelType::Kling3p0Pro, "kling_3p0_pro"); assert_serialization(CommonModelType::HappyHorse1p0, "happy_horse_1p0"); + assert_serialization(CommonModelType::Wan3p0, "wan_3p0"); + assert_serialization(CommonModelType::Wan3p0Prime, "wan_3p0_prime"); assert_serialization(CommonModelType::MinimaxH3, "minimax_h3"); assert_serialization(CommonModelType::MinimaxH3Turbo, "minimax_h3_turbo"); assert_serialization(CommonModelType::MinimaxH3Ultra, "minimax_h3_ultra"); @@ -974,6 +990,8 @@ mod tests { assert_eq!(CommonModelType::Kling3p0Standard.to_str(), "kling_3p0_standard"); assert_eq!(CommonModelType::Kling3p0Pro.to_str(), "kling_3p0_pro"); assert_eq!(CommonModelType::HappyHorse1p0.to_str(), "happy_horse_1p0"); + assert_eq!(CommonModelType::Wan3p0.to_str(), "wan_3p0"); + assert_eq!(CommonModelType::Wan3p0Prime.to_str(), "wan_3p0_prime"); assert_eq!(CommonModelType::MinimaxH3.to_str(), "minimax_h3"); assert_eq!(CommonModelType::MinimaxH3Turbo.to_str(), "minimax_h3_turbo"); assert_eq!(CommonModelType::MinimaxH3Ultra.to_str(), "minimax_h3_ultra"); @@ -1094,6 +1112,8 @@ mod tests { assert_eq!(CommonModelType::from_str("kling_3p0_standard").unwrap(), CommonModelType::Kling3p0Standard); assert_eq!(CommonModelType::from_str("kling_3p0_pro").unwrap(), CommonModelType::Kling3p0Pro); assert_eq!(CommonModelType::from_str("happy_horse_1p0").unwrap(), CommonModelType::HappyHorse1p0); + assert_eq!(CommonModelType::from_str("wan_3p0").unwrap(), CommonModelType::Wan3p0); + assert_eq!(CommonModelType::from_str("wan_3p0_prime").unwrap(), CommonModelType::Wan3p0Prime); assert_eq!(CommonModelType::from_str("minimax_h3").unwrap(), CommonModelType::MinimaxH3); assert_eq!(CommonModelType::from_str("minimax_h3_turbo").unwrap(), CommonModelType::MinimaxH3Turbo); assert_eq!(CommonModelType::from_str("minimax_h3_ultra").unwrap(), CommonModelType::MinimaxH3Ultra); @@ -1158,7 +1178,7 @@ mod tests { #[test] fn all_variants() { let mut variants = CommonModelType::all_variants(); - assert_eq!(variants.len(), 108); + assert_eq!(variants.len(), 110); // Image models assert_eq!(variants.pop_first(), Some(CommonModelType::Flux1Dev)); assert_eq!(variants.pop_first(), Some(CommonModelType::Flux1Schnell)); @@ -1214,6 +1234,8 @@ mod tests { assert_eq!(variants.pop_first(), Some(CommonModelType::Kling3p0Standard)); assert_eq!(variants.pop_first(), Some(CommonModelType::Kling3p0Pro)); assert_eq!(variants.pop_first(), Some(CommonModelType::HappyHorse1p0)); + assert_eq!(variants.pop_first(), Some(CommonModelType::Wan3p0)); + assert_eq!(variants.pop_first(), Some(CommonModelType::Wan3p0Prime)); assert_eq!(variants.pop_first(), Some(CommonModelType::MinimaxH3)); assert_eq!(variants.pop_first(), Some(CommonModelType::MinimaxH3Turbo)); assert_eq!(variants.pop_first(), Some(CommonModelType::MinimaxH3Ultra)); diff --git a/crates/schema/public/enums/src/common/generation/common_quality.rs b/crates/schema/public/enums/src/common/generation/common_quality.rs index cdcc1e5560..4063a75910 100644 --- a/crates/schema/public/enums/src/common/generation/common_quality.rs +++ b/crates/schema/public/enums/src/common/generation/common_quality.rs @@ -13,11 +13,22 @@ pub const MAX_LENGTH: usize = 16; /// Common quality levels for generation. /// +/// Declared from "let the model decide" through the highest tier down to the lowest, so the +/// derived `Ord` (and `all_variants()`) reads top-down. Not every model supports every tier; +/// the router clamps unsupported tiers to the nearest one the model offers. +/// /// NB: Keep the max serialized length to 16 characters. #[cfg_attr(test, derive(EnumIter, EnumCount))] #[derive(Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "snake_case")] pub enum CommonQuality { + /// Let the model choose. Models without a native "auto" treat this as their default tier. + Auto, + /// Highest tier (GPT Image 2.5 `max`). + Max, + /// Between High and Max (GPT Image 2.5 `xhigh`). + #[serde(rename = "xhigh")] + XHigh, High, Medium, Low, @@ -30,6 +41,9 @@ impl_mysql_from_row!(CommonQuality); impl CommonQuality { pub fn to_str(&self) -> &'static str { match self { + Self::Auto => "auto", + Self::Max => "max", + Self::XHigh => "xhigh", Self::High => "high", Self::Medium => "medium", Self::Low => "low", @@ -38,6 +52,9 @@ impl CommonQuality { pub fn from_str(value: &str) -> Result { match value { + "auto" => Ok(Self::Auto), + "max" => Ok(Self::Max), + "xhigh" => Ok(Self::XHigh), "high" => Ok(Self::High), "medium" => Ok(Self::Medium), "low" => Ok(Self::Low), @@ -47,6 +64,9 @@ impl CommonQuality { pub fn all_variants() -> BTreeSet { BTreeSet::from([ + Self::Auto, + Self::Max, + Self::XHigh, Self::High, Self::Medium, Self::Low, @@ -66,6 +86,9 @@ mod tests { #[test] fn test_serialization() { + assert_serialization(CommonQuality::Auto, "auto"); + assert_serialization(CommonQuality::Max, "max"); + assert_serialization(CommonQuality::XHigh, "xhigh"); assert_serialization(CommonQuality::High, "high"); assert_serialization(CommonQuality::Medium, "medium"); assert_serialization(CommonQuality::Low, "low"); @@ -73,6 +96,9 @@ mod tests { #[test] fn to_str() { + assert_eq!(CommonQuality::Auto.to_str(), "auto"); + assert_eq!(CommonQuality::Max.to_str(), "max"); + assert_eq!(CommonQuality::XHigh.to_str(), "xhigh"); assert_eq!(CommonQuality::High.to_str(), "high"); assert_eq!(CommonQuality::Medium.to_str(), "medium"); assert_eq!(CommonQuality::Low.to_str(), "low"); @@ -80,6 +106,9 @@ mod tests { #[test] fn from_str() { + assert_eq!(CommonQuality::from_str("auto").unwrap(), CommonQuality::Auto); + assert_eq!(CommonQuality::from_str("max").unwrap(), CommonQuality::Max); + assert_eq!(CommonQuality::from_str("xhigh").unwrap(), CommonQuality::XHigh); assert_eq!(CommonQuality::from_str("high").unwrap(), CommonQuality::High); assert_eq!(CommonQuality::from_str("medium").unwrap(), CommonQuality::Medium); assert_eq!(CommonQuality::from_str("low").unwrap(), CommonQuality::Low); @@ -99,7 +128,10 @@ mod tests { #[test] fn all_variants() { let mut variants = CommonQuality::all_variants(); - assert_eq!(variants.len(), 3); + assert_eq!(variants.len(), 6); + assert_eq!(variants.pop_first(), Some(CommonQuality::Auto)); + assert_eq!(variants.pop_first(), Some(CommonQuality::Max)); + assert_eq!(variants.pop_first(), Some(CommonQuality::XHigh)); assert_eq!(variants.pop_first(), Some(CommonQuality::High)); assert_eq!(variants.pop_first(), Some(CommonQuality::Medium)); assert_eq!(variants.pop_first(), Some(CommonQuality::Low)); diff --git a/crates/schema/public/enums/src/common/generation/common_video_model.rs b/crates/schema/public/enums/src/common/generation/common_video_model.rs index 6e0a4f3e9d..ecaf2e7460 100644 --- a/crates/schema/public/enums/src/common/generation/common_video_model.rs +++ b/crates/schema/public/enums/src/common/generation/common_video_model.rs @@ -51,6 +51,12 @@ pub enum CommonVideoModel { #[serde(rename = "happy_horse_1p0")] HappyHorse1p0, + #[serde(rename = "wan_3p0")] + Wan3p0, + + #[serde(rename = "wan_3p0_prime")] + Wan3p0Prime, + #[serde(rename = "minimax_h3")] MinimaxH3, @@ -168,6 +174,8 @@ impl CommonVideoModel { Self::Kling3p0Standard => CommonModelType::Kling3p0Standard, Self::Kling3p0Pro => CommonModelType::Kling3p0Pro, Self::HappyHorse1p0 => CommonModelType::HappyHorse1p0, + Self::Wan3p0 => CommonModelType::Wan3p0, + Self::Wan3p0Prime => CommonModelType::Wan3p0Prime, Self::MinimaxH3 => CommonModelType::MinimaxH3, Self::MinimaxH3Turbo => CommonModelType::MinimaxH3Turbo, Self::MinimaxH3Ultra => CommonModelType::MinimaxH3Ultra, @@ -224,6 +232,8 @@ mod tests { assert_serialization(CommonVideoModel::Kling3p0Standard, "kling_3p0_standard"); assert_serialization(CommonVideoModel::Kling3p0Pro, "kling_3p0_pro"); assert_serialization(CommonVideoModel::HappyHorse1p0, "happy_horse_1p0"); + assert_serialization(CommonVideoModel::Wan3p0, "wan_3p0"); + assert_serialization(CommonVideoModel::Wan3p0Prime, "wan_3p0_prime"); assert_serialization(CommonVideoModel::MinimaxH3, "minimax_h3"); assert_serialization(CommonVideoModel::MinimaxH3Turbo, "minimax_h3_turbo"); assert_serialization(CommonVideoModel::MinimaxH3Ultra, "minimax_h3_ultra"); @@ -273,6 +283,8 @@ mod tests { ("kling_3p0_standard", CommonVideoModel::Kling3p0Standard), ("kling_3p0_pro", CommonVideoModel::Kling3p0Pro), ("happy_horse_1p0", CommonVideoModel::HappyHorse1p0), + ("wan_3p0", CommonVideoModel::Wan3p0), + ("wan_3p0_prime", CommonVideoModel::Wan3p0Prime), ("minimax_h3", CommonVideoModel::MinimaxH3), ("minimax_h3_turbo", CommonVideoModel::MinimaxH3Turbo), ("minimax_h3_ultra", CommonVideoModel::MinimaxH3Ultra), @@ -329,6 +341,8 @@ mod tests { CommonVideoModel::Kling3p0Standard, CommonVideoModel::Kling3p0Pro, CommonVideoModel::HappyHorse1p0, + CommonVideoModel::Wan3p0, + CommonVideoModel::Wan3p0Prime, CommonVideoModel::MinimaxH3, CommonVideoModel::MinimaxH3Turbo, CommonVideoModel::MinimaxH3Ultra, @@ -384,6 +398,8 @@ mod tests { (CommonVideoModel::Kling3p0Standard, CommonModelType::Kling3p0Standard), (CommonVideoModel::Kling3p0Pro, CommonModelType::Kling3p0Pro), (CommonVideoModel::HappyHorse1p0, CommonModelType::HappyHorse1p0), + (CommonVideoModel::Wan3p0, CommonModelType::Wan3p0), + (CommonVideoModel::Wan3p0Prime, CommonModelType::Wan3p0Prime), (CommonVideoModel::MinimaxH3, CommonModelType::MinimaxH3), (CommonVideoModel::MinimaxH3Turbo, CommonModelType::MinimaxH3Turbo), (CommonVideoModel::MinimaxH3Ultra, CommonModelType::MinimaxH3Ultra), diff --git a/crates/schema/public/enums/src/common/generation/common_video_output_format.rs b/crates/schema/public/enums/src/common/generation/common_video_output_format.rs new file mode 100644 index 0000000000..bfad109fb8 --- /dev/null +++ b/crates/schema/public/enums/src/common/generation/common_video_output_format.rs @@ -0,0 +1,162 @@ +use std::collections::BTreeSet; + +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; +use utoipa::ToSchema; + +use crate::error::enum_error::EnumError; + +/// Maximum serialized string length for database storage. +pub const MAX_LENGTH: usize = 16; + +/// Output video containers for generation. Unset options retain the provider default. +/// Models without output-format support ignore this option. +/// +/// NB: Keep the max serialized length to 16 characters. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum CommonVideoOutputFormat { + #[serde(rename = "mp4")] + Mp4, + #[serde(rename = "mov")] + Mov, +} + +impl_enum_display_and_debug_using_to_str!(CommonVideoOutputFormat); +impl_mysql_enum_coders!(CommonVideoOutputFormat); +impl_mysql_from_row!(CommonVideoOutputFormat); + +impl CommonVideoOutputFormat { + pub fn to_str(&self) -> &'static str { + match self { + Self::Mp4 => "mp4", + Self::Mov => "mov", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "mp4" => Ok(Self::Mp4), + "mov" => Ok(Self::Mov), + _ => Err(EnumError::CouldNotConvertFromString(value.to_string())), + } + } + + pub fn all_variants() -> BTreeSet { + BTreeSet::from([ + Self::Mp4, + Self::Mov, + ]) + } +} + +#[cfg(test)] +mod tests { + use crate::common::generation::common_video_output_format::CommonVideoOutputFormat; + use crate::common::generation::common_video_output_format::MAX_LENGTH; + use crate::test_helpers::assert_serialization; + + mod explicit_checks { + use super::*; + use crate::error::enum_error::EnumError; + + #[test] + fn test_serialization() { + assert_serialization(CommonVideoOutputFormat::Mp4, "mp4"); + assert_serialization(CommonVideoOutputFormat::Mov, "mov"); + } + + #[test] + fn to_str() { + assert_eq!(CommonVideoOutputFormat::Mp4.to_str(), "mp4"); + assert_eq!(CommonVideoOutputFormat::Mov.to_str(), "mov"); + } + + #[test] + fn from_str() { + assert_eq!(CommonVideoOutputFormat::from_str("mp4").unwrap(), CommonVideoOutputFormat::Mp4); + assert_eq!(CommonVideoOutputFormat::from_str("mov").unwrap(), CommonVideoOutputFormat::Mov); + } + + #[test] + fn from_str_err() { + let result = CommonVideoOutputFormat::from_str("invalid"); + assert!(result.is_err()); + if let Err(EnumError::CouldNotConvertFromString(value)) = result { + assert_eq!(value, "invalid"); + } else { + panic!("Expected EnumError::CouldNotConvertFromString"); + } + } + + #[test] + fn all_variants() { + let mut variants = CommonVideoOutputFormat::all_variants(); + assert_eq!(variants.len(), 2); + assert_eq!(variants.pop_first(), Some(CommonVideoOutputFormat::Mp4)); + assert_eq!(variants.pop_first(), Some(CommonVideoOutputFormat::Mov)); + assert_eq!(variants.pop_first(), None); + } + } + + mod mechanical_checks { + use super::*; + + #[test] + fn variant_length() { + use strum::IntoEnumIterator; + assert_eq!(CommonVideoOutputFormat::all_variants().len(), CommonVideoOutputFormat::iter().len()); + } + + #[test] + fn round_trip() { + for variant in CommonVideoOutputFormat::all_variants() { + assert_eq!(variant, CommonVideoOutputFormat::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, CommonVideoOutputFormat::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, CommonVideoOutputFormat::from_str(&format!("{:?}", variant)).unwrap()); + } + } + + #[test] + fn serialized_length_ok_for_database() { + for variant in CommonVideoOutputFormat::all_variants() { + let serialized = variant.to_str(); + assert!(serialized.len() > 0, "variant {:?} is too short", variant); + assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long via to_str()", variant); + } + for variant in CommonVideoOutputFormat::all_variants() { + let json = serde_json::to_string(&variant).unwrap().replace('"', ""); + assert!(json.len() <= MAX_LENGTH, "variant {:?} is too long via JSON: {:?}", variant, json); + } + } + + #[test] + fn serialized_names_must_not_contain_dots() { + for variant in CommonVideoOutputFormat::all_variants() { + let to_str_value = variant.to_str(); + assert!(!to_str_value.contains('.'), "to_str() for {:?} contains a dot: {:?}", variant, to_str_value); + + let json_value = serde_json::to_string(&variant).unwrap().replace('"', ""); + assert!(!json_value.contains('.'), "JSON serialization for {:?} contains a dot: {:?}", variant, json_value); + } + } + + #[test] + fn serialized_names_must_only_contain_lowercase_alphanumeric_and_underscore() { + let valid_pattern = regex::Regex::new(r"^[a-z0-9_]+$").unwrap(); + + for variant in CommonVideoOutputFormat::all_variants() { + let to_str_value = variant.to_str(); + assert!(valid_pattern.is_match(to_str_value), + "to_str() for {:?} contains invalid characters: {:?} (only a-z, 0-9, _ allowed)", variant, to_str_value); + + let json_value = serde_json::to_string(&variant).unwrap().replace('"', ""); + assert!(valid_pattern.is_match(&json_value), + "JSON serialization for {:?} contains invalid characters: {:?} (only a-z, 0-9, _ allowed)", variant, json_value); + } + } + } +} diff --git a/crates/schema/public/enums/src/common/generation/mod.rs b/crates/schema/public/enums/src/common/generation/mod.rs index d8a346ed3b..dac15d5a5a 100644 --- a/crates/schema/public/enums/src/common/generation/mod.rs +++ b/crates/schema/public/enums/src/common/generation/mod.rs @@ -15,3 +15,4 @@ pub mod model_creator; pub mod common_resolution; pub mod common_splat_model; pub mod common_video_model; +pub mod common_video_output_format; diff --git a/frontend/libs/api-enums/src/lib/common/generation/CommonImageModel.ts b/frontend/libs/api-enums/src/lib/common/generation/CommonImageModel.ts index da5b5742a8..bd23813eeb 100644 --- a/frontend/libs/api-enums/src/lib/common/generation/CommonImageModel.ts +++ b/frontend/libs/api-enums/src/lib/common/generation/CommonImageModel.ts @@ -12,12 +12,19 @@ export enum CommonImageModel { GptImage2 = "gpt_image_2", GptImage2p5Flare = "gpt_image_2p5_flare", GptImage2p5Sunburst = "gpt_image_2p5_sunburst", + GrokImagineImage = "grok_imagine_image", + GrokImagineImageQuality = "grok_imagine_image_q", + Midjourney7 = "midjourney_7", + Midjourney7Niji = "midjourney_7_niji", + Midjourney8 = "midjourney_8", NanaBanana = "nano_banana", NanaBanana2 = "nano_banana_2", NanaBananaPro = "nano_banana_pro", Seedream4 = "seedream_4", Seedream4p5 = "seedream_4p5", Seedream5Lite = "seedream_5_lite", + Seedream5p0Pro = "seedream_5p0_pro", + Seedream5p0ProUltra = "seedream_5p0_pro_u", QwenEdit2511Angles = "qwen_edit_2511_angles", Flux2LoraAngles = "flux_2_lora_angles", } diff --git a/frontend/libs/api-enums/src/lib/common/generation/CommonQuality.ts b/frontend/libs/api-enums/src/lib/common/generation/CommonQuality.ts index 5e35935d44..17dc839345 100644 --- a/frontend/libs/api-enums/src/lib/common/generation/CommonQuality.ts +++ b/frontend/libs/api-enums/src/lib/common/generation/CommonQuality.ts @@ -3,6 +3,9 @@ // In the future, we should use code gen (protobufs or similar) to keep the two sides in sync. export enum CommonQuality { + Auto = "auto", + Max = "max", + XHigh = "xhigh", High = "high", Medium = "medium", Low = "low", diff --git a/frontend/libs/api-enums/src/lib/common/generation/CommonVideoModel.ts b/frontend/libs/api-enums/src/lib/common/generation/CommonVideoModel.ts index 963cd302f3..2e8f6169ca 100644 --- a/frontend/libs/api-enums/src/lib/common/generation/CommonVideoModel.ts +++ b/frontend/libs/api-enums/src/lib/common/generation/CommonVideoModel.ts @@ -4,6 +4,10 @@ export enum CommonVideoModel { GrokVideo = "grok_video", + GrokImagineVideo = "grok_imagine_video", + GrokImagineVideo1p5 = "grok_imagine_video_1p5", + Flux3 = "flux_3", + Flux3Draft = "flux_3_draft", Kling16Pro = "kling_1p6_pro", Kling21Pro = "kling_2p1_pro", Kling21Master = "kling_2p1_master", @@ -11,10 +15,28 @@ export enum CommonVideoModel { Kling2p6Pro = "kling_2p6_pro", Kling3p0Standard = "kling_3p0_standard", Kling3p0Pro = "kling_3p0_pro", + HappyHorse1p0 = "happy_horse_1p0", + Wan3p0 = "wan_3p0", + Wan3p0Prime = "wan_3p0_prime", + MinimaxH3 = "minimax_h3", + MinimaxH3Turbo = "minimax_h3_turbo", + MinimaxH3Ultra = "minimax_h3_ultra", Seedance10Lite = "seedance_1p0_lite", Seedance1p5Pro = "seedance_1p5_pro", Seedance2p0 = "seedance_2p0", Seedance2p0Fast = "seedance_2p0_fast", + Seedance2p0BytePlus = "seedance_2p0_bp", + Seedance2p0BytePlusFast = "seedance_2p0_bp_fast", + Seedance2p0Ultra = "seedance_2p0_u", + Seedance2p0UltraFast = "seedance_2p0_u_fast", + Seedance2p0BytePlusUltra = "seedance_2p0_bpu", + Seedance2p0BytePlusUltraFast = "seedance_2p0_bpu_fast", + Seedance2p0Mini = "seedance_2p0_mini", + Seedance2p0BytePlusMini = "seedance_2p0_bp_mini", + Seedance2p0BytePlusUltraMini = "seedance_2p0_bpu_mini", + Seedance2p5Preview = "seedance_2p5_preview", + Seedance2p5 = "seedance_2p5", + Seedance2p5Ultra = "seedance_2p5_u", Sora2 = "sora_2", Sora2Pro = "sora_2_pro", Veo2 = "veo_2", @@ -22,4 +44,9 @@ export enum CommonVideoModel { Veo3Fast = "veo_3_fast", Veo3p1 = "veo_3p1", Veo3p1Fast = "veo_3p1_fast", + Veo3p1Lite = "veo_3p1_lite", + ViduQ3 = "vidu_q3", + ViduQ3Turbo = "vidu_q3_turbo", + PreviewModel = "preview_model", + PreviewModelFast = "preview_model_fast", } diff --git a/frontend/libs/components/pricing-modal/src/lib/convert/stringToCommonQuality.ts b/frontend/libs/components/pricing-modal/src/lib/convert/stringToCommonQuality.ts index d987f8613b..69d4a36268 100644 --- a/frontend/libs/components/pricing-modal/src/lib/convert/stringToCommonQuality.ts +++ b/frontend/libs/components/pricing-modal/src/lib/convert/stringToCommonQuality.ts @@ -3,12 +3,18 @@ import { CommonQuality } from "@storyteller/api-enums"; /** * Convert a quality value to CommonQuality for the image cost estimate API. * - * Accepts a CommonQuality string value from @storyteller/model-list ("high", "medium", "low"). + * Preserves all API quality tiers, including values added after this build. */ export function stringToCommonQuality( quality: string | undefined, ): string | null { switch (quality) { + case "auto": + return CommonQuality.Auto; + case "max": + return CommonQuality.Max; + case "xhigh": + return CommonQuality.XHigh; case "high": return CommonQuality.High; case "medium": diff --git a/frontend/libs/components/pricing-modal/src/lib/useImageCostEstimate.ts b/frontend/libs/components/pricing-modal/src/lib/useImageCostEstimate.ts index f8a5629349..6ea1d2cbac 100644 --- a/frontend/libs/components/pricing-modal/src/lib/useImageCostEstimate.ts +++ b/frontend/libs/components/pricing-modal/src/lib/useImageCostEstimate.ts @@ -78,7 +78,7 @@ export function useImageCostEstimate( // 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; + qualityStr = imageModel.resolveQuality(imageQuality); referenceImageCount = imageModel.canUseImagePrompt ? imageReferenceImages.filter((image) => image.mediaToken.length > 0).length : 0; generationCount = imageGenerationCount; diff --git a/frontend/libs/components/pricing-modal/src/lib/useVideoCostEstimate.spec.tsx b/frontend/libs/components/pricing-modal/src/lib/useVideoCostEstimate.spec.tsx index 2d0fe26557..bfbc7ded02 100644 --- a/frontend/libs/components/pricing-modal/src/lib/useVideoCostEstimate.spec.tsx +++ b/frontend/libs/components/pricing-modal/src/lib/useVideoCostEstimate.spec.tsx @@ -17,6 +17,7 @@ const mocks = vi.hoisted(() => ({ referenceVideos: [{ mediaToken: "mf_video", duration: 7.25 }], referenceAudios: [{ mediaToken: "mf_audio", duration: 3.5 }], bitrate: "high", + outputFormat: "mov", generationCount: 1, generateWithSound: true, }, @@ -38,6 +39,7 @@ vi.mock("./cost-breakdown-modal-store", () => ({ })); beforeEach(() => { + mocks.state.generationCount = 1; mocks.estimate.mockReset(); mocks.setCredits.mockReset(); mocks.estimate.mockResolvedValue({ @@ -61,6 +63,7 @@ it("quotes a future model with the chosen bitrate and reference durations in mil model: "future_video", resolution: "four_k", bitrate: "high", + output_format: "mov", duration_seconds: 30, reference_video_media_tokens: ["mf_video"], reference_audio_media_tokens: ["mf_audio"], @@ -72,6 +75,20 @@ it("quotes a future model with the chosen bitrate and reference durations in mil ); }); +it("prices all selected generations for any model with API batch options", async () => { + mocks.state.generationCount = 3; + renderHook(() => useVideoCostEstimate(ModelPage.ImageToVideo, model, "artcraft")); + await waitFor(() => expect(mocks.setCredits).toHaveBeenLastCalledWith(ModelPage.ImageToVideo, 126)); +}); + +it("clears format options and clamps stale batch counts for unsupported models", async () => { + mocks.state.generationCount = 4; + const single = { ...model, generationCountOptions: [1], outputFormatOptions: undefined } as VideoModel; + renderHook(() => useVideoCostEstimate(ModelPage.ImageToVideo, single, "artcraft")); + await waitFor(() => expect(mocks.setCredits).toHaveBeenLastCalledWith(ModelPage.ImageToVideo, 42)); + expect(mocks.estimate).toHaveBeenCalledWith(expect.objectContaining({ output_format: undefined })); +}); + it("does not overwrite a newer model's quote with a slow earlier response", async () => { let finishOld: (result: unknown) => void = () => {}; mocks.estimate.mockImplementationOnce( @@ -111,6 +128,10 @@ const model = { resolutionOptions: ["4K"], bitrateOptions: ["normal", "high"], defaultBitrate: "normal", + outputFormatOptions: ["mp4", "mov"], + defaultOutputFormat: "mp4", + generationCountOptions: [1, 2, 3, 4], + defaultGenerationCount: 1, generateWithSound: true, sizeOptions: [{ tauriValue: "wide_sixteen_by_nine", textLabel: "16:9" }], } as VideoModel; diff --git a/frontend/libs/components/pricing-modal/src/lib/useVideoCostEstimate.ts b/frontend/libs/components/pricing-modal/src/lib/useVideoCostEstimate.ts index 6bae3e3f5a..77e053c0c5 100644 --- a/frontend/libs/components/pricing-modal/src/lib/useVideoCostEstimate.ts +++ b/frontend/libs/components/pricing-modal/src/lib/useVideoCostEstimate.ts @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; import { ModelPage } from "@storyteller/ui-model-selector"; -import { Model, VideoModel, resolveVideoDuration, videoResolutionValue } from "@storyteller/model-list"; +import { Model, VideoModel, resolveVideoDuration, resolveVideoGenerationCount, resolveVideoOutputFormat, videoResolutionValue } from "@storyteller/model-list"; import { GenerationProvider } from "@storyteller/api-enums"; import { usePromptVideoStore } from "@storyteller/ui-promptbox"; import { @@ -32,6 +32,7 @@ export function useVideoCostEstimate( const referenceVideos = usePromptVideoStore((s) => s.referenceVideos); const referenceAudios = usePromptVideoStore((s) => s.referenceAudios); const bitrate = usePromptVideoStore((s) => s.bitrate); + const outputFormat = usePromptVideoStore((s) => s.outputFormat); const generationCount = usePromptVideoStore((s) => s.generationCount); const generateWithSound = usePromptVideoStore((s) => s.generateWithSound); @@ -72,6 +73,7 @@ export function useVideoCostEstimate( duration_seconds: resolveVideoDuration(videoModel, duration, isReferenceMode) ?? undefined, generate_audio: videoModel.generateWithSound ? generateWithSound : undefined, bitrate: videoModel.bitrateOptions?.length ? bitrate ?? videoModel.defaultBitrate ?? videoModel.bitrateOptions[0] : undefined, + output_format: resolveVideoOutputFormat(videoModel, outputFormat), 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, @@ -88,7 +90,7 @@ export function useVideoCostEstimate( if (cancelled) return; if (isEstimateVideoCostSuccess(result)) { const unitCredits = result.payload.cost_in_credits; - const count = videoModel.id === "seedance_2p0" ? generationCount : 1; + const count = resolveVideoGenerationCount(videoModel, generationCount); const credits = unitCredits == null ? null : unitCredits * count; setEstimatedCreditsForPage(ModelPage.ImageToVideo, credits); } else { @@ -115,6 +117,7 @@ export function useVideoCostEstimate( referenceVideos, referenceAudios, bitrate, + outputFormat, generationCount, endFrameImage, generateWithSound, diff --git a/frontend/libs/components/promptbox/src/lib/PromptBoxImage.tsx b/frontend/libs/components/promptbox/src/lib/PromptBoxImage.tsx index c1a386f181..a1451da120 100644 --- a/frontend/libs/components/promptbox/src/lib/PromptBoxImage.tsx +++ b/frontend/libs/components/promptbox/src/lib/PromptBoxImage.tsx @@ -407,7 +407,7 @@ export const PromptBoxImage = ({ } if (selectedModel?.supportsQuality()) { - request.quality = commonQuality ?? selectedModel.defaultQuality; + request.quality = selectedModel.resolveQuality(commonQuality); } if ( diff --git a/frontend/libs/components/promptbox/src/lib/PromptBoxVideo.tsx b/frontend/libs/components/promptbox/src/lib/PromptBoxVideo.tsx index ab24e5e001..8ed2128cca 100644 --- a/frontend/libs/components/promptbox/src/lib/PromptBoxVideo.tsx +++ b/frontend/libs/components/promptbox/src/lib/PromptBoxVideo.tsx @@ -11,6 +11,9 @@ import { DynamicIcon } from "@storyteller/icons"; import { arrayMove } from "@dnd-kit/sortable"; import { resolveVideoDuration, + resolveVideoGenerationCount, + resolveVideoOutputFormat, + videoGenerationCounts, videoDurationRange, videoResolutionValue, effectivePromptMaxLength, @@ -143,6 +146,8 @@ export const PromptBoxVideo = ({ const duration = usePromptVideoStore((s) => s.duration); const bitrate = usePromptVideoStore((s) => s.bitrate); const setBitrate = usePromptVideoStore((s) => s.setBitrate); + const outputFormat = usePromptVideoStore((s) => s.outputFormat); + const setOutputFormat = usePromptVideoStore((s) => s.setOutputFormat); const setDuration = usePromptVideoStore((s) => s.setDuration); const inputMode = usePromptVideoStore((s) => s.inputMode); const setInputMode = usePromptVideoStore((s) => s.setInputMode); @@ -361,6 +366,14 @@ export const PromptBoxVideo = ({ } }, [selectedModel, setBitrate]); + useEffect(() => { + const current = usePromptVideoStore.getState().outputFormat; + const options = selectedModel?.outputFormatOptions; + if (!current || !options?.includes(current)) { + setOutputFormat(selectedModel?.defaultOutputFormat ?? options?.[0] ?? null); + } + }, [selectedModel, setOutputFormat]); + useEffect(() => { const current = usePromptVideoStore.getState().aspectRatio; if (selectedModel?.sizeOptions.some((option) => option.textLabel === current)) return; @@ -396,15 +409,16 @@ export const PromptBoxVideo = ({ } }, [selectedModel]); - // Reset generation count when switching away from seedance 2.0. - // Read from store directly to avoid stale closure (same as duration above). + // Keep the selected count within the API's options when switching models. useEffect(() => { - const currentGenerationCount = - usePromptVideoStore.getState().generationCount; - if (selectedModel?.id !== "seedance_2p0" && currentGenerationCount > 1) { - setGenerationCount(1); - } - }, [selectedModel]); + const current = usePromptVideoStore.getState().generationCount; + const next = selectedModel ? resolveVideoGenerationCount(selectedModel, current) : 1; + if (next !== current) setGenerationCount(next); + }, [selectedModel, setGenerationCount]); + + const generationCounts = selectedModel ? videoGenerationCounts(selectedModel) : [1]; + const effectiveGenerationCount = selectedModel + ? resolveVideoGenerationCount(selectedModel, generationCount) : 1; const durationRange = selectedModel ? videoDurationRange(selectedModel, inputMode === "reference") @@ -1049,8 +1063,7 @@ export const PromptBoxVideo = ({ gtagEvent("enqueue_video"); - const isSeedance2 = selectedModel.id === "seedance_2p0"; - const count = isSeedance2 ? generationCount : 1; + const count = effectiveGenerationCount; const isRefMode = inputMode === "reference" && !!selectedModel.supportsReferenceMode; @@ -1145,6 +1158,9 @@ export const PromptBoxVideo = ({ if (selectedModel.bitrateOptions?.length) { request.bitrate = bitrate ?? selectedModel.defaultBitrate ?? selectedModel.bitrateOptions[0]; } + if (selectedModel.outputFormatOptions?.length) { + request.output_format = resolveVideoOutputFormat(selectedModel, outputFormat); + } // Pass the chosen resolution when the model exposes a resolution picker. // Guarded on `resolutionOptions` so a stale store value (left over from a @@ -1454,6 +1470,21 @@ export const PromptBoxVideo = ({ )} + {!!selectedModel?.outputFormatOptions?.length && ( + + ({ + label: value.toUpperCase(), + action: value, + selected: value === outputFormat, + }))} + onSelect={(item) => setOutputFormat(item.action ?? item.label)} + /> + + )} + {durationRange && durationRange.max > durationRange.min && ( - {selectedModel?.id === "seedance_2p0" && ( + {generationCounts.length > 1 && ( )} @@ -1541,9 +1572,7 @@ export const PromptBoxVideo = ({ onClick={handleEnqueue} disabled={!prompt.trim()} loading={isEnqueueing} - credits={ - credits != null ? credits * generationCount : credits - } + credits={credits} /> diff --git a/frontend/libs/components/promptbox/src/lib/common/QualityPicker.tsx b/frontend/libs/components/promptbox/src/lib/common/QualityPicker.tsx index 327f61b746..60cd89c3f1 100644 --- a/frontend/libs/components/promptbox/src/lib/common/QualityPicker.tsx +++ b/frontend/libs/components/promptbox/src/lib/common/QualityPicker.tsx @@ -10,6 +10,9 @@ interface QualityPickerProps { } const QUALITY_LABELS: Record = { + [CommonQuality.Auto]: "Auto", + [CommonQuality.Max]: "Max", + [CommonQuality.XHigh]: "Extra high", [CommonQuality.High]: "High", [CommonQuality.Medium]: "Medium", [CommonQuality.Low]: "Low", @@ -17,7 +20,7 @@ const QUALITY_LABELS: Record = { /** * Stateless picker for image generation "quality" (used by OpenAI image - * models — gpt_image_1, gpt_image_1p5, gpt_image_2). Models that don't + * models, including GPT Image 2.5). Models that don't * declare `qualities` should not render this picker. */ export const QualityPicker = ({ @@ -25,7 +28,7 @@ export const QualityPicker = ({ currentQuality, handleCommonQualitySelect, }: QualityPickerProps) => { - const useQuality = currentQuality ?? model.defaultQuality ?? undefined; + const useQuality = model.resolveQuality(currentQuality); const handleSelectAdapter = (item: PopoverItem) => { const quality = (item.action ?? item.label) as CommonQuality; diff --git a/frontend/libs/components/promptbox/src/lib/common/VideoGenerationCountPicker.tsx b/frontend/libs/components/promptbox/src/lib/common/VideoGenerationCountPicker.tsx index 0c40df9035..5b6ca9f1f7 100644 --- a/frontend/libs/components/promptbox/src/lib/common/VideoGenerationCountPicker.tsx +++ b/frontend/libs/components/promptbox/src/lib/common/VideoGenerationCountPicker.tsx @@ -4,27 +4,24 @@ import { Tooltip } from "@storyteller/ui-tooltip"; import { PROMPT_TOOLBAR_ICON_BUTTON_CLASSES } from "../PromptClearAllButton"; interface VideoGenerationCountPickerProps { - maxCount: number; + counts: number[]; currentCount: number; handleCountChange: (count: number) => void; } export const VideoGenerationCountPicker = ({ - maxCount, + counts, currentCount, handleCountChange, }: VideoGenerationCountPickerProps) => { - const options: PopoverItem[] = []; - for (let i = 1; i <= maxCount; i++) { - options.push({ - label: String(i), - selected: i === currentCount, - }); - } + const options: PopoverItem[] = counts.map((count) => ({ + label: String(count), + selected: count === currentCount, + })); const onSelect = (item: PopoverItem) => { const count = parseInt(item.label, 10); - if (!isNaN(count) && count >= 1 && count <= maxCount) { + if (counts.includes(count)) { handleCountChange(count); } }; diff --git a/frontend/libs/components/promptbox/src/lib/promptStore.ts b/frontend/libs/components/promptbox/src/lib/promptStore.ts index b81f4ee62d..8c4d815587 100644 --- a/frontend/libs/components/promptbox/src/lib/promptStore.ts +++ b/frontend/libs/components/promptbox/src/lib/promptStore.ts @@ -165,6 +165,7 @@ interface PromptVideoStore { generateWithSound: boolean; duration: number | null; bitrate: string | null; + outputFormat: string | null; inputMode: VideoInputMode; generationCount: number; setPrompt: (prompt: string) => void; @@ -178,6 +179,7 @@ interface PromptVideoStore { setGenerateWithSound: (value: boolean) => void; setDuration: (duration: number | null) => void; setBitrate: (bitrate: string | null) => void; + setOutputFormat: (outputFormat: string | null) => void; setInputMode: (mode: VideoInputMode) => void; setGenerationCount: (count: number) => void; } @@ -194,6 +196,7 @@ export const usePromptVideoStore = create()((set) => ({ generateWithSound: true, duration: null, bitrate: null, + outputFormat: null, inputMode: "reference", generationCount: 1, setPrompt: (prompt) => set({ prompt }), @@ -207,6 +210,7 @@ export const usePromptVideoStore = create()((set) => ({ setGenerateWithSound: (generateWithSound) => set({ generateWithSound }), setDuration: (duration) => set({ duration }), setBitrate: (bitrate) => set({ bitrate }), + setOutputFormat: (outputFormat) => set({ outputFormat }), setInputMode: (inputMode) => set({ inputMode }), setGenerationCount: (generationCount) => set({ generationCount }), })); diff --git a/frontend/libs/model-list/src/lib/classes/ImageModel.ts b/frontend/libs/model-list/src/lib/classes/ImageModel.ts index a188129a71..170183ed54 100644 --- a/frontend/libs/model-list/src/lib/classes/ImageModel.ts +++ b/frontend/libs/model-list/src/lib/classes/ImageModel.ts @@ -189,6 +189,13 @@ export class ImageModel extends Model { return this.qualityOptions && this.qualityOptions.length > 0; } + // A saved tier from another model may not be supported by this model. + resolveQuality(current?: CommonQuality): CommonQuality | undefined { + if (!this.supportsQuality()) return undefined; + return current && this.qualityOptions.includes(current) + ? current : this.defaultQuality ?? this.qualityOptions[0]; + } + // Return whether the count of generations is valid for this model isValidGenerationCount(count: number): boolean { if (this.predefinedGenerationCounts) { diff --git a/frontend/libs/model-list/src/lib/classes/VideoModel.ts b/frontend/libs/model-list/src/lib/classes/VideoModel.ts index bfacfdf4c9..6a34642558 100644 --- a/frontend/libs/model-list/src/lib/classes/VideoModel.ts +++ b/frontend/libs/model-list/src/lib/classes/VideoModel.ts @@ -41,6 +41,10 @@ export class VideoModel extends Model { readonly maxDurationWithImageReferences?: number; readonly bitrateOptions?: string[]; readonly defaultBitrate?: string; + readonly outputFormatOptions?: string[]; + readonly defaultOutputFormat?: string; + readonly generationCountOptions?: number[]; + readonly defaultGenerationCount?: number; readonly defaultAspectRatio?: string; @@ -102,6 +106,10 @@ export class VideoModel extends Model { maxDurationWithImageReferences?: number; bitrateOptions?: string[]; defaultBitrate?: string; + outputFormatOptions?: string[]; + defaultOutputFormat?: string; + generationCountOptions?: number[]; + defaultGenerationCount?: number; defaultAspectRatio?: string; supportsReferenceMode?: boolean; @@ -130,6 +138,10 @@ export class VideoModel extends Model { this.maxDurationWithImageReferences = args.maxDurationWithImageReferences; this.bitrateOptions = args.bitrateOptions; this.defaultBitrate = args.defaultBitrate; + this.outputFormatOptions = args.outputFormatOptions; + this.defaultOutputFormat = args.defaultOutputFormat; + this.generationCountOptions = args.generationCountOptions; + this.defaultGenerationCount = args.defaultGenerationCount; this.defaultAspectRatio = args.defaultAspectRatio; this.supportsReferenceMode = args.supportsReferenceMode; diff --git a/frontend/libs/model-list/src/lib/classes/metadata/ModelCreatorIconForId.ts b/frontend/libs/model-list/src/lib/classes/metadata/ModelCreatorIconForId.ts index 0000cbdcf0..b01d7a04fc 100644 --- a/frontend/libs/model-list/src/lib/classes/metadata/ModelCreatorIconForId.ts +++ b/frontend/libs/model-list/src/lib/classes/metadata/ModelCreatorIconForId.ts @@ -24,6 +24,7 @@ export const MODEL_ID_PREFIX_CREATORS: Array<[string, ModelCreator]> = [ ["vidu", ModelCreator.Vidu], ["recraft", ModelCreator.Recraft], ["happy_horse", ModelCreator.Alibaba], + ["wan", ModelCreator.Alibaba], ["minimax", ModelCreator.Hailuo], ["qwen", ModelCreator.Alibaba], ["suno", ModelCreator.Suno], diff --git a/frontend/libs/model-list/src/lib/classes/metadata/ModelFamilies.ts b/frontend/libs/model-list/src/lib/classes/metadata/ModelFamilies.ts index 61a12a9142..f4e2efb457 100644 --- a/frontend/libs/model-list/src/lib/classes/metadata/ModelFamilies.ts +++ b/frontend/libs/model-list/src/lib/classes/metadata/ModelFamilies.ts @@ -13,6 +13,7 @@ const MODEL_ID_PREFIX_FAMILIES: Array<[string, string]> = [ ["minimax", "MiniMax"], ["hailuo", "MiniMax"], ["happy_horse", "Happy Horse"], + ["wan", "Wan"], ["vidu", "Vidu"], // ── Image ── (flux also covers the Flux 3 video model) ["flux", "Flux"], @@ -50,6 +51,7 @@ export const FAMILY_ORDER: string[] = [ "Grok", "Vidu", "Happy Horse", + "Wan", // Image families. "Nano Banana", "Seedream", diff --git a/frontend/libs/model-list/src/lib/classes/metadata/ModelMapping.ts b/frontend/libs/model-list/src/lib/classes/metadata/ModelMapping.ts index e81474a738..7bd49f83bc 100644 --- a/frontend/libs/model-list/src/lib/classes/metadata/ModelMapping.ts +++ b/frontend/libs/model-list/src/lib/classes/metadata/ModelMapping.ts @@ -96,7 +96,10 @@ export const getModelDisplayName = ( seedance_2p0_bpu_fast: "Seedance 2.0 Plus Ultra Fast", seedance_2p0_bpu_mini: "Seedance 2.0 Plus Ultra Mini", seedance_2p5: "Seedance 2.5", + seedance_2p5_u: "Seedance 2.5 Ultra", seedance_2p5_preview: "Seedance 2.5 Preview", + wan_3p0: "Wan 3.0", + wan_3p0_prime: "Wan 3.0 Prime", // Seedance — dot-normalized aliases seedance_1_0_lite: "Seedance 1.0 Lite", // Anonymous preview aliases the backend used pre-announce diff --git a/frontend/libs/model-list/src/lib/classes/properties/CommonQuality.ts b/frontend/libs/model-list/src/lib/classes/properties/CommonQuality.ts index 3f53574be7..d1b915496b 100644 --- a/frontend/libs/model-list/src/lib/classes/properties/CommonQuality.ts +++ b/frontend/libs/model-list/src/lib/classes/properties/CommonQuality.ts @@ -2,6 +2,9 @@ /// Not all models have each of these qualities, /// but Tauri will interpolate if a mistake is sent. export enum CommonQuality { + Auto = "auto", + Max = "max", + XHigh = "xhigh", High = "high", Medium = "medium", Low = "low", diff --git a/frontend/libs/model-list/src/lib/classes/videoOptions.ts b/frontend/libs/model-list/src/lib/classes/videoOptions.ts index f03e46a8f7..7657fb5c05 100644 --- a/frontend/libs/model-list/src/lib/classes/videoOptions.ts +++ b/frontend/libs/model-list/src/lib/classes/videoOptions.ts @@ -1,5 +1,23 @@ import type { VideoModel } from "./VideoModel.js"; +export function resolveVideoOutputFormat(model: VideoModel, current?: string | null): string | undefined { + const options = model.outputFormatOptions; + if (!options?.length) return undefined; + if (current && options.includes(current)) return current; + return model.defaultOutputFormat ?? options[0]; +} + +export function videoGenerationCounts(model: VideoModel): number[] { + return model.generationCountOptions?.length ? model.generationCountOptions : [1]; +} + +export function resolveVideoGenerationCount(model: VideoModel, current: number): number { + const options = videoGenerationCounts(model); + if (options.includes(current)) return current; + return model.defaultGenerationCount != null && options.includes(model.defaultGenerationCount) + ? model.defaultGenerationCount : options[0]; +} + /** Normalize old display values while preserving future API values. */ export function videoResolutionValue(value: string): string { const legacy: Record = { diff --git a/frontend/libs/model-list/src/lib/lists/ImageModels.ts b/frontend/libs/model-list/src/lib/lists/ImageModels.ts index 7750d2660e..7629ed5e45 100644 --- a/frontend/libs/model-list/src/lib/lists/ImageModels.ts +++ b/frontend/libs/model-list/src/lib/lists/ImageModels.ts @@ -403,7 +403,7 @@ export const IMAGE_MODELS: ImageModel[] = [ progressBarTime: 60000, maxPromptLength: Infinity, canChangeAspectRatio: true, - defaultAspectRatio: CommonAspectRatio.WideSixteenByNine, + defaultAspectRatio: CommonAspectRatio.Square, aspectRatios: [ CommonAspectRatio.Auto, CommonAspectRatio.Square, @@ -413,8 +413,19 @@ export const IMAGE_MODELS: ImageModel[] = [ CommonAspectRatio.WideFourByThree, CommonAspectRatio.WideSixteenByNine, ], + canChangeResolution: true, + resolutions: [ + CommonResolution.OneK, + CommonResolution.TwoK, + CommonResolution.ThreeK, + CommonResolution.FourK, + ], + defaultResolution: CommonResolution.OneK, defaultQuality: CommonQuality.High, qualityOptions: [ + CommonQuality.Auto, + CommonQuality.Max, + CommonQuality.XHigh, CommonQuality.High, CommonQuality.Medium, CommonQuality.Low, @@ -438,7 +449,7 @@ export const IMAGE_MODELS: ImageModel[] = [ progressBarTime: 120000, maxPromptLength: Infinity, canChangeAspectRatio: true, - defaultAspectRatio: CommonAspectRatio.WideSixteenByNine, + defaultAspectRatio: CommonAspectRatio.Square, aspectRatios: [ CommonAspectRatio.Auto, CommonAspectRatio.Square, @@ -448,8 +459,19 @@ export const IMAGE_MODELS: ImageModel[] = [ CommonAspectRatio.WideFourByThree, CommonAspectRatio.WideSixteenByNine, ], + canChangeResolution: true, + resolutions: [ + CommonResolution.OneK, + CommonResolution.TwoK, + CommonResolution.ThreeK, + CommonResolution.FourK, + ], + defaultResolution: CommonResolution.OneK, defaultQuality: CommonQuality.High, qualityOptions: [ + CommonQuality.Auto, + CommonQuality.Max, + CommonQuality.XHigh, CommonQuality.High, CommonQuality.Medium, CommonQuality.Low, diff --git a/frontend/libs/model-list/src/lib/lists/VideoModels.ts b/frontend/libs/model-list/src/lib/lists/VideoModels.ts index a35f92fcb7..8111b8773f 100644 --- a/frontend/libs/model-list/src/lib/lists/VideoModels.ts +++ b/frontend/libs/model-list/src/lib/lists/VideoModels.ts @@ -345,6 +345,8 @@ export const VIDEO_MODELS: VideoModel[] = [ new VideoModel({ id: "seedance_2p0", tauriId: "seedance_2p0", + generationCountOptions: [1, 2, 3, 4], + defaultGenerationCount: 1, fullName: "Seedance 2.0", category: "video", creator: ModelCreator.Bytedance, diff --git a/frontend/libs/model-list/src/lib/loader/buildModelsFromListing.spec.ts b/frontend/libs/model-list/src/lib/loader/buildModelsFromListing.spec.ts index 982e0ea952..193a6f8eef 100644 --- a/frontend/libs/model-list/src/lib/loader/buildModelsFromListing.spec.ts +++ b/frontend/libs/model-list/src/lib/loader/buildModelsFromListing.spec.ts @@ -7,10 +7,14 @@ import { } from "./buildModelsFromListing.js"; import { resolveVideoDuration, + resolveVideoGenerationCount, + resolveVideoOutputFormat, + videoGenerationCounts, videoDurationRange, videoResolutionValue, } from "../classes/videoOptions.js"; import { IMAGE_MODELS } from "../lists/ImageModels.js"; +import { CommonQuality } from "../classes/properties/CommonQuality.js"; import { VIDEO_MODELS } from "../lists/VideoModels.js"; import { SPLAT_MODELS } from "../lists/SplatModels.js"; @@ -24,6 +28,8 @@ describe("desktop catalog", () => { "flux_3", "minimax_h3", "vidu_q3", + "wan_3p0", + "wan_3p0_prime", "future_video_v99", ]; const models = buildVideoModelsFromListing( @@ -97,6 +103,61 @@ describe("desktop catalog", () => { }); describe("API video options", () => { + it.each(["wan_3p0", "wan_3p0_prime"])("hydrates %s with Wan's reference and duration capabilities", (id) => { + const [model] = buildVideoModelsFromListing([], [{ + model: id, + model_creator: "alibaba", + text_to_video_supported: true, + text_prompt_max_length: 20000, + starting_keyframe_supported: true, + ending_keyframe_supported: true, + image_references_supported: true, + image_references_max: 10, + video_references_max: 5, + video_references_max_total_duration_seconds: 15, + audio_references_max: 5, + audio_references_max_total_duration_seconds: 15, + show_generate_with_sound_toggle: true, + resolution_options: ["four_eighty_p", "seven_twenty_p", "ten_eighty_p"], + resolution_default: "seven_twenty_p", + duration_seconds_min: 2, + duration_seconds_max: 30, + duration_seconds_default: 5, + batch_size_options: [1], + batch_size_default: 1, + }]); + expect(model.getProviders()).toEqual([GenerationProvider.Artcraft]); + expect(model).toMatchObject({ + startFrame: true, endFrame: true, textToVideoSupported: true, + supportsReferenceMode: true, maxReferenceImages: 10, + maxReferenceVideos: 5, maxVideoRefDuration: 15, + maxReferenceAudios: 5, maxAudioRefDuration: 15, + generateWithSound: true, maxPromptLength: 20000, + resolutionOptions: ["480p", "720p", "1080p"], defaultResolution: "720p", + }); + expect(videoDurationRange(model, true)).toEqual({ min: 2, max: 30 }); + expect(resolveVideoDuration(model, null, false)).toBe(5); + expect(videoGenerationCounts(model)).toEqual([1]); + expect(resolveVideoGenerationCount(model, 4)).toBe(1); + }); + + it.each(["seedance_2p5", "seedance_2p5_u"])("preserves %s output formats and batch choices", (id) => { + const [model] = buildVideoModelsFromListing([], [{ + model: id, + output_format_options: ["mp4", "mov", "future_format"], + output_format_default: "mp4", + batch_size_options: [1, 2, 3, 4], + batch_size_default: 1, + }]); + expect(model.outputFormatOptions).toEqual(["mp4", "mov", "future_format"]); + expect(model.defaultOutputFormat).toBe("mp4"); + expect(resolveVideoOutputFormat(model, "mov")).toBe("mov"); + expect(resolveVideoOutputFormat(model, "unsupported")).toBe("mp4"); + expect(videoGenerationCounts(model)).toEqual([1, 2, 3, 4]); + expect(resolveVideoGenerationCount(model, 3)).toBe(3); + expect(resolveVideoGenerationCount(model, 5)).toBe(1); + }); + it("uses range durations, reference-mode caps, bitrate, and future resolutions", () => { const [model] = buildVideoModelsFromListing( [], @@ -134,6 +195,37 @@ describe("API video options", () => { }); }); +it.each(["gpt_image_2p5_flare", "gpt_image_2p5_sunburst"])("preserves every %s quality and resolution option", (id) => { + const qualities = ["auto", "max", "xhigh", "high", "medium", "low"]; + const resolutions = ["one_k", "two_k", "three_k", "four_k"]; + const model = buildImageModelsFromListing(IMAGE_MODELS, [{ + model: id, + quality_options: qualities, + default_quality: "high", + resolution_options: resolutions, + resolution_default: "one_k", + image_refs_supported: true, + }]).find((m) => m.tauriId === id)!; + expect(model.qualityOptions).toEqual(qualities); + expect(model.defaultQuality).toBe("high"); + expect(model.resolutions).toEqual(resolutions); + expect(model.defaultResolution).toBe("one_k"); + expect(model.canEditImages).toBe(true); + expect(model.resolveQuality(CommonQuality.Max)).toBe("max"); + expect(model.resolveQuality(CommonQuality.Auto)).toBe("auto"); +}); + +it("uses the new model's default when a saved quality tier is unsupported", () => { + const [model] = buildImageModelsFromListing([], [{ + model: "gpt_image_2", + quality_options: ["high", "medium", "low"], + default_quality: "high", + }]); + expect(model.resolveQuality(CommonQuality.Max)).toBe("high"); + expect(model.resolveQuality(CommonQuality.XHigh)).toBe("high"); + expect(model.resolveQuality(CommonQuality.Low)).toBe("low"); +}); + it("offers ArtCraft and direct Midjourney only for supported Midjourney models", () => { const models = buildImageModelsFromListing([], [ { model: "midjourney_7" }, { model: "midjourney_7_niji" }, diff --git a/frontend/libs/model-list/src/lib/loader/buildModelsFromListing.ts b/frontend/libs/model-list/src/lib/loader/buildModelsFromListing.ts index 475796c6dd..7f0058b280 100644 --- a/frontend/libs/model-list/src/lib/loader/buildModelsFromListing.ts +++ b/frontend/libs/model-list/src/lib/loader/buildModelsFromListing.ts @@ -76,6 +76,8 @@ export interface ListingVideoModel extends ListingModelBase { duration_seconds_max_with_image_references?: number | null; bitrate_options?: string[] | null; bitrate_default?: string | null; + output_format_options?: string[] | null; + output_format_default?: string | null; duration_seconds_default?: number | null; } @@ -269,6 +271,10 @@ const mergedVideoModel = ( maxDurationWithImageReferences: m.duration_seconds_max_with_image_references ?? o?.maxDurationWithImageReferences, bitrateOptions: m.bitrate_options ?? o?.bitrateOptions, defaultBitrate: m.bitrate_default ?? o?.defaultBitrate, + outputFormatOptions: m.output_format_options ?? o?.outputFormatOptions, + defaultOutputFormat: m.output_format_default ?? o?.defaultOutputFormat, + generationCountOptions: m.batch_size_options ?? o?.generationCountOptions, + defaultGenerationCount: m.batch_size_default ?? o?.defaultGenerationCount, defaultAspectRatio: m.aspect_ratio_default ?? o?.defaultAspectRatio, supportsReferenceMode: m.image_references_supported ?? o?.supportsReferenceMode, maxReferenceImages: m.image_references_max ?? o?.maxReferenceImages, diff --git a/frontend/libs/tauri-api/src/lib/cost_estimate/EstimateVideoCost.ts b/frontend/libs/tauri-api/src/lib/cost_estimate/EstimateVideoCost.ts index 8af6e2322e..e12b27fad7 100644 --- a/frontend/libs/tauri-api/src/lib/cost_estimate/EstimateVideoCost.ts +++ b/frontend/libs/tauri-api/src/lib/cost_estimate/EstimateVideoCost.ts @@ -16,6 +16,7 @@ export interface EstimateVideoCostRequest { video_batch_count?: number; generate_audio?: boolean; bitrate?: string; + output_format?: string; start_frame_image_media_token?: string; end_frame_image_media_token?: string; reference_image_media_tokens?: string[]; diff --git a/frontend/libs/tauri-api/src/lib/generate/GenerateOmni.spec.ts b/frontend/libs/tauri-api/src/lib/generate/GenerateOmni.spec.ts index 50839d00fd..eab80dfede 100644 --- a/frontend/libs/tauri-api/src/lib/generate/GenerateOmni.spec.ts +++ b/frontend/libs/tauri-api/src/lib/generate/GenerateOmni.spec.ts @@ -24,6 +24,7 @@ it("passes unknown model and option strings through image and video commands", a const video = { model: "future_video", bitrate: "high", + output_format: "mov", resolution: "eight_k", duration_seconds: 30, generate_audio: false, @@ -54,6 +55,25 @@ it("dispatches every added modality and its pricing through Tauri", async () => ]); }); +it.each(["gpt_image_2p5_flare", "gpt_image_2p5_sunburst"])("forwards every %s quality tier", async (model) => { + for (const quality of ["auto", "max", "xhigh", "high", "medium", "low"]) { + const request = { model, quality, resolution: "four_k" }; + await GenerateImage(request); + expect(invoke).toHaveBeenLastCalledWith("generate_image_command", { request }); + } +}); + +it.each(["wan_3p0", "wan_3p0_prime"])("forwards %s with reference media", async (model) => { + const request = { + model, duration_seconds: 15, resolution: "ten_eighty_p", generate_audio: true, + reference_image_media_tokens: ["mf_image"], + reference_video_media_tokens: ["mf_video"], + reference_audio_media_tokens: ["mf_audio"], + }; + await GenerateVideo(request); + expect(invoke).toHaveBeenLastCalledWith("generate_video_command", { request }); +}); + it("preserves reference duration hints in video pricing", async () => { const request = { model: "future_video", diff --git a/frontend/libs/tauri-api/src/lib/generate/GenerateVideo.ts b/frontend/libs/tauri-api/src/lib/generate/GenerateVideo.ts index 648b3020a3..480f830b04 100644 --- a/frontend/libs/tauri-api/src/lib/generate/GenerateVideo.ts +++ b/frontend/libs/tauri-api/src/lib/generate/GenerateVideo.ts @@ -38,6 +38,7 @@ export interface GenerateVideoRequest { resolution?: CommonResolution | string; bitrate?: string; + output_format?: string; duration_seconds?: number; generate_audio?: boolean; video_batch_count?: number; diff --git a/frontend/libs/tauri-api/src/lib/generate/models/video/ListVideoModels.ts b/frontend/libs/tauri-api/src/lib/generate/models/video/ListVideoModels.ts index 6a5d0c6d25..b95792b701 100644 --- a/frontend/libs/tauri-api/src/lib/generate/models/video/ListVideoModels.ts +++ b/frontend/libs/tauri-api/src/lib/generate/models/video/ListVideoModels.ts @@ -55,6 +55,8 @@ export interface OmniGenVideoModelDetails { resolution_default?: string; bitrate_options?: string[]; bitrate_default?: string; + output_format_options?: string[]; + output_format_default?: string; quality_options?: string[]; default_quality?: string; duration_seconds_min?: number;