Compare commits

...
Author SHA1 Message Date
Brandon Thomas 38cbd724d4 codegen + fixes 2026-06-03 10:36:58 -04:00
Brandon Thomas f669e0071e list email address changes 2026-06-03 01:08:54 -04:00
Brandon Thomas a8af0e8141 mod endpoint to update email 2026-06-03 00:51:30 -04:00
Brandon Thomas a78ac8c391 cleanup 2026-06-03 00:38:02 -04:00
Brandon Thomas 3a62afb53c usernames 2026-06-03 00:33:52 -04:00
Brandon Thomas 998a7b60b1 queries for new table 2026-06-03 00:30:02 -04:00
Brandon Thomas aeebad3a40 enum variant 2026-06-03 00:21:17 -04:00
Brandon Thomas bb53238c56 new table to track email changes 2026-06-03 00:10:13 -04:00
Brandon Thomas ca43d5cbe8 grok failure update (#1603) 2026-06-02 22:00:10 -04:00
BFlat 1d8f64dd23 webapp: midjourney icon and name mappings (#1602) 2026-06-02 22:58:33 +07:00
BFlat 2a49226479 mobile view update (#1601)
- Separate mobile generations page
- Bottom mobile navbar
2026-06-02 20:36:02 +07:00
Brandon Thomas a6f8d2a4aa midjourney images - router, web, jobs, etc. (#1600) 2026-06-02 06:49:12 -04:00
Brandon Thomas 017b923c5a midjourney client (#1599) 2026-06-02 04:02:09 -04:00
Brandon Thomas f0244c6da8 analytics queries (#1598) 2026-06-01 23:55:52 -04:00
125 changed files with 8522 additions and 598 deletions
Generated
+1
View File
@@ -809,6 +809,7 @@ dependencies = [
"seedance2pro_client",
"serde",
"serde_derive",
"serde_json",
"strum",
"test_data",
"tokens",
@@ -0,0 +1,23 @@
-- Daily ArtCraft generations broken down by model_type since January 1st, 2026.
-- One row per (day, model_type) pair.
SELECT
DATE(created_at) AS day,
maybe_model_type AS model_type,
COUNT(*) AS generations
FROM generic_inference_jobs
WHERE created_at >= '2026-01-01'
AND maybe_model_type IS NOT NULL
AND job_type NOT IN (
'gpt_sovits',
'tacotron2',
'rvc_v2',
'f5_tts',
'so_vits_svc',
'styletts2',
'face_fusion',
'seed_vc',
'comfy_ui'
)
GROUP BY day, model_type
ORDER BY day DESC, generations DESC;
@@ -0,0 +1,53 @@
select
u.created_at,
CASE
WHEN u.maybe_landing_url LIKE '%?r=%' THEN 'user_referral'
WHEN u.maybe_landing_url LIKE '%gad_source%' THEN 'google_ads'
WHEN u.maybe_landing_url LIKE '%utm_source=fakeyou%' THEN 'fakeyou'
WHEN u.maybe_landing_url LIKE '%utm_source=storyteller%' THEN 'storyteller'
WHEN u.maybe_landing_url LIKE '%utm_source=artcraftai%' THEN 'artcraft_dot_ai_domain'
WHEN u.maybe_referral_url LIKE '%instagram%' THEN 'instagram'
WHEN u.maybe_referral_url LIKE '%reddit%' THEN 'reddit'
WHEN u.maybe_referral_url LIKE '%google%' THEN 'google'
WHEN u.maybe_referral_url LIKE '%brave%' THEN 'brave'
WHEN u.maybe_referral_url LIKE '%duckduckgo%' THEN 'duckduckgo'
WHEN u.maybe_referral_url LIKE '%yahoo.com%' THEN 'yahoo'
WHEN u.maybe_referral_url LIKE '%yandex%' THEN 'yandex'
WHEN u.maybe_referral_url LIKE '%bing.com%' THEN 'bing'
WHEN u.maybe_referral_url LIKE '%github.com%' THEN 'github'
WHEN u.maybe_referral_url LIKE '%chatgpt%' THEN 'chatgpt'
WHEN u.maybe_referral_url LIKE '%https://t.co%' THEN 'twitter'
WHEN u.maybe_referral_url LIKE '%x.com%' THEN 'twitter'
WHEN u.maybe_referral_url LIKE '%youtube%' THEN 'youtube'
WHEN u.maybe_referral_url LIKE '%fakeyou.com%' THEN 'fakeyou'
WHEN u.maybe_referral_url LIKE '%ycombinator%' THEN 'hackernews'
WHEN u.maybe_referral_url LIKE '%seedanceprice%' THEN 'seedanceprice_reddit'
WHEN u.maybe_landing_url LIKE '%utm_source=ig%' THEN 'instagram'
WHEN u.maybe_landing_url LIKE '%utm_source=chatgpt%' THEN 'chatgpt'
WHEN u.maybe_landing_url LIKE '%/media/%' THEN 'reddit'
ELSE 'unknown'
END AS user_source,
u.maybe_referral_url,
u.maybe_landing_url,
u.username,
us.maybe_stripe_customer_id as subscription_customer,
l.stripe_customer_id as credits_customer,
u.email_address,
CASE
WHEN u.maybe_source LIKE '%artcraft_get_web%' THEN 'website'
WHEN u.maybe_source LIKE '%artcraft%' THEN 'app_or_website'
WHEN u.maybe_source LIKE '%artcraft_get_s%' THEN 'website_stripe_checkout'
ELSE u.maybe_source
END AS maybe_source,
u.maybe_signup_method,
u.created_at,
u.updated_at
from users u
left outer join user_subscriptions us
on us.user_token = u.token
and us.subscription_namespace = 'artcraft'
left outer join user_stripe_customer_links l
on l.user_token = u.token
and l.payments_namespace = 'artcraft'
where (us.maybe_stripe_customer_id IS NOT NULL OR l.stripe_customer_id IS NOT NULL)
order by u.created_at desc;
@@ -0,0 +1,5 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
DROP TABLE IF EXISTS user_email_changes;
@@ -0,0 +1,35 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
CREATE TABLE user_email_changes (
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
-- The user whose email was changed.
user_token VARCHAR(32) NOT NULL,
-- The email address before the change.
old_email VARCHAR(255) NOT NULL,
-- The email address after the change.
new_email VARCHAR(255) NOT NULL,
-- The IP address the change was made from.
-- Wide enough for IPv4/6.
ip_address VARCHAR(40) NOT NULL,
-- The user that performed the change, if known. NULL when the change was
-- performed by the user themselves, by an automated process, or in any
-- other context where there is no acting user.
maybe_changed_by_user_token VARCHAR(32) DEFAULT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- INDICES --
PRIMARY KEY (id),
KEY index_user_token (user_token),
KEY index_old_email (old_email),
KEY index_new_email (new_email),
KEY index_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -0,0 +1,38 @@
-- NB: This is a manually squashed view of all the CREATE and ALTER statements,
-- with comments attached to the fields for centralized documentation.
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
CREATE TABLE user_email_changes (
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
-- The user whose email was changed.
user_token VARCHAR(32) NOT NULL,
-- The email address before the change.
old_email VARCHAR(255) NOT NULL,
-- The email address after the change.
new_email VARCHAR(255) NOT NULL,
-- The IP address the change was made from.
-- Wide enough for IPv4/6.
ip_address VARCHAR(40) NOT NULL,
-- The user that performed the change, if known. NULL when the change was
-- performed by the user themselves, by an automated process, or in any
-- other context where there is no acting user.
maybe_changed_by_user_token VARCHAR(32) DEFAULT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- INDICES --
PRIMARY KEY (id),
KEY index_user_token (user_token),
KEY index_old_email (old_email),
KEY index_new_email (new_email),
KEY index_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -40,4 +40,5 @@ uuid.workspace = true
[dev-dependencies]
test_data.workspace = true
strum.workspace = true
serde_json.workspace = true
@@ -2,7 +2,7 @@ use serde_derive::{Deserialize, Serialize};
/// Common image models supported by the router.
/// Not all models are available through all providers.
#[derive(Copy, Clone, Debug, Serialize, Deserialize)]
#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RouterImageModel {
#[serde(rename = "flux_1_dev")]
@@ -23,6 +23,12 @@ pub enum RouterImageModel {
GrokImagineImage,
#[serde(rename = "grok_imagine_image_q")]
GrokImagineImageQuality,
#[serde(rename = "midjourney_7")]
Midjourney7,
#[serde(rename = "midjourney_7_niji")]
Midjourney7Niji,
#[serde(rename = "midjourney_8")]
Midjourney8,
#[serde(rename = "nano_banana")]
NanoBanana,
#[serde(rename = "nano_banana_2")]
@@ -40,3 +46,31 @@ pub enum RouterImageModel {
#[serde(rename = "flux_2_lora_angles")]
Flux2LoraAngles,
}
#[cfg(test)]
mod tests {
use super::*;
fn assert_round_trip(model: RouterImageModel, expected_wire: &str) {
let json = serde_json::to_string(&model).unwrap();
let unquoted = json.trim_matches('"');
assert_eq!(unquoted, expected_wire, "serialize {:?}", model);
let back: RouterImageModel = serde_json::from_str(&json).unwrap();
assert_eq!(back, model, "deserialize round-trip for {:?}", model);
}
#[test]
fn midjourney_7_serializes_to_midjourney_7() {
assert_round_trip(RouterImageModel::Midjourney7, "midjourney_7");
}
#[test]
fn midjourney_7_niji_serializes_to_midjourney_7_niji() {
assert_round_trip(RouterImageModel::Midjourney7Niji, "midjourney_7_niji");
}
#[test]
fn midjourney_8_serializes_to_midjourney_8() {
assert_round_trip(RouterImageModel::Midjourney8, "midjourney_8");
}
}
@@ -16,6 +16,9 @@ use crate::generate::generate_image::providers::artcraft::flux_pro_1p1_ultra::bu
use crate::generate::generate_image::providers::artcraft::gpt_image_1::build::build_artcraft_gpt_image_1;
use crate::generate::generate_image::providers::artcraft::gpt_image_1p5::build::build_artcraft_gpt_image_1p5;
use crate::generate::generate_image::providers::artcraft::gpt_image_2::build::build_artcraft_gpt_image_2;
use crate::generate::generate_image::providers::artcraft::midjourney_7::build::build_artcraft_midjourney_7;
use crate::generate::generate_image::providers::artcraft::midjourney_7_niji::build::build_artcraft_midjourney_7_niji;
use crate::generate::generate_image::providers::artcraft::midjourney_8::build::build_artcraft_midjourney_8;
use crate::generate::generate_image::providers::artcraft::nano_banana::build::build_artcraft_nano_banana;
use crate::generate::generate_image::providers::artcraft::nano_banana_2::build::build_artcraft_nano_banana_2;
use crate::generate::generate_image::providers::artcraft::nano_banana_pro::build::build_artcraft_nano_banana_pro;
@@ -38,6 +41,9 @@ use crate::generate::generate_image::providers::fal::qwen_edit_2511_angles::buil
use crate::generate::generate_image::providers::fal::seedream_4::build::build_fal_seedream_4;
use crate::generate::generate_image::providers::fal::seedream_4p5::build::build_fal_seedream_4p5;
use crate::generate::generate_image::providers::fal::seedream_5_lite::build::build_fal_seedream_5_lite;
use crate::generate::generate_image::providers::kinovi::midjourney_7::build::build_kinovi_midjourney_7;
use crate::generate::generate_image::providers::kinovi::midjourney_7_niji::build::build_kinovi_midjourney_7_niji;
use crate::generate::generate_image::providers::kinovi::midjourney_8::build::build_kinovi_midjourney_8;
#[derive(Clone, Debug)]
pub struct GenerateImageRequestBuilder {
@@ -115,6 +121,9 @@ impl GenerateImageRequestBuilder {
(RouterProvider::Artcraft, RouterImageModel::Seedream5Lite) => build_artcraft_seedream_5_lite(self),
(RouterProvider::Artcraft, RouterImageModel::QwenEdit2511Angles) => build_artcraft_qwen_edit_2511_angles(self),
(RouterProvider::Artcraft, RouterImageModel::Flux2LoraAngles) => build_artcraft_flux_2_lora_angles(self),
(RouterProvider::Artcraft, RouterImageModel::Midjourney7) => build_artcraft_midjourney_7(self),
(RouterProvider::Artcraft, RouterImageModel::Midjourney7Niji) => build_artcraft_midjourney_7_niji(self),
(RouterProvider::Artcraft, RouterImageModel::Midjourney8) => build_artcraft_midjourney_8(self),
(RouterProvider::Fal, RouterImageModel::Flux1Dev) => build_fal_flux_1_dev(self),
(RouterProvider::Fal, RouterImageModel::Flux1Schnell) => build_fal_flux_1_schnell(self),
@@ -132,6 +141,11 @@ impl GenerateImageRequestBuilder {
(RouterProvider::Fal, RouterImageModel::QwenEdit2511Angles) => build_fal_qwen_edit_2511_angles(self),
(RouterProvider::Fal, RouterImageModel::Flux2LoraAngles) => build_fal_flux_2_lora_angles(self),
// Kinovi / Seedance2Pro (Midjourney)
(RouterProvider::Seedance2Pro, RouterImageModel::Midjourney7) => build_kinovi_midjourney_7(self),
(RouterProvider::Seedance2Pro, RouterImageModel::Midjourney7Niji) => build_kinovi_midjourney_7_niji(self),
(RouterProvider::Seedance2Pro, RouterImageModel::Midjourney8) => build_kinovi_midjourney_8(self),
_ => self.unsupported_provider_and_model(),
}
}
@@ -25,10 +25,22 @@ pub struct FalImageResponsePayload {
pub maybe_outbound_request: Option<Arc<dyn Debug + Send + Sync>>,
}
/// Response from the Seedance2Pro/Kinovi provider (used for Midjourney
/// image generation). Mirrors `Seedance2proVideoResponsePayload` on the
/// video side.
#[derive(Clone, Debug)]
pub struct Seedance2proImageResponsePayload {
pub order_id: String,
pub task_id: String,
pub maybe_order_ids: Option<Vec<String>>,
pub maybe_task_ids: Option<Vec<String>>,
}
#[derive(Clone, Debug)]
pub enum GenerateImageResponse {
Artcraft(ArtcraftImageResponsePayload),
Fal(FalImageResponsePayload),
Seedance2Pro(Seedance2proImageResponsePayload),
}
impl GenerateImageResponse {
@@ -45,4 +57,11 @@ impl GenerateImageResponse {
_ => None,
}
}
pub fn get_seedance2pro_payload(&self) -> Option<Seedance2proImageResponsePayload> {
match self {
Self::Seedance2Pro(p) => Some(p.clone()),
_ => None,
}
}
}
@@ -3,26 +3,58 @@ use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
use crate::generate::generate_image::image_generation_draft_context::ImageGenerationDraftContext;
use crate::generate::generate_image::image_generation_request::ImageGenerationRequest;
use crate::generate::generate_image::providers::kinovi::midjourney_7::cost::KinoviMidjourney7CostState;
use crate::generate::generate_image::providers::kinovi::midjourney_7::draft::KinoviMidjourney7DraftState;
use crate::generate::generate_image::providers::kinovi::midjourney_7_niji::cost::KinoviMidjourney7NijiCostState;
use crate::generate::generate_image::providers::kinovi::midjourney_7_niji::draft::KinoviMidjourney7NijiDraftState;
use crate::generate::generate_image::providers::kinovi::midjourney_8::cost::KinoviMidjourney8CostState;
use crate::generate::generate_image::providers::kinovi::midjourney_8::draft::KinoviMidjourney8DraftState;
/// Wrapper for all image generation draft requests.
///
/// Drafts hold validated/planned parameters but may still need media resolution
/// (e.g. uploading local files to a provider) before they can be sent.
/// Currently empty — FAL doesn't need a draft phase.
/// Drafts hold validated/planned parameters but may still need media
/// resolution (e.g. uploading local files to a provider) before they can
/// be sent. Today only Kinovi/Midjourney needs this — when the caller
/// supplies reference images, we must upload them to the Seedance2Pro
/// CDN before sending the request.
#[derive(Clone, Debug)]
pub enum ImageGenerationDraftRequest {
KinoviMidjourney7(KinoviMidjourney7DraftState),
KinoviMidjourney7Niji(KinoviMidjourney7NijiDraftState),
KinoviMidjourney8(KinoviMidjourney8DraftState),
}
impl ImageGenerationDraftRequest {
pub fn get_provider(&self) -> RouterProvider {
match *self {}
match self {
Self::KinoviMidjourney7(_) => RouterProvider::Seedance2Pro,
Self::KinoviMidjourney7Niji(_) => RouterProvider::Seedance2Pro,
Self::KinoviMidjourney8(_) => RouterProvider::Seedance2Pro,
}
}
pub fn estimate_cost(&self) -> Result<ImageGenerationCostEstimate, ArtcraftRouterError> {
match *self {}
match self {
Self::KinoviMidjourney7(draft) => Ok(KinoviMidjourney7CostState::from_draft(draft).estimate_cost()),
Self::KinoviMidjourney7Niji(draft) => Ok(KinoviMidjourney7NijiCostState::from_draft(draft).estimate_cost()),
Self::KinoviMidjourney8(draft) => Ok(KinoviMidjourney8CostState::from_draft(draft).estimate_cost()),
}
}
pub async fn finalize(self, _draft_context: ImageGenerationDraftContext<'_>) -> Result<ImageGenerationRequest, ArtcraftRouterError> {
match self {}
pub async fn finalize(self, draft_context: ImageGenerationDraftContext<'_>) -> Result<ImageGenerationRequest, ArtcraftRouterError> {
match self {
Self::KinoviMidjourney7(mut draft) => {
let result = draft.to_request(&draft_context).await?;
Ok(ImageGenerationRequest::KinoviMidjourney7(result))
}
Self::KinoviMidjourney7Niji(mut draft) => {
let result = draft.to_request(&draft_context).await?;
Ok(ImageGenerationRequest::KinoviMidjourney7Niji(result))
}
Self::KinoviMidjourney8(mut draft) => {
let result = draft.to_request(&draft_context).await?;
Ok(ImageGenerationRequest::KinoviMidjourney8(result))
}
}
}
}
@@ -1,18 +1,46 @@
use std::collections::HashMap;
use std::fmt::Debug;
/// Context passed when finalizing a draft into a concrete request.
use tokens::tokens::media_files::MediaFileToken;
use crate::client::router_client::RouterClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
/// Context passed when finalizing an image draft into a concrete request.
///
/// Currently empty — FAL doesn't need a draft phase so no context is required.
/// Future providers that need media uploads during finalization will add fields here
/// (e.g. a router client reference, media-file-to-URL maps, etc.).
/// Drafts only exist for providers that need to upload assets before
/// sending the request. Today that's the Seedance2Pro/Kinovi pipeline
/// (used for Midjourney image generation with reference images); other
/// providers — Artcraft, Fal — accept image URLs directly and skip the
/// draft phase entirely.
#[derive(Clone, Default)]
pub struct ImageGenerationDraftContext<'a> {
/// Prevent construction without Default; reserving the lifetime for future use.
_phantom: std::marker::PhantomData<&'a ()>,
/// Optional: the router client, needed for providers that require
/// authenticated calls (e.g. Seedance2Pro/Kinovi uploads).
pub client: Option<&'a RouterClient>,
/// Optional: a map of Media File Tokens to their ArtCraft URLs.
/// Required only when the draft has unresolved `MediaFileToken` image
/// inputs that must be downloaded from ArtCraft and re-uploaded to the
/// target provider's CDN.
pub media_file_to_artcraft_url_map: Option<&'a HashMap<MediaFileToken, String>>,
}
impl<'a> ImageGenerationDraftContext<'a> {
pub fn get_seedance2pro_client_ref(&self) -> Result<&RouterSeedance2ProClient, ArtcraftRouterError> {
let client = self.client.ok_or(ArtcraftRouterError::Client(ClientError::RouterClientNotProvided))?;
client.get_seedance2pro_client_ref()
.map_err(ArtcraftRouterError::Client)
}
}
impl Debug for ImageGenerationDraftContext<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ImageGenerationDraftContext").finish()
f.debug_struct("ImageGenerationDraftContext")
.field("client", &self.client.is_some())
.field("media_file_to_artcraft_url_map", &self.media_file_to_artcraft_url_map.map(|m| m.len()))
.finish()
}
}
@@ -19,6 +19,12 @@ use crate::generate::generate_image::providers::artcraft::gpt_image_1p5::cost::A
use crate::generate::generate_image::providers::artcraft::gpt_image_1p5::request::ArtcraftGptImage1p5RequestState;
use crate::generate::generate_image::providers::artcraft::gpt_image_2::cost::ArtcraftGptImage2CostState;
use crate::generate::generate_image::providers::artcraft::gpt_image_2::request::ArtcraftGptImage2RequestState;
use crate::generate::generate_image::providers::artcraft::midjourney_7::cost::ArtcraftMidjourney7CostState;
use crate::generate::generate_image::providers::artcraft::midjourney_7::request::ArtcraftMidjourney7RequestState;
use crate::generate::generate_image::providers::artcraft::midjourney_7_niji::cost::ArtcraftMidjourney7NijiCostState;
use crate::generate::generate_image::providers::artcraft::midjourney_7_niji::request::ArtcraftMidjourney7NijiRequestState;
use crate::generate::generate_image::providers::artcraft::midjourney_8::cost::ArtcraftMidjourney8CostState;
use crate::generate::generate_image::providers::artcraft::midjourney_8::request::ArtcraftMidjourney8RequestState;
use crate::generate::generate_image::providers::artcraft::nano_banana::cost::ArtcraftNanoBananaCostState;
use crate::generate::generate_image::providers::artcraft::nano_banana::request::ArtcraftNanoBananaRequestState;
use crate::generate::generate_image::providers::artcraft::nano_banana_2::cost::ArtcraftNanoBanana2CostState;
@@ -63,6 +69,12 @@ use crate::generate::generate_image::providers::fal::seedream_4p5::cost::FalSeed
use crate::generate::generate_image::providers::fal::seedream_4p5::request::FalSeedream4p5RequestState;
use crate::generate::generate_image::providers::fal::seedream_5_lite::cost::FalSeedream5LiteCostState;
use crate::generate::generate_image::providers::fal::seedream_5_lite::request::FalSeedream5LiteRequestState;
use crate::generate::generate_image::providers::kinovi::midjourney_7::cost::KinoviMidjourney7CostState;
use crate::generate::generate_image::providers::kinovi::midjourney_7::request::KinoviMidjourney7RequestState;
use crate::generate::generate_image::providers::kinovi::midjourney_7_niji::cost::KinoviMidjourney7NijiCostState;
use crate::generate::generate_image::providers::kinovi::midjourney_7_niji::request::KinoviMidjourney7NijiRequestState;
use crate::generate::generate_image::providers::kinovi::midjourney_8::cost::KinoviMidjourney8CostState;
use crate::generate::generate_image::providers::kinovi::midjourney_8::request::KinoviMidjourney8RequestState;
#[derive(Clone, Debug)]
pub enum ImageGenerationRequest {
@@ -82,6 +94,9 @@ pub enum ImageGenerationRequest {
ArtcraftSeedream5Lite(ArtcraftSeedream5LiteRequestState),
ArtcraftQwenEdit2511Angles(ArtcraftQwenEdit2511AnglesRequestState),
ArtcraftFlux2LoraAngles(ArtcraftFlux2LoraAnglesRequestState),
ArtcraftMidjourney7(ArtcraftMidjourney7RequestState),
ArtcraftMidjourney7Niji(ArtcraftMidjourney7NijiRequestState),
ArtcraftMidjourney8(ArtcraftMidjourney8RequestState),
// ── Fal provider ──
FalFlux1Dev(FalFlux1DevRequestState),
@@ -99,6 +114,11 @@ pub enum ImageGenerationRequest {
FalSeedream5Lite(FalSeedream5LiteRequestState),
FalQwenEdit2511Angles(FalQwenEdit2511AnglesRequestState),
FalFlux2LoraAngles(FalFlux2LoraAnglesRequestState),
// ── Kinovi / Seedance2Pro provider (Midjourney image generation) ──
KinoviMidjourney7(KinoviMidjourney7RequestState),
KinoviMidjourney7Niji(KinoviMidjourney7NijiRequestState),
KinoviMidjourney8(KinoviMidjourney8RequestState),
}
impl ImageGenerationRequest {
@@ -119,6 +139,9 @@ impl ImageGenerationRequest {
Self::ArtcraftSeedream5Lite(_) => RouterProvider::Artcraft,
Self::ArtcraftQwenEdit2511Angles(_) => RouterProvider::Artcraft,
Self::ArtcraftFlux2LoraAngles(_) => RouterProvider::Artcraft,
Self::ArtcraftMidjourney7(_) => RouterProvider::Artcraft,
Self::ArtcraftMidjourney7Niji(_) => RouterProvider::Artcraft,
Self::ArtcraftMidjourney8(_) => RouterProvider::Artcraft,
Self::FalFlux1Dev(_) => RouterProvider::Fal,
Self::FalFlux1Schnell(_) => RouterProvider::Fal,
@@ -135,6 +158,10 @@ impl ImageGenerationRequest {
Self::FalSeedream5Lite(_) => RouterProvider::Fal,
Self::FalQwenEdit2511Angles(_) => RouterProvider::Fal,
Self::FalFlux2LoraAngles(_) => RouterProvider::Fal,
Self::KinoviMidjourney7(_) => RouterProvider::Seedance2Pro,
Self::KinoviMidjourney7Niji(_) => RouterProvider::Seedance2Pro,
Self::KinoviMidjourney8(_) => RouterProvider::Seedance2Pro,
}
}
@@ -186,6 +213,15 @@ impl ImageGenerationRequest {
Self::ArtcraftFlux2LoraAngles(request) => {
Ok(ArtcraftFlux2LoraAnglesCostState::from_request(request).estimate_cost())
}
Self::ArtcraftMidjourney7(request) => {
Ok(ArtcraftMidjourney7CostState::from_request(request).estimate_cost())
}
Self::ArtcraftMidjourney7Niji(request) => {
Ok(ArtcraftMidjourney7NijiCostState::from_request(request).estimate_cost())
}
Self::ArtcraftMidjourney8(request) => {
Ok(ArtcraftMidjourney8CostState::from_request(request).estimate_cost())
}
// ── Fal ──
Self::FalFlux1Dev(request) => Ok(FalFlux1DevCostState::from_request(request).estimate_cost()),
@@ -203,6 +239,10 @@ impl ImageGenerationRequest {
Self::FalSeedream5Lite(request) => Ok(FalSeedream5LiteCostState::from_request(request).estimate_cost()),
Self::FalQwenEdit2511Angles(request) => Ok(FalQwenEdit2511AnglesCostState::from_request(request).estimate_cost()),
Self::FalFlux2LoraAngles(request) => Ok(FalFlux2LoraAnglesCostState::from_request(request).estimate_cost()),
Self::KinoviMidjourney7(request) => Ok(KinoviMidjourney7CostState::from_request(request).estimate_cost()),
Self::KinoviMidjourney7Niji(request) => Ok(KinoviMidjourney7NijiCostState::from_request(request).estimate_cost()),
Self::KinoviMidjourney8(request) => Ok(KinoviMidjourney8CostState::from_request(request).estimate_cost()),
}
}
@@ -269,6 +309,18 @@ impl ImageGenerationRequest {
let artcraft_client = client.get_artcraft_client_ref()?;
request.send(artcraft_client).await
}
Self::ArtcraftMidjourney7(request) => {
let artcraft_client = client.get_artcraft_client_ref()?;
request.send(artcraft_client).await
}
Self::ArtcraftMidjourney7Niji(request) => {
let artcraft_client = client.get_artcraft_client_ref()?;
request.send(artcraft_client).await
}
Self::ArtcraftMidjourney8(request) => {
let artcraft_client = client.get_artcraft_client_ref()?;
request.send(artcraft_client).await
}
// ── Fal ──
Self::FalFlux1Dev(request) => {
@@ -336,6 +388,23 @@ impl ImageGenerationRequest {
let fal_client = client.get_fal_client_ref()?;
request.send(fal_client).await
}
// ── Kinovi / Seedance2Pro (Midjourney) ──
Self::KinoviMidjourney7(request) => {
let seedance_client = client.get_seedance2pro_client_ref()
.map_err(ArtcraftRouterError::Client)?;
request.send(seedance_client).await
}
Self::KinoviMidjourney7Niji(request) => {
let seedance_client = client.get_seedance2pro_client_ref()
.map_err(ArtcraftRouterError::Client)?;
request.send(seedance_client).await
}
Self::KinoviMidjourney8(request) => {
let seedance_client = client.get_seedance2pro_client_ref()
.map_err(ArtcraftRouterError::Client)?;
request.send(seedance_client).await
}
}
}
}
@@ -0,0 +1,17 @@
use enums::common::generation::common_image_model::CommonImageModel as CommonImageModelEnum;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder;
use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest;
use crate::generate::generate_image::image_generation_request::ImageGenerationRequest;
use crate::generate::generate_image::providers::artcraft::build_common::build_artcraft_omni_image_request;
use crate::generate::generate_image::providers::artcraft::midjourney_7::request::ArtcraftMidjourney7RequestState;
pub fn build_artcraft_midjourney_7(
builder: GenerateImageRequestBuilder,
) -> Result<ImageGenerationDraftOrRequest, ArtcraftRouterError> {
let request = build_artcraft_omni_image_request(builder, CommonImageModelEnum::Midjourney7)?;
Ok(ImageGenerationDraftOrRequest::Request(
ImageGenerationRequest::ArtcraftMidjourney7(ArtcraftMidjourney7RequestState { request }),
))
}
@@ -0,0 +1,186 @@
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
use crate::generate::generate_image::providers::artcraft::midjourney_7::request::ArtcraftMidjourney7RequestState;
/// Cost state for Artcraft Midjourney 7. Pricing mirrors the Kinovi
/// midjourney_7 provider 1:1 in USD cents — ArtCraft credits map cent-for-cent.
///
/// | batch | credits | USD cents |
/// |-------|---------|-----------|
/// | 1 | 6 | 6¢ |
/// | 2 | 12 | 12¢ |
/// | 3 | 19 | 19¢ | (interpolated; Kinovi doesn't expose 3)
/// | 4 | 25 | 25¢ |
///
/// Kinovi-side math: 12 Kinovi credits per Midjourney task; 193 Kinovi
/// credits per USD. Kinovi rounds to nearest cent, so batches 1/2/4 land
/// on 6/12/25. Batch 3 is `ceil((12 × 3 / 193) × 100) = 19`.
#[derive(Clone, Debug)]
pub struct ArtcraftMidjourney7CostState {
pub num_images: u16,
}
impl ArtcraftMidjourney7CostState {
pub fn from_request(request: &ArtcraftMidjourney7RequestState) -> Self {
Self {
num_images: request.request.image_batch_count.unwrap_or(1),
}
}
pub fn estimate_cost(&self) -> ImageGenerationCostEstimate {
let cost_in_usd_cents = midjourney_cost_for_batch(self.num_images);
ImageGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
cost_in_usd_cents: Some(cost_in_usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
}
/// Returns the customer-facing midjourney cost in USD cents for a given
/// batch count. Shared between v7, v7-niji, and v8 (all three Midjourney
/// models have identical pricing on Kinovi).
pub(crate) fn midjourney_cost_for_batch(batch_count: u16) -> u64 {
match batch_count {
0 => 0, // Defensive; the builder rejects zero before reaching cost.
1 => 6,
2 => 12,
3 => 19,
4 => 25,
// Should never happen — `plan_batch_count` clamps to 1..=4.
_ => 25,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::router_image_model::RouterImageModel;
use crate::api::router_provider::RouterProvider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder;
fn cost_cents(image_batch_count: u16) -> u64 {
let builder = GenerateImageRequestBuilder {
model: RouterImageModel::Midjourney7,
provider: RouterProvider::Artcraft,
prompt: Some("a corgi astronaut".to_string()),
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: Some(image_batch_count),
horizontal_angle: None,
vertical_angle: None,
zoom: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
};
builder.build2().unwrap().estimate_cost().unwrap().cost_in_usd_cents.unwrap()
}
fn cost_credits(image_batch_count: u16) -> u64 {
let builder = GenerateImageRequestBuilder {
model: RouterImageModel::Midjourney7,
provider: RouterProvider::Artcraft,
prompt: Some("a corgi astronaut".to_string()),
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: Some(image_batch_count),
horizontal_angle: None,
vertical_angle: None,
zoom: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
};
builder.build2().unwrap().estimate_cost().unwrap().cost_in_credits.unwrap()
}
// ── Spot checks: each supported batch size ──
#[test]
fn one_image_is_six_cents() { assert_eq!(cost_cents(1), 6); }
#[test]
fn two_images_is_twelve_cents() { assert_eq!(cost_cents(2), 12); }
#[test]
fn three_images_is_nineteen_cents() { assert_eq!(cost_cents(3), 19); }
#[test]
fn four_images_is_twentyfive_cents() { assert_eq!(cost_cents(4), 25); }
// ── Credits track USD cents exactly (1 credit = 1 cent) ──
#[test]
fn credits_equal_usd_cents() {
for batch in 1..=4u16 {
assert_eq!(cost_credits(batch), cost_cents(batch),
"credits should equal usd cents at batch {}", batch);
}
}
// ── 1:1 cost parity with Kinovi for batches 1/2/4 ──
//
// Kinovi midjourney_7 returns 6/12/25¢ for batches 1/2/4. The artcraft
// provider must match those values byte-for-byte so users pay the same.
mod kinovi_parity {
use super::*;
use crate::generate::generate_image::providers::kinovi::midjourney_7::cost::KinoviMidjourney7CostState;
use seedance2pro_client::generate::image::generate_midjourney_v7::KinoviMidjourneyBatchCount;
#[test]
fn batch_1_matches_kinovi() {
let kinovi = KinoviMidjourney7CostState { batch_count: KinoviMidjourneyBatchCount::One }
.estimate_cost();
assert_eq!(cost_cents(1), kinovi.cost_in_usd_cents.unwrap());
}
#[test]
fn batch_2_matches_kinovi() {
let kinovi = KinoviMidjourney7CostState { batch_count: KinoviMidjourneyBatchCount::Two }
.estimate_cost();
assert_eq!(cost_cents(2), kinovi.cost_in_usd_cents.unwrap());
}
#[test]
fn batch_4_matches_kinovi() {
let kinovi = KinoviMidjourney7CostState { batch_count: KinoviMidjourneyBatchCount::Four }
.estimate_cost();
assert_eq!(cost_cents(4), kinovi.cost_in_usd_cents.unwrap());
}
}
// ── Monotonicity ──
#[test]
fn cost_is_monotonically_increasing() {
let c1 = cost_cents(1);
let c2 = cost_cents(2);
let c3 = cost_cents(3);
let c4 = cost_cents(4);
assert!(c1 < c2, "{} < {}", c1, c2);
assert!(c2 < c3, "{} < {}", c2, c3);
assert!(c3 < c4, "{} < {}", c3, c4);
}
// ── Flags ──
#[test]
fn cost_flags_are_correct() {
let cost = ArtcraftMidjourney7CostState { num_images: 1 }.estimate_cost();
assert!(!cost.is_free);
assert!(!cost.is_unlimited);
assert!(!cost.is_rate_limited);
assert!(!cost.has_watermark);
assert_eq!(cost.failures_are_refunded, None);
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -0,0 +1,17 @@
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::OmniGenImageCostAndGenerateRequest;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_image::generate_image_response::GenerateImageResponse;
use crate::generate::generate_image::providers::artcraft::request_common::send_artcraft_omni_image_request;
#[derive(Clone, Debug)]
pub struct ArtcraftMidjourney7RequestState {
pub request: OmniGenImageCostAndGenerateRequest,
}
impl ArtcraftMidjourney7RequestState {
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateImageResponse, ArtcraftRouterError> {
send_artcraft_omni_image_request(&self.request, client).await
}
}
@@ -0,0 +1,17 @@
use enums::common::generation::common_image_model::CommonImageModel as CommonImageModelEnum;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder;
use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest;
use crate::generate::generate_image::image_generation_request::ImageGenerationRequest;
use crate::generate::generate_image::providers::artcraft::build_common::build_artcraft_omni_image_request;
use crate::generate::generate_image::providers::artcraft::midjourney_7_niji::request::ArtcraftMidjourney7NijiRequestState;
pub fn build_artcraft_midjourney_7_niji(
builder: GenerateImageRequestBuilder,
) -> Result<ImageGenerationDraftOrRequest, ArtcraftRouterError> {
let request = build_artcraft_omni_image_request(builder, CommonImageModelEnum::Midjourney7Niji)?;
Ok(ImageGenerationDraftOrRequest::Request(
ImageGenerationRequest::ArtcraftMidjourney7Niji(ArtcraftMidjourney7NijiRequestState { request }),
))
}
@@ -0,0 +1,113 @@
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
use crate::generate::generate_image::providers::artcraft::midjourney_7::cost::midjourney_cost_for_batch;
use crate::generate::generate_image::providers::artcraft::midjourney_7_niji::request::ArtcraftMidjourney7NijiRequestState;
/// Cost state for Artcraft Midjourney 7 Niji. Pricing is identical to v7
/// and v8 on Kinovi, so we share the per-batch table via
/// `midjourney_cost_for_batch`.
#[derive(Clone, Debug)]
pub struct ArtcraftMidjourney7NijiCostState {
pub num_images: u16,
}
impl ArtcraftMidjourney7NijiCostState {
pub fn from_request(request: &ArtcraftMidjourney7NijiRequestState) -> Self {
Self {
num_images: request.request.image_batch_count.unwrap_or(1),
}
}
pub fn estimate_cost(&self) -> ImageGenerationCostEstimate {
let cost_in_usd_cents = midjourney_cost_for_batch(self.num_images);
ImageGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
cost_in_usd_cents: Some(cost_in_usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::router_image_model::RouterImageModel;
use crate::api::router_provider::RouterProvider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder;
fn cost_cents(image_batch_count: u16) -> u64 {
let builder = GenerateImageRequestBuilder {
model: RouterImageModel::Midjourney7Niji,
provider: RouterProvider::Artcraft,
prompt: Some("anime forest".to_string()),
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: Some(image_batch_count),
horizontal_angle: None,
vertical_angle: None,
zoom: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
};
builder.build2().unwrap().estimate_cost().unwrap().cost_in_usd_cents.unwrap()
}
#[test]
fn batch_1_is_6() { assert_eq!(cost_cents(1), 6); }
#[test]
fn batch_2_is_12() { assert_eq!(cost_cents(2), 12); }
#[test]
fn batch_3_is_19() { assert_eq!(cost_cents(3), 19); }
#[test]
fn batch_4_is_25() { assert_eq!(cost_cents(4), 25); }
#[test]
fn niji_pricing_matches_v7() {
use crate::generate::generate_image::providers::artcraft::midjourney_7::cost::ArtcraftMidjourney7CostState;
for batch in 1..=4u16 {
let v7 = ArtcraftMidjourney7CostState { num_images: batch }.estimate_cost();
let niji = ArtcraftMidjourney7NijiCostState { num_images: batch }.estimate_cost();
assert_eq!(v7.cost_in_usd_cents, niji.cost_in_usd_cents, "batch {}", batch);
assert_eq!(v7.cost_in_credits, niji.cost_in_credits, "batch {}", batch);
}
}
// ── 1:1 cost parity with Kinovi 7-niji ──
mod kinovi_parity {
use super::*;
use crate::generate::generate_image::providers::kinovi::midjourney_7_niji::cost::KinoviMidjourney7NijiCostState;
use seedance2pro_client::generate::image::generate_midjourney_v7_niji::KinoviMidjourneyBatchCount;
#[test]
fn batch_1_matches_kinovi() {
let kinovi = KinoviMidjourney7NijiCostState { batch_count: KinoviMidjourneyBatchCount::One }
.estimate_cost();
assert_eq!(cost_cents(1), kinovi.cost_in_usd_cents.unwrap());
}
#[test]
fn batch_2_matches_kinovi() {
let kinovi = KinoviMidjourney7NijiCostState { batch_count: KinoviMidjourneyBatchCount::Two }
.estimate_cost();
assert_eq!(cost_cents(2), kinovi.cost_in_usd_cents.unwrap());
}
#[test]
fn batch_4_matches_kinovi() {
let kinovi = KinoviMidjourney7NijiCostState { batch_count: KinoviMidjourneyBatchCount::Four }
.estimate_cost();
assert_eq!(cost_cents(4), kinovi.cost_in_usd_cents.unwrap());
}
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -0,0 +1,17 @@
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::OmniGenImageCostAndGenerateRequest;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_image::generate_image_response::GenerateImageResponse;
use crate::generate::generate_image::providers::artcraft::request_common::send_artcraft_omni_image_request;
#[derive(Clone, Debug)]
pub struct ArtcraftMidjourney7NijiRequestState {
pub request: OmniGenImageCostAndGenerateRequest,
}
impl ArtcraftMidjourney7NijiRequestState {
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateImageResponse, ArtcraftRouterError> {
send_artcraft_omni_image_request(&self.request, client).await
}
}
@@ -0,0 +1,17 @@
use enums::common::generation::common_image_model::CommonImageModel as CommonImageModelEnum;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder;
use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest;
use crate::generate::generate_image::image_generation_request::ImageGenerationRequest;
use crate::generate::generate_image::providers::artcraft::build_common::build_artcraft_omni_image_request;
use crate::generate::generate_image::providers::artcraft::midjourney_8::request::ArtcraftMidjourney8RequestState;
pub fn build_artcraft_midjourney_8(
builder: GenerateImageRequestBuilder,
) -> Result<ImageGenerationDraftOrRequest, ArtcraftRouterError> {
let request = build_artcraft_omni_image_request(builder, CommonImageModelEnum::Midjourney8)?;
Ok(ImageGenerationDraftOrRequest::Request(
ImageGenerationRequest::ArtcraftMidjourney8(ArtcraftMidjourney8RequestState { request }),
))
}
@@ -0,0 +1,113 @@
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
use crate::generate::generate_image::providers::artcraft::midjourney_7::cost::midjourney_cost_for_batch;
use crate::generate::generate_image::providers::artcraft::midjourney_8::request::ArtcraftMidjourney8RequestState;
/// Cost state for Artcraft Midjourney 8. Pricing is identical to v7 and
/// v7-niji on Kinovi; shares the per-batch table via
/// `midjourney_cost_for_batch`.
#[derive(Clone, Debug)]
pub struct ArtcraftMidjourney8CostState {
pub num_images: u16,
}
impl ArtcraftMidjourney8CostState {
pub fn from_request(request: &ArtcraftMidjourney8RequestState) -> Self {
Self {
num_images: request.request.image_batch_count.unwrap_or(1),
}
}
pub fn estimate_cost(&self) -> ImageGenerationCostEstimate {
let cost_in_usd_cents = midjourney_cost_for_batch(self.num_images);
ImageGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
cost_in_usd_cents: Some(cost_in_usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::router_image_model::RouterImageModel;
use crate::api::router_provider::RouterProvider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder;
fn cost_cents(image_batch_count: u16) -> u64 {
let builder = GenerateImageRequestBuilder {
model: RouterImageModel::Midjourney8,
provider: RouterProvider::Artcraft,
prompt: Some("a haunted mansion".to_string()),
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: Some(image_batch_count),
horizontal_angle: None,
vertical_angle: None,
zoom: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
};
builder.build2().unwrap().estimate_cost().unwrap().cost_in_usd_cents.unwrap()
}
#[test]
fn batch_1_is_6() { assert_eq!(cost_cents(1), 6); }
#[test]
fn batch_2_is_12() { assert_eq!(cost_cents(2), 12); }
#[test]
fn batch_3_is_19() { assert_eq!(cost_cents(3), 19); }
#[test]
fn batch_4_is_25() { assert_eq!(cost_cents(4), 25); }
#[test]
fn v8_pricing_matches_v7() {
use crate::generate::generate_image::providers::artcraft::midjourney_7::cost::ArtcraftMidjourney7CostState;
for batch in 1..=4u16 {
let v7 = ArtcraftMidjourney7CostState { num_images: batch }.estimate_cost();
let v8 = ArtcraftMidjourney8CostState { num_images: batch }.estimate_cost();
assert_eq!(v7.cost_in_usd_cents, v8.cost_in_usd_cents, "batch {}", batch);
assert_eq!(v7.cost_in_credits, v8.cost_in_credits, "batch {}", batch);
}
}
// ── 1:1 cost parity with Kinovi v8 ──
mod kinovi_parity {
use super::*;
use crate::generate::generate_image::providers::kinovi::midjourney_8::cost::KinoviMidjourney8CostState;
use seedance2pro_client::generate::image::generate_midjourney_v8::KinoviMidjourneyBatchCount;
#[test]
fn batch_1_matches_kinovi() {
let kinovi = KinoviMidjourney8CostState { batch_count: KinoviMidjourneyBatchCount::One }
.estimate_cost();
assert_eq!(cost_cents(1), kinovi.cost_in_usd_cents.unwrap());
}
#[test]
fn batch_2_matches_kinovi() {
let kinovi = KinoviMidjourney8CostState { batch_count: KinoviMidjourneyBatchCount::Two }
.estimate_cost();
assert_eq!(cost_cents(2), kinovi.cost_in_usd_cents.unwrap());
}
#[test]
fn batch_4_matches_kinovi() {
let kinovi = KinoviMidjourney8CostState { batch_count: KinoviMidjourneyBatchCount::Four }
.estimate_cost();
assert_eq!(cost_cents(4), kinovi.cost_in_usd_cents.unwrap());
}
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -0,0 +1,17 @@
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::OmniGenImageCostAndGenerateRequest;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_image::generate_image_response::GenerateImageResponse;
use crate::generate::generate_image::providers::artcraft::request_common::send_artcraft_omni_image_request;
#[derive(Clone, Debug)]
pub struct ArtcraftMidjourney8RequestState {
pub request: OmniGenImageCostAndGenerateRequest,
}
impl ArtcraftMidjourney8RequestState {
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateImageResponse, ArtcraftRouterError> {
send_artcraft_omni_image_request(&self.request, client).await
}
}
@@ -8,6 +8,9 @@ pub mod flux_pro_1p1_ultra;
pub mod gpt_image_1;
pub mod gpt_image_1p5;
pub mod gpt_image_2;
pub mod midjourney_7;
pub mod midjourney_7_niji;
pub mod midjourney_8;
pub mod nano_banana;
pub mod nano_banana_2;
pub mod nano_banana_pro;
@@ -0,0 +1,460 @@
use seedance2pro_client::generate::image::generate_midjourney_v7::{
GenerateMidjourneyV7AspectRatio, GenerateMidjourneyV7Quality,
GenerateMidjourneyV7Request, KinoviMidjourneyBatchCount,
};
use crate::api::image_list_ref::ImageListRef;
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_quality::RouterQuality;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder;
use crate::generate::generate_image::image_generation_draft::ImageGenerationDraftRequest;
use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest;
use crate::generate::generate_image::image_generation_request::ImageGenerationRequest;
use crate::generate::generate_image::providers::kinovi::midjourney_7::draft::{
KinoviMidjourney7DraftState, KinoviMidjourney7RemainingItems,
};
use crate::generate::generate_image::providers::kinovi::midjourney_7::request::KinoviMidjourney7RequestState;
pub fn build_kinovi_midjourney_7(
mut builder: GenerateImageRequestBuilder,
) -> Result<ImageGenerationDraftOrRequest, ArtcraftRouterError> {
let strategy = builder.request_mismatch_mitigation_strategy;
let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio.take(), strategy)?;
let quality = plan_quality(builder.quality.take());
let batch_count = plan_batch_count(builder.image_batch_count.take(), strategy)?;
let prompt = builder.prompt.take().unwrap_or_default();
let image_inputs = builder.image_inputs.take();
// No image inputs → we can build the final request immediately. No
// draft/upload phase needed.
if !has_image_inputs(image_inputs.as_ref()) {
let request = GenerateMidjourneyV7Request {
prompt,
aspect_ratio,
negative_prompt: None,
stylize: None,
weird: None,
chaos: None,
quality,
raw_mode: false,
batch_count,
reference_image_urls: None,
};
return Ok(ImageGenerationDraftOrRequest::Request(
ImageGenerationRequest::KinoviMidjourney7(KinoviMidjourney7RequestState { request }),
));
}
// Image inputs present → must go through the draft phase so we can
// upload each image to the Seedance2Pro CDN before sending.
let draft = KinoviMidjourney7DraftState {
prompt,
aspect_ratio,
quality,
batch_count,
unhandled_request_state: Some(KinoviMidjourney7RemainingItems {
reference_images: image_inputs,
}),
};
Ok(ImageGenerationDraftOrRequest::Draft(
ImageGenerationDraftRequest::KinoviMidjourney7(draft),
))
}
fn has_image_inputs(image_inputs: Option<&ImageListRef>) -> bool {
match image_inputs {
None => false,
Some(ImageListRef::Urls(urls)) => !urls.is_empty(),
Some(ImageListRef::MediaFileTokens(tokens)) => !tokens.is_empty(),
}
}
// ── Plan helpers ──
pub(crate) fn plan_aspect_ratio(
aspect_ratio: Option<RouterAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<GenerateMidjourneyV7AspectRatio, ArtcraftRouterError> {
use GenerateMidjourneyV7AspectRatio as Ar;
match aspect_ratio {
// No preference or auto → square, matching Midjourney's own default for new
// jobs in their UI.
None
| Some(RouterAspectRatio::Auto)
| Some(RouterAspectRatio::Auto2k)
| Some(RouterAspectRatio::Auto3k)
| Some(RouterAspectRatio::Auto4k) => Ok(Ar::Square1x1),
// Direct mappings — Midjourney supports every common ratio.
Some(RouterAspectRatio::Square) | Some(RouterAspectRatio::SquareHd) => Ok(Ar::Square1x1),
Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Ar::Landscape16x9),
Some(RouterAspectRatio::WideTwentyOneByNine) => Ok(Ar::UltraWide21x9),
Some(RouterAspectRatio::WideFourByThree) => Ok(Ar::Standard4x3),
Some(RouterAspectRatio::WideFiveByFour) => Ok(Ar::Wide5x4),
Some(RouterAspectRatio::WideThreeByTwo) => Ok(Ar::Wide3x2),
Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Ar::Portrait9x16),
Some(RouterAspectRatio::TallNineByTwentyOne) => Ok(Ar::UltraTall9x21),
Some(RouterAspectRatio::TallThreeByFour) => Ok(Ar::Portrait3x4),
Some(RouterAspectRatio::TallFourByFive) => Ok(Ar::Tall4x5),
Some(RouterAspectRatio::TallTwoByThree) => Ok(Ar::Tall2x3),
// Every aspect ratio listed in `RouterAspectRatio` has a direct mapping;
// the catch-all is reserved for future variants.
#[allow(unreachable_patterns)]
Some(unsupported) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "aspect_ratio",
value: format!("{:?}", unsupported),
}))
}
_ => Ok(Ar::Square1x1),
},
}
}
pub(crate) fn plan_quality(quality: Option<RouterQuality>) -> Option<GenerateMidjourneyV7Quality> {
// Midjourney's quality knob is a compute-budget dial that doesn't change
// Kinovi pricing — pass through whatever the caller asked for, and let
// `None` ride Midjourney's server-side default.
match quality {
None => None,
Some(RouterQuality::Low) => Some(GenerateMidjourneyV7Quality::Quarter),
Some(RouterQuality::Medium) => Some(GenerateMidjourneyV7Quality::Half),
Some(RouterQuality::High) => Some(GenerateMidjourneyV7Quality::Full),
}
}
pub(crate) fn plan_batch_count(
image_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<KinoviMidjourneyBatchCount, ArtcraftRouterError> {
let count = image_batch_count.unwrap_or(1);
match count {
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 => Ok(KinoviMidjourneyBatchCount::One),
2 => Ok(KinoviMidjourneyBatchCount::Two),
4 => Ok(KinoviMidjourneyBatchCount::Four),
other => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "image_batch_count",
value: format!("{}", other),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => {
// 3 → 4, anything > 4 → 4
Ok(if other < 3 { KinoviMidjourneyBatchCount::Two } else { KinoviMidjourneyBatchCount::Four })
}
RequestMismatchMitigationStrategy::PayLessDowngrade => {
// 3 → 2, anything > 4 → 4
Ok(if other < 4 { KinoviMidjourneyBatchCount::Two } else { KinoviMidjourneyBatchCount::Four })
}
},
}
}
#[cfg(test)]
mod tests {
use seedance2pro_client::generate::image::generate_midjourney_v7::KinoviMidjourneyBatchCount;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::image_list_ref::ImageListRef;
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_image_model::RouterImageModel;
use crate::api::router_provider::RouterProvider;
use crate::api::router_quality::RouterQuality;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder;
use crate::generate::generate_image::image_generation_draft::ImageGenerationDraftRequest;
use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest;
use crate::generate::generate_image::image_generation_request::ImageGenerationRequest;
use super::*;
fn base_builder() -> GenerateImageRequestBuilder {
GenerateImageRequestBuilder {
model: RouterImageModel::Midjourney7,
provider: RouterProvider::Seedance2Pro,
prompt: Some("a corgi astronaut".to_string()),
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: None,
horizontal_angle: None,
vertical_angle: None,
zoom: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
}
}
// ── Mode dispatch: no image inputs → Request; with → Draft ──
mod mode_dispatch {
use super::*;
#[test]
fn no_image_inputs_returns_request_directly() {
let result = build_kinovi_midjourney_7(base_builder()).expect("build");
assert!(matches!(
result,
ImageGenerationDraftOrRequest::Request(ImageGenerationRequest::KinoviMidjourney7(_)),
));
}
#[test]
fn empty_image_inputs_returns_request_directly() {
let builder = GenerateImageRequestBuilder {
image_inputs: Some(ImageListRef::Urls(vec![])),
..base_builder()
};
let result = build_kinovi_midjourney_7(builder).expect("build");
assert!(matches!(
result,
ImageGenerationDraftOrRequest::Request(ImageGenerationRequest::KinoviMidjourney7(_)),
));
}
#[test]
fn image_url_inputs_returns_draft() {
let builder = GenerateImageRequestBuilder {
image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/ref.png".to_string()])),
..base_builder()
};
let result = build_kinovi_midjourney_7(builder).expect("build");
assert!(matches!(
result,
ImageGenerationDraftOrRequest::Draft(ImageGenerationDraftRequest::KinoviMidjourney7(_)),
));
}
#[test]
fn image_token_inputs_returns_draft() {
let builder = GenerateImageRequestBuilder {
image_inputs: Some(ImageListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_test".to_string()),
])),
..base_builder()
};
let result = build_kinovi_midjourney_7(builder).expect("build");
assert!(matches!(
result,
ImageGenerationDraftOrRequest::Draft(ImageGenerationDraftRequest::KinoviMidjourney7(_)),
));
}
#[test]
fn draft_remembers_image_inputs() {
let builder = GenerateImageRequestBuilder {
image_inputs: Some(ImageListRef::Urls(vec![
"https://example.com/a.png".to_string(),
"https://example.com/b.png".to_string(),
])),
..base_builder()
};
let draft = unwrap_draft(build_kinovi_midjourney_7(builder));
let remaining = draft.unhandled_request_state.as_ref().unwrap();
let urls = match remaining.reference_images.as_ref().unwrap() {
ImageListRef::Urls(urls) => urls,
_ => panic!("expected Urls"),
};
assert_eq!(urls.len(), 2);
}
}
// ── Field passthrough ──
mod field_passthrough {
use super::*;
#[test]
fn prompt_is_passed_through() {
let req = unwrap_direct_request(build_kinovi_midjourney_7(base_builder()));
assert_eq!(req.request.prompt, "a corgi astronaut");
}
#[test]
fn prompt_defaults_to_empty() {
let builder = GenerateImageRequestBuilder { prompt: None, ..base_builder() };
let req = unwrap_direct_request(build_kinovi_midjourney_7(builder));
assert_eq!(req.request.prompt, "");
}
}
// ── Aspect ratio ──
mod aspect_ratio_tests {
use super::*;
use seedance2pro_client::generate::image::generate_midjourney_v7::GenerateMidjourneyV7AspectRatio as Ar;
fn aspect_ratio_for(input: Option<RouterAspectRatio>) -> Ar {
let builder = GenerateImageRequestBuilder { aspect_ratio: input, ..base_builder() };
unwrap_direct_request(build_kinovi_midjourney_7(builder)).request.aspect_ratio
}
#[test]
fn none_defaults_to_square() {
assert_eq!(aspect_ratio_for(None), Ar::Square1x1);
}
#[test]
fn auto_defaults_to_square() {
assert_eq!(aspect_ratio_for(Some(RouterAspectRatio::Auto)), Ar::Square1x1);
assert_eq!(aspect_ratio_for(Some(RouterAspectRatio::Auto2k)), Ar::Square1x1);
assert_eq!(aspect_ratio_for(Some(RouterAspectRatio::Auto3k)), Ar::Square1x1);
assert_eq!(aspect_ratio_for(Some(RouterAspectRatio::Auto4k)), Ar::Square1x1);
}
#[test]
fn every_ratio_maps_directly() {
assert_eq!(aspect_ratio_for(Some(RouterAspectRatio::Square)), Ar::Square1x1);
assert_eq!(aspect_ratio_for(Some(RouterAspectRatio::SquareHd)), Ar::Square1x1);
assert_eq!(aspect_ratio_for(Some(RouterAspectRatio::WideSixteenByNine)), Ar::Landscape16x9);
assert_eq!(aspect_ratio_for(Some(RouterAspectRatio::WideTwentyOneByNine)), Ar::UltraWide21x9);
assert_eq!(aspect_ratio_for(Some(RouterAspectRatio::WideFourByThree)), Ar::Standard4x3);
assert_eq!(aspect_ratio_for(Some(RouterAspectRatio::WideFiveByFour)), Ar::Wide5x4);
assert_eq!(aspect_ratio_for(Some(RouterAspectRatio::WideThreeByTwo)), Ar::Wide3x2);
assert_eq!(aspect_ratio_for(Some(RouterAspectRatio::Wide)), Ar::Landscape16x9);
assert_eq!(aspect_ratio_for(Some(RouterAspectRatio::TallNineBySixteen)), Ar::Portrait9x16);
assert_eq!(aspect_ratio_for(Some(RouterAspectRatio::TallNineByTwentyOne)), Ar::UltraTall9x21);
assert_eq!(aspect_ratio_for(Some(RouterAspectRatio::TallThreeByFour)), Ar::Portrait3x4);
assert_eq!(aspect_ratio_for(Some(RouterAspectRatio::TallFourByFive)), Ar::Tall4x5);
assert_eq!(aspect_ratio_for(Some(RouterAspectRatio::TallTwoByThree)), Ar::Tall2x3);
assert_eq!(aspect_ratio_for(Some(RouterAspectRatio::Tall)), Ar::Portrait9x16);
}
}
// ── Quality ──
mod quality_tests {
use super::*;
use seedance2pro_client::generate::image::generate_midjourney_v7::GenerateMidjourneyV7Quality as Q;
fn quality_for(input: Option<RouterQuality>) -> Option<Q> {
let builder = GenerateImageRequestBuilder { quality: input, ..base_builder() };
unwrap_direct_request(build_kinovi_midjourney_7(builder)).request.quality
}
#[test]
fn none_passes_through_as_none() {
assert_eq!(quality_for(None), None);
}
#[test]
fn low_maps_to_quarter() {
assert_eq!(quality_for(Some(RouterQuality::Low)), Some(Q::Quarter));
}
#[test]
fn medium_maps_to_half() {
assert_eq!(quality_for(Some(RouterQuality::Medium)), Some(Q::Half));
}
#[test]
fn high_maps_to_full() {
assert_eq!(quality_for(Some(RouterQuality::High)), Some(Q::Full));
}
}
// ── Batch count ──
mod batch_count_tests {
use super::*;
fn batch_for(count: Option<u16>) -> KinoviMidjourneyBatchCount {
let builder = GenerateImageRequestBuilder { image_batch_count: count, ..base_builder() };
unwrap_direct_request(build_kinovi_midjourney_7(builder)).request.batch_count
}
#[test]
fn none_defaults_to_one() {
assert_eq!(batch_for(None), KinoviMidjourneyBatchCount::One);
}
#[test]
fn one_two_four_map_directly() {
assert_eq!(batch_for(Some(1)), KinoviMidjourneyBatchCount::One);
assert_eq!(batch_for(Some(2)), KinoviMidjourneyBatchCount::Two);
assert_eq!(batch_for(Some(4)), KinoviMidjourneyBatchCount::Four);
}
#[test]
fn zero_is_rejected() {
let builder = GenerateImageRequestBuilder { image_batch_count: Some(0), ..base_builder() };
assert!(matches!(
build_kinovi_midjourney_7(builder),
Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
));
}
#[test]
fn three_errors_under_strict() {
let builder = GenerateImageRequestBuilder {
image_batch_count: Some(3),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..base_builder()
};
assert!(matches!(
build_kinovi_midjourney_7(builder),
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { .. })),
));
}
#[test]
fn three_upgrades_to_four_with_pay_more() {
let builder = GenerateImageRequestBuilder {
image_batch_count: Some(3),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
..base_builder()
};
assert_eq!(
unwrap_direct_request(build_kinovi_midjourney_7(builder)).request.batch_count,
KinoviMidjourneyBatchCount::Four,
);
}
#[test]
fn three_downgrades_to_two_with_pay_less() {
let builder = GenerateImageRequestBuilder {
image_batch_count: Some(3),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayLessDowngrade,
..base_builder()
};
assert_eq!(
unwrap_direct_request(build_kinovi_midjourney_7(builder)).request.batch_count,
KinoviMidjourneyBatchCount::Two,
);
}
}
// ── Helpers ──
fn unwrap_direct_request(
result: Result<ImageGenerationDraftOrRequest, ArtcraftRouterError>,
) -> KinoviMidjourney7RequestState {
match result.expect("build should succeed") {
ImageGenerationDraftOrRequest::Request(
ImageGenerationRequest::KinoviMidjourney7(req),
) => req,
other => panic!("expected KinoviMidjourney7 Request, got {:?}", other),
}
}
fn unwrap_draft(
result: Result<ImageGenerationDraftOrRequest, ArtcraftRouterError>,
) -> KinoviMidjourney7DraftState {
match result.expect("build should succeed") {
ImageGenerationDraftOrRequest::Draft(
ImageGenerationDraftRequest::KinoviMidjourney7(draft),
) => draft,
other => panic!("expected KinoviMidjourney7 Draft, got {:?}", other),
}
}
}
@@ -0,0 +1,211 @@
use seedance2pro_client::generate::image::generate_midjourney_v7::{
GenerateMidjourneyV7AspectRatio, GenerateMidjourneyV7Request, KinoviMidjourneyBatchCount,
};
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
use crate::generate::generate_image::providers::kinovi::midjourney_7::draft::KinoviMidjourney7DraftState;
use crate::generate::generate_image::providers::kinovi::midjourney_7::request::KinoviMidjourney7RequestState;
/// Midjourney v7 (via Kinovi) cost state. Only `batch_count` matters for
/// pricing — Midjourney pricing is flat per task regardless of aspect
/// ratio, quality, or whether reference images were supplied.
pub struct KinoviMidjourney7CostState {
pub batch_count: KinoviMidjourneyBatchCount,
}
impl KinoviMidjourney7CostState {
pub fn from_request(request: &KinoviMidjourney7RequestState) -> Self {
Self { batch_count: request.request.batch_count }
}
pub fn from_draft(draft: &KinoviMidjourney7DraftState) -> Self {
Self { batch_count: draft.batch_count }
}
pub fn estimate_cost(&self) -> ImageGenerationCostEstimate {
let pricing_request = GenerateMidjourneyV7Request {
batch_count: self.batch_count,
// Cost-irrelevant placeholders below — only `batch_count` matters.
prompt: String::new(),
aspect_ratio: GenerateMidjourneyV7AspectRatio::Square1x1,
negative_prompt: None,
stylize: None,
weird: None,
chaos: None,
quality: None,
raw_mode: false,
reference_image_urls: None,
};
let cost_in_credits = pricing_request.estimate_credits() as u64;
let cost_in_usd_cents = pricing_request.estimate_cost_in_usd_cents();
ImageGenerationCostEstimate {
cost_in_credits: Some(cost_in_credits),
cost_in_usd_cents: Some(cost_in_usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::image_list_ref::ImageListRef;
use crate::api::router_image_model::RouterImageModel;
use crate::api::router_provider::RouterProvider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder;
use crate::generate::generate_image::image_generation_draft::ImageGenerationDraftRequest;
use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest;
use crate::generate::generate_image::image_generation_request::ImageGenerationRequest;
// ── Credit pricing (12 per task × batch) ──
mod credits {
use super::*;
#[test]
fn batch_one_is_twelve_credits() {
assert_eq!(credits_for(KinoviMidjourneyBatchCount::One), 12);
}
#[test]
fn batch_two_is_twentyfour_credits() {
assert_eq!(credits_for(KinoviMidjourneyBatchCount::Two), 24);
}
#[test]
fn batch_four_is_fortyeight_credits() {
assert_eq!(credits_for(KinoviMidjourneyBatchCount::Four), 48);
}
}
// ── USD pricing (193 credits per dollar; rounded) ──
mod usd {
use super::*;
#[test]
fn batch_one_is_six_cents() {
// 12 / 193 × 100 = 6.21 → 6¢
assert_eq!(usd_cents_for(KinoviMidjourneyBatchCount::One), 6);
}
#[test]
fn batch_two_is_twelve_cents() {
// 24 / 193 × 100 = 12.44 → 12¢
assert_eq!(usd_cents_for(KinoviMidjourneyBatchCount::Two), 12);
}
#[test]
fn batch_four_is_twentyfive_cents() {
// 48 / 193 × 100 = 24.87 → 25¢
assert_eq!(usd_cents_for(KinoviMidjourneyBatchCount::Four), 25);
}
}
// ── Flags ──
#[test]
fn flags_are_correct() {
let cost = KinoviMidjourney7CostState { batch_count: KinoviMidjourneyBatchCount::One }
.estimate_cost();
assert!(!cost.is_free);
assert!(!cost.is_unlimited);
assert!(!cost.is_rate_limited);
assert!(!cost.has_watermark);
assert_eq!(cost.failures_are_refunded, None);
}
// ── from_request / from_draft ──
mod source_state_round_trip {
use super::*;
#[test]
fn from_request_picks_up_batch_count() {
let req = build_request_no_image_inputs(2);
let state = KinoviMidjourney7CostState::from_request(&req);
assert_eq!(state.batch_count, KinoviMidjourneyBatchCount::Two);
assert_eq!(state.estimate_cost().cost_in_credits, Some(24));
}
#[test]
fn from_draft_picks_up_batch_count() {
let draft = build_draft_with_image_inputs(4);
let state = KinoviMidjourney7CostState::from_draft(&draft);
assert_eq!(state.batch_count, KinoviMidjourneyBatchCount::Four);
assert_eq!(state.estimate_cost().cost_in_credits, Some(48));
}
#[test]
fn draft_and_request_produce_same_cost() {
// Same batch count → same cost, regardless of draft vs request path.
let req_cost = KinoviMidjourney7CostState::from_request(&build_request_no_image_inputs(2))
.estimate_cost();
let draft_cost = KinoviMidjourney7CostState::from_draft(&build_draft_with_image_inputs(2))
.estimate_cost();
assert_eq!(req_cost.cost_in_credits, draft_cost.cost_in_credits);
assert_eq!(req_cost.cost_in_usd_cents, draft_cost.cost_in_usd_cents);
}
}
// ── Helpers ──
fn credits_for(batch: KinoviMidjourneyBatchCount) -> u64 {
KinoviMidjourney7CostState { batch_count: batch }
.estimate_cost().cost_in_credits.unwrap()
}
fn usd_cents_for(batch: KinoviMidjourneyBatchCount) -> u64 {
KinoviMidjourney7CostState { batch_count: batch }
.estimate_cost().cost_in_usd_cents.unwrap()
}
fn base_builder() -> GenerateImageRequestBuilder {
GenerateImageRequestBuilder {
model: RouterImageModel::Midjourney7,
provider: RouterProvider::Seedance2Pro,
prompt: Some("test".to_string()),
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: None,
horizontal_angle: None,
vertical_angle: None,
zoom: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
}
}
fn build_request_no_image_inputs(batch_count: u16) -> KinoviMidjourney7RequestState {
let builder = GenerateImageRequestBuilder {
image_batch_count: Some(batch_count),
..base_builder()
};
match builder.build2().expect("build2") {
ImageGenerationDraftOrRequest::Request(ImageGenerationRequest::KinoviMidjourney7(r)) => r,
_ => panic!("expected Request"),
}
}
fn build_draft_with_image_inputs(batch_count: u16) -> KinoviMidjourney7DraftState {
let builder = GenerateImageRequestBuilder {
image_batch_count: Some(batch_count),
image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/ref.png".to_string()])),
..base_builder()
};
match builder.build2().expect("build2") {
ImageGenerationDraftOrRequest::Draft(ImageGenerationDraftRequest::KinoviMidjourney7(d)) => d,
_ => panic!("expected Draft"),
}
}
}
@@ -0,0 +1,94 @@
use seedance2pro_client::generate::image::generate_midjourney_v7::{
GenerateMidjourneyV7AspectRatio, GenerateMidjourneyV7Quality,
GenerateMidjourneyV7Request, KinoviMidjourneyBatchCount,
};
use crate::api::image_list_ref::ImageListRef;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_image::image_generation_draft_context::ImageGenerationDraftContext;
use crate::generate::generate_image::providers::kinovi::midjourney_7::request::KinoviMidjourney7RequestState;
use crate::generate::generate_image::providers::kinovi::resolve::resolve_and_upload_image_list;
#[derive(Debug, Clone)]
pub struct KinoviMidjourney7DraftState {
// Materialized fields — all enums already resolved.
pub prompt: String,
pub aspect_ratio: GenerateMidjourneyV7AspectRatio,
pub quality: Option<GenerateMidjourneyV7Quality>,
pub batch_count: KinoviMidjourneyBatchCount,
/// Image references that still need to be uploaded to Seedance2Pro CDN
/// before the request is sendable. `None` once `to_request()` has run.
pub unhandled_request_state: Option<KinoviMidjourney7RemainingItems>,
}
#[derive(Debug, Clone)]
pub struct KinoviMidjourney7RemainingItems {
pub reference_images: Option<ImageListRef>,
}
impl KinoviMidjourney7DraftState {
pub async fn to_request(
&mut self,
draft_context: &ImageGenerationDraftContext<'_>,
) -> Result<KinoviMidjourney7RequestState, ArtcraftRouterError> {
let client = draft_context.get_seedance2pro_client_ref()?;
let session = &client.session;
let mut reference_image_urls = None;
if let Some(remaining) = self.unhandled_request_state.take() {
let map = draft_context.media_file_to_artcraft_url_map;
reference_image_urls = resolve_and_upload_image_list(
session, remaining.reference_images, map,
).await?;
}
let request = GenerateMidjourneyV7Request {
prompt: self.prompt.clone(),
aspect_ratio: self.aspect_ratio,
negative_prompt: None,
stylize: None,
weird: None,
chaos: None,
quality: self.quality,
raw_mode: false,
batch_count: self.batch_count,
reference_image_urls,
};
Ok(KinoviMidjourney7RequestState { request })
}
}
#[cfg(test)]
mod tests {
use super::*;
use seedance2pro_client::generate::image::generate_midjourney_v7::GenerateMidjourneyV7AspectRatio;
// Note: most behavioral tests live in `build.rs` and `cost.rs`. The
// `to_request()` method requires a live Seedance2Pro client to upload
// images, so its end-to-end behavior is exercised by the `request.rs`
// #[ignore]'d integration tests rather than unit-tested here.
#[test]
fn draft_with_no_image_inputs_finalises_to_empty_reference_image_urls() {
// Even when the draft *has* an unhandled state, an empty `ImageListRef`
// shouldn't trigger an upload. This is technically not the path the
// builder takes (it short-circuits to Request), but we exercise it
// here for robustness.
let mut draft = KinoviMidjourney7DraftState {
prompt: "test".to_string(),
aspect_ratio: GenerateMidjourneyV7AspectRatio::Square1x1,
quality: None,
batch_count: KinoviMidjourneyBatchCount::One,
unhandled_request_state: Some(KinoviMidjourney7RemainingItems {
reference_images: Some(ImageListRef::Urls(vec![])),
}),
};
// We can't actually call `to_request()` without a client, but the
// resolve path returns `None` for empty inputs. Smoke-test the resolve
// helper directly via the test_resolve helper.
let inputs = draft.unhandled_request_state.take().unwrap().reference_images;
assert!(matches!(inputs, Some(ImageListRef::Urls(ref v)) if v.is_empty()));
}
}
@@ -0,0 +1,4 @@
pub mod build;
pub mod cost;
pub mod draft;
pub mod request;
@@ -0,0 +1,142 @@
use seedance2pro_client::generate::image::generate_midjourney_v7::{
generate_midjourney_v7, GenerateMidjourneyV7Args, GenerateMidjourneyV7Request,
};
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_image::generate_image_response::{
GenerateImageResponse, Seedance2proImageResponsePayload,
};
#[derive(Debug, Clone)]
pub struct KinoviMidjourney7RequestState {
/// Final materialized request; ready to fire against Kinovi.
pub request: GenerateMidjourneyV7Request,
}
impl KinoviMidjourney7RequestState {
pub async fn send(
&self,
client: &RouterSeedance2ProClient,
) -> Result<GenerateImageResponse, ArtcraftRouterError> {
let args = GenerateMidjourneyV7Args {
session: &client.session,
host_override: None,
request: self.request.clone(),
};
let response = generate_midjourney_v7(args)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?;
Ok(GenerateImageResponse::Seedance2Pro(Seedance2proImageResponsePayload {
order_id: response.order_id,
task_id: response.task_id,
maybe_order_ids: response.order_ids,
maybe_task_ids: response.task_ids,
}))
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::image_list_ref::ImageListRef;
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_image_model::RouterImageModel;
use crate::api::router_provider::RouterProvider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::client::router_client::RouterClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder;
use crate::generate::generate_image::generate_image_response::GenerateImageResponse;
use crate::generate::generate_image::image_generation_draft::ImageGenerationDraftRequest;
use crate::generate::generate_image::image_generation_draft_context::ImageGenerationDraftContext;
use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest;
use crate::generate::generate_image::image_generation_request::ImageGenerationRequest;
use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL;
fn base_builder() -> GenerateImageRequestBuilder {
GenerateImageRequestBuilder {
model: RouterImageModel::Midjourney7,
provider: RouterProvider::Seedance2Pro,
prompt: Some("a serene mountain lake at sunrise, cinematic".to_string()),
image_inputs: None,
resolution: None,
aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine),
quality: None,
image_batch_count: Some(1),
horizontal_angle: None,
vertical_angle: None,
zoom: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
}
}
fn get_kinovi_client() -> RouterClient {
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/seedance2pro_cookies.txt")
.expect("Failed to read seedance2pro cookies");
let session = Seedance2ProSession::from_cookies_string(cookies.trim().to_string());
RouterClient::Seedance2Pro(RouterSeedance2ProClient::new(session))
}
#[tokio::test]
#[ignore] // manually test — requires real cookies; costs credits
async fn text_to_image_round_trip() {
let client = get_kinovi_client();
let result = base_builder().build2().expect("build2");
let request = match result {
ImageGenerationDraftOrRequest::Request(ImageGenerationRequest::KinoviMidjourney7(r)) => r,
_ => panic!("expected direct Request for text-to-image"),
};
let response = request.send(client.get_seedance2pro_client_ref().unwrap())
.await.expect("send should succeed");
let payload = response.get_seedance2pro_payload().expect("expected seedance2pro payload");
println!("v7 t2i — task_id={}, order_id={}", payload.task_id, payload.order_id);
assert!(!payload.task_id.is_empty());
}
#[tokio::test]
#[ignore] // manually test — requires real cookies; costs credits
async fn image_reference_round_trip_via_draft() {
let client = get_kinovi_client();
let token = MediaFileToken::new("mf_test_juno".to_string());
let mut map = HashMap::new();
map.insert(token.clone(), JUNO_AT_LAKE_IMAGE_URL.to_string());
let builder = GenerateImageRequestBuilder {
prompt: Some("a corgi in the style of @1".to_string()),
image_inputs: Some(ImageListRef::MediaFileTokens(vec![token])),
..base_builder()
};
let draft_or_request = builder.build2().expect("build2");
let draft = match draft_or_request {
ImageGenerationDraftOrRequest::Draft(ImageGenerationDraftRequest::KinoviMidjourney7(d)) => d,
_ => panic!("expected Draft for image-input flow"),
};
let ctx = ImageGenerationDraftContext {
client: Some(&client),
media_file_to_artcraft_url_map: Some(&map),
};
let request = ImageGenerationDraftRequest::KinoviMidjourney7(draft)
.finalize(ctx).await.expect("finalize");
let response = request.send_request(&client).await.expect("send_request");
let payload = response.get_seedance2pro_payload().expect("expected seedance2pro payload");
println!("v7 with ref — task_id={}, order_id={}", payload.task_id, payload.order_id);
assert!(!payload.task_id.is_empty());
}
}
@@ -0,0 +1,267 @@
use seedance2pro_client::generate::image::generate_midjourney_v7_niji::{
GenerateMidjourneyV7NijiAspectRatio, GenerateMidjourneyV7NijiQuality,
GenerateMidjourneyV7NijiRequest, KinoviMidjourneyBatchCount,
};
use crate::api::image_list_ref::ImageListRef;
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_quality::RouterQuality;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder;
use crate::generate::generate_image::image_generation_draft::ImageGenerationDraftRequest;
use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest;
use crate::generate::generate_image::image_generation_request::ImageGenerationRequest;
use crate::generate::generate_image::providers::kinovi::midjourney_7_niji::draft::{
KinoviMidjourney7NijiDraftState, KinoviMidjourney7NijiRemainingItems,
};
use crate::generate::generate_image::providers::kinovi::midjourney_7_niji::request::KinoviMidjourney7NijiRequestState;
pub fn build_kinovi_midjourney_7_niji(
mut builder: GenerateImageRequestBuilder,
) -> Result<ImageGenerationDraftOrRequest, ArtcraftRouterError> {
let strategy = builder.request_mismatch_mitigation_strategy;
let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio.take(), strategy)?;
let quality = plan_quality(builder.quality.take());
let batch_count = plan_batch_count(builder.image_batch_count.take(), strategy)?;
let prompt = builder.prompt.take().unwrap_or_default();
let image_inputs = builder.image_inputs.take();
if !has_image_inputs(image_inputs.as_ref()) {
let request = GenerateMidjourneyV7NijiRequest {
prompt,
aspect_ratio,
negative_prompt: None,
stylize: None,
weird: None,
chaos: None,
quality,
raw_mode: false,
batch_count,
reference_image_urls: None,
};
return Ok(ImageGenerationDraftOrRequest::Request(
ImageGenerationRequest::KinoviMidjourney7Niji(KinoviMidjourney7NijiRequestState { request }),
));
}
let draft = KinoviMidjourney7NijiDraftState {
prompt,
aspect_ratio,
quality,
batch_count,
unhandled_request_state: Some(KinoviMidjourney7NijiRemainingItems {
reference_images: image_inputs,
}),
};
Ok(ImageGenerationDraftOrRequest::Draft(
ImageGenerationDraftRequest::KinoviMidjourney7Niji(draft),
))
}
fn has_image_inputs(image_inputs: Option<&ImageListRef>) -> bool {
match image_inputs {
None => false,
Some(ImageListRef::Urls(urls)) => !urls.is_empty(),
Some(ImageListRef::MediaFileTokens(tokens)) => !tokens.is_empty(),
}
}
// ── Plan helpers ──
pub(crate) fn plan_aspect_ratio(
aspect_ratio: Option<RouterAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<GenerateMidjourneyV7NijiAspectRatio, ArtcraftRouterError> {
use GenerateMidjourneyV7NijiAspectRatio as Ar;
match aspect_ratio {
None
| Some(RouterAspectRatio::Auto)
| Some(RouterAspectRatio::Auto2k)
| Some(RouterAspectRatio::Auto3k)
| Some(RouterAspectRatio::Auto4k) => Ok(Ar::Square1x1),
Some(RouterAspectRatio::Square) | Some(RouterAspectRatio::SquareHd) => Ok(Ar::Square1x1),
Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Ar::Landscape16x9),
Some(RouterAspectRatio::WideTwentyOneByNine) => Ok(Ar::UltraWide21x9),
Some(RouterAspectRatio::WideFourByThree) => Ok(Ar::Standard4x3),
Some(RouterAspectRatio::WideFiveByFour) => Ok(Ar::Wide5x4),
Some(RouterAspectRatio::WideThreeByTwo) => Ok(Ar::Wide3x2),
Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Ar::Portrait9x16),
Some(RouterAspectRatio::TallNineByTwentyOne) => Ok(Ar::UltraTall9x21),
Some(RouterAspectRatio::TallThreeByFour) => Ok(Ar::Portrait3x4),
Some(RouterAspectRatio::TallFourByFive) => Ok(Ar::Tall4x5),
Some(RouterAspectRatio::TallTwoByThree) => Ok(Ar::Tall2x3),
#[allow(unreachable_patterns)]
Some(unsupported) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "aspect_ratio",
value: format!("{:?}", unsupported),
}))
}
_ => Ok(Ar::Square1x1),
},
}
}
pub(crate) fn plan_quality(quality: Option<RouterQuality>) -> Option<GenerateMidjourneyV7NijiQuality> {
match quality {
None => None,
Some(RouterQuality::Low) => Some(GenerateMidjourneyV7NijiQuality::Quarter),
Some(RouterQuality::Medium) => Some(GenerateMidjourneyV7NijiQuality::Half),
Some(RouterQuality::High) => Some(GenerateMidjourneyV7NijiQuality::Full),
}
}
pub(crate) fn plan_batch_count(
image_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<KinoviMidjourneyBatchCount, ArtcraftRouterError> {
let count = image_batch_count.unwrap_or(1);
match count {
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 => Ok(KinoviMidjourneyBatchCount::One),
2 => Ok(KinoviMidjourneyBatchCount::Two),
4 => Ok(KinoviMidjourneyBatchCount::Four),
other => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "image_batch_count",
value: format!("{}", other),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => {
Ok(if other < 3 { KinoviMidjourneyBatchCount::Two } else { KinoviMidjourneyBatchCount::Four })
}
RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(if other < 4 { KinoviMidjourneyBatchCount::Two } else { KinoviMidjourneyBatchCount::Four })
}
},
}
}
#[cfg(test)]
mod tests {
use seedance2pro_client::generate::image::generate_midjourney_v7_niji::KinoviMidjourneyBatchCount;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::image_list_ref::ImageListRef;
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_image_model::RouterImageModel;
use crate::api::router_provider::RouterProvider;
use crate::api::router_quality::RouterQuality;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder;
use crate::generate::generate_image::image_generation_draft::ImageGenerationDraftRequest;
use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest;
use crate::generate::generate_image::image_generation_request::ImageGenerationRequest;
use super::*;
fn base_builder() -> GenerateImageRequestBuilder {
GenerateImageRequestBuilder {
model: RouterImageModel::Midjourney7Niji,
provider: RouterProvider::Seedance2Pro,
prompt: Some("a magical anime forest scene".to_string()),
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: None,
horizontal_angle: None,
vertical_angle: None,
zoom: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
}
}
#[test]
fn no_image_inputs_returns_request_directly() {
let result = build_kinovi_midjourney_7_niji(base_builder()).expect("build");
assert!(matches!(
result,
ImageGenerationDraftOrRequest::Request(ImageGenerationRequest::KinoviMidjourney7Niji(_)),
));
}
#[test]
fn image_inputs_returns_draft() {
let builder = GenerateImageRequestBuilder {
image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/ref.png".to_string()])),
..base_builder()
};
let result = build_kinovi_midjourney_7_niji(builder).expect("build");
assert!(matches!(
result,
ImageGenerationDraftOrRequest::Draft(ImageGenerationDraftRequest::KinoviMidjourney7Niji(_)),
));
}
#[test]
fn batch_count_zero_errors() {
let builder = GenerateImageRequestBuilder { image_batch_count: Some(0), ..base_builder() };
assert!(matches!(
build_kinovi_midjourney_7_niji(builder),
Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
));
}
#[test]
fn batch_count_four_is_accepted() {
let builder = GenerateImageRequestBuilder { image_batch_count: Some(4), ..base_builder() };
let req = match build_kinovi_midjourney_7_niji(builder).expect("build") {
ImageGenerationDraftOrRequest::Request(ImageGenerationRequest::KinoviMidjourney7Niji(r)) => r,
_ => panic!("expected Request"),
};
assert_eq!(req.request.batch_count, KinoviMidjourneyBatchCount::Four);
}
#[test]
fn aspect_ratio_passes_through_to_niji_enum() {
use seedance2pro_client::generate::image::generate_midjourney_v7_niji::GenerateMidjourneyV7NijiAspectRatio as Ar;
let builder = GenerateImageRequestBuilder {
aspect_ratio: Some(RouterAspectRatio::WideTwentyOneByNine),
..base_builder()
};
let req = match build_kinovi_midjourney_7_niji(builder).expect("build") {
ImageGenerationDraftOrRequest::Request(ImageGenerationRequest::KinoviMidjourney7Niji(r)) => r,
_ => panic!("expected Request"),
};
assert_eq!(req.request.aspect_ratio, Ar::UltraWide21x9);
}
#[test]
fn quality_high_maps_to_full() {
use seedance2pro_client::generate::image::generate_midjourney_v7_niji::GenerateMidjourneyV7NijiQuality as Q;
let builder = GenerateImageRequestBuilder {
quality: Some(RouterQuality::High),
..base_builder()
};
let req = match build_kinovi_midjourney_7_niji(builder).expect("build") {
ImageGenerationDraftOrRequest::Request(ImageGenerationRequest::KinoviMidjourney7Niji(r)) => r,
_ => panic!("expected Request"),
};
assert_eq!(req.request.quality, Some(Q::Full));
}
#[test]
fn media_file_tokens_route_through_draft() {
let builder = GenerateImageRequestBuilder {
image_inputs: Some(ImageListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_x".to_string()),
])),
..base_builder()
};
let draft = match build_kinovi_midjourney_7_niji(builder).expect("build") {
ImageGenerationDraftOrRequest::Draft(ImageGenerationDraftRequest::KinoviMidjourney7Niji(d)) => d,
_ => panic!("expected Draft"),
};
assert!(draft.unhandled_request_state.is_some());
}
}
@@ -0,0 +1,90 @@
use seedance2pro_client::generate::image::generate_midjourney_v7_niji::{
GenerateMidjourneyV7NijiAspectRatio, GenerateMidjourneyV7NijiRequest, KinoviMidjourneyBatchCount,
};
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
use crate::generate::generate_image::providers::kinovi::midjourney_7_niji::draft::KinoviMidjourney7NijiDraftState;
use crate::generate::generate_image::providers::kinovi::midjourney_7_niji::request::KinoviMidjourney7NijiRequestState;
/// Midjourney v7 Niji (via Kinovi) cost state. Pricing is flat per task
/// regardless of aspect ratio or quality, identical to v7 / v8.
pub struct KinoviMidjourney7NijiCostState {
pub batch_count: KinoviMidjourneyBatchCount,
}
impl KinoviMidjourney7NijiCostState {
pub fn from_request(request: &KinoviMidjourney7NijiRequestState) -> Self {
Self { batch_count: request.request.batch_count }
}
pub fn from_draft(draft: &KinoviMidjourney7NijiDraftState) -> Self {
Self { batch_count: draft.batch_count }
}
pub fn estimate_cost(&self) -> ImageGenerationCostEstimate {
let pricing_request = GenerateMidjourneyV7NijiRequest {
batch_count: self.batch_count,
prompt: String::new(),
aspect_ratio: GenerateMidjourneyV7NijiAspectRatio::Square1x1,
negative_prompt: None,
stylize: None,
weird: None,
chaos: None,
quality: None,
raw_mode: false,
reference_image_urls: None,
};
let cost_in_credits = pricing_request.estimate_credits() as u64;
let cost_in_usd_cents = pricing_request.estimate_cost_in_usd_cents();
ImageGenerationCostEstimate {
cost_in_credits: Some(cost_in_credits),
cost_in_usd_cents: Some(cost_in_usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn batch_one_is_twelve_credits_and_six_cents() {
let cost = KinoviMidjourney7NijiCostState { batch_count: KinoviMidjourneyBatchCount::One }
.estimate_cost();
assert_eq!(cost.cost_in_credits, Some(12));
assert_eq!(cost.cost_in_usd_cents, Some(6));
}
#[test]
fn batch_two_doubles() {
let cost = KinoviMidjourney7NijiCostState { batch_count: KinoviMidjourneyBatchCount::Two }
.estimate_cost();
assert_eq!(cost.cost_in_credits, Some(24));
assert_eq!(cost.cost_in_usd_cents, Some(12));
}
#[test]
fn batch_four_quadruples() {
let cost = KinoviMidjourney7NijiCostState { batch_count: KinoviMidjourneyBatchCount::Four }
.estimate_cost();
assert_eq!(cost.cost_in_credits, Some(48));
assert_eq!(cost.cost_in_usd_cents, Some(25));
}
#[test]
fn flags_are_correct() {
let cost = KinoviMidjourney7NijiCostState { batch_count: KinoviMidjourneyBatchCount::One }
.estimate_cost();
assert!(!cost.is_free);
assert!(!cost.is_unlimited);
assert!(!cost.is_rate_limited);
assert!(!cost.has_watermark);
}
}
@@ -0,0 +1,58 @@
use seedance2pro_client::generate::image::generate_midjourney_v7_niji::{
GenerateMidjourneyV7NijiAspectRatio, GenerateMidjourneyV7NijiQuality,
GenerateMidjourneyV7NijiRequest, KinoviMidjourneyBatchCount,
};
use crate::api::image_list_ref::ImageListRef;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_image::image_generation_draft_context::ImageGenerationDraftContext;
use crate::generate::generate_image::providers::kinovi::midjourney_7_niji::request::KinoviMidjourney7NijiRequestState;
use crate::generate::generate_image::providers::kinovi::resolve::resolve_and_upload_image_list;
#[derive(Debug, Clone)]
pub struct KinoviMidjourney7NijiDraftState {
pub prompt: String,
pub aspect_ratio: GenerateMidjourneyV7NijiAspectRatio,
pub quality: Option<GenerateMidjourneyV7NijiQuality>,
pub batch_count: KinoviMidjourneyBatchCount,
pub unhandled_request_state: Option<KinoviMidjourney7NijiRemainingItems>,
}
#[derive(Debug, Clone)]
pub struct KinoviMidjourney7NijiRemainingItems {
pub reference_images: Option<ImageListRef>,
}
impl KinoviMidjourney7NijiDraftState {
pub async fn to_request(
&mut self,
draft_context: &ImageGenerationDraftContext<'_>,
) -> Result<KinoviMidjourney7NijiRequestState, ArtcraftRouterError> {
let client = draft_context.get_seedance2pro_client_ref()?;
let session = &client.session;
let mut reference_image_urls = None;
if let Some(remaining) = self.unhandled_request_state.take() {
let map = draft_context.media_file_to_artcraft_url_map;
reference_image_urls = resolve_and_upload_image_list(
session, remaining.reference_images, map,
).await?;
}
let request = GenerateMidjourneyV7NijiRequest {
prompt: self.prompt.clone(),
aspect_ratio: self.aspect_ratio,
negative_prompt: None,
stylize: None,
weird: None,
chaos: None,
quality: self.quality,
raw_mode: false,
batch_count: self.batch_count,
reference_image_urls,
};
Ok(KinoviMidjourney7NijiRequestState { request })
}
}
@@ -0,0 +1,4 @@
pub mod build;
pub mod cost;
pub mod draft;
pub mod request;
@@ -0,0 +1,140 @@
use seedance2pro_client::generate::image::generate_midjourney_v7_niji::{
generate_midjourney_v7_niji, GenerateMidjourneyV7NijiArgs, GenerateMidjourneyV7NijiRequest,
};
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_image::generate_image_response::{
GenerateImageResponse, Seedance2proImageResponsePayload,
};
#[derive(Debug, Clone)]
pub struct KinoviMidjourney7NijiRequestState {
pub request: GenerateMidjourneyV7NijiRequest,
}
impl KinoviMidjourney7NijiRequestState {
pub async fn send(
&self,
client: &RouterSeedance2ProClient,
) -> Result<GenerateImageResponse, ArtcraftRouterError> {
let args = GenerateMidjourneyV7NijiArgs {
session: &client.session,
host_override: None,
request: self.request.clone(),
};
let response = generate_midjourney_v7_niji(args)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?;
Ok(GenerateImageResponse::Seedance2Pro(Seedance2proImageResponsePayload {
order_id: response.order_id,
task_id: response.task_id,
maybe_order_ids: response.order_ids,
maybe_task_ids: response.task_ids,
}))
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::image_list_ref::ImageListRef;
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_image_model::RouterImageModel;
use crate::api::router_provider::RouterProvider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::client::router_client::RouterClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder;
use crate::generate::generate_image::image_generation_draft::ImageGenerationDraftRequest;
use crate::generate::generate_image::image_generation_draft_context::ImageGenerationDraftContext;
use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest;
use crate::generate::generate_image::image_generation_request::ImageGenerationRequest;
use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL;
fn base_builder() -> GenerateImageRequestBuilder {
GenerateImageRequestBuilder {
model: RouterImageModel::Midjourney7Niji,
provider: RouterProvider::Seedance2Pro,
prompt: Some("a magical anime fox spirit in a moonlit bamboo forest".to_string()),
image_inputs: None,
resolution: None,
aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine),
quality: None,
image_batch_count: Some(1),
horizontal_angle: None,
vertical_angle: None,
zoom: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
}
}
fn get_kinovi_client() -> RouterClient {
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/seedance2pro_cookies.txt")
.expect("Failed to read seedance2pro cookies");
let session = Seedance2ProSession::from_cookies_string(cookies.trim().to_string());
RouterClient::Seedance2Pro(RouterSeedance2ProClient::new(session))
}
#[tokio::test]
#[ignore] // manually test — requires real cookies; costs credits
async fn text_to_image_round_trip() {
let client = get_kinovi_client();
let result = base_builder().build2().expect("build2");
let request = match result {
ImageGenerationDraftOrRequest::Request(ImageGenerationRequest::KinoviMidjourney7Niji(r)) => r,
_ => panic!("expected direct Request for text-to-image"),
};
let response = request.send(client.get_seedance2pro_client_ref().unwrap())
.await.expect("send should succeed");
let payload = response.get_seedance2pro_payload().expect("expected seedance2pro payload");
println!("v7-niji t2i — task_id={}, order_id={}", payload.task_id, payload.order_id);
assert!(!payload.task_id.is_empty());
}
#[tokio::test]
#[ignore] // manually test — requires real cookies; costs credits
async fn image_reference_round_trip_via_draft() {
let client = get_kinovi_client();
let token = MediaFileToken::new("mf_test_juno_niji".to_string());
let mut map = HashMap::new();
map.insert(token.clone(), JUNO_AT_LAKE_IMAGE_URL.to_string());
let builder = GenerateImageRequestBuilder {
prompt: Some("an anime corgi sorcerer inspired by @1".to_string()),
image_inputs: Some(ImageListRef::MediaFileTokens(vec![token])),
..base_builder()
};
let draft_or_request = builder.build2().expect("build2");
let draft = match draft_or_request {
ImageGenerationDraftOrRequest::Draft(ImageGenerationDraftRequest::KinoviMidjourney7Niji(d)) => d,
_ => panic!("expected Draft for image-input flow"),
};
let ctx = ImageGenerationDraftContext {
client: Some(&client),
media_file_to_artcraft_url_map: Some(&map),
};
let request = ImageGenerationDraftRequest::KinoviMidjourney7Niji(draft)
.finalize(ctx).await.expect("finalize");
let response = request.send_request(&client).await.expect("send_request");
let payload = response.get_seedance2pro_payload().expect("expected seedance2pro payload");
println!("v7-niji with ref — task_id={}, order_id={}", payload.task_id, payload.order_id);
assert!(!payload.task_id.is_empty());
}
}
@@ -0,0 +1,228 @@
use seedance2pro_client::generate::image::generate_midjourney_v8::{
GenerateMidjourneyV8AspectRatio, GenerateMidjourneyV8Quality,
GenerateMidjourneyV8Request, KinoviMidjourneyBatchCount,
};
use crate::api::image_list_ref::ImageListRef;
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_quality::RouterQuality;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder;
use crate::generate::generate_image::image_generation_draft::ImageGenerationDraftRequest;
use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest;
use crate::generate::generate_image::image_generation_request::ImageGenerationRequest;
use crate::generate::generate_image::providers::kinovi::midjourney_8::draft::{
KinoviMidjourney8DraftState, KinoviMidjourney8RemainingItems,
};
use crate::generate::generate_image::providers::kinovi::midjourney_8::request::KinoviMidjourney8RequestState;
pub fn build_kinovi_midjourney_8(
mut builder: GenerateImageRequestBuilder,
) -> Result<ImageGenerationDraftOrRequest, ArtcraftRouterError> {
let strategy = builder.request_mismatch_mitigation_strategy;
let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio.take(), strategy)?;
let quality = plan_quality(builder.quality.take());
let batch_count = plan_batch_count(builder.image_batch_count.take(), strategy)?;
let prompt = builder.prompt.take().unwrap_or_default();
let image_inputs = builder.image_inputs.take();
if !has_image_inputs(image_inputs.as_ref()) {
let request = GenerateMidjourneyV8Request {
prompt,
aspect_ratio,
negative_prompt: None,
stylize: None,
weird: None,
chaos: None,
quality,
raw_mode: false,
batch_count,
reference_image_urls: None,
};
return Ok(ImageGenerationDraftOrRequest::Request(
ImageGenerationRequest::KinoviMidjourney8(KinoviMidjourney8RequestState { request }),
));
}
let draft = KinoviMidjourney8DraftState {
prompt,
aspect_ratio,
quality,
batch_count,
unhandled_request_state: Some(KinoviMidjourney8RemainingItems {
reference_images: image_inputs,
}),
};
Ok(ImageGenerationDraftOrRequest::Draft(
ImageGenerationDraftRequest::KinoviMidjourney8(draft),
))
}
fn has_image_inputs(image_inputs: Option<&ImageListRef>) -> bool {
match image_inputs {
None => false,
Some(ImageListRef::Urls(urls)) => !urls.is_empty(),
Some(ImageListRef::MediaFileTokens(tokens)) => !tokens.is_empty(),
}
}
// ── Plan helpers ──
pub(crate) fn plan_aspect_ratio(
aspect_ratio: Option<RouterAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<GenerateMidjourneyV8AspectRatio, ArtcraftRouterError> {
use GenerateMidjourneyV8AspectRatio as Ar;
match aspect_ratio {
None
| Some(RouterAspectRatio::Auto)
| Some(RouterAspectRatio::Auto2k)
| Some(RouterAspectRatio::Auto3k)
| Some(RouterAspectRatio::Auto4k) => Ok(Ar::Square1x1),
Some(RouterAspectRatio::Square) | Some(RouterAspectRatio::SquareHd) => Ok(Ar::Square1x1),
Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Ar::Landscape16x9),
Some(RouterAspectRatio::WideTwentyOneByNine) => Ok(Ar::UltraWide21x9),
Some(RouterAspectRatio::WideFourByThree) => Ok(Ar::Standard4x3),
Some(RouterAspectRatio::WideFiveByFour) => Ok(Ar::Wide5x4),
Some(RouterAspectRatio::WideThreeByTwo) => Ok(Ar::Wide3x2),
Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Ar::Portrait9x16),
Some(RouterAspectRatio::TallNineByTwentyOne) => Ok(Ar::UltraTall9x21),
Some(RouterAspectRatio::TallThreeByFour) => Ok(Ar::Portrait3x4),
Some(RouterAspectRatio::TallFourByFive) => Ok(Ar::Tall4x5),
Some(RouterAspectRatio::TallTwoByThree) => Ok(Ar::Tall2x3),
#[allow(unreachable_patterns)]
Some(unsupported) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "aspect_ratio",
value: format!("{:?}", unsupported),
}))
}
_ => Ok(Ar::Square1x1),
},
}
}
pub(crate) fn plan_quality(quality: Option<RouterQuality>) -> Option<GenerateMidjourneyV8Quality> {
match quality {
None => None,
Some(RouterQuality::Low) => Some(GenerateMidjourneyV8Quality::Quarter),
Some(RouterQuality::Medium) => Some(GenerateMidjourneyV8Quality::Half),
Some(RouterQuality::High) => Some(GenerateMidjourneyV8Quality::Full),
}
}
pub(crate) fn plan_batch_count(
image_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<KinoviMidjourneyBatchCount, ArtcraftRouterError> {
let count = image_batch_count.unwrap_or(1);
match count {
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 => Ok(KinoviMidjourneyBatchCount::One),
2 => Ok(KinoviMidjourneyBatchCount::Two),
4 => Ok(KinoviMidjourneyBatchCount::Four),
other => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "image_batch_count",
value: format!("{}", other),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => {
Ok(if other < 3 { KinoviMidjourneyBatchCount::Two } else { KinoviMidjourneyBatchCount::Four })
}
RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(if other < 4 { KinoviMidjourneyBatchCount::Two } else { KinoviMidjourneyBatchCount::Four })
}
},
}
}
#[cfg(test)]
mod tests {
use seedance2pro_client::generate::image::generate_midjourney_v8::KinoviMidjourneyBatchCount;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::image_list_ref::ImageListRef;
use crate::api::router_image_model::RouterImageModel;
use crate::api::router_provider::RouterProvider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder;
use crate::generate::generate_image::image_generation_draft::ImageGenerationDraftRequest;
use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest;
use crate::generate::generate_image::image_generation_request::ImageGenerationRequest;
use super::*;
fn base_builder() -> GenerateImageRequestBuilder {
GenerateImageRequestBuilder {
model: RouterImageModel::Midjourney8,
provider: RouterProvider::Seedance2Pro,
prompt: Some("a spooky horror scene".to_string()),
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: None,
horizontal_angle: None,
vertical_angle: None,
zoom: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
}
}
#[test]
fn no_image_inputs_returns_request_directly() {
let result = build_kinovi_midjourney_8(base_builder()).expect("build");
assert!(matches!(
result,
ImageGenerationDraftOrRequest::Request(ImageGenerationRequest::KinoviMidjourney8(_)),
));
}
#[test]
fn image_inputs_returns_draft() {
let builder = GenerateImageRequestBuilder {
image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/ref.png".to_string()])),
..base_builder()
};
let result = build_kinovi_midjourney_8(builder).expect("build");
assert!(matches!(
result,
ImageGenerationDraftOrRequest::Draft(ImageGenerationDraftRequest::KinoviMidjourney8(_)),
));
}
#[test]
fn batch_count_four_is_accepted() {
let builder = GenerateImageRequestBuilder { image_batch_count: Some(4), ..base_builder() };
let req = match build_kinovi_midjourney_8(builder).expect("build") {
ImageGenerationDraftOrRequest::Request(ImageGenerationRequest::KinoviMidjourney8(r)) => r,
_ => panic!("expected Request"),
};
assert_eq!(req.request.batch_count, KinoviMidjourneyBatchCount::Four);
}
#[test]
fn media_file_token_route_through_draft() {
let builder = GenerateImageRequestBuilder {
image_inputs: Some(ImageListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_x".to_string()),
])),
..base_builder()
};
let draft = match build_kinovi_midjourney_8(builder).expect("build") {
ImageGenerationDraftOrRequest::Draft(ImageGenerationDraftRequest::KinoviMidjourney8(d)) => d,
_ => panic!("expected Draft"),
};
assert!(draft.unhandled_request_state.is_some());
}
}
@@ -0,0 +1,80 @@
use seedance2pro_client::generate::image::generate_midjourney_v8::{
GenerateMidjourneyV8AspectRatio, GenerateMidjourneyV8Request, KinoviMidjourneyBatchCount,
};
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
use crate::generate::generate_image::providers::kinovi::midjourney_8::draft::KinoviMidjourney8DraftState;
use crate::generate::generate_image::providers::kinovi::midjourney_8::request::KinoviMidjourney8RequestState;
/// Midjourney v8 (via Kinovi) cost state. Pricing is flat per task,
/// identical to v7 / v7 niji.
pub struct KinoviMidjourney8CostState {
pub batch_count: KinoviMidjourneyBatchCount,
}
impl KinoviMidjourney8CostState {
pub fn from_request(request: &KinoviMidjourney8RequestState) -> Self {
Self { batch_count: request.request.batch_count }
}
pub fn from_draft(draft: &KinoviMidjourney8DraftState) -> Self {
Self { batch_count: draft.batch_count }
}
pub fn estimate_cost(&self) -> ImageGenerationCostEstimate {
let pricing_request = GenerateMidjourneyV8Request {
batch_count: self.batch_count,
prompt: String::new(),
aspect_ratio: GenerateMidjourneyV8AspectRatio::Square1x1,
negative_prompt: None,
stylize: None,
weird: None,
chaos: None,
quality: None,
raw_mode: false,
reference_image_urls: None,
};
let cost_in_credits = pricing_request.estimate_credits() as u64;
let cost_in_usd_cents = pricing_request.estimate_cost_in_usd_cents();
ImageGenerationCostEstimate {
cost_in_credits: Some(cost_in_credits),
cost_in_usd_cents: Some(cost_in_usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn batch_one_is_twelve_credits_and_six_cents() {
let cost = KinoviMidjourney8CostState { batch_count: KinoviMidjourneyBatchCount::One }
.estimate_cost();
assert_eq!(cost.cost_in_credits, Some(12));
assert_eq!(cost.cost_in_usd_cents, Some(6));
}
#[test]
fn batch_two_doubles() {
let cost = KinoviMidjourney8CostState { batch_count: KinoviMidjourneyBatchCount::Two }
.estimate_cost();
assert_eq!(cost.cost_in_credits, Some(24));
assert_eq!(cost.cost_in_usd_cents, Some(12));
}
#[test]
fn batch_four_quadruples() {
let cost = KinoviMidjourney8CostState { batch_count: KinoviMidjourneyBatchCount::Four }
.estimate_cost();
assert_eq!(cost.cost_in_credits, Some(48));
assert_eq!(cost.cost_in_usd_cents, Some(25));
}
}
@@ -0,0 +1,58 @@
use seedance2pro_client::generate::image::generate_midjourney_v8::{
GenerateMidjourneyV8AspectRatio, GenerateMidjourneyV8Quality,
GenerateMidjourneyV8Request, KinoviMidjourneyBatchCount,
};
use crate::api::image_list_ref::ImageListRef;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_image::image_generation_draft_context::ImageGenerationDraftContext;
use crate::generate::generate_image::providers::kinovi::midjourney_8::request::KinoviMidjourney8RequestState;
use crate::generate::generate_image::providers::kinovi::resolve::resolve_and_upload_image_list;
#[derive(Debug, Clone)]
pub struct KinoviMidjourney8DraftState {
pub prompt: String,
pub aspect_ratio: GenerateMidjourneyV8AspectRatio,
pub quality: Option<GenerateMidjourneyV8Quality>,
pub batch_count: KinoviMidjourneyBatchCount,
pub unhandled_request_state: Option<KinoviMidjourney8RemainingItems>,
}
#[derive(Debug, Clone)]
pub struct KinoviMidjourney8RemainingItems {
pub reference_images: Option<ImageListRef>,
}
impl KinoviMidjourney8DraftState {
pub async fn to_request(
&mut self,
draft_context: &ImageGenerationDraftContext<'_>,
) -> Result<KinoviMidjourney8RequestState, ArtcraftRouterError> {
let client = draft_context.get_seedance2pro_client_ref()?;
let session = &client.session;
let mut reference_image_urls = None;
if let Some(remaining) = self.unhandled_request_state.take() {
let map = draft_context.media_file_to_artcraft_url_map;
reference_image_urls = resolve_and_upload_image_list(
session, remaining.reference_images, map,
).await?;
}
let request = GenerateMidjourneyV8Request {
prompt: self.prompt.clone(),
aspect_ratio: self.aspect_ratio,
negative_prompt: None,
stylize: None,
weird: None,
chaos: None,
quality: self.quality,
raw_mode: false,
batch_count: self.batch_count,
reference_image_urls,
};
Ok(KinoviMidjourney8RequestState { request })
}
}
@@ -0,0 +1,4 @@
pub mod build;
pub mod cost;
pub mod draft;
pub mod request;
@@ -0,0 +1,140 @@
use seedance2pro_client::generate::image::generate_midjourney_v8::{
generate_midjourney_v8, GenerateMidjourneyV8Args, GenerateMidjourneyV8Request,
};
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_image::generate_image_response::{
GenerateImageResponse, Seedance2proImageResponsePayload,
};
#[derive(Debug, Clone)]
pub struct KinoviMidjourney8RequestState {
pub request: GenerateMidjourneyV8Request,
}
impl KinoviMidjourney8RequestState {
pub async fn send(
&self,
client: &RouterSeedance2ProClient,
) -> Result<GenerateImageResponse, ArtcraftRouterError> {
let args = GenerateMidjourneyV8Args {
session: &client.session,
host_override: None,
request: self.request.clone(),
};
let response = generate_midjourney_v8(args)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?;
Ok(GenerateImageResponse::Seedance2Pro(Seedance2proImageResponsePayload {
order_id: response.order_id,
task_id: response.task_id,
maybe_order_ids: response.order_ids,
maybe_task_ids: response.task_ids,
}))
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::image_list_ref::ImageListRef;
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_image_model::RouterImageModel;
use crate::api::router_provider::RouterProvider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::client::router_client::RouterClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder;
use crate::generate::generate_image::image_generation_draft::ImageGenerationDraftRequest;
use crate::generate::generate_image::image_generation_draft_context::ImageGenerationDraftContext;
use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest;
use crate::generate::generate_image::image_generation_request::ImageGenerationRequest;
use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL;
fn base_builder() -> GenerateImageRequestBuilder {
GenerateImageRequestBuilder {
model: RouterImageModel::Midjourney8,
provider: RouterProvider::Seedance2Pro,
prompt: Some("a spooky horror scene with ghosts in an abandoned mansion".to_string()),
image_inputs: None,
resolution: None,
aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine),
quality: None,
image_batch_count: Some(1),
horizontal_angle: None,
vertical_angle: None,
zoom: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
}
}
fn get_kinovi_client() -> RouterClient {
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/seedance2pro_cookies.txt")
.expect("Failed to read seedance2pro cookies");
let session = Seedance2ProSession::from_cookies_string(cookies.trim().to_string());
RouterClient::Seedance2Pro(RouterSeedance2ProClient::new(session))
}
#[tokio::test]
#[ignore] // manually test — requires real cookies; costs credits
async fn text_to_image_round_trip() {
let client = get_kinovi_client();
let result = base_builder().build2().expect("build2");
let request = match result {
ImageGenerationDraftOrRequest::Request(ImageGenerationRequest::KinoviMidjourney8(r)) => r,
_ => panic!("expected direct Request for text-to-image"),
};
let response = request.send(client.get_seedance2pro_client_ref().unwrap())
.await.expect("send should succeed");
let payload = response.get_seedance2pro_payload().expect("expected seedance2pro payload");
println!("v8 t2i — task_id={}, order_id={}", payload.task_id, payload.order_id);
assert!(!payload.task_id.is_empty());
}
#[tokio::test]
#[ignore] // manually test — requires real cookies; costs credits
async fn image_reference_round_trip_via_draft() {
let client = get_kinovi_client();
let token = MediaFileToken::new("mf_test_juno_v8".to_string());
let mut map = HashMap::new();
map.insert(token.clone(), JUNO_AT_LAKE_IMAGE_URL.to_string());
let builder = GenerateImageRequestBuilder {
prompt: Some("a dramatic painterly portrait inspired by @1".to_string()),
image_inputs: Some(ImageListRef::MediaFileTokens(vec![token])),
..base_builder()
};
let draft_or_request = builder.build2().expect("build2");
let draft = match draft_or_request {
ImageGenerationDraftOrRequest::Draft(ImageGenerationDraftRequest::KinoviMidjourney8(d)) => d,
_ => panic!("expected Draft for image-input flow"),
};
let ctx = ImageGenerationDraftContext {
client: Some(&client),
media_file_to_artcraft_url_map: Some(&map),
};
let request = ImageGenerationDraftRequest::KinoviMidjourney8(draft)
.finalize(ctx).await.expect("finalize");
let response = request.send_request(&client).await.expect("send_request");
let payload = response.get_seedance2pro_payload().expect("expected seedance2pro payload");
println!("v8 with ref — task_id={}, order_id={}", payload.task_id, payload.order_id);
assert!(!payload.task_id.is_empty());
}
}
@@ -0,0 +1,6 @@
pub mod midjourney_7;
pub mod midjourney_7_niji;
pub mod midjourney_8;
pub(crate) mod resolve;
pub(crate) mod upload;
@@ -0,0 +1,51 @@
use std::collections::HashMap;
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::image_list_ref::ImageListRef;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_image::providers::kinovi::upload::upload_to_seedance2pro;
/// Resolve an [`ImageListRef`] to a list of public Seedance2Pro CDN URLs,
/// uploading each image one at a time. Preserves order.
///
/// Returns `Ok(None)` when the input is `None` or an empty list — the
/// caller can then omit the field from the wire request entirely. (The
/// downstream Midjourney API rejects empty arrays.)
pub(crate) async fn resolve_and_upload_image_list(
session: &Seedance2ProSession,
list: Option<ImageListRef>,
maybe_map: Option<&HashMap<MediaFileToken, String>>,
) -> Result<Option<Vec<String>>, ArtcraftRouterError> {
let source_urls = match list {
None => return Ok(None),
Some(ImageListRef::Urls(urls)) if urls.is_empty() => return Ok(None),
Some(ImageListRef::Urls(urls)) => urls,
Some(ImageListRef::MediaFileTokens(tokens)) if tokens.is_empty() => return Ok(None),
Some(ImageListRef::MediaFileTokens(tokens)) => resolve_tokens(maybe_map, &tokens)?,
};
let mut uploaded = Vec::with_capacity(source_urls.len());
for url in &source_urls {
uploaded.push(upload_to_seedance2pro(session, url).await?);
}
Ok(Some(uploaded))
}
fn resolve_tokens(
maybe_map: Option<&HashMap<MediaFileToken, String>>,
tokens: &[MediaFileToken],
) -> Result<Vec<String>, ArtcraftRouterError> {
let map = maybe_map.ok_or(ArtcraftRouterError::Client(ClientError::MediaFileToUrlMapNotProvided))?;
tokens.iter()
.map(|token| {
map.get(token).cloned().ok_or_else(|| {
ArtcraftRouterError::Client(ClientError::MediaFileTokenNotFoundInMap {
token: token.clone(),
})
})
})
.collect()
}
@@ -0,0 +1,44 @@
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use seedance2pro_client::requests::prepare_file_upload::prepare_file_upload::{prepare_file_upload, PrepareFileUploadArgs};
use seedance2pro_client::requests::upload_file::upload_file::{upload_file, UploadFileArgs};
use url_utils::extension::extract_extension_from_url::{extract_extension_from_url_str, ExtractExtensions};
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::utils::download_file::download_file;
/// Downloads an image from a source URL and re-uploads it to the
/// Seedance2Pro/Kinovi CDN. Returns the public URL.
///
/// This is the image-side twin of `generate_video::providers::kinovi::upload`.
/// The actual upload path doesn't care whether the file is a video or an
/// image, but keeping the two helpers separate lets each side evolve
/// independently and makes intent clear at call sites.
pub(crate) async fn upload_to_seedance2pro(
session: &Seedance2ProSession,
source_url: &str,
) -> Result<String, ArtcraftRouterError> {
let extension = extract_extension_from_url_str(source_url, &ExtractExtensions::All)
.map(|ext| ext.without_period().to_string())
.unwrap_or_else(|| "jpg".to_string());
let file_bytes = download_file(source_url).await?;
let prepare_response = prepare_file_upload(PrepareFileUploadArgs {
session,
extension,
host_override: None,
})
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?;
let upload_response = upload_file(UploadFileArgs {
upload_url: prepare_response.upload_url,
file_bytes,
host_override: None,
})
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?;
Ok(upload_response.public_url)
}
@@ -1,2 +1,3 @@
pub mod artcraft;
pub mod fal;
pub mod kinovi;
@@ -1,10 +1,11 @@
use log::info;
use serde_derive::Serialize;
use serde_json::Value as JsonValue;
use crate::api::requests::videos::video_status::request_types::*;
use crate::api::requests::xai_host::XAI_API_BASE_URL;
use crate::creds::grok_api_key::GrokApiKey;
use crate::error::classify_grok_http_error::classify_grok_http_error;
use crate::error::classify_grok_http_error::{body_indicates_moderation, classify_grok_http_error};
use crate::error::grok_client_error::GrokClientError;
use crate::error::grok_error::GrokError;
use crate::error::grok_generic_api_error::GrokGenericApiError;
@@ -31,31 +32,46 @@ pub struct VideoStatusRequest {
// ── Public response ──
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum VideoStatusState {
/// Still working.
Pending,
/// Finished successfully — `video.url` is populated.
Done,
/// xAI couldn't render this — see `VideoJobFailed` in the error path.
Failed,
#[derive(Debug, Clone)]
pub struct VideoStatusResponse {
pub status: VideoStatus,
}
/// The lifecycle state of a video job. `Failed` is a *status*, not an error —
/// xAI accepted the job and (usually) billed for it, but didn't produce a
/// usable video.
#[derive(Debug, Clone)]
pub struct VideoStatusSuccess {
pub state: VideoStatusState,
pub enum VideoStatus {
/// Still working. `progress` is 0–100 when xAI provides it.
Pending {
progress: Option<u8>,
},
/// Finished successfully — `video.url` is typically populated.
Complete {
model: Option<String>,
video: Option<VideoOutputInfo>,
cost_in_usd_ticks: Option<u64>,
},
/// xAI couldn't render this. `reason` is our best classification; the
/// remaining fields surface whatever xAI told us so the caller can log,
/// mark the row failed, and (where applicable) refund / surface a
/// user-facing message.
Failed {
reason: FailureReason,
code: Option<String>,
error: Option<String>,
cost_in_usd_ticks: Option<u64>,
full_error_json_payload: Option<String>,
},
}
/// 0–100, may be `None` while still queued.
pub progress: Option<u8>,
/// The model that fulfilled the request.
pub model: Option<String>,
/// Populated when `state == Done`.
pub video: Option<VideoOutputInfo>,
/// xAI cost reporting (in micro-dollars / "USD ticks").
pub cost_in_usd_ticks: Option<u64>,
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FailureReason {
/// xAI's safety filter rejected the prompt or the generated video.
ContentModerated,
/// Anything else: invalid argument we can't recover from, internal error,
/// expired job, etc.
Unknown,
}
#[derive(Debug, Clone)]
@@ -72,15 +88,13 @@ pub struct VideoOutputInfo {
/// GET https://api.x.ai/v1/videos/{request_id} — poll a video generation /
/// edit / extension job for its current status.
///
/// Returns:
/// - `Ok(VideoStatusSuccess { state: Pending, .. })` while the job is in flight
/// - `Ok(VideoStatusSuccess { state: Done, video: Some(..), .. })` when finished
/// - `Err(GrokSpecificApiError::VideoJobFailed { code, message })` when xAI
/// reports the job as failed
/// - `Err(GrokSpecificApiError::VideoJobExpired)` when the job expired
/// Returns `Ok(VideoStatusResponse)` for any well-formed xAI response,
/// including jobs that failed or were moderated — those come back as
/// `VideoStatus::Failed`. Only infrastructure problems (auth, network, 404
/// for unknown request_id, 5xx) bubble up as `Err(GrokError)`.
///
/// Docs: <https://docs.x.ai/developers/model-capabilities/video/generation>
pub async fn video_status(args: VideoStatusArgs<'_>) -> Result<VideoStatusSuccess, GrokError> {
pub async fn video_status(args: VideoStatusArgs<'_>) -> Result<VideoStatusResponse, GrokError> {
let req = args.request;
let url = format!("{}/v1/videos/{}", XAI_API_BASE_URL, req.request_id);
@@ -98,55 +112,155 @@ pub async fn video_status(args: VideoStatusArgs<'_>) -> Result<VideoStatusSucces
.await
.map_err(GrokGenericApiError::ReqwestError)?;
let status = response.status();
let http_status = response.status();
let response_body = response.text()
.await
.map_err(GrokGenericApiError::ReqwestError)?;
info!("Grok video_status response: http_status={}", status);
info!("Grok video_status response: http_status={}", http_status);
classify_grok_http_error(status, Some(&response_body))?;
if http_status.is_success() {
let parsed: VideoStatusResponseBody = serde_json::from_str(&response_body)
.map_err(|err| GrokGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.clone()))?;
return classify_status_field(&parsed, &response_body);
}
let parsed: VideoStatusResponseBody = serde_json::from_str(&response_body)
.map_err(|err| GrokGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.clone()))?;
// Non-2xx. xAI returns 4xx for some failure modes that are really "the job
// failed" rather than "something is wrong with your request" — most
// notably content moderation. Recognize those and return them as a Failed
// status so callers don't have to thread an error path.
if body_indicates_moderation(&response_body) {
return Ok(build_moderated_failed_response(&response_body));
}
classify_status_field(&parsed)
// Anything else (401, 402, 404, 429, plain 400/403, 5xx) is a real error.
Err(unwrap_classify_err(classify_grok_http_error(http_status, Some(&response_body))))
}
fn classify_status_field(parsed: &VideoStatusResponseBody) -> Result<VideoStatusSuccess, GrokError> {
let state = match parsed.status.as_str() {
"pending" | "queued" | "in_progress" | "processing" => VideoStatusState::Pending,
"done" | "completed" | "succeeded" => VideoStatusState::Done,
fn classify_status_field(
parsed: &VideoStatusResponseBody,
raw_body: &str,
) -> Result<VideoStatusResponse, GrokError> {
let cost = parsed.usage.as_ref().and_then(|u| u.cost_in_usd_ticks);
match parsed.status.as_str() {
"pending" | "queued" | "in_progress" | "processing" => Ok(VideoStatusResponse {
status: VideoStatus::Pending { progress: parsed.progress },
}),
"done" | "completed" | "succeeded" => {
let video = parsed.video.as_ref().map(|v| VideoOutputInfo {
url: v.url.clone(),
duration: v.duration,
respect_moderation: v.respect_moderation,
});
Ok(VideoStatusResponse {
status: VideoStatus::Complete {
model: parsed.model.clone(),
video,
cost_in_usd_ticks: cost,
},
})
}
"failed" => {
let (code, message) = parsed.error.as_ref()
.map(|e| (e.code.clone(), e.message.clone()))
.unwrap_or_else(|| ("unknown".to_string(), "video job failed without details".to_string()));
return Err(crate::error::grok_specific_api_error::GrokSpecificApiError::VideoJobFailed { code, message }.into());
}
"expired" => {
return Err(crate::error::grok_specific_api_error::GrokSpecificApiError::VideoJobExpired.into());
}
other => {
return Err(GrokGenericApiError::UncategorizedBadResponseWithStatusAndBody {
status_code: reqwest::StatusCode::OK,
body: format!("unknown video status: {:?}", other),
}.into());
let code = parsed.error.as_ref().map(|e| e.code.clone());
let error_msg = parsed.error.as_ref().map(|e| e.message.clone());
let reason = if text_indicates_moderation(error_msg.as_deref())
|| body_indicates_moderation(raw_body)
{
FailureReason::ContentModerated
} else {
FailureReason::Unknown
};
Ok(VideoStatusResponse {
status: VideoStatus::Failed {
reason,
code,
error: error_msg,
cost_in_usd_ticks: cost,
full_error_json_payload: Some(raw_body.to_string()),
},
})
}
"expired" => Ok(VideoStatusResponse {
status: VideoStatus::Failed {
reason: FailureReason::Unknown,
code: Some("expired".to_string()),
error: Some("video job expired".to_string()),
cost_in_usd_ticks: cost,
full_error_json_payload: Some(raw_body.to_string()),
},
}),
other => Err(GrokGenericApiError::UncategorizedBadResponseWithStatusAndBody {
status_code: reqwest::StatusCode::OK,
body: format!("unknown video status: {:?}", other),
}.into()),
}
}
fn build_moderated_failed_response(raw_body: &str) -> VideoStatusResponse {
let (code, error_msg, cost) = parse_error_body(raw_body);
VideoStatusResponse {
status: VideoStatus::Failed {
reason: FailureReason::ContentModerated,
code,
error: error_msg,
cost_in_usd_ticks: cost,
full_error_json_payload: Some(raw_body.to_string()),
},
}
}
/// Best-effort extraction of `{code, error, usage.cost_in_usd_ticks}` from
/// the two error-body shapes xAI uses:
///
/// 1. Status-poll 4xx:
/// `{"code":"...", "error":"<string>", "usage":{"cost_in_usd_ticks":...}}`
/// 2. OpenAI-compatible:
/// `{"error":{"code":"...", "message":"..."}}`
fn parse_error_body(body: &str) -> (Option<String>, Option<String>, Option<u64>) {
let parsed: JsonValue = match serde_json::from_str(body) {
Ok(v) => v,
Err(_) => return (None, None, None),
};
let video = parsed.video.as_ref().map(|v| VideoOutputInfo {
url: v.url.clone(),
duration: v.duration,
respect_moderation: v.respect_moderation,
});
let top_level_code = parsed.get("code")
.and_then(JsonValue::as_str)
.map(str::to_string);
Ok(VideoStatusSuccess {
state,
progress: parsed.progress,
model: parsed.model.clone(),
video,
cost_in_usd_ticks: parsed.usage.as_ref().and_then(|u| u.cost_in_usd_ticks),
})
let (error_msg, nested_code) = match parsed.get("error") {
Some(JsonValue::String(s)) => (Some(s.clone()), None),
Some(obj @ JsonValue::Object(_)) => {
let msg = obj.get("message").and_then(JsonValue::as_str).map(str::to_string);
let nested = obj.get("code").and_then(JsonValue::as_str).map(str::to_string);
(msg, nested)
}
_ => (None, None),
};
let code = top_level_code.or(nested_code);
let cost = parsed.get("usage")
.and_then(|u| u.get("cost_in_usd_ticks"))
.and_then(JsonValue::as_u64);
(code, error_msg, cost)
}
fn text_indicates_moderation(maybe_text: Option<&str>) -> bool {
maybe_text.map(body_indicates_moderation).unwrap_or(false)
}
/// `classify_grok_http_error` always returns `Err` when the status is non-2xx,
/// so this is infallible in our call path. Spelled out for clarity at the call
/// site rather than `.unwrap_err()` on the inline expression.
fn unwrap_classify_err(result: Result<(), GrokError>) -> GrokError {
match result {
Ok(()) => GrokGenericApiError::UncategorizedBadResponseWithStatusAndBody {
status_code: reqwest::StatusCode::OK,
body: "classify_grok_http_error returned Ok for a non-2xx response".to_string(),
}.into(),
Err(err) => err,
}
}
#[cfg(test)]
@@ -156,36 +270,30 @@ mod tests {
use crate::error::grok_specific_api_error::GrokSpecificApiError;
// ── Shape tests ──
// ── Status-field dispatch (HTTP 200) ──
#[test]
fn pending_response_classifies_as_pending() {
let json = r#"{ "status": "pending", "progress": 12, "model": "grok-imagine-video" }"#;
let parsed: VideoStatusResponseBody = serde_json::from_str(json).unwrap();
let result = classify_status_field(&parsed).unwrap();
assert_eq!(result.state, VideoStatusState::Pending);
assert_eq!(result.progress, Some(12));
assert_eq!(result.model.as_deref(), Some("grok-imagine-video"));
assert!(result.video.is_none());
let result = classify_status_field(&parsed, json).unwrap();
match result.status {
VideoStatus::Pending { progress } => assert_eq!(progress, Some(12)),
other => panic!("expected Pending, got: {:?}", other),
}
}
#[test]
fn pending_response_with_v1p5_model_passes_model_string_through() {
// The `model` field is opaque Option<String>; the v1.5 preview model
// identifier (and its dated alias) flow through unchanged.
fn pending_response_with_v1p5_model_does_not_break_dispatch() {
// `model` doesn't surface on Pending; it flows through on Complete only.
let json = r#"{ "status": "pending", "progress": 5, "model": "grok-imagine-video-1.5-preview" }"#;
let parsed: VideoStatusResponseBody = serde_json::from_str(json).unwrap();
let result = classify_status_field(&parsed).unwrap();
assert_eq!(result.model.as_deref(), Some("grok-imagine-video-1.5-preview"));
let json_alias = r#"{ "status": "pending", "progress": 5, "model": "grok-imagine-video-1.5-2026-05-30" }"#;
let parsed_alias: VideoStatusResponseBody = serde_json::from_str(json_alias).unwrap();
let result_alias = classify_status_field(&parsed_alias).unwrap();
assert_eq!(result_alias.model.as_deref(), Some("grok-imagine-video-1.5-2026-05-30"));
let result = classify_status_field(&parsed, json).unwrap();
assert!(matches!(result.status, VideoStatus::Pending { .. }));
}
#[test]
fn done_response_classifies_as_done_with_video() {
fn done_response_classifies_as_complete_with_video() {
let json = r#"{
"status": "done",
"progress": 100,
@@ -194,62 +302,150 @@ mod tests {
"usage": { "cost_in_usd_ticks": 42 }
}"#;
let parsed: VideoStatusResponseBody = serde_json::from_str(json).unwrap();
let result = classify_status_field(&parsed).unwrap();
assert_eq!(result.state, VideoStatusState::Done);
let video = result.video.unwrap();
assert_eq!(video.url.as_deref(), Some("https://imagine.x.ai/v.mp4"));
assert_eq!(video.duration, Some(5));
assert_eq!(video.respect_moderation, Some(true));
assert_eq!(result.cost_in_usd_ticks, Some(42));
let result = classify_status_field(&parsed, json).unwrap();
match result.status {
VideoStatus::Complete { model, video, cost_in_usd_ticks } => {
assert_eq!(model.as_deref(), Some("grok-imagine-video"));
let video = video.unwrap();
assert_eq!(video.url.as_deref(), Some("https://imagine.x.ai/v.mp4"));
assert_eq!(video.duration, Some(5));
assert_eq!(video.respect_moderation, Some(true));
assert_eq!(cost_in_usd_ticks, Some(42));
}
other => panic!("expected Complete, got: {:?}", other),
}
}
#[test]
fn failed_response_returns_video_job_failed_error() {
fn failed_response_returns_ok_failed_unknown() {
let json = r#"{
"status": "failed",
"progress": 50,
"error": { "code": "invalid_argument", "message": "duration too long" }
"error": { "code": "invalid_argument", "message": "duration too long" },
"usage": { "cost_in_usd_ticks": 7 }
}"#;
let parsed: VideoStatusResponseBody = serde_json::from_str(json).unwrap();
let err = classify_status_field(&parsed).unwrap_err();
match err {
GrokError::ApiSpecific(GrokSpecificApiError::VideoJobFailed { code, message }) => {
assert_eq!(code, "invalid_argument");
assert_eq!(message, "duration too long");
let result = classify_status_field(&parsed, json).unwrap();
match result.status {
VideoStatus::Failed { reason, code, error, cost_in_usd_ticks, full_error_json_payload } => {
assert_eq!(reason, FailureReason::Unknown);
assert_eq!(code.as_deref(), Some("invalid_argument"));
assert_eq!(error.as_deref(), Some("duration too long"));
assert_eq!(cost_in_usd_ticks, Some(7));
assert_eq!(full_error_json_payload.as_deref(), Some(json));
}
other => panic!("expected VideoJobFailed, got: {:?}", other),
other => panic!("expected Failed, got: {:?}", other),
}
}
#[test]
fn failed_response_without_error_object_uses_unknown() {
fn failed_response_with_moderation_message_is_classified_as_content_moderated() {
let json = r#"{
"status": "failed",
"error": { "code": "permission_denied", "message": "Video rejected by safety moderation" },
"usage": { "cost_in_usd_ticks": 11 }
}"#;
let parsed: VideoStatusResponseBody = serde_json::from_str(json).unwrap();
let result = classify_status_field(&parsed, json).unwrap();
match result.status {
VideoStatus::Failed { reason, code, cost_in_usd_ticks, .. } => {
assert_eq!(reason, FailureReason::ContentModerated);
assert_eq!(code.as_deref(), Some("permission_denied"));
assert_eq!(cost_in_usd_ticks, Some(11));
}
other => panic!("expected Failed, got: {:?}", other),
}
}
#[test]
fn failed_response_without_error_object_yields_none_code_and_error() {
let json = r#"{ "status": "failed", "progress": 0 }"#;
let parsed: VideoStatusResponseBody = serde_json::from_str(json).unwrap();
let err = classify_status_field(&parsed).unwrap_err();
match err {
GrokError::ApiSpecific(GrokSpecificApiError::VideoJobFailed { code, .. }) => {
assert_eq!(code, "unknown");
let result = classify_status_field(&parsed, json).unwrap();
match result.status {
VideoStatus::Failed { reason, code, error, .. } => {
assert_eq!(reason, FailureReason::Unknown);
assert!(code.is_none());
assert!(error.is_none());
}
other => panic!("expected VideoJobFailed(unknown), got: {:?}", other),
other => panic!("expected Failed, got: {:?}", other),
}
}
#[test]
fn expired_response_returns_expired_error() {
let json = r#"{ "status": "expired" }"#;
fn expired_response_classifies_as_failed_unknown() {
let json = r#"{ "status": "expired", "usage": { "cost_in_usd_ticks": 3 } }"#;
let parsed: VideoStatusResponseBody = serde_json::from_str(json).unwrap();
let err = classify_status_field(&parsed).unwrap_err();
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::VideoJobExpired)));
let result = classify_status_field(&parsed, json).unwrap();
match result.status {
VideoStatus::Failed { reason, code, cost_in_usd_ticks, .. } => {
assert_eq!(reason, FailureReason::Unknown);
assert_eq!(code.as_deref(), Some("expired"));
assert_eq!(cost_in_usd_ticks, Some(3));
}
other => panic!("expected Failed, got: {:?}", other),
}
}
#[test]
fn unknown_status_field_returns_generic_error() {
let json = r#"{ "status": "magnificent" }"#;
let parsed: VideoStatusResponseBody = serde_json::from_str(json).unwrap();
let err = classify_status_field(&parsed).unwrap_err();
let err = classify_status_field(&parsed, json).unwrap_err();
assert!(matches!(err, GrokError::ApiGeneric(_)));
}
// ── Error-body dispatch (HTTP 4xx) ──
#[test]
fn moderation_4xx_body_becomes_failed_content_moderated() {
// The real-world body that previously surfaced as
// `Err(GrokSpecificApiError::PromptModerated(..))`. It should now be
// an Ok(Failed { ContentModerated }) with all fields populated from
// the JSON.
let body = r#"{"code":"Client specified an invalid argument","error":"Generated video rejected by content moderation.","usage":{"cost_in_usd_ticks":11300000000}}"#;
let response = build_moderated_failed_response(body);
match response.status {
VideoStatus::Failed { reason, code, error, cost_in_usd_ticks, full_error_json_payload } => {
assert_eq!(reason, FailureReason::ContentModerated);
assert_eq!(code.as_deref(), Some("Client specified an invalid argument"));
assert_eq!(error.as_deref(), Some("Generated video rejected by content moderation."));
assert_eq!(cost_in_usd_ticks, Some(11_300_000_000));
assert_eq!(full_error_json_payload.as_deref(), Some(body));
}
other => panic!("expected Failed, got: {:?}", other),
}
}
#[test]
fn moderation_openai_shape_body_extracts_nested_code_and_message() {
let body = r#"{"error":{"code":"content_policy","message":"Content moderation blocked this prompt"}}"#;
let response = build_moderated_failed_response(body);
match response.status {
VideoStatus::Failed { reason, code, error, .. } => {
assert_eq!(reason, FailureReason::ContentModerated);
assert_eq!(code.as_deref(), Some("content_policy"));
assert_eq!(error.as_deref(), Some("Content moderation blocked this prompt"));
}
other => panic!("expected Failed, got: {:?}", other),
}
}
#[test]
fn moderation_unparseable_body_still_yields_failed_with_payload() {
let body = "totally not json but mentions moderation";
let response = build_moderated_failed_response(body);
match response.status {
VideoStatus::Failed { reason, code, error, full_error_json_payload, .. } => {
assert_eq!(reason, FailureReason::ContentModerated);
assert!(code.is_none());
assert!(error.is_none());
assert_eq!(full_error_json_payload.as_deref(), Some(body));
}
other => panic!("expected Failed, got: {:?}", other),
}
}
// ── Public Request shape ──
#[test]
@@ -284,7 +480,7 @@ mod tests {
},
}).await;
println!("Result: {:?}", result.as_ref().map(|s| (&s.state, s.progress)));
println!("Result: {:?}", result.as_ref().map(|r| &r.status));
let err = result.unwrap_err();
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::NotFound)),
"expected NotFound, got: {:?}", err);
@@ -292,8 +488,8 @@ mod tests {
}
/// Polls a known request_id and prints the result. Doesn't assert a
/// specific state — depending on when the test runs, the job may be
/// pending, done, failed, or expired. The point is to exercise the
/// specific status — depending on when the test runs, the job may be
/// pending, complete, or failed. The point is to exercise the
/// happy-path against the real xAI API.
#[tokio::test]
#[ignore] // manually test — requires real API key
@@ -306,26 +502,19 @@ mod tests {
let result = video_status(VideoStatusArgs {
api_key: &api_key,
request: VideoStatusRequest {
//request_id: "e397ac83-c22f-90b1-9831-900c01497345".to_string(),
request_id: "ce681bd0-133d-9cf3-975c-422d292d4e8e".to_string(),
},
}).await;
match &result {
Ok(s) => println!(
"status: state={:?} progress={:?} model={:?} video={:?} cost_in_usd_ticks={:?}",
s.state, s.progress, s.model, s.video, s.cost_in_usd_ticks,
),
Ok(r) => println!("status: {:?}", r.status),
Err(e) => println!("status error: {:?}", e),
}
// No assertion on the specific state — the job may have completed,
// failed, or expired by the time this runs. We just want to confirm
// the request_id was accepted and parsed.
Ok(())
}
/// Polls another known request_id. Same lax assertions as the test above
/// — state varies with time.
/// — status varies with time.
#[tokio::test]
#[ignore] // manually test — requires real API key
async fn live_test_video_status_known_id_2() -> AnyhowResult<()> {
@@ -342,10 +531,7 @@ mod tests {
}).await;
match &result {
Ok(s) => println!(
"status: state={:?} progress={:?} model={:?} video={:?} cost_in_usd_ticks={:?}",
s.state, s.progress, s.model, s.video, s.cost_in_usd_ticks,
),
Ok(r) => println!("status: {:?}", r.status),
Err(e) => println!("status error: {:?}", e),
}
Ok(())
@@ -363,17 +549,12 @@ mod tests {
let result = video_status(VideoStatusArgs {
api_key: &api_key,
request: VideoStatusRequest {
//request_id: "ff94f941-62a7-9966-92da-6b84d9eedb50".to_string(),
//request_id: "4eea215a-9d21-9261-be1a-324409ec22c5".to_string(),
request_id: "4d4a6185-209e-95f3-942b-510042637839".to_string(),
},
}).await;
match &result {
Ok(s) => println!(
"status: state={:?} progress={:?} model={:?} video={:?} cost_in_usd_ticks={:?}",
s.state, s.progress, s.model, s.video, s.cost_in_usd_ticks,
),
Ok(r) => println!("status: {:?}", r.status),
Err(e) => println!("status error: {:?}", e),
}
Ok(())
@@ -68,7 +68,7 @@ fn extract_xai_error_message(body: &str) -> Option<String> {
/// Heuristic: does this response body mention content moderation? xAI's
/// safety filter rejections include words like "moderation", "safety",
/// "content_policy", or "blocked".
fn body_indicates_moderation(body: &str) -> bool {
pub(crate) fn body_indicates_moderation(body: &str) -> bool {
let lower = body.to_lowercase();
lower.contains("moderat") // moderation / moderated
|| lower.contains("safety")
@@ -29,19 +29,6 @@ pub enum GrokSpecificApiError {
/// 400 Bad Request — the request body shape was invalid (e.g. missing
/// required field, unsupported model, invalid aspect ratio).
BadRequest(String),
/// The video generation/edit/extension job finished with `status:"failed"`.
///
/// The `code` mirrors xAI's machine-readable error code
/// (`invalid_argument`, `permission_denied`, `failed_precondition`,
/// `internal_error`); `message` is the human-readable description.
VideoJobFailed {
code: String,
message: String,
},
/// The video job expired (poll status was `"expired"`).
VideoJobExpired,
}
impl Error for GrokSpecificApiError {}
@@ -56,10 +43,6 @@ impl Display for GrokSpecificApiError {
Self::RateLimited => write!(f, "Grok API: Rate limited"),
Self::PromptModerated(msg) => write!(f, "Grok API: Prompt moderated: {}", msg),
Self::BadRequest(msg) => write!(f, "Grok API: Bad request: {}", msg),
Self::VideoJobFailed { code, message } => {
write!(f, "Grok API: Video job failed ({}): {}", code, message)
}
Self::VideoJobExpired => write!(f, "Grok API: Video job expired"),
}
}
}
@@ -11,6 +11,10 @@ pub enum Seedance2ProClientError {
/// An error was encountered in building the Wreq client.
WreqClientError(wreq::Error),
/// A request field couldn't be parsed into the value expected on the wire.
/// Carries the field name and the raw input the caller supplied.
InvalidRequestField { field: &'static str, raw_value: String, reason: String },
}
impl Error for Seedance2ProClientError {}
@@ -21,6 +25,9 @@ impl Display for Seedance2ProClientError {
Self::NoCookiesPresent => write!(f, "No cookies present in the session."),
Self::UrlParseError(err) => write!(f, "URL parse error: {}", err),
Self::WreqClientError(err) => write!(f, "Wreq client error (during client creation): {}", err),
Self::InvalidRequestField { field, raw_value, reason } => {
write!(f, "Invalid value for request field `{}`: {:?} ({})", field, raw_value, reason)
}
}
}
}
@@ -0,0 +1,365 @@
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::error::seedance2pro_error::Seedance2ProError;
use crate::requests::generate_image::generate_image::{
generate_image, GenerateImageArgs, KinoviGenerateImageRequest, KinoviMidjourneyModel,
};
use crate::requests::kinovi_host::KinoviHost;
// `KinoviMidjourneyBatchCount` is shared across all Midjourney models, so we
// re-export it from the base. Aspect ratio and quality, however, are pinned
// to model-specific enums on this wrapper to keep their valid values typed
// at the call site.
pub use crate::requests::generate_image::generate_image::KinoviMidjourneyBatchCount;
// ── Args ──
pub struct GenerateMidjourneyV7Args<'a> {
pub request: GenerateMidjourneyV7Request,
pub session: &'a Seedance2ProSession,
pub host_override: Option<KinoviHost>,
}
// ── Request ──
#[derive(Clone, Debug)]
pub struct GenerateMidjourneyV7Request {
pub prompt: String,
pub aspect_ratio: GenerateMidjourneyV7AspectRatio,
pub negative_prompt: Option<String>,
pub stylize: Option<u16>,
pub weird: Option<u16>,
pub chaos: Option<u8>,
pub quality: Option<GenerateMidjourneyV7Quality>,
pub raw_mode: bool,
pub batch_count: KinoviMidjourneyBatchCount,
pub reference_image_urls: Option<Vec<String>>,
}
impl GenerateMidjourneyV7Request {
pub fn estimate_credits(&self) -> u32 {
self.to_inner_request().estimate_credits()
}
pub fn estimate_cost_in_usd_cents(&self) -> u64 {
self.to_inner_request().estimate_cost_in_usd_cents()
}
pub(crate) fn to_inner_request(&self) -> KinoviGenerateImageRequest {
KinoviGenerateImageRequest {
model: KinoviMidjourneyModel::V7,
prompt: self.prompt.clone(),
aspect_ratio: self.aspect_ratio.as_api_str().to_string(),
negative_prompt: self.negative_prompt.clone(),
stylize: self.stylize,
weird: self.weird,
chaos: self.chaos,
quality: self.quality.map(|q| q.as_api_str().to_string()),
raw_mode: self.raw_mode,
batch_count: self.batch_count,
reference_image_urls: self.reference_image_urls.clone(),
}
}
}
// ── Model-specific enums ──
/// Aspect ratios supported by Midjourney v7.
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum GenerateMidjourneyV7AspectRatio {
Square1x1,
Landscape16x9,
Portrait9x16,
UltraWide21x9,
UltraTall9x21,
Standard4x3,
Portrait3x4,
Wide5x4,
Tall4x5,
Wide3x2,
Tall2x3,
}
impl GenerateMidjourneyV7AspectRatio {
fn as_api_str(&self) -> &'static str {
match self {
Self::Square1x1 => "1:1",
Self::Landscape16x9 => "16:9",
Self::Portrait9x16 => "9:16",
Self::UltraWide21x9 => "21:9",
Self::UltraTall9x21 => "9:21",
Self::Standard4x3 => "4:3",
Self::Portrait3x4 => "3:4",
Self::Wide5x4 => "5:4",
Self::Tall4x5 => "4:5",
Self::Wide3x2 => "3:2",
Self::Tall2x3 => "2:3",
}
}
}
/// Quality presets supported by Midjourney v7. Higher = more compute and
/// slower; Kinovi credit pricing is flat regardless.
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum GenerateMidjourneyV7Quality {
/// 0.25
Quarter,
/// 0.5
Half,
/// 1 (default)
Full,
}
impl GenerateMidjourneyV7Quality {
fn as_api_str(&self) -> &'static str {
match self {
Self::Quarter => "0.25",
Self::Half => "0.5",
Self::Full => "1",
}
}
}
// ── Response ──
pub struct GenerateMidjourneyV7Response {
pub task_id: String,
pub order_id: String,
pub task_ids: Option<Vec<String>>,
pub order_ids: Option<Vec<String>>,
}
// ── Entry point ──
pub async fn generate_midjourney_v7(
args: GenerateMidjourneyV7Args<'_>,
) -> Result<GenerateMidjourneyV7Response, Seedance2ProError> {
let inner_request = args.request.to_inner_request();
let result = generate_image(GenerateImageArgs {
request: inner_request,
session: args.session,
host_override: args.host_override,
}).await?;
Ok(GenerateMidjourneyV7Response {
task_id: result.task_id,
order_id: result.order_id,
task_ids: result.task_ids,
order_ids: result.order_ids,
})
}
// ── Tests ──
#[cfg(test)]
mod tests {
use super::*;
use crate::test_utils::get_test_cookies::get_test_cookies;
use crate::test_utils::setup_test_logging::setup_test_logging;
use errors::AnyhowResult;
use log::LevelFilter;
fn test_session() -> AnyhowResult<Seedance2ProSession> {
let cookies = get_test_cookies()?;
Ok(Seedance2ProSession::from_cookies_string(cookies))
}
fn make_request(batch_count: KinoviMidjourneyBatchCount) -> GenerateMidjourneyV7Request {
GenerateMidjourneyV7Request {
prompt: "test".to_string(),
aspect_ratio: GenerateMidjourneyV7AspectRatio::Square1x1,
negative_prompt: None,
stylize: None,
weird: None,
chaos: None,
quality: None,
raw_mode: false,
batch_count,
reference_image_urls: None,
}
}
// ── Inner-request mapping ──
mod inner_request_tests {
use super::*;
#[test]
fn inner_request_pins_model_to_v7() {
let inner = make_request(KinoviMidjourneyBatchCount::One).to_inner_request();
assert_eq!(inner.model, KinoviMidjourneyModel::V7);
}
#[test]
fn inner_request_preserves_all_fields() {
let req = GenerateMidjourneyV7Request {
prompt: "a corgi in space".to_string(),
aspect_ratio: GenerateMidjourneyV7AspectRatio::Landscape16x9,
negative_prompt: Some("dark".to_string()),
stylize: Some(500),
weird: Some(1500),
chaos: Some(50),
quality: Some(GenerateMidjourneyV7Quality::Half),
raw_mode: true,
batch_count: KinoviMidjourneyBatchCount::Four,
reference_image_urls: Some(vec!["https://example.com/x.png".to_string()]),
};
let inner = req.to_inner_request();
assert_eq!(inner.model, KinoviMidjourneyModel::V7);
assert_eq!(inner.prompt, "a corgi in space");
assert_eq!(inner.aspect_ratio, "16:9");
assert_eq!(inner.negative_prompt.as_deref(), Some("dark"));
assert_eq!(inner.stylize, Some(500));
assert_eq!(inner.weird, Some(1500));
assert_eq!(inner.chaos, Some(50));
assert_eq!(inner.quality.as_deref(), Some("0.5"));
assert!(inner.raw_mode);
assert_eq!(inner.batch_count, KinoviMidjourneyBatchCount::Four);
assert_eq!(
inner.reference_image_urls.as_deref(),
Some(&["https://example.com/x.png".to_string()][..]),
);
}
/// Every wrapper-side aspect ratio must serialize to the exact wire
/// string Midjourney expects. Catches silent drift between the enum
/// surface and the API.
#[test]
fn every_aspect_ratio_maps_to_canonical_wire_string() {
let cases = [
(GenerateMidjourneyV7AspectRatio::Square1x1, "1:1"),
(GenerateMidjourneyV7AspectRatio::Landscape16x9, "16:9"),
(GenerateMidjourneyV7AspectRatio::Portrait9x16, "9:16"),
(GenerateMidjourneyV7AspectRatio::UltraWide21x9, "21:9"),
(GenerateMidjourneyV7AspectRatio::UltraTall9x21, "9:21"),
(GenerateMidjourneyV7AspectRatio::Standard4x3, "4:3"),
(GenerateMidjourneyV7AspectRatio::Portrait3x4, "3:4"),
(GenerateMidjourneyV7AspectRatio::Wide5x4, "5:4"),
(GenerateMidjourneyV7AspectRatio::Tall4x5, "4:5"),
(GenerateMidjourneyV7AspectRatio::Wide3x2, "3:2"),
(GenerateMidjourneyV7AspectRatio::Tall2x3, "2:3"),
];
for (variant, expected) in cases {
let req = GenerateMidjourneyV7Request { aspect_ratio: variant, ..make_request(KinoviMidjourneyBatchCount::One) };
assert_eq!(req.to_inner_request().aspect_ratio, expected, "variant={:?}", variant);
}
}
#[test]
fn every_quality_maps_to_canonical_wire_string() {
let cases = [
(GenerateMidjourneyV7Quality::Quarter, "0.25"),
(GenerateMidjourneyV7Quality::Half, "0.5"),
(GenerateMidjourneyV7Quality::Full, "1"),
];
for (variant, expected) in cases {
let req = GenerateMidjourneyV7Request { quality: Some(variant), ..make_request(KinoviMidjourneyBatchCount::One) };
assert_eq!(req.to_inner_request().quality.as_deref(), Some(expected), "variant={:?}", variant);
}
}
}
// ── Pricing (delegates to the inner module, but pinned via explicit values) ──
mod pricing_tests {
use super::*;
#[test]
fn batch_one_is_twelve_credits() {
assert_eq!(make_request(KinoviMidjourneyBatchCount::One).estimate_credits(), 12);
}
#[test]
fn batch_two_is_twentyfour_credits() {
assert_eq!(make_request(KinoviMidjourneyBatchCount::Two).estimate_credits(), 24);
}
#[test]
fn batch_four_is_fortyeight_credits() {
assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).estimate_credits(), 48);
}
#[test]
fn usd_cents_batch_one_is_six() {
assert_eq!(make_request(KinoviMidjourneyBatchCount::One).estimate_cost_in_usd_cents(), 6);
}
#[test]
fn usd_cents_batch_four_is_twentyfive() {
assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).estimate_cost_in_usd_cents(), 25);
}
/// Pricing must match the inner module byte-for-byte (sanity that no
/// wrapper drift introduces an extra rounding step).
#[test]
fn matches_inner_pricing_exactly() {
for batch in [
KinoviMidjourneyBatchCount::One,
KinoviMidjourneyBatchCount::Two,
KinoviMidjourneyBatchCount::Four,
] {
let outer = make_request(batch);
let inner = outer.to_inner_request();
assert_eq!(outer.estimate_credits(), inner.estimate_credits(), "batch={:?}", batch);
assert_eq!(outer.estimate_cost_in_usd_cents(), inner.estimate_cost_in_usd_cents(), "batch={:?}", batch);
}
}
}
// ── Real requests ──
mod real_requests {
use super::*;
#[tokio::test]
#[ignore] // manually test — requires real cookies, costs credits
async fn test_generate_v7_minimal() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let result = generate_midjourney_v7(GenerateMidjourneyV7Args {
session: &session,
host_override: None,
request: GenerateMidjourneyV7Request {
prompt: "A corgi astronaut floating among stars".to_string(),
aspect_ratio: GenerateMidjourneyV7AspectRatio::Square1x1,
negative_prompt: None,
stylize: None,
weird: None,
chaos: None,
quality: None,
raw_mode: false,
batch_count: KinoviMidjourneyBatchCount::One,
reference_image_urls: None,
},
}).await?;
println!("v7 minimal — task_id={}, order_id={}", result.task_id, result.order_id);
assert!(!result.task_id.is_empty());
assert!(!result.order_id.is_empty());
Ok(())
}
#[tokio::test]
#[ignore] // manually test — requires real cookies, costs credits
async fn test_generate_v7_all_knobs() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let result = generate_midjourney_v7(GenerateMidjourneyV7Args {
session: &session,
host_override: None,
request: GenerateMidjourneyV7Request {
prompt: "abandoned skyscrapers".to_string(),
aspect_ratio: GenerateMidjourneyV7AspectRatio::Square1x1,
negative_prompt: Some("dark, gloomy, night".to_string()),
stylize: Some(1000),
weird: Some(3000),
chaos: Some(100),
quality: Some(GenerateMidjourneyV7Quality::Half),
raw_mode: true,
batch_count: KinoviMidjourneyBatchCount::One,
reference_image_urls: None,
},
}).await?;
println!("v7 all knobs — task_id={}, order_id={}", result.task_id, result.order_id);
assert!(!result.task_id.is_empty());
Ok(())
}
}
}
@@ -0,0 +1,335 @@
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::error::seedance2pro_error::Seedance2ProError;
use crate::requests::generate_image::generate_image::{
generate_image, GenerateImageArgs, KinoviGenerateImageRequest, KinoviMidjourneyModel,
};
use crate::requests::kinovi_host::KinoviHost;
// `KinoviMidjourneyBatchCount` is shared across all Midjourney models, so we
// re-export it from the base. Aspect ratio and quality, however, are pinned
// to model-specific enums on this wrapper to keep their valid values typed
// at the call site.
pub use crate::requests::generate_image::generate_image::KinoviMidjourneyBatchCount;
// ── Args ──
pub struct GenerateMidjourneyV7NijiArgs<'a> {
pub request: GenerateMidjourneyV7NijiRequest,
pub session: &'a Seedance2ProSession,
pub host_override: Option<KinoviHost>,
}
// ── Request ──
#[derive(Clone, Debug)]
pub struct GenerateMidjourneyV7NijiRequest {
pub prompt: String,
pub aspect_ratio: GenerateMidjourneyV7NijiAspectRatio,
pub negative_prompt: Option<String>,
pub stylize: Option<u16>,
pub weird: Option<u16>,
pub chaos: Option<u8>,
pub quality: Option<GenerateMidjourneyV7NijiQuality>,
pub raw_mode: bool,
pub batch_count: KinoviMidjourneyBatchCount,
pub reference_image_urls: Option<Vec<String>>,
}
impl GenerateMidjourneyV7NijiRequest {
pub fn estimate_credits(&self) -> u32 {
self.to_inner_request().estimate_credits()
}
pub fn estimate_cost_in_usd_cents(&self) -> u64 {
self.to_inner_request().estimate_cost_in_usd_cents()
}
pub(crate) fn to_inner_request(&self) -> KinoviGenerateImageRequest {
KinoviGenerateImageRequest {
model: KinoviMidjourneyModel::V7Niji,
prompt: self.prompt.clone(),
aspect_ratio: self.aspect_ratio.as_api_str().to_string(),
negative_prompt: self.negative_prompt.clone(),
stylize: self.stylize,
weird: self.weird,
chaos: self.chaos,
quality: self.quality.map(|q| q.as_api_str().to_string()),
raw_mode: self.raw_mode,
batch_count: self.batch_count,
reference_image_urls: self.reference_image_urls.clone(),
}
}
}
// ── Model-specific enums ──
/// Aspect ratios supported by Midjourney v7 Niji.
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum GenerateMidjourneyV7NijiAspectRatio {
Square1x1,
Landscape16x9,
Portrait9x16,
UltraWide21x9,
UltraTall9x21,
Standard4x3,
Portrait3x4,
Wide5x4,
Tall4x5,
Wide3x2,
Tall2x3,
}
impl GenerateMidjourneyV7NijiAspectRatio {
fn as_api_str(&self) -> &'static str {
match self {
Self::Square1x1 => "1:1",
Self::Landscape16x9 => "16:9",
Self::Portrait9x16 => "9:16",
Self::UltraWide21x9 => "21:9",
Self::UltraTall9x21 => "9:21",
Self::Standard4x3 => "4:3",
Self::Portrait3x4 => "3:4",
Self::Wide5x4 => "5:4",
Self::Tall4x5 => "4:5",
Self::Wide3x2 => "3:2",
Self::Tall2x3 => "2:3",
}
}
}
/// Quality presets supported by Midjourney v7 Niji. Higher = more compute
/// and slower; Kinovi credit pricing is flat regardless.
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum GenerateMidjourneyV7NijiQuality {
/// 0.25
Quarter,
/// 0.5
Half,
/// 1 (default)
Full,
}
impl GenerateMidjourneyV7NijiQuality {
fn as_api_str(&self) -> &'static str {
match self {
Self::Quarter => "0.25",
Self::Half => "0.5",
Self::Full => "1",
}
}
}
// ── Response ──
pub struct GenerateMidjourneyV7NijiResponse {
pub task_id: String,
pub order_id: String,
pub task_ids: Option<Vec<String>>,
pub order_ids: Option<Vec<String>>,
}
// ── Entry point ──
pub async fn generate_midjourney_v7_niji(
args: GenerateMidjourneyV7NijiArgs<'_>,
) -> Result<GenerateMidjourneyV7NijiResponse, Seedance2ProError> {
let inner_request = args.request.to_inner_request();
let result = generate_image(GenerateImageArgs {
request: inner_request,
session: args.session,
host_override: args.host_override,
}).await?;
Ok(GenerateMidjourneyV7NijiResponse {
task_id: result.task_id,
order_id: result.order_id,
task_ids: result.task_ids,
order_ids: result.order_ids,
})
}
// ── Tests ──
#[cfg(test)]
mod tests {
use super::*;
use crate::test_utils::get_test_cookies::get_test_cookies;
use crate::test_utils::setup_test_logging::setup_test_logging;
use errors::AnyhowResult;
use log::LevelFilter;
fn test_session() -> AnyhowResult<Seedance2ProSession> {
let cookies = get_test_cookies()?;
Ok(Seedance2ProSession::from_cookies_string(cookies))
}
fn make_request(batch_count: KinoviMidjourneyBatchCount) -> GenerateMidjourneyV7NijiRequest {
GenerateMidjourneyV7NijiRequest {
prompt: "test".to_string(),
aspect_ratio: GenerateMidjourneyV7NijiAspectRatio::Square1x1,
negative_prompt: None,
stylize: None,
weird: None,
chaos: None,
quality: None,
raw_mode: false,
batch_count,
reference_image_urls: None,
}
}
// ── Inner-request mapping ──
mod inner_request_tests {
use super::*;
#[test]
fn inner_request_pins_model_to_v7_niji() {
let inner = make_request(KinoviMidjourneyBatchCount::One).to_inner_request();
assert_eq!(inner.model, KinoviMidjourneyModel::V7Niji);
}
#[test]
fn inner_request_preserves_all_fields() {
let req = GenerateMidjourneyV7NijiRequest {
prompt: "anime mecha duel".to_string(),
aspect_ratio: GenerateMidjourneyV7NijiAspectRatio::UltraWide21x9,
negative_prompt: Some("blurry".to_string()),
stylize: Some(750),
weird: Some(0),
chaos: Some(25),
quality: Some(GenerateMidjourneyV7NijiQuality::Full),
raw_mode: false,
batch_count: KinoviMidjourneyBatchCount::Two,
reference_image_urls: Some(vec![
"https://example.com/a.png".to_string(),
"https://example.com/b.png".to_string(),
]),
};
let inner = req.to_inner_request();
assert_eq!(inner.model, KinoviMidjourneyModel::V7Niji);
assert_eq!(inner.prompt, "anime mecha duel");
assert_eq!(inner.aspect_ratio, "21:9");
assert_eq!(inner.negative_prompt.as_deref(), Some("blurry"));
assert_eq!(inner.stylize, Some(750));
assert_eq!(inner.weird, Some(0));
assert_eq!(inner.chaos, Some(25));
assert_eq!(inner.quality.as_deref(), Some("1"));
assert!(!inner.raw_mode);
assert_eq!(inner.batch_count, KinoviMidjourneyBatchCount::Two);
assert_eq!(inner.reference_image_urls.as_ref().map(|v| v.len()), Some(2));
}
/// Every wrapper-side aspect ratio must serialize to the canonical wire string.
#[test]
fn every_aspect_ratio_maps_to_canonical_wire_string() {
let cases = [
(GenerateMidjourneyV7NijiAspectRatio::Square1x1, "1:1"),
(GenerateMidjourneyV7NijiAspectRatio::Landscape16x9, "16:9"),
(GenerateMidjourneyV7NijiAspectRatio::Portrait9x16, "9:16"),
(GenerateMidjourneyV7NijiAspectRatio::UltraWide21x9, "21:9"),
(GenerateMidjourneyV7NijiAspectRatio::UltraTall9x21, "9:21"),
(GenerateMidjourneyV7NijiAspectRatio::Standard4x3, "4:3"),
(GenerateMidjourneyV7NijiAspectRatio::Portrait3x4, "3:4"),
(GenerateMidjourneyV7NijiAspectRatio::Wide5x4, "5:4"),
(GenerateMidjourneyV7NijiAspectRatio::Tall4x5, "4:5"),
(GenerateMidjourneyV7NijiAspectRatio::Wide3x2, "3:2"),
(GenerateMidjourneyV7NijiAspectRatio::Tall2x3, "2:3"),
];
for (variant, expected) in cases {
let req = GenerateMidjourneyV7NijiRequest { aspect_ratio: variant, ..make_request(KinoviMidjourneyBatchCount::One) };
assert_eq!(req.to_inner_request().aspect_ratio, expected, "variant={:?}", variant);
}
}
#[test]
fn every_quality_maps_to_canonical_wire_string() {
let cases = [
(GenerateMidjourneyV7NijiQuality::Quarter, "0.25"),
(GenerateMidjourneyV7NijiQuality::Half, "0.5"),
(GenerateMidjourneyV7NijiQuality::Full, "1"),
];
for (variant, expected) in cases {
let req = GenerateMidjourneyV7NijiRequest { quality: Some(variant), ..make_request(KinoviMidjourneyBatchCount::One) };
assert_eq!(req.to_inner_request().quality.as_deref(), Some(expected), "variant={:?}", variant);
}
}
}
// ── Pricing ──
mod pricing_tests {
use super::*;
#[test]
fn batch_one_is_twelve_credits() {
assert_eq!(make_request(KinoviMidjourneyBatchCount::One).estimate_credits(), 12);
}
#[test]
fn batch_two_is_twentyfour_credits() {
assert_eq!(make_request(KinoviMidjourneyBatchCount::Two).estimate_credits(), 24);
}
#[test]
fn batch_four_is_fortyeight_credits() {
assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).estimate_credits(), 48);
}
#[test]
fn usd_cents_batch_one_is_six() {
assert_eq!(make_request(KinoviMidjourneyBatchCount::One).estimate_cost_in_usd_cents(), 6);
}
#[test]
fn usd_cents_batch_four_is_twentyfive() {
assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).estimate_cost_in_usd_cents(), 25);
}
/// Pricing must match the inner module exactly.
#[test]
fn matches_inner_pricing_exactly() {
for batch in [
KinoviMidjourneyBatchCount::One,
KinoviMidjourneyBatchCount::Two,
KinoviMidjourneyBatchCount::Four,
] {
let outer = make_request(batch);
let inner = outer.to_inner_request();
assert_eq!(outer.estimate_credits(), inner.estimate_credits(), "batch={:?}", batch);
assert_eq!(outer.estimate_cost_in_usd_cents(), inner.estimate_cost_in_usd_cents(), "batch={:?}", batch);
}
}
}
// ── Real requests ──
mod real_requests {
use super::*;
#[tokio::test]
#[ignore] // manually test — requires real cookies, costs credits
async fn test_generate_v7_niji_anime() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let result = generate_midjourney_v7_niji(GenerateMidjourneyV7NijiArgs {
session: &session,
host_override: None,
request: GenerateMidjourneyV7NijiRequest {
prompt: "A magical shiba inu sorcerer casting spells in a crystal cave".to_string(),
aspect_ratio: GenerateMidjourneyV7NijiAspectRatio::UltraWide21x9,
negative_prompt: None,
stylize: None,
weird: None,
chaos: None,
quality: None,
raw_mode: false,
batch_count: KinoviMidjourneyBatchCount::One,
reference_image_urls: None,
},
}).await?;
println!("v7-niji — task_id={}, order_id={}", result.task_id, result.order_id);
assert!(!result.task_id.is_empty());
Ok(())
}
}
}
@@ -0,0 +1,386 @@
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::error::seedance2pro_error::Seedance2ProError;
use crate::requests::generate_image::generate_image::{
generate_image, GenerateImageArgs, KinoviGenerateImageRequest, KinoviMidjourneyModel,
};
use crate::requests::kinovi_host::KinoviHost;
// `KinoviMidjourneyBatchCount` is shared across all Midjourney models, so we
// re-export it from the base. Aspect ratio and quality, however, are pinned
// to model-specific enums on this wrapper to keep their valid values typed
// at the call site.
pub use crate::requests::generate_image::generate_image::KinoviMidjourneyBatchCount;
// ── Args ──
pub struct GenerateMidjourneyV8Args<'a> {
pub request: GenerateMidjourneyV8Request,
pub session: &'a Seedance2ProSession,
pub host_override: Option<KinoviHost>,
}
// ── Request ──
#[derive(Clone, Debug)]
pub struct GenerateMidjourneyV8Request {
pub prompt: String,
pub aspect_ratio: GenerateMidjourneyV8AspectRatio,
pub negative_prompt: Option<String>,
pub stylize: Option<u16>,
pub weird: Option<u16>,
pub chaos: Option<u8>,
pub quality: Option<GenerateMidjourneyV8Quality>,
pub raw_mode: bool,
pub batch_count: KinoviMidjourneyBatchCount,
pub reference_image_urls: Option<Vec<String>>,
}
impl GenerateMidjourneyV8Request {
pub fn estimate_credits(&self) -> u32 {
self.to_inner_request().estimate_credits()
}
pub fn estimate_cost_in_usd_cents(&self) -> u64 {
self.to_inner_request().estimate_cost_in_usd_cents()
}
pub(crate) fn to_inner_request(&self) -> KinoviGenerateImageRequest {
KinoviGenerateImageRequest {
model: KinoviMidjourneyModel::V8,
prompt: self.prompt.clone(),
aspect_ratio: self.aspect_ratio.as_api_str().to_string(),
negative_prompt: self.negative_prompt.clone(),
stylize: self.stylize,
weird: self.weird,
chaos: self.chaos,
quality: self.quality.map(|q| q.as_api_str().to_string()),
raw_mode: self.raw_mode,
batch_count: self.batch_count,
reference_image_urls: self.reference_image_urls.clone(),
}
}
}
// ── Model-specific enums ──
/// Aspect ratios supported by Midjourney v8.
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum GenerateMidjourneyV8AspectRatio {
Square1x1,
Landscape16x9,
Portrait9x16,
UltraWide21x9,
UltraTall9x21,
Standard4x3,
Portrait3x4,
Wide5x4,
Tall4x5,
Wide3x2,
Tall2x3,
}
impl GenerateMidjourneyV8AspectRatio {
fn as_api_str(&self) -> &'static str {
match self {
Self::Square1x1 => "1:1",
Self::Landscape16x9 => "16:9",
Self::Portrait9x16 => "9:16",
Self::UltraWide21x9 => "21:9",
Self::UltraTall9x21 => "9:21",
Self::Standard4x3 => "4:3",
Self::Portrait3x4 => "3:4",
Self::Wide5x4 => "5:4",
Self::Tall4x5 => "4:5",
Self::Wide3x2 => "3:2",
Self::Tall2x3 => "2:3",
}
}
}
/// Quality presets supported by Midjourney v8. Higher = more compute and
/// slower; Kinovi credit pricing is flat regardless.
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum GenerateMidjourneyV8Quality {
/// 0.25
Quarter,
/// 0.5
Half,
/// 1 (default)
Full,
}
impl GenerateMidjourneyV8Quality {
fn as_api_str(&self) -> &'static str {
match self {
Self::Quarter => "0.25",
Self::Half => "0.5",
Self::Full => "1",
}
}
}
// ── Response ──
pub struct GenerateMidjourneyV8Response {
pub task_id: String,
pub order_id: String,
pub task_ids: Option<Vec<String>>,
pub order_ids: Option<Vec<String>>,
}
// ── Entry point ──
pub async fn generate_midjourney_v8(
args: GenerateMidjourneyV8Args<'_>,
) -> Result<GenerateMidjourneyV8Response, Seedance2ProError> {
let inner_request = args.request.to_inner_request();
let result = generate_image(GenerateImageArgs {
request: inner_request,
session: args.session,
host_override: args.host_override,
}).await?;
Ok(GenerateMidjourneyV8Response {
task_id: result.task_id,
order_id: result.order_id,
task_ids: result.task_ids,
order_ids: result.order_ids,
})
}
// ── Tests ──
#[cfg(test)]
mod tests {
use super::*;
use crate::test_utils::get_test_cookies::get_test_cookies;
use crate::test_utils::setup_test_logging::setup_test_logging;
use errors::AnyhowResult;
use log::LevelFilter;
fn test_session() -> AnyhowResult<Seedance2ProSession> {
let cookies = get_test_cookies()?;
Ok(Seedance2ProSession::from_cookies_string(cookies))
}
fn make_request(batch_count: KinoviMidjourneyBatchCount) -> GenerateMidjourneyV8Request {
GenerateMidjourneyV8Request {
prompt: "test".to_string(),
aspect_ratio: GenerateMidjourneyV8AspectRatio::Square1x1,
negative_prompt: None,
stylize: None,
weird: None,
chaos: None,
quality: None,
raw_mode: false,
batch_count,
reference_image_urls: None,
}
}
// ── Inner-request mapping ──
mod inner_request_tests {
use super::*;
#[test]
fn inner_request_pins_model_to_v8() {
let inner = make_request(KinoviMidjourneyBatchCount::One).to_inner_request();
assert_eq!(inner.model, KinoviMidjourneyModel::V8);
}
#[test]
fn inner_request_preserves_all_fields() {
let req = GenerateMidjourneyV8Request {
prompt: "abandoned k-mart, overgrown vines".to_string(),
aspect_ratio: GenerateMidjourneyV8AspectRatio::Portrait9x16,
negative_prompt: Some("dark, gloomy".to_string()),
stylize: Some(730),
weird: Some(2050),
chaos: Some(70),
quality: Some(GenerateMidjourneyV8Quality::Half),
raw_mode: true,
batch_count: KinoviMidjourneyBatchCount::Four,
reference_image_urls: Some(vec!["https://example.com/ref.jpg".to_string()]),
};
let inner = req.to_inner_request();
assert_eq!(inner.model, KinoviMidjourneyModel::V8);
assert_eq!(inner.prompt, "abandoned k-mart, overgrown vines");
assert_eq!(inner.aspect_ratio, "9:16");
assert_eq!(inner.negative_prompt.as_deref(), Some("dark, gloomy"));
assert_eq!(inner.stylize, Some(730));
assert_eq!(inner.weird, Some(2050));
assert_eq!(inner.chaos, Some(70));
assert_eq!(inner.quality.as_deref(), Some("0.5"));
assert!(inner.raw_mode);
assert_eq!(inner.batch_count, KinoviMidjourneyBatchCount::Four);
assert_eq!(inner.reference_image_urls.as_ref().map(|v| v.len()), Some(1));
}
/// Every wrapper-side aspect ratio must serialize to the canonical wire string.
#[test]
fn every_aspect_ratio_maps_to_canonical_wire_string() {
let cases = [
(GenerateMidjourneyV8AspectRatio::Square1x1, "1:1"),
(GenerateMidjourneyV8AspectRatio::Landscape16x9, "16:9"),
(GenerateMidjourneyV8AspectRatio::Portrait9x16, "9:16"),
(GenerateMidjourneyV8AspectRatio::UltraWide21x9, "21:9"),
(GenerateMidjourneyV8AspectRatio::UltraTall9x21, "9:21"),
(GenerateMidjourneyV8AspectRatio::Standard4x3, "4:3"),
(GenerateMidjourneyV8AspectRatio::Portrait3x4, "3:4"),
(GenerateMidjourneyV8AspectRatio::Wide5x4, "5:4"),
(GenerateMidjourneyV8AspectRatio::Tall4x5, "4:5"),
(GenerateMidjourneyV8AspectRatio::Wide3x2, "3:2"),
(GenerateMidjourneyV8AspectRatio::Tall2x3, "2:3"),
];
for (variant, expected) in cases {
let req = GenerateMidjourneyV8Request { aspect_ratio: variant, ..make_request(KinoviMidjourneyBatchCount::One) };
assert_eq!(req.to_inner_request().aspect_ratio, expected, "variant={:?}", variant);
}
}
#[test]
fn every_quality_maps_to_canonical_wire_string() {
let cases = [
(GenerateMidjourneyV8Quality::Quarter, "0.25"),
(GenerateMidjourneyV8Quality::Half, "0.5"),
(GenerateMidjourneyV8Quality::Full, "1"),
];
for (variant, expected) in cases {
let req = GenerateMidjourneyV8Request { quality: Some(variant), ..make_request(KinoviMidjourneyBatchCount::One) };
assert_eq!(req.to_inner_request().quality.as_deref(), Some(expected), "variant={:?}", variant);
}
}
}
// ── Pricing ──
mod pricing_tests {
use super::*;
#[test]
fn batch_one_is_twelve_credits() {
assert_eq!(make_request(KinoviMidjourneyBatchCount::One).estimate_credits(), 12);
}
#[test]
fn batch_two_is_twentyfour_credits() {
assert_eq!(make_request(KinoviMidjourneyBatchCount::Two).estimate_credits(), 24);
}
#[test]
fn batch_four_is_fortyeight_credits() {
assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).estimate_credits(), 48);
}
#[test]
fn usd_cents_batch_one_is_six() {
assert_eq!(make_request(KinoviMidjourneyBatchCount::One).estimate_cost_in_usd_cents(), 6);
}
#[test]
fn usd_cents_batch_four_is_twentyfive() {
assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).estimate_cost_in_usd_cents(), 25);
}
/// Pricing must match the inner module exactly.
#[test]
fn matches_inner_pricing_exactly() {
for batch in [
KinoviMidjourneyBatchCount::One,
KinoviMidjourneyBatchCount::Two,
KinoviMidjourneyBatchCount::Four,
] {
let outer = make_request(batch);
let inner = outer.to_inner_request();
assert_eq!(outer.estimate_credits(), inner.estimate_credits(), "batch={:?}", batch);
assert_eq!(outer.estimate_cost_in_usd_cents(), inner.estimate_cost_in_usd_cents(), "batch={:?}", batch);
}
}
}
// ── Real requests ──
mod real_requests {
use super::*;
#[tokio::test]
#[ignore] // manually test — requires real cookies, costs credits
async fn test_generate_v8_minimal() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let result = generate_midjourney_v8(GenerateMidjourneyV8Args {
session: &session,
host_override: None,
request: GenerateMidjourneyV8Request {
prompt: "spooky horror scene, ghosts".to_string(),
aspect_ratio: GenerateMidjourneyV8AspectRatio::Portrait9x16,
negative_prompt: None,
stylize: None,
weird: None,
chaos: None,
quality: Some(GenerateMidjourneyV8Quality::Full),
raw_mode: false,
batch_count: KinoviMidjourneyBatchCount::One,
reference_image_urls: None,
},
}).await?;
println!("v8 minimal — task_id={}, order_id={}", result.task_id, result.order_id);
assert!(!result.task_id.is_empty());
Ok(())
}
#[tokio::test]
#[ignore] // manually test — requires real cookies, costs 48 credits
async fn test_generate_v8_batch_4() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let result = generate_midjourney_v8(GenerateMidjourneyV8Args {
session: &session,
host_override: None,
request: GenerateMidjourneyV8Request {
prompt: "desolate cliff overlooking the ocean".to_string(),
aspect_ratio: GenerateMidjourneyV8AspectRatio::Portrait9x16,
negative_prompt: None,
stylize: None,
weird: None,
chaos: None,
quality: Some(GenerateMidjourneyV8Quality::Full),
raw_mode: false,
batch_count: KinoviMidjourneyBatchCount::Four,
reference_image_urls: None,
},
}).await?;
println!("v8 batch-4 — task_id={}, order_id={}", result.task_id, result.order_id);
assert_eq!(result.task_ids.as_ref().map(|v| v.len()), Some(4));
Ok(())
}
#[tokio::test]
#[ignore] // manually test — requires real cookies, costs credits
async fn test_generate_v8_with_reference_image() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let result = generate_midjourney_v8(GenerateMidjourneyV8Args {
session: &session,
host_override: None,
request: GenerateMidjourneyV8Request {
prompt: "spooky horror scene, ghosts".to_string(),
aspect_ratio: GenerateMidjourneyV8AspectRatio::Portrait9x16,
negative_prompt: None,
stylize: None,
weird: None,
chaos: None,
quality: Some(GenerateMidjourneyV8Quality::Full),
raw_mode: false,
batch_count: KinoviMidjourneyBatchCount::One,
reference_image_urls: Some(vec![
"https://static.seedance2-pro.com/materials/20260602/1780380104765-10cec276.jpg".to_string(),
]),
},
}).await?;
println!("v8 with ref image — task_id={}, order_id={}", result.task_id, result.order_id);
assert!(!result.task_id.is_empty());
Ok(())
}
}
}
@@ -0,0 +1,3 @@
pub mod generate_midjourney_v7;
pub mod generate_midjourney_v7_niji;
pub mod generate_midjourney_v8;
@@ -1 +1,2 @@
pub mod image;
pub mod video;
@@ -0,0 +1,894 @@
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::error::seedance2pro_client_error::Seedance2ProClientError;
use crate::error::seedance2pro_error::Seedance2ProError;
use crate::error::seedance2pro_generic_api_error::Seedance2ProGenericApiError;
use crate::error::seedance2pro_specific_api_error::Seedance2ProSpecificApiError;
use crate::requests::generate_image::request_types::*;
use crate::requests::kinovi_host::{KinoviHost, resolve_host};
use crate::utils::categorize_seedance2pro_error::categorize_seedance2pro_error;
use crate::utils::common_headers::FIREFOX_USER_AGENT;
use log::info;
use wreq::Client;
use wreq_util::Emulation;
// ── Constants ──
/// All Midjourney generations on Kinovi cost 12 credits per image-task,
/// regardless of model (v7, v7-niji, v8) or aspect ratio. Batches are billed
/// at this rate × `batch_count`.
const CREDITS_PER_MIDJOURNEY_TASK: u32 = 12;
/// Newer Kinovi credit-package rate. Used to convert credits to USD for the
/// Midjourney image generation flow.
///
/// 22,000 credits / $114 = 192.98 ≈ 193 credits per dollar.
const CREDITS_PER_DOLLAR: f64 = 193.0;
/// Kinovi only currently exposes a single Midjourney resolution preset.
const MIDJOURNEY_RESOLUTION: &str = "1k";
const BUSINESS_TYPE: &str = "midjourney-image-generation";
// ── Request args ──
/// Wrapper that bundles a [`KinoviGenerateImageRequest`] with session and host info.
pub struct GenerateImageArgs<'a> {
pub request: KinoviGenerateImageRequest,
pub session: &'a Seedance2ProSession,
pub host_override: Option<KinoviHost>,
}
/// Image generation parameters (no session/host info).
///
/// `aspect_ratio` and `quality` are loosely-typed strings here so this
/// function can serve image-generation flows that aren't Midjourney without
/// being constrained to Midjourney's particular set of values. The thin
/// per-model wrappers (`generate_midjourney_v7`, etc.) own strongly-typed
/// enums and convert them to strings before calling in.
#[derive(Clone)]
pub struct KinoviGenerateImageRequest {
/// Which Midjourney variant to run.
pub model: KinoviMidjourneyModel,
/// User prompt.
pub prompt: String,
/// Output aspect ratio, e.g. `"16:9"`, `"1:1"`, `"9:16"`. Forwarded
/// verbatim into the request's `aspectRatio` field.
pub aspect_ratio: String,
/// Optional negative prompt. Sent as the `"no"` field on the wire.
pub negative_prompt: Option<String>,
/// "0 = literal, 1000 = artistic"
/// Midjourney "stylize" parameter. Valid range 0–1000. `None` omits the
/// field and lets Kinovi pick its default.
pub stylize: Option<u16>,
/// "Unconventional aesthetics"
/// Midjourney "weird" parameter. Valid range 0–3000.
pub weird: Option<u16>,
/// "Variation between images"
/// Midjourney "chaos" parameter. Valid range 0–100.
pub chaos: Option<u8>,
/// Quality preset, e.g. `"0.25"`, `"0.5"`, `"1"`. Parsed as an `f32` and
/// emitted as a JSON number on the wire. `None` omits the field.
///
/// If the string isn't a valid `f32`, `generate_image()` returns a
/// `Seedance2ProClientError::InvalidRequestField` error rather than
/// panicking. The typed wrappers always supply well-formed values, so
/// errors here only occur for callers that bypass them.
pub quality: Option<String>,
/// Enables Midjourney "raw" style mode when true. Sent as `"style":"raw"`.
pub raw_mode: bool,
/// Number of distinct generations to enqueue in a single request.
pub batch_count: KinoviMidjourneyBatchCount,
/// Optional reference image URLs. Sent on the wire as `uploadedUrls`.
/// Midjourney uses these as visual inspiration alongside the prompt.
pub reference_image_urls: Option<Vec<String>>,
}
impl std::fmt::Debug for KinoviGenerateImageRequest {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GenerateImageRequest")
.field("model", &self.model)
.field("prompt", &self.prompt)
.field("aspect_ratio", &self.aspect_ratio)
.field("negative_prompt", &self.negative_prompt)
.field("stylize", &self.stylize)
.field("weird", &self.weird)
.field("chaos", &self.chaos)
.field("quality", &self.quality)
.field("raw_mode", &self.raw_mode)
.field("batch_count", &self.batch_count)
.field("reference_image_urls", &self.reference_image_urls)
.finish()
}
}
impl std::fmt::Debug for GenerateImageArgs<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GenerateImageArgs")
.field("request", &self.request)
.field("host_override", &self.host_override)
.finish()
}
}
impl KinoviGenerateImageRequest {
/// Estimates the Kinovi credit cost for this request.
///
/// Pricing for Midjourney is flat: 12 credits per task, regardless of
/// model or aspect ratio. Batches multiply the cost by `batch_count`.
pub fn estimate_credits(&self) -> u32 {
CREDITS_PER_MIDJOURNEY_TASK * u32::from(self.batch_count.as_u8())
}
/// Estimates the dollar cost (in USD cents, rounded to the nearest cent).
pub fn estimate_cost_in_usd_cents(&self) -> u64 {
let credits = self.estimate_credits() as f64;
let cost = credits / CREDITS_PER_DOLLAR * 100.0;
cost.round() as u64
}
}
// ── Public enums ──
/// Midjourney model variant.
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum KinoviMidjourneyModel {
/// Midjourney v7 — the current general-purpose model.
V7,
/// Midjourney v7 Niji — v7 trained for anime/illustration.
V7Niji,
/// Midjourney v8 — the newest model.
V8,
}
impl KinoviMidjourneyModel {
fn as_api_str(&self) -> &'static str {
match self {
Self::V7 => "midjourney-v7",
Self::V7Niji => "midjourney-v7-niji",
Self::V8 => "midjourney-v8",
}
}
}
/// Number of distinct Midjourney generations to enqueue.
/// Each task itself returns a 4-image grid; this knob just enqueues N tasks.
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum KinoviMidjourneyBatchCount {
One,
Two,
Four,
}
impl KinoviMidjourneyBatchCount {
fn as_u8(&self) -> u8 {
match self {
Self::One => 1,
Self::Two => 2,
Self::Four => 4,
}
}
}
// ── Response ──
pub struct GenerateImageResponse {
pub task_id: String,
pub order_id: String,
/// Present when batch_count > 1.
pub task_ids: Option<Vec<String>>,
/// Present when batch_count > 1.
pub order_ids: Option<Vec<String>>,
}
// ── Implementation ──
pub async fn generate_image(args: GenerateImageArgs<'_>) -> Result<GenerateImageResponse, Seedance2ProError> {
let host = resolve_host(args.host_override.as_ref());
let base_url = host.api_base_url();
let run_task_url = format!("{}/api/trpc/workflow.runTask?batch=1", base_url);
let req = args.request;
info!("Requesting Midjourney image from Kinovi: {:?}", req);
let batch_count_value = req.batch_count.as_u8();
let batch_count = if batch_count_value > 1 { Some(batch_count_value) } else { None };
let style = if req.raw_mode { Some("raw") } else { None };
// Empty Vec is treated as "no references" so the field is omitted from the
// wire — Midjourney rejects empty arrays.
let uploaded_urls = req.reference_image_urls.filter(|urls| !urls.is_empty());
let quality = req.quality.as_deref().map(parse_quality).transpose()?;
let request_body = BatchRequest {
zero: BatchRequestInner {
json: BatchRequestJson {
business_type: BUSINESS_TYPE,
api_params: ApiParams {
prompt: req.prompt,
aspect_ratio: req.aspect_ratio,
resolution: MIDJOURNEY_RESOLUTION,
model: req.model.as_api_str(),
stylize: req.stylize,
chaos: req.chaos,
weird: req.weird,
quality,
style,
negative_prompt: req.negative_prompt,
batch_count,
uploaded_urls,
},
},
},
};
info!("Kinovi Midjourney request: {:?}", request_body);
let cookie = args.session.cookies.as_str();
let client = Client::builder()
.emulation(Emulation::Firefox143)
.build()
.map_err(|err| Seedance2ProClientError::WreqClientError(err))?;
let referer = format!("{}/", base_url);
let response = client.post(&run_task_url)
.header("User-Agent", FIREFOX_USER_AGENT)
.header("Accept", "*/*")
.header("Accept-Language", "en-US,en;q=0.9")
.header("Accept-Encoding", "gzip, deflate, br, zstd")
.header("Referer", &referer)
.header("Content-Type", "application/json")
.header("x-trpc-source", "client")
.header("Origin", base_url)
.header("Connection", "keep-alive")
.header("Cookie", cookie)
.header("Sec-Fetch-Dest", "empty")
.header("Sec-Fetch-Mode", "cors")
.header("Sec-Fetch-Site", "same-origin")
.header("Priority", "u=4")
.header("TE", "trailers")
.json(&request_body)
.send()
.await
.map_err(|err| Seedance2ProGenericApiError::WreqError(err))?;
let status = response.status();
let response_body = response.text()
.await
.map_err(|err| Seedance2ProGenericApiError::WreqError(err))?;
info!("Response status: {}, body: {}", status, response_body);
if !status.is_success() {
return Err(categorize_seedance2pro_error(status, response_body));
}
let batch_response: Vec<BatchResponseItem> = serde_json::from_str(&response_body)
.map_err(|err| Seedance2ProGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.clone()))?;
let task_data = batch_response
.into_iter()
.next()
.ok_or_else(|| Seedance2ProGenericApiError::UncategorizedBadResponse(
"Empty batch response array".to_string()
))?
.result
.data
.json;
if task_data.violation_warning {
return Err(Seedance2ProSpecificApiError::VideoGenerationViolation(response_body).into());
}
Ok(GenerateImageResponse {
task_id: task_data.task_id,
order_id: task_data.order_id,
task_ids: task_data.task_ids,
order_ids: task_data.order_ids,
})
}
/// The wire format for `quality` is a JSON number, but the public API
/// accepts a string so future models with different quality semantics can
/// hand in whatever they like. Callers funnel through strongly-typed
/// wrapper enums whose string output is always parseable; arbitrary
/// callers might not, so we surface a structured client error instead of
/// panicking.
fn parse_quality(raw: &str) -> Result<f32, Seedance2ProClientError> {
raw.parse::<f32>().map_err(|err| Seedance2ProClientError::InvalidRequestField {
field: "quality",
raw_value: raw.to_string(),
reason: format!("not a valid f32: {}", err),
})
}
#[cfg(test)]
mod tests {
use super::*;
// ── Pricing ──
mod pricing_tests {
use super::*;
fn make_request(batch_count: KinoviMidjourneyBatchCount) -> KinoviGenerateImageRequest {
KinoviGenerateImageRequest {
model: KinoviMidjourneyModel::V7,
prompt: String::new(),
aspect_ratio: "1:1".to_string(),
negative_prompt: None,
stylize: None,
weird: None,
chaos: None,
quality: None,
raw_mode: false,
batch_count,
reference_image_urls: None,
}
}
// ── Headline cost rule: 12 credits × batch_count ──
#[test]
fn batch_one_is_twelve_credits() {
assert_eq!(make_request(KinoviMidjourneyBatchCount::One).estimate_credits(), 12);
}
#[test]
fn batch_two_is_twentyfour_credits() {
assert_eq!(make_request(KinoviMidjourneyBatchCount::Two).estimate_credits(), 24);
}
#[test]
fn batch_four_is_fortyeight_credits() {
assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).estimate_credits(), 48);
}
/// Pricing is identical across all three Midjourney models.
#[test]
fn pricing_is_model_agnostic() {
let baseline = make_request(KinoviMidjourneyBatchCount::One).estimate_credits();
for model in [
KinoviMidjourneyModel::V7,
KinoviMidjourneyModel::V7Niji,
KinoviMidjourneyModel::V8,
] {
let req = KinoviGenerateImageRequest { model, ..make_request(KinoviMidjourneyBatchCount::One) };
assert_eq!(req.estimate_credits(), baseline, "model={:?}", model);
}
}
/// Pricing does not vary by aspect ratio.
#[test]
fn pricing_is_aspect_ratio_agnostic() {
let baseline = make_request(KinoviMidjourneyBatchCount::One).estimate_credits();
for ar in ["1:1", "16:9", "9:16", "21:9", "9:21", "4:3", "3:4", "5:4", "4:5", "3:2", "2:3"] {
let req = KinoviGenerateImageRequest {
aspect_ratio: ar.to_string(),
..make_request(KinoviMidjourneyBatchCount::One)
};
assert_eq!(req.estimate_credits(), baseline, "aspect_ratio={:?}", ar);
}
}
/// Pricing does not vary with the Midjourney style knobs.
#[test]
fn pricing_is_independent_of_style_knobs() {
let baseline = make_request(KinoviMidjourneyBatchCount::One).estimate_credits();
let req = KinoviGenerateImageRequest {
stylize: Some(1000),
weird: Some(3000),
chaos: Some(100),
quality: Some("1".to_string()),
raw_mode: true,
negative_prompt: Some("ugly, blurry".to_string()),
..make_request(KinoviMidjourneyBatchCount::One)
};
assert_eq!(req.estimate_credits(), baseline);
}
// ── USD cents conversion ──
#[test]
fn usd_cents_batch_one() {
// 12 / 193 × 100 = 6.21¢ → 6¢
assert_eq!(make_request(KinoviMidjourneyBatchCount::One).estimate_cost_in_usd_cents(), 6);
}
#[test]
fn usd_cents_batch_two() {
// 24 / 193 × 100 = 12.43¢ → 12¢
assert_eq!(make_request(KinoviMidjourneyBatchCount::Two).estimate_cost_in_usd_cents(), 12);
}
#[test]
fn usd_cents_batch_four() {
// 48 / 193 × 100 = 24.87¢ → 25¢
assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).estimate_cost_in_usd_cents(), 25);
}
/// Batch 4 should be exactly 4× the credit cost of batch 1, and the USD
/// cents conversion should track within rounding (1¢ slack).
#[test]
fn batch_4_costs_4x_credits_and_approximately_4x_dollars() {
let one = make_request(KinoviMidjourneyBatchCount::One);
let four = make_request(KinoviMidjourneyBatchCount::Four);
assert_eq!(four.estimate_credits(), one.estimate_credits() * 4);
let one_cents = one.estimate_cost_in_usd_cents();
let four_cents = four.estimate_cost_in_usd_cents();
let expected = one_cents * 4;
let delta = (four_cents as i64 - expected as i64).abs();
assert!(delta <= 1, "expected ~{}¢, got {}¢", expected, four_cents);
}
}
// ── Wire-shape ──
mod wire_shape_tests {
use super::*;
use serde_json::Value;
fn build_wire_json(req: KinoviGenerateImageRequest) -> Value {
try_build_wire_json(req).expect("test helper: malformed quality string")
}
fn try_build_wire_json(req: KinoviGenerateImageRequest) -> Result<Value, Seedance2ProClientError> {
// Mirrors the body assembly in `generate_image()` minus the actual
// network call, so we can introspect the JSON the server would see.
let batch_count_value = req.batch_count.as_u8();
let batch_count = if batch_count_value > 1 { Some(batch_count_value) } else { None };
let style = if req.raw_mode { Some("raw") } else { None };
let uploaded_urls = req.reference_image_urls.filter(|urls| !urls.is_empty());
let quality = req.quality.as_deref().map(parse_quality).transpose()?;
let request_body = BatchRequest {
zero: BatchRequestInner {
json: BatchRequestJson {
business_type: BUSINESS_TYPE,
api_params: ApiParams {
prompt: req.prompt,
aspect_ratio: req.aspect_ratio,
resolution: MIDJOURNEY_RESOLUTION,
model: req.model.as_api_str(),
stylize: req.stylize,
chaos: req.chaos,
weird: req.weird,
quality,
style,
negative_prompt: req.negative_prompt,
batch_count,
uploaded_urls,
},
},
},
};
Ok(serde_json::to_value(&request_body).unwrap())
}
fn api_params(req: KinoviGenerateImageRequest) -> Value {
build_wire_json(req)["0"]["json"]["apiParams"].clone()
}
fn minimal_request(model: KinoviMidjourneyModel, prompt: &str) -> KinoviGenerateImageRequest {
KinoviGenerateImageRequest {
model,
prompt: prompt.to_string(),
aspect_ratio: "1:1".to_string(),
negative_prompt: None,
stylize: None,
weird: None,
chaos: None,
quality: None,
raw_mode: false,
batch_count: KinoviMidjourneyBatchCount::One,
reference_image_urls: None,
}
}
// ── Minimal: matches captured sample #1 ──
#[test]
fn minimal_v7_matches_captured_sample() {
// Sample #1: `{"prompt":"Anime dinosaur","aspectRatio":"1:1","resolution":"1k","model":"midjourney-v7"}`
let params = api_params(minimal_request(KinoviMidjourneyModel::V7, "Anime dinosaur"));
assert_eq!(params["prompt"], "Anime dinosaur");
assert_eq!(params["aspectRatio"], "1:1");
assert_eq!(params["resolution"], "1k");
assert_eq!(params["model"], "midjourney-v7");
let obj = params.as_object().unwrap();
let unexpected: Vec<&str> = obj.keys()
.filter(|k| !["prompt", "aspectRatio", "resolution", "model"].contains(&k.as_str()))
.map(|s| s.as_str())
.collect();
assert!(unexpected.is_empty(), "minimal request should omit optional fields, found: {:?}", unexpected);
}
// ── Model strings ──
#[test]
fn model_v7_maps_to_midjourney_v7() {
let p = api_params(minimal_request(KinoviMidjourneyModel::V7, "p"));
assert_eq!(p["model"], "midjourney-v7");
}
#[test]
fn model_v7_niji_maps_to_midjourney_v7_niji() {
let p = api_params(minimal_request(KinoviMidjourneyModel::V7Niji, "p"));
assert_eq!(p["model"], "midjourney-v7-niji");
}
#[test]
fn model_v8_maps_to_midjourney_v8() {
let p = api_params(minimal_request(KinoviMidjourneyModel::V8, "p"));
assert_eq!(p["model"], "midjourney-v8");
}
// ── Aspect ratio strings ──
#[test]
fn aspect_ratio_passes_through_verbatim() {
// The base API is stringly-typed for aspect_ratio — whatever the caller
// (typically a per-model wrapper) hands in must appear on the wire as-is.
for input in ["1:1", "16:9", "9:16", "21:9", "9:21", "4:3", "3:4", "5:4", "4:5", "3:2", "2:3"] {
let req = KinoviGenerateImageRequest {
aspect_ratio: input.to_string(),
..minimal_request(KinoviMidjourneyModel::V7, "p")
};
let p = api_params(req);
assert_eq!(p["aspectRatio"], input, "aspect_ratio={:?}", input);
}
}
// ── Optional fields omit when None ──
#[test]
fn optionals_omitted_when_none() {
let p = api_params(minimal_request(KinoviMidjourneyModel::V7, "p"));
let obj = p.as_object().unwrap();
for key in ["stylize", "chaos", "weird", "quality", "style", "no", "batchCount", "uploadedUrls"] {
assert!(!obj.contains_key(key), "expected `{}` to be omitted in minimal request", key);
}
}
// ── Style knobs ──
#[test]
fn stylize_serializes_when_set() {
let req = KinoviGenerateImageRequest { stylize: Some(730), ..minimal_request(KinoviMidjourneyModel::V8, "p") };
assert_eq!(api_params(req)["stylize"], 730);
}
#[test]
fn chaos_serializes_when_set() {
let req = KinoviGenerateImageRequest { chaos: Some(70), ..minimal_request(KinoviMidjourneyModel::V8, "p") };
assert_eq!(api_params(req)["chaos"], 70);
}
#[test]
fn weird_serializes_when_set() {
let req = KinoviGenerateImageRequest { weird: Some(2050), ..minimal_request(KinoviMidjourneyModel::V8, "p") };
assert_eq!(api_params(req)["weird"], 2050);
}
#[test]
fn stylize_zero_is_explicitly_sent_not_omitted() {
// Captured sample #9 sends explicit zeros — `None` ≠ `Some(0)` on the wire.
let req = KinoviGenerateImageRequest {
stylize: Some(0),
chaos: Some(0),
weird: Some(0),
..minimal_request(KinoviMidjourneyModel::V7, "p")
};
let p = api_params(req);
assert_eq!(p["stylize"], 0);
assert_eq!(p["chaos"], 0);
assert_eq!(p["weird"], 0);
}
#[test]
fn quality_quarter_serializes_as_0_25() {
let req = KinoviGenerateImageRequest { quality: Some("0.25".to_string()), ..minimal_request(KinoviMidjourneyModel::V7, "p") };
let q = api_params(req)["quality"].as_f64().unwrap();
assert!((q - 0.25).abs() < 1e-6, "got {}", q);
}
#[test]
fn quality_half_serializes_as_0_5() {
let req = KinoviGenerateImageRequest { quality: Some("0.5".to_string()), ..minimal_request(KinoviMidjourneyModel::V7, "p") };
let q = api_params(req)["quality"].as_f64().unwrap();
assert!((q - 0.5).abs() < 1e-6, "got {}", q);
}
#[test]
fn quality_full_serializes_as_1_0() {
let req = KinoviGenerateImageRequest { quality: Some("1".to_string()), ..minimal_request(KinoviMidjourneyModel::V7, "p") };
let q = api_params(req)["quality"].as_f64().unwrap();
assert!((q - 1.0).abs() < 1e-6, "got {}", q);
}
#[test]
fn quality_with_non_numeric_string_returns_client_error() {
// The base function accepts strings; malformed input surfaces as a
// structured `Seedance2ProClientError::InvalidRequestField` rather
// than panicking.
let req = KinoviGenerateImageRequest {
quality: Some("not-a-number".to_string()),
..minimal_request(KinoviMidjourneyModel::V7, "p")
};
let err = try_build_wire_json(req).expect_err("expected an error");
match err {
Seedance2ProClientError::InvalidRequestField { field, raw_value, .. } => {
assert_eq!(field, "quality");
assert_eq!(raw_value, "not-a-number");
}
other => panic!("expected InvalidRequestField, got {:?}", other),
}
}
// ── Raw mode ──
#[test]
fn raw_mode_true_sends_style_raw() {
let req = KinoviGenerateImageRequest { raw_mode: true, ..minimal_request(KinoviMidjourneyModel::V7, "p") };
assert_eq!(api_params(req)["style"], "raw");
}
#[test]
fn raw_mode_false_omits_style() {
let p = api_params(minimal_request(KinoviMidjourneyModel::V7, "p"));
assert!(p.as_object().unwrap().get("style").is_none());
}
// ── Negative prompt → `"no"` field ──
#[test]
fn negative_prompt_serializes_as_no_field() {
let req = KinoviGenerateImageRequest {
negative_prompt: Some("dark, gloomy, night".to_string()),
..minimal_request(KinoviMidjourneyModel::V7, "p")
};
let p = api_params(req);
assert_eq!(p["no"], "dark, gloomy, night");
assert!(p.as_object().unwrap().get("negative_prompt").is_none(), "must not leak field name");
assert!(p.as_object().unwrap().get("negativePrompt").is_none(), "must not leak camelCase variant");
}
// ── Batch count ──
#[test]
fn batch_one_omits_batch_count() {
let p = api_params(minimal_request(KinoviMidjourneyModel::V7, "p"));
assert!(p.as_object().unwrap().get("batchCount").is_none());
}
#[test]
fn batch_two_sends_batch_count_2() {
let req = KinoviGenerateImageRequest { batch_count: KinoviMidjourneyBatchCount::Two, ..minimal_request(KinoviMidjourneyModel::V7, "p") };
assert_eq!(api_params(req)["batchCount"], 2);
}
#[test]
fn batch_four_sends_batch_count_4() {
let req = KinoviGenerateImageRequest { batch_count: KinoviMidjourneyBatchCount::Four, ..minimal_request(KinoviMidjourneyModel::V7, "p") };
assert_eq!(api_params(req)["batchCount"], 4);
}
// ── businessType and trpc envelope ──
#[test]
fn business_type_is_midjourney_image_generation() {
let body = build_wire_json(minimal_request(KinoviMidjourneyModel::V7, "p"));
assert_eq!(body["0"]["json"]["businessType"], "midjourney-image-generation");
}
#[test]
fn resolution_is_1k() {
let p = api_params(minimal_request(KinoviMidjourneyModel::V7, "p"));
assert_eq!(p["resolution"], "1k");
}
// ── Captured-sample replay (#9: kitchen sink) ──
/// Reconstructs the captured sample #9 (V7 + raw + all knobs + negative
/// prompt) and asserts the wire matches byte-for-byte.
#[test]
fn captured_sample_9_matches_byte_for_byte() {
let req = KinoviGenerateImageRequest {
model: KinoviMidjourneyModel::V7,
prompt: "abandoned skyscrapers".to_string(),
aspect_ratio: "1:1".to_string(),
negative_prompt: Some("dark, gloomy, night".to_string()),
stylize: Some(1000),
weird: Some(3000),
chaos: Some(100),
quality: Some("0.5".to_string()),
raw_mode: true,
batch_count: KinoviMidjourneyBatchCount::One,
reference_image_urls: None,
};
let got = api_params(req);
let expected: Value = serde_json::from_str(
r#"{"prompt":"abandoned skyscrapers","aspectRatio":"1:1","resolution":"1k","model":"midjourney-v7","stylize":1000,"chaos":100,"weird":3000,"quality":0.5,"style":"raw","no":"dark, gloomy, night"}"#,
).unwrap();
assert_eq!(got, expected);
}
/// Captured sample #12 (V8 + batch 4).
#[test]
fn captured_sample_12_batch_4() {
let req = KinoviGenerateImageRequest {
model: KinoviMidjourneyModel::V8,
prompt: "desolate cliff overlooking the ocean".to_string(),
aspect_ratio: "9:16".to_string(),
negative_prompt: None,
stylize: None,
weird: None,
chaos: None,
quality: Some("1".to_string()),
raw_mode: false,
batch_count: KinoviMidjourneyBatchCount::Four,
reference_image_urls: None,
};
let got = api_params(req);
let expected: Value = serde_json::from_str(
r#"{"prompt":"desolate cliff overlooking the ocean","aspectRatio":"9:16","resolution":"1k","model":"midjourney-v8","batchCount":4,"quality":1.0}"#,
).unwrap();
assert_eq!(got, expected);
}
}
// ── Real requests (manually run; require live cookies and cost credits) ──
mod real_requests {
use super::*;
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::test_utils::get_test_cookies::get_test_cookies;
use crate::test_utils::setup_test_logging::setup_test_logging;
use errors::AnyhowResult;
use log::LevelFilter;
fn test_session() -> AnyhowResult<Seedance2ProSession> {
let cookies = get_test_cookies()?;
Ok(Seedance2ProSession::from_cookies_string(cookies))
}
#[tokio::test]
#[ignore] // manually test — requires real cookies, costs credits
async fn test_generate_v7_minimal() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let args = GenerateImageArgs {
session: &session,
host_override: None,
request: KinoviGenerateImageRequest {
model: KinoviMidjourneyModel::V7,
prompt: "A corgi astronaut floating among stars".to_string(),
aspect_ratio: "1:1".to_string(),
negative_prompt: None,
stylize: None,
weird: None,
chaos: None,
quality: None,
raw_mode: false,
batch_count: KinoviMidjourneyBatchCount::One,
reference_image_urls: None,
},
};
let result = generate_image(args).await?;
println!("Task ID: {}", result.task_id);
println!("Order ID: {}", result.order_id);
assert!(!result.task_id.is_empty());
assert!(!result.order_id.is_empty());
Ok(())
}
#[tokio::test]
#[ignore] // manually test — requires real cookies, costs credits
async fn test_generate_v7_niji_anime() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let args = GenerateImageArgs {
session: &session,
host_override: None,
request: KinoviGenerateImageRequest {
model: KinoviMidjourneyModel::V7Niji,
prompt: "A magical shiba inu sorcerer casting spells in a crystal cave".to_string(),
aspect_ratio: "21:9".to_string(),
negative_prompt: None,
stylize: None,
weird: None,
chaos: None,
quality: None,
raw_mode: false,
batch_count: KinoviMidjourneyBatchCount::One,
reference_image_urls: None,
},
};
let result = generate_image(args).await?;
println!("Task ID: {}", result.task_id);
println!("Order ID: {}", result.order_id);
assert!(!result.task_id.is_empty());
Ok(())
}
#[tokio::test]
#[ignore] // manually test — requires real cookies, costs credits
async fn test_generate_v8_all_knobs() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let args = GenerateImageArgs {
session: &session,
host_override: None,
request: KinoviGenerateImageRequest {
model: KinoviMidjourneyModel::V8,
prompt: "A magical shiba inu sorcerer casting spells in a crystal cave".to_string(),
aspect_ratio: "1:1".to_string(),
negative_prompt: Some("dark, gloomy".to_string()),
stylize: Some(730),
weird: Some(2050),
chaos: Some(70),
quality: Some("0.5".to_string()),
raw_mode: true,
batch_count: KinoviMidjourneyBatchCount::One,
reference_image_urls: None,
},
};
let result = generate_image(args).await?;
println!("Task ID: {}", result.task_id);
println!("Order ID: {}", result.order_id);
assert!(!result.task_id.is_empty());
Ok(())
}
#[tokio::test]
#[ignore] // manually test — requires real cookies, costs 48 credits
async fn test_generate_v8_batch_4() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let args = GenerateImageArgs {
session: &session,
host_override: None,
request: KinoviGenerateImageRequest {
model: KinoviMidjourneyModel::V8,
prompt: "desolate cliff overlooking the ocean".to_string(),
aspect_ratio: "9:16".to_string(),
negative_prompt: None,
stylize: None,
weird: None,
chaos: None,
quality: Some("1".to_string()),
raw_mode: false,
batch_count: KinoviMidjourneyBatchCount::Four,
reference_image_urls: None,
},
};
let result = generate_image(args).await?;
println!("Task ID: {}", result.task_id);
println!("Order ID: {}", result.order_id);
println!("All task IDs: {:?}", result.task_ids);
println!("All order IDs: {:?}", result.order_ids);
assert!(!result.task_id.is_empty());
assert_eq!(result.task_ids.as_ref().map(|v| v.len()), Some(4), "batch 4 should return 4 task IDs");
Ok(())
}
}
}
@@ -0,0 +1,2 @@
pub mod generate_image;
mod request_types;
@@ -0,0 +1,83 @@
use serde_derive::{Deserialize, Serialize};
#[derive(Serialize, Debug)]
pub (super) struct BatchRequest {
#[serde(rename = "0")]
pub zero: BatchRequestInner,
}
#[derive(Serialize, Debug)]
pub (super) struct BatchRequestInner {
pub json: BatchRequestJson,
}
#[derive(Serialize, Debug)]
pub (super) struct BatchRequestJson {
#[serde(rename = "businessType")]
pub business_type: &'static str,
#[serde(rename = "apiParams")]
pub api_params: ApiParams,
}
#[derive(Serialize, Debug)]
pub (super) struct ApiParams {
pub prompt: String,
#[serde(rename = "aspectRatio")]
pub aspect_ratio: String,
pub resolution: &'static str,
pub model: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub stylize: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
pub chaos: Option<u8>,
#[serde(skip_serializing_if = "Option::is_none")]
pub weird: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
pub quality: Option<f32>,
/// Sent as `"raw"` for raw mode; omitted otherwise. Currently `"raw"` is
/// the only documented value.
#[serde(skip_serializing_if = "Option::is_none")]
pub style: Option<&'static str>,
/// Negative prompt. Sent as the field `"no"` to match Midjourney's
/// CLI-style convention.
#[serde(rename = "no", skip_serializing_if = "Option::is_none")]
pub negative_prompt: Option<String>,
/// Number of distinct image generations to enqueue. Omitted when 1
/// (Midjourney returns four images per task regardless).
#[serde(rename = "batchCount", skip_serializing_if = "Option::is_none")]
pub batch_count: Option<u8>,
/// Reference image URLs. When present, Midjourney uses them as visual
/// inspiration alongside the prompt.
#[serde(rename = "uploadedUrls", skip_serializing_if = "Option::is_none")]
pub uploaded_urls: Option<Vec<String>>,
}
#[derive(Deserialize, Debug)]
pub (super) struct BatchResponseItem {
pub result: BatchResponseResult,
}
#[derive(Deserialize, Debug)]
pub (super) struct BatchResponseResult {
pub data: BatchResponseData,
}
#[derive(Deserialize, Debug)]
pub (super) struct BatchResponseData {
pub json: TaskResponseJson,
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub (super) struct TaskResponseJson {
pub task_id: String,
pub order_id: String,
pub task_ids: Option<Vec<String>>,
pub order_ids: Option<Vec<String>>,
pub violation_warning: bool,
}
@@ -1,4 +1,5 @@
pub mod generate_character;
pub mod generate_image;
pub mod generate_video;
pub mod workflow_run_task;
pub mod get_user_auth_details;
@@ -66,7 +66,9 @@ impl TaskStatus {
}
}
/// A single generated video result attached to an order.
/// A single generated result attached to an order — a video frame for video
/// orders, or one of Midjourney's 4 generated images for image orders.
/// (Originally named for the video-only days; the underlying shape is shared.)
#[derive(Debug, Clone)]
pub struct VideoResult {
pub url: String,
@@ -77,17 +79,49 @@ pub struct VideoResult {
// pub ratio: Option<f64>,
}
/// The status of one order (one video generation task).
/// Type-neutral alias for [`VideoResult`]. Prefer this name in new
/// callers that deal with both image and video orders.
pub type MediaResult = VideoResult;
/// Media type of an order. Midjourney orders are `Image`; the various
/// Seedance/keyframe/reference flows are `Video`.
///
/// `Unknown` covers response payloads that omit the field (older video
/// polling) or return an unrecognised value.
#[derive(Debug, Clone, PartialEq)]
pub enum OrderMediaType {
Image,
Video,
Unknown(String),
}
impl OrderMediaType {
fn from_str(s: &str) -> Self {
match s {
"image" => Self::Image,
"video" => Self::Video,
other => Self::Unknown(other.to_string()),
}
}
pub fn is_image(&self) -> bool { matches!(self, Self::Image) }
pub fn is_video(&self) -> bool { matches!(self, Self::Video) }
}
/// The status of one order (one generation task — video or image).
#[derive(Debug, Clone)]
pub struct OrderStatus {
pub order_id: String,
pub task_status: TaskStatus,
/// Top-level result video URL. Populated when `task_status` is `Completed`.
/// Top-level result URL (video file for video orders, the first image of
/// the 4-image grid for Midjourney image orders). Populated when
/// `task_status` is `Completed`.
pub result_url: Option<String>,
/// Detailed result entries. Typically one entry per video.
/// Detailed result entries. One entry per video frame (video orders), or
/// four entries per Midjourney task (image orders).
pub results: Vec<VideoResult>,
/// Structured failure reason. Populated when `task_status` is `Failed` or `fail_reason` is present.
@@ -98,6 +132,11 @@ pub struct OrderStatus {
/// Parsed `created_at` as a `DateTime<Utc>`. `None` if the raw string could not be parsed.
pub created_at_utc: Option<DateTime<Utc>>,
/// Whether this order produced an image or a video. `None` for older
/// polling responses that didn't include the field — those came from the
/// video-only era and can be treated as video by callers that need to.
pub media_type: Option<OrderMediaType>,
}
// --- Implementation ---
@@ -184,6 +223,8 @@ pub async fn poll_orders(args: PollOrdersArgs<'_>) -> Result<PollOrdersResponse,
.map(|dt| dt.with_timezone(&Utc))
.ok();
let media_type = o.media_type.as_deref().map(OrderMediaType::from_str);
OrderStatus {
order_id: o.order_id,
task_status,
@@ -196,6 +237,7 @@ pub async fn poll_orders(args: PollOrdersArgs<'_>) -> Result<PollOrdersResponse,
fail_reason,
created_at: o.created_at,
created_at_utc,
media_type,
}
})
.collect();
@@ -231,6 +273,163 @@ mod tests {
Ok(Seedance2ProSession::from_cookies_string(cookies))
}
// ── Offline parsing tests against captured responses ──
//
// These verify the deserializer + mapping handle the Midjourney image
// polling shape, including the new `mediaType` field, without breaking
// the older video-only callers.
mod offline_parsing {
use super::*;
/// Helper: take a captured response file with a `Response:` header and
/// extract the first complete JSON array body. Tolerant of whitespace
/// between `[` and `{` (the pretty-printed captures look like
/// `[\n {`).
fn extract_json_body(raw: &str) -> &str {
// The body always sits after the `Response:` header in the capture.
// Anchor the search there so we don't accidentally grab a `[` from
// a header value above.
let after_header = match raw.find("Response") {
Some(i) => &raw[i..],
None => raw,
};
let local_start = after_header.find('[').expect("no `[` found in response body");
let start = (raw.len() - after_header.len()) + local_start;
let mut depth = 0i32;
let mut in_str = false;
let mut esc = false;
let bytes = raw.as_bytes();
for i in start..bytes.len() {
let c = bytes[i] as char;
if in_str {
if esc { esc = false; }
else if c == '\\' { esc = true; }
else if c == '"' { in_str = false; }
} else {
if c == '"' { in_str = true; }
else if c == '[' || c == '{' { depth += 1; }
else if c == ']' || c == '}' {
depth -= 1;
if depth == 0 {
return &raw[start..=i];
}
}
}
}
panic!("never found closing bracket")
}
/// The pretty-printed captures occasionally contain trailing commas
/// before `]` or `}` because the curator excised neighbouring orders
/// by hand. serde_json is strict about JSON, so scrub those out
/// before parsing.
fn strip_trailing_commas(body: &str) -> String {
let mut out = String::with_capacity(body.len());
let mut in_str = false;
let mut esc = false;
let bytes = body.as_bytes();
let mut i = 0;
while i < bytes.len() {
let c = bytes[i] as char;
if in_str {
out.push(c);
if esc { esc = false; }
else if c == '\\' { esc = true; }
else if c == '"' { in_str = false; }
i += 1;
continue;
}
if c == '"' { in_str = true; out.push(c); i += 1; continue; }
if c == ',' {
// Peek ahead past whitespace; if the next non-whitespace char is
// `]` or `}`, drop the comma.
let mut j = i + 1;
while j < bytes.len() && (bytes[j] as char).is_whitespace() { j += 1; }
if j < bytes.len() && (bytes[j] == b']' || bytes[j] == b'}') {
i += 1;
continue;
}
}
out.push(c);
i += 1;
}
out
}
fn parse_orders(raw: &str) -> Vec<OrderStatus> {
let body = extract_json_body(raw);
let body = strip_trailing_commas(body);
let batch: Vec<BatchResponseItem> = serde_json::from_str(&body).expect("parse batch");
let json = batch.into_iter().next().expect("non-empty batch").result.data.json;
json.orders.into_iter().map(|o| {
let task_status = TaskStatus::from_str(&o.task_status);
let fail_reason = match (&o.fail_reason, &task_status) {
(Some(r), _) => Some(FailureReason::from_reason(r)),
(None, TaskStatus::Failed) => Some(FailureReason::from_reason("(no reason)")),
_ => None,
};
let created_at_utc = DateTime::parse_from_rfc3339(&o.created_at)
.map(|dt| dt.with_timezone(&Utc)).ok();
let media_type = o.media_type.as_deref().map(OrderMediaType::from_str);
OrderStatus {
order_id: o.order_id,
task_status,
result_url: o.result_url,
results: o.results.into_iter().map(|r| VideoResult {
url: r.url, width: r.width, height: r.height,
}).collect(),
fail_reason,
created_at: o.created_at,
created_at_utc,
media_type,
}
}).collect()
}
/// Back-compat: when the raw payload omits `mediaType` (older video-only
/// polling), `media_type` parses as `None` and the rest of the order
/// continues to deserialise unchanged.
#[test]
fn omitted_media_type_field_yields_none() {
// Hand-built minimal video-shaped order, no `mediaType` field.
let body = r#"[{"result":{"data":{"json":{"orders":[{
"orderId":"ord_legacy_video",
"resultUrl":null,
"taskStatus":"PROCESSING",
"results":[],
"failReason":null,
"createdAt":"2026-02-19T01:20:50.398Z"
}],"nextCursor":null}}}}]"#;
let orders = parse_orders(body);
assert_eq!(orders.len(), 1);
assert!(orders[0].media_type.is_none(), "legacy responses without mediaType should map to None");
assert_eq!(orders[0].task_status, TaskStatus::Processing);
}
/// Unrecognised mediaType values shouldn't crash deserialisation;
/// they end up as `Unknown(_)`.
#[test]
fn unknown_media_type_value_is_preserved() {
let body = r#"[{"result":{"data":{"json":{"orders":[{
"orderId":"ord_x",
"resultUrl":null,
"taskStatus":"PROCESSING",
"results":[],
"failReason":null,
"createdAt":"2026-02-19T01:20:50.398Z",
"mediaType":"hologram"
}],"nextCursor":null}}}}]"#;
let orders = parse_orders(body);
assert_eq!(orders.len(), 1);
match &orders[0].media_type {
Some(OrderMediaType::Unknown(s)) => assert_eq!(s, "hologram"),
other => panic!("expected Unknown(\"hologram\"), got {:?}", other),
}
}
}
#[tokio::test]
#[ignore] // manually test — requires real cookies
async fn test_poll_all_orders() -> AnyhowResult<()> {
@@ -239,10 +438,25 @@ mod tests {
let result = poll_orders(PollOrdersArgs { session: &session, cursor: None, host_override: None }).await?;
println!("Orders returned: {}", result.orders.len());
println!("Next cursor: {:?}", result.next_cursor);
let mut images = 0;
let mut videos = 0;
let mut unknown = 0;
let mut missing = 0;
for order in &result.orders {
println!(" {} | {:?} | result_url={:?} | fail={:?}",
order.order_id, order.task_status, order.result_url, order.fail_reason);
match &order.media_type {
Some(OrderMediaType::Image) => images += 1,
Some(OrderMediaType::Video) => videos += 1,
Some(OrderMediaType::Unknown(_)) => unknown += 1,
None => missing += 1,
}
println!(" {} | {:?} | media={:?} | results={} | result_url={:?} | fail={:?}",
order.order_id, order.task_status, order.media_type,
order.results.len(), order.result_url, order.fail_reason);
}
println!(
"media_type tally: image={}, video={}, unknown={}, missing={}",
images, videos, unknown, missing,
);
assert_eq!(1, 2); // NB: Intentional failure to inspect output.
Ok(())
}
@@ -258,13 +472,64 @@ mod tests {
println!("Orders returned (with cursor {}): {}", cursor, result.orders.len());
println!("Next cursor: {:?}", result.next_cursor);
for order in &result.orders {
println!(" {} | {:?} | result_url={:?} | fail={:?}",
order.order_id, order.task_status, order.result_url, order.fail_reason);
println!(" {} | {:?} | media={:?} | results={} | result_url={:?} | fail={:?}",
order.order_id, order.task_status, order.media_type,
order.results.len(), order.result_url, order.fail_reason);
}
assert_eq!(1, 2); // NB: Intentional failure to inspect output.
Ok(())
}
/// Page back from the most recent orders until we hit a Midjourney image
/// order, then assert the new parsing path detects it. Bounded to a few
/// pages so this doesn't accidentally exhaust the whole account.
#[tokio::test]
#[ignore] // manually test — requires real cookies
async fn test_poll_back_to_first_image_order() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
const MAX_PAGES: usize = 10;
let mut cursor: Option<u64> = None;
let mut found_image_orders: Vec<OrderStatus> = Vec::new();
let mut pages_scanned = 0usize;
for _ in 0..MAX_PAGES {
let result = poll_orders(PollOrdersArgs {
session: &session, cursor, host_override: None,
}).await?;
pages_scanned += 1;
println!("Page {}: {} orders, next_cursor={:?}", pages_scanned, result.orders.len(), result.next_cursor);
for order in &result.orders {
if order.media_type == Some(OrderMediaType::Image) {
found_image_orders.push(order.clone());
}
}
if !found_image_orders.is_empty() { break; }
cursor = result.next_cursor;
if cursor.is_none() { break; }
}
println!("Scanned {} pages; found {} image order(s)", pages_scanned, found_image_orders.len());
for o in &found_image_orders {
println!(" IMG {} | {:?} | results={} | result_url={:?}",
o.order_id, o.task_status, o.results.len(), o.result_url);
}
assert!(!found_image_orders.is_empty(),
"expected to find at least one image order within {} pages — \
create a Midjourney generation if needed and rerun", MAX_PAGES);
// Each Midjourney task returns exactly 4 images when completed.
if let Some(completed) = found_image_orders.iter().find(|o| o.task_status == TaskStatus::Completed) {
assert_eq!(completed.results.len(), 4,
"completed Midjourney image order should have 4 results, got {}",
completed.results.len());
}
Ok(())
}
#[tokio::test]
#[ignore] // manually test — requires real cookies; exhausts all pages
async fn test_poll_all_pages() -> AnyhowResult<()> {
@@ -31,6 +31,11 @@ pub(super) struct RawOrder {
pub results: Vec<RawVideoResult>,
pub fail_reason: Option<String>,
pub created_at: String,
/// `"image"` for Midjourney image-generation orders, `"video"` for video
/// orders. `None` for older response shapes that didn't include the field.
#[serde(default)]
pub media_type: Option<String>,
}
#[derive(Deserialize, Debug)]
@@ -90,7 +90,11 @@ pub(super) async fn handle_image_edit_artcraft_via_router(
let request = match dor {
ImageGenerationDraftOrRequest::Request(req) => req,
// Artcraft never returns a Draft — image-token resolution happens server-side.
ImageGenerationDraftOrRequest::Draft(d) => match d {},
// The only draft-producing models today are Kinovi-Midjourney variants,
// which are routed to a separate provider.
ImageGenerationDraftOrRequest::Draft(_) => unreachable!(
"Artcraft router should never produce a draft for image edits"
),
};
let response = match request.send_request(&client).await {
@@ -70,7 +70,11 @@ pub(super) async fn handle_text_to_image_artcraft_via_router(
let request = match dor {
ImageGenerationDraftOrRequest::Request(req) => req,
// Artcraft never returns a Draft — image-token resolution happens server-side.
ImageGenerationDraftOrRequest::Draft(d) => match d {},
// The only draft-producing models today are Kinovi-Midjourney variants,
// which are routed to a separate provider.
ImageGenerationDraftOrRequest::Draft(_) => unreachable!(
"Artcraft router should never produce a draft for text-to-image"
),
};
let response = match request.send_request(&client).await {
@@ -58,6 +58,9 @@ fn image_model_to_common_model_type(model: RouterImageModel) -> Option<CommonMod
RouterImageModel::Flux2LoraAngles => Some(CommonModelType::Flux2LoraAngles),
RouterImageModel::GrokImagineImage => Some(CommonModelType::GrokImagineImage),
RouterImageModel::GrokImagineImageQuality => Some(CommonModelType::GrokImagineImageQuality),
RouterImageModel::Midjourney7 => Some(CommonModelType::Midjourney7),
RouterImageModel::Midjourney7Niji => Some(CommonModelType::Midjourney7Niji),
RouterImageModel::Midjourney8 => Some(CommonModelType::Midjourney8),
}
}
@@ -57,7 +57,11 @@ pub async fn handle_flux_2_lora_angles(
let request = match dor {
ImageGenerationDraftOrRequest::Request(req) => req,
// Artcraft-side angle models always return a Request — never a Draft.
ImageGenerationDraftOrRequest::Draft(d) => match d {},
// The only draft-producing models today are Kinovi-Midjourney variants,
// which are routed to a separate provider.
ImageGenerationDraftOrRequest::Draft(_) => unreachable!(
"Artcraft Flux2LoraAngles should never produce a draft"
),
};
let response = request.send_request(&client).await.map_err(|err| {
@@ -57,7 +57,11 @@ pub async fn handle_qwen_edit_2511_angles(
let request = match dor {
ImageGenerationDraftOrRequest::Request(req) => req,
// Artcraft-side angle models always return a Request — never a Draft.
ImageGenerationDraftOrRequest::Draft(d) => match d {},
// The only draft-producing models today are Kinovi-Midjourney variants,
// which are routed to a separate provider.
ImageGenerationDraftOrRequest::Draft(_) => unreachable!(
"Artcraft QwenEdit2511Angles should never produce a draft"
),
};
let response = request.send_request(&client).await.map_err(|err| {
@@ -70,28 +70,46 @@ pub async fn insert_generic_inference_job_for_seedance2pro_queue_with_apriori_jo
) -> Result<InferenceJobToken, DatabaseQueryError>
where E: 'e + Executor<'c, Database = MySql>
{
let is_image_model = match args.maybe_model_type {
Some(CommonModelType::Midjourney7) |
Some(CommonModelType::Midjourney7Niji) |
Some(CommonModelType::Midjourney8) => true,
_ => false,
};
let (
job_type,
external_third_party,
product_category
) = match args.kinovi_version {
KinoviVersion::Volcengine => (
) = match (args.kinovi_version, is_image_model) {
(KinoviVersion::Volcengine, true) => (
InferenceJobType::Seedance2ProQueue,
InferenceJobExternalThirdParty::Seedance2Pro,
InferenceJobProductCategory::Seedance2ProVideo,
),
KinoviVersion::BytePlus => (
(KinoviVersion::Volcengine, false) => (
InferenceJobType::Seedance2ProQueue,
InferenceJobExternalThirdParty::Seedance2Pro,
InferenceJobProductCategory::Seedance2ProImage,
),
(KinoviVersion::BytePlus, _) => (
InferenceJobType::Seedance2ProAltQueue,
InferenceJobExternalThirdParty::Seedance2ProAlt,
InferenceJobProductCategory::Seedance2ProVideoAlt,
),
KinoviVersion::BytePlusUltra => (
(KinoviVersion::BytePlusUltra, _) => (
InferenceJobType::Seedance2ProBytePlusUltraQueue,
InferenceJobExternalThirdParty::Seedance2ProBytePlusUltra,
InferenceJobProductCategory::Seedance2ProVideoBytePlusUltra,
),
};
let inference_category = if is_image_model {
InferenceCategory::ImageGeneration
} else {
InferenceCategory::VideoGeneration
};
let record_id = insert_generic_inference_job_for_provider(InsertGenericInferenceJobForProviderArgs {
apriori_job_token: args.apriori_job_token,
uuid_idempotency_token: args.uuid_idempotency_token,
@@ -99,7 +117,7 @@ pub async fn insert_generic_inference_job_for_seedance2pro_queue_with_apriori_jo
external_third_party,
external_third_party_id: args.maybe_external_third_party_id,
product_category,
inference_category: InferenceCategory::VideoGeneration,
inference_category,
maybe_model_type: args.maybe_model_type,
maybe_prompt_token: args.maybe_prompt_token,
maybe_wallet_ledger_entry_token: args.maybe_wallet_ledger_entry_token,
@@ -1,6 +1,7 @@
pub mod user;
pub mod user_badges;
pub mod user_bookmarks;
pub mod user_email_changes;
pub mod user_password_resets;
pub mod user_profiles;
pub mod user_ratings;
@@ -0,0 +1,59 @@
use std::marker::PhantomData;
use sqlx::{Executor, MySql};
use tokens::tokens::users::UserToken;
pub struct InsertUserEmailChangeArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
/// The user whose email was changed.
pub user_token: &'e UserToken,
/// The email address before the change.
pub old_email: &'e str,
/// The email address after the change.
pub new_email: &'e str,
/// The IP address the change was made from.
pub ip_address: &'e str,
/// The user that performed the change, if known. `None` when the change
/// was performed by the user themselves, by an automated process, or in
/// any other context where there is no acting user.
pub maybe_changed_by_user_token: Option<&'e UserToken>,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
/// Insert a single `user_email_changes` row recording an email-address change.
pub async fn insert_user_email_change<'e, 'c: 'e, E>(
args: InsertUserEmailChangeArgs<'e, 'c, E>,
) -> Result<(), sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
sqlx::query!(
r#"
INSERT INTO user_email_changes
SET
user_token = ?,
old_email = ?,
new_email = ?,
ip_address = ?,
maybe_changed_by_user_token = ?
"#,
args.user_token.as_str(),
args.old_email,
args.new_email,
args.ip_address,
args.maybe_changed_by_user_token.map(|t| t.as_str()),
)
.execute(args.mysql_executor)
.await?;
Ok(())
}
@@ -0,0 +1,208 @@
use std::marker::PhantomData;
use chrono::{DateTime, Utc};
use sqlx::{Executor, MySql};
use tokens::tokens::users::UserToken;
pub struct ListUserEmailChangesForUserArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
/// The user whose email-change history is being listed.
pub user_token: &'e UserToken,
/// When `Some`, only return rows with `id < maybe_cursor_id`. Used to
/// page backward through the descending-by-id list.
pub maybe_cursor_id: Option<u64>,
/// Maximum number of rows to return.
pub limit: u32,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
#[derive(Debug)]
pub struct UserEmailChangeRow {
pub id: u64,
pub old_email: String,
pub new_email: String,
pub ip_address: String,
pub created_at: DateTime<Utc>,
/// The user whose email was changed (the subject of the audit row).
pub user: UserDisplay,
/// The user who performed the change. `None` for self-service changes
/// (where `user_email_changes.maybe_changed_by_user_token` is `NULL`) or
/// when the referenced user row has been hard-deleted.
pub maybe_changed_by_user: Option<UserDisplay>,
}
/// Denormalized display fields for a `users` row, joined into a query result.
#[derive(Debug)]
pub struct UserDisplay {
pub token: UserToken,
pub username: String,
pub display_name: String,
pub gravatar_hash: String,
}
#[derive(Debug)]
struct RawUserEmailChangeRow {
id: u64,
old_email: String,
new_email: String,
ip_address: String,
created_at: DateTime<Utc>,
user_token: UserToken,
user_username: String,
user_display_name: String,
user_gravatar_hash: String,
maybe_changed_by_user_token: Option<UserToken>,
maybe_changed_by_user_username: Option<String>,
maybe_changed_by_user_display_name: Option<String>,
maybe_changed_by_user_gravatar_hash: Option<String>,
}
/// Return `user_email_changes` rows for the given user, newest first,
/// capped at `limit`. When `maybe_cursor_id` is set, only rows with `id`
/// strictly less than the cursor are returned — pass the last id from the
/// previous page to walk backwards through history.
///
/// Joins `users` twice to denormalize the token, username, display name,
/// and gravatar hash for both the subject (`user_token`) and the acting
/// user (`maybe_changed_by_user_token`). The actor join is a LEFT JOIN so
/// rows where there is no acting user (e.g. self-service changes) still
/// come back.
pub async fn list_user_email_changes_for_user<'e, 'c: 'e, E>(
args: ListUserEmailChangesForUserArgs<'e, 'c, E>,
) -> Result<Vec<UserEmailChangeRow>, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
let limit = args.limit as i64;
// Two query branches because sqlx::query_as! can't accept conditional
// WHERE clauses at the macro level. `args.mysql_executor` is moved into
// exactly one arm at runtime — Rust permits this since the arms are
// mutually exclusive.
let rows = match args.maybe_cursor_id {
Some(cursor_id) => {
sqlx::query_as!(
RawUserEmailChangeRow,
r#"
SELECT
uec.id as `id: u64`,
uec.old_email,
uec.new_email,
uec.ip_address,
uec.created_at,
u_subject.token as `user_token: tokens::tokens::users::UserToken`,
u_subject.username as user_username,
u_subject.display_name as user_display_name,
u_subject.email_gravatar_hash as user_gravatar_hash,
u_changer.token as `maybe_changed_by_user_token?: tokens::tokens::users::UserToken`,
u_changer.username as `maybe_changed_by_user_username?`,
u_changer.display_name as `maybe_changed_by_user_display_name?`,
u_changer.email_gravatar_hash as `maybe_changed_by_user_gravatar_hash?`
FROM user_email_changes AS uec
JOIN users AS u_subject
ON uec.user_token = u_subject.token
LEFT JOIN users AS u_changer
ON uec.maybe_changed_by_user_token = u_changer.token
WHERE uec.user_token = ?
AND uec.id < ?
ORDER BY uec.id DESC
LIMIT ?
"#,
args.user_token.as_str(),
cursor_id,
limit,
)
.fetch_all(args.mysql_executor)
.await?
}
None => {
sqlx::query_as!(
RawUserEmailChangeRow,
r#"
SELECT
uec.id as `id: u64`,
uec.old_email,
uec.new_email,
uec.ip_address,
uec.created_at,
u_subject.token as `user_token: tokens::tokens::users::UserToken`,
u_subject.username as user_username,
u_subject.display_name as user_display_name,
u_subject.email_gravatar_hash as user_gravatar_hash,
u_changer.token as `maybe_changed_by_user_token?: tokens::tokens::users::UserToken`,
u_changer.username as `maybe_changed_by_user_username?`,
u_changer.display_name as `maybe_changed_by_user_display_name?`,
u_changer.email_gravatar_hash as `maybe_changed_by_user_gravatar_hash?`
FROM user_email_changes AS uec
JOIN users AS u_subject
ON uec.user_token = u_subject.token
LEFT JOIN users AS u_changer
ON uec.maybe_changed_by_user_token = u_changer.token
WHERE uec.user_token = ?
ORDER BY uec.id DESC
LIMIT ?
"#,
args.user_token.as_str(),
limit,
)
.fetch_all(args.mysql_executor)
.await?
}
};
let results = rows.into_iter().map(|row| {
let user = UserDisplay {
token: row.user_token,
username: row.user_username,
display_name: row.user_display_name,
gravatar_hash: row.user_gravatar_hash,
};
// The four actor columns come from the same LEFT JOIN row, so they are
// either all `Some` (matched user row) or all `None` (no actor token or
// the user row was hard-deleted) together.
let maybe_changed_by_user = match (
row.maybe_changed_by_user_token,
row.maybe_changed_by_user_username,
row.maybe_changed_by_user_display_name,
row.maybe_changed_by_user_gravatar_hash,
) {
(Some(token), Some(username), Some(display_name), Some(gravatar_hash)) => Some(UserDisplay {
token,
username,
display_name,
gravatar_hash,
}),
_ => None,
};
UserEmailChangeRow {
id: row.id,
old_email: row.old_email,
new_email: row.new_email,
ip_address: row.ip_address,
created_at: row.created_at,
user,
maybe_changed_by_user,
}
}).collect();
Ok(results)
}
@@ -0,0 +1,2 @@
pub mod insert_user_email_change;
pub mod list_user_email_changes_for_user;
@@ -53,6 +53,10 @@ pub enum InferenceJobProductCategory {
#[serde(rename = "seedance2pro_character")]
Seedance2ProCharacter,
/// Seedance 2 Pro / Kinovi: Midjourney image generation.
#[serde(rename = "seedance2pro_image")]
Seedance2ProImage,
// =============== SEEDANCE 2 PRO ALT ===============
#[serde(rename = "seedance2pro_video_alt")]
@@ -169,6 +173,7 @@ impl InferenceJobProductCategory {
Self::BeebleVideo => "beeble_video",
Self::Seedance2ProVideo => "seedance2pro_video",
Self::Seedance2ProCharacter => "seedance2pro_character",
Self::Seedance2ProImage => "seedance2pro_image",
Self::Seedance2ProVideoAlt => "seedance2pro_video_alt",
Self::Seedance2ProVideoBytePlusUltra => "seedance2pro_video_bpu",
Self::WorldlabsSplat => "worldlabs_splat",
@@ -209,6 +214,7 @@ impl InferenceJobProductCategory {
"beeble_video" => Ok(Self::BeebleVideo),
"seedance2pro_video" => Ok(Self::Seedance2ProVideo),
"seedance2pro_character" => Ok(Self::Seedance2ProCharacter),
"seedance2pro_image" => Ok(Self::Seedance2ProImage),
"seedance2pro_video_alt" => Ok(Self::Seedance2ProVideoAlt),
"seedance2pro_video_bpu" => Ok(Self::Seedance2ProVideoBytePlusUltra),
"worldlabs_splat" => Ok(Self::WorldlabsSplat),
@@ -252,6 +258,7 @@ impl InferenceJobProductCategory {
Self::BeebleVideo,
Self::Seedance2ProVideo,
Self::Seedance2ProCharacter,
Self::Seedance2ProImage,
Self::Seedance2ProVideoAlt,
Self::Seedance2ProVideoBytePlusUltra,
Self::WorldlabsSplat,
@@ -301,6 +308,7 @@ mod tests {
assert_serialization(InferenceJobProductCategory::BeebleVideo, "beeble_video");
assert_serialization(InferenceJobProductCategory::Seedance2ProVideo, "seedance2pro_video");
assert_serialization(InferenceJobProductCategory::Seedance2ProCharacter, "seedance2pro_character");
assert_serialization(InferenceJobProductCategory::Seedance2ProImage, "seedance2pro_image");
assert_serialization(InferenceJobProductCategory::Seedance2ProVideoAlt, "seedance2pro_video_alt");
assert_serialization(InferenceJobProductCategory::Seedance2ProVideoBytePlusUltra, "seedance2pro_video_bpu");
assert_serialization(InferenceJobProductCategory::WorldlabsSplat, "worldlabs_splat");
@@ -340,6 +348,7 @@ mod tests {
assert_eq!(InferenceJobProductCategory::BeebleVideo.to_str(), "beeble_video");
assert_eq!(InferenceJobProductCategory::Seedance2ProVideo.to_str(), "seedance2pro_video");
assert_eq!(InferenceJobProductCategory::Seedance2ProCharacter.to_str(), "seedance2pro_character");
assert_eq!(InferenceJobProductCategory::Seedance2ProImage.to_str(), "seedance2pro_image");
assert_eq!(InferenceJobProductCategory::Seedance2ProVideoAlt.to_str(), "seedance2pro_video_alt");
assert_eq!(InferenceJobProductCategory::Seedance2ProVideoBytePlusUltra.to_str(), "seedance2pro_video_bpu");
assert_eq!(InferenceJobProductCategory::WorldlabsSplat.to_str(), "worldlabs_splat");
@@ -378,6 +387,7 @@ mod tests {
assert_eq!(InferenceJobProductCategory::from_str("beeble_video").unwrap(), InferenceJobProductCategory::BeebleVideo);
assert_eq!(InferenceJobProductCategory::from_str("seedance2pro_video").unwrap(), InferenceJobProductCategory::Seedance2ProVideo);
assert_eq!(InferenceJobProductCategory::from_str("seedance2pro_character").unwrap(), InferenceJobProductCategory::Seedance2ProCharacter);
assert_eq!(InferenceJobProductCategory::from_str("seedance2pro_image").unwrap(), InferenceJobProductCategory::Seedance2ProImage);
assert_eq!(InferenceJobProductCategory::from_str("seedance2pro_video_alt").unwrap(), InferenceJobProductCategory::Seedance2ProVideoAlt);
assert_eq!(InferenceJobProductCategory::from_str("seedance2pro_video_bpu").unwrap(), InferenceJobProductCategory::Seedance2ProVideoBytePlusUltra);
assert_eq!(InferenceJobProductCategory::from_str("worldlabs_splat").unwrap(), InferenceJobProductCategory::WorldlabsSplat);
@@ -407,7 +417,7 @@ mod tests {
#[test]
fn all_variants() {
// Static check
const EXPECTED_COUNT : usize = 35;
const EXPECTED_COUNT : usize = 36;
assert_eq!(InferenceJobProductCategory::all_variants().len(), EXPECTED_COUNT);
assert_eq!(InferenceJobProductCategory::iter().len(), EXPECTED_COUNT);
@@ -143,6 +143,15 @@ pub enum InferenceModelType {
MidjourneyV7DraftRaw,
#[serde(rename = "midjourney_v7_raw")]
MidjourneyV7Raw,
/// Midjourney v7 as routed via kinovi
#[serde(rename = "midjourney_7")]
Midjourney7,
/// Midjourney v7 niji as routed via kinovi
#[serde(rename = "midjourney_7_niji")]
Midjourney7Niji,
/// Midjourney v8 as routed via kinovi
#[serde(rename = "midjourney_8")]
Midjourney8,
// Video models
#[serde(rename = "grok_video")]
@@ -284,6 +293,9 @@ impl InferenceModelType {
Self::MidjourneyV7Draft => "midjourney_v7_draft",
Self::MidjourneyV7DraftRaw => "midjourney_v7_draft_raw",
Self::MidjourneyV7Raw => "midjourney_v7_raw",
Self::Midjourney7 => "midjourney_7",
Self::Midjourney7Niji => "midjourney_7_niji",
Self::Midjourney8 => "midjourney_8",
// Video models
Self::GrokVideo => "grok_video",
@@ -383,6 +395,9 @@ impl InferenceModelType {
"midjourney_v7_draft" => Ok(Self::MidjourneyV7Draft),
"midjourney_v7_draft_raw" => Ok(Self::MidjourneyV7DraftRaw),
"midjourney_v7_raw" => Ok(Self::MidjourneyV7Raw),
"midjourney_7" => Ok(Self::Midjourney7),
"midjourney_7_niji" => Ok(Self::Midjourney7Niji),
"midjourney_8" => Ok(Self::Midjourney8),
// Video models
"grok_video" => Ok(Self::GrokVideo),
@@ -486,6 +501,9 @@ impl InferenceModelType {
Self::MidjourneyV7Draft,
Self::MidjourneyV7DraftRaw,
Self::MidjourneyV7Raw,
Self::Midjourney7,
Self::Midjourney7Niji,
Self::Midjourney8,
// Video models
Self::GrokVideo,
@@ -572,6 +590,9 @@ impl InferenceModelType {
CommonModelType::MidjourneyV7Draft => Self::MidjourneyV7Draft,
CommonModelType::MidjourneyV7DraftRaw => Self::MidjourneyV7DraftRaw,
CommonModelType::MidjourneyV7Raw => Self::MidjourneyV7Raw,
CommonModelType::Midjourney7 => Self::Midjourney7,
CommonModelType::Midjourney7Niji => Self::Midjourney7Niji,
CommonModelType::Midjourney8 => Self::Midjourney8,
// Video models
CommonModelType::GrokVideo => Self::GrokVideo,
@@ -681,6 +702,9 @@ mod tests {
assert_serialization(InferenceModelType::MidjourneyV7Draft, "midjourney_v7_draft");
assert_serialization(InferenceModelType::MidjourneyV7DraftRaw, "midjourney_v7_draft_raw");
assert_serialization(InferenceModelType::MidjourneyV7Raw, "midjourney_v7_raw");
assert_serialization(InferenceModelType::Midjourney7, "midjourney_7");
assert_serialization(InferenceModelType::Midjourney7Niji, "midjourney_7_niji");
assert_serialization(InferenceModelType::Midjourney8, "midjourney_8");
// Video models
assert_serialization(InferenceModelType::GrokVideo, "grok_video");
@@ -779,6 +803,9 @@ mod tests {
assert_eq!(InferenceModelType::MidjourneyV7Draft.to_str(), "midjourney_v7_draft");
assert_eq!(InferenceModelType::MidjourneyV7DraftRaw.to_str(), "midjourney_v7_draft_raw");
assert_eq!(InferenceModelType::MidjourneyV7Raw.to_str(), "midjourney_v7_raw");
assert_eq!(InferenceModelType::Midjourney7.to_str(), "midjourney_7");
assert_eq!(InferenceModelType::Midjourney7Niji.to_str(), "midjourney_7_niji");
assert_eq!(InferenceModelType::Midjourney8.to_str(), "midjourney_8");
// Video models
assert_eq!(InferenceModelType::GrokVideo.to_str(), "grok_video");
@@ -877,6 +904,9 @@ mod tests {
assert_eq!(InferenceModelType::from_str("midjourney_v7_draft").unwrap(), InferenceModelType::MidjourneyV7Draft);
assert_eq!(InferenceModelType::from_str("midjourney_v7_draft_raw").unwrap(), InferenceModelType::MidjourneyV7DraftRaw);
assert_eq!(InferenceModelType::from_str("midjourney_v7_raw").unwrap(), InferenceModelType::MidjourneyV7Raw);
assert_eq!(InferenceModelType::from_str("midjourney_7").unwrap(), InferenceModelType::Midjourney7);
assert_eq!(InferenceModelType::from_str("midjourney_7_niji").unwrap(), InferenceModelType::Midjourney7Niji);
assert_eq!(InferenceModelType::from_str("midjourney_8").unwrap(), InferenceModelType::Midjourney8);
// Video models
assert_eq!(InferenceModelType::from_str("grok_video").unwrap(), InferenceModelType::GrokVideo);
@@ -40,6 +40,10 @@ pub enum StaffAuditAction {
/// Staff edited a user's feature flags.
#[serde(rename = "edit_user_feature_flags")]
EditUserFeatureFlags,
/// Staff changed a user's email address.
#[serde(rename = "change_user_email")]
ChangeUserEmail,
}
impl_enum_display_and_debug_using_to_str!(StaffAuditAction);
@@ -56,6 +60,7 @@ impl StaffAuditAction {
Self::AddWalletBankedBalance => "add_wallet_banked_balance",
Self::SendAlert => "send_alert",
Self::EditUserFeatureFlags => "edit_user_feature_flags",
Self::ChangeUserEmail => "change_user_email",
}
}
@@ -68,6 +73,7 @@ impl StaffAuditAction {
"add_wallet_banked_balance" => Ok(Self::AddWalletBankedBalance),
"send_alert" => Ok(Self::SendAlert),
"edit_user_feature_flags" => Ok(Self::EditUserFeatureFlags),
"change_user_email" => Ok(Self::ChangeUserEmail),
_ => Err(format!("invalid StaffAuditAction value: {:?}", value)),
}
}
@@ -81,6 +87,7 @@ impl StaffAuditAction {
Self::AddWalletBankedBalance,
Self::SendAlert,
Self::EditUserFeatureFlags,
Self::ChangeUserEmail,
])
}
}
@@ -102,6 +109,7 @@ mod tests {
assert_serialization(StaffAuditAction::AddWalletBankedBalance, "add_wallet_banked_balance");
assert_serialization(StaffAuditAction::SendAlert, "send_alert");
assert_serialization(StaffAuditAction::EditUserFeatureFlags, "edit_user_feature_flags");
assert_serialization(StaffAuditAction::ChangeUserEmail, "change_user_email");
}
#[test]
@@ -113,6 +121,7 @@ mod tests {
assert_eq!(StaffAuditAction::AddWalletBankedBalance.to_str(), "add_wallet_banked_balance");
assert_eq!(StaffAuditAction::SendAlert.to_str(), "send_alert");
assert_eq!(StaffAuditAction::EditUserFeatureFlags.to_str(), "edit_user_feature_flags");
assert_eq!(StaffAuditAction::ChangeUserEmail.to_str(), "change_user_email");
}
#[test]
@@ -124,12 +133,13 @@ mod tests {
assert_eq!(StaffAuditAction::from_str("add_wallet_banked_balance").unwrap(), StaffAuditAction::AddWalletBankedBalance);
assert_eq!(StaffAuditAction::from_str("send_alert").unwrap(), StaffAuditAction::SendAlert);
assert_eq!(StaffAuditAction::from_str("edit_user_feature_flags").unwrap(), StaffAuditAction::EditUserFeatureFlags);
assert_eq!(StaffAuditAction::from_str("change_user_email").unwrap(), StaffAuditAction::ChangeUserEmail);
assert!(StaffAuditAction::from_str("invalid").is_err());
}
#[test]
fn all_variants() {
const EXPECTED_COUNT: usize = 7;
const EXPECTED_COUNT: usize = 8;
assert_eq!(StaffAuditAction::all_variants().len(), EXPECTED_COUNT);
}
}
@@ -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,52 +1,121 @@
use log::{error, warn};
use log::{error, info, warn};
use enums::by_table::generic_inference_jobs::frontend_failure_category::FrontendFailureCategory;
use grok_api_client::api::requests::videos::video_status::video_status::FailureReason;
use grok_api_client::error::grok_error::GrokError;
use grok_api_client::error::grok_specific_api_error::GrokSpecificApiError;
use mysql_queries::queries::generic_inference::api_providers::grok_api::list_pending_grok_api_jobs::PendingGrokApiJob;
use mysql_queries::queries::generic_inference::job::mark_job_failed_by_token::{mark_job_failed_by_token, MarkJobFailedByTokenArgs};
use crate::job_dependencies::JobDependencies;
/// Mark the job failed in the database.
/// Handle a `VideoStatus::Failed` poll response: format the reason, derive
/// the user-facing failure category, mark the job failed in the database,
/// and increment the job-stats failure counter.
///
/// Unlike Seedance2Pro/Kinovi, Grok API video jobs are **non-refundable** —
/// xAI's video generation is billed regardless of outcome, so we never issue
/// a wallet refund here.
pub async fn process_failed_job(
/// Grok API video jobs are **non-refundable** — xAI's video generation is
/// billed regardless of outcome, so we never issue a wallet refund here.
pub async fn process_failed_status(
deps: &JobDependencies,
job: &PendingGrokApiJob,
reason: &str,
reason: FailureReason,
maybe_code: Option<&str>,
maybe_error: Option<&str>,
) {
let reason_lower = reason.to_lowercase();
let (reason_text, category) = describe_failed_status(reason, maybe_code, maybe_error);
let platform_rules_violation = reason_lower.contains("moderated")
|| reason_lower.contains("moderation")
|| reason_lower.contains("violates")
|| reason_lower.contains("platform rules")
|| reason_lower.contains("content policy");
info!(
"Grok request {} for job {} reported failed: {}.",
job.request_id, job.job_token.as_str(), reason_text,
);
let frontend_failure_category = if platform_rules_violation {
Some(FrontendFailureCategory::ModelRulesViolation)
} else {
Some(FrontendFailureCategory::GenerationFailed)
};
mark_job_failed_with_category(deps, job, &reason_text, category).await;
}
/// Handle a `GrokError` returned by the video-status poll. `NotFound` is
/// terminal (xAI no longer knows about this `request_id`); everything else
/// is treated as transient and just logged so the next poll retries.
///
/// Note: a true `status:"expired"` poll response comes back as
/// `VideoStatus::Failed` and is handled by [`process_failed_status`], not
/// here.
pub async fn process_video_status_error(
deps: &JobDependencies,
job: &PendingGrokApiJob,
err: GrokError,
) {
match &err {
GrokError::ApiSpecific(GrokSpecificApiError::NotFound) => {
let reason_text = "Grok video job not found (likely expired)";
info!(
"Grok request {} for job {} not found. Marking job failed.",
job.request_id, job.job_token.as_str(),
);
mark_job_failed_with_category(
deps,
job,
reason_text,
FrontendFailureCategory::GenerationFailed,
).await;
}
_ => {
warn!(
"Transient error polling Grok request {} for job {}: {:?}",
job.request_id, job.job_token.as_str(), err,
);
}
}
}
fn describe_failed_status(
reason: FailureReason,
maybe_code: Option<&str>,
maybe_error: Option<&str>,
) -> (String, FrontendFailureCategory) {
match reason {
FailureReason::ContentModerated => (
format!(
"Grok video content moderated: {}",
maybe_error.unwrap_or("no details"),
),
FrontendFailureCategory::ModelRulesViolation,
),
FailureReason::Unknown => {
let code_part = maybe_code.unwrap_or("unknown");
let error_part = maybe_error.unwrap_or("no details");
(
format!("Grok video failed ({}): {}", code_part, error_part),
FrontendFailureCategory::GenerationFailed,
)
}
}
}
async fn mark_job_failed_with_category(
deps: &JobDependencies,
job: &PendingGrokApiJob,
reason_text: &str,
category: FrontendFailureCategory,
) {
warn!(
"Request for job {} failed: {}. Marking job failed.",
job.job_token.as_str(), reason
job.job_token.as_str(), reason_text,
);
let mark_failed_result = mark_job_failed_by_token(MarkJobFailedByTokenArgs {
pool: &deps.mysql_pool,
job_token: &job.job_token,
maybe_public_failure_reason: Some(reason),
internal_debugging_failure_reason: reason,
maybe_frontend_failure_category: frontend_failure_category,
maybe_public_failure_reason: Some(reason_text),
internal_debugging_failure_reason: reason_text,
maybe_frontend_failure_category: Some(category),
}).await;
if let Err(err) = mark_failed_result {
error!(
"Error marking job {} as failed: {:?}",
job.job_token.as_str(), err
job.job_token.as_str(), err,
);
}
let _ = deps.job_stats.increment_failure_count();
}
@@ -1,5 +1,5 @@
use anyhow::anyhow;
use log::{error, info};
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;
@@ -9,6 +9,7 @@ use enums::by_table::media_files::media_file_origin_product_category::MediaFileO
use enums::by_table::media_files::media_file_type::MediaFileType;
use enums::common::generation_provider::GenerationProvider;
use errors::AnyhowResult;
use grok_api_client::api::requests::videos::video_status::video_status::VideoOutputInfo;
use hashing::sha256::sha256_hash_bytes::sha256_hash_bytes;
use mysql_queries::queries::generic_inference::api_providers::grok_api::list_pending_grok_api_jobs::PendingGrokApiJob;
use mysql_queries::queries::generic_inference::web::mark_generic_inference_job_successfully_done_by_token::mark_generic_inference_job_successfully_done_by_token;
@@ -20,9 +21,49 @@ use crate::jobs::video_polling_job::alert_on_error::alert_pager_and_return_err;
const VIDEO_PREFIX: &str = "artcraft_";
const VIDEO_SUFFIX: &str = ".mp4";
/// Handle a `VideoStatus::Complete` poll response: extract the video URL,
/// download and persist the video, and bump the job-stats counter. If the
/// `Complete` response is missing a `video.url`, log and skip (the job
/// stays pending so a later poll can retry). Internal failures during
/// download / upload / mark-done bump the failure counter.
pub async fn process_complete_response(
deps: &JobDependencies,
job: &PendingGrokApiJob,
maybe_video: Option<VideoOutputInfo>,
) {
let video_url = match maybe_video.as_ref().and_then(|v| v.url.clone()) {
Some(url) => url,
None => {
warn!(
"Grok request {} reported Complete with no video.url for job {}. Skipping.",
job.request_id, job.job_token.as_str(),
);
return;
}
};
info!(
"Grok request {} completed, processing job {}.",
job.request_id, job.job_token.as_str(),
);
match download_and_finalize_video(deps, job, &video_url).await {
Ok(()) => {
let _ = deps.job_stats.increment_success_count();
}
Err(err) => {
warn!(
"Error processing completed Grok request {} for job {}: {:?}",
job.request_id, job.job_token.as_str(), err,
);
let _ = deps.job_stats.increment_failure_count();
}
}
}
/// Download the completed video from xAI, upload to bucket, create media file
/// record, and mark the job done.
pub async fn process_successful_job(
async fn download_and_finalize_video(
deps: &JobDependencies,
job: &PendingGrokApiJob,
video_url: &str,
@@ -3,17 +3,17 @@ use std::time::Duration;
use log::{error, info, warn};
use grok_api_client::api::requests::videos::video_status::video_status::{
video_status, VideoStatusArgs, VideoStatusRequest, VideoStatusState, VideoStatusSuccess,
video_status, VideoStatus, VideoStatusArgs, VideoStatusRequest,
};
use grok_api_client::error::grok_error::GrokError;
use grok_api_client::error::grok_specific_api_error::GrokSpecificApiError;
use mysql_queries::queries::generic_inference::api_providers::grok_api::list_pending_grok_api_jobs::{
list_pending_grok_api_jobs, PendingGrokApiJob,
};
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_job::process_successful_job;
use crate::jobs::video_polling_job::process_job::process_failed_job::{
process_failed_status, process_video_status_error,
};
use crate::jobs::video_polling_job::process_job::process_successful_job::process_complete_response;
pub async fn video_polling_main_loop(job_dependencies: JobDependencies) {
while !job_dependencies.application_shutdown.get() {
@@ -66,115 +66,27 @@ async fn poll_one_job(deps: &JobDependencies, job: &PendingGrokApiJob) {
},
}).await;
let success = match poll_result {
Ok(success) => success,
let response = match poll_result {
Ok(response) => response,
Err(err) => {
handle_video_status_error(deps, job, err).await;
process_video_status_error(deps, job, err).await;
return;
}
};
match success.state {
VideoStatusState::Pending => {
let progress = success.progress.map(|p| format!("{}%", p)).unwrap_or_else(|| "?".to_string());
match response.status {
VideoStatus::Pending { progress } => {
let progress_str = progress.map(|p| format!("{}%", p)).unwrap_or_else(|| "?".to_string());
info!(
"Grok request {} for job {} still pending ({} progress).",
job.request_id, job.job_token.as_str(), progress,
job.request_id, job.job_token.as_str(), progress_str,
);
}
VideoStatusState::Done => {
finalize_done_response(deps, job, success).await;
VideoStatus::Complete { video, .. } => {
process_complete_response(deps, job, video).await;
}
VideoStatusState::Failed => {
// The Failed variant is mapped into `GrokError::ApiSpecific(VideoJobFailed)`
// by the client. We shouldn't hit this branch, but treat defensively.
warn!(
"Unexpected VideoStatusState::Failed surfaced as Ok for request {} (job {}).",
job.request_id, job.job_token.as_str(),
);
process_failed_job(deps, job, "Grok video status reported failed").await;
let _ = deps.job_stats.increment_failure_count();
}
}
}
async fn finalize_done_response(
deps: &JobDependencies,
job: &PendingGrokApiJob,
success: VideoStatusSuccess,
) {
let video_url = match success.video.as_ref().and_then(|v| v.url.clone()) {
Some(url) => url,
None => {
warn!(
"Grok request {} reported Done with no video.url for job {}. Skipping.",
job.request_id, job.job_token.as_str(),
);
return;
}
};
info!(
"Grok request {} completed, processing job {}.",
job.request_id, job.job_token.as_str(),
);
match process_successful_job(deps, job, &video_url).await {
Ok(()) => {
let _ = deps.job_stats.increment_success_count();
}
Err(err) => {
warn!(
"Error processing completed Grok request {} for job {}: {:?}",
job.request_id, job.job_token.as_str(), err,
);
let _ = deps.job_stats.increment_failure_count();
}
}
}
async fn handle_video_status_error(
deps: &JobDependencies,
job: &PendingGrokApiJob,
err: GrokError,
) {
match &err {
// Terminal: mark the job failed.
GrokError::ApiSpecific(GrokSpecificApiError::VideoJobFailed { code, message }) => {
let reason = format!("Grok video failed ({}): {}", code, message);
info!(
"Grok request {} for job {} reported failed. Marking job failed.",
job.request_id, job.job_token.as_str(),
);
process_failed_job(deps, job, &reason).await;
let _ = deps.job_stats.increment_failure_count();
}
GrokError::ApiSpecific(GrokSpecificApiError::VideoJobExpired) => {
let reason = "Grok video job expired";
info!(
"Grok request {} for job {} expired. Marking job failed.",
job.request_id, job.job_token.as_str(),
);
process_failed_job(deps, job, reason).await;
let _ = deps.job_stats.increment_failure_count();
}
GrokError::ApiSpecific(GrokSpecificApiError::NotFound) => {
// Treat NotFound as terminal — xAI doesn't know about this request_id
// anymore (expired retention or never existed).
let reason = "Grok video job not found (likely expired)";
info!(
"Grok request {} for job {} not found. Marking job failed.",
job.request_id, job.job_token.as_str(),
);
process_failed_job(deps, job, reason).await;
let _ = deps.job_stats.increment_failure_count();
}
// Transient: just log and let the next iteration retry.
_ => {
warn!(
"Transient error polling Grok request {} for job {}: {:?}",
job.request_id, job.job_token.as_str(), err,
);
VideoStatus::Failed { reason, code, error, .. } => {
process_failed_status(deps, job, reason, code.as_deref(), error.as_deref()).await;
}
}
}
@@ -1,2 +1,3 @@
pub mod process_failed_job;
pub mod process_successful_image_job;
pub mod process_successful_job;
@@ -0,0 +1,218 @@
use anyhow::anyhow;
use log::{error, info, warn};
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
use enums::by_table::generic_inference_jobs::inference_result_type::InferenceResultType;
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory;
use enums::by_table::media_files::media_file_origin_product_category::MediaFileOriginProductCategory;
use enums::by_table::media_files::media_file_type::MediaFileType;
use enums::common::generation_provider::GenerationProvider;
use errors::AnyhowResult;
use hashing::sha256::sha256_hash_bytes::sha256_hash_bytes;
use mysql_queries::queries::generic_inference::api_providers::seedance2pro::list_pending_seedance2pro_video_jobs::PendingSeedance2ProJob;
use mysql_queries::queries::generic_inference::web::mark_generic_inference_job_successfully_done_by_token::mark_generic_inference_job_successfully_done_by_token;
use mysql_queries::queries::media_files::create::insert_builder::media_file_insert_builder::MediaFileInsertBuilder;
use seedance2pro_client::requests::poll_orders::poll_orders::{OrderStatus, VideoResult};
use tokens::tokens::batch_generations::BatchGenerationToken;
use tokens::tokens::media_files::MediaFileToken;
use crate::job_dependencies::JobDependencies;
use crate::jobs::video_polling_job::alert_on_error::alert_pager_and_return_err;
const PREFIX: &str = "artcraft_";
const SUFFIX: &str = ".png";
const MIME_TYPE: &str = "image/png";
/// Process a completed Midjourney image order from Kinovi.
///
/// Each completed Midjourney task returns up to four images (one per
/// position in the 2×2 grid). We download each, upload to the public
/// bucket, and insert a `media_files` row per image. The inference job
/// row is marked successful with the first media file as the primary
/// result entity.
pub async fn process_successful_image_job(
deps: &JobDependencies,
job: &PendingSeedance2ProJob,
order: &OrderStatus,
) -> AnyhowResult<()> {
if order.results.is_empty() {
return Err(anyhow!(
"Completed image order {} has no result entries",
order.order_id
));
}
info!(
"Processing completed image order {}: {} image(s) to download.",
order.order_id, order.results.len(),
);
// Download, hash, upload, and insert one media_file per image. We
// process sequentially: Kinovi/upstream is the bottleneck here, so
// parallelising adds little while complicating refund-on-partial-failure.
let mut created_tokens: Vec<MediaFileToken> = Vec::with_capacity(order.results.len());
let is_batch = order.results.len() > 1;
let maybe_batch_prompt_token = if is_batch {
Some(BatchGenerationToken::generate())
} else {
None
};
for (idx, result) in order.results.iter().enumerate() {
let token = match download_and_store_one_image(deps, job, order, result, idx, maybe_batch_prompt_token.as_ref()).await {
Ok(t) => t,
Err(err) => {
// The first image is the headline result. If it fails, surface
// the error so the caller can retry on the next poll. Failures on
// later images are logged but don't sink the whole order — we
// still want the primary result available to the user.
if idx == 0 {
return Err(err);
}
warn!(
"Failed to process image {}/{} for order {}: {:?}",
idx + 1, order.results.len(), order.order_id, err,
);
continue;
}
};
created_tokens.push(token);
}
let primary_token = created_tokens.first().ok_or_else(|| {
anyhow!(
"No media files were created for order {} despite {} result(s)",
order.order_id, order.results.len(),
)
})?;
info!(
"Created {} media file(s) for order {} (primary={}). Marking job {} complete.",
created_tokens.len(),
order.order_id,
primary_token.as_str(),
job.job_token.as_str(),
);
if let Err(err) = mark_generic_inference_job_successfully_done_by_token(
&deps.mysql_pool,
&job.job_token,
Some(InferenceResultType::MediaFile),
Some(primary_token.as_str()),
None,
None,
).await {
error!("Error marking image job {} done: {:?}", job.job_token.as_str(), err);
return alert_pager_and_return_err(
&deps.pager,
"Seedance2Pro image job completion update failed",
anyhow!("error marking job done: {:?}", err),
Some(job),
);
}
info!("Image job {} completed successfully.", job.job_token.as_str());
Ok(())
}
/// Download a single image from Kinovi's CDN, upload to our public bucket,
/// and insert a `media_files` row. Returns the new media file token.
async fn download_and_store_one_image(
deps: &JobDependencies,
job: &PendingSeedance2ProJob,
order: &OrderStatus,
result: &VideoResult,
index: usize,
maybe_batch_token: Option<&BatchGenerationToken>,
) -> AnyhowResult<MediaFileToken> {
let image_url = result.url.as_str();
info!(
"Downloading image {}/{} for order {} from: {}",
index + 1, order.results.len(), order.order_id, image_url,
);
let image_bytes: Vec<u8> = reqwest::get(image_url)
.await
.map_err(|err| anyhow!("reqwest error downloading image {}: {:?}", index, err))?
.bytes()
.await
.map_err(|err| anyhow!("error reading image bytes for index {}: {:?}", index, err))?
.to_vec();
info!(
"Downloaded {} bytes for image {}/{} of order {}",
image_bytes.len(), index + 1, order.results.len(), order.order_id,
);
let checksum = sha256_hash_bytes(&image_bytes)
.map_err(|err| anyhow!("error hashing image {}: {:?}", index, err))?;
let bucket_path = MediaFileBucketPath::generate_new(Some(PREFIX), Some(SUFFIX));
let object_path = bucket_path.get_full_object_path_str();
info!(
"Uploading image {}/{} to public bucket at path: {}",
index + 1, order.results.len(), object_path,
);
deps
.public_bucket_client
.upload_file_with_content_type_process(object_path, &image_bytes, MIME_TYPE)
.await
.map_err(|err| anyhow!("bucket upload failed for image {}: {:?}", index, err))?;
let media_file_token = MediaFileInsertBuilder::new()
.checksum_sha2(&checksum)
.creator_ip_address(&job.creator_ip_address)
.creator_set_visibility(job.creator_set_visibility)
.file_size_bytes(image_bytes.len() as u64)
.maybe_batch_generation_token(maybe_batch_token)
.maybe_creator_anonymous_visitor(job.maybe_creator_anonymous_visitor_token.as_ref())
.maybe_creator_user(job.maybe_creator_user_token.as_ref())
.maybe_frame_height(Some(result.height))
.maybe_frame_width(Some(result.width))
.maybe_generation_provider(Some(GenerationProvider::Artcraft))
.maybe_prompt_token(job.maybe_prompt_token.as_ref())
.media_file_class(MediaFileClass::Image)
.media_file_origin_category(MediaFileOriginCategory::Inference)
.media_file_origin_product_category(MediaFileOriginProductCategory::ImageGeneration)
.media_file_type(MediaFileType::Png)
.mime_type(MIME_TYPE)
.public_bucket_directory_hash(&bucket_path)
.insert_pool(&deps.mysql_pool)
.await
.map_err(|err| anyhow!("media_file insert failed for image {}: {:?}", index, err))?;
Ok(media_file_token)
}
#[cfg(test)]
mod tests {
use super::*;
/// Quick sanity check that the bucket-path constants are what the rest
/// of the codebase expects for image generations (the video flow uses
/// `artcraft_` + `.mp4`; we use `.png`).
#[test]
fn bucket_path_uses_png_suffix() {
assert_eq!(PREFIX, "artcraft_");
assert_eq!(SUFFIX, ".png");
assert_eq!(MIME_TYPE, "image/png");
}
/// Generated bucket paths should end with the configured `.png` suffix
/// (the underlying helper is shared with the video flow but parameterised
/// on the extension).
#[test]
fn generated_bucket_path_ends_with_png() {
let path = MediaFileBucketPath::generate_new(Some(PREFIX), Some(SUFFIX));
let s = path.get_full_object_path_str().to_string();
assert!(s.ends_with(".png"), "expected .png suffix, got {}", s);
assert!(s.contains("artcraft_"), "expected `artcraft_` prefix in path, got {}", s);
}
}
@@ -2,10 +2,11 @@ use std::collections::HashMap;
use log::{info, warn};
use mysql_queries::queries::generic_inference::api_providers::seedance2pro::list_pending_seedance2pro_video_jobs::PendingSeedance2ProJob;
use seedance2pro_client::requests::poll_orders::poll_orders::{OrderStatus, TaskStatus};
use seedance2pro_client::requests::poll_orders::poll_orders::{OrderMediaType, OrderStatus, TaskStatus};
use crate::job_dependencies::JobDependencies;
use crate::jobs::video_polling_job::process_job::process_failed_job::process_failed_job;
use crate::jobs::video_polling_job::process_job::process_successful_image_job::process_successful_image_job;
use crate::jobs::video_polling_job::process_job::process_successful_job::process_successful_job;
/// Process a batch of polled orders against the pending jobs map.
@@ -38,11 +39,30 @@ pub async fn process_orders_batch(
match &order.task_status {
TaskStatus::Completed => {
info!(
"Order {} completed, processing job {}",
"Order {} completed (media_type={:?}), processing job {}",
order.order_id,
order.media_type,
job.job_token.as_str()
);
if let Err(err) = process_successful_job(deps, &job, order).await {
// Dispatch on media type: Midjourney image orders return ~4 PNGs
// and need different download/insert handling than the video
// path. `None` (older response shapes that pre-date the
// `mediaType` field) and `Some(Video)` both fall to the video
// handler for back-compat with the seedance video flow.
let result = match &order.media_type {
Some(OrderMediaType::Image) => process_successful_image_job(deps, &job, order).await,
Some(OrderMediaType::Video) | None => process_successful_job(deps, &job, order).await,
Some(OrderMediaType::Unknown(other)) => {
warn!(
"Order {} has unrecognised media_type {:?}; treating as video",
order.order_id, other,
);
process_successful_job(deps, &job, order).await
}
};
if let Err(err) = result {
warn!(
"Error processing completed order {}: {:?}",
order.order_id, err
@@ -103,6 +103,87 @@ fn build_omni_gen_image_models() -> Vec<OmniGenImageModelDetails> {
..Default::default()
});
models.push(OmniGenImageModelDetails {
model: CommonImageModel::Midjourney7,
model_creator: Some(ModelCreator::Midjourney),
full_name: Some("Midjourney v7".to_string()),
text_prompt_supported: Some(true),
image_refs_supported: Some(true),
image_refs_max: Some(1),
aspect_ratio_options: Some(vec![
CommonAspectRatio::Auto,
CommonAspectRatio::TallTwoByThree,
CommonAspectRatio::TallThreeByFour,
CommonAspectRatio::TallFourByFive,
CommonAspectRatio::TallNineBySixteen,
CommonAspectRatio::WideThreeByTwo,
CommonAspectRatio::WideFourByThree,
CommonAspectRatio::WideFiveByFour,
CommonAspectRatio::WideSixteenByNine,
CommonAspectRatio::WideTwentyOneByNine,
]),
aspect_ratio_default: Some(CommonAspectRatio::Square),
//aspect_ratio_default_when_editing: Some(CommonAspectRatio::Auto),
batch_size_min: Some(1),
batch_size_max: Some(2), // TODO: Fix batch sizes
batch_size_default: Some(1),
..Default::default()
});
models.push(OmniGenImageModelDetails {
model: CommonImageModel::Midjourney7Niji,
model_creator: Some(ModelCreator::Midjourney),
full_name: Some("Midjourney v7 Niji (Anime)".to_string()),
text_prompt_supported: Some(true),
image_refs_supported: Some(true),
image_refs_max: Some(1),
aspect_ratio_options: Some(vec![
CommonAspectRatio::Auto,
CommonAspectRatio::TallTwoByThree,
CommonAspectRatio::TallThreeByFour,
CommonAspectRatio::TallFourByFive,
CommonAspectRatio::TallNineBySixteen,
CommonAspectRatio::WideThreeByTwo,
CommonAspectRatio::WideFourByThree,
CommonAspectRatio::WideFiveByFour,
CommonAspectRatio::WideSixteenByNine,
CommonAspectRatio::WideTwentyOneByNine,
]),
aspect_ratio_default: Some(CommonAspectRatio::Square),
//aspect_ratio_default_when_editing: Some(CommonAspectRatio::Auto),
batch_size_min: Some(1),
batch_size_max: Some(2), // TODO: Fix batch sizes
batch_size_default: Some(1),
..Default::default()
});
models.push(OmniGenImageModelDetails {
model: CommonImageModel::Midjourney8,
model_creator: Some(ModelCreator::Midjourney),
full_name: Some("Midjourney v8".to_string()),
text_prompt_supported: Some(true),
image_refs_supported: Some(true),
image_refs_max: Some(1),
aspect_ratio_options: Some(vec![
CommonAspectRatio::Auto,
CommonAspectRatio::TallTwoByThree,
CommonAspectRatio::TallThreeByFour,
CommonAspectRatio::TallFourByFive,
CommonAspectRatio::TallNineBySixteen,
CommonAspectRatio::WideThreeByTwo,
CommonAspectRatio::WideFourByThree,
CommonAspectRatio::WideFiveByFour,
CommonAspectRatio::WideSixteenByNine,
CommonAspectRatio::WideTwentyOneByNine,
]),
aspect_ratio_default: Some(CommonAspectRatio::Square),
//aspect_ratio_default_when_editing: Some(CommonAspectRatio::Auto),
batch_size_min: Some(1),
batch_size_max: Some(2), // TODO: Fix batch sizes
batch_size_default: Some(1),
..Default::default()
});
models.push(OmniGenImageModelDetails {
model: CommonImageModel::NanoBanana, // NB: currently Gemini25Flash in our system
model_creator: Some(ModelCreator::ArtCraft),
@@ -92,6 +92,8 @@ use crate::http_server::endpoints::moderation::user_feature_flags::moderator_lis
use crate::http_server::endpoints::moderation::user_feature_flags::moderator_list_user_feature_flags_handler::*;
use crate::http_server::endpoints::moderation::staff_audit_logs::moderator_list_staff_audit_logs_handler::*;
use crate::http_server::endpoints::moderation::user_bans::moderation_ban_user_handler::*;
use crate::http_server::endpoints::moderation::user_emails::moderator_change_user_email::*;
use crate::http_server::endpoints::moderation::user_emails::moderator_list_email_address_changes_for_user::*;
use artcraft_api_defs::moderation::user_referrals::list_global_user_referrals::*;
use artcraft_api_defs::moderation::user_referrals::list_user_referrals_for_user::*;
use crate::http_server::endpoints::moderation::user_sessions::moderator_list_user_session_impersonation_requests_for_user_handler::*;
@@ -533,6 +535,8 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
crate::http_server::endpoints::moderation::user_referrals::moderator_list_global_user_referrals_handler::moderator_list_global_user_referrals_handler,
crate::http_server::endpoints::moderation::user_referrals::moderator_list_user_referrals_for_user_handler::moderator_list_user_referrals_for_user_handler,
crate::http_server::endpoints::moderation::user_bans::moderation_ban_user_handler::moderation_ban_user_handler,
crate::http_server::endpoints::moderation::user_emails::moderator_change_user_email::moderator_change_user_email_handler,
crate::http_server::endpoints::moderation::user_emails::moderator_list_email_address_changes_for_user::moderator_list_email_address_changes_for_user_handler,
crate::http_server::endpoints::moderation::user_sessions::moderator_list_user_session_impersonation_requests_for_user_handler::moderator_list_user_session_impersonation_requests_for_user_handler,
crate::http_server::endpoints::moderation::user_sessions::moderator_list_user_session_impersonation_requests_handler::moderator_list_user_session_impersonation_requests_handler,
crate::http_server::endpoints::moderation::user_sessions::moderator_user_session_impersonation_request_handler::moderator_user_session_impersonation_request_handler,
@@ -956,6 +960,11 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
ModerationBanUserSuccessResponse,
ModerationImpersonateRequest,
ModerationImpersonateSuccessResponse,
ModeratorChangeUserEmailRequest,
ModeratorChangeUserEmailSuccessResponse,
ModeratorListUserEmailChangesSuccessResponse,
ModeratorUserEmailChangeItem,
ModeratorUserEmailChangeUserSummary,
ModernInferenceQueueStats,
PinnedMediaFile,
PinnedModelWeightForList,
@@ -1193,6 +1202,9 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
ListUserReferralsForUserQueryParams,
ListUserReferralsForUserPathInfo,
ListUserReferralsForUserSuccessResponse,
// User Emails (Moderation)
ModeratorListUserEmailChangesQueryParams,
))
)]
pub struct ApiDoc;
@@ -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)
}
@@ -6,6 +6,7 @@ pub mod jobs;
pub mod staff_audit_logs;
pub mod user;
pub mod user_bans;
pub mod user_emails;
pub mod user_feature_flags;
pub mod user_referrals;
pub mod user_sessions;
@@ -0,0 +1,2 @@
pub mod moderator_change_user_email;
pub mod moderator_list_email_address_changes_for_user;
@@ -0,0 +1,179 @@
use std::marker::PhantomData;
use std::sync::Arc;
use actix_web::web::Json;
use actix_web::{web, HttpRequest};
use log::{info, warn};
use utoipa::ToSchema;
use enums::by_table::staff_audit_logs::staff_audit_action::StaffAuditAction;
use enums::by_table::staff_audit_logs::staff_audit_entity_type::StaffAuditEntityType;
use http_server_common::request::get_request_ip::get_request_ip;
use mysql_queries::queries::staff_audit_logs::insert_staff_audit_log::{
insert_staff_audit_log, InsertStaffAuditLogArgs,
};
use mysql_queries::queries::users::user::get::lookup_user_for_moderation::lookup_user_for_moderation_by_token;
use mysql_queries::queries::users::user::update::update_email::{
update_email, UpdateEmailArgs, UpdateEmailError,
};
use mysql_queries::queries::users::user_email_changes::insert_user_email_change::{
insert_user_email_change, InsertUserEmailChangeArgs,
};
use mysql_queries::utils::transactor::Transactor;
use users::email::email_to_gravatar_hash::email_to_gravatar_hash;
use users::email::validate_email_address_format::validate_email_address_format;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::web_utils::user_session::require_moderator::{
require_moderator, UseDatabase,
};
use crate::state::server_state::ServerState;
// --- Request ---
#[derive(Deserialize, ToSchema)]
pub struct ModeratorChangeUserEmailRequest {
/// The user whose email should be changed.
pub user_token: String,
/// The new email address. Will be trimmed and lowercased before use.
pub new_email: String,
}
// --- Response ---
#[derive(Serialize, ToSchema)]
pub struct ModeratorChangeUserEmailSuccessResponse {
pub success: bool,
}
// --- Handler ---
/// Change a user's email address as a moderator. Records the change in
/// `user_email_changes` and writes a `staff_audit_logs` row.
#[utoipa::path(
post,
tag = "Moderation",
path = "/v1/moderation/user_emails/change",
request_body = ModeratorChangeUserEmailRequest,
responses(
(status = 200, description = "Email changed", body = ModeratorChangeUserEmailSuccessResponse),
(status = 400, description = "Bad email format or duplicate email", body = CommonWebError),
(status = 401, description = "Unauthorized", body = CommonWebError),
(status = 404, description = "Target user not found", body = CommonWebError),
(status = 500, description = "Server error", body = CommonWebError),
),
)]
pub async fn moderator_change_user_email_handler(
http_request: HttpRequest,
request: Json<ModeratorChangeUserEmailRequest>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<ModeratorChangeUserEmailSuccessResponse>, CommonWebError> {
// 1. Require moderator.
let user_session = require_moderator(
&http_request,
&server_state,
UseDatabase::GrabNewConnection,
).await.map_err(|err| {
warn!("Moderator check failed: {:?}", err);
CommonWebError::NotAuthorized
})?;
// 2. Normalize + validate the new email.
let new_email = request.new_email.trim().to_lowercase();
if let Err(reason) = validate_email_address_format(&new_email) {
return Err(CommonWebError::BadInputWithSimpleMessage(format!("bad email: {}", reason)));
}
// 3. Look up the target user. Not-found → 404; everything else → 500.
let target_user_token_input = request.user_token.trim();
let target = lookup_user_for_moderation_by_token(target_user_token_input, &server_state.mysql_pool)
.await
.map_err(|err| {
warn!("Target user lookup failed: {:?}", err);
CommonWebError::from_anyhow_error(err)
})?
.ok_or_else(|| {
warn!("Target user not found: {:?}", target_user_token_input);
CommonWebError::NotFound
})?;
let target_user_token = target.user_token;
let old_email = target.email_address;
let new_gravatar_hash = email_to_gravatar_hash(&new_email);
let actor_ip = get_request_ip(&http_request);
info!(
"Moderator {} changing email for user {}.",
user_session.user_token.as_str(), target_user_token.as_str(),
);
// 4. Open transaction: update email, audit row, audit log.
let mut transaction = server_state.mysql_pool.begin().await
.map_err(|err| {
warn!("Failed to begin transaction: {:?}", err);
CommonWebError::from_error(err)
})?;
// 4a. Update the user's email address. Duplicate-email errors surface as 400.
match update_email(UpdateEmailArgs {
token: &target_user_token,
email_address: &new_email,
email_gravatar_hash: &new_gravatar_hash,
ip_address: &actor_ip,
transactor: Transactor::for_transaction(&mut transaction),
}).await {
Ok(()) => {}
Err(UpdateEmailError::EmailIsTaken) => {
return Err(CommonWebError::BadInputWithSimpleMessage(
"email address is already in use".to_string(),
));
}
Err(UpdateEmailError::DatabaseError { source }) => {
warn!("Error updating user email: {:?}", source);
return Err(CommonWebError::from_error(source));
}
}
// 4b. Record the change in user_email_changes.
insert_user_email_change(InsertUserEmailChangeArgs {
user_token: &target_user_token,
old_email: &old_email,
new_email: &new_email,
ip_address: &actor_ip,
maybe_changed_by_user_token: Some(&user_session.user_token),
mysql_executor: &mut *transaction,
phantom: PhantomData,
}).await.map_err(|err| {
warn!("Failed to insert user_email_changes row: {:?}", err);
CommonWebError::from_error(err)
})?;
// 4c. Insert staff audit log.
let _audit_token = insert_staff_audit_log(InsertStaffAuditLogArgs {
audit_action: StaffAuditAction::ChangeUserEmail,
maybe_entity_type: Some(StaffAuditEntityType::User),
maybe_entity_token: Some(target_user_token.as_str()),
staff_user_token: &user_session.user_token,
actor_ip_address: &actor_ip,
mysql_executor: &mut *transaction,
phantom: PhantomData,
}).await.map_err(|err| {
warn!("Failed to insert staff audit log: {:?}", err);
CommonWebError::from_error(err)
})?;
// 5. Commit.
transaction.commit().await.map_err(|err| {
warn!("Failed to commit transaction: {:?}", err);
CommonWebError::from_error(err)
})?;
info!(
"Email changed for user {} by moderator {}.",
target_user_token.as_str(), user_session.user_token.as_str(),
);
Ok(Json(ModeratorChangeUserEmailSuccessResponse { success: true }))
}
@@ -0,0 +1,194 @@
use std::marker::PhantomData;
use std::sync::Arc;
use actix_web::web::{Json, Path, Query};
use actix_web::{web, HttpRequest};
use chrono::{DateTime, Utc};
use log::warn;
use utoipa::{IntoParams, ToSchema};
use mysql_queries::queries::users::user::get::get_user_token_by_username::get_user_token_by_username;
use mysql_queries::queries::users::user_email_changes::list_user_email_changes_for_user::{
list_user_email_changes_for_user, ListUserEmailChangesForUserArgs, UserDisplay,
UserEmailChangeRow,
};
use tokens::tokens::users::UserToken;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::web_utils::user_session::require_moderator::{
require_moderator, UseDatabase,
};
use crate::state::server_state::ServerState;
const CURSOR_NAME: &str = "modemchg";
const DEFAULT_LIMIT: u32 = 100;
const MAX_LIMIT: u32 = 1000;
// --- Path / Query ---
#[derive(Deserialize, ToSchema)]
pub struct ModeratorListUserEmailChangesForUserPathInfo {
pub username: String,
}
#[derive(Deserialize, IntoParams)]
pub struct ModeratorListUserEmailChangesQueryParams {
/// Opaque cursor from a previous page's `maybe_cursor`. Omit for the
/// first page.
pub cursor: Option<String>,
/// Page size. Defaults to 100, capped at 1000.
pub limit: Option<u32>,
}
// --- Response ---
#[derive(Serialize, ToSchema)]
pub struct ModeratorListUserEmailChangesSuccessResponse {
pub success: bool,
pub changes: Vec<ModeratorUserEmailChangeItem>,
/// Opaque cursor for the next page. `None` means there are no more rows.
pub maybe_cursor: Option<String>,
}
#[derive(Serialize, ToSchema)]
pub struct ModeratorUserEmailChangeItem {
pub id: u64,
pub user: ModeratorUserEmailChangeUserSummary,
/// The user that performed the change. `None` for self-service changes
/// or when the actor's `users` row has been hard-deleted.
pub maybe_changed_by_user: Option<ModeratorUserEmailChangeUserSummary>,
pub old_email: String,
pub new_email: String,
pub ip_address: String,
pub created_at: DateTime<Utc>,
}
#[derive(Serialize, ToSchema)]
pub struct ModeratorUserEmailChangeUserSummary {
pub user_token: UserToken,
pub username: String,
pub display_name: String,
pub gravatar_hash: String,
}
// --- Handler ---
/// List recorded email-address changes for a given user, newest first.
/// Moderators only. Paginated via opaque cursor + limit.
#[utoipa::path(
get,
tag = "Moderation",
path = "/v1/moderation/user_emails/list/{username}",
params(
("username" = String, description = "Username of the user whose email history to list"),
ModeratorListUserEmailChangesQueryParams,
),
responses(
(status = 200, description = "Success", body = ModeratorListUserEmailChangesSuccessResponse),
(status = 400, description = "Bad cursor", body = CommonWebError),
(status = 401, description = "Unauthorized", body = CommonWebError),
(status = 404, description = "User not found", body = CommonWebError),
(status = 500, description = "Server error", body = CommonWebError),
),
)]
pub async fn moderator_list_email_address_changes_for_user_handler(
http_request: HttpRequest,
path: Path<ModeratorListUserEmailChangesForUserPathInfo>,
query: Query<ModeratorListUserEmailChangesQueryParams>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<ModeratorListUserEmailChangesSuccessResponse>, CommonWebError> {
// 1. Require moderator.
let _user_session = require_moderator(
&http_request,
&server_state,
UseDatabase::GrabNewConnection,
).await.map_err(|err| {
warn!("Moderator check failed: {:?}", err);
CommonWebError::NotAuthorized
})?;
// 2. Resolve username → user_token. Not-found → 404; everything else → 500.
let username = path.username.trim().to_lowercase();
let user_token = get_user_token_by_username(&username, &server_state.mysql_pool)
.await
.map_err(|err| {
warn!("Failed to resolve username {:?}: {:?}", username, err);
CommonWebError::from_anyhow_error(err)
})?
.ok_or_else(|| {
warn!("User not found: {:?}", username);
CommonWebError::NotFound
})?;
// 3. Decode the page cursor, if any.
let limit = query.limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT);
let maybe_cursor_id = match &query.cursor {
None => None,
Some(cursor_str) => {
let decoded = server_state.opaque_cursors
.decode_cursor_expecting_name(CURSOR_NAME, cursor_str)
.map_err(|err| {
warn!("Failed to decode cursor: {:?}", err);
CommonWebError::BadInputWithSimpleMessage("Invalid cursor".to_string())
})?;
decoded.last_id
}
};
// 4. Fetch the email-change history for that user.
let rows = list_user_email_changes_for_user(ListUserEmailChangesForUserArgs {
user_token: &user_token,
maybe_cursor_id,
limit,
mysql_executor: &server_state.mysql_pool,
phantom: PhantomData,
}).await.map_err(|err| {
warn!("Failed to list email changes for user {:?}: {:?}", username, err);
CommonWebError::from_error(err)
})?;
// 5. Encode the next-page cursor from the last id, if there was one.
let maybe_cursor = rows.last().map(|last| {
server_state.opaque_cursors
.encode_last_id_cursor(CURSOR_NAME, last.id)
}).transpose().map_err(|err| {
warn!("Failed to encode cursor: {:?}", err);
CommonWebError::server_error_with_message("Failed to encode cursor")
})?;
let changes = rows.into_iter().map(to_response_item).collect();
Ok(Json(ModeratorListUserEmailChangesSuccessResponse {
success: true,
changes,
maybe_cursor,
}))
}
fn to_response_item(row: UserEmailChangeRow) -> ModeratorUserEmailChangeItem {
ModeratorUserEmailChangeItem {
id: row.id,
user: to_user_summary(row.user),
maybe_changed_by_user: row.maybe_changed_by_user.map(to_user_summary),
old_email: row.old_email,
new_email: row.new_email,
ip_address: row.ip_address,
created_at: row.created_at,
}
}
fn to_user_summary(display: UserDisplay) -> ModeratorUserEmailChangeUserSummary {
ModeratorUserEmailChangeUserSummary {
user_token: display.token,
username: display.username,
display_name: display.display_name,
gravatar_hash: display.gravatar_hash,
}
}
@@ -0,0 +1,59 @@
use log::warn;
use enums::common::visibility::Visibility;
use mysql_queries::queries::generic_inference::api_providers::fal::insert_generic_inference_job_for_fal_queue::FalCategory;
use mysql_queries::queries::generic_inference::api_providers::fal::insert_generic_inference_job_for_fal_queue_with_apriori_job_token::{
insert_generic_inference_job_for_fal_queue_with_apriori_job_token,
InsertGenericInferenceForFalWithAprioriJobTokenArgs,
};
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use super::shared_job_args::SharedJobArgs;
pub struct InsertFalJobArgs<'a, 'tx> {
pub external_job_id: &'a str,
pub shared: SharedJobArgs<'a, 'tx>,
}
/// Insert a single Fal-queue inference job row for an image generation.
/// Mirrors `omni_gen::generate::video::insert_db_job::insert_fal_job::insert_fal_job`
/// but tags the row with `FalCategory::ImageGeneration` so the worker
/// routes it to the image-generation queue.
pub async fn insert_fal_job(args: InsertFalJobArgs<'_, '_>) -> Result<InferenceJobToken, CommonWebError> {
let InsertFalJobArgs {
external_job_id,
shared,
} = args;
let db_result = insert_generic_inference_job_for_fal_queue_with_apriori_job_token(
InsertGenericInferenceForFalWithAprioriJobTokenArgs {
apriori_job_token: shared.apriori_job_token,
uuid_idempotency_token: shared.idempotency_token,
maybe_external_third_party_id: external_job_id,
fal_category: FalCategory::ImageGeneration,
maybe_model_type: shared.maybe_model_type,
maybe_prompt_token: shared.maybe_prompt_token,
maybe_creator_user_token: Some(shared.user_token),
maybe_avt_token: shared.maybe_avt_token,
creator_ip_address: shared.ip_address,
creator_set_visibility: Visibility::Public,
maybe_debug_log_event_token: shared.maybe_debug_log_event_token,
mysql_executor: &mut **shared.transaction,
maybe_inference_args: None,
starting_job_status_override: None,
maybe_frontend_failure_category: None,
maybe_failure_reason: None,
phantom: Default::default(),
}
).await;
match db_result {
Ok(token) => Ok(token),
Err(err) => {
warn!("Error inserting fal inference job (image): {:?}", err);
Err(CommonWebError::from_error(err))
}
}
}
@@ -0,0 +1,115 @@
use log::{error, warn};
use enums::common::visibility::Visibility;
use mysql_queries::queries::generic_inference::api_providers::seedance2pro::insert_generic_inference_job_for_seedance2pro_queue_with_apriori_job_token::{
insert_generic_inference_job_for_seedance2pro_queue_with_apriori_job_token,
InsertGenericInferenceForSeedance2ProWithAprioriJobTokenArgs, KinoviVersion,
};
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken;
use uuid_utils::uuid::generate_random_uuid;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use super::shared_job_args::SharedJobArgs;
pub struct InsertSeedance2proJobsArgs<'a, 'tx> {
/// The order_id for the primary job (always inserted first).
pub primary_order_id: &'a str,
/// Additional order_ids returned by Kinovi when batch_count > 1. Each
/// becomes its own inference job row.
pub maybe_additional_order_ids: Option<&'a [String]>,
/// Wallet ledger entry from the up-front deduction. Stamped on each
/// inserted row so the worker can issue a refund on failure.
pub maybe_wallet_ledger_entry_token: Option<&'a WalletLedgerEntryToken>,
/// Which Kinovi account fulfilled the request — Volcengine, BytePlus,
/// or BytePlusUltra. For Midjourney image generation today this is
/// always Volcengine, but the helper stays version-agnostic so a future
/// account-picking layer can use it unchanged.
pub kinovi_version: KinoviVersion,
pub shared: SharedJobArgs<'a, 'tx>,
}
pub struct InsertSeedance2proJobsResult {
pub primary_job_token: InferenceJobToken,
pub all_job_tokens: Vec<InferenceJobToken>,
}
/// Insert one Seedance2Pro/Kinovi inference job per returned order_id.
/// Mirrors the video-side helper at
/// `omni_gen::generate::video::insert_db_job::insert_seedance2pro_jobs`.
pub async fn insert_seedance2pro_jobs(args: InsertSeedance2proJobsArgs<'_, '_>) -> Result<InsertSeedance2proJobsResult, CommonWebError> {
let InsertSeedance2proJobsArgs {
primary_order_id,
maybe_additional_order_ids,
maybe_wallet_ledger_entry_token,
kinovi_version,
mut shared,
} = args;
// Build a deduplicated list with primary_order_id first.
let mut all_order_ids = vec![primary_order_id.to_string()];
if let Some(additional) = maybe_additional_order_ids {
for id in additional {
if id != primary_order_id {
all_order_ids.push(id.clone());
}
}
}
let mut all_job_tokens: Vec<InferenceJobToken> = Vec::with_capacity(all_order_ids.len());
for (i, order_id) in all_order_ids.iter().enumerate() {
let job_token = if i == 0 { shared.apriori_job_token.clone() } else { InferenceJobToken::generate() };
// The idempotency_token column has a max length too tight for the
// `{idempotency}-batch-{i}` suffix pattern used elsewhere; mint a
// fresh UUID for each additional row instead. The primary row still
// wants a deterministic value, but historic behavior on the video
// side has been to mint UUIDs for all rows, which we mirror here.
let idempotency_str = generate_random_uuid();
let db_result = insert_generic_inference_job_for_seedance2pro_queue_with_apriori_job_token(
InsertGenericInferenceForSeedance2ProWithAprioriJobTokenArgs {
kinovi_version,
apriori_job_token: &job_token,
uuid_idempotency_token: &idempotency_str,
maybe_external_third_party_id: order_id,
maybe_model_type: shared.maybe_model_type,
maybe_prompt_token: shared.maybe_prompt_token,
maybe_wallet_ledger_entry_token,
maybe_creator_user_token: Some(shared.user_token),
maybe_avt_token: shared.maybe_avt_token,
creator_ip_address: shared.ip_address,
creator_set_visibility: Visibility::Public,
maybe_debug_log_event_token: shared.maybe_debug_log_event_token,
mysql_executor: &mut **shared.transaction,
maybe_inference_args: None,
phantom: Default::default(),
}
).await;
match db_result {
Ok(token) => all_job_tokens.push(token),
Err(err) => {
warn!("Error inserting seedance2pro inference job (image, order_id={}): {:?}", order_id, err);
if i == 0 { return Err(CommonWebError::from_error(err)); }
}
}
}
let primary_job_token = all_job_tokens.first().cloned().ok_or_else(|| {
error!("No inference job token was created");
CommonWebError::server_error_with_message("No inference job token was created")
})?;
Ok(InsertSeedance2proJobsResult {
primary_job_token,
all_job_tokens,
})
}
@@ -0,0 +1,3 @@
pub mod insert_fal_job;
pub mod insert_seedance2pro_jobs;
pub mod shared_job_args;
@@ -0,0 +1,20 @@
use enums::common::generation::common_model_type::CommonModelType;
use tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken;
use tokens::tokens::prompts::PromptToken;
use tokens::tokens::users::UserToken;
/// Fields every image-side `insert_*_job` helper needs.
/// Parallels `omni_gen::generate::video::insert_db_job::shared_job_args::SharedJobArgs`.
pub struct SharedJobArgs<'a, 'tx> {
pub apriori_job_token: &'a InferenceJobToken,
pub idempotency_token: &'a str,
pub user_token: &'a UserToken,
pub maybe_avt_token: Option<&'a AnonymousVisitorTrackingToken>,
pub maybe_prompt_token: Option<&'a PromptToken>,
pub maybe_debug_log_event_token: Option<&'a DebugLogEventToken>,
pub maybe_model_type: Option<CommonModelType>,
pub ip_address: &'a str,
pub transaction: &'a mut sqlx::Transaction<'tx, sqlx::MySql>,
}
@@ -1,4 +1,5 @@
pub mod omni_gen_image_generate_handler;
pub(crate) mod hydrate_to_router_request;
pub(crate) mod insert_db_job;
pub(crate) mod pipeline_result;
pub mod pipeline_v2;
@@ -14,24 +14,23 @@ use enums::by_table::prompts::prompt_type::PromptType;
use enums::common::generation::common_generation_mode::CommonGenerationMode;
use enums::common::generation::common_model_type::CommonModelType;
use enums::common::generation_provider::GenerationProvider;
use enums::common::visibility::Visibility;
use http_server_common::request::get_request_ip::get_request_ip;
use mysql_queries::queries::debug_logs::insert_debug_log::{insert_debug_log, InsertDebugLogArgs};
use mysql_queries::queries::generic_inference::api_providers::fal::insert_generic_inference_job_for_fal_queue::FalCategory;
use mysql_queries::queries::generic_inference::api_providers::fal::insert_generic_inference_job_for_fal_queue_with_apriori_job_token::{
insert_generic_inference_job_for_fal_queue_with_apriori_job_token,
InsertGenericInferenceForFalWithAprioriJobTokenArgs,
};
use mysql_queries::queries::generic_inference::api_providers::seedance2pro::insert_generic_inference_job_for_seedance2pro_queue_with_apriori_job_token::KinoviVersion;
use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_idempotency_token;
use mysql_queries::queries::prompt_context_items::insert_batch_prompt_context_items::{
insert_batch_prompt_context_items, InsertBatchArgs, PromptContextItem,
};
use mysql_queries::queries::prompts::insert_prompt::{insert_prompt, InsertPromptArgs};
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::endpoints::generate::common::payments_error_test::payments_error_test;
use crate::http_server::endpoints::omni_gen::generate::image::hydrate_to_router_request::hydrate_to_router_request;
use crate::http_server::endpoints::omni_gen::generate::image::insert_db_job::insert_fal_job::{insert_fal_job, InsertFalJobArgs};
use crate::http_server::endpoints::omni_gen::generate::image::insert_db_job::insert_seedance2pro_jobs::{insert_seedance2pro_jobs, InsertSeedance2proJobsArgs};
use crate::http_server::endpoints::omni_gen::generate::image::insert_db_job::shared_job_args::SharedJobArgs;
use crate::http_server::endpoints::omni_gen::generate::image::pipeline_v2::run_pipeline_v2::{run_pipeline_v2, RunPipelineV2Args};
use crate::http_server::session::lookup::user_session_feature_flags::UserSessionFeatureFlags;
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
@@ -168,15 +167,6 @@ pub async fn omni_gen_image_generate_handler(
}
}
let external_job_id = match &pipeline_result.response {
GenerateImageResponse::Artcraft(p) => {
p.inference_job_token.as_str().to_string()
}
GenerateImageResponse::Fal(p) => {
p.request_id.clone().unwrap_or_default()
}
};
// ==================== WRITE RESULT ==================== //
let ip_address = get_request_ip(&http_request);
@@ -251,34 +241,63 @@ pub async fn omni_gen_image_generate_handler(
}
// -- Inference job --
//
// Each provider has its own queue / worker. Branch by response variant
// so the row lands on the correct queue (the Seedance2Pro/Kinovi worker
// for Midjourney; the Fal worker for everything else).
let db_result = insert_generic_inference_job_for_fal_queue_with_apriori_job_token(
InsertGenericInferenceForFalWithAprioriJobTokenArgs {
apriori_job_token: &pipeline_result.apriori_job_token,
uuid_idempotency_token: &idempotency_token,
maybe_external_third_party_id: &external_job_id,
fal_category: FalCategory::ImageGeneration,
maybe_model_type: request.model.map(|v| v.to_common_model_type()),
maybe_inference_args: None,
maybe_prompt_token: prompt_token.as_ref(),
maybe_creator_user_token: Some(user_token),
maybe_avt_token: maybe_avt_token.as_ref(),
creator_ip_address: &ip_address,
creator_set_visibility: Visibility::Public,
maybe_debug_log_event_token: Some(&debug_log_event_token),
mysql_executor: &mut *transaction,
starting_job_status_override: None,
maybe_frontend_failure_category: None,
maybe_failure_reason: None,
phantom: Default::default(),
let job_token: InferenceJobToken = match &pipeline_result.response {
GenerateImageResponse::Seedance2Pro(payload) => {
info!("Inserting seedance2pro image job(s) with token: {:?}", pipeline_result.apriori_job_token);
// The image-side omni pipeline always dispatches Midjourney via the
// Volcengine Kinovi account today. If we ever route to BytePlus /
// BytePlus Ultra here, mirror the video-side `kinovi_account` knob.
let kinovi_version = KinoviVersion::Volcengine;
let result = insert_seedance2pro_jobs(InsertSeedance2proJobsArgs {
primary_order_id: &payload.order_id,
maybe_additional_order_ids: payload.maybe_order_ids.as_deref(),
maybe_wallet_ledger_entry_token: pipeline_result.maybe_wallet_ledger_entry_token.as_ref(),
kinovi_version,
shared: SharedJobArgs {
apriori_job_token: &pipeline_result.apriori_job_token,
idempotency_token: &idempotency_token,
user_token,
maybe_avt_token: maybe_avt_token.as_ref(),
maybe_model_type: request.model.map(|v| v.to_common_model_type()),
maybe_prompt_token: prompt_token.as_ref(),
maybe_debug_log_event_token: Some(&debug_log_event_token),
ip_address: &ip_address,
transaction: &mut transaction,
},
}).await?;
result.primary_job_token
}
).await;
let job_token = match db_result {
Ok(token) => token,
Err(err) => {
warn!("Error inserting inference job: {:?}", err);
return Err(CommonWebError::from_error(err));
GenerateImageResponse::Fal(payload) => {
info!("Inserting fal image job with token: {:?}", pipeline_result.apriori_job_token);
let external_job_id = payload.request_id.clone().unwrap_or_default();
insert_fal_job(InsertFalJobArgs {
external_job_id: &external_job_id,
shared: SharedJobArgs {
apriori_job_token: &pipeline_result.apriori_job_token,
idempotency_token: &idempotency_token,
user_token,
maybe_avt_token: maybe_avt_token.as_ref(),
maybe_model_type: request.model.map(|v| v.to_common_model_type()),
maybe_prompt_token: prompt_token.as_ref(),
maybe_debug_log_event_token: Some(&debug_log_event_token),
ip_address: &ip_address,
transaction: &mut transaction,
},
}).await?
}
GenerateImageResponse::Artcraft(payload) => {
// The omni image pipeline never dispatches via the Artcraft provider
// itself today (everything routes to Fal or Kinovi), but the response
// variant exists so we cover it defensively — Artcraft jobs come back
// already inserted server-side, so just propagate the token.
payload.inference_job_token.clone()
}
};
@@ -1,8 +1,15 @@
use artcraft_router::generate::generate_image::generate_image_response::GenerateImageResponse;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken;
/// Both image pipelines produce this, then the shared handler suffix writes DB rows.
pub struct ImagePipelineResult {
pub apriori_job_token: InferenceJobToken,
pub response: GenerateImageResponse,
/// The wallet ledger entry created when the user was charged for this
/// generation, if any (None when the generation is free). Used as a
/// foreign key on the inference job row so the worker can refund on
/// failure.
pub maybe_wallet_ledger_entry_token: Option<WalletLedgerEntryToken>,
}
@@ -1,14 +1,17 @@
use log::{info, warn};
use artcraft_router::api::image_list_ref::ImageListRef;
use artcraft_router::api::router_image_model::RouterImageModel;
use artcraft_router::api::router_provider::RouterProvider;
use artcraft_router::client::router_client::RouterClient;
use artcraft_router::client::router_fal_client::RouterFalClient;
use artcraft_router::client::router_seedance2pro_client::RouterSeedance2ProClient;
use artcraft_router::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder;
use artcraft_router::generate::generate_image::generate_image_response::GenerateImageResponse;
use artcraft_router::generate::generate_image::image_generation_draft_context::ImageGenerationDraftContext;
use artcraft_router::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest;
use artcraft_router::generate::generate_image::image_generation_request::ImageGenerationRequest;
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::users::UserToken;
@@ -50,20 +53,24 @@ pub async fn run_pipeline_v2(
let apriori_job_token = InferenceJobToken::generate();
if cost > 0 {
attempt_wallet_deduction_else_common_web_error(
let maybe_wallet_ledger_entry_token = if cost > 0 {
let deduction = attempt_wallet_deduction_else_common_web_error(
user_token,
Some(apriori_job_token.as_str()),
cost,
mysql_connection,
).await?;
}
Some(deduction.ledger_entry_token)
} else {
None
};
let response = finalize_and_generate(draft_or_request, server_state).await?;
let response = finalize_and_generate(draft_or_request, server_state, resolved_media).await?;
Ok(ImagePipelineResult {
apriori_job_token,
response,
maybe_wallet_ledger_entry_token,
})
}
@@ -71,7 +78,7 @@ fn build_execution_request(
router_builder: &GenerateImageRequestBuilder,
) -> Result<ImageGenerationDraftOrRequest, CommonWebError> {
let mut execution_builder = router_builder.clone();
execution_builder.provider = RouterProvider::Fal;
execution_builder.provider = provider_for_model(execution_builder.model);
execution_builder.build2().map_err(|e| {
warn!("Failed to build2 for image v2 pipeline: {}", e);
@@ -79,6 +86,18 @@ fn build_execution_request(
})
}
/// Route each image model to the provider that fulfils it. Midjourney is
/// served via the Seedance2Pro/Kinovi (Volcengine) backend; everything else
/// flows through Fal.
fn provider_for_model(model: RouterImageModel) -> RouterProvider {
match model {
RouterImageModel::Midjourney7
| RouterImageModel::Midjourney7Niji
| RouterImageModel::Midjourney8 => RouterProvider::Seedance2Pro,
_ => RouterProvider::Fal,
}
}
fn apply_hydrated_media_inputs(
router_builder: &GenerateImageRequestBuilder,
resolved_media: &MediaFilesAsCdnUrlListAndMap,
@@ -119,11 +138,27 @@ fn estimate_cost_in_credits(
async fn finalize_and_generate(
draft_or_request: ImageGenerationDraftOrRequest,
server_state: &ServerState,
resolved_media: &MediaFilesAsCdnUrlListAndMap,
) -> Result<GenerateImageResponse, CommonWebError> {
let provider = draft_or_request.get_provider();
let client = build_router_client(provider, server_state)?;
let request = finalize_request(draft_or_request).await?;
// Inline the draft finalization so the draft context can borrow `client`.
let request: ImageGenerationRequest = match draft_or_request {
ImageGenerationDraftOrRequest::Request(request) => request,
ImageGenerationDraftOrRequest::Draft(draft) => {
let draft_context = ImageGenerationDraftContext {
client: Some(&client),
media_file_to_artcraft_url_map: Some(&resolved_media.token_to_url_map),
};
draft.finalize(draft_context)
.await
.map_err(|err| {
warn!("Failed to finalize image v2 draft: {:?}", err);
CommonWebError::from_error(err)
})?
}
};
request.send_request(&client)
.await
@@ -133,22 +168,6 @@ async fn finalize_and_generate(
})
}
async fn finalize_request(
draft_or_request: ImageGenerationDraftOrRequest,
) -> Result<ImageGenerationRequest, CommonWebError> {
match draft_or_request {
ImageGenerationDraftOrRequest::Request(request) => Ok(request),
ImageGenerationDraftOrRequest::Draft(draft) => {
draft.finalize(ImageGenerationDraftContext::default())
.await
.map_err(|err| {
warn!("Failed to finalize image v2 draft: {:?}", err);
CommonWebError::from_error(err)
})
}
}
}
fn build_router_client(
provider: RouterProvider,
server_state: &ServerState,
@@ -161,6 +180,15 @@ fn build_router_client(
);
Ok(RouterClient::Fal(fal_client))
},
RouterProvider::Seedance2Pro => {
// Midjourney image generation always uses the Volcengine account.
// Per-request account selection (like the video flow's
// `KinoviAccount` enum) isn't needed here yet — if it ever is, plumb
// a parallel knob through `RunPipelineV2Args`.
let cookies = server_state.inference_providers.seedance2pro.cookies_volcengine.clone();
let session = Seedance2ProSession::from_cookies_string(cookies);
Ok(RouterClient::Seedance2Pro(RouterSeedance2ProClient::new(session)))
},
other => {
Err(CommonWebError::server_error_with_message(
&format!("Unsupported provider for image v2 generation: {:?}", other),
@@ -28,6 +28,8 @@ use crate::http_server::endpoints::moderation::wallets::moderator_get_wallet_han
use crate::http_server::endpoints::moderation::jobs::get_tts_inference_queue_count::get_tts_inference_queue_count_handler;
use crate::http_server::endpoints::moderation::jobs::kill_tts_inference_jobs::kill_tts_inference_jobs_handler;
use crate::http_server::endpoints::moderation::user_bans::moderation_ban_user_handler::moderation_ban_user_handler;
use crate::http_server::endpoints::moderation::user_emails::moderator_change_user_email::moderator_change_user_email_handler;
use crate::http_server::endpoints::moderation::user_emails::moderator_list_email_address_changes_for_user::moderator_list_email_address_changes_for_user_handler;
use crate::http_server::endpoints::moderation::user::moderator_list_subscribing_users_by_signup_date::moderator_list_subscribing_users_by_signup_date_handler;
use crate::http_server::endpoints::moderation::user::moderator_list_users_by_signup_date::moderator_list_users_by_signup_date_handler;
use crate::http_server::endpoints::moderation::user::moderator_user_lookup_by_stripe_customer_id_handler::moderator_user_lookup_by_stripe_customer_id_handler;
@@ -216,6 +218,19 @@ pub fn add_moderator_routes<T, B> (app: App<T>) -> App<T>
.route(web::head().to(|| HttpResponse::Ok()))
)
)
.service(
web::scope("/user_emails")
.service(
web::resource("/change")
.route(web::post().to(moderator_change_user_email_handler))
.route(web::head().to(|| HttpResponse::Ok()))
)
.service(
web::resource("/list/{username}")
.route(web::get().to(moderator_list_email_address_changes_for_user_handler))
.route(web::head().to(|| HttpResponse::Ok()))
)
)
.service(
web::scope("/roles")
.service(
@@ -34,6 +34,7 @@ import {
useSidebar,
} from "../components/ui/sidebar";
import { AppSidebar } from "../components/sidebar/app-sidebar";
import { MobileBottomNav } from "../components/sidebar/mobile-bottom-nav";
import { TopBar } from "../components/topbar/topbar";
import { SignupCtaModal } from "../components/signup-cta-modal";
import { InsufficientCreditsModal } from "../components/insufficient-credits-modal";
@@ -103,6 +104,7 @@ function ProtectedContent() {
<SidebarInset className="flex-1 min-h-0 overflow-y-auto bg-[#121212]">
<Outlet />
</SidebarInset>
{isMobile && <MobileBottomNav />}
</div>
);
}
@@ -1,10 +1,17 @@
import { type ReactNode } from "react";
import { type ReactNode, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faSpinnerThird } from "@fortawesome/pro-solid-svg-icons";
import { isMobile } from "react-device-detect";
import { type PopoverItem } from "@storyteller/ui-popover";
import { TabSelector } from "@storyteller/ui-tab-selector";
import { TruchetPattern } from "@storyteller/ui-vfx";
import Seo from "../../components/seo";
import { useIsMobile } from "../ui/use-mobile";
import { GalleryViewToggle } from "./GalleryViewToggle";
import {
MobileCreateTabsProvider,
type MobileCreateTab,
} from "./mobile-create-tabs";
interface CreateMediaPageShellProps {
// SEO
@@ -30,9 +37,18 @@ interface CreateMediaPageShellProps {
// Children slots
gridContent: ReactNode;
promptBox: ReactNode;
// Mobile-only form rendered on the "Generate" tab. When provided and the
// viewport is mobile, the shell swaps the floating prompt box for a tabbed
// Generate / History layout.
promptForm?: ReactNode;
modals: ReactNode;
}
const MOBILE_TABS = [
{ id: "generate", label: "Generate" },
{ id: "history", label: "History" },
];
export function CreateMediaPageShell({
title,
description,
@@ -45,8 +61,12 @@ export function CreateMediaPageShell({
glowOrbs,
gridContent,
promptBox,
promptForm,
modals,
}: CreateMediaPageShellProps) {
const viewportIsMobile = useIsMobile();
const [mobileTab, setMobileTab] = useState<MobileCreateTab>("generate");
if (!authChecked) {
return (
<div className="flex h-full items-center justify-center bg-[#101014]">
@@ -58,6 +78,59 @@ export function CreateMediaPageShell({
);
}
// Mobile: split into Generate (form) / History (gallery) tabs so the prompt
// box no longer overlaps results. Desktop keeps the floating prompt box.
if (viewportIsMobile && promptForm) {
return (
<MobileCreateTabsProvider
value={{
tab: mobileTab,
setTab: setMobileTab,
goToHistory: () => setMobileTab("history"),
}}
>
<div className="flex h-full w-full flex-col bg-[#101014] text-white">
<Seo title={title} description={description} />
<div className="relative flex items-center justify-center border-b border-ui-panel-border px-3 py-2">
<TabSelector
tabs={MOBILE_TABS}
activeTab={mobileTab}
onTabChange={(id) => setMobileTab(id as MobileCreateTab)}
className="w-auto"
/>
{mobileTab === "history" && hasContent && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
<GalleryViewToggle />
</div>
)}
</div>
<div className="min-h-0 flex-1 overflow-hidden">
{mobileTab === "generate" ? (
promptForm
) : hasContent ? (
<div className="h-full w-full overflow-y-auto pt-0.5">
<div className="px-3">{gridContent}</div>
</div>
) : (
<div className="flex h-full flex-col items-center justify-center px-6 text-center">
<span className="text-lg font-semibold text-white">
{emptyStateTitle}
</span>
<span className="pt-1 text-sm text-white/60">
Your generations will appear here.
</span>
</div>
)}
</div>
</div>
{modals}
</MobileCreateTabsProvider>
);
}
return (
<div className="flex h-full w-full bg-[#101014] text-white">
<Seo title={title} description={description} />
@@ -0,0 +1,22 @@
import { createContext, useContext } from "react";
export type MobileCreateTab = "generate" | "history";
interface MobileCreateTabsValue {
tab: MobileCreateTab;
setTab: (tab: MobileCreateTab) => void;
// Switch to the History tab — called by the form after a generation starts.
goToHistory: () => void;
}
const MobileCreateTabsContext = createContext<MobileCreateTabsValue>({
tab: "generate",
setTab: () => {},
goToHistory: () => {},
});
export const MobileCreateTabsProvider = MobileCreateTabsContext.Provider;
export function useMobileCreateTabs(): MobileCreateTabsValue {
return useContext(MobileCreateTabsContext);
}

Some files were not shown because too many files have changed in this diff Show More