Compare commits
1 Commits
artcraft-i
...
bt-new-mod
| Author | SHA1 | Date |
|---|---|---|
|
|
08dfb8515f |
|
|
@ -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>,
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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() }));
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -407,7 +407,7 @@ export const PromptBoxImage = ({
|
|||
}
|
||||
|
||||
if (selectedModel?.supportsQuality()) {
|
||||
request.quality = commonQuality ?? selectedModel.defaultQuality;
|
||||
request.quality = selectedModel.resolveQuality(commonQuality);
|
||||
}
|
||||
|
||||
if (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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> = {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue