Compare commits

...

1 Commits

Author SHA1 Message Date
Brandon Thomas 08dfb8515f Added new models and modes: GPT Image 2.5, Wan, Output Formats 2026-09-18 16:10:26 -04:00
58 changed files with 883 additions and 78 deletions

View File

@ -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<CommonBitrate>,
/// 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<CommonVideoOutputFormat>,
/// The quality to use.
pub quality: Option<CommonQuality>,

View File

@ -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<CommonBitrate>,
#[serde(skip_serializing_if = "Option::is_none")]
pub output_format_options: Option<Vec<CommonVideoOutputFormat>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub output_format_default: Option<CommonVideoOutputFormat>,
#[serde(skip_serializing_if = "Option::is_none")]
pub quality_options: Option<Vec<CommonQuality>>,
@ -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,

View File

@ -158,6 +158,10 @@ pub struct OmniGenVideoModelDetails {
#[serde(default)]
pub bitrate_default: Option<ApiClientBitrate>,
#[serde(default)]
pub output_format_options: Option<Vec<String>>,
#[serde(default)]
pub output_format_default: Option<String>,
#[serde(default)]
pub quality_options: Option<Vec<ApiClientQuality>>,
#[serde(default)]
pub default_quality: Option<ApiClientQuality>,

View File

@ -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,

View File

@ -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,
}
}

View File

@ -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>) -> 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<CommonAspectRatioEnum>) -> bool {
use CommonAspectRatioEnum as Ar;
matches!(

View File

@ -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>) -> 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<CommonAspectRatioEnum>) -> SizeBucket {
use CommonAspectRatioEnum as Ar;
match aspect_ratio {

View File

@ -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>) -> 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<CommonAspectRatioEnum>) -> SizeBucket {

View File

@ -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<Ar>, Option<R>, [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));

View File

@ -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<RouterQuality>) -> 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 {

View File

@ -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<RouterQuality>) -> 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 {

View File

@ -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<RouterQuality>) -> 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 {

View File

@ -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<RouterQuality>) -> 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() }));

View File

@ -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),

View File

@ -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),

View File

@ -122,10 +122,14 @@ pub(crate) fn plan_quality(quality: Option<RouterQuality>) -> Option<GenerateMid
// Kinovi pricing — pass through whatever the caller asked for, and let
// `None` ride Midjourney's server-side default.
match quality {
None => 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),
}
}

View File

@ -110,10 +110,14 @@ pub(crate) fn plan_aspect_ratio(
pub(crate) fn plan_quality(quality: Option<RouterQuality>) -> Option<GenerateMidjourneyV7NijiQuality> {
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),
}
}

View File

@ -110,10 +110,14 @@ pub(crate) fn plan_aspect_ratio(
pub(crate) fn plan_quality(quality: Option<RouterQuality>) -> Option<GenerateMidjourneyV8Quality> {
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),
}
}

View File

@ -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,

View File

@ -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),

View File

@ -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,

View File

@ -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,

View File

@ -552,3 +552,25 @@ impl From<OmniGenImageModelDetails> 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);
}
}
}

View File

@ -597,6 +597,10 @@ pub struct ListVideoModelsModelDetails {
#[serde(skip_serializing_if = "Option::is_none")]
pub bitrate_default: Option<ListVideoModelsBitrate>,
#[serde(skip_serializing_if = "Option::is_none")]
pub output_format_options: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub output_format_default: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub quality_options: Option<Vec<ListVideoModelsQuality>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_quality: Option<ListVideoModelsQuality>,
@ -655,6 +659,8 @@ impl From<OmniGenVideoModelDetails> 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<OmniGenVideoModelDetails> 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);
}
}
}

View File

@ -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!({

View File

@ -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);

View File

@ -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));

View File

@ -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<Self, EnumError> {
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<Self> {
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));

View File

@ -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),

View File

@ -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<Self, EnumError> {
match value {
"mp4" => Ok(Self::Mp4),
"mov" => Ok(Self::Mov),
_ => Err(EnumError::CouldNotConvertFromString(value.to_string())),
}
}
pub fn all_variants() -> BTreeSet<Self> {
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);
}
}
}
}

View File

@ -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;

View File

@ -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",
}

View File

@ -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",

View File

@ -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",
}

View File

@ -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":

View File

@ -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;

View File

@ -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;

View File

@ -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,

View File

@ -407,7 +407,7 @@ export const PromptBoxImage = ({
}
if (selectedModel?.supportsQuality()) {
request.quality = commonQuality ?? selectedModel.defaultQuality;
request.quality = selectedModel.resolveQuality(commonQuality);
}
if (

View File

@ -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 = ({
</Tooltip>
)}
{!!selectedModel?.outputFormatOptions?.length && (
<Tooltip content="Output format" position="top" className="z-50">
<PopoverMenu
panelTitle="Output format"
mode="toggle"
items={selectedModel.outputFormatOptions.map((value) => ({
label: value.toUpperCase(),
action: value,
selected: value === outputFormat,
}))}
onSelect={(item) => setOutputFormat(item.action ?? item.label)}
/>
</Tooltip>
)}
{durationRange && durationRange.max > durationRange.min && (
<Tooltip content="Duration" position="top" className="z-50">
<PopoverMenu
@ -1522,10 +1553,10 @@ export const PromptBoxVideo = ({
disabled={!hasClearableContent}
confirmClear={hasAttachedRefs}
/>
{selectedModel?.id === "seedance_2p0" && (
{generationCounts.length > 1 && (
<VideoGenerationCountPicker
maxCount={4}
currentCount={generationCount}
counts={generationCounts}
currentCount={effectiveGenerationCount}
handleCountChange={setGenerationCount}
/>
)}
@ -1541,9 +1572,7 @@ export const PromptBoxVideo = ({
onClick={handleEnqueue}
disabled={!prompt.trim()}
loading={isEnqueueing}
credits={
credits != null ? credits * generationCount : credits
}
credits={credits}
/>
</div>
</Tooltip>

View File

@ -10,6 +10,9 @@ interface QualityPickerProps {
}
const QUALITY_LABELS: Record<CommonQuality, string> = {
[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<CommonQuality, string> = {
/**
* 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;

View File

@ -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);
}
};

View File

@ -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<PromptVideoStore>()((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<PromptVideoStore>()((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 }),
}));

View File

@ -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) {

View File

@ -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;

View File

@ -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],

View File

@ -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",

View File

@ -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

View File

@ -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",

View File

@ -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<string, string> = {

View File

@ -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,

View File

@ -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,

View File

@ -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" },

View File

@ -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,

View File

@ -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[];

View File

@ -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",

View File

@ -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;

View File

@ -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;