Compare commits

...
Author SHA1 Message Date
Brandon Thomas d61f77caff use new artcraft_router in artcraft for seedance 2026-02-24 20:24:16 +07:00
Brandon Thomas 5debd4bc55 tests 2026-02-24 17:40:55 +07:00
Brandon Thomas 2a5f9eded8 update tests 2026-02-23 23:24:16 +07:00
Brandon Thomas d73ddfa5ad fixup artcraft client (NEEDS TESTING) 2026-02-23 20:07:13 +07:00
Brandon Thomas 11c25fc78a tests 2026-02-23 17:30:58 +07:00
Brandon Thomas 5903dc77a3 rename 2026-02-23 16:54:36 +07:00
Brandon Thomas 92722db7ac fix 2026-02-23 16:52:40 +07:00
Brandon Thomas ca33c6ccbf refactor to planning 2026-02-23 16:49:53 +07:00
Brandon Thomas 81ed860e49 refactor 2026-02-23 16:46:30 +07:00
Brandon Thomas 364a7e4e9c refactor 2026-02-23 16:33:53 +07:00
Brandon Thomas 4704d57e17 rename client 2026-02-23 16:27:16 +07:00
Brandon Thomas 2833c2a05b cleanup 2026-02-23 16:23:01 +07:00
Brandon Thomas 22d0631280 planning 2026-02-23 16:10:11 +07:00
Brandon Thomas 9949178a4b imgref 2026-02-23 15:08:32 +07:00
Brandon Thomas 1e000e8163 refactor 2026-02-23 14:50:00 +07:00
Brandon Thomas 29b8d9eea8 mitigation strategy 2026-02-23 14:47:25 +07:00
Brandon Thomas f460e9d21f wip 2026-02-23 14:26:23 +07:00
Brandon Thomas c02f99e98d wip 2026-02-23 14:20:10 +07:00
Brandon Thomas 9b08cf77b8 wip 2026-02-23 14:09:13 +07:00
Brandon Thomas 95a678f574 wip 2026-02-23 13:52:43 +07:00
Brandon Thomas 8553ff9366 wip 2026-02-23 13:44:37 +07:00
Brandon Thomas e23d1281a0 wip 2026-02-23 13:37:58 +07:00
Brandon Thomas 896812c5ed wip 2026-02-23 10:05:32 +07:00
Brandon Thomas a437b2dc60 Router update 2026-02-22 20:30:43 +07:00
Brandon Thomas c39d28b5b3 wip 2026-02-22 20:00:32 +07:00
Brandon Thomas 2a90699382 cargo 2026-02-22 17:54:23 +07:00
Brandon Thomas 8d5d9f8991 new crate 2026-02-22 17:53:46 +07:00
45 changed files with 1109 additions and 56 deletions
Generated
+17
View File
@@ -681,6 +681,7 @@ version = "0.7.0"
dependencies = [
"anyhow",
"artcraft_api_defs",
"artcraft_router",
"base64 0.22.1",
"build_metadata",
"bytes",
@@ -749,6 +750,22 @@ dependencies = [
"workspace-build-acceleration",
]
[[package]]
name = "artcraft_router"
version = "0.0.1"
dependencies = [
"anyhow",
"artcraft_api_defs",
"log",
"serde",
"serde_derive",
"storyteller_client",
"tokens",
"tokio",
"uuid 1.18.1",
"workspace-build-acceleration",
]
[[package]]
name = "ashpd"
version = "0.9.2"
+2
View File
@@ -72,6 +72,7 @@ members = [
"crates/lib/files/videos",
"crates/lib/http_headers",
"crates/lib/idempotency",
"crates/lib/artcraft_router",
"crates/lib/image_algorithms",
"crates/lib/jobs_common",
"crates/lib/jwt_light",
@@ -207,6 +208,7 @@ workspace-build-acceleration = { path = "crates/workspace-build-acceleration" }
# Setting paths here ensures we don't need to do so in every crate's Cargo.toml
# It makes refactoring much easier.
actix_artcraft = { path = "crates/lib/actix_artcraft" }
artcraft_router = { path = "crates/lib/artcraft_router" }
actix_helpers = { path = "crates/lib/actix_helpers" }
artcraft_api_defs = { path = "crates/api_clients/artcraft/artcraft_api_defs" }
build_metadata = { path = "crates/lib/build_metadata" }
@@ -33,7 +33,7 @@ pub struct Seedance2p0MultiFunctionVideoGenRequest {
pub batch_count: Option<Seedance2p0BatchCount>,
}
#[derive(Serialize, Deserialize, ToSchema)]
#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum Seedance2p0AspectRatio {
/// 16:9 landscape (1280x720)
@@ -48,7 +48,7 @@ pub enum Seedance2p0AspectRatio {
Portrait3x4,
}
#[derive(Serialize, Deserialize, ToSchema)]
#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum Seedance2p0BatchCount {
One,
@@ -1,3 +1,4 @@
pub mod parse_multi_cookie_header;
pub mod storyteller_avt_cookie;
pub mod storyteller_credential_set;
pub mod storyteller_session_cookie;
@@ -0,0 +1,35 @@
use crate::credentials::storyteller_avt_cookie::StorytellerAvtCookie;
use crate::credentials::storyteller_credential_set::StorytellerCredentialSet;
use crate::credentials::storyteller_session_cookie::StorytellerSessionCookie;
use cookie::Cookie;
/// Parses a multi-cookie header string (e.g. `"visitor=abc; session=xyz"`) and extracts
/// the known Storyteller cookies by name.
///
/// Returns `Ok(Some(...))` if at least one recognised cookie is present,
/// `Ok(None)` if the input is empty or contains no recognised cookies,
/// and `Err` if any individual cookie segment fails to parse.
pub fn parse_multi_cookie_header(header: &str) -> Result<Option<StorytellerCredentialSet>, cookie::ParseError> {
let mut avt: Option<StorytellerAvtCookie> = None;
let mut session: Option<StorytellerSessionCookie> = None;
for segment in header.split(';') {
let segment = segment.trim();
if segment.is_empty() {
continue;
}
let cookie = Cookie::parse(segment.to_owned())?;
if avt.is_none() {
avt = StorytellerAvtCookie::maybe_from_cookie(&cookie);
}
if session.is_none() {
session = StorytellerSessionCookie::maybe_from_cookie(&cookie);
}
}
if avt.is_none() && session.is_none() {
return Ok(None);
}
Ok(Some(StorytellerCredentialSet::initialize(avt, session)))
}
@@ -1,3 +1,4 @@
use crate::credentials::parse_multi_cookie_header::parse_multi_cookie_header;
use crate::credentials::storyteller_avt_cookie::StorytellerAvtCookie;
use crate::credentials::storyteller_session_cookie::StorytellerSessionCookie;
@@ -25,6 +26,10 @@ impl StorytellerCredentialSet {
}
}
pub fn parse_multi_cookie_header(header: &str) -> Result<Option<Self>, cookie::ParseError> {
parse_multi_cookie_header(header)
}
pub fn initialize_with_just_cookie(session: StorytellerSessionCookie) -> Self {
Self {
avt: None,
@@ -18,11 +18,9 @@ use enums::no_table::style_transfer::style_transfer_name::StyleTransferName;
use enums_public::by_table::model_weights::public_weights_types::PublicWeightsType;
use idempotency::uuid::generate_random_uuid;
use log::debug;
use reqwest::multipart::Form;
use reqwest::multipart::{Form, Part};
use reqwest::Client;
use serde_derive::{Deserialize, Serialize};
use std::fs::File;
use std::io::Read;
use std::path::Path;
use tokens::tokens::batch_generations::BatchGenerationToken;
use tokens::tokens::media_files::MediaFileToken;
@@ -73,11 +71,13 @@ pub async fn legacy_upload_media_file_from_file<P: AsRef<Path>>(
.build()
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
let file_bytes = std::fs::read(args.path.as_ref())
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
let file_name = args.path.as_ref().file_name()
.and_then(|n| n.to_str()).unwrap_or("file").to_string();
let mut form = Form::new()
.text("uuid_idempotency_token", generate_random_uuid())
.file("file", args.path)
.await
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
.part("file", Part::bytes(file_bytes).file_name(file_name));
//if args.is_intermediate_system_file {
// form = form.text("is_intermediate_system_file", "true");
@@ -18,11 +18,9 @@ use enums::no_table::style_transfer::style_transfer_name::StyleTransferName;
use enums_public::by_table::model_weights::public_weights_types::PublicWeightsType;
use idempotency::uuid::generate_random_uuid;
use log::debug;
use reqwest::multipart::Form;
use reqwest::multipart::{Form, Part};
use reqwest::Client;
use serde_derive::{Deserialize, Serialize};
use std::fs::File;
use std::io::Read;
use std::path::Path;
use tokens::tokens::batch_generations::BatchGenerationToken;
use tokens::tokens::media_files::MediaFileToken;
@@ -73,11 +71,13 @@ pub async fn upload_image_media_file_from_file<P: AsRef<Path>>(
.build()
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
let file_bytes = std::fs::read(args.path.as_ref())
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
let file_name = args.path.as_ref().file_name()
.and_then(|n| n.to_str()).unwrap_or("file").to_string();
let mut form = Form::new()
.text("uuid_idempotency_token", generate_random_uuid())
.file("file", args.path)
.await
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
.part("file", Part::bytes(file_bytes).file_name(file_name));
if args.is_intermediate_system_file {
form = form.text("is_intermediate_system_file", "true");
@@ -15,11 +15,9 @@ use enums::no_table::style_transfer::style_transfer_name::StyleTransferName;
use enums_public::by_table::model_weights::public_weights_types::PublicWeightsType;
use idempotency::uuid::generate_random_uuid;
use log::debug;
use reqwest::multipart::Form;
use reqwest::multipart::{Form, Part};
use reqwest::Client;
use serde_derive::{Deserialize, Serialize};
use std::fs::File;
use std::io::Read;
use std::path::Path;
use tokens::tokens::batch_generations::BatchGenerationToken;
use tokens::tokens::media_files::MediaFileToken;
@@ -44,11 +42,13 @@ pub async fn upload_new_engine_asset_from_file<P: AsRef<Path>>(
.gzip(true)
.build()?;
let file_bytes = std::fs::read(path.as_ref())?;
let file_name = path.as_ref().file_name()
.and_then(|n| n.to_str()).unwrap_or("file").to_string();
let form = Form::new()
.text("uuid_idempotency_token", generate_random_uuid())
.text("engine_category", "object") // TODO: Which type should we use?
.file("file", path)
.await?;
.text("engine_category", "object") // TODO: Which type should we use?
.part("file", Part::bytes(file_bytes).file_name(file_name));
let mut request_builder = client.post(url)
.header("User-Agent", USER_AGENT)
@@ -15,11 +15,9 @@ use enums::no_table::style_transfer::style_transfer_name::StyleTransferName;
use enums_public::by_table::model_weights::public_weights_types::PublicWeightsType;
use idempotency::uuid::generate_random_uuid;
use log::debug;
use reqwest::multipart::Form;
use reqwest::multipart::{Form, Part};
use reqwest::Client;
use serde_derive::{Deserialize, Serialize};
use std::fs::File;
use std::io::Read;
use std::path::Path;
use tokens::tokens::batch_generations::BatchGenerationToken;
use tokens::tokens::media_files::MediaFileToken;
@@ -74,10 +72,12 @@ pub async fn upload_video_media_file_from_file<P: AsRef<Path>>(
.gzip(true)
.build()?;
let file_bytes = std::fs::read(args.path.as_ref())?;
let file_name = args.path.as_ref().file_name()
.and_then(|n| n.to_str()).unwrap_or("file").to_string();
let mut form = Form::new()
.text("uuid_idempotency_token", generate_random_uuid())
.file("file", args.path)
.await?;
.part("file", Part::bytes(file_bytes).file_name(file_name));
if let Some(prompt_token) = &args.maybe_prompt_token {
form = form.text("maybe_prompt_token", prompt_token.to_string());
+1
View File
@@ -27,6 +27,7 @@ workspace-build-acceleration.workspace = true
# Internal
artcraft_api_defs.workspace = true
artcraft_router.workspace = true
build_metadata.workspace = true
cookie_store.workspace = true
enums.workspace = true
@@ -1,5 +1,7 @@
use crate::core::artcraft_error::ArtcraftError;
use anyhow::anyhow;
use artcraft_router::errors::artcraft_router_error::ArtcraftRouterError;
use artcraft_router::errors::provider_error::ProviderError;
use base64::DecodeError;
use enums::common::generation_provider::GenerationProvider;
use enums::common::model_type::ModelType;
@@ -211,3 +213,16 @@ impl From<WorldLabsError> for GenerateError {
Self::ProviderFailure(ProviderFailureReason::WorldLabsError(value))
}
}
impl From<ArtcraftRouterError> for GenerateError {
fn from(value: ArtcraftRouterError) -> Self {
match value {
ArtcraftRouterError::Client(e) => Self::BadInput(BadInputReason::WrongImageArguments(e.to_string())),
ArtcraftRouterError::UnsupportedModel(model) => Self::NotYetImplemented(format!("Unsupported model: {}", model)),
ArtcraftRouterError::InvalidInput(msg) => Self::BadInput(BadInputReason::WrongImageArguments(msg)),
ArtcraftRouterError::Provider(ProviderError::Storyteller(e)) => {
Self::ProviderFailure(ProviderFailureReason::StorytellerError(e))
}
}
}
}
@@ -1,51 +1,59 @@
use crate::core::commands::enqueue::generate_error::GenerateError;
use crate::core::commands::enqueue::image_to_video::enqueue_image_to_video_command::{EnqueueImageToVideoRequest, SoraOrientation};
use crate::core::commands::enqueue::image_to_video::enqueue_image_to_video_command::EnqueueImageToVideoRequest;
use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
use crate::core::events::generation_events::common::{GenerationModel};
use crate::core::events::generation_events::common::GenerationModel;
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::{Seedance2p0AspectRatio, Seedance2p0MultiFunctionVideoGenRequest};
use artcraft_router::api::common_video_model::CommonVideoModel;
use artcraft_router::api::image_list_ref::ImageListRef;
use artcraft_router::api::image_ref::ImageRef;
use artcraft_router::api::provider::Provider;
use artcraft_router::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use artcraft_router::client::router_artcraft_client::RouterArtcraftClient;
use artcraft_router::client::router_client::RouterClient;
use artcraft_router::generate::generate_video::begin_video_generation::begin_video_generation;
use artcraft_router::generate::generate_video::generate_video_request::GenerateVideoRequest;
use enums::common::generation_provider::GenerationProvider;
use enums::tauri::tasks::task_type::TaskType;
use idempotency::uuid::generate_random_uuid;
use log::{error, info};
use storyteller_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use storyteller_client::endpoints::generate::video::multi_function::seedance_2p0_multi_function_video_gen::seedance_2p0_multi_function_video_gen;
pub (super) async fn handle_artcraft_seedance_2p0(
pub(super) async fn handle_artcraft_seedance_2p0(
request: &EnqueueImageToVideoRequest,
app_env_configs: &AppEnvConfigs,
creds: &StorytellerCredentialSet,
) -> Result<TaskEnqueueSuccess, GenerateError> {
let client = RouterClient::Artcraft(RouterArtcraftClient::new(
app_env_configs.storyteller_host.clone(),
creds.clone(),
));
let uuid_idempotency_token = generate_random_uuid();
let start_frame = request.image_media_token.as_ref().map(ImageRef::MediaFileToken);
let end_frame = request.end_frame_image_media_token.as_ref().map(ImageRef::MediaFileToken);
let reference_images = request.reference_image_media_tokens.as_ref().map(ImageListRef::MediaFileTokens);
let aspect_ratio = match request.sora_orientation {
Some(SoraOrientation::Portrait) => Seedance2p0AspectRatio::Portrait9x16,
Some(SoraOrientation::Landscape) => Seedance2p0AspectRatio::Landscape16x9,
None => Seedance2p0AspectRatio::Landscape16x9,
let router_request = GenerateVideoRequest {
model: CommonVideoModel::Seedance2p0,
provider: Provider::Artcraft,
prompt: request.prompt.as_deref(),
start_frame,
end_frame,
reference_images,
resolution: None,
aspect_ratio: request.aspect_ratio,
duration_seconds: request.duration_seconds,
video_batch_count: request.video_batch_count,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
idempotency_token: None,
};
let request = Seedance2p0MultiFunctionVideoGenRequest {
uuid_idempotency_token,
prompt: request.prompt.clone(),
start_frame_media_token: request.image_media_token.clone(),
end_frame_media_token: request.end_frame_image_media_token.clone(),
reference_image_media_tokens: None,
aspect_ratio: Some(aspect_ratio),
duration_seconds: None, // TODO: Parameterize
batch_count: None,
};
let plan = begin_video_generation(&router_request)?;
info!("Video Generation Plan: {:?}", plan);
let result = seedance_2p0_multi_function_video_gen(
&app_env_configs.storyteller_host,
Some(&creds),
request,
).await;
let job_token = match result {
Ok(enqueued) => {
let response = match plan.generate_video(&client).await {
Ok(resp) => {
info!("Successfully enqueued.");
enqueued.inference_job_token
resp
}
Err(err) => {
error!("Failed to enqueue: {:?}", err);
@@ -57,6 +65,6 @@ pub (super) async fn handle_artcraft_seedance_2p0(
task_type: TaskType::VideoGeneration,
model: Some(GenerationModel::Seedance2p0),
provider: GenerationProvider::Artcraft,
provider_job_id: Some(job_token.to_string()),
provider_job_id: Some(response.inference_job_token.to_string()),
})
}
@@ -22,6 +22,7 @@ use crate::services::grok::state::grok_credential_manager::GrokCredentialManager
use crate::services::sora::state::sora_credential_manager::SoraCredentialManager;
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use artcraft_router::api::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation_provider::GenerationProvider;
use enums::tauri::ux::tauri_command_caller::TauriCommandCaller;
use log::{error, info, warn};
@@ -101,6 +102,10 @@ pub struct EnqueueImageToVideoRequest {
/// TODO: In the future we may support base64 images, URLs, or file paths here.
pub end_frame_image_media_token: Option<MediaFileToken>,
/// OPTIONAL.
/// Reference images for the video generation (e.g. character consistency, props, etc.)
pub reference_image_media_tokens: Option<Vec<MediaFileToken>>,
/// Optional.
/// Text prompt used to direct the video.
pub prompt: Option<String>,
@@ -110,6 +115,18 @@ pub struct EnqueueImageToVideoRequest {
/// Typically, this costs money when presented as an option, so we default to off.
pub generate_audio: Option<bool>,
/// OPTIONAL.
/// Aspect ratio for the generated video.
pub aspect_ratio: Option<CommonAspectRatio>,
/// OPTIONAL.
/// Duration of the generated video in seconds.
pub duration_seconds: Option<u16>,
/// OPTIONAL.
/// Number of videos to generate in one batch.
pub video_batch_count: Option<u16>,
/// OPTIONAL.
/// Only for Sora2 model currently.
pub sora_orientation: Option<SoraOrientation>,
+29
View File
@@ -0,0 +1,29 @@
[package]
name = "artcraft_router"
edition = "2024"
version = "0.0.1"
publish = false
[package.metadata.cargo-udeps.ignore]
normal = ["workspace-build-acceleration"]
[lib]
name = "artcraft_router"
path = "src/lib.rs"
[dependencies]
# cargo-hakari workspace hack (faster compile times)
workspace-build-acceleration.workspace = true
# Internal
artcraft_api_defs.workspace = true
storyteller_client.workspace = true
tokens.workspace = true
# External
anyhow.workspace = true
log.workspace = true
serde.workspace = true
serde_derive.workspace = true
tokio.workspace = true
uuid.workspace = true
@@ -0,0 +1,42 @@
use serde_derive::{Deserialize, Serialize};
/// This is a comprehensive list of common aspect ratios you can specify when enqueuing a generation.
/// Not every model will support every aspect ratio.
/// In the case a model doesn't support the aspect ratio, gracefully pick the nearest option.
#[derive(Copy, Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CommonAspectRatio {
// Auto (eg. for image editing to use the source; used by Nano Banana Pro edit image, but not text-to-image)
Auto,
// Square
Square,
// Wide
WideThreeByTwo,
WideFourByThree,
WideFiveByFour,
WideSixteenByNine,
WideTwentyOneByNine,
// Tall
TallTwoByThree,
TallThreeByFour,
TallFourByFive,
TallNineBySixteen,
TallNineByTwentyOne,
// Imprecise semantic values that we probably remap to other meanings
// on a model-by-model basis.
Wide,
Tall,
// Auto values that bake in resolution
// These are from the Seedream models
Auto2k,
Auto4k,
// Defined aspect ratios that bake in resolution
// These are from the Seedream models
SquareHd,
}
@@ -0,0 +1,17 @@
use serde_derive::{Deserialize, Serialize};
/// This is a comprehensive list of common resolutions you can specify when enqueuing a generation.
/// Not every model will support every resolution.
/// In the case a model doesn't support the resolution, gracefully pick the nearest option.
#[derive(Copy, Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CommonVideoResolution {
/// Models: Nano Banana Pro
OneK,
/// Models: Nano Banana Pro
TwoK,
/// Models: (None)
ThreeK,
/// Models: Nano Banana Pro
FourK,
}
@@ -0,0 +1,52 @@
use serde_derive::{Deserialize, Serialize};
/// Common video models supported by the router.
/// Not all models are available through all providers.
#[derive(Copy, Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CommonVideoModel {
#[serde(rename = "grok_video")]
GrokVideo,
#[serde(rename = "kling_1p6_pro")]
Kling16Pro,
#[serde(rename = "kling_2p1_pro")]
Kling21Pro,
#[serde(rename = "kling_2p1_master")]
Kling21Master,
#[serde(rename = "kling_2p5_turbo_pro")]
Kling2p5TurboPro,
#[serde(rename = "kling_2p6_pro")]
Kling2p6Pro,
#[serde(rename = "seedance_1p0_lite")]
Seedance10Lite,
#[serde(rename = "seedance_2p0")]
Seedance2p0,
#[serde(rename = "sora_2")]
Sora2,
#[serde(rename = "sora_2_pro")]
Sora2Pro,
#[serde(rename = "veo_2")]
Veo2,
#[serde(rename = "veo_3")]
Veo3,
#[serde(rename = "veo_3_fast")]
Veo3Fast,
#[serde(rename = "veo_3p1")]
Veo3p1,
#[serde(rename = "veo_3p1_fast")]
Veo3p1Fast,
}
@@ -0,0 +1,7 @@
use tokens::tokens::media_files::MediaFileToken;
#[derive(Copy, Clone)]
pub enum ImageListRef<'a> {
MediaFileTokens(&'a Vec<MediaFileToken>),
Urls(&'a Vec<String>),
}
@@ -0,0 +1,7 @@
use tokens::tokens::media_files::MediaFileToken;
#[derive(Copy, Clone)]
pub enum ImageRef<'a> {
MediaFileToken(&'a MediaFileToken),
Url(&'a str),
}
@@ -0,0 +1,6 @@
pub mod common_aspect_ratio;
pub mod common_resolution;
pub mod common_video_model;
pub mod image_list_ref;
pub mod image_ref;
pub mod provider;
@@ -0,0 +1,8 @@
use serde_derive::{Deserialize, Serialize};
/// The provider to route a generation request to.
#[derive(Copy, Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Provider {
Artcraft,
}
@@ -0,0 +1,5 @@
pub mod multi_router_client;
pub mod multi_router_client_builder;
pub mod request_mismatch_mitigation_strategy;
pub mod router_artcraft_client;
pub mod router_client;
@@ -0,0 +1,13 @@
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::errors::client_error::{ClientError, ClientType};
pub struct MultiRouterClient {
pub(crate) artcraft_client: Option<RouterArtcraftClient>,
}
impl MultiRouterClient {
pub fn get_artcraft_client_ref(&self) -> Result<&RouterArtcraftClient, ClientError> {
self.artcraft_client.as_ref()
.ok_or(ClientError::ClientNotConfigured(ClientType::Artcraft))
}
}
@@ -0,0 +1,25 @@
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::client::multi_router_client::MultiRouterClient;
pub struct MultiRouterClientBuilder {
artcraft_client: Option<RouterArtcraftClient>,
}
impl MultiRouterClientBuilder {
pub fn new() -> Self {
Self {
artcraft_client: None,
}
}
pub fn set_artcraft_client(mut self, client: RouterArtcraftClient) -> Self {
self.artcraft_client = Some(client);
self
}
pub fn build(self) -> MultiRouterClient {
MultiRouterClient {
artcraft_client: self.artcraft_client,
}
}
}
@@ -0,0 +1,12 @@
#[derive(Copy, Clone, Debug)]
pub enum RequestMismatchMitigationStrategy {
/// Upgrade the request to pay more.
PayMoreUpgrade,
/// Downgrade the request to pay less
PayLessDowngrade,
/// Fail the request.
ErrorOut,
}
@@ -0,0 +1,13 @@
use storyteller_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use storyteller_client::utils::api_host::ApiHost;
pub struct RouterArtcraftClient {
pub(crate) api_host: ApiHost,
pub(crate) credentials: StorytellerCredentialSet,
}
impl RouterArtcraftClient {
pub fn new(api_host: ApiHost, credentials: StorytellerCredentialSet) -> Self {
Self { api_host, credentials }
}
}
@@ -0,0 +1,17 @@
use crate::client::multi_router_client::MultiRouterClient;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::errors::client_error::ClientError;
pub enum RouterClient {
Multi(MultiRouterClient),
Artcraft(RouterArtcraftClient),
}
impl RouterClient {
pub fn get_artcraft_client_ref(&self) -> Result<&RouterArtcraftClient, ClientError> {
match self {
RouterClient::Artcraft(client) => Ok(client),
RouterClient::Multi(multi) => multi.get_artcraft_client_ref(),
}
}
}
@@ -0,0 +1,44 @@
use crate::errors::client_error::ClientError;
use crate::errors::provider_error::ProviderError;
use std::error::Error;
use std::fmt::{Display, Formatter};
#[derive(Debug)]
pub enum ArtcraftRouterError {
/// A client configuration error.
Client(ClientError),
/// The requested model is not yet supported by the router.
UnsupportedModel(String),
/// Invalid or missing input arguments.
InvalidInput(String),
/// An error from an underlying provider.
Provider(ProviderError),
}
impl Error for ArtcraftRouterError {}
impl Display for ArtcraftRouterError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::Client(e) => write!(f, "Client 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),
}
}
}
impl From<ClientError> for ArtcraftRouterError {
fn from(error: ClientError) -> Self {
Self::Client(error)
}
}
impl From<ProviderError> for ArtcraftRouterError {
fn from(error: ProviderError) -> Self {
Self::Provider(error)
}
}
@@ -0,0 +1,52 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
#[derive(Debug, Clone, Copy)]
pub enum ClientType {
Artcraft,
}
impl Display for ClientType {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::Artcraft => write!(f, "Artcraft"),
}
}
}
#[derive(Debug)]
pub enum ClientError {
/// The requested client is not configured on the RouterClient.
ClientNotConfigured(ClientType),
/// The model does not support the given option value.
/// `field` is the request field name, `value` is what was requested.
ModelDoesNotSupportOption { field: &'static str, value: String },
/// The caller requested zero generations, which is never valid.
UserRequestedZeroGenerations,
/// ArtCraft only accepts media tokens for image inputs, not raw URLs.
ArtcraftOnlySupportsMediaTokens,
}
impl Error for ClientError {}
impl Display for ClientError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::ClientNotConfigured(client_type) => {
write!(f, "{} client is not configured on the RouterClient", client_type)
}
Self::ModelDoesNotSupportOption { field, value } => {
write!(f, "Model does not support '{}' for field '{}'", value, field)
}
Self::UserRequestedZeroGenerations => {
write!(f, "Cannot request zero generations")
}
Self::ArtcraftOnlySupportsMediaTokens => {
write!(f, "ArtCraft only supports media tokens for image inputs; upload the image first to obtain a media token")
}
}
}
}
@@ -0,0 +1,3 @@
pub mod artcraft_router_error;
pub mod client_error;
pub mod provider_error;
@@ -0,0 +1,24 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
use storyteller_client::error::storyteller_error::StorytellerError;
#[derive(Debug)]
pub enum ProviderError {
Storyteller(StorytellerError),
}
impl Error for ProviderError {}
impl Display for ProviderError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::Storyteller(e) => write!(f, "Storyteller provider error: {}", e),
}
}
}
impl From<StorytellerError> for ProviderError {
fn from(error: StorytellerError) -> Self {
Self::Storyteller(error)
}
}
@@ -0,0 +1,20 @@
use crate::api::common_video_model::CommonVideoModel;
use crate::api::provider::Provider;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_seedance2p0::plan_generate_video_artcraft_seedance2p0;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
/// Read the video generation request, construct a plan, then yield a means to execute it.
pub fn begin_video_generation<'a>(
request: &'a GenerateVideoRequest<'a>,
) -> Result<VideoGenerationPlan<'a>, ArtcraftRouterError> {
match request.provider {
Provider::Artcraft => match request.model {
CommonVideoModel::Seedance2p0 => {
plan_generate_video_artcraft_seedance2p0(request).map(VideoGenerationPlan::ArtcraftSeedance2p0)
}
_ => Err(ArtcraftRouterError::UnsupportedModel(format!("{:?}", request.model))),
},
}
}
@@ -0,0 +1,36 @@
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_seedance2p0::PlanArtcraftSeedance2p0;
use crate::generate::generate_video::video_generation_plan::GenerateVideoResponse;
use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::Seedance2p0MultiFunctionVideoGenRequest;
use storyteller_client::endpoints::generate::video::multi_function::seedance_2p0_multi_function_video_gen::seedance_2p0_multi_function_video_gen;
pub async fn execute_artcraft_seedance2p0(
plan: &PlanArtcraftSeedance2p0<'_>,
artcraft_client: &RouterArtcraftClient,
) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let request = Seedance2p0MultiFunctionVideoGenRequest {
uuid_idempotency_token: plan.idempotency_token.clone(),
prompt: plan.prompt.map(|p| p.to_string()),
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()),
aspect_ratio: plan.aspect_ratio,
duration_seconds: plan.duration_seconds,
batch_count: Some(plan.batch_count),
};
let response = seedance_2p0_multi_function_video_gen(
&artcraft_client.api_host,
Some(&artcraft_client.credentials),
request,
)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Storyteller(err)))?;
Ok(GenerateVideoResponse {
inference_job_token: response.inference_job_token,
all_inference_job_tokens: response.all_inference_job_tokens,
})
}
@@ -0,0 +1 @@
pub mod generate_video_artcraft_seedance2p0;
@@ -0,0 +1 @@
pub mod artcraft;
@@ -0,0 +1,60 @@
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_resolution::CommonVideoResolution;
use crate::api::common_video_model::CommonVideoModel;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::provider::Provider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
pub struct GenerateVideoRequest<'a> {
/// Which model to use.
pub model: CommonVideoModel,
/// Which provider to use.
pub provider: Provider,
/// The prompt for the video generation
pub prompt: Option<&'a str>,
// /// Some models support negative prompts
// pub negative_prompt: Option<String>,
/// Starting keyframe (optional).
pub start_frame: Option<ImageRef<'a>>,
/// Ending keyframe (optional).
pub end_frame: Option<ImageRef<'a>>,
/// Reference images (optional).
pub reference_images: Option<ImageListRef<'a>>,
/// The resolution to use
pub resolution: Option<CommonVideoResolution>,
/// The aspect ratio to use
pub aspect_ratio: Option<CommonAspectRatio>,
/// How many seconds to generate.
pub duration_seconds: Option<u16>,
/// How many videos to generate.
pub video_batch_count: Option<u16>,
// /// Whether to turn on/off audio.
// /// Not all models support audio, not all models have a choice.
// pub generate_audio: Option<bool>,
/// If the request is a mismatch with the (model/provider), how to mitigate it.
pub request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy,
/// Some providers support idempotency.
/// If not supplied, we'll generate one for the required providers.
pub idempotency_token: Option<&'a str>,
}
impl <'a> GenerateVideoRequest<'a> {
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())
}
}
@@ -0,0 +1,5 @@
pub mod execute;
pub mod generate_video_request;
pub mod plan;
pub mod begin_video_generation;
pub mod video_generation_plan;
@@ -0,0 +1 @@
pub mod plan_generate_video_artcraft_seedance2p0;
@@ -0,0 +1,183 @@
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
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 artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::{
Seedance2p0AspectRatio, Seedance2p0BatchCount,
};
use tokens::tokens::media_files::MediaFileToken;
#[derive(Debug, Clone)]
pub struct PlanArtcraftSeedance2p0<'a> {
pub prompt: Option<&'a str>,
pub start_frame: Option<&'a MediaFileToken>,
pub end_frame: Option<&'a MediaFileToken>,
pub reference_images: Option<&'a Vec<MediaFileToken>>,
pub aspect_ratio: Option<Seedance2p0AspectRatio>,
pub duration_seconds: Option<u8>,
pub batch_count: Seedance2p0BatchCount,
pub idempotency_token: String,
}
pub fn plan_generate_video_artcraft_seedance2p0<'a>(
request: &'a GenerateVideoRequest<'a>,
) -> Result<PlanArtcraftSeedance2p0<'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 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 {
prompt: request.prompt,
start_frame,
end_frame,
reference_images,
aspect_ratio,
duration_seconds,
batch_count,
idempotency_token: request.get_or_generate_idempotency_token(),
})
}
fn resolve_image_ref<'a>(
image_ref: Option<ImageRef<'a>>,
) -> Result<Option<&'a MediaFileToken>, ArtcraftRouterError> {
match image_ref {
None => Ok(None),
Some(ImageRef::MediaFileToken(t)) => Ok(Some(t)),
Some(ImageRef::Url(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
}
}
}
fn resolve_image_list_ref<'a>(
image_list_ref: Option<ImageListRef<'a>>,
) -> Result<Option<&'a Vec<MediaFileToken>>, ArtcraftRouterError> {
match image_list_ref {
None => Ok(None),
Some(ImageListRef::MediaFileTokens(tokens)) => Ok(Some(tokens)),
Some(ImageListRef::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
//
// All supported ratios cost the same, so PayMoreUpgrade and PayLessDowngrade both
// select the nearest match rather than rounding in a specific direction.
fn plan_aspect_ratio(
aspect_ratio: Option<CommonAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<Seedance2p0AspectRatio>, ArtcraftRouterError> {
match aspect_ratio {
// No preference or auto — let the model decide
None
| Some(CommonAspectRatio::Auto)
| Some(CommonAspectRatio::Auto2k)
| Some(CommonAspectRatio::Auto4k) => Ok(None),
// Direct mappings
Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => {
Ok(Some(Seedance2p0AspectRatio::Landscape16x9))
}
Some(CommonAspectRatio::TallNineBySixteen) | Some(CommonAspectRatio::Tall) => {
Ok(Some(Seedance2p0AspectRatio::Portrait9x16))
}
Some(CommonAspectRatio::Square) | Some(CommonAspectRatio::SquareHd) => {
Ok(Some(Seedance2p0AspectRatio::Square1x1))
}
Some(CommonAspectRatio::WideFourByThree) => Ok(Some(Seedance2p0AspectRatio::Standard4x3)),
Some(CommonAspectRatio::TallThreeByFour) => Ok(Some(Seedance2p0AspectRatio::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(Some(nearest_aspect_ratio(unsupported)))
}
},
}
}
/// Pick the nearest supported aspect ratio by AR value (width / height).
/// All Seedance2p0 aspect ratios cost the same, so this is used for both upgrade and downgrade.
fn nearest_aspect_ratio(aspect_ratio: CommonAspectRatio) -> Seedance2p0AspectRatio {
match aspect_ratio {
CommonAspectRatio::WideFiveByFour => Seedance2p0AspectRatio::Standard4x3, // 1.25, nearest 1.33
CommonAspectRatio::WideThreeByTwo => Seedance2p0AspectRatio::Standard4x3, // 1.50, nearest 1.33
CommonAspectRatio::WideTwentyOneByNine => Seedance2p0AspectRatio::Landscape16x9, // 2.33, nearest 1.78
CommonAspectRatio::TallFourByFive => Seedance2p0AspectRatio::Portrait3x4, // 0.80, nearest 0.75
CommonAspectRatio::TallTwoByThree => Seedance2p0AspectRatio::Portrait3x4, // 0.67, nearest 0.75
CommonAspectRatio::TallNineByTwentyOne => Seedance2p0AspectRatio::Portrait9x16, // 0.43, nearest 0.56
_ => Seedance2p0AspectRatio::Square1x1,
}
}
// Seedance2p0 supports batch counts of 1, 2, and 4 only.
fn plan_batch_count(
video_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Seedance2p0BatchCount, ArtcraftRouterError> {
let count = video_batch_count.unwrap_or(1);
match count {
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 => Ok(Seedance2p0BatchCount::One),
2 => Ok(Seedance2p0BatchCount::Two),
4 => Ok(Seedance2p0BatchCount::Four),
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "video_batch_count",
value: format!("{}", count),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => {
// Round up: 3 → Four; anything > 4 clamps to Four
Ok(if count < 4 { Seedance2p0BatchCount::Four } else { Seedance2p0BatchCount::Four })
}
RequestMismatchMitigationStrategy::PayLessDowngrade => {
// Round down: 3 → Two; anything > 4 clamps to Four (max available)
Ok(if count < 4 { Seedance2p0BatchCount::Two } else { Seedance2p0BatchCount::Four })
}
},
}
}
// Seedance2p0 supports duration of 4–15 seconds.
fn plan_duration(
duration_seconds: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<u8>, ArtcraftRouterError> {
const MIN: u16 = 4;
const MAX: u16 = 15;
match duration_seconds {
None => Ok(None),
Some(d) if d >= MIN && d <= MAX => Ok(Some(d as u8)),
Some(d) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "duration_seconds",
value: format!("{}", d),
}))
}
_ => Ok(Some(d.clamp(MIN, MAX) as u8)),
},
}
}
@@ -0,0 +1 @@
pub mod artcraft;
@@ -0,0 +1,30 @@
use crate::client::router_client::RouterClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::execute::artcraft::generate_video_artcraft_seedance2p0::execute_artcraft_seedance2p0;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_seedance2p0::PlanArtcraftSeedance2p0;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
#[derive(Clone, Debug)]
pub struct GenerateVideoResponse {
pub inference_job_token: InferenceJobToken,
pub all_inference_job_tokens: Vec<InferenceJobToken>,
}
#[derive(Debug)]
pub enum VideoGenerationPlan<'a> {
ArtcraftSeedance2p0(PlanArtcraftSeedance2p0<'a>),
}
impl<'a> VideoGenerationPlan<'a> {
pub async fn generate_video(
&self,
client: &RouterClient,
) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
match self {
VideoGenerationPlan::ArtcraftSeedance2p0(plan) => {
let artcraft_client = client.get_artcraft_client_ref()?;
execute_artcraft_seedance2p0(plan, artcraft_client).await
}
}
}
}
@@ -0,0 +1 @@
pub mod generate_video;
+4
View File
@@ -0,0 +1,4 @@
pub mod api;
pub mod client;
pub mod errors;
pub mod generate;
@@ -0,0 +1,233 @@
use artcraft_router::api::common_aspect_ratio::CommonAspectRatio;
use artcraft_router::api::common_video_model::CommonVideoModel;
use artcraft_router::api::image_ref::ImageRef;
use artcraft_router::api::provider::Provider;
use artcraft_router::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use artcraft_router::client::router_artcraft_client::RouterArtcraftClient;
use artcraft_router::client::router_client::RouterClient;
use artcraft_router::errors::artcraft_router_error::ArtcraftRouterError;
use artcraft_router::errors::client_error::ClientError;
use artcraft_router::generate::generate_video::begin_video_generation::begin_video_generation;
use artcraft_router::generate::generate_video::generate_video_request::GenerateVideoRequest;
use artcraft_router::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::{
Seedance2p0AspectRatio, Seedance2p0BatchCount,
};
use storyteller_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use storyteller_client::credentials::storyteller_session_cookie::StorytellerSessionCookie;
use storyteller_client::utils::api_host::ApiHost;
fn get_artcraft_client() -> RouterClient {
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/artcraft_cookies.txt")
.expect("Failed to read /Users/bt/Artcraft/credentials/artcraft_cookies.txt");
let cookies = cookies.trim().to_string();
let credentials = StorytellerCredentialSet::parse_multi_cookie_header(&cookies)
.expect("Failed to parse cookies")
.expect("No credentials found");
RouterClient::Artcraft(RouterArtcraftClient::new(ApiHost::Storyteller, credentials))
}
fn base_request() -> GenerateVideoRequest<'static> {
GenerateVideoRequest {
model: CommonVideoModel::Seedance2p0,
provider: Provider::Artcraft,
prompt: Some("a cat in space"),
start_frame: None,
end_frame: None,
reference_images: None,
resolution: None,
aspect_ratio: None,
duration_seconds: None,
video_batch_count: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
idempotency_token: None,
}
}
mod plan_tests {
use super::*;
#[test]
fn aspect_ratio_direct_16x9() {
let request = GenerateVideoRequest {
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..base_request()
};
let VideoGenerationPlan::ArtcraftSeedance2p0(plan) = begin_video_generation(&request).unwrap();
assert!(matches!(plan.aspect_ratio, Some(Seedance2p0AspectRatio::Landscape16x9)));
}
#[test]
fn aspect_ratio_direct_9x16() {
let request = GenerateVideoRequest {
aspect_ratio: Some(CommonAspectRatio::TallNineBySixteen),
..base_request()
};
let VideoGenerationPlan::ArtcraftSeedance2p0(plan) = begin_video_generation(&request).unwrap();
assert!(matches!(plan.aspect_ratio, Some(Seedance2p0AspectRatio::Portrait9x16)));
}
#[test]
fn aspect_ratio_direct_square() {
let request = GenerateVideoRequest {
aspect_ratio: Some(CommonAspectRatio::Square),
..base_request()
};
let VideoGenerationPlan::ArtcraftSeedance2p0(plan) = begin_video_generation(&request).unwrap();
assert!(matches!(plan.aspect_ratio, Some(Seedance2p0AspectRatio::Square1x1)));
}
#[test]
fn aspect_ratio_nearest_match_both_strategies() {
// WideThreeByTwo (AR 1.5) is nearest to Standard4x3 (1.33), not Landscape16x9 (1.78)
for strategy in [
RequestMismatchMitigationStrategy::PayMoreUpgrade,
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let request = GenerateVideoRequest {
aspect_ratio: Some(CommonAspectRatio::WideThreeByTwo),
request_mismatch_mitigation_strategy: strategy,
..base_request()
};
let VideoGenerationPlan::ArtcraftSeedance2p0(plan) = begin_video_generation(&request).unwrap();
assert!(
matches!(plan.aspect_ratio, Some(Seedance2p0AspectRatio::Standard4x3)),
"Expected Standard4x3 for WideThreeByTwo with {:?}", strategy,
);
}
}
#[test]
fn aspect_ratio_error_out_on_unsupported() {
let request = GenerateVideoRequest {
aspect_ratio: Some(CommonAspectRatio::WideThreeByTwo),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..base_request()
};
let result = begin_video_generation(&request);
assert!(matches!(
result,
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { .. }))
));
}
#[test]
fn batch_count_zero_is_always_error() {
for strategy in [
RequestMismatchMitigationStrategy::ErrorOut,
RequestMismatchMitigationStrategy::PayMoreUpgrade,
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let request = GenerateVideoRequest {
video_batch_count: Some(0),
request_mismatch_mitigation_strategy: strategy,
..base_request()
};
let result = begin_video_generation(&request);
assert!(
matches!(result, Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations))),
"Expected UserRequestedZeroGenerations with {:?}", strategy,
);
}
}
#[test]
fn batch_count_direct_mapping() {
let req = GenerateVideoRequest { video_batch_count: Some(1), ..base_request() };
let VideoGenerationPlan::ArtcraftSeedance2p0(plan) = begin_video_generation(&req).unwrap();
assert!(matches!(plan.batch_count, Seedance2p0BatchCount::One));
let req = GenerateVideoRequest { video_batch_count: Some(2), ..base_request() };
let VideoGenerationPlan::ArtcraftSeedance2p0(plan) = begin_video_generation(&req).unwrap();
assert!(matches!(plan.batch_count, Seedance2p0BatchCount::Two));
let req = GenerateVideoRequest { video_batch_count: Some(4), ..base_request() };
let VideoGenerationPlan::ArtcraftSeedance2p0(plan) = begin_video_generation(&req).unwrap();
assert!(matches!(plan.batch_count, Seedance2p0BatchCount::Four));
}
#[test]
fn batch_count_three_upgrade_rounds_to_four() {
let request = GenerateVideoRequest {
video_batch_count: Some(3),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
..base_request()
};
let VideoGenerationPlan::ArtcraftSeedance2p0(plan) = begin_video_generation(&request).unwrap();
assert!(matches!(plan.batch_count, Seedance2p0BatchCount::Four));
}
#[test]
fn batch_count_three_downgrade_rounds_to_two() {
let request = GenerateVideoRequest {
video_batch_count: Some(3),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayLessDowngrade,
..base_request()
};
let VideoGenerationPlan::ArtcraftSeedance2p0(plan) = begin_video_generation(&request).unwrap();
assert!(matches!(plan.batch_count, Seedance2p0BatchCount::Two));
}
#[test]
fn url_image_ref_returns_error() {
let request = GenerateVideoRequest {
start_frame: Some(ImageRef::Url("https://example.com/image.jpg")),
..base_request()
};
let result = begin_video_generation(&request);
assert!(matches!(
result,
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
));
}
}
mod real_requests {
use super::*;
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_text_to_video_seedance_2p0() {
let client = get_artcraft_client();
let request = GenerateVideoRequest {
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
video_batch_count: Some(1),
prompt: Some("a cat walking through a cyberpunk city at night"),
..base_request()
};
let plan = begin_video_generation(&request).unwrap();
let result = plan.generate_video(&client).await;
println!("Result: {:?}", result);
let response = result.expect("generate_video request failed");
println!("Job token: {:?}", response.inference_job_token);
println!("All job tokens: {:?}", response.all_inference_job_tokens);
assert_eq!(1, 2); // NB: Intentional failure to inspect the response above.
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_text_to_video_seedance_2p0_batch_two() {
let client = get_artcraft_client();
let request = GenerateVideoRequest {
aspect_ratio: Some(CommonAspectRatio::Square),
video_batch_count: Some(2),
prompt: Some("a dog surfing a wave, cinematic"),
..base_request()
};
let plan = begin_video_generation(&request).unwrap();
let result = plan.generate_video(&client).await;
println!("Result: {:?}", result);
let response = result.expect("generate_video request failed");
println!("Job tokens ({} total):", response.all_inference_job_tokens.len());
for token in &response.all_inference_job_tokens {
println!(" {:?}", token);
}
assert_eq!(1, 2); // NB: Intentional failure to inspect the response above.
}
}