Compare commits

...
Author SHA1 Message Date
Tanish Manku e7fc884d73 pagedraw: composite draw layer into reference image for default generation case
3D nodes and drawings on the canvas were missing from the reference image
sent to the model in the default generation path. The fix mirrors the Nano
Banana Pro approach: render a composite of the base image and the draw layer,
upload it via uploadSceneSnapshot, and pass it as scene_image_media_token
rather than putting editedImageToken in image_media_tokens.
2026-03-17 14:57:40 -07:00
Brandon Thomas 42bb07c110 hide website image and video gen for now (it's slightly broken) (#1155) 2026-03-17 16:03:02 -04:00
Brandon Thomas 5afa81b57d alter media_files to include thumbnail information (#1154) 2026-03-17 15:42:44 -04:00
Brandon Thomas 81cadf35b0 fix compile error 2026-03-17 09:46:50 -04:00
BFlat d4b3579915 web image and video generations (#1146)
Image and video generation pages on getartcraft website.
2026-03-17 20:36:29 +07:00
Brandon Thomas 7cd8c0299b artcraft router: seedance (#1153)
add multiple providers to artcraft router for seedance
2026-03-16 22:43:50 -04:00
Brandon Thomas 87296752a5 Use muapi as backup (#1152)
Possible problem in our Seedance 2.0 provider means we need a backup.
This is here in case we need it.

Muapi is inferior for several reasons:

- Videos contain watermarks
- Videos cost 2x as much
- Not as many aspect ratio controls
- Not as many duration controls
- No starting frame / ending frame
- No video references
- No audio references

It does seem fast, however.
2026-03-16 17:15:53 -04:00
Brandon Thomas 1416c5cd17 bump 0.14 -> 0.15 2026-03-16 13:27:54 -04:00
BFlat de29444626 allow playing audio ref in seedance prompt input (#1151) 2026-03-16 22:02:39 +07:00
Brandon Thomas 41988a4462 plumb references to seedance (#1150) 2026-03-16 10:49:00 -04:00
BFlat 4c0c536fb0 video and audio references seedance 2.0 (#1121)
<img width="846" height="544" alt="image"
src="https://github.com/user-attachments/assets/1ca92a7e-1faa-47a4-b32c-24d9587aff61"
/>
<img width="374" height="308" alt="image"
src="https://github.com/user-attachments/assets/2331fc09-b576-4556-9a56-225d24eb30e1"
/>

Do not merge: awaiting backend.
2026-03-16 09:26:26 -04:00
bflatastic b15a5d532a angles history stack base thumbnail to use cdn image instead of blob 2026-03-16 19:55:30 +07:00
Brandon Thomas 8ae983ee32 seedance omni video (#1148) 2026-03-16 08:53:47 -04:00
bflatastic 279a79e60a angles local upload thumbnails not showing and get rid of double toast 2026-03-16 19:48:33 +07:00
bflatastic ebd2a276f9 body font updates and fixed title font in app 2026-03-14 15:52:02 +07:00
bflatastic 0d2f1cfa2c website and app font update 2026-03-14 13:13:34 +07:00
Tanish Manku 3592111019 Improve 2D (PageDraw) render stability and performance (#1143)
Generous use of caching used for react elements. Made a pretty
significant difference too.
Minor refactor to move helper functions outside instead of within
component tree.
2026-03-13 16:42:11 -07:00
Tanish Manku 9d12603d1f 2D Editor support for 3D objects (#1142)
3D objects can now be drag-n-dropped from gallery onto 2D canvas, with
support for scale, rotation and FOV changes.
History is tracks so changes support undo/redo as well.
2026-03-13 16:35:46 -07:00
bflatastic 8902f77a18 Show both enum failure reason and dynamic failure message in task queue 2026-03-14 04:18:40 +07:00
Brandon Thomas 6d91b5616f add failure messages to backend + tauri (#1141) 2026-03-13 13:45:54 -04:00
Brandon Thomas 3783c194e3 make job statuses forward- and backwards- compatible (#1140)
this could have possibly been breaking older clients
2026-03-13 10:44:29 -04:00
268 changed files with 13810 additions and 1201 deletions
+4
View File
@@ -1,5 +1,9 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<codeStyleSettings language="Markdown">
<option name="RIGHT_MARGIN" value="100" />
<option name="WRAP_ON_TYPING" value="1" />
</codeStyleSettings>
<codeStyleSettings language="Rust">
<option name="RIGHT_MARGIN" value="180" />
<option name="ALIGN_MULTILINE_PARAMETERS" value="false" />
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO generic_inference_jobs\nSET\n token = ?,\n uuid_idempotency_token = ?,\n\n job_type = ?,\n\n maybe_external_third_party = ?,\n maybe_external_third_party_id = ?,\n\n product_category = ?,\n inference_category = ?,\n\n maybe_model_type = NULL,\n maybe_model_token = NULL,\n\n maybe_input_source_token = NULL,\n maybe_input_source_token_type = NULL,\n\n maybe_download_url = NULL,\n maybe_cover_image_media_file_token = NULL,\n \n maybe_prompt_token = ?,\n\n maybe_raw_inference_text = NULL,\n\n maybe_inference_args = ?,\n\n maybe_creator_user_token = ?,\n maybe_creator_anonymous_visitor_token = ?,\n creator_ip_address = ?,\n creator_set_visibility = ?,\n\n priority_level = 0,\n is_keepalive_required = FALSE,\n max_duration_seconds = 0,\n\n is_debug_request = FALSE,\n maybe_routing_tag = NULL,\n\n status = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 14
},
"nullable": []
},
"hash": "002cdb3d086f3b4181e2997befa54bd8516cb1d3d3bb6e389862695318d5c299"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO generic_inference_jobs\nSET\n token = ?,\n uuid_idempotency_token = ?,\n\n job_type = ?,\n\n maybe_external_third_party = ?,\n maybe_external_third_party_id = ?,\n\n product_category = ?,\n inference_category = ?,\n\n maybe_model_type = NULL,\n maybe_model_token = NULL,\n\n maybe_input_source_token = NULL,\n maybe_input_source_token_type = NULL,\n\n maybe_download_url = NULL,\n maybe_cover_image_media_file_token = NULL,\n\n maybe_prompt_token = ?,\n\n maybe_raw_inference_text = NULL,\n\n maybe_inference_args = ?,\n\n maybe_creator_user_token = ?,\n maybe_creator_anonymous_visitor_token = ?,\n creator_ip_address = ?,\n creator_set_visibility = ?,\n\n priority_level = 0,\n is_keepalive_required = FALSE,\n max_duration_seconds = 0,\n\n is_debug_request = FALSE,\n maybe_routing_tag = NULL,\n\n frontend_failure_category = ?,\n failure_reason = ?,\n\n status = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 16
},
"nullable": []
},
"hash": "29916e73a1784ea0b70ef4f637520b05efccc31e01840f681e9b042f82eb8f7e"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n UPDATE tasks\n SET task_status = ?,\n on_failure_type = ?,\n on_failure_message = ?\n WHERE id = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 4
},
"nullable": []
},
"hash": "9b99ccd0baf9a4b74d8903f63e3a3dcf4c09b75cad978f0532388cbd743b7516"
}
@@ -1,6 +1,6 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n jobs.token as `job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,\n\n jobs.status as `status: enums::common::job_status_plus::JobStatusPlus`,\n jobs.attempt_count,\n\n jobs.maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n jobs.maybe_creator_anonymous_visitor_token as `maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken`,\n jobs.creator_ip_address,\n\n jobs.product_category as `product_category: enums::by_table::generic_inference_jobs::inference_job_product_category::InferenceJobProductCategory`,\n jobs.inference_category as `inference_category: enums::by_table::generic_inference_jobs::inference_category::InferenceCategory`,\n jobs.maybe_model_type,\n jobs.maybe_model_token,\n jobs.maybe_raw_inference_text,\n jobs.maybe_inference_args,\n\n jobs.frontend_failure_category as `maybe_frontend_failure_category: enums::by_table::generic_inference_jobs::frontend_failure_category::FrontendFailureCategory`,\n\n jobs.on_success_result_entity_type as maybe_result_entity_type,\n jobs.on_success_result_entity_token as maybe_result_entity_token,\n jobs.on_success_result_batch_token as maybe_result_batch_token,\n\n model_weights.title as maybe_model_weights_title,\n tts_models.title as maybe_tts_model_title,\n voice_conversion_models.title as maybe_voice_conversion_model_title,\n\n tts_results.public_bucket_wav_audio_path as maybe_tts_public_bucket_path,\n voice_conversion_results.public_bucket_hash as maybe_voice_conversion_public_bucket_hash,\n\n media_files.public_bucket_directory_hash as maybe_media_file_public_bucket_directory_hash,\n media_files.maybe_public_bucket_prefix as maybe_media_file_public_bucket_prefix,\n media_files.maybe_public_bucket_extension as maybe_media_file_public_bucket_extension,\n\n jobs.assigned_worker as maybe_assigned_worker,\n jobs.assigned_cluster as maybe_assigned_cluster,\n\n jobs.is_keepalive_required,\n\n jobs.created_at,\n jobs.updated_at,\n\n jobs.first_started_at as maybe_first_started_at,\n jobs.successfully_completed_at as maybe_successfully_completed_at,\n\n NOW() as database_clock\n\nFROM generic_inference_jobs as jobs\n\nLEFT OUTER JOIN model_weights ON jobs.maybe_model_token = model_weights.token\nLEFT OUTER JOIN tts_models ON jobs.maybe_model_token = tts_models.token\nLEFT OUTER JOIN voice_conversion_models ON jobs.maybe_model_token = voice_conversion_models.token\n\nLEFT OUTER JOIN tts_results ON jobs.on_success_result_entity_token = tts_results.token\nLEFT OUTER JOIN voice_conversion_results ON jobs.on_success_result_entity_token = voice_conversion_results.token\nLEFT OUTER JOIN media_files ON jobs.on_success_result_entity_token = media_files.token\n\nWHERE jobs.token = ?\n ",
"query": "\nSELECT\n jobs.token as `job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,\n\n jobs.status as `status: enums::common::job_status_plus::JobStatusPlus`,\n jobs.attempt_count,\n\n jobs.maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n jobs.maybe_creator_anonymous_visitor_token as `maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken`,\n jobs.creator_ip_address,\n\n jobs.product_category as `product_category: enums::by_table::generic_inference_jobs::inference_job_product_category::InferenceJobProductCategory`,\n jobs.inference_category as `inference_category: enums::by_table::generic_inference_jobs::inference_category::InferenceCategory`,\n jobs.maybe_model_type,\n jobs.maybe_model_token,\n jobs.maybe_raw_inference_text,\n jobs.maybe_inference_args,\n\n jobs.frontend_failure_category as `maybe_frontend_failure_category: enums::by_table::generic_inference_jobs::frontend_failure_category::FrontendFailureCategory`,\n jobs.failure_reason,\n\n jobs.on_success_result_entity_type as maybe_result_entity_type,\n jobs.on_success_result_entity_token as maybe_result_entity_token,\n jobs.on_success_result_batch_token as maybe_result_batch_token,\n\n model_weights.title as maybe_model_weights_title,\n tts_models.title as maybe_tts_model_title,\n voice_conversion_models.title as maybe_voice_conversion_model_title,\n\n tts_results.public_bucket_wav_audio_path as maybe_tts_public_bucket_path,\n voice_conversion_results.public_bucket_hash as maybe_voice_conversion_public_bucket_hash,\n\n media_files.public_bucket_directory_hash as maybe_media_file_public_bucket_directory_hash,\n media_files.maybe_public_bucket_prefix as maybe_media_file_public_bucket_prefix,\n media_files.maybe_public_bucket_extension as maybe_media_file_public_bucket_extension,\n\n jobs.assigned_worker as maybe_assigned_worker,\n jobs.assigned_cluster as maybe_assigned_cluster,\n\n jobs.is_keepalive_required,\n\n jobs.created_at,\n jobs.updated_at,\n\n jobs.first_started_at as maybe_first_started_at,\n jobs.successfully_completed_at as maybe_successfully_completed_at,\n\n NOW() as database_clock\n\nFROM generic_inference_jobs as jobs\n\nLEFT OUTER JOIN model_weights ON jobs.maybe_model_token = model_weights.token\nLEFT OUTER JOIN tts_models ON jobs.maybe_model_token = tts_models.token\nLEFT OUTER JOIN voice_conversion_models ON jobs.maybe_model_token = voice_conversion_models.token\n\nLEFT OUTER JOIN tts_results ON jobs.on_success_result_entity_token = tts_results.token\nLEFT OUTER JOIN voice_conversion_results ON jobs.on_success_result_entity_token = voice_conversion_results.token\nLEFT OUTER JOIN media_files ON jobs.on_success_result_entity_token = media_files.token\n\nWHERE jobs.token = ?\n ",
"describe": {
"columns": [
{
@@ -135,6 +135,16 @@
},
{
"ordinal": 13,
"name": "failure_reason",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 2048
}
},
{
"ordinal": 14,
"name": "maybe_result_entity_type",
"type_info": {
"type": "VarString",
@@ -144,7 +154,7 @@
}
},
{
"ordinal": 14,
"ordinal": 15,
"name": "maybe_result_entity_token",
"type_info": {
"type": "VarString",
@@ -154,7 +164,7 @@
}
},
{
"ordinal": 15,
"ordinal": 16,
"name": "maybe_result_batch_token",
"type_info": {
"type": "VarString",
@@ -164,7 +174,7 @@
}
},
{
"ordinal": 16,
"ordinal": 17,
"name": "maybe_model_weights_title",
"type_info": {
"type": "VarString",
@@ -174,7 +184,7 @@
}
},
{
"ordinal": 17,
"ordinal": 18,
"name": "maybe_tts_model_title",
"type_info": {
"type": "VarString",
@@ -184,7 +194,7 @@
}
},
{
"ordinal": 18,
"ordinal": 19,
"name": "maybe_voice_conversion_model_title",
"type_info": {
"type": "VarString",
@@ -194,7 +204,7 @@
}
},
{
"ordinal": 19,
"ordinal": 20,
"name": "maybe_tts_public_bucket_path",
"type_info": {
"type": "VarString",
@@ -204,7 +214,7 @@
}
},
{
"ordinal": 20,
"ordinal": 21,
"name": "maybe_voice_conversion_public_bucket_hash",
"type_info": {
"type": "VarString",
@@ -214,7 +224,7 @@
}
},
{
"ordinal": 21,
"ordinal": 22,
"name": "maybe_media_file_public_bucket_directory_hash",
"type_info": {
"type": "VarString",
@@ -224,7 +234,7 @@
}
},
{
"ordinal": 22,
"ordinal": 23,
"name": "maybe_media_file_public_bucket_prefix",
"type_info": {
"type": "VarString",
@@ -234,7 +244,7 @@
}
},
{
"ordinal": 23,
"ordinal": 24,
"name": "maybe_media_file_public_bucket_extension",
"type_info": {
"type": "VarString",
@@ -244,7 +254,7 @@
}
},
{
"ordinal": 24,
"ordinal": 25,
"name": "maybe_assigned_worker",
"type_info": {
"type": "VarString",
@@ -254,7 +264,7 @@
}
},
{
"ordinal": 25,
"ordinal": 26,
"name": "maybe_assigned_cluster",
"type_info": {
"type": "VarString",
@@ -264,7 +274,7 @@
}
},
{
"ordinal": 26,
"ordinal": 27,
"name": "is_keepalive_required",
"type_info": {
"type": "Tiny",
@@ -274,7 +284,7 @@
}
},
{
"ordinal": 27,
"ordinal": 28,
"name": "created_at",
"type_info": {
"type": "Timestamp",
@@ -284,7 +294,7 @@
}
},
{
"ordinal": 28,
"ordinal": 29,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
@@ -294,7 +304,7 @@
}
},
{
"ordinal": 29,
"ordinal": 30,
"name": "maybe_first_started_at",
"type_info": {
"type": "Timestamp",
@@ -304,7 +314,7 @@
}
},
{
"ordinal": 30,
"ordinal": 31,
"name": "maybe_successfully_completed_at",
"type_info": {
"type": "Timestamp",
@@ -314,7 +324,7 @@
}
},
{
"ordinal": 31,
"ordinal": 32,
"name": "database_clock",
"type_info": {
"type": "Datetime",
@@ -354,6 +364,7 @@
true,
true,
true,
true,
false,
false,
false,
@@ -362,5 +373,5 @@
false
]
},
"hash": "243afe0bc574f9f233c7797add9faf96440e2bafd52032e07951272fb1ec78b1"
"hash": "a2c9e2f61eba666254f8432b3c50a0a7738ee3b3faa77c5281bc8e4af96159b9"
}
Generated
+29
View File
@@ -788,6 +788,8 @@ dependencies = [
"artcraft_client",
"fal_client",
"log",
"muapi_client",
"reqwest 0.12.23",
"seedance2pro",
"serde",
"serde_derive",
@@ -795,9 +797,11 @@ dependencies = [
"test_data",
"tokens",
"tokio",
"url_utils",
"uuid 1.18.1",
"workspace-build-acceleration",
"world_labs_api",
"wreq",
]
[[package]]
@@ -3934,6 +3938,7 @@ dependencies = [
name = "enums"
version = "0.0.1"
dependencies = [
"log",
"serde",
"serde_derive",
"serde_json",
@@ -7228,6 +7233,29 @@ dependencies = [
"thiserror 1.0.63",
]
[[package]]
name = "muapi_client"
version = "0.0.1"
dependencies = [
"chrono",
"cloudflare_errors",
"env",
"env_logger 0.11.9",
"errors",
"log",
"rand 0.9.2",
"serde",
"serde_derive",
"serde_json",
"test_data",
"testing",
"tokio",
"url",
"workspace-build-acceleration",
"wreq",
"wreq-util",
]
[[package]]
name = "muda"
version = "0.17.1"
@@ -11597,6 +11625,7 @@ dependencies = [
"tts_common",
"url",
"url_config",
"url_utils",
"user_input_common",
"user_traits_component",
"users",
+2
View File
@@ -16,6 +16,7 @@ members = [
"crates/api_clients/midjourney_client",
"crates/api_clients/openai_sora_client",
"crates/api_clients/openart_client",
"crates/api_clients/muapi_client",
"crates/api_clients/seedance2pro",
"crates/api_clients/runwayml_client",
"crates/api_clients/thumbnail_generator",
@@ -240,6 +241,7 @@ memory_store = { path = "crates/lib/caching/memory_store" }
midjourney_client = { path = "crates/api_clients/midjourney_client" }
mimetypes = { path = "crates/lib/files/mimetypes" }
openai_sora_client = { path = "crates/api_clients/openai_sora_client" }
muapi_client = { path = "crates/api_clients/muapi_client" }
seedance2pro = { path = "crates/api_clients/seedance2pro" }
primitives = { path = "crates/lib/data/primitives" }
shared_service_components = { path = "crates/lib/shared_service_components" }
@@ -0,0 +1,7 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE media_files
DROP COLUMN
maybe_thumbnail_version;
@@ -0,0 +1,7 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE media_files
ADD COLUMN
maybe_thumbnail_version TINYINT UNSIGNED DEFAULT NULL;
@@ -224,6 +224,12 @@ CREATE TABLE media_files (
-- If so, we may not need to add a watermark downstream in further processing steps.
has_watermark BOOLEAN NOT NULL DEFAULT FALSE,
-- Tracks the thumbnail algorithm/type used for this media file.
-- NULL = no thumbnail generated yet.
-- 1 = video thumbnail.
-- Other values are reserved for future thumbnail algorithms or media-specific types.
maybe_thumbnail_version TINYINT UNSIGNED DEFAULT NULL,
-- ========== UPLOAD, TRANSCODING, AND TRUNCATION DETAILS ==========
-- The hash for the bucket directory that contains the original upload
@@ -23,6 +23,12 @@ pub struct Seedance2p0MultiFunctionVideoGenRequest {
/// Optional reference images (reference mode). When present, takes priority over start/end frames.
pub reference_image_media_tokens: Option<Vec<MediaFileToken>>,
/// Optional reference videos.
pub reference_video_media_tokens: Option<Vec<MediaFileToken>>,
/// Optional reference audio files.
pub reference_audio_media_tokens: Option<Vec<MediaFileToken>>,
/// Video aspect ratio / resolution.
pub aspect_ratio: Option<Seedance2p0AspectRatio>,
@@ -9,6 +9,8 @@ use serde::Deserialize;
use serde::Serialize;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use utoipa::{IntoParams, ToSchema};
use enums::api_safe::by_table::generic_inference_jobs::frontend_failure_category_for_api_clients::FrontendFailureCategoryForApiClients;
use enums::api_safe::by_table::generic_inference_jobs::frontend_failure_category_for_old_clients::FrontendFailureCategoryForOldClients;
use tokens::tokens::batch_generations::BatchGenerationToken;
pub const LIST_SESSION_JOBS_URL_PATH: &str = "/v1/jobs/session";
@@ -98,9 +100,19 @@ pub struct ListSessionStatusDetailsResponse {
/// This is typically only for non-premium users.
pub requires_keepalive: bool,
/// NB: DO NOT CHANGE: Old clients can't read the new enum payload values as they are introduced.
///
/// An enum the frontend can use to display localized/I18N error
/// messages. These pertain to both transient and permanent failures.
pub maybe_failure_category: Option<FrontendFailureCategory>,
#[deprecated(note="use maybe_failure_category_updated")]
pub maybe_failure_category: Option<FrontendFailureCategoryForOldClients>,
/// An enum the frontend can use to display localized/I18N error
/// messages. These pertain to both transient and permanent failures.
pub maybe_failure_category_updated: Option<FrontendFailureCategoryForApiClients>,
/// User-facing failure messages
pub maybe_failure_message: Option<String>,
/// This is an integer number between 0 and 100 (both inclusive) that
/// reports the completeness.
@@ -125,4 +137,3 @@ pub struct ListSessionResultDetailsResponse {
pub maybe_successfully_completed_at: Option<DateTime<Utc>>,
}
@@ -19,14 +19,18 @@ workspace-build-acceleration.workspace = true
artcraft_api_defs.workspace = true
artcraft_client.workspace = true
fal_client.workspace = true
muapi_client.workspace = true
seedance2pro.workspace = true
tokens.workspace = true
url_utils.workspace = true
world_labs_api.workspace = true
# External
anyhow.workspace = true
log.workspace = true
reqwest.workspace = true
serde.workspace = true
wreq.workspace = true
serde_derive.workspace = true
tokio.workspace = true
uuid.workspace = true
@@ -0,0 +1,7 @@
use tokens::tokens::media_files::MediaFileToken;
#[derive(Copy, Clone)]
pub enum AudioListRef<'a> {
MediaFileTokens(&'a Vec<MediaFileToken>),
Urls(&'a Vec<String>),
}
@@ -1,3 +1,4 @@
pub mod audio_list_ref;
pub mod common_aspect_ratio;
pub mod common_image_model;
pub mod common_resolution;
@@ -6,3 +7,4 @@ pub mod common_video_model;
pub mod image_list_ref;
pub mod image_ref;
pub mod provider;
pub mod video_list_ref;
@@ -6,4 +6,6 @@ use serde_derive::{Deserialize, Serialize};
pub enum Provider {
Artcraft,
Fal,
Muapi,
Seedance2Pro,
}
@@ -0,0 +1,7 @@
use tokens::tokens::media_files::MediaFileToken;
#[derive(Copy, Clone)]
pub enum VideoListRef<'a> {
MediaFileTokens(&'a Vec<MediaFileToken>),
Urls(&'a Vec<String>),
}
@@ -5,3 +5,5 @@ pub mod request_mismatch_mitigation_strategy;
pub mod router_artcraft_client;
pub mod router_client;
pub mod router_fal_client;
pub mod router_muapi_client;
pub mod router_seedance2pro_client;
@@ -1,10 +1,14 @@
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::client::router_fal_client::RouterFalClient;
use crate::client::router_muapi_client::RouterMuapiClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::errors::client_error::{ClientError, ClientType};
pub struct MultiRouterClient {
pub(crate) artcraft_client: Option<RouterArtcraftClient>,
pub(crate) fal_client: Option<RouterFalClient>,
pub(crate) muapi_client: Option<RouterMuapiClient>,
pub(crate) seedance2pro_client: Option<RouterSeedance2ProClient>,
}
impl MultiRouterClient {
@@ -17,4 +21,14 @@ impl MultiRouterClient {
self.fal_client.as_ref()
.ok_or(ClientError::ClientNotConfigured(ClientType::Fal))
}
pub fn get_muapi_client_ref(&self) -> Result<&RouterMuapiClient, ClientError> {
self.muapi_client.as_ref()
.ok_or(ClientError::ClientNotConfigured(ClientType::Muapi))
}
pub fn get_seedance2pro_client_ref(&self) -> Result<&RouterSeedance2ProClient, ClientError> {
self.seedance2pro_client.as_ref()
.ok_or(ClientError::ClientNotConfigured(ClientType::Seedance2Pro))
}
}
@@ -1,10 +1,14 @@
use crate::client::multi_router_client::MultiRouterClient;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::client::router_fal_client::RouterFalClient;
use crate::client::router_muapi_client::RouterMuapiClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
pub struct MultiRouterClientBuilder {
artcraft_client: Option<RouterArtcraftClient>,
fal_client: Option<RouterFalClient>,
muapi_client: Option<RouterMuapiClient>,
seedance2pro_client: Option<RouterSeedance2ProClient>,
}
impl MultiRouterClientBuilder {
@@ -12,6 +16,8 @@ impl MultiRouterClientBuilder {
Self {
artcraft_client: None,
fal_client: None,
muapi_client: None,
seedance2pro_client: None,
}
}
@@ -25,10 +31,22 @@ impl MultiRouterClientBuilder {
self
}
pub fn set_muapi_client(mut self, client: RouterMuapiClient) -> Self {
self.muapi_client = Some(client);
self
}
pub fn set_seedance2pro_client(mut self, client: RouterSeedance2ProClient) -> Self {
self.seedance2pro_client = Some(client);
self
}
pub fn build(self) -> MultiRouterClient {
MultiRouterClient {
artcraft_client: self.artcraft_client,
fal_client: self.fal_client,
muapi_client: self.muapi_client,
seedance2pro_client: self.seedance2pro_client,
}
}
}
@@ -1,12 +1,16 @@
use crate::client::multi_router_client::MultiRouterClient;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::client::router_fal_client::RouterFalClient;
use crate::client::router_muapi_client::RouterMuapiClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::errors::client_error::{ClientError, ClientType};
pub enum RouterClient {
Multi(MultiRouterClient),
Artcraft(RouterArtcraftClient),
Fal(RouterFalClient),
Muapi(RouterMuapiClient),
Seedance2Pro(RouterSeedance2ProClient),
}
impl RouterClient {
@@ -25,4 +29,20 @@ impl RouterClient {
_ => Err(ClientError::ClientNotConfigured(ClientType::Fal)),
}
}
pub fn get_muapi_client_ref(&self) -> Result<&RouterMuapiClient, ClientError> {
match self {
RouterClient::Muapi(client) => Ok(client),
RouterClient::Multi(multi) => multi.get_muapi_client_ref(),
_ => Err(ClientError::ClientNotConfigured(ClientType::Muapi)),
}
}
pub fn get_seedance2pro_client_ref(&self) -> Result<&RouterSeedance2ProClient, ClientError> {
match self {
RouterClient::Seedance2Pro(client) => Ok(client),
RouterClient::Multi(multi) => multi.get_seedance2pro_client_ref(),
_ => Err(ClientError::ClientNotConfigured(ClientType::Seedance2Pro)),
}
}
}
@@ -0,0 +1,11 @@
use muapi_client::creds::muapi_session::MuapiSession;
pub struct RouterMuapiClient {
pub(crate) session: MuapiSession,
}
impl RouterMuapiClient {
pub fn new(session: MuapiSession) -> Self {
RouterMuapiClient { session }
}
}
@@ -0,0 +1,11 @@
use seedance2pro::creds::seedance2pro_session::Seedance2ProSession;
pub struct RouterSeedance2ProClient {
pub(crate) session: Seedance2ProSession,
}
impl RouterSeedance2ProClient {
pub fn new(session: Seedance2ProSession) -> Self {
RouterSeedance2ProClient { session }
}
}
@@ -1,4 +1,5 @@
use crate::errors::client_error::ClientError;
use crate::errors::download_error::DownloadError;
use crate::errors::provider_error::ProviderError;
use std::error::Error;
use std::fmt::{Display, Formatter};
@@ -8,6 +9,9 @@ pub enum ArtcraftRouterError {
/// A client configuration error.
Client(ClientError),
/// Failed to download a file from a URL (e.g. when re-uploading to a provider's CDN).
Download(DownloadError),
/// The requested model is not yet supported by the router.
UnsupportedModel(String),
@@ -24,6 +28,7 @@ impl Display for ArtcraftRouterError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::Client(e) => write!(f, "Client error: {}", e),
Self::Download(e) => write!(f, "Download error: {}", e),
Self::UnsupportedModel(model) => write!(f, "Unsupported model: {}", model),
Self::InvalidInput(msg) => write!(f, "Invalid input: {}", msg),
Self::Provider(e) => write!(f, "Provider error: {}", e),
@@ -37,6 +42,12 @@ impl From<ClientError> for ArtcraftRouterError {
}
}
impl From<DownloadError> for ArtcraftRouterError {
fn from(error: DownloadError) -> Self {
Self::Download(error)
}
}
impl From<ProviderError> for ArtcraftRouterError {
fn from(error: ProviderError) -> Self {
Self::Provider(error)
@@ -5,6 +5,8 @@ use std::fmt::{Display, Formatter};
pub enum ClientType {
Artcraft,
Fal,
Muapi,
Seedance2Pro,
}
impl Display for ClientType {
@@ -12,6 +14,8 @@ impl Display for ClientType {
match self {
Self::Artcraft => write!(f, "Artcraft"),
Self::Fal => write!(f, "Fal"),
Self::Muapi => write!(f, "Muapi"),
Self::Seedance2Pro => write!(f, "Seedance2Pro"),
}
}
}
@@ -33,6 +37,12 @@ pub enum ClientError {
/// Fal only accepts image URLs for image inputs, not media tokens.
FalOnlySupportsUrls,
/// Muapi only accepts URLs for image inputs, not media tokens.
MuapiOnlySupportsUrls,
/// Seedance2Pro only accepts URLs for media inputs, not media tokens.
Seedance2ProOnlySupportsUrls,
}
impl Error for ClientError {}
@@ -55,6 +65,12 @@ impl Display for ClientError {
Self::FalOnlySupportsUrls => {
write!(f, "Fal only supports image URLs for image inputs, not media tokens")
}
Self::MuapiOnlySupportsUrls => {
write!(f, "Muapi only supports URLs for image inputs; resolve media tokens to URLs before calling this provider")
}
Self::Seedance2ProOnlySupportsUrls => {
write!(f, "Seedance2Pro only supports URLs for media inputs; resolve media tokens to URLs before calling this provider")
}
}
}
}
@@ -0,0 +1,40 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
#[derive(Debug)]
pub enum DownloadError {
BadStatus {
url: String,
status_code: u16,
},
ReqwestDownload {
url: String,
error: reqwest::Error,
maybe_status: Option<reqwest::StatusCode>,
},
WreqDownload {
url: String,
error: wreq::Error,
maybe_status: Option<wreq::StatusCode>,
},
}
impl Error for DownloadError {}
impl Display for DownloadError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::BadStatus { url, status_code } => {
write!(f, "Download failed for {} with status {}", url, status_code)
}
Self::ReqwestDownload { url, error, maybe_status } => {
write!(f, "Reqwest download error for {}: {} (status: {:?})", url, error, maybe_status)
}
Self::WreqDownload { url, error, maybe_status } => {
write!(f, "Wreq download error for {}: {} (status: {:?})", url, error, maybe_status)
}
}
}
}
@@ -1,3 +1,4 @@
pub mod artcraft_router_error;
pub mod client_error;
pub mod download_error;
pub mod provider_error;
@@ -2,11 +2,15 @@ use std::error::Error;
use std::fmt::{Display, Formatter};
use artcraft_client::error::storyteller_error::StorytellerError;
use fal_client::error::fal_error_plus::FalErrorPlus;
use muapi_client::error::muapi_error::MuapiError;
use seedance2pro::error::seedance2pro_error::Seedance2ProError;
#[derive(Debug)]
pub enum ProviderError {
Storyteller(StorytellerError),
Fal(FalErrorPlus),
Muapi(MuapiError),
Seedance2Pro(Seedance2ProError),
}
impl Error for ProviderError {}
@@ -16,6 +20,8 @@ impl Display for ProviderError {
match self {
Self::Storyteller(e) => write!(f, "Storyteller provider error: {}", e),
Self::Fal(e) => write!(f, "Fal provider error: {}", e),
Self::Muapi(e) => write!(f, "Muapi provider error: {}", e),
Self::Seedance2Pro(e) => write!(f, "Seedance2Pro provider error: {}", e),
}
}
}
@@ -31,3 +37,15 @@ impl From<FalErrorPlus> for ProviderError {
Self::Fal(error)
}
}
impl From<MuapiError> for ProviderError {
fn from(error: MuapiError) -> Self {
Self::Muapi(error)
}
}
impl From<Seedance2ProError> for ProviderError {
fn from(error: Seedance2ProError) -> Self {
Self::Seedance2Pro(error)
}
}
@@ -73,72 +73,48 @@ impl<'a> GenerateImageRequest<'a> {
/// Read the image generation request, construct a plan, then yield a means to execute it.
pub fn build(&self) -> Result<ImageGenerationPlan<'_>, ArtcraftRouterError> {
match self.provider {
Provider::Artcraft => match self.model {
CommonImageModel::Flux1Dev => {
plan_generate_image_artcraft_flux_1_dev(self).map(ImageGenerationPlan::ArtcraftFlux1Dev)
}
CommonImageModel::Flux1Schnell => {
plan_generate_image_artcraft_flux_1_schnell(self).map(ImageGenerationPlan::ArtcraftFlux1Schnell)
}
CommonImageModel::FluxPro11 => {
plan_generate_image_artcraft_flux_pro_1p1(self).map(ImageGenerationPlan::ArtcraftFluxPro11)
}
CommonImageModel::FluxPro11Ultra => {
plan_generate_image_artcraft_flux_pro_1p1_ultra(self).map(ImageGenerationPlan::ArtcraftFluxPro11Ultra)
}
CommonImageModel::GptImage1p5 => {
plan_generate_image_artcraft_gpt_image_1p5(self).map(ImageGenerationPlan::ArtcraftGptImage1p5)
}
CommonImageModel::NanaBanana => {
plan_generate_image_artcraft_nano_banana(self).map(ImageGenerationPlan::ArtcraftNanaBanana)
}
CommonImageModel::NanaBanana2 => {
plan_generate_image_artcraft_nano_banana_2(self).map(ImageGenerationPlan::ArtcraftNanaBanana2)
}
CommonImageModel::NanaBananaPro => {
plan_generate_image_artcraft_nano_banana_pro(self).map(ImageGenerationPlan::ArtcraftNanaBananaPro)
}
CommonImageModel::Seedream4 => {
plan_generate_image_artcraft_seedream_4(self).map(ImageGenerationPlan::ArtcraftSeedream4)
}
CommonImageModel::Seedream4p5 => {
plan_generate_image_artcraft_seedream_4p5(self).map(ImageGenerationPlan::ArtcraftSeedream4p5)
}
CommonImageModel::Seedream5Lite => {
plan_generate_image_artcraft_seedream_5_lite(self).map(ImageGenerationPlan::ArtcraftSeedream5Lite)
}
CommonImageModel::QwenEdit2511Angles => {
plan_generate_image_artcraft_qwen_edit_2511_angles(self).map(ImageGenerationPlan::ArtcraftQwenEdit2511Angles)
}
CommonImageModel::Flux2LoraAngles => {
plan_generate_image_artcraft_flux_2_lora_angles(self).map(ImageGenerationPlan::ArtcraftFlux2LoraAngles)
}
},
Provider::Fal => match self.model {
CommonImageModel::NanaBananaPro => {
plan_generate_image_fal_nano_banana_pro(self).map(ImageGenerationPlan::FalNanaBananaPro)
}
CommonImageModel::Flux1Dev
| CommonImageModel::Flux1Schnell
| CommonImageModel::FluxPro11
| CommonImageModel::FluxPro11Ultra
| CommonImageModel::GptImage1p5
| CommonImageModel::NanaBanana
| CommonImageModel::NanaBanana2
| CommonImageModel::Seedream4
| CommonImageModel::Seedream4p5
| CommonImageModel::Seedream5Lite
| CommonImageModel::QwenEdit2511Angles
| CommonImageModel::Flux2LoraAngles => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "provider",
value: format!("{:?} is only available on the Artcraft provider", self.model),
}))
}
},
Provider::Artcraft => self.build_artcraft(),
Provider::Fal => self.build_fal(),
_ => self.unsupported_provider(),
}
}
fn build_artcraft(&self) -> Result<ImageGenerationPlan<'_>, ArtcraftRouterError> {
match self.model {
CommonImageModel::Flux1Dev => plan_generate_image_artcraft_flux_1_dev(self),
CommonImageModel::Flux1Schnell => plan_generate_image_artcraft_flux_1_schnell(self),
CommonImageModel::FluxPro11 => plan_generate_image_artcraft_flux_pro_1p1(self),
CommonImageModel::FluxPro11Ultra => plan_generate_image_artcraft_flux_pro_1p1_ultra(self),
CommonImageModel::GptImage1p5 => plan_generate_image_artcraft_gpt_image_1p5(self),
CommonImageModel::NanaBanana => plan_generate_image_artcraft_nano_banana(self),
CommonImageModel::NanaBanana2 => plan_generate_image_artcraft_nano_banana_2(self),
CommonImageModel::NanaBananaPro => plan_generate_image_artcraft_nano_banana_pro(self),
CommonImageModel::Seedream4 => plan_generate_image_artcraft_seedream_4(self),
CommonImageModel::Seedream4p5 => plan_generate_image_artcraft_seedream_4p5(self),
CommonImageModel::Seedream5Lite => plan_generate_image_artcraft_seedream_5_lite(self),
CommonImageModel::QwenEdit2511Angles => plan_generate_image_artcraft_qwen_edit_2511_angles(self),
CommonImageModel::Flux2LoraAngles => plan_generate_image_artcraft_flux_2_lora_angles(self),
}
}
fn build_fal(&self) -> Result<ImageGenerationPlan<'_>, ArtcraftRouterError> {
match self.model {
CommonImageModel::NanaBananaPro => plan_generate_image_fal_nano_banana_pro(self),
_ => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "provider",
value: format!("{:?} is only available on the Artcraft provider", self.model),
}))
}
}
}
fn unsupported_provider(&self) -> Result<ImageGenerationPlan<'_>, ArtcraftRouterError> {
Err(ArtcraftRouterError::UnsupportedModel(
format!("Image generation for model `{:?}` is not supported for provider {:?}", self.model, self.provider)
))
}
pub fn get_or_generate_idempotency_token(&self) -> String {
self.idempotency_token.map(|t| t.to_string())
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string())
@@ -4,6 +4,7 @@ use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigati
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_image::generate_image_request::GenerateImageRequest;
use crate::generate::generate_image::image_generation_plan::ImageGenerationPlan;
use artcraft_api_defs::generate::image::text::generate_flux_1_dev_text_to_image::{
GenerateFlux1DevTextToImageAspectRatio, GenerateFlux1DevTextToImageNumImages,
};
@@ -18,7 +19,7 @@ pub struct PlanArtcraftFlux1Dev<'a> {
pub fn plan_generate_image_artcraft_flux_1_dev<'a>(
request: &'a GenerateImageRequest<'a>,
) -> Result<PlanArtcraftFlux1Dev<'a>, ArtcraftRouterError> {
) -> Result<ImageGenerationPlan<'a>, ArtcraftRouterError> {
// Flux 1 Dev is text-to-image only. Abort if image inputs are provided and
// the caller has opted into strict mode.
if request.image_inputs.is_some() {
@@ -35,12 +36,12 @@ pub fn plan_generate_image_artcraft_flux_1_dev<'a>(
let aspect_ratio = plan_aspect_ratio(request.aspect_ratio, strategy)?;
let num_images = plan_num_images(request.image_batch_count, strategy)?;
Ok(PlanArtcraftFlux1Dev {
Ok(ImageGenerationPlan::ArtcraftFlux1Dev(PlanArtcraftFlux1Dev {
prompt: request.prompt,
aspect_ratio,
num_images,
idempotency_token: request.get_or_generate_idempotency_token(),
})
}))
}
// Flux 1 Dev supported aspect ratios:
@@ -134,7 +135,6 @@ mod tests {
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_image::generate_image_request::GenerateImageRequest;
use crate::generate::generate_image::image_generation_plan::ImageGenerationPlan;
use crate::test_helpers::base_flux_1_dev_image_request;
use artcraft_api_defs::generate::image::text::generate_flux_1_dev_text_to_image::{
GenerateFlux1DevTextToImageAspectRatio as FlAr,
@@ -4,6 +4,7 @@ use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigati
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_image::generate_image_request::GenerateImageRequest;
use crate::generate::generate_image::image_generation_plan::ImageGenerationPlan;
use artcraft_api_defs::generate::image::text::generate_flux_1_schnell_text_to_image::{
GenerateFlux1SchnellTextToImageAspectRatio, GenerateFlux1SchnellTextToImageNumImages,
};
@@ -18,7 +19,7 @@ pub struct PlanArtcraftFlux1Schnell<'a> {
pub fn plan_generate_image_artcraft_flux_1_schnell<'a>(
request: &'a GenerateImageRequest<'a>,
) -> Result<PlanArtcraftFlux1Schnell<'a>, ArtcraftRouterError> {
) -> Result<ImageGenerationPlan<'a>, ArtcraftRouterError> {
// Flux 1 Schnell is text-to-image only. Abort if image inputs are provided and
// the caller has opted into strict mode.
if request.image_inputs.is_some() {
@@ -35,12 +36,12 @@ pub fn plan_generate_image_artcraft_flux_1_schnell<'a>(
let aspect_ratio = plan_aspect_ratio(request.aspect_ratio, strategy)?;
let num_images = plan_num_images(request.image_batch_count, strategy)?;
Ok(PlanArtcraftFlux1Schnell {
Ok(ImageGenerationPlan::ArtcraftFlux1Schnell(PlanArtcraftFlux1Schnell {
prompt: request.prompt,
aspect_ratio,
num_images,
idempotency_token: request.get_or_generate_idempotency_token(),
})
}))
}
// Flux 1 Schnell supported aspect ratios (identical to Flux 1 Dev):
@@ -134,7 +135,6 @@ mod tests {
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_image::generate_image_request::GenerateImageRequest;
use crate::generate::generate_image::image_generation_plan::ImageGenerationPlan;
use crate::test_helpers::base_flux_1_schnell_image_request;
use artcraft_api_defs::generate::image::text::generate_flux_1_schnell_text_to_image::{
GenerateFlux1SchnellTextToImageAspectRatio as FlAr,
@@ -4,6 +4,7 @@ use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigati
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_image::generate_image_request::GenerateImageRequest;
use crate::generate::generate_image::image_generation_plan::ImageGenerationPlan;
use artcraft_api_defs::generate::image::angle::flux_2_lora_edit_image_angle::{
Flux2LoraEditImageAngleImageSize,
Flux2LoraEditImageAngleNumImages,
@@ -23,7 +24,7 @@ pub struct PlanArtcraftFlux2LoraAngles<'a> {
pub fn plan_generate_image_artcraft_flux_2_lora_angles<'a>(
request: &'a GenerateImageRequest<'a>,
) -> Result<PlanArtcraftFlux2LoraAngles<'a>, ArtcraftRouterError> {
) -> Result<ImageGenerationPlan<'a>, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
// Angle models require exactly one input image.
@@ -31,7 +32,7 @@ pub fn plan_generate_image_artcraft_flux_2_lora_angles<'a>(
let image_size = plan_image_size(request.aspect_ratio, strategy)?;
let num_images = plan_num_images(request.image_batch_count, strategy)?;
Ok(PlanArtcraftFlux2LoraAngles {
Ok(ImageGenerationPlan::ArtcraftFlux2LoraAngles(PlanArtcraftFlux2LoraAngles {
image_input,
horizontal_angle: request.horizontal_angle,
vertical_angle: request.vertical_angle,
@@ -39,7 +40,7 @@ pub fn plan_generate_image_artcraft_flux_2_lora_angles<'a>(
image_size,
num_images,
idempotency_token: request.get_or_generate_idempotency_token(),
})
}))
}
fn resolve_single_image_input<'a>(
@@ -139,7 +140,6 @@ fn plan_num_images(
mod tests {
use super::*;
use crate::api::image_list_ref::ImageListRef;
use crate::generate::generate_image::image_generation_plan::ImageGenerationPlan;
use crate::test_helpers::base_flux_2_lora_angles_image_request;
use artcraft_api_defs::generate::image::angle::flux_2_lora_edit_image_angle::{
Flux2LoraEditImageAngleImageSize as S,
@@ -4,6 +4,7 @@ use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigati
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_image::generate_image_request::GenerateImageRequest;
use crate::generate::generate_image::image_generation_plan::ImageGenerationPlan;
use artcraft_api_defs::generate::image::text::generate_flux_pro_11_text_to_image::{
GenerateFluxPro11TextToImageAspectRatio, GenerateFluxPro11TextToImageNumImages,
};
@@ -18,7 +19,7 @@ pub struct PlanArtcraftFluxPro11<'a> {
pub fn plan_generate_image_artcraft_flux_pro_1p1<'a>(
request: &'a GenerateImageRequest<'a>,
) -> Result<PlanArtcraftFluxPro11<'a>, ArtcraftRouterError> {
) -> Result<ImageGenerationPlan<'a>, ArtcraftRouterError> {
// Flux Pro 1.1 is text-to-image only. Abort if image inputs are provided and
// the caller has opted into strict mode.
if request.image_inputs.is_some() {
@@ -35,12 +36,12 @@ pub fn plan_generate_image_artcraft_flux_pro_1p1<'a>(
let aspect_ratio = plan_aspect_ratio(request.aspect_ratio, strategy)?;
let num_images = plan_num_images(request.image_batch_count, strategy)?;
Ok(PlanArtcraftFluxPro11 {
Ok(ImageGenerationPlan::ArtcraftFluxPro11(PlanArtcraftFluxPro11 {
prompt: request.prompt,
aspect_ratio,
num_images,
idempotency_token: request.get_or_generate_idempotency_token(),
})
}))
}
// Flux Pro 1.1 supported aspect ratios (no SquareHd):
@@ -134,7 +135,6 @@ mod tests {
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_image::generate_image_request::GenerateImageRequest;
use crate::generate::generate_image::image_generation_plan::ImageGenerationPlan;
use crate::test_helpers::base_flux_pro_1p1_image_request;
use artcraft_api_defs::generate::image::text::generate_flux_pro_11_text_to_image::{
GenerateFluxPro11TextToImageAspectRatio as FlAr,
@@ -4,6 +4,7 @@ use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigati
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_image::generate_image_request::GenerateImageRequest;
use crate::generate::generate_image::image_generation_plan::ImageGenerationPlan;
use artcraft_api_defs::generate::image::text::generate_flux_pro_11_ultra_text_to_image::{
GenerateFluxPro11UltraTextToImageAspectRatio, GenerateFluxPro11UltraTextToImageNumImages,
};
@@ -18,7 +19,7 @@ pub struct PlanArtcraftFluxPro11Ultra<'a> {
pub fn plan_generate_image_artcraft_flux_pro_1p1_ultra<'a>(
request: &'a GenerateImageRequest<'a>,
) -> Result<PlanArtcraftFluxPro11Ultra<'a>, ArtcraftRouterError> {
) -> Result<ImageGenerationPlan<'a>, ArtcraftRouterError> {
// Flux Pro 1.1 Ultra is text-to-image only. Abort if image inputs are provided and
// the caller has opted into strict mode.
if request.image_inputs.is_some() {
@@ -35,12 +36,12 @@ pub fn plan_generate_image_artcraft_flux_pro_1p1_ultra<'a>(
let aspect_ratio = plan_aspect_ratio(request.aspect_ratio, strategy)?;
let num_images = plan_num_images(request.image_batch_count, strategy)?;
Ok(PlanArtcraftFluxPro11Ultra {
Ok(ImageGenerationPlan::ArtcraftFluxPro11Ultra(PlanArtcraftFluxPro11Ultra {
prompt: request.prompt,
aspect_ratio,
num_images,
idempotency_token: request.get_or_generate_idempotency_token(),
})
}))
}
// Flux Pro 1.1 Ultra supported aspect ratios:
@@ -137,7 +138,6 @@ mod tests {
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_image::generate_image_request::GenerateImageRequest;
use crate::generate::generate_image::image_generation_plan::ImageGenerationPlan;
use crate::test_helpers::base_flux_pro_1p1_ultra_image_request;
use artcraft_api_defs::generate::image::text::generate_flux_pro_11_ultra_text_to_image::{
GenerateFluxPro11UltraTextToImageAspectRatio as FlAr,
@@ -4,6 +4,7 @@ use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigati
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_image::generate_image_request::GenerateImageRequest;
use crate::generate::generate_image::image_generation_plan::ImageGenerationPlan;
use artcraft_api_defs::generate::image::multi_function::gpt_image_1p5_multi_function_image_gen::{
GptImage1p5MultiFunctionImageGenNumImages, GptImage1p5MultiFunctionImageGenQuality,
GptImage1p5MultiFunctionImageGenSize,
@@ -24,21 +25,21 @@ pub struct PlanArtcraftGptImage1p5<'a> {
pub fn plan_generate_image_artcraft_gpt_image_1p5<'a>(
request: &'a GenerateImageRequest<'a>,
) -> Result<PlanArtcraftGptImage1p5<'a>, ArtcraftRouterError> {
) -> Result<ImageGenerationPlan<'a>, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
let image_inputs = resolve_image_list_ref(request.image_inputs)?;
let image_size = plan_image_size(request.aspect_ratio, strategy)?;
let num_images = plan_num_images(request.image_batch_count, strategy)?;
Ok(PlanArtcraftGptImage1p5 {
Ok(ImageGenerationPlan::ArtcraftGptImage1p5(PlanArtcraftGptImage1p5 {
prompt: request.prompt,
image_inputs,
image_size,
quality: GptImage1p5MultiFunctionImageGenQuality::Medium,
num_images,
idempotency_token: request.get_or_generate_idempotency_token(),
})
}))
}
fn resolve_image_list_ref<'a>(
@@ -123,7 +124,6 @@ mod tests {
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::image_generation_plan::ImageGenerationPlan;
use crate::test_helpers::base_gpt_image_1p5_image_request;
use artcraft_api_defs::generate::image::multi_function::gpt_image_1p5_multi_function_image_gen::{
GptImage1p5MultiFunctionImageGenNumImages as GN,
@@ -4,6 +4,7 @@ use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigati
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_image::generate_image_request::GenerateImageRequest;
use crate::generate::generate_image::image_generation_plan::ImageGenerationPlan;
use artcraft_api_defs::generate::image::multi_function::nano_banana_multi_function_image_gen::{
NanoBananaMultiFunctionImageGenAspectRatio, NanoBananaMultiFunctionImageGenNumImages,
};
@@ -21,7 +22,7 @@ pub struct PlanArtcraftNanaBanana<'a> {
pub fn plan_generate_image_artcraft_nano_banana<'a>(
request: &'a GenerateImageRequest<'a>,
) -> Result<PlanArtcraftNanaBanana<'a>, ArtcraftRouterError> {
) -> Result<ImageGenerationPlan<'a>, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
let is_edit_mode = request.image_inputs.is_some();
@@ -29,13 +30,13 @@ pub fn plan_generate_image_artcraft_nano_banana<'a>(
let aspect_ratio = plan_aspect_ratio(request.aspect_ratio, is_edit_mode, strategy)?;
let num_images = plan_num_images(request.image_batch_count, strategy)?;
Ok(PlanArtcraftNanaBanana {
Ok(ImageGenerationPlan::ArtcraftNanaBanana(PlanArtcraftNanaBanana {
prompt: request.prompt,
image_inputs,
aspect_ratio,
num_images,
idempotency_token: request.get_or_generate_idempotency_token(),
})
}))
}
fn resolve_image_list_ref<'a>(
@@ -148,7 +149,6 @@ mod tests {
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::image_generation_plan::ImageGenerationPlan;
use crate::test_helpers::base_nano_banana_image_request;
use artcraft_api_defs::generate::image::multi_function::nano_banana_multi_function_image_gen::{
NanoBananaMultiFunctionImageGenAspectRatio as NbAr,
@@ -5,6 +5,7 @@ use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigati
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_image::generate_image_request::GenerateImageRequest;
use crate::generate::generate_image::image_generation_plan::ImageGenerationPlan;
use artcraft_api_defs::generate::image::multi_function::nano_banana_2_multi_function_image_gen::{
NanaBanana2MultiFunctionImageGenAspectRatio, NanaBanana2MultiFunctionImageGenImageResolution,
NanaBanana2MultiFunctionImageGenNumImages,
@@ -24,7 +25,7 @@ pub struct PlanArtcraftNanaBanana2<'a> {
pub fn plan_generate_image_artcraft_nano_banana_2<'a>(
request: &'a GenerateImageRequest<'a>,
) -> Result<PlanArtcraftNanaBanana2<'a>, ArtcraftRouterError> {
) -> Result<ImageGenerationPlan<'a>, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
let is_edit_mode = request.image_inputs.is_some();
@@ -33,14 +34,14 @@ pub fn plan_generate_image_artcraft_nano_banana_2<'a>(
let resolution = plan_resolution(request.resolution, strategy)?;
let num_images = plan_num_images(request.image_batch_count, strategy)?;
Ok(PlanArtcraftNanaBanana2 {
Ok(ImageGenerationPlan::ArtcraftNanaBanana2(PlanArtcraftNanaBanana2 {
prompt: request.prompt,
image_inputs,
aspect_ratio,
resolution,
num_images,
idempotency_token: request.get_or_generate_idempotency_token(),
})
}))
}
fn resolve_image_list_ref<'a>(
@@ -172,7 +173,6 @@ mod tests {
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::image_generation_plan::ImageGenerationPlan;
use crate::test_helpers::base_nano_banana_2_image_request;
use artcraft_api_defs::generate::image::multi_function::nano_banana_2_multi_function_image_gen::{
NanaBanana2MultiFunctionImageGenAspectRatio as Nb2Ar,
@@ -5,6 +5,7 @@ use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigati
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_image::generate_image_request::GenerateImageRequest;
use crate::generate::generate_image::image_generation_plan::ImageGenerationPlan;
use artcraft_api_defs::generate::image::multi_function::nano_banana_pro_multi_function_image_gen::{
NanoBananaProMultiFunctionImageGenAspectRatio, NanoBananaProMultiFunctionImageGenImageResolution,
NanoBananaProMultiFunctionImageGenNumImages,
@@ -24,7 +25,7 @@ pub struct PlanArtcraftNanaBananaPro<'a> {
pub fn plan_generate_image_artcraft_nano_banana_pro<'a>(
request: &'a GenerateImageRequest<'a>,
) -> Result<PlanArtcraftNanaBananaPro<'a>, ArtcraftRouterError> {
) -> Result<ImageGenerationPlan<'a>, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
let is_edit_mode = request.image_inputs.is_some();
@@ -33,14 +34,14 @@ pub fn plan_generate_image_artcraft_nano_banana_pro<'a>(
let resolution = plan_resolution(request.resolution, strategy)?;
let num_images = plan_num_images(request.image_batch_count, strategy)?;
Ok(PlanArtcraftNanaBananaPro {
Ok(ImageGenerationPlan::ArtcraftNanaBananaPro(PlanArtcraftNanaBananaPro {
prompt: request.prompt,
image_inputs,
aspect_ratio,
resolution,
num_images,
idempotency_token: request.get_or_generate_idempotency_token(),
})
}))
}
fn resolve_image_list_ref<'a>(
@@ -172,7 +173,6 @@ mod tests {
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::image_generation_plan::ImageGenerationPlan;
use crate::test_helpers::base_image_request;
use artcraft_api_defs::generate::image::multi_function::nano_banana_pro_multi_function_image_gen::{
NanoBananaProMultiFunctionImageGenAspectRatio as NbpAr,
@@ -4,6 +4,7 @@ use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigati
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_image::generate_image_request::GenerateImageRequest;
use crate::generate::generate_image::image_generation_plan::ImageGenerationPlan;
use artcraft_api_defs::generate::image::angle::qwen_edit_2511_edit_image_angle::{
QwenEdit2511EditImageAngleImageSize,
QwenEdit2511EditImageAngleNumImages,
@@ -24,7 +25,7 @@ pub struct PlanArtcraftQwenEdit2511Angles<'a> {
pub fn plan_generate_image_artcraft_qwen_edit_2511_angles<'a>(
request: &'a GenerateImageRequest<'a>,
) -> Result<PlanArtcraftQwenEdit2511Angles<'a>, ArtcraftRouterError> {
) -> Result<ImageGenerationPlan<'a>, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
// Angle models require exactly one input image.
@@ -32,7 +33,7 @@ pub fn plan_generate_image_artcraft_qwen_edit_2511_angles<'a>(
let image_size = plan_image_size(request.aspect_ratio, strategy)?;
let num_images = plan_num_images(request.image_batch_count, strategy)?;
Ok(PlanArtcraftQwenEdit2511Angles {
Ok(ImageGenerationPlan::ArtcraftQwenEdit2511Angles(PlanArtcraftQwenEdit2511Angles {
prompt: request.prompt,
image_input,
horizontal_angle: request.horizontal_angle,
@@ -41,7 +42,7 @@ pub fn plan_generate_image_artcraft_qwen_edit_2511_angles<'a>(
image_size,
num_images,
idempotency_token: request.get_or_generate_idempotency_token(),
})
}))
}
fn resolve_single_image_input<'a>(
@@ -141,7 +142,6 @@ fn plan_num_images(
mod tests {
use super::*;
use crate::api::image_list_ref::ImageListRef;
use crate::generate::generate_image::image_generation_plan::ImageGenerationPlan;
use crate::test_helpers::base_qwen_edit_2511_angles_image_request;
use artcraft_api_defs::generate::image::angle::qwen_edit_2511_edit_image_angle::{
QwenEdit2511EditImageAngleImageSize as S,
@@ -4,6 +4,7 @@ use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigati
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_image::generate_image_request::GenerateImageRequest;
use crate::generate::generate_image::image_generation_plan::ImageGenerationPlan;
use artcraft_api_defs::generate::image::multi_function::bytedance_seedream_v4_multi_function_image_gen::{
BytedanceSeedreamV4MultiFunctionImageGenImageSize,
BytedanceSeedreamV4MultiFunctionImageGenNumImages,
@@ -22,7 +23,7 @@ pub struct PlanArtcraftSeedream4<'a> {
pub fn plan_generate_image_artcraft_seedream_4<'a>(
request: &'a GenerateImageRequest<'a>,
) -> Result<PlanArtcraftSeedream4<'a>, ArtcraftRouterError> {
) -> Result<ImageGenerationPlan<'a>, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
let is_edit_mode = request.image_inputs.is_some();
@@ -30,13 +31,13 @@ pub fn plan_generate_image_artcraft_seedream_4<'a>(
let image_size = plan_image_size(request.aspect_ratio, is_edit_mode, strategy)?;
let num_images = plan_num_images(request.image_batch_count, strategy)?;
Ok(PlanArtcraftSeedream4 {
Ok(ImageGenerationPlan::ArtcraftSeedream4(PlanArtcraftSeedream4 {
prompt: request.prompt,
image_inputs,
image_size,
num_images,
idempotency_token: request.get_or_generate_idempotency_token(),
})
}))
}
fn resolve_image_list_ref<'a>(
@@ -147,7 +148,6 @@ mod tests {
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::image_generation_plan::ImageGenerationPlan;
use crate::test_helpers::base_seedream_4_image_request;
use artcraft_api_defs::generate::image::multi_function::bytedance_seedream_v4_multi_function_image_gen::{
BytedanceSeedreamV4MultiFunctionImageGenImageSize as S,
@@ -4,6 +4,7 @@ use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigati
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_image::generate_image_request::GenerateImageRequest;
use crate::generate::generate_image::image_generation_plan::ImageGenerationPlan;
use artcraft_api_defs::generate::image::multi_function::bytedance_seedream_v4p5_multi_function_image_gen::{
BytedanceSeedreamV4p5MultiFunctionImageGenImageSize,
BytedanceSeedreamV4p5MultiFunctionImageGenNumImages,
@@ -22,7 +23,7 @@ pub struct PlanArtcraftSeedream4p5<'a> {
pub fn plan_generate_image_artcraft_seedream_4p5<'a>(
request: &'a GenerateImageRequest<'a>,
) -> Result<PlanArtcraftSeedream4p5<'a>, ArtcraftRouterError> {
) -> Result<ImageGenerationPlan<'a>, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
let is_edit_mode = request.image_inputs.is_some();
@@ -30,13 +31,13 @@ pub fn plan_generate_image_artcraft_seedream_4p5<'a>(
let image_size = plan_image_size(request.aspect_ratio, is_edit_mode, strategy)?;
let num_images = plan_num_images(request.image_batch_count, strategy)?;
Ok(PlanArtcraftSeedream4p5 {
Ok(ImageGenerationPlan::ArtcraftSeedream4p5(PlanArtcraftSeedream4p5 {
prompt: request.prompt,
image_inputs,
image_size,
num_images,
idempotency_token: request.get_or_generate_idempotency_token(),
})
}))
}
fn resolve_image_list_ref<'a>(
@@ -147,7 +148,6 @@ mod tests {
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::image_generation_plan::ImageGenerationPlan;
use crate::test_helpers::base_seedream_4p5_image_request;
use artcraft_api_defs::generate::image::multi_function::bytedance_seedream_v4p5_multi_function_image_gen::{
BytedanceSeedreamV4p5MultiFunctionImageGenImageSize as S,
@@ -4,6 +4,7 @@ use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigati
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_image::generate_image_request::GenerateImageRequest;
use crate::generate::generate_image::image_generation_plan::ImageGenerationPlan;
use artcraft_api_defs::generate::image::multi_function::bytedance_seedream_5_lite_multi_function_image_gen::{
BytedanceSeedream5LiteMultiFunctionImageGenImageSize,
BytedanceSeedream5LiteMultiFunctionImageGenNumImages,
@@ -22,7 +23,7 @@ pub struct PlanArtcraftSeedream5Lite<'a> {
pub fn plan_generate_image_artcraft_seedream_5_lite<'a>(
request: &'a GenerateImageRequest<'a>,
) -> Result<PlanArtcraftSeedream5Lite<'a>, ArtcraftRouterError> {
) -> Result<ImageGenerationPlan<'a>, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
let is_edit_mode = request.image_inputs.is_some();
@@ -30,13 +31,13 @@ pub fn plan_generate_image_artcraft_seedream_5_lite<'a>(
let image_size = plan_image_size(request.aspect_ratio, is_edit_mode, strategy)?;
let num_images = plan_num_images(request.image_batch_count, strategy)?;
Ok(PlanArtcraftSeedream5Lite {
Ok(ImageGenerationPlan::ArtcraftSeedream5Lite(PlanArtcraftSeedream5Lite {
prompt: request.prompt,
image_inputs,
image_size,
num_images,
idempotency_token: request.get_or_generate_idempotency_token(),
})
}))
}
fn resolve_image_list_ref<'a>(
@@ -148,7 +149,6 @@ mod tests {
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::image_generation_plan::ImageGenerationPlan;
use crate::test_helpers::base_seedream_5_lite_image_request;
use artcraft_api_defs::generate::image::multi_function::bytedance_seedream_5_lite_multi_function_image_gen::{
BytedanceSeedream5LiteMultiFunctionImageGenImageSize as S,
@@ -5,6 +5,7 @@ use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigati
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_image::generate_image_request::GenerateImageRequest;
use crate::generate::generate_image::image_generation_plan::ImageGenerationPlan;
use fal_client::requests::webhook::image::edit::enqueue_nano_banana_pro_edit_image_webhook::EnqueueNanoBananaProEditImageAspectRatio;
use fal_client::requests::webhook::image::text::enqueue_nano_banana_pro_text_to_image_webhook::EnqueueNanoBananaProTextToImageAspectRatio;
@@ -44,7 +45,7 @@ pub struct PlanFalNanaBananaPro<'a> {
pub fn plan_generate_image_fal_nano_banana_pro<'a>(
request: &'a GenerateImageRequest<'a>,
) -> Result<PlanFalNanaBananaPro<'a>, ArtcraftRouterError> {
) -> Result<ImageGenerationPlan<'a>, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
let is_edit_mode = request.image_inputs.is_some();
let image_urls = resolve_image_list_ref(request.image_inputs)?;
@@ -53,14 +54,14 @@ pub fn plan_generate_image_fal_nano_banana_pro<'a>(
let resolution = plan_resolution(request.resolution)?;
let num_images = plan_num_images(request.image_batch_count, strategy)?;
Ok(PlanFalNanaBananaPro {
Ok(ImageGenerationPlan::FalNanaBananaPro(PlanFalNanaBananaPro {
prompt: request.prompt,
image_urls,
t2i_aspect_ratio,
edit_aspect_ratio,
resolution,
num_images,
})
}))
}
fn resolve_image_list_ref(
@@ -263,7 +264,10 @@ mod tests {
fn build_plan<'a>(
request: &'a GenerateImageRequest<'a>,
) -> PlanFalNanaBananaPro<'a> {
plan_generate_image_fal_nano_banana_pro(request).expect("plan should succeed")
let ImageGenerationPlan::FalNanaBananaPro(plan) = plan_generate_image_fal_nano_banana_pro(request).expect("plan should succeed") else {
panic!("expected FalNanaBananaPro variant")
};
plan
}
// ── Image inputs / mode detection ────────────────────────────────────────────
@@ -28,20 +28,24 @@ impl<'a> GenerateSplatRequest<'a> {
/// Read the splat generation request, construct a plan, then yield a means to execute it.
pub fn build(&self) -> Result<SplatGenerationPlan<'_>, ArtcraftRouterError> {
match self.provider {
Provider::Artcraft => match self.model {
CommonSplatModel::Marble0p1Mini => {
plan_generate_splat_artcraft_marble_0p1_mini(self).map(SplatGenerationPlan::ArtcraftMarble0p1Mini)
}
CommonSplatModel::Marble0p1Plus => {
plan_generate_splat_artcraft_marble_0p1_plus(self).map(SplatGenerationPlan::ArtcraftMarble0p1Plus)
}
},
Provider::Fal => Err(ArtcraftRouterError::UnsupportedModel(
format!("Splat generation via Fal is not yet supported (model: {:?})", self.model)
)),
Provider::Artcraft => self.build_artcraft(),
_ => self.unsupported_provider(),
}
}
fn build_artcraft(&self) -> Result<SplatGenerationPlan<'_>, ArtcraftRouterError> {
match self.model {
CommonSplatModel::Marble0p1Mini => plan_generate_splat_artcraft_marble_0p1_mini(self),
CommonSplatModel::Marble0p1Plus => plan_generate_splat_artcraft_marble_0p1_plus(self),
}
}
fn unsupported_provider(&self) -> Result<SplatGenerationPlan<'_>, ArtcraftRouterError> {
Err(ArtcraftRouterError::UnsupportedModel(
format!("Splat generation for model `{:?}` is not supported for provider {:?}", self.model, self.provider)
))
}
pub fn get_or_generate_idempotency_token(&self) -> String {
self.idempotency_token.map(|t| t.to_string())
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string())
@@ -2,6 +2,7 @@ use crate::api::image_list_ref::ImageListRef;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_splat::generate_splat_request::GenerateSplatRequest;
use crate::generate::generate_splat::splat_generation_plan::SplatGenerationPlan;
use tokens::tokens::media_files::MediaFileToken;
#[derive(Debug, Clone)]
@@ -13,14 +14,14 @@ pub struct PlanArtcraftMarble0p1Mini<'a> {
pub fn plan_generate_splat_artcraft_marble_0p1_mini<'a>(
request: &'a GenerateSplatRequest<'a>,
) -> Result<PlanArtcraftMarble0p1Mini<'a>, ArtcraftRouterError> {
) -> Result<SplatGenerationPlan<'a>, ArtcraftRouterError> {
let reference_image = resolve_single_image_ref(request.reference_images)?;
Ok(PlanArtcraftMarble0p1Mini {
Ok(SplatGenerationPlan::ArtcraftMarble0p1Mini(PlanArtcraftMarble0p1Mini {
prompt: request.prompt,
reference_image,
idempotency_token: request.get_or_generate_idempotency_token(),
})
}))
}
fn resolve_single_image_ref<'a>(
@@ -2,6 +2,7 @@ use crate::api::image_list_ref::ImageListRef;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_splat::generate_splat_request::GenerateSplatRequest;
use crate::generate::generate_splat::splat_generation_plan::SplatGenerationPlan;
use tokens::tokens::media_files::MediaFileToken;
#[derive(Debug, Clone)]
@@ -13,14 +14,14 @@ pub struct PlanArtcraftMarble0p1Plus<'a> {
pub fn plan_generate_splat_artcraft_marble_0p1_plus<'a>(
request: &'a GenerateSplatRequest<'a>,
) -> Result<PlanArtcraftMarble0p1Plus<'a>, ArtcraftRouterError> {
) -> Result<SplatGenerationPlan<'a>, ArtcraftRouterError> {
let reference_image = resolve_single_image_ref(request.reference_images)?;
Ok(PlanArtcraftMarble0p1Plus {
Ok(SplatGenerationPlan::ArtcraftMarble0p1Plus(PlanArtcraftMarble0p1Plus {
prompt: request.prompt,
reference_image,
idempotency_token: request.get_or_generate_idempotency_token(),
})
}))
}
fn resolve_single_image_ref<'a>(
@@ -69,6 +69,8 @@ mod tests {
start_frame: None,
end_frame: None,
reference_images: None,
reference_videos: None,
reference_audio: None,
resolution: None,
aspect_ratio: None,
duration_seconds: Some(duration_seconds),
@@ -69,6 +69,8 @@ mod tests {
start_frame: None,
end_frame: None,
reference_images: None,
reference_videos: None,
reference_audio: None,
resolution: None,
aspect_ratio: None,
duration_seconds: Some(duration_seconds),
@@ -72,6 +72,8 @@ mod tests {
start_frame: None,
end_frame: None,
reference_images: None,
reference_videos: None,
reference_audio: None,
resolution: None,
aspect_ratio: None,
duration_seconds: Some(duration_seconds),
@@ -31,6 +31,9 @@ pub (crate) fn estimate_video_cost_artcraft_seedance2p0(
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
reference_video_urls: None,
reference_audio_urls: None,
use_face_blur_hack: None,
};
let cost_in_usd_cents = args.estimate_cost_in_usd_cents();
@@ -61,6 +64,8 @@ mod tests {
start_frame: None,
end_frame: None,
reference_images: None,
reference_videos: None,
reference_audio: None,
resolution: None,
aspect_ratio: None,
duration_seconds: Some(duration_seconds),
@@ -1 +1,3 @@
pub (crate) mod artcraft;
pub (crate) mod muapi;
pub (crate) mod seedance2pro;
@@ -0,0 +1,59 @@
use crate::generate::generate_video::plan::muapi::plan_generate_video_muapi_seedance2p0::{
MuapiSeedance2p0Mode, PlanMuapiSeedance2p0,
};
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use muapi_client::creds::muapi_api_key::MuapiApiKey;
use muapi_client::creds::muapi_session::MuapiSession;
use muapi_client::requests::seedance_2p0_image_to_video::seedance_2p0_image_to_video::Seedance2p0ImageToVideoArgs;
use muapi_client::requests::seedance_2p0_text_to_video::seedance_2p0_text_to_video::Seedance2p0TextToVideoArgs;
pub(crate) fn estimate_video_cost_muapi_seedance2p0(
plan: &PlanMuapiSeedance2p0,
) -> VideoGenerationCostEstimate {
// A dummy session is sufficient — cost estimation does not make any network calls.
let dummy_session = MuapiSession::new(MuapiApiKey::new(String::new()));
let cost_in_usd_cents;
let cost_in_credits;
match &plan.mode {
MuapiSeedance2p0Mode::TextToVideo { aspect_ratio, duration, quality } => {
let args = Seedance2p0TextToVideoArgs {
session: &dummy_session,
prompt: String::new(),
aspect_ratio: *aspect_ratio,
duration: *duration,
quality: *quality,
};
cost_in_usd_cents = args.estimate_cost_in_usd_cents();
// NB: This is off by a factor of 100 because they use a fractional system.
cost_in_credits = (args.estimate_credits() * 100.0f64).round() as u64;
}
MuapiSeedance2p0Mode::ImageToVideo { aspect_ratio, duration, quality, .. } => {
let args = Seedance2p0ImageToVideoArgs {
session: &dummy_session,
prompt: String::new(),
image_urls: vec![],
aspect_ratio: *aspect_ratio,
duration: *duration,
quality: *quality,
};
cost_in_usd_cents = args.estimate_cost_in_usd_cents();
// NB: This is off by a factor of 100 because they use a fractional system.
cost_in_credits = (args.estimate_credits() * 100.0f64).round() as u64;
}
}
VideoGenerationCostEstimate {
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,
}
}
@@ -0,0 +1 @@
pub mod estimate_video_cost_muapi_seedance2p0;
@@ -0,0 +1,38 @@
use seedance2pro::creds::seedance2pro_session::Seedance2ProSession;
use seedance2pro::requests::generate_video::generate_video::{GenerateVideoArgs, Resolution};
use crate::generate::generate_video::plan::seedance2pro::plan_generate_video_seedance2pro_seedance2p0::PlanSeedance2proSeedance2p0;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
pub(crate) fn estimate_video_cost_seedance2pro_seedance2p0(
plan: &PlanSeedance2proSeedance2p0,
) -> VideoGenerationCostEstimate {
// A dummy session is sufficient — cost estimation does not make any network calls.
let dummy_session = Seedance2ProSession::from_cookies_string(String::new());
let args = GenerateVideoArgs {
session: &dummy_session,
prompt: String::new(),
resolution: Resolution::Square1x1, // Resolution does not affect cost
duration_seconds: plan.duration_seconds,
batch_count: plan.batch_count,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
reference_video_urls: None,
reference_audio_urls: None,
use_face_blur_hack: None,
};
let cost_in_credits = args.estimate_credits();
let cost_in_usd_cents = args.estimate_cost_in_usd_cents();
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_credits as u64),
cost_in_usd_cents: Some(cost_in_usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
}
}
@@ -0,0 +1 @@
pub mod estimate_video_cost_seedance2pro_seedance2p0;
@@ -18,6 +18,8 @@ pub async fn execute_artcraft_seedance2p0(
start_frame_media_token: plan.start_frame.map(|t| t.to_owned()),
end_frame_media_token: plan.end_frame.map(|t| t.to_owned()),
reference_image_media_tokens: plan.reference_images.map(|tokens| tokens.to_owned()),
reference_video_media_tokens: plan.reference_videos.map(|tokens| tokens.to_owned()),
reference_audio_media_tokens: plan.reference_audio.map(|tokens| tokens.to_owned()),
aspect_ratio: plan.aspect_ratio,
duration_seconds: plan.duration_seconds,
batch_count: Some(plan.batch_count),
@@ -1 +1,3 @@
pub mod artcraft;
pub mod muapi;
pub mod seedance2pro;
@@ -0,0 +1,56 @@
use crate::client::router_muapi_client::RouterMuapiClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_video::generate_video_response::{
GenerateVideoResponse, MuapiVideoResponsePayload,
};
use crate::generate::generate_video::plan::muapi::plan_generate_video_muapi_seedance2p0::{
MuapiSeedance2p0Mode, PlanMuapiSeedance2p0,
};
use muapi_client::requests::seedance_2p0_image_to_video::seedance_2p0_image_to_video::{
Seedance2p0ImageToVideoArgs, seedance_2p0_image_to_video,
};
use muapi_client::requests::seedance_2p0_text_to_video::seedance_2p0_text_to_video::{
Seedance2p0TextToVideoArgs, seedance_2p0_text_to_video,
};
pub async fn execute_muapi_seedance2p0(
plan: &PlanMuapiSeedance2p0,
muapi_client: &RouterMuapiClient,
) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let session = &muapi_client.session;
let request_id = match &plan.mode {
MuapiSeedance2p0Mode::TextToVideo { aspect_ratio, duration, quality } => {
let args = Seedance2p0TextToVideoArgs {
session,
prompt: plan.prompt.clone(),
aspect_ratio: *aspect_ratio,
duration: *duration,
quality: *quality,
};
let response = seedance_2p0_text_to_video(args)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Muapi(err)))?;
response.request_id
}
MuapiSeedance2p0Mode::ImageToVideo { image_urls, aspect_ratio, duration, quality } => {
let args = Seedance2p0ImageToVideoArgs {
session,
prompt: plan.prompt.clone(),
image_urls: image_urls.clone(),
aspect_ratio: *aspect_ratio,
duration: *duration,
quality: *quality,
};
let response = seedance_2p0_image_to_video(args)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Muapi(err)))?;
response.request_id
}
};
Ok(GenerateVideoResponse::Muapi(MuapiVideoResponsePayload {
request_id,
}))
}
@@ -0,0 +1 @@
pub mod execute_muapi_seedance2p0;
@@ -0,0 +1,112 @@
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_video::generate_video_response::{
GenerateVideoResponse, Seedance2proVideoResponsePayload,
};
use crate::generate::generate_video::plan::seedance2pro::plan_generate_video_seedance2pro_seedance2p0::PlanSeedance2proSeedance2p0;
use crate::utils::download_file::download_file;
use seedance2pro::requests::generate_video::generate_video::{
generate_video, GenerateVideoArgs,
};
use seedance2pro::requests::prepare_file_upload::prepare_file_upload::{
prepare_file_upload, PrepareFileUploadArgs,
};
use seedance2pro::requests::upload_file::upload_file::{
upload_file, UploadFileArgs,
};
use url_utils::extension::extract_extension_from_url::{
extract_extension_from_url_str, ExtractExtensions,
};
pub async fn execute_seedance2pro_seedance2p0(
plan: &PlanSeedance2proSeedance2p0,
seedance2pro_client: &RouterSeedance2ProClient,
) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let session = &seedance2pro_client.session;
// Upload media files to seedance2pro CDN
let start_frame_url = upload_optional_url(session, plan.start_frame_url.as_deref()).await?;
let end_frame_url = upload_optional_url(session, plan.end_frame_url.as_deref()).await?;
let reference_image_urls = upload_optional_url_list(session, plan.reference_image_urls.as_deref()).await?;
let reference_video_urls = upload_optional_url_list(session, plan.reference_video_urls.as_deref()).await?;
let reference_audio_urls = upload_optional_url_list(session, plan.reference_audio_urls.as_deref()).await?;
let args = GenerateVideoArgs {
session,
prompt: plan.prompt.clone().unwrap_or_default(),
resolution: plan.resolution,
duration_seconds: plan.duration_seconds,
batch_count: plan.batch_count,
start_frame_url,
end_frame_url,
reference_image_urls,
reference_video_urls,
reference_audio_urls,
use_face_blur_hack: None,
};
let response = generate_video(args)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?;
Ok(GenerateVideoResponse::Seedance2Pro(Seedance2proVideoResponsePayload {
order_id: response.order_id,
task_id: response.task_id,
}))
}
/// Downloads a file from a source URL and re-uploads it to seedance2pro CDN.
async fn upload_to_seedance2pro(
session: &seedance2pro::creds::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,
})
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?;
let upload_response = upload_file(UploadFileArgs {
upload_url: prepare_response.upload_url,
file_bytes,
})
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?;
Ok(upload_response.public_url)
}
async fn upload_optional_url(
session: &seedance2pro::creds::seedance2pro_session::Seedance2ProSession,
url: Option<&str>,
) -> Result<Option<String>, ArtcraftRouterError> {
match url {
None => Ok(None),
Some(u) => Ok(Some(upload_to_seedance2pro(session, u).await?)),
}
}
async fn upload_optional_url_list(
session: &seedance2pro::creds::seedance2pro_session::Seedance2ProSession,
urls: Option<&[String]>,
) -> Result<Option<Vec<String>>, ArtcraftRouterError> {
match urls {
None => Ok(None),
Some(url_list) if url_list.is_empty() => Ok(None),
Some(url_list) => {
let mut uploaded = Vec::with_capacity(url_list.len());
for url in url_list {
uploaded.push(upload_to_seedance2pro(session, url).await?);
}
Ok(Some(uploaded))
}
}
}
@@ -0,0 +1 @@
pub mod execute_seedance2pro_seedance2p0;
@@ -1,17 +1,24 @@
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_resolution::CommonResolution;
use crate::api::common_video_model::CommonVideoModel;
use crate::api::audio_list_ref::AudioListRef;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::provider::Provider;
use crate::api::video_list_ref::VideoListRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_kling3p0_pro::plan_generate_video_artcraft_kling3p0_pro;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_kling3p0_standard::plan_generate_video_artcraft_kling3p0_standard;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_seedance1p5_pro::plan_generate_video_artcraft_seedance1p5_pro;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_seedance2p0::plan_generate_video_artcraft_seedance2p0;
use crate::generate::generate_video::plan::muapi::plan_generate_video_muapi_seedance2p0::plan_generate_video_muapi_seedance2p0;
use crate::generate::generate_video::plan::seedance2pro::plan_generate_video_seedance2pro_seedance2p0::plan_generate_video_seedance2pro_seedance2p0;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
/// Plan to either (1) generate a video or (2) determine how much it costs to generate that video.
/// This works across multiple providers by shaping a generic "GenerateVideoRequest" into a provider-specific plan.
/// That plan can then be used to return a cost estimate for that given provider or return a struct that can be used to send a real generation request.
pub struct GenerateVideoRequest<'a> {
/// Which model to use.
pub model: CommonVideoModel,
@@ -34,6 +41,12 @@ pub struct GenerateVideoRequest<'a> {
/// Reference images (optional).
pub reference_images: Option<ImageListRef<'a>>,
/// Reference videos (optional).
pub reference_videos: Option<VideoListRef<'a>>,
/// Reference audio (optional).
pub reference_audio: Option<AudioListRef<'a>>,
/// The resolution to use
pub resolution: Option<CommonResolution>,
@@ -64,27 +77,43 @@ impl<'a> GenerateVideoRequest<'a> {
/// Read the video generation request, construct a plan, then yield a means to execute it.
pub fn build(&self) -> Result<VideoGenerationPlan<'_>, ArtcraftRouterError> {
match self.provider {
Provider::Artcraft => match self.model {
CommonVideoModel::Kling3p0Pro => {
plan_generate_video_artcraft_kling3p0_pro(self).map(VideoGenerationPlan::ArtcraftKling3p0Pro)
}
CommonVideoModel::Kling3p0Standard => {
plan_generate_video_artcraft_kling3p0_standard(self).map(VideoGenerationPlan::ArtcraftKling3p0Standard)
}
CommonVideoModel::Seedance1p5Pro => {
plan_generate_video_artcraft_seedance1p5_pro(self).map(VideoGenerationPlan::ArtcraftSeedance1p5Pro)
}
CommonVideoModel::Seedance2p0 => {
plan_generate_video_artcraft_seedance2p0(self).map(VideoGenerationPlan::ArtcraftSeedance2p0)
}
_ => Err(ArtcraftRouterError::UnsupportedModel(format!("{:?}", self.model))),
},
Provider::Fal => Err(ArtcraftRouterError::UnsupportedModel(
format!("Video generation via Fal is not yet supported (model: {:?})", self.model)
)),
Provider::Artcraft => self.build_artcraft(),
Provider::Muapi => self.build_muapi(),
Provider::Seedance2Pro => self.build_seedance2pro(),
_ => self.unsupported_provider(),
}
}
fn build_artcraft(&self) -> Result<VideoGenerationPlan<'_>, ArtcraftRouterError> {
match self.model {
CommonVideoModel::Kling3p0Pro => plan_generate_video_artcraft_kling3p0_pro(self),
CommonVideoModel::Kling3p0Standard => plan_generate_video_artcraft_kling3p0_standard(self),
CommonVideoModel::Seedance1p5Pro => plan_generate_video_artcraft_seedance1p5_pro(self),
CommonVideoModel::Seedance2p0 => plan_generate_video_artcraft_seedance2p0(self),
_ => Err(ArtcraftRouterError::UnsupportedModel(format!("{:?}", self.model))),
}
}
fn build_muapi(&self) -> Result<VideoGenerationPlan<'_>, ArtcraftRouterError> {
match self.model {
CommonVideoModel::Seedance2p0 => plan_generate_video_muapi_seedance2p0(self),
_ => Err(ArtcraftRouterError::UnsupportedModel(format!("{:?}", self.model))),
}
}
fn build_seedance2pro(&self) -> Result<VideoGenerationPlan<'_>, ArtcraftRouterError> {
match self.model {
CommonVideoModel::Seedance2p0 => plan_generate_video_seedance2pro_seedance2p0(self),
_ => Err(ArtcraftRouterError::UnsupportedModel(format!("{:?}", self.model))),
}
}
fn unsupported_provider(&self) -> Result<VideoGenerationPlan<'_>, ArtcraftRouterError> {
Err(ArtcraftRouterError::UnsupportedModel(
format!("Video generation for model `{:?}` is not supported for provider {:?}", self.model, self.provider)
))
}
pub fn get_or_generate_idempotency_token(&self) -> String {
self.idempotency_token.map(|t| t.to_string())
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string())
@@ -1,3 +1,4 @@
use muapi_client::api_types::request_id::RequestId as MuapiRequestId;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
#[derive(Clone, Debug)]
@@ -6,15 +7,43 @@ pub struct ArtcraftVideoResponsePayload {
pub all_inference_job_tokens: Vec<InferenceJobToken>,
}
#[derive(Clone, Debug)]
pub struct MuapiVideoResponsePayload {
pub request_id: MuapiRequestId,
}
#[derive(Clone, Debug)]
pub struct Seedance2proVideoResponsePayload {
pub order_id: String,
pub task_id: String,
}
#[derive(Clone, Debug)]
pub enum GenerateVideoResponse {
Artcraft(ArtcraftVideoResponsePayload),
Muapi(MuapiVideoResponsePayload),
Seedance2Pro(Seedance2proVideoResponsePayload),
}
impl GenerateVideoResponse {
pub fn get_artcraft_payload(&self) -> Option<ArtcraftVideoResponsePayload> {
match self {
Self::Artcraft(p) => Some(p.clone()),
_ => None,
}
}
pub fn get_muapi_payload(&self) -> Option<MuapiVideoResponsePayload> {
match self {
Self::Muapi(p) => Some(p.clone()),
_ => None,
}
}
pub fn get_seedance2pro_payload(&self) -> Option<Seedance2proVideoResponsePayload> {
match self {
Self::Seedance2Pro(p) => Some(p.clone()),
_ => None,
}
}
}
@@ -4,6 +4,7 @@ use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigati
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use artcraft_api_defs::generate::video::multi_function::kling_3p0_pro_multi_function_video_gen::{
Kling3p0ProMultiFunctionVideoGenAspectRatio,
Kling3p0ProMultiFunctionVideoGenDuration,
@@ -24,7 +25,7 @@ pub struct PlanArtcraftKling3p0Pro<'a> {
pub fn plan_generate_video_artcraft_kling3p0_pro<'a>(
request: &'a GenerateVideoRequest<'a>,
) -> Result<PlanArtcraftKling3p0Pro<'a>, ArtcraftRouterError> {
) -> Result<VideoGenerationPlan<'a>, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
let start_frame = resolve_image_ref(request.start_frame)?;
@@ -33,7 +34,7 @@ pub fn plan_generate_video_artcraft_kling3p0_pro<'a>(
let aspect_ratio = plan_aspect_ratio(request.aspect_ratio, strategy)?;
let duration = plan_duration(request.duration_seconds, strategy)?;
Ok(PlanArtcraftKling3p0Pro {
Ok(VideoGenerationPlan::ArtcraftKling3p0Pro(PlanArtcraftKling3p0Pro {
prompt: request.prompt,
negative_prompt: request.negative_prompt,
start_frame,
@@ -42,7 +43,7 @@ pub fn plan_generate_video_artcraft_kling3p0_pro<'a>(
duration,
generate_audio: request.generate_audio,
idempotency_token: request.get_or_generate_idempotency_token(),
})
}))
}
fn resolve_image_ref<'a>(
@@ -177,6 +178,8 @@ mod tests {
start_frame: None,
end_frame: None,
reference_images: None,
reference_videos: None,
reference_audio: None,
resolution: None,
aspect_ratio: None,
duration_seconds: None,
@@ -4,6 +4,7 @@ use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigati
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use artcraft_api_defs::generate::video::multi_function::kling_3p0_standard_multi_function_video_gen::{
Kling3p0StandardMultiFunctionVideoGenAspectRatio,
Kling3p0StandardMultiFunctionVideoGenDuration,
@@ -24,7 +25,7 @@ pub struct PlanArtcraftKling3p0Standard<'a> {
pub fn plan_generate_video_artcraft_kling3p0_standard<'a>(
request: &'a GenerateVideoRequest<'a>,
) -> Result<PlanArtcraftKling3p0Standard<'a>, ArtcraftRouterError> {
) -> Result<VideoGenerationPlan<'a>, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
let start_frame = resolve_image_ref(request.start_frame)?;
@@ -33,7 +34,7 @@ pub fn plan_generate_video_artcraft_kling3p0_standard<'a>(
let aspect_ratio = plan_aspect_ratio(request.aspect_ratio, strategy)?;
let duration = plan_duration(request.duration_seconds, strategy)?;
Ok(PlanArtcraftKling3p0Standard {
Ok(VideoGenerationPlan::ArtcraftKling3p0Standard(PlanArtcraftKling3p0Standard {
prompt: request.prompt,
negative_prompt: request.negative_prompt,
start_frame,
@@ -42,7 +43,7 @@ pub fn plan_generate_video_artcraft_kling3p0_standard<'a>(
duration,
generate_audio: request.generate_audio,
idempotency_token: request.get_or_generate_idempotency_token(),
})
}))
}
fn resolve_image_ref<'a>(
@@ -177,6 +178,8 @@ mod tests {
start_frame: None,
end_frame: None,
reference_images: None,
reference_videos: None,
reference_audio: None,
resolution: None,
aspect_ratio: None,
duration_seconds: None,
@@ -4,6 +4,7 @@ use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigati
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use artcraft_api_defs::generate::video::multi_function::seedance_1p5_pro_multi_function_video_gen::{
Seedance1p5ProMultiFunctionVideoGenAspectRatio,
Seedance1p5ProMultiFunctionVideoGenDuration,
@@ -25,7 +26,7 @@ pub struct PlanArtcraftSeedance1p5Pro<'a> {
pub fn plan_generate_video_artcraft_seedance1p5_pro<'a>(
request: &'a GenerateVideoRequest<'a>,
) -> Result<PlanArtcraftSeedance1p5Pro<'a>, ArtcraftRouterError> {
) -> Result<VideoGenerationPlan<'a>, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
let start_frame = resolve_image_ref(request.start_frame)?;
@@ -35,7 +36,7 @@ pub fn plan_generate_video_artcraft_seedance1p5_pro<'a>(
let duration = plan_duration(request.duration_seconds, strategy)?;
let resolution = plan_resolution(request.resolution, strategy)?;
Ok(PlanArtcraftSeedance1p5Pro {
Ok(VideoGenerationPlan::ArtcraftSeedance1p5Pro(PlanArtcraftSeedance1p5Pro {
prompt: request.prompt,
start_frame,
end_frame,
@@ -44,7 +45,7 @@ pub fn plan_generate_video_artcraft_seedance1p5_pro<'a>(
resolution,
generate_audio: request.generate_audio,
idempotency_token: request.get_or_generate_idempotency_token(),
})
}))
}
fn resolve_image_ref<'a>(
@@ -190,6 +191,8 @@ mod tests {
start_frame: None,
end_frame: None,
reference_images: None,
reference_videos: None,
reference_audio: None,
resolution: None,
aspect_ratio: None,
duration_seconds: None,
@@ -1,10 +1,13 @@
use crate::api::audio_list_ref::AudioListRef;
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::video_list_ref::VideoListRef;
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_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::{
Seedance2p0AspectRatio, Seedance2p0BatchCount,
};
@@ -16,6 +19,8 @@ pub struct PlanArtcraftSeedance2p0<'a> {
pub start_frame: Option<&'a MediaFileToken>,
pub end_frame: Option<&'a MediaFileToken>,
pub reference_images: Option<&'a Vec<MediaFileToken>>,
pub reference_videos: Option<&'a Vec<MediaFileToken>>,
pub reference_audio: Option<&'a Vec<MediaFileToken>>,
pub aspect_ratio: Option<Seedance2p0AspectRatio>,
pub duration_seconds: Option<u8>,
pub batch_count: Seedance2p0BatchCount,
@@ -24,27 +29,31 @@ pub struct PlanArtcraftSeedance2p0<'a> {
pub fn plan_generate_video_artcraft_seedance2p0<'a>(
request: &'a GenerateVideoRequest<'a>,
) -> Result<PlanArtcraftSeedance2p0<'a>, ArtcraftRouterError> {
) -> Result<VideoGenerationPlan<'a>, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
let start_frame = resolve_image_ref(request.start_frame)?;
let end_frame = resolve_image_ref(request.end_frame)?;
let reference_images = resolve_image_list_ref(request.reference_images)?;
let reference_videos = resolve_video_list_ref(request.reference_videos)?;
let reference_audio = resolve_audio_list_ref(request.reference_audio)?;
let aspect_ratio = plan_aspect_ratio(request.aspect_ratio, strategy)?;
let batch_count = plan_batch_count(request.video_batch_count, strategy)?;
let duration_seconds = plan_duration(request.duration_seconds, strategy)?;
Ok(PlanArtcraftSeedance2p0 {
Ok(VideoGenerationPlan::ArtcraftSeedance2p0(PlanArtcraftSeedance2p0 {
prompt: request.prompt,
start_frame,
end_frame,
reference_images,
reference_videos,
reference_audio,
aspect_ratio,
duration_seconds,
batch_count,
idempotency_token: request.get_or_generate_idempotency_token(),
})
}))
}
fn resolve_image_ref<'a>(
@@ -71,6 +80,30 @@ fn resolve_image_list_ref<'a>(
}
}
fn resolve_video_list_ref<'a>(
video_list_ref: Option<VideoListRef<'a>>,
) -> Result<Option<&'a Vec<MediaFileToken>>, ArtcraftRouterError> {
match video_list_ref {
None => Ok(None),
Some(VideoListRef::MediaFileTokens(tokens)) => Ok(Some(tokens)),
Some(VideoListRef::Urls(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
}
}
}
fn resolve_audio_list_ref<'a>(
audio_list_ref: Option<AudioListRef<'a>>,
) -> Result<Option<&'a Vec<MediaFileToken>>, ArtcraftRouterError> {
match audio_list_ref {
None => Ok(None),
Some(AudioListRef::MediaFileTokens(tokens)) => Ok(Some(tokens)),
Some(AudioListRef::Urls(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
}
}
}
// Supported aspect ratios and their AR values (width / height):
// Portrait9x16 = 0.5625, Portrait3x4 = 0.75, Square1x1 = 1.0, Standard4x3 = 1.33, Landscape16x9 = 1.78
//
@@ -1 +1,3 @@
pub mod artcraft;
pub mod artcraft;
pub mod muapi;
pub mod seedance2pro;
@@ -0,0 +1 @@
pub mod plan_generate_video_muapi_seedance2p0;
@@ -0,0 +1,259 @@
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::image_list_ref::ImageListRef;
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_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use muapi_client::requests::seedance_2p0_image_to_video::seedance_2p0_image_to_video::{
AspectRatio as I2vAspectRatio,
Duration as I2vDuration,
Quality as I2vQuality,
};
use muapi_client::requests::seedance_2p0_text_to_video::seedance_2p0_text_to_video::{
AspectRatio as T2vAspectRatio,
Duration as T2vDuration,
Quality as T2vQuality,
};
/// The generation mode determined at plan time.
/// Muapi has separate text-to-video and image-to-video endpoints.
/// If reference images are present, we use image-to-video; otherwise text-to-video.
#[derive(Debug, Clone)]
pub enum MuapiSeedance2p0Mode {
TextToVideo {
aspect_ratio: T2vAspectRatio,
duration: T2vDuration,
quality: T2vQuality,
},
ImageToVideo {
image_urls: Vec<String>,
aspect_ratio: I2vAspectRatio,
duration: I2vDuration,
quality: I2vQuality,
},
}
#[derive(Debug, Clone)]
pub struct PlanMuapiSeedance2p0 {
pub prompt: String,
pub mode: MuapiSeedance2p0Mode,
}
pub fn plan_generate_video_muapi_seedance2p0<'a>(
request: &'a GenerateVideoRequest<'a>,
) -> Result<VideoGenerationPlan<'a>, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
// Muapi does not support start/end frames, video references, or audio references.
if request.start_frame.is_some() {
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "start_frame",
value: "Muapi does not support start frame keyframes".to_string(),
}));
}
if request.end_frame.is_some() {
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "end_frame",
value: "Muapi does not support end frame keyframes".to_string(),
}));
}
if request.reference_videos.is_some() {
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "reference_videos",
value: "Muapi does not support video references".to_string(),
}));
}
if request.reference_audio.is_some() {
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "reference_audio",
value: "Muapi does not support audio references".to_string(),
}));
}
let image_urls = resolve_image_urls(request.reference_images)?;
let prompt = request.prompt.unwrap_or("").to_string();
let mode = match image_urls {
Some(urls) => {
MuapiSeedance2p0Mode::ImageToVideo {
image_urls: urls,
aspect_ratio: plan_i2v_aspect_ratio(request.aspect_ratio, strategy)?,
duration: plan_i2v_duration(request.duration_seconds, strategy)?,
quality: I2vQuality::High, // NB: "Low" is a "fast" mode, I think
}
}
None => {
MuapiSeedance2p0Mode::TextToVideo {
aspect_ratio: plan_t2v_aspect_ratio(request.aspect_ratio, strategy)?,
duration: plan_t2v_duration(request.duration_seconds, strategy)?,
quality: T2vQuality::High, // NB: "Low" is a "fast" mode, I think
}
}
};
Ok(VideoGenerationPlan::MuapiSeedance2p0(PlanMuapiSeedance2p0 {
prompt,
mode,
}))
}
fn resolve_image_urls(
reference_images: Option<ImageListRef<'_>>,
) -> Result<Option<Vec<String>>, ArtcraftRouterError> {
match reference_images {
None => Ok(None),
Some(ImageListRef::Urls(urls)) => {
if urls.is_empty() {
Ok(None)
} else {
Ok(Some(urls.clone()))
}
}
Some(ImageListRef::MediaFileTokens(_)) => {
Err(ArtcraftRouterError::Client(ClientError::MuapiOnlySupportsUrls))
}
}
}
// Muapi supports: 16:9, 9:16, 4:3, 3:4 (no square, no 1:1)
fn plan_t2v_aspect_ratio(
aspect_ratio: Option<CommonAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<T2vAspectRatio, ArtcraftRouterError> {
match aspect_ratio {
None
| Some(CommonAspectRatio::Auto)
| Some(CommonAspectRatio::Auto2k)
| Some(CommonAspectRatio::Auto4k) => Ok(T2vAspectRatio::Landscape16x9),
Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => {
Ok(T2vAspectRatio::Landscape16x9)
}
Some(CommonAspectRatio::TallNineBySixteen) | Some(CommonAspectRatio::Tall) => {
Ok(T2vAspectRatio::Portrait9x16)
}
Some(CommonAspectRatio::WideFourByThree) => Ok(T2vAspectRatio::Standard4x3),
Some(CommonAspectRatio::TallThreeByFour) => Ok(T2vAspectRatio::Portrait3x4),
// Square and others are not supported by Muapi
Some(unsupported) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "aspect_ratio",
value: format!("{:?}", unsupported),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(nearest_t2v_aspect_ratio(unsupported))
}
},
}
}
fn nearest_t2v_aspect_ratio(aspect_ratio: CommonAspectRatio) -> T2vAspectRatio {
match aspect_ratio {
// Wide ratios → nearest wide
CommonAspectRatio::Square | CommonAspectRatio::SquareHd
| CommonAspectRatio::WideFiveByFour => T2vAspectRatio::Standard4x3,
CommonAspectRatio::WideThreeByTwo => T2vAspectRatio::Landscape16x9,
CommonAspectRatio::WideTwentyOneByNine => T2vAspectRatio::Landscape16x9,
// Tall ratios → nearest tall
CommonAspectRatio::TallFourByFive => T2vAspectRatio::Portrait3x4,
CommonAspectRatio::TallTwoByThree => T2vAspectRatio::Portrait9x16,
CommonAspectRatio::TallNineByTwentyOne => T2vAspectRatio::Portrait9x16,
_ => T2vAspectRatio::Landscape16x9,
}
}
fn plan_i2v_aspect_ratio(
aspect_ratio: Option<CommonAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<I2vAspectRatio, ArtcraftRouterError> {
match aspect_ratio {
None
| Some(CommonAspectRatio::Auto)
| Some(CommonAspectRatio::Auto2k)
| Some(CommonAspectRatio::Auto4k) => Ok(I2vAspectRatio::Landscape16x9),
Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => {
Ok(I2vAspectRatio::Landscape16x9)
}
Some(CommonAspectRatio::TallNineBySixteen) | Some(CommonAspectRatio::Tall) => {
Ok(I2vAspectRatio::Portrait9x16)
}
Some(CommonAspectRatio::WideFourByThree) => Ok(I2vAspectRatio::Standard4x3),
Some(CommonAspectRatio::TallThreeByFour) => Ok(I2vAspectRatio::Portrait3x4),
Some(unsupported) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "aspect_ratio",
value: format!("{:?}", unsupported),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(nearest_i2v_aspect_ratio(unsupported))
}
},
}
}
fn nearest_i2v_aspect_ratio(aspect_ratio: CommonAspectRatio) -> I2vAspectRatio {
match aspect_ratio {
CommonAspectRatio::Square | CommonAspectRatio::SquareHd
| CommonAspectRatio::WideFiveByFour => I2vAspectRatio::Standard4x3,
CommonAspectRatio::WideThreeByTwo => I2vAspectRatio::Landscape16x9,
CommonAspectRatio::WideTwentyOneByNine => I2vAspectRatio::Landscape16x9,
CommonAspectRatio::TallFourByFive => I2vAspectRatio::Portrait3x4,
CommonAspectRatio::TallTwoByThree => I2vAspectRatio::Portrait9x16,
CommonAspectRatio::TallNineByTwentyOne => I2vAspectRatio::Portrait9x16,
_ => I2vAspectRatio::Landscape16x9,
}
}
// Muapi supports durations of 5, 10, and 15 seconds.
fn plan_t2v_duration(
duration_seconds: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<T2vDuration, ArtcraftRouterError> {
let d = duration_seconds.unwrap_or(5);
match d {
0..=5 => Ok(T2vDuration::FiveSeconds),
6..=10 => Ok(T2vDuration::TenSeconds),
11..=15 => Ok(T2vDuration::FifteenSeconds),
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "duration_seconds",
value: format!("{}", d),
}))
}
_ => Ok(T2vDuration::FifteenSeconds), // clamp to max
},
}
}
fn plan_i2v_duration(
duration_seconds: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<I2vDuration, ArtcraftRouterError> {
let d = duration_seconds.unwrap_or(5);
match d {
0..=5 => Ok(I2vDuration::FiveSeconds),
6..=10 => Ok(I2vDuration::TenSeconds),
11..=15 => Ok(I2vDuration::FifteenSeconds),
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "duration_seconds",
value: format!("{}", d),
}))
}
_ => Ok(I2vDuration::FifteenSeconds),
},
}
}
@@ -0,0 +1 @@
pub mod plan_generate_video_seedance2pro_seedance2p0;
@@ -0,0 +1,209 @@
use crate::api::audio_list_ref::AudioListRef;
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::video_list_ref::VideoListRef;
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_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use seedance2pro::requests::generate_video::generate_video::{BatchCount, Resolution};
#[derive(Debug, Clone)]
pub struct PlanSeedance2proSeedance2p0 {
pub prompt: Option<String>,
pub start_frame_url: Option<String>,
pub end_frame_url: Option<String>,
pub reference_image_urls: Option<Vec<String>>,
pub reference_video_urls: Option<Vec<String>>,
pub reference_audio_urls: Option<Vec<String>>,
pub resolution: Resolution,
pub duration_seconds: u8,
pub batch_count: BatchCount,
}
pub fn plan_generate_video_seedance2pro_seedance2p0<'a>(
request: &'a GenerateVideoRequest<'a>,
) -> Result<VideoGenerationPlan<'a>, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
let start_frame_url = resolve_image_ref_url(request.start_frame)?;
let end_frame_url = resolve_image_ref_url(request.end_frame)?;
let reference_image_urls = resolve_image_list_ref_urls(request.reference_images)?;
let reference_video_urls = resolve_video_list_ref_urls(request.reference_videos)?;
let reference_audio_urls = resolve_audio_list_ref_urls(request.reference_audio)?;
let resolution = plan_resolution(request.aspect_ratio, strategy)?;
let batch_count = plan_batch_count(request.video_batch_count, strategy)?;
let duration_seconds = plan_duration(request.duration_seconds, strategy)?;
Ok(VideoGenerationPlan::Seedance2proSeedance2p0(PlanSeedance2proSeedance2p0 {
prompt: request.prompt.map(|p| p.to_string()),
start_frame_url,
end_frame_url,
reference_image_urls,
reference_video_urls,
reference_audio_urls,
resolution,
duration_seconds,
batch_count,
}))
}
fn resolve_image_ref_url(
image_ref: Option<ImageRef<'_>>,
) -> Result<Option<String>, ArtcraftRouterError> {
match image_ref {
None => Ok(None),
Some(ImageRef::Url(url)) => Ok(Some(url.to_string())),
Some(ImageRef::MediaFileToken(_)) => {
Err(ArtcraftRouterError::Client(ClientError::Seedance2ProOnlySupportsUrls))
}
}
}
fn resolve_image_list_ref_urls(
image_list_ref: Option<ImageListRef<'_>>,
) -> Result<Option<Vec<String>>, ArtcraftRouterError> {
match image_list_ref {
None => Ok(None),
Some(ImageListRef::Urls(urls)) => Ok(Some(urls.clone())),
Some(ImageListRef::MediaFileTokens(_)) => {
Err(ArtcraftRouterError::Client(ClientError::Seedance2ProOnlySupportsUrls))
}
}
}
fn resolve_video_list_ref_urls(
video_list_ref: Option<VideoListRef<'_>>,
) -> Result<Option<Vec<String>>, ArtcraftRouterError> {
match video_list_ref {
None => Ok(None),
Some(VideoListRef::Urls(urls)) => Ok(Some(urls.clone())),
Some(VideoListRef::MediaFileTokens(_)) => {
Err(ArtcraftRouterError::Client(ClientError::Seedance2ProOnlySupportsUrls))
}
}
}
fn resolve_audio_list_ref_urls(
audio_list_ref: Option<AudioListRef<'_>>,
) -> Result<Option<Vec<String>>, ArtcraftRouterError> {
match audio_list_ref {
None => Ok(None),
Some(AudioListRef::Urls(urls)) => Ok(Some(urls.clone())),
Some(AudioListRef::MediaFileTokens(_)) => {
Err(ArtcraftRouterError::Client(ClientError::Seedance2ProOnlySupportsUrls))
}
}
}
// Supported aspect ratios and their AR values (width / height):
// Portrait9x16 = 0.5625, Portrait3x4 = 0.75, Square1x1 = 1.0, Standard4x3 = 1.33, Landscape16x9 = 1.78
//
// All supported ratios cost the same, so PayMoreUpgrade and PayLessDowngrade both
// select the nearest match rather than rounding in a specific direction.
fn plan_resolution(
aspect_ratio: Option<CommonAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Resolution, ArtcraftRouterError> {
match aspect_ratio {
// No preference or auto — default to landscape
None
| Some(CommonAspectRatio::Auto)
| Some(CommonAspectRatio::Auto2k)
| Some(CommonAspectRatio::Auto4k) => Ok(Resolution::Landscape16x9),
// Direct mappings
Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => {
Ok(Resolution::Landscape16x9)
}
Some(CommonAspectRatio::TallNineBySixteen) | Some(CommonAspectRatio::Tall) => {
Ok(Resolution::Portrait9x16)
}
Some(CommonAspectRatio::Square) | Some(CommonAspectRatio::SquareHd) => {
Ok(Resolution::Square1x1)
}
Some(CommonAspectRatio::WideFourByThree) => Ok(Resolution::Standard4x3),
Some(CommonAspectRatio::TallThreeByFour) => Ok(Resolution::Portrait3x4),
// Mismatches — apply strategy
Some(unsupported) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "aspect_ratio",
value: format!("{:?}", unsupported),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(nearest_resolution(unsupported))
}
},
}
}
/// Pick the nearest supported resolution by AR value (width / height).
fn nearest_resolution(aspect_ratio: CommonAspectRatio) -> Resolution {
match aspect_ratio {
CommonAspectRatio::WideFiveByFour => Resolution::Standard4x3, // 1.25, nearest 1.33
CommonAspectRatio::WideThreeByTwo => Resolution::Standard4x3, // 1.50, nearest 1.33
CommonAspectRatio::WideTwentyOneByNine => Resolution::Landscape16x9, // 2.33, nearest 1.78
CommonAspectRatio::TallFourByFive => Resolution::Portrait3x4, // 0.80, nearest 0.75
CommonAspectRatio::TallTwoByThree => Resolution::Portrait3x4, // 0.67, nearest 0.75
CommonAspectRatio::TallNineByTwentyOne => Resolution::Portrait9x16, // 0.43, nearest 0.56
_ => Resolution::Square1x1,
}
}
// Seedance2p0 supports batch counts of 1, 2, and 4 only.
fn plan_batch_count(
video_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<BatchCount, ArtcraftRouterError> {
let count = video_batch_count.unwrap_or(1);
match count {
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 => Ok(BatchCount::One),
2 => Ok(BatchCount::Two),
4 => Ok(BatchCount::Four),
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "video_batch_count",
value: format!("{}", count),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => {
Ok(if count < 4 { BatchCount::Four } else { BatchCount::Four })
}
RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(if count < 4 { BatchCount::Two } else { BatchCount::Four })
}
},
}
}
// Seedance2p0 supports duration of 4–15 seconds.
fn plan_duration(
duration_seconds: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<u8, ArtcraftRouterError> {
const MIN: u16 = 4;
const MAX: u16 = 15;
const DEFAULT: u8 = 5;
match duration_seconds {
None => Ok(DEFAULT),
Some(d) if d >= MIN && d <= MAX => Ok(d as u8),
Some(d) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "duration_seconds",
value: format!("{}", d),
}))
}
_ => Ok(d.clamp(MIN, MAX) as u8),
},
}
}
@@ -4,15 +4,21 @@ use crate::generate::generate_video::cost::artcraft::estimate_video_cost_artcraf
use crate::generate::generate_video::cost::artcraft::estimate_video_cost_artcraft_kling3p0_standard::estimate_video_cost_artcraft_kling3p0_standard;
use crate::generate::generate_video::cost::artcraft::estimate_video_cost_artcraft_seedance1p5_pro::estimate_video_cost_artcraft_seedance1p5_pro;
use crate::generate::generate_video::cost::artcraft::estimate_video_cost_artcraft_seedance2p0::estimate_video_cost_artcraft_seedance2p0;
use crate::generate::generate_video::cost::muapi::estimate_video_cost_muapi_seedance2p0::estimate_video_cost_muapi_seedance2p0;
use crate::generate::generate_video::cost::seedance2pro::estimate_video_cost_seedance2pro_seedance2p0::estimate_video_cost_seedance2pro_seedance2p0;
use crate::generate::generate_video::execute::artcraft::generate_video_artcraft_kling3p0_pro::execute_artcraft_kling3p0_pro;
use crate::generate::generate_video::execute::artcraft::generate_video_artcraft_kling3p0_standard::execute_artcraft_kling3p0_standard;
use crate::generate::generate_video::execute::artcraft::generate_video_artcraft_seedance1p5_pro::execute_artcraft_seedance1p5_pro;
use crate::generate::generate_video::execute::artcraft::generate_video_artcraft_seedance2p0::execute_artcraft_seedance2p0;
use crate::generate::generate_video::execute::muapi::execute_muapi_seedance2p0::execute_muapi_seedance2p0;
use crate::generate::generate_video::execute::seedance2pro::execute_seedance2pro_seedance2p0::execute_seedance2pro_seedance2p0;
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_kling3p0_pro::PlanArtcraftKling3p0Pro;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_kling3p0_standard::PlanArtcraftKling3p0Standard;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_seedance1p5_pro::PlanArtcraftSeedance1p5Pro;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_seedance2p0::PlanArtcraftSeedance2p0;
use crate::generate::generate_video::plan::muapi::plan_generate_video_muapi_seedance2p0::PlanMuapiSeedance2p0;
use crate::generate::generate_video::plan::seedance2pro::plan_generate_video_seedance2pro_seedance2p0::PlanSeedance2proSeedance2p0;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
#[derive(Debug)]
@@ -21,6 +27,8 @@ pub enum VideoGenerationPlan<'a> {
ArtcraftKling3p0Standard(PlanArtcraftKling3p0Standard<'a>),
ArtcraftSeedance1p5Pro(PlanArtcraftSeedance1p5Pro<'a>),
ArtcraftSeedance2p0(PlanArtcraftSeedance2p0<'a>),
MuapiSeedance2p0(PlanMuapiSeedance2p0),
Seedance2proSeedance2p0(PlanSeedance2proSeedance2p0),
}
impl<'a> VideoGenerationPlan<'a> {
@@ -45,6 +53,14 @@ impl<'a> VideoGenerationPlan<'a> {
let artcraft_client = client.get_artcraft_client_ref()?;
execute_artcraft_seedance2p0(plan, artcraft_client).await
}
VideoGenerationPlan::MuapiSeedance2p0(plan) => {
let muapi_client = client.get_muapi_client_ref()?;
execute_muapi_seedance2p0(plan, muapi_client).await
}
VideoGenerationPlan::Seedance2proSeedance2p0(plan) => {
let seedance2pro_client = client.get_seedance2pro_client_ref()?;
execute_seedance2pro_seedance2p0(plan, seedance2pro_client).await
}
}
}
@@ -62,6 +78,12 @@ impl<'a> VideoGenerationPlan<'a> {
VideoGenerationPlan::ArtcraftSeedance2p0(plan) => {
estimate_video_cost_artcraft_seedance2p0(plan)
}
VideoGenerationPlan::MuapiSeedance2p0(plan) => {
estimate_video_cost_muapi_seedance2p0(plan)
}
VideoGenerationPlan::Seedance2proSeedance2p0(plan) => {
estimate_video_cost_seedance2pro_seedance2p0(plan)
}
}
}
}
@@ -2,6 +2,7 @@ pub mod api;
pub mod client;
pub mod errors;
pub mod generate;
pub mod utils;
#[cfg(test)]
pub mod test_helpers;
@@ -290,6 +290,8 @@ pub fn base_video_request() -> GenerateVideoRequest<'static> {
start_frame: None,
end_frame: None,
reference_images: None,
reference_videos: None,
reference_audio: None,
resolution: None,
aspect_ratio: None,
duration_seconds: None,
@@ -0,0 +1,30 @@
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::download_error::DownloadError;
/// Download a file from a URL, returning its bytes.
pub async fn download_file(url: &str) -> Result<Vec<u8>, ArtcraftRouterError> {
let response = reqwest::get(url)
.await
.map_err(|err| DownloadError::ReqwestDownload {
url: url.to_string(),
maybe_status: err.status(),
error: err,
})?;
let status = response.status();
if !status.is_success() {
return Err(DownloadError::BadStatus {
url: url.to_string(),
status_code: status.as_u16(),
}.into());
}
response.bytes()
.await
.map(|b| b.to_vec())
.map_err(|err| DownloadError::ReqwestDownload {
url: url.to_string(),
maybe_status: err.status(),
error: err,
}.into())
}
@@ -0,0 +1 @@
pub mod download_file;
@@ -0,0 +1,33 @@
[package]
name = "muapi_client"
edition = "2024"
version = "0.0.1"
publish = false
[package.metadata.cargo-udeps.ignore]
normal = ["workspace-build-acceleration"]
[lib]
name = "muapi_client"
path = "src/lib.rs"
[dependencies]
workspace-build-acceleration.workspace = true
errors.workspace = true
chrono.workspace = true
cloudflare_errors.workspace = true
log.workspace = true
rand.workspace = true
serde.workspace = true
serde_derive.workspace = true
serde_json.workspace = true
wreq-util.workspace = true
wreq.workspace = true
url.workspace = true
[dev-dependencies]
env.workspace = true
env_logger.workspace = true
test_data = { path = "../../lib/test_data" }
testing = { path = "../../lib/testing" }
tokio = { version = "1", features = ["full"] }
@@ -0,0 +1 @@
pub mod request_id;
@@ -0,0 +1,24 @@
/// ID for a prediction request in the Muapi API.
#[derive(Clone, Debug)]
pub struct RequestId(String);
impl RequestId {
pub fn new(value: String) -> Self {
RequestId(value)
}
pub fn from_str(value: &str) -> Self {
RequestId(value.to_string())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for RequestId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
@@ -0,0 +1,2 @@
pub mod muapi_api_key;
pub mod muapi_session;
@@ -0,0 +1,20 @@
/// An API key is the credential required to interact with the Muapi API.
#[derive(Clone)]
pub struct MuapiApiKey {
api_key: String,
}
impl MuapiApiKey {
pub fn new(api_key: String) -> Self {
MuapiApiKey { api_key }
}
pub fn as_str(&self) -> &str {
&self.api_key
}
pub fn to_string(&self) -> String {
self.api_key.clone()
}
}
@@ -0,0 +1,19 @@
use crate::creds::muapi_api_key::MuapiApiKey;
/// Holds the full session data needed to make authenticated requests to Muapi.
#[derive(Clone)]
pub struct MuapiSession {
pub api_key: MuapiApiKey,
}
impl MuapiSession {
pub fn new(api_key: MuapiApiKey) -> Self {
MuapiSession { api_key }
}
pub fn from_api_key_string(api_key: String) -> Self {
MuapiSession {
api_key: MuapiApiKey::new(api_key),
}
}
}
@@ -0,0 +1,4 @@
pub mod muapi_client_error;
pub mod muapi_error;
pub mod muapi_generic_api_error;
pub mod muapi_specific_api_error;
@@ -0,0 +1,26 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
#[derive(Debug)]
pub enum MuapiClientError {
/// No API key is present in the session.
NoApiKeyPresent,
/// Error parsing a URL.
UrlParseError(url::ParseError),
/// An error was encountered in building the Wreq client.
WreqClientError(wreq::Error),
}
impl Error for MuapiClientError {}
impl Display for MuapiClientError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::NoApiKeyPresent => write!(f, "No API key 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),
}
}
}
@@ -0,0 +1,61 @@
use crate::error::muapi_client_error::MuapiClientError;
use crate::error::muapi_generic_api_error::MuapiGenericApiError;
use crate::error::muapi_specific_api_error::MuapiSpecificApiError;
use cloudflare_errors::cloudflare_error::CloudflareError;
use std::error::Error;
#[derive(Debug)]
pub enum MuapiError {
Client(MuapiClientError),
ApiSpecific(MuapiSpecificApiError),
ApiGeneric(MuapiGenericApiError),
}
impl MuapiError {
pub fn is_having_downtime_issues(&self) -> bool {
match self {
Self::ApiGeneric(MuapiGenericApiError::CloudflareError(CloudflareError::BadGateway502)) => true,
Self::ApiGeneric(MuapiGenericApiError::CloudflareError(CloudflareError::GatewayTimeout504)) => true,
Self::ApiGeneric(MuapiGenericApiError::CloudflareError(CloudflareError::TimeoutOccurred524)) => true,
Self::ApiGeneric(MuapiGenericApiError::UncategorizedBadResponseWithStatusAndBody { status_code, body: _ }) => {
match status_code.as_u16() {
502 => true,
504 => true,
524 => true,
_ => false,
}
},
_ => false,
}
}
}
impl Error for MuapiError {}
impl std::fmt::Display for MuapiError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Client(e) => write!(f, "MuapiClientError: {:?}", e),
Self::ApiSpecific(e) => write!(f, "MuapiSpecificApiError: {:?}", e),
Self::ApiGeneric(e) => write!(f, "MuapiGenericApiError: {:?}", e),
}
}
}
impl From<MuapiClientError> for MuapiError {
fn from(error: MuapiClientError) -> Self {
Self::Client(error)
}
}
impl From<MuapiSpecificApiError> for MuapiError {
fn from(error: MuapiSpecificApiError) -> Self {
Self::ApiSpecific(error)
}
}
impl From<MuapiGenericApiError> for MuapiError {
fn from(error: MuapiGenericApiError) -> Self {
Self::ApiGeneric(error)
}
}
@@ -0,0 +1,41 @@
use cloudflare_errors::cloudflare_error::CloudflareError;
use std::error::Error;
use std::fmt::{Display, Formatter};
use wreq::StatusCode;
#[derive(Debug)]
pub enum MuapiGenericApiError {
/// Specific Cloudflare errors.
CloudflareError(CloudflareError),
/// serde_json::Error, likely from JSON deserialization schema mismatch.
/// Includes the original body.
SerdeResponseParseErrorWithBody(serde_json::Error, String),
/// serde_json::Error on a non-200 (error) response.
/// Includes the original body.
SerdeParseErrorWithBodyOnNon200(serde_json::Error, String),
/// An uncategorized bad HTTP response with status code and body.
UncategorizedBadResponseWithStatusAndBody {
status_code: StatusCode,
body: String,
},
/// An uncaught error from the HTTP client.
WreqError(wreq::Error),
}
impl Error for MuapiGenericApiError {}
impl Display for MuapiGenericApiError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::CloudflareError(err) => write!(f, "Cloudflare error: {}", err),
Self::SerdeResponseParseErrorWithBody(err, body) => write!(f, "Failed to parse response body: {:?}. Body: {}", err, body),
Self::SerdeParseErrorWithBodyOnNon200(err, body) => write!(f, "Failed to parse non-200 response body: {:?}. Body: {}", err, body),
Self::UncategorizedBadResponseWithStatusAndBody { status_code, body } => write!(f, "Uncategorized bad response: status code {}, body: {}", status_code, body),
Self::WreqError(err) => write!(f, "Wreq client error: {}", err),
}
}
}
@@ -0,0 +1,18 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
#[derive(Debug)]
pub enum MuapiSpecificApiError {
/// Invalid API key.
InvalidApiKey,
}
impl Error for MuapiSpecificApiError {}
impl Display for MuapiSpecificApiError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidApiKey => write!(f, "Unauthorized: invalid API key."),
}
}
}
@@ -0,0 +1,8 @@
#[cfg(test)]
pub (crate) mod test_utils;
pub mod api_types;
pub mod creds;
pub mod error;
pub mod requests;
pub mod utils;
@@ -0,0 +1,3 @@
pub mod poll_prediction_result;
pub mod seedance_2p0_image_to_video;
pub mod seedance_2p0_text_to_video;
@@ -0,0 +1,2 @@
pub mod poll_prediction_result;
mod request_types;
@@ -0,0 +1,142 @@
use crate::api_types::request_id::RequestId;
use crate::creds::muapi_session::MuapiSession;
use crate::error::muapi_client_error::MuapiClientError;
use crate::error::muapi_error::MuapiError;
use crate::error::muapi_generic_api_error::MuapiGenericApiError;
use crate::requests::poll_prediction_result::request_types::*;
use log::info;
use wreq::Client;
const PREDICTIONS_BASE_URL: &str = "https://api.muapi.ai/api/v1/predictions";
// --- Public types ---
/// The lifecycle status of a prediction.
#[derive(Debug, Clone, PartialEq)]
pub enum PredictionStatus {
/// The task is queued and has not started yet.
Pending,
/// The task is actively being processed.
Processing,
/// The task finished successfully. `outputs` will be populated.
Completed,
/// The task failed. `error` will contain the reason.
Failed,
/// An unrecognised status string was returned by the server.
Unknown(String),
}
impl PredictionStatus {
fn from_str(s: &str) -> Self {
match s {
"pending" => Self::Pending,
"processing" => Self::Processing,
"completed" => Self::Completed,
"failed" => Self::Failed,
other => Self::Unknown(other.to_string()),
}
}
pub fn is_terminal(&self) -> bool {
matches!(self, Self::Completed | Self::Failed)
}
}
// --- Args & response ---
pub struct PollPredictionResultArgs<'a> {
pub session: &'a MuapiSession,
/// The request_id returned from a submission endpoint.
pub request_id: &'a RequestId,
}
pub struct PollPredictionResultApiResponse {
/// The current status of the prediction.
pub status: PredictionStatus,
/// Output URLs. Populated when `status` is `Completed`.
pub output_urls: Option<Vec<String>>,
/// Error message. Populated when `status` is `Failed`.
pub error: Option<String>,
}
// --- Implementation ---
pub async fn poll_prediction_result(
args: PollPredictionResultArgs<'_>,
) -> Result<PollPredictionResultApiResponse, MuapiError> {
let url = format!("{}/{}/result", PREDICTIONS_BASE_URL, args.request_id.as_str());
info!("Polling Muapi prediction result: {}", url);
let api_key = args.session.api_key.as_str();
let client = Client::builder()
.build()
.map_err(|err| MuapiClientError::WreqClientError(err))?;
let response = client.get(&url)
.header("Content-Type", "application/json")
.header("x-api-key", api_key)
.send()
.await
.map_err(|err| MuapiGenericApiError::WreqError(err))?;
let status = response.status();
let response_body = response.text()
.await
.map_err(|err| MuapiGenericApiError::WreqError(err))?;
info!("Muapi poll response status: {}, body: {}", status, response_body);
if !status.is_success() {
return Err(MuapiGenericApiError::UncategorizedBadResponseWithStatusAndBody {
status_code: status,
body: response_body,
}.into());
}
let parsed: PollPredictionResultResponse = serde_json::from_str(&response_body)
.map_err(|err| MuapiGenericApiError::SerdeResponseParseErrorWithBody(err, response_body))?;
let output_urls = parsed.outputs.and_then(|urls| if urls.is_empty() { None } else { Some(urls) });
Ok(PollPredictionResultApiResponse {
status: PredictionStatus::from_str(&parsed.status),
output_urls,
error: parsed.error,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_utils::get_test_api_key::get_test_api_key;
use crate::test_utils::setup_test_logging::setup_test_logging;
use errors::AnyhowResult;
use log::LevelFilter;
#[tokio::test]
#[ignore] // manually test — requires real API key and a valid request_id
async fn test_poll_prediction_result() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = get_test_api_key()?;
let request_id = RequestId::from_str("5fd7f575-4b9e-4c0a-b7f7-0eb036027b81"); // Corgi shiba I2V
let request_id = RequestId::from_str("8a4b578b-4514-443f-b444-4fb3bc5c2cbb"); // Cat TTV
let request_id = RequestId::from_str("82d622b1-8911-4cac-8362-e18efaa743ed"); // Corgi I2V
let request_id = RequestId::from_str("671c2dee-2ca9-47d7-a12e-d16610b1b365"); // Corgi TTV #1
let request_id = RequestId::from_str("8c5303f6-b602-433c-bae8-2d3ef915b7bb"); // Corgi TTV #2
let result = poll_prediction_result(PollPredictionResultArgs {
session: &session,
request_id: &request_id,
}).await?;
println!("Status: {:?}", result.status);
println!("Output URLs: {:?}", result.output_urls);
println!("Error: {:?}", result.error);
assert_eq!(1, 2); // NB: Intentional failure to inspect output.
Ok(())
}
}
@@ -0,0 +1,8 @@
use serde_derive::Deserialize;
#[derive(Deserialize, Debug)]
pub(super) struct PollPredictionResultResponse {
pub status: String,
pub outputs: Option<Vec<String>>,
pub error: Option<String>,
}
@@ -0,0 +1,2 @@
pub mod seedance_2p0_image_to_video;
mod request_types;
@@ -0,0 +1,19 @@
use serde_derive::{Deserialize, Serialize};
// --- Request ---
#[derive(Serialize, Debug)]
pub(super) struct Seedance2p0I2vRequest {
pub prompt: String,
pub images_list: Vec<String>,
pub aspect_ratio: &'static str,
pub duration: u8,
pub quality: &'static str,
}
// --- Response ---
#[derive(Deserialize, Debug)]
pub(super) struct Seedance2p0I2vResponse {
pub request_id: String,
}
@@ -0,0 +1,300 @@
use crate::api_types::request_id::RequestId;
use crate::creds::muapi_session::MuapiSession;
use crate::error::muapi_client_error::MuapiClientError;
use crate::error::muapi_error::MuapiError;
use crate::error::muapi_generic_api_error::MuapiGenericApiError;
use crate::requests::seedance_2p0_image_to_video::request_types::*;
use log::info;
use wreq::Client;
const SEEDANCE_2P0_I2V_URL: &str = "https://api.muapi.ai/api/v1/seedance-v2.0-i2v";
// --- Public enums ---
/// Video aspect ratio.
#[derive(Debug, Clone, Copy)]
pub enum AspectRatio {
/// 16:9 landscape
Landscape16x9,
/// 9:16 portrait
Portrait9x16,
/// 4:3 standard
Standard4x3,
/// 3:4 portrait
Portrait3x4,
}
impl AspectRatio {
fn as_str(&self) -> &'static str {
match self {
Self::Landscape16x9 => "16:9",
Self::Portrait9x16 => "9:16",
Self::Standard4x3 => "4:3",
Self::Portrait3x4 => "3:4",
}
}
}
/// Video duration.
#[derive(Debug, Clone, Copy)]
pub enum Duration {
/// 5 seconds
FiveSeconds,
/// 10 seconds
TenSeconds,
/// 15 seconds
FifteenSeconds,
}
impl Duration {
fn as_u8(&self) -> u8 {
match self {
Self::FiveSeconds => 5,
Self::TenSeconds => 10,
Self::FifteenSeconds => 15,
}
}
}
/// Video quality tier.
#[derive(Debug, Clone, Copy)]
pub enum Quality {
Basic,
High,
}
impl Quality {
fn as_str(&self) -> &'static str {
match self {
Self::Basic => "basic",
Self::High => "high",
}
}
}
// --- Args & response ---
pub struct Seedance2p0ImageToVideoArgs<'a> {
pub session: &'a MuapiSession,
/// The prompt describing the desired video.
pub prompt: String,
/// One or more image URLs to use as input frames.
pub image_urls: Vec<String>,
/// The aspect ratio for the output video.
pub aspect_ratio: AspectRatio,
/// Duration of the generated video.
pub duration: Duration,
/// Quality tier.
pub quality: Quality,
}
impl Seedance2p0ImageToVideoArgs<'_> {
/// Estimates the credit cost for this generation request.
///
/// Pricing (as of 2026-03):
///
/// | Duration | Basic | High |
/// |----------|-------|------|
/// | 5s | 0.60 | 1.25 |
/// | 10s | 1.20 | 2.50 |
/// | 15s | 1.80 | 3.75 |
pub fn estimate_credits(&self) -> f64 {
match (&self.quality, &self.duration) {
(Quality::Basic, Duration::FiveSeconds) => 0.6,
(Quality::Basic, Duration::TenSeconds) => 1.2,
(Quality::Basic, Duration::FifteenSeconds) => 1.8,
(Quality::High, Duration::FiveSeconds) => 1.25,
(Quality::High, Duration::TenSeconds) => 2.5,
(Quality::High, Duration::FifteenSeconds) => 3.75,
}
}
/// Estimates the USD-cent cost for this generation request.
///
/// Conversion: $1.00 = 1.0 credits.
///
/// | Duration | Basic | High |
/// |----------|-------|------|
/// | 5s | 60¢ | 125¢ |
/// | 10s | 120¢ | 250¢ |
/// | 15s | 180¢ | 375¢ |
pub fn estimate_cost_in_usd_cents(&self) -> u64 {
let credits = self.estimate_credits();
(credits * 100.0).round() as u64
}
}
impl std::fmt::Debug for Seedance2p0ImageToVideoArgs<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Seedance2p0ImageToVideoArgs")
.field("prompt", &self.prompt)
.field("image_urls", &self.image_urls)
.field("aspect_ratio", &self.aspect_ratio)
.field("duration", &self.duration)
.field("quality", &self.quality)
.finish()
}
}
#[derive(Debug)]
pub struct Seedance2p0ImageToVideoResponse {
/// The request ID used to poll for results.
pub request_id: RequestId,
}
// --- Implementation ---
pub async fn seedance_2p0_image_to_video(
args: Seedance2p0ImageToVideoArgs<'_>,
) -> Result<Seedance2p0ImageToVideoResponse, MuapiError> {
info!("Submitting Seedance 2.0 i2v task to Muapi: {:?}", args);
let request_body = Seedance2p0I2vRequest {
prompt: args.prompt,
images_list: args.image_urls,
aspect_ratio: args.aspect_ratio.as_str(),
duration: args.duration.as_u8(),
quality: args.quality.as_str(),
};
info!("Muapi request body: {:?}", request_body);
let api_key = args.session.api_key.as_str();
let client = Client::builder()
.build()
.map_err(|err| MuapiClientError::WreqClientError(err))?;
let response = client.post(SEEDANCE_2P0_I2V_URL)
.header("Content-Type", "application/json")
.header("x-api-key", api_key)
.json(&request_body)
.send()
.await
.map_err(|err| MuapiGenericApiError::WreqError(err))?;
let status = response.status();
let response_body = response.text()
.await
.map_err(|err| MuapiGenericApiError::WreqError(err))?;
info!("Muapi response status: {}, body: {}", status, response_body);
if !status.is_success() {
return Err(MuapiGenericApiError::UncategorizedBadResponseWithStatusAndBody {
status_code: status,
body: response_body,
}.into());
}
let parsed: Seedance2p0I2vResponse = serde_json::from_str(&response_body)
.map_err(|err| MuapiGenericApiError::SerdeResponseParseErrorWithBody(err, response_body))?;
Ok(Seedance2p0ImageToVideoResponse {
request_id: RequestId::new(parsed.request_id),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_utils::get_test_api_key::get_test_api_key;
use crate::test_utils::setup_test_logging::setup_test_logging;
use errors::AnyhowResult;
use log::LevelFilter;
use test_data::web::image_urls::*;
fn dummy_session() -> MuapiSession {
use crate::creds::muapi_api_key::MuapiApiKey;
MuapiSession::new(MuapiApiKey::new(String::new()))
}
fn args_with(duration: Duration, quality: Quality) -> Seedance2p0ImageToVideoArgs<'static> {
let session = Box::leak(Box::new(dummy_session()));
Seedance2p0ImageToVideoArgs {
session,
prompt: String::new(),
image_urls: vec![],
aspect_ratio: AspectRatio::Landscape16x9,
duration,
quality,
}
}
#[test]
fn test_estimate_credits() {
// Basic
assert_eq!(args_with(Duration::FiveSeconds, Quality::Basic).estimate_credits(), 0.6);
assert_eq!(args_with(Duration::TenSeconds, Quality::Basic).estimate_credits(), 1.2);
assert_eq!(args_with(Duration::FifteenSeconds, Quality::Basic).estimate_credits(), 1.8);
// High
assert_eq!(args_with(Duration::FiveSeconds, Quality::High).estimate_credits(), 1.25);
assert_eq!(args_with(Duration::TenSeconds, Quality::High).estimate_credits(), 2.5);
assert_eq!(args_with(Duration::FifteenSeconds, Quality::High).estimate_credits(), 3.75);
}
#[test]
fn test_estimate_cost_in_usd_cents() {
// Basic
assert_eq!(args_with(Duration::FiveSeconds, Quality::Basic).estimate_cost_in_usd_cents(), 60);
assert_eq!(args_with(Duration::TenSeconds, Quality::Basic).estimate_cost_in_usd_cents(), 120);
assert_eq!(args_with(Duration::FifteenSeconds, Quality::Basic).estimate_cost_in_usd_cents(), 180);
// High
assert_eq!(args_with(Duration::FiveSeconds, Quality::High).estimate_cost_in_usd_cents(), 125);
assert_eq!(args_with(Duration::TenSeconds, Quality::High).estimate_cost_in_usd_cents(), 250);
assert_eq!(args_with(Duration::FifteenSeconds, Quality::High).estimate_cost_in_usd_cents(), 375);
}
#[tokio::test]
#[ignore] // manually test — requires real API key
async fn test_seedance_2p0_image_to_video() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = get_test_api_key()?;
let args = Seedance2p0ImageToVideoArgs {
session: &session,
prompt: "The dog barks and runs across the lake's pier, tail wagging.".to_string(),
image_urls: vec![
JUNO_AT_LAKE_IMAGE_URL.to_string(),
],
aspect_ratio: AspectRatio::Landscape16x9,
duration: Duration::FiveSeconds,
quality: Quality::High,
};
let result = seedance_2p0_image_to_video(args).await?;
println!("Result: {:?}", result);
println!("Request ID: {}", result.request_id);
assert!(!result.request_id.as_str().is_empty());
assert_eq!(1, 2); // NB: Intentional failure to inspect output.
Ok(())
}
#[tokio::test]
#[ignore] // manually test — requires real API key
async fn test_seedance_2p0_image_to_video_portrait() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = get_test_api_key()?;
let args = Seedance2p0ImageToVideoArgs {
session: &session,
prompt: "The corgi and shiba swim through the treasure-filled ocean, bubbles rising around them.".to_string(),
image_urls: vec![
TALL_CORGI_SHIBA_TREASURE_OCEAN_URL.to_string(),
],
aspect_ratio: AspectRatio::Portrait9x16,
duration: Duration::FiveSeconds,
quality: Quality::High,
};
let result = seedance_2p0_image_to_video(args).await?;
println!("Result: {:?}", result);
println!("Request ID: {}", result.request_id);
assert!(!result.request_id.as_str().is_empty());
assert_eq!(1, 2); // NB: Intentional failure to inspect output.
Ok(())
}
}
@@ -0,0 +1,2 @@
pub mod seedance_2p0_text_to_video;
mod request_types;
@@ -0,0 +1,18 @@
use serde_derive::{Deserialize, Serialize};
// --- Request ---
#[derive(Serialize, Debug)]
pub(super) struct Seedance2p0T2vRequest {
pub prompt: String,
pub aspect_ratio: &'static str,
pub duration: u8,
pub quality: &'static str,
}
// --- Response ---
#[derive(Deserialize, Debug)]
pub(super) struct Seedance2p0T2vResponse {
pub request_id: String,
}
@@ -0,0 +1,287 @@
use crate::api_types::request_id::RequestId;
use crate::creds::muapi_session::MuapiSession;
use crate::error::muapi_client_error::MuapiClientError;
use crate::error::muapi_error::MuapiError;
use crate::error::muapi_generic_api_error::MuapiGenericApiError;
use crate::requests::seedance_2p0_text_to_video::request_types::*;
use log::info;
use wreq::Client;
const SEEDANCE_2P0_T2V_URL: &str = "https://api.muapi.ai/api/v1/seedance-v2.0-t2v";
// --- Public enums ---
/// Video aspect ratio.
#[derive(Debug, Clone, Copy)]
pub enum AspectRatio {
/// 16:9 landscape
Landscape16x9,
/// 9:16 portrait
Portrait9x16,
/// 4:3 standard
Standard4x3,
/// 3:4 portrait
Portrait3x4,
}
impl AspectRatio {
fn as_str(&self) -> &'static str {
match self {
Self::Landscape16x9 => "16:9",
Self::Portrait9x16 => "9:16",
Self::Standard4x3 => "4:3",
Self::Portrait3x4 => "3:4",
}
}
}
/// Video duration.
#[derive(Debug, Clone, Copy)]
pub enum Duration {
/// 5 seconds
FiveSeconds,
/// 10 seconds
TenSeconds,
/// 15 seconds
FifteenSeconds,
}
impl Duration {
fn as_u8(&self) -> u8 {
match self {
Self::FiveSeconds => 5,
Self::TenSeconds => 10,
Self::FifteenSeconds => 15,
}
}
}
/// Video quality tier.
#[derive(Debug, Clone, Copy)]
pub enum Quality {
Basic,
High,
}
impl Quality {
fn as_str(&self) -> &'static str {
match self {
Self::Basic => "basic",
Self::High => "high",
}
}
}
// --- Args & response ---
pub struct Seedance2p0TextToVideoArgs<'a> {
pub session: &'a MuapiSession,
/// The prompt describing the desired video.
pub prompt: String,
/// The aspect ratio for the output video.
pub aspect_ratio: AspectRatio,
/// Duration of the generated video.
pub duration: Duration,
/// Quality tier.
pub quality: Quality,
}
impl Seedance2p0TextToVideoArgs<'_> {
/// Estimates the credit cost for this generation request.
///
/// Pricing (as of 2026-03):
///
/// | Duration | Basic | High |
/// |----------|-------|------|
/// | 5s | 0.60 | 1.25 |
/// | 10s | 1.20 | 2.50 |
/// | 15s | 1.80 | 3.75 |
pub fn estimate_credits(&self) -> f64 {
match (&self.quality, &self.duration) {
(Quality::Basic, Duration::FiveSeconds) => 0.6,
(Quality::Basic, Duration::TenSeconds) => 1.2,
(Quality::Basic, Duration::FifteenSeconds) => 1.8,
(Quality::High, Duration::FiveSeconds) => 1.25,
(Quality::High, Duration::TenSeconds) => 2.5,
(Quality::High, Duration::FifteenSeconds) => 3.75,
}
}
/// Estimates the USD-cent cost for this generation request.
///
/// Conversion: $1.00 = 1.0 credits.
///
/// | Duration | Basic | High |
/// |----------|-------|------|
/// | 5s | 60¢ | 125¢ |
/// | 10s | 120¢ | 250¢ |
/// | 15s | 180¢ | 375¢ |
pub fn estimate_cost_in_usd_cents(&self) -> u64 {
let credits = self.estimate_credits();
(credits * 100.0).round() as u64
}
}
impl std::fmt::Debug for Seedance2p0TextToVideoArgs<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Seedance2p0TextToVideoArgs")
.field("prompt", &self.prompt)
.field("aspect_ratio", &self.aspect_ratio)
.field("duration", &self.duration)
.field("quality", &self.quality)
.finish()
}
}
#[derive(Debug)]
pub struct Seedance2p0TextToVideoResponse {
/// The request ID used to poll for results.
pub request_id: RequestId,
}
// --- Implementation ---
pub async fn seedance_2p0_text_to_video(
args: Seedance2p0TextToVideoArgs<'_>,
) -> Result<Seedance2p0TextToVideoResponse, MuapiError> {
info!("Submitting Seedance 2.0 t2v task to Muapi: {:?}", args);
let request_body = Seedance2p0T2vRequest {
prompt: args.prompt,
aspect_ratio: args.aspect_ratio.as_str(),
duration: args.duration.as_u8(),
quality: args.quality.as_str(),
};
info!("Muapi request body: {:?}", request_body);
let api_key = args.session.api_key.as_str();
let client = Client::builder()
.build()
.map_err(|err| MuapiClientError::WreqClientError(err))?;
let response = client.post(SEEDANCE_2P0_T2V_URL)
.header("Content-Type", "application/json")
.header("x-api-key", api_key)
.json(&request_body)
.send()
.await
.map_err(|err| MuapiGenericApiError::WreqError(err))?;
let status = response.status();
let response_body = response.text()
.await
.map_err(|err| MuapiGenericApiError::WreqError(err))?;
info!("Muapi response status: {}, body: {}", status, response_body);
if !status.is_success() {
return Err(MuapiGenericApiError::UncategorizedBadResponseWithStatusAndBody {
status_code: status,
body: response_body,
}.into());
}
let parsed: Seedance2p0T2vResponse = serde_json::from_str(&response_body)
.map_err(|err| MuapiGenericApiError::SerdeResponseParseErrorWithBody(err, response_body))?;
Ok(Seedance2p0TextToVideoResponse {
request_id: RequestId::new(parsed.request_id),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::muapi_api_key::MuapiApiKey;
use crate::test_utils::get_test_api_key::get_test_api_key;
use crate::test_utils::setup_test_logging::setup_test_logging;
use errors::AnyhowResult;
use log::LevelFilter;
fn dummy_session() -> MuapiSession {
MuapiSession::new(MuapiApiKey::new(String::new()))
}
fn args_with(duration: Duration, quality: Quality) -> Seedance2p0TextToVideoArgs<'static> {
let session = Box::leak(Box::new(dummy_session()));
Seedance2p0TextToVideoArgs {
session,
prompt: String::new(),
aspect_ratio: AspectRatio::Landscape16x9,
duration,
quality,
}
}
#[test]
fn test_estimate_credits() {
// Basic
assert_eq!(args_with(Duration::FiveSeconds, Quality::Basic).estimate_credits(), 0.6);
assert_eq!(args_with(Duration::TenSeconds, Quality::Basic).estimate_credits(), 1.2);
assert_eq!(args_with(Duration::FifteenSeconds, Quality::Basic).estimate_credits(), 1.8);
// High
assert_eq!(args_with(Duration::FiveSeconds, Quality::High).estimate_credits(), 1.25);
assert_eq!(args_with(Duration::TenSeconds, Quality::High).estimate_credits(), 2.5);
assert_eq!(args_with(Duration::FifteenSeconds, Quality::High).estimate_credits(), 3.75);
}
#[test]
fn test_estimate_cost_in_usd_cents() {
// Basic
assert_eq!(args_with(Duration::FiveSeconds, Quality::Basic).estimate_cost_in_usd_cents(), 60);
assert_eq!(args_with(Duration::TenSeconds, Quality::Basic).estimate_cost_in_usd_cents(), 120);
assert_eq!(args_with(Duration::FifteenSeconds, Quality::Basic).estimate_cost_in_usd_cents(), 180);
// High
assert_eq!(args_with(Duration::FiveSeconds, Quality::High).estimate_cost_in_usd_cents(), 125);
assert_eq!(args_with(Duration::TenSeconds, Quality::High).estimate_cost_in_usd_cents(), 250);
assert_eq!(args_with(Duration::FifteenSeconds, Quality::High).estimate_cost_in_usd_cents(), 375);
}
#[tokio::test]
#[ignore] // manually test — requires real API key
async fn test_seedance_2p0_text_to_video_landscape() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = get_test_api_key()?;
let args = Seedance2p0TextToVideoArgs {
session: &session,
prompt: "A corgi walks past a lighthouse on the shore. In the background, mossy rocks and rolling green hills dot the landscape. The sand is black, and the air is full of fog and mist".to_string(),
aspect_ratio: AspectRatio::Landscape16x9,
duration: Duration::FiveSeconds,
quality: Quality::High,
};
let result = seedance_2p0_text_to_video(args).await?;
println!("Result: {:?}", result);
println!("Request ID: {}", result.request_id);
assert!(!result.request_id.as_str().is_empty());
assert_eq!(1, 2); // NB: Intentional failure to inspect output.
Ok(())
}
#[tokio::test]
#[ignore] // manually test — requires real API key
async fn test_seedance_2p0_text_to_video_portrait() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = get_test_api_key()?;
let args = Seedance2p0TextToVideoArgs {
session: &session,
prompt: "A cat sits on a windowsill watching rain fall outside. Lightning flashes and the cat's eyes widen.".to_string(),
aspect_ratio: AspectRatio::Portrait9x16,
duration: Duration::TenSeconds,
quality: Quality::Basic,
};
let result = seedance_2p0_text_to_video(args).await?;
println!("Result: {:?}", result);
println!("Request ID: {}", result.request_id);
assert!(!result.request_id.as_str().is_empty());
assert_eq!(1, 2); // NB: Intentional failure to inspect output.
Ok(())
}
}
@@ -0,0 +1,10 @@
use crate::creds::muapi_session::MuapiSession;
use errors::AnyhowResult;
use std::fs::read_to_string;
#[cfg(test)]
pub fn get_test_api_key() -> AnyhowResult<MuapiSession> {
let api_key = read_to_string("/Users/bt/Artcraft/credentials/muapi_api_key.txt")?;
let api_key = api_key.trim().to_string();
Ok(MuapiSession::from_api_key_string(api_key))
}

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