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