Compare commits

...
Author SHA1 Message Date
Brandon Thomas d961d88459 fix api 2026-02-25 06:22:53 +07:00
Brandon Thomas b3ae7e44cc Bt fix seedance api (#1055) 2026-02-25 05:33:38 +07:00
Brandon Thomas c466c68c33 bump artcraft 0.7 -> 0.8 2026-02-25 05:14:46 +07:00
Brandon Thomas 74e1150f3c log hostnames 2026-02-25 04:52:43 +07:00
BFlatandBrandon Thomas 3e0bf8ebfd more controls for seedance 2.0 (#1052)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-02-25 04:21:52 +07:00
Brandon Thomas f36a5d5e02 model router (#1054)
This is the new middleware I'll use for model routing.
2026-02-24 21:04:57 +07:00
Brandon Thomas 9545dba684 bump github download links on website (#1051) 2026-02-23 10:09:34 +07:00
Brandon Thomas 9506885529 seedance 2026-02-22 14:43:45 +07:00
Brandon Thomas ed6dd542d3 fix wreq compression (#1050)
this was silently broken across all calls - yikes
2026-02-22 13:01:39 +07:00
Brandon Thomas 21aba384a6 artcraft 0.7.0 2026-02-22 10:18:56 +07:00
Brandon Thomas 895a38cd04 job 2026-02-22 10:06:24 +07:00
Brandon Thomas f947c78bf5 fix openssl build issues (again) (#1049) 2026-02-22 09:41:25 +07:00
Brandon Thomas 94ff298cfa run docker builds on "test" branch 2026-02-21 08:58:29 +07:00
Tanish Manku c98aca330b Map history stack and corresponding edits (#1041)
Event based, so only updates on creation, selection and deletion of
history images.
2026-02-20 16:37:38 -08:00
Brandon Thomas f9da20315e clang 2026-02-21 06:53:45 +07:00
Brandon Thomas 4134c648ab upgrade boringssl, wreq, etc. 2026-02-21 06:40:53 +07:00
Brandon Thomas 56d40c7800 replace reqwest with wreq in web 2026-02-21 06:07:27 +07:00
Brandon Thomas 69adc0d95a remove seedance job for now 2026-02-21 05:49:47 +07:00
Brandon Thomas 0613e6d3ca seedance2pro cargo.toml dep 2026-02-21 05:43:28 +07:00
Brandon Thomas 4b77e589e8 build verbosely 2026-02-21 05:26:07 +07:00
Brandon Thomas 71ba9e7286 revert all build tomls 2026-02-21 05:19:31 +07:00
Brandon Thomas e219c4728b revert storyteller-web cargo.toml 2026-02-21 05:11:50 +07:00
Brandon Thomas 0b3cce75f3 revert cargo/docker 2026-02-21 05:08:29 +07:00
Brandon Thomas 339fb8263c revert some of dockerfile 2026-02-21 04:56:52 +07:00
Brandon Thomas 8cd0f543fa reqwest augh 2026-02-21 04:54:25 +07:00
Brandon Thomas a25c4005a9 reqwest issues 2026-02-21 04:32:28 +07:00
Brandon Thomas dbd89cdd10 consolidate reqwest usage 2026-02-21 04:08:25 +07:00
Brandon Thomas 9c1136710d this could have introduced the issue 2026-02-21 03:23:46 +07:00
Brandon Thomas 7054e3a381 clang packages 2026-02-21 03:07:43 +07:00
Brandon Thomas bbc4b98b50 debug failing build 2026-02-21 02:53:59 +07:00
Brandon Thomas 6c2f60801e artcraft seedance (tauri app) (#1046) 2026-02-21 02:41:16 +07:00
Brandon Thomas 68624e81d8 bump rust docker version 2026-02-20 21:46:02 +07:00
Brandon Thomas 5bbb0fdb06 db codegen 2026-02-20 21:15:02 +07:00
Brandon Thomas 8273822a5c seedance job - download seedance results and upload to storage (#1045) 2026-02-20 17:45:49 +07:00
Brandon Thomas e1427094c3 seedance 2.0 generation endpoint (#1044) 2026-02-20 15:50:27 +07:00
Brandon Thomas 2bfeb49d17 seedance API #1 (#1043) 2026-02-20 07:05:50 +07:00
Brandon Thomas 4752c54851 ignore directory 2026-02-19 13:25:47 +07:00
Brandon Thomas e0779897b6 single quotes in db migrations 2026-02-17 23:40:56 +07:00
172 changed files with 6851 additions and 1462 deletions
+1
View File
@@ -9,6 +9,7 @@ on:
# NB: Default-branch doesn't work, despite Github's documentation.
#- $default-branch
- main
- test
env:
IMAGE_NAME: storyteller-rust
+3
View File
@@ -35,6 +35,9 @@ script/socat_dev_database_connect.sh
# Python virtual envs (named by convention)
venv/
# Testing
/requests/
# Scratch data
scratch/
test_data/temp/
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nUPDATE generic_inference_jobs\nSET\n status = ?,\n failure_reason = ?,\n internal_debugging_failure_reason = ?,\n frontend_failure_category = ?,\n retry_at = NULL\nWHERE token = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 5
},
"nullable": []
},
"hash": "15939605e0da2722693371799ba94c7d0cc2f77a5c5b37e346855996d16033ec"
}
@@ -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 status = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 14
},
"nullable": []
},
"hash": "33aa8a1cf85bf1b8b68f4e2ab46541f25d7e8bb5eeb503c5ec14cb5e115f69ce"
}
@@ -0,0 +1,91 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n jobs.token as `job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,\n jobs.maybe_external_third_party_id as `order_id`,\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 jobs.creator_set_visibility as `creator_set_visibility: enums::common::visibility::Visibility`,\n jobs.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`\n\nFROM generic_inference_jobs as jobs\n\nWHERE jobs.maybe_external_third_party = ?\n AND jobs.status NOT IN ('complete_success', 'complete_failure')\n AND jobs.maybe_external_third_party_id IS NOT NULL\n\nLIMIT 25000\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "order_id",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 256
}
},
{
"ordinal": 2,
"name": "maybe_creator_user_token: tokens::tokens::users::UserToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 3,
"name": "maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 4,
"name": "creator_ip_address",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 160
}
},
{
"ordinal": 5,
"name": "creator_set_visibility: enums::common::visibility::Visibility",
"type_info": {
"type": "String",
"flags": "NOT_NULL | BINARY | ENUM",
"char_set": 224,
"max_size": 28
}
},
{
"ordinal": 6,
"name": "maybe_prompt_token: tokens::tokens::prompts::PromptToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
true,
true,
true,
false,
false,
true
]
},
"hash": "ca96942d8214edb84f163cf40131ceed873209e86718054e1502219fc81d2dc6"
}
Generated
+445 -319
View File
File diff suppressed because it is too large Load Diff
+13 -7
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/seedance2pro",
"crates/api_clients/runwayml_client",
"crates/api_clients/thumbnail_generator",
"crates/api_clients/world_labs_client",
@@ -71,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",
@@ -116,6 +118,7 @@ members = [
"crates/service/job/email_sender_job",
"crates/service/job/es-update-job",
"crates/service/job/inference_job",
"crates/service/job/seedance2_pro_job",
"crates/service/job/tts_download_job",
"crates/service/job/tts_inference_job",
"crates/service/job/w2l_download_job",
@@ -205,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" }
@@ -232,6 +236,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" }
seedance2pro = { path = "crates/api_clients/seedance2pro" }
primitives = { path = "crates/lib/data/primitives" }
shared_service_components = { path = "crates/lib/shared_service_components" }
sqlite_tasks = { path = "crates/schema/database/sqlite_tasks" }
@@ -258,24 +263,24 @@ actix-utils = "3.0.1"
actix-web = "4.7.0" # TODO(bt): We're behind and need to bump versions.
actix-web-actors = "4.3.1+deprecated"
actix-web-lab = "0.20.2"
anyhow = "1.0.98"
anyhow = "1.0.102"
base64 = "0.22.1"
bstr = "1.12.1"
bytes = "1.10.1"
chrono = { version = "0.4.41", features = ["serde"] }
cookie = "0.18.1"
env = "1.0.1"
env_logger = "0.11.8"
env_logger = "0.11.9"
futures = { version = "0.3.31" }
hex-literal = "1.0.0"
indexmap = "2.9.0"
log = "0.4.27"
log = "0.4.29"
md-5 = "0.10.6" # insecure but used for gravatar
once_cell = "1.21.3"
r2d2 = "0.8.10"
rand = "0.9.1"
redis = { version = "1.0.2", features = ["r2d2", "tokio-rustls-comp", "tokio-comp", "cluster-async", "aio"] }
regex = "1.12.2"
regex = "1.12.3"
reqwest = { version = "0.12.23", default-features = false, features = [ "charset", "http2", "system-proxy", "json", "cookies", "rustls-tls", "gzip" ] }
reqwest-websocket = { version = "0.5.0" }
serde = { version = "1.0.219", features = ["derive"] }
@@ -293,7 +298,7 @@ tempdir = "0.3.7"
tempfile = "3.20.0"
thiserror = "2.0.12"
tokio = { version = "1.45.1", features = ["macros"] }
toml = "0.8.22"
toml = "0.8.23"
url = { version = "2.5.4", features = ["serde"] }
uuid = "1.18.1"
@@ -307,5 +312,6 @@ uuid = "1.18.1"
# choco install pkgconfiglite -y
# choco install llvm -y
# choco install nasm -y
wreq = { version = "6.0.0-rc.20", features = ["json", "multipart", "cookies", "ws", "stream"] }
wreq-util = "3.0.0-rc.6"
wreq-util = "3.0.0-rc.10"
wreq = { version = "6.0.0-rc.28", features = ["json", "query", "form", "multipart", "cookies", "ws", "stream", "gzip", "brotli", "zstd", "deflate"] }
+1 -1
View File
@@ -55,7 +55,7 @@ We haven't abandoned text-to-asset generation for quick prototyping and ideation
| Provider | Features |
|------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| Artcraft | Nano Banana, Nano Banana Pro, GPT-Image-1 / 1.5, Seedream 4 / 4.5, Flux 1.1 / Kontext, Veo 2 / 3 / 3.1, Kling 1.6 / 2.1 / 2.5 / 2.6, Seedance, Sora 2 / Pro, Hunyuan 3d 2 / 3 |
| Artcraft | Nano Banana, Nano Banana Pro, GPT-Image-1 / 1.5, Seedance 2 / Seedream 4 / 4.5, Flux 1.1 / Kontext, Veo 2 / 3 / 3.1, Kling 1.6 / 2.1 / 2.5 / 2.6, Seedance, Sora 2 / Pro, Hunyuan 3d 2 / 3 |
| Grok | Grok Imagine, Grok Video |
| Midjourney | Image Gen (all versions) |
| Sora | Sora 1, Sora 2, GPT-Image-1 |
@@ -3,7 +3,7 @@
-- noinspection SqlResolveForFile
ALTER TABLE media_files
ADD COLUMN origin_product_category VARCHAR(16) NOT NULL DEFAULT "unknown"
ADD COLUMN origin_product_category VARCHAR(16) NOT NULL DEFAULT 'unknown'
AFTER origin_category;
ALTER TABLE media_files
@@ -3,7 +3,7 @@
-- noinspection SqlResolveForFile
ALTER TABLE media_files
ADD COLUMN media_class VARCHAR(16) NOT NULL DEFAULT "unknown"
ADD COLUMN media_class VARCHAR(16) NOT NULL DEFAULT 'unknown'
AFTER media_type;
ALTER TABLE media_files
@@ -3,7 +3,7 @@
-- noinspection SqlResolveForFile
ALTER TABLE media_files
ADD COLUMN nsfw_status VARCHAR(32) NOT NULL DEFAULT "unknown"
ADD COLUMN nsfw_status VARCHAR(32) NOT NULL DEFAULT 'unknown'
AFTER maybe_scene_source_media_file_token;
ALTER TABLE media_files
+12 -1
View File
@@ -33,7 +33,7 @@ RUN apt-get update \
FROM ubuntu:jammy as rust-base
# NB: This can be "stable" or another version.
ARG RUST_TOOLCHAIN="1.86.0"
ARG RUST_TOOLCHAIN="1.93.0"
WORKDIR /tmp
@@ -48,8 +48,12 @@ RUN apt-get update \
curl \
ffmpeg \
fontconfig \
git \
libclang-dev \
libfontconfig1-dev \
libssl-dev \
musl-tools \
perl \
pkg-config
# NB: Fix for fontconfig (servo-fontconfig-sys): https://github.com/alacritty/alacritty/issues/4423#issuecomment-727277235
@@ -133,6 +137,12 @@ RUN SQLX_OFFLINE=true \
--release \
--bin storyteller-web
RUN SQLX_OFFLINE=true \
LD_LIBRARY_PATH=/usr/lib:${LD_LIBRARY_PATH} \
$HOME/.cargo/bin/cargo build \
--release \
--bin seedance2-pro-job
RUN SQLX_OFFLINE=true \
LD_LIBRARY_PATH=/usr/lib:${LD_LIBRARY_PATH} \
$HOME/.cargo/bin/cargo build \
@@ -192,6 +202,7 @@ RUN echo -n ${GIT_SHA} > GIT_SHA
# Copy all the binaries (except those that need a GPU):
COPY --from=builder /tmp/target/release/storyteller-web /
COPY --from=builder /tmp/target/release/seedance2-pro-job /
COPY --from=builder /tmp/target/release/dummy-service /
COPY --from=builder /tmp/target/release/analytics-job /
COPY --from=builder /tmp/target/release/email-sender-job /
@@ -36,3 +36,4 @@ serde_json.workspace = true
utoipa = { version = "4", features = ["chrono", "actix_extras"] }
[dev-dependencies]
anyhow.workspace = true
@@ -1,5 +1,6 @@
pub mod kling_2_5_turbo_multi_function_video_gen;
pub mod kling_2_6_multi_function_video_gen;
pub mod seedance_2p0_multi_function_video_gen;
pub mod sora_2_multi_function_video_gen;
pub mod sora_2_pro_multi_function_video_gen;
pub mod veo_3p1_fast_multi_function_video_gen;
@@ -0,0 +1,85 @@
use serde::{Deserialize, Serialize};
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::media_files::MediaFileToken;
use utoipa::ToSchema;
pub const SEEDANCE_2P0_MULTI_FUNCTION_VIDEO_GEN_PATH: &str = "/v1/generate/video/multi_function/seedance_2p0";
/// Seedance 2.0 multi-function video generation (text-to-video, image-to-video, and reference-based).
#[derive(Serialize, Deserialize, ToSchema)]
pub struct Seedance2p0MultiFunctionVideoGenRequest {
/// Idempotency token to prevent duplicate requests.
pub uuid_idempotency_token: String,
/// Text prompt.
pub prompt: Option<String>,
/// Optional start frame image.
pub start_frame_media_token: Option<MediaFileToken>,
/// Optional end frame image.
pub end_frame_media_token: Option<MediaFileToken>,
/// Optional reference images (reference mode). When present, takes priority over start/end frames.
pub reference_image_media_tokens: Option<Vec<MediaFileToken>>,
/// Video aspect ratio / resolution.
pub aspect_ratio: Option<Seedance2p0AspectRatio>,
/// Duration in seconds (4–15).
pub duration_seconds: Option<u8>,
/// Number of videos to generate.
pub batch_count: Option<Seedance2p0BatchCount>,
}
#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum Seedance2p0AspectRatio {
/// 16:9 landscape (1280x720)
Landscape16x9,
/// 9:16 portrait (720x1280)
Portrait9x16,
/// 1:1 square (720x720)
Square1x1,
/// 4:3 standard (960x720)
Standard4x3,
/// 3:4 portrait (720x960)
Portrait3x4,
}
#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum Seedance2p0BatchCount {
One,
Two,
Four,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct Seedance2p0MultiFunctionVideoGenResponse {
pub success: bool,
/// The first inference job token (always present).
pub inference_job_token: InferenceJobToken,
/// All inference job tokens for the batch (includes `inference_job_token` as the first entry).
pub all_inference_job_tokens: Vec<InferenceJobToken>,
}
#[cfg(test)]
mod tests {
use super::*;
fn aspect_to_string(input: Seedance2p0AspectRatio) -> anyhow::Result<String> {
let val = serde_json::to_string(&input)?;
Ok(val.replace("\"", ""))
}
#[test]
fn just_testing_snake_case() -> anyhow::Result<()> {
// Ugh, not sure what these are...
assert_eq!(aspect_to_string(Seedance2p0AspectRatio::Landscape16x9)?, "landscape16x9");
Ok(())
}
}
@@ -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,
@@ -1,4 +1,5 @@
pub mod kling_2p5_turbo_pro_multi_function_video_gen;
pub mod seedance_2p0_multi_function_video_gen;
pub mod kling_2p6_pro_multi_function_video_gen;
pub mod sora_2_multi_function_video_gen;
pub mod sora_2_pro_multi_function_video_gen;
@@ -0,0 +1,18 @@
use crate::credentials::storyteller_credential_set::StorytellerCredentialSet;
use crate::error::storyteller_error::StorytellerError;
use crate::utils::api_host::ApiHost;
use crate::utils::basic_json_post_request::basic_json_post_request;
use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::{Seedance2p0MultiFunctionVideoGenRequest, Seedance2p0MultiFunctionVideoGenResponse, SEEDANCE_2P0_MULTI_FUNCTION_VIDEO_GEN_PATH};
pub async fn seedance_2p0_multi_function_video_gen(
api_host: &ApiHost,
maybe_creds: Option<&StorytellerCredentialSet>,
request: Seedance2p0MultiFunctionVideoGenRequest,
) -> Result<Seedance2p0MultiFunctionVideoGenResponse, StorytellerError> {
Ok(basic_json_post_request(
api_host,
SEEDANCE_2P0_MULTI_FUNCTION_VIDEO_GEN_PATH,
maybe_creds,
request,
).await?)
}
@@ -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());
@@ -48,6 +48,16 @@ pub async fn basic_json_delete_request<Req: Serialize, Res: DeserializeOwned>(
.await
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
let hostname = response.headers().get("x-backend-hostname")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_owned());
let build_sha = response.headers().get("x-build-sha")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_owned());
debug!("[server] x-backend-hostname: {:?}, x-build-sha: {:?}", hostname, build_sha);
let response = filter_bad_response(response)
.await
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
@@ -5,7 +5,7 @@ use crate::error::storyteller_error::StorytellerError;
use crate::utils::api_host::ApiHost;
use crate::utils::constants::{APPLICATION_JSON, USER_AGENT};
use crate::utils::filter_bad_response::filter_bad_response;
use log::debug;
use log::{debug, info};
use reqwest::Client;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
@@ -40,6 +40,16 @@ pub async fn basic_json_get_request<Res: DeserializeOwned>(
.await
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
let hostname = response.headers().get("x-backend-hostname")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_owned());
let build_sha = response.headers().get("x-build-sha")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_owned());
debug!("[server] x-backend-hostname: {:?}, x-build-sha: {:?}", hostname, build_sha);
let response = filter_bad_response(response)
.await
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
@@ -48,6 +48,16 @@ pub async fn basic_json_post_request<Req: Serialize, Res: DeserializeOwned>(
.await
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
let hostname = response.headers().get("x-backend-hostname")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_owned());
let build_sha = response.headers().get("x-build-sha")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_owned());
debug!("[server] x-backend-hostname: {:?}, x-build-sha: {:?}", hostname, build_sha);
let response = filter_bad_response(response)
.await
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
@@ -46,6 +46,16 @@ pub async fn basic_query_string_post_request<Res: DeserializeOwned>(
.await
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
let hostname = response.headers().get("x-backend-hostname")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_owned());
let build_sha = response.headers().get("x-build-sha")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_owned());
debug!("[server] x-backend-hostname: {:?}, x-build-sha: {:?}", hostname, build_sha);
let response = filter_bad_response(response)
.await
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
@@ -36,11 +36,11 @@ sha3.workspace = true
thiserror.workspace = true
#tokio.workspace = true
toml.workspace = true
url.workspace = true
tokio = { version = "1", features = ["full"] }
wreq-util.workspace = true
wreq.workspace = true
url.workspace = true
[dev-dependencies]
testing = { path = "../../lib/testing" }
@@ -0,0 +1,38 @@
[package]
name = "seedance2pro"
edition = "2024"
version = "0.0.1"
publish = false
[package.metadata.cargo-udeps.ignore]
normal = ["workspace-build-acceleration"]
[lib]
name = "seedance2pro"
path = "src/lib.rs"
[dependencies]
# Workspace hack (faster compile times)
workspace-build-acceleration.workspace = true
# Internal
errors.workspace = true
# External
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]
chrono.workspace = true
env.workspace = true
env_logger.workspace = true
testing = { path = "../../lib/testing" }
tokio = { version = "1", features = ["full"] }
@@ -0,0 +1,2 @@
pub mod seedance2pro_cookies;
pub mod seedance2pro_session;
@@ -0,0 +1,24 @@
/// Cookies are the credential required to interact with the Seedance2 Pro API.
#[derive(Clone)]
pub struct Seedance2ProCookies {
cookies: String,
}
impl Seedance2ProCookies {
pub fn new(cookies: String) -> Self {
Seedance2ProCookies { cookies }
}
pub fn as_str(&self) -> &str {
&self.cookies
}
pub fn as_bytes(&self) -> &[u8] {
self.cookies.as_bytes()
}
pub fn to_string(&self) -> String {
self.cookies.clone()
}
}
@@ -0,0 +1,19 @@
use crate::creds::seedance2pro_cookies::Seedance2ProCookies;
/// Holds the full session data needed to make authenticated requests to Seedance2 Pro.
#[derive(Clone)]
pub struct Seedance2ProSession {
pub cookies: Seedance2ProCookies,
}
impl Seedance2ProSession {
pub fn new(cookies: Seedance2ProCookies) -> Self {
Seedance2ProSession { cookies }
}
pub fn from_cookies_string(cookies: String) -> Self {
Seedance2ProSession {
cookies: Seedance2ProCookies::new(cookies),
}
}
}
@@ -0,0 +1,4 @@
pub mod seedance2pro_client_error;
pub mod seedance2pro_error;
pub mod seedance2pro_generic_api_error;
pub mod seedance2pro_specific_api_error;
@@ -0,0 +1,26 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
#[derive(Debug)]
pub enum Seedance2ProClientError {
/// No cookies are present in the session.
NoCookiesPresent,
/// Error parsing a URL.
UrlParseError(url::ParseError),
/// An error was encountered in building the Wreq client.
WreqClientError(wreq::Error),
}
impl Error for Seedance2ProClientError {}
impl Display for Seedance2ProClientError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::NoCookiesPresent => write!(f, "No cookies present in the session."),
Self::UrlParseError(err) => write!(f, "URL parse error: {}", err),
Self::WreqClientError(err) => write!(f, "Wreq client error (during client creation): {}", err),
}
}
}
@@ -0,0 +1,54 @@
use crate::error::seedance2pro_client_error::Seedance2ProClientError;
use crate::error::seedance2pro_generic_api_error::Seedance2ProGenericApiError;
use crate::error::seedance2pro_specific_api_error::Seedance2ProSpecificApiError;
use cloudflare_errors::cloudflare_error::CloudflareError;
use std::error::Error;
#[derive(Debug)]
pub enum Seedance2ProError {
Client(Seedance2ProClientError),
ApiSpecific(Seedance2ProSpecificApiError),
ApiGeneric(Seedance2ProGenericApiError),
}
impl Seedance2ProError {
pub fn is_having_downtime_issues(&self) -> bool {
match self {
Self::ApiGeneric(Seedance2ProGenericApiError::Http502ErrorBadGateway(_)) => true,
Self::ApiGeneric(Seedance2ProGenericApiError::CloudflareError(CloudflareError::BadGateway502)) => true,
Self::ApiGeneric(Seedance2ProGenericApiError::CloudflareError(CloudflareError::GatewayTimeout504)) => true,
Self::ApiGeneric(Seedance2ProGenericApiError::CloudflareError(CloudflareError::TimeoutOccurred524)) => true,
_ => false,
}
}
}
impl Error for Seedance2ProError {}
impl std::fmt::Display for Seedance2ProError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Client(e) => write!(f, "Seedance2ProClientError: {:?}", e),
Self::ApiSpecific(e) => write!(f, "Seedance2ProSpecificApiError: {:?}", e),
Self::ApiGeneric(e) => write!(f, "Seedance2ProGenericApiError: {:?}", e),
}
}
}
impl From<Seedance2ProClientError> for Seedance2ProError {
fn from(error: Seedance2ProClientError) -> Self {
Self::Client(error)
}
}
impl From<Seedance2ProSpecificApiError> for Seedance2ProError {
fn from(error: Seedance2ProSpecificApiError) -> Self {
Self::ApiSpecific(error)
}
}
impl From<Seedance2ProGenericApiError> for Seedance2ProError {
fn from(error: Seedance2ProGenericApiError) -> Self {
Self::ApiGeneric(error)
}
}
@@ -0,0 +1,50 @@
use cloudflare_errors::cloudflare_error::CloudflareError;
use std::error::Error;
use std::fmt::{Display, Formatter};
use wreq::StatusCode;
#[derive(Debug)]
pub enum Seedance2ProGenericApiError {
/// Specific Cloudflare errors.
CloudflareError(CloudflareError),
/// HTTP 502 Bad Gateway.
/// We preserve the message for debugging.
Http502ErrorBadGateway(String),
/// 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.
UncategorizedBadResponse(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 Seedance2ProGenericApiError {}
impl Display for Seedance2ProGenericApiError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::CloudflareError(err) => write!(f, "Cloudflare error: {}", err),
Self::Http502ErrorBadGateway(msg) => write!(f, "HTTP 502 Bad Gateway: {}", msg),
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::UncategorizedBadResponse(msg) => write!(f, "Uncategorized bad response: {}", msg),
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,22 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
#[derive(Debug)]
pub enum Seedance2ProSpecificApiError {
/// The session cookies are expired or invalid.
UnauthorizedSessionExpired,
/// The video generation request was flagged as a content violation.
VideoGenerationViolation(String),
}
impl Error for Seedance2ProSpecificApiError {}
impl Display for Seedance2ProSpecificApiError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::UnauthorizedSessionExpired => write!(f, "Unauthorized: session cookies expired or invalid."),
Self::VideoGenerationViolation(body) => write!(f, "Video generation violation: {}", body),
}
}
}
@@ -0,0 +1,8 @@
#[cfg(test)]
pub (crate) mod test_utils;
// User API
pub mod creds;
pub mod error;
pub mod requests;
pub mod utils;
@@ -0,0 +1,386 @@
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::error::seedance2pro_client_error::Seedance2ProClientError;
use crate::error::seedance2pro_error::Seedance2ProError;
use crate::error::seedance2pro_generic_api_error::Seedance2ProGenericApiError;
use crate::error::seedance2pro_specific_api_error::Seedance2ProSpecificApiError;
use crate::requests::generate_video::request_types::*;
use crate::utils::common_headers::FIREFOX_USER_AGENT;
use log::info;
use wreq::Client;
use wreq_util::Emulation;
const RUN_TASK_URL: &str = "https://seedance2-pro.com/api/trpc/workflow.runTask?batch=1";
// --- Request args ---
pub struct GenerateVideoArgs<'a> {
pub session: &'a Seedance2ProSession,
pub prompt: String,
pub resolution: Resolution,
/// Duration in seconds (4–15).
pub duration_seconds: u8,
pub batch_count: BatchCount,
/// Optional start frame image URL (keyframe mode).
pub start_frame_url: Option<String>,
/// Optional end frame image URL (keyframe mode).
pub end_frame_url: Option<String>,
/// Optional reference image URLs (reference mode). When present, takes priority over start/end frames.
pub reference_image_urls: Option<Vec<String>>,
}
impl GenerateVideoArgs<'_> {
/// Estimates the credit cost for this generation request.
///
/// Pricing rules:
/// - 40 credits per second of video (4s = 160, 15s = 600)
/// - Resolution has no effect on cost
/// - Input mode (text, keyframe, reference) has no effect on cost
/// - Batch 1 = 1×, Batch 2 = 2×, Batch 4 = 3× (not 4×)
pub fn estimate_credits(&self) -> u32 {
let per_video = u32::from(self.duration_seconds) * 40;
let batch_multiplier = match self.batch_count {
BatchCount::One => 1,
BatchCount::Two => 2,
BatchCount::Four => 3, // NB: 3x, not 4x.
};
per_video * batch_multiplier
}
pub fn estimate_cost_in_usd_cents(&self) -> u64 {
// 25000 Credits costs $99.99 as of 2026-02-20
// 250 for $1.
let credits = self.estimate_credits() as f64;
let cost = credits / 250.0 * 100.0;
cost.round() as u64
}
}
// --- Public enums ---
/// Video resolution / aspect ratio.
#[derive(Debug, Clone, Copy)]
pub enum Resolution {
/// 16:9 landscape (1280x720)
Landscape16x9,
/// 9:16 portrait (720x1280)
Portrait9x16,
/// 1:1 square (720x720)
Square1x1,
/// 4:3 standard (960x720)
Standard4x3,
/// 3:4 portrait (720x960)
Portrait3x4,
}
impl Resolution {
fn as_str(&self) -> &'static str {
match self {
Self::Landscape16x9 => "1280x720",
Self::Portrait9x16 => "720x1280",
Self::Square1x1 => "720x720",
Self::Standard4x3 => "960x720",
Self::Portrait3x4 => "720x960",
}
}
}
/// Number of videos to generate in a single request.
#[derive(Debug, Clone, Copy)]
pub enum BatchCount {
One,
Two,
Four,
}
impl BatchCount {
fn as_u8(&self) -> u8 {
match self {
Self::One => 1,
Self::Two => 2,
Self::Four => 4,
}
}
}
// --- Response ---
pub struct GenerateVideoResponse {
pub task_id: String,
pub order_id: String,
/// Present when batch_count > 1.
pub task_ids: Option<Vec<String>>,
/// Present when batch_count > 1.
pub order_ids: Option<Vec<String>>,
}
// --- Implementation ---
pub async fn generate_video(args: GenerateVideoArgs<'_>) -> Result<GenerateVideoResponse, Seedance2ProError> {
let is_reference_mode = args.reference_image_urls.as_ref().is_some_and(|urls| !urls.is_empty());
let video_input_mode = if is_reference_mode { "reference" } else { "keyframe" };
let uploaded_urls: Option<Vec<String>> = if is_reference_mode {
args.reference_image_urls
} else {
let mut urls = Vec::new();
if let Some(url) = args.start_frame_url {
urls.push(url);
}
if let Some(url) = args.end_frame_url {
urls.push(url);
}
if urls.is_empty() { None } else { Some(urls) }
};
let batch_count_value = args.batch_count.as_u8();
let batch_count = if batch_count_value > 1 { Some(batch_count_value) } else { None };
let duration = format!("{}s", args.duration_seconds);
info!(
"Generating video: mode={}, resolution={}, duration={}, batch={}",
video_input_mode, args.resolution.as_str(), duration, batch_count_value
);
let request_body = BatchRequest {
zero: BatchRequestInner {
json: BatchRequestJson {
business_type: "wan22-video-generation",
api_params: ApiParams {
prompt: args.prompt,
resolution: args.resolution.as_str().to_string(),
mode: "normal",
model: "seedance-20",
duration,
video_input_mode,
uploaded_urls,
batch_count,
},
},
},
};
let cookie = args.session.cookies.as_str();
let client = Client::builder()
.emulation(Emulation::Firefox143)
.build()
.map_err(|err| Seedance2ProClientError::WreqClientError(err))?;
let response = client.post(RUN_TASK_URL)
.header("User-Agent", FIREFOX_USER_AGENT)
.header("Accept", "*/*")
.header("Accept-Language", "en-US,en;q=0.9")
.header("Accept-Encoding", "gzip, deflate, br, zstd")
.header("Referer", "https://seedance2-pro.com/")
.header("Content-Type", "application/json")
.header("x-trpc-source", "client")
.header("Origin", "https://seedance2-pro.com")
.header("Connection", "keep-alive")
.header("Cookie", cookie)
.header("Sec-Fetch-Dest", "empty")
.header("Sec-Fetch-Mode", "cors")
.header("Sec-Fetch-Site", "same-origin")
.header("Priority", "u=4")
.header("TE", "trailers")
.json(&request_body)
.send()
.await
.map_err(|err| Seedance2ProGenericApiError::WreqError(err))?;
let status = response.status();
let response_body = response.text()
.await
.map_err(|err| Seedance2ProGenericApiError::WreqError(err))?;
info!("Response status: {}, body: {}", status, response_body);
if !status.is_success() {
return Err(Seedance2ProGenericApiError::UncategorizedBadResponseWithStatusAndBody {
status_code: status,
body: response_body,
}.into());
}
let batch_response: Vec<BatchResponseItem> = serde_json::from_str(&response_body)
.map_err(|err| Seedance2ProGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.clone()))?;
let task_data = batch_response
.into_iter()
.next()
.ok_or_else(|| Seedance2ProGenericApiError::UncategorizedBadResponse(
"Empty batch response array".to_string()
))?
.result
.data
.json;
if task_data.violation_warning {
return Err(Seedance2ProSpecificApiError::VideoGenerationViolation(response_body).into());
}
Ok(GenerateVideoResponse {
task_id: task_data.task_id,
order_id: task_data.order_id,
task_ids: task_data.task_ids,
order_ids: task_data.order_ids,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::test_utils::get_test_cookies::get_test_cookies;
use crate::test_utils::setup_test_logging::setup_test_logging;
use errors::AnyhowResult;
use log::LevelFilter;
fn dummy_session() -> Seedance2ProSession {
Seedance2ProSession::from_cookies_string(String::new())
}
fn args_with(duration_seconds: u8, batch_count: BatchCount) -> GenerateVideoArgs<'static> {
// Safety: the dummy session is leaked so the reference is 'static for test purposes.
let session = Box::leak(Box::new(dummy_session()));
GenerateVideoArgs {
session,
prompt: String::new(),
resolution: Resolution::Square1x1,
duration_seconds,
batch_count,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
}
}
#[test]
fn test_estimate_credits() {
// 40 credits per second, batch 1
assert_eq!(args_with(4, BatchCount::One).estimate_credits(), 160);
assert_eq!(args_with(5, BatchCount::One).estimate_credits(), 200);
assert_eq!(args_with(6, BatchCount::One).estimate_credits(), 240);
assert_eq!(args_with(7, BatchCount::One).estimate_credits(), 280);
assert_eq!(args_with(15, BatchCount::One).estimate_credits(), 600);
// Batch 2 = 2×
assert_eq!(args_with(4, BatchCount::Two).estimate_credits(), 320);
assert_eq!(args_with(5, BatchCount::Two).estimate_credits(), 400);
assert_eq!(args_with(15, BatchCount::Two).estimate_credits(), 1200);
// Batch 4 = 3× (not 4×)
assert_eq!(args_with(4, BatchCount::Four).estimate_credits(), 480);
assert_eq!(args_with(5, BatchCount::Four).estimate_credits(), 600);
assert_eq!(args_with(15, BatchCount::Four).estimate_credits(), 1800);
}
#[test]
fn test_estimate_cost_usd_cents() {
// 40 credits per second, batch 1
assert_eq!(args_with(4, BatchCount::One).estimate_cost_in_usd_cents(), 64);
assert_eq!(args_with(5, BatchCount::One).estimate_cost_in_usd_cents(), 80);
assert_eq!(args_with(6, BatchCount::One).estimate_cost_in_usd_cents(), 96);
assert_eq!(args_with(7, BatchCount::One).estimate_cost_in_usd_cents(), 112);
assert_eq!(args_with(15, BatchCount::One).estimate_cost_in_usd_cents(), 240);
// Batch 2 = 2×
assert_eq!(args_with(4, BatchCount::Two).estimate_cost_in_usd_cents(), 128);
assert_eq!(args_with(5, BatchCount::Two).estimate_cost_in_usd_cents(), 160);
assert_eq!(args_with(15, BatchCount::Two).estimate_cost_in_usd_cents(), 480);
// Batch 4 = 3× (not 4×)
assert_eq!(args_with(4, BatchCount::Four).estimate_cost_in_usd_cents(), 192);
assert_eq!(args_with(5, BatchCount::Four).estimate_cost_in_usd_cents(), 240);
assert_eq!(args_with(15, BatchCount::Four).estimate_cost_in_usd_cents(), 720);
}
fn test_session() -> AnyhowResult<Seedance2ProSession> {
let cookies = get_test_cookies()?;
Ok(Seedance2ProSession::from_cookies_string(cookies))
}
#[tokio::test]
#[ignore] // manually test — requires real cookies
async fn test_generate_text_to_video() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let args = GenerateVideoArgs {
session: &session,
prompt: "A corgi eating a cake in a fancy kitchen.".to_string(),
resolution: Resolution::Square1x1,
duration_seconds: 5,
batch_count: BatchCount::One,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
};
let result = generate_video(args).await?;
println!("Task ID: {}", result.task_id);
println!("Order ID: {}", result.order_id);
assert!(!result.task_id.is_empty());
assert!(!result.order_id.is_empty());
assert_eq!(1, 2); // NB: Intentional failure to inspect output.
Ok(())
}
#[tokio::test]
#[ignore] // manually test — requires real cookies
async fn test_generate_keyframe_video() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let args = GenerateVideoArgs {
session: &session,
prompt: "A dog shakes the glasses off its head. The camera pans out as the shiba shakes. The shiba barks.".to_string(),
resolution: Resolution::Landscape16x9,
duration_seconds: 15,
batch_count: BatchCount::One,
start_frame_url: Some("https://static.seedance2-pro.com/materials/20260219/1771496300184-fb32e08c.jpg".to_string()),
end_frame_url: None,
reference_image_urls: None,
};
let result = generate_video(args).await?;
println!("Task ID: {}", result.task_id);
println!("Order ID: {}", result.order_id);
assert!(!result.task_id.is_empty());
assert_eq!(1, 2); // NB: Intentional failure to inspect output.
Ok(())
}
#[tokio::test]
#[ignore] // manually test — requires real cookies
async fn test_generate_reference_video() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let args = GenerateVideoArgs {
session: &session,
prompt: "The dog in @2 is in the office at @1 without the man. The office is dark and moonlight streams in through the windows. Particles of dust gleam in the moon beams. Suddenly, the dog jumps walks in front of the desk and barks.".to_string(),
resolution: Resolution::Standard4x3,
duration_seconds: 10,
batch_count: BatchCount::One,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: Some(vec![
"https://static.seedance2-pro.com/materials/20260219/1771463564512-b14bfe90.png".to_string(),
"https://static.seedance2-pro.com/materials/20260219/1771496300184-fb32e08c.jpg".to_string(),
]),
};
let result = generate_video(args).await?;
println!("Task ID: {}", result.task_id);
println!("Order ID: {}", result.order_id);
assert!(!result.task_id.is_empty());
assert_eq!(1, 2); // NB: Intentional failure to inspect output.
Ok(())
}
}
@@ -0,0 +1,2 @@
pub mod generate_video;
mod request_types;
@@ -0,0 +1,60 @@
use serde_derive::{Deserialize, Serialize};
#[derive(Serialize)]
pub (super) struct BatchRequest {
#[serde(rename = "0")]
pub zero: BatchRequestInner,
}
#[derive(Serialize)]
pub (super) struct BatchRequestInner {
pub json: BatchRequestJson,
}
#[derive(Serialize)]
pub (super) struct BatchRequestJson {
#[serde(rename = "businessType")]
pub business_type: &'static str,
#[serde(rename = "apiParams")]
pub api_params: ApiParams,
}
#[derive(Serialize)]
pub (super) struct ApiParams {
pub prompt: String,
pub resolution: String,
pub mode: &'static str,
pub model: &'static str,
pub duration: String,
#[serde(rename = "videoInputMode")]
pub video_input_mode: &'static str,
#[serde(rename = "uploadedUrls", skip_serializing_if = "Option::is_none")]
pub uploaded_urls: Option<Vec<String>>,
#[serde(rename = "batchCount", skip_serializing_if = "Option::is_none")]
pub batch_count: Option<u8>,
}
#[derive(Deserialize, Debug)]
pub (super) struct BatchResponseItem {
pub result: BatchResponseResult,
}
#[derive(Deserialize, Debug)]
pub (super) struct BatchResponseResult {
pub data: BatchResponseData,
}
#[derive(Deserialize, Debug)]
pub (super) struct BatchResponseData {
pub json: TaskResponseJson,
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub (super) struct TaskResponseJson {
pub task_id: String,
pub order_id: String,
pub task_ids: Option<Vec<String>>,
pub order_ids: Option<Vec<String>>,
pub violation_warning: bool,
}
@@ -0,0 +1,4 @@
pub mod generate_video;
pub mod poll_orders;
pub mod prepare_image_upload;
pub mod upload_image;
@@ -0,0 +1,2 @@
pub mod poll_orders;
mod request_types;
@@ -0,0 +1,274 @@
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::error::seedance2pro_client_error::Seedance2ProClientError;
use crate::error::seedance2pro_error::Seedance2ProError;
use crate::error::seedance2pro_generic_api_error::Seedance2ProGenericApiError;
use crate::requests::poll_orders::request_types::*;
use crate::utils::common_headers::FIREFOX_USER_AGENT;
use log::info;
use wreq::Client;
use wreq_util::Emulation;
const GET_ORDERS_BASE_URL: &str = "https://seedance2-pro.com/api/trpc/userOrder.getOrders";
/// Builds the tRPC `input` JSON for the getOrders endpoint.
/// When `cursor` is `Some`, it is included in the JSON payload.
fn build_input_json(cursor: Option<u64>) -> String {
match cursor {
None => r#"{"0":{"json":{"limit":30,"format":null,"direction":"forward"},"meta":{"values":{"format":["undefined"]},"v":1}}}"#.to_string(),
Some(c) => format!(
r#"{{"0":{{"json":{{"limit":30,"format":null,"cursor":{cursor},"direction":"forward"}},"meta":{{"values":{{"format":["undefined"]}},"v":1}}}}}}"#,
cursor = c
),
}
}
// --- Args & response ---
pub struct PollOrdersArgs<'a> {
pub session: &'a Seedance2ProSession,
/// Optional cursor from a previous `PollOrdersResponse::next_cursor`.
/// When `None`, the most recent orders are returned.
pub cursor: Option<u64>,
}
pub struct PollOrdersResponse {
pub orders: Vec<OrderStatus>,
/// Present when there are more orders to fetch.
/// Pass this value as `PollOrdersArgs::cursor` in the next call.
pub next_cursor: Option<u64>,
}
// --- Public types ---
/// The lifecycle status of a video generation task.
#[derive(Debug, Clone, PartialEq)]
pub enum TaskStatus {
/// The task is queued and has not started yet.
Pending,
/// The task is actively being processed.
Processing,
/// The task finished successfully. `result_url` and `results` will be populated.
Completed,
/// The task failed. `fail_reason` will contain the reason.
Failed,
/// An unrecognised status string was returned by the server.
Unknown(String),
}
impl TaskStatus {
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)
}
}
/// A single generated video result attached to an order.
#[derive(Debug, Clone)]
pub struct VideoResult {
pub url: String,
pub width: u32,
pub height: u32,
// NB: We don't need these.
// /// Width / height ratio (e.g. 1.777… for 16:9). `None` when the server returns null (e.g. width/height are 0).
// pub ratio: Option<f64>,
}
/// The status of one order (one video generation task).
#[derive(Debug, Clone)]
pub struct OrderStatus {
pub order_id: String,
pub task_status: TaskStatus,
/// Top-level result video URL. Populated when `task_status` is `Completed`.
pub result_url: Option<String>,
/// Detailed result entries. Typically one entry per video.
pub results: Vec<VideoResult>,
/// Failure reason. Populated when `task_status` is `Failed`.
pub fail_reason: Option<String>,
/// ISO 8601 creation timestamp (e.g. `"2026-02-19T01:20:50.398Z"`).
pub created_at: String,
}
// --- Implementation ---
pub async fn poll_orders(args: PollOrdersArgs<'_>) -> Result<PollOrdersResponse, Seedance2ProError> {
info!("Polling orders (cursor: {:?})...", args.cursor);
let input_json = build_input_json(args.cursor);
let client = Client::builder()
.emulation(Emulation::Firefox143)
.build()
.map_err(|err| Seedance2ProClientError::WreqClientError(err))?;
let cookie = args.session.cookies.as_str();
let request = client.get(GET_ORDERS_BASE_URL)
.query(&[("batch", "1"), ("input", input_json.as_str())])
.header("User-Agent", FIREFOX_USER_AGENT)
.header("Accept", "*/*")
.header("Accept-Language", "en-US,en;q=0.9")
.header("Accept-Encoding", "gzip, deflate, br, zstd")
.header("Referer", "https://seedance2-pro.com/app/gallery")
.header("content-type", "application/json")
.header("x-trpc-source", "client")
.header("Connection", "keep-alive")
.header("Cookie", cookie)
.header("Sec-Fetch-Dest", "empty")
.header("Sec-Fetch-Mode", "cors")
.header("Sec-Fetch-Site", "same-origin")
.header("Priority", "u=4")
.header("TE", "trailers")
.build()
.map_err(|err| Seedance2ProClientError::WreqClientError(err))?;
let response = client.execute(request)
.await
.map_err(|err| Seedance2ProGenericApiError::WreqError(err))?;
let status = response.status();
let response_body = response.text()
.await
.map_err(|err| Seedance2ProGenericApiError::WreqError(err))?;
info!("Poll orders response status: {}", status);
if !status.is_success() {
return Err(Seedance2ProGenericApiError::UncategorizedBadResponseWithStatusAndBody {
status_code: status,
body: response_body,
}.into());
}
let batch_response: Vec<BatchResponseItem> = serde_json::from_str(&response_body)
.map_err(|err| Seedance2ProGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.clone()))?;
let json = batch_response
.into_iter()
.next()
.ok_or_else(|| Seedance2ProGenericApiError::UncategorizedBadResponse(
"Empty batch response array".to_string()
))?
.result
.data
.json;
let next_cursor = json.next_cursor;
let orders: Vec<OrderStatus> = json.orders
.into_iter()
.map(|o| OrderStatus {
order_id: o.order_id,
task_status: TaskStatus::from_str(&o.task_status),
result_url: o.result_url,
results: o.results.into_iter().map(|r| VideoResult {
url: r.url,
width: r.width,
height: r.height,
// ratio: r.ratio,
}).collect(),
fail_reason: o.fail_reason,
created_at: o.created_at,
})
.collect();
info!("Polled {} orders, next_cursor: {:?}", orders.len(), next_cursor);
Ok(PollOrdersResponse { orders, next_cursor })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::test_utils::get_test_cookies::get_test_cookies;
use crate::test_utils::setup_test_logging::setup_test_logging;
use errors::AnyhowResult;
use log::LevelFilter;
fn test_session() -> AnyhowResult<Seedance2ProSession> {
let cookies = get_test_cookies()?;
Ok(Seedance2ProSession::from_cookies_string(cookies))
}
#[tokio::test]
#[ignore] // manually test — requires real cookies
async fn test_poll_all_orders() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let result = poll_orders(PollOrdersArgs { session: &session, cursor: None }).await?;
println!("Orders returned: {}", result.orders.len());
println!("Next cursor: {:?}", result.next_cursor);
for order in &result.orders {
println!(" {} | {:?} | result_url={:?} | fail={:?}",
order.order_id, order.task_status, order.result_url, order.fail_reason);
}
assert_eq!(1, 2); // NB: Intentional failure to inspect output.
Ok(())
}
#[tokio::test]
#[ignore] // manually test — requires real cookies and a known cursor value
async fn test_poll_with_cursor() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
// Use the cursor value returned from a prior call (e.g. 394062 from the example responses).
let cursor: u64 = 394062;
let result = poll_orders(PollOrdersArgs { session: &session, cursor: Some(cursor) }).await?;
println!("Orders returned (with cursor {}): {}", cursor, result.orders.len());
println!("Next cursor: {:?}", result.next_cursor);
for order in &result.orders {
println!(" {} | {:?} | result_url={:?} | fail={:?}",
order.order_id, order.task_status, order.result_url, order.fail_reason);
}
assert_eq!(1, 2); // NB: Intentional failure to inspect output.
Ok(())
}
#[tokio::test]
#[ignore] // manually test — requires real cookies; exhausts all pages
async fn test_poll_all_pages() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let mut cursor: Option<u64> = None;
let mut page = 0usize;
let mut total_orders = 0usize;
loop {
page += 1;
let result = poll_orders(PollOrdersArgs { session: &session, cursor }).await?;
let page_count = result.orders.len();
total_orders += page_count;
println!("Page {}: {} orders, next_cursor: {:?}", page, page_count, result.next_cursor);
for order in &result.orders {
println!(" {} | {:?}", order.order_id, order.task_status);
}
cursor = result.next_cursor;
if cursor.is_none() {
break;
}
}
println!("Total orders across {} pages: {}", page, total_orders);
assert_eq!(1, 2); // NB: Intentional failure to inspect output.
Ok(())
}
}
@@ -0,0 +1,42 @@
use serde_derive::Deserialize;
#[derive(Deserialize, Debug)]
pub(super) struct BatchResponseItem {
pub result: BatchResponseResult,
}
#[derive(Deserialize, Debug)]
pub(super) struct BatchResponseResult {
pub data: BatchResponseData,
}
#[derive(Deserialize, Debug)]
pub(super) struct BatchResponseData {
pub json: OrdersResponseJson,
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub(super) struct OrdersResponseJson {
pub orders: Vec<RawOrder>,
pub next_cursor: Option<u64>,
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub(super) struct RawOrder {
pub order_id: String,
pub result_url: Option<String>,
pub task_status: String,
pub results: Vec<RawVideoResult>,
pub fail_reason: Option<String>,
pub created_at: String,
}
#[derive(Deserialize, Debug)]
pub(super) struct RawVideoResult {
pub url: String,
pub width: u32,
pub height: u32,
// pub ratio: Option<f64>,
}
@@ -0,0 +1,2 @@
pub mod prepare_image_upload;
mod request_types;
@@ -0,0 +1,140 @@
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::error::seedance2pro_client_error::Seedance2ProClientError;
use crate::error::seedance2pro_error::Seedance2ProError;
use crate::error::seedance2pro_generic_api_error::Seedance2ProGenericApiError;
use crate::requests::prepare_image_upload::request_types::*;
use crate::utils::common_headers::FIREFOX_USER_AGENT;
use chrono::Utc;
use log::info;
use rand::Rng;
use wreq::Client;
use wreq_util::Emulation;
const SIGNED_UPLOAD_URL: &str = "https://seedance2-pro.com/api/trpc/uploads.signedUploadUrl?batch=1";
/// Generates a material path based on the current time.
/// Format: `materials/YYYYMMDD/<unix_millis>-<random_hex>.png`
fn generate_material_path() -> String {
let now = Utc::now();
let date_part = now.format("%Y%m%d").to_string();
let timestamp_millis = now.timestamp_millis();
let random_hex: u32 = rand::rng().random();
let hex_part = format!("{:08x}", random_hex);
format!("materials/{}/{}-{}.png", date_part, timestamp_millis, hex_part)
}
pub struct PrepareImageUploadArgs<'a> {
pub session: &'a Seedance2ProSession,
}
pub struct PrepareImageUploadResponse {
/// The signed URL to upload the image to (Cloudflare R2 / S3-compatible).
pub upload_url: String,
/// The material path that was generated for this upload.
pub material_path: String,
}
pub async fn prepare_image_upload(args: PrepareImageUploadArgs<'_>) -> Result<PrepareImageUploadResponse, Seedance2ProError> {
let material_path = generate_material_path();
info!("Preparing image upload with path: {}", material_path);
let client = Client::builder()
.emulation(Emulation::Firefox143)
.build()
.map_err(|err| Seedance2ProClientError::WreqClientError(err))?;
let request_body = BatchRequest {
zero: BatchRequestInner {
json: BatchRequestJson {
path: material_path.clone(),
},
},
};
let cookie = args.session.cookies.as_str();
let response = client.post(SIGNED_UPLOAD_URL)
.header("User-Agent", FIREFOX_USER_AGENT)
.header("Accept", "*/*")
.header("Accept-Language", "en-US,en;q=0.9")
.header("Accept-Encoding", "gzip, deflate, br, zstd")
.header("Referer", "https://seedance2-pro.com/")
.header("Content-Type", "application/json")
.header("x-trpc-source", "client")
.header("Origin", "https://seedance2-pro.com")
.header("Connection", "keep-alive")
.header("Cookie", cookie)
.header("Sec-Fetch-Dest", "empty")
.header("Sec-Fetch-Mode", "cors")
.header("Sec-Fetch-Site", "same-origin")
.header("Priority", "u=4")
.header("TE", "trailers")
.json(&request_body)
.send()
.await
.map_err(|err| Seedance2ProGenericApiError::WreqError(err))?;
let status = response.status();
let response_body = response.text()
.await
.map_err(|err| Seedance2ProGenericApiError::WreqError(err))?;
info!("Response status: {}, body: {}", status, response_body);
if !status.is_success() {
return Err(Seedance2ProGenericApiError::UncategorizedBadResponseWithStatusAndBody {
status_code: status,
body: response_body,
}.into());
}
let batch_response: Vec<BatchResponseItem> = serde_json::from_str(&response_body)
.map_err(|err| Seedance2ProGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.clone()))?;
let upload_url = batch_response
.into_iter()
.next()
.ok_or_else(|| Seedance2ProGenericApiError::UncategorizedBadResponse(
"Empty batch response array".to_string()
))?
.result
.data
.json;
Ok(PrepareImageUploadResponse {
upload_url,
material_path,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::test_utils::get_test_cookies::get_test_cookies;
use crate::test_utils::setup_test_logging::setup_test_logging;
use errors::AnyhowResult;
use log::LevelFilter;
#[tokio::test]
#[ignore] // manually test — requires real cookies
async fn test_prepare_image_upload() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let cookies = get_test_cookies()?;
let session = Seedance2ProSession::from_cookies_string(cookies);
let args = PrepareImageUploadArgs {
session: &session,
};
let result = prepare_image_upload(args).await?;
println!("Upload URL: {}", result.upload_url);
println!("Material path: {}", result.material_path);
assert!(!result.upload_url.is_empty());
assert!(result.upload_url.contains("cloudflarestorage.com"));
assert!(result.material_path.starts_with("materials/"));
assert_eq!(1, 2); // NB: Intentional failure to check the response.
Ok(())
}
}
@@ -0,0 +1,32 @@
use serde_derive::{Deserialize, Serialize};
#[derive(Serialize)]
pub(super) struct BatchRequest {
#[serde(rename = "0")]
pub zero: BatchRequestInner,
}
#[derive(Serialize)]
pub(super) struct BatchRequestInner {
pub json: BatchRequestJson,
}
#[derive(Serialize)]
pub(super) struct BatchRequestJson {
pub path: String,
}
#[derive(Deserialize, Debug)]
pub(super) struct BatchResponseItem {
pub result: BatchResponseResult,
}
#[derive(Deserialize, Debug)]
pub(super) struct BatchResponseResult {
pub data: BatchResponseData,
}
#[derive(Deserialize, Debug)]
pub(super) struct BatchResponseData {
pub json: String,
}
@@ -0,0 +1 @@
pub mod upload_image;
@@ -0,0 +1,127 @@
use crate::error::seedance2pro_client_error::Seedance2ProClientError;
use crate::error::seedance2pro_error::Seedance2ProError;
use crate::error::seedance2pro_generic_api_error::Seedance2ProGenericApiError;
use crate::utils::common_headers::FIREFOX_USER_AGENT;
use log::info;
use url::Url;
use wreq::Client;
use wreq_util::Emulation;
const STATIC_BASE_URL: &str = "https://static.seedance2-pro.com";
pub struct UploadImageArgs {
/// The signed upload URL returned by `prepare_image_upload`.
pub upload_url: String,
/// The raw image bytes to upload.
pub image_bytes: Vec<u8>,
}
pub struct UploadImageResponse {
/// The public-facing URL for the uploaded image.
/// e.g. `https://static.seedance2-pro.com/materials/20260219/1771463564512-b14bfe90.png`
pub public_url: String,
}
/// Extracts the path from the R2 upload URL and builds the static public URL.
/// e.g. `https://comm.….r2.cloudflarestorage.com/materials/20260219/…?X-Amz-…`
/// -> `https://static.seedance2-pro.com/materials/20260219/…`
fn build_public_url(upload_url: &str) -> Result<String, Seedance2ProError> {
let parsed = Url::parse(upload_url)
.map_err(|err| Seedance2ProClientError::UrlParseError(err))?;
let path = parsed.path(); // e.g. "/materials/20260219/1771463564512-b14bfe90.png"
Ok(format!("{}{}", STATIC_BASE_URL, path))
}
pub async fn upload_image(args: UploadImageArgs) -> Result<UploadImageResponse, Seedance2ProError> {
info!("Uploading image to: {}", args.upload_url);
let client = Client::builder()
.emulation(Emulation::Firefox143)
.build()
.map_err(|err| Seedance2ProClientError::WreqClientError(err))?;
let response = client.put(&args.upload_url)
.header("User-Agent", FIREFOX_USER_AGENT)
.header("Accept", "*/*")
.header("Accept-Language", "en-US,en;q=0.9")
.header("Accept-Encoding", "gzip, deflate, br, zstd")
.header("Referer", "https://seedance2-pro.com/")
.header("Origin", "https://seedance2-pro.com")
.header("Connection", "keep-alive")
.header("Sec-Fetch-Dest", "empty")
.header("Sec-Fetch-Mode", "cors")
.header("Sec-Fetch-Site", "cross-site")
.header("Priority", "u=4")
.body(args.image_bytes)
.send()
.await
.map_err(|err| Seedance2ProGenericApiError::WreqError(err))?;
let status = response.status();
info!("Upload response status: {}", status);
if !status.is_success() {
let body = response.text()
.await
.map_err(|err| Seedance2ProGenericApiError::WreqError(err))?;
return Err(Seedance2ProGenericApiError::UncategorizedBadResponseWithStatusAndBody {
status_code: status,
body,
}.into());
}
let public_url = build_public_url(&args.upload_url)?;
info!("Public URL: {}", public_url);
Ok(UploadImageResponse { public_url })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::test_utils::get_test_cookies::get_test_cookies;
use crate::test_utils::setup_test_logging::setup_test_logging;
use crate::requests::prepare_image_upload::prepare_image_upload::{
prepare_image_upload, PrepareImageUploadArgs,
};
use errors::AnyhowResult;
use log::LevelFilter;
use std::fs;
#[tokio::test]
#[ignore] // manually test — requires real cookies and a test image
async fn test_upload_image() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
// Step 1: Get a signed upload URL
let cookies = get_test_cookies()?;
let session = Seedance2ProSession::from_cookies_string(cookies);
let prepare_args = PrepareImageUploadArgs {
session: &session,
};
let prepare_result = prepare_image_upload(prepare_args).await?;
println!("Upload URL: {}", prepare_result.upload_url);
// Step 2: Read a test image
let image_bytes = fs::read("/Users/bt/dev/storyteller/artcraft/test_data/image/juno.jpg")?;
println!("Image size: {} bytes", image_bytes.len());
// Step 3: Upload
let upload_args = UploadImageArgs {
upload_url: prepare_result.upload_url,
image_bytes,
};
let result = upload_image(upload_args).await?;
println!("Public URL: {}", result.public_url);
assert!(result.public_url.starts_with("https://static.seedance2-pro.com/materials/"));
assert_eq!(1, 2); // NB: Intentional failure to check the response.
Ok(())
}
}
@@ -0,0 +1,9 @@
use errors::AnyhowResult;
use std::fs::read_to_string;
#[cfg(test)]
pub fn get_test_cookies() -> AnyhowResult<String> {
let cookies = read_to_string("/Users/bt/Artcraft/credentials/seedance2pro_cookies.txt")?;
let cookies = cookies.trim().to_string();
Ok(cookies)
}
@@ -0,0 +1,5 @@
#[cfg(test)]
pub (crate) mod get_test_cookies;
#[cfg(test)]
pub (crate) mod setup_test_logging;
@@ -0,0 +1,52 @@
use chrono::Local;
use env_logger::Builder;
use log::{debug, error, info, trace, warn, LevelFilter};
use std::io::Write;
const RUST_LOG: &str = "RUST_LOG";
pub fn setup_test_logging(level: LevelFilter) {
let level_str = get_level_str(level);
println!("Previous log level: {:?}", env::var(RUST_LOG));
let is_unsafe = env::set_var(RUST_LOG, level_str);
if is_unsafe.is_none() {
println!("Unsafely setting Rust Log Level...");
unsafe { std::env::set_var(RUST_LOG, level_str) }
}
println!("Log level: {:?}", env::var(RUST_LOG));
Builder::new()
.is_test(true)
.format(|buf, record| {
writeln!(buf,
"{} [{}] - {}",
Local::now().format("%H:%M:%S%.6f"),
record.level(),
record.args()
)
})
.filter(None, level)
.filter_level(level)
.init();
trace!("Test trace log");
debug!("Test debug log");
info!("Test info log");
warn!("Test warn log");
error!("Test error log");
}
fn get_level_str(level: LevelFilter) -> &'static str {
match level {
LevelFilter::Error => "error",
LevelFilter::Warn => "warn",
LevelFilter::Info => "info",
LevelFilter::Debug => "debug",
LevelFilter::Trace => "trace",
LevelFilter::Off => "off",
}
}
@@ -0,0 +1,2 @@
pub const FIREFOX_USER_AGENT: &str =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:147.0) Gecko/20100101 Firefox/147.0";
@@ -0,0 +1 @@
pub mod common_headers;
@@ -12,8 +12,10 @@ normal = ["workspace-build-acceleration"]
# Workspace hack (faster compile times)
workspace-build-acceleration.workspace = true
# External
# Internal
easyenv = { path = "../../lib/easyenv" }
# External
log.workspace = true
reqwest = { version = "0.12.4", default-features = false, features = [ "charset", "http2", "system-proxy", "rustls-tls" ] }
serde_json.workspace = true
+2 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "artcraft"
version = "0.6.0"
version = "0.8.0"
description = "Artcraft"
authors = ["you"]
license = ""
@@ -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))
}
}
}
}
@@ -0,0 +1,70 @@
use crate::core::commands::enqueue::generate_error::GenerateError;
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::state::app_env_configs::app_env_configs::AppEnvConfigs;
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 log::{error, info};
use storyteller_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
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 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 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 plan = begin_video_generation(&router_request)?;
info!("Video Generation Plan: {:?}", plan);
let response = match plan.generate_video(&client).await {
Ok(resp) => {
info!("Successfully enqueued.");
resp
}
Err(err) => {
error!("Failed to enqueue: {:?}", err);
return Err(GenerateError::from(err));
}
};
Ok(TaskEnqueueSuccess {
task_type: TaskType::VideoGeneration,
model: Some(GenerationModel::Seedance2p0),
provider: GenerationProvider::Artcraft,
provider_job_id: Some(response.inference_job_token.to_string()),
})
}
@@ -6,6 +6,7 @@ use crate::core::commands::enqueue::image_to_video::artcraft::handle_artcraft_kl
use crate::core::commands::enqueue::image_to_video::artcraft::handle_artcraft_kling_2p5_turbo_pro::handle_artcraft_kling_2p5_turbo_pro;
use crate::core::commands::enqueue::image_to_video::artcraft::handle_artcraft_kling_2p6_pro::handle_artcraft_kling_2p6_pro;
use crate::core::commands::enqueue::image_to_video::artcraft::handle_artcraft_seedance_1_lite::handle_artcraft_seedance_1_lite;
use crate::core::commands::enqueue::image_to_video::artcraft::handle_artcraft_seedance_2p0::handle_artcraft_seedance_2p0;
use crate::core::commands::enqueue::image_to_video::artcraft::handle_artcraft_sora2::handle_artcraft_sora2;
use crate::core::commands::enqueue::image_to_video::artcraft::handle_artcraft_sora2_pro::handle_artcraft_sora2_pro;
use crate::core::commands::enqueue::image_to_video::artcraft::handle_artcraft_veo2::handle_artcraft_veo2;
@@ -39,6 +40,7 @@ pub async fn handle_video_artcraft(
Some(VideoModel::Kling2p5TurboPro) => handle_artcraft_kling_2p5_turbo_pro(request, app_env_configs, &creds).await,
Some(VideoModel::Kling2p6Pro) => handle_artcraft_kling_2p6_pro(request, app_env_configs, &creds).await,
Some(VideoModel::Seedance10Lite) => handle_artcraft_seedance_1_lite(request, app_env_configs, &creds).await,
Some(VideoModel::Seedance2p0) => handle_artcraft_seedance_2p0(request, app_env_configs, &creds).await,
Some(VideoModel::Sora2) => handle_artcraft_sora2(request, app_env_configs, &creds).await,
Some(VideoModel::Sora2Pro) => handle_artcraft_sora2_pro(request, app_env_configs, &creds).await,
Some(VideoModel::Veo2) => handle_artcraft_veo2(request, app_env_configs, &creds).await,
@@ -5,6 +5,7 @@ mod handle_artcraft_kling_2p1_pro;
mod handle_artcraft_kling_2p5_turbo_pro;
mod handle_artcraft_kling_2p6_pro;
mod handle_artcraft_seedance_1_lite;
mod handle_artcraft_seedance_2p0;
mod handle_artcraft_sora2;
mod handle_artcraft_sora2_pro;
mod handle_artcraft_veo2;
@@ -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};
@@ -56,6 +57,9 @@ pub enum VideoModel {
#[serde(rename = "seedance_1.0_lite")]
Seedance10Lite,
#[serde(rename = "seedance_2p0")]
Seedance2p0,
#[serde(rename = "sora_2")]
Sora2,
@@ -98,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>,
@@ -107,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>,
@@ -82,6 +82,7 @@ impl TaskEnqueueSuccess{
Some(GenerationModel::Kling2p5TurboPro) => Some(TaskModelType::Kling2p5TurboPro),
Some(GenerationModel::Kling2p6Pro) => Some(TaskModelType::Kling2p6Pro),
Some(GenerationModel::Seedance10Lite) => Some(TaskModelType::Seedance10Lite),
Some(GenerationModel::Seedance2p0) => Some(TaskModelType::Seedance2p0),
Some(GenerationModel::Sora2) => Some(TaskModelType::Sora2),
Some(GenerationModel::Sora2Pro) => Some(TaskModelType::Sora2Pro),
Some(GenerationModel::Veo2) => Some(TaskModelType::Veo2),
@@ -86,6 +86,8 @@ pub enum GenerationModel {
Sora2Pro,
#[serde(rename = "seedance_1.0_lite")]
Seedance10Lite,
#[serde(rename = "seedance_2p0")]
Seedance2p0,
#[serde(rename = "veo_2")]
Veo2,
#[serde(rename = "veo_3")]
+1 -1
View File
@@ -1,4 +1,4 @@
/// The version of the ArtCraft desktop app reported in the UI and via Analytics.
/// This should match the CI builds.
pub const ARTCRAFT_VERSION: &str = "0.6.0";
pub const ARTCRAFT_VERSION: &str = "0.8.0";
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2.0.0-rc",
"productName": "ArtCraft",
"version": "0.6.0",
"version": "0.8.0",
"identifier": "ai.artcraft.app",
"build": {
"frontendDist": "../../../frontend/apps/artcraft/dist",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2.0.0-rc",
"productName": "ArtCraft",
"version": "0.6.0",
"version": "0.8.0",
"identifier": "ai.artcraft.app",
"build": {
"frontendDist": "../../../frontend/apps/artcraft/dist",
@@ -16,4 +16,5 @@ pub fn add_artcraft_3d(cors: Cors, _is_production: bool) -> Cors {
.allowed_origin("http://localhost:5175") // If already started
.allowed_origin("https://macaroni-1.tailce84f.ts.net") // TODO
.allowed_origin("https://halide.tailce84f.ts.net") // TODO
.allowed_origin("https://brandons-macbook-pro.taild62114.ts.net") // TODO
}
+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.
}
}

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