router for mj

This commit is contained in:
Brandon Thomas committed 2026-06-02 04:54:40 -04:00
1 parent 17d647c6c4
commit b736562e77
31 files changed
+2048 -18

No files matched your search

@@ -38,6 +38,9 @@ use crate::generate::generate_image::providers::fal::qwen_edit_2511_angles::buil
use crate::generate::generate_image::providers::fal::seedream_4::build::build_fal_seedream_4;
use crate::generate::generate_image::providers::fal::seedream_4p5::build::build_fal_seedream_4p5;
use crate::generate::generate_image::providers::fal::seedream_5_lite::build::build_fal_seedream_5_lite;
use crate::generate::generate_image::providers::kinovi::midjourney_7::build::build_kinovi_midjourney_7;
use crate::generate::generate_image::providers::kinovi::midjourney_7_niji::build::build_kinovi_midjourney_7_niji;
use crate::generate::generate_image::providers::kinovi::midjourney_8::build::build_kinovi_midjourney_8;
#[derive(Clone, Debug)]
pub struct GenerateImageRequestBuilder {
@@ -132,6 +135,11 @@ impl GenerateImageRequestBuilder {
(RouterProvider::Fal, RouterImageModel::QwenEdit2511Angles) => build_fal_qwen_edit_2511_angles(self),
(RouterProvider::Fal, RouterImageModel::Flux2LoraAngles) => build_fal_flux_2_lora_angles(self),
// Kinovi / Seedance2Pro (Midjourney)
(RouterProvider::Seedance2Pro, RouterImageModel::Midjourney7) => build_kinovi_midjourney_7(self),
(RouterProvider::Seedance2Pro, RouterImageModel::Midjourney7Niji) => build_kinovi_midjourney_7_niji(self),
(RouterProvider::Seedance2Pro, RouterImageModel::Midjourney8) => build_kinovi_midjourney_8(self),
_ => self.unsupported_provider_and_model(),
}
}
@@ -25,10 +25,22 @@ pub struct FalImageResponsePayload {
pub maybe_outbound_request: Option<Arc<dyn Debug + Send + Sync>>,
}
/// Response from the Seedance2Pro/Kinovi provider (used for Midjourney
/// image generation). Mirrors `Seedance2proVideoResponsePayload` on the
/// video side.
#[derive(Clone, Debug)]
pub struct Seedance2proImageResponsePayload {
pub order_id: String,
pub task_id: String,
pub maybe_order_ids: Option<Vec<String>>,
pub maybe_task_ids: Option<Vec<String>>,
}
#[derive(Clone, Debug)]
pub enum GenerateImageResponse {
Artcraft(ArtcraftImageResponsePayload),
Fal(FalImageResponsePayload),
Seedance2Pro(Seedance2proImageResponsePayload),
}
impl GenerateImageResponse {
@@ -45,4 +57,11 @@ impl GenerateImageResponse {
_ => None,
}
}
pub fn get_seedance2pro_payload(&self) -> Option<Seedance2proImageResponsePayload> {
match self {
Self::Seedance2Pro(p) => Some(p.clone()),
_ => None,
}
}
}
@@ -3,26 +3,58 @@ use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
use crate::generate::generate_image::image_generation_draft_context::ImageGenerationDraftContext;
use crate::generate::generate_image::image_generation_request::ImageGenerationRequest;
use crate::generate::generate_image::providers::kinovi::midjourney_7::cost::KinoviMidjourney7CostState;
use crate::generate::generate_image::providers::kinovi::midjourney_7::draft::KinoviMidjourney7DraftState;
use crate::generate::generate_image::providers::kinovi::midjourney_7_niji::cost::KinoviMidjourney7NijiCostState;
use crate::generate::generate_image::providers::kinovi::midjourney_7_niji::draft::KinoviMidjourney7NijiDraftState;
use crate::generate::generate_image::providers::kinovi::midjourney_8::cost::KinoviMidjourney8CostState;
use crate::generate::generate_image::providers::kinovi::midjourney_8::draft::KinoviMidjourney8DraftState;
/// Wrapper for all image generation draft requests.
///
/// Drafts hold validated/planned parameters but may still need media resolution
/// (e.g. uploading local files to a provider) before they can be sent.
/// Currently empty — FAL doesn't need a draft phase.
/// Drafts hold validated/planned parameters but may still need media
/// resolution (e.g. uploading local files to a provider) before they can
/// be sent. Today only Kinovi/Midjourney needs this — when the caller
/// supplies reference images, we must upload them to the Seedance2Pro
/// CDN before sending the request.
#[derive(Clone, Debug)]
pub enum ImageGenerationDraftRequest {
KinoviMidjourney7(KinoviMidjourney7DraftState),
KinoviMidjourney7Niji(KinoviMidjourney7NijiDraftState),
KinoviMidjourney8(KinoviMidjourney8DraftState),
}
impl ImageGenerationDraftRequest {
pub fn get_provider(&self) -> RouterProvider {
match *self {}
match self {
Self::KinoviMidjourney7(_) => RouterProvider::Seedance2Pro,
Self::KinoviMidjourney7Niji(_) => RouterProvider::Seedance2Pro,
Self::KinoviMidjourney8(_) => RouterProvider::Seedance2Pro,
}
}
pub fn estimate_cost(&self) -> Result<ImageGenerationCostEstimate, ArtcraftRouterError> {
match *self {}
match self {
Self::KinoviMidjourney7(draft) => Ok(KinoviMidjourney7CostState::from_draft(draft).estimate_cost()),
Self::KinoviMidjourney7Niji(draft) => Ok(KinoviMidjourney7NijiCostState::from_draft(draft).estimate_cost()),
Self::KinoviMidjourney8(draft) => Ok(KinoviMidjourney8CostState::from_draft(draft).estimate_cost()),
}
}
pub async fn finalize(self, _draft_context: ImageGenerationDraftContext<'_>) -> Result<ImageGenerationRequest, ArtcraftRouterError> {
match self {}
pub async fn finalize(self, draft_context: ImageGenerationDraftContext<'_>) -> Result<ImageGenerationRequest, ArtcraftRouterError> {
match self {
Self::KinoviMidjourney7(mut draft) => {
let result = draft.to_request(&draft_context).await?;
Ok(ImageGenerationRequest::KinoviMidjourney7(result))
}
Self::KinoviMidjourney7Niji(mut draft) => {
let result = draft.to_request(&draft_context).await?;
Ok(ImageGenerationRequest::KinoviMidjourney7Niji(result))
}
Self::KinoviMidjourney8(mut draft) => {
let result = draft.to_request(&draft_context).await?;
Ok(ImageGenerationRequest::KinoviMidjourney8(result))
}
}
}
}
@@ -1,18 +1,46 @@
use std::collections::HashMap;
use std::fmt::Debug;
/// Context passed when finalizing a draft into a concrete request.
use tokens::tokens::media_files::MediaFileToken;
use crate::client::router_client::RouterClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
/// Context passed when finalizing an image draft into a concrete request.
///
/// Currently empty — FAL doesn't need a draft phase so no context is required.
/// Future providers that need media uploads during finalization will add fields here
/// (e.g. a router client reference, media-file-to-URL maps, etc.).
/// Drafts only exist for providers that need to upload assets before
/// sending the request. Today that's the Seedance2Pro/Kinovi pipeline
/// (used for Midjourney image generation with reference images); other
/// providers — Artcraft, Fal — accept image URLs directly and skip the
/// draft phase entirely.
#[derive(Clone, Default)]
pub struct ImageGenerationDraftContext<'a> {
/// Prevent construction without Default; reserving the lifetime for future use.
_phantom: std::marker::PhantomData<&'a ()>,
/// Optional: the router client, needed for providers that require
/// authenticated calls (e.g. Seedance2Pro/Kinovi uploads).
pub client: Option<&'a RouterClient>,
/// Optional: a map of Media File Tokens to their ArtCraft URLs.
/// Required only when the draft has unresolved `MediaFileToken` image
/// inputs that must be downloaded from ArtCraft and re-uploaded to the
/// target provider's CDN.
pub media_file_to_artcraft_url_map: Option<&'a HashMap<MediaFileToken, String>>,
}
impl<'a> ImageGenerationDraftContext<'a> {
pub fn get_seedance2pro_client_ref(&self) -> Result<&RouterSeedance2ProClient, ArtcraftRouterError> {
let client = self.client.ok_or(ArtcraftRouterError::Client(ClientError::RouterClientNotProvided))?;
client.get_seedance2pro_client_ref()
.map_err(ArtcraftRouterError::Client)
}
}
impl Debug for ImageGenerationDraftContext<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ImageGenerationDraftContext").finish()
f.debug_struct("ImageGenerationDraftContext")
.field("client", &self.client.is_some())
.field("media_file_to_artcraft_url_map", &self.media_file_to_artcraft_url_map.map(|m| m.len()))
.finish()
}
}
@@ -63,6 +63,12 @@ use crate::generate::generate_image::providers::fal::seedream_4p5::cost::FalSeed
use crate::generate::generate_image::providers::fal::seedream_4p5::request::FalSeedream4p5RequestState;
use crate::generate::generate_image::providers::fal::seedream_5_lite::cost::FalSeedream5LiteCostState;
use crate::generate::generate_image::providers::fal::seedream_5_lite::request::FalSeedream5LiteRequestState;
use crate::generate::generate_image::providers::kinovi::midjourney_7::cost::KinoviMidjourney7CostState;
use crate::generate::generate_image::providers::kinovi::midjourney_7::request::KinoviMidjourney7RequestState;
use crate::generate::generate_image::providers::kinovi::midjourney_7_niji::cost::KinoviMidjourney7NijiCostState;
use crate::generate::generate_image::providers::kinovi::midjourney_7_niji::request::KinoviMidjourney7NijiRequestState;
use crate::generate::generate_image::providers::kinovi::midjourney_8::cost::KinoviMidjourney8CostState;
use crate::generate::generate_image::providers::kinovi::midjourney_8::request::KinoviMidjourney8RequestState;
#[derive(Clone, Debug)]
pub enum ImageGenerationRequest {
@@ -99,6 +105,11 @@ pub enum ImageGenerationRequest {
FalSeedream5Lite(FalSeedream5LiteRequestState),
FalQwenEdit2511Angles(FalQwenEdit2511AnglesRequestState),
FalFlux2LoraAngles(FalFlux2LoraAnglesRequestState),
// ── Kinovi / Seedance2Pro provider (Midjourney image generation) ──
KinoviMidjourney7(KinoviMidjourney7RequestState),
KinoviMidjourney7Niji(KinoviMidjourney7NijiRequestState),
KinoviMidjourney8(KinoviMidjourney8RequestState),
}
impl ImageGenerationRequest {
@@ -135,6 +146,10 @@ impl ImageGenerationRequest {
Self::FalSeedream5Lite(_) => RouterProvider::Fal,
Self::FalQwenEdit2511Angles(_) => RouterProvider::Fal,
Self::FalFlux2LoraAngles(_) => RouterProvider::Fal,
Self::KinoviMidjourney7(_) => RouterProvider::Seedance2Pro,
Self::KinoviMidjourney7Niji(_) => RouterProvider::Seedance2Pro,
Self::KinoviMidjourney8(_) => RouterProvider::Seedance2Pro,
}
}
@@ -203,6 +218,10 @@ impl ImageGenerationRequest {
Self::FalSeedream5Lite(request) => Ok(FalSeedream5LiteCostState::from_request(request).estimate_cost()),
Self::FalQwenEdit2511Angles(request) => Ok(FalQwenEdit2511AnglesCostState::from_request(request).estimate_cost()),
Self::FalFlux2LoraAngles(request) => Ok(FalFlux2LoraAnglesCostState::from_request(request).estimate_cost()),
Self::KinoviMidjourney7(request) => Ok(KinoviMidjourney7CostState::from_request(request).estimate_cost()),
Self::KinoviMidjourney7Niji(request) => Ok(KinoviMidjourney7NijiCostState::from_request(request).estimate_cost()),
Self::KinoviMidjourney8(request) => Ok(KinoviMidjourney8CostState::from_request(request).estimate_cost()),
}
}
@@ -336,6 +355,23 @@ impl ImageGenerationRequest {
let fal_client = client.get_fal_client_ref()?;
request.send(fal_client).await
}
// ── Kinovi / Seedance2Pro (Midjourney) ──
Self::KinoviMidjourney7(request) => {
let seedance_client = client.get_seedance2pro_client_ref()
.map_err(ArtcraftRouterError::Client)?;
request.send(seedance_client).await
}
Self::KinoviMidjourney7Niji(request) => {
let seedance_client = client.get_seedance2pro_client_ref()
.map_err(ArtcraftRouterError::Client)?;
request.send(seedance_client).await
}
Self::KinoviMidjourney8(request) => {
let seedance_client = client.get_seedance2pro_client_ref()
.map_err(ArtcraftRouterError::Client)?;
request.send(seedance_client).await
}
}
}
}
@@ -0,0 +1,460 @@
use seedance2pro_client::generate::image::generate_midjourney_v7::{
GenerateMidjourneyV7AspectRatio, GenerateMidjourneyV7Quality,
GenerateMidjourneyV7Request, KinoviMidjourneyBatchCount,
};
use crate::api::image_list_ref::ImageListRef;
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_quality::RouterQuality;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder;
use crate::generate::generate_image::image_generation_draft::ImageGenerationDraftRequest;
use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest;
use crate::generate::generate_image::image_generation_request::ImageGenerationRequest;
use crate::generate::generate_image::providers::kinovi::midjourney_7::draft::{
KinoviMidjourney7DraftState, KinoviMidjourney7RemainingItems,
};
use crate::generate::generate_image::providers::kinovi::midjourney_7::request::KinoviMidjourney7RequestState;
pub fn build_kinovi_midjourney_7(
mut builder: GenerateImageRequestBuilder,
) -> Result<ImageGenerationDraftOrRequest, ArtcraftRouterError> {
let strategy = builder.request_mismatch_mitigation_strategy;
let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio.take(), strategy)?;
let quality = plan_quality(builder.quality.take());
let batch_count = plan_batch_count(builder.image_batch_count.take(), strategy)?;
let prompt = builder.prompt.take().unwrap_or_default();
let image_inputs = builder.image_inputs.take();
// No image inputs → we can build the final request immediately. No
// draft/upload phase needed.
if !has_image_inputs(image_inputs.as_ref()) {
let request = GenerateMidjourneyV7Request {
prompt,
aspect_ratio,
negative_prompt: None,
stylize: None,
weird: None,
chaos: None,
quality,
raw_mode: false,
batch_count,
reference_image_urls: None,
};
return Ok(ImageGenerationDraftOrRequest::Request(
ImageGenerationRequest::KinoviMidjourney7(KinoviMidjourney7RequestState { request }),
));
}
// Image inputs present → must go through the draft phase so we can
// upload each image to the Seedance2Pro CDN before sending.
let draft = KinoviMidjourney7DraftState {
prompt,
aspect_ratio,
quality,
batch_count,
unhandled_request_state: Some(KinoviMidjourney7RemainingItems {
reference_images: image_inputs,
}),
};
Ok(ImageGenerationDraftOrRequest::Draft(
ImageGenerationDraftRequest::KinoviMidjourney7(draft),
))
}
fn has_image_inputs(image_inputs: Option<&ImageListRef>) -> bool {
match image_inputs {
None => false,
Some(ImageListRef::Urls(urls)) => !urls.is_empty(),
Some(ImageListRef::MediaFileTokens(tokens)) => !tokens.is_empty(),
}
}
// ── Plan helpers ──
pub(crate) fn plan_aspect_ratio(
aspect_ratio: Option<RouterAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<GenerateMidjourneyV7AspectRatio, ArtcraftRouterError> {
use GenerateMidjourneyV7AspectRatio as Ar;
match aspect_ratio {
// No preference or auto → square, matching Midjourney's own default for new
// jobs in their UI.
None
| Some(RouterAspectRatio::Auto)
| Some(RouterAspectRatio::Auto2k)
| Some(RouterAspectRatio::Auto3k)
| Some(RouterAspectRatio::Auto4k) => Ok(Ar::Square1x1),
// Direct mappings — Midjourney supports every common ratio.
Some(RouterAspectRatio::Square) | Some(RouterAspectRatio::SquareHd) => Ok(Ar::Square1x1),
Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Ar::Landscape16x9),
Some(RouterAspectRatio::WideTwentyOneByNine) => Ok(Ar::UltraWide21x9),
Some(RouterAspectRatio::WideFourByThree) => Ok(Ar::Standard4x3),
Some(RouterAspectRatio::WideFiveByFour) => Ok(Ar::Wide5x4),
Some(RouterAspectRatio::WideThreeByTwo) => Ok(Ar::Wide3x2),
Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Ar::Portrait9x16),
Some(RouterAspectRatio::TallNineByTwentyOne) => Ok(Ar::UltraTall9x21),
Some(RouterAspectRatio::TallThreeByFour) => Ok(Ar::Portrait3x4),
Some(RouterAspectRatio::TallFourByFive) => Ok(Ar::Tall4x5),
Some(RouterAspectRatio::TallTwoByThree) => Ok(Ar::Tall2x3),
// Every aspect ratio listed in `RouterAspectRatio` has a direct mapping;
// the catch-all is reserved for future variants.
#[allow(unreachable_patterns)]
Some(unsupported) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "aspect_ratio",
value: format!("{:?}", unsupported),
}))
}
_ => Ok(Ar::Square1x1),
},
}
}
pub(crate) fn plan_quality(quality: Option<RouterQuality>) -> Option<GenerateMidjourneyV7Quality> {
// Midjourney's quality knob is a compute-budget dial that doesn't change
// Kinovi pricing — pass through whatever the caller asked for, and let
// `None` ride Midjourney's server-side default.
match quality {
None => None,
Some(RouterQuality::Low) => Some(GenerateMidjourneyV7Quality::Quarter),
Some(RouterQuality::Medium) => Some(GenerateMidjourneyV7Quality::Half),
Some(RouterQuality::High) => Some(GenerateMidjourneyV7Quality::Full),
}
}
pub(crate) fn plan_batch_count(
image_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<KinoviMidjourneyBatchCount, ArtcraftRouterError> {
let count = image_batch_count.unwrap_or(1);
match count {
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 => Ok(KinoviMidjourneyBatchCount::One),
2 => Ok(KinoviMidjourneyBatchCount::Two),
4 => Ok(KinoviMidjourneyBatchCount::Four),
other => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "image_batch_count",
value: format!("{}", other),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => {
// 3 → 4, anything > 4 → 4
Ok(if other < 3 { KinoviMidjourneyBatchCount::Two } else { KinoviMidjourneyBatchCount::Four })
}
RequestMismatchMitigationStrategy::PayLessDowngrade => {
// 3 → 2, anything > 4 → 4
Ok(if other < 4 { KinoviMidjourneyBatchCount::Two } else { KinoviMidjourneyBatchCount::Four })
}
},
}
}
#[cfg(test)]
mod tests {
use seedance2pro_client::generate::image::generate_midjourney_v7::KinoviMidjourneyBatchCount;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::image_list_ref::ImageListRef;
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_image_model::RouterImageModel;
use crate::api::router_provider::RouterProvider;
use crate::api::router_quality::RouterQuality;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder;
use crate::generate::generate_image::image_generation_draft::ImageGenerationDraftRequest;
use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest;
use crate::generate::generate_image::image_generation_request::ImageGenerationRequest;
use super::*;
fn base_builder() -> GenerateImageRequestBuilder {
GenerateImageRequestBuilder {
model: RouterImageModel::Midjourney7,
provider: RouterProvider::Seedance2Pro,
prompt: Some("a corgi astronaut".to_string()),
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: None,
horizontal_angle: None,
vertical_angle: None,
zoom: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
}
}
// ── Mode dispatch: no image inputs → Request; with → Draft ──
mod mode_dispatch {
use super::*;
#[test]
fn no_image_inputs_returns_request_directly() {
let result = build_kinovi_midjourney_7(base_builder()).expect("build");
assert!(matches!(
result,
ImageGenerationDraftOrRequest::Request(ImageGenerationRequest::KinoviMidjourney7(_)),
));
}
#[test]
fn empty_image_inputs_returns_request_directly() {
let builder = GenerateImageRequestBuilder {
image_inputs: Some(ImageListRef::Urls(vec![])),
..base_builder()
};
let result = build_kinovi_midjourney_7(builder).expect("build");
assert!(matches!(
result,
ImageGenerationDraftOrRequest::Request(ImageGenerationRequest::KinoviMidjourney7(_)),
));
}
#[test]
fn image_url_inputs_returns_draft() {
let builder = GenerateImageRequestBuilder {
image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/ref.png".to_string()])),
..base_builder()
};
let result = build_kinovi_midjourney_7(builder).expect("build");
assert!(matches!(
result,
ImageGenerationDraftOrRequest::Draft(ImageGenerationDraftRequest::KinoviMidjourney7(_)),
));
}
#[test]
fn image_token_inputs_returns_draft() {
let builder = GenerateImageRequestBuilder {
image_inputs: Some(ImageListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_test".to_string()),
])),
..base_builder()
};
let result = build_kinovi_midjourney_7(builder).expect("build");
assert!(matches!(
result,
ImageGenerationDraftOrRequest::Draft(ImageGenerationDraftRequest::KinoviMidjourney7(_)),
));
}
#[test]
fn draft_remembers_image_inputs() {
let builder = GenerateImageRequestBuilder {
image_inputs: Some(ImageListRef::Urls(vec![
"https://example.com/a.png".to_string(),
"https://example.com/b.png".to_string(),
])),
..base_builder()
};
let draft = unwrap_draft(build_kinovi_midjourney_7(builder));
let remaining = draft.unhandled_request_state.as_ref().unwrap();
let urls = match remaining.reference_images.as_ref().unwrap() {
ImageListRef::Urls(urls) => urls,
_ => panic!("expected Urls"),
};
assert_eq!(urls.len(), 2);
}
}
// ── Field passthrough ──
mod field_passthrough {
use super::*;
#[test]
fn prompt_is_passed_through() {
let req = unwrap_direct_request(build_kinovi_midjourney_7(base_builder()));
assert_eq!(req.request.prompt, "a corgi astronaut");
}
#[test]
fn prompt_defaults_to_empty() {
let builder = GenerateImageRequestBuilder { prompt: None, ..base_builder() };
let req = unwrap_direct_request(build_kinovi_midjourney_7(builder));
assert_eq!(req.request.prompt, "");
}
}
// ── Aspect ratio ──
mod aspect_ratio_tests {
use super::*;
use seedance2pro_client::generate::image::generate_midjourney_v7::GenerateMidjourneyV7AspectRatio as Ar;
fn aspect_ratio_for(input: Option<RouterAspectRatio>) -> Ar {
let builder = GenerateImageRequestBuilder { aspect_ratio: input, ..base_builder() };
unwrap_direct_request(build_kinovi_midjourney_7(builder)).request.aspect_ratio
}
#[test]
fn none_defaults_to_square() {
assert_eq!(aspect_ratio_for(None), Ar::Square1x1);
}
#[test]
fn auto_defaults_to_square() {
assert_eq!(aspect_ratio_for(Some(RouterAspectRatio::Auto)), Ar::Square1x1);
assert_eq!(aspect_ratio_for(Some(RouterAspectRatio::Auto2k)), Ar::Square1x1);
assert_eq!(aspect_ratio_for(Some(RouterAspectRatio::Auto3k)), Ar::Square1x1);
assert_eq!(aspect_ratio_for(Some(RouterAspectRatio::Auto4k)), Ar::Square1x1);
}
#[test]
fn every_ratio_maps_directly() {
assert_eq!(aspect_ratio_for(Some(RouterAspectRatio::Square)), Ar::Square1x1);
assert_eq!(aspect_ratio_for(Some(RouterAspectRatio::SquareHd)), Ar::Square1x1);
assert_eq!(aspect_ratio_for(Some(RouterAspectRatio::WideSixteenByNine)), Ar::Landscape16x9);
assert_eq!(aspect_ratio_for(Some(RouterAspectRatio::WideTwentyOneByNine)), Ar::UltraWide21x9);
assert_eq!(aspect_ratio_for(Some(RouterAspectRatio::WideFourByThree)), Ar::Standard4x3);
assert_eq!(aspect_ratio_for(Some(RouterAspectRatio::WideFiveByFour)), Ar::Wide5x4);
assert_eq!(aspect_ratio_for(Some(RouterAspectRatio::WideThreeByTwo)), Ar::Wide3x2);
assert_eq!(aspect_ratio_for(Some(RouterAspectRatio::Wide)), Ar::Landscape16x9);
assert_eq!(aspect_ratio_for(Some(RouterAspectRatio::TallNineBySixteen)), Ar::Portrait9x16);
assert_eq!(aspect_ratio_for(Some(RouterAspectRatio::TallNineByTwentyOne)), Ar::UltraTall9x21);
assert_eq!(aspect_ratio_for(Some(RouterAspectRatio::TallThreeByFour)), Ar::Portrait3x4);
assert_eq!(aspect_ratio_for(Some(RouterAspectRatio::TallFourByFive)), Ar::Tall4x5);
assert_eq!(aspect_ratio_for(Some(RouterAspectRatio::TallTwoByThree)), Ar::Tall2x3);
assert_eq!(aspect_ratio_for(Some(RouterAspectRatio::Tall)), Ar::Portrait9x16);
}
}
// ── Quality ──
mod quality_tests {
use super::*;
use seedance2pro_client::generate::image::generate_midjourney_v7::GenerateMidjourneyV7Quality as Q;
fn quality_for(input: Option<RouterQuality>) -> Option<Q> {
let builder = GenerateImageRequestBuilder { quality: input, ..base_builder() };
unwrap_direct_request(build_kinovi_midjourney_7(builder)).request.quality
}
#[test]
fn none_passes_through_as_none() {
assert_eq!(quality_for(None), None);
}
#[test]
fn low_maps_to_quarter() {
assert_eq!(quality_for(Some(RouterQuality::Low)), Some(Q::Quarter));
}
#[test]
fn medium_maps_to_half() {
assert_eq!(quality_for(Some(RouterQuality::Medium)), Some(Q::Half));
}
#[test]
fn high_maps_to_full() {
assert_eq!(quality_for(Some(RouterQuality::High)), Some(Q::Full));
}
}
// ── Batch count ──
mod batch_count_tests {
use super::*;
fn batch_for(count: Option<u16>) -> KinoviMidjourneyBatchCount {
let builder = GenerateImageRequestBuilder { image_batch_count: count, ..base_builder() };
unwrap_direct_request(build_kinovi_midjourney_7(builder)).request.batch_count
}
#[test]
fn none_defaults_to_one() {
assert_eq!(batch_for(None), KinoviMidjourneyBatchCount::One);
}
#[test]
fn one_two_four_map_directly() {
assert_eq!(batch_for(Some(1)), KinoviMidjourneyBatchCount::One);
assert_eq!(batch_for(Some(2)), KinoviMidjourneyBatchCount::Two);
assert_eq!(batch_for(Some(4)), KinoviMidjourneyBatchCount::Four);
}
#[test]
fn zero_is_rejected() {
let builder = GenerateImageRequestBuilder { image_batch_count: Some(0), ..base_builder() };
assert!(matches!(
build_kinovi_midjourney_7(builder),
Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
));
}
#[test]
fn three_errors_under_strict() {
let builder = GenerateImageRequestBuilder {
image_batch_count: Some(3),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..base_builder()
};
assert!(matches!(
build_kinovi_midjourney_7(builder),
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { .. })),
));
}
#[test]
fn three_upgrades_to_four_with_pay_more() {
let builder = GenerateImageRequestBuilder {
image_batch_count: Some(3),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
..base_builder()
};
assert_eq!(
unwrap_direct_request(build_kinovi_midjourney_7(builder)).request.batch_count,
KinoviMidjourneyBatchCount::Four,
);
}
#[test]
fn three_downgrades_to_two_with_pay_less() {
let builder = GenerateImageRequestBuilder {
image_batch_count: Some(3),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayLessDowngrade,
..base_builder()
};
assert_eq!(
unwrap_direct_request(build_kinovi_midjourney_7(builder)).request.batch_count,
KinoviMidjourneyBatchCount::Two,
);
}
}
// ── Helpers ──
fn unwrap_direct_request(
result: Result<ImageGenerationDraftOrRequest, ArtcraftRouterError>,
) -> KinoviMidjourney7RequestState {
match result.expect("build should succeed") {
ImageGenerationDraftOrRequest::Request(
ImageGenerationRequest::KinoviMidjourney7(req),
) => req,
other => panic!("expected KinoviMidjourney7 Request, got {:?}", other),
}
}
fn unwrap_draft(
result: Result<ImageGenerationDraftOrRequest, ArtcraftRouterError>,
) -> KinoviMidjourney7DraftState {
match result.expect("build should succeed") {
ImageGenerationDraftOrRequest::Draft(
ImageGenerationDraftRequest::KinoviMidjourney7(draft),
) => draft,
other => panic!("expected KinoviMidjourney7 Draft, got {:?}", other),
}
}
}
@@ -0,0 +1,211 @@
use seedance2pro_client::generate::image::generate_midjourney_v7::{
GenerateMidjourneyV7AspectRatio, GenerateMidjourneyV7Request, KinoviMidjourneyBatchCount,
};
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
use crate::generate::generate_image::providers::kinovi::midjourney_7::draft::KinoviMidjourney7DraftState;
use crate::generate::generate_image::providers::kinovi::midjourney_7::request::KinoviMidjourney7RequestState;
/// Midjourney v7 (via Kinovi) cost state. Only `batch_count` matters for
/// pricing — Midjourney pricing is flat per task regardless of aspect
/// ratio, quality, or whether reference images were supplied.
pub struct KinoviMidjourney7CostState {
pub batch_count: KinoviMidjourneyBatchCount,
}
impl KinoviMidjourney7CostState {
pub fn from_request(request: &KinoviMidjourney7RequestState) -> Self {
Self { batch_count: request.request.batch_count }
}
pub fn from_draft(draft: &KinoviMidjourney7DraftState) -> Self {
Self { batch_count: draft.batch_count }
}
pub fn estimate_cost(&self) -> ImageGenerationCostEstimate {
let pricing_request = GenerateMidjourneyV7Request {
batch_count: self.batch_count,
// Cost-irrelevant placeholders below — only `batch_count` matters.
prompt: String::new(),
aspect_ratio: GenerateMidjourneyV7AspectRatio::Square1x1,
negative_prompt: None,
stylize: None,
weird: None,
chaos: None,
quality: None,
raw_mode: false,
reference_image_urls: None,
};
let cost_in_credits = pricing_request.estimate_credits() as u64;
let cost_in_usd_cents = pricing_request.estimate_cost_in_usd_cents();
ImageGenerationCostEstimate {
cost_in_credits: Some(cost_in_credits),
cost_in_usd_cents: Some(cost_in_usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::image_list_ref::ImageListRef;
use crate::api::router_image_model::RouterImageModel;
use crate::api::router_provider::RouterProvider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder;
use crate::generate::generate_image::image_generation_draft::ImageGenerationDraftRequest;
use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest;
use crate::generate::generate_image::image_generation_request::ImageGenerationRequest;
// ── Credit pricing (12 per task × batch) ──
mod credits {
use super::*;
#[test]
fn batch_one_is_twelve_credits() {
assert_eq!(credits_for(KinoviMidjourneyBatchCount::One), 12);
}
#[test]
fn batch_two_is_twentyfour_credits() {
assert_eq!(credits_for(KinoviMidjourneyBatchCount::Two), 24);
}
#[test]
fn batch_four_is_fortyeight_credits() {
assert_eq!(credits_for(KinoviMidjourneyBatchCount::Four), 48);
}
}
// ── USD pricing (193 credits per dollar; rounded) ──
mod usd {
use super::*;
#[test]
fn batch_one_is_six_cents() {
// 12 / 193 × 100 = 6.21 → 6¢
assert_eq!(usd_cents_for(KinoviMidjourneyBatchCount::One), 6);
}
#[test]
fn batch_two_is_twelve_cents() {
// 24 / 193 × 100 = 12.44 → 12¢
assert_eq!(usd_cents_for(KinoviMidjourneyBatchCount::Two), 12);
}
#[test]
fn batch_four_is_twentyfive_cents() {
// 48 / 193 × 100 = 24.87 → 25¢
assert_eq!(usd_cents_for(KinoviMidjourneyBatchCount::Four), 25);
}
}
// ── Flags ──
#[test]
fn flags_are_correct() {
let cost = KinoviMidjourney7CostState { batch_count: KinoviMidjourneyBatchCount::One }
.estimate_cost();
assert!(!cost.is_free);
assert!(!cost.is_unlimited);
assert!(!cost.is_rate_limited);
assert!(!cost.has_watermark);
assert_eq!(cost.failures_are_refunded, None);
}
// ── from_request / from_draft ──
mod source_state_round_trip {
use super::*;
#[test]
fn from_request_picks_up_batch_count() {
let req = build_request_no_image_inputs(2);
let state = KinoviMidjourney7CostState::from_request(&req);
assert_eq!(state.batch_count, KinoviMidjourneyBatchCount::Two);
assert_eq!(state.estimate_cost().cost_in_credits, Some(24));
}
#[test]
fn from_draft_picks_up_batch_count() {
let draft = build_draft_with_image_inputs(4);
let state = KinoviMidjourney7CostState::from_draft(&draft);
assert_eq!(state.batch_count, KinoviMidjourneyBatchCount::Four);
assert_eq!(state.estimate_cost().cost_in_credits, Some(48));
}
#[test]
fn draft_and_request_produce_same_cost() {
// Same batch count → same cost, regardless of draft vs request path.
let req_cost = KinoviMidjourney7CostState::from_request(&build_request_no_image_inputs(2))
.estimate_cost();
let draft_cost = KinoviMidjourney7CostState::from_draft(&build_draft_with_image_inputs(2))
.estimate_cost();
assert_eq!(req_cost.cost_in_credits, draft_cost.cost_in_credits);
assert_eq!(req_cost.cost_in_usd_cents, draft_cost.cost_in_usd_cents);
}
}
// ── Helpers ──
fn credits_for(batch: KinoviMidjourneyBatchCount) -> u64 {
KinoviMidjourney7CostState { batch_count: batch }
.estimate_cost().cost_in_credits.unwrap()
}
fn usd_cents_for(batch: KinoviMidjourneyBatchCount) -> u64 {
KinoviMidjourney7CostState { batch_count: batch }
.estimate_cost().cost_in_usd_cents.unwrap()
}
fn base_builder() -> GenerateImageRequestBuilder {
GenerateImageRequestBuilder {
model: RouterImageModel::Midjourney7,
provider: RouterProvider::Seedance2Pro,
prompt: Some("test".to_string()),
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: None,
horizontal_angle: None,
vertical_angle: None,
zoom: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
}
}
fn build_request_no_image_inputs(batch_count: u16) -> KinoviMidjourney7RequestState {
let builder = GenerateImageRequestBuilder {
image_batch_count: Some(batch_count),
..base_builder()
};
match builder.build2().expect("build2") {
ImageGenerationDraftOrRequest::Request(ImageGenerationRequest::KinoviMidjourney7(r)) => r,
_ => panic!("expected Request"),
}
}
fn build_draft_with_image_inputs(batch_count: u16) -> KinoviMidjourney7DraftState {
let builder = GenerateImageRequestBuilder {
image_batch_count: Some(batch_count),
image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/ref.png".to_string()])),
..base_builder()
};
match builder.build2().expect("build2") {
ImageGenerationDraftOrRequest::Draft(ImageGenerationDraftRequest::KinoviMidjourney7(d)) => d,
_ => panic!("expected Draft"),
}
}
}
@@ -0,0 +1,94 @@
use seedance2pro_client::generate::image::generate_midjourney_v7::{
GenerateMidjourneyV7AspectRatio, GenerateMidjourneyV7Quality,
GenerateMidjourneyV7Request, KinoviMidjourneyBatchCount,
};
use crate::api::image_list_ref::ImageListRef;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_image::image_generation_draft_context::ImageGenerationDraftContext;
use crate::generate::generate_image::providers::kinovi::midjourney_7::request::KinoviMidjourney7RequestState;
use crate::generate::generate_image::providers::kinovi::resolve::resolve_and_upload_image_list;
#[derive(Debug, Clone)]
pub struct KinoviMidjourney7DraftState {
// Materialized fields — all enums already resolved.
pub prompt: String,
pub aspect_ratio: GenerateMidjourneyV7AspectRatio,
pub quality: Option<GenerateMidjourneyV7Quality>,
pub batch_count: KinoviMidjourneyBatchCount,
/// Image references that still need to be uploaded to Seedance2Pro CDN
/// before the request is sendable. `None` once `to_request()` has run.
pub unhandled_request_state: Option<KinoviMidjourney7RemainingItems>,
}
#[derive(Debug, Clone)]
pub struct KinoviMidjourney7RemainingItems {
pub reference_images: Option<ImageListRef>,
}
impl KinoviMidjourney7DraftState {
pub async fn to_request(
&mut self,
draft_context: &ImageGenerationDraftContext<'_>,
) -> Result<KinoviMidjourney7RequestState, ArtcraftRouterError> {
let client = draft_context.get_seedance2pro_client_ref()?;
let session = &client.session;
let mut reference_image_urls = None;
if let Some(remaining) = self.unhandled_request_state.take() {
let map = draft_context.media_file_to_artcraft_url_map;
reference_image_urls = resolve_and_upload_image_list(
session, remaining.reference_images, map,
).await?;
}
let request = GenerateMidjourneyV7Request {
prompt: self.prompt.clone(),
aspect_ratio: self.aspect_ratio,
negative_prompt: None,
stylize: None,
weird: None,
chaos: None,
quality: self.quality,
raw_mode: false,
batch_count: self.batch_count,
reference_image_urls,
};
Ok(KinoviMidjourney7RequestState { request })
}
}
#[cfg(test)]
mod tests {
use super::*;
use seedance2pro_client::generate::image::generate_midjourney_v7::GenerateMidjourneyV7AspectRatio;
// Note: most behavioral tests live in `build.rs` and `cost.rs`. The
// `to_request()` method requires a live Seedance2Pro client to upload
// images, so its end-to-end behavior is exercised by the `request.rs`
// #[ignore]'d integration tests rather than unit-tested here.
#[test]
fn draft_with_no_image_inputs_finalises_to_empty_reference_image_urls() {
// Even when the draft *has* an unhandled state, an empty `ImageListRef`
// shouldn't trigger an upload. This is technically not the path the
// builder takes (it short-circuits to Request), but we exercise it
// here for robustness.
let mut draft = KinoviMidjourney7DraftState {
prompt: "test".to_string(),
aspect_ratio: GenerateMidjourneyV7AspectRatio::Square1x1,
quality: None,
batch_count: KinoviMidjourneyBatchCount::One,
unhandled_request_state: Some(KinoviMidjourney7RemainingItems {
reference_images: Some(ImageListRef::Urls(vec![])),
}),
};
// We can't actually call `to_request()` without a client, but the
// resolve path returns `None` for empty inputs. Smoke-test the resolve
// helper directly via the test_resolve helper.
let inputs = draft.unhandled_request_state.take().unwrap().reference_images;
assert!(matches!(inputs, Some(ImageListRef::Urls(ref v)) if v.is_empty()));
}
}
@@ -0,0 +1,4 @@
pub mod build;
pub mod cost;
pub mod draft;
pub mod request;
@@ -0,0 +1,142 @@
use seedance2pro_client::generate::image::generate_midjourney_v7::{
generate_midjourney_v7, GenerateMidjourneyV7Args, GenerateMidjourneyV7Request,
};
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_image::generate_image_response::{
GenerateImageResponse, Seedance2proImageResponsePayload,
};
#[derive(Debug, Clone)]
pub struct KinoviMidjourney7RequestState {
/// Final materialized request; ready to fire against Kinovi.
pub request: GenerateMidjourneyV7Request,
}
impl KinoviMidjourney7RequestState {
pub async fn send(
&self,
client: &RouterSeedance2ProClient,
) -> Result<GenerateImageResponse, ArtcraftRouterError> {
let args = GenerateMidjourneyV7Args {
session: &client.session,
host_override: None,
request: self.request.clone(),
};
let response = generate_midjourney_v7(args)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?;
Ok(GenerateImageResponse::Seedance2Pro(Seedance2proImageResponsePayload {
order_id: response.order_id,
task_id: response.task_id,
maybe_order_ids: response.order_ids,
maybe_task_ids: response.task_ids,
}))
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::image_list_ref::ImageListRef;
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_image_model::RouterImageModel;
use crate::api::router_provider::RouterProvider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::client::router_client::RouterClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder;
use crate::generate::generate_image::generate_image_response::GenerateImageResponse;
use crate::generate::generate_image::image_generation_draft::ImageGenerationDraftRequest;
use crate::generate::generate_image::image_generation_draft_context::ImageGenerationDraftContext;
use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest;
use crate::generate::generate_image::image_generation_request::ImageGenerationRequest;
use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL;
fn base_builder() -> GenerateImageRequestBuilder {
GenerateImageRequestBuilder {
model: RouterImageModel::Midjourney7,
provider: RouterProvider::Seedance2Pro,
prompt: Some("a serene mountain lake at sunrise, cinematic".to_string()),
image_inputs: None,
resolution: None,
aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine),
quality: None,
image_batch_count: Some(1),
horizontal_angle: None,
vertical_angle: None,
zoom: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
}
}
fn get_kinovi_client() -> RouterClient {
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/seedance2pro_cookies.txt")
.expect("Failed to read seedance2pro cookies");
let session = Seedance2ProSession::from_cookies_string(cookies.trim().to_string());
RouterClient::Seedance2Pro(RouterSeedance2ProClient::new(session))
}
#[tokio::test]
#[ignore] // manually test — requires real cookies; costs credits
async fn text_to_image_round_trip() {
let client = get_kinovi_client();
let result = base_builder().build2().expect("build2");
let request = match result {
ImageGenerationDraftOrRequest::Request(ImageGenerationRequest::KinoviMidjourney7(r)) => r,
_ => panic!("expected direct Request for text-to-image"),
};
let response = request.send(client.get_seedance2pro_client_ref().unwrap())
.await.expect("send should succeed");
let payload = response.get_seedance2pro_payload().expect("expected seedance2pro payload");
println!("v7 t2i — task_id={}, order_id={}", payload.task_id, payload.order_id);
assert!(!payload.task_id.is_empty());
}
#[tokio::test]
#[ignore] // manually test — requires real cookies; costs credits
async fn image_reference_round_trip_via_draft() {
let client = get_kinovi_client();
let token = MediaFileToken::new("mf_test_juno".to_string());
let mut map = HashMap::new();
map.insert(token.clone(), JUNO_AT_LAKE_IMAGE_URL.to_string());
let builder = GenerateImageRequestBuilder {
prompt: Some("a corgi in the style of @1".to_string()),
image_inputs: Some(ImageListRef::MediaFileTokens(vec![token])),
..base_builder()
};
let draft_or_request = builder.build2().expect("build2");
let draft = match draft_or_request {
ImageGenerationDraftOrRequest::Draft(ImageGenerationDraftRequest::KinoviMidjourney7(d)) => d,
_ => panic!("expected Draft for image-input flow"),
};
let ctx = ImageGenerationDraftContext {
client: Some(&client),
media_file_to_artcraft_url_map: Some(&map),
};
let request = ImageGenerationDraftRequest::KinoviMidjourney7(draft)
.finalize(ctx).await.expect("finalize");
let response = request.send_request(&client).await.expect("send_request");
let payload = response.get_seedance2pro_payload().expect("expected seedance2pro payload");
println!("v7 with ref — task_id={}, order_id={}", payload.task_id, payload.order_id);
assert!(!payload.task_id.is_empty());
}
}
@@ -0,0 +1,267 @@
use seedance2pro_client::generate::image::generate_midjourney_v7_niji::{
GenerateMidjourneyV7NijiAspectRatio, GenerateMidjourneyV7NijiQuality,
GenerateMidjourneyV7NijiRequest, KinoviMidjourneyBatchCount,
};
use crate::api::image_list_ref::ImageListRef;
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_quality::RouterQuality;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder;
use crate::generate::generate_image::image_generation_draft::ImageGenerationDraftRequest;
use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest;
use crate::generate::generate_image::image_generation_request::ImageGenerationRequest;
use crate::generate::generate_image::providers::kinovi::midjourney_7_niji::draft::{
KinoviMidjourney7NijiDraftState, KinoviMidjourney7NijiRemainingItems,
};
use crate::generate::generate_image::providers::kinovi::midjourney_7_niji::request::KinoviMidjourney7NijiRequestState;
pub fn build_kinovi_midjourney_7_niji(
mut builder: GenerateImageRequestBuilder,
) -> Result<ImageGenerationDraftOrRequest, ArtcraftRouterError> {
let strategy = builder.request_mismatch_mitigation_strategy;
let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio.take(), strategy)?;
let quality = plan_quality(builder.quality.take());
let batch_count = plan_batch_count(builder.image_batch_count.take(), strategy)?;
let prompt = builder.prompt.take().unwrap_or_default();
let image_inputs = builder.image_inputs.take();
if !has_image_inputs(image_inputs.as_ref()) {
let request = GenerateMidjourneyV7NijiRequest {
prompt,
aspect_ratio,
negative_prompt: None,
stylize: None,
weird: None,
chaos: None,
quality,
raw_mode: false,
batch_count,
reference_image_urls: None,
};
return Ok(ImageGenerationDraftOrRequest::Request(
ImageGenerationRequest::KinoviMidjourney7Niji(KinoviMidjourney7NijiRequestState { request }),
));
}
let draft = KinoviMidjourney7NijiDraftState {
prompt,
aspect_ratio,
quality,
batch_count,
unhandled_request_state: Some(KinoviMidjourney7NijiRemainingItems {
reference_images: image_inputs,
}),
};
Ok(ImageGenerationDraftOrRequest::Draft(
ImageGenerationDraftRequest::KinoviMidjourney7Niji(draft),
))
}
fn has_image_inputs(image_inputs: Option<&ImageListRef>) -> bool {
match image_inputs {
None => false,
Some(ImageListRef::Urls(urls)) => !urls.is_empty(),
Some(ImageListRef::MediaFileTokens(tokens)) => !tokens.is_empty(),
}
}
// ── Plan helpers ──
pub(crate) fn plan_aspect_ratio(
aspect_ratio: Option<RouterAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<GenerateMidjourneyV7NijiAspectRatio, ArtcraftRouterError> {
use GenerateMidjourneyV7NijiAspectRatio as Ar;
match aspect_ratio {
None
| Some(RouterAspectRatio::Auto)
| Some(RouterAspectRatio::Auto2k)
| Some(RouterAspectRatio::Auto3k)
| Some(RouterAspectRatio::Auto4k) => Ok(Ar::Square1x1),
Some(RouterAspectRatio::Square) | Some(RouterAspectRatio::SquareHd) => Ok(Ar::Square1x1),
Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Ar::Landscape16x9),
Some(RouterAspectRatio::WideTwentyOneByNine) => Ok(Ar::UltraWide21x9),
Some(RouterAspectRatio::WideFourByThree) => Ok(Ar::Standard4x3),
Some(RouterAspectRatio::WideFiveByFour) => Ok(Ar::Wide5x4),
Some(RouterAspectRatio::WideThreeByTwo) => Ok(Ar::Wide3x2),
Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Ar::Portrait9x16),
Some(RouterAspectRatio::TallNineByTwentyOne) => Ok(Ar::UltraTall9x21),
Some(RouterAspectRatio::TallThreeByFour) => Ok(Ar::Portrait3x4),
Some(RouterAspectRatio::TallFourByFive) => Ok(Ar::Tall4x5),
Some(RouterAspectRatio::TallTwoByThree) => Ok(Ar::Tall2x3),
#[allow(unreachable_patterns)]
Some(unsupported) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "aspect_ratio",
value: format!("{:?}", unsupported),
}))
}
_ => Ok(Ar::Square1x1),
},
}
}
pub(crate) fn plan_quality(quality: Option<RouterQuality>) -> Option<GenerateMidjourneyV7NijiQuality> {
match quality {
None => None,
Some(RouterQuality::Low) => Some(GenerateMidjourneyV7NijiQuality::Quarter),
Some(RouterQuality::Medium) => Some(GenerateMidjourneyV7NijiQuality::Half),
Some(RouterQuality::High) => Some(GenerateMidjourneyV7NijiQuality::Full),
}
}
pub(crate) fn plan_batch_count(
image_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<KinoviMidjourneyBatchCount, ArtcraftRouterError> {
let count = image_batch_count.unwrap_or(1);
match count {
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 => Ok(KinoviMidjourneyBatchCount::One),
2 => Ok(KinoviMidjourneyBatchCount::Two),
4 => Ok(KinoviMidjourneyBatchCount::Four),
other => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "image_batch_count",
value: format!("{}", other),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => {
Ok(if other < 3 { KinoviMidjourneyBatchCount::Two } else { KinoviMidjourneyBatchCount::Four })
}
RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(if other < 4 { KinoviMidjourneyBatchCount::Two } else { KinoviMidjourneyBatchCount::Four })
}
},
}
}
#[cfg(test)]
mod tests {
use seedance2pro_client::generate::image::generate_midjourney_v7_niji::KinoviMidjourneyBatchCount;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::image_list_ref::ImageListRef;
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_image_model::RouterImageModel;
use crate::api::router_provider::RouterProvider;
use crate::api::router_quality::RouterQuality;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder;
use crate::generate::generate_image::image_generation_draft::ImageGenerationDraftRequest;
use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest;
use crate::generate::generate_image::image_generation_request::ImageGenerationRequest;
use super::*;
fn base_builder() -> GenerateImageRequestBuilder {
GenerateImageRequestBuilder {
model: RouterImageModel::Midjourney7Niji,
provider: RouterProvider::Seedance2Pro,
prompt: Some("a magical anime forest scene".to_string()),
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: None,
horizontal_angle: None,
vertical_angle: None,
zoom: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
}
}
#[test]
fn no_image_inputs_returns_request_directly() {
let result = build_kinovi_midjourney_7_niji(base_builder()).expect("build");
assert!(matches!(
result,
ImageGenerationDraftOrRequest::Request(ImageGenerationRequest::KinoviMidjourney7Niji(_)),
));
}
#[test]
fn image_inputs_returns_draft() {
let builder = GenerateImageRequestBuilder {
image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/ref.png".to_string()])),
..base_builder()
};
let result = build_kinovi_midjourney_7_niji(builder).expect("build");
assert!(matches!(
result,
ImageGenerationDraftOrRequest::Draft(ImageGenerationDraftRequest::KinoviMidjourney7Niji(_)),
));
}
#[test]
fn batch_count_zero_errors() {
let builder = GenerateImageRequestBuilder { image_batch_count: Some(0), ..base_builder() };
assert!(matches!(
build_kinovi_midjourney_7_niji(builder),
Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
));
}
#[test]
fn batch_count_four_is_accepted() {
let builder = GenerateImageRequestBuilder { image_batch_count: Some(4), ..base_builder() };
let req = match build_kinovi_midjourney_7_niji(builder).expect("build") {
ImageGenerationDraftOrRequest::Request(ImageGenerationRequest::KinoviMidjourney7Niji(r)) => r,
_ => panic!("expected Request"),
};
assert_eq!(req.request.batch_count, KinoviMidjourneyBatchCount::Four);
}
#[test]
fn aspect_ratio_passes_through_to_niji_enum() {
use seedance2pro_client::generate::image::generate_midjourney_v7_niji::GenerateMidjourneyV7NijiAspectRatio as Ar;
let builder = GenerateImageRequestBuilder {
aspect_ratio: Some(RouterAspectRatio::WideTwentyOneByNine),
..base_builder()
};
let req = match build_kinovi_midjourney_7_niji(builder).expect("build") {
ImageGenerationDraftOrRequest::Request(ImageGenerationRequest::KinoviMidjourney7Niji(r)) => r,
_ => panic!("expected Request"),
};
assert_eq!(req.request.aspect_ratio, Ar::UltraWide21x9);
}
#[test]
fn quality_high_maps_to_full() {
use seedance2pro_client::generate::image::generate_midjourney_v7_niji::GenerateMidjourneyV7NijiQuality as Q;
let builder = GenerateImageRequestBuilder {
quality: Some(RouterQuality::High),
..base_builder()
};
let req = match build_kinovi_midjourney_7_niji(builder).expect("build") {
ImageGenerationDraftOrRequest::Request(ImageGenerationRequest::KinoviMidjourney7Niji(r)) => r,
_ => panic!("expected Request"),
};
assert_eq!(req.request.quality, Some(Q::Full));
}
#[test]
fn media_file_tokens_route_through_draft() {
let builder = GenerateImageRequestBuilder {
image_inputs: Some(ImageListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_x".to_string()),
])),
..base_builder()
};
let draft = match build_kinovi_midjourney_7_niji(builder).expect("build") {
ImageGenerationDraftOrRequest::Draft(ImageGenerationDraftRequest::KinoviMidjourney7Niji(d)) => d,
_ => panic!("expected Draft"),
};
assert!(draft.unhandled_request_state.is_some());
}
}
@@ -0,0 +1,90 @@
use seedance2pro_client::generate::image::generate_midjourney_v7_niji::{
GenerateMidjourneyV7NijiAspectRatio, GenerateMidjourneyV7NijiRequest, KinoviMidjourneyBatchCount,
};
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
use crate::generate::generate_image::providers::kinovi::midjourney_7_niji::draft::KinoviMidjourney7NijiDraftState;
use crate::generate::generate_image::providers::kinovi::midjourney_7_niji::request::KinoviMidjourney7NijiRequestState;
/// Midjourney v7 Niji (via Kinovi) cost state. Pricing is flat per task
/// regardless of aspect ratio or quality, identical to v7 / v8.
pub struct KinoviMidjourney7NijiCostState {
pub batch_count: KinoviMidjourneyBatchCount,
}
impl KinoviMidjourney7NijiCostState {
pub fn from_request(request: &KinoviMidjourney7NijiRequestState) -> Self {
Self { batch_count: request.request.batch_count }
}
pub fn from_draft(draft: &KinoviMidjourney7NijiDraftState) -> Self {
Self { batch_count: draft.batch_count }
}
pub fn estimate_cost(&self) -> ImageGenerationCostEstimate {
let pricing_request = GenerateMidjourneyV7NijiRequest {
batch_count: self.batch_count,
prompt: String::new(),
aspect_ratio: GenerateMidjourneyV7NijiAspectRatio::Square1x1,
negative_prompt: None,
stylize: None,
weird: None,
chaos: None,
quality: None,
raw_mode: false,
reference_image_urls: None,
};
let cost_in_credits = pricing_request.estimate_credits() as u64;
let cost_in_usd_cents = pricing_request.estimate_cost_in_usd_cents();
ImageGenerationCostEstimate {
cost_in_credits: Some(cost_in_credits),
cost_in_usd_cents: Some(cost_in_usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn batch_one_is_twelve_credits_and_six_cents() {
let cost = KinoviMidjourney7NijiCostState { batch_count: KinoviMidjourneyBatchCount::One }
.estimate_cost();
assert_eq!(cost.cost_in_credits, Some(12));
assert_eq!(cost.cost_in_usd_cents, Some(6));
}
#[test]
fn batch_two_doubles() {
let cost = KinoviMidjourney7NijiCostState { batch_count: KinoviMidjourneyBatchCount::Two }
.estimate_cost();
assert_eq!(cost.cost_in_credits, Some(24));
assert_eq!(cost.cost_in_usd_cents, Some(12));
}
#[test]
fn batch_four_quadruples() {
let cost = KinoviMidjourney7NijiCostState { batch_count: KinoviMidjourneyBatchCount::Four }
.estimate_cost();
assert_eq!(cost.cost_in_credits, Some(48));
assert_eq!(cost.cost_in_usd_cents, Some(25));
}
#[test]
fn flags_are_correct() {
let cost = KinoviMidjourney7NijiCostState { batch_count: KinoviMidjourneyBatchCount::One }
.estimate_cost();
assert!(!cost.is_free);
assert!(!cost.is_unlimited);
assert!(!cost.is_rate_limited);
assert!(!cost.has_watermark);
}
}
@@ -0,0 +1,58 @@
use seedance2pro_client::generate::image::generate_midjourney_v7_niji::{
GenerateMidjourneyV7NijiAspectRatio, GenerateMidjourneyV7NijiQuality,
GenerateMidjourneyV7NijiRequest, KinoviMidjourneyBatchCount,
};
use crate::api::image_list_ref::ImageListRef;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_image::image_generation_draft_context::ImageGenerationDraftContext;
use crate::generate::generate_image::providers::kinovi::midjourney_7_niji::request::KinoviMidjourney7NijiRequestState;
use crate::generate::generate_image::providers::kinovi::resolve::resolve_and_upload_image_list;
#[derive(Debug, Clone)]
pub struct KinoviMidjourney7NijiDraftState {
pub prompt: String,
pub aspect_ratio: GenerateMidjourneyV7NijiAspectRatio,
pub quality: Option<GenerateMidjourneyV7NijiQuality>,
pub batch_count: KinoviMidjourneyBatchCount,
pub unhandled_request_state: Option<KinoviMidjourney7NijiRemainingItems>,
}
#[derive(Debug, Clone)]
pub struct KinoviMidjourney7NijiRemainingItems {
pub reference_images: Option<ImageListRef>,
}
impl KinoviMidjourney7NijiDraftState {
pub async fn to_request(
&mut self,
draft_context: &ImageGenerationDraftContext<'_>,
) -> Result<KinoviMidjourney7NijiRequestState, ArtcraftRouterError> {
let client = draft_context.get_seedance2pro_client_ref()?;
let session = &client.session;
let mut reference_image_urls = None;
if let Some(remaining) = self.unhandled_request_state.take() {
let map = draft_context.media_file_to_artcraft_url_map;
reference_image_urls = resolve_and_upload_image_list(
session, remaining.reference_images, map,
).await?;
}
let request = GenerateMidjourneyV7NijiRequest {
prompt: self.prompt.clone(),
aspect_ratio: self.aspect_ratio,
negative_prompt: None,
stylize: None,
weird: None,
chaos: None,
quality: self.quality,
raw_mode: false,
batch_count: self.batch_count,
reference_image_urls,
};
Ok(KinoviMidjourney7NijiRequestState { request })
}
}
@@ -0,0 +1,4 @@
pub mod build;
pub mod cost;
pub mod draft;
pub mod request;
@@ -0,0 +1,39 @@
use seedance2pro_client::generate::image::generate_midjourney_v7_niji::{
generate_midjourney_v7_niji, GenerateMidjourneyV7NijiArgs, GenerateMidjourneyV7NijiRequest,
};
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_image::generate_image_response::{
GenerateImageResponse, Seedance2proImageResponsePayload,
};
#[derive(Debug, Clone)]
pub struct KinoviMidjourney7NijiRequestState {
pub request: GenerateMidjourneyV7NijiRequest,
}
impl KinoviMidjourney7NijiRequestState {
pub async fn send(
&self,
client: &RouterSeedance2ProClient,
) -> Result<GenerateImageResponse, ArtcraftRouterError> {
let args = GenerateMidjourneyV7NijiArgs {
session: &client.session,
host_override: None,
request: self.request.clone(),
};
let response = generate_midjourney_v7_niji(args)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?;
Ok(GenerateImageResponse::Seedance2Pro(Seedance2proImageResponsePayload {
order_id: response.order_id,
task_id: response.task_id,
maybe_order_ids: response.order_ids,
maybe_task_ids: response.task_ids,
}))
}
}
@@ -0,0 +1,228 @@
use seedance2pro_client::generate::image::generate_midjourney_v8::{
GenerateMidjourneyV8AspectRatio, GenerateMidjourneyV8Quality,
GenerateMidjourneyV8Request, KinoviMidjourneyBatchCount,
};
use crate::api::image_list_ref::ImageListRef;
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_quality::RouterQuality;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder;
use crate::generate::generate_image::image_generation_draft::ImageGenerationDraftRequest;
use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest;
use crate::generate::generate_image::image_generation_request::ImageGenerationRequest;
use crate::generate::generate_image::providers::kinovi::midjourney_8::draft::{
KinoviMidjourney8DraftState, KinoviMidjourney8RemainingItems,
};
use crate::generate::generate_image::providers::kinovi::midjourney_8::request::KinoviMidjourney8RequestState;
pub fn build_kinovi_midjourney_8(
mut builder: GenerateImageRequestBuilder,
) -> Result<ImageGenerationDraftOrRequest, ArtcraftRouterError> {
let strategy = builder.request_mismatch_mitigation_strategy;
let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio.take(), strategy)?;
let quality = plan_quality(builder.quality.take());
let batch_count = plan_batch_count(builder.image_batch_count.take(), strategy)?;
let prompt = builder.prompt.take().unwrap_or_default();
let image_inputs = builder.image_inputs.take();
if !has_image_inputs(image_inputs.as_ref()) {
let request = GenerateMidjourneyV8Request {
prompt,
aspect_ratio,
negative_prompt: None,
stylize: None,
weird: None,
chaos: None,
quality,
raw_mode: false,
batch_count,
reference_image_urls: None,
};
return Ok(ImageGenerationDraftOrRequest::Request(
ImageGenerationRequest::KinoviMidjourney8(KinoviMidjourney8RequestState { request }),
));
}
let draft = KinoviMidjourney8DraftState {
prompt,
aspect_ratio,
quality,
batch_count,
unhandled_request_state: Some(KinoviMidjourney8RemainingItems {
reference_images: image_inputs,
}),
};
Ok(ImageGenerationDraftOrRequest::Draft(
ImageGenerationDraftRequest::KinoviMidjourney8(draft),
))
}
fn has_image_inputs(image_inputs: Option<&ImageListRef>) -> bool {
match image_inputs {
None => false,
Some(ImageListRef::Urls(urls)) => !urls.is_empty(),
Some(ImageListRef::MediaFileTokens(tokens)) => !tokens.is_empty(),
}
}
// ── Plan helpers ──
pub(crate) fn plan_aspect_ratio(
aspect_ratio: Option<RouterAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<GenerateMidjourneyV8AspectRatio, ArtcraftRouterError> {
use GenerateMidjourneyV8AspectRatio as Ar;
match aspect_ratio {
None
| Some(RouterAspectRatio::Auto)
| Some(RouterAspectRatio::Auto2k)
| Some(RouterAspectRatio::Auto3k)
| Some(RouterAspectRatio::Auto4k) => Ok(Ar::Square1x1),
Some(RouterAspectRatio::Square) | Some(RouterAspectRatio::SquareHd) => Ok(Ar::Square1x1),
Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Ar::Landscape16x9),
Some(RouterAspectRatio::WideTwentyOneByNine) => Ok(Ar::UltraWide21x9),
Some(RouterAspectRatio::WideFourByThree) => Ok(Ar::Standard4x3),
Some(RouterAspectRatio::WideFiveByFour) => Ok(Ar::Wide5x4),
Some(RouterAspectRatio::WideThreeByTwo) => Ok(Ar::Wide3x2),
Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Ar::Portrait9x16),
Some(RouterAspectRatio::TallNineByTwentyOne) => Ok(Ar::UltraTall9x21),
Some(RouterAspectRatio::TallThreeByFour) => Ok(Ar::Portrait3x4),
Some(RouterAspectRatio::TallFourByFive) => Ok(Ar::Tall4x5),
Some(RouterAspectRatio::TallTwoByThree) => Ok(Ar::Tall2x3),
#[allow(unreachable_patterns)]
Some(unsupported) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "aspect_ratio",
value: format!("{:?}", unsupported),
}))
}
_ => Ok(Ar::Square1x1),
},
}
}
pub(crate) fn plan_quality(quality: Option<RouterQuality>) -> Option<GenerateMidjourneyV8Quality> {
match quality {
None => None,
Some(RouterQuality::Low) => Some(GenerateMidjourneyV8Quality::Quarter),
Some(RouterQuality::Medium) => Some(GenerateMidjourneyV8Quality::Half),
Some(RouterQuality::High) => Some(GenerateMidjourneyV8Quality::Full),
}
}
pub(crate) fn plan_batch_count(
image_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<KinoviMidjourneyBatchCount, ArtcraftRouterError> {
let count = image_batch_count.unwrap_or(1);
match count {
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 => Ok(KinoviMidjourneyBatchCount::One),
2 => Ok(KinoviMidjourneyBatchCount::Two),
4 => Ok(KinoviMidjourneyBatchCount::Four),
other => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "image_batch_count",
value: format!("{}", other),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => {
Ok(if other < 3 { KinoviMidjourneyBatchCount::Two } else { KinoviMidjourneyBatchCount::Four })
}
RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(if other < 4 { KinoviMidjourneyBatchCount::Two } else { KinoviMidjourneyBatchCount::Four })
}
},
}
}
#[cfg(test)]
mod tests {
use seedance2pro_client::generate::image::generate_midjourney_v8::KinoviMidjourneyBatchCount;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::image_list_ref::ImageListRef;
use crate::api::router_image_model::RouterImageModel;
use crate::api::router_provider::RouterProvider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder;
use crate::generate::generate_image::image_generation_draft::ImageGenerationDraftRequest;
use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest;
use crate::generate::generate_image::image_generation_request::ImageGenerationRequest;
use super::*;
fn base_builder() -> GenerateImageRequestBuilder {
GenerateImageRequestBuilder {
model: RouterImageModel::Midjourney8,
provider: RouterProvider::Seedance2Pro,
prompt: Some("a spooky horror scene".to_string()),
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: None,
horizontal_angle: None,
vertical_angle: None,
zoom: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
}
}
#[test]
fn no_image_inputs_returns_request_directly() {
let result = build_kinovi_midjourney_8(base_builder()).expect("build");
assert!(matches!(
result,
ImageGenerationDraftOrRequest::Request(ImageGenerationRequest::KinoviMidjourney8(_)),
));
}
#[test]
fn image_inputs_returns_draft() {
let builder = GenerateImageRequestBuilder {
image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/ref.png".to_string()])),
..base_builder()
};
let result = build_kinovi_midjourney_8(builder).expect("build");
assert!(matches!(
result,
ImageGenerationDraftOrRequest::Draft(ImageGenerationDraftRequest::KinoviMidjourney8(_)),
));
}
#[test]
fn batch_count_four_is_accepted() {
let builder = GenerateImageRequestBuilder { image_batch_count: Some(4), ..base_builder() };
let req = match build_kinovi_midjourney_8(builder).expect("build") {
ImageGenerationDraftOrRequest::Request(ImageGenerationRequest::KinoviMidjourney8(r)) => r,
_ => panic!("expected Request"),
};
assert_eq!(req.request.batch_count, KinoviMidjourneyBatchCount::Four);
}
#[test]
fn media_file_token_route_through_draft() {
let builder = GenerateImageRequestBuilder {
image_inputs: Some(ImageListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_x".to_string()),
])),
..base_builder()
};
let draft = match build_kinovi_midjourney_8(builder).expect("build") {
ImageGenerationDraftOrRequest::Draft(ImageGenerationDraftRequest::KinoviMidjourney8(d)) => d,
_ => panic!("expected Draft"),
};
assert!(draft.unhandled_request_state.is_some());
}
}
@@ -0,0 +1,80 @@
use seedance2pro_client::generate::image::generate_midjourney_v8::{
GenerateMidjourneyV8AspectRatio, GenerateMidjourneyV8Request, KinoviMidjourneyBatchCount,
};
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
use crate::generate::generate_image::providers::kinovi::midjourney_8::draft::KinoviMidjourney8DraftState;
use crate::generate::generate_image::providers::kinovi::midjourney_8::request::KinoviMidjourney8RequestState;
/// Midjourney v8 (via Kinovi) cost state. Pricing is flat per task,
/// identical to v7 / v7 niji.
pub struct KinoviMidjourney8CostState {
pub batch_count: KinoviMidjourneyBatchCount,
}
impl KinoviMidjourney8CostState {
pub fn from_request(request: &KinoviMidjourney8RequestState) -> Self {
Self { batch_count: request.request.batch_count }
}
pub fn from_draft(draft: &KinoviMidjourney8DraftState) -> Self {
Self { batch_count: draft.batch_count }
}
pub fn estimate_cost(&self) -> ImageGenerationCostEstimate {
let pricing_request = GenerateMidjourneyV8Request {
batch_count: self.batch_count,
prompt: String::new(),
aspect_ratio: GenerateMidjourneyV8AspectRatio::Square1x1,
negative_prompt: None,
stylize: None,
weird: None,
chaos: None,
quality: None,
raw_mode: false,
reference_image_urls: None,
};
let cost_in_credits = pricing_request.estimate_credits() as u64;
let cost_in_usd_cents = pricing_request.estimate_cost_in_usd_cents();
ImageGenerationCostEstimate {
cost_in_credits: Some(cost_in_credits),
cost_in_usd_cents: Some(cost_in_usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn batch_one_is_twelve_credits_and_six_cents() {
let cost = KinoviMidjourney8CostState { batch_count: KinoviMidjourneyBatchCount::One }
.estimate_cost();
assert_eq!(cost.cost_in_credits, Some(12));
assert_eq!(cost.cost_in_usd_cents, Some(6));
}
#[test]
fn batch_two_doubles() {
let cost = KinoviMidjourney8CostState { batch_count: KinoviMidjourneyBatchCount::Two }
.estimate_cost();
assert_eq!(cost.cost_in_credits, Some(24));
assert_eq!(cost.cost_in_usd_cents, Some(12));
}
#[test]
fn batch_four_quadruples() {
let cost = KinoviMidjourney8CostState { batch_count: KinoviMidjourneyBatchCount::Four }
.estimate_cost();
assert_eq!(cost.cost_in_credits, Some(48));
assert_eq!(cost.cost_in_usd_cents, Some(25));
}
}
@@ -0,0 +1,58 @@
use seedance2pro_client::generate::image::generate_midjourney_v8::{
GenerateMidjourneyV8AspectRatio, GenerateMidjourneyV8Quality,
GenerateMidjourneyV8Request, KinoviMidjourneyBatchCount,
};
use crate::api::image_list_ref::ImageListRef;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_image::image_generation_draft_context::ImageGenerationDraftContext;
use crate::generate::generate_image::providers::kinovi::midjourney_8::request::KinoviMidjourney8RequestState;
use crate::generate::generate_image::providers::kinovi::resolve::resolve_and_upload_image_list;
#[derive(Debug, Clone)]
pub struct KinoviMidjourney8DraftState {
pub prompt: String,
pub aspect_ratio: GenerateMidjourneyV8AspectRatio,
pub quality: Option<GenerateMidjourneyV8Quality>,
pub batch_count: KinoviMidjourneyBatchCount,
pub unhandled_request_state: Option<KinoviMidjourney8RemainingItems>,
}
#[derive(Debug, Clone)]
pub struct KinoviMidjourney8RemainingItems {
pub reference_images: Option<ImageListRef>,
}
impl KinoviMidjourney8DraftState {
pub async fn to_request(
&mut self,
draft_context: &ImageGenerationDraftContext<'_>,
) -> Result<KinoviMidjourney8RequestState, ArtcraftRouterError> {
let client = draft_context.get_seedance2pro_client_ref()?;
let session = &client.session;
let mut reference_image_urls = None;
if let Some(remaining) = self.unhandled_request_state.take() {
let map = draft_context.media_file_to_artcraft_url_map;
reference_image_urls = resolve_and_upload_image_list(
session, remaining.reference_images, map,
).await?;
}
let request = GenerateMidjourneyV8Request {
prompt: self.prompt.clone(),
aspect_ratio: self.aspect_ratio,
negative_prompt: None,
stylize: None,
weird: None,
chaos: None,
quality: self.quality,
raw_mode: false,
batch_count: self.batch_count,
reference_image_urls,
};
Ok(KinoviMidjourney8RequestState { request })
}
}
@@ -0,0 +1,4 @@
pub mod build;
pub mod cost;
pub mod draft;
pub mod request;
@@ -0,0 +1,39 @@
use seedance2pro_client::generate::image::generate_midjourney_v8::{
generate_midjourney_v8, GenerateMidjourneyV8Args, GenerateMidjourneyV8Request,
};
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_image::generate_image_response::{
GenerateImageResponse, Seedance2proImageResponsePayload,
};
#[derive(Debug, Clone)]
pub struct KinoviMidjourney8RequestState {
pub request: GenerateMidjourneyV8Request,
}
impl KinoviMidjourney8RequestState {
pub async fn send(
&self,
client: &RouterSeedance2ProClient,
) -> Result<GenerateImageResponse, ArtcraftRouterError> {
let args = GenerateMidjourneyV8Args {
session: &client.session,
host_override: None,
request: self.request.clone(),
};
let response = generate_midjourney_v8(args)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?;
Ok(GenerateImageResponse::Seedance2Pro(Seedance2proImageResponsePayload {
order_id: response.order_id,
task_id: response.task_id,
maybe_order_ids: response.order_ids,
maybe_task_ids: response.task_ids,
}))
}
}
@@ -0,0 +1,6 @@
pub mod midjourney_7;
pub mod midjourney_7_niji;
pub mod midjourney_8;
pub(crate) mod resolve;
pub(crate) mod upload;
@@ -0,0 +1,51 @@
use std::collections::HashMap;
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::image_list_ref::ImageListRef;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_image::providers::kinovi::upload::upload_to_seedance2pro;
/// Resolve an [`ImageListRef`] to a list of public Seedance2Pro CDN URLs,
/// uploading each image one at a time. Preserves order.
///
/// Returns `Ok(None)` when the input is `None` or an empty list — the
/// caller can then omit the field from the wire request entirely. (The
/// downstream Midjourney API rejects empty arrays.)
pub(crate) async fn resolve_and_upload_image_list(
session: &Seedance2ProSession,
list: Option<ImageListRef>,
maybe_map: Option<&HashMap<MediaFileToken, String>>,
) -> Result<Option<Vec<String>>, ArtcraftRouterError> {
let source_urls = match list {
None => return Ok(None),
Some(ImageListRef::Urls(urls)) if urls.is_empty() => return Ok(None),
Some(ImageListRef::Urls(urls)) => urls,
Some(ImageListRef::MediaFileTokens(tokens)) if tokens.is_empty() => return Ok(None),
Some(ImageListRef::MediaFileTokens(tokens)) => resolve_tokens(maybe_map, &tokens)?,
};
let mut uploaded = Vec::with_capacity(source_urls.len());
for url in &source_urls {
uploaded.push(upload_to_seedance2pro(session, url).await?);
}
Ok(Some(uploaded))
}
fn resolve_tokens(
maybe_map: Option<&HashMap<MediaFileToken, String>>,
tokens: &[MediaFileToken],
) -> Result<Vec<String>, ArtcraftRouterError> {
let map = maybe_map.ok_or(ArtcraftRouterError::Client(ClientError::MediaFileToUrlMapNotProvided))?;
tokens.iter()
.map(|token| {
map.get(token).cloned().ok_or_else(|| {
ArtcraftRouterError::Client(ClientError::MediaFileTokenNotFoundInMap {
token: token.clone(),
})
})
})
.collect()
}
@@ -0,0 +1,44 @@
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use seedance2pro_client::requests::prepare_file_upload::prepare_file_upload::{prepare_file_upload, PrepareFileUploadArgs};
use seedance2pro_client::requests::upload_file::upload_file::{upload_file, UploadFileArgs};
use url_utils::extension::extract_extension_from_url::{extract_extension_from_url_str, ExtractExtensions};
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::utils::download_file::download_file;
/// Downloads an image from a source URL and re-uploads it to the
/// Seedance2Pro/Kinovi CDN. Returns the public URL.
///
/// This is the image-side twin of `generate_video::providers::kinovi::upload`.
/// The actual upload path doesn't care whether the file is a video or an
/// image, but keeping the two helpers separate lets each side evolve
/// independently and makes intent clear at call sites.
pub(crate) async fn upload_to_seedance2pro(
session: &Seedance2ProSession,
source_url: &str,
) -> Result<String, ArtcraftRouterError> {
let extension = extract_extension_from_url_str(source_url, &ExtractExtensions::All)
.map(|ext| ext.without_period().to_string())
.unwrap_or_else(|| "jpg".to_string());
let file_bytes = download_file(source_url).await?;
let prepare_response = prepare_file_upload(PrepareFileUploadArgs {
session,
extension,
host_override: None,
})
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?;
let upload_response = upload_file(UploadFileArgs {
upload_url: prepare_response.upload_url,
file_bytes,
host_override: None,
})
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?;
Ok(upload_response.public_url)
}
@@ -1,2 +1,3 @@
pub mod artcraft;
pub mod fal;
pub mod kinovi;
@@ -90,7 +90,11 @@ pub(super) async fn handle_image_edit_artcraft_via_router(
let request = match dor {
ImageGenerationDraftOrRequest::Request(req) => req,
// Artcraft never returns a Draft — image-token resolution happens server-side.
ImageGenerationDraftOrRequest::Draft(d) => match d {},
// The only draft-producing models today are Kinovi-Midjourney variants,
// which are routed to a separate provider.
ImageGenerationDraftOrRequest::Draft(_) => unreachable!(
"Artcraft router should never produce a draft for image edits"
),
};
let response = match request.send_request(&client).await {
@@ -70,7 +70,11 @@ pub(super) async fn handle_text_to_image_artcraft_via_router(
let request = match dor {
ImageGenerationDraftOrRequest::Request(req) => req,
// Artcraft never returns a Draft — image-token resolution happens server-side.
ImageGenerationDraftOrRequest::Draft(d) => match d {},
// The only draft-producing models today are Kinovi-Midjourney variants,
// which are routed to a separate provider.
ImageGenerationDraftOrRequest::Draft(_) => unreachable!(
"Artcraft router should never produce a draft for text-to-image"
),
};
let response = match request.send_request(&client).await {
@@ -58,6 +58,9 @@ fn image_model_to_common_model_type(model: RouterImageModel) -> Option<CommonMod
RouterImageModel::Flux2LoraAngles => Some(CommonModelType::Flux2LoraAngles),
RouterImageModel::GrokImagineImage => Some(CommonModelType::GrokImagineImage),
RouterImageModel::GrokImagineImageQuality => Some(CommonModelType::GrokImagineImageQuality),
RouterImageModel::Midjourney7 => Some(CommonModelType::Midjourney7),
RouterImageModel::Midjourney7Niji => Some(CommonModelType::Midjourney7Niji),
RouterImageModel::Midjourney8 => Some(CommonModelType::Midjourney8),
}
}
@@ -57,7 +57,11 @@ pub async fn handle_flux_2_lora_angles(
let request = match dor {
ImageGenerationDraftOrRequest::Request(req) => req,
// Artcraft-side angle models always return a Request — never a Draft.
ImageGenerationDraftOrRequest::Draft(d) => match d {},
// The only draft-producing models today are Kinovi-Midjourney variants,
// which are routed to a separate provider.
ImageGenerationDraftOrRequest::Draft(_) => unreachable!(
"Artcraft Flux2LoraAngles should never produce a draft"
),
};
let response = request.send_request(&client).await.map_err(|err| {
@@ -57,7 +57,11 @@ pub async fn handle_qwen_edit_2511_angles(
let request = match dor {
ImageGenerationDraftOrRequest::Request(req) => req,
// Artcraft-side angle models always return a Request — never a Draft.
ImageGenerationDraftOrRequest::Draft(d) => match d {},
// The only draft-producing models today are Kinovi-Midjourney variants,
// which are routed to a separate provider.
ImageGenerationDraftOrRequest::Draft(_) => unreachable!(
"Artcraft QwenEdit2511Angles should never produce a draft"
),
};
let response = request.send_request(&client).await.map_err(|err| {
@@ -145,6 +145,9 @@ fn map_image_model(model: CommonImageModel) -> Result<RouterImageModel, HandlerE
CommonImageModel::GrokImagineImageQuality => RouterImageModel::GrokImagineImageQuality,
CommonImageModel::QwenEdit2511Angles => RouterImageModel::QwenEdit2511Angles,
CommonImageModel::Flux2LoraAngles => RouterImageModel::Flux2LoraAngles,
CommonImageModel::Midjourney7 => RouterImageModel::Midjourney7,
CommonImageModel::Midjourney7Niji => RouterImageModel::Midjourney7Niji,
CommonImageModel::Midjourney8 => RouterImageModel::Midjourney8,
};
Ok(router_model)
}
@@ -175,6 +175,11 @@ pub async fn omni_gen_image_generate_handler(
GenerateImageResponse::Fal(p) => {
p.request_id.clone().unwrap_or_default()
}
GenerateImageResponse::Seedance2Pro(p) => {
// Kinovi/Midjourney uses Seedance2Pro's task_id as the external job
// identifier (the order_id is also available on the payload).
p.task_id.clone()
}
};
// ==================== WRITE RESULT ==================== //