From a6f8d2a4aa9803aa0d32e8d24486d443fd7c6658 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 2 Jun 2026 06:49:12 -0400 Subject: [PATCH] midjourney images - router, web, jobs, etc. (#1600) --- Cargo.lock | 1 + .../artcraft/artcraft_router/Cargo.toml | 1 + .../src/api/router_image_model.rs | 36 +- .../generate_image_request_builder.rs | 14 + .../generate_image/generate_image_response.rs | 19 + .../generate_image/image_generation_draft.rs | 46 +- .../image_generation_draft_context.rs | 42 +- .../image_generation_request.rs | 69 +++ .../providers/artcraft/midjourney_7/build.rs | 17 + .../providers/artcraft/midjourney_7/cost.rs | 186 +++++++ .../providers/artcraft/midjourney_7/mod.rs | 3 + .../artcraft/midjourney_7/request.rs | 17 + .../artcraft/midjourney_7_niji/build.rs | 17 + .../artcraft/midjourney_7_niji/cost.rs | 113 +++++ .../artcraft/midjourney_7_niji/mod.rs | 3 + .../artcraft/midjourney_7_niji/request.rs | 17 + .../providers/artcraft/midjourney_8/build.rs | 17 + .../providers/artcraft/midjourney_8/cost.rs | 113 +++++ .../providers/artcraft/midjourney_8/mod.rs | 3 + .../artcraft/midjourney_8/request.rs | 17 + .../generate_image/providers/artcraft/mod.rs | 3 + .../providers/kinovi/midjourney_7/build.rs | 460 ++++++++++++++++++ .../providers/kinovi/midjourney_7/cost.rs | 211 ++++++++ .../providers/kinovi/midjourney_7/draft.rs | 94 ++++ .../providers/kinovi/midjourney_7/mod.rs | 4 + .../providers/kinovi/midjourney_7/request.rs | 142 ++++++ .../kinovi/midjourney_7_niji/build.rs | 267 ++++++++++ .../kinovi/midjourney_7_niji/cost.rs | 90 ++++ .../kinovi/midjourney_7_niji/draft.rs | 58 +++ .../providers/kinovi/midjourney_7_niji/mod.rs | 4 + .../kinovi/midjourney_7_niji/request.rs | 140 ++++++ .../providers/kinovi/midjourney_8/build.rs | 228 +++++++++ .../providers/kinovi/midjourney_8/cost.rs | 80 +++ .../providers/kinovi/midjourney_8/draft.rs | 58 +++ .../providers/kinovi/midjourney_8/mod.rs | 4 + .../providers/kinovi/midjourney_8/request.rs | 140 ++++++ .../generate_image/providers/kinovi/mod.rs | 6 + .../providers/kinovi/resolve.rs | 51 ++ .../generate_image/providers/kinovi/upload.rs | 44 ++ .../generate/generate_image/providers/mod.rs | 1 + .../handle_image_edit_artcraft_via_router.rs | 6 +- ...andle_text_to_image_artcraft_via_router.rs | 6 +- ...router_image_request_to_artcraft_prompt.rs | 3 + .../legacy/artcraft_flux_2_lora_angles.rs | 6 +- .../legacy/artcraft_qwen_edit_2511_angles.rs | 6 +- ...edance2pro_queue_with_apriori_job_token.rs | 28 +- .../inference_job_product_category.rs | 12 +- .../inference_model_type.rs | 30 ++ .../common/generation/common_image_model.rs | 17 +- .../common/generation/common_model_type.rs | 35 +- .../jobs/video_polling_job/process_job/mod.rs | 1 + .../process_successful_image_job.rs | 218 +++++++++ .../video_polling_job/process_orders_batch.rs | 26 +- .../src/configs/omni_gen/image_models.rs | 81 +++ .../image/estimate_image_cost_handler.rs | 3 + .../image/insert_db_job/insert_fal_job.rs | 59 +++ .../insert_db_job/insert_seedance2pro_jobs.rs | 115 +++++ .../generate/image/insert_db_job/mod.rs | 3 + .../image/insert_db_job/shared_job_args.rs | 20 + .../endpoints/omni_gen/generate/image/mod.rs | 1 + .../image/omni_gen_image_generate_handler.rs | 101 ++-- .../generate/image/pipeline_result.rs | 7 + .../image/pipeline_v2/run_pipeline_v2.rs | 72 ++- 63 files changed, 3599 insertions(+), 93 deletions(-) create mode 100644 crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_7/build.rs create mode 100644 crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_7/cost.rs create mode 100644 crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_7/mod.rs create mode 100644 crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_7/request.rs create mode 100644 crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_7_niji/build.rs create mode 100644 crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_7_niji/cost.rs create mode 100644 crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_7_niji/mod.rs create mode 100644 crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_7_niji/request.rs create mode 100644 crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_8/build.rs create mode 100644 crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_8/cost.rs create mode 100644 crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_8/mod.rs create mode 100644 crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_8/request.rs create mode 100644 crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7/build.rs create mode 100644 crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7/cost.rs create mode 100644 crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7/draft.rs create mode 100644 crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7/mod.rs create mode 100644 crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7/request.rs create mode 100644 crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7_niji/build.rs create mode 100644 crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7_niji/cost.rs create mode 100644 crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7_niji/draft.rs create mode 100644 crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7_niji/mod.rs create mode 100644 crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7_niji/request.rs create mode 100644 crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_8/build.rs create mode 100644 crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_8/cost.rs create mode 100644 crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_8/draft.rs create mode 100644 crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_8/mod.rs create mode 100644 crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_8/request.rs create mode 100644 crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/mod.rs create mode 100644 crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/resolve.rs create mode 100644 crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/upload.rs create mode 100644 crates/service/job/seedance2_pro_job/src/jobs/video_polling_job/process_job/process_successful_image_job.rs create mode 100644 crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/image/insert_db_job/insert_fal_job.rs create mode 100644 crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/image/insert_db_job/insert_seedance2pro_jobs.rs create mode 100644 crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/image/insert_db_job/mod.rs create mode 100644 crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/image/insert_db_job/shared_job_args.rs diff --git a/Cargo.lock b/Cargo.lock index 0df590fc40..f8a6682dec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -809,6 +809,7 @@ dependencies = [ "seedance2pro_client", "serde", "serde_derive", + "serde_json", "strum", "test_data", "tokens", diff --git a/crates/api_clients/artcraft/artcraft_router/Cargo.toml b/crates/api_clients/artcraft/artcraft_router/Cargo.toml index 5ed89ecdb3..3177cf1fc9 100644 --- a/crates/api_clients/artcraft/artcraft_router/Cargo.toml +++ b/crates/api_clients/artcraft/artcraft_router/Cargo.toml @@ -40,4 +40,5 @@ uuid.workspace = true [dev-dependencies] test_data.workspace = true strum.workspace = true +serde_json.workspace = true diff --git a/crates/api_clients/artcraft/artcraft_router/src/api/router_image_model.rs b/crates/api_clients/artcraft/artcraft_router/src/api/router_image_model.rs index 87a5fff319..945ef769de 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/api/router_image_model.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/api/router_image_model.rs @@ -2,7 +2,7 @@ use serde_derive::{Deserialize, Serialize}; /// Common image models supported by the router. /// Not all models are available through all providers. -#[derive(Copy, Clone, Debug, Serialize, Deserialize)] +#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum RouterImageModel { #[serde(rename = "flux_1_dev")] @@ -23,6 +23,12 @@ pub enum RouterImageModel { GrokImagineImage, #[serde(rename = "grok_imagine_image_q")] GrokImagineImageQuality, + #[serde(rename = "midjourney_7")] + Midjourney7, + #[serde(rename = "midjourney_7_niji")] + Midjourney7Niji, + #[serde(rename = "midjourney_8")] + Midjourney8, #[serde(rename = "nano_banana")] NanoBanana, #[serde(rename = "nano_banana_2")] @@ -40,3 +46,31 @@ pub enum RouterImageModel { #[serde(rename = "flux_2_lora_angles")] Flux2LoraAngles, } + +#[cfg(test)] +mod tests { + use super::*; + + fn assert_round_trip(model: RouterImageModel, expected_wire: &str) { + let json = serde_json::to_string(&model).unwrap(); + let unquoted = json.trim_matches('"'); + assert_eq!(unquoted, expected_wire, "serialize {:?}", model); + let back: RouterImageModel = serde_json::from_str(&json).unwrap(); + assert_eq!(back, model, "deserialize round-trip for {:?}", model); + } + + #[test] + fn midjourney_7_serializes_to_midjourney_7() { + assert_round_trip(RouterImageModel::Midjourney7, "midjourney_7"); + } + + #[test] + fn midjourney_7_niji_serializes_to_midjourney_7_niji() { + assert_round_trip(RouterImageModel::Midjourney7Niji, "midjourney_7_niji"); + } + + #[test] + fn midjourney_8_serializes_to_midjourney_8() { + assert_round_trip(RouterImageModel::Midjourney8, "midjourney_8"); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/generate_image_request_builder.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/generate_image_request_builder.rs index 8a0784bfe5..00b7956cac 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/generate_image_request_builder.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/generate_image_request_builder.rs @@ -16,6 +16,9 @@ use crate::generate::generate_image::providers::artcraft::flux_pro_1p1_ultra::bu use crate::generate::generate_image::providers::artcraft::gpt_image_1::build::build_artcraft_gpt_image_1; use crate::generate::generate_image::providers::artcraft::gpt_image_1p5::build::build_artcraft_gpt_image_1p5; use crate::generate::generate_image::providers::artcraft::gpt_image_2::build::build_artcraft_gpt_image_2; +use crate::generate::generate_image::providers::artcraft::midjourney_7::build::build_artcraft_midjourney_7; +use crate::generate::generate_image::providers::artcraft::midjourney_7_niji::build::build_artcraft_midjourney_7_niji; +use crate::generate::generate_image::providers::artcraft::midjourney_8::build::build_artcraft_midjourney_8; use crate::generate::generate_image::providers::artcraft::nano_banana::build::build_artcraft_nano_banana; use crate::generate::generate_image::providers::artcraft::nano_banana_2::build::build_artcraft_nano_banana_2; use crate::generate::generate_image::providers::artcraft::nano_banana_pro::build::build_artcraft_nano_banana_pro; @@ -38,6 +41,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 { @@ -115,6 +121,9 @@ impl GenerateImageRequestBuilder { (RouterProvider::Artcraft, RouterImageModel::Seedream5Lite) => build_artcraft_seedream_5_lite(self), (RouterProvider::Artcraft, RouterImageModel::QwenEdit2511Angles) => build_artcraft_qwen_edit_2511_angles(self), (RouterProvider::Artcraft, RouterImageModel::Flux2LoraAngles) => build_artcraft_flux_2_lora_angles(self), + (RouterProvider::Artcraft, RouterImageModel::Midjourney7) => build_artcraft_midjourney_7(self), + (RouterProvider::Artcraft, RouterImageModel::Midjourney7Niji) => build_artcraft_midjourney_7_niji(self), + (RouterProvider::Artcraft, RouterImageModel::Midjourney8) => build_artcraft_midjourney_8(self), (RouterProvider::Fal, RouterImageModel::Flux1Dev) => build_fal_flux_1_dev(self), (RouterProvider::Fal, RouterImageModel::Flux1Schnell) => build_fal_flux_1_schnell(self), @@ -132,6 +141,11 @@ impl GenerateImageRequestBuilder { (RouterProvider::Fal, RouterImageModel::QwenEdit2511Angles) => build_fal_qwen_edit_2511_angles(self), (RouterProvider::Fal, RouterImageModel::Flux2LoraAngles) => build_fal_flux_2_lora_angles(self), + // Kinovi / Seedance2Pro (Midjourney) + (RouterProvider::Seedance2Pro, RouterImageModel::Midjourney7) => build_kinovi_midjourney_7(self), + (RouterProvider::Seedance2Pro, RouterImageModel::Midjourney7Niji) => build_kinovi_midjourney_7_niji(self), + (RouterProvider::Seedance2Pro, RouterImageModel::Midjourney8) => build_kinovi_midjourney_8(self), + _ => self.unsupported_provider_and_model(), } } diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/generate_image_response.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/generate_image_response.rs index a330c7c5a6..8ef1888f36 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/generate_image_response.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/generate_image_response.rs @@ -25,10 +25,22 @@ pub struct FalImageResponsePayload { pub maybe_outbound_request: Option>, } +/// Response from the Seedance2Pro/Kinovi provider (used for Midjourney +/// image generation). Mirrors `Seedance2proVideoResponsePayload` on the +/// video side. +#[derive(Clone, Debug)] +pub struct Seedance2proImageResponsePayload { + pub order_id: String, + pub task_id: String, + pub maybe_order_ids: Option>, + pub maybe_task_ids: Option>, +} + #[derive(Clone, Debug)] pub enum GenerateImageResponse { Artcraft(ArtcraftImageResponsePayload), Fal(FalImageResponsePayload), + Seedance2Pro(Seedance2proImageResponsePayload), } impl GenerateImageResponse { @@ -45,4 +57,11 @@ impl GenerateImageResponse { _ => None, } } + + pub fn get_seedance2pro_payload(&self) -> Option { + match self { + Self::Seedance2Pro(p) => Some(p.clone()), + _ => None, + } + } } diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/image_generation_draft.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/image_generation_draft.rs index 649fca38c1..3a4e563b9f 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/image_generation_draft.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/image_generation_draft.rs @@ -3,26 +3,58 @@ use crate::errors::artcraft_router_error::ArtcraftRouterError; use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate; use crate::generate::generate_image::image_generation_draft_context::ImageGenerationDraftContext; use crate::generate::generate_image::image_generation_request::ImageGenerationRequest; +use crate::generate::generate_image::providers::kinovi::midjourney_7::cost::KinoviMidjourney7CostState; +use crate::generate::generate_image::providers::kinovi::midjourney_7::draft::KinoviMidjourney7DraftState; +use crate::generate::generate_image::providers::kinovi::midjourney_7_niji::cost::KinoviMidjourney7NijiCostState; +use crate::generate::generate_image::providers::kinovi::midjourney_7_niji::draft::KinoviMidjourney7NijiDraftState; +use crate::generate::generate_image::providers::kinovi::midjourney_8::cost::KinoviMidjourney8CostState; +use crate::generate::generate_image::providers::kinovi::midjourney_8::draft::KinoviMidjourney8DraftState; /// Wrapper for all image generation draft requests. /// -/// Drafts hold validated/planned parameters but may still need media resolution -/// (e.g. uploading local files to a provider) before they can be sent. -/// Currently empty — FAL doesn't need a draft phase. +/// Drafts hold validated/planned parameters but may still need media +/// resolution (e.g. uploading local files to a provider) before they can +/// be sent. Today only Kinovi/Midjourney needs this — when the caller +/// supplies reference images, we must upload them to the Seedance2Pro +/// CDN before sending the request. #[derive(Clone, Debug)] pub enum ImageGenerationDraftRequest { + KinoviMidjourney7(KinoviMidjourney7DraftState), + KinoviMidjourney7Niji(KinoviMidjourney7NijiDraftState), + KinoviMidjourney8(KinoviMidjourney8DraftState), } impl ImageGenerationDraftRequest { pub fn get_provider(&self) -> RouterProvider { - match *self {} + match self { + Self::KinoviMidjourney7(_) => RouterProvider::Seedance2Pro, + Self::KinoviMidjourney7Niji(_) => RouterProvider::Seedance2Pro, + Self::KinoviMidjourney8(_) => RouterProvider::Seedance2Pro, + } } pub fn estimate_cost(&self) -> Result { - match *self {} + match self { + Self::KinoviMidjourney7(draft) => Ok(KinoviMidjourney7CostState::from_draft(draft).estimate_cost()), + Self::KinoviMidjourney7Niji(draft) => Ok(KinoviMidjourney7NijiCostState::from_draft(draft).estimate_cost()), + Self::KinoviMidjourney8(draft) => Ok(KinoviMidjourney8CostState::from_draft(draft).estimate_cost()), + } } - pub async fn finalize(self, _draft_context: ImageGenerationDraftContext<'_>) -> Result { - match self {} + pub async fn finalize(self, draft_context: ImageGenerationDraftContext<'_>) -> Result { + match self { + Self::KinoviMidjourney7(mut draft) => { + let result = draft.to_request(&draft_context).await?; + Ok(ImageGenerationRequest::KinoviMidjourney7(result)) + } + Self::KinoviMidjourney7Niji(mut draft) => { + let result = draft.to_request(&draft_context).await?; + Ok(ImageGenerationRequest::KinoviMidjourney7Niji(result)) + } + Self::KinoviMidjourney8(mut draft) => { + let result = draft.to_request(&draft_context).await?; + Ok(ImageGenerationRequest::KinoviMidjourney8(result)) + } + } } } diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/image_generation_draft_context.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/image_generation_draft_context.rs index d8c8b78650..4d4656624a 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/image_generation_draft_context.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/image_generation_draft_context.rs @@ -1,18 +1,46 @@ +use std::collections::HashMap; use std::fmt::Debug; -/// Context passed when finalizing a draft into a concrete request. +use tokens::tokens::media_files::MediaFileToken; + +use crate::client::router_client::RouterClient; +use crate::client::router_seedance2pro_client::RouterSeedance2ProClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; + +/// Context passed when finalizing an image draft into a concrete request. /// -/// Currently empty — FAL doesn't need a draft phase so no context is required. -/// Future providers that need media uploads during finalization will add fields here -/// (e.g. a router client reference, media-file-to-URL maps, etc.). +/// Drafts only exist for providers that need to upload assets before +/// sending the request. Today that's the Seedance2Pro/Kinovi pipeline +/// (used for Midjourney image generation with reference images); other +/// providers — Artcraft, Fal — accept image URLs directly and skip the +/// draft phase entirely. #[derive(Clone, Default)] pub struct ImageGenerationDraftContext<'a> { - /// Prevent construction without Default; reserving the lifetime for future use. - _phantom: std::marker::PhantomData<&'a ()>, + /// Optional: the router client, needed for providers that require + /// authenticated calls (e.g. Seedance2Pro/Kinovi uploads). + pub client: Option<&'a RouterClient>, + + /// Optional: a map of Media File Tokens to their ArtCraft URLs. + /// Required only when the draft has unresolved `MediaFileToken` image + /// inputs that must be downloaded from ArtCraft and re-uploaded to the + /// target provider's CDN. + pub media_file_to_artcraft_url_map: Option<&'a HashMap>, +} + +impl<'a> ImageGenerationDraftContext<'a> { + pub fn get_seedance2pro_client_ref(&self) -> Result<&RouterSeedance2ProClient, ArtcraftRouterError> { + let client = self.client.ok_or(ArtcraftRouterError::Client(ClientError::RouterClientNotProvided))?; + client.get_seedance2pro_client_ref() + .map_err(ArtcraftRouterError::Client) + } } impl Debug for ImageGenerationDraftContext<'_> { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("ImageGenerationDraftContext").finish() + f.debug_struct("ImageGenerationDraftContext") + .field("client", &self.client.is_some()) + .field("media_file_to_artcraft_url_map", &self.media_file_to_artcraft_url_map.map(|m| m.len())) + .finish() } } diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/image_generation_request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/image_generation_request.rs index 2e3018312c..5714c4b439 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/image_generation_request.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/image_generation_request.rs @@ -19,6 +19,12 @@ use crate::generate::generate_image::providers::artcraft::gpt_image_1p5::cost::A use crate::generate::generate_image::providers::artcraft::gpt_image_1p5::request::ArtcraftGptImage1p5RequestState; use crate::generate::generate_image::providers::artcraft::gpt_image_2::cost::ArtcraftGptImage2CostState; use crate::generate::generate_image::providers::artcraft::gpt_image_2::request::ArtcraftGptImage2RequestState; +use crate::generate::generate_image::providers::artcraft::midjourney_7::cost::ArtcraftMidjourney7CostState; +use crate::generate::generate_image::providers::artcraft::midjourney_7::request::ArtcraftMidjourney7RequestState; +use crate::generate::generate_image::providers::artcraft::midjourney_7_niji::cost::ArtcraftMidjourney7NijiCostState; +use crate::generate::generate_image::providers::artcraft::midjourney_7_niji::request::ArtcraftMidjourney7NijiRequestState; +use crate::generate::generate_image::providers::artcraft::midjourney_8::cost::ArtcraftMidjourney8CostState; +use crate::generate::generate_image::providers::artcraft::midjourney_8::request::ArtcraftMidjourney8RequestState; use crate::generate::generate_image::providers::artcraft::nano_banana::cost::ArtcraftNanoBananaCostState; use crate::generate::generate_image::providers::artcraft::nano_banana::request::ArtcraftNanoBananaRequestState; use crate::generate::generate_image::providers::artcraft::nano_banana_2::cost::ArtcraftNanoBanana2CostState; @@ -63,6 +69,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 { @@ -82,6 +94,9 @@ pub enum ImageGenerationRequest { ArtcraftSeedream5Lite(ArtcraftSeedream5LiteRequestState), ArtcraftQwenEdit2511Angles(ArtcraftQwenEdit2511AnglesRequestState), ArtcraftFlux2LoraAngles(ArtcraftFlux2LoraAnglesRequestState), + ArtcraftMidjourney7(ArtcraftMidjourney7RequestState), + ArtcraftMidjourney7Niji(ArtcraftMidjourney7NijiRequestState), + ArtcraftMidjourney8(ArtcraftMidjourney8RequestState), // ── Fal provider ── FalFlux1Dev(FalFlux1DevRequestState), @@ -99,6 +114,11 @@ pub enum ImageGenerationRequest { FalSeedream5Lite(FalSeedream5LiteRequestState), FalQwenEdit2511Angles(FalQwenEdit2511AnglesRequestState), FalFlux2LoraAngles(FalFlux2LoraAnglesRequestState), + + // ── Kinovi / Seedance2Pro provider (Midjourney image generation) ── + KinoviMidjourney7(KinoviMidjourney7RequestState), + KinoviMidjourney7Niji(KinoviMidjourney7NijiRequestState), + KinoviMidjourney8(KinoviMidjourney8RequestState), } impl ImageGenerationRequest { @@ -119,6 +139,9 @@ impl ImageGenerationRequest { Self::ArtcraftSeedream5Lite(_) => RouterProvider::Artcraft, Self::ArtcraftQwenEdit2511Angles(_) => RouterProvider::Artcraft, Self::ArtcraftFlux2LoraAngles(_) => RouterProvider::Artcraft, + Self::ArtcraftMidjourney7(_) => RouterProvider::Artcraft, + Self::ArtcraftMidjourney7Niji(_) => RouterProvider::Artcraft, + Self::ArtcraftMidjourney8(_) => RouterProvider::Artcraft, Self::FalFlux1Dev(_) => RouterProvider::Fal, Self::FalFlux1Schnell(_) => RouterProvider::Fal, @@ -135,6 +158,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, } } @@ -186,6 +213,15 @@ impl ImageGenerationRequest { Self::ArtcraftFlux2LoraAngles(request) => { Ok(ArtcraftFlux2LoraAnglesCostState::from_request(request).estimate_cost()) } + Self::ArtcraftMidjourney7(request) => { + Ok(ArtcraftMidjourney7CostState::from_request(request).estimate_cost()) + } + Self::ArtcraftMidjourney7Niji(request) => { + Ok(ArtcraftMidjourney7NijiCostState::from_request(request).estimate_cost()) + } + Self::ArtcraftMidjourney8(request) => { + Ok(ArtcraftMidjourney8CostState::from_request(request).estimate_cost()) + } // ── Fal ── Self::FalFlux1Dev(request) => Ok(FalFlux1DevCostState::from_request(request).estimate_cost()), @@ -203,6 +239,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()), } } @@ -269,6 +309,18 @@ impl ImageGenerationRequest { let artcraft_client = client.get_artcraft_client_ref()?; request.send(artcraft_client).await } + Self::ArtcraftMidjourney7(request) => { + let artcraft_client = client.get_artcraft_client_ref()?; + request.send(artcraft_client).await + } + Self::ArtcraftMidjourney7Niji(request) => { + let artcraft_client = client.get_artcraft_client_ref()?; + request.send(artcraft_client).await + } + Self::ArtcraftMidjourney8(request) => { + let artcraft_client = client.get_artcraft_client_ref()?; + request.send(artcraft_client).await + } // ── Fal ── Self::FalFlux1Dev(request) => { @@ -336,6 +388,23 @@ impl ImageGenerationRequest { let fal_client = client.get_fal_client_ref()?; request.send(fal_client).await } + + // ── Kinovi / Seedance2Pro (Midjourney) ── + Self::KinoviMidjourney7(request) => { + let seedance_client = client.get_seedance2pro_client_ref() + .map_err(ArtcraftRouterError::Client)?; + request.send(seedance_client).await + } + Self::KinoviMidjourney7Niji(request) => { + let seedance_client = client.get_seedance2pro_client_ref() + .map_err(ArtcraftRouterError::Client)?; + request.send(seedance_client).await + } + Self::KinoviMidjourney8(request) => { + let seedance_client = client.get_seedance2pro_client_ref() + .map_err(ArtcraftRouterError::Client)?; + request.send(seedance_client).await + } } } } diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_7/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_7/build.rs new file mode 100644 index 0000000000..aea16c0779 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_7/build.rs @@ -0,0 +1,17 @@ +use enums::common::generation::common_image_model::CommonImageModel as CommonImageModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; +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::artcraft::build_common::build_artcraft_omni_image_request; +use crate::generate::generate_image::providers::artcraft::midjourney_7::request::ArtcraftMidjourney7RequestState; + +pub fn build_artcraft_midjourney_7( + builder: GenerateImageRequestBuilder, +) -> Result { + let request = build_artcraft_omni_image_request(builder, CommonImageModelEnum::Midjourney7)?; + Ok(ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::ArtcraftMidjourney7(ArtcraftMidjourney7RequestState { request }), + )) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_7/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_7/cost.rs new file mode 100644 index 0000000000..3d5116afbc --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_7/cost.rs @@ -0,0 +1,186 @@ +use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate; +use crate::generate::generate_image::providers::artcraft::midjourney_7::request::ArtcraftMidjourney7RequestState; + +/// Cost state for Artcraft Midjourney 7. Pricing mirrors the Kinovi +/// midjourney_7 provider 1:1 in USD cents — ArtCraft credits map cent-for-cent. +/// +/// | batch | credits | USD cents | +/// |-------|---------|-----------| +/// | 1 | 6 | 6¢ | +/// | 2 | 12 | 12¢ | +/// | 3 | 19 | 19¢ | (interpolated; Kinovi doesn't expose 3) +/// | 4 | 25 | 25¢ | +/// +/// Kinovi-side math: 12 Kinovi credits per Midjourney task; 193 Kinovi +/// credits per USD. Kinovi rounds to nearest cent, so batches 1/2/4 land +/// on 6/12/25. Batch 3 is `ceil((12 × 3 / 193) × 100) = 19`. +#[derive(Clone, Debug)] +pub struct ArtcraftMidjourney7CostState { + pub num_images: u16, +} + +impl ArtcraftMidjourney7CostState { + pub fn from_request(request: &ArtcraftMidjourney7RequestState) -> Self { + Self { + num_images: request.request.image_batch_count.unwrap_or(1), + } + } + + pub fn estimate_cost(&self) -> ImageGenerationCostEstimate { + let cost_in_usd_cents = midjourney_cost_for_batch(self.num_images); + ImageGenerationCostEstimate { + cost_in_credits: Some(cost_in_usd_cents), + 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, + } + } +} + +/// Returns the customer-facing midjourney cost in USD cents for a given +/// batch count. Shared between v7, v7-niji, and v8 (all three Midjourney +/// models have identical pricing on Kinovi). +pub(crate) fn midjourney_cost_for_batch(batch_count: u16) -> u64 { + match batch_count { + 0 => 0, // Defensive; the builder rejects zero before reaching cost. + 1 => 6, + 2 => 12, + 3 => 19, + 4 => 25, + // Should never happen — `plan_batch_count` clamps to 1..=4. + _ => 25, + } +} + +#[cfg(test)] +mod tests { + use super::*; + 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; + + fn cost_cents(image_batch_count: u16) -> u64 { + let builder = GenerateImageRequestBuilder { + model: RouterImageModel::Midjourney7, + provider: RouterProvider::Artcraft, + prompt: Some("a corgi astronaut".to_string()), + image_inputs: None, + resolution: None, + aspect_ratio: None, + quality: None, + image_batch_count: Some(image_batch_count), + horizontal_angle: None, + vertical_angle: None, + zoom: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + }; + builder.build2().unwrap().estimate_cost().unwrap().cost_in_usd_cents.unwrap() + } + + fn cost_credits(image_batch_count: u16) -> u64 { + let builder = GenerateImageRequestBuilder { + model: RouterImageModel::Midjourney7, + provider: RouterProvider::Artcraft, + prompt: Some("a corgi astronaut".to_string()), + image_inputs: None, + resolution: None, + aspect_ratio: None, + quality: None, + image_batch_count: Some(image_batch_count), + horizontal_angle: None, + vertical_angle: None, + zoom: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + }; + builder.build2().unwrap().estimate_cost().unwrap().cost_in_credits.unwrap() + } + + // ── Spot checks: each supported batch size ── + + #[test] + fn one_image_is_six_cents() { assert_eq!(cost_cents(1), 6); } + + #[test] + fn two_images_is_twelve_cents() { assert_eq!(cost_cents(2), 12); } + + #[test] + fn three_images_is_nineteen_cents() { assert_eq!(cost_cents(3), 19); } + + #[test] + fn four_images_is_twentyfive_cents() { assert_eq!(cost_cents(4), 25); } + + // ── Credits track USD cents exactly (1 credit = 1 cent) ── + + #[test] + fn credits_equal_usd_cents() { + for batch in 1..=4u16 { + assert_eq!(cost_credits(batch), cost_cents(batch), + "credits should equal usd cents at batch {}", batch); + } + } + + // ── 1:1 cost parity with Kinovi for batches 1/2/4 ── + // + // Kinovi midjourney_7 returns 6/12/25¢ for batches 1/2/4. The artcraft + // provider must match those values byte-for-byte so users pay the same. + + mod kinovi_parity { + use super::*; + use crate::generate::generate_image::providers::kinovi::midjourney_7::cost::KinoviMidjourney7CostState; + use seedance2pro_client::generate::image::generate_midjourney_v7::KinoviMidjourneyBatchCount; + + #[test] + fn batch_1_matches_kinovi() { + let kinovi = KinoviMidjourney7CostState { batch_count: KinoviMidjourneyBatchCount::One } + .estimate_cost(); + assert_eq!(cost_cents(1), kinovi.cost_in_usd_cents.unwrap()); + } + + #[test] + fn batch_2_matches_kinovi() { + let kinovi = KinoviMidjourney7CostState { batch_count: KinoviMidjourneyBatchCount::Two } + .estimate_cost(); + assert_eq!(cost_cents(2), kinovi.cost_in_usd_cents.unwrap()); + } + + #[test] + fn batch_4_matches_kinovi() { + let kinovi = KinoviMidjourney7CostState { batch_count: KinoviMidjourneyBatchCount::Four } + .estimate_cost(); + assert_eq!(cost_cents(4), kinovi.cost_in_usd_cents.unwrap()); + } + } + + // ── Monotonicity ── + + #[test] + fn cost_is_monotonically_increasing() { + let c1 = cost_cents(1); + let c2 = cost_cents(2); + let c3 = cost_cents(3); + let c4 = cost_cents(4); + assert!(c1 < c2, "{} < {}", c1, c2); + assert!(c2 < c3, "{} < {}", c2, c3); + assert!(c3 < c4, "{} < {}", c3, c4); + } + + // ── Flags ── + + #[test] + fn cost_flags_are_correct() { + let cost = ArtcraftMidjourney7CostState { num_images: 1 }.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); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_7/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_7/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_7/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_7/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_7/request.rs new file mode 100644 index 0000000000..ab97947edc --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_7/request.rs @@ -0,0 +1,17 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::OmniGenImageCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_image::generate_image_response::GenerateImageResponse; +use crate::generate::generate_image::providers::artcraft::request_common::send_artcraft_omni_image_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftMidjourney7RequestState { + pub request: OmniGenImageCostAndGenerateRequest, +} + +impl ArtcraftMidjourney7RequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_image_request(&self.request, client).await + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_7_niji/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_7_niji/build.rs new file mode 100644 index 0000000000..d5d3e0e945 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_7_niji/build.rs @@ -0,0 +1,17 @@ +use enums::common::generation::common_image_model::CommonImageModel as CommonImageModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; +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::artcraft::build_common::build_artcraft_omni_image_request; +use crate::generate::generate_image::providers::artcraft::midjourney_7_niji::request::ArtcraftMidjourney7NijiRequestState; + +pub fn build_artcraft_midjourney_7_niji( + builder: GenerateImageRequestBuilder, +) -> Result { + let request = build_artcraft_omni_image_request(builder, CommonImageModelEnum::Midjourney7Niji)?; + Ok(ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::ArtcraftMidjourney7Niji(ArtcraftMidjourney7NijiRequestState { request }), + )) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_7_niji/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_7_niji/cost.rs new file mode 100644 index 0000000000..cc8fd55ca1 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_7_niji/cost.rs @@ -0,0 +1,113 @@ +use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate; +use crate::generate::generate_image::providers::artcraft::midjourney_7::cost::midjourney_cost_for_batch; +use crate::generate::generate_image::providers::artcraft::midjourney_7_niji::request::ArtcraftMidjourney7NijiRequestState; + +/// Cost state for Artcraft Midjourney 7 Niji. Pricing is identical to v7 +/// and v8 on Kinovi, so we share the per-batch table via +/// `midjourney_cost_for_batch`. +#[derive(Clone, Debug)] +pub struct ArtcraftMidjourney7NijiCostState { + pub num_images: u16, +} + +impl ArtcraftMidjourney7NijiCostState { + pub fn from_request(request: &ArtcraftMidjourney7NijiRequestState) -> Self { + Self { + num_images: request.request.image_batch_count.unwrap_or(1), + } + } + + pub fn estimate_cost(&self) -> ImageGenerationCostEstimate { + let cost_in_usd_cents = midjourney_cost_for_batch(self.num_images); + ImageGenerationCostEstimate { + cost_in_credits: Some(cost_in_usd_cents), + 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::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; + + fn cost_cents(image_batch_count: u16) -> u64 { + let builder = GenerateImageRequestBuilder { + model: RouterImageModel::Midjourney7Niji, + provider: RouterProvider::Artcraft, + prompt: Some("anime forest".to_string()), + image_inputs: None, + resolution: None, + aspect_ratio: None, + quality: None, + image_batch_count: Some(image_batch_count), + horizontal_angle: None, + vertical_angle: None, + zoom: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + }; + builder.build2().unwrap().estimate_cost().unwrap().cost_in_usd_cents.unwrap() + } + + #[test] + fn batch_1_is_6() { assert_eq!(cost_cents(1), 6); } + + #[test] + fn batch_2_is_12() { assert_eq!(cost_cents(2), 12); } + + #[test] + fn batch_3_is_19() { assert_eq!(cost_cents(3), 19); } + + #[test] + fn batch_4_is_25() { assert_eq!(cost_cents(4), 25); } + + #[test] + fn niji_pricing_matches_v7() { + use crate::generate::generate_image::providers::artcraft::midjourney_7::cost::ArtcraftMidjourney7CostState; + for batch in 1..=4u16 { + let v7 = ArtcraftMidjourney7CostState { num_images: batch }.estimate_cost(); + let niji = ArtcraftMidjourney7NijiCostState { num_images: batch }.estimate_cost(); + assert_eq!(v7.cost_in_usd_cents, niji.cost_in_usd_cents, "batch {}", batch); + assert_eq!(v7.cost_in_credits, niji.cost_in_credits, "batch {}", batch); + } + } + + // ── 1:1 cost parity with Kinovi 7-niji ── + + mod kinovi_parity { + use super::*; + use crate::generate::generate_image::providers::kinovi::midjourney_7_niji::cost::KinoviMidjourney7NijiCostState; + use seedance2pro_client::generate::image::generate_midjourney_v7_niji::KinoviMidjourneyBatchCount; + + #[test] + fn batch_1_matches_kinovi() { + let kinovi = KinoviMidjourney7NijiCostState { batch_count: KinoviMidjourneyBatchCount::One } + .estimate_cost(); + assert_eq!(cost_cents(1), kinovi.cost_in_usd_cents.unwrap()); + } + + #[test] + fn batch_2_matches_kinovi() { + let kinovi = KinoviMidjourney7NijiCostState { batch_count: KinoviMidjourneyBatchCount::Two } + .estimate_cost(); + assert_eq!(cost_cents(2), kinovi.cost_in_usd_cents.unwrap()); + } + + #[test] + fn batch_4_matches_kinovi() { + let kinovi = KinoviMidjourney7NijiCostState { batch_count: KinoviMidjourneyBatchCount::Four } + .estimate_cost(); + assert_eq!(cost_cents(4), kinovi.cost_in_usd_cents.unwrap()); + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_7_niji/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_7_niji/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_7_niji/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_7_niji/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_7_niji/request.rs new file mode 100644 index 0000000000..d56f3291ce --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_7_niji/request.rs @@ -0,0 +1,17 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::OmniGenImageCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_image::generate_image_response::GenerateImageResponse; +use crate::generate::generate_image::providers::artcraft::request_common::send_artcraft_omni_image_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftMidjourney7NijiRequestState { + pub request: OmniGenImageCostAndGenerateRequest, +} + +impl ArtcraftMidjourney7NijiRequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_image_request(&self.request, client).await + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_8/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_8/build.rs new file mode 100644 index 0000000000..825ecced6a --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_8/build.rs @@ -0,0 +1,17 @@ +use enums::common::generation::common_image_model::CommonImageModel as CommonImageModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; +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::artcraft::build_common::build_artcraft_omni_image_request; +use crate::generate::generate_image::providers::artcraft::midjourney_8::request::ArtcraftMidjourney8RequestState; + +pub fn build_artcraft_midjourney_8( + builder: GenerateImageRequestBuilder, +) -> Result { + let request = build_artcraft_omni_image_request(builder, CommonImageModelEnum::Midjourney8)?; + Ok(ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::ArtcraftMidjourney8(ArtcraftMidjourney8RequestState { request }), + )) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_8/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_8/cost.rs new file mode 100644 index 0000000000..6ab6e0759f --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_8/cost.rs @@ -0,0 +1,113 @@ +use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate; +use crate::generate::generate_image::providers::artcraft::midjourney_7::cost::midjourney_cost_for_batch; +use crate::generate::generate_image::providers::artcraft::midjourney_8::request::ArtcraftMidjourney8RequestState; + +/// Cost state for Artcraft Midjourney 8. Pricing is identical to v7 and +/// v7-niji on Kinovi; shares the per-batch table via +/// `midjourney_cost_for_batch`. +#[derive(Clone, Debug)] +pub struct ArtcraftMidjourney8CostState { + pub num_images: u16, +} + +impl ArtcraftMidjourney8CostState { + pub fn from_request(request: &ArtcraftMidjourney8RequestState) -> Self { + Self { + num_images: request.request.image_batch_count.unwrap_or(1), + } + } + + pub fn estimate_cost(&self) -> ImageGenerationCostEstimate { + let cost_in_usd_cents = midjourney_cost_for_batch(self.num_images); + ImageGenerationCostEstimate { + cost_in_credits: Some(cost_in_usd_cents), + 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::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; + + fn cost_cents(image_batch_count: u16) -> u64 { + let builder = GenerateImageRequestBuilder { + model: RouterImageModel::Midjourney8, + provider: RouterProvider::Artcraft, + prompt: Some("a haunted mansion".to_string()), + image_inputs: None, + resolution: None, + aspect_ratio: None, + quality: None, + image_batch_count: Some(image_batch_count), + horizontal_angle: None, + vertical_angle: None, + zoom: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + }; + builder.build2().unwrap().estimate_cost().unwrap().cost_in_usd_cents.unwrap() + } + + #[test] + fn batch_1_is_6() { assert_eq!(cost_cents(1), 6); } + + #[test] + fn batch_2_is_12() { assert_eq!(cost_cents(2), 12); } + + #[test] + fn batch_3_is_19() { assert_eq!(cost_cents(3), 19); } + + #[test] + fn batch_4_is_25() { assert_eq!(cost_cents(4), 25); } + + #[test] + fn v8_pricing_matches_v7() { + use crate::generate::generate_image::providers::artcraft::midjourney_7::cost::ArtcraftMidjourney7CostState; + for batch in 1..=4u16 { + let v7 = ArtcraftMidjourney7CostState { num_images: batch }.estimate_cost(); + let v8 = ArtcraftMidjourney8CostState { num_images: batch }.estimate_cost(); + assert_eq!(v7.cost_in_usd_cents, v8.cost_in_usd_cents, "batch {}", batch); + assert_eq!(v7.cost_in_credits, v8.cost_in_credits, "batch {}", batch); + } + } + + // ── 1:1 cost parity with Kinovi v8 ── + + mod kinovi_parity { + use super::*; + use crate::generate::generate_image::providers::kinovi::midjourney_8::cost::KinoviMidjourney8CostState; + use seedance2pro_client::generate::image::generate_midjourney_v8::KinoviMidjourneyBatchCount; + + #[test] + fn batch_1_matches_kinovi() { + let kinovi = KinoviMidjourney8CostState { batch_count: KinoviMidjourneyBatchCount::One } + .estimate_cost(); + assert_eq!(cost_cents(1), kinovi.cost_in_usd_cents.unwrap()); + } + + #[test] + fn batch_2_matches_kinovi() { + let kinovi = KinoviMidjourney8CostState { batch_count: KinoviMidjourneyBatchCount::Two } + .estimate_cost(); + assert_eq!(cost_cents(2), kinovi.cost_in_usd_cents.unwrap()); + } + + #[test] + fn batch_4_matches_kinovi() { + let kinovi = KinoviMidjourney8CostState { batch_count: KinoviMidjourneyBatchCount::Four } + .estimate_cost(); + assert_eq!(cost_cents(4), kinovi.cost_in_usd_cents.unwrap()); + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_8/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_8/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_8/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_8/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_8/request.rs new file mode 100644 index 0000000000..1f0bd730bf --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/midjourney_8/request.rs @@ -0,0 +1,17 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::OmniGenImageCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_image::generate_image_response::GenerateImageResponse; +use crate::generate::generate_image::providers::artcraft::request_common::send_artcraft_omni_image_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftMidjourney8RequestState { + pub request: OmniGenImageCostAndGenerateRequest, +} + +impl ArtcraftMidjourney8RequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_image_request(&self.request, client).await + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/mod.rs index 129daf541d..727384ef8a 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/mod.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/artcraft/mod.rs @@ -8,6 +8,9 @@ pub mod flux_pro_1p1_ultra; pub mod gpt_image_1; pub mod gpt_image_1p5; pub mod gpt_image_2; +pub mod midjourney_7; +pub mod midjourney_7_niji; +pub mod midjourney_8; pub mod nano_banana; pub mod nano_banana_2; pub mod nano_banana_pro; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7/build.rs new file mode 100644 index 0000000000..dd4ab6a629 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7/build.rs @@ -0,0 +1,460 @@ +use seedance2pro_client::generate::image::generate_midjourney_v7::{ + GenerateMidjourneyV7AspectRatio, GenerateMidjourneyV7Quality, + GenerateMidjourneyV7Request, KinoviMidjourneyBatchCount, +}; + +use crate::api::image_list_ref::ImageListRef; +use crate::api::router_aspect_ratio::RouterAspectRatio; +use crate::api::router_quality::RouterQuality; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; +use crate::generate::generate_image::image_generation_draft::ImageGenerationDraftRequest; +use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest; +use crate::generate::generate_image::image_generation_request::ImageGenerationRequest; +use crate::generate::generate_image::providers::kinovi::midjourney_7::draft::{ + KinoviMidjourney7DraftState, KinoviMidjourney7RemainingItems, +}; +use crate::generate::generate_image::providers::kinovi::midjourney_7::request::KinoviMidjourney7RequestState; + +pub fn build_kinovi_midjourney_7( + mut builder: GenerateImageRequestBuilder, +) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio.take(), strategy)?; + let quality = plan_quality(builder.quality.take()); + let batch_count = plan_batch_count(builder.image_batch_count.take(), strategy)?; + let prompt = builder.prompt.take().unwrap_or_default(); + let image_inputs = builder.image_inputs.take(); + + // No image inputs → we can build the final request immediately. No + // draft/upload phase needed. + if !has_image_inputs(image_inputs.as_ref()) { + let request = GenerateMidjourneyV7Request { + prompt, + aspect_ratio, + negative_prompt: None, + stylize: None, + weird: None, + chaos: None, + quality, + raw_mode: false, + batch_count, + reference_image_urls: None, + }; + return Ok(ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::KinoviMidjourney7(KinoviMidjourney7RequestState { request }), + )); + } + + // Image inputs present → must go through the draft phase so we can + // upload each image to the Seedance2Pro CDN before sending. + let draft = KinoviMidjourney7DraftState { + prompt, + aspect_ratio, + quality, + batch_count, + unhandled_request_state: Some(KinoviMidjourney7RemainingItems { + reference_images: image_inputs, + }), + }; + Ok(ImageGenerationDraftOrRequest::Draft( + ImageGenerationDraftRequest::KinoviMidjourney7(draft), + )) +} + +fn has_image_inputs(image_inputs: Option<&ImageListRef>) -> bool { + match image_inputs { + None => false, + Some(ImageListRef::Urls(urls)) => !urls.is_empty(), + Some(ImageListRef::MediaFileTokens(tokens)) => !tokens.is_empty(), + } +} + +// ── Plan helpers ── + +pub(crate) fn plan_aspect_ratio( + aspect_ratio: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + use GenerateMidjourneyV7AspectRatio as Ar; + match aspect_ratio { + // No preference or auto → square, matching Midjourney's own default for new + // jobs in their UI. + None + | Some(RouterAspectRatio::Auto) + | Some(RouterAspectRatio::Auto2k) + | Some(RouterAspectRatio::Auto3k) + | Some(RouterAspectRatio::Auto4k) => Ok(Ar::Square1x1), + + // Direct mappings — Midjourney supports every common ratio. + Some(RouterAspectRatio::Square) | Some(RouterAspectRatio::SquareHd) => Ok(Ar::Square1x1), + Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Ar::Landscape16x9), + Some(RouterAspectRatio::WideTwentyOneByNine) => Ok(Ar::UltraWide21x9), + Some(RouterAspectRatio::WideFourByThree) => Ok(Ar::Standard4x3), + Some(RouterAspectRatio::WideFiveByFour) => Ok(Ar::Wide5x4), + Some(RouterAspectRatio::WideThreeByTwo) => Ok(Ar::Wide3x2), + Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Ar::Portrait9x16), + Some(RouterAspectRatio::TallNineByTwentyOne) => Ok(Ar::UltraTall9x21), + Some(RouterAspectRatio::TallThreeByFour) => Ok(Ar::Portrait3x4), + Some(RouterAspectRatio::TallFourByFive) => Ok(Ar::Tall4x5), + Some(RouterAspectRatio::TallTwoByThree) => Ok(Ar::Tall2x3), + + // Every aspect ratio listed in `RouterAspectRatio` has a direct mapping; + // the catch-all is reserved for future variants. + #[allow(unreachable_patterns)] + Some(unsupported) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "aspect_ratio", + value: format!("{:?}", unsupported), + })) + } + _ => Ok(Ar::Square1x1), + }, + } +} + +pub(crate) fn plan_quality(quality: Option) -> Option { + // Midjourney's quality knob is a compute-budget dial that doesn't change + // Kinovi pricing — pass through whatever the caller asked for, and let + // `None` ride Midjourney's server-side default. + match quality { + None => None, + Some(RouterQuality::Low) => Some(GenerateMidjourneyV7Quality::Quarter), + Some(RouterQuality::Medium) => Some(GenerateMidjourneyV7Quality::Half), + Some(RouterQuality::High) => Some(GenerateMidjourneyV7Quality::Full), + } +} + +pub(crate) fn plan_batch_count( + image_batch_count: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + let count = image_batch_count.unwrap_or(1); + match count { + 0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)), + 1 => Ok(KinoviMidjourneyBatchCount::One), + 2 => Ok(KinoviMidjourneyBatchCount::Two), + 4 => Ok(KinoviMidjourneyBatchCount::Four), + other => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "image_batch_count", + value: format!("{}", other), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade => { + // 3 → 4, anything > 4 → 4 + Ok(if other < 3 { KinoviMidjourneyBatchCount::Two } else { KinoviMidjourneyBatchCount::Four }) + } + RequestMismatchMitigationStrategy::PayLessDowngrade => { + // 3 → 2, anything > 4 → 4 + Ok(if other < 4 { KinoviMidjourneyBatchCount::Two } else { KinoviMidjourneyBatchCount::Four }) + } + }, + } +} + +#[cfg(test)] +mod tests { + use seedance2pro_client::generate::image::generate_midjourney_v7::KinoviMidjourneyBatchCount; + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_aspect_ratio::RouterAspectRatio; + use crate::api::router_image_model::RouterImageModel; + use crate::api::router_provider::RouterProvider; + use crate::api::router_quality::RouterQuality; + use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; + use crate::generate::generate_image::image_generation_draft::ImageGenerationDraftRequest; + use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest; + use crate::generate::generate_image::image_generation_request::ImageGenerationRequest; + + use super::*; + + fn base_builder() -> GenerateImageRequestBuilder { + GenerateImageRequestBuilder { + model: RouterImageModel::Midjourney7, + provider: RouterProvider::Seedance2Pro, + prompt: Some("a corgi astronaut".to_string()), + image_inputs: None, + resolution: None, + aspect_ratio: None, + quality: None, + image_batch_count: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + } + } + + // ── Mode dispatch: no image inputs → Request; with → Draft ── + + mod mode_dispatch { + use super::*; + + #[test] + fn no_image_inputs_returns_request_directly() { + let result = build_kinovi_midjourney_7(base_builder()).expect("build"); + assert!(matches!( + result, + ImageGenerationDraftOrRequest::Request(ImageGenerationRequest::KinoviMidjourney7(_)), + )); + } + + #[test] + fn empty_image_inputs_returns_request_directly() { + let builder = GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::Urls(vec![])), + ..base_builder() + }; + let result = build_kinovi_midjourney_7(builder).expect("build"); + assert!(matches!( + result, + ImageGenerationDraftOrRequest::Request(ImageGenerationRequest::KinoviMidjourney7(_)), + )); + } + + #[test] + fn image_url_inputs_returns_draft() { + let builder = GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/ref.png".to_string()])), + ..base_builder() + }; + let result = build_kinovi_midjourney_7(builder).expect("build"); + assert!(matches!( + result, + ImageGenerationDraftOrRequest::Draft(ImageGenerationDraftRequest::KinoviMidjourney7(_)), + )); + } + + #[test] + fn image_token_inputs_returns_draft() { + let builder = GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_test".to_string()), + ])), + ..base_builder() + }; + let result = build_kinovi_midjourney_7(builder).expect("build"); + assert!(matches!( + result, + ImageGenerationDraftOrRequest::Draft(ImageGenerationDraftRequest::KinoviMidjourney7(_)), + )); + } + + #[test] + fn draft_remembers_image_inputs() { + let builder = GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::Urls(vec![ + "https://example.com/a.png".to_string(), + "https://example.com/b.png".to_string(), + ])), + ..base_builder() + }; + let draft = unwrap_draft(build_kinovi_midjourney_7(builder)); + let remaining = draft.unhandled_request_state.as_ref().unwrap(); + let urls = match remaining.reference_images.as_ref().unwrap() { + ImageListRef::Urls(urls) => urls, + _ => panic!("expected Urls"), + }; + assert_eq!(urls.len(), 2); + } + } + + // ── Field passthrough ── + + mod field_passthrough { + use super::*; + + #[test] + fn prompt_is_passed_through() { + let req = unwrap_direct_request(build_kinovi_midjourney_7(base_builder())); + assert_eq!(req.request.prompt, "a corgi astronaut"); + } + + #[test] + fn prompt_defaults_to_empty() { + let builder = GenerateImageRequestBuilder { prompt: None, ..base_builder() }; + let req = unwrap_direct_request(build_kinovi_midjourney_7(builder)); + assert_eq!(req.request.prompt, ""); + } + } + + // ── Aspect ratio ── + + mod aspect_ratio_tests { + use super::*; + use seedance2pro_client::generate::image::generate_midjourney_v7::GenerateMidjourneyV7AspectRatio as Ar; + + fn aspect_ratio_for(input: Option) -> Ar { + let builder = GenerateImageRequestBuilder { aspect_ratio: input, ..base_builder() }; + unwrap_direct_request(build_kinovi_midjourney_7(builder)).request.aspect_ratio + } + + #[test] + fn none_defaults_to_square() { + assert_eq!(aspect_ratio_for(None), Ar::Square1x1); + } + + #[test] + fn auto_defaults_to_square() { + assert_eq!(aspect_ratio_for(Some(RouterAspectRatio::Auto)), Ar::Square1x1); + assert_eq!(aspect_ratio_for(Some(RouterAspectRatio::Auto2k)), Ar::Square1x1); + assert_eq!(aspect_ratio_for(Some(RouterAspectRatio::Auto3k)), Ar::Square1x1); + assert_eq!(aspect_ratio_for(Some(RouterAspectRatio::Auto4k)), Ar::Square1x1); + } + + #[test] + fn every_ratio_maps_directly() { + assert_eq!(aspect_ratio_for(Some(RouterAspectRatio::Square)), Ar::Square1x1); + assert_eq!(aspect_ratio_for(Some(RouterAspectRatio::SquareHd)), Ar::Square1x1); + assert_eq!(aspect_ratio_for(Some(RouterAspectRatio::WideSixteenByNine)), Ar::Landscape16x9); + assert_eq!(aspect_ratio_for(Some(RouterAspectRatio::WideTwentyOneByNine)), Ar::UltraWide21x9); + assert_eq!(aspect_ratio_for(Some(RouterAspectRatio::WideFourByThree)), Ar::Standard4x3); + assert_eq!(aspect_ratio_for(Some(RouterAspectRatio::WideFiveByFour)), Ar::Wide5x4); + assert_eq!(aspect_ratio_for(Some(RouterAspectRatio::WideThreeByTwo)), Ar::Wide3x2); + assert_eq!(aspect_ratio_for(Some(RouterAspectRatio::Wide)), Ar::Landscape16x9); + assert_eq!(aspect_ratio_for(Some(RouterAspectRatio::TallNineBySixteen)), Ar::Portrait9x16); + assert_eq!(aspect_ratio_for(Some(RouterAspectRatio::TallNineByTwentyOne)), Ar::UltraTall9x21); + assert_eq!(aspect_ratio_for(Some(RouterAspectRatio::TallThreeByFour)), Ar::Portrait3x4); + assert_eq!(aspect_ratio_for(Some(RouterAspectRatio::TallFourByFive)), Ar::Tall4x5); + assert_eq!(aspect_ratio_for(Some(RouterAspectRatio::TallTwoByThree)), Ar::Tall2x3); + assert_eq!(aspect_ratio_for(Some(RouterAspectRatio::Tall)), Ar::Portrait9x16); + } + } + + // ── Quality ── + + mod quality_tests { + use super::*; + use seedance2pro_client::generate::image::generate_midjourney_v7::GenerateMidjourneyV7Quality as Q; + + fn quality_for(input: Option) -> Option { + let builder = GenerateImageRequestBuilder { quality: input, ..base_builder() }; + unwrap_direct_request(build_kinovi_midjourney_7(builder)).request.quality + } + + #[test] + fn none_passes_through_as_none() { + assert_eq!(quality_for(None), None); + } + + #[test] + fn low_maps_to_quarter() { + assert_eq!(quality_for(Some(RouterQuality::Low)), Some(Q::Quarter)); + } + + #[test] + fn medium_maps_to_half() { + assert_eq!(quality_for(Some(RouterQuality::Medium)), Some(Q::Half)); + } + + #[test] + fn high_maps_to_full() { + assert_eq!(quality_for(Some(RouterQuality::High)), Some(Q::Full)); + } + } + + // ── Batch count ── + + mod batch_count_tests { + use super::*; + + fn batch_for(count: Option) -> KinoviMidjourneyBatchCount { + let builder = GenerateImageRequestBuilder { image_batch_count: count, ..base_builder() }; + unwrap_direct_request(build_kinovi_midjourney_7(builder)).request.batch_count + } + + #[test] + fn none_defaults_to_one() { + assert_eq!(batch_for(None), KinoviMidjourneyBatchCount::One); + } + + #[test] + fn one_two_four_map_directly() { + assert_eq!(batch_for(Some(1)), KinoviMidjourneyBatchCount::One); + assert_eq!(batch_for(Some(2)), KinoviMidjourneyBatchCount::Two); + assert_eq!(batch_for(Some(4)), KinoviMidjourneyBatchCount::Four); + } + + #[test] + fn zero_is_rejected() { + let builder = GenerateImageRequestBuilder { image_batch_count: Some(0), ..base_builder() }; + assert!(matches!( + build_kinovi_midjourney_7(builder), + Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)), + )); + } + + #[test] + fn three_errors_under_strict() { + let builder = GenerateImageRequestBuilder { + image_batch_count: Some(3), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + ..base_builder() + }; + assert!(matches!( + build_kinovi_midjourney_7(builder), + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { .. })), + )); + } + + #[test] + fn three_upgrades_to_four_with_pay_more() { + let builder = GenerateImageRequestBuilder { + image_batch_count: Some(3), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade, + ..base_builder() + }; + assert_eq!( + unwrap_direct_request(build_kinovi_midjourney_7(builder)).request.batch_count, + KinoviMidjourneyBatchCount::Four, + ); + } + + #[test] + fn three_downgrades_to_two_with_pay_less() { + let builder = GenerateImageRequestBuilder { + image_batch_count: Some(3), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayLessDowngrade, + ..base_builder() + }; + assert_eq!( + unwrap_direct_request(build_kinovi_midjourney_7(builder)).request.batch_count, + KinoviMidjourneyBatchCount::Two, + ); + } + } + + // ── Helpers ── + + fn unwrap_direct_request( + result: Result, + ) -> KinoviMidjourney7RequestState { + match result.expect("build should succeed") { + ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::KinoviMidjourney7(req), + ) => req, + other => panic!("expected KinoviMidjourney7 Request, got {:?}", other), + } + } + + fn unwrap_draft( + result: Result, + ) -> KinoviMidjourney7DraftState { + match result.expect("build should succeed") { + ImageGenerationDraftOrRequest::Draft( + ImageGenerationDraftRequest::KinoviMidjourney7(draft), + ) => draft, + other => panic!("expected KinoviMidjourney7 Draft, got {:?}", other), + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7/cost.rs new file mode 100644 index 0000000000..e1d7faae86 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7/cost.rs @@ -0,0 +1,211 @@ +use seedance2pro_client::generate::image::generate_midjourney_v7::{ + GenerateMidjourneyV7AspectRatio, GenerateMidjourneyV7Request, KinoviMidjourneyBatchCount, +}; + +use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate; +use crate::generate::generate_image::providers::kinovi::midjourney_7::draft::KinoviMidjourney7DraftState; +use crate::generate::generate_image::providers::kinovi::midjourney_7::request::KinoviMidjourney7RequestState; + +/// Midjourney v7 (via Kinovi) cost state. Only `batch_count` matters for +/// pricing — Midjourney pricing is flat per task regardless of aspect +/// ratio, quality, or whether reference images were supplied. +pub struct KinoviMidjourney7CostState { + pub batch_count: KinoviMidjourneyBatchCount, +} + +impl KinoviMidjourney7CostState { + pub fn from_request(request: &KinoviMidjourney7RequestState) -> Self { + Self { batch_count: request.request.batch_count } + } + + pub fn from_draft(draft: &KinoviMidjourney7DraftState) -> Self { + Self { batch_count: draft.batch_count } + } + + pub fn estimate_cost(&self) -> ImageGenerationCostEstimate { + let pricing_request = GenerateMidjourneyV7Request { + batch_count: self.batch_count, + // Cost-irrelevant placeholders below — only `batch_count` matters. + prompt: String::new(), + aspect_ratio: GenerateMidjourneyV7AspectRatio::Square1x1, + negative_prompt: None, + stylize: None, + weird: None, + chaos: None, + quality: None, + raw_mode: false, + reference_image_urls: None, + }; + + let cost_in_credits = pricing_request.estimate_credits() as u64; + let cost_in_usd_cents = pricing_request.estimate_cost_in_usd_cents(); + + ImageGenerationCostEstimate { + cost_in_credits: Some(cost_in_credits), + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_image_model::RouterImageModel; + use crate::api::router_provider::RouterProvider; + use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; + use crate::generate::generate_image::image_generation_draft::ImageGenerationDraftRequest; + use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest; + use crate::generate::generate_image::image_generation_request::ImageGenerationRequest; + + // ── Credit pricing (12 per task × batch) ── + + mod credits { + use super::*; + + #[test] + fn batch_one_is_twelve_credits() { + assert_eq!(credits_for(KinoviMidjourneyBatchCount::One), 12); + } + + #[test] + fn batch_two_is_twentyfour_credits() { + assert_eq!(credits_for(KinoviMidjourneyBatchCount::Two), 24); + } + + #[test] + fn batch_four_is_fortyeight_credits() { + assert_eq!(credits_for(KinoviMidjourneyBatchCount::Four), 48); + } + } + + // ── USD pricing (193 credits per dollar; rounded) ── + + mod usd { + use super::*; + + #[test] + fn batch_one_is_six_cents() { + // 12 / 193 × 100 = 6.21 → 6¢ + assert_eq!(usd_cents_for(KinoviMidjourneyBatchCount::One), 6); + } + + #[test] + fn batch_two_is_twelve_cents() { + // 24 / 193 × 100 = 12.44 → 12¢ + assert_eq!(usd_cents_for(KinoviMidjourneyBatchCount::Two), 12); + } + + #[test] + fn batch_four_is_twentyfive_cents() { + // 48 / 193 × 100 = 24.87 → 25¢ + assert_eq!(usd_cents_for(KinoviMidjourneyBatchCount::Four), 25); + } + } + + // ── Flags ── + + #[test] + fn flags_are_correct() { + let cost = KinoviMidjourney7CostState { batch_count: KinoviMidjourneyBatchCount::One } + .estimate_cost(); + assert!(!cost.is_free); + assert!(!cost.is_unlimited); + assert!(!cost.is_rate_limited); + assert!(!cost.has_watermark); + assert_eq!(cost.failures_are_refunded, None); + } + + // ── from_request / from_draft ── + + mod source_state_round_trip { + use super::*; + + #[test] + fn from_request_picks_up_batch_count() { + let req = build_request_no_image_inputs(2); + let state = KinoviMidjourney7CostState::from_request(&req); + assert_eq!(state.batch_count, KinoviMidjourneyBatchCount::Two); + assert_eq!(state.estimate_cost().cost_in_credits, Some(24)); + } + + #[test] + fn from_draft_picks_up_batch_count() { + let draft = build_draft_with_image_inputs(4); + let state = KinoviMidjourney7CostState::from_draft(&draft); + assert_eq!(state.batch_count, KinoviMidjourneyBatchCount::Four); + assert_eq!(state.estimate_cost().cost_in_credits, Some(48)); + } + + #[test] + fn draft_and_request_produce_same_cost() { + // Same batch count → same cost, regardless of draft vs request path. + let req_cost = KinoviMidjourney7CostState::from_request(&build_request_no_image_inputs(2)) + .estimate_cost(); + let draft_cost = KinoviMidjourney7CostState::from_draft(&build_draft_with_image_inputs(2)) + .estimate_cost(); + assert_eq!(req_cost.cost_in_credits, draft_cost.cost_in_credits); + assert_eq!(req_cost.cost_in_usd_cents, draft_cost.cost_in_usd_cents); + } + } + + // ── Helpers ── + + fn credits_for(batch: KinoviMidjourneyBatchCount) -> u64 { + KinoviMidjourney7CostState { batch_count: batch } + .estimate_cost().cost_in_credits.unwrap() + } + + fn usd_cents_for(batch: KinoviMidjourneyBatchCount) -> u64 { + KinoviMidjourney7CostState { batch_count: batch } + .estimate_cost().cost_in_usd_cents.unwrap() + } + + fn base_builder() -> GenerateImageRequestBuilder { + GenerateImageRequestBuilder { + model: RouterImageModel::Midjourney7, + provider: RouterProvider::Seedance2Pro, + prompt: Some("test".to_string()), + image_inputs: None, + resolution: None, + aspect_ratio: None, + quality: None, + image_batch_count: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + } + } + + fn build_request_no_image_inputs(batch_count: u16) -> KinoviMidjourney7RequestState { + let builder = GenerateImageRequestBuilder { + image_batch_count: Some(batch_count), + ..base_builder() + }; + match builder.build2().expect("build2") { + ImageGenerationDraftOrRequest::Request(ImageGenerationRequest::KinoviMidjourney7(r)) => r, + _ => panic!("expected Request"), + } + } + + fn build_draft_with_image_inputs(batch_count: u16) -> KinoviMidjourney7DraftState { + let builder = GenerateImageRequestBuilder { + image_batch_count: Some(batch_count), + image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/ref.png".to_string()])), + ..base_builder() + }; + match builder.build2().expect("build2") { + ImageGenerationDraftOrRequest::Draft(ImageGenerationDraftRequest::KinoviMidjourney7(d)) => d, + _ => panic!("expected Draft"), + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7/draft.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7/draft.rs new file mode 100644 index 0000000000..68d7ec534f --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7/draft.rs @@ -0,0 +1,94 @@ +use seedance2pro_client::generate::image::generate_midjourney_v7::{ + GenerateMidjourneyV7AspectRatio, GenerateMidjourneyV7Quality, + GenerateMidjourneyV7Request, KinoviMidjourneyBatchCount, +}; + +use crate::api::image_list_ref::ImageListRef; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_image::image_generation_draft_context::ImageGenerationDraftContext; +use crate::generate::generate_image::providers::kinovi::midjourney_7::request::KinoviMidjourney7RequestState; +use crate::generate::generate_image::providers::kinovi::resolve::resolve_and_upload_image_list; + +#[derive(Debug, Clone)] +pub struct KinoviMidjourney7DraftState { + // Materialized fields — all enums already resolved. + pub prompt: String, + pub aspect_ratio: GenerateMidjourneyV7AspectRatio, + pub quality: Option, + pub batch_count: KinoviMidjourneyBatchCount, + + /// Image references that still need to be uploaded to Seedance2Pro CDN + /// before the request is sendable. `None` once `to_request()` has run. + pub unhandled_request_state: Option, +} + +#[derive(Debug, Clone)] +pub struct KinoviMidjourney7RemainingItems { + pub reference_images: Option, +} + +impl KinoviMidjourney7DraftState { + pub async fn to_request( + &mut self, + draft_context: &ImageGenerationDraftContext<'_>, + ) -> Result { + let client = draft_context.get_seedance2pro_client_ref()?; + let session = &client.session; + + let mut reference_image_urls = None; + if let Some(remaining) = self.unhandled_request_state.take() { + let map = draft_context.media_file_to_artcraft_url_map; + reference_image_urls = resolve_and_upload_image_list( + session, remaining.reference_images, map, + ).await?; + } + + let request = GenerateMidjourneyV7Request { + prompt: self.prompt.clone(), + aspect_ratio: self.aspect_ratio, + negative_prompt: None, + stylize: None, + weird: None, + chaos: None, + quality: self.quality, + raw_mode: false, + batch_count: self.batch_count, + reference_image_urls, + }; + + Ok(KinoviMidjourney7RequestState { request }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use seedance2pro_client::generate::image::generate_midjourney_v7::GenerateMidjourneyV7AspectRatio; + + // Note: most behavioral tests live in `build.rs` and `cost.rs`. The + // `to_request()` method requires a live Seedance2Pro client to upload + // images, so its end-to-end behavior is exercised by the `request.rs` + // #[ignore]'d integration tests rather than unit-tested here. + + #[test] + fn draft_with_no_image_inputs_finalises_to_empty_reference_image_urls() { + // Even when the draft *has* an unhandled state, an empty `ImageListRef` + // shouldn't trigger an upload. This is technically not the path the + // builder takes (it short-circuits to Request), but we exercise it + // here for robustness. + let mut draft = KinoviMidjourney7DraftState { + prompt: "test".to_string(), + aspect_ratio: GenerateMidjourneyV7AspectRatio::Square1x1, + quality: None, + batch_count: KinoviMidjourneyBatchCount::One, + unhandled_request_state: Some(KinoviMidjourney7RemainingItems { + reference_images: Some(ImageListRef::Urls(vec![])), + }), + }; + // We can't actually call `to_request()` without a client, but the + // resolve path returns `None` for empty inputs. Smoke-test the resolve + // helper directly via the test_resolve helper. + let inputs = draft.unhandled_request_state.take().unwrap().reference_images; + assert!(matches!(inputs, Some(ImageListRef::Urls(ref v)) if v.is_empty())); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7/mod.rs new file mode 100644 index 0000000000..89225a3692 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7/mod.rs @@ -0,0 +1,4 @@ +pub mod build; +pub mod cost; +pub mod draft; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7/request.rs new file mode 100644 index 0000000000..269baf00b6 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7/request.rs @@ -0,0 +1,142 @@ +use seedance2pro_client::generate::image::generate_midjourney_v7::{ + generate_midjourney_v7, GenerateMidjourneyV7Args, GenerateMidjourneyV7Request, +}; + +use crate::client::router_seedance2pro_client::RouterSeedance2ProClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::provider_error::ProviderError; +use crate::generate::generate_image::generate_image_response::{ + GenerateImageResponse, Seedance2proImageResponsePayload, +}; + +#[derive(Debug, Clone)] +pub struct KinoviMidjourney7RequestState { + /// Final materialized request; ready to fire against Kinovi. + pub request: GenerateMidjourneyV7Request, +} + +impl KinoviMidjourney7RequestState { + pub async fn send( + &self, + client: &RouterSeedance2ProClient, + ) -> Result { + let args = GenerateMidjourneyV7Args { + session: &client.session, + host_override: None, + request: self.request.clone(), + }; + + let response = generate_midjourney_v7(args) + .await + .map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?; + + Ok(GenerateImageResponse::Seedance2Pro(Seedance2proImageResponsePayload { + order_id: response.order_id, + task_id: response.task_id, + maybe_order_ids: response.order_ids, + maybe_task_ids: response.task_ids, + })) + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession; + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_aspect_ratio::RouterAspectRatio; + use crate::api::router_image_model::RouterImageModel; + use crate::api::router_provider::RouterProvider; + use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + use crate::client::router_client::RouterClient; + use crate::client::router_seedance2pro_client::RouterSeedance2ProClient; + use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; + use crate::generate::generate_image::generate_image_response::GenerateImageResponse; + use crate::generate::generate_image::image_generation_draft::ImageGenerationDraftRequest; + use crate::generate::generate_image::image_generation_draft_context::ImageGenerationDraftContext; + use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest; + use crate::generate::generate_image::image_generation_request::ImageGenerationRequest; + use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL; + + fn base_builder() -> GenerateImageRequestBuilder { + GenerateImageRequestBuilder { + model: RouterImageModel::Midjourney7, + provider: RouterProvider::Seedance2Pro, + prompt: Some("a serene mountain lake at sunrise, cinematic".to_string()), + image_inputs: None, + resolution: None, + aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine), + quality: None, + image_batch_count: Some(1), + horizontal_angle: None, + vertical_angle: None, + zoom: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + } + } + + fn get_kinovi_client() -> RouterClient { + let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/seedance2pro_cookies.txt") + .expect("Failed to read seedance2pro cookies"); + let session = Seedance2ProSession::from_cookies_string(cookies.trim().to_string()); + RouterClient::Seedance2Pro(RouterSeedance2ProClient::new(session)) + } + + #[tokio::test] + #[ignore] // manually test — requires real cookies; costs credits + async fn text_to_image_round_trip() { + let client = get_kinovi_client(); + let result = base_builder().build2().expect("build2"); + + let request = match result { + ImageGenerationDraftOrRequest::Request(ImageGenerationRequest::KinoviMidjourney7(r)) => r, + _ => panic!("expected direct Request for text-to-image"), + }; + + let response = request.send(client.get_seedance2pro_client_ref().unwrap()) + .await.expect("send should succeed"); + let payload = response.get_seedance2pro_payload().expect("expected seedance2pro payload"); + println!("v7 t2i — task_id={}, order_id={}", payload.task_id, payload.order_id); + assert!(!payload.task_id.is_empty()); + } + + #[tokio::test] + #[ignore] // manually test — requires real cookies; costs credits + async fn image_reference_round_trip_via_draft() { + let client = get_kinovi_client(); + let token = MediaFileToken::new("mf_test_juno".to_string()); + + let mut map = HashMap::new(); + map.insert(token.clone(), JUNO_AT_LAKE_IMAGE_URL.to_string()); + + let builder = GenerateImageRequestBuilder { + prompt: Some("a corgi in the style of @1".to_string()), + image_inputs: Some(ImageListRef::MediaFileTokens(vec![token])), + ..base_builder() + }; + + let draft_or_request = builder.build2().expect("build2"); + let draft = match draft_or_request { + ImageGenerationDraftOrRequest::Draft(ImageGenerationDraftRequest::KinoviMidjourney7(d)) => d, + _ => panic!("expected Draft for image-input flow"), + }; + + let ctx = ImageGenerationDraftContext { + client: Some(&client), + media_file_to_artcraft_url_map: Some(&map), + }; + + let request = ImageGenerationDraftRequest::KinoviMidjourney7(draft) + .finalize(ctx).await.expect("finalize"); + + let response = request.send_request(&client).await.expect("send_request"); + let payload = response.get_seedance2pro_payload().expect("expected seedance2pro payload"); + println!("v7 with ref — task_id={}, order_id={}", payload.task_id, payload.order_id); + assert!(!payload.task_id.is_empty()); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7_niji/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7_niji/build.rs new file mode 100644 index 0000000000..875743fa8c --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7_niji/build.rs @@ -0,0 +1,267 @@ +use seedance2pro_client::generate::image::generate_midjourney_v7_niji::{ + GenerateMidjourneyV7NijiAspectRatio, GenerateMidjourneyV7NijiQuality, + GenerateMidjourneyV7NijiRequest, KinoviMidjourneyBatchCount, +}; + +use crate::api::image_list_ref::ImageListRef; +use crate::api::router_aspect_ratio::RouterAspectRatio; +use crate::api::router_quality::RouterQuality; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; +use crate::generate::generate_image::image_generation_draft::ImageGenerationDraftRequest; +use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest; +use crate::generate::generate_image::image_generation_request::ImageGenerationRequest; +use crate::generate::generate_image::providers::kinovi::midjourney_7_niji::draft::{ + KinoviMidjourney7NijiDraftState, KinoviMidjourney7NijiRemainingItems, +}; +use crate::generate::generate_image::providers::kinovi::midjourney_7_niji::request::KinoviMidjourney7NijiRequestState; + +pub fn build_kinovi_midjourney_7_niji( + mut builder: GenerateImageRequestBuilder, +) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio.take(), strategy)?; + let quality = plan_quality(builder.quality.take()); + let batch_count = plan_batch_count(builder.image_batch_count.take(), strategy)?; + let prompt = builder.prompt.take().unwrap_or_default(); + let image_inputs = builder.image_inputs.take(); + + if !has_image_inputs(image_inputs.as_ref()) { + let request = GenerateMidjourneyV7NijiRequest { + prompt, + aspect_ratio, + negative_prompt: None, + stylize: None, + weird: None, + chaos: None, + quality, + raw_mode: false, + batch_count, + reference_image_urls: None, + }; + return Ok(ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::KinoviMidjourney7Niji(KinoviMidjourney7NijiRequestState { request }), + )); + } + + let draft = KinoviMidjourney7NijiDraftState { + prompt, + aspect_ratio, + quality, + batch_count, + unhandled_request_state: Some(KinoviMidjourney7NijiRemainingItems { + reference_images: image_inputs, + }), + }; + Ok(ImageGenerationDraftOrRequest::Draft( + ImageGenerationDraftRequest::KinoviMidjourney7Niji(draft), + )) +} + +fn has_image_inputs(image_inputs: Option<&ImageListRef>) -> bool { + match image_inputs { + None => false, + Some(ImageListRef::Urls(urls)) => !urls.is_empty(), + Some(ImageListRef::MediaFileTokens(tokens)) => !tokens.is_empty(), + } +} + +// ── Plan helpers ── + +pub(crate) fn plan_aspect_ratio( + aspect_ratio: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + use GenerateMidjourneyV7NijiAspectRatio as Ar; + match aspect_ratio { + None + | Some(RouterAspectRatio::Auto) + | Some(RouterAspectRatio::Auto2k) + | Some(RouterAspectRatio::Auto3k) + | Some(RouterAspectRatio::Auto4k) => Ok(Ar::Square1x1), + + Some(RouterAspectRatio::Square) | Some(RouterAspectRatio::SquareHd) => Ok(Ar::Square1x1), + Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Ar::Landscape16x9), + Some(RouterAspectRatio::WideTwentyOneByNine) => Ok(Ar::UltraWide21x9), + Some(RouterAspectRatio::WideFourByThree) => Ok(Ar::Standard4x3), + Some(RouterAspectRatio::WideFiveByFour) => Ok(Ar::Wide5x4), + Some(RouterAspectRatio::WideThreeByTwo) => Ok(Ar::Wide3x2), + Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Ar::Portrait9x16), + Some(RouterAspectRatio::TallNineByTwentyOne) => Ok(Ar::UltraTall9x21), + Some(RouterAspectRatio::TallThreeByFour) => Ok(Ar::Portrait3x4), + Some(RouterAspectRatio::TallFourByFive) => Ok(Ar::Tall4x5), + Some(RouterAspectRatio::TallTwoByThree) => Ok(Ar::Tall2x3), + + #[allow(unreachable_patterns)] + Some(unsupported) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "aspect_ratio", + value: format!("{:?}", unsupported), + })) + } + _ => Ok(Ar::Square1x1), + }, + } +} + +pub(crate) fn plan_quality(quality: Option) -> Option { + match quality { + None => None, + Some(RouterQuality::Low) => Some(GenerateMidjourneyV7NijiQuality::Quarter), + Some(RouterQuality::Medium) => Some(GenerateMidjourneyV7NijiQuality::Half), + Some(RouterQuality::High) => Some(GenerateMidjourneyV7NijiQuality::Full), + } +} + +pub(crate) fn plan_batch_count( + image_batch_count: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + let count = image_batch_count.unwrap_or(1); + match count { + 0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)), + 1 => Ok(KinoviMidjourneyBatchCount::One), + 2 => Ok(KinoviMidjourneyBatchCount::Two), + 4 => Ok(KinoviMidjourneyBatchCount::Four), + other => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "image_batch_count", + value: format!("{}", other), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade => { + Ok(if other < 3 { KinoviMidjourneyBatchCount::Two } else { KinoviMidjourneyBatchCount::Four }) + } + RequestMismatchMitigationStrategy::PayLessDowngrade => { + Ok(if other < 4 { KinoviMidjourneyBatchCount::Two } else { KinoviMidjourneyBatchCount::Four }) + } + }, + } +} + +#[cfg(test)] +mod tests { + use seedance2pro_client::generate::image::generate_midjourney_v7_niji::KinoviMidjourneyBatchCount; + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_aspect_ratio::RouterAspectRatio; + use crate::api::router_image_model::RouterImageModel; + use crate::api::router_provider::RouterProvider; + use crate::api::router_quality::RouterQuality; + use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; + use crate::generate::generate_image::image_generation_draft::ImageGenerationDraftRequest; + use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest; + use crate::generate::generate_image::image_generation_request::ImageGenerationRequest; + + use super::*; + + fn base_builder() -> GenerateImageRequestBuilder { + GenerateImageRequestBuilder { + model: RouterImageModel::Midjourney7Niji, + provider: RouterProvider::Seedance2Pro, + prompt: Some("a magical anime forest scene".to_string()), + image_inputs: None, + resolution: None, + aspect_ratio: None, + quality: None, + image_batch_count: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + } + } + + #[test] + fn no_image_inputs_returns_request_directly() { + let result = build_kinovi_midjourney_7_niji(base_builder()).expect("build"); + assert!(matches!( + result, + ImageGenerationDraftOrRequest::Request(ImageGenerationRequest::KinoviMidjourney7Niji(_)), + )); + } + + #[test] + fn image_inputs_returns_draft() { + let builder = GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/ref.png".to_string()])), + ..base_builder() + }; + let result = build_kinovi_midjourney_7_niji(builder).expect("build"); + assert!(matches!( + result, + ImageGenerationDraftOrRequest::Draft(ImageGenerationDraftRequest::KinoviMidjourney7Niji(_)), + )); + } + + #[test] + fn batch_count_zero_errors() { + let builder = GenerateImageRequestBuilder { image_batch_count: Some(0), ..base_builder() }; + assert!(matches!( + build_kinovi_midjourney_7_niji(builder), + Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)), + )); + } + + #[test] + fn batch_count_four_is_accepted() { + let builder = GenerateImageRequestBuilder { image_batch_count: Some(4), ..base_builder() }; + let req = match build_kinovi_midjourney_7_niji(builder).expect("build") { + ImageGenerationDraftOrRequest::Request(ImageGenerationRequest::KinoviMidjourney7Niji(r)) => r, + _ => panic!("expected Request"), + }; + assert_eq!(req.request.batch_count, KinoviMidjourneyBatchCount::Four); + } + + #[test] + fn aspect_ratio_passes_through_to_niji_enum() { + use seedance2pro_client::generate::image::generate_midjourney_v7_niji::GenerateMidjourneyV7NijiAspectRatio as Ar; + let builder = GenerateImageRequestBuilder { + aspect_ratio: Some(RouterAspectRatio::WideTwentyOneByNine), + ..base_builder() + }; + let req = match build_kinovi_midjourney_7_niji(builder).expect("build") { + ImageGenerationDraftOrRequest::Request(ImageGenerationRequest::KinoviMidjourney7Niji(r)) => r, + _ => panic!("expected Request"), + }; + assert_eq!(req.request.aspect_ratio, Ar::UltraWide21x9); + } + + #[test] + fn quality_high_maps_to_full() { + use seedance2pro_client::generate::image::generate_midjourney_v7_niji::GenerateMidjourneyV7NijiQuality as Q; + let builder = GenerateImageRequestBuilder { + quality: Some(RouterQuality::High), + ..base_builder() + }; + let req = match build_kinovi_midjourney_7_niji(builder).expect("build") { + ImageGenerationDraftOrRequest::Request(ImageGenerationRequest::KinoviMidjourney7Niji(r)) => r, + _ => panic!("expected Request"), + }; + assert_eq!(req.request.quality, Some(Q::Full)); + } + + #[test] + fn media_file_tokens_route_through_draft() { + let builder = GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_x".to_string()), + ])), + ..base_builder() + }; + let draft = match build_kinovi_midjourney_7_niji(builder).expect("build") { + ImageGenerationDraftOrRequest::Draft(ImageGenerationDraftRequest::KinoviMidjourney7Niji(d)) => d, + _ => panic!("expected Draft"), + }; + assert!(draft.unhandled_request_state.is_some()); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7_niji/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7_niji/cost.rs new file mode 100644 index 0000000000..50de0f8d29 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7_niji/cost.rs @@ -0,0 +1,90 @@ +use seedance2pro_client::generate::image::generate_midjourney_v7_niji::{ + GenerateMidjourneyV7NijiAspectRatio, GenerateMidjourneyV7NijiRequest, KinoviMidjourneyBatchCount, +}; + +use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate; +use crate::generate::generate_image::providers::kinovi::midjourney_7_niji::draft::KinoviMidjourney7NijiDraftState; +use crate::generate::generate_image::providers::kinovi::midjourney_7_niji::request::KinoviMidjourney7NijiRequestState; + +/// Midjourney v7 Niji (via Kinovi) cost state. Pricing is flat per task +/// regardless of aspect ratio or quality, identical to v7 / v8. +pub struct KinoviMidjourney7NijiCostState { + pub batch_count: KinoviMidjourneyBatchCount, +} + +impl KinoviMidjourney7NijiCostState { + pub fn from_request(request: &KinoviMidjourney7NijiRequestState) -> Self { + Self { batch_count: request.request.batch_count } + } + + pub fn from_draft(draft: &KinoviMidjourney7NijiDraftState) -> Self { + Self { batch_count: draft.batch_count } + } + + pub fn estimate_cost(&self) -> ImageGenerationCostEstimate { + let pricing_request = GenerateMidjourneyV7NijiRequest { + batch_count: self.batch_count, + prompt: String::new(), + aspect_ratio: GenerateMidjourneyV7NijiAspectRatio::Square1x1, + negative_prompt: None, + stylize: None, + weird: None, + chaos: None, + quality: None, + raw_mode: false, + reference_image_urls: None, + }; + + let cost_in_credits = pricing_request.estimate_credits() as u64; + let cost_in_usd_cents = pricing_request.estimate_cost_in_usd_cents(); + + ImageGenerationCostEstimate { + cost_in_credits: Some(cost_in_credits), + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn batch_one_is_twelve_credits_and_six_cents() { + let cost = KinoviMidjourney7NijiCostState { batch_count: KinoviMidjourneyBatchCount::One } + .estimate_cost(); + assert_eq!(cost.cost_in_credits, Some(12)); + assert_eq!(cost.cost_in_usd_cents, Some(6)); + } + + #[test] + fn batch_two_doubles() { + let cost = KinoviMidjourney7NijiCostState { batch_count: KinoviMidjourneyBatchCount::Two } + .estimate_cost(); + assert_eq!(cost.cost_in_credits, Some(24)); + assert_eq!(cost.cost_in_usd_cents, Some(12)); + } + + #[test] + fn batch_four_quadruples() { + let cost = KinoviMidjourney7NijiCostState { batch_count: KinoviMidjourneyBatchCount::Four } + .estimate_cost(); + assert_eq!(cost.cost_in_credits, Some(48)); + assert_eq!(cost.cost_in_usd_cents, Some(25)); + } + + #[test] + fn flags_are_correct() { + let cost = KinoviMidjourney7NijiCostState { batch_count: KinoviMidjourneyBatchCount::One } + .estimate_cost(); + assert!(!cost.is_free); + assert!(!cost.is_unlimited); + assert!(!cost.is_rate_limited); + assert!(!cost.has_watermark); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7_niji/draft.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7_niji/draft.rs new file mode 100644 index 0000000000..d2cda52fe5 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7_niji/draft.rs @@ -0,0 +1,58 @@ +use seedance2pro_client::generate::image::generate_midjourney_v7_niji::{ + GenerateMidjourneyV7NijiAspectRatio, GenerateMidjourneyV7NijiQuality, + GenerateMidjourneyV7NijiRequest, KinoviMidjourneyBatchCount, +}; + +use crate::api::image_list_ref::ImageListRef; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_image::image_generation_draft_context::ImageGenerationDraftContext; +use crate::generate::generate_image::providers::kinovi::midjourney_7_niji::request::KinoviMidjourney7NijiRequestState; +use crate::generate::generate_image::providers::kinovi::resolve::resolve_and_upload_image_list; + +#[derive(Debug, Clone)] +pub struct KinoviMidjourney7NijiDraftState { + pub prompt: String, + pub aspect_ratio: GenerateMidjourneyV7NijiAspectRatio, + pub quality: Option, + pub batch_count: KinoviMidjourneyBatchCount, + + pub unhandled_request_state: Option, +} + +#[derive(Debug, Clone)] +pub struct KinoviMidjourney7NijiRemainingItems { + pub reference_images: Option, +} + +impl KinoviMidjourney7NijiDraftState { + pub async fn to_request( + &mut self, + draft_context: &ImageGenerationDraftContext<'_>, + ) -> Result { + let client = draft_context.get_seedance2pro_client_ref()?; + let session = &client.session; + + let mut reference_image_urls = None; + if let Some(remaining) = self.unhandled_request_state.take() { + let map = draft_context.media_file_to_artcraft_url_map; + reference_image_urls = resolve_and_upload_image_list( + session, remaining.reference_images, map, + ).await?; + } + + let request = GenerateMidjourneyV7NijiRequest { + prompt: self.prompt.clone(), + aspect_ratio: self.aspect_ratio, + negative_prompt: None, + stylize: None, + weird: None, + chaos: None, + quality: self.quality, + raw_mode: false, + batch_count: self.batch_count, + reference_image_urls, + }; + + Ok(KinoviMidjourney7NijiRequestState { request }) + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7_niji/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7_niji/mod.rs new file mode 100644 index 0000000000..89225a3692 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7_niji/mod.rs @@ -0,0 +1,4 @@ +pub mod build; +pub mod cost; +pub mod draft; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7_niji/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7_niji/request.rs new file mode 100644 index 0000000000..f9e02be306 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_7_niji/request.rs @@ -0,0 +1,140 @@ +use seedance2pro_client::generate::image::generate_midjourney_v7_niji::{ + generate_midjourney_v7_niji, GenerateMidjourneyV7NijiArgs, GenerateMidjourneyV7NijiRequest, +}; + +use crate::client::router_seedance2pro_client::RouterSeedance2ProClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::provider_error::ProviderError; +use crate::generate::generate_image::generate_image_response::{ + GenerateImageResponse, Seedance2proImageResponsePayload, +}; + +#[derive(Debug, Clone)] +pub struct KinoviMidjourney7NijiRequestState { + pub request: GenerateMidjourneyV7NijiRequest, +} + +impl KinoviMidjourney7NijiRequestState { + pub async fn send( + &self, + client: &RouterSeedance2ProClient, + ) -> Result { + let args = GenerateMidjourneyV7NijiArgs { + session: &client.session, + host_override: None, + request: self.request.clone(), + }; + + let response = generate_midjourney_v7_niji(args) + .await + .map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?; + + Ok(GenerateImageResponse::Seedance2Pro(Seedance2proImageResponsePayload { + order_id: response.order_id, + task_id: response.task_id, + maybe_order_ids: response.order_ids, + maybe_task_ids: response.task_ids, + })) + } +} + +#[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::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::Midjourney7Niji, + provider: RouterProvider::Seedance2Pro, + prompt: Some("a magical anime fox spirit in a moonlit bamboo forest".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::KinoviMidjourney7Niji(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-niji 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_niji".to_string()); + + let mut map = HashMap::new(); + map.insert(token.clone(), JUNO_AT_LAKE_IMAGE_URL.to_string()); + + let builder = GenerateImageRequestBuilder { + prompt: Some("an anime corgi sorcerer inspired by @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::KinoviMidjourney7Niji(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::KinoviMidjourney7Niji(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-niji with ref — task_id={}, order_id={}", payload.task_id, payload.order_id); + assert!(!payload.task_id.is_empty()); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_8/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_8/build.rs new file mode 100644 index 0000000000..929024fca8 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_8/build.rs @@ -0,0 +1,228 @@ +use seedance2pro_client::generate::image::generate_midjourney_v8::{ + GenerateMidjourneyV8AspectRatio, GenerateMidjourneyV8Quality, + GenerateMidjourneyV8Request, KinoviMidjourneyBatchCount, +}; + +use crate::api::image_list_ref::ImageListRef; +use crate::api::router_aspect_ratio::RouterAspectRatio; +use crate::api::router_quality::RouterQuality; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; +use crate::generate::generate_image::image_generation_draft::ImageGenerationDraftRequest; +use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest; +use crate::generate::generate_image::image_generation_request::ImageGenerationRequest; +use crate::generate::generate_image::providers::kinovi::midjourney_8::draft::{ + KinoviMidjourney8DraftState, KinoviMidjourney8RemainingItems, +}; +use crate::generate::generate_image::providers::kinovi::midjourney_8::request::KinoviMidjourney8RequestState; + +pub fn build_kinovi_midjourney_8( + mut builder: GenerateImageRequestBuilder, +) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio.take(), strategy)?; + let quality = plan_quality(builder.quality.take()); + let batch_count = plan_batch_count(builder.image_batch_count.take(), strategy)?; + let prompt = builder.prompt.take().unwrap_or_default(); + let image_inputs = builder.image_inputs.take(); + + if !has_image_inputs(image_inputs.as_ref()) { + let request = GenerateMidjourneyV8Request { + prompt, + aspect_ratio, + negative_prompt: None, + stylize: None, + weird: None, + chaos: None, + quality, + raw_mode: false, + batch_count, + reference_image_urls: None, + }; + return Ok(ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::KinoviMidjourney8(KinoviMidjourney8RequestState { request }), + )); + } + + let draft = KinoviMidjourney8DraftState { + prompt, + aspect_ratio, + quality, + batch_count, + unhandled_request_state: Some(KinoviMidjourney8RemainingItems { + reference_images: image_inputs, + }), + }; + Ok(ImageGenerationDraftOrRequest::Draft( + ImageGenerationDraftRequest::KinoviMidjourney8(draft), + )) +} + +fn has_image_inputs(image_inputs: Option<&ImageListRef>) -> bool { + match image_inputs { + None => false, + Some(ImageListRef::Urls(urls)) => !urls.is_empty(), + Some(ImageListRef::MediaFileTokens(tokens)) => !tokens.is_empty(), + } +} + +// ── Plan helpers ── + +pub(crate) fn plan_aspect_ratio( + aspect_ratio: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + use GenerateMidjourneyV8AspectRatio as Ar; + match aspect_ratio { + None + | Some(RouterAspectRatio::Auto) + | Some(RouterAspectRatio::Auto2k) + | Some(RouterAspectRatio::Auto3k) + | Some(RouterAspectRatio::Auto4k) => Ok(Ar::Square1x1), + + Some(RouterAspectRatio::Square) | Some(RouterAspectRatio::SquareHd) => Ok(Ar::Square1x1), + Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Ar::Landscape16x9), + Some(RouterAspectRatio::WideTwentyOneByNine) => Ok(Ar::UltraWide21x9), + Some(RouterAspectRatio::WideFourByThree) => Ok(Ar::Standard4x3), + Some(RouterAspectRatio::WideFiveByFour) => Ok(Ar::Wide5x4), + Some(RouterAspectRatio::WideThreeByTwo) => Ok(Ar::Wide3x2), + Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Ar::Portrait9x16), + Some(RouterAspectRatio::TallNineByTwentyOne) => Ok(Ar::UltraTall9x21), + Some(RouterAspectRatio::TallThreeByFour) => Ok(Ar::Portrait3x4), + Some(RouterAspectRatio::TallFourByFive) => Ok(Ar::Tall4x5), + Some(RouterAspectRatio::TallTwoByThree) => Ok(Ar::Tall2x3), + + #[allow(unreachable_patterns)] + Some(unsupported) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "aspect_ratio", + value: format!("{:?}", unsupported), + })) + } + _ => Ok(Ar::Square1x1), + }, + } +} + +pub(crate) fn plan_quality(quality: Option) -> Option { + match quality { + None => None, + Some(RouterQuality::Low) => Some(GenerateMidjourneyV8Quality::Quarter), + Some(RouterQuality::Medium) => Some(GenerateMidjourneyV8Quality::Half), + Some(RouterQuality::High) => Some(GenerateMidjourneyV8Quality::Full), + } +} + +pub(crate) fn plan_batch_count( + image_batch_count: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + let count = image_batch_count.unwrap_or(1); + match count { + 0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)), + 1 => Ok(KinoviMidjourneyBatchCount::One), + 2 => Ok(KinoviMidjourneyBatchCount::Two), + 4 => Ok(KinoviMidjourneyBatchCount::Four), + other => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "image_batch_count", + value: format!("{}", other), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade => { + Ok(if other < 3 { KinoviMidjourneyBatchCount::Two } else { KinoviMidjourneyBatchCount::Four }) + } + RequestMismatchMitigationStrategy::PayLessDowngrade => { + Ok(if other < 4 { KinoviMidjourneyBatchCount::Two } else { KinoviMidjourneyBatchCount::Four }) + } + }, + } +} + +#[cfg(test)] +mod tests { + use seedance2pro_client::generate::image::generate_midjourney_v8::KinoviMidjourneyBatchCount; + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_image_model::RouterImageModel; + use crate::api::router_provider::RouterProvider; + use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; + use crate::generate::generate_image::image_generation_draft::ImageGenerationDraftRequest; + use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest; + use crate::generate::generate_image::image_generation_request::ImageGenerationRequest; + + use super::*; + + fn base_builder() -> GenerateImageRequestBuilder { + GenerateImageRequestBuilder { + model: RouterImageModel::Midjourney8, + provider: RouterProvider::Seedance2Pro, + prompt: Some("a spooky horror scene".to_string()), + image_inputs: None, + resolution: None, + aspect_ratio: None, + quality: None, + image_batch_count: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + } + } + + #[test] + fn no_image_inputs_returns_request_directly() { + let result = build_kinovi_midjourney_8(base_builder()).expect("build"); + assert!(matches!( + result, + ImageGenerationDraftOrRequest::Request(ImageGenerationRequest::KinoviMidjourney8(_)), + )); + } + + #[test] + fn image_inputs_returns_draft() { + let builder = GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/ref.png".to_string()])), + ..base_builder() + }; + let result = build_kinovi_midjourney_8(builder).expect("build"); + assert!(matches!( + result, + ImageGenerationDraftOrRequest::Draft(ImageGenerationDraftRequest::KinoviMidjourney8(_)), + )); + } + + #[test] + fn batch_count_four_is_accepted() { + let builder = GenerateImageRequestBuilder { image_batch_count: Some(4), ..base_builder() }; + let req = match build_kinovi_midjourney_8(builder).expect("build") { + ImageGenerationDraftOrRequest::Request(ImageGenerationRequest::KinoviMidjourney8(r)) => r, + _ => panic!("expected Request"), + }; + assert_eq!(req.request.batch_count, KinoviMidjourneyBatchCount::Four); + } + + #[test] + fn media_file_token_route_through_draft() { + let builder = GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_x".to_string()), + ])), + ..base_builder() + }; + let draft = match build_kinovi_midjourney_8(builder).expect("build") { + ImageGenerationDraftOrRequest::Draft(ImageGenerationDraftRequest::KinoviMidjourney8(d)) => d, + _ => panic!("expected Draft"), + }; + assert!(draft.unhandled_request_state.is_some()); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_8/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_8/cost.rs new file mode 100644 index 0000000000..139f6fc118 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_8/cost.rs @@ -0,0 +1,80 @@ +use seedance2pro_client::generate::image::generate_midjourney_v8::{ + GenerateMidjourneyV8AspectRatio, GenerateMidjourneyV8Request, KinoviMidjourneyBatchCount, +}; + +use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate; +use crate::generate::generate_image::providers::kinovi::midjourney_8::draft::KinoviMidjourney8DraftState; +use crate::generate::generate_image::providers::kinovi::midjourney_8::request::KinoviMidjourney8RequestState; + +/// Midjourney v8 (via Kinovi) cost state. Pricing is flat per task, +/// identical to v7 / v7 niji. +pub struct KinoviMidjourney8CostState { + pub batch_count: KinoviMidjourneyBatchCount, +} + +impl KinoviMidjourney8CostState { + pub fn from_request(request: &KinoviMidjourney8RequestState) -> Self { + Self { batch_count: request.request.batch_count } + } + + pub fn from_draft(draft: &KinoviMidjourney8DraftState) -> Self { + Self { batch_count: draft.batch_count } + } + + pub fn estimate_cost(&self) -> ImageGenerationCostEstimate { + let pricing_request = GenerateMidjourneyV8Request { + batch_count: self.batch_count, + prompt: String::new(), + aspect_ratio: GenerateMidjourneyV8AspectRatio::Square1x1, + negative_prompt: None, + stylize: None, + weird: None, + chaos: None, + quality: None, + raw_mode: false, + reference_image_urls: None, + }; + + let cost_in_credits = pricing_request.estimate_credits() as u64; + let cost_in_usd_cents = pricing_request.estimate_cost_in_usd_cents(); + + ImageGenerationCostEstimate { + cost_in_credits: Some(cost_in_credits), + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn batch_one_is_twelve_credits_and_six_cents() { + let cost = KinoviMidjourney8CostState { batch_count: KinoviMidjourneyBatchCount::One } + .estimate_cost(); + assert_eq!(cost.cost_in_credits, Some(12)); + assert_eq!(cost.cost_in_usd_cents, Some(6)); + } + + #[test] + fn batch_two_doubles() { + let cost = KinoviMidjourney8CostState { batch_count: KinoviMidjourneyBatchCount::Two } + .estimate_cost(); + assert_eq!(cost.cost_in_credits, Some(24)); + assert_eq!(cost.cost_in_usd_cents, Some(12)); + } + + #[test] + fn batch_four_quadruples() { + let cost = KinoviMidjourney8CostState { batch_count: KinoviMidjourneyBatchCount::Four } + .estimate_cost(); + assert_eq!(cost.cost_in_credits, Some(48)); + assert_eq!(cost.cost_in_usd_cents, Some(25)); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_8/draft.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_8/draft.rs new file mode 100644 index 0000000000..4fa628eda4 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_8/draft.rs @@ -0,0 +1,58 @@ +use seedance2pro_client::generate::image::generate_midjourney_v8::{ + GenerateMidjourneyV8AspectRatio, GenerateMidjourneyV8Quality, + GenerateMidjourneyV8Request, KinoviMidjourneyBatchCount, +}; + +use crate::api::image_list_ref::ImageListRef; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_image::image_generation_draft_context::ImageGenerationDraftContext; +use crate::generate::generate_image::providers::kinovi::midjourney_8::request::KinoviMidjourney8RequestState; +use crate::generate::generate_image::providers::kinovi::resolve::resolve_and_upload_image_list; + +#[derive(Debug, Clone)] +pub struct KinoviMidjourney8DraftState { + pub prompt: String, + pub aspect_ratio: GenerateMidjourneyV8AspectRatio, + pub quality: Option, + pub batch_count: KinoviMidjourneyBatchCount, + + pub unhandled_request_state: Option, +} + +#[derive(Debug, Clone)] +pub struct KinoviMidjourney8RemainingItems { + pub reference_images: Option, +} + +impl KinoviMidjourney8DraftState { + pub async fn to_request( + &mut self, + draft_context: &ImageGenerationDraftContext<'_>, + ) -> Result { + let client = draft_context.get_seedance2pro_client_ref()?; + let session = &client.session; + + let mut reference_image_urls = None; + if let Some(remaining) = self.unhandled_request_state.take() { + let map = draft_context.media_file_to_artcraft_url_map; + reference_image_urls = resolve_and_upload_image_list( + session, remaining.reference_images, map, + ).await?; + } + + let request = GenerateMidjourneyV8Request { + prompt: self.prompt.clone(), + aspect_ratio: self.aspect_ratio, + negative_prompt: None, + stylize: None, + weird: None, + chaos: None, + quality: self.quality, + raw_mode: false, + batch_count: self.batch_count, + reference_image_urls, + }; + + Ok(KinoviMidjourney8RequestState { request }) + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_8/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_8/mod.rs new file mode 100644 index 0000000000..89225a3692 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_8/mod.rs @@ -0,0 +1,4 @@ +pub mod build; +pub mod cost; +pub mod draft; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_8/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_8/request.rs new file mode 100644 index 0000000000..0d34673c94 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/midjourney_8/request.rs @@ -0,0 +1,140 @@ +use seedance2pro_client::generate::image::generate_midjourney_v8::{ + generate_midjourney_v8, GenerateMidjourneyV8Args, GenerateMidjourneyV8Request, +}; + +use crate::client::router_seedance2pro_client::RouterSeedance2ProClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::provider_error::ProviderError; +use crate::generate::generate_image::generate_image_response::{ + GenerateImageResponse, Seedance2proImageResponsePayload, +}; + +#[derive(Debug, Clone)] +pub struct KinoviMidjourney8RequestState { + pub request: GenerateMidjourneyV8Request, +} + +impl KinoviMidjourney8RequestState { + pub async fn send( + &self, + client: &RouterSeedance2ProClient, + ) -> Result { + let args = GenerateMidjourneyV8Args { + session: &client.session, + host_override: None, + request: self.request.clone(), + }; + + let response = generate_midjourney_v8(args) + .await + .map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?; + + Ok(GenerateImageResponse::Seedance2Pro(Seedance2proImageResponsePayload { + order_id: response.order_id, + task_id: response.task_id, + maybe_order_ids: response.order_ids, + maybe_task_ids: response.task_ids, + })) + } +} + +#[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::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::Midjourney8, + provider: RouterProvider::Seedance2Pro, + prompt: Some("a spooky horror scene with ghosts in an abandoned mansion".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::KinoviMidjourney8(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!("v8 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_v8".to_string()); + + let mut map = HashMap::new(); + map.insert(token.clone(), JUNO_AT_LAKE_IMAGE_URL.to_string()); + + let builder = GenerateImageRequestBuilder { + prompt: Some("a dramatic painterly portrait inspired by @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::KinoviMidjourney8(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::KinoviMidjourney8(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!("v8 with ref — task_id={}, order_id={}", payload.task_id, payload.order_id); + assert!(!payload.task_id.is_empty()); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/mod.rs new file mode 100644 index 0000000000..e3bdbadd51 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/mod.rs @@ -0,0 +1,6 @@ +pub mod midjourney_7; +pub mod midjourney_7_niji; +pub mod midjourney_8; + +pub(crate) mod resolve; +pub(crate) mod upload; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/resolve.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/resolve.rs new file mode 100644 index 0000000000..4e069932cd --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/resolve.rs @@ -0,0 +1,51 @@ +use std::collections::HashMap; + +use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession; +use tokens::tokens::media_files::MediaFileToken; + +use crate::api::image_list_ref::ImageListRef; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_image::providers::kinovi::upload::upload_to_seedance2pro; + +/// Resolve an [`ImageListRef`] to a list of public Seedance2Pro CDN URLs, +/// uploading each image one at a time. Preserves order. +/// +/// Returns `Ok(None)` when the input is `None` or an empty list — the +/// caller can then omit the field from the wire request entirely. (The +/// downstream Midjourney API rejects empty arrays.) +pub(crate) async fn resolve_and_upload_image_list( + session: &Seedance2ProSession, + list: Option, + maybe_map: Option<&HashMap>, +) -> Result>, ArtcraftRouterError> { + let source_urls = match list { + None => return Ok(None), + Some(ImageListRef::Urls(urls)) if urls.is_empty() => return Ok(None), + Some(ImageListRef::Urls(urls)) => urls, + Some(ImageListRef::MediaFileTokens(tokens)) if tokens.is_empty() => return Ok(None), + Some(ImageListRef::MediaFileTokens(tokens)) => resolve_tokens(maybe_map, &tokens)?, + }; + + let mut uploaded = Vec::with_capacity(source_urls.len()); + for url in &source_urls { + uploaded.push(upload_to_seedance2pro(session, url).await?); + } + Ok(Some(uploaded)) +} + +fn resolve_tokens( + maybe_map: Option<&HashMap>, + tokens: &[MediaFileToken], +) -> Result, ArtcraftRouterError> { + let map = maybe_map.ok_or(ArtcraftRouterError::Client(ClientError::MediaFileToUrlMapNotProvided))?; + tokens.iter() + .map(|token| { + map.get(token).cloned().ok_or_else(|| { + ArtcraftRouterError::Client(ClientError::MediaFileTokenNotFoundInMap { + token: token.clone(), + }) + }) + }) + .collect() +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/upload.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/upload.rs new file mode 100644 index 0000000000..61e66b316e --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/kinovi/upload.rs @@ -0,0 +1,44 @@ +use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession; +use seedance2pro_client::requests::prepare_file_upload::prepare_file_upload::{prepare_file_upload, PrepareFileUploadArgs}; +use seedance2pro_client::requests::upload_file::upload_file::{upload_file, UploadFileArgs}; +use url_utils::extension::extract_extension_from_url::{extract_extension_from_url_str, ExtractExtensions}; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::provider_error::ProviderError; +use crate::utils::download_file::download_file; + +/// Downloads an image from a source URL and re-uploads it to the +/// Seedance2Pro/Kinovi CDN. Returns the public URL. +/// +/// This is the image-side twin of `generate_video::providers::kinovi::upload`. +/// The actual upload path doesn't care whether the file is a video or an +/// image, but keeping the two helpers separate lets each side evolve +/// independently and makes intent clear at call sites. +pub(crate) async fn upload_to_seedance2pro( + session: &Seedance2ProSession, + source_url: &str, +) -> Result { + let extension = extract_extension_from_url_str(source_url, &ExtractExtensions::All) + .map(|ext| ext.without_period().to_string()) + .unwrap_or_else(|| "jpg".to_string()); + + let file_bytes = download_file(source_url).await?; + + let prepare_response = prepare_file_upload(PrepareFileUploadArgs { + session, + extension, + host_override: None, + }) + .await + .map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?; + + let upload_response = upload_file(UploadFileArgs { + upload_url: prepare_response.upload_url, + file_bytes, + host_override: None, + }) + .await + .map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?; + + Ok(upload_response.public_url) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/mod.rs index cded7c363d..1adb32b1fa 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/mod.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/providers/mod.rs @@ -1,2 +1,3 @@ pub mod artcraft; pub mod fal; +pub mod kinovi; diff --git a/crates/desktop/artcraft/src/core/commands/deprecated/image_edit/artcraft/handle_image_edit_artcraft_via_router.rs b/crates/desktop/artcraft/src/core/commands/deprecated/image_edit/artcraft/handle_image_edit_artcraft_via_router.rs index ceb4869ad8..3b9895339d 100644 --- a/crates/desktop/artcraft/src/core/commands/deprecated/image_edit/artcraft/handle_image_edit_artcraft_via_router.rs +++ b/crates/desktop/artcraft/src/core/commands/deprecated/image_edit/artcraft/handle_image_edit_artcraft_via_router.rs @@ -90,7 +90,11 @@ pub(super) async fn handle_image_edit_artcraft_via_router( let request = match dor { ImageGenerationDraftOrRequest::Request(req) => req, // Artcraft never returns a Draft — image-token resolution happens server-side. - ImageGenerationDraftOrRequest::Draft(d) => match d {}, + // The only draft-producing models today are Kinovi-Midjourney variants, + // which are routed to a separate provider. + ImageGenerationDraftOrRequest::Draft(_) => unreachable!( + "Artcraft router should never produce a draft for image edits" + ), }; let response = match request.send_request(&client).await { diff --git a/crates/desktop/artcraft/src/core/commands/deprecated/text_to_image/artcraft/handle_text_to_image_artcraft_via_router.rs b/crates/desktop/artcraft/src/core/commands/deprecated/text_to_image/artcraft/handle_text_to_image_artcraft_via_router.rs index f5e2764441..66f98e5c3a 100644 --- a/crates/desktop/artcraft/src/core/commands/deprecated/text_to_image/artcraft/handle_text_to_image_artcraft_via_router.rs +++ b/crates/desktop/artcraft/src/core/commands/deprecated/text_to_image/artcraft/handle_text_to_image_artcraft_via_router.rs @@ -70,7 +70,11 @@ pub(super) async fn handle_text_to_image_artcraft_via_router( let request = match dor { ImageGenerationDraftOrRequest::Request(req) => req, // Artcraft never returns a Draft — image-token resolution happens server-side. - ImageGenerationDraftOrRequest::Draft(d) => match d {}, + // The only draft-producing models today are Kinovi-Midjourney variants, + // which are routed to a separate provider. + ImageGenerationDraftOrRequest::Draft(_) => unreachable!( + "Artcraft router should never produce a draft for text-to-image" + ), }; let response = match request.send_request(&client).await { diff --git a/crates/desktop/artcraft/src/core/commands/generate/common/router_image_request_to_artcraft_prompt.rs b/crates/desktop/artcraft/src/core/commands/generate/common/router_image_request_to_artcraft_prompt.rs index 5f03e3f3b6..fa39e16fdf 100644 --- a/crates/desktop/artcraft/src/core/commands/generate/common/router_image_request_to_artcraft_prompt.rs +++ b/crates/desktop/artcraft/src/core/commands/generate/common/router_image_request_to_artcraft_prompt.rs @@ -58,6 +58,9 @@ fn image_model_to_common_model_type(model: RouterImageModel) -> Option Some(CommonModelType::Flux2LoraAngles), RouterImageModel::GrokImagineImage => Some(CommonModelType::GrokImagineImage), RouterImageModel::GrokImagineImageQuality => Some(CommonModelType::GrokImagineImageQuality), + RouterImageModel::Midjourney7 => Some(CommonModelType::Midjourney7), + RouterImageModel::Midjourney7Niji => Some(CommonModelType::Midjourney7Niji), + RouterImageModel::Midjourney8 => Some(CommonModelType::Midjourney8), } } diff --git a/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft/legacy/artcraft_flux_2_lora_angles.rs b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft/legacy/artcraft_flux_2_lora_angles.rs index 128266ae03..929df4474f 100644 --- a/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft/legacy/artcraft_flux_2_lora_angles.rs +++ b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft/legacy/artcraft_flux_2_lora_angles.rs @@ -57,7 +57,11 @@ pub async fn handle_flux_2_lora_angles( let request = match dor { ImageGenerationDraftOrRequest::Request(req) => req, // Artcraft-side angle models always return a Request — never a Draft. - ImageGenerationDraftOrRequest::Draft(d) => match d {}, + // The only draft-producing models today are Kinovi-Midjourney variants, + // which are routed to a separate provider. + ImageGenerationDraftOrRequest::Draft(_) => unreachable!( + "Artcraft Flux2LoraAngles should never produce a draft" + ), }; let response = request.send_request(&client).await.map_err(|err| { diff --git a/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft/legacy/artcraft_qwen_edit_2511_angles.rs b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft/legacy/artcraft_qwen_edit_2511_angles.rs index 741ea71714..30a9b0880e 100644 --- a/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft/legacy/artcraft_qwen_edit_2511_angles.rs +++ b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft/legacy/artcraft_qwen_edit_2511_angles.rs @@ -57,7 +57,11 @@ pub async fn handle_qwen_edit_2511_angles( let request = match dor { ImageGenerationDraftOrRequest::Request(req) => req, // Artcraft-side angle models always return a Request — never a Draft. - ImageGenerationDraftOrRequest::Draft(d) => match d {}, + // The only draft-producing models today are Kinovi-Midjourney variants, + // which are routed to a separate provider. + ImageGenerationDraftOrRequest::Draft(_) => unreachable!( + "Artcraft QwenEdit2511Angles should never produce a draft" + ), }; let response = request.send_request(&client).await.map_err(|err| { diff --git a/crates/schema/database/mysql_queries/src/queries/generic_inference/api_providers/seedance2pro/insert_generic_inference_job_for_seedance2pro_queue_with_apriori_job_token.rs b/crates/schema/database/mysql_queries/src/queries/generic_inference/api_providers/seedance2pro/insert_generic_inference_job_for_seedance2pro_queue_with_apriori_job_token.rs index 021b3d883c..9139375556 100644 --- a/crates/schema/database/mysql_queries/src/queries/generic_inference/api_providers/seedance2pro/insert_generic_inference_job_for_seedance2pro_queue_with_apriori_job_token.rs +++ b/crates/schema/database/mysql_queries/src/queries/generic_inference/api_providers/seedance2pro/insert_generic_inference_job_for_seedance2pro_queue_with_apriori_job_token.rs @@ -70,28 +70,46 @@ pub async fn insert_generic_inference_job_for_seedance2pro_queue_with_apriori_jo ) -> Result where E: 'e + Executor<'c, Database = MySql> { + let is_image_model = match args.maybe_model_type { + Some(CommonModelType::Midjourney7) | + Some(CommonModelType::Midjourney7Niji) | + Some(CommonModelType::Midjourney8) => true, + _ => false, + }; + let ( job_type, external_third_party, product_category - ) = match args.kinovi_version { - KinoviVersion::Volcengine => ( + ) = match (args.kinovi_version, is_image_model) { + (KinoviVersion::Volcengine, true) => ( InferenceJobType::Seedance2ProQueue, InferenceJobExternalThirdParty::Seedance2Pro, InferenceJobProductCategory::Seedance2ProVideo, ), - KinoviVersion::BytePlus => ( + (KinoviVersion::Volcengine, false) => ( + InferenceJobType::Seedance2ProQueue, + InferenceJobExternalThirdParty::Seedance2Pro, + InferenceJobProductCategory::Seedance2ProImage, + ), + (KinoviVersion::BytePlus, _) => ( InferenceJobType::Seedance2ProAltQueue, InferenceJobExternalThirdParty::Seedance2ProAlt, InferenceJobProductCategory::Seedance2ProVideoAlt, ), - KinoviVersion::BytePlusUltra => ( + (KinoviVersion::BytePlusUltra, _) => ( InferenceJobType::Seedance2ProBytePlusUltraQueue, InferenceJobExternalThirdParty::Seedance2ProBytePlusUltra, InferenceJobProductCategory::Seedance2ProVideoBytePlusUltra, ), }; + let inference_category = if is_image_model { + InferenceCategory::ImageGeneration + } else { + InferenceCategory::VideoGeneration + }; + let record_id = insert_generic_inference_job_for_provider(InsertGenericInferenceJobForProviderArgs { apriori_job_token: args.apriori_job_token, uuid_idempotency_token: args.uuid_idempotency_token, @@ -99,7 +117,7 @@ pub async fn insert_generic_inference_job_for_seedance2pro_queue_with_apriori_jo external_third_party, external_third_party_id: args.maybe_external_third_party_id, product_category, - inference_category: InferenceCategory::VideoGeneration, + inference_category, maybe_model_type: args.maybe_model_type, maybe_prompt_token: args.maybe_prompt_token, maybe_wallet_ledger_entry_token: args.maybe_wallet_ledger_entry_token, diff --git a/crates/schema/public/enums/src/by_table/generic_inference_jobs/inference_job_product_category.rs b/crates/schema/public/enums/src/by_table/generic_inference_jobs/inference_job_product_category.rs index fb447351de..1ce6fc6eaf 100644 --- a/crates/schema/public/enums/src/by_table/generic_inference_jobs/inference_job_product_category.rs +++ b/crates/schema/public/enums/src/by_table/generic_inference_jobs/inference_job_product_category.rs @@ -53,6 +53,10 @@ pub enum InferenceJobProductCategory { #[serde(rename = "seedance2pro_character")] Seedance2ProCharacter, + /// Seedance 2 Pro / Kinovi: Midjourney image generation. + #[serde(rename = "seedance2pro_image")] + Seedance2ProImage, + // =============== SEEDANCE 2 PRO ALT =============== #[serde(rename = "seedance2pro_video_alt")] @@ -169,6 +173,7 @@ impl InferenceJobProductCategory { Self::BeebleVideo => "beeble_video", Self::Seedance2ProVideo => "seedance2pro_video", Self::Seedance2ProCharacter => "seedance2pro_character", + Self::Seedance2ProImage => "seedance2pro_image", Self::Seedance2ProVideoAlt => "seedance2pro_video_alt", Self::Seedance2ProVideoBytePlusUltra => "seedance2pro_video_bpu", Self::WorldlabsSplat => "worldlabs_splat", @@ -209,6 +214,7 @@ impl InferenceJobProductCategory { "beeble_video" => Ok(Self::BeebleVideo), "seedance2pro_video" => Ok(Self::Seedance2ProVideo), "seedance2pro_character" => Ok(Self::Seedance2ProCharacter), + "seedance2pro_image" => Ok(Self::Seedance2ProImage), "seedance2pro_video_alt" => Ok(Self::Seedance2ProVideoAlt), "seedance2pro_video_bpu" => Ok(Self::Seedance2ProVideoBytePlusUltra), "worldlabs_splat" => Ok(Self::WorldlabsSplat), @@ -252,6 +258,7 @@ impl InferenceJobProductCategory { Self::BeebleVideo, Self::Seedance2ProVideo, Self::Seedance2ProCharacter, + Self::Seedance2ProImage, Self::Seedance2ProVideoAlt, Self::Seedance2ProVideoBytePlusUltra, Self::WorldlabsSplat, @@ -301,6 +308,7 @@ mod tests { assert_serialization(InferenceJobProductCategory::BeebleVideo, "beeble_video"); assert_serialization(InferenceJobProductCategory::Seedance2ProVideo, "seedance2pro_video"); assert_serialization(InferenceJobProductCategory::Seedance2ProCharacter, "seedance2pro_character"); + assert_serialization(InferenceJobProductCategory::Seedance2ProImage, "seedance2pro_image"); assert_serialization(InferenceJobProductCategory::Seedance2ProVideoAlt, "seedance2pro_video_alt"); assert_serialization(InferenceJobProductCategory::Seedance2ProVideoBytePlusUltra, "seedance2pro_video_bpu"); assert_serialization(InferenceJobProductCategory::WorldlabsSplat, "worldlabs_splat"); @@ -340,6 +348,7 @@ mod tests { assert_eq!(InferenceJobProductCategory::BeebleVideo.to_str(), "beeble_video"); assert_eq!(InferenceJobProductCategory::Seedance2ProVideo.to_str(), "seedance2pro_video"); assert_eq!(InferenceJobProductCategory::Seedance2ProCharacter.to_str(), "seedance2pro_character"); + assert_eq!(InferenceJobProductCategory::Seedance2ProImage.to_str(), "seedance2pro_image"); assert_eq!(InferenceJobProductCategory::Seedance2ProVideoAlt.to_str(), "seedance2pro_video_alt"); assert_eq!(InferenceJobProductCategory::Seedance2ProVideoBytePlusUltra.to_str(), "seedance2pro_video_bpu"); assert_eq!(InferenceJobProductCategory::WorldlabsSplat.to_str(), "worldlabs_splat"); @@ -378,6 +387,7 @@ mod tests { assert_eq!(InferenceJobProductCategory::from_str("beeble_video").unwrap(), InferenceJobProductCategory::BeebleVideo); assert_eq!(InferenceJobProductCategory::from_str("seedance2pro_video").unwrap(), InferenceJobProductCategory::Seedance2ProVideo); assert_eq!(InferenceJobProductCategory::from_str("seedance2pro_character").unwrap(), InferenceJobProductCategory::Seedance2ProCharacter); + assert_eq!(InferenceJobProductCategory::from_str("seedance2pro_image").unwrap(), InferenceJobProductCategory::Seedance2ProImage); assert_eq!(InferenceJobProductCategory::from_str("seedance2pro_video_alt").unwrap(), InferenceJobProductCategory::Seedance2ProVideoAlt); assert_eq!(InferenceJobProductCategory::from_str("seedance2pro_video_bpu").unwrap(), InferenceJobProductCategory::Seedance2ProVideoBytePlusUltra); assert_eq!(InferenceJobProductCategory::from_str("worldlabs_splat").unwrap(), InferenceJobProductCategory::WorldlabsSplat); @@ -407,7 +417,7 @@ mod tests { #[test] fn all_variants() { // Static check - const EXPECTED_COUNT : usize = 35; + const EXPECTED_COUNT : usize = 36; assert_eq!(InferenceJobProductCategory::all_variants().len(), EXPECTED_COUNT); assert_eq!(InferenceJobProductCategory::iter().len(), EXPECTED_COUNT); diff --git a/crates/schema/public/enums/src/by_table/generic_inference_jobs/inference_model_type.rs b/crates/schema/public/enums/src/by_table/generic_inference_jobs/inference_model_type.rs index 8899a91074..545a16fd79 100644 --- a/crates/schema/public/enums/src/by_table/generic_inference_jobs/inference_model_type.rs +++ b/crates/schema/public/enums/src/by_table/generic_inference_jobs/inference_model_type.rs @@ -143,6 +143,15 @@ pub enum InferenceModelType { MidjourneyV7DraftRaw, #[serde(rename = "midjourney_v7_raw")] MidjourneyV7Raw, + /// Midjourney v7 as routed via kinovi + #[serde(rename = "midjourney_7")] + Midjourney7, + /// Midjourney v7 niji as routed via kinovi + #[serde(rename = "midjourney_7_niji")] + Midjourney7Niji, + /// Midjourney v8 as routed via kinovi + #[serde(rename = "midjourney_8")] + Midjourney8, // Video models #[serde(rename = "grok_video")] @@ -284,6 +293,9 @@ impl InferenceModelType { Self::MidjourneyV7Draft => "midjourney_v7_draft", Self::MidjourneyV7DraftRaw => "midjourney_v7_draft_raw", Self::MidjourneyV7Raw => "midjourney_v7_raw", + Self::Midjourney7 => "midjourney_7", + Self::Midjourney7Niji => "midjourney_7_niji", + Self::Midjourney8 => "midjourney_8", // Video models Self::GrokVideo => "grok_video", @@ -383,6 +395,9 @@ impl InferenceModelType { "midjourney_v7_draft" => Ok(Self::MidjourneyV7Draft), "midjourney_v7_draft_raw" => Ok(Self::MidjourneyV7DraftRaw), "midjourney_v7_raw" => Ok(Self::MidjourneyV7Raw), + "midjourney_7" => Ok(Self::Midjourney7), + "midjourney_7_niji" => Ok(Self::Midjourney7Niji), + "midjourney_8" => Ok(Self::Midjourney8), // Video models "grok_video" => Ok(Self::GrokVideo), @@ -486,6 +501,9 @@ impl InferenceModelType { Self::MidjourneyV7Draft, Self::MidjourneyV7DraftRaw, Self::MidjourneyV7Raw, + Self::Midjourney7, + Self::Midjourney7Niji, + Self::Midjourney8, // Video models Self::GrokVideo, @@ -572,6 +590,9 @@ impl InferenceModelType { CommonModelType::MidjourneyV7Draft => Self::MidjourneyV7Draft, CommonModelType::MidjourneyV7DraftRaw => Self::MidjourneyV7DraftRaw, CommonModelType::MidjourneyV7Raw => Self::MidjourneyV7Raw, + CommonModelType::Midjourney7 => Self::Midjourney7, + CommonModelType::Midjourney7Niji => Self::Midjourney7Niji, + CommonModelType::Midjourney8 => Self::Midjourney8, // Video models CommonModelType::GrokVideo => Self::GrokVideo, @@ -681,6 +702,9 @@ mod tests { assert_serialization(InferenceModelType::MidjourneyV7Draft, "midjourney_v7_draft"); assert_serialization(InferenceModelType::MidjourneyV7DraftRaw, "midjourney_v7_draft_raw"); assert_serialization(InferenceModelType::MidjourneyV7Raw, "midjourney_v7_raw"); + assert_serialization(InferenceModelType::Midjourney7, "midjourney_7"); + assert_serialization(InferenceModelType::Midjourney7Niji, "midjourney_7_niji"); + assert_serialization(InferenceModelType::Midjourney8, "midjourney_8"); // Video models assert_serialization(InferenceModelType::GrokVideo, "grok_video"); @@ -779,6 +803,9 @@ mod tests { assert_eq!(InferenceModelType::MidjourneyV7Draft.to_str(), "midjourney_v7_draft"); assert_eq!(InferenceModelType::MidjourneyV7DraftRaw.to_str(), "midjourney_v7_draft_raw"); assert_eq!(InferenceModelType::MidjourneyV7Raw.to_str(), "midjourney_v7_raw"); + assert_eq!(InferenceModelType::Midjourney7.to_str(), "midjourney_7"); + assert_eq!(InferenceModelType::Midjourney7Niji.to_str(), "midjourney_7_niji"); + assert_eq!(InferenceModelType::Midjourney8.to_str(), "midjourney_8"); // Video models assert_eq!(InferenceModelType::GrokVideo.to_str(), "grok_video"); @@ -877,6 +904,9 @@ mod tests { assert_eq!(InferenceModelType::from_str("midjourney_v7_draft").unwrap(), InferenceModelType::MidjourneyV7Draft); assert_eq!(InferenceModelType::from_str("midjourney_v7_draft_raw").unwrap(), InferenceModelType::MidjourneyV7DraftRaw); assert_eq!(InferenceModelType::from_str("midjourney_v7_raw").unwrap(), InferenceModelType::MidjourneyV7Raw); + assert_eq!(InferenceModelType::from_str("midjourney_7").unwrap(), InferenceModelType::Midjourney7); + assert_eq!(InferenceModelType::from_str("midjourney_7_niji").unwrap(), InferenceModelType::Midjourney7Niji); + assert_eq!(InferenceModelType::from_str("midjourney_8").unwrap(), InferenceModelType::Midjourney8); // Video models assert_eq!(InferenceModelType::from_str("grok_video").unwrap(), InferenceModelType::GrokVideo); diff --git a/crates/schema/public/enums/src/common/generation/common_image_model.rs b/crates/schema/public/enums/src/common/generation/common_image_model.rs index 8d75435a1a..f558376f80 100644 --- a/crates/schema/public/enums/src/common/generation/common_image_model.rs +++ b/crates/schema/public/enums/src/common/generation/common_image_model.rs @@ -29,6 +29,12 @@ pub enum CommonImageModel { GrokImagineImage, #[serde(rename = "grok_imagine_image_q")] GrokImagineImageQuality, + #[serde(rename = "midjourney_7")] + Midjourney7, + #[serde(rename = "midjourney_7_niji")] + Midjourney7Niji, + #[serde(rename = "midjourney_8")] + Midjourney8, #[serde(rename = "nano_banana")] NanoBanana, #[serde(rename = "nano_banana_2")] @@ -60,6 +66,9 @@ impl CommonImageModel { Self::GptImage2 => CommonModelType::GptImage2, Self::GrokImagineImage => CommonModelType::GrokImagineImage, Self::GrokImagineImageQuality => CommonModelType::GrokImagineImageQuality, + Self::Midjourney7 => CommonModelType::Midjourney7, + Self::Midjourney7Niji => CommonModelType::Midjourney7Niji, + Self::Midjourney8 => CommonModelType::Midjourney8, Self::NanoBanana => CommonModelType::NanoBanana, Self::NanoBanana2 => CommonModelType::NanoBanana2, Self::NanoBananaPro => CommonModelType::NanoBananaPro, @@ -93,6 +102,9 @@ mod tests { assert_serialization(CommonImageModel::GptImage2, "gpt_image_2"); assert_serialization(CommonImageModel::GrokImagineImage, "grok_imagine_image"); assert_serialization(CommonImageModel::GrokImagineImageQuality, "grok_imagine_image_q"); + assert_serialization(CommonImageModel::Midjourney7, "midjourney_7"); + assert_serialization(CommonImageModel::Midjourney7Niji, "midjourney_7_niji"); + assert_serialization(CommonImageModel::Midjourney8, "midjourney_8"); assert_serialization(CommonImageModel::NanoBanana, "nano_banana"); assert_serialization(CommonImageModel::NanoBanana2, "nano_banana_2"); assert_serialization(CommonImageModel::NanoBananaPro, "nano_banana_pro"); @@ -115,6 +127,9 @@ mod tests { (CommonImageModel::GptImage2, CommonModelType::GptImage2), (CommonImageModel::GrokImagineImage, CommonModelType::GrokImagineImage), (CommonImageModel::GrokImagineImageQuality, CommonModelType::GrokImagineImageQuality), + (CommonImageModel::Midjourney7, CommonModelType::Midjourney7), + (CommonImageModel::Midjourney7Niji, CommonModelType::Midjourney7Niji), + (CommonImageModel::Midjourney8, CommonModelType::Midjourney8), (CommonImageModel::NanoBanana, CommonModelType::NanoBanana), (CommonImageModel::NanoBanana2, CommonModelType::NanoBanana2), (CommonImageModel::NanoBananaPro, CommonModelType::NanoBananaPro), @@ -149,7 +164,7 @@ mod tests { #[test] fn variant_count() { use strum::IntoEnumIterator; - assert_eq!(CommonImageModel::iter().len(), 17); + assert_eq!(CommonImageModel::iter().len(), 20); } #[test] diff --git a/crates/schema/public/enums/src/common/generation/common_model_type.rs b/crates/schema/public/enums/src/common/generation/common_model_type.rs index bcbfb45ec1..897d48d53c 100644 --- a/crates/schema/public/enums/src/common/generation/common_model_type.rs +++ b/crates/schema/public/enums/src/common/generation/common_model_type.rs @@ -100,6 +100,15 @@ pub enum CommonModelType { MidjourneyV7DraftRaw, #[serde(rename = "midjourney_v7_raw")] MidjourneyV7Raw, + /// Midjourney v7 as routed via kinovi + #[serde(rename = "midjourney_7")] + Midjourney7, + /// Midjourney v7 niji as routed via kinovi + #[serde(rename = "midjourney_7_niji")] + Midjourney7Niji, + /// Midjourney v8 as routed via kinovi + #[serde(rename = "midjourney_8")] + Midjourney8, //// Image Infill models //#[serde(rename = "flux_pro_1_infill")] @@ -237,6 +246,9 @@ impl CommonModelType { Self::MidjourneyV7Draft => "midjourney_v7_draft", Self::MidjourneyV7DraftRaw => "midjourney_v7_draft_raw", Self::MidjourneyV7Raw => "midjourney_v7_raw", + Self::Midjourney7 => "midjourney_7", + Self::Midjourney7Niji => "midjourney_7_niji", + Self::Midjourney8 => "midjourney_8", // Video models Self::GrokVideo => "grok_video", @@ -319,6 +331,9 @@ impl CommonModelType { "midjourney_v7_draft" => Ok(Self::MidjourneyV7Draft), "midjourney_v7_draft_raw" => Ok(Self::MidjourneyV7DraftRaw), "midjourney_v7_raw" => Ok(Self::MidjourneyV7Raw), + "midjourney_7" => Ok(Self::Midjourney7), + "midjourney_7_niji" => Ok(Self::Midjourney7Niji), + "midjourney_8" => Ok(Self::Midjourney8), // Video models "grok_video" => Ok(Self::GrokVideo), @@ -405,6 +420,9 @@ impl CommonModelType { Self::MidjourneyV7Draft, Self::MidjourneyV7DraftRaw, Self::MidjourneyV7Raw, + Self::Midjourney7, + Self::Midjourney7Niji, + Self::Midjourney8, // Video models Self::GrokVideo, @@ -488,6 +506,9 @@ impl CommonModelType { Self::MidjourneyV7Draft => CommonModelClass::Image, Self::MidjourneyV7DraftRaw => CommonModelClass::Image, Self::MidjourneyV7Raw => CommonModelClass::Image, + Self::Midjourney7 => CommonModelClass::Image, + Self::Midjourney7Niji => CommonModelClass::Image, + Self::Midjourney8 => CommonModelClass::Image, // Video models Self::GrokVideo => CommonModelClass::Video, @@ -581,6 +602,9 @@ mod tests { assert_serialization(CommonModelType::MidjourneyV7Draft, "midjourney_v7_draft"); assert_serialization(CommonModelType::MidjourneyV7DraftRaw, "midjourney_v7_draft_raw"); assert_serialization(CommonModelType::MidjourneyV7Raw, "midjourney_v7_raw"); + assert_serialization(CommonModelType::Midjourney7, "midjourney_7"); + assert_serialization(CommonModelType::Midjourney7Niji, "midjourney_7_niji"); + assert_serialization(CommonModelType::Midjourney8, "midjourney_8"); // Video models assert_serialization(CommonModelType::GrokVideo, "grok_video"); assert_serialization(CommonModelType::GrokImagineVideo, "grok_imagine_video"); @@ -659,6 +683,9 @@ mod tests { assert_eq!(CommonModelType::MidjourneyV7Draft.to_str(), "midjourney_v7_draft"); assert_eq!(CommonModelType::MidjourneyV7DraftRaw.to_str(), "midjourney_v7_draft_raw"); assert_eq!(CommonModelType::MidjourneyV7Raw.to_str(), "midjourney_v7_raw"); + assert_eq!(CommonModelType::Midjourney7.to_str(), "midjourney_7"); + assert_eq!(CommonModelType::Midjourney7Niji.to_str(), "midjourney_7_niji"); + assert_eq!(CommonModelType::Midjourney8.to_str(), "midjourney_8"); // Video models assert_eq!(CommonModelType::GrokVideo.to_str(), "grok_video"); @@ -739,6 +766,9 @@ mod tests { assert_eq!(CommonModelType::from_str("midjourney_v7_draft").unwrap(), CommonModelType::MidjourneyV7Draft); assert_eq!(CommonModelType::from_str("midjourney_v7_draft_raw").unwrap(), CommonModelType::MidjourneyV7DraftRaw); assert_eq!(CommonModelType::from_str("midjourney_v7_raw").unwrap(), CommonModelType::MidjourneyV7Raw); + assert_eq!(CommonModelType::from_str("midjourney_7").unwrap(), CommonModelType::Midjourney7); + assert_eq!(CommonModelType::from_str("midjourney_7_niji").unwrap(), CommonModelType::Midjourney7Niji); + assert_eq!(CommonModelType::from_str("midjourney_8").unwrap(), CommonModelType::Midjourney8); // Video models assert_eq!(CommonModelType::from_str("grok_video").unwrap(), CommonModelType::GrokVideo); assert_eq!(CommonModelType::from_str("grok_imagine_video").unwrap(), CommonModelType::GrokImagineVideo); @@ -785,7 +815,7 @@ mod tests { #[test] fn all_variants() { let mut variants = CommonModelType::all_variants(); - assert_eq!(variants.len(), 70); + assert_eq!(variants.len(), 73); // Image models assert_eq!(variants.pop_first(), Some(CommonModelType::Flux1Dev)); assert_eq!(variants.pop_first(), Some(CommonModelType::Flux1Schnell)); @@ -820,6 +850,9 @@ mod tests { assert_eq!(variants.pop_first(), Some(CommonModelType::MidjourneyV7Draft)); assert_eq!(variants.pop_first(), Some(CommonModelType::MidjourneyV7DraftRaw)); assert_eq!(variants.pop_first(), Some(CommonModelType::MidjourneyV7Raw)); + assert_eq!(variants.pop_first(), Some(CommonModelType::Midjourney7)); + assert_eq!(variants.pop_first(), Some(CommonModelType::Midjourney7Niji)); + assert_eq!(variants.pop_first(), Some(CommonModelType::Midjourney8)); // Video models assert_eq!(variants.pop_first(), Some(CommonModelType::GrokVideo)); assert_eq!(variants.pop_first(), Some(CommonModelType::GrokImagineVideo)); diff --git a/crates/service/job/seedance2_pro_job/src/jobs/video_polling_job/process_job/mod.rs b/crates/service/job/seedance2_pro_job/src/jobs/video_polling_job/process_job/mod.rs index 357e8d5ae1..b2de8e165f 100644 --- a/crates/service/job/seedance2_pro_job/src/jobs/video_polling_job/process_job/mod.rs +++ b/crates/service/job/seedance2_pro_job/src/jobs/video_polling_job/process_job/mod.rs @@ -1,2 +1,3 @@ pub mod process_failed_job; +pub mod process_successful_image_job; pub mod process_successful_job; diff --git a/crates/service/job/seedance2_pro_job/src/jobs/video_polling_job/process_job/process_successful_image_job.rs b/crates/service/job/seedance2_pro_job/src/jobs/video_polling_job/process_job/process_successful_image_job.rs new file mode 100644 index 0000000000..35ddb0a49b --- /dev/null +++ b/crates/service/job/seedance2_pro_job/src/jobs/video_polling_job/process_job/process_successful_image_job.rs @@ -0,0 +1,218 @@ +use anyhow::anyhow; +use log::{error, info, warn}; + +use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath; +use enums::by_table::generic_inference_jobs::inference_result_type::InferenceResultType; +use enums::by_table::media_files::media_file_class::MediaFileClass; +use enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory; +use enums::by_table::media_files::media_file_origin_product_category::MediaFileOriginProductCategory; +use enums::by_table::media_files::media_file_type::MediaFileType; +use enums::common::generation_provider::GenerationProvider; +use errors::AnyhowResult; +use hashing::sha256::sha256_hash_bytes::sha256_hash_bytes; +use mysql_queries::queries::generic_inference::api_providers::seedance2pro::list_pending_seedance2pro_video_jobs::PendingSeedance2ProJob; +use mysql_queries::queries::generic_inference::web::mark_generic_inference_job_successfully_done_by_token::mark_generic_inference_job_successfully_done_by_token; +use mysql_queries::queries::media_files::create::insert_builder::media_file_insert_builder::MediaFileInsertBuilder; +use seedance2pro_client::requests::poll_orders::poll_orders::{OrderStatus, VideoResult}; +use tokens::tokens::batch_generations::BatchGenerationToken; +use tokens::tokens::media_files::MediaFileToken; + +use crate::job_dependencies::JobDependencies; +use crate::jobs::video_polling_job::alert_on_error::alert_pager_and_return_err; + +const PREFIX: &str = "artcraft_"; +const SUFFIX: &str = ".png"; +const MIME_TYPE: &str = "image/png"; + +/// Process a completed Midjourney image order from Kinovi. +/// +/// Each completed Midjourney task returns up to four images (one per +/// position in the 2×2 grid). We download each, upload to the public +/// bucket, and insert a `media_files` row per image. The inference job +/// row is marked successful with the first media file as the primary +/// result entity. +pub async fn process_successful_image_job( + deps: &JobDependencies, + job: &PendingSeedance2ProJob, + order: &OrderStatus, +) -> AnyhowResult<()> { + if order.results.is_empty() { + return Err(anyhow!( + "Completed image order {} has no result entries", + order.order_id + )); + } + + info!( + "Processing completed image order {}: {} image(s) to download.", + order.order_id, order.results.len(), + ); + + // Download, hash, upload, and insert one media_file per image. We + // process sequentially: Kinovi/upstream is the bottleneck here, so + // parallelising adds little while complicating refund-on-partial-failure. + let mut created_tokens: Vec = Vec::with_capacity(order.results.len()); + + let is_batch = order.results.len() > 1; + + let maybe_batch_prompt_token = if is_batch { + Some(BatchGenerationToken::generate()) + } else { + None + }; + + for (idx, result) in order.results.iter().enumerate() { + let token = match download_and_store_one_image(deps, job, order, result, idx, maybe_batch_prompt_token.as_ref()).await { + Ok(t) => t, + Err(err) => { + // The first image is the headline result. If it fails, surface + // the error so the caller can retry on the next poll. Failures on + // later images are logged but don't sink the whole order — we + // still want the primary result available to the user. + if idx == 0 { + return Err(err); + } + warn!( + "Failed to process image {}/{} for order {}: {:?}", + idx + 1, order.results.len(), order.order_id, err, + ); + continue; + } + }; + created_tokens.push(token); + } + + let primary_token = created_tokens.first().ok_or_else(|| { + anyhow!( + "No media files were created for order {} despite {} result(s)", + order.order_id, order.results.len(), + ) + })?; + + info!( + "Created {} media file(s) for order {} (primary={}). Marking job {} complete.", + created_tokens.len(), + order.order_id, + primary_token.as_str(), + job.job_token.as_str(), + ); + + if let Err(err) = mark_generic_inference_job_successfully_done_by_token( + &deps.mysql_pool, + &job.job_token, + Some(InferenceResultType::MediaFile), + Some(primary_token.as_str()), + None, + None, + ).await { + error!("Error marking image job {} done: {:?}", job.job_token.as_str(), err); + return alert_pager_and_return_err( + &deps.pager, + "Seedance2Pro image job completion update failed", + anyhow!("error marking job done: {:?}", err), + Some(job), + ); + } + + info!("Image job {} completed successfully.", job.job_token.as_str()); + + Ok(()) +} + +/// Download a single image from Kinovi's CDN, upload to our public bucket, +/// and insert a `media_files` row. Returns the new media file token. +async fn download_and_store_one_image( + deps: &JobDependencies, + job: &PendingSeedance2ProJob, + order: &OrderStatus, + result: &VideoResult, + index: usize, + maybe_batch_token: Option<&BatchGenerationToken>, +) -> AnyhowResult { + let image_url = result.url.as_str(); + + info!( + "Downloading image {}/{} for order {} from: {}", + index + 1, order.results.len(), order.order_id, image_url, + ); + + let image_bytes: Vec = reqwest::get(image_url) + .await + .map_err(|err| anyhow!("reqwest error downloading image {}: {:?}", index, err))? + .bytes() + .await + .map_err(|err| anyhow!("error reading image bytes for index {}: {:?}", index, err))? + .to_vec(); + + info!( + "Downloaded {} bytes for image {}/{} of order {}", + image_bytes.len(), index + 1, order.results.len(), order.order_id, + ); + + let checksum = sha256_hash_bytes(&image_bytes) + .map_err(|err| anyhow!("error hashing image {}: {:?}", index, err))?; + + let bucket_path = MediaFileBucketPath::generate_new(Some(PREFIX), Some(SUFFIX)); + let object_path = bucket_path.get_full_object_path_str(); + + info!( + "Uploading image {}/{} to public bucket at path: {}", + index + 1, order.results.len(), object_path, + ); + + deps + .public_bucket_client + .upload_file_with_content_type_process(object_path, &image_bytes, MIME_TYPE) + .await + .map_err(|err| anyhow!("bucket upload failed for image {}: {:?}", index, err))?; + + let media_file_token = MediaFileInsertBuilder::new() + .checksum_sha2(&checksum) + .creator_ip_address(&job.creator_ip_address) + .creator_set_visibility(job.creator_set_visibility) + .file_size_bytes(image_bytes.len() as u64) + .maybe_batch_generation_token(maybe_batch_token) + .maybe_creator_anonymous_visitor(job.maybe_creator_anonymous_visitor_token.as_ref()) + .maybe_creator_user(job.maybe_creator_user_token.as_ref()) + .maybe_frame_height(Some(result.height)) + .maybe_frame_width(Some(result.width)) + .maybe_generation_provider(Some(GenerationProvider::Artcraft)) + .maybe_prompt_token(job.maybe_prompt_token.as_ref()) + .media_file_class(MediaFileClass::Image) + .media_file_origin_category(MediaFileOriginCategory::Inference) + .media_file_origin_product_category(MediaFileOriginProductCategory::ImageGeneration) + .media_file_type(MediaFileType::Png) + .mime_type(MIME_TYPE) + .public_bucket_directory_hash(&bucket_path) + .insert_pool(&deps.mysql_pool) + .await + .map_err(|err| anyhow!("media_file insert failed for image {}: {:?}", index, err))?; + + Ok(media_file_token) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Quick sanity check that the bucket-path constants are what the rest + /// of the codebase expects for image generations (the video flow uses + /// `artcraft_` + `.mp4`; we use `.png`). + #[test] + fn bucket_path_uses_png_suffix() { + assert_eq!(PREFIX, "artcraft_"); + assert_eq!(SUFFIX, ".png"); + assert_eq!(MIME_TYPE, "image/png"); + } + + /// Generated bucket paths should end with the configured `.png` suffix + /// (the underlying helper is shared with the video flow but parameterised + /// on the extension). + #[test] + fn generated_bucket_path_ends_with_png() { + let path = MediaFileBucketPath::generate_new(Some(PREFIX), Some(SUFFIX)); + let s = path.get_full_object_path_str().to_string(); + assert!(s.ends_with(".png"), "expected .png suffix, got {}", s); + assert!(s.contains("artcraft_"), "expected `artcraft_` prefix in path, got {}", s); + } +} diff --git a/crates/service/job/seedance2_pro_job/src/jobs/video_polling_job/process_orders_batch.rs b/crates/service/job/seedance2_pro_job/src/jobs/video_polling_job/process_orders_batch.rs index 5a75626d17..045620dd0b 100644 --- a/crates/service/job/seedance2_pro_job/src/jobs/video_polling_job/process_orders_batch.rs +++ b/crates/service/job/seedance2_pro_job/src/jobs/video_polling_job/process_orders_batch.rs @@ -2,10 +2,11 @@ use std::collections::HashMap; use log::{info, warn}; use mysql_queries::queries::generic_inference::api_providers::seedance2pro::list_pending_seedance2pro_video_jobs::PendingSeedance2ProJob; -use seedance2pro_client::requests::poll_orders::poll_orders::{OrderStatus, TaskStatus}; +use seedance2pro_client::requests::poll_orders::poll_orders::{OrderMediaType, OrderStatus, TaskStatus}; use crate::job_dependencies::JobDependencies; use crate::jobs::video_polling_job::process_job::process_failed_job::process_failed_job; +use crate::jobs::video_polling_job::process_job::process_successful_image_job::process_successful_image_job; use crate::jobs::video_polling_job::process_job::process_successful_job::process_successful_job; /// Process a batch of polled orders against the pending jobs map. @@ -38,11 +39,30 @@ pub async fn process_orders_batch( match &order.task_status { TaskStatus::Completed => { info!( - "Order {} completed, processing job {}", + "Order {} completed (media_type={:?}), processing job {}", order.order_id, + order.media_type, job.job_token.as_str() ); - if let Err(err) = process_successful_job(deps, &job, order).await { + + // Dispatch on media type: Midjourney image orders return ~4 PNGs + // and need different download/insert handling than the video + // path. `None` (older response shapes that pre-date the + // `mediaType` field) and `Some(Video)` both fall to the video + // handler for back-compat with the seedance video flow. + let result = match &order.media_type { + Some(OrderMediaType::Image) => process_successful_image_job(deps, &job, order).await, + Some(OrderMediaType::Video) | None => process_successful_job(deps, &job, order).await, + Some(OrderMediaType::Unknown(other)) => { + warn!( + "Order {} has unrecognised media_type {:?}; treating as video", + order.order_id, other, + ); + process_successful_job(deps, &job, order).await + } + }; + + if let Err(err) = result { warn!( "Error processing completed order {}: {:?}", order.order_id, err diff --git a/crates/service/web/storyteller_web/src/configs/omni_gen/image_models.rs b/crates/service/web/storyteller_web/src/configs/omni_gen/image_models.rs index 9c9e2fae86..c3ee6b52cd 100644 --- a/crates/service/web/storyteller_web/src/configs/omni_gen/image_models.rs +++ b/crates/service/web/storyteller_web/src/configs/omni_gen/image_models.rs @@ -103,6 +103,87 @@ fn build_omni_gen_image_models() -> Vec { ..Default::default() }); + models.push(OmniGenImageModelDetails { + model: CommonImageModel::Midjourney7, + model_creator: Some(ModelCreator::Midjourney), + full_name: Some("Midjourney v7".to_string()), + text_prompt_supported: Some(true), + image_refs_supported: Some(true), + image_refs_max: Some(1), + aspect_ratio_options: Some(vec![ + CommonAspectRatio::Auto, + CommonAspectRatio::TallTwoByThree, + CommonAspectRatio::TallThreeByFour, + CommonAspectRatio::TallFourByFive, + CommonAspectRatio::TallNineBySixteen, + CommonAspectRatio::WideThreeByTwo, + CommonAspectRatio::WideFourByThree, + CommonAspectRatio::WideFiveByFour, + CommonAspectRatio::WideSixteenByNine, + CommonAspectRatio::WideTwentyOneByNine, + ]), + aspect_ratio_default: Some(CommonAspectRatio::Square), + //aspect_ratio_default_when_editing: Some(CommonAspectRatio::Auto), + batch_size_min: Some(1), + batch_size_max: Some(2), // TODO: Fix batch sizes + batch_size_default: Some(1), + ..Default::default() + }); + + models.push(OmniGenImageModelDetails { + model: CommonImageModel::Midjourney7Niji, + model_creator: Some(ModelCreator::Midjourney), + full_name: Some("Midjourney v7 Niji (Anime)".to_string()), + text_prompt_supported: Some(true), + image_refs_supported: Some(true), + image_refs_max: Some(1), + aspect_ratio_options: Some(vec![ + CommonAspectRatio::Auto, + CommonAspectRatio::TallTwoByThree, + CommonAspectRatio::TallThreeByFour, + CommonAspectRatio::TallFourByFive, + CommonAspectRatio::TallNineBySixteen, + CommonAspectRatio::WideThreeByTwo, + CommonAspectRatio::WideFourByThree, + CommonAspectRatio::WideFiveByFour, + CommonAspectRatio::WideSixteenByNine, + CommonAspectRatio::WideTwentyOneByNine, + ]), + aspect_ratio_default: Some(CommonAspectRatio::Square), + //aspect_ratio_default_when_editing: Some(CommonAspectRatio::Auto), + batch_size_min: Some(1), + batch_size_max: Some(2), // TODO: Fix batch sizes + batch_size_default: Some(1), + ..Default::default() + }); + + models.push(OmniGenImageModelDetails { + model: CommonImageModel::Midjourney8, + model_creator: Some(ModelCreator::Midjourney), + full_name: Some("Midjourney v8".to_string()), + text_prompt_supported: Some(true), + image_refs_supported: Some(true), + image_refs_max: Some(1), + aspect_ratio_options: Some(vec![ + CommonAspectRatio::Auto, + CommonAspectRatio::TallTwoByThree, + CommonAspectRatio::TallThreeByFour, + CommonAspectRatio::TallFourByFive, + CommonAspectRatio::TallNineBySixteen, + CommonAspectRatio::WideThreeByTwo, + CommonAspectRatio::WideFourByThree, + CommonAspectRatio::WideFiveByFour, + CommonAspectRatio::WideSixteenByNine, + CommonAspectRatio::WideTwentyOneByNine, + ]), + aspect_ratio_default: Some(CommonAspectRatio::Square), + //aspect_ratio_default_when_editing: Some(CommonAspectRatio::Auto), + batch_size_min: Some(1), + batch_size_max: Some(2), // TODO: Fix batch sizes + batch_size_default: Some(1), + ..Default::default() + }); + models.push(OmniGenImageModelDetails { model: CommonImageModel::NanoBanana, // NB: currently Gemini25Flash in our system model_creator: Some(ModelCreator::ArtCraft), diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/cost_estimate/image/estimate_image_cost_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/cost_estimate/image/estimate_image_cost_handler.rs index 848af61a09..3b5e985d31 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/cost_estimate/image/estimate_image_cost_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/cost_estimate/image/estimate_image_cost_handler.rs @@ -145,6 +145,9 @@ fn map_image_model(model: CommonImageModel) -> Result RouterImageModel::GrokImagineImageQuality, CommonImageModel::QwenEdit2511Angles => RouterImageModel::QwenEdit2511Angles, CommonImageModel::Flux2LoraAngles => RouterImageModel::Flux2LoraAngles, + CommonImageModel::Midjourney7 => RouterImageModel::Midjourney7, + CommonImageModel::Midjourney7Niji => RouterImageModel::Midjourney7Niji, + CommonImageModel::Midjourney8 => RouterImageModel::Midjourney8, }; Ok(router_model) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/image/insert_db_job/insert_fal_job.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/image/insert_db_job/insert_fal_job.rs new file mode 100644 index 0000000000..19df43757e --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/image/insert_db_job/insert_fal_job.rs @@ -0,0 +1,59 @@ +use log::warn; + +use enums::common::visibility::Visibility; +use mysql_queries::queries::generic_inference::api_providers::fal::insert_generic_inference_job_for_fal_queue::FalCategory; +use mysql_queries::queries::generic_inference::api_providers::fal::insert_generic_inference_job_for_fal_queue_with_apriori_job_token::{ + insert_generic_inference_job_for_fal_queue_with_apriori_job_token, + InsertGenericInferenceForFalWithAprioriJobTokenArgs, +}; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; + +use crate::http_server::common_responses::common_web_error::CommonWebError; + +use super::shared_job_args::SharedJobArgs; + +pub struct InsertFalJobArgs<'a, 'tx> { + pub external_job_id: &'a str, + pub shared: SharedJobArgs<'a, 'tx>, +} + +/// Insert a single Fal-queue inference job row for an image generation. +/// Mirrors `omni_gen::generate::video::insert_db_job::insert_fal_job::insert_fal_job` +/// but tags the row with `FalCategory::ImageGeneration` so the worker +/// routes it to the image-generation queue. +pub async fn insert_fal_job(args: InsertFalJobArgs<'_, '_>) -> Result { + let InsertFalJobArgs { + external_job_id, + shared, + } = args; + + let db_result = insert_generic_inference_job_for_fal_queue_with_apriori_job_token( + InsertGenericInferenceForFalWithAprioriJobTokenArgs { + apriori_job_token: shared.apriori_job_token, + uuid_idempotency_token: shared.idempotency_token, + maybe_external_third_party_id: external_job_id, + fal_category: FalCategory::ImageGeneration, + maybe_model_type: shared.maybe_model_type, + maybe_prompt_token: shared.maybe_prompt_token, + maybe_creator_user_token: Some(shared.user_token), + maybe_avt_token: shared.maybe_avt_token, + creator_ip_address: shared.ip_address, + creator_set_visibility: Visibility::Public, + maybe_debug_log_event_token: shared.maybe_debug_log_event_token, + mysql_executor: &mut **shared.transaction, + maybe_inference_args: None, + starting_job_status_override: None, + maybe_frontend_failure_category: None, + maybe_failure_reason: None, + phantom: Default::default(), + } + ).await; + + match db_result { + Ok(token) => Ok(token), + Err(err) => { + warn!("Error inserting fal inference job (image): {:?}", err); + Err(CommonWebError::from_error(err)) + } + } +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/image/insert_db_job/insert_seedance2pro_jobs.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/image/insert_db_job/insert_seedance2pro_jobs.rs new file mode 100644 index 0000000000..00c6ca5c9b --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/image/insert_db_job/insert_seedance2pro_jobs.rs @@ -0,0 +1,115 @@ +use log::{error, warn}; + +use enums::common::visibility::Visibility; +use mysql_queries::queries::generic_inference::api_providers::seedance2pro::insert_generic_inference_job_for_seedance2pro_queue_with_apriori_job_token::{ + insert_generic_inference_job_for_seedance2pro_queue_with_apriori_job_token, + InsertGenericInferenceForSeedance2ProWithAprioriJobTokenArgs, KinoviVersion, +}; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken; +use uuid_utils::uuid::generate_random_uuid; + +use crate::http_server::common_responses::common_web_error::CommonWebError; + +use super::shared_job_args::SharedJobArgs; + +pub struct InsertSeedance2proJobsArgs<'a, 'tx> { + /// The order_id for the primary job (always inserted first). + pub primary_order_id: &'a str, + + /// Additional order_ids returned by Kinovi when batch_count > 1. Each + /// becomes its own inference job row. + pub maybe_additional_order_ids: Option<&'a [String]>, + + /// Wallet ledger entry from the up-front deduction. Stamped on each + /// inserted row so the worker can issue a refund on failure. + pub maybe_wallet_ledger_entry_token: Option<&'a WalletLedgerEntryToken>, + + /// Which Kinovi account fulfilled the request — Volcengine, BytePlus, + /// or BytePlusUltra. For Midjourney image generation today this is + /// always Volcengine, but the helper stays version-agnostic so a future + /// account-picking layer can use it unchanged. + pub kinovi_version: KinoviVersion, + + pub shared: SharedJobArgs<'a, 'tx>, +} + +pub struct InsertSeedance2proJobsResult { + pub primary_job_token: InferenceJobToken, + pub all_job_tokens: Vec, +} + +/// Insert one Seedance2Pro/Kinovi inference job per returned order_id. +/// Mirrors the video-side helper at +/// `omni_gen::generate::video::insert_db_job::insert_seedance2pro_jobs`. +pub async fn insert_seedance2pro_jobs(args: InsertSeedance2proJobsArgs<'_, '_>) -> Result { + let InsertSeedance2proJobsArgs { + primary_order_id, + maybe_additional_order_ids, + maybe_wallet_ledger_entry_token, + kinovi_version, + mut shared, + } = args; + + // Build a deduplicated list with primary_order_id first. + let mut all_order_ids = vec![primary_order_id.to_string()]; + + if let Some(additional) = maybe_additional_order_ids { + for id in additional { + if id != primary_order_id { + all_order_ids.push(id.clone()); + } + } + } + + let mut all_job_tokens: Vec = Vec::with_capacity(all_order_ids.len()); + + for (i, order_id) in all_order_ids.iter().enumerate() { + let job_token = if i == 0 { shared.apriori_job_token.clone() } else { InferenceJobToken::generate() }; + + // The idempotency_token column has a max length too tight for the + // `{idempotency}-batch-{i}` suffix pattern used elsewhere; mint a + // fresh UUID for each additional row instead. The primary row still + // wants a deterministic value, but historic behavior on the video + // side has been to mint UUIDs for all rows, which we mirror here. + let idempotency_str = generate_random_uuid(); + + let db_result = insert_generic_inference_job_for_seedance2pro_queue_with_apriori_job_token( + InsertGenericInferenceForSeedance2ProWithAprioriJobTokenArgs { + kinovi_version, + apriori_job_token: &job_token, + uuid_idempotency_token: &idempotency_str, + maybe_external_third_party_id: order_id, + maybe_model_type: shared.maybe_model_type, + maybe_prompt_token: shared.maybe_prompt_token, + maybe_wallet_ledger_entry_token, + maybe_creator_user_token: Some(shared.user_token), + maybe_avt_token: shared.maybe_avt_token, + creator_ip_address: shared.ip_address, + creator_set_visibility: Visibility::Public, + maybe_debug_log_event_token: shared.maybe_debug_log_event_token, + mysql_executor: &mut **shared.transaction, + maybe_inference_args: None, + phantom: Default::default(), + } + ).await; + + match db_result { + Ok(token) => all_job_tokens.push(token), + Err(err) => { + warn!("Error inserting seedance2pro inference job (image, order_id={}): {:?}", order_id, err); + if i == 0 { return Err(CommonWebError::from_error(err)); } + } + } + } + + let primary_job_token = all_job_tokens.first().cloned().ok_or_else(|| { + error!("No inference job token was created"); + CommonWebError::server_error_with_message("No inference job token was created") + })?; + + Ok(InsertSeedance2proJobsResult { + primary_job_token, + all_job_tokens, + }) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/image/insert_db_job/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/image/insert_db_job/mod.rs new file mode 100644 index 0000000000..751cbf87e7 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/image/insert_db_job/mod.rs @@ -0,0 +1,3 @@ +pub mod insert_fal_job; +pub mod insert_seedance2pro_jobs; +pub mod shared_job_args; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/image/insert_db_job/shared_job_args.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/image/insert_db_job/shared_job_args.rs new file mode 100644 index 0000000000..cc3dbde042 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/image/insert_db_job/shared_job_args.rs @@ -0,0 +1,20 @@ +use enums::common::generation::common_model_type::CommonModelType; +use tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken; +use tokens::tokens::prompts::PromptToken; +use tokens::tokens::users::UserToken; + +/// Fields every image-side `insert_*_job` helper needs. +/// Parallels `omni_gen::generate::video::insert_db_job::shared_job_args::SharedJobArgs`. +pub struct SharedJobArgs<'a, 'tx> { + pub apriori_job_token: &'a InferenceJobToken, + pub idempotency_token: &'a str, + pub user_token: &'a UserToken, + pub maybe_avt_token: Option<&'a AnonymousVisitorTrackingToken>, + pub maybe_prompt_token: Option<&'a PromptToken>, + pub maybe_debug_log_event_token: Option<&'a DebugLogEventToken>, + pub maybe_model_type: Option, + pub ip_address: &'a str, + pub transaction: &'a mut sqlx::Transaction<'tx, sqlx::MySql>, +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/image/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/image/mod.rs index 16c7d5276f..54e053a7f5 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/image/mod.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/image/mod.rs @@ -1,4 +1,5 @@ pub mod omni_gen_image_generate_handler; pub(crate) mod hydrate_to_router_request; +pub(crate) mod insert_db_job; pub(crate) mod pipeline_result; pub mod pipeline_v2; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/image/omni_gen_image_generate_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/image/omni_gen_image_generate_handler.rs index c8c2f01fe9..543db74ea1 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/image/omni_gen_image_generate_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/image/omni_gen_image_generate_handler.rs @@ -14,24 +14,23 @@ use enums::by_table::prompts::prompt_type::PromptType; use enums::common::generation::common_generation_mode::CommonGenerationMode; use enums::common::generation::common_model_type::CommonModelType; use enums::common::generation_provider::GenerationProvider; -use enums::common::visibility::Visibility; use http_server_common::request::get_request_ip::get_request_ip; use mysql_queries::queries::debug_logs::insert_debug_log::{insert_debug_log, InsertDebugLogArgs}; -use mysql_queries::queries::generic_inference::api_providers::fal::insert_generic_inference_job_for_fal_queue::FalCategory; -use mysql_queries::queries::generic_inference::api_providers::fal::insert_generic_inference_job_for_fal_queue_with_apriori_job_token::{ - insert_generic_inference_job_for_fal_queue_with_apriori_job_token, - InsertGenericInferenceForFalWithAprioriJobTokenArgs, -}; +use mysql_queries::queries::generic_inference::api_providers::seedance2pro::insert_generic_inference_job_for_seedance2pro_queue_with_apriori_job_token::KinoviVersion; use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_idempotency_token; use mysql_queries::queries::prompt_context_items::insert_batch_prompt_context_items::{ insert_batch_prompt_context_items, InsertBatchArgs, PromptContextItem, }; use mysql_queries::queries::prompts::insert_prompt::{insert_prompt, InsertPromptArgs}; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken; use crate::http_server::common_responses::common_web_error::CommonWebError; use crate::http_server::endpoints::generate::common::payments_error_test::payments_error_test; use crate::http_server::endpoints::omni_gen::generate::image::hydrate_to_router_request::hydrate_to_router_request; +use crate::http_server::endpoints::omni_gen::generate::image::insert_db_job::insert_fal_job::{insert_fal_job, InsertFalJobArgs}; +use crate::http_server::endpoints::omni_gen::generate::image::insert_db_job::insert_seedance2pro_jobs::{insert_seedance2pro_jobs, InsertSeedance2proJobsArgs}; +use crate::http_server::endpoints::omni_gen::generate::image::insert_db_job::shared_job_args::SharedJobArgs; use crate::http_server::endpoints::omni_gen::generate::image::pipeline_v2::run_pipeline_v2::{run_pipeline_v2, RunPipelineV2Args}; use crate::http_server::session::lookup::user_session_feature_flags::UserSessionFeatureFlags; use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format; @@ -168,15 +167,6 @@ pub async fn omni_gen_image_generate_handler( } } - let external_job_id = match &pipeline_result.response { - GenerateImageResponse::Artcraft(p) => { - p.inference_job_token.as_str().to_string() - } - GenerateImageResponse::Fal(p) => { - p.request_id.clone().unwrap_or_default() - } - }; - // ==================== WRITE RESULT ==================== // let ip_address = get_request_ip(&http_request); @@ -251,34 +241,63 @@ pub async fn omni_gen_image_generate_handler( } // -- Inference job -- + // + // Each provider has its own queue / worker. Branch by response variant + // so the row lands on the correct queue (the Seedance2Pro/Kinovi worker + // for Midjourney; the Fal worker for everything else). - let db_result = insert_generic_inference_job_for_fal_queue_with_apriori_job_token( - InsertGenericInferenceForFalWithAprioriJobTokenArgs { - apriori_job_token: &pipeline_result.apriori_job_token, - uuid_idempotency_token: &idempotency_token, - maybe_external_third_party_id: &external_job_id, - fal_category: FalCategory::ImageGeneration, - maybe_model_type: request.model.map(|v| v.to_common_model_type()), - maybe_inference_args: None, - maybe_prompt_token: prompt_token.as_ref(), - maybe_creator_user_token: Some(user_token), - maybe_avt_token: maybe_avt_token.as_ref(), - creator_ip_address: &ip_address, - creator_set_visibility: Visibility::Public, - maybe_debug_log_event_token: Some(&debug_log_event_token), - mysql_executor: &mut *transaction, - starting_job_status_override: None, - maybe_frontend_failure_category: None, - maybe_failure_reason: None, - phantom: Default::default(), + let job_token: InferenceJobToken = match &pipeline_result.response { + GenerateImageResponse::Seedance2Pro(payload) => { + info!("Inserting seedance2pro image job(s) with token: {:?}", pipeline_result.apriori_job_token); + + // The image-side omni pipeline always dispatches Midjourney via the + // Volcengine Kinovi account today. If we ever route to BytePlus / + // BytePlus Ultra here, mirror the video-side `kinovi_account` knob. + let kinovi_version = KinoviVersion::Volcengine; + + let result = insert_seedance2pro_jobs(InsertSeedance2proJobsArgs { + primary_order_id: &payload.order_id, + maybe_additional_order_ids: payload.maybe_order_ids.as_deref(), + maybe_wallet_ledger_entry_token: pipeline_result.maybe_wallet_ledger_entry_token.as_ref(), + kinovi_version, + shared: SharedJobArgs { + apriori_job_token: &pipeline_result.apriori_job_token, + idempotency_token: &idempotency_token, + user_token, + maybe_avt_token: maybe_avt_token.as_ref(), + maybe_model_type: request.model.map(|v| v.to_common_model_type()), + maybe_prompt_token: prompt_token.as_ref(), + maybe_debug_log_event_token: Some(&debug_log_event_token), + ip_address: &ip_address, + transaction: &mut transaction, + }, + }).await?; + result.primary_job_token } - ).await; - - let job_token = match db_result { - Ok(token) => token, - Err(err) => { - warn!("Error inserting inference job: {:?}", err); - return Err(CommonWebError::from_error(err)); + GenerateImageResponse::Fal(payload) => { + info!("Inserting fal image job with token: {:?}", pipeline_result.apriori_job_token); + let external_job_id = payload.request_id.clone().unwrap_or_default(); + insert_fal_job(InsertFalJobArgs { + external_job_id: &external_job_id, + shared: SharedJobArgs { + apriori_job_token: &pipeline_result.apriori_job_token, + idempotency_token: &idempotency_token, + user_token, + maybe_avt_token: maybe_avt_token.as_ref(), + maybe_model_type: request.model.map(|v| v.to_common_model_type()), + maybe_prompt_token: prompt_token.as_ref(), + maybe_debug_log_event_token: Some(&debug_log_event_token), + ip_address: &ip_address, + transaction: &mut transaction, + }, + }).await? + } + GenerateImageResponse::Artcraft(payload) => { + // The omni image pipeline never dispatches via the Artcraft provider + // itself today (everything routes to Fal or Kinovi), but the response + // variant exists so we cover it defensively — Artcraft jobs come back + // already inserted server-side, so just propagate the token. + payload.inference_job_token.clone() } }; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/image/pipeline_result.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/image/pipeline_result.rs index 80a094b166..4d4bd9120d 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/image/pipeline_result.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/image/pipeline_result.rs @@ -1,8 +1,15 @@ use artcraft_router::generate::generate_image::generate_image_response::GenerateImageResponse; use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken; /// Both image pipelines produce this, then the shared handler suffix writes DB rows. pub struct ImagePipelineResult { pub apriori_job_token: InferenceJobToken, pub response: GenerateImageResponse, + + /// The wallet ledger entry created when the user was charged for this + /// generation, if any (None when the generation is free). Used as a + /// foreign key on the inference job row so the worker can refund on + /// failure. + pub maybe_wallet_ledger_entry_token: Option, } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/image/pipeline_v2/run_pipeline_v2.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/image/pipeline_v2/run_pipeline_v2.rs index 9e4eaf82d7..22552a640a 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/image/pipeline_v2/run_pipeline_v2.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/image/pipeline_v2/run_pipeline_v2.rs @@ -1,14 +1,17 @@ use log::{info, warn}; use artcraft_router::api::image_list_ref::ImageListRef; +use artcraft_router::api::router_image_model::RouterImageModel; use artcraft_router::api::router_provider::RouterProvider; use artcraft_router::client::router_client::RouterClient; use artcraft_router::client::router_fal_client::RouterFalClient; +use artcraft_router::client::router_seedance2pro_client::RouterSeedance2ProClient; use artcraft_router::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; use artcraft_router::generate::generate_image::generate_image_response::GenerateImageResponse; use artcraft_router::generate::generate_image::image_generation_draft_context::ImageGenerationDraftContext; use artcraft_router::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest; use artcraft_router::generate::generate_image::image_generation_request::ImageGenerationRequest; +use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession; use tokens::tokens::generic_inference_jobs::InferenceJobToken; use tokens::tokens::users::UserToken; @@ -50,20 +53,24 @@ pub async fn run_pipeline_v2( let apriori_job_token = InferenceJobToken::generate(); - if cost > 0 { - attempt_wallet_deduction_else_common_web_error( + let maybe_wallet_ledger_entry_token = if cost > 0 { + let deduction = attempt_wallet_deduction_else_common_web_error( user_token, Some(apriori_job_token.as_str()), cost, mysql_connection, ).await?; - } + Some(deduction.ledger_entry_token) + } else { + None + }; - let response = finalize_and_generate(draft_or_request, server_state).await?; + let response = finalize_and_generate(draft_or_request, server_state, resolved_media).await?; Ok(ImagePipelineResult { apriori_job_token, response, + maybe_wallet_ledger_entry_token, }) } @@ -71,7 +78,7 @@ fn build_execution_request( router_builder: &GenerateImageRequestBuilder, ) -> Result { let mut execution_builder = router_builder.clone(); - execution_builder.provider = RouterProvider::Fal; + execution_builder.provider = provider_for_model(execution_builder.model); execution_builder.build2().map_err(|e| { warn!("Failed to build2 for image v2 pipeline: {}", e); @@ -79,6 +86,18 @@ fn build_execution_request( }) } +/// Route each image model to the provider that fulfils it. Midjourney is +/// served via the Seedance2Pro/Kinovi (Volcengine) backend; everything else +/// flows through Fal. +fn provider_for_model(model: RouterImageModel) -> RouterProvider { + match model { + RouterImageModel::Midjourney7 + | RouterImageModel::Midjourney7Niji + | RouterImageModel::Midjourney8 => RouterProvider::Seedance2Pro, + _ => RouterProvider::Fal, + } +} + fn apply_hydrated_media_inputs( router_builder: &GenerateImageRequestBuilder, resolved_media: &MediaFilesAsCdnUrlListAndMap, @@ -119,11 +138,27 @@ fn estimate_cost_in_credits( async fn finalize_and_generate( draft_or_request: ImageGenerationDraftOrRequest, server_state: &ServerState, + resolved_media: &MediaFilesAsCdnUrlListAndMap, ) -> Result { let provider = draft_or_request.get_provider(); let client = build_router_client(provider, server_state)?; - let request = finalize_request(draft_or_request).await?; + // Inline the draft finalization so the draft context can borrow `client`. + let request: ImageGenerationRequest = match draft_or_request { + ImageGenerationDraftOrRequest::Request(request) => request, + ImageGenerationDraftOrRequest::Draft(draft) => { + let draft_context = ImageGenerationDraftContext { + client: Some(&client), + media_file_to_artcraft_url_map: Some(&resolved_media.token_to_url_map), + }; + draft.finalize(draft_context) + .await + .map_err(|err| { + warn!("Failed to finalize image v2 draft: {:?}", err); + CommonWebError::from_error(err) + })? + } + }; request.send_request(&client) .await @@ -133,22 +168,6 @@ async fn finalize_and_generate( }) } -async fn finalize_request( - draft_or_request: ImageGenerationDraftOrRequest, -) -> Result { - match draft_or_request { - ImageGenerationDraftOrRequest::Request(request) => Ok(request), - ImageGenerationDraftOrRequest::Draft(draft) => { - draft.finalize(ImageGenerationDraftContext::default()) - .await - .map_err(|err| { - warn!("Failed to finalize image v2 draft: {:?}", err); - CommonWebError::from_error(err) - }) - } - } -} - fn build_router_client( provider: RouterProvider, server_state: &ServerState, @@ -161,6 +180,15 @@ fn build_router_client( ); Ok(RouterClient::Fal(fal_client)) }, + RouterProvider::Seedance2Pro => { + // Midjourney image generation always uses the Volcengine account. + // Per-request account selection (like the video flow's + // `KinoviAccount` enum) isn't needed here yet — if it ever is, plumb + // a parallel knob through `RunPipelineV2Args`. + let cookies = server_state.inference_providers.seedance2pro.cookies_volcengine.clone(); + let session = Seedance2ProSession::from_cookies_string(cookies); + Ok(RouterClient::Seedance2Pro(RouterSeedance2ProClient::new(session))) + }, other => { Err(CommonWebError::server_error_with_message( &format!("Unsupported provider for image v2 generation: {:?}", other),