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