diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/generate_image_request_builder.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/generate_image_request_builder.rs index 8a0784bfe5..8df5fecb11 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/generate_image_request_builder.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/generate_image_request_builder.rs @@ -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(), } } diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/generate_image_response.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/generate_image_response.rs index a330c7c5a6..8ef1888f36 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/generate_image_response.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/generate_image_response.rs @@ -25,10 +25,22 @@ pub struct FalImageResponsePayload { pub maybe_outbound_request: Option>, } +/// 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>, + pub maybe_task_ids: Option>, +} + #[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 { + match self { + Self::Seedance2Pro(p) => Some(p.clone()), + _ => None, + } + } } diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/image_generation_draft.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/image_generation_draft.rs index 649fca38c1..3a4e563b9f 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/image_generation_draft.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/image_generation_draft.rs @@ -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 { - 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 { - match self {} + pub async fn finalize(self, draft_context: ImageGenerationDraftContext<'_>) -> Result { + 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)) + } + } } } diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/image_generation_draft_context.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/image_generation_draft_context.rs index d8c8b78650..4d4656624a 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/image_generation_draft_context.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/image_generation_draft_context.rs @@ -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>, +} + +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() } } diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/image_generation_request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/image_generation_request.rs index 2e3018312c..e23a30d050 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/image_generation_request.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/image_generation_request.rs @@ -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 + } } } } diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7/build.rs new file mode 100644 index 0000000000..dd4ab6a629 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7/build.rs @@ -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 { + 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, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + 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) -> Option { + // 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, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + 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) -> 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) -> Option { + 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) -> 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, + ) -> 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, + ) -> KinoviMidjourney7DraftState { + match result.expect("build should succeed") { + ImageGenerationDraftOrRequest::Draft( + ImageGenerationDraftRequest::KinoviMidjourney7(draft), + ) => draft, + other => panic!("expected KinoviMidjourney7 Draft, got {:?}", other), + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7/cost.rs new file mode 100644 index 0000000000..e1d7faae86 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7/cost.rs @@ -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"), + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7/draft.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7/draft.rs new file mode 100644 index 0000000000..68d7ec534f --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7/draft.rs @@ -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, + 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, +} + +#[derive(Debug, Clone)] +pub struct KinoviMidjourney7RemainingItems { + pub reference_images: Option, +} + +impl KinoviMidjourney7DraftState { + pub async fn to_request( + &mut self, + draft_context: &ImageGenerationDraftContext<'_>, + ) -> Result { + 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())); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7/mod.rs new file mode 100644 index 0000000000..89225a3692 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7/mod.rs @@ -0,0 +1,4 @@ +pub mod build; +pub mod cost; +pub mod draft; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7/request.rs new file mode 100644 index 0000000000..269baf00b6 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7/request.rs @@ -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 { + 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()); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7_niji/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7_niji/build.rs new file mode 100644 index 0000000000..875743fa8c --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7_niji/build.rs @@ -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 { + 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, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + 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) -> Option { + 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, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + 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()); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7_niji/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7_niji/cost.rs new file mode 100644 index 0000000000..50de0f8d29 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7_niji/cost.rs @@ -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); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7_niji/draft.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7_niji/draft.rs new file mode 100644 index 0000000000..d2cda52fe5 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7_niji/draft.rs @@ -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, + pub batch_count: KinoviMidjourneyBatchCount, + + pub unhandled_request_state: Option, +} + +#[derive(Debug, Clone)] +pub struct KinoviMidjourney7NijiRemainingItems { + pub reference_images: Option, +} + +impl KinoviMidjourney7NijiDraftState { + pub async fn to_request( + &mut self, + draft_context: &ImageGenerationDraftContext<'_>, + ) -> Result { + 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 }) + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7_niji/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7_niji/mod.rs new file mode 100644 index 0000000000..89225a3692 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7_niji/mod.rs @@ -0,0 +1,4 @@ +pub mod build; +pub mod cost; +pub mod draft; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7_niji/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7_niji/request.rs new file mode 100644 index 0000000000..b0c30c623f --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7_niji/request.rs @@ -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 { + 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, + })) + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_8/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_8/build.rs new file mode 100644 index 0000000000..929024fca8 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_8/build.rs @@ -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 { + 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, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + 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) -> Option { + 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, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + 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()); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_8/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_8/cost.rs new file mode 100644 index 0000000000..139f6fc118 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_8/cost.rs @@ -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)); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_8/draft.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_8/draft.rs new file mode 100644 index 0000000000..4fa628eda4 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_8/draft.rs @@ -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, + pub batch_count: KinoviMidjourneyBatchCount, + + pub unhandled_request_state: Option, +} + +#[derive(Debug, Clone)] +pub struct KinoviMidjourney8RemainingItems { + pub reference_images: Option, +} + +impl KinoviMidjourney8DraftState { + pub async fn to_request( + &mut self, + draft_context: &ImageGenerationDraftContext<'_>, + ) -> Result { + 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 }) + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_8/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_8/mod.rs new file mode 100644 index 0000000000..89225a3692 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_8/mod.rs @@ -0,0 +1,4 @@ +pub mod build; +pub mod cost; +pub mod draft; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_8/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_8/request.rs new file mode 100644 index 0000000000..2b6863c773 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_8/request.rs @@ -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 { + 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, + })) + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/mod.rs new file mode 100644 index 0000000000..e3bdbadd51 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/mod.rs @@ -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; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/resolve.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/resolve.rs new file mode 100644 index 0000000000..4e069932cd --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/resolve.rs @@ -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, + maybe_map: Option<&HashMap>, +) -> Result>, 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>, + tokens: &[MediaFileToken], +) -> Result, 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() +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/upload.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/upload.rs new file mode 100644 index 0000000000..61e66b316e --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/upload.rs @@ -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 { + 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) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/mod.rs index cded7c363d..1adb32b1fa 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/mod.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/mod.rs @@ -1,2 +1,3 @@ pub mod artcraft; pub mod fal; +pub mod kinovi; diff --git a/crates/desktop/artcraft/src/core/commands/deprecated/image_edit/artcraft/handle_image_edit_artcraft_via_router.rs b/crates/desktop/artcraft/src/core/commands/deprecated/image_edit/artcraft/handle_image_edit_artcraft_via_router.rs index ceb4869ad8..3b9895339d 100644 --- a/crates/desktop/artcraft/src/core/commands/deprecated/image_edit/artcraft/handle_image_edit_artcraft_via_router.rs +++ b/crates/desktop/artcraft/src/core/commands/deprecated/image_edit/artcraft/handle_image_edit_artcraft_via_router.rs @@ -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 { diff --git a/crates/desktop/artcraft/src/core/commands/deprecated/text_to_image/artcraft/handle_text_to_image_artcraft_via_router.rs b/crates/desktop/artcraft/src/core/commands/deprecated/text_to_image/artcraft/handle_text_to_image_artcraft_via_router.rs index f5e2764441..66f98e5c3a 100644 --- a/crates/desktop/artcraft/src/core/commands/deprecated/text_to_image/artcraft/handle_text_to_image_artcraft_via_router.rs +++ b/crates/desktop/artcraft/src/core/commands/deprecated/text_to_image/artcraft/handle_text_to_image_artcraft_via_router.rs @@ -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 { diff --git a/crates/desktop/artcraft/src/core/commands/generate/common/router_image_request_to_artcraft_prompt.rs b/crates/desktop/artcraft/src/core/commands/generate/common/router_image_request_to_artcraft_prompt.rs index 5f03e3f3b6..fa39e16fdf 100644 --- a/crates/desktop/artcraft/src/core/commands/generate/common/router_image_request_to_artcraft_prompt.rs +++ b/crates/desktop/artcraft/src/core/commands/generate/common/router_image_request_to_artcraft_prompt.rs @@ -58,6 +58,9 @@ fn image_model_to_common_model_type(model: RouterImageModel) -> Option 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), } } diff --git a/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft/legacy/artcraft_flux_2_lora_angles.rs b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft/legacy/artcraft_flux_2_lora_angles.rs index 128266ae03..929df4474f 100644 --- a/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft/legacy/artcraft_flux_2_lora_angles.rs +++ b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft/legacy/artcraft_flux_2_lora_angles.rs @@ -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| { diff --git a/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft/legacy/artcraft_qwen_edit_2511_angles.rs b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft/legacy/artcraft_qwen_edit_2511_angles.rs index 741ea71714..30a9b0880e 100644 --- a/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft/legacy/artcraft_qwen_edit_2511_angles.rs +++ b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft/legacy/artcraft_qwen_edit_2511_angles.rs @@ -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| { diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/cost_estimate/image/estimate_image_cost_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/cost_estimate/image/estimate_image_cost_handler.rs index 848af61a09..3b5e985d31 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/cost_estimate/image/estimate_image_cost_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/cost_estimate/image/estimate_image_cost_handler.rs @@ -145,6 +145,9 @@ fn map_image_model(model: CommonImageModel) -> Result 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) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/image/omni_gen_image_generate_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/image/omni_gen_image_generate_handler.rs index c8c2f01fe9..3dc08c688b 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/image/omni_gen_image_generate_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/image/omni_gen_image_generate_handler.rs @@ -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 ==================== //