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