stronger typing

This commit is contained in:
Brandon Thomas committed 2026-06-02 03:12:50 -04:00
1 parent bfd572a530
commit 3cc8b39b08
6 files changed
+433 -167

No files matched your search

@@ -11,6 +11,10 @@ pub enum Seedance2ProClientError {
/// An error was encountered in building the Wreq client.
WreqClientError(wreq::Error),
/// A request field couldn't be parsed into the value expected on the wire.
/// Carries the field name and the raw input the caller supplied.
InvalidRequestField { field: &'static str, raw_value: String, reason: String },
}
impl Error for Seedance2ProClientError {}
@@ -21,6 +25,9 @@ impl Display for Seedance2ProClientError {
Self::NoCookiesPresent => write!(f, "No cookies present in the session."),
Self::UrlParseError(err) => write!(f, "URL parse error: {}", err),
Self::WreqClientError(err) => write!(f, "Wreq client error (during client creation): {}", err),
Self::InvalidRequestField { field, raw_value, reason } => {
write!(f, "Invalid value for request field `{}`: {:?} ({})", field, raw_value, reason)
}
}
}
}
@@ -5,11 +5,11 @@ use crate::requests::generate_image::generate_image::{
};
use crate::requests::kinovi_host::KinoviHost;
// Re-export shared enums so callers can use this module without dipping into
// the lower-level `requests::generate_image` module path.
pub use crate::requests::generate_image::generate_image::{
KinoviMidjourneyAspectRatio, KinoviMidjourneyBatchCount, KinoviMidjourneyQuality,
};
// `KinoviMidjourneyBatchCount` is shared across all Midjourney models, so we
// re-export it from the base. Aspect ratio and quality, however, are pinned
// to model-specific enums on this wrapper to keep their valid values typed
// at the call site.
pub use crate::requests::generate_image::generate_image::KinoviMidjourneyBatchCount;
// ── Args ──
@@ -24,12 +24,12 @@ pub struct GenerateMidjourneyV7Args<'a> {
#[derive(Clone, Debug)]
pub struct GenerateMidjourneyV7Request {
pub prompt: String,
pub aspect_ratio: KinoviMidjourneyAspectRatio,
pub aspect_ratio: GenerateMidjourneyV7AspectRatio,
pub negative_prompt: Option<String>,
pub stylize: Option<u16>,
pub weird: Option<u16>,
pub chaos: Option<u8>,
pub quality: Option<KinoviMidjourneyQuality>,
pub quality: Option<GenerateMidjourneyV7Quality>,
pub raw_mode: bool,
pub batch_count: KinoviMidjourneyBatchCount,
pub reference_image_urls: Option<Vec<String>>,
@@ -48,12 +48,12 @@ impl GenerateMidjourneyV7Request {
KinoviGenerateImageRequest {
model: KinoviMidjourneyModel::V7,
prompt: self.prompt.clone(),
aspect_ratio: self.aspect_ratio,
aspect_ratio: self.aspect_ratio.as_api_str().to_string(),
negative_prompt: self.negative_prompt.clone(),
stylize: self.stylize,
weird: self.weird,
chaos: self.chaos,
quality: self.quality,
quality: self.quality.map(|q| q.as_api_str().to_string()),
raw_mode: self.raw_mode,
batch_count: self.batch_count,
reference_image_urls: self.reference_image_urls.clone(),
@@ -61,6 +61,64 @@ impl GenerateMidjourneyV7Request {
}
}
// ── Model-specific enums ──
/// Aspect ratios supported by Midjourney v7.
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum GenerateMidjourneyV7AspectRatio {
Square1x1,
Landscape16x9,
Portrait9x16,
UltraWide21x9,
UltraTall9x21,
Standard4x3,
Portrait3x4,
Wide5x4,
Tall4x5,
Wide3x2,
Tall2x3,
}
impl GenerateMidjourneyV7AspectRatio {
fn as_api_str(&self) -> &'static str {
match self {
Self::Square1x1 => "1:1",
Self::Landscape16x9 => "16:9",
Self::Portrait9x16 => "9:16",
Self::UltraWide21x9 => "21:9",
Self::UltraTall9x21 => "9:21",
Self::Standard4x3 => "4:3",
Self::Portrait3x4 => "3:4",
Self::Wide5x4 => "5:4",
Self::Tall4x5 => "4:5",
Self::Wide3x2 => "3:2",
Self::Tall2x3 => "2:3",
}
}
}
/// Quality presets supported by Midjourney v7. Higher = more compute and
/// slower; Kinovi credit pricing is flat regardless.
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum GenerateMidjourneyV7Quality {
/// 0.25
Quarter,
/// 0.5
Half,
/// 1 (default)
Full,
}
impl GenerateMidjourneyV7Quality {
fn as_api_str(&self) -> &'static str {
match self {
Self::Quarter => "0.25",
Self::Half => "0.5",
Self::Full => "1",
}
}
}
// ── Response ──
pub struct GenerateMidjourneyV7Response {
@@ -107,7 +165,7 @@ mod tests {
fn make_request(batch_count: KinoviMidjourneyBatchCount) -> GenerateMidjourneyV7Request {
GenerateMidjourneyV7Request {
prompt: "test".to_string(),
aspect_ratio: KinoviMidjourneyAspectRatio::Square1x1,
aspect_ratio: GenerateMidjourneyV7AspectRatio::Square1x1,
negative_prompt: None,
stylize: None,
weird: None,
@@ -134,12 +192,12 @@ mod tests {
fn inner_request_preserves_all_fields() {
let req = GenerateMidjourneyV7Request {
prompt: "a corgi in space".to_string(),
aspect_ratio: KinoviMidjourneyAspectRatio::Landscape16x9,
aspect_ratio: GenerateMidjourneyV7AspectRatio::Landscape16x9,
negative_prompt: Some("dark".to_string()),
stylize: Some(500),
weird: Some(1500),
chaos: Some(50),
quality: Some(KinoviMidjourneyQuality::Half),
quality: Some(GenerateMidjourneyV7Quality::Half),
raw_mode: true,
batch_count: KinoviMidjourneyBatchCount::Four,
reference_image_urls: Some(vec!["https://example.com/x.png".to_string()]),
@@ -147,12 +205,12 @@ mod tests {
let inner = req.to_inner_request();
assert_eq!(inner.model, KinoviMidjourneyModel::V7);
assert_eq!(inner.prompt, "a corgi in space");
assert_eq!(inner.aspect_ratio, KinoviMidjourneyAspectRatio::Landscape16x9);
assert_eq!(inner.aspect_ratio, "16:9");
assert_eq!(inner.negative_prompt.as_deref(), Some("dark"));
assert_eq!(inner.stylize, Some(500));
assert_eq!(inner.weird, Some(1500));
assert_eq!(inner.chaos, Some(50));
assert_eq!(inner.quality, Some(KinoviMidjourneyQuality::Half));
assert_eq!(inner.quality.as_deref(), Some("0.5"));
assert!(inner.raw_mode);
assert_eq!(inner.batch_count, KinoviMidjourneyBatchCount::Four);
assert_eq!(
@@ -160,6 +218,43 @@ mod tests {
Some(&["https://example.com/x.png".to_string()][..]),
);
}
/// Every wrapper-side aspect ratio must serialize to the exact wire
/// string Midjourney expects. Catches silent drift between the enum
/// surface and the API.
#[test]
fn every_aspect_ratio_maps_to_canonical_wire_string() {
let cases = [
(GenerateMidjourneyV7AspectRatio::Square1x1, "1:1"),
(GenerateMidjourneyV7AspectRatio::Landscape16x9, "16:9"),
(GenerateMidjourneyV7AspectRatio::Portrait9x16, "9:16"),
(GenerateMidjourneyV7AspectRatio::UltraWide21x9, "21:9"),
(GenerateMidjourneyV7AspectRatio::UltraTall9x21, "9:21"),
(GenerateMidjourneyV7AspectRatio::Standard4x3, "4:3"),
(GenerateMidjourneyV7AspectRatio::Portrait3x4, "3:4"),
(GenerateMidjourneyV7AspectRatio::Wide5x4, "5:4"),
(GenerateMidjourneyV7AspectRatio::Tall4x5, "4:5"),
(GenerateMidjourneyV7AspectRatio::Wide3x2, "3:2"),
(GenerateMidjourneyV7AspectRatio::Tall2x3, "2:3"),
];
for (variant, expected) in cases {
let req = GenerateMidjourneyV7Request { aspect_ratio: variant, ..make_request(KinoviMidjourneyBatchCount::One) };
assert_eq!(req.to_inner_request().aspect_ratio, expected, "variant={:?}", variant);
}
}
#[test]
fn every_quality_maps_to_canonical_wire_string() {
let cases = [
(GenerateMidjourneyV7Quality::Quarter, "0.25"),
(GenerateMidjourneyV7Quality::Half, "0.5"),
(GenerateMidjourneyV7Quality::Full, "1"),
];
for (variant, expected) in cases {
let req = GenerateMidjourneyV7Request { quality: Some(variant), ..make_request(KinoviMidjourneyBatchCount::One) };
assert_eq!(req.to_inner_request().quality.as_deref(), Some(expected), "variant={:?}", variant);
}
}
}
// ── Pricing (delegates to the inner module, but pinned via explicit values) ──
@@ -224,7 +319,7 @@ mod tests {
host_override: None,
request: GenerateMidjourneyV7Request {
prompt: "A corgi astronaut floating among stars".to_string(),
aspect_ratio: KinoviMidjourneyAspectRatio::Square1x1,
aspect_ratio: GenerateMidjourneyV7AspectRatio::Square1x1,
negative_prompt: None,
stylize: None,
weird: None,
@@ -251,12 +346,12 @@ mod tests {
host_override: None,
request: GenerateMidjourneyV7Request {
prompt: "abandoned skyscrapers".to_string(),
aspect_ratio: KinoviMidjourneyAspectRatio::Square1x1,
aspect_ratio: GenerateMidjourneyV7AspectRatio::Square1x1,
negative_prompt: Some("dark, gloomy, night".to_string()),
stylize: Some(1000),
weird: Some(3000),
chaos: Some(100),
quality: Some(KinoviMidjourneyQuality::Half),
quality: Some(GenerateMidjourneyV7Quality::Half),
raw_mode: true,
batch_count: KinoviMidjourneyBatchCount::One,
reference_image_urls: None,
@@ -5,11 +5,11 @@ use crate::requests::generate_image::generate_image::{
};
use crate::requests::kinovi_host::KinoviHost;
// Re-export shared enums so callers can use this module without dipping into
// the lower-level `requests::generate_image` module path.
pub use crate::requests::generate_image::generate_image::{
KinoviMidjourneyAspectRatio, KinoviMidjourneyBatchCount, KinoviMidjourneyQuality,
};
// `KinoviMidjourneyBatchCount` is shared across all Midjourney models, so we
// re-export it from the base. Aspect ratio and quality, however, are pinned
// to model-specific enums on this wrapper to keep their valid values typed
// at the call site.
pub use crate::requests::generate_image::generate_image::KinoviMidjourneyBatchCount;
// ── Args ──
@@ -24,12 +24,12 @@ pub struct GenerateMidjourneyV7NijiArgs<'a> {
#[derive(Clone, Debug)]
pub struct GenerateMidjourneyV7NijiRequest {
pub prompt: String,
pub aspect_ratio: KinoviMidjourneyAspectRatio,
pub aspect_ratio: GenerateMidjourneyV7NijiAspectRatio,
pub negative_prompt: Option<String>,
pub stylize: Option<u16>,
pub weird: Option<u16>,
pub chaos: Option<u8>,
pub quality: Option<KinoviMidjourneyQuality>,
pub quality: Option<GenerateMidjourneyV7NijiQuality>,
pub raw_mode: bool,
pub batch_count: KinoviMidjourneyBatchCount,
pub reference_image_urls: Option<Vec<String>>,
@@ -48,12 +48,12 @@ impl GenerateMidjourneyV7NijiRequest {
KinoviGenerateImageRequest {
model: KinoviMidjourneyModel::V7Niji,
prompt: self.prompt.clone(),
aspect_ratio: self.aspect_ratio,
aspect_ratio: self.aspect_ratio.as_api_str().to_string(),
negative_prompt: self.negative_prompt.clone(),
stylize: self.stylize,
weird: self.weird,
chaos: self.chaos,
quality: self.quality,
quality: self.quality.map(|q| q.as_api_str().to_string()),
raw_mode: self.raw_mode,
batch_count: self.batch_count,
reference_image_urls: self.reference_image_urls.clone(),
@@ -61,6 +61,64 @@ impl GenerateMidjourneyV7NijiRequest {
}
}
// ── Model-specific enums ──
/// Aspect ratios supported by Midjourney v7 Niji.
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum GenerateMidjourneyV7NijiAspectRatio {
Square1x1,
Landscape16x9,
Portrait9x16,
UltraWide21x9,
UltraTall9x21,
Standard4x3,
Portrait3x4,
Wide5x4,
Tall4x5,
Wide3x2,
Tall2x3,
}
impl GenerateMidjourneyV7NijiAspectRatio {
fn as_api_str(&self) -> &'static str {
match self {
Self::Square1x1 => "1:1",
Self::Landscape16x9 => "16:9",
Self::Portrait9x16 => "9:16",
Self::UltraWide21x9 => "21:9",
Self::UltraTall9x21 => "9:21",
Self::Standard4x3 => "4:3",
Self::Portrait3x4 => "3:4",
Self::Wide5x4 => "5:4",
Self::Tall4x5 => "4:5",
Self::Wide3x2 => "3:2",
Self::Tall2x3 => "2:3",
}
}
}
/// Quality presets supported by Midjourney v7 Niji. Higher = more compute
/// and slower; Kinovi credit pricing is flat regardless.
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum GenerateMidjourneyV7NijiQuality {
/// 0.25
Quarter,
/// 0.5
Half,
/// 1 (default)
Full,
}
impl GenerateMidjourneyV7NijiQuality {
fn as_api_str(&self) -> &'static str {
match self {
Self::Quarter => "0.25",
Self::Half => "0.5",
Self::Full => "1",
}
}
}
// ── Response ──
pub struct GenerateMidjourneyV7NijiResponse {
@@ -107,7 +165,7 @@ mod tests {
fn make_request(batch_count: KinoviMidjourneyBatchCount) -> GenerateMidjourneyV7NijiRequest {
GenerateMidjourneyV7NijiRequest {
prompt: "test".to_string(),
aspect_ratio: KinoviMidjourneyAspectRatio::Square1x1,
aspect_ratio: GenerateMidjourneyV7NijiAspectRatio::Square1x1,
negative_prompt: None,
stylize: None,
weird: None,
@@ -134,12 +192,12 @@ mod tests {
fn inner_request_preserves_all_fields() {
let req = GenerateMidjourneyV7NijiRequest {
prompt: "anime mecha duel".to_string(),
aspect_ratio: KinoviMidjourneyAspectRatio::UltraWide21x9,
aspect_ratio: GenerateMidjourneyV7NijiAspectRatio::UltraWide21x9,
negative_prompt: Some("blurry".to_string()),
stylize: Some(750),
weird: Some(0),
chaos: Some(25),
quality: Some(KinoviMidjourneyQuality::Full),
quality: Some(GenerateMidjourneyV7NijiQuality::Full),
raw_mode: false,
batch_count: KinoviMidjourneyBatchCount::Two,
reference_image_urls: Some(vec![
@@ -150,16 +208,51 @@ mod tests {
let inner = req.to_inner_request();
assert_eq!(inner.model, KinoviMidjourneyModel::V7Niji);
assert_eq!(inner.prompt, "anime mecha duel");
assert_eq!(inner.aspect_ratio, KinoviMidjourneyAspectRatio::UltraWide21x9);
assert_eq!(inner.aspect_ratio, "21:9");
assert_eq!(inner.negative_prompt.as_deref(), Some("blurry"));
assert_eq!(inner.stylize, Some(750));
assert_eq!(inner.weird, Some(0));
assert_eq!(inner.chaos, Some(25));
assert_eq!(inner.quality, Some(KinoviMidjourneyQuality::Full));
assert_eq!(inner.quality.as_deref(), Some("1"));
assert!(!inner.raw_mode);
assert_eq!(inner.batch_count, KinoviMidjourneyBatchCount::Two);
assert_eq!(inner.reference_image_urls.as_ref().map(|v| v.len()), Some(2));
}
/// Every wrapper-side aspect ratio must serialize to the canonical wire string.
#[test]
fn every_aspect_ratio_maps_to_canonical_wire_string() {
let cases = [
(GenerateMidjourneyV7NijiAspectRatio::Square1x1, "1:1"),
(GenerateMidjourneyV7NijiAspectRatio::Landscape16x9, "16:9"),
(GenerateMidjourneyV7NijiAspectRatio::Portrait9x16, "9:16"),
(GenerateMidjourneyV7NijiAspectRatio::UltraWide21x9, "21:9"),
(GenerateMidjourneyV7NijiAspectRatio::UltraTall9x21, "9:21"),
(GenerateMidjourneyV7NijiAspectRatio::Standard4x3, "4:3"),
(GenerateMidjourneyV7NijiAspectRatio::Portrait3x4, "3:4"),
(GenerateMidjourneyV7NijiAspectRatio::Wide5x4, "5:4"),
(GenerateMidjourneyV7NijiAspectRatio::Tall4x5, "4:5"),
(GenerateMidjourneyV7NijiAspectRatio::Wide3x2, "3:2"),
(GenerateMidjourneyV7NijiAspectRatio::Tall2x3, "2:3"),
];
for (variant, expected) in cases {
let req = GenerateMidjourneyV7NijiRequest { aspect_ratio: variant, ..make_request(KinoviMidjourneyBatchCount::One) };
assert_eq!(req.to_inner_request().aspect_ratio, expected, "variant={:?}", variant);
}
}
#[test]
fn every_quality_maps_to_canonical_wire_string() {
let cases = [
(GenerateMidjourneyV7NijiQuality::Quarter, "0.25"),
(GenerateMidjourneyV7NijiQuality::Half, "0.5"),
(GenerateMidjourneyV7NijiQuality::Full, "1"),
];
for (variant, expected) in cases {
let req = GenerateMidjourneyV7NijiRequest { quality: Some(variant), ..make_request(KinoviMidjourneyBatchCount::One) };
assert_eq!(req.to_inner_request().quality.as_deref(), Some(expected), "variant={:?}", variant);
}
}
}
// ── Pricing ──
@@ -223,7 +316,7 @@ mod tests {
host_override: None,
request: GenerateMidjourneyV7NijiRequest {
prompt: "A magical shiba inu sorcerer casting spells in a crystal cave".to_string(),
aspect_ratio: KinoviMidjourneyAspectRatio::UltraWide21x9,
aspect_ratio: GenerateMidjourneyV7NijiAspectRatio::UltraWide21x9,
negative_prompt: None,
stylize: None,
weird: None,
@@ -5,11 +5,11 @@ use crate::requests::generate_image::generate_image::{
};
use crate::requests::kinovi_host::KinoviHost;
// Re-export shared enums so callers can use this module without dipping into
// the lower-level `requests::generate_image` module path.
pub use crate::requests::generate_image::generate_image::{
KinoviMidjourneyAspectRatio, KinoviMidjourneyBatchCount, KinoviMidjourneyQuality,
};
// `KinoviMidjourneyBatchCount` is shared across all Midjourney models, so we
// re-export it from the base. Aspect ratio and quality, however, are pinned
// to model-specific enums on this wrapper to keep their valid values typed
// at the call site.
pub use crate::requests::generate_image::generate_image::KinoviMidjourneyBatchCount;
// ── Args ──
@@ -24,12 +24,12 @@ pub struct GenerateMidjourneyV8Args<'a> {
#[derive(Clone, Debug)]
pub struct GenerateMidjourneyV8Request {
pub prompt: String,
pub aspect_ratio: KinoviMidjourneyAspectRatio,
pub aspect_ratio: GenerateMidjourneyV8AspectRatio,
pub negative_prompt: Option<String>,
pub stylize: Option<u16>,
pub weird: Option<u16>,
pub chaos: Option<u8>,
pub quality: Option<KinoviMidjourneyQuality>,
pub quality: Option<GenerateMidjourneyV8Quality>,
pub raw_mode: bool,
pub batch_count: KinoviMidjourneyBatchCount,
pub reference_image_urls: Option<Vec<String>>,
@@ -48,12 +48,12 @@ impl GenerateMidjourneyV8Request {
KinoviGenerateImageRequest {
model: KinoviMidjourneyModel::V8,
prompt: self.prompt.clone(),
aspect_ratio: self.aspect_ratio,
aspect_ratio: self.aspect_ratio.as_api_str().to_string(),
negative_prompt: self.negative_prompt.clone(),
stylize: self.stylize,
weird: self.weird,
chaos: self.chaos,
quality: self.quality,
quality: self.quality.map(|q| q.as_api_str().to_string()),
raw_mode: self.raw_mode,
batch_count: self.batch_count,
reference_image_urls: self.reference_image_urls.clone(),
@@ -61,6 +61,64 @@ impl GenerateMidjourneyV8Request {
}
}
// ── Model-specific enums ──
/// Aspect ratios supported by Midjourney v8.
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum GenerateMidjourneyV8AspectRatio {
Square1x1,
Landscape16x9,
Portrait9x16,
UltraWide21x9,
UltraTall9x21,
Standard4x3,
Portrait3x4,
Wide5x4,
Tall4x5,
Wide3x2,
Tall2x3,
}
impl GenerateMidjourneyV8AspectRatio {
fn as_api_str(&self) -> &'static str {
match self {
Self::Square1x1 => "1:1",
Self::Landscape16x9 => "16:9",
Self::Portrait9x16 => "9:16",
Self::UltraWide21x9 => "21:9",
Self::UltraTall9x21 => "9:21",
Self::Standard4x3 => "4:3",
Self::Portrait3x4 => "3:4",
Self::Wide5x4 => "5:4",
Self::Tall4x5 => "4:5",
Self::Wide3x2 => "3:2",
Self::Tall2x3 => "2:3",
}
}
}
/// Quality presets supported by Midjourney v8. Higher = more compute and
/// slower; Kinovi credit pricing is flat regardless.
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum GenerateMidjourneyV8Quality {
/// 0.25
Quarter,
/// 0.5
Half,
/// 1 (default)
Full,
}
impl GenerateMidjourneyV8Quality {
fn as_api_str(&self) -> &'static str {
match self {
Self::Quarter => "0.25",
Self::Half => "0.5",
Self::Full => "1",
}
}
}
// ── Response ──
pub struct GenerateMidjourneyV8Response {
@@ -107,7 +165,7 @@ mod tests {
fn make_request(batch_count: KinoviMidjourneyBatchCount) -> GenerateMidjourneyV8Request {
GenerateMidjourneyV8Request {
prompt: "test".to_string(),
aspect_ratio: KinoviMidjourneyAspectRatio::Square1x1,
aspect_ratio: GenerateMidjourneyV8AspectRatio::Square1x1,
negative_prompt: None,
stylize: None,
weird: None,
@@ -134,12 +192,12 @@ mod tests {
fn inner_request_preserves_all_fields() {
let req = GenerateMidjourneyV8Request {
prompt: "abandoned k-mart, overgrown vines".to_string(),
aspect_ratio: KinoviMidjourneyAspectRatio::Portrait9x16,
aspect_ratio: GenerateMidjourneyV8AspectRatio::Portrait9x16,
negative_prompt: Some("dark, gloomy".to_string()),
stylize: Some(730),
weird: Some(2050),
chaos: Some(70),
quality: Some(KinoviMidjourneyQuality::Half),
quality: Some(GenerateMidjourneyV8Quality::Half),
raw_mode: true,
batch_count: KinoviMidjourneyBatchCount::Four,
reference_image_urls: Some(vec!["https://example.com/ref.jpg".to_string()]),
@@ -147,16 +205,51 @@ mod tests {
let inner = req.to_inner_request();
assert_eq!(inner.model, KinoviMidjourneyModel::V8);
assert_eq!(inner.prompt, "abandoned k-mart, overgrown vines");
assert_eq!(inner.aspect_ratio, KinoviMidjourneyAspectRatio::Portrait9x16);
assert_eq!(inner.aspect_ratio, "9:16");
assert_eq!(inner.negative_prompt.as_deref(), Some("dark, gloomy"));
assert_eq!(inner.stylize, Some(730));
assert_eq!(inner.weird, Some(2050));
assert_eq!(inner.chaos, Some(70));
assert_eq!(inner.quality, Some(KinoviMidjourneyQuality::Half));
assert_eq!(inner.quality.as_deref(), Some("0.5"));
assert!(inner.raw_mode);
assert_eq!(inner.batch_count, KinoviMidjourneyBatchCount::Four);
assert_eq!(inner.reference_image_urls.as_ref().map(|v| v.len()), Some(1));
}
/// Every wrapper-side aspect ratio must serialize to the canonical wire string.
#[test]
fn every_aspect_ratio_maps_to_canonical_wire_string() {
let cases = [
(GenerateMidjourneyV8AspectRatio::Square1x1, "1:1"),
(GenerateMidjourneyV8AspectRatio::Landscape16x9, "16:9"),
(GenerateMidjourneyV8AspectRatio::Portrait9x16, "9:16"),
(GenerateMidjourneyV8AspectRatio::UltraWide21x9, "21:9"),
(GenerateMidjourneyV8AspectRatio::UltraTall9x21, "9:21"),
(GenerateMidjourneyV8AspectRatio::Standard4x3, "4:3"),
(GenerateMidjourneyV8AspectRatio::Portrait3x4, "3:4"),
(GenerateMidjourneyV8AspectRatio::Wide5x4, "5:4"),
(GenerateMidjourneyV8AspectRatio::Tall4x5, "4:5"),
(GenerateMidjourneyV8AspectRatio::Wide3x2, "3:2"),
(GenerateMidjourneyV8AspectRatio::Tall2x3, "2:3"),
];
for (variant, expected) in cases {
let req = GenerateMidjourneyV8Request { aspect_ratio: variant, ..make_request(KinoviMidjourneyBatchCount::One) };
assert_eq!(req.to_inner_request().aspect_ratio, expected, "variant={:?}", variant);
}
}
#[test]
fn every_quality_maps_to_canonical_wire_string() {
let cases = [
(GenerateMidjourneyV8Quality::Quarter, "0.25"),
(GenerateMidjourneyV8Quality::Half, "0.5"),
(GenerateMidjourneyV8Quality::Full, "1"),
];
for (variant, expected) in cases {
let req = GenerateMidjourneyV8Request { quality: Some(variant), ..make_request(KinoviMidjourneyBatchCount::One) };
assert_eq!(req.to_inner_request().quality.as_deref(), Some(expected), "variant={:?}", variant);
}
}
}
// ── Pricing ──
@@ -220,12 +313,12 @@ mod tests {
host_override: None,
request: GenerateMidjourneyV8Request {
prompt: "spooky horror scene, ghosts".to_string(),
aspect_ratio: KinoviMidjourneyAspectRatio::Portrait9x16,
aspect_ratio: GenerateMidjourneyV8AspectRatio::Portrait9x16,
negative_prompt: None,
stylize: None,
weird: None,
chaos: None,
quality: Some(KinoviMidjourneyQuality::Full),
quality: Some(GenerateMidjourneyV8Quality::Full),
raw_mode: false,
batch_count: KinoviMidjourneyBatchCount::One,
reference_image_urls: None,
@@ -246,12 +339,12 @@ mod tests {
host_override: None,
request: GenerateMidjourneyV8Request {
prompt: "desolate cliff overlooking the ocean".to_string(),
aspect_ratio: KinoviMidjourneyAspectRatio::Portrait9x16,
aspect_ratio: GenerateMidjourneyV8AspectRatio::Portrait9x16,
negative_prompt: None,
stylize: None,
weird: None,
chaos: None,
quality: Some(KinoviMidjourneyQuality::Full),
quality: Some(GenerateMidjourneyV8Quality::Full),
raw_mode: false,
batch_count: KinoviMidjourneyBatchCount::Four,
reference_image_urls: None,
@@ -272,12 +365,12 @@ mod tests {
host_override: None,
request: GenerateMidjourneyV8Request {
prompt: "spooky horror scene, ghosts".to_string(),
aspect_ratio: KinoviMidjourneyAspectRatio::Portrait9x16,
aspect_ratio: GenerateMidjourneyV8AspectRatio::Portrait9x16,
negative_prompt: None,
stylize: None,
weird: None,
chaos: None,
quality: Some(KinoviMidjourneyQuality::Full),
quality: Some(GenerateMidjourneyV8Quality::Full),
raw_mode: false,
batch_count: KinoviMidjourneyBatchCount::One,
reference_image_urls: Some(vec![
@@ -39,6 +39,12 @@ pub struct GenerateImageArgs<'a> {
}
/// Image generation parameters (no session/host info).
///
/// `aspect_ratio` and `quality` are loosely-typed strings here so this
/// function can serve image-generation flows that aren't Midjourney without
/// being constrained to Midjourney's particular set of values. The thin
/// per-model wrappers (`generate_midjourney_v7`, etc.) own strongly-typed
/// enums and convert them to strings before calling in.
#[derive(Clone)]
pub struct KinoviGenerateImageRequest {
/// Which Midjourney variant to run.
@@ -47,8 +53,9 @@ pub struct KinoviGenerateImageRequest {
/// User prompt.
pub prompt: String,
/// Output aspect ratio.
pub aspect_ratio: KinoviMidjourneyAspectRatio,
/// Output aspect ratio, e.g. `"16:9"`, `"1:1"`, `"9:16"`. Forwarded
/// verbatim into the request's `aspectRatio` field.
pub aspect_ratio: String,
/// Optional negative prompt. Sent as the `"no"` field on the wire.
pub negative_prompt: Option<String>,
@@ -66,8 +73,14 @@ pub struct KinoviGenerateImageRequest {
/// Midjourney "chaos" parameter. Valid range 0–100.
pub chaos: Option<u8>,
/// Quality preset. `None` omits the field.
pub quality: Option<KinoviMidjourneyQuality>,
/// Quality preset, e.g. `"0.25"`, `"0.5"`, `"1"`. Parsed as an `f32` and
/// emitted as a JSON number on the wire. `None` omits the field.
///
/// If the string isn't a valid `f32`, `generate_image()` returns a
/// `Seedance2ProClientError::InvalidRequestField` error rather than
/// panicking. The typed wrappers always supply well-formed values, so
/// errors here only occur for callers that bypass them.
pub quality: Option<String>,
/// Enables Midjourney "raw" style mode when true. Sent as `"style":"raw"`.
pub raw_mode: bool,
@@ -147,64 +160,6 @@ impl KinoviMidjourneyModel {
}
}
/// Output aspect ratio. Kinovi/Midjourney accepts a wide range; the values
/// here cover every ratio seen in the captured request samples plus the
/// common Midjourney-supported variants.
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum KinoviMidjourneyAspectRatio {
Square1x1,
Landscape16x9,
Portrait9x16,
UltraWide21x9,
UltraTall9x21,
Standard4x3,
Portrait3x4,
Wide5x4,
Tall4x5,
Wide3x2,
Tall2x3,
}
impl KinoviMidjourneyAspectRatio {
fn as_api_str(&self) -> &'static str {
match self {
Self::Square1x1 => "1:1",
Self::Landscape16x9 => "16:9",
Self::Portrait9x16 => "9:16",
Self::UltraWide21x9 => "21:9",
Self::UltraTall9x21 => "9:21",
Self::Standard4x3 => "4:3",
Self::Portrait3x4 => "3:4",
Self::Wide5x4 => "5:4",
Self::Tall4x5 => "4:5",
Self::Wide3x2 => "3:2",
Self::Tall2x3 => "2:3",
}
}
}
/// Midjourney quality presets. Higher = more compute and slower; pricing is
/// flat regardless.
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum KinoviMidjourneyQuality {
/// 0.25
Quarter,
/// 0.5
Half,
/// 1 (default)
Full,
}
impl KinoviMidjourneyQuality {
fn as_api_value(&self) -> f32 {
match self {
Self::Quarter => 0.25,
Self::Half => 0.5,
Self::Full => 1.0,
}
}
}
/// Number of distinct Midjourney generations to enqueue.
/// Each task itself returns a 4-image grid; this knob just enqueues N tasks.
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
@@ -257,19 +212,21 @@ pub async fn generate_image(args: GenerateImageArgs<'_>) -> Result<GenerateImage
// wire — Midjourney rejects empty arrays.
let uploaded_urls = req.reference_image_urls.filter(|urls| !urls.is_empty());
let quality = req.quality.as_deref().map(parse_quality).transpose()?;
let request_body = BatchRequest {
zero: BatchRequestInner {
json: BatchRequestJson {
business_type: BUSINESS_TYPE,
api_params: ApiParams {
prompt: req.prompt,
aspect_ratio: req.aspect_ratio.as_api_str(),
aspect_ratio: req.aspect_ratio,
resolution: MIDJOURNEY_RESOLUTION,
model: req.model.as_api_str(),
stylize: req.stylize,
chaos: req.chaos,
weird: req.weird,
quality: req.quality.map(|q| q.as_api_value()),
quality,
style,
negative_prompt: req.negative_prompt,
batch_count,
@@ -347,6 +304,20 @@ pub async fn generate_image(args: GenerateImageArgs<'_>) -> Result<GenerateImage
})
}
/// The wire format for `quality` is a JSON number, but the public API
/// accepts a string so future models with different quality semantics can
/// hand in whatever they like. Callers funnel through strongly-typed
/// wrapper enums whose string output is always parseable; arbitrary
/// callers might not, so we surface a structured client error instead of
/// panicking.
fn parse_quality(raw: &str) -> Result<f32, Seedance2ProClientError> {
raw.parse::<f32>().map_err(|err| Seedance2ProClientError::InvalidRequestField {
field: "quality",
raw_value: raw.to_string(),
reason: format!("not a valid f32: {}", err),
})
}
#[cfg(test)]
mod tests {
use super::*;
@@ -360,7 +331,7 @@ mod tests {
KinoviGenerateImageRequest {
model: KinoviMidjourneyModel::V7,
prompt: String::new(),
aspect_ratio: KinoviMidjourneyAspectRatio::Square1x1,
aspect_ratio: "1:1".to_string(),
negative_prompt: None,
stylize: None,
weird: None,
@@ -407,20 +378,11 @@ mod tests {
#[test]
fn pricing_is_aspect_ratio_agnostic() {
let baseline = make_request(KinoviMidjourneyBatchCount::One).estimate_credits();
for ar in [
KinoviMidjourneyAspectRatio::Square1x1,
KinoviMidjourneyAspectRatio::Landscape16x9,
KinoviMidjourneyAspectRatio::Portrait9x16,
KinoviMidjourneyAspectRatio::UltraWide21x9,
KinoviMidjourneyAspectRatio::UltraTall9x21,
KinoviMidjourneyAspectRatio::Standard4x3,
KinoviMidjourneyAspectRatio::Portrait3x4,
KinoviMidjourneyAspectRatio::Wide5x4,
KinoviMidjourneyAspectRatio::Tall4x5,
KinoviMidjourneyAspectRatio::Wide3x2,
KinoviMidjourneyAspectRatio::Tall2x3,
] {
let req = KinoviGenerateImageRequest { aspect_ratio: ar, ..make_request(KinoviMidjourneyBatchCount::One) };
for ar in ["1:1", "16:9", "9:16", "21:9", "9:21", "4:3", "3:4", "5:4", "4:5", "3:2", "2:3"] {
let req = KinoviGenerateImageRequest {
aspect_ratio: ar.to_string(),
..make_request(KinoviMidjourneyBatchCount::One)
};
assert_eq!(req.estimate_credits(), baseline, "aspect_ratio={:?}", ar);
}
}
@@ -433,7 +395,7 @@ mod tests {
stylize: Some(1000),
weird: Some(3000),
chaos: Some(100),
quality: Some(KinoviMidjourneyQuality::Full),
quality: Some("1".to_string()),
raw_mode: true,
negative_prompt: Some("ugly, blurry".to_string()),
..make_request(KinoviMidjourneyBatchCount::One)
@@ -485,12 +447,17 @@ mod tests {
use serde_json::Value;
fn build_wire_json(req: KinoviGenerateImageRequest) -> Value {
try_build_wire_json(req).expect("test helper: malformed quality string")
}
fn try_build_wire_json(req: KinoviGenerateImageRequest) -> Result<Value, Seedance2ProClientError> {
// Mirrors the body assembly in `generate_image()` minus the actual
// network call, so we can introspect the JSON the server would see.
let batch_count_value = req.batch_count.as_u8();
let batch_count = if batch_count_value > 1 { Some(batch_count_value) } else { None };
let style = if req.raw_mode { Some("raw") } else { None };
let uploaded_urls = req.reference_image_urls.filter(|urls| !urls.is_empty());
let quality = req.quality.as_deref().map(parse_quality).transpose()?;
let request_body = BatchRequest {
zero: BatchRequestInner {
@@ -498,13 +465,13 @@ mod tests {
business_type: BUSINESS_TYPE,
api_params: ApiParams {
prompt: req.prompt,
aspect_ratio: req.aspect_ratio.as_api_str(),
aspect_ratio: req.aspect_ratio,
resolution: MIDJOURNEY_RESOLUTION,
model: req.model.as_api_str(),
stylize: req.stylize,
chaos: req.chaos,
weird: req.weird,
quality: req.quality.map(|q| q.as_api_value()),
quality,
style,
negative_prompt: req.negative_prompt,
batch_count,
@@ -513,7 +480,7 @@ mod tests {
},
},
};
serde_json::to_value(&request_body).unwrap()
Ok(serde_json::to_value(&request_body).unwrap())
}
fn api_params(req: KinoviGenerateImageRequest) -> Value {
@@ -524,7 +491,7 @@ mod tests {
KinoviGenerateImageRequest {
model,
prompt: prompt.to_string(),
aspect_ratio: KinoviMidjourneyAspectRatio::Square1x1,
aspect_ratio: "1:1".to_string(),
negative_prompt: None,
stylize: None,
weird: None,
@@ -578,24 +545,16 @@ mod tests {
// ── Aspect ratio strings ──
#[test]
fn all_aspect_ratios_serialize_correctly() {
let cases = [
(KinoviMidjourneyAspectRatio::Square1x1, "1:1"),
(KinoviMidjourneyAspectRatio::Landscape16x9, "16:9"),
(KinoviMidjourneyAspectRatio::Portrait9x16, "9:16"),
(KinoviMidjourneyAspectRatio::UltraWide21x9, "21:9"),
(KinoviMidjourneyAspectRatio::UltraTall9x21, "9:21"),
(KinoviMidjourneyAspectRatio::Standard4x3, "4:3"),
(KinoviMidjourneyAspectRatio::Portrait3x4, "3:4"),
(KinoviMidjourneyAspectRatio::Wide5x4, "5:4"),
(KinoviMidjourneyAspectRatio::Tall4x5, "4:5"),
(KinoviMidjourneyAspectRatio::Wide3x2, "3:2"),
(KinoviMidjourneyAspectRatio::Tall2x3, "2:3"),
];
for (ar, expected) in cases {
let req = KinoviGenerateImageRequest { aspect_ratio: ar, ..minimal_request(KinoviMidjourneyModel::V7, "p") };
fn aspect_ratio_passes_through_verbatim() {
// The base API is stringly-typed for aspect_ratio — whatever the caller
// (typically a per-model wrapper) hands in must appear on the wire as-is.
for input in ["1:1", "16:9", "9:16", "21:9", "9:21", "4:3", "3:4", "5:4", "4:5", "3:2", "2:3"] {
let req = KinoviGenerateImageRequest {
aspect_ratio: input.to_string(),
..minimal_request(KinoviMidjourneyModel::V7, "p")
};
let p = api_params(req);
assert_eq!(p["aspectRatio"], expected, "aspect_ratio={:?}", ar);
assert_eq!(p["aspectRatio"], input, "aspect_ratio={:?}", input);
}
}
@@ -647,25 +606,44 @@ mod tests {
#[test]
fn quality_quarter_serializes_as_0_25() {
let req = KinoviGenerateImageRequest { quality: Some(KinoviMidjourneyQuality::Quarter), ..minimal_request(KinoviMidjourneyModel::V7, "p") };
let req = KinoviGenerateImageRequest { quality: Some("0.25".to_string()), ..minimal_request(KinoviMidjourneyModel::V7, "p") };
let q = api_params(req)["quality"].as_f64().unwrap();
assert!((q - 0.25).abs() < 1e-6, "got {}", q);
}
#[test]
fn quality_half_serializes_as_0_5() {
let req = KinoviGenerateImageRequest { quality: Some(KinoviMidjourneyQuality::Half), ..minimal_request(KinoviMidjourneyModel::V7, "p") };
let req = KinoviGenerateImageRequest { quality: Some("0.5".to_string()), ..minimal_request(KinoviMidjourneyModel::V7, "p") };
let q = api_params(req)["quality"].as_f64().unwrap();
assert!((q - 0.5).abs() < 1e-6, "got {}", q);
}
#[test]
fn quality_full_serializes_as_1_0() {
let req = KinoviGenerateImageRequest { quality: Some(KinoviMidjourneyQuality::Full), ..minimal_request(KinoviMidjourneyModel::V7, "p") };
let req = KinoviGenerateImageRequest { quality: Some("1".to_string()), ..minimal_request(KinoviMidjourneyModel::V7, "p") };
let q = api_params(req)["quality"].as_f64().unwrap();
assert!((q - 1.0).abs() < 1e-6, "got {}", q);
}
#[test]
fn quality_with_non_numeric_string_returns_client_error() {
// The base function accepts strings; malformed input surfaces as a
// structured `Seedance2ProClientError::InvalidRequestField` rather
// than panicking.
let req = KinoviGenerateImageRequest {
quality: Some("not-a-number".to_string()),
..minimal_request(KinoviMidjourneyModel::V7, "p")
};
let err = try_build_wire_json(req).expect_err("expected an error");
match err {
Seedance2ProClientError::InvalidRequestField { field, raw_value, .. } => {
assert_eq!(field, "quality");
assert_eq!(raw_value, "not-a-number");
}
other => panic!("expected InvalidRequestField, got {:?}", other),
}
}
// ── Raw mode ──
#[test]
@@ -737,12 +715,12 @@ mod tests {
let req = KinoviGenerateImageRequest {
model: KinoviMidjourneyModel::V7,
prompt: "abandoned skyscrapers".to_string(),
aspect_ratio: KinoviMidjourneyAspectRatio::Square1x1,
aspect_ratio: "1:1".to_string(),
negative_prompt: Some("dark, gloomy, night".to_string()),
stylize: Some(1000),
weird: Some(3000),
chaos: Some(100),
quality: Some(KinoviMidjourneyQuality::Half),
quality: Some("0.5".to_string()),
raw_mode: true,
batch_count: KinoviMidjourneyBatchCount::One,
reference_image_urls: None,
@@ -760,12 +738,12 @@ mod tests {
let req = KinoviGenerateImageRequest {
model: KinoviMidjourneyModel::V8,
prompt: "desolate cliff overlooking the ocean".to_string(),
aspect_ratio: KinoviMidjourneyAspectRatio::Portrait9x16,
aspect_ratio: "9:16".to_string(),
negative_prompt: None,
stylize: None,
weird: None,
chaos: None,
quality: Some(KinoviMidjourneyQuality::Full),
quality: Some("1".to_string()),
raw_mode: false,
batch_count: KinoviMidjourneyBatchCount::Four,
reference_image_urls: None,
@@ -804,7 +782,7 @@ mod tests {
request: KinoviGenerateImageRequest {
model: KinoviMidjourneyModel::V7,
prompt: "A corgi astronaut floating among stars".to_string(),
aspect_ratio: KinoviMidjourneyAspectRatio::Square1x1,
aspect_ratio: "1:1".to_string(),
negative_prompt: None,
stylize: None,
weird: None,
@@ -834,7 +812,7 @@ mod tests {
request: KinoviGenerateImageRequest {
model: KinoviMidjourneyModel::V7Niji,
prompt: "A magical shiba inu sorcerer casting spells in a crystal cave".to_string(),
aspect_ratio: KinoviMidjourneyAspectRatio::UltraWide21x9,
aspect_ratio: "21:9".to_string(),
negative_prompt: None,
stylize: None,
weird: None,
@@ -863,12 +841,12 @@ mod tests {
request: KinoviGenerateImageRequest {
model: KinoviMidjourneyModel::V8,
prompt: "A magical shiba inu sorcerer casting spells in a crystal cave".to_string(),
aspect_ratio: KinoviMidjourneyAspectRatio::Square1x1,
aspect_ratio: "1:1".to_string(),
negative_prompt: Some("dark, gloomy".to_string()),
stylize: Some(730),
weird: Some(2050),
chaos: Some(70),
quality: Some(KinoviMidjourneyQuality::Half),
quality: Some("0.5".to_string()),
raw_mode: true,
batch_count: KinoviMidjourneyBatchCount::One,
reference_image_urls: None,
@@ -892,12 +870,12 @@ mod tests {
request: KinoviGenerateImageRequest {
model: KinoviMidjourneyModel::V8,
prompt: "desolate cliff overlooking the ocean".to_string(),
aspect_ratio: KinoviMidjourneyAspectRatio::Portrait9x16,
aspect_ratio: "9:16".to_string(),
negative_prompt: None,
stylize: None,
weird: None,
chaos: None,
quality: Some(KinoviMidjourneyQuality::Full),
quality: Some("1".to_string()),
raw_mode: false,
batch_count: KinoviMidjourneyBatchCount::Four,
reference_image_urls: None,
@@ -23,7 +23,7 @@ pub (super) struct BatchRequestJson {
pub (super) struct ApiParams {
pub prompt: String,
#[serde(rename = "aspectRatio")]
pub aspect_ratio: &'static str,
pub aspect_ratio: String,
pub resolution: &'static str,
pub model: &'static str,