From 3cc8b39b08e765b7281e67014bba549f2ead0a54 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 2 Jun 2026 03:12:50 -0400 Subject: [PATCH] stronger typing --- .../src/error/seedance2pro_client_error.rs | 7 + .../generate/image/generate_midjourney_v7.rs | 129 +++++++++-- .../image/generate_midjourney_v7_niji.rs | 123 +++++++++-- .../generate/image/generate_midjourney_v8.rs | 133 +++++++++-- .../requests/generate_image/generate_image.rs | 206 ++++++++---------- .../requests/generate_image/request_types.rs | 2 +- 6 files changed, 433 insertions(+), 167 deletions(-) diff --git a/crates/api_clients/seedance2pro_client/src/error/seedance2pro_client_error.rs b/crates/api_clients/seedance2pro_client/src/error/seedance2pro_client_error.rs index 751a518df9..fab7145219 100644 --- a/crates/api_clients/seedance2pro_client/src/error/seedance2pro_client_error.rs +++ b/crates/api_clients/seedance2pro_client/src/error/seedance2pro_client_error.rs @@ -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) + } } } } diff --git a/crates/api_clients/seedance2pro_client/src/generate/image/generate_midjourney_v7.rs b/crates/api_clients/seedance2pro_client/src/generate/image/generate_midjourney_v7.rs index ff0d414aa2..6f6268fa9a 100644 --- a/crates/api_clients/seedance2pro_client/src/generate/image/generate_midjourney_v7.rs +++ b/crates/api_clients/seedance2pro_client/src/generate/image/generate_midjourney_v7.rs @@ -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, pub stylize: Option, pub weird: Option, pub chaos: Option, - pub quality: Option, + pub quality: Option, pub raw_mode: bool, pub batch_count: KinoviMidjourneyBatchCount, pub reference_image_urls: Option>, @@ -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, diff --git a/crates/api_clients/seedance2pro_client/src/generate/image/generate_midjourney_v7_niji.rs b/crates/api_clients/seedance2pro_client/src/generate/image/generate_midjourney_v7_niji.rs index 8eba458b9f..9b9a957b23 100644 --- a/crates/api_clients/seedance2pro_client/src/generate/image/generate_midjourney_v7_niji.rs +++ b/crates/api_clients/seedance2pro_client/src/generate/image/generate_midjourney_v7_niji.rs @@ -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, pub stylize: Option, pub weird: Option, pub chaos: Option, - pub quality: Option, + pub quality: Option, pub raw_mode: bool, pub batch_count: KinoviMidjourneyBatchCount, pub reference_image_urls: Option>, @@ -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, diff --git a/crates/api_clients/seedance2pro_client/src/generate/image/generate_midjourney_v8.rs b/crates/api_clients/seedance2pro_client/src/generate/image/generate_midjourney_v8.rs index 38c039b512..73be550473 100644 --- a/crates/api_clients/seedance2pro_client/src/generate/image/generate_midjourney_v8.rs +++ b/crates/api_clients/seedance2pro_client/src/generate/image/generate_midjourney_v8.rs @@ -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, pub stylize: Option, pub weird: Option, pub chaos: Option, - pub quality: Option, + pub quality: Option, pub raw_mode: bool, pub batch_count: KinoviMidjourneyBatchCount, pub reference_image_urls: Option>, @@ -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![ diff --git a/crates/api_clients/seedance2pro_client/src/requests/generate_image/generate_image.rs b/crates/api_clients/seedance2pro_client/src/requests/generate_image/generate_image.rs index bb00ae4f5d..f89cbe8810 100644 --- a/crates/api_clients/seedance2pro_client/src/requests/generate_image/generate_image.rs +++ b/crates/api_clients/seedance2pro_client/src/requests/generate_image/generate_image.rs @@ -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, @@ -66,8 +73,14 @@ pub struct KinoviGenerateImageRequest { /// Midjourney "chaos" parameter. Valid range 0–100. pub chaos: Option, - /// Quality preset. `None` omits the field. - pub quality: Option, + /// 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, /// 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) -> Result Result { + raw.parse::().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 { // 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, diff --git a/crates/api_clients/seedance2pro_client/src/requests/generate_image/request_types.rs b/crates/api_clients/seedance2pro_client/src/requests/generate_image/request_types.rs index 86bf4943d5..5c426c82b8 100644 --- a/crates/api_clients/seedance2pro_client/src/requests/generate_image/request_types.rs +++ b/crates/api_clients/seedance2pro_client/src/requests/generate_image/request_types.rs @@ -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,