Compare commits

...
4 Commits
Author SHA1 Message Date
Brandon Thomas cfa8264642 debug tool 2026-09-18 18:54:10 -04:00
Brandon Thomas fcb567ec3c Merge pull request #25 from storytold/gpt-image-qualities
gpt image 2.5: new qualities
2026-09-18 14:13:57 -04:00
Brandon Thomas 734b757ef9 gpt image 2.5: new qualities 2026-09-18 14:12:34 -04:00
Brandon Thomas daf401c635 Merge pull request #24 from storytold/bt-regroup
group Wan video differently
2026-09-18 14:00:04 -04:00
32 changed files with 561 additions and 135 deletions

No files matched your search

@@ -8,6 +8,9 @@ use serde_derive::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(from = "String", into = "String")]
pub enum ApiClientQuality {
Auto,
Max,
XHigh,
High,
Medium,
Low,
@@ -18,6 +21,9 @@ pub enum ApiClientQuality {
impl From<String> for ApiClientQuality {
fn from(value: String) -> Self {
match value.as_str() {
"auto" => Self::Auto,
"max" => Self::Max,
"xhigh" => Self::XHigh,
"high" => Self::High,
"medium" => Self::Medium,
"low" => Self::Low,
@@ -29,6 +35,9 @@ impl From<String> for ApiClientQuality {
impl From<ApiClientQuality> for String {
fn from(value: ApiClientQuality) -> Self {
match value {
ApiClientQuality::Auto => "auto".to_string(),
ApiClientQuality::Max => "max".to_string(),
ApiClientQuality::XHigh => "xhigh".to_string(),
ApiClientQuality::High => "high".to_string(),
ApiClientQuality::Medium => "medium".to_string(),
ApiClientQuality::Low => "low".to_string(),
@@ -58,6 +67,9 @@ mod tests {
#[test]
fn every_known_variant_round_trips() {
let all = [
(ApiClientQuality::Auto, "auto"),
(ApiClientQuality::Max, "max"),
(ApiClientQuality::XHigh, "xhigh"),
(ApiClientQuality::High, "high"),
(ApiClientQuality::Medium, "medium"),
(ApiClientQuality::Low, "low"),
@@ -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;
@@ -79,6 +81,20 @@ impl ArtcraftGptImage2CostState {
/// 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`.
/// 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,
}
}
fn size_bucket(aspect_ratio: Option<CommonAspectRatioEnum>) -> SizeBucket {
use CommonAspectRatioEnum as Ar;
match aspect_ratio {
@@ -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),
}
}
+17 -1
View File
@@ -13,9 +13,25 @@ cargo run -p support-tool -- kinovi_web generate_video --prompt "A corgi at the
Requires a `.env-support-tool-secrets` file (or env vars) with:
- `SEEDANCE2PRO_COOKIES` — session cookies for Kinovi/KinoviWeb API auth
- `SEEDANCE2PRO_VOLCENGINE_COOKIES` — Volcengine cookies (legacy fallback: `SEEDANCE2PRO_COOKIES`)
- `SEEDANCE2PRO_BYTEPLUS_COOKIES` — BytePlus cookies (legacy fallback: `SEEDANCE2PRO_ALT_COOKIES`)
- `SEEDANCE2PRO_BYTEPLUS_ULTRA_COOKIES` — BytePlus Ultra cookies
- `ARTCRAFT_COOKIES` — session cookies for ArtCraft API auth (format: `session=...; visitor=...`)
All `kinovi_web` commands (also available as `kinovi` / `seedance2pro`) accept
`--kinovi-account volcengine|byteplus|byteplusultra`, defaulting to `volcengine`.
Use `--cookies-env ENV_VAR_NAME` to explicitly override that account's cookie
configuration. The override has no default; when omitted, only the selected
account's variables and its legacy fallback are checked. Either flag can appear
before or after the subcommand. CSV account labels default to the resolved cookie
env var name; `--account` still overrides the output label.
```bash
support-tool kinovi_web account_info --kinovi-account byteplus
support-tool seedance2pro find_job --token <order_id> --kinovi-account byteplusultra
support-tool kinovi_web account_info --kinovi-account byteplus --cookies-env CUSTOM_COOKIES
```
## Architecture
- Entry point: `src/main.rs` → normalizes args (underscores optional) → dispatches
@@ -0,0 +1,20 @@
use clap::ValueEnum;
#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
#[value(rename_all = "lowercase")]
pub enum KinoviAccount {
Volcengine,
BytePlus,
BytePlusUltra,
}
impl KinoviAccount {
/// Cookie configuration and legacy fallbacks used by seedance2-pro-job.
pub fn cookie_env_vars(self) -> &'static [&'static str] {
match self {
Self::Volcengine => &["SEEDANCE2PRO_VOLCENGINE_COOKIES", "SEEDANCE2PRO_COOKIES"],
Self::BytePlus => &["SEEDANCE2PRO_BYTEPLUS_COOKIES", "SEEDANCE2PRO_ALT_COOKIES"],
Self::BytePlusUltra => &["SEEDANCE2PRO_BYTEPLUS_ULTRA_COOKIES"],
}
}
}
@@ -1,6 +1,6 @@
use anyhow::anyhow;
use clap::Subcommand;
use clap::{Args, Subcommand};
use super::account::KinoviAccount;
use super::state::KinoviWebState;
use super::subcommands;
@@ -16,6 +16,20 @@ pub const SUBCOMMAND_NAMES: &[&str] = &[
"generate_video",
];
#[derive(Args)]
pub struct KinoviWebArgs {
/// Kinovi account whose cookie configuration to use.
#[arg(long, global = true, value_enum, ignore_case = true, default_value = "volcengine")]
pub kinovi_account: KinoviAccount,
/// Read cookies from this env var, overriding --kinovi-account only when supplied.
#[arg(long = "cookies-env", global = true, value_name = "ENV_VAR_NAME")]
pub maybe_cookies_env: Option<String>,
#[command(subcommand)]
pub command: KinoviWebCommand,
}
#[derive(Subcommand)]
#[command(rename_all = "snake_case")]
pub enum KinoviWebCommand {
@@ -44,13 +58,10 @@ pub enum KinoviWebCommand {
GenerateVideo(subcommands::generate_video::GenerateVideoArgs),
}
pub async fn run(command: KinoviWebCommand) -> anyhow::Result<()> {
let cookies = easyenv::get_env_string_required("SEEDANCE2PRO_COOKIES")
.map_err(|err| anyhow!("Missing SEEDANCE2PRO_COOKIES env var: {:?}", err))?;
pub async fn run(args: KinoviWebArgs) -> anyhow::Result<()> {
let state = KinoviWebState::from_env(args.kinovi_account, args.maybe_cookies_env.as_deref())?;
let state = KinoviWebState { cookies };
match command {
match args.command {
KinoviWebCommand::AccountInfo(args) => subcommands::account_info::run(&state, args).await,
KinoviWebCommand::AuditCredits(args) => subcommands::audit_credits::run(&state, args).await,
KinoviWebCommand::AuditOrders(args) => subcommands::audit_orders::run(&state, args).await,
@@ -60,3 +71,85 @@ pub async fn run(command: KinoviWebCommand) -> anyhow::Result<()> {
KinoviWebCommand::GenerateVideo(args) => subcommands::generate_video::run(&state, args).await,
}
}
#[cfg(test)]
mod tests {
use clap::{error::ErrorKind, Parser};
use super::*;
use crate::commands::run::{all_canonical_names, Cli, TopLevelCommand};
use crate::utils::normalize_subcommands::normalize_subcommand_args;
const COMMANDS: &[&[&str]] = &[
&["account_info"],
&["audit_credits", "--out", "/tmp/credits.csv"],
&["audit_orders", "--out", "/tmp/orders.csv"],
&["audit_payments", "--out", "/tmp/payments.csv"],
&["find_job", "--token", "test-order"],
&["failed_job_histogram"],
&["generate_video", "--prompt", "A corgi at the lake"],
];
mod cli_parsing {
use super::*;
#[test]
fn every_command_defaults_to_volcengine_without_a_cookie_override() {
for command in COMMANDS {
let args = parse_kinovi(command);
assert_eq!(args.kinovi_account, KinoviAccount::Volcengine);
assert!(args.maybe_cookies_env.is_none());
}
}
#[test]
fn every_command_accepts_all_accounts_and_cookie_overrides_before_or_after_subcommand() {
for (value, account) in [
("volcengine", KinoviAccount::Volcengine),
("byteplus", KinoviAccount::BytePlus),
("byteplusultra", KinoviAccount::BytePlusUltra),
] {
for command in COMMANDS {
let flags = ["--kinovi-account", value, "--cookies-env", "CUSTOM_COOKIES"];
for args in [
[flags.as_slice(), *command].concat(),
[*command, flags.as_slice()].concat(),
] {
let args = parse_kinovi(&args);
assert_eq!(args.kinovi_account, account);
assert_eq!(args.maybe_cookies_env.as_deref(), Some("CUSTOM_COOKIES"));
}
}
}
}
#[test]
fn aliases_and_underscore_normalization_preserve_account_selection() {
for alias in ["kinovi_web", "kinoviweb", "kinovi", "seedance2pro", "seedance2_pro"] {
let args = ["support-tool", alias, "accountinfo", "--kinovi-account", "BYTEPLUSULTRA"];
let args = normalize_subcommand_args(args.map(String::from), &all_canonical_names());
let cli = Cli::try_parse_from(args).unwrap();
let TopLevelCommand::KinoviWeb(args) = cli.command else {
panic!("expected Kinovi command");
};
assert_eq!(args.kinovi_account, KinoviAccount::BytePlusUltra);
}
}
#[test]
fn rejects_unknown_accounts() {
let err = Cli::try_parse_from([
"support-tool", "kinovi_web", "account_info", "--kinovi-account", "unknown",
]).err().expect("unknown accounts should be rejected");
assert_eq!(err.kind(), ErrorKind::InvalidValue);
}
}
fn parse_kinovi(args: &[&str]) -> KinoviWebArgs {
let cli = Cli::try_parse_from([&["support-tool", "kinovi_web"], args].concat()).unwrap();
match cli.command {
TopLevelCommand::KinoviWeb(args) => args,
_ => panic!("expected Kinovi command"),
}
}
}
@@ -1,6 +1,7 @@
mod account;
pub mod dispatch;
mod state;
mod subcommands;
pub use dispatch::KinoviWebCommand;
pub use dispatch::KinoviWebArgs;
pub use dispatch::run;
@@ -1,3 +1,131 @@
use anyhow::anyhow;
use super::account::KinoviAccount;
pub struct KinoviWebState {
pub cookies: String,
pub cookies_env: String,
}
impl KinoviWebState {
pub fn from_env(account: KinoviAccount, maybe_cookies_env: Option<&str>) -> anyhow::Result<Self> {
Self::from_env_lookup(account, maybe_cookies_env, easyenv::get_env_string_optional)
}
fn from_env_lookup(
account: KinoviAccount,
maybe_cookies_env: Option<&str>,
mut lookup: impl FnMut(&str) -> Option<String>,
) -> anyhow::Result<Self> {
// An explicit override must resolve on its own, without requiring or falling
// back to credentials for the selected account.
let override_vars;
let cookie_env_vars = match maybe_cookies_env {
Some(var) => {
override_vars = [var];
&override_vars[..]
},
None => account.cookie_env_vars(),
};
for &var in cookie_env_vars {
if let Some(cookies) = lookup(var) {
return Ok(Self {
cookies,
cookies_env: var.to_string(),
});
}
}
Err(anyhow!("Missing Kinovi cookies: set {}", cookie_env_vars.join(" or ")))
}
}
#[cfg(test)]
mod tests {
use super::*;
const COOKIES: &[(&str, &str)] = &[
("SEEDANCE2PRO_VOLCENGINE_COOKIES", "volcengine-cookies"),
("SEEDANCE2PRO_BYTEPLUS_COOKIES", "byteplus-cookies"),
("SEEDANCE2PRO_BYTEPLUS_ULTRA_COOKIES", "ultra-cookies"),
("SEEDANCE2PRO_COOKIES", "legacy-cookies"),
("SEEDANCE2PRO_ALT_COOKIES", "legacy-alt-cookies"),
("CUSTOM_COOKIES", "override-cookies"),
];
mod account_selection {
use super::*;
#[test]
fn selects_each_account_even_when_legacy_cookies_are_set() {
for (account, var, cookies) in [
(KinoviAccount::Volcengine, "SEEDANCE2PRO_VOLCENGINE_COOKIES", "volcengine-cookies"),
(KinoviAccount::BytePlus, "SEEDANCE2PRO_BYTEPLUS_COOKIES", "byteplus-cookies"),
(KinoviAccount::BytePlusUltra, "SEEDANCE2PRO_BYTEPLUS_ULTRA_COOKIES", "ultra-cookies"),
] {
for env in [COOKIES, &[(var, cookies)]] {
let state = resolve(account, None, env).unwrap();
assert_eq!(state.cookies, cookies);
assert_eq!(state.cookies_env, var);
}
}
}
#[test]
fn falls_back_to_the_matching_legacy_variable() {
let env = &COOKIES[3..5];
for (account, var, cookies) in [
(KinoviAccount::Volcengine, "SEEDANCE2PRO_COOKIES", "legacy-cookies"),
(KinoviAccount::BytePlus, "SEEDANCE2PRO_ALT_COOKIES", "legacy-alt-cookies"),
] {
let state = resolve(account, None, env).unwrap();
assert_eq!(state.cookies, cookies);
assert_eq!(state.cookies_env, var);
}
}
#[test]
fn missing_account_cookies_never_use_another_accounts_credentials() {
for (account, expected_var) in [
(KinoviAccount::BytePlus, "SEEDANCE2PRO_BYTEPLUS_COOKIES"),
(KinoviAccount::BytePlusUltra, "SEEDANCE2PRO_BYTEPLUS_ULTRA_COOKIES"),
] {
let err = resolve(account, None, &COOKIES[3..4]).err().expect("account cookies are missing");
assert!(err.to_string().contains(expected_var));
}
}
}
mod cookie_overrides {
use super::*;
#[test]
fn explicit_override_wins_with_or_without_account_cookies() {
for account in [KinoviAccount::Volcengine, KinoviAccount::BytePlus, KinoviAccount::BytePlusUltra] {
for env in [COOKIES, &COOKIES[5..]] {
let state = resolve(account, Some("CUSTOM_COOKIES"), env).unwrap();
assert_eq!(state.cookies, "override-cookies");
assert_eq!(state.cookies_env, "CUSTOM_COOKIES");
}
}
}
#[test]
fn missing_explicit_override_errors_instead_of_using_account_cookies() {
let err = resolve(KinoviAccount::Volcengine, Some("MISSING_COOKIES"), COOKIES)
.err().expect("explicit override must exist");
assert_eq!(err.to_string(), "Missing Kinovi cookies: set MISSING_COOKIES");
}
}
fn resolve(
account: KinoviAccount,
maybe_override: Option<&str>,
env: &[(&str, &str)],
) -> anyhow::Result<KinoviWebState> {
KinoviWebState::from_env_lookup(account, maybe_override, |var| {
env.iter().find(|(name, _)| *name == var).map(|(_, value)| value.to_string())
})
}
}
@@ -11,28 +11,14 @@ use super::super::state::KinoviWebState;
/// single CSV line: account,email,credits,available_credits.
#[derive(Args)]
pub struct AccountInfoArgs {
/// Read session cookies from this env var instead of SEEDANCE2PRO_COOKIES.
#[arg(long)]
pub cookies_env: Option<String>,
/// Account label for the output line. Defaults to the cookies env var name.
#[arg(long)]
pub account: Option<String>,
}
pub async fn run(state: &KinoviWebState, args: AccountInfoArgs) -> anyhow::Result<()> {
let (cookies, account_label) = match &args.cookies_env {
Some(var) => (
easyenv::get_env_string_required(var)
.map_err(|err| anyhow!("Missing {} env var: {:?}", var, err))?,
args.account.clone().unwrap_or_else(|| var.clone()),
),
None => (
state.cookies.clone(),
args.account.clone().unwrap_or_else(|| "SEEDANCE2PRO_COOKIES".to_string()),
),
};
let session = KinoviWebSession::from_cookies_string(cookies);
let account_label = args.account.as_deref().unwrap_or(&state.cookies_env);
let session = KinoviWebSession::from_cookies_string(state.cookies.clone());
let details = get_user_auth_details(GetUserAuthDetailsArgs {
session: &session,
@@ -44,10 +44,6 @@ pub struct AuditCreditsArgs {
#[arg(long, default_value_t = 250)]
pub delay_ms: u64,
/// Read session cookies from this env var instead of SEEDANCE2PRO_COOKIES.
#[arg(long)]
pub cookies_env: Option<String>,
/// Account label written into each CSV row. Defaults to the cookies env
/// var name.
#[arg(long)]
@@ -55,18 +51,8 @@ pub struct AuditCreditsArgs {
}
pub async fn run(state: &KinoviWebState, args: AuditCreditsArgs) -> anyhow::Result<()> {
let (cookies, account_label) = match &args.cookies_env {
Some(var) => (
easyenv::get_env_string_required(var)
.map_err(|err| anyhow!("Missing {} env var: {:?}", var, err))?,
args.account.clone().unwrap_or_else(|| var.clone()),
),
None => (
state.cookies.clone(),
args.account.clone().unwrap_or_else(|| "SEEDANCE2PRO_COOKIES".to_string()),
),
};
let session = KinoviWebSession::from_cookies_string(cookies);
let account_label = args.account.as_deref().unwrap_or(&state.cookies_env);
let session = KinoviWebSession::from_cookies_string(state.cookies.clone());
let file_is_new = std::fs::metadata(&args.out).map(|m| m.len() == 0).unwrap_or(true);
let mut out = OpenOptions::new().create(true).append(true).open(&args.out)
@@ -101,7 +87,7 @@ pub async fn run(state: &KinoviWebState, args: AuditCreditsArgs) -> anyhow::Resu
let oldest_day = page.entries.last().map(|e| day_of(e)).unwrap_or_default();
for entry in &page.entries {
write_entry_row(&mut out, &account_label, entry)?;
write_entry_row(&mut out, account_label, entry)?;
rows += 1;
let tally = type_tallies.entry(entry.entry_type.as_str().to_string()).or_insert((0, 0));
tally.0 += 1;
@@ -43,31 +43,15 @@ pub struct AuditOrdersArgs {
#[arg(long, default_value_t = 250)]
pub delay_ms: u64,
/// Read session cookies from this env var instead of SEEDANCE2PRO_COOKIES
/// (e.g. SEEDANCE2PRO_VOLCENGINE_COOKIES / SEEDANCE2PRO_BYTEPLUS_COOKIES /
/// SEEDANCE2PRO_BYTEPLUS_ULTRA_COOKIES to audit each Kinovi account).
#[arg(long)]
pub cookies_env: Option<String>,
/// Account label written into each CSV row. Defaults to the cookies env
/// var name (or "SEEDANCE2PRO_COOKIES").
/// var name.
#[arg(long)]
pub account: Option<String>,
}
pub async fn run(state: &KinoviWebState, args: AuditOrdersArgs) -> anyhow::Result<()> {
let (cookies, account_label) = match &args.cookies_env {
Some(var) => (
easyenv::get_env_string_required(var)
.map_err(|err| anyhow!("Missing {} env var: {:?}", var, err))?,
args.account.clone().unwrap_or_else(|| var.clone()),
),
None => (
state.cookies.clone(),
args.account.clone().unwrap_or_else(|| "SEEDANCE2PRO_COOKIES".to_string()),
),
};
let session = KinoviWebSession::from_cookies_string(cookies);
let account_label = args.account.as_deref().unwrap_or(&state.cookies_env);
let session = KinoviWebSession::from_cookies_string(state.cookies.clone());
let file_is_new = std::fs::metadata(&args.out).map(|m| m.len() == 0).unwrap_or(true);
let mut out = OpenOptions::new().create(true).append(true).open(&args.out)
@@ -106,7 +90,7 @@ pub async fn run(state: &KinoviWebState, args: AuditOrdersArgs) -> anyhow::Resul
stats.skipped_before_window += 1;
continue;
}
write_order_row(&mut out, &account_label, order)?;
write_order_row(&mut out, account_label, order)?;
stats.record(order);
}
out.flush()?;
@@ -133,7 +117,7 @@ pub async fn run(state: &KinoviWebState, args: AuditOrdersArgs) -> anyhow::Resul
tokio::time::sleep(Duration::from_millis(args.delay_ms)).await;
}
stats.print_summary(&account_label, cursor);
stats.print_summary(account_label, cursor);
Ok(())
}
@@ -29,10 +29,6 @@ pub struct AuditPaymentsArgs {
#[arg(long, default_value_t = 250)]
pub delay_ms: u64,
/// Read session cookies from this env var instead of SEEDANCE2PRO_COOKIES.
#[arg(long)]
pub cookies_env: Option<String>,
/// Account label written into each CSV row. Defaults to the cookies env
/// var name.
#[arg(long)]
@@ -40,18 +36,8 @@ pub struct AuditPaymentsArgs {
}
pub async fn run(state: &KinoviWebState, args: AuditPaymentsArgs) -> anyhow::Result<()> {
let (cookies, account_label) = match &args.cookies_env {
Some(var) => (
easyenv::get_env_string_required(var)
.map_err(|err| anyhow!("Missing {} env var: {:?}", var, err))?,
args.account.clone().unwrap_or_else(|| var.clone()),
),
None => (
state.cookies.clone(),
args.account.clone().unwrap_or_else(|| "SEEDANCE2PRO_COOKIES".to_string()),
),
};
let session = KinoviWebSession::from_cookies_string(cookies);
let account_label = args.account.as_deref().unwrap_or(&state.cookies_env);
let session = KinoviWebSession::from_cookies_string(state.cookies.clone());
let file_is_new = std::fs::metadata(&args.out).map(|m| m.len() == 0).unwrap_or(true);
let mut out = OpenOptions::new().create(true).append(true).open(&args.out)
@@ -82,7 +68,7 @@ pub async fn run(state: &KinoviWebState, args: AuditPaymentsArgs) -> anyhow::Res
}
for payment in &page.payments {
write_payment_row(&mut out, &account_label, payment)?;
write_payment_row(&mut out, account_label, payment)?;
rows += 1;
total_amount += payment.amount_usd;
total_credits += payment.credits_earned;
@@ -6,7 +6,7 @@ use super::kinovi_web;
/// All canonical subcommand names across all modules.
/// Used by the underscore-insensitive arg normalizer.
pub fn all_canonical_names() -> Vec<&'static str> {
let mut names: Vec<&str> = vec!["kinovi_web", "artcraft"];
let mut names: Vec<&str> = vec!["kinovi_web", "kinovi", "seedance2pro", "artcraft"];
names.extend_from_slice(kinovi_web::dispatch::SUBCOMMAND_NAMES);
names.extend_from_slice(artcraft::dispatch::SUBCOMMAND_NAMES);
names
@@ -23,10 +23,8 @@ pub struct Cli {
#[command(rename_all = "snake_case")]
pub enum TopLevelCommand {
/// Seedance2 Pro support commands (direct Kinovi API)
KinoviWeb {
#[command(subcommand)]
command: kinovi_web::KinoviWebCommand,
},
#[command(visible_aliases = ["kinovi", "seedance2pro"])]
KinoviWeb(kinovi_web::KinoviWebArgs),
/// ArtCraft support commands (omni API)
Artcraft(artcraft::dispatch::ArtcraftArgs),
@@ -34,8 +32,8 @@ pub enum TopLevelCommand {
pub async fn run(cli: Cli) -> anyhow::Result<()> {
match cli.command {
TopLevelCommand::KinoviWeb { command } => {
kinovi_web::run(command).await
TopLevelCommand::KinoviWeb(args) => {
kinovi_web::run(args).await
}
TopLevelCommand::Artcraft(args) => {
artcraft::dispatch(args).await
@@ -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));
@@ -384,6 +384,9 @@ fn build_omni_gen_image_models() -> Vec<OmniGenImageModelDetails> {
aspect_ratio_default: Some(CommonAspectRatio::Square),
aspect_ratio_default_when_editing: Some(CommonAspectRatio::Auto),
quality_options: Some(vec![
CommonQuality::Auto,
CommonQuality::Max,
CommonQuality::XHigh,
CommonQuality::High,
CommonQuality::Medium,
CommonQuality::Low,
@@ -422,6 +425,9 @@ fn build_omni_gen_image_models() -> Vec<OmniGenImageModelDetails> {
aspect_ratio_default: Some(CommonAspectRatio::Square),
aspect_ratio_default_when_editing: Some(CommonAspectRatio::Auto),
quality_options: Some(vec![
CommonQuality::Auto,
CommonQuality::Max,
CommonQuality::XHigh,
CommonQuality::High,
CommonQuality::Medium,
CommonQuality::Low,
@@ -673,7 +679,17 @@ mod tests {
assert_eq!(details.text_prompt_supported, Some(true));
assert_eq!(details.image_refs_supported, Some(true));
assert_eq!(details.default_quality, Some(CommonQuality::High));
assert_eq!(details.quality_options.as_ref().map(|q| q.len()), Some(3));
assert_eq!(
details.quality_options.as_deref(),
Some(&[
CommonQuality::Auto,
CommonQuality::Max,
CommonQuality::XHigh,
CommonQuality::High,
CommonQuality::Medium,
CommonQuality::Low,
][..]),
);
assert_eq!(details.resolution_default, Some(CommonResolution::OneK));
assert_eq!(details.resolution_options.as_ref().map(|r| r.len()), Some(4));
assert_eq!(details.aspect_ratio_default_when_editing, Some(CommonAspectRatio::Auto));
@@ -192,10 +192,13 @@ fn map_resolution(res: CommonResolution) -> RouterResolution {
}
}
fn map_quality(res: CommonQuality) -> RouterQuality {
match res {
CommonQuality::Low => RouterQuality::Low,
CommonQuality::Medium => RouterQuality::Medium,
fn map_quality(quality: CommonQuality) -> RouterQuality {
match quality {
CommonQuality::Auto => RouterQuality::Auto,
CommonQuality::Max => RouterQuality::Max,
CommonQuality::XHigh => RouterQuality::XHigh,
CommonQuality::High => RouterQuality::High,
CommonQuality::Medium => RouterQuality::Medium,
CommonQuality::Low => RouterQuality::Low,
}
}
@@ -10,6 +10,9 @@ interface QualityPickerProps {
}
const QUALITY_LABELS: Record<string, string> = {
auto: "Auto",
max: "Max",
xhigh: "Extra High",
high: "High",
medium: "Medium",
low: "Low",
@@ -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",
@@ -415,6 +415,9 @@ export const IMAGE_MODELS: ImageModel[] = [
],
defaultQuality: CommonQuality.High,
qualityOptions: [
CommonQuality.Auto,
CommonQuality.Max,
CommonQuality.XHigh,
CommonQuality.High,
CommonQuality.Medium,
CommonQuality.Low,
@@ -450,6 +453,9 @@ export const IMAGE_MODELS: ImageModel[] = [
],
defaultQuality: CommonQuality.High,
qualityOptions: [
CommonQuality.Auto,
CommonQuality.Max,
CommonQuality.XHigh,
CommonQuality.High,
CommonQuality.Medium,
CommonQuality.Low,