midjourney images - router, web, jobs, etc. (#1600)

This commit is contained in:
Brandon Thomas authored and GitHub committed 2026-06-02 06:49:12 -04:00
1 parent 017b923c5a
commit a6f8d2a4aa
63 files changed
+3599 -93

No files matched your search

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