Compare commits

...
Author SHA1 Message Date
BFlat aa5fad440f reuse gallery modal for the artcraft website and app styling aligned 2026-04-22 17:10:17 +07:00
Brandon Thomas bc226fb361 omni gpt image 2 (#1414)
this will enable it in storyteller-web
2026-04-22 01:11:42 -04:00
Brandon Thomas 7817f3af70 fal gpt image 2 (#1413) 2026-04-21 21:53:14 -04:00
Brandon Thomas cf1f4eb8c8 gpt image 2 enum (#1412) 2026-04-21 21:08:28 -04:00
Brandon Thomas 2e6ccf0d00 artcraft v2 router for seedance 2.0 (non-fast) (#1411) 2026-04-21 20:32:41 -04:00
Brandon Thomas a560541b62 import cleanup 2026-04-21 18:43:20 -04:00
Brandon Thomas df4e300119 artcraft_router v2 kinovi seedance 2 fast (#1410) 2026-04-21 18:34:49 -04:00
Brandon Thomas 9bd3726d7a artcraft router redesign (#1409) 2026-04-21 17:45:54 -04:00
BFlat fc0f4b7f5c fix: prevent character mention from matching wrong character 2026-04-21 23:20:00 +07:00
Brandon Thomas 94fdd6cb72 rename struct to better enable a state machine flow (#1406) 2026-04-20 18:14:24 -04:00
BFlat 0d235bfe27 gallery card action buttons (#1405)
- Quick actions on gallery cards: Recreate, Share, and Download buttons
now appear on hover directly on every image/video card, so users can act
on content without opening the lightbox first.
- Media type and model badges are larger and restyled for better
readability on thumbnails.
- The library page now uses the same card component as the generation
gallery, so any future card improvements land everywhere at once.
- Library grid tightened from up to 6 columns to a cleaner max of 5 per
row.
- Removed the dotted background on the library page so skeletons and
cards sit on a solid surface.
2026-04-20 23:58:24 +07:00
BFlat 6883b64276 website: unified lightbox, recreate, desktop CTA, prompt fixes (#1404)
## Artcraft website: unified lightbox, recreate, desktop CTA, prompt
fixes

**Lightbox**
- Gallery modal and `/media/:id` page now share one details component -
no more drift.
- Author sticks to the top when you scroll; `X` moved to the details
panel; "Show more" prompt toggle added to the media page.
- Shows aspect ratio, resolution, duration, and audio on/off.

**Recreate (new)**
- Button in the lightbox restores prompt, refs (image / end-frame /
video / audio), model, aspect, resolution, duration, audio toggle, and
input mode from a past generation.
- Routes to Create Image vs Create Video based on the saved model class,
not URL guessing.
- Does not auto-generate.

**Desktop CTA**
- Dismissible banner above the promptbox linking to the desktop app.
Hidden on mobile, reappears 24h after dismissal.

**Model names**
- Model selector uses `full_name` from the OmniGen API so new models
show up correctly without a code change.
- Added Seedance 2.0 Fast to the shared model list for the lightbox
details panel.

**Promptbox `@mentions`**
- Triggers correctly after Chinese, punctuation, or emoji - not just
whitespace.
- Case-insensitive: `@image1` highlights as `@Image1`.
2026-04-20 21:20:27 +07:00
Brandon Thomas 2c9a78b802 rename struct 2026-04-19 22:04:43 -04:00
BFlat 6542834336 fix: video promptbox layout bugs (#1403)
fixes:
- prompt box no longer overlaps bottom buttons or wastes space on big
screens
- long unbroken text wraps instead of escaping the box
- resizing the window updates the layout without needing to refocus
2026-04-20 07:36:47 +07:00
bflatastic 6a2719c742 website download link update 2026-04-20 06:57:24 +07:00
Brandon Thomas 38219a4abf link website omni video jobs to ledger entries for refunds (#1402) 2026-04-19 15:03:20 -04:00
Brandon Thomas 3fc002d793 quick refunds hack for kinovi (#1401)
code is actually solid, but the old omni pipeline itself is a hack on
top of an incomplete artcraft_router, so this will be replaced when
we're done.
2026-04-19 13:51:57 -04:00
Brandon Thomas c0c17b27b5 1080p support (quick) (#1400) 2026-04-18 18:15:35 -04:00
Brandon Thomas b68b8f7d71 simplify artcraft_router (#1399) 2026-04-18 08:40:49 -04:00
Brandon Thomas 3d9b4be7ab rename field (#1398) 2026-04-18 08:10:36 -04:00
Brandon Thomas 489390c881 refacor kinovi generate_video request (#1397) 2026-04-18 07:48:52 -04:00
Brandon Thomas f5338611ac seedance 2.0 resolution options (#1396) 2026-04-17 20:51:17 -04:00
Brandon Thomas 11fc5746ec seedance 1080p (#1395) 2026-04-17 20:31:01 -04:00
Tanish Manku 47383daba8 Experimental: Storyboard page (#1394) 2026-04-17 15:32:04 -07:00
Tanish Manku 9166985418 Experimental Menu: Hidden experimental settings menu via 7x version click (#1393) 2026-04-17 14:17:34 -07:00
BFlat 8fc488551f website: bug fixes (#1391)
## Website bug fixes: Seedance Fast + Characters + Toasts

### Create Video page
- Switching between Seedance and Seedance Fast no longer loses your
attached references
- Seedance Fast now supports Characters (was missing from the UI)
- Characters work in both Keyframe and Reference modes
- Fixed a bug where typing `@Bob2` could accidentally attach an older
character named `@Bob`

### Task queue notifications
- No more ghost "job complete" toasts every time you reload the site
- Cleaner wording: "Image creation complete" instead of "Image complete
— Image Generation"
- Character jobs now say "Character" instead of the generic "Task"

### Toast redesign
- Matches the prompt box style instead of the old bright green
- Success = green circle with a white checkmark, error = red circle with
a white exclamation
- Centered on mobile, stays top-right on desktop
- Message no longer awkwardly wraps onto multiple lines on mobile
2026-04-17 15:39:20 +07:00
Brandon Thomas 3a9cebc1b8 omni character support (#1390) 2026-04-17 03:48:35 -04:00
205 changed files with 14438 additions and 4671 deletions
+19 -1
View File
@@ -42,6 +42,21 @@ then the story, then the fine print.
- A method that calls another method in the same file should appear *above* the method
it calls. This lets readers follow the call chain downward.
### Ordering within test modules
Put test cases first, helpers last. A reader opening a test file should immediately see
*what* is being tested before *how* the test machinery works.
1. **Imports**
2. **Constants** (test IDs, URLs, fixture data) — small context that tests reference
3. **Test functions** or **sub-modules** grouped by category
4. **Helper functions** (builders, pipeline runners, assertions)
Group related tests into sub-modules (`mod pricing_tests { ... }`) when a category has 2+
tests. Don't create a sub-module for a single test. Keep nesting to two levels max
(`mod tests { mod category { ... } }`). Shared helpers go in the parent module so
sub-modules can reach them via `use super::*`.
## Naming
- Handler functions: `{verb}_{noun}_handler` (e.g. `get_health_check_handler`, `login_handler`)
@@ -53,7 +68,10 @@ then the story, then the fine print.
## Imports
- Group imports: std, external crates, internal workspace crates, `crate::` imports
- Use fully qualified paths for one-off references; `use` for repeated references
- Prefer `use` imports over inline fully-qualified paths. Inline qualification is acceptable
only for genuine one-off references where adding an import would be noise, or for types
that collide with std prelude names (`Result`, `Option`, `Error`). If you reference a symbol
more than once, always import it.
- Prefer specific imports over wildcards, except in `api_doc.rs` and test modules
- When two crates export a type with the same name, alias with a suffix describing the source crate:
`use enums::...::CommonResolution as CommonResolutionEnum;`
+27 -1
View File
@@ -10,10 +10,28 @@
## Test Patterns
- Tests live in `#[cfg(test)] mod tests { ... }` at the bottom of each file
- Group related tests in sub-modules: `mod explicit_checks`, `mod mechanical_checks`
- Group related tests into sub-modules by category (e.g. `mod pricing_tests`, `mod resolution_tests`)
- Only create a sub-module when it has 2+ tests — don't wrap a single test in its own module
- Keep nesting shallow: `mod tests { mod category { ... } }` is fine; a third level is rarely needed
- Shared helpers used by multiple sub-modules belong in the parent `mod tests`, not duplicated in each sub-module
- Enum tests should cover: serialization, deserialization, `to_str`/`from_str` round-trip, variant count
- Password/crypto tests: be mindful of computation cost, don't add too many bcrypt test cases
## Test File Layout
Put test cases first, helpers last. Engineers should see the actual tests before scrolling
to understand helper plumbing.
Within a `mod tests { ... }` block:
1. **Imports** at the top
2. **Constants** (test data IDs, URLs, etc.) — small context that tests reference
3. **Test functions** (`#[test]` / `#[tokio::test]`) grouped by category
4. **Helper functions** (builders, pipeline runners, etc.) at the very bottom
If tests are grouped into sub-modules, the same rule applies within each sub-module:
constants first, then tests, then helpers at the end of that sub-module.
## What to Test
- New error types and their mappings
@@ -21,6 +39,14 @@
- Hash/deduplication functions with known inputs
- Edge cases in parsers (empty input, missing fields, malformed data)
## Imports in Tests
- Import test fixtures and constants with `use`, not inline fully-qualified paths.
Write `use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL;` at the top,
then reference `JUNO_AT_LAKE_IMAGE_URL` in test bodies.
- Same rule applies to enum variants used in match arms — import them, don't write
`crate::some::deep::module::MyEnum::Variant` inline.
## What Not to Test
- Don't add integration tests that require a running database unless explicitly asked
+2
View File
@@ -62,6 +62,7 @@ artcraft/
- A mix of wreq and reqwest for Rust HTTP clients
- Never use `println!` or `eprintln!` outside of tests; use `log` crate macros instead
- When two crates export the same type name, alias imports with a suffix: `use foo::Bar as BarFoo;`
- Prefer `use` imports over inline fully-qualified paths; only qualify inline for true one-offs or std collisions
- TypeScript with Nx, React, Vite, Zustand, and Three.js
- Use two spaces for indentation
@@ -75,3 +76,4 @@ Organize for top-to-bottom reading. Important things first, details later.
- **In impl blocks**: constructors first, then public methods, then private helpers
- Private helpers go *below* the methods that call them
- Among helpers: meatier logic above leaf-level formatters
- **In test modules**: constants first, then test cases (grouped into sub-modules when 2+), then helper functions last
+59
View File
@@ -0,0 +1,59 @@
# Rust Workspace
All Rust code lives under `crates/`. The workspace is defined in `Cargo.toml` at the repo root.
## Code Style
- Two spaces for indentation (not four)
- No minimum supported Rust version; use latest stable features freely
- Never use `println!` or `eprintln!` outside of tests; use `log` crate macros instead
- Use `anyhow::Result` / `AnyhowResult` for fallible functions in application code
- Fields that are optional use the `maybe_` prefix: `maybe_creator_user_token`
- When two crates export the same type name, alias with a suffix: `use foo::Bar as BarFoo;`
- Prefer `use` imports over inline fully-qualified paths; only qualify inline for true one-offs or std prelude collisions (`Result`, `Option`, `Error`)
## File Layout
Organize for top-to-bottom reading. Important things first, details later.
1. **Constants** at the top (after imports)
2. **Primary public type** (the main struct/enum the file defines)
3. **Supporting types** below the primary type
4. **API types** in order: Request, Response, Error
5. **`impl` blocks** after their type definitions
- Constructors first, then public methods, then private helpers
- Callers above callees — follow the call chain downward
## Test Layout
Within `#[cfg(test)] mod tests { ... }`:
1. **Imports**
2. **Constants** (test IDs, URLs, fixture data) — small context that tests reference
3. **Test functions or sub-modules** grouped by category
4. **Helper functions** (builders, pipeline runners, assertions)
Constants provide small context up front. Test cases come next so engineers see *what* is
tested before *how* the helpers work.
Group related tests into sub-modules (`mod pricing_tests { ... }`) when a category has 2+
tests. Don't wrap a single test in its own module. Keep nesting to two levels max. Shared
helpers go in the parent `mod tests` so sub-modules can `use super::*`.
## Building
- Most crates: `cargo check -p {crate_name}` or `cargo test -p {crate_name}`
- Crates using SQLx: `SQLX_OFFLINE=true cargo check -p {crate_name}`
- `storyteller-web` and `mysql_queries` require `SQLX_OFFLINE=true`
- `storyteller-web` has ~370 pre-existing warnings — check the last few lines for actual errors
## Key Crates
- `storyteller_web` — main HTTP API monolith (actix-web)
- `artcraft` (desktop) — Tauri desktop app
- `artcraft_router` — provider routing for image/video generation (Artcraft, Fal, Seedance2Pro, Muapi)
- `artcraft_api_defs` — shared HTTP API type definitions
- `seedance2pro_client` — HTTP client for the Kinovi/Seedance2Pro video generation service
- `enums` — database-backed enums stored as VARCHAR fields
- `tokens` — primary key identifiers with Stripe-like prefixes (e.g. `user_`, `mf_`)
- `mysql_queries` — SQLx MySQL queries for the backend
@@ -7,7 +7,7 @@ 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)]
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct Seedance2p0MultiFunctionVideoGenRequest {
/// Idempotency token to prevent duplicate requests.
pub uuid_idempotency_token: String,
@@ -37,6 +37,9 @@ pub struct Seedance2p0MultiFunctionVideoGenRequest {
/// Video aspect ratio / resolution.
pub aspect_ratio: Option<Seedance2p0AspectRatio>,
/// Output resolution quality tier (480p, 720p, 1080p).
pub output_resolution: Option<Seedance2p0OutputResolution>,
/// Duration in seconds (4–15).
pub duration_seconds: Option<u8>,
@@ -67,6 +70,17 @@ pub enum Seedance2p0BatchCount {
Four,
}
#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum Seedance2p0OutputResolution {
/// 480p
FourEightyP,
/// 720p (default)
SevenTwentyP,
/// 1080p
TenEightyP,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct Seedance2p0MultiFunctionVideoGenResponse {
pub success: bool,
@@ -0,0 +1 @@
pub mod omni_gen_image;
@@ -0,0 +1,21 @@
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::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::OmniGenImageCostAndGenerateRequest;
use artcraft_api_defs::omni_gen::generate_response::omni_gen_image_generate_response::OmniGenImageGenerateResponse;
pub const OMNI_GEN_IMAGE_GENERATE_PATH: &str = "/v1/omni_gen/generate/image";
pub async fn omni_gen_image_generate(
api_host: &ApiHost,
maybe_creds: Option<&StorytellerCredentialSet>,
request: OmniGenImageCostAndGenerateRequest,
) -> Result<OmniGenImageGenerateResponse, StorytellerError> {
Ok(basic_json_post_request(
api_host,
OMNI_GEN_IMAGE_GENERATE_PATH,
maybe_creds,
request,
).await?)
}
@@ -1 +1,2 @@
pub mod image;
pub mod video;
@@ -17,6 +17,8 @@ pub enum CommonImageModel {
GptImage1,
#[serde(rename = "gpt_image_1p5")]
GptImage1p5,
#[serde(rename = "gpt_image_2")]
GptImage2,
#[serde(rename = "nano_banana")]
NanoBanana,
#[serde(rename = "nano_banana_2")]
@@ -15,6 +15,9 @@ pub enum ArtcraftRouterError {
/// The requested model is not yet supported by the router.
UnsupportedModel(String),
/// [Temporary during migration] The requested model/provider is not yet supported by the router.
UnsupportedProviderAndModelForNewApi(String),
/// Invalid or missing input arguments.
InvalidInput(String),
@@ -30,6 +33,7 @@ impl Display for ArtcraftRouterError {
Self::Client(e) => write!(f, "Client error: {}", e),
Self::Download(e) => write!(f, "Download error: {}", e),
Self::UnsupportedModel(model) => write!(f, "Unsupported model: {}", model),
Self::UnsupportedProviderAndModelForNewApi(msg) => write!(f, "Unsupported provider/model (for new API during migration): {}", msg),
Self::InvalidInput(msg) => write!(f, "Invalid input: {}", msg),
Self::Provider(e) => write!(f, "Provider error: {}", e),
}
@@ -1,5 +1,7 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::media_files::MediaFileToken;
#[derive(Debug, Clone, Copy)]
pub enum ClientType {
@@ -22,6 +24,9 @@ impl Display for ClientType {
#[derive(Debug)]
pub enum ClientError {
/// A RouterClient was required but not provided on the draft context.
RouterClientNotProvided,
/// The requested client is not configured on the RouterClient.
ClientNotConfigured(ClientType),
@@ -43,6 +48,18 @@ pub enum ClientError {
/// Seedance2Pro only accepts URLs for media inputs, not media tokens.
Seedance2ProOnlySupportsUrls,
/// The pre-dispatch context of media file token to URL map was not supplied.
MediaFileToUrlMapNotProvided,
/// A media file token was not found in the provided media-file-to-URL map.
MediaFileTokenNotFoundInMap { token: MediaFileToken },
/// The pre-dispatch context of character token to ID map was not supplied.
CharacterTokenToKinoviCharacterIdNotProvided,
/// A character token was not found in the provided character-token-to-id map
CharacterTokenNotFoundInMap { token: CharacterToken },
}
impl Error for ClientError {}
@@ -50,6 +67,9 @@ impl Error for ClientError {}
impl Display for ClientError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::RouterClientNotProvided => {
write!(f, "A RouterClient is required but was not provided on the draft context")
}
Self::ClientNotConfigured(client_type) => {
write!(f, "{} client is not configured on the RouterClient", client_type)
}
@@ -71,6 +91,18 @@ impl Display for ClientError {
Self::Seedance2ProOnlySupportsUrls => {
write!(f, "Seedance2Pro only supports URLs for media inputs; resolve media tokens to URLs before calling this provider")
}
Self::MediaFileToUrlMapNotProvided => {
write!(f, "Media file to URL map was not provided")
}
Self::MediaFileTokenNotFoundInMap { token } => {
write!(f, "Media file token '{}' was not found in the provided URL map", token.as_str())
}
Self::CharacterTokenToKinoviCharacterIdNotProvided => {
write!(f, "Character token to Kinovi character ID map was not provided")
}
Self::CharacterTokenNotFoundInMap { token } => {
write!(f, "Character token '{}' was not found in the provided character-token-to-id map", token.as_str())
}
}
}
}
@@ -0,0 +1,210 @@
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_gpt_image_2::{
ArtcraftGptImage2ImageSize, ArtcraftGptImage2Quality, PlanArtcraftGptImage2,
};
pub(crate) fn estimate_image_cost_artcraft_gpt_image_2(
plan: &PlanArtcraftGptImage2,
) -> ImageGenerationCostEstimate {
// Same cost table as the Fal GPT Image 2 estimator.
//
// Output image cost (per output image), rounded up to whole cents:
// Low: all sizes → 1¢
// Medium: landscape_4_3/portrait_4_3 → 4¢
// landscape_16_9/portrait_16_9 → 4¢
// square → 6¢
// square_hd → 6¢
// High: landscape_4_3/portrait_4_3 → 15¢
// landscape_16_9/portrait_16_9 → 16¢
// square → 22¢
// square_hd → 23¢
//
use ArtcraftGptImage2ImageSize as S;
let cost_per_image: u64 = match (plan.quality, plan.image_size) {
// Low quality
(ArtcraftGptImage2Quality::Low, _) => 1,
// Medium quality
(ArtcraftGptImage2Quality::Medium, None | Some(S::Square) | Some(S::SquareHd)) => 6,
(ArtcraftGptImage2Quality::Medium, Some(S::Landscape4x3) | Some(S::Portrait4x3)) => 4,
(ArtcraftGptImage2Quality::Medium, Some(S::Landscape16x9) | Some(S::Portrait16x9)) => 4,
(ArtcraftGptImage2Quality::Medium, Some(S::Auto)) => 6,
// High quality
(ArtcraftGptImage2Quality::High, None | Some(S::Square)) => 22,
(ArtcraftGptImage2Quality::High, Some(S::SquareHd)) => 23,
(ArtcraftGptImage2Quality::High, Some(S::Landscape4x3) | Some(S::Portrait4x3)) => 15,
(ArtcraftGptImage2Quality::High, Some(S::Landscape16x9) | Some(S::Portrait16x9)) => 16,
(ArtcraftGptImage2Quality::High, Some(S::Auto)) => 23,
};
let cost_in_usd_cents = cost_per_image * plan.num_images.as_u64();
ImageGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
cost_in_usd_cents: Some(cost_in_usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_gpt_image_2::ArtcraftGptImage2NumImages;
fn plan(
quality: ArtcraftGptImage2Quality,
image_size: Option<ArtcraftGptImage2ImageSize>,
num_images: ArtcraftGptImage2NumImages,
) -> PlanArtcraftGptImage2 {
PlanArtcraftGptImage2 {
prompt: Some("p".to_string()),
image_inputs: None,
image_size,
quality,
num_images,
idempotency_token: "test".to_string(),
}
}
fn cents(
quality: ArtcraftGptImage2Quality,
image_size: Option<ArtcraftGptImage2ImageSize>,
num_images: ArtcraftGptImage2NumImages,
) -> u64 {
estimate_image_cost_artcraft_gpt_image_2(&plan(quality, image_size, num_images))
.cost_in_usd_cents.unwrap()
}
// -- Low quality (1c for all sizes) -------------------------------------------
#[test]
fn low_square() {
assert_eq!(cents(ArtcraftGptImage2Quality::Low, Some(ArtcraftGptImage2ImageSize::Square), ArtcraftGptImage2NumImages::One), 1);
}
#[test]
fn low_square_hd() {
assert_eq!(cents(ArtcraftGptImage2Quality::Low, Some(ArtcraftGptImage2ImageSize::SquareHd), ArtcraftGptImage2NumImages::One), 1);
}
#[test]
fn low_unset() {
assert_eq!(cents(ArtcraftGptImage2Quality::Low, None, ArtcraftGptImage2NumImages::One), 1);
}
#[test]
fn low_landscape_4x3() {
assert_eq!(cents(ArtcraftGptImage2Quality::Low, Some(ArtcraftGptImage2ImageSize::Landscape4x3), ArtcraftGptImage2NumImages::One), 1);
}
#[test]
fn low_four() {
assert_eq!(cents(ArtcraftGptImage2Quality::Low, None, ArtcraftGptImage2NumImages::Four), 4);
}
// -- Medium quality -----------------------------------------------------------
#[test]
fn medium_square() {
assert_eq!(cents(ArtcraftGptImage2Quality::Medium, Some(ArtcraftGptImage2ImageSize::Square), ArtcraftGptImage2NumImages::One), 6);
}
#[test]
fn medium_square_hd() {
assert_eq!(cents(ArtcraftGptImage2Quality::Medium, Some(ArtcraftGptImage2ImageSize::SquareHd), ArtcraftGptImage2NumImages::One), 6);
}
#[test]
fn medium_unset() {
assert_eq!(cents(ArtcraftGptImage2Quality::Medium, None, ArtcraftGptImage2NumImages::One), 6);
}
#[test]
fn medium_landscape_4x3() {
assert_eq!(cents(ArtcraftGptImage2Quality::Medium, Some(ArtcraftGptImage2ImageSize::Landscape4x3), ArtcraftGptImage2NumImages::One), 4);
}
#[test]
fn medium_portrait_4x3() {
assert_eq!(cents(ArtcraftGptImage2Quality::Medium, Some(ArtcraftGptImage2ImageSize::Portrait4x3), ArtcraftGptImage2NumImages::One), 4);
}
#[test]
fn medium_landscape_16x9() {
assert_eq!(cents(ArtcraftGptImage2Quality::Medium, Some(ArtcraftGptImage2ImageSize::Landscape16x9), ArtcraftGptImage2NumImages::One), 4);
}
#[test]
fn medium_portrait_16x9() {
assert_eq!(cents(ArtcraftGptImage2Quality::Medium, Some(ArtcraftGptImage2ImageSize::Portrait16x9), ArtcraftGptImage2NumImages::One), 4);
}
#[test]
fn medium_square_four() {
assert_eq!(cents(ArtcraftGptImage2Quality::Medium, Some(ArtcraftGptImage2ImageSize::Square), ArtcraftGptImage2NumImages::Four), 24);
}
// -- High quality -------------------------------------------------------------
#[test]
fn high_square() {
assert_eq!(cents(ArtcraftGptImage2Quality::High, Some(ArtcraftGptImage2ImageSize::Square), ArtcraftGptImage2NumImages::One), 22);
}
#[test]
fn high_square_hd() {
assert_eq!(cents(ArtcraftGptImage2Quality::High, Some(ArtcraftGptImage2ImageSize::SquareHd), ArtcraftGptImage2NumImages::One), 23);
}
#[test]
fn high_unset() {
assert_eq!(cents(ArtcraftGptImage2Quality::High, None, ArtcraftGptImage2NumImages::One), 22);
}
#[test]
fn high_landscape_4x3() {
assert_eq!(cents(ArtcraftGptImage2Quality::High, Some(ArtcraftGptImage2ImageSize::Landscape4x3), ArtcraftGptImage2NumImages::One), 15);
}
#[test]
fn high_portrait_4x3() {
assert_eq!(cents(ArtcraftGptImage2Quality::High, Some(ArtcraftGptImage2ImageSize::Portrait4x3), ArtcraftGptImage2NumImages::One), 15);
}
#[test]
fn high_landscape_16x9() {
assert_eq!(cents(ArtcraftGptImage2Quality::High, Some(ArtcraftGptImage2ImageSize::Landscape16x9), ArtcraftGptImage2NumImages::One), 16);
}
#[test]
fn high_portrait_16x9() {
assert_eq!(cents(ArtcraftGptImage2Quality::High, Some(ArtcraftGptImage2ImageSize::Portrait16x9), ArtcraftGptImage2NumImages::One), 16);
}
#[test]
fn high_square_four() {
assert_eq!(cents(ArtcraftGptImage2Quality::High, Some(ArtcraftGptImage2ImageSize::Square), ArtcraftGptImage2NumImages::Four), 88);
}
#[test]
fn high_square_hd_four() {
assert_eq!(cents(ArtcraftGptImage2Quality::High, Some(ArtcraftGptImage2ImageSize::SquareHd), ArtcraftGptImage2NumImages::Four), 92);
}
// -- Metadata flags -----------------------------------------------------------
#[test]
fn metadata_flags_are_default() {
let estimate = estimate_image_cost_artcraft_gpt_image_2(&plan(
ArtcraftGptImage2Quality::Medium, None, ArtcraftGptImage2NumImages::One,
));
assert!(!estimate.is_free);
assert!(!estimate.is_unlimited);
assert!(!estimate.is_rate_limited);
assert!(!estimate.has_watermark);
}
}
@@ -4,6 +4,7 @@ pub mod estimate_image_cost_artcraft_flux_pro_1p1;
pub mod estimate_image_cost_artcraft_flux_pro_1p1_ultra;
pub mod estimate_image_cost_artcraft_gpt_image_1;
pub mod estimate_image_cost_artcraft_gpt_image_1p5;
pub mod estimate_image_cost_artcraft_gpt_image_2;
pub mod estimate_image_cost_artcraft_nano_banana;
pub mod estimate_image_cost_artcraft_nano_banana_2;
pub mod estimate_image_cost_artcraft_nano_banana_pro;
@@ -0,0 +1,209 @@
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_gpt_image_2::{
FalGptImage2ImageSize, FalGptImage2Quality, PlanFalGptImage2,
};
pub(crate) fn estimate_image_cost_fal_gpt_image_2(
plan: &PlanFalGptImage2,
) -> ImageGenerationCostEstimate {
// Per fal docs (fal-ai/gpt-image-2 and fal-ai/gpt-image-2/edit):
//
// Output image cost (per output image), rounded up to whole cents:
// Low: all sizes → 1¢
// Medium: landscape_4_3/portrait_4_3 → 4¢
// landscape_16_9/portrait_16_9 → 4¢
// square → 6¢
// square_hd → 6¢
// High: landscape_4_3/portrait_4_3 → 15¢
// landscape_16_9/portrait_16_9 → 16¢
// square → 22¢
// square_hd → 23¢
//
use FalGptImage2ImageSize as S;
let cost_per_image: u64 = match (plan.quality, plan.image_size) {
// Low quality
(FalGptImage2Quality::Low, _) => 1,
// Medium quality
(FalGptImage2Quality::Medium, None | Some(S::Square) | Some(S::SquareHd)) => 6,
(FalGptImage2Quality::Medium, Some(S::Landscape4x3) | Some(S::Portrait4x3)) => 4,
(FalGptImage2Quality::Medium, Some(S::Landscape16x9) | Some(S::Portrait16x9)) => 4,
(FalGptImage2Quality::Medium, Some(S::Auto)) => 6, // TODO: Not checked
// High quality
(FalGptImage2Quality::High, None | Some(S::Square)) => 22,
(FalGptImage2Quality::High, Some(S::SquareHd)) => 23,
(FalGptImage2Quality::High, Some(S::Landscape4x3) | Some(S::Portrait4x3)) => 15,
(FalGptImage2Quality::High, Some(S::Landscape16x9) | Some(S::Portrait16x9)) => 16,
(FalGptImage2Quality::High, Some(S::Auto)) => 23, // TODO: Not checked
};
let cost_in_usd_cents = cost_per_image * plan.num_images.as_u64();
ImageGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
cost_in_usd_cents: Some(cost_in_usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_gpt_image_2::FalGptImage2NumImages;
fn plan(
quality: FalGptImage2Quality,
image_size: Option<FalGptImage2ImageSize>,
num_images: FalGptImage2NumImages,
) -> PlanFalGptImage2 {
PlanFalGptImage2 {
prompt: Some("p".to_string()),
image_urls: vec![],
image_size,
quality,
num_images,
}
}
fn cents(
quality: FalGptImage2Quality,
image_size: Option<FalGptImage2ImageSize>,
num_images: FalGptImage2NumImages,
) -> u64 {
estimate_image_cost_fal_gpt_image_2(&plan(quality, image_size, num_images))
.cost_in_usd_cents.unwrap()
}
// -- Low quality (1c for all sizes) -------------------------------------------
#[test]
fn low_square() {
assert_eq!(cents(FalGptImage2Quality::Low, Some(FalGptImage2ImageSize::Square), FalGptImage2NumImages::One), 1);
}
#[test]
fn low_square_hd() {
assert_eq!(cents(FalGptImage2Quality::Low, Some(FalGptImage2ImageSize::SquareHd), FalGptImage2NumImages::One), 1);
}
#[test]
fn low_unset() {
assert_eq!(cents(FalGptImage2Quality::Low, None, FalGptImage2NumImages::One), 1);
}
#[test]
fn low_landscape_4x3() {
assert_eq!(cents(FalGptImage2Quality::Low, Some(FalGptImage2ImageSize::Landscape4x3), FalGptImage2NumImages::One), 1);
}
#[test]
fn low_four() {
assert_eq!(cents(FalGptImage2Quality::Low, None, FalGptImage2NumImages::Four), 4);
}
// -- Medium quality -----------------------------------------------------------
#[test]
fn medium_square() {
assert_eq!(cents(FalGptImage2Quality::Medium, Some(FalGptImage2ImageSize::Square), FalGptImage2NumImages::One), 6);
}
#[test]
fn medium_square_hd() {
assert_eq!(cents(FalGptImage2Quality::Medium, Some(FalGptImage2ImageSize::SquareHd), FalGptImage2NumImages::One), 6);
}
#[test]
fn medium_unset() {
assert_eq!(cents(FalGptImage2Quality::Medium, None, FalGptImage2NumImages::One), 6);
}
#[test]
fn medium_landscape_4x3() {
assert_eq!(cents(FalGptImage2Quality::Medium, Some(FalGptImage2ImageSize::Landscape4x3), FalGptImage2NumImages::One), 4);
}
#[test]
fn medium_portrait_4x3() {
assert_eq!(cents(FalGptImage2Quality::Medium, Some(FalGptImage2ImageSize::Portrait4x3), FalGptImage2NumImages::One), 4);
}
#[test]
fn medium_landscape_16x9() {
assert_eq!(cents(FalGptImage2Quality::Medium, Some(FalGptImage2ImageSize::Landscape16x9), FalGptImage2NumImages::One), 4);
}
#[test]
fn medium_portrait_16x9() {
assert_eq!(cents(FalGptImage2Quality::Medium, Some(FalGptImage2ImageSize::Portrait16x9), FalGptImage2NumImages::One), 4);
}
#[test]
fn medium_square_four() {
assert_eq!(cents(FalGptImage2Quality::Medium, Some(FalGptImage2ImageSize::Square), FalGptImage2NumImages::Four), 24);
}
// -- High quality -------------------------------------------------------------
#[test]
fn high_square() {
assert_eq!(cents(FalGptImage2Quality::High, Some(FalGptImage2ImageSize::Square), FalGptImage2NumImages::One), 22);
}
#[test]
fn high_square_hd() {
assert_eq!(cents(FalGptImage2Quality::High, Some(FalGptImage2ImageSize::SquareHd), FalGptImage2NumImages::One), 23);
}
#[test]
fn high_unset() {
assert_eq!(cents(FalGptImage2Quality::High, None, FalGptImage2NumImages::One), 22);
}
#[test]
fn high_landscape_4x3() {
assert_eq!(cents(FalGptImage2Quality::High, Some(FalGptImage2ImageSize::Landscape4x3), FalGptImage2NumImages::One), 15);
}
#[test]
fn high_portrait_4x3() {
assert_eq!(cents(FalGptImage2Quality::High, Some(FalGptImage2ImageSize::Portrait4x3), FalGptImage2NumImages::One), 15);
}
#[test]
fn high_landscape_16x9() {
assert_eq!(cents(FalGptImage2Quality::High, Some(FalGptImage2ImageSize::Landscape16x9), FalGptImage2NumImages::One), 16);
}
#[test]
fn high_portrait_16x9() {
assert_eq!(cents(FalGptImage2Quality::High, Some(FalGptImage2ImageSize::Portrait16x9), FalGptImage2NumImages::One), 16);
}
#[test]
fn high_square_four() {
assert_eq!(cents(FalGptImage2Quality::High, Some(FalGptImage2ImageSize::Square), FalGptImage2NumImages::Four), 88);
}
#[test]
fn high_square_hd_four() {
assert_eq!(cents(FalGptImage2Quality::High, Some(FalGptImage2ImageSize::SquareHd), FalGptImage2NumImages::Four), 92);
}
// -- Metadata flags -----------------------------------------------------------
#[test]
fn metadata_flags_are_default() {
let estimate = estimate_image_cost_fal_gpt_image_2(&plan(
FalGptImage2Quality::Medium, None, FalGptImage2NumImages::One,
));
assert!(!estimate.is_free);
assert!(!estimate.is_unlimited);
assert!(!estimate.is_rate_limited);
assert!(!estimate.has_watermark);
}
}
@@ -4,6 +4,7 @@ pub(crate) mod estimate_image_cost_fal_flux_pro_1p1;
pub(crate) mod estimate_image_cost_fal_flux_pro_1p1_ultra;
pub(crate) mod estimate_image_cost_fal_gpt_image_1;
pub(crate) mod estimate_image_cost_fal_gpt_image_1p5;
pub(crate) mod estimate_image_cost_fal_gpt_image_2;
pub(crate) mod estimate_image_cost_fal_seedream_4;
pub(crate) mod estimate_image_cost_fal_seedream_4p5;
pub(crate) mod estimate_image_cost_fal_seedream_5_lite;
@@ -0,0 +1,145 @@
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_image::generate_image_response::{
ArtcraftImageResponsePayload, GenerateImageResponse,
};
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_gpt_image_2::PlanArtcraftGptImage2;
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::OmniGenImageCostAndGenerateRequest;
use artcraft_client::endpoints::omni_gen::generate::image::omni_gen_image::omni_gen_image_generate;
use enums::common::generation::common_image_model::CommonImageModel;
/// Execute GPT Image 2 via the Artcraft omni-gen image endpoint.
pub async fn execute_artcraft_gpt_image_2(
plan: &PlanArtcraftGptImage2,
artcraft_client: &RouterArtcraftClient,
) -> Result<GenerateImageResponse, ArtcraftRouterError> {
let request = OmniGenImageCostAndGenerateRequest {
idempotency_token: Some(plan.idempotency_token.clone()),
model: Some(CommonImageModel::GptImage2),
prompt: plan.prompt.clone(),
image_media_tokens: plan.image_inputs.clone(),
resolution: None,
aspect_ratio: plan.aspect_ratio_enum(),
quality: Some(plan.quality_enum()),
image_batch_count: Some(plan.num_images_u16()),
adjust_horizontal_angle: None,
adjust_vertical_angle: None,
adjust_zoom: None,
};
let response = omni_gen_image_generate(
&artcraft_client.api_host,
Some(&artcraft_client.credentials),
request,
)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Storyteller(err)))?;
Ok(GenerateImageResponse::Artcraft(ArtcraftImageResponsePayload {
inference_job_token: response.inference_job_token,
}))
}
#[cfg(test)]
mod tests {
use artcraft_client::utils::api_host::ApiHost;
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_quality::CommonQuality;
use crate::api::image_list_ref::ImageListRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::generate::generate_image::generate_image_request::GenerateImageRequest;
use crate::generate::generate_image::image_generation_plan::ImageGenerationPlan;
use crate::test_helpers::{get_artcraft_client, get_artcraft_client_for_env};
use tokens::tokens::media_files::MediaFileToken;
fn base_gpt_image_2_request() -> GenerateImageRequest {
GenerateImageRequest {
model: crate::api::common_image_model::CommonImageModel::GptImage2,
provider: crate::api::provider::Provider::Artcraft,
prompt: Some("a t-rex walking through a cyberpunk city at night".to_string()),
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
horizontal_angle: None,
vertical_angle: None,
zoom: None,
}
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_text_to_image_gpt_image_2() {
let client = get_artcraft_client_for_env(ApiHost::Localhost { port: 12345 });
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
image_batch_count: Some(1),
..base_gpt_image_2_request()
};
let plan = request.build().unwrap();
let result = plan.generate_image(&client).await;
println!("Result: {:?}", result);
let response = result.expect("generate_image request failed");
let payload = response.get_artcraft_payload().expect("expected Artcraft payload");
println!("Job token: {:?}", payload.inference_job_token);
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_edit_image_gpt_image_2() {
let client = get_artcraft_client();
let image_tokens = vec![
MediaFileToken::new_from_str("m_r45apt2swza6wp8j2z10237t92kkr8"),
];
let request = GenerateImageRequest {
prompt: Some("Change the background to a desert with cacti".to_string()),
image_inputs: Some(ImageListRef::MediaFileTokens(image_tokens)),
aspect_ratio: Some(CommonAspectRatio::Auto),
image_batch_count: Some(1),
..base_gpt_image_2_request()
};
let plan = request.build().unwrap();
let result = plan.generate_image(&client).await;
println!("Result: {:?}", result);
let response = result.expect("generate_image request failed");
let payload = response.get_artcraft_payload().expect("expected Artcraft payload");
println!("Job token: {:?}", payload.inference_job_token);
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_image_gpt_image_2_high_quality() {
let client = get_artcraft_client();
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::Square),
quality: Some(CommonQuality::High),
image_batch_count: Some(1),
..base_gpt_image_2_request()
};
let plan = request.build().unwrap();
let result = plan.generate_image(&client).await;
println!("Result: {:?}", result);
let response = result.expect("generate_image request failed");
let payload = response.get_artcraft_payload().expect("expected Artcraft payload");
println!("Job token: {:?}", payload.inference_job_token);
assert_eq!(1, 2); // NB: Intentional failure to inspect the response above.
}
}
@@ -4,6 +4,7 @@ pub mod generate_image_artcraft_flux_pro_1p1;
pub mod generate_image_artcraft_flux_pro_1p1_ultra;
pub mod generate_image_artcraft_gpt_image_1;
pub mod generate_image_artcraft_gpt_image_1p5;
pub mod generate_image_artcraft_gpt_image_2;
pub mod generate_image_artcraft_nano_banana;
pub mod generate_image_artcraft_nano_banana_2;
pub mod generate_image_artcraft_nano_banana_pro;
@@ -0,0 +1,175 @@
use crate::client::router_fal_client::RouterFalClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_image::generate_image_response::{
FalImageResponsePayload, GenerateImageResponse,
};
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_gpt_image_2::PlanFalGptImage2;
use fal_client::requests::webhook::image::edit::enqueue_gpt_image_2_edit_image_webhook::{
enqueue_gpt_image_2_edit_image_webhook, EnqueueGptImage2EditImageArgs,
};
use fal_client::requests::webhook::image::text::enqueue_gpt_image_2_text_to_image_webhook::{
enqueue_gpt_image_2_text_to_image_webhook, EnqueueGptImage2TextToImageArgs,
};
pub async fn execute_fal_gpt_image_2(
plan: &PlanFalGptImage2,
fal_client: &RouterFalClient,
) -> Result<GenerateImageResponse, ArtcraftRouterError> {
let webhook_response = if plan.image_urls.is_empty() {
let args = EnqueueGptImage2TextToImageArgs {
prompt: plan.prompt.as_deref().unwrap_or(""),
num_images: plan.num_images.to_t2i(),
image_size: plan.image_size.map(|s| s.to_t2i()),
quality: Some(plan.quality.to_t2i()),
output_format: None,
webhook_url: fal_client.webhook_url.as_str(),
api_key: &fal_client.api_key,
};
enqueue_gpt_image_2_text_to_image_webhook(args)
.await
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?
} else {
let args = EnqueueGptImage2EditImageArgs {
prompt: plan.prompt.as_deref().unwrap_or(""),
image_urls: plan.image_urls.clone(),
num_images: plan.num_images.to_edit(),
mask_url: None,
image_size: plan.image_size.map(|s| s.to_edit()),
quality: Some(plan.quality.to_edit()),
output_format: None,
webhook_url: fal_client.webhook_url.as_str(),
api_key: &fal_client.api_key,
};
enqueue_gpt_image_2_edit_image_webhook(args)
.await
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?
};
Ok(GenerateImageResponse::Fal(FalImageResponsePayload {
request_id: webhook_response.request_id,
gateway_request_id: webhook_response.gateway_request_id,
}))
}
#[cfg(test)]
mod tests {
use test_data::web::image_urls::{GHOST_IMAGE_URL, JUNO_AT_LAKE_IMAGE_URL, TREX_SKELETON_IMAGE_URL};
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_image_model::CommonImageModel;
use crate::api::image_list_ref::ImageListRef;
use crate::api::provider::Provider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::generate::generate_image::generate_image_request::GenerateImageRequest;
use crate::generate::generate_image::image_generation_plan::ImageGenerationPlan;
use crate::test_helpers::get_fal_client;
// ── Build smoke tests ──
mod build_smoke_tests {
use super::*;
#[test]
fn text_to_image_plan() {
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::Square),
image_batch_count: Some(1),
..base_request()
};
let plan = request.build().expect("plan should build");
let ImageGenerationPlan::FalGptImage2(plan) = plan else {
panic!("expected FalGptImage2")
};
assert!(plan.image_urls.is_empty(), "text mode must have no image_urls");
}
#[test]
fn edit_image_plan() {
let urls = vec![
JUNO_AT_LAKE_IMAGE_URL.to_string(),
GHOST_IMAGE_URL.to_string(),
];
let request = GenerateImageRequest {
prompt: Some("dog running from a scary ghost".to_string()),
image_inputs: Some(ImageListRef::Urls(urls.clone())),
..base_request()
};
let plan = request.build().expect("plan should build");
let ImageGenerationPlan::FalGptImage2(plan) = plan else {
panic!("expected FalGptImage2")
};
assert_eq!(plan.image_urls, urls);
}
}
// ── Live API tests ──
mod live_api_tests {
use super::*;
#[tokio::test]
#[ignore] // manually run — fires a real fal API request and incurs cost
async fn text_to_image() {
let client = get_fal_client();
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
image_batch_count: Some(1),
prompt: Some("a horse walking through a cyberpunk city at night".to_string()),
..base_request()
};
let plan = request.build().unwrap();
let result = plan.generate_image(&client).await;
println!("Result: {:?}", result);
let response = result.expect("generate_image request failed");
let payload = response.get_fal_payload().expect("expected Fal payload");
println!("Request id: {:?}", payload.request_id);
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // manually run — fires a real fal API request and incurs cost
async fn edit_image() {
let client = get_fal_client();
let request = GenerateImageRequest {
prompt: Some("dog running from a scary ghost".to_string()),
image_inputs: Some(ImageListRef::Urls(vec![
JUNO_AT_LAKE_IMAGE_URL.to_string(),
GHOST_IMAGE_URL.to_string(),
])),
aspect_ratio: Some(CommonAspectRatio::Square),
image_batch_count: Some(1),
..base_request()
};
let plan = request.build().unwrap();
let result = plan.generate_image(&client).await;
println!("Result: {:?}", result);
let response = result.expect("generate_image request failed");
let payload = response.get_fal_payload().expect("expected Fal payload");
println!("Request id: {:?}", payload.request_id);
assert_eq!(1, 2, "Inspect output above");
}
}
// ── Helpers ──
fn base_request() -> GenerateImageRequest {
GenerateImageRequest {
model: CommonImageModel::GptImage2,
provider: Provider::Fal,
prompt: Some("a cat in space".to_string()),
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
horizontal_angle: None,
vertical_angle: None,
zoom: None,
}
}
}
@@ -4,6 +4,7 @@ pub mod generate_image_fal_flux_pro_1p1;
pub mod generate_image_fal_flux_pro_1p1_ultra;
pub mod generate_image_fal_gpt_image_1;
pub mod generate_image_fal_gpt_image_1p5;
pub mod generate_image_fal_gpt_image_2;
pub mod generate_image_fal_seedream_4;
pub mod generate_image_fal_seedream_4p5;
pub mod generate_image_fal_seedream_5_lite;
@@ -16,6 +16,7 @@ use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraf
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_flux_pro_1p1_ultra::plan_generate_image_artcraft_flux_pro_1p1_ultra;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_gpt_image_1::plan_generate_image_artcraft_gpt_image_1;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_gpt_image_1p5::plan_generate_image_artcraft_gpt_image_1p5;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_gpt_image_2::plan_generate_image_artcraft_gpt_image_2;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_nano_banana::plan_generate_image_artcraft_nano_banana;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_nano_banana_2::plan_generate_image_artcraft_nano_banana_2;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_nano_banana_pro::plan_generate_image_artcraft_nano_banana_pro;
@@ -29,6 +30,7 @@ use crate::generate::generate_image::plan::fal::plan_generate_image_fal_flux_pro
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_flux_pro_1p1_ultra::plan_generate_image_fal_flux_pro_1p1_ultra;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_gpt_image_1::plan_generate_image_fal_gpt_image_1;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_gpt_image_1p5::plan_generate_image_fal_gpt_image_1p5;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_gpt_image_2::plan_generate_image_fal_gpt_image_2;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_seedream_4::plan_generate_image_fal_seedream_4;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_seedream_4p5::plan_generate_image_fal_seedream_4p5;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_seedream_5_lite::plan_generate_image_fal_seedream_5_lite;
@@ -100,18 +102,20 @@ impl GenerateImageRequest {
match self.model {
CommonImageModel::Flux1Dev => plan_generate_image_artcraft_flux_1_dev(self),
CommonImageModel::Flux1Schnell => plan_generate_image_artcraft_flux_1_schnell(self),
CommonImageModel::Flux2LoraAngles => plan_generate_image_artcraft_flux_2_lora_angles(self),
CommonImageModel::FluxPro11 => plan_generate_image_artcraft_flux_pro_1p1(self),
CommonImageModel::FluxPro11Ultra => plan_generate_image_artcraft_flux_pro_1p1_ultra(self),
CommonImageModel::GptImage1 => plan_generate_image_artcraft_gpt_image_1(self),
CommonImageModel::GptImage1p5 => plan_generate_image_artcraft_gpt_image_1p5(self),
CommonImageModel::GptImage2 => plan_generate_image_artcraft_gpt_image_2(self),
CommonImageModel::NanoBanana => plan_generate_image_artcraft_nano_banana(self),
CommonImageModel::NanoBanana2 => plan_generate_image_artcraft_nano_banana_2(self),
CommonImageModel::NanoBananaPro => plan_generate_image_artcraft_nano_banana_pro(self),
CommonImageModel::QwenEdit2511Angles => plan_generate_image_artcraft_qwen_edit_2511_angles(self),
CommonImageModel::Seedream4 => plan_generate_image_artcraft_seedream_4(self),
CommonImageModel::Seedream4p5 => plan_generate_image_artcraft_seedream_4p5(self),
CommonImageModel::Seedream5Lite => plan_generate_image_artcraft_seedream_5_lite(self),
CommonImageModel::QwenEdit2511Angles => plan_generate_image_artcraft_qwen_edit_2511_angles(self),
CommonImageModel::Flux2LoraAngles => plan_generate_image_artcraft_flux_2_lora_angles(self),
_ => Err(ArtcraftRouterError::UnsupportedModel(format!("{:?}", self.model))),
}
}
@@ -123,12 +127,13 @@ impl GenerateImageRequest {
CommonImageModel::FluxPro11Ultra => plan_generate_image_fal_flux_pro_1p1_ultra(self),
CommonImageModel::GptImage1 => plan_generate_image_fal_gpt_image_1(self),
CommonImageModel::GptImage1p5 => plan_generate_image_fal_gpt_image_1p5(self),
CommonImageModel::Seedream4 => plan_generate_image_fal_seedream_4(self),
CommonImageModel::Seedream4p5 => plan_generate_image_fal_seedream_4p5(self),
CommonImageModel::Seedream5Lite => plan_generate_image_fal_seedream_5_lite(self),
CommonImageModel::GptImage2 => plan_generate_image_fal_gpt_image_2(self),
CommonImageModel::NanoBanana => plan_generate_image_fal_nano_banana(self),
CommonImageModel::NanoBanana2 => plan_generate_image_fal_nano_banana_2(self),
CommonImageModel::NanoBananaPro => plan_generate_image_fal_nano_banana_pro(self),
CommonImageModel::Seedream4 => plan_generate_image_fal_seedream_4(self),
CommonImageModel::Seedream4p5 => plan_generate_image_fal_seedream_4p5(self),
CommonImageModel::Seedream5Lite => plan_generate_image_fal_seedream_5_lite(self),
_ => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "provider",
@@ -6,6 +6,7 @@ use crate::generate::generate_image::cost::artcraft::estimate_image_cost_artcraf
use crate::generate::generate_image::cost::artcraft::estimate_image_cost_artcraft_flux_pro_1p1_ultra::estimate_image_cost_artcraft_flux_pro_1p1_ultra;
use crate::generate::generate_image::cost::artcraft::estimate_image_cost_artcraft_gpt_image_1::estimate_image_cost_artcraft_gpt_image_1;
use crate::generate::generate_image::cost::artcraft::estimate_image_cost_artcraft_gpt_image_1p5::estimate_image_cost_artcraft_gpt_image_1p5;
use crate::generate::generate_image::cost::artcraft::estimate_image_cost_artcraft_gpt_image_2::estimate_image_cost_artcraft_gpt_image_2;
use crate::generate::generate_image::cost::artcraft::estimate_image_cost_artcraft_nano_banana::estimate_image_cost_artcraft_nano_banana;
use crate::generate::generate_image::cost::artcraft::estimate_image_cost_artcraft_nano_banana_2::estimate_image_cost_artcraft_nano_banana_2;
use crate::generate::generate_image::cost::artcraft::estimate_image_cost_artcraft_nano_banana_pro::estimate_image_cost_artcraft_nano_banana_pro;
@@ -20,6 +21,7 @@ use crate::generate::generate_image::cost::fal::estimate_image_cost_fal_flux_pro
use crate::generate::generate_image::cost::fal::estimate_image_cost_fal_flux_pro_1p1_ultra::estimate_image_cost_fal_flux_pro_1p1_ultra;
use crate::generate::generate_image::cost::fal::estimate_image_cost_fal_gpt_image_1::estimate_image_cost_fal_gpt_image_1;
use crate::generate::generate_image::cost::fal::estimate_image_cost_fal_gpt_image_1p5::estimate_image_cost_fal_gpt_image_1p5;
use crate::generate::generate_image::cost::fal::estimate_image_cost_fal_gpt_image_2::estimate_image_cost_fal_gpt_image_2;
use crate::generate::generate_image::cost::fal::estimate_image_cost_fal_seedream_4::estimate_image_cost_fal_seedream_4;
use crate::generate::generate_image::cost::fal::estimate_image_cost_fal_seedream_4p5::estimate_image_cost_fal_seedream_4p5;
use crate::generate::generate_image::cost::fal::estimate_image_cost_fal_seedream_5_lite::estimate_image_cost_fal_seedream_5_lite;
@@ -32,6 +34,7 @@ use crate::generate::generate_image::execute::artcraft::generate_image_artcraft_
use crate::generate::generate_image::execute::artcraft::generate_image_artcraft_flux_pro_1p1_ultra::execute_artcraft_flux_pro_1p1_ultra;
use crate::generate::generate_image::execute::artcraft::generate_image_artcraft_gpt_image_1::execute_artcraft_gpt_image_1;
use crate::generate::generate_image::execute::artcraft::generate_image_artcraft_gpt_image_1p5::execute_artcraft_gpt_image_1p5;
use crate::generate::generate_image::execute::artcraft::generate_image_artcraft_gpt_image_2::execute_artcraft_gpt_image_2;
use crate::generate::generate_image::execute::artcraft::generate_image_artcraft_nano_banana::execute_artcraft_nano_banana;
use crate::generate::generate_image::execute::artcraft::generate_image_artcraft_nano_banana_2::execute_artcraft_nano_banana_2;
use crate::generate::generate_image::execute::artcraft::generate_image_artcraft_nano_banana_pro::execute_artcraft_nano_banana_pro;
@@ -46,6 +49,7 @@ use crate::generate::generate_image::execute::fal::generate_image_fal_flux_pro_1
use crate::generate::generate_image::execute::fal::generate_image_fal_flux_pro_1p1_ultra::execute_fal_flux_pro_1p1_ultra;
use crate::generate::generate_image::execute::fal::generate_image_fal_gpt_image_1::execute_fal_gpt_image_1;
use crate::generate::generate_image::execute::fal::generate_image_fal_gpt_image_1p5::execute_fal_gpt_image_1p5;
use crate::generate::generate_image::execute::fal::generate_image_fal_gpt_image_2::execute_fal_gpt_image_2;
use crate::generate::generate_image::execute::fal::generate_image_fal_seedream_4::execute_fal_seedream_4;
use crate::generate::generate_image::execute::fal::generate_image_fal_seedream_4p5::execute_fal_seedream_4p5;
use crate::generate::generate_image::execute::fal::generate_image_fal_seedream_5_lite::execute_fal_seedream_5_lite;
@@ -60,6 +64,7 @@ use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraf
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_flux_pro_1p1_ultra::PlanArtcraftFluxPro11Ultra;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_gpt_image_1::PlanArtcraftGptImage1;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_gpt_image_1p5::PlanArtcraftGptImage1p5;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_gpt_image_2::PlanArtcraftGptImage2;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_nano_banana::PlanArtcraftNanaBanana;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_nano_banana_2::PlanArtcraftNanaBanana2;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_nano_banana_pro::PlanArtcraftNanaBananaPro;
@@ -74,6 +79,7 @@ use crate::generate::generate_image::plan::fal::plan_generate_image_fal_flux_pro
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_flux_pro_1p1_ultra::PlanFalFluxPro11Ultra;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_gpt_image_1::PlanFalGptImage1;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_gpt_image_1p5::PlanFalGptImage1p5;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_gpt_image_2::PlanFalGptImage2;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_seedream_4::PlanFalSeedream4;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_seedream_4p5::PlanFalSeedream4p5;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_seedream_5_lite::PlanFalSeedream5Lite;
@@ -89,6 +95,7 @@ pub enum ImageGenerationPlan {
ArtcraftFluxPro11Ultra(PlanArtcraftFluxPro11Ultra),
ArtcraftGptImage1(PlanArtcraftGptImage1),
ArtcraftGptImage1p5(PlanArtcraftGptImage1p5),
ArtcraftGptImage2(PlanArtcraftGptImage2),
ArtcraftNanaBanana(PlanArtcraftNanaBanana),
ArtcraftNanaBanana2(PlanArtcraftNanaBanana2),
ArtcraftNanaBananaPro(PlanArtcraftNanaBananaPro),
@@ -103,6 +110,7 @@ pub enum ImageGenerationPlan {
FalFluxPro11Ultra(PlanFalFluxPro11Ultra),
FalGptImage1(PlanFalGptImage1),
FalGptImage1p5(PlanFalGptImage1p5),
FalGptImage2(PlanFalGptImage2),
FalSeedream4(PlanFalSeedream4),
FalSeedream4p5(PlanFalSeedream4p5),
FalSeedream5Lite(PlanFalSeedream5Lite),
@@ -141,6 +149,10 @@ impl ImageGenerationPlan {
let artcraft_client = client.get_artcraft_client_ref()?;
execute_artcraft_gpt_image_1p5(plan, artcraft_client).await
}
ImageGenerationPlan::ArtcraftGptImage2(plan) => {
let artcraft_client = client.get_artcraft_client_ref()?;
execute_artcraft_gpt_image_2(plan, artcraft_client).await
}
ImageGenerationPlan::ArtcraftNanaBanana(plan) => {
let artcraft_client = client.get_artcraft_client_ref()?;
execute_artcraft_nano_banana(plan, artcraft_client).await
@@ -197,6 +209,10 @@ impl ImageGenerationPlan {
let fal_client = client.get_fal_client_ref()?;
execute_fal_gpt_image_1p5(plan, fal_client).await
}
ImageGenerationPlan::FalGptImage2(plan) => {
let fal_client = client.get_fal_client_ref()?;
execute_fal_gpt_image_2(plan, fal_client).await
}
ImageGenerationPlan::FalSeedream4(plan) => {
let fal_client = client.get_fal_client_ref()?;
execute_fal_seedream_4(plan, fal_client).await
@@ -244,6 +260,9 @@ impl ImageGenerationPlan {
ImageGenerationPlan::ArtcraftGptImage1p5(plan) => {
estimate_image_cost_artcraft_gpt_image_1p5(plan)
}
ImageGenerationPlan::ArtcraftGptImage2(plan) => {
estimate_image_cost_artcraft_gpt_image_2(plan)
}
ImageGenerationPlan::ArtcraftNanaBanana(plan) => {
estimate_image_cost_artcraft_nano_banana(plan)
}
@@ -286,6 +305,9 @@ impl ImageGenerationPlan {
ImageGenerationPlan::FalGptImage1p5(plan) => {
estimate_image_cost_fal_gpt_image_1p5(plan)
}
ImageGenerationPlan::FalGptImage2(plan) => {
estimate_image_cost_fal_gpt_image_2(plan)
}
ImageGenerationPlan::FalSeedream4(plan) => {
estimate_image_cost_fal_seedream_4(plan)
}
@@ -4,6 +4,7 @@ pub mod plan_generate_image_artcraft_flux_pro_1p1;
pub mod plan_generate_image_artcraft_flux_pro_1p1_ultra;
pub mod plan_generate_image_artcraft_gpt_image_1;
pub mod plan_generate_image_artcraft_gpt_image_1p5;
pub mod plan_generate_image_artcraft_gpt_image_2;
pub mod plan_generate_image_artcraft_nano_banana;
pub mod plan_generate_image_artcraft_nano_banana_2;
pub mod plan_generate_image_artcraft_nano_banana_pro;
@@ -0,0 +1,435 @@
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_quality::CommonQuality;
use crate::api::image_list_ref::ImageListRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_image::generate_image_request::GenerateImageRequest;
use crate::generate::generate_image::image_generation_plan::ImageGenerationPlan;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio as CommonAspectRatioEnum;
use enums::common::generation::common_quality::CommonQuality as CommonQualityEnum;
use tokens::tokens::media_files::MediaFileToken;
/// GPT Image 2 supports six image sizes.
#[derive(Debug, Clone, Copy)]
pub enum ArtcraftGptImage2ImageSize {
SquareHd,
Square,
Portrait4x3,
Portrait16x9,
Landscape4x3,
Landscape16x9,
Auto,
}
/// Quality: Low / Medium / High.
#[derive(Debug, Clone, Copy)]
pub enum ArtcraftGptImage2Quality {
Low,
Medium,
High,
}
#[derive(Debug, Clone, Copy)]
pub enum ArtcraftGptImage2NumImages {
One,
Two,
Three,
Four,
}
#[derive(Debug, Clone)]
pub struct PlanArtcraftGptImage2 {
pub prompt: Option<String>,
pub image_inputs: Option<Vec<MediaFileToken>>,
pub image_size: Option<ArtcraftGptImage2ImageSize>,
pub quality: ArtcraftGptImage2Quality,
pub num_images: ArtcraftGptImage2NumImages,
pub idempotency_token: String,
}
pub fn plan_generate_image_artcraft_gpt_image_2(
request: &GenerateImageRequest,
) -> Result<ImageGenerationPlan, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
let image_inputs = resolve_image_list_ref(request.image_inputs.clone())?;
let image_size = plan_image_size(request.aspect_ratio);
let quality = plan_quality(request.quality);
let num_images = plan_num_images(request.image_batch_count, strategy)?;
Ok(ImageGenerationPlan::ArtcraftGptImage2(PlanArtcraftGptImage2 {
prompt: request.prompt.clone(),
image_inputs,
image_size,
quality,
num_images,
idempotency_token: request.get_or_generate_idempotency_token(),
}))
}
impl PlanArtcraftGptImage2 {
/// Map the plan's aspect ratio to the `CommonAspectRatioEnum` used by the
/// omni-gen request.
pub fn aspect_ratio_enum(&self) -> Option<CommonAspectRatioEnum> {
self.image_size.map(|s| match s {
ArtcraftGptImage2ImageSize::SquareHd => CommonAspectRatioEnum::SquareHd,
ArtcraftGptImage2ImageSize::Square => CommonAspectRatioEnum::Square,
ArtcraftGptImage2ImageSize::Portrait4x3 => CommonAspectRatioEnum::TallThreeByFour,
ArtcraftGptImage2ImageSize::Portrait16x9 => CommonAspectRatioEnum::TallNineBySixteen,
ArtcraftGptImage2ImageSize::Landscape4x3 => CommonAspectRatioEnum::WideFourByThree,
ArtcraftGptImage2ImageSize::Landscape16x9 => CommonAspectRatioEnum::WideSixteenByNine,
ArtcraftGptImage2ImageSize::Auto => CommonAspectRatioEnum::Auto,
})
}
/// Map the plan's quality to the `CommonQualityEnum` used by the omni-gen
/// request.
pub fn quality_enum(&self) -> CommonQualityEnum {
match self.quality {
ArtcraftGptImage2Quality::Low => CommonQualityEnum::Low,
ArtcraftGptImage2Quality::Medium => CommonQualityEnum::Medium,
ArtcraftGptImage2Quality::High => CommonQualityEnum::High,
}
}
pub fn num_images_u16(&self) -> u16 {
match self.num_images {
ArtcraftGptImage2NumImages::One => 1,
ArtcraftGptImage2NumImages::Two => 2,
ArtcraftGptImage2NumImages::Three => 3,
ArtcraftGptImage2NumImages::Four => 4,
}
}
}
impl ArtcraftGptImage2NumImages {
pub fn as_u64(self) -> u64 {
match self {
Self::One => 1,
Self::Two => 2,
Self::Three => 3,
Self::Four => 4,
}
}
}
fn resolve_image_list_ref(
image_list_ref: Option<ImageListRef>,
) -> Result<Option<Vec<MediaFileToken>>, ArtcraftRouterError> {
match image_list_ref {
None => Ok(None),
Some(ImageListRef::MediaFileTokens(tokens)) => Ok(Some(tokens)),
Some(ImageListRef::Urls(_)) => Ok(None),
}
}
fn plan_quality(quality: Option<CommonQuality>) -> ArtcraftGptImage2Quality {
match quality {
Some(CommonQuality::Low) => ArtcraftGptImage2Quality::Low,
Some(CommonQuality::Medium) => ArtcraftGptImage2Quality::Medium,
Some(CommonQuality::High) => ArtcraftGptImage2Quality::High,
None => ArtcraftGptImage2Quality::High,
}
}
fn plan_image_size(aspect_ratio: Option<CommonAspectRatio>) -> Option<ArtcraftGptImage2ImageSize> {
use ArtcraftGptImage2ImageSize as S;
match aspect_ratio {
None
| Some(CommonAspectRatio::Auto)
| Some(CommonAspectRatio::Auto2k)
| Some(CommonAspectRatio::Auto3k)
| Some(CommonAspectRatio::Auto4k) => Some(S::Auto),
Some(CommonAspectRatio::Square) => Some(S::Square),
Some(CommonAspectRatio::SquareHd) => Some(S::SquareHd),
Some(CommonAspectRatio::WideFourByThree)
| Some(CommonAspectRatio::WideFiveByFour) => Some(S::Landscape4x3),
Some(CommonAspectRatio::WideThreeByTwo)
| Some(CommonAspectRatio::WideSixteenByNine)
| Some(CommonAspectRatio::WideTwentyOneByNine)
| Some(CommonAspectRatio::Wide) => Some(S::Landscape16x9),
Some(CommonAspectRatio::TallThreeByFour)
| Some(CommonAspectRatio::TallFourByFive) => Some(S::Portrait4x3),
Some(CommonAspectRatio::TallTwoByThree)
| Some(CommonAspectRatio::TallNineBySixteen)
| Some(CommonAspectRatio::TallNineByTwentyOne)
| Some(CommonAspectRatio::Tall) => Some(S::Portrait16x9),
}
}
fn plan_num_images(
image_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<ArtcraftGptImage2NumImages, ArtcraftRouterError> {
let count = image_batch_count.unwrap_or(1);
match count {
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 => Ok(ArtcraftGptImage2NumImages::One),
2 => Ok(ArtcraftGptImage2NumImages::Two),
3 => Ok(ArtcraftGptImage2NumImages::Three),
4 => Ok(ArtcraftGptImage2NumImages::Four),
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "image_batch_count",
value: format!("{}", count),
}))
}
_ => Ok(ArtcraftGptImage2NumImages::Four),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::common_image_model::CommonImageModel;
use crate::api::provider::Provider;
// ── Mode detection ──
mod mode_detection {
use super::*;
#[test]
fn no_image_inputs_yields_none() {
let plan = build_plan(&GenerateImageRequest { image_inputs: None, ..base_request() });
assert!(plan.image_inputs.is_none());
}
#[test]
fn media_token_inputs_are_extracted() {
let tokens = vec![
MediaFileToken::new_from_str("mf_test_a"),
MediaFileToken::new_from_str("mf_test_b"),
];
let plan = build_plan(&GenerateImageRequest {
image_inputs: Some(ImageListRef::MediaFileTokens(tokens.clone())),
..base_request()
});
assert!(plan.image_inputs.is_some());
assert_eq!(plan.image_inputs.unwrap().len(), 2);
}
#[test]
fn url_inputs_are_accepted_for_cost_path() {
let urls = vec!["https://example.com/a.jpg".to_string()];
let plan = build_plan(&GenerateImageRequest {
image_inputs: Some(ImageListRef::Urls(urls)),
..base_request()
});
assert!(plan.image_inputs.is_none());
}
}
// ── Quality ──
mod quality_tests {
use super::*;
#[test]
fn defaults_to_high() {
let plan = build_plan(&base_request());
assert!(matches!(plan.quality, ArtcraftGptImage2Quality::High));
}
#[test]
fn low() {
let plan = build_plan(&GenerateImageRequest { quality: Some(CommonQuality::Low), ..base_request() });
assert!(matches!(plan.quality, ArtcraftGptImage2Quality::Low));
}
#[test]
fn medium() {
let plan = build_plan(&GenerateImageRequest { quality: Some(CommonQuality::Medium), ..base_request() });
assert!(matches!(plan.quality, ArtcraftGptImage2Quality::Medium));
}
#[test]
fn high() {
let plan = build_plan(&GenerateImageRequest { quality: Some(CommonQuality::High), ..base_request() });
assert!(matches!(plan.quality, ArtcraftGptImage2Quality::High));
}
}
// ── Image size mapping ──
mod image_size_tests {
use super::*;
#[test]
fn none_yields_auto() {
let plan = build_plan(&GenerateImageRequest { aspect_ratio: None, ..base_request() });
assert!(matches!(plan.image_size, Some(ArtcraftGptImage2ImageSize::Auto)));
}
#[test]
fn auto_variants_yield_auto() {
for ar in [CommonAspectRatio::Auto, CommonAspectRatio::Auto2k, CommonAspectRatio::Auto4k] {
let plan = build_plan(&GenerateImageRequest { aspect_ratio: Some(ar), ..base_request() });
assert!(matches!(plan.image_size, Some(ArtcraftGptImage2ImageSize::Auto)), "expected Auto for {:?}", ar);
}
}
#[test]
fn square() {
let plan = build_plan(&GenerateImageRequest { aspect_ratio: Some(CommonAspectRatio::Square), ..base_request() });
assert!(matches!(plan.image_size, Some(ArtcraftGptImage2ImageSize::Square)));
}
#[test]
fn square_hd() {
let plan = build_plan(&GenerateImageRequest { aspect_ratio: Some(CommonAspectRatio::SquareHd), ..base_request() });
assert!(matches!(plan.image_size, Some(ArtcraftGptImage2ImageSize::SquareHd)));
}
#[test]
fn landscape_4x3_variants() {
for ar in [CommonAspectRatio::WideFourByThree, CommonAspectRatio::WideFiveByFour] {
let plan = build_plan(&GenerateImageRequest { aspect_ratio: Some(ar), ..base_request() });
assert!(matches!(plan.image_size, Some(ArtcraftGptImage2ImageSize::Landscape4x3)), "expected Landscape4x3 for {:?}", ar);
}
}
#[test]
fn landscape_16x9_variants() {
for ar in [
CommonAspectRatio::WideThreeByTwo,
CommonAspectRatio::WideSixteenByNine,
CommonAspectRatio::WideTwentyOneByNine,
CommonAspectRatio::Wide,
] {
let plan = build_plan(&GenerateImageRequest { aspect_ratio: Some(ar), ..base_request() });
assert!(matches!(plan.image_size, Some(ArtcraftGptImage2ImageSize::Landscape16x9)), "expected Landscape16x9 for {:?}", ar);
}
}
#[test]
fn portrait_4x3_variants() {
for ar in [CommonAspectRatio::TallThreeByFour, CommonAspectRatio::TallFourByFive] {
let plan = build_plan(&GenerateImageRequest { aspect_ratio: Some(ar), ..base_request() });
assert!(matches!(plan.image_size, Some(ArtcraftGptImage2ImageSize::Portrait4x3)), "expected Portrait4x3 for {:?}", ar);
}
}
#[test]
fn portrait_16x9_variants() {
for ar in [
CommonAspectRatio::TallTwoByThree,
CommonAspectRatio::TallNineBySixteen,
CommonAspectRatio::TallNineByTwentyOne,
CommonAspectRatio::Tall,
] {
let plan = build_plan(&GenerateImageRequest { aspect_ratio: Some(ar), ..base_request() });
assert!(matches!(plan.image_size, Some(ArtcraftGptImage2ImageSize::Portrait16x9)), "expected Portrait16x9 for {:?}", ar);
}
}
}
// ── Num images ──
mod num_images_tests {
use super::*;
#[test]
fn default_is_one() {
let plan = build_plan(&GenerateImageRequest { image_batch_count: None, ..base_request() });
assert!(matches!(plan.num_images, ArtcraftGptImage2NumImages::One));
}
#[test]
fn direct_mapping() {
for (count, expected) in [(1, 1), (2, 2), (3, 3), (4, 4)] {
let plan = build_plan(&GenerateImageRequest { image_batch_count: Some(count), ..base_request() });
assert_eq!(plan.num_images.as_u64(), expected);
}
}
#[test]
fn zero_is_always_error() {
for strategy in [
RequestMismatchMitigationStrategy::ErrorOut,
RequestMismatchMitigationStrategy::PayMoreUpgrade,
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let result = plan_generate_image_artcraft_gpt_image_2(&GenerateImageRequest {
image_batch_count: Some(0),
request_mismatch_mitigation_strategy: strategy,
..base_request()
});
assert!(matches!(result, Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations))));
}
}
#[test]
fn out_of_range_error_out() {
let result = plan_generate_image_artcraft_gpt_image_2(&GenerateImageRequest {
image_batch_count: Some(5),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..base_request()
});
assert!(matches!(result, Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { .. }))));
}
#[test]
fn out_of_range_clamps_to_four() {
for strategy in [
RequestMismatchMitigationStrategy::PayMoreUpgrade,
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let plan = build_plan(&GenerateImageRequest {
image_batch_count: Some(9),
request_mismatch_mitigation_strategy: strategy,
..base_request()
});
assert!(matches!(plan.num_images, ArtcraftGptImage2NumImages::Four));
}
}
}
// ── as_u64 ──
#[test]
fn num_images_as_u64() {
assert_eq!(ArtcraftGptImage2NumImages::One.as_u64(), 1);
assert_eq!(ArtcraftGptImage2NumImages::Two.as_u64(), 2);
assert_eq!(ArtcraftGptImage2NumImages::Three.as_u64(), 3);
assert_eq!(ArtcraftGptImage2NumImages::Four.as_u64(), 4);
}
// ── Helpers ──
fn base_request() -> GenerateImageRequest {
GenerateImageRequest {
model: CommonImageModel::GptImage2,
provider: Provider::Artcraft,
prompt: Some("a cat in space".to_string()),
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
horizontal_angle: None,
vertical_angle: None,
zoom: None,
}
}
fn build_plan(request: &GenerateImageRequest) -> PlanArtcraftGptImage2 {
let ImageGenerationPlan::ArtcraftGptImage2(plan) =
plan_generate_image_artcraft_gpt_image_2(request).expect("plan should succeed")
else {
panic!("expected ArtcraftGptImage2 variant")
};
plan
}
}
@@ -4,6 +4,7 @@ pub mod plan_generate_image_fal_flux_pro_1p1;
pub mod plan_generate_image_fal_flux_pro_1p1_ultra;
pub mod plan_generate_image_fal_gpt_image_1;
pub mod plan_generate_image_fal_gpt_image_1p5;
pub mod plan_generate_image_fal_gpt_image_2;
pub mod plan_generate_image_fal_seedream_4;
pub mod plan_generate_image_fal_seedream_4p5;
pub mod plan_generate_image_fal_seedream_5_lite;
@@ -0,0 +1,535 @@
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_quality::CommonQuality;
use crate::api::image_list_ref::ImageListRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_image::generate_image_request::GenerateImageRequest;
use crate::generate::generate_image::image_generation_plan::ImageGenerationPlan;
use fal_client::requests::webhook::image::edit::enqueue_gpt_image_2_edit_image_webhook::{
EnqueueGptImage2EditImageNumImages, EnqueueGptImage2EditImageQuality,
EnqueueGptImage2EditImageSize,
};
use fal_client::requests::webhook::image::text::enqueue_gpt_image_2_text_to_image_webhook::{
EnqueueGptImage2TextToImageNumImages, EnqueueGptImage2TextToImageQuality,
EnqueueGptImage2TextToImageSize,
};
#[derive(Debug, Clone, Copy)]
pub enum FalGptImage2NumImages {
One,
Two,
Three,
Four,
}
/// Quality is shared between t2i and edit (Low / Medium / High).
#[derive(Debug, Clone, Copy)]
pub enum FalGptImage2Quality {
Low,
Medium,
High,
}
/// GPT Image 2 supports six image sizes.
#[derive(Debug, Clone, Copy)]
pub enum FalGptImage2ImageSize {
SquareHd,
Square,
Portrait4x3,
Portrait16x9,
Landscape4x3,
Landscape16x9,
Auto,
}
#[derive(Debug, Clone)]
pub struct PlanFalGptImage2 {
pub prompt: Option<String>,
pub image_urls: Vec<String>,
pub image_size: Option<FalGptImage2ImageSize>,
pub quality: FalGptImage2Quality,
pub num_images: FalGptImage2NumImages,
}
pub fn plan_generate_image_fal_gpt_image_2(
request: &GenerateImageRequest,
) -> Result<ImageGenerationPlan, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
let image_urls = resolve_image_list_ref(request.image_inputs.clone())?;
let image_size = plan_image_size(request.aspect_ratio);
let quality = plan_quality(request.quality);
let num_images = plan_num_images(request.image_batch_count, strategy)?;
Ok(ImageGenerationPlan::FalGptImage2(PlanFalGptImage2 {
prompt: request.prompt.clone(),
image_urls,
image_size,
quality,
num_images,
}))
}
fn resolve_image_list_ref(
image_list_ref: Option<ImageListRef>,
) -> Result<Vec<String>, ArtcraftRouterError> {
match image_list_ref {
None => Ok(vec![]),
Some(ImageListRef::Urls(urls)) => Ok(urls.clone()),
Some(ImageListRef::MediaFileTokens(_)) => {
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
}
}
}
fn plan_quality(quality: Option<CommonQuality>) -> FalGptImage2Quality {
match quality {
Some(CommonQuality::Low) => FalGptImage2Quality::Low,
Some(CommonQuality::Medium) => FalGptImage2Quality::Medium,
Some(CommonQuality::High) => FalGptImage2Quality::High,
None => FalGptImage2Quality::High,
}
}
fn plan_image_size(aspect_ratio: Option<CommonAspectRatio>) -> Option<FalGptImage2ImageSize> {
use FalGptImage2ImageSize as S;
match aspect_ratio {
None
| Some(CommonAspectRatio::Auto)
| Some(CommonAspectRatio::Auto2k)
| Some(CommonAspectRatio::Auto3k)
| Some(CommonAspectRatio::Auto4k) => Some(S::Auto),
Some(CommonAspectRatio::Square) => Some(S::Square),
Some(CommonAspectRatio::SquareHd) => Some(S::SquareHd),
Some(CommonAspectRatio::WideFourByThree)
| Some(CommonAspectRatio::WideFiveByFour) => Some(S::Landscape4x3),
Some(CommonAspectRatio::WideThreeByTwo)
| Some(CommonAspectRatio::WideSixteenByNine)
| Some(CommonAspectRatio::WideTwentyOneByNine)
| Some(CommonAspectRatio::Wide) => Some(S::Landscape16x9),
Some(CommonAspectRatio::TallThreeByFour)
| Some(CommonAspectRatio::TallFourByFive) => Some(S::Portrait4x3),
Some(CommonAspectRatio::TallTwoByThree)
| Some(CommonAspectRatio::TallNineBySixteen)
| Some(CommonAspectRatio::TallNineByTwentyOne)
| Some(CommonAspectRatio::Tall) => Some(S::Portrait16x9),
}
}
fn plan_num_images(
image_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<FalGptImage2NumImages, ArtcraftRouterError> {
let count = image_batch_count.unwrap_or(1);
match count {
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 => Ok(FalGptImage2NumImages::One),
2 => Ok(FalGptImage2NumImages::Two),
3 => Ok(FalGptImage2NumImages::Three),
4 => Ok(FalGptImage2NumImages::Four),
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "image_batch_count",
value: format!("{}", count),
}))
}
_ => Ok(FalGptImage2NumImages::Four),
},
}
}
impl FalGptImage2NumImages {
pub fn to_t2i(self) -> EnqueueGptImage2TextToImageNumImages {
use EnqueueGptImage2TextToImageNumImages as T;
match self {
Self::One => T::One,
Self::Two => T::Two,
Self::Three => T::Three,
Self::Four => T::Four,
}
}
pub fn to_edit(self) -> EnqueueGptImage2EditImageNumImages {
use EnqueueGptImage2EditImageNumImages as E;
match self {
Self::One => E::One,
Self::Two => E::Two,
Self::Three => E::Three,
Self::Four => E::Four,
}
}
pub fn as_u64(self) -> u64 {
match self {
Self::One => 1,
Self::Two => 2,
Self::Three => 3,
Self::Four => 4,
}
}
}
impl FalGptImage2Quality {
pub fn to_t2i(self) -> EnqueueGptImage2TextToImageQuality {
use EnqueueGptImage2TextToImageQuality as T;
match self {
Self::Low => T::Low,
Self::Medium => T::Medium,
Self::High => T::High,
}
}
pub fn to_edit(self) -> EnqueueGptImage2EditImageQuality {
use EnqueueGptImage2EditImageQuality as E;
match self {
Self::Low => E::Low,
Self::Medium => E::Medium,
Self::High => E::High,
}
}
}
impl FalGptImage2ImageSize {
pub fn to_t2i(self) -> EnqueueGptImage2TextToImageSize {
use EnqueueGptImage2TextToImageSize as T;
match self {
Self::SquareHd => T::SquareHd,
Self::Square => T::Square,
Self::Portrait4x3 => T::Portrait4x3,
Self::Portrait16x9 => T::Portrait16x9,
Self::Landscape4x3 => T::Landscape4x3,
Self::Landscape16x9 => T::Landscape16x9,
Self::Auto => T::SquareHd,
}
}
pub fn to_edit(self) -> EnqueueGptImage2EditImageSize {
use EnqueueGptImage2EditImageSize as E;
match self {
Self::SquareHd => E::SquareHd,
Self::Square => E::Square,
Self::Portrait4x3 => E::Portrait4x3,
Self::Portrait16x9 => E::Portrait16x9,
Self::Landscape4x3 => E::Landscape4x3,
Self::Landscape16x9 => E::Landscape16x9,
Self::Auto => E::Auto,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::common_image_model::CommonImageModel;
use crate::api::provider::Provider;
// ── Mode detection ──
mod mode_detection {
use super::*;
#[test]
fn no_image_inputs_yields_empty_urls() {
let plan = build_plan(&GenerateImageRequest { image_inputs: None, ..base_request() });
assert!(plan.image_urls.is_empty());
}
#[test]
fn url_image_inputs_are_extracted() {
let urls = vec![
"https://example.com/a.jpg".to_string(),
"https://example.com/b.jpg".to_string(),
];
let plan = build_plan(&GenerateImageRequest {
image_inputs: Some(ImageListRef::Urls(urls.clone())),
..base_request()
});
assert_eq!(plan.image_urls, urls);
}
#[test]
fn media_token_inputs_return_error() {
let result = plan_generate_image_fal_gpt_image_2(&GenerateImageRequest {
image_inputs: Some(ImageListRef::MediaFileTokens(vec![])),
..base_request()
});
assert!(matches!(result, Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))));
}
}
// ── Quality ──
mod quality_tests {
use super::*;
#[test]
fn defaults_to_high() {
let plan = build_plan(&base_request());
assert!(matches!(plan.quality, FalGptImage2Quality::High));
}
#[test]
fn low() {
let plan = build_plan(&GenerateImageRequest { quality: Some(CommonQuality::Low), ..base_request() });
assert!(matches!(plan.quality, FalGptImage2Quality::Low));
}
#[test]
fn medium() {
let plan = build_plan(&GenerateImageRequest { quality: Some(CommonQuality::Medium), ..base_request() });
assert!(matches!(plan.quality, FalGptImage2Quality::Medium));
}
#[test]
fn high() {
let plan = build_plan(&GenerateImageRequest { quality: Some(CommonQuality::High), ..base_request() });
assert!(matches!(plan.quality, FalGptImage2Quality::High));
}
}
// ── Image size mapping ──
mod image_size_tests {
use super::*;
#[test]
fn none_yields_auto() {
let plan = build_plan(&GenerateImageRequest { aspect_ratio: None, ..base_request() });
assert!(matches!(plan.image_size, Some(FalGptImage2ImageSize::Auto)));
}
#[test]
fn auto_variants_yield_auto() {
for ar in [CommonAspectRatio::Auto, CommonAspectRatio::Auto2k, CommonAspectRatio::Auto4k] {
let plan = build_plan(&GenerateImageRequest { aspect_ratio: Some(ar), ..base_request() });
assert!(matches!(plan.image_size, Some(FalGptImage2ImageSize::Auto)), "expected Auto for {:?}", ar);
}
}
#[test]
fn square() {
let plan = build_plan(&GenerateImageRequest { aspect_ratio: Some(CommonAspectRatio::Square), ..base_request() });
assert!(matches!(plan.image_size, Some(FalGptImage2ImageSize::Square)));
}
#[test]
fn square_hd() {
let plan = build_plan(&GenerateImageRequest { aspect_ratio: Some(CommonAspectRatio::SquareHd), ..base_request() });
assert!(matches!(plan.image_size, Some(FalGptImage2ImageSize::SquareHd)));
}
#[test]
fn landscape_4x3_variants() {
for ar in [CommonAspectRatio::WideFourByThree, CommonAspectRatio::WideFiveByFour] {
let plan = build_plan(&GenerateImageRequest { aspect_ratio: Some(ar), ..base_request() });
assert!(matches!(plan.image_size, Some(FalGptImage2ImageSize::Landscape4x3)), "expected Landscape4x3 for {:?}", ar);
}
}
#[test]
fn landscape_16x9_variants() {
for ar in [
CommonAspectRatio::WideThreeByTwo,
CommonAspectRatio::WideSixteenByNine,
CommonAspectRatio::WideTwentyOneByNine,
CommonAspectRatio::Wide,
] {
let plan = build_plan(&GenerateImageRequest { aspect_ratio: Some(ar), ..base_request() });
assert!(matches!(plan.image_size, Some(FalGptImage2ImageSize::Landscape16x9)), "expected Landscape16x9 for {:?}", ar);
}
}
#[test]
fn portrait_4x3_variants() {
for ar in [CommonAspectRatio::TallThreeByFour, CommonAspectRatio::TallFourByFive] {
let plan = build_plan(&GenerateImageRequest { aspect_ratio: Some(ar), ..base_request() });
assert!(matches!(plan.image_size, Some(FalGptImage2ImageSize::Portrait4x3)), "expected Portrait4x3 for {:?}", ar);
}
}
#[test]
fn portrait_16x9_variants() {
for ar in [
CommonAspectRatio::TallTwoByThree,
CommonAspectRatio::TallNineBySixteen,
CommonAspectRatio::TallNineByTwentyOne,
CommonAspectRatio::Tall,
] {
let plan = build_plan(&GenerateImageRequest { aspect_ratio: Some(ar), ..base_request() });
assert!(matches!(plan.image_size, Some(FalGptImage2ImageSize::Portrait16x9)), "expected Portrait16x9 for {:?}", ar);
}
}
}
// ── Num images ──
mod num_images_tests {
use super::*;
#[test]
fn default_is_one() {
let plan = build_plan(&GenerateImageRequest { image_batch_count: None, ..base_request() });
assert!(matches!(plan.num_images, FalGptImage2NumImages::One));
}
#[test]
fn direct_mapping() {
for (count, expected) in [(1, 1), (2, 2), (3, 3), (4, 4)] {
let plan = build_plan(&GenerateImageRequest { image_batch_count: Some(count), ..base_request() });
assert_eq!(plan.num_images.as_u64(), expected);
}
}
#[test]
fn zero_is_always_error() {
for strategy in [
RequestMismatchMitigationStrategy::ErrorOut,
RequestMismatchMitigationStrategy::PayMoreUpgrade,
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let result = plan_generate_image_fal_gpt_image_2(&GenerateImageRequest {
image_batch_count: Some(0),
request_mismatch_mitigation_strategy: strategy,
..base_request()
});
assert!(matches!(result, Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations))));
}
}
#[test]
fn out_of_range_error_out() {
let result = plan_generate_image_fal_gpt_image_2(&GenerateImageRequest {
image_batch_count: Some(5),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..base_request()
});
assert!(matches!(result, Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { .. }))));
}
#[test]
fn out_of_range_clamps_to_four() {
for strategy in [
RequestMismatchMitigationStrategy::PayMoreUpgrade,
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let plan = build_plan(&GenerateImageRequest {
image_batch_count: Some(9),
request_mismatch_mitigation_strategy: strategy,
..base_request()
});
assert!(matches!(plan.num_images, FalGptImage2NumImages::Four));
}
}
}
// ── as_u64 ──
#[test]
fn num_images_as_u64() {
assert_eq!(FalGptImage2NumImages::One.as_u64(), 1);
assert_eq!(FalGptImage2NumImages::Two.as_u64(), 2);
assert_eq!(FalGptImage2NumImages::Three.as_u64(), 3);
assert_eq!(FalGptImage2NumImages::Four.as_u64(), 4);
}
// ── Conversions to fal-client enqueue enums ──
mod conversion_tests {
use super::*;
#[test]
fn num_images_to_t2i() {
use EnqueueGptImage2TextToImageNumImages as T;
assert!(matches!(FalGptImage2NumImages::One.to_t2i(), T::One));
assert!(matches!(FalGptImage2NumImages::Two.to_t2i(), T::Two));
assert!(matches!(FalGptImage2NumImages::Three.to_t2i(), T::Three));
assert!(matches!(FalGptImage2NumImages::Four.to_t2i(), T::Four));
}
#[test]
fn num_images_to_edit() {
use EnqueueGptImage2EditImageNumImages as E;
assert!(matches!(FalGptImage2NumImages::One.to_edit(), E::One));
assert!(matches!(FalGptImage2NumImages::Two.to_edit(), E::Two));
assert!(matches!(FalGptImage2NumImages::Three.to_edit(), E::Three));
assert!(matches!(FalGptImage2NumImages::Four.to_edit(), E::Four));
}
#[test]
fn quality_to_t2i() {
use EnqueueGptImage2TextToImageQuality as T;
assert!(matches!(FalGptImage2Quality::Low.to_t2i(), T::Low));
assert!(matches!(FalGptImage2Quality::Medium.to_t2i(), T::Medium));
assert!(matches!(FalGptImage2Quality::High.to_t2i(), T::High));
}
#[test]
fn quality_to_edit() {
use EnqueueGptImage2EditImageQuality as E;
assert!(matches!(FalGptImage2Quality::Low.to_edit(), E::Low));
assert!(matches!(FalGptImage2Quality::Medium.to_edit(), E::Medium));
assert!(matches!(FalGptImage2Quality::High.to_edit(), E::High));
}
#[test]
fn size_to_t2i() {
use EnqueueGptImage2TextToImageSize as T;
assert!(matches!(FalGptImage2ImageSize::SquareHd.to_t2i(), T::SquareHd));
assert!(matches!(FalGptImage2ImageSize::Square.to_t2i(), T::Square));
assert!(matches!(FalGptImage2ImageSize::Portrait4x3.to_t2i(), T::Portrait4x3));
assert!(matches!(FalGptImage2ImageSize::Portrait16x9.to_t2i(), T::Portrait16x9));
assert!(matches!(FalGptImage2ImageSize::Landscape4x3.to_t2i(), T::Landscape4x3));
assert!(matches!(FalGptImage2ImageSize::Landscape16x9.to_t2i(), T::Landscape16x9));
// Auto falls back to SquareHd for t2i (which doesn't have an Auto variant)
assert!(matches!(FalGptImage2ImageSize::Auto.to_t2i(), T::SquareHd));
}
#[test]
fn size_to_edit() {
use EnqueueGptImage2EditImageSize as E;
assert!(matches!(FalGptImage2ImageSize::SquareHd.to_edit(), E::SquareHd));
assert!(matches!(FalGptImage2ImageSize::Square.to_edit(), E::Square));
assert!(matches!(FalGptImage2ImageSize::Portrait4x3.to_edit(), E::Portrait4x3));
assert!(matches!(FalGptImage2ImageSize::Portrait16x9.to_edit(), E::Portrait16x9));
assert!(matches!(FalGptImage2ImageSize::Landscape4x3.to_edit(), E::Landscape4x3));
assert!(matches!(FalGptImage2ImageSize::Landscape16x9.to_edit(), E::Landscape16x9));
assert!(matches!(FalGptImage2ImageSize::Auto.to_edit(), E::Auto));
}
}
// ── Helpers ──
fn base_request() -> GenerateImageRequest {
GenerateImageRequest {
model: CommonImageModel::GptImage2,
provider: Provider::Fal,
prompt: Some("a cat in space".to_string()),
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
horizontal_angle: None,
vertical_angle: None,
zoom: None,
}
}
fn build_plan(request: &GenerateImageRequest) -> PlanFalGptImage2 {
let ImageGenerationPlan::FalGptImage2(plan) =
plan_generate_image_fal_gpt_image_2(request).expect("plan should succeed")
else {
panic!("expected FalGptImage2 variant")
};
plan
}
}
@@ -58,10 +58,10 @@ mod tests {
use crate::api::common_video_model::CommonVideoModel;
use crate::api::provider::Provider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
fn estimate_usd_cents(duration_seconds: u16) -> u64 {
let request = GenerateVideoRequest {
let request = GenerateVideoRequestBuilder {
model: CommonVideoModel::Kling3p0Pro,
provider: Provider::Artcraft,
prompt: None,
@@ -58,10 +58,10 @@ mod tests {
use crate::api::common_video_model::CommonVideoModel;
use crate::api::provider::Provider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
fn estimate_usd_cents(duration_seconds: u16) -> u64 {
let request = GenerateVideoRequest {
let request = GenerateVideoRequestBuilder {
model: CommonVideoModel::Kling3p0Standard,
provider: Provider::Artcraft,
prompt: None,
@@ -61,10 +61,10 @@ mod tests {
use crate::api::common_video_model::CommonVideoModel;
use crate::api::provider::Provider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
fn estimate_usd_cents(duration_seconds: u16) -> u64 {
let request = GenerateVideoRequest {
let request = GenerateVideoRequestBuilder {
model: CommonVideoModel::Seedance1p5Pro,
provider: Provider::Artcraft,
prompt: None,
@@ -1,7 +1,7 @@
use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::Seedance2p0BatchCount;
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use seedance2pro_client::requests::generate_video::generate_video::{KinoviBatchCount, GenerateVideoArgs, KinoviModelType, KinoviResolution};
use seedance2pro_client::requests::generate_video::generate_video::{KinoviAspectRatio, KinoviBatchCount, KinoviGenerateVideoRequest, KinoviModelType, KinoviOutputResolution};
use crate::api::common_resolution::CommonResolution;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_seedance2p0::PlanArtcraftSeedance2p0;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
@@ -16,30 +16,29 @@ pub (crate) fn estimate_video_cost_artcraft_seedance2p0(
Seedance2p0BatchCount::Four => KinoviBatchCount::Four,
};
// TODO: Make a better client that doesn't require this.
// A dummy session is sufficient — cost estimation does not make any network calls.
let dummy_session = Seedance2ProSession::from_cookies_string(String::new());
let output_resolution = plan.resolution.map(map_common_resolution_to_kinovi);
let args = GenerateVideoArgs {
session: &dummy_session,
let request = KinoviGenerateVideoRequest {
model_type: KinoviModelType::Seedance2Pro,
prompt: String::new(),
resolution: KinoviResolution::Square1x1,
aspect_ratio: KinoviAspectRatio::Square1x1,
duration_seconds,
batch_count,
output_resolution,
// TODO: This is a cost factor
reference_video_urls: None,
// NB: These do not contribute to costs in the Seedance2 integration
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
reference_video_urls: None,
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
host_override: None,
};
let cost_in_usd_cents = args.estimate_cost_in_usd_cents();
let cost_in_usd_cents = request.estimate_cost_in_usd_cents();
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
@@ -51,15 +50,30 @@ pub (crate) fn estimate_video_cost_artcraft_seedance2p0(
}
}
fn map_common_resolution_to_kinovi(resolution: CommonResolution) -> KinoviOutputResolution {
match resolution {
CommonResolution::FourEightyP => KinoviOutputResolution::FourEightyP,
CommonResolution::SevenTwentyP => KinoviOutputResolution::SevenTwentyP,
CommonResolution::TenEightyP => KinoviOutputResolution::TenEightyP,
// For resolutions that don't map directly, default to 720p
_ => KinoviOutputResolution::SevenTwentyP,
}
}
#[cfg(test)]
mod tests {
use crate::api::common_resolution::CommonResolution;
use crate::api::common_video_model::CommonVideoModel;
use crate::api::provider::Provider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
fn estimate_usd_cents(duration_seconds: u16, video_batch_count: u16) -> u64 {
let request = GenerateVideoRequest {
fn estimate_usd_cents(
duration_seconds: u16,
video_batch_count: u16,
resolution: Option<CommonResolution>,
) -> u64 {
let request = GenerateVideoRequestBuilder {
model: CommonVideoModel::Seedance2p0,
provider: Provider::Artcraft,
prompt: None,
@@ -70,7 +84,7 @@ mod tests {
reference_videos: None,
reference_audio: None,
reference_character_tokens: None,
resolution: None,
resolution,
aspect_ratio: None,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(video_batch_count),
@@ -85,23 +99,105 @@ mod tests {
.expect("cost_in_usd_cents should be present")
}
// -- 720p (default / legacy pricing: 250 credits/$1) --
#[test]
fn test_estimate_cost_usd_cents() {
// Batch 1:
assert_eq!(estimate_usd_cents(4, 1), 64);
assert_eq!(estimate_usd_cents(5, 1), 80);
assert_eq!(estimate_usd_cents(6, 1), 96);
assert_eq!(estimate_usd_cents(7, 1), 112);
assert_eq!(estimate_usd_cents(15, 1), 240);
fn pro_720p_batch_1() {
assert_eq!(estimate_usd_cents(4, 1, Some(CommonResolution::SevenTwentyP)), 64);
assert_eq!(estimate_usd_cents(5, 1, Some(CommonResolution::SevenTwentyP)), 80);
assert_eq!(estimate_usd_cents(6, 1, Some(CommonResolution::SevenTwentyP)), 96);
assert_eq!(estimate_usd_cents(7, 1, Some(CommonResolution::SevenTwentyP)), 112);
assert_eq!(estimate_usd_cents(15, 1, Some(CommonResolution::SevenTwentyP)), 240);
}
// Batch 2 = 2×
assert_eq!(estimate_usd_cents(4, 2), 128);
assert_eq!(estimate_usd_cents(5, 2), 160);
assert_eq!(estimate_usd_cents(15, 2), 480);
#[test]
fn pro_720p_batch_2() {
assert_eq!(estimate_usd_cents(4, 2, Some(CommonResolution::SevenTwentyP)), 128);
assert_eq!(estimate_usd_cents(5, 2, Some(CommonResolution::SevenTwentyP)), 160);
assert_eq!(estimate_usd_cents(15, 2, Some(CommonResolution::SevenTwentyP)), 480);
}
// Batch 4 = 4×
assert_eq!(estimate_usd_cents(4, 4), 256);
assert_eq!(estimate_usd_cents(5, 4), 320);
assert_eq!(estimate_usd_cents(15, 4), 960);
#[test]
fn pro_720p_batch_4() {
assert_eq!(estimate_usd_cents(4, 4, Some(CommonResolution::SevenTwentyP)), 256);
assert_eq!(estimate_usd_cents(5, 4, Some(CommonResolution::SevenTwentyP)), 320);
assert_eq!(estimate_usd_cents(15, 4, Some(CommonResolution::SevenTwentyP)), 960);
}
#[test]
fn pro_none_resolution_same_as_720p() {
assert_eq!(
estimate_usd_cents(5, 1, None),
estimate_usd_cents(5, 1, Some(CommonResolution::SevenTwentyP)),
);
}
// -- 480p (new pricing: 193 credits/$1) --
#[test]
fn pro_480p_batch_1() {
assert_eq!(estimate_usd_cents(4, 1, Some(CommonResolution::FourEightyP)), 31);
assert_eq!(estimate_usd_cents(5, 1, Some(CommonResolution::FourEightyP)), 39);
assert_eq!(estimate_usd_cents(10, 1, Some(CommonResolution::FourEightyP)), 78);
assert_eq!(estimate_usd_cents(15, 1, Some(CommonResolution::FourEightyP)), 117);
}
#[test]
fn pro_480p_batch_2() {
assert_eq!(estimate_usd_cents(5, 2, Some(CommonResolution::FourEightyP)), 78);
}
#[test]
fn pro_480p_batch_4() {
assert_eq!(estimate_usd_cents(5, 4, Some(CommonResolution::FourEightyP)), 155);
}
// -- 1080p (new pricing: 193 credits/$1) --
#[test]
fn pro_1080p_batch_1() {
assert_eq!(estimate_usd_cents(4, 1, Some(CommonResolution::TenEightyP)), 187);
assert_eq!(estimate_usd_cents(5, 1, Some(CommonResolution::TenEightyP)), 233);
assert_eq!(estimate_usd_cents(10, 1, Some(CommonResolution::TenEightyP)), 466);
assert_eq!(estimate_usd_cents(15, 1, Some(CommonResolution::TenEightyP)), 699);
}
#[test]
fn pro_1080p_batch_2() {
assert_eq!(estimate_usd_cents(5, 2, Some(CommonResolution::TenEightyP)), 466);
}
#[test]
fn pro_1080p_batch_4() {
assert_eq!(estimate_usd_cents(5, 4, Some(CommonResolution::TenEightyP)), 933);
}
// -- Relative pricing --
#[test]
fn pro_480p_cheaper_than_720p_cheaper_than_1080p() {
let c480 = estimate_usd_cents(5, 1, Some(CommonResolution::FourEightyP));
let c720 = estimate_usd_cents(5, 1, Some(CommonResolution::SevenTwentyP));
let c1080 = estimate_usd_cents(5, 1, Some(CommonResolution::TenEightyP));
assert!(c480 < c720, "480p ({}) should be cheaper than 720p ({})", c480, c720);
assert!(c720 < c1080, "720p ({}) should be cheaper than 1080p ({})", c720, c1080);
}
#[test]
fn cost_scales_with_duration() {
let c4 = estimate_usd_cents(4, 1, Some(CommonResolution::SevenTwentyP));
let c10 = estimate_usd_cents(10, 1, Some(CommonResolution::SevenTwentyP));
let c15 = estimate_usd_cents(15, 1, Some(CommonResolution::SevenTwentyP));
assert!(c4 < c10);
assert!(c10 < c15);
}
#[test]
fn cost_scales_with_batch() {
let b1 = estimate_usd_cents(5, 1, Some(CommonResolution::TenEightyP));
let b2 = estimate_usd_cents(5, 2, Some(CommonResolution::TenEightyP));
let b4 = estimate_usd_cents(5, 4, Some(CommonResolution::TenEightyP));
assert!(b1 < b2);
assert!(b2 < b4);
}
}
@@ -1,7 +1,7 @@
use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::Seedance2p0BatchCount;
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use seedance2pro_client::requests::generate_video::generate_video::{KinoviBatchCount, GenerateVideoArgs, KinoviModelType, KinoviResolution};
use seedance2pro_client::requests::generate_video::generate_video::{KinoviGenerateVideoRequest, KinoviBatchCount, KinoviModelType, KinoviOutputResolution, KinoviAspectRatio};
use crate::api::common_resolution::CommonResolution;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_seedance2p0::PlanArtcraftSeedance2p0;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
@@ -16,26 +16,29 @@ pub(crate) fn estimate_video_cost_artcraft_seedance2p0_fast(
Seedance2p0BatchCount::Four => KinoviBatchCount::Four,
};
let dummy_session = Seedance2ProSession::from_cookies_string(String::new());
let output_resolution = plan.resolution.map(map_common_resolution_to_kinovi);
let args = GenerateVideoArgs {
session: &dummy_session,
model_type: KinoviModelType::Seedance2Fast, // <-- Fast, not Pro
let request = KinoviGenerateVideoRequest {
model_type: KinoviModelType::Seedance2Fast,
prompt: String::new(),
resolution: KinoviResolution::Square1x1,
aspect_ratio: KinoviAspectRatio::Square1x1,
duration_seconds,
batch_count,
output_resolution,
// TODO: This is a cost factor
reference_video_urls: None,
// NB: These do not contribute to costs in the Seedance2 integration
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
reference_video_urls: None,
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
host_override: None,
};
let cost_in_usd_cents = args.estimate_cost_in_usd_cents();
let cost_in_usd_cents = request.estimate_cost_in_usd_cents();
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
@@ -47,15 +50,30 @@ pub(crate) fn estimate_video_cost_artcraft_seedance2p0_fast(
}
}
fn map_common_resolution_to_kinovi(resolution: CommonResolution) -> KinoviOutputResolution {
match resolution {
CommonResolution::FourEightyP => KinoviOutputResolution::FourEightyP,
CommonResolution::SevenTwentyP => KinoviOutputResolution::SevenTwentyP,
CommonResolution::TenEightyP => KinoviOutputResolution::TenEightyP,
// For resolutions that don't map directly, default to 720p
_ => KinoviOutputResolution::SevenTwentyP,
}
}
#[cfg(test)]
mod tests {
use crate::api::common_resolution::CommonResolution;
use crate::api::common_video_model::CommonVideoModel;
use crate::api::provider::Provider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
fn estimate_usd_cents(duration_seconds: u16, video_batch_count: u16) -> u64 {
let request = GenerateVideoRequest {
fn estimate_usd_cents(
duration_seconds: u16,
video_batch_count: u16,
resolution: Option<CommonResolution>,
) -> u64 {
let request = GenerateVideoRequestBuilder {
model: CommonVideoModel::Seedance2p0Fast,
provider: Provider::Artcraft,
prompt: None,
@@ -66,7 +84,7 @@ mod tests {
reference_videos: None,
reference_audio: None,
reference_character_tokens: None,
resolution: None,
resolution,
aspect_ratio: None,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(video_batch_count),
@@ -81,8 +99,43 @@ mod tests {
.expect("cost_in_usd_cents should be present")
}
fn estimate_pro_usd_cents(duration_seconds: u16, video_batch_count: u16) -> u64 {
let request = GenerateVideoRequest {
fn estimate_usd_cents_downgrade(
duration_seconds: u16,
video_batch_count: u16,
resolution: Option<CommonResolution>,
) -> u64 {
let request = GenerateVideoRequestBuilder {
model: CommonVideoModel::Seedance2p0Fast,
provider: Provider::Artcraft,
prompt: None,
negative_prompt: None,
start_frame: None,
end_frame: None,
reference_images: None,
reference_videos: None,
reference_audio: None,
reference_character_tokens: None,
resolution,
aspect_ratio: None,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(video_batch_count),
generate_audio: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayLessDowngrade,
idempotency_token: None,
};
request.build()
.expect("build should succeed")
.estimate_costs()
.cost_in_usd_cents
.expect("cost_in_usd_cents should be present")
}
fn estimate_pro_usd_cents(
duration_seconds: u16,
video_batch_count: u16,
resolution: Option<CommonResolution>,
) -> u64 {
let request = GenerateVideoRequestBuilder {
model: CommonVideoModel::Seedance2p0,
provider: Provider::Artcraft,
prompt: None,
@@ -93,7 +146,7 @@ mod tests {
reference_videos: None,
reference_audio: None,
reference_character_tokens: None,
resolution: None,
resolution,
aspect_ratio: None,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(video_batch_count),
@@ -108,56 +161,133 @@ mod tests {
.expect("cost_in_usd_cents should be present")
}
// -- 720p (default / legacy pricing: 220 credits/$1) --
#[test]
fn cost_is_present() {
assert!(estimate_usd_cents(5, 1) > 0);
fn fast_720p_batch_1() {
assert_eq!(estimate_usd_cents(4, 1, Some(CommonResolution::SevenTwentyP)), 51);
assert_eq!(estimate_usd_cents(5, 1, Some(CommonResolution::SevenTwentyP)), 64);
assert_eq!(estimate_usd_cents(10, 1, Some(CommonResolution::SevenTwentyP)), 127);
assert_eq!(estimate_usd_cents(15, 1, Some(CommonResolution::SevenTwentyP)), 191);
}
#[test]
fn cost_batch_1() {
assert_eq!(estimate_usd_cents(4, 1), 51);
assert_eq!(estimate_usd_cents(5, 1), 64);
assert_eq!(estimate_usd_cents(10, 1), 127);
assert_eq!(estimate_usd_cents(15, 1), 191);
fn fast_720p_batch_2() {
assert_eq!(estimate_usd_cents(5, 2, Some(CommonResolution::SevenTwentyP)), 127);
}
#[test]
fn cost_batch_2() {
assert_eq!(estimate_usd_cents(5, 2), 127);
fn fast_720p_batch_4() {
assert_eq!(estimate_usd_cents(5, 4, Some(CommonResolution::SevenTwentyP)), 255);
}
#[test]
fn cost_batch_4() {
assert_eq!(estimate_usd_cents(5, 4), 255);
fn fast_none_resolution_same_as_720p() {
assert_eq!(
estimate_usd_cents(5, 1, None),
estimate_usd_cents(5, 1, Some(CommonResolution::SevenTwentyP)),
);
}
// -- 480p (new pricing: 193 credits/$1) --
#[test]
fn fast_480p_batch_1() {
assert_eq!(estimate_usd_cents(4, 1, Some(CommonResolution::FourEightyP)), 21);
assert_eq!(estimate_usd_cents(5, 1, Some(CommonResolution::FourEightyP)), 26);
assert_eq!(estimate_usd_cents(10, 1, Some(CommonResolution::FourEightyP)), 52);
assert_eq!(estimate_usd_cents(15, 1, Some(CommonResolution::FourEightyP)), 78);
}
#[test]
fn fast_480p_batch_2() {
assert_eq!(estimate_usd_cents(5, 2, Some(CommonResolution::FourEightyP)), 52);
}
#[test]
fn fast_480p_batch_4() {
assert_eq!(estimate_usd_cents(5, 4, Some(CommonResolution::FourEightyP)), 104);
}
// -- 1080p is not supported by Fast; ErrorOut rejects it --
#[test]
fn fast_1080p_error_out_rejects() {
let request = GenerateVideoRequestBuilder {
model: CommonVideoModel::Seedance2p0Fast,
provider: Provider::Artcraft,
prompt: None,
negative_prompt: None,
start_frame: None,
end_frame: None,
reference_images: None,
reference_videos: None,
reference_audio: None,
reference_character_tokens: None,
resolution: Some(CommonResolution::TenEightyP),
aspect_ratio: None,
duration_seconds: Some(5),
video_batch_count: Some(1),
generate_audio: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
idempotency_token: None,
};
assert!(request.build().is_err());
}
// -- 1080p rounds down to 720p with PayLessDowngrade --
#[test]
fn fast_1080p_downgraded_same_as_720p() {
for duration in [4, 5, 10, 15] {
assert_eq!(
estimate_usd_cents_downgrade(duration, 1, Some(CommonResolution::TenEightyP)),
estimate_usd_cents(duration, 1, Some(CommonResolution::SevenTwentyP)),
);
}
}
// -- Relative pricing --
#[test]
fn fast_480p_cheaper_than_720p() {
let c480 = estimate_usd_cents(5, 1, Some(CommonResolution::FourEightyP));
let c720 = estimate_usd_cents(5, 1, Some(CommonResolution::SevenTwentyP));
assert!(c480 < c720, "480p ({}) should be cheaper than 720p ({})", c480, c720);
}
#[test]
fn fast_is_cheaper_than_pro() {
for duration in [4, 5, 10, 15] {
let fast = estimate_usd_cents(duration, 1);
let pro = estimate_pro_usd_cents(duration, 1);
assert!(
fast < pro,
"Fast ({}) should be cheaper than Pro ({}) at {}s",
fast, pro, duration,
);
for res in [
Some(CommonResolution::FourEightyP),
Some(CommonResolution::SevenTwentyP),
] {
for duration in [4, 5, 10, 15] {
let fast = estimate_usd_cents(duration, 1, res);
let pro = estimate_pro_usd_cents(duration, 1, res);
assert!(
fast < pro,
"Fast ({}) should be cheaper than Pro ({}) at {}s {:?}",
fast, pro, duration, res,
);
}
}
}
#[test]
fn cost_scales_with_duration() {
let c4 = estimate_usd_cents(4, 1);
let c10 = estimate_usd_cents(10, 1);
let c15 = estimate_usd_cents(15, 1);
let c4 = estimate_usd_cents(4, 1, Some(CommonResolution::SevenTwentyP));
let c10 = estimate_usd_cents(10, 1, Some(CommonResolution::SevenTwentyP));
let c15 = estimate_usd_cents(15, 1, Some(CommonResolution::SevenTwentyP));
assert!(c4 < c10);
assert!(c10 < c15);
}
#[test]
fn cost_scales_with_batch() {
let b1 = estimate_usd_cents(5, 1);
let b2 = estimate_usd_cents(5, 2);
let b4 = estimate_usd_cents(5, 4);
let b1 = estimate_usd_cents(5, 1, Some(CommonResolution::SevenTwentyP));
let b2 = estimate_usd_cents(5, 2, Some(CommonResolution::SevenTwentyP));
let b4 = estimate_usd_cents(5, 4, Some(CommonResolution::SevenTwentyP));
assert!(b1 < b2);
assert!(b2 < b4);
}
@@ -1,34 +1,11 @@
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use seedance2pro_client::requests::generate_video::generate_video::{GenerateVideoArgs, KinoviModelType, KinoviResolution};
use crate::generate::generate_video::plan::seedance2pro::plan_generate_video_seedance2pro_seedance2p0::PlanSeedance2proSeedance2p0;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
pub(crate) fn estimate_video_cost_seedance2pro_seedance2p0(
plan: &PlanSeedance2proSeedance2p0,
) -> VideoGenerationCostEstimate {
// A dummy session is sufficient — cost estimation does not make any network calls.
let dummy_session = Seedance2ProSession::from_cookies_string(String::new());
let args = GenerateVideoArgs {
session: &dummy_session,
model_type: KinoviModelType::Seedance2Pro,
prompt: String::new(),
resolution: KinoviResolution::Square1x1, // Resolution does not affect cost
duration_seconds: plan.duration_seconds,
batch_count: plan.batch_count,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
reference_video_urls: None,
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
host_override: None,
};
let cost_in_credits = args.estimate_credits();
let cost_in_usd_cents = args.estimate_cost_in_usd_cents();
let cost_in_credits = plan.request.estimate_credits();
let cost_in_usd_cents = plan.request.estimate_cost_in_usd_cents();
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_credits as u64),
@@ -39,3 +16,146 @@ pub(crate) fn estimate_video_cost_seedance2pro_seedance2p0(
has_watermark: false,
}
}
#[cfg(test)]
mod tests {
use crate::api::common_resolution::CommonResolution;
use crate::api::common_video_model::CommonVideoModel;
use crate::api::provider::Provider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
fn estimate_usd_cents(
duration_seconds: u16,
video_batch_count: u16,
resolution: Option<CommonResolution>,
) -> u64 {
let request = GenerateVideoRequestBuilder {
model: CommonVideoModel::Seedance2p0,
provider: Provider::Seedance2Pro,
prompt: None,
negative_prompt: None,
start_frame: None,
end_frame: None,
reference_images: None,
reference_videos: None,
reference_audio: None,
reference_character_tokens: None,
resolution,
aspect_ratio: None,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(video_batch_count),
generate_audio: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
idempotency_token: None,
};
request.build()
.expect("build should succeed")
.estimate_costs()
.cost_in_usd_cents
.expect("cost_in_usd_cents should be present")
}
// -- 720p (default / legacy pricing: 250 credits/$1) --
#[test]
fn pro_720p_batch_1() {
assert_eq!(estimate_usd_cents(4, 1, Some(CommonResolution::SevenTwentyP)), 64);
assert_eq!(estimate_usd_cents(5, 1, Some(CommonResolution::SevenTwentyP)), 80);
assert_eq!(estimate_usd_cents(6, 1, Some(CommonResolution::SevenTwentyP)), 96);
assert_eq!(estimate_usd_cents(7, 1, Some(CommonResolution::SevenTwentyP)), 112);
assert_eq!(estimate_usd_cents(10, 1, Some(CommonResolution::SevenTwentyP)), 160);
assert_eq!(estimate_usd_cents(15, 1, Some(CommonResolution::SevenTwentyP)), 240);
}
#[test]
fn pro_720p_batch_2() {
assert_eq!(estimate_usd_cents(4, 2, Some(CommonResolution::SevenTwentyP)), 128);
assert_eq!(estimate_usd_cents(5, 2, Some(CommonResolution::SevenTwentyP)), 160);
assert_eq!(estimate_usd_cents(15, 2, Some(CommonResolution::SevenTwentyP)), 480);
}
#[test]
fn pro_720p_batch_4() {
assert_eq!(estimate_usd_cents(4, 4, Some(CommonResolution::SevenTwentyP)), 256);
assert_eq!(estimate_usd_cents(5, 4, Some(CommonResolution::SevenTwentyP)), 320);
assert_eq!(estimate_usd_cents(15, 4, Some(CommonResolution::SevenTwentyP)), 960);
}
#[test]
fn pro_none_resolution_same_as_720p() {
assert_eq!(
estimate_usd_cents(5, 1, None),
estimate_usd_cents(5, 1, Some(CommonResolution::SevenTwentyP)),
);
}
// -- 480p (new pricing: 193 credits/$1) --
#[test]
fn pro_480p_batch_1() {
assert_eq!(estimate_usd_cents(4, 1, Some(CommonResolution::FourEightyP)), 31);
assert_eq!(estimate_usd_cents(5, 1, Some(CommonResolution::FourEightyP)), 39);
assert_eq!(estimate_usd_cents(10, 1, Some(CommonResolution::FourEightyP)), 78);
assert_eq!(estimate_usd_cents(15, 1, Some(CommonResolution::FourEightyP)), 117);
}
#[test]
fn pro_480p_batch_2() {
assert_eq!(estimate_usd_cents(5, 2, Some(CommonResolution::FourEightyP)), 78);
}
#[test]
fn pro_480p_batch_4() {
assert_eq!(estimate_usd_cents(5, 4, Some(CommonResolution::FourEightyP)), 155);
}
// -- 1080p (new pricing: 193 credits/$1) --
#[test]
fn pro_1080p_batch_1() {
assert_eq!(estimate_usd_cents(4, 1, Some(CommonResolution::TenEightyP)), 187);
assert_eq!(estimate_usd_cents(5, 1, Some(CommonResolution::TenEightyP)), 233);
assert_eq!(estimate_usd_cents(10, 1, Some(CommonResolution::TenEightyP)), 466);
assert_eq!(estimate_usd_cents(15, 1, Some(CommonResolution::TenEightyP)), 699);
}
#[test]
fn pro_1080p_batch_2() {
assert_eq!(estimate_usd_cents(5, 2, Some(CommonResolution::TenEightyP)), 466);
}
#[test]
fn pro_1080p_batch_4() {
assert_eq!(estimate_usd_cents(5, 4, Some(CommonResolution::TenEightyP)), 933);
}
// -- Relative pricing --
#[test]
fn pro_480p_cheaper_than_720p_cheaper_than_1080p() {
let c480 = estimate_usd_cents(5, 1, Some(CommonResolution::FourEightyP));
let c720 = estimate_usd_cents(5, 1, Some(CommonResolution::SevenTwentyP));
let c1080 = estimate_usd_cents(5, 1, Some(CommonResolution::TenEightyP));
assert!(c480 < c720, "480p ({}) should be cheaper than 720p ({})", c480, c720);
assert!(c720 < c1080, "720p ({}) should be cheaper than 1080p ({})", c720, c1080);
}
#[test]
fn cost_scales_with_duration() {
let c4 = estimate_usd_cents(4, 1, Some(CommonResolution::SevenTwentyP));
let c10 = estimate_usd_cents(10, 1, Some(CommonResolution::SevenTwentyP));
let c15 = estimate_usd_cents(15, 1, Some(CommonResolution::SevenTwentyP));
assert!(c4 < c10);
assert!(c10 < c15);
}
#[test]
fn cost_scales_with_batch() {
let b1 = estimate_usd_cents(5, 1, Some(CommonResolution::TenEightyP));
let b2 = estimate_usd_cents(5, 2, Some(CommonResolution::TenEightyP));
let b4 = estimate_usd_cents(5, 4, Some(CommonResolution::TenEightyP));
assert!(b1 < b2);
assert!(b2 < b4);
}
}
@@ -1,34 +1,11 @@
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use seedance2pro_client::requests::generate_video::generate_video::{GenerateVideoArgs, KinoviModelType, KinoviResolution};
use crate::generate::generate_video::plan::seedance2pro::plan_generate_video_seedance2pro_seedance2p0_fast::PlanSeedance2proSeedance2p0Fast;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
pub(crate) fn estimate_video_cost_seedance2pro_seedance2p0_fast(
plan: &PlanSeedance2proSeedance2p0Fast,
) -> VideoGenerationCostEstimate {
// A dummy session is sufficient — cost estimation does not make any network calls.
let dummy_session = Seedance2ProSession::from_cookies_string(String::new());
let args = GenerateVideoArgs {
session: &dummy_session,
model_type: KinoviModelType::Seedance2Fast, // <-- Fast, not Pro
prompt: String::new(),
resolution: KinoviResolution::Square1x1, // Resolution does not affect cost
duration_seconds: plan.duration_seconds,
batch_count: plan.batch_count,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
reference_video_urls: None,
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
host_override: None,
};
let cost_in_credits = args.estimate_credits();
let cost_in_usd_cents = args.estimate_cost_in_usd_cents();
let cost_in_credits = plan.request.estimate_credits();
let cost_in_usd_cents = plan.request.estimate_cost_in_usd_cents();
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_credits as u64),
@@ -42,13 +19,18 @@ pub(crate) fn estimate_video_cost_seedance2pro_seedance2p0_fast(
#[cfg(test)]
mod tests {
use crate::api::common_resolution::CommonResolution;
use crate::api::common_video_model::CommonVideoModel;
use crate::api::provider::Provider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
fn estimate_usd_cents(duration_seconds: u16, video_batch_count: u16) -> u64 {
let request = GenerateVideoRequest {
fn estimate_usd_cents(
duration_seconds: u16,
video_batch_count: u16,
resolution: Option<CommonResolution>,
) -> u64 {
let request = GenerateVideoRequestBuilder {
model: CommonVideoModel::Seedance2p0Fast,
provider: Provider::Seedance2Pro,
prompt: None,
@@ -59,7 +41,7 @@ mod tests {
reference_videos: None,
reference_audio: None,
reference_character_tokens: None,
resolution: None,
resolution,
aspect_ratio: None,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(video_batch_count),
@@ -74,36 +56,12 @@ mod tests {
.expect("cost_in_usd_cents should be present")
}
#[test]
fn cost_is_present() {
let cents = estimate_usd_cents(5, 1);
assert!(cents > 0);
}
#[test]
fn cost_batch_1() {
// Seedance2Fast: 28 credits/sec, 220 credits/$1 (with rounding)
assert_eq!(estimate_usd_cents(4, 1), 51);
assert_eq!(estimate_usd_cents(5, 1), 64);
assert_eq!(estimate_usd_cents(10, 1), 127);
assert_eq!(estimate_usd_cents(15, 1), 191);
}
#[test]
fn cost_batch_2() {
assert_eq!(estimate_usd_cents(5, 2), 127);
}
#[test]
fn cost_batch_4() {
assert_eq!(estimate_usd_cents(5, 4), 255);
}
#[test]
fn fast_is_cheaper_than_pro() {
let fast = estimate_usd_cents(5, 1);
let pro_request = GenerateVideoRequest {
fn estimate_pro_usd_cents(
duration_seconds: u16,
video_batch_count: u16,
resolution: Option<CommonResolution>,
) -> u64 {
let request = GenerateVideoRequestBuilder {
model: CommonVideoModel::Seedance2p0,
provider: Provider::Seedance2Pro,
prompt: None,
@@ -114,16 +72,140 @@ mod tests {
reference_videos: None,
reference_audio: None,
reference_character_tokens: None,
resolution: None,
resolution,
aspect_ratio: None,
duration_seconds: Some(5),
video_batch_count: Some(1),
duration_seconds: Some(duration_seconds),
video_batch_count: Some(video_batch_count),
generate_audio: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
idempotency_token: None,
};
let pro = pro_request.build().unwrap().estimate_costs().cost_in_usd_cents.unwrap();
request.build()
.expect("build should succeed")
.estimate_costs()
.cost_in_usd_cents
.expect("cost_in_usd_cents should be present")
}
assert!(fast < pro, "Fast ({}) should be cheaper than Pro ({})", fast, pro);
// -- 720p (default / legacy pricing: 220 credits/$1) --
#[test]
fn fast_720p_batch_1() {
assert_eq!(estimate_usd_cents(4, 1, Some(CommonResolution::SevenTwentyP)), 51);
assert_eq!(estimate_usd_cents(5, 1, Some(CommonResolution::SevenTwentyP)), 64);
assert_eq!(estimate_usd_cents(10, 1, Some(CommonResolution::SevenTwentyP)), 127);
assert_eq!(estimate_usd_cents(15, 1, Some(CommonResolution::SevenTwentyP)), 191);
}
#[test]
fn fast_720p_batch_2() {
assert_eq!(estimate_usd_cents(5, 2, Some(CommonResolution::SevenTwentyP)), 127);
}
#[test]
fn fast_720p_batch_4() {
assert_eq!(estimate_usd_cents(5, 4, Some(CommonResolution::SevenTwentyP)), 255);
}
#[test]
fn fast_none_resolution_same_as_720p() {
assert_eq!(
estimate_usd_cents(5, 1, None),
estimate_usd_cents(5, 1, Some(CommonResolution::SevenTwentyP)),
);
}
// -- 480p (new pricing: 193 credits/$1) --
#[test]
fn fast_480p_batch_1() {
assert_eq!(estimate_usd_cents(4, 1, Some(CommonResolution::FourEightyP)), 21);
assert_eq!(estimate_usd_cents(5, 1, Some(CommonResolution::FourEightyP)), 26);
assert_eq!(estimate_usd_cents(10, 1, Some(CommonResolution::FourEightyP)), 52);
assert_eq!(estimate_usd_cents(15, 1, Some(CommonResolution::FourEightyP)), 78);
}
#[test]
fn fast_480p_batch_2() {
assert_eq!(estimate_usd_cents(5, 2, Some(CommonResolution::FourEightyP)), 52);
}
#[test]
fn fast_480p_batch_4() {
assert_eq!(estimate_usd_cents(5, 4, Some(CommonResolution::FourEightyP)), 104);
}
// -- 1080p maps to 720p for Fast (Kinovi doesn't support 1080p fast) --
#[test]
fn fast_1080p_same_as_720p() {
for duration in [4, 5, 10, 15] {
assert_eq!(
estimate_usd_cents(duration, 1, Some(CommonResolution::TenEightyP)),
estimate_usd_cents(duration, 1, Some(CommonResolution::SevenTwentyP)),
);
}
}
#[test]
fn fast_1080p_batch_2() {
assert_eq!(
estimate_usd_cents(5, 2, Some(CommonResolution::TenEightyP)),
estimate_usd_cents(5, 2, Some(CommonResolution::SevenTwentyP)),
);
}
#[test]
fn fast_1080p_batch_4() {
assert_eq!(
estimate_usd_cents(5, 4, Some(CommonResolution::TenEightyP)),
estimate_usd_cents(5, 4, Some(CommonResolution::SevenTwentyP)),
);
}
// -- Relative pricing --
#[test]
fn fast_480p_cheaper_than_720p() {
let c480 = estimate_usd_cents(5, 1, Some(CommonResolution::FourEightyP));
let c720 = estimate_usd_cents(5, 1, Some(CommonResolution::SevenTwentyP));
assert!(c480 < c720, "480p ({}) should be cheaper than 720p ({})", c480, c720);
}
#[test]
fn fast_is_cheaper_than_pro() {
for res in [
Some(CommonResolution::FourEightyP),
Some(CommonResolution::SevenTwentyP),
Some(CommonResolution::TenEightyP),
] {
for duration in [4, 5, 10, 15] {
let fast = estimate_usd_cents(duration, 1, res);
let pro = estimate_pro_usd_cents(duration, 1, res);
assert!(
fast < pro,
"Fast ({}) should be cheaper than Pro ({}) at {}s {:?}",
fast, pro, duration, res,
);
}
}
}
#[test]
fn cost_scales_with_duration() {
let c4 = estimate_usd_cents(4, 1, Some(CommonResolution::SevenTwentyP));
let c10 = estimate_usd_cents(10, 1, Some(CommonResolution::SevenTwentyP));
let c15 = estimate_usd_cents(15, 1, Some(CommonResolution::SevenTwentyP));
assert!(c4 < c10);
assert!(c10 < c15);
}
#[test]
fn cost_scales_with_batch() {
let b1 = estimate_usd_cents(5, 1, Some(CommonResolution::TenEightyP));
let b2 = estimate_usd_cents(5, 2, Some(CommonResolution::TenEightyP));
let b4 = estimate_usd_cents(5, 4, Some(CommonResolution::TenEightyP));
assert!(b1 < b2);
assert!(b2 < b4);
}
}
@@ -5,13 +5,23 @@ use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraf
use crate::generate::generate_video::generate_video_response::{
ArtcraftVideoResponsePayload, GenerateVideoResponse,
};
use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::Seedance2p0MultiFunctionVideoGenRequest;
use crate::api::common_resolution::CommonResolution as CommonResolutionRouter;
use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::{
Seedance2p0MultiFunctionVideoGenRequest, Seedance2p0OutputResolution,
};
use artcraft_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 output_resolution = plan.resolution.map(|r| match r {
CommonResolutionRouter::FourEightyP => Seedance2p0OutputResolution::FourEightyP,
CommonResolutionRouter::SevenTwentyP => Seedance2p0OutputResolution::SevenTwentyP,
CommonResolutionRouter::TenEightyP => Seedance2p0OutputResolution::TenEightyP,
_ => Seedance2p0OutputResolution::SevenTwentyP,
});
let request = Seedance2p0MultiFunctionVideoGenRequest {
uuid_idempotency_token: plan.idempotency_token.clone(),
prompt: plan.prompt.clone(),
@@ -22,6 +32,7 @@ pub async fn execute_artcraft_seedance2p0(
reference_audio_media_tokens: plan.reference_audio.clone(),
reference_character_tokens: plan.reference_characters.clone(),
aspect_ratio: plan.aspect_ratio,
output_resolution,
duration_seconds: plan.duration_seconds,
batch_count: Some(plan.batch_count),
};
@@ -50,7 +61,7 @@ mod tests {
#[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 = crate::generate::generate_video::generate_video_request::GenerateVideoRequest {
let request = crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder {
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
video_batch_count: Some(1),
prompt: Some("a cat walking through a cyberpunk city at night".to_string()),
@@ -73,7 +84,7 @@ mod tests {
#[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 = crate::generate::generate_video::generate_video_request::GenerateVideoRequest {
let request = crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder {
aspect_ratio: Some(CommonAspectRatio::Square),
video_batch_count: Some(2),
prompt: Some("a dog surfing a wave, cinematic".to_string()),
@@ -1,3 +1,4 @@
use crate::api::common_resolution::CommonResolution as CommonResolutionRouter;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
@@ -5,9 +6,11 @@ use crate::generate::generate_video::generate_video_response::{
ArtcraftVideoResponsePayload, GenerateVideoResponse,
};
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_seedance2p0::PlanArtcraftSeedance2p0;
use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::{Seedance2p0AspectRatio, Seedance2p0BatchCount};
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use artcraft_client::endpoints::omni_gen::generate::video::omni_gen_video::omni_gen_video_generate;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_resolution::CommonResolution;
use enums::common::generation::common_video_model::CommonVideoModel;
/// Execute Seedance 2.0 Fast via the Artcraft omni-gen video endpoint.
@@ -19,34 +22,45 @@ pub async fn execute_artcraft_seedance2p0_fast(
artcraft_client: &RouterArtcraftClient,
) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let aspect_ratio = plan.aspect_ratio.map(|ar| match ar {
artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::Seedance2p0AspectRatio::Landscape16x9 => CommonAspectRatio::WideSixteenByNine,
artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::Seedance2p0AspectRatio::Portrait9x16 => CommonAspectRatio::TallNineBySixteen,
artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::Seedance2p0AspectRatio::Square1x1 => CommonAspectRatio::Square,
artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::Seedance2p0AspectRatio::Standard4x3 => CommonAspectRatio::WideFourByThree,
artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::Seedance2p0AspectRatio::Portrait3x4 => CommonAspectRatio::TallThreeByFour,
Seedance2p0AspectRatio::Landscape16x9 => CommonAspectRatio::WideSixteenByNine,
Seedance2p0AspectRatio::Portrait9x16 => CommonAspectRatio::TallNineBySixteen,
Seedance2p0AspectRatio::Square1x1 => CommonAspectRatio::Square,
Seedance2p0AspectRatio::Standard4x3 => CommonAspectRatio::WideFourByThree,
Seedance2p0AspectRatio::Portrait3x4 => CommonAspectRatio::TallThreeByFour,
});
let resolution = plan.resolution.map(|r| match r {
CommonResolutionRouter::FourEightyP => CommonResolution::FourEightyP,
CommonResolutionRouter::SevenTwentyP => CommonResolution::SevenTwentyP,
CommonResolutionRouter::TenEightyP => CommonResolution::TenEightyP,
CommonResolutionRouter::HalfK => CommonResolution::HalfK,
CommonResolutionRouter::OneK => CommonResolution::OneK,
CommonResolutionRouter::TwoK => CommonResolution::TwoK,
CommonResolutionRouter::ThreeK => CommonResolution::ThreeK,
CommonResolutionRouter::FourK => CommonResolution::FourK,
});
let request = OmniGenVideoCostAndGenerateRequest {
idempotency_token: Some(plan.idempotency_token.clone()),
model: Some(CommonVideoModel::Seedance2p0Fast),
prompt: plan.prompt.clone(),
negative_prompt: None,
start_frame_image_media_token: plan.start_frame.clone(),
end_frame_image_media_token: plan.end_frame.clone(),
reference_image_media_tokens: plan.reference_images.clone(),
reference_video_media_tokens: plan.reference_videos.clone(),
reference_audio_media_tokens: plan.reference_audio.clone(),
reference_character_tokens: None,
resolution: None,
reference_character_tokens: plan.reference_characters.clone(),
resolution,
aspect_ratio,
quality: None,
duration_seconds: plan.duration_seconds.map(|d| d as u16),
video_batch_count: Some(match plan.batch_count {
artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::Seedance2p0BatchCount::One => 1,
artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::Seedance2p0BatchCount::Two => 2,
artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::Seedance2p0BatchCount::Four => 4,
Seedance2p0BatchCount::One => 1,
Seedance2p0BatchCount::Two => 2,
Seedance2p0BatchCount::Four => 4,
}),
quality: None,
generate_audio: None,
negative_prompt: None,
};
let response = omni_gen_video_generate(
@@ -63,3 +77,160 @@ pub async fn execute_artcraft_seedance2p0_fast(
all_inference_job_tokens: all_tokens,
}))
}
#[cfg(test)]
mod tests {
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_resolution::CommonResolution;
use crate::api::common_video_model::CommonVideoModel;
use crate::api::image_ref::ImageRef;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use crate::test_helpers::{base_video_request, get_artcraft_client};
use tokens::tokens::media_files::MediaFileToken;
fn fast_request() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: CommonVideoModel::Seedance2p0Fast,
prompt: Some("a corgi walking through a cyberpunk city at night".to_string()),
..base_video_request()
}
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_text_to_video_fast_720p_wide() {
let client = get_artcraft_client();
let request = GenerateVideoRequestBuilder {
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
resolution: Some(CommonResolution::SevenTwentyP),
video_batch_count: Some(1),
..fast_request()
};
let plan = request.build().unwrap();
let result = plan.generate_video(&client).await;
println!("Result: {:?}", result);
let response = result.expect("generate_video request failed");
let payload = response.get_artcraft_payload().expect("expected Artcraft payload");
println!("Job token: {:?}", payload.inference_job_token);
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_fast_480p_square() {
let client = get_artcraft_client();
let request = GenerateVideoRequestBuilder {
aspect_ratio: Some(CommonAspectRatio::Square),
resolution: Some(CommonResolution::FourEightyP),
video_batch_count: Some(1),
..fast_request()
};
let plan = request.build().unwrap();
let result = plan.generate_video(&client).await;
println!("Result: {:?}", result);
let response = result.expect("generate_video request failed");
let payload = response.get_artcraft_payload().expect("expected Artcraft payload");
println!("Job token: {:?}", payload.inference_job_token);
assert_eq!(1, 2);
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_image_to_video_fast_with_start_keyframe() {
let client = get_artcraft_client();
let request = GenerateVideoRequestBuilder {
start_frame: Some(ImageRef::MediaFileToken(
MediaFileToken::new("mf_test_keyframe_placeholder".to_string()),
)),
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
resolution: Some(CommonResolution::SevenTwentyP),
video_batch_count: Some(1),
..fast_request()
};
let plan = request.build().unwrap();
let result = plan.generate_video(&client).await;
println!("Result: {:?}", result);
let response = result.expect("generate_video request failed");
let payload = response.get_artcraft_payload().expect("expected Artcraft payload");
println!("Job token: {:?}", payload.inference_job_token);
assert_eq!(1, 2);
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_text_to_video_fast_batch_2() {
let client = get_artcraft_client();
let request = GenerateVideoRequestBuilder {
aspect_ratio: Some(CommonAspectRatio::TallNineBySixteen),
resolution: Some(CommonResolution::SevenTwentyP),
video_batch_count: Some(2),
..fast_request()
};
let plan = request.build().unwrap();
let result = plan.generate_video(&client).await;
println!("Result: {:?}", result);
let response = result.expect("generate_video request failed");
let payload = response.get_artcraft_payload().expect("expected Artcraft payload");
println!("Job tokens ({} total):", payload.all_inference_job_tokens.len());
for token in &payload.all_inference_job_tokens {
println!(" {:?}", token);
}
assert_eq!(1, 2);
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_text_to_video_fast_with_reference_images() {
let client = get_artcraft_client();
let request = GenerateVideoRequestBuilder {
reference_images: Some(crate::api::image_list_ref::ImageListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_test_ref_image_placeholder".to_string()),
])),
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
resolution: Some(CommonResolution::SevenTwentyP),
video_batch_count: Some(1),
..fast_request()
};
let plan = request.build().unwrap();
let result = plan.generate_video(&client).await;
println!("Result: {:?}", result);
let response = result.expect("generate_video request failed");
let payload = response.get_artcraft_payload().expect("expected Artcraft payload");
println!("Job token: {:?}", payload.inference_job_token);
assert_eq!(1, 2);
}
#[test]
fn plan_includes_resolution() {
let request = GenerateVideoRequestBuilder {
resolution: Some(CommonResolution::FourEightyP),
..fast_request()
};
let plan = request.build().unwrap();
let VideoGenerationPlan::ArtcraftSeedance2p0Fast(p) = plan else { panic!("wrong variant") };
assert!(matches!(p.resolution, Some(CommonResolution::FourEightyP)));
}
#[test]
fn plan_includes_characters() {
use crate::api::character_list_ref::CharacterListRef;
use tokens::tokens::characters::CharacterToken;
let request = GenerateVideoRequestBuilder {
reference_character_tokens: Some(CharacterListRef::CharacterTokens(vec![
CharacterToken::new("char_test123".to_string()),
])),
..fast_request()
};
let plan = request.build().unwrap();
let VideoGenerationPlan::ArtcraftSeedance2p0Fast(p) = plan else { panic!("wrong variant") };
assert!(p.reference_characters.is_some());
assert_eq!(p.reference_characters.unwrap().len(), 1);
}
}
@@ -7,7 +7,7 @@ use crate::generate::generate_video::generate_video_response::{
use crate::generate::generate_video::plan::seedance2pro::plan_generate_video_seedance2pro_seedance2p0::PlanSeedance2proSeedance2p0;
use crate::utils::download_file::download_file;
use seedance2pro_client::requests::generate_video::generate_video::{
generate_video, GenerateVideoArgs, KinoviModelType,
generate_video, GenerateVideoArgs,
};
use seedance2pro_client::requests::prepare_file_upload::prepare_file_upload::{
prepare_file_upload, PrepareFileUploadArgs,
@@ -26,27 +26,23 @@ pub async fn execute_seedance2pro_seedance2p0(
let session = &seedance2pro_client.session;
// Upload media files to seedance2pro CDN
let start_frame_url = upload_optional_url(session, plan.start_frame_url.as_deref()).await?;
let end_frame_url = upload_optional_url(session, plan.end_frame_url.as_deref()).await?;
let reference_image_urls = upload_optional_url_list(session, plan.reference_image_urls.as_deref()).await?;
let reference_video_urls = upload_optional_url_list(session, plan.reference_video_urls.as_deref()).await?;
let reference_audio_urls = upload_optional_url_list(session, plan.reference_audio_urls.as_deref()).await?;
let start_frame_url = upload_optional_url(session, plan.request.start_frame_url.as_deref()).await?;
let end_frame_url = upload_optional_url(session, plan.request.end_frame_url.as_deref()).await?;
let reference_image_urls = upload_optional_url_list(session, plan.request.reference_image_urls.as_deref()).await?;
let reference_video_urls = upload_optional_url_list(session, plan.request.reference_video_urls.as_deref()).await?;
let reference_audio_urls = upload_optional_url_list(session, plan.request.reference_audio_urls.as_deref()).await?;
let mut request = plan.request.clone();
request.start_frame_url = start_frame_url;
request.end_frame_url = end_frame_url;
request.reference_image_urls = reference_image_urls;
request.reference_video_urls = reference_video_urls;
request.reference_audio_urls = reference_audio_urls;
let args = GenerateVideoArgs {
session,
model_type: KinoviModelType::Seedance2Pro,
prompt: plan.prompt.clone().unwrap_or_default(),
resolution: plan.resolution,
duration_seconds: plan.duration_seconds,
batch_count: plan.batch_count,
start_frame_url,
end_frame_url,
reference_image_urls,
reference_video_urls,
reference_audio_urls,
character_ids: None,
use_face_blur_hack: None,
host_override: None,
request,
};
let response = generate_video(args)
@@ -1,15 +1,15 @@
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_video::execute::seedance2pro::execute_seedance2pro_seedance2p0::{
upload_optional_url, upload_optional_url_list,
};
use crate::generate::generate_video::generate_video_response::{
GenerateVideoResponse, Seedance2proVideoResponsePayload,
};
use crate::generate::generate_video::plan::seedance2pro::plan_generate_video_seedance2pro_seedance2p0_fast::PlanSeedance2proSeedance2p0Fast;
use crate::generate::generate_video::execute::seedance2pro::execute_seedance2pro_seedance2p0::{
upload_optional_url, upload_optional_url_list,
};
use seedance2pro_client::requests::generate_video::generate_video::{
generate_video, GenerateVideoArgs, KinoviModelType,
generate_video, GenerateVideoArgs,
};
/// Execute Seedance 2.0 Fast via the Seedance2Pro/Kinovi provider.
@@ -23,27 +23,23 @@ pub async fn execute_seedance2pro_seedance2p0_fast(
let session = &seedance2pro_client.session;
// Upload media files to seedance2pro CDN
let start_frame_url = upload_optional_url(session, plan.start_frame_url.as_deref()).await?;
let end_frame_url = upload_optional_url(session, plan.end_frame_url.as_deref()).await?;
let reference_image_urls = upload_optional_url_list(session, plan.reference_image_urls.as_deref()).await?;
let reference_video_urls = upload_optional_url_list(session, plan.reference_video_urls.as_deref()).await?;
let reference_audio_urls = upload_optional_url_list(session, plan.reference_audio_urls.as_deref()).await?;
let start_frame_url = upload_optional_url(session, plan.request.start_frame_url.as_deref()).await?;
let end_frame_url = upload_optional_url(session, plan.request.end_frame_url.as_deref()).await?;
let reference_image_urls = upload_optional_url_list(session, plan.request.reference_image_urls.as_deref()).await?;
let reference_video_urls = upload_optional_url_list(session, plan.request.reference_video_urls.as_deref()).await?;
let reference_audio_urls = upload_optional_url_list(session, plan.request.reference_audio_urls.as_deref()).await?;
let mut request = plan.request.clone();
request.start_frame_url = start_frame_url;
request.end_frame_url = end_frame_url;
request.reference_image_urls = reference_image_urls;
request.reference_video_urls = reference_video_urls;
request.reference_audio_urls = reference_audio_urls;
let args = GenerateVideoArgs {
session,
model_type: KinoviModelType::Seedance2Fast, // <-- Fast, not Pro
prompt: plan.prompt.clone().unwrap_or_default(),
resolution: plan.resolution,
duration_seconds: plan.duration_seconds,
batch_count: plan.batch_count,
start_frame_url,
end_frame_url,
reference_image_urls,
reference_video_urls,
reference_audio_urls,
character_ids: None,
use_face_blur_hack: None,
host_override: None,
request,
};
let response = generate_video(args)
@@ -9,17 +9,17 @@ use crate::api::provider::Provider;
use crate::api::video_list_ref::VideoListRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_kling3p0_pro::plan_generate_video_artcraft_kling3p0_pro;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_kling3p0_standard::plan_generate_video_artcraft_kling3p0_standard;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_kling_1_6_pro::plan_generate_video_artcraft_kling_1_6_pro;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_kling_2_1_master::plan_generate_video_artcraft_kling_2_1_master;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_kling_2_1_pro::plan_generate_video_artcraft_kling_2_1_pro;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_kling_2_5_turbo_pro::plan_generate_video_artcraft_kling_2_5_turbo_pro;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_kling_2_6_pro::plan_generate_video_artcraft_kling_2_6_pro;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_kling3p0_pro::plan_generate_video_artcraft_kling3p0_pro;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_kling3p0_standard::plan_generate_video_artcraft_kling3p0_standard;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_seedance_1_0_lite::plan_generate_video_artcraft_seedance_1_0_lite;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_seedance1p5_pro::plan_generate_video_artcraft_seedance1p5_pro;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_seedance2p0::plan_generate_video_artcraft_seedance2p0;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_seedance2p0_fast::plan_generate_video_artcraft_seedance2p0_fast;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_seedance_1_0_lite::plan_generate_video_artcraft_seedance_1_0_lite;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_sora_2::plan_generate_video_artcraft_sora_2;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_sora_2_pro::plan_generate_video_artcraft_sora_2_pro;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_veo_2::plan_generate_video_artcraft_veo_2;
@@ -47,12 +47,16 @@ use crate::generate::generate_video::plan::muapi::plan_generate_video_muapi_seed
use crate::generate::generate_video::plan::seedance2pro::plan_generate_video_seedance2pro_seedance2p0::plan_generate_video_seedance2pro_seedance2p0;
use crate::generate::generate_video::plan::seedance2pro::plan_generate_video_seedance2pro_seedance2p0_fast::plan_generate_video_seedance2pro_seedance2p0_fast;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0::build::build_artcraft_seedance_2p0;
use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0::build::build_kinovi_seedance_2p0;
use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0_fast::build::build_kinovi_seedance_2p0_fast;
use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
/// Plan to either (1) generate a video or (2) determine how much it costs to generate that video.
/// This works across multiple providers by shaping a generic "GenerateVideoRequest" into a provider-specific plan.
/// That plan can then be used to return a cost estimate for that given provider or return a struct that can be used to send a real generation request.
#[derive(Clone, Debug)]
pub struct GenerateVideoRequest {
pub struct GenerateVideoRequestBuilder {
/// Which model to use.
pub model: CommonVideoModel,
@@ -109,7 +113,51 @@ pub struct GenerateVideoRequest {
pub idempotency_token: Option<String>,
}
impl GenerateVideoRequest {
impl Default for GenerateVideoRequestBuilder {
fn default() -> Self {
Self {
model: CommonVideoModel::Seedance2p0,
provider: Provider::Artcraft,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
prompt: None,
negative_prompt: None,
start_frame: None,
end_frame: None,
reference_images: None,
reference_videos: None,
reference_audio: None,
reference_character_tokens: None,
resolution: None,
aspect_ratio: None,
duration_seconds: None,
video_batch_count: None,
generate_audio: None,
idempotency_token: None,
}
}
}
impl GenerateVideoRequestBuilder {
pub fn use_new_builder(&self) -> bool {
match (self.provider, self.model) {
(Provider::Artcraft, CommonVideoModel::Seedance2p0) => true,
(Provider::Seedance2Pro, CommonVideoModel::Seedance2p0) => true,
(Provider::Seedance2Pro, CommonVideoModel::Seedance2p0Fast) => true,
_ => false,
}
}
// New builder (transitional state)
pub fn build2(self) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
match (self.provider, self.model) {
(Provider::Artcraft, CommonVideoModel::Seedance2p0) => build_artcraft_seedance_2p0(self),
(Provider::Seedance2Pro, CommonVideoModel::Seedance2p0) => build_kinovi_seedance_2p0(self),
(Provider::Seedance2Pro, CommonVideoModel::Seedance2p0Fast) => build_kinovi_seedance_2p0_fast(self),
_ => self.unsupported_provider_and_model(),
}
}
/// Read the video generation request, construct a plan, then yield a means to execute it.
pub fn build(&self) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
match self.provider {
@@ -188,6 +236,12 @@ impl GenerateVideoRequest {
))
}
fn unsupported_provider_and_model(&self) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
Err(ArtcraftRouterError::UnsupportedProviderAndModelForNewApi(
format!("Video generation for model `{:?}` is not supported for provider {:?} using the new API", self.model, self.provider)
))
}
pub fn get_or_generate_idempotency_token(&self) -> String {
self.idempotency_token.clone()
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string())
@@ -1,6 +1,6 @@
pub mod cost;
pub mod execute;
pub mod generate_video_request;
pub mod generate_video_request_builder;
pub mod generate_video_response;
pub mod plan;
pub mod video_generation_cost_estimate;
@@ -3,7 +3,7 @@ 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 crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use artcraft_api_defs::generate::video::multi_function::kling_3p0_pro_multi_function_video_gen::{
Kling3p0ProMultiFunctionVideoGenAspectRatio,
@@ -24,7 +24,7 @@ pub struct PlanArtcraftKling3p0Pro {
}
pub fn plan_generate_video_artcraft_kling3p0_pro(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
@@ -166,11 +166,11 @@ mod tests {
use crate::api::provider::Provider;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
fn base_request() -> GenerateVideoRequest {
GenerateVideoRequest {
fn base_request() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: CommonVideoModel::Kling3p0Pro,
provider: Provider::Artcraft,
prompt: Some("a cat in space".to_string()),
@@ -193,7 +193,7 @@ mod tests {
#[test]
fn aspect_ratio_direct_16x9() {
let request = GenerateVideoRequest {
let request = GenerateVideoRequestBuilder {
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..base_request()
};
@@ -203,7 +203,7 @@ mod tests {
#[test]
fn aspect_ratio_direct_9x16() {
let request = GenerateVideoRequest {
let request = GenerateVideoRequestBuilder {
aspect_ratio: Some(CommonAspectRatio::TallNineBySixteen),
..base_request()
};
@@ -213,7 +213,7 @@ mod tests {
#[test]
fn aspect_ratio_direct_square() {
let request = GenerateVideoRequest {
let request = GenerateVideoRequestBuilder {
aspect_ratio: Some(CommonAspectRatio::Square),
..base_request()
};
@@ -223,7 +223,7 @@ mod tests {
#[test]
fn aspect_ratio_error_out_on_unsupported() {
let request = GenerateVideoRequest {
let request = GenerateVideoRequestBuilder {
aspect_ratio: Some(CommonAspectRatio::WideThreeByTwo),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..base_request()
@@ -238,7 +238,7 @@ mod tests {
#[test]
fn duration_valid_range() {
for d in 3..=15 {
let request = GenerateVideoRequest {
let request = GenerateVideoRequestBuilder {
duration_seconds: Some(d),
..base_request()
};
@@ -249,7 +249,7 @@ mod tests {
#[test]
fn duration_out_of_range_error_out() {
let request = GenerateVideoRequest {
let request = GenerateVideoRequestBuilder {
duration_seconds: Some(16),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..base_request()
@@ -263,7 +263,7 @@ mod tests {
#[test]
fn url_image_ref_returns_error() {
let request = GenerateVideoRequest {
let request = GenerateVideoRequestBuilder {
start_frame: Some(ImageRef::Url("https://example.com/image.jpg".to_string())),
..base_request()
};
@@ -3,7 +3,7 @@ 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 crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use artcraft_api_defs::generate::video::multi_function::kling_3p0_standard_multi_function_video_gen::{
Kling3p0StandardMultiFunctionVideoGenAspectRatio,
@@ -24,7 +24,7 @@ pub struct PlanArtcraftKling3p0Standard {
}
pub fn plan_generate_video_artcraft_kling3p0_standard(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
@@ -166,11 +166,11 @@ mod tests {
use crate::api::provider::Provider;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
fn base_request() -> GenerateVideoRequest {
GenerateVideoRequest {
fn base_request() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: CommonVideoModel::Kling3p0Standard,
provider: Provider::Artcraft,
prompt: Some("a cat in space".to_string()),
@@ -193,7 +193,7 @@ mod tests {
#[test]
fn aspect_ratio_direct_16x9() {
let request = GenerateVideoRequest {
let request = GenerateVideoRequestBuilder {
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..base_request()
};
@@ -203,7 +203,7 @@ mod tests {
#[test]
fn aspect_ratio_direct_9x16() {
let request = GenerateVideoRequest {
let request = GenerateVideoRequestBuilder {
aspect_ratio: Some(CommonAspectRatio::TallNineBySixteen),
..base_request()
};
@@ -213,7 +213,7 @@ mod tests {
#[test]
fn aspect_ratio_direct_square() {
let request = GenerateVideoRequest {
let request = GenerateVideoRequestBuilder {
aspect_ratio: Some(CommonAspectRatio::Square),
..base_request()
};
@@ -223,7 +223,7 @@ mod tests {
#[test]
fn aspect_ratio_error_out_on_unsupported() {
let request = GenerateVideoRequest {
let request = GenerateVideoRequestBuilder {
aspect_ratio: Some(CommonAspectRatio::WideThreeByTwo),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..base_request()
@@ -238,7 +238,7 @@ mod tests {
#[test]
fn duration_valid_range() {
for d in 3..=15 {
let request = GenerateVideoRequest {
let request = GenerateVideoRequestBuilder {
duration_seconds: Some(d),
..base_request()
};
@@ -249,7 +249,7 @@ mod tests {
#[test]
fn duration_out_of_range_error_out() {
let request = GenerateVideoRequest {
let request = GenerateVideoRequestBuilder {
duration_seconds: Some(16),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..base_request()
@@ -263,7 +263,7 @@ mod tests {
#[test]
fn url_image_ref_returns_error() {
let request = GenerateVideoRequest {
let request = GenerateVideoRequestBuilder {
start_frame: Some(ImageRef::Url("https://example.com/image.jpg".to_string())),
..base_request()
};
@@ -3,7 +3,7 @@ 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 crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use artcraft_api_defs::generate::video::generate_kling_1_6_pro_image_to_video::{
GenerateKling16ProAspectRatio, GenerateKling16ProDuration,
@@ -21,7 +21,7 @@ pub struct PlanArtcraftKling16Pro {
}
pub fn plan_generate_video_artcraft_kling_1_6_pro(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
@@ -3,7 +3,7 @@ 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 crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use artcraft_api_defs::generate::video::generate_kling_2_1_master_image_to_video::{
GenerateKling21MasterAspectRatio, GenerateKling21MasterDuration,
@@ -20,7 +20,7 @@ pub struct PlanArtcraftKling21Master {
}
pub fn plan_generate_video_artcraft_kling_2_1_master(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
@@ -3,7 +3,7 @@ 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 crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use artcraft_api_defs::generate::video::generate_kling_2_1_pro_image_to_video::{
GenerateKling21ProAspectRatio, GenerateKling21ProDuration,
@@ -21,7 +21,7 @@ pub struct PlanArtcraftKling21Pro {
}
pub fn plan_generate_video_artcraft_kling_2_1_pro(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
@@ -3,7 +3,7 @@ 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 crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use artcraft_api_defs::generate::video::multi_function::kling_2_5_turbo_multi_function_video_gen::{
Kling2p5TurboProMultiFunctionVideoGenAspectRatio, Kling2p5TurboProMultiFunctionVideoGenDuration,
@@ -22,7 +22,7 @@ pub struct PlanArtcraftKling2p5TurboPro {
}
pub fn plan_generate_video_artcraft_kling_2_5_turbo_pro(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
@@ -3,7 +3,7 @@ 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 crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use artcraft_api_defs::generate::video::multi_function::kling_2_6_multi_function_video_gen::{
Kling2p6ProMultiFunctionVideoGenAspectRatio, Kling2p6ProMultiFunctionVideoGenDuration,
@@ -22,7 +22,7 @@ pub struct PlanArtcraftKling2p6Pro {
}
pub fn plan_generate_video_artcraft_kling_2_6_pro(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
@@ -3,7 +3,7 @@ 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 crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use artcraft_api_defs::generate::video::multi_function::seedance_1p5_pro_multi_function_video_gen::{
Seedance1p5ProMultiFunctionVideoGenAspectRatio,
@@ -25,7 +25,7 @@ pub struct PlanArtcraftSeedance1p5Pro {
}
pub fn plan_generate_video_artcraft_seedance1p5_pro(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
@@ -184,11 +184,11 @@ mod tests {
use crate::api::provider::Provider;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
fn base_seedance_1p5_pro_request() -> GenerateVideoRequest {
GenerateVideoRequest {
fn base_seedance_1p5_pro_request() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: CommonVideoModel::Seedance1p5Pro,
provider: Provider::Artcraft,
prompt: Some("a cat in space".to_string()),
@@ -211,7 +211,7 @@ mod tests {
#[test]
fn aspect_ratio_direct_16x9() {
let request = GenerateVideoRequest {
let request = GenerateVideoRequestBuilder {
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..base_seedance_1p5_pro_request()
};
@@ -221,7 +221,7 @@ mod tests {
#[test]
fn aspect_ratio_direct_9x16() {
let request = GenerateVideoRequest {
let request = GenerateVideoRequestBuilder {
aspect_ratio: Some(CommonAspectRatio::TallNineBySixteen),
..base_seedance_1p5_pro_request()
};
@@ -231,7 +231,7 @@ mod tests {
#[test]
fn aspect_ratio_direct_21x9() {
let request = GenerateVideoRequest {
let request = GenerateVideoRequestBuilder {
aspect_ratio: Some(CommonAspectRatio::WideTwentyOneByNine),
..base_seedance_1p5_pro_request()
};
@@ -241,7 +241,7 @@ mod tests {
#[test]
fn aspect_ratio_error_out_on_unsupported() {
let request = GenerateVideoRequest {
let request = GenerateVideoRequestBuilder {
aspect_ratio: Some(CommonAspectRatio::WideThreeByTwo),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..base_seedance_1p5_pro_request()
@@ -256,7 +256,7 @@ mod tests {
#[test]
fn duration_valid_range() {
for d in 4..=12 {
let request = GenerateVideoRequest {
let request = GenerateVideoRequestBuilder {
duration_seconds: Some(d),
..base_seedance_1p5_pro_request()
};
@@ -267,7 +267,7 @@ mod tests {
#[test]
fn duration_out_of_range_error_out() {
let request = GenerateVideoRequest {
let request = GenerateVideoRequestBuilder {
duration_seconds: Some(13),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..base_seedance_1p5_pro_request()
@@ -281,7 +281,7 @@ mod tests {
#[test]
fn url_image_ref_accepted_for_cost_path() {
let request = GenerateVideoRequest {
let request = GenerateVideoRequestBuilder {
start_frame: Some(ImageRef::Url("https://example.com/image.jpg".to_string())),
..base_seedance_1p5_pro_request()
};
@@ -1,12 +1,13 @@
use crate::api::audio_list_ref::AudioListRef;
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_resolution::CommonResolution;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::video_list_ref::VideoListRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::{
Seedance2p0AspectRatio, Seedance2p0BatchCount,
@@ -24,13 +25,14 @@ pub struct PlanArtcraftSeedance2p0 {
pub reference_audio: Option<Vec<MediaFileToken>>,
pub reference_characters: Option<Vec<CharacterToken>>,
pub aspect_ratio: Option<Seedance2p0AspectRatio>,
pub resolution: Option<CommonResolution>,
pub duration_seconds: Option<u8>,
pub batch_count: Seedance2p0BatchCount,
pub idempotency_token: String,
}
pub fn plan_generate_video_artcraft_seedance2p0(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
@@ -41,6 +43,7 @@ pub fn plan_generate_video_artcraft_seedance2p0(
let reference_audio = resolve_audio_list_ref(request.reference_audio.clone())?;
let aspect_ratio = plan_aspect_ratio(request.aspect_ratio, strategy)?;
let resolution = plan_output_resolution(request.resolution, strategy)?;
let batch_count = plan_batch_count(request.video_batch_count, strategy)?;
let duration_seconds = plan_duration(request.duration_seconds, strategy)?;
@@ -53,6 +56,7 @@ pub fn plan_generate_video_artcraft_seedance2p0(
reference_audio,
reference_characters: resolve_character_list_ref(request.reference_character_tokens.clone()),
aspect_ratio,
resolution,
duration_seconds,
batch_count,
idempotency_token: request.get_or_generate_idempotency_token(),
@@ -228,6 +232,45 @@ fn plan_duration(
}
}
// Seedance 2.0 Pro supports output resolutions: 480p, 720p, 1080p.
fn plan_output_resolution(
resolution: Option<CommonResolution>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<CommonResolution>, ArtcraftRouterError> {
match resolution {
None => Ok(None),
// Direct mappings
Some(CommonResolution::FourEightyP)
| Some(CommonResolution::SevenTwentyP)
| Some(CommonResolution::TenEightyP) => Ok(resolution),
// Mismatches
Some(unsupported) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "resolution",
value: format!("{:?}", unsupported),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => {
// HalfK → 480p (up); OneK/TwoK/ThreeK/FourK → 1080p (max)
Ok(Some(match unsupported {
CommonResolution::HalfK => CommonResolution::FourEightyP,
_ => CommonResolution::TenEightyP,
}))
}
RequestMismatchMitigationStrategy::PayLessDowngrade => {
// HalfK → 480p (min); OneK/TwoK/ThreeK/FourK → 1080p (closest below)
Ok(Some(match unsupported {
CommonResolution::HalfK => CommonResolution::FourEightyP,
_ => CommonResolution::TenEightyP,
}))
}
},
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -243,7 +286,7 @@ mod tests {
#[test]
fn aspect_ratio_direct_16x9() {
let request = GenerateVideoRequest {
let request = GenerateVideoRequestBuilder {
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..base_video_request()
};
@@ -253,7 +296,7 @@ mod tests {
#[test]
fn aspect_ratio_direct_9x16() {
let request = GenerateVideoRequest {
let request = GenerateVideoRequestBuilder {
aspect_ratio: Some(CommonAspectRatio::TallNineBySixteen),
..base_video_request()
};
@@ -263,7 +306,7 @@ mod tests {
#[test]
fn aspect_ratio_direct_square() {
let request = GenerateVideoRequest {
let request = GenerateVideoRequestBuilder {
aspect_ratio: Some(CommonAspectRatio::Square),
..base_video_request()
};
@@ -278,7 +321,7 @@ mod tests {
RequestMismatchMitigationStrategy::PayMoreUpgrade,
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let request = GenerateVideoRequest {
let request = GenerateVideoRequestBuilder {
aspect_ratio: Some(CommonAspectRatio::WideThreeByTwo),
request_mismatch_mitigation_strategy: strategy,
..base_video_request()
@@ -293,7 +336,7 @@ mod tests {
#[test]
fn aspect_ratio_error_out_on_unsupported() {
let request = GenerateVideoRequest {
let request = GenerateVideoRequestBuilder {
aspect_ratio: Some(CommonAspectRatio::WideThreeByTwo),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..base_video_request()
@@ -312,7 +355,7 @@ mod tests {
RequestMismatchMitigationStrategy::PayMoreUpgrade,
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let request = GenerateVideoRequest {
let request = GenerateVideoRequestBuilder {
video_batch_count: Some(0),
request_mismatch_mitigation_strategy: strategy,
..base_video_request()
@@ -327,22 +370,22 @@ mod tests {
#[test]
fn batch_count_direct_mapping() {
let req = GenerateVideoRequest { video_batch_count: Some(1), ..base_video_request() };
let req = GenerateVideoRequestBuilder { video_batch_count: Some(1), ..base_video_request() };
let VideoGenerationPlan::ArtcraftSeedance2p0(plan) = req.build().unwrap() else { panic!("wrong variant") };
assert!(matches!(plan.batch_count, Seedance2p0BatchCount::One));
let req = GenerateVideoRequest { video_batch_count: Some(2), ..base_video_request() };
let req = GenerateVideoRequestBuilder { video_batch_count: Some(2), ..base_video_request() };
let VideoGenerationPlan::ArtcraftSeedance2p0(plan) = req.build().unwrap() else { panic!("wrong variant") };
assert!(matches!(plan.batch_count, Seedance2p0BatchCount::Two));
let req = GenerateVideoRequest { video_batch_count: Some(4), ..base_video_request() };
let req = GenerateVideoRequestBuilder { video_batch_count: Some(4), ..base_video_request() };
let VideoGenerationPlan::ArtcraftSeedance2p0(plan) = req.build().unwrap() else { panic!("wrong variant") };
assert!(matches!(plan.batch_count, Seedance2p0BatchCount::Four));
}
#[test]
fn batch_count_three_upgrade_rounds_to_four() {
let request = GenerateVideoRequest {
let request = GenerateVideoRequestBuilder {
video_batch_count: Some(3),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
..base_video_request()
@@ -353,7 +396,7 @@ mod tests {
#[test]
fn batch_count_three_downgrade_rounds_to_two() {
let request = GenerateVideoRequest {
let request = GenerateVideoRequestBuilder {
video_batch_count: Some(3),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayLessDowngrade,
..base_video_request()
@@ -364,7 +407,7 @@ mod tests {
#[test]
fn url_image_ref_returns_error() {
let request = GenerateVideoRequest {
let request = GenerateVideoRequestBuilder {
start_frame: Some(ImageRef::Url("https://example.com/image.jpg".to_string())),
..base_video_request()
};
@@ -1,12 +1,13 @@
use crate::api::audio_list_ref::AudioListRef;
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_resolution::CommonResolution;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::video_list_ref::VideoListRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_seedance2p0::PlanArtcraftSeedance2p0;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::{
@@ -16,7 +17,7 @@ use tokens::tokens::characters::CharacterToken;
use tokens::tokens::media_files::MediaFileToken;
pub fn plan_generate_video_artcraft_seedance2p0_fast(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
@@ -27,6 +28,7 @@ pub fn plan_generate_video_artcraft_seedance2p0_fast(
let reference_audio = resolve_audio_list_ref(request.reference_audio.clone())?;
let aspect_ratio = plan_aspect_ratio(request.aspect_ratio, strategy)?;
let resolution = plan_output_resolution(request.resolution, strategy)?;
let batch_count = plan_batch_count(request.video_batch_count, strategy)?;
let duration_seconds = plan_duration(request.duration_seconds, strategy)?;
@@ -39,6 +41,7 @@ pub fn plan_generate_video_artcraft_seedance2p0_fast(
reference_audio,
reference_characters: resolve_character_list_ref(request.reference_character_tokens.clone()),
aspect_ratio,
resolution,
duration_seconds,
batch_count,
idempotency_token: request.get_or_generate_idempotency_token(),
@@ -199,6 +202,44 @@ fn plan_duration(
}
}
// Seedance 2.0 Fast supports output resolutions: 480p and 720p only.
fn plan_output_resolution(
resolution: Option<CommonResolution>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<CommonResolution>, ArtcraftRouterError> {
match resolution {
None => Ok(None),
// Direct mappings
Some(CommonResolution::FourEightyP)
| Some(CommonResolution::SevenTwentyP) => Ok(resolution),
// Mismatches
Some(unsupported) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "resolution",
value: format!("{:?}", unsupported),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => {
// HalfK → 480p (up); TenEightyP/OneK+ → 720p (max)
Ok(Some(match unsupported {
CommonResolution::HalfK => CommonResolution::FourEightyP,
_ => CommonResolution::SevenTwentyP,
}))
}
RequestMismatchMitigationStrategy::PayLessDowngrade => {
// HalfK → 480p (min); TenEightyP/OneK+ → 720p (closest below)
Ok(Some(match unsupported {
CommonResolution::HalfK => CommonResolution::FourEightyP,
_ => CommonResolution::SevenTwentyP,
}))
}
},
}
}
#[cfg(test)]
mod tests {
use crate::api::common_aspect_ratio::CommonAspectRatio;
@@ -208,14 +249,14 @@ mod tests {
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::{
Seedance2p0AspectRatio, Seedance2p0BatchCount,
};
fn base_request() -> GenerateVideoRequest {
GenerateVideoRequest {
fn base_request() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: CommonVideoModel::Seedance2p0Fast,
provider: Provider::Artcraft,
prompt: Some("a cat in space".to_string()),
@@ -254,7 +295,7 @@ mod tests {
#[test]
fn aspect_ratio_16x9() {
let req = GenerateVideoRequest {
let req = GenerateVideoRequestBuilder {
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..base_request()
};
@@ -265,7 +306,7 @@ mod tests {
#[test]
fn aspect_ratio_9x16() {
let req = GenerateVideoRequest {
let req = GenerateVideoRequestBuilder {
aspect_ratio: Some(CommonAspectRatio::TallNineBySixteen),
..base_request()
};
@@ -276,7 +317,7 @@ mod tests {
#[test]
fn aspect_ratio_square() {
let req = GenerateVideoRequest {
let req = GenerateVideoRequestBuilder {
aspect_ratio: Some(CommonAspectRatio::Square),
..base_request()
};
@@ -295,7 +336,7 @@ mod tests {
#[test]
fn batch_count_2() {
let req = GenerateVideoRequest { video_batch_count: Some(2), ..base_request() };
let req = GenerateVideoRequestBuilder { video_batch_count: Some(2), ..base_request() };
let plan = req.build().unwrap();
let VideoGenerationPlan::ArtcraftSeedance2p0Fast(p) = plan else { panic!("wrong variant") };
assert!(matches!(p.batch_count, Seedance2p0BatchCount::Two));
@@ -303,7 +344,7 @@ mod tests {
#[test]
fn batch_count_4() {
let req = GenerateVideoRequest { video_batch_count: Some(4), ..base_request() };
let req = GenerateVideoRequestBuilder { video_batch_count: Some(4), ..base_request() };
let plan = req.build().unwrap();
let VideoGenerationPlan::ArtcraftSeedance2p0Fast(p) = plan else { panic!("wrong variant") };
assert!(matches!(p.batch_count, Seedance2p0BatchCount::Four));
@@ -311,7 +352,7 @@ mod tests {
#[test]
fn duration_in_range() {
let req = GenerateVideoRequest { duration_seconds: Some(10), ..base_request() };
let req = GenerateVideoRequestBuilder { duration_seconds: Some(10), ..base_request() };
let plan = req.build().unwrap();
let VideoGenerationPlan::ArtcraftSeedance2p0Fast(p) = plan else { panic!("wrong variant") };
assert_eq!(p.duration_seconds, Some(10));
@@ -319,7 +360,7 @@ mod tests {
#[test]
fn duration_clamped_to_max() {
let req = GenerateVideoRequest { duration_seconds: Some(99), ..base_request() };
let req = GenerateVideoRequestBuilder { duration_seconds: Some(99), ..base_request() };
let plan = req.build().unwrap();
let VideoGenerationPlan::ArtcraftSeedance2p0Fast(p) = plan else { panic!("wrong variant") };
assert_eq!(p.duration_seconds, Some(15));
@@ -327,7 +368,7 @@ mod tests {
#[test]
fn url_image_ref_returns_error() {
let req = GenerateVideoRequest {
let req = GenerateVideoRequestBuilder {
start_frame: Some(ImageRef::Url("https://example.com/image.jpg".to_string())),
..base_request()
};
@@ -4,7 +4,7 @@ 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 crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use artcraft_api_defs::generate::video::generate_seedance_1_0_lite_image_to_video::{
GenerateSeedance10LiteAspectRatio, GenerateSeedance10LiteDuration,
@@ -24,7 +24,7 @@ pub struct PlanArtcraftSeedance10Lite {
}
pub fn plan_generate_video_artcraft_seedance_1_0_lite(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
@@ -4,7 +4,7 @@ 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 crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use artcraft_api_defs::generate::video::multi_function::sora_2_multi_function_video_gen::{
Sora2MultiFunctionVideoGenAspectRatio, Sora2MultiFunctionVideoGenDuration,
@@ -23,7 +23,7 @@ pub struct PlanArtcraftSora2 {
}
pub fn plan_generate_video_artcraft_sora_2(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
@@ -4,7 +4,7 @@ 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 crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use artcraft_api_defs::generate::video::multi_function::sora_2_pro_multi_function_video_gen::{
Sora2ProMultiFunctionVideoGenAspectRatio, Sora2ProMultiFunctionVideoGenDuration,
@@ -23,7 +23,7 @@ pub struct PlanArtcraftSora2Pro {
}
pub fn plan_generate_video_artcraft_sora_2_pro(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
@@ -3,7 +3,7 @@ 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 crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use artcraft_api_defs::generate::video::generate_veo_2_image_to_video::{
GenerateVeo2AspectRatio, GenerateVeo2Duration,
@@ -23,7 +23,7 @@ pub struct PlanArtcraftVeo2 {
}
pub fn plan_generate_video_artcraft_veo_2(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
@@ -4,7 +4,7 @@ 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 crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use artcraft_api_defs::generate::video::generate_veo_3_image_to_video::{
GenerateVeo3AspectRatio, GenerateVeo3Duration, GenerateVeo3Resolution,
@@ -29,7 +29,7 @@ pub struct PlanArtcraftVeo3 {
}
pub fn plan_generate_video_artcraft_veo_3(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
@@ -4,7 +4,7 @@ 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 crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use artcraft_api_defs::generate::video::generate_veo_3_fast_image_to_video::{
GenerateVeo3FastAspectRatio, GenerateVeo3FastDuration, GenerateVeo3FastResolution,
@@ -23,7 +23,7 @@ pub struct PlanArtcraftVeo3Fast {
}
pub fn plan_generate_video_artcraft_veo_3_fast(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
@@ -4,7 +4,7 @@ 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 crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use artcraft_api_defs::generate::video::multi_function::veo_3p1_multi_function_video_gen::{
Veo3p1MultiFunctionVideoGenAspectRatio, Veo3p1MultiFunctionVideoGenDuration,
@@ -26,14 +26,14 @@ pub struct PlanArtcraftVeo3p1 {
}
pub fn plan_generate_video_artcraft_veo_3p1(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
let plan = build_plan_artcraft_veo_3p1(request)?;
Ok(VideoGenerationPlan::ArtcraftVeo3p1(plan))
}
pub(crate) fn build_plan_artcraft_veo_3p1(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<PlanArtcraftVeo3p1, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
@@ -4,7 +4,7 @@ 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 crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use artcraft_api_defs::generate::video::multi_function::veo_3p1_fast_multi_function_video_gen::{
Veo3p1FastMultiFunctionVideoGenAspectRatio, Veo3p1FastMultiFunctionVideoGenDuration,
@@ -26,7 +26,7 @@ pub struct PlanArtcraftVeo3p1Fast {
}
pub fn plan_generate_video_artcraft_veo_3p1_fast(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
@@ -3,7 +3,7 @@ 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 crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use fal_client::requests::webhook::video::image::enqueue_kling_v1p6_pro_image_to_video_webhook::{
Kling1p6ProAspectRatio, Kling1p6ProDuration,
@@ -19,7 +19,7 @@ pub struct PlanFalKling16Pro {
}
pub fn plan_generate_video_fal_kling_1_6_pro(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
@@ -2,7 +2,7 @@ use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::plan::fal::plan_generate_video_fal_kling_1_6_pro::require_url;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use fal_client::requests::webhook::video::image::enqueue_kling_v2p1_master_image_to_video_webhook::{
@@ -18,7 +18,7 @@ pub struct PlanFalKling21Master {
}
pub fn plan_generate_video_fal_kling_2_1_master(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
@@ -2,7 +2,7 @@ use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::plan::fal::plan_generate_video_fal_kling_1_6_pro::{optional_url, require_url};
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use fal_client::requests::webhook::video::image::enqueue_kling_v2p1_pro_image_to_video_webhook::{
@@ -19,7 +19,7 @@ pub struct PlanFalKling21Pro {
}
pub fn plan_generate_video_fal_kling_2_1_pro(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
@@ -2,7 +2,7 @@ use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::plan::fal::plan_generate_video_fal_kling_1_6_pro::optional_url;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
@@ -38,7 +38,7 @@ pub struct PlanFalKling2p5TurboPro {
}
pub fn plan_generate_video_fal_kling_2_5_turbo_pro(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
@@ -2,7 +2,7 @@ use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::plan::fal::plan_generate_video_fal_kling_1_6_pro::optional_url;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
@@ -36,7 +36,7 @@ pub struct PlanFalKling2p6Pro {
}
pub fn plan_generate_video_fal_kling_2_6_pro(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
@@ -2,7 +2,7 @@ use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::plan::fal::plan_generate_video_fal_kling_1_6_pro::optional_url;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
@@ -37,7 +37,7 @@ pub struct PlanFalKling3p0Pro {
}
pub fn plan_generate_video_fal_kling_3p0_pro(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
let inner = build_kling_3p0_plan(request, "Kling 3.0 Pro")?;
Ok(VideoGenerationPlan::FalKling3p0Pro(PlanFalKling3p0Pro {
@@ -60,7 +60,7 @@ pub(crate) struct Kling3p0Common {
}
pub(crate) fn build_kling_3p0_plan(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
_model_label: &'static str,
) -> Result<Kling3p0Common, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
@@ -1,5 +1,5 @@
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::plan::fal::plan_generate_video_fal_kling_3p0_pro::{
build_kling_3p0_plan, FalKling3p0AspectRatio, FalKling3p0Duration, FalKling3p0Mode,
};
@@ -16,7 +16,7 @@ pub struct PlanFalKling3p0Standard {
}
pub fn plan_generate_video_fal_kling_3p0_standard(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
let inner = build_kling_3p0_plan(request, "Kling 3.0 Standard")?;
Ok(VideoGenerationPlan::FalKling3p0Standard(PlanFalKling3p0Standard {
@@ -3,7 +3,7 @@ use crate::api::common_resolution::CommonResolution;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::plan::fal::plan_generate_video_fal_kling_1_6_pro::{optional_url, require_url};
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use fal_client::requests::webhook::video::image::enqueue_seedance_1_lite_image_to_video_webhook::{
@@ -21,7 +21,7 @@ pub struct PlanFalSeedance10Lite {
}
pub fn plan_generate_video_fal_seedance_1_0_lite(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
@@ -3,7 +3,7 @@ use crate::api::common_resolution::CommonResolution;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::plan::fal::plan_generate_video_fal_kling_1_6_pro::optional_url;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
@@ -58,7 +58,7 @@ pub struct PlanFalSeedance1p5Pro {
}
pub fn plan_generate_video_fal_seedance_1p5_pro(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
@@ -3,7 +3,7 @@ use crate::api::common_resolution::CommonResolution;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::plan::fal::plan_generate_video_fal_kling_1_6_pro::optional_url;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
@@ -44,7 +44,7 @@ pub struct PlanFalSora2 {
}
pub fn plan_generate_video_fal_sora_2(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
@@ -3,7 +3,7 @@ use crate::api::common_resolution::CommonResolution;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::plan::fal::plan_generate_video_fal_kling_1_6_pro::optional_url;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
@@ -44,7 +44,7 @@ pub struct PlanFalSora2Pro {
}
pub fn plan_generate_video_fal_sora_2_pro(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
@@ -3,7 +3,7 @@ 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 crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use fal_client::requests::webhook::video::image::enqueue_veo_2_image_to_video_webhook::{
Veo2AspectRatio, Veo2Duration,
@@ -29,7 +29,7 @@ pub struct PlanFalVeo2 {
}
pub fn plan_generate_video_fal_veo_2(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
@@ -4,7 +4,7 @@ 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 crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use fal_client::requests::webhook::video::image::enqueue_veo_3_image_to_video_webhook::{
Veo3I2vAspectRatio, Veo3I2vDuration, Veo3I2vResolution,
@@ -70,7 +70,7 @@ pub struct PlanFalVeo3 {
}
pub fn plan_generate_video_fal_veo_3(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
@@ -3,7 +3,7 @@ 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 crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use crate::api::common_aspect_ratio::CommonAspectRatio;
use fal_client::requests::webhook::video::image::enqueue_veo_3_fast_image_to_video_webhook::{
@@ -21,7 +21,7 @@ pub struct PlanFalVeo3Fast {
}
pub fn plan_generate_video_fal_veo_3_fast(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
@@ -4,7 +4,7 @@ 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 crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
/// Three-way internal selection of which Fal Veo 3.1 endpoint to call.
@@ -49,14 +49,14 @@ pub struct PlanFalVeo3p1 {
}
pub fn plan_generate_video_fal_veo_3p1(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
let plan = build_plan_fal_veo_3p1(request, "Veo 3.1")?;
Ok(VideoGenerationPlan::FalVeo3p1(plan))
}
pub(crate) fn build_plan_fal_veo_3p1(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
model_label: &'static str,
) -> Result<PlanFalVeo3p1, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
@@ -1,5 +1,5 @@
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::plan::fal::plan_generate_video_fal_veo_3p1::{
build_plan_fal_veo_3p1, PlanFalVeo3p1,
};
@@ -14,7 +14,7 @@ pub struct PlanFalVeo3p1Fast {
}
pub fn plan_generate_video_fal_veo_3p1_fast(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
let inner = build_plan_fal_veo_3p1(request, "Veo 3.1 Fast")?;
Ok(VideoGenerationPlan::FalVeo3p1Fast(PlanFalVeo3p1Fast { inner }))
@@ -3,7 +3,7 @@ use crate::api::image_list_ref::ImageListRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use muapi_client::requests::seedance_2p0_image_to_video::seedance_2p0_image_to_video::{
AspectRatio as I2vAspectRatio,
@@ -41,7 +41,7 @@ pub struct PlanMuapiSeedance2p0 {
}
pub fn plan_generate_video_muapi_seedance2p0(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
@@ -6,25 +6,22 @@ use crate::api::video_list_ref::VideoListRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use seedance2pro_client::requests::generate_video::generate_video::{KinoviBatchCount, KinoviResolution};
use seedance2pro_client::requests::generate_video::generate_video::{
KinoviGenerateVideoRequest as SeedanceGenerateVideoRequest,
KinoviBatchCount, KinoviModelType, KinoviOutputResolution, KinoviAspectRatio,
};
use crate::api::common_resolution::CommonResolution;
#[derive(Debug, Clone)]
pub struct PlanSeedance2proSeedance2p0 {
pub prompt: Option<String>,
pub start_frame_url: Option<String>,
pub end_frame_url: Option<String>,
pub reference_image_urls: Option<Vec<String>>,
pub reference_video_urls: Option<Vec<String>>,
pub reference_audio_urls: Option<Vec<String>>,
pub resolution: KinoviResolution,
pub duration_seconds: u8,
pub batch_count: KinoviBatchCount,
pub request: SeedanceGenerateVideoRequest,
}
pub fn plan_generate_video_seedance2pro_seedance2p0(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
@@ -34,23 +31,40 @@ pub fn plan_generate_video_seedance2pro_seedance2p0(
let reference_video_urls = resolve_video_list_ref_urls(request.reference_videos.clone())?;
let reference_audio_urls = resolve_audio_list_ref_urls(request.reference_audio.clone())?;
let resolution = plan_resolution(request.aspect_ratio, strategy)?;
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)?;
let output_resolution = request.resolution.map(map_common_resolution_to_kinovi);
Ok(VideoGenerationPlan::Seedance2proSeedance2p0(PlanSeedance2proSeedance2p0 {
prompt: request.prompt.clone(),
start_frame_url,
end_frame_url,
reference_image_urls,
reference_video_urls,
reference_audio_urls,
resolution,
duration_seconds,
batch_count,
request: SeedanceGenerateVideoRequest {
model_type: KinoviModelType::Seedance2Pro,
prompt: request.prompt.clone().unwrap_or_default(),
aspect_ratio,
output_resolution,
duration_seconds,
batch_count,
start_frame_url,
end_frame_url,
reference_image_urls,
reference_video_urls,
reference_audio_urls,
character_ids: None,
use_face_blur_hack: None,
},
}))
}
fn map_common_resolution_to_kinovi(resolution: CommonResolution) -> KinoviOutputResolution {
match resolution {
CommonResolution::FourEightyP => KinoviOutputResolution::FourEightyP,
CommonResolution::SevenTwentyP => KinoviOutputResolution::SevenTwentyP,
CommonResolution::TenEightyP => KinoviOutputResolution::TenEightyP,
_ => KinoviOutputResolution::SevenTwentyP,
}
}
fn resolve_image_ref_url(
image_ref: Option<ImageRef>,
) -> Result<Option<String>, ArtcraftRouterError> {
@@ -104,29 +118,29 @@ fn resolve_audio_list_ref_urls(
//
// All supported ratios cost the same, so PayMoreUpgrade and PayLessDowngrade both
// select the nearest match rather than rounding in a specific direction.
fn plan_resolution(
fn plan_aspect_ratio(
aspect_ratio: Option<CommonAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<KinoviResolution, ArtcraftRouterError> {
) -> Result<KinoviAspectRatio, ArtcraftRouterError> {
match aspect_ratio {
// No preference or auto — default to landscape
None
| Some(CommonAspectRatio::Auto)
| Some(CommonAspectRatio::Auto2k)
| Some(CommonAspectRatio::Auto4k) => Ok(KinoviResolution::Landscape16x9),
| Some(CommonAspectRatio::Auto4k) => Ok(KinoviAspectRatio::Landscape16x9),
// Direct mappings
Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => {
Ok(KinoviResolution::Landscape16x9)
Ok(KinoviAspectRatio::Landscape16x9)
}
Some(CommonAspectRatio::TallNineBySixteen) | Some(CommonAspectRatio::Tall) => {
Ok(KinoviResolution::Portrait9x16)
Ok(KinoviAspectRatio::Portrait9x16)
}
Some(CommonAspectRatio::Square) | Some(CommonAspectRatio::SquareHd) => {
Ok(KinoviResolution::Square1x1)
Ok(KinoviAspectRatio::Square1x1)
}
Some(CommonAspectRatio::WideFourByThree) => Ok(KinoviResolution::Standard4x3),
Some(CommonAspectRatio::TallThreeByFour) => Ok(KinoviResolution::Portrait3x4),
Some(CommonAspectRatio::WideFourByThree) => Ok(KinoviAspectRatio::Standard4x3),
Some(CommonAspectRatio::TallThreeByFour) => Ok(KinoviAspectRatio::Portrait3x4),
// Mismatches — apply strategy
Some(unsupported) => match strategy {
@@ -145,15 +159,15 @@ fn plan_resolution(
}
/// Pick the nearest supported resolution by AR value (width / height).
fn nearest_resolution(aspect_ratio: CommonAspectRatio) -> KinoviResolution {
fn nearest_resolution(aspect_ratio: CommonAspectRatio) -> KinoviAspectRatio {
match aspect_ratio {
CommonAspectRatio::WideFiveByFour => KinoviResolution::Standard4x3, // 1.25, nearest 1.33
CommonAspectRatio::WideThreeByTwo => KinoviResolution::Standard4x3, // 1.50, nearest 1.33
CommonAspectRatio::WideTwentyOneByNine => KinoviResolution::Landscape16x9, // 2.33, nearest 1.78
CommonAspectRatio::TallFourByFive => KinoviResolution::Portrait3x4, // 0.80, nearest 0.75
CommonAspectRatio::TallTwoByThree => KinoviResolution::Portrait3x4, // 0.67, nearest 0.75
CommonAspectRatio::TallNineByTwentyOne => KinoviResolution::Portrait9x16, // 0.43, nearest 0.56
_ => KinoviResolution::Square1x1,
CommonAspectRatio::WideFiveByFour => KinoviAspectRatio::Standard4x3, // 1.25, nearest 1.33
CommonAspectRatio::WideThreeByTwo => KinoviAspectRatio::Standard4x3, // 1.50, nearest 1.33
CommonAspectRatio::WideTwentyOneByNine => KinoviAspectRatio::Landscape16x9, // 2.33, nearest 1.78
CommonAspectRatio::TallFourByFive => KinoviAspectRatio::Portrait3x4, // 0.80, nearest 0.75
CommonAspectRatio::TallTwoByThree => KinoviAspectRatio::Portrait3x4, // 0.67, nearest 0.75
CommonAspectRatio::TallNineByTwentyOne => KinoviAspectRatio::Portrait9x16, // 0.43, nearest 0.56
_ => KinoviAspectRatio::Square1x1,
}
}
@@ -6,25 +6,22 @@ use crate::api::video_list_ref::VideoListRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use seedance2pro_client::requests::generate_video::generate_video::{KinoviBatchCount, KinoviResolution};
use seedance2pro_client::requests::generate_video::generate_video::{
KinoviGenerateVideoRequest as SeedanceGenerateVideoRequest,
KinoviBatchCount, KinoviModelType, KinoviOutputResolution, KinoviAspectRatio,
};
use crate::api::common_resolution::CommonResolution;
#[derive(Debug, Clone)]
pub struct PlanSeedance2proSeedance2p0Fast {
pub prompt: Option<String>,
pub start_frame_url: Option<String>,
pub end_frame_url: Option<String>,
pub reference_image_urls: Option<Vec<String>>,
pub reference_video_urls: Option<Vec<String>>,
pub reference_audio_urls: Option<Vec<String>>,
pub resolution: KinoviResolution,
pub duration_seconds: u8,
pub batch_count: KinoviBatchCount,
pub request: SeedanceGenerateVideoRequest,
}
pub fn plan_generate_video_seedance2pro_seedance2p0_fast(
request: &GenerateVideoRequest,
request: &GenerateVideoRequestBuilder,
) -> Result<VideoGenerationPlan, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
@@ -34,23 +31,40 @@ pub fn plan_generate_video_seedance2pro_seedance2p0_fast(
let reference_video_urls = resolve_video_list_ref_urls(request.reference_videos.clone())?;
let reference_audio_urls = resolve_audio_list_ref_urls(request.reference_audio.clone())?;
let resolution = plan_resolution(request.aspect_ratio, strategy)?;
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)?;
let output_resolution = request.resolution.map(map_common_resolution_to_kinovi);
Ok(VideoGenerationPlan::Seedance2proSeedance2p0Fast(PlanSeedance2proSeedance2p0Fast {
prompt: request.prompt.clone(),
start_frame_url,
end_frame_url,
reference_image_urls,
reference_video_urls,
reference_audio_urls,
resolution,
duration_seconds,
batch_count,
request: SeedanceGenerateVideoRequest {
model_type: KinoviModelType::Seedance2Fast,
prompt: request.prompt.clone().unwrap_or_default(),
aspect_ratio,
output_resolution,
duration_seconds,
batch_count,
start_frame_url,
end_frame_url,
reference_image_urls,
reference_video_urls,
reference_audio_urls,
character_ids: None,
use_face_blur_hack: None,
},
}))
}
fn map_common_resolution_to_kinovi(resolution: CommonResolution) -> KinoviOutputResolution {
match resolution {
CommonResolution::FourEightyP => KinoviOutputResolution::FourEightyP,
CommonResolution::SevenTwentyP => KinoviOutputResolution::SevenTwentyP,
CommonResolution::TenEightyP => KinoviOutputResolution::SevenTwentyP, // NB: Kinovi doesn't have 1080p fast currently
_ => KinoviOutputResolution::SevenTwentyP,
}
}
fn resolve_image_ref_url(
image_ref: Option<ImageRef>,
) -> Result<Option<String>, ArtcraftRouterError> {
@@ -99,27 +113,27 @@ fn resolve_audio_list_ref_urls(
}
}
fn plan_resolution(
fn plan_aspect_ratio(
aspect_ratio: Option<CommonAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<KinoviResolution, ArtcraftRouterError> {
) -> Result<KinoviAspectRatio, ArtcraftRouterError> {
match aspect_ratio {
None
| Some(CommonAspectRatio::Auto)
| Some(CommonAspectRatio::Auto2k)
| Some(CommonAspectRatio::Auto4k) => Ok(KinoviResolution::Landscape16x9),
| Some(CommonAspectRatio::Auto4k) => Ok(KinoviAspectRatio::Landscape16x9),
Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => {
Ok(KinoviResolution::Landscape16x9)
Ok(KinoviAspectRatio::Landscape16x9)
}
Some(CommonAspectRatio::TallNineBySixteen) | Some(CommonAspectRatio::Tall) => {
Ok(KinoviResolution::Portrait9x16)
Ok(KinoviAspectRatio::Portrait9x16)
}
Some(CommonAspectRatio::Square) | Some(CommonAspectRatio::SquareHd) => {
Ok(KinoviResolution::Square1x1)
Ok(KinoviAspectRatio::Square1x1)
}
Some(CommonAspectRatio::WideFourByThree) => Ok(KinoviResolution::Standard4x3),
Some(CommonAspectRatio::TallThreeByFour) => Ok(KinoviResolution::Portrait3x4),
Some(CommonAspectRatio::WideFourByThree) => Ok(KinoviAspectRatio::Standard4x3),
Some(CommonAspectRatio::TallThreeByFour) => Ok(KinoviAspectRatio::Portrait3x4),
Some(unsupported) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
@@ -136,15 +150,15 @@ fn plan_resolution(
}
}
fn nearest_resolution(aspect_ratio: CommonAspectRatio) -> KinoviResolution {
fn nearest_resolution(aspect_ratio: CommonAspectRatio) -> KinoviAspectRatio {
match aspect_ratio {
CommonAspectRatio::WideFiveByFour => KinoviResolution::Standard4x3,
CommonAspectRatio::WideThreeByTwo => KinoviResolution::Standard4x3,
CommonAspectRatio::WideTwentyOneByNine => KinoviResolution::Landscape16x9,
CommonAspectRatio::TallFourByFive => KinoviResolution::Portrait3x4,
CommonAspectRatio::TallTwoByThree => KinoviResolution::Portrait3x4,
CommonAspectRatio::TallNineByTwentyOne => KinoviResolution::Portrait9x16,
_ => KinoviResolution::Square1x1,
CommonAspectRatio::WideFiveByFour => KinoviAspectRatio::Standard4x3,
CommonAspectRatio::WideThreeByTwo => KinoviAspectRatio::Standard4x3,
CommonAspectRatio::WideTwentyOneByNine => KinoviAspectRatio::Landscape16x9,
CommonAspectRatio::TallFourByFive => KinoviAspectRatio::Portrait3x4,
CommonAspectRatio::TallTwoByThree => KinoviAspectRatio::Portrait3x4,
CommonAspectRatio::TallNineByTwentyOne => KinoviAspectRatio::Portrait9x16,
_ => KinoviAspectRatio::Square1x1,
}
}
@@ -1,5 +1,5 @@
/// The result of estimating the cost of a video generation plan.
#[derive(Debug)]
#[derive(Clone, Debug)]
pub struct VideoGenerationCostEstimate {
pub cost_in_credits: Option<u64>,
pub cost_in_usd_cents: Option<u64>,
@@ -0,0 +1,122 @@
# generate_video_v2
This module implements the v2 video generation pipeline. It replaces the v1 system
(in `generate_video/`) with a two-phase approach that separates request planning from
execution, allowing cost estimation and media uploads to happen at the right time.
## Lifecycle
```
GenerateVideoRequestBuilder
│
▼ build2()
VideoGenerationDraftOrRequest
│
├─ Draft(VideoGenerationDraftRequest)
│ │
│ ▼ estimate_cost() ← cost estimate from draft state
│ │
│ ▼ finalize(context) ← uploads media, resolves tokens
│ │
│ ▼
│ VideoGenerationRequest
│ │
│ ▼ estimate_cost() ← cost estimate from finalized request
│ │
│ ▼ send_request() ← fires the API call
│ │
│ ▼
│ GenerateVideoResponse
│
└─ Request(VideoGenerationRequest) ← skips draft phase (used by Artcraft provider)
```
### Key types
- **`GenerateVideoRequestBuilder`** — generic builder with provider/model/prompt/resolution/etc.
Located in `generate_video/generate_video_request_builder.rs`. The `use_new_builder()` method
gates which provider+model combos use the v2 path. `build2()` dispatches to the correct
provider's build function.
- **`VideoGenerationDraftOrRequest`** — the output of `build2()`. Either `Draft` (for providers
that need media upload/resolution, like Kinovi) or `Request` (for providers that work with
media file tokens directly, like Artcraft).
- **`VideoGenerationDraftRequest`** — enum with one variant per provider+model. Holds
materialized settings (resolution, duration, batch count) plus unresolved media references.
Can estimate cost without network calls.
- **`VideoGenerationDraftContext`** — context needed during `finalize()`: the provider client,
media file token-to-URL map, and character token-to-ID map.
- **`VideoGenerationRequest`** — enum with one variant per provider+model. Holds a fully
materialized request ready to send. All media URLs are resolved and uploaded.
### Cost estimation
Cost can be estimated at two stages:
1. **From draft** — before `finalize()`. Uses the planned resolution/duration/batch count.
Good for showing the user a price before committing.
2. **From request** — after `finalize()`. Uses the final materialized request. Should match
the draft estimate for the same parameters.
## Module layout
```
generate_video_v2/
├── mod.rs
├── video_generation_draft.rs ← VideoGenerationDraftRequest enum
├── video_generation_draft_context.rs ← context for finalize()
├── video_generation_draft_or_request.rs
├── video_generation_request.rs ← VideoGenerationRequest enum
└── providers/
├── artcraft/ ← Artcraft provider (uses media file tokens directly)
│ └── seedance_2p0/ ← Seedance 2.0 Pro model (skips draft, returns Request directly)
│ ├── mod.rs
│ ├── build.rs ← builder → Request conversion + plan helpers
│ ├── cost.rs ← standalone cost estimation (independent of seedance2pro_client)
│ └── request.rs ← request state + send() via Artcraft omni-gen API
└── kinovi/ ← Kinovi/Seedance2Pro provider
├── mod.rs
├── resolve.rs ← shared: media token resolution, upload helpers
├── upload.rs ← shared: download + re-upload to Seedance2Pro CDN
├── seedance_2p0/ ← Seedance 2.0 Pro model
│ ├── mod.rs
│ ├── build.rs ← builder → draft conversion + plan helpers
│ ├── cost.rs ← cost estimation state
│ ├── draft.rs ← draft state + finalize (to_request)
│ └── request.rs ← request state + send()
└── seedance_2p0_fast/ ← Seedance 2.0 Fast model
├── mod.rs
├── build.rs
├── cost.rs
├── draft.rs
└── request.rs
```
## Adding a new provider or model
1. **Create the module** under `providers/{provider}/{model}/` with these files:
- `mod.rs` — declares sub-modules
- `build.rs` — `build_{provider}_{model}(builder) -> VideoGenerationDraftOrRequest`.
Contains `plan_*` helpers for aspect ratio, resolution, batch count, duration.
- `draft.rs` — draft state struct with `to_request()` async method for finalization.
**Not needed** if the provider uses media tokens directly (no upload/resolution needed);
in that case `build.rs` returns `VideoGenerationDraftOrRequest::Request(...)` directly.
- `cost.rs` — cost state with `from_request()` and `estimate_cost()`. Add `from_draft()`
if the provider uses the draft phase.
- `request.rs` — request state with `send()` async method.
2. **Register the module** in the parent provider's `mod.rs`.
3. **Add enum variants** in:
- `video_generation_draft.rs` — `VideoGenerationDraftRequest` enum + `estimate_cost` + `finalize`
(skip if provider has no draft phase)
- `video_generation_request.rs` — `VideoGenerationRequest` enum + `estimate_cost` + `send_request`
4. **Wire into the builder** in `generate_video_request_builder.rs`:
- Add the `(Provider, Model)` pair to `use_new_builder()` returning `true`
- Add the dispatch arm in `build2()`
5. **Tests**: each `build.rs` and `cost.rs` should have comprehensive tests. `request.rs`
should have `#[ignore]` live API tests for manual verification.
@@ -0,0 +1,5 @@
pub mod providers;
pub mod video_generation_request;
pub mod video_generation_draft;
pub mod video_generation_draft_or_request;
mod video_generation_draft_context;
@@ -0,0 +1,615 @@
use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::{
Seedance2p0AspectRatio, Seedance2p0BatchCount, Seedance2p0MultiFunctionVideoGenRequest,
Seedance2p0OutputResolution,
};
use crate::api::audio_list_ref::AudioListRef;
use crate::api::character_list_ref::CharacterListRef;
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_resolution::CommonResolution;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::video_list_ref::VideoListRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0::request::ArtcraftSeedance2p0RequestState;
use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video_v2::video_generation_request::VideoGenerationRequest;
pub fn build_artcraft_seedance_2p0(mut builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let strategy = builder.request_mismatch_mitigation_strategy;
let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio.take(), strategy)?;
let output_resolution = plan_output_resolution(builder.resolution.take(), strategy)?;
let batch_count = plan_batch_count(builder.video_batch_count.take(), strategy)?;
let duration_seconds = plan_duration(builder.duration_seconds.take(), strategy)?;
let prompt = builder.prompt.take();
let start_frame_media_token = resolve_image_ref(builder.start_frame.take())?;
let end_frame_media_token = resolve_image_ref(builder.end_frame.take())?;
let reference_image_media_tokens = resolve_image_list_ref(builder.reference_images.take())?;
let reference_video_media_tokens = resolve_video_list_ref(builder.reference_videos.take())?;
let reference_audio_media_tokens = resolve_audio_list_ref(builder.reference_audio.take())?;
let reference_character_tokens = resolve_character_list_ref(builder.reference_character_tokens.take());
let idempotency_token = builder.get_or_generate_idempotency_token();
let request = Seedance2p0MultiFunctionVideoGenRequest {
uuid_idempotency_token: idempotency_token,
prompt,
start_frame_media_token,
end_frame_media_token,
reference_image_media_tokens,
reference_video_media_tokens,
reference_audio_media_tokens,
reference_character_tokens,
aspect_ratio,
output_resolution,
duration_seconds,
batch_count: Some(batch_count),
};
let state = ArtcraftSeedance2p0RequestState { request };
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0(state)))
}
// ── Resolve helpers ──
fn resolve_image_ref(
image_ref: Option<ImageRef>,
) -> Result<Option<tokens::tokens::media_files::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(
image_list_ref: Option<ImageListRef>,
) -> Result<Option<Vec<tokens::tokens::media_files::MediaFileToken>>, ArtcraftRouterError> {
match image_list_ref {
None => Ok(None),
Some(ImageListRef::MediaFileTokens(tokens)) => Ok(Some(tokens)),
Some(ImageListRef::Urls(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
}
}
}
fn resolve_video_list_ref(
video_list_ref: Option<VideoListRef>,
) -> Result<Option<Vec<tokens::tokens::media_files::MediaFileToken>>, ArtcraftRouterError> {
match video_list_ref {
None => Ok(None),
Some(VideoListRef::MediaFileTokens(tokens)) => Ok(Some(tokens)),
Some(VideoListRef::Urls(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
}
}
}
fn resolve_audio_list_ref(
audio_list_ref: Option<AudioListRef>,
) -> Result<Option<Vec<tokens::tokens::media_files::MediaFileToken>>, ArtcraftRouterError> {
match audio_list_ref {
None => Ok(None),
Some(AudioListRef::MediaFileTokens(tokens)) => Ok(Some(tokens)),
Some(AudioListRef::Urls(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
}
}
}
fn resolve_character_list_ref(
character_list_ref: Option<CharacterListRef>,
) -> Option<Vec<tokens::tokens::characters::CharacterToken>> {
match character_list_ref {
None => None,
Some(CharacterListRef::CharacterTokens(tokens)) => Some(tokens),
}
}
// ── Plan helpers ──
// 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).
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,
}
}
// Seedance 2.0 Pro supports output resolutions: 480p, 720p, 1080p.
fn plan_output_resolution(
resolution: Option<CommonResolution>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<Seedance2p0OutputResolution>, ArtcraftRouterError> {
match resolution {
None => Ok(None),
// Direct mappings
Some(CommonResolution::FourEightyP) => Ok(Some(Seedance2p0OutputResolution::FourEightyP)),
Some(CommonResolution::SevenTwentyP) => Ok(Some(Seedance2p0OutputResolution::SevenTwentyP)),
Some(CommonResolution::TenEightyP) => Ok(Some(Seedance2p0OutputResolution::TenEightyP)),
// Mismatches
Some(unsupported) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "resolution",
value: format!("{:?}", unsupported),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => {
Ok(Some(match unsupported {
CommonResolution::HalfK => Seedance2p0OutputResolution::FourEightyP,
_ => Seedance2p0OutputResolution::TenEightyP,
}))
}
RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(Some(match unsupported {
CommonResolution::HalfK => Seedance2p0OutputResolution::FourEightyP,
_ => Seedance2p0OutputResolution::TenEightyP,
}))
}
},
}
}
// 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 => {
Ok(if count < 4 { Seedance2p0BatchCount::Four } else { Seedance2p0BatchCount::Four })
}
RequestMismatchMitigationStrategy::PayLessDowngrade => {
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)),
},
}
}
#[cfg(test)]
mod tests {
use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::{
Seedance2p0AspectRatio, Seedance2p0BatchCount, Seedance2p0OutputResolution,
};
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::character_list_ref::CharacterListRef;
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_resolution::CommonResolution;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::provider::Provider;
use crate::api::video_list_ref::VideoListRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0::request::ArtcraftSeedance2p0RequestState;
use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video_v2::video_generation_request::VideoGenerationRequest;
use super::*;
// ── Materialized field conversions ──
mod materialized_field_conversions {
use super::*;
#[test]
fn prompt_is_passed_through() {
let state = unwrap_request(build_artcraft_seedance_2p0(artcraft_builder()));
assert_eq!(state.request.prompt.as_deref(), Some("a cat dancing"));
}
#[test]
fn prompt_defaults_to_none() {
let builder = GenerateVideoRequestBuilder { prompt: None, ..artcraft_builder() };
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
assert!(state.request.prompt.is_none());
}
#[test]
fn duration_seconds_converted() {
let builder = GenerateVideoRequestBuilder { duration_seconds: Some(10), ..artcraft_builder() };
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
assert_eq!(state.request.duration_seconds, Some(10));
}
#[test]
fn duration_defaults_to_none() {
let builder = GenerateVideoRequestBuilder { duration_seconds: None, ..artcraft_builder() };
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
assert!(state.request.duration_seconds.is_none());
}
#[test]
fn duration_clamped_to_max() {
let builder = GenerateVideoRequestBuilder {
duration_seconds: Some(99),
..artcraft_builder()
};
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
assert_eq!(state.request.duration_seconds, Some(15));
}
#[test]
fn batch_count_one() {
let builder = GenerateVideoRequestBuilder { video_batch_count: Some(1), ..artcraft_builder() };
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
assert!(matches!(state.request.batch_count, Some(Seedance2p0BatchCount::One)));
}
#[test]
fn batch_count_two() {
let builder = GenerateVideoRequestBuilder { video_batch_count: Some(2), ..artcraft_builder() };
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
assert!(matches!(state.request.batch_count, Some(Seedance2p0BatchCount::Two)));
}
#[test]
fn batch_count_four() {
let builder = GenerateVideoRequestBuilder { video_batch_count: Some(4), ..artcraft_builder() };
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
assert!(matches!(state.request.batch_count, Some(Seedance2p0BatchCount::Four)));
}
}
// ── Aspect ratio conversions ──
mod aspect_ratio_conversions {
use super::*;
#[test]
fn aspect_ratio_wide() {
let builder = GenerateVideoRequestBuilder {
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..artcraft_builder()
};
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
assert!(matches!(state.request.aspect_ratio, Some(Seedance2p0AspectRatio::Landscape16x9)));
}
#[test]
fn aspect_ratio_tall() {
let builder = GenerateVideoRequestBuilder {
aspect_ratio: Some(CommonAspectRatio::TallNineBySixteen),
..artcraft_builder()
};
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
assert!(matches!(state.request.aspect_ratio, Some(Seedance2p0AspectRatio::Portrait9x16)));
}
#[test]
fn aspect_ratio_square() {
let builder = GenerateVideoRequestBuilder {
aspect_ratio: Some(CommonAspectRatio::Square),
..artcraft_builder()
};
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
assert!(matches!(state.request.aspect_ratio, Some(Seedance2p0AspectRatio::Square1x1)));
}
#[test]
fn aspect_ratio_defaults_to_none() {
let builder = GenerateVideoRequestBuilder { aspect_ratio: None, ..artcraft_builder() };
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
assert!(state.request.aspect_ratio.is_none());
}
}
// ── Resolution conversions ──
mod resolution_conversions {
use super::*;
#[test]
fn resolution_480p() {
let builder = GenerateVideoRequestBuilder {
resolution: Some(CommonResolution::FourEightyP),
..artcraft_builder()
};
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
assert!(matches!(state.request.output_resolution, Some(Seedance2p0OutputResolution::FourEightyP)));
}
#[test]
fn resolution_720p() {
let builder = GenerateVideoRequestBuilder {
resolution: Some(CommonResolution::SevenTwentyP),
..artcraft_builder()
};
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
assert!(matches!(state.request.output_resolution, Some(Seedance2p0OutputResolution::SevenTwentyP)));
}
#[test]
fn resolution_1080p() {
let builder = GenerateVideoRequestBuilder {
resolution: Some(CommonResolution::TenEightyP),
..artcraft_builder()
};
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
assert!(matches!(state.request.output_resolution, Some(Seedance2p0OutputResolution::TenEightyP)));
}
#[test]
fn resolution_none() {
let builder = GenerateVideoRequestBuilder { resolution: None, ..artcraft_builder() };
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
assert!(state.request.output_resolution.is_none());
}
#[test]
fn unsupported_resolution_error_out() {
let builder = GenerateVideoRequestBuilder {
resolution: Some(CommonResolution::FourK),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..artcraft_builder()
};
assert!(build_artcraft_seedance_2p0(builder).is_err());
}
#[test]
fn unsupported_resolution_rounds_up() {
let builder = GenerateVideoRequestBuilder {
resolution: Some(CommonResolution::FourK),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
..artcraft_builder()
};
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
assert!(matches!(state.request.output_resolution, Some(Seedance2p0OutputResolution::TenEightyP)));
}
}
// ── Media token resolution ──
mod media_token_resolution {
use super::*;
#[test]
fn start_frame_media_token() {
let builder = GenerateVideoRequestBuilder {
start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_start123".to_string()))),
..artcraft_builder()
};
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
assert_eq!(state.request.start_frame_media_token.as_ref().unwrap().as_str(), "mf_start123");
}
#[test]
fn end_frame_media_token() {
let builder = GenerateVideoRequestBuilder {
end_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_end456".to_string()))),
..artcraft_builder()
};
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
assert_eq!(state.request.end_frame_media_token.as_ref().unwrap().as_str(), "mf_end456");
}
#[test]
fn reference_image_media_tokens() {
let builder = GenerateVideoRequestBuilder {
reference_images: Some(ImageListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_ref1".to_string()),
MediaFileToken::new("mf_ref2".to_string()),
])),
..artcraft_builder()
};
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
let tokens = state.request.reference_image_media_tokens.unwrap();
assert_eq!(tokens.len(), 2);
assert_eq!(tokens[0].as_str(), "mf_ref1");
assert_eq!(tokens[1].as_str(), "mf_ref2");
}
#[test]
fn reference_video_media_tokens() {
let builder = GenerateVideoRequestBuilder {
reference_videos: Some(VideoListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_vid1".to_string()),
])),
..artcraft_builder()
};
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
let tokens = state.request.reference_video_media_tokens.unwrap();
assert_eq!(tokens.len(), 1);
assert_eq!(tokens[0].as_str(), "mf_vid1");
}
#[test]
fn character_tokens() {
let builder = GenerateVideoRequestBuilder {
reference_character_tokens: Some(CharacterListRef::CharacterTokens(vec![
CharacterToken::new("char_abc".to_string()),
CharacterToken::new("char_def".to_string()),
])),
..artcraft_builder()
};
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
let tokens = state.request.reference_character_tokens.unwrap();
assert_eq!(tokens.len(), 2);
assert_eq!(tokens[0].as_str(), "char_abc");
assert_eq!(tokens[1].as_str(), "char_def");
}
#[test]
fn url_image_ref_returns_error() {
let builder = GenerateVideoRequestBuilder {
start_frame: Some(ImageRef::Url("https://example.com/image.jpg".to_string())),
..artcraft_builder()
};
assert!(matches!(
build_artcraft_seedance_2p0(builder),
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
));
}
#[test]
fn empty_refs_are_none() {
let state = unwrap_request(build_artcraft_seedance_2p0(artcraft_builder()));
assert!(state.request.start_frame_media_token.is_none());
assert!(state.request.end_frame_media_token.is_none());
assert!(state.request.reference_image_media_tokens.is_none());
assert!(state.request.reference_video_media_tokens.is_none());
assert!(state.request.reference_audio_media_tokens.is_none());
assert!(state.request.reference_character_tokens.is_none());
}
}
// ── Returns Request, not Draft ──
#[test]
fn build_returns_request_variant() {
let result = build_artcraft_seedance_2p0(artcraft_builder()).expect("build should succeed");
assert!(matches!(result, VideoGenerationDraftOrRequest::Request(_)));
}
// ── Full combination ──
#[test]
fn full_request_all_fields() {
let builder = GenerateVideoRequestBuilder {
prompt: Some("full test".to_string()),
aspect_ratio: Some(CommonAspectRatio::TallNineBySixteen),
resolution: Some(CommonResolution::TenEightyP),
duration_seconds: Some(10),
video_batch_count: Some(4),
start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_start".to_string()))),
end_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_end".to_string()))),
reference_images: Some(ImageListRef::MediaFileTokens(vec![MediaFileToken::new("mf_img".to_string())])),
reference_videos: Some(VideoListRef::MediaFileTokens(vec![MediaFileToken::new("mf_vid".to_string())])),
reference_character_tokens: Some(CharacterListRef::CharacterTokens(vec![
CharacterToken::new("char_xyz".to_string()),
])),
..artcraft_builder()
};
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
assert_eq!(state.request.prompt.as_deref(), Some("full test"));
assert!(matches!(state.request.aspect_ratio, Some(Seedance2p0AspectRatio::Portrait9x16)));
assert!(matches!(state.request.output_resolution, Some(Seedance2p0OutputResolution::TenEightyP)));
assert_eq!(state.request.duration_seconds, Some(10));
assert!(matches!(state.request.batch_count, Some(Seedance2p0BatchCount::Four)));
assert!(state.request.start_frame_media_token.is_some());
assert!(state.request.end_frame_media_token.is_some());
assert!(state.request.reference_image_media_tokens.is_some());
assert!(state.request.reference_video_media_tokens.is_some());
assert!(state.request.reference_character_tokens.is_some());
}
// ── Helpers ──
fn artcraft_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
provider: Provider::Artcraft,
prompt: Some("a cat dancing".to_string()),
duration_seconds: Some(5),
video_batch_count: Some(1),
..Default::default()
}
}
fn unwrap_request(result: Result<VideoGenerationDraftOrRequest, ArtcraftRouterError>) -> ArtcraftSeedance2p0RequestState {
match result.expect("build should succeed") {
VideoGenerationDraftOrRequest::Request(
VideoGenerationRequest::ArtcraftSeedance2p0(state)
) => state,
_ => panic!("expected ArtcraftSeedance2p0 request"),
}
}
}
@@ -0,0 +1,531 @@
use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::{
Seedance2p0BatchCount, Seedance2p0OutputResolution,
};
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0::request::ArtcraftSeedance2p0RequestState;
// ── Pricing constants ──
//
// ArtCraft credits: 100 credits = $1.00. Credits always equal USD cents.
//
// The per-second USD cost varies by resolution. We compute cents directly
// from the upstream credit rates and their credit-package prices,
// then set ArtCraft credits = cents.
/// USD cents per second by resolution, derived from Upstream rates:
/// 480p: 15 upstream-credits/sec ÷ 193 upstream-credits/$1 × 100 ≈ 7.772 ¢/s
/// 720p: 40 upstream-credits/sec ÷ 250 upstream-credits/$1 × 100 = 16.0 ¢/s
/// 1080p: 90 upstream-credits/sec ÷ 193 upstream-credits/$1 × 100 ≈ 46.632 ¢/s
///
/// We keep these as f64 because per-second rates are fractional; rounding
/// happens once at the end after multiplying by duration × batch.
const CENTS_PER_SECOND_480P: f64 = 7.772;
const CENTS_PER_SECOND_720P: f64 = 16.0;
const CENTS_PER_SECOND_1080P: f64 = 46.632;
pub struct ArtcraftSeedance2p0CostState {
pub resolution: Seedance2p0OutputResolution,
pub duration_seconds: u8,
pub batch_count: Seedance2p0BatchCount,
pub has_video_reference: bool,
}
impl ArtcraftSeedance2p0CostState {
pub fn from_request(request: &ArtcraftSeedance2p0RequestState) -> Self {
let resolution = request.request.output_resolution
.unwrap_or(Seedance2p0OutputResolution::SevenTwentyP);
let duration_seconds = request.request.duration_seconds.unwrap_or(5);
let batch_count = request.request.batch_count
.unwrap_or(Seedance2p0BatchCount::One);
let has_video_reference = request.request.reference_video_media_tokens
.as_ref()
.is_some_and(|tokens| !tokens.is_empty());
Self { resolution, duration_seconds, batch_count, has_video_reference }
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
let cents_per_second = match self.resolution {
Seedance2p0OutputResolution::FourEightyP => CENTS_PER_SECOND_480P,
Seedance2p0OutputResolution::SevenTwentyP => CENTS_PER_SECOND_720P,
Seedance2p0OutputResolution::TenEightyP => CENTS_PER_SECOND_1080P,
};
let batch_multiplier: f64 = match self.batch_count {
Seedance2p0BatchCount::One => 1.0,
Seedance2p0BatchCount::Two => 2.0,
Seedance2p0BatchCount::Four => 4.0,
};
let usd_cents = (self.duration_seconds as f64 * cents_per_second * batch_multiplier).round() as u64;
// ArtCraft credits: 100 credits = $1.00, so credits = cents.
VideoGenerationCostEstimate {
cost_in_credits: Some(usd_cents),
cost_in_usd_cents: Some(usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
}
}
}
#[cfg(test)]
mod tests {
use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::{
Seedance2p0BatchCount, Seedance2p0MultiFunctionVideoGenRequest, Seedance2p0OutputResolution,
};
use tokens::tokens::media_files::MediaFileToken;
use crate::api::common_resolution::CommonResolution;
use crate::api::provider::Provider;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0::request::ArtcraftSeedance2p0RequestState;
use super::*;
// ── 720p pricing ──
mod pricing_720p {
use super::*;
#[test]
fn cost_720p_batch_1() {
assert_eq!(usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 4, Seedance2p0BatchCount::One), 64);
assert_eq!(usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 5, Seedance2p0BatchCount::One), 80);
assert_eq!(usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 6, Seedance2p0BatchCount::One), 96);
assert_eq!(usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 7, Seedance2p0BatchCount::One), 112);
assert_eq!(usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 10, Seedance2p0BatchCount::One), 160);
assert_eq!(usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 15, Seedance2p0BatchCount::One), 240);
}
#[test]
fn cost_720p_batch_2() {
assert_eq!(usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 4, Seedance2p0BatchCount::Two), 128);
assert_eq!(usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 5, Seedance2p0BatchCount::Two), 160);
assert_eq!(usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 15, Seedance2p0BatchCount::Two), 480);
}
#[test]
fn cost_720p_batch_4() {
assert_eq!(usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 4, Seedance2p0BatchCount::Four), 256);
assert_eq!(usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 5, Seedance2p0BatchCount::Four), 320);
assert_eq!(usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 15, Seedance2p0BatchCount::Four), 960);
}
}
// ── 480p pricing ──
mod pricing_480p {
use super::*;
#[test]
fn cost_480p_batch_1() {
assert_eq!(usd_cents(Seedance2p0OutputResolution::FourEightyP, 4, Seedance2p0BatchCount::One), 31);
assert_eq!(usd_cents(Seedance2p0OutputResolution::FourEightyP, 5, Seedance2p0BatchCount::One), 39);
assert_eq!(usd_cents(Seedance2p0OutputResolution::FourEightyP, 10, Seedance2p0BatchCount::One), 78);
assert_eq!(usd_cents(Seedance2p0OutputResolution::FourEightyP, 15, Seedance2p0BatchCount::One), 117);
}
#[test]
fn cost_480p_batch_2() {
assert_eq!(usd_cents(Seedance2p0OutputResolution::FourEightyP, 5, Seedance2p0BatchCount::Two), 78);
}
#[test]
fn cost_480p_batch_4() {
assert_eq!(usd_cents(Seedance2p0OutputResolution::FourEightyP, 5, Seedance2p0BatchCount::Four), 155);
}
}
// ── 1080p pricing ──
mod pricing_1080p {
use super::*;
#[test]
fn cost_1080p_batch_1() {
assert_eq!(usd_cents(Seedance2p0OutputResolution::TenEightyP, 4, Seedance2p0BatchCount::One), 187);
assert_eq!(usd_cents(Seedance2p0OutputResolution::TenEightyP, 5, Seedance2p0BatchCount::One), 233);
assert_eq!(usd_cents(Seedance2p0OutputResolution::TenEightyP, 10, Seedance2p0BatchCount::One), 466);
assert_eq!(usd_cents(Seedance2p0OutputResolution::TenEightyP, 15, Seedance2p0BatchCount::One), 699);
}
#[test]
fn cost_1080p_batch_2() {
assert_eq!(usd_cents(Seedance2p0OutputResolution::TenEightyP, 5, Seedance2p0BatchCount::Two), 466);
}
#[test]
fn cost_1080p_batch_4() {
assert_eq!(usd_cents(Seedance2p0OutputResolution::TenEightyP, 5, Seedance2p0BatchCount::Four), 933);
}
}
// ── Relative pricing ──
mod relative_pricing_tests {
use super::*;
#[test]
fn cost_480p_cheaper_than_720p_cheaper_than_1080p() {
let c480 = usd_cents(Seedance2p0OutputResolution::FourEightyP, 5, Seedance2p0BatchCount::One);
let c720 = usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 5, Seedance2p0BatchCount::One);
let c1080 = usd_cents(Seedance2p0OutputResolution::TenEightyP, 5, Seedance2p0BatchCount::One);
assert!(c480 < c720, "480p ({}) should be cheaper than 720p ({})", c480, c720);
assert!(c720 < c1080, "720p ({}) should be cheaper than 1080p ({})", c720, c1080);
}
#[test]
fn cost_scales_with_duration() {
let c4 = usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 4, Seedance2p0BatchCount::One);
let c10 = usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 10, Seedance2p0BatchCount::One);
let c15 = usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 15, Seedance2p0BatchCount::One);
assert!(c4 < c10);
assert!(c10 < c15);
}
#[test]
fn cost_scales_with_batch() {
let b1 = usd_cents(Seedance2p0OutputResolution::TenEightyP, 5, Seedance2p0BatchCount::One);
let b2 = usd_cents(Seedance2p0OutputResolution::TenEightyP, 5, Seedance2p0BatchCount::Two);
let b4 = usd_cents(Seedance2p0OutputResolution::TenEightyP, 5, Seedance2p0BatchCount::Four);
assert!(b1 < b2);
assert!(b2 < b4);
}
}
// ── Video reference does NOT affect cost (yet) ──
#[test]
fn video_reference_does_not_affect_cost() {
let base = ArtcraftSeedance2p0CostState {
resolution: Seedance2p0OutputResolution::SevenTwentyP,
duration_seconds: 5,
batch_count: Seedance2p0BatchCount::One,
has_video_reference: false,
};
let without = base.estimate_cost();
let with = ArtcraftSeedance2p0CostState { has_video_reference: true, ..base }.estimate_cost();
assert_eq!(without.cost_in_usd_cents, with.cost_in_usd_cents);
assert_eq!(without.cost_in_credits, with.cost_in_credits);
}
// ── from_request() tests ──
mod from_request_tests {
use super::*;
#[test]
fn from_request_720p() {
let req = make_request_state(Some(Seedance2p0OutputResolution::SevenTwentyP), 5, Seedance2p0BatchCount::One, false);
let cost = ArtcraftSeedance2p0CostState::from_request(&req);
assert!(matches!(cost.resolution, Seedance2p0OutputResolution::SevenTwentyP));
assert_eq!(cost.duration_seconds, 5);
assert!(matches!(cost.batch_count, Seedance2p0BatchCount::One));
assert!(!cost.has_video_reference);
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(80));
}
#[test]
fn from_request_none_defaults_to_720p() {
let req = make_request_state(None, 5, Seedance2p0BatchCount::One, false);
let cost = ArtcraftSeedance2p0CostState::from_request(&req);
assert!(matches!(cost.resolution, Seedance2p0OutputResolution::SevenTwentyP));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(80));
}
#[test]
fn from_request_480p() {
let req = make_request_state(Some(Seedance2p0OutputResolution::FourEightyP), 5, Seedance2p0BatchCount::One, false);
let cost = ArtcraftSeedance2p0CostState::from_request(&req);
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(39));
}
#[test]
fn from_request_1080p_batch_2() {
let req = make_request_state(Some(Seedance2p0OutputResolution::TenEightyP), 5, Seedance2p0BatchCount::Two, false);
let cost = ArtcraftSeedance2p0CostState::from_request(&req);
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(466));
}
#[test]
fn from_request_with_video_reference() {
let req = make_request_state(Some(Seedance2p0OutputResolution::SevenTwentyP), 5, Seedance2p0BatchCount::One, true);
let cost = ArtcraftSeedance2p0CostState::from_request(&req);
assert!(cost.has_video_reference);
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(80));
}
}
// ── Cross-check: Artcraft v2 matches Kinovi v2 via GenerateVideoRequestBuilder ──
mod cross_check_with_kinovi_via_builder {
use crate::api::common_video_model::CommonVideoModel;
use super::*;
/// Combinatorial test: build the same request with Provider::Artcraft and
/// Provider::Seedance2Pro, then compare USD cents. They must match for all
/// resolution × duration × batch combinations.
#[test]
fn artcraft_matches_kinovi_all_combos() {
let resolutions = [
Some(CommonResolution::FourEightyP),
Some(CommonResolution::SevenTwentyP),
None, // defaults to 720p
Some(CommonResolution::TenEightyP),
];
let durations: [u16; 4] = [4, 5, 10, 15];
let batches: [u16; 3] = [1, 2, 4];
for res in &resolutions {
for dur in &durations {
for batch in &batches {
let artcraft = GenerateVideoRequestBuilder {
model: CommonVideoModel::Seedance2p0,
provider: Provider::Artcraft,
resolution: *res,
duration_seconds: Some(*dur),
video_batch_count: Some(*batch),
..Default::default()
};
let kinovi = GenerateVideoRequestBuilder {
model: CommonVideoModel::Seedance2p0,
provider: Provider::Seedance2Pro,
resolution: *res,
duration_seconds: Some(*dur),
video_batch_count: Some(*batch),
..Default::default()
};
let artcraft_cost = artcraft.build2()
.expect("artcraft build2 should succeed")
.estimate_cost()
.expect("artcraft estimate_cost should succeed");
let kinovi_cost = kinovi.build2()
.expect("kinovi build2 should succeed")
.estimate_cost()
.expect("kinovi estimate_cost should succeed");
assert_eq!(
artcraft_cost.cost_in_usd_cents, kinovi_cost.cost_in_usd_cents,
"USD cents mismatch: res={:?} dur={}s batch={} — artcraft={:?}, kinovi={:?}",
res, dur, batch,
artcraft_cost.cost_in_usd_cents, kinovi_cost.cost_in_usd_cents,
);
}
}
}
}
}
// ── Cross-check: Artcraft v2 matches v1 estimate_video_cost_artcraft_seedance2p0 ──
mod cross_check_with_v1 {
use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::Seedance2p0AspectRatio;
use tokens::tokens::media_files::MediaFileToken;
use crate::generate::generate_video::cost::artcraft::estimate_video_cost_artcraft_seedance2p0::estimate_video_cost_artcraft_seedance2p0;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_seedance2p0::PlanArtcraftSeedance2p0;
use super::*;
/// Combinatorial test: compare v2 ArtcraftSeedance2p0CostState against
/// v1 estimate_video_cost_artcraft_seedance2p0 for all resolution ×
/// duration × batch combinations.
#[test]
fn v2_matches_v1_all_combos() {
let resolutions = [
(Some(CommonResolution::FourEightyP), Seedance2p0OutputResolution::FourEightyP),
(Some(CommonResolution::SevenTwentyP), Seedance2p0OutputResolution::SevenTwentyP),
(None, Seedance2p0OutputResolution::SevenTwentyP), // None defaults to 720p
(Some(CommonResolution::TenEightyP), Seedance2p0OutputResolution::TenEightyP),
];
let durations: [u8; 4] = [4, 5, 10, 15];
let batches = [
(Seedance2p0BatchCount::One, Seedance2p0BatchCount::One),
(Seedance2p0BatchCount::Two, Seedance2p0BatchCount::Two),
(Seedance2p0BatchCount::Four, Seedance2p0BatchCount::Four),
];
for (v1_res, v2_res) in &resolutions {
for dur in &durations {
for (v1_batch, v2_batch) in &batches {
// v1: construct a PlanArtcraftSeedance2p0 and call the v1 cost function
let v1_plan = PlanArtcraftSeedance2p0 {
prompt: None,
start_frame: None,
end_frame: None,
reference_images: None,
reference_videos: None,
reference_audio: None,
reference_characters: None,
aspect_ratio: Some(Seedance2p0AspectRatio::Square1x1),
resolution: *v1_res,
duration_seconds: Some(*dur),
batch_count: *v1_batch,
idempotency_token: "test".to_string(),
};
let v1_cost = estimate_video_cost_artcraft_seedance2p0(&v1_plan);
// v2: construct cost state directly
let v2_cost = ArtcraftSeedance2p0CostState {
resolution: *v2_res,
duration_seconds: *dur,
batch_count: *v2_batch,
has_video_reference: false,
}.estimate_cost();
assert_eq!(
v2_cost.cost_in_usd_cents, v1_cost.cost_in_usd_cents,
"USD cents mismatch: res={:?} dur={}s batch={:?} — v2={:?}, v1={:?}",
v1_res, dur, v1_batch,
v2_cost.cost_in_usd_cents, v1_cost.cost_in_usd_cents,
);
assert_eq!(
v2_cost.cost_in_credits, v1_cost.cost_in_credits,
"Credits mismatch: res={:?} dur={}s batch={:?} — v2={:?}, v1={:?}",
v1_res, dur, v1_batch,
v2_cost.cost_in_credits, v1_cost.cost_in_credits,
);
}
}
}
}
}
// ── Credits spot checks ──
mod credits_tests {
use super::*;
#[test]
fn credits_equal_usd_cents() {
// ArtCraft credits: 100 credits = $1.00, so credits always equal cents.
for res in [
Seedance2p0OutputResolution::FourEightyP,
Seedance2p0OutputResolution::SevenTwentyP,
Seedance2p0OutputResolution::TenEightyP,
] {
for dur in [4, 5, 10, 15] {
for batch in [Seedance2p0BatchCount::One, Seedance2p0BatchCount::Two, Seedance2p0BatchCount::Four] {
assert_eq!(
credits(res, dur, batch),
usd_cents(res, dur, batch),
"credits should equal usd_cents for {:?} {}s {:?}", res, dur, batch,
);
}
}
}
}
#[test]
fn credits_720p() {
assert_eq!(credits(Seedance2p0OutputResolution::SevenTwentyP, 5, Seedance2p0BatchCount::One), 80);
}
#[test]
fn credits_480p() {
assert_eq!(credits(Seedance2p0OutputResolution::FourEightyP, 5, Seedance2p0BatchCount::One), 39);
}
#[test]
fn credits_1080p() {
assert_eq!(credits(Seedance2p0OutputResolution::TenEightyP, 5, Seedance2p0BatchCount::One), 233);
}
}
// ── Builder round-trip ──
mod builder_round_trip {
use super::*;
#[test]
fn build2_then_estimate_cost_720p() {
let builder = GenerateVideoRequestBuilder {
provider: Provider::Artcraft,
resolution: Some(CommonResolution::SevenTwentyP),
duration_seconds: Some(5),
video_batch_count: Some(1),
..Default::default()
};
let result = builder.build2().expect("build2 should succeed");
let cost = result.estimate_cost().expect("estimate_cost should succeed");
assert_eq!(cost.cost_in_usd_cents, Some(80));
}
#[test]
fn build2_then_estimate_cost_1080p_batch_4() {
let builder = GenerateVideoRequestBuilder {
provider: Provider::Artcraft,
resolution: Some(CommonResolution::TenEightyP),
duration_seconds: Some(5),
video_batch_count: Some(4),
..Default::default()
};
let result = builder.build2().expect("build2 should succeed");
let cost = result.estimate_cost().expect("estimate_cost should succeed");
assert_eq!(cost.cost_in_usd_cents, Some(933));
}
}
// ── Helpers ──
fn usd_cents(
resolution: Seedance2p0OutputResolution,
duration_seconds: u8,
batch_count: Seedance2p0BatchCount,
) -> u64 {
ArtcraftSeedance2p0CostState { resolution, duration_seconds, batch_count, has_video_reference: false }
.estimate_cost()
.cost_in_usd_cents
.unwrap()
}
fn credits(
resolution: Seedance2p0OutputResolution,
duration_seconds: u8,
batch_count: Seedance2p0BatchCount,
) -> u64 {
ArtcraftSeedance2p0CostState { resolution, duration_seconds, batch_count, has_video_reference: false }
.estimate_cost()
.cost_in_credits
.unwrap()
}
fn make_request_state(
resolution: Option<Seedance2p0OutputResolution>,
duration_seconds: u8,
batch_count: Seedance2p0BatchCount,
with_video_ref: bool,
) -> ArtcraftSeedance2p0RequestState {
let reference_video_media_tokens = if with_video_ref {
Some(vec![MediaFileToken::new("mf_testvid".to_string())])
} else {
None
};
ArtcraftSeedance2p0RequestState {
request: Seedance2p0MultiFunctionVideoGenRequest {
uuid_idempotency_token: "test-idem".to_string(),
prompt: Some("test".to_string()),
start_frame_media_token: None,
end_frame_media_token: None,
reference_image_media_tokens: None,
reference_video_media_tokens,
reference_audio_media_tokens: None,
reference_character_tokens: None,
aspect_ratio: None,
output_resolution: resolution,
duration_seconds: Some(duration_seconds),
batch_count: Some(batch_count),
},
}
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -0,0 +1,276 @@
use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::Seedance2p0MultiFunctionVideoGenRequest;
use artcraft_client::endpoints::generate::video::multi_function::seedance_2p0_multi_function_video_gen::seedance_2p0_multi_function_video_gen;
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::generate_video_response::{
ArtcraftVideoResponsePayload, GenerateVideoResponse,
};
#[derive(Clone, Debug)]
pub struct ArtcraftSeedance2p0RequestState {
/// Final materialized request; ready to fire.
pub request: Seedance2p0MultiFunctionVideoGenRequest,
}
impl ArtcraftSeedance2p0RequestState {
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let response = seedance_2p0_multi_function_video_gen(
&client.api_host,
Some(&client.credentials),
self.request.clone(),
)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Storyteller(err)))?;
Ok(GenerateVideoResponse::Artcraft(ArtcraftVideoResponsePayload {
inference_job_token: response.inference_job_token,
all_inference_job_tokens: response.all_inference_job_tokens,
}))
}
}
#[cfg(test)]
mod tests {
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::character_list_ref::CharacterListRef;
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_resolution::CommonResolution;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::provider::Provider;
use crate::api::video_list_ref::VideoListRef;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::client::router_client::RouterClient;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use artcraft_client::utils::api_host::ApiHost;
use test_data::web::character_tokens::{JIM, KNIGHT};
use test_data::web::image_media_tokens::{
FOREST_BACKDROP_PRODUCTION_MEDIA_TOKEN,
JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN,
WHITE_HOUSE_SUNSET_PRODUCTION_MEDIA_TOKEN,
};
// ── Aspect ratio tests ──
mod aspect_ratio_tests {
use super::*;
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn landscape() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A corgi running through a field of wildflowers at sunset.".to_string()),
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..artcraft_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn portrait() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A cat sitting on a windowsill watching rain.".to_string()),
aspect_ratio: Some(CommonAspectRatio::TallNineBySixteen),
..artcraft_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn square() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A hummingbird hovering near a flower.".to_string()),
aspect_ratio: Some(CommonAspectRatio::Square),
..artcraft_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
}
// ── Resolution tests ──
mod resolution_tests {
use super::*;
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn res_480p() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A shiba inu playing in autumn leaves.".to_string()),
resolution: Some(CommonResolution::FourEightyP),
..artcraft_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn res_720p() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A golden retriever catching a frisbee on the beach.".to_string()),
resolution: Some(CommonResolution::SevenTwentyP),
..artcraft_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn res_1080p() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A fox walking through a snowy forest.".to_string()),
resolution: Some(CommonResolution::TenEightyP),
..artcraft_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
}
// ── Modality tests ──
mod modality_tests {
use super::*;
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn text_to_video() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A whale breaching in the open ocean at dawn, cinematic.".to_string()),
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..artcraft_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn keyframe_start_and_end_frame() {
// Artcraft uses media file tokens directly — no URL resolution needed.
// Tokens from test_data::web::image_urls comments.
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("The dog walks from the lake to the forest.".to_string()),
start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new(JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN.to_string()))),
end_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new(FOREST_BACKDROP_PRODUCTION_MEDIA_TOKEN.to_string()))),
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..artcraft_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn image_references() {
// Artcraft uses media file tokens directly.
// Tokens from test_data::web::image_urls comments.
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("The dog in @2 runs through the scenery in @1 towards the building in @3.".to_string()),
reference_images: Some(ImageListRef::MediaFileTokens(vec![
MediaFileToken::new(FOREST_BACKDROP_PRODUCTION_MEDIA_TOKEN.to_string()),
MediaFileToken::new(JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN.to_string()),
MediaFileToken::new(WHITE_HOUSE_SUNSET_PRODUCTION_MEDIA_TOKEN.to_string()),
])),
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..artcraft_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn video_references() {
// TODO: Needs a real video media file token to test properly.
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("Change @video1 to a nighttime scene with moonlight.".to_string()),
reference_videos: Some(VideoListRef::MediaFileTokens(vec![
MediaFileToken::new(JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN.to_string()), // Using an image token as placeholder
])),
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..artcraft_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn character_references() {
// Character tokens are passed through directly to the Artcraft API.
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("@Jim and @Knight are sparring in a medieval arena.".to_string()),
reference_character_tokens: Some(CharacterListRef::CharacterTokens(vec![
CharacterToken::new(JIM.token.to_string()),
CharacterToken::new(KNIGHT.token.to_string()),
])),
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..artcraft_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
// TODO: Add audio reference test once a media file token for a test audio file is available.
}
// ── Helpers ──
fn artcraft_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
provider: Provider::Artcraft,
duration_seconds: Some(4),
video_batch_count: Some(1),
..Default::default()
}
}
fn get_artcraft_client() -> RouterClient {
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/artcraft_cookies.txt")
.expect("Failed to read artcraft cookies");
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))
}
async fn run_pipeline(builder: GenerateVideoRequestBuilder) -> GenerateVideoResponse {
let client = get_artcraft_client();
let draft_or_request = builder.build2().expect("build2 should succeed");
let request = match draft_or_request {
VideoGenerationDraftOrRequest::Request(r) => r,
_ => panic!("expected Request variant (Artcraft skips draft)"),
};
let response = request.send_request(&client).await.expect("send_request should succeed");
match &response {
GenerateVideoResponse::Artcraft(p) => {
println!("inference_job_token={:?}", p.inference_job_token);
println!("all_inference_job_tokens={:?}", p.all_inference_job_tokens);
}
other => println!("response: {:?}", other),
}
response
}
}
@@ -0,0 +1,4 @@
pub mod seedance_2p0;
pub mod seedance_2p0_fast;
pub(crate) mod resolve;
pub(crate) mod upload;
@@ -0,0 +1,126 @@
use std::collections::HashMap;
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::audio_list_ref::AudioListRef;
use crate::api::character_list_ref::CharacterListRef;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::video_list_ref::VideoListRef;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video_v2::providers::kinovi::upload::upload_to_seedance2pro;
use crate::generate::generate_video_v2::video_generation_draft_context::VideoGenerationDraftContext;
/// Either resolved URLs or unresolved media file tokens.
pub(crate) enum UrlsOrTokens {
Urls(Vec<String>),
Tokens(Vec<MediaFileToken>),
}
pub(crate) fn image_list_ref_into_urls_or_tokens(list: ImageListRef) -> UrlsOrTokens {
match list {
ImageListRef::Urls(urls) => UrlsOrTokens::Urls(urls),
ImageListRef::MediaFileTokens(tokens) => UrlsOrTokens::Tokens(tokens),
}
}
pub(crate) fn video_list_ref_into_urls_or_tokens(list: VideoListRef) -> UrlsOrTokens {
match list {
VideoListRef::Urls(urls) => UrlsOrTokens::Urls(urls),
VideoListRef::MediaFileTokens(tokens) => UrlsOrTokens::Tokens(tokens),
}
}
pub(crate) fn audio_list_ref_into_urls_or_tokens(list: AudioListRef) -> UrlsOrTokens {
match list {
AudioListRef::Urls(urls) => UrlsOrTokens::Urls(urls),
AudioListRef::MediaFileTokens(tokens) => UrlsOrTokens::Tokens(tokens),
}
}
/// Resolve a single ImageRef and upload to Seedance2Pro CDN.
pub(crate) async fn resolve_and_upload_single(
session: &Seedance2ProSession,
image_ref: Option<ImageRef>,
maybe_map: Option<&HashMap<MediaFileToken, String>>,
) -> Result<Option<String>, ArtcraftRouterError> {
let source_url = match image_ref {
None => return Ok(None),
Some(ImageRef::Url(url)) => url,
Some(ImageRef::MediaFileToken(token)) => resolve_token(maybe_map, &token)?,
};
Ok(Some(upload_to_seedance2pro(session, &source_url).await?))
}
/// Resolve a list of refs to URLs and upload each to Seedance2Pro CDN.
/// Order is preserved.
pub(crate) async fn resolve_and_upload_list(
session: &Seedance2ProSession,
urls_or_tokens: Option<UrlsOrTokens>,
maybe_map: Option<&HashMap<MediaFileToken, String>>,
) -> Result<Option<Vec<String>>, ArtcraftRouterError> {
let source_urls = match urls_or_tokens {
None => return Ok(None),
Some(UrlsOrTokens::Urls(urls)) if urls.is_empty() => return Ok(None),
Some(UrlsOrTokens::Urls(urls)) => urls,
Some(UrlsOrTokens::Tokens(tokens)) if tokens.is_empty() => return Ok(None),
Some(UrlsOrTokens::Tokens(tokens)) => resolve_tokens(maybe_map, &tokens)?,
};
let mut uploaded = Vec::with_capacity(source_urls.len());
for url in &source_urls {
uploaded.push(upload_to_seedance2pro(session, url).await?);
}
Ok(Some(uploaded))
}
/// Map character tokens to their Kinovi character IDs, preserving order.
pub(crate) fn resolve_character_tokens(
character_list_ref: Option<&CharacterListRef>,
draft_context: &VideoGenerationDraftContext<'_>,
) -> Result<Option<Vec<String>>, ArtcraftRouterError> {
let list = match character_list_ref {
None => return Ok(None),
Some(r) => r,
};
let tokens = match list {
CharacterListRef::CharacterTokens(tokens) if tokens.is_empty() => return Ok(None),
CharacterListRef::CharacterTokens(tokens) => tokens,
};
let map = draft_context.get_character_token_to_kinovi_map()?;
let ids: Result<Vec<String>, _> = tokens.iter()
.map(|token| {
map.get(token).cloned().ok_or_else(|| {
ArtcraftRouterError::Client(ClientError::CharacterTokenNotFoundInMap {
token: token.clone(),
})
})
})
.collect();
ids.map(Some)
}
fn resolve_token(
maybe_map: Option<&HashMap<MediaFileToken, String>>,
token: &MediaFileToken,
) -> Result<String, ArtcraftRouterError> {
let map = maybe_map.ok_or(ArtcraftRouterError::Client(ClientError::MediaFileToUrlMapNotProvided))?;
map.get(token).cloned().ok_or_else(|| {
ArtcraftRouterError::Client(ClientError::MediaFileTokenNotFoundInMap {
token: token.clone(),
})
})
}
fn resolve_tokens(
maybe_map: Option<&HashMap<MediaFileToken, String>>,
tokens: &[MediaFileToken],
) -> Result<Vec<String>, ArtcraftRouterError> {
tokens.iter().map(|t| resolve_token(maybe_map, t)).collect()
}
@@ -0,0 +1,568 @@
use seedance2pro_client::requests::generate_video::generate_video::{KinoviAspectRatio, KinoviBatchCount, KinoviOutputResolution};
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_resolution::CommonResolution;
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_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0::draft::{KinoviSeedance2p0DraftState, KinoviSeedance2p0RemainingItems};
use crate::generate::generate_video_v2::video_generation_draft::VideoGenerationDraftRequest;
use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
pub fn build_kinovi_seedance_2p0(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let draft = do_build_kinovi_seedance_2p0(builder)?;
Ok(VideoGenerationDraftOrRequest::Draft(VideoGenerationDraftRequest::KinoviSeedance2p0(draft)))
}
fn do_build_kinovi_seedance_2p0(mut builder: GenerateVideoRequestBuilder) -> Result<KinoviSeedance2p0DraftState, ArtcraftRouterError> {
let strategy = builder.request_mismatch_mitigation_strategy;
let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio.take(), strategy)?;
let resolution = plan_output_resolution(builder.resolution.take(), strategy)?;
let batch_count = plan_batch_count(builder.video_batch_count.take(), strategy)?;
let duration_seconds = plan_duration(builder.duration_seconds.take(), strategy)?;
let prompt = builder.prompt.take().unwrap_or_default();
let unhandled_request_state = KinoviSeedance2p0RemainingItems {
start_frame: builder.start_frame.take(),
end_frame: builder.end_frame.take(),
reference_images: builder.reference_images.take(),
reference_videos: builder.reference_videos.take(),
reference_audio: builder.reference_audio.take(),
reference_character_tokens: builder.reference_character_tokens.take(),
};
Ok(KinoviSeedance2p0DraftState {
aspect_ratio,
resolution,
batch_count,
duration_seconds,
prompt,
unhandled_request_state: Some(unhandled_request_state),
})
}
// ── Plan helpers ──
// 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<KinoviAspectRatio, ArtcraftRouterError> {
match aspect_ratio {
// No preference or auto — default to landscape
None
| Some(CommonAspectRatio::Auto)
| Some(CommonAspectRatio::Auto2k)
| Some(CommonAspectRatio::Auto4k) => Ok(KinoviAspectRatio::Landscape16x9),
// Direct mappings
Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => {
Ok(KinoviAspectRatio::Landscape16x9)
}
Some(CommonAspectRatio::TallNineBySixteen) | Some(CommonAspectRatio::Tall) => {
Ok(KinoviAspectRatio::Portrait9x16)
}
Some(CommonAspectRatio::Square) | Some(CommonAspectRatio::SquareHd) => {
Ok(KinoviAspectRatio::Square1x1)
}
Some(CommonAspectRatio::WideFourByThree) => Ok(KinoviAspectRatio::Standard4x3),
Some(CommonAspectRatio::TallThreeByFour) => Ok(KinoviAspectRatio::Portrait3x4),
// Mismatches — apply strategy
Some(unsupported) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "aspect_ratio",
value: format!("{:?}", unsupported),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(nearest_aspect_ratio(unsupported))
}
},
}
}
/// Pick the nearest supported aspect ratio by AR value (width / height).
fn nearest_aspect_ratio(aspect_ratio: CommonAspectRatio) -> KinoviAspectRatio {
match aspect_ratio {
CommonAspectRatio::WideFiveByFour => KinoviAspectRatio::Standard4x3, // 1.25, nearest 1.33
CommonAspectRatio::WideThreeByTwo => KinoviAspectRatio::Standard4x3, // 1.50, nearest 1.33
CommonAspectRatio::WideTwentyOneByNine => KinoviAspectRatio::Landscape16x9, // 2.33, nearest 1.78
CommonAspectRatio::TallFourByFive => KinoviAspectRatio::Portrait3x4, // 0.80, nearest 0.75
CommonAspectRatio::TallTwoByThree => KinoviAspectRatio::Portrait3x4, // 0.67, nearest 0.75
CommonAspectRatio::TallNineByTwentyOne => KinoviAspectRatio::Portrait9x16, // 0.43, nearest 0.56
_ => KinoviAspectRatio::Square1x1,
}
}
// Seedance 2.0 Pro supports output resolutions: 480p, 720p, 1080p.
fn plan_output_resolution(
resolution: Option<CommonResolution>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<KinoviOutputResolution>, ArtcraftRouterError> {
match resolution {
None => Ok(None),
// Direct mappings
Some(CommonResolution::FourEightyP) => Ok(Some(KinoviOutputResolution::FourEightyP)),
Some(CommonResolution::SevenTwentyP) => Ok(Some(KinoviOutputResolution::SevenTwentyP)),
Some(CommonResolution::TenEightyP) => Ok(Some(KinoviOutputResolution::TenEightyP)),
// Mismatches
Some(unsupported) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "resolution",
value: format!("{:?}", unsupported),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => {
Ok(Some(match unsupported {
CommonResolution::HalfK => KinoviOutputResolution::FourEightyP,
_ => KinoviOutputResolution::TenEightyP,
}))
}
RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(Some(match unsupported {
CommonResolution::HalfK => KinoviOutputResolution::FourEightyP,
_ => KinoviOutputResolution::TenEightyP,
}))
}
},
}
}
// Seedance2p0 supports batch counts of 1, 2, and 4 only.
fn plan_batch_count(
video_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<KinoviBatchCount, ArtcraftRouterError> {
let count = video_batch_count.unwrap_or(1);
match count {
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 => Ok(KinoviBatchCount::One),
2 => Ok(KinoviBatchCount::Two),
4 => Ok(KinoviBatchCount::Four),
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "video_batch_count",
value: format!("{}", count),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => {
Ok(if count < 4 { KinoviBatchCount::Four } else { KinoviBatchCount::Four })
}
RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(if count < 4 { KinoviBatchCount::Two } else { KinoviBatchCount::Four })
}
},
}
}
// Seedance2p0 supports duration of 4–15 seconds.
fn plan_duration(
duration_seconds: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<u8, ArtcraftRouterError> {
const MIN: u16 = 4;
const MAX: u16 = 15;
const DEFAULT: u8 = 5;
match duration_seconds {
None => Ok(DEFAULT),
Some(d) if d >= MIN && d <= MAX => Ok(d as u8),
Some(d) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "duration_seconds",
value: format!("{}", d),
}))
}
_ => Ok(d.clamp(MIN, MAX) as u8),
},
}
}
#[cfg(test)]
mod tests {
use seedance2pro_client::requests::generate_video::generate_video::{KinoviAspectRatio, KinoviBatchCount, KinoviOutputResolution};
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::audio_list_ref::AudioListRef;
use crate::api::character_list_ref::CharacterListRef;
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_resolution::CommonResolution;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::provider::Provider;
use crate::api::video_list_ref::VideoListRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video_v2::video_generation_draft::VideoGenerationDraftRequest;
use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use super::*;
// ── Materialized field conversions ──
mod materialized_field_conversions {
use super::*;
#[test]
fn prompt_is_passed_through() {
let draft = unwrap_draft(build_kinovi_seedance_2p0(seedance2pro_builder()));
assert_eq!(draft.prompt, "a cat dancing");
}
#[test]
fn prompt_defaults_to_empty() {
let builder = GenerateVideoRequestBuilder { prompt: None, ..seedance2pro_builder() };
let draft = unwrap_draft(build_kinovi_seedance_2p0(builder));
assert_eq!(draft.prompt, "");
}
#[test]
fn duration_seconds_converted() {
let builder = GenerateVideoRequestBuilder { duration_seconds: Some(10), ..seedance2pro_builder() };
let draft = unwrap_draft(build_kinovi_seedance_2p0(builder));
assert_eq!(draft.duration_seconds, 10);
}
#[test]
fn duration_defaults_to_5() {
let builder = GenerateVideoRequestBuilder { duration_seconds: None, ..seedance2pro_builder() };
let draft = unwrap_draft(build_kinovi_seedance_2p0(builder));
assert_eq!(draft.duration_seconds, 5);
}
#[test]
fn duration_clamped_to_max() {
let builder = GenerateVideoRequestBuilder {
duration_seconds: Some(99),
..seedance2pro_builder()
};
let draft = unwrap_draft(build_kinovi_seedance_2p0(builder));
assert_eq!(draft.duration_seconds, 15);
}
#[test]
fn batch_count_one() {
let builder = GenerateVideoRequestBuilder { video_batch_count: Some(1), ..seedance2pro_builder() };
let draft = unwrap_draft(build_kinovi_seedance_2p0(builder));
assert!(matches!(draft.batch_count, KinoviBatchCount::One));
}
#[test]
fn batch_count_two() {
let builder = GenerateVideoRequestBuilder { video_batch_count: Some(2), ..seedance2pro_builder() };
let draft = unwrap_draft(build_kinovi_seedance_2p0(builder));
assert!(matches!(draft.batch_count, KinoviBatchCount::Two));
}
#[test]
fn batch_count_four() {
let builder = GenerateVideoRequestBuilder { video_batch_count: Some(4), ..seedance2pro_builder() };
let draft = unwrap_draft(build_kinovi_seedance_2p0(builder));
assert!(matches!(draft.batch_count, KinoviBatchCount::Four));
}
}
// ── Aspect ratio conversions ──
mod aspect_ratio_conversions {
use super::*;
#[test]
fn aspect_ratio_wide() {
let builder = GenerateVideoRequestBuilder {
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..seedance2pro_builder()
};
let draft = unwrap_draft(build_kinovi_seedance_2p0(builder));
assert!(matches!(draft.aspect_ratio, KinoviAspectRatio::Landscape16x9));
}
#[test]
fn aspect_ratio_tall() {
let builder = GenerateVideoRequestBuilder {
aspect_ratio: Some(CommonAspectRatio::TallNineBySixteen),
..seedance2pro_builder()
};
let draft = unwrap_draft(build_kinovi_seedance_2p0(builder));
assert!(matches!(draft.aspect_ratio, KinoviAspectRatio::Portrait9x16));
}
#[test]
fn aspect_ratio_square() {
let builder = GenerateVideoRequestBuilder {
aspect_ratio: Some(CommonAspectRatio::Square),
..seedance2pro_builder()
};
let draft = unwrap_draft(build_kinovi_seedance_2p0(builder));
assert!(matches!(draft.aspect_ratio, KinoviAspectRatio::Square1x1));
}
#[test]
fn aspect_ratio_defaults_to_landscape() {
let builder = GenerateVideoRequestBuilder { aspect_ratio: None, ..seedance2pro_builder() };
let draft = unwrap_draft(build_kinovi_seedance_2p0(builder));
assert!(matches!(draft.aspect_ratio, KinoviAspectRatio::Landscape16x9));
}
}
// ── Resolution conversions ──
mod resolution_conversions {
use super::*;
#[test]
fn resolution_480p() {
let builder = GenerateVideoRequestBuilder {
resolution: Some(CommonResolution::FourEightyP),
..seedance2pro_builder()
};
let draft = unwrap_draft(build_kinovi_seedance_2p0(builder));
assert!(matches!(draft.resolution, Some(KinoviOutputResolution::FourEightyP)));
}
#[test]
fn resolution_720p() {
let builder = GenerateVideoRequestBuilder {
resolution: Some(CommonResolution::SevenTwentyP),
..seedance2pro_builder()
};
let draft = unwrap_draft(build_kinovi_seedance_2p0(builder));
assert!(matches!(draft.resolution, Some(KinoviOutputResolution::SevenTwentyP)));
}
#[test]
fn resolution_1080p() {
let builder = GenerateVideoRequestBuilder {
resolution: Some(CommonResolution::TenEightyP),
..seedance2pro_builder()
};
let draft = unwrap_draft(build_kinovi_seedance_2p0(builder));
assert!(matches!(draft.resolution, Some(KinoviOutputResolution::TenEightyP)));
}
#[test]
fn resolution_none() {
let builder = GenerateVideoRequestBuilder { resolution: None, ..seedance2pro_builder() };
let draft = unwrap_draft(build_kinovi_seedance_2p0(builder));
assert!(draft.resolution.is_none());
}
#[test]
fn unsupported_resolution_error_out() {
let builder = GenerateVideoRequestBuilder {
resolution: Some(CommonResolution::FourK),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..seedance2pro_builder()
};
assert!(build_kinovi_seedance_2p0(builder).is_err());
}
#[test]
fn unsupported_resolution_rounds_up() {
let builder = GenerateVideoRequestBuilder {
resolution: Some(CommonResolution::FourK),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
..seedance2pro_builder()
};
let draft = unwrap_draft(build_kinovi_seedance_2p0(builder));
assert!(matches!(draft.resolution, Some(KinoviOutputResolution::TenEightyP)));
}
}
// ── unhandled_request_state: media refs are placed there ──
mod unhandled_request_state {
use super::*;
#[test]
fn unhandled_state_is_present() {
let draft = unwrap_draft(build_kinovi_seedance_2p0(seedance2pro_builder()));
assert!(draft.unhandled_request_state.is_some());
}
#[test]
fn start_frame_placed_in_unhandled() {
let builder = GenerateVideoRequestBuilder {
start_frame: Some(ImageRef::Url("https://example.com/start.jpg".to_string())),
..seedance2pro_builder()
};
let draft = unwrap_draft(build_kinovi_seedance_2p0(builder));
let remaining = draft.unhandled_request_state.unwrap();
assert!(matches!(remaining.start_frame, Some(ImageRef::Url(url)) if url == "https://example.com/start.jpg"));
}
#[test]
fn end_frame_placed_in_unhandled() {
let builder = GenerateVideoRequestBuilder {
end_frame: Some(ImageRef::Url("https://example.com/end.jpg".to_string())),
..seedance2pro_builder()
};
let draft = unwrap_draft(build_kinovi_seedance_2p0(builder));
let remaining = draft.unhandled_request_state.unwrap();
assert!(matches!(remaining.end_frame, Some(ImageRef::Url(url)) if url == "https://example.com/end.jpg"));
}
#[test]
fn start_frame_media_token_placed_in_unhandled() {
let builder = GenerateVideoRequestBuilder {
start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_test123".to_string()))),
..seedance2pro_builder()
};
let draft = unwrap_draft(build_kinovi_seedance_2p0(builder));
let remaining = draft.unhandled_request_state.unwrap();
assert!(matches!(remaining.start_frame, Some(ImageRef::MediaFileToken(t)) if t.as_str() == "mf_test123"));
}
#[test]
fn reference_images_placed_in_unhandled() {
let builder = GenerateVideoRequestBuilder {
reference_images: Some(ImageListRef::Urls(vec![
"https://example.com/ref1.jpg".to_string(),
"https://example.com/ref2.jpg".to_string(),
])),
..seedance2pro_builder()
};
let draft = unwrap_draft(build_kinovi_seedance_2p0(builder));
let remaining = draft.unhandled_request_state.unwrap();
match remaining.reference_images {
Some(ImageListRef::Urls(urls)) => {
assert_eq!(urls.len(), 2);
assert_eq!(urls[0], "https://example.com/ref1.jpg");
assert_eq!(urls[1], "https://example.com/ref2.jpg");
}
_ => panic!("expected Urls variant"),
}
}
#[test]
fn reference_videos_placed_in_unhandled() {
let builder = GenerateVideoRequestBuilder {
reference_videos: Some(VideoListRef::Urls(vec![
"https://example.com/vid.mp4".to_string(),
])),
..seedance2pro_builder()
};
let draft = unwrap_draft(build_kinovi_seedance_2p0(builder));
let remaining = draft.unhandled_request_state.unwrap();
assert!(matches!(remaining.reference_videos, Some(VideoListRef::Urls(urls)) if urls.len() == 1));
}
#[test]
fn reference_audio_placed_in_unhandled() {
let builder = GenerateVideoRequestBuilder {
reference_audio: Some(AudioListRef::Urls(vec![
"https://example.com/audio.mp3".to_string(),
])),
..seedance2pro_builder()
};
let draft = unwrap_draft(build_kinovi_seedance_2p0(builder));
let remaining = draft.unhandled_request_state.unwrap();
assert!(matches!(remaining.reference_audio, Some(AudioListRef::Urls(urls)) if urls.len() == 1));
}
#[test]
fn character_tokens_placed_in_unhandled() {
let builder = GenerateVideoRequestBuilder {
reference_character_tokens: Some(CharacterListRef::CharacterTokens(vec![
CharacterToken::new("char_abc".to_string()),
CharacterToken::new("char_def".to_string()),
])),
..seedance2pro_builder()
};
let draft = unwrap_draft(build_kinovi_seedance_2p0(builder));
let remaining = draft.unhandled_request_state.unwrap();
match remaining.reference_character_tokens {
Some(CharacterListRef::CharacterTokens(tokens)) => {
assert_eq!(tokens.len(), 2);
assert_eq!(tokens[0].as_str(), "char_abc");
assert_eq!(tokens[1].as_str(), "char_def");
}
_ => panic!("expected CharacterTokens variant"),
}
}
#[test]
fn empty_refs_are_none_in_unhandled() {
let draft = unwrap_draft(build_kinovi_seedance_2p0(seedance2pro_builder()));
let remaining = draft.unhandled_request_state.unwrap();
assert!(remaining.start_frame.is_none());
assert!(remaining.end_frame.is_none());
assert!(remaining.reference_images.is_none());
assert!(remaining.reference_videos.is_none());
assert!(remaining.reference_audio.is_none());
assert!(remaining.reference_character_tokens.is_none());
}
}
// ── Full combination ──
#[test]
fn full_request_all_fields() {
let builder = GenerateVideoRequestBuilder {
prompt: Some("full test".to_string()),
aspect_ratio: Some(CommonAspectRatio::TallNineBySixteen),
resolution: Some(CommonResolution::TenEightyP),
duration_seconds: Some(10),
video_batch_count: Some(4),
start_frame: Some(ImageRef::Url("https://example.com/start.jpg".to_string())),
end_frame: Some(ImageRef::Url("https://example.com/end.jpg".to_string())),
reference_images: Some(ImageListRef::Urls(vec!["https://example.com/ref.jpg".to_string()])),
reference_videos: Some(VideoListRef::Urls(vec!["https://example.com/vid.mp4".to_string()])),
reference_audio: Some(AudioListRef::Urls(vec!["https://example.com/audio.mp3".to_string()])),
reference_character_tokens: Some(CharacterListRef::CharacterTokens(vec![
CharacterToken::new("char_xyz".to_string()),
])),
..seedance2pro_builder()
};
let draft = unwrap_draft(build_kinovi_seedance_2p0(builder));
assert_eq!(draft.prompt, "full test");
assert!(matches!(draft.aspect_ratio, KinoviAspectRatio::Portrait9x16));
assert!(matches!(draft.resolution, Some(KinoviOutputResolution::TenEightyP)));
assert_eq!(draft.duration_seconds, 10);
assert!(matches!(draft.batch_count, KinoviBatchCount::Four));
let remaining = draft.unhandled_request_state.unwrap();
assert!(remaining.start_frame.is_some());
assert!(remaining.end_frame.is_some());
assert!(remaining.reference_images.is_some());
assert!(remaining.reference_videos.is_some());
assert!(remaining.reference_audio.is_some());
assert!(remaining.reference_character_tokens.is_some());
}
// ── Helpers ──
fn seedance2pro_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
provider: Provider::Seedance2Pro,
prompt: Some("a cat dancing".to_string()),
duration_seconds: Some(5),
video_batch_count: Some(1),
..Default::default()
}
}
fn unwrap_draft(result: Result<VideoGenerationDraftOrRequest, ArtcraftRouterError>) -> KinoviSeedance2p0DraftState {
match result.expect("build should succeed") {
VideoGenerationDraftOrRequest::Draft(
VideoGenerationDraftRequest::KinoviSeedance2p0(draft)
) => draft,
_ => panic!("expected KinoviSeedance2p0 draft"),
}
}
}
@@ -0,0 +1,463 @@
use seedance2pro_client::requests::generate_video::generate_video::{KinoviAspectRatio, KinoviBatchCount, KinoviGenerateVideoRequest, KinoviModelType, KinoviOutputResolution};
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0::draft::KinoviSeedance2p0DraftState;
use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0::request::KinoviSeedance2p0RequestState;
pub struct KinoviSeedance2p0CostState {
pub resolution: KinoviOutputResolution,
pub duration_seconds: u8,
pub batch_count: KinoviBatchCount,
/// Forward-looking: Kinovi charges for this. We need to take it into
/// account in the future.
pub has_video_reference: bool,
}
impl KinoviSeedance2p0CostState {
pub fn from_request(request: &KinoviSeedance2p0RequestState) -> Self {
Self {
resolution: request.request.output_resolution.unwrap_or(KinoviOutputResolution::SevenTwentyP),
duration_seconds: request.request.duration_seconds,
batch_count: request.request.batch_count,
has_video_reference: request.request.reference_video_urls
.as_ref()
.is_some_and(|urls| !urls.is_empty()),
}
}
pub fn from_draft(draft: &KinoviSeedance2p0DraftState) -> Self {
let has_video_reference = draft.unhandled_request_state
.as_ref()
.and_then(|rem| rem.reference_videos.as_ref())
.is_some();
Self {
resolution: draft.resolution.unwrap_or(KinoviOutputResolution::SevenTwentyP),
duration_seconds: draft.duration_seconds,
batch_count: draft.batch_count,
has_video_reference,
}
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
let request = KinoviGenerateVideoRequest {
// Pricing factors
model_type: KinoviModelType::Seedance2Pro,
output_resolution: Some(self.resolution),
duration_seconds: self.duration_seconds,
batch_count: self.batch_count,
// NB: has_video_reference is tracked but does not affect price yet.
// When Kinovi starts charging for video references, add cost here.
// No impact on price
prompt: String::new(),
aspect_ratio: KinoviAspectRatio::Portrait9x16,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
reference_video_urls: None,
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
};
let cost_in_credits = request.estimate_credits();
let cost_in_usd_cents = request.estimate_cost_in_usd_cents();
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_credits as u64),
cost_in_usd_cents: Some(cost_in_usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::common_resolution::CommonResolution;
use crate::api::provider::Provider;
use crate::api::video_list_ref::VideoListRef;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video_v2::video_generation_draft::VideoGenerationDraftRequest;
use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
// ── 720p pricing ──
mod pricing_720p {
use super::*;
#[test]
fn cost_720p_batch_1() {
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::One), 64);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One), 80);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 6, KinoviBatchCount::One), 96);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 7, KinoviBatchCount::One), 112);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 10, KinoviBatchCount::One), 160);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One), 240);
}
#[test]
fn cost_720p_batch_2() {
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::Two), 128);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Two), 160);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::Two), 480);
}
#[test]
fn cost_720p_batch_4() {
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::Four), 256);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Four), 320);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::Four), 960);
}
}
// ── 480p pricing ──
mod pricing_480p {
use super::*;
#[test]
fn cost_480p_batch_1() {
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 4, KinoviBatchCount::One), 31);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One), 39);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 10, KinoviBatchCount::One), 78);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 15, KinoviBatchCount::One), 117);
}
#[test]
fn cost_480p_batch_2() {
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Two), 78);
}
#[test]
fn cost_480p_batch_4() {
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Four), 155);
}
}
// ── 1080p pricing ──
mod pricing_1080p {
use super::*;
#[test]
fn cost_1080p_batch_1() {
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 4, KinoviBatchCount::One), 187);
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 5, KinoviBatchCount::One), 233);
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 10, KinoviBatchCount::One), 466);
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 15, KinoviBatchCount::One), 699);
}
#[test]
fn cost_1080p_batch_2() {
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 5, KinoviBatchCount::Two), 466);
}
#[test]
fn cost_1080p_batch_4() {
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 5, KinoviBatchCount::Four), 933);
}
}
// ── Relative pricing ──
mod relative_pricing_tests {
use super::*;
#[test]
fn cost_480p_cheaper_than_720p_cheaper_than_1080p() {
let c480 = usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One);
let c720 = usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One);
let c1080 = usd_cents(KinoviOutputResolution::TenEightyP, 5, KinoviBatchCount::One);
assert!(c480 < c720, "480p ({}) should be cheaper than 720p ({})", c480, c720);
assert!(c720 < c1080, "720p ({}) should be cheaper than 1080p ({})", c720, c1080);
}
#[test]
fn cost_scales_with_duration() {
let c4 = usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::One);
let c10 = usd_cents(KinoviOutputResolution::SevenTwentyP, 10, KinoviBatchCount::One);
let c15 = usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One);
assert!(c4 < c10);
assert!(c10 < c15);
}
#[test]
fn cost_scales_with_batch() {
let b1 = usd_cents(KinoviOutputResolution::TenEightyP, 5, KinoviBatchCount::One);
let b2 = usd_cents(KinoviOutputResolution::TenEightyP, 5, KinoviBatchCount::Two);
let b4 = usd_cents(KinoviOutputResolution::TenEightyP, 5, KinoviBatchCount::Four);
assert!(b1 < b2);
assert!(b2 < b4);
}
}
// -- Video reference does NOT affect cost (yet) --
#[test]
fn video_reference_does_not_affect_cost() {
let base = KinoviSeedance2p0CostState {
resolution: KinoviOutputResolution::SevenTwentyP,
duration_seconds: 5,
batch_count: KinoviBatchCount::One,
has_video_reference: false,
};
let without = base.estimate_cost();
let with = KinoviSeedance2p0CostState { has_video_reference: true, ..base }.estimate_cost();
assert_eq!(without.cost_in_usd_cents, with.cost_in_usd_cents);
assert_eq!(without.cost_in_credits, with.cost_in_credits);
}
// ── from_request() tests ──
mod from_request_tests {
use super::*;
#[test]
fn from_request_720p() {
let req = make_request_state(Some(KinoviOutputResolution::SevenTwentyP), 5, KinoviBatchCount::One, false);
let cost = KinoviSeedance2p0CostState::from_request(&req);
assert!(matches!(cost.resolution, KinoviOutputResolution::SevenTwentyP));
assert_eq!(cost.duration_seconds, 5);
assert!(matches!(cost.batch_count, KinoviBatchCount::One));
assert!(!cost.has_video_reference);
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(80));
}
#[test]
fn from_request_none_defaults_to_720p() {
let req = make_request_state(None, 5, KinoviBatchCount::One, false);
let cost = KinoviSeedance2p0CostState::from_request(&req);
assert!(matches!(cost.resolution, KinoviOutputResolution::SevenTwentyP));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(80));
}
#[test]
fn from_request_480p() {
let req = make_request_state(Some(KinoviOutputResolution::FourEightyP), 5, KinoviBatchCount::One, false);
let cost = KinoviSeedance2p0CostState::from_request(&req);
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(39));
}
#[test]
fn from_request_1080p_batch_2() {
let req = make_request_state(Some(KinoviOutputResolution::TenEightyP), 5, KinoviBatchCount::Two, false);
let cost = KinoviSeedance2p0CostState::from_request(&req);
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(466));
}
#[test]
fn from_request_with_video_reference() {
let req = make_request_state(Some(KinoviOutputResolution::SevenTwentyP), 5, KinoviBatchCount::One, true);
let cost = KinoviSeedance2p0CostState::from_request(&req);
assert!(cost.has_video_reference);
// Video refs don't affect cost yet
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(80));
}
#[test]
fn from_request_without_video_reference() {
let req = make_request_state(Some(KinoviOutputResolution::SevenTwentyP), 5, KinoviBatchCount::One, false);
let cost = KinoviSeedance2p0CostState::from_request(&req);
assert!(!cost.has_video_reference);
}
}
// ── from_draft() tests ──
mod from_draft_tests {
use super::*;
#[test]
fn from_draft_720p_default() {
let draft = make_draft(5, 1, None, false);
let cost = KinoviSeedance2p0CostState::from_draft(&draft);
assert!(matches!(cost.resolution, KinoviOutputResolution::SevenTwentyP));
assert_eq!(cost.duration_seconds, 5);
assert!(matches!(cost.batch_count, KinoviBatchCount::One));
assert!(!cost.has_video_reference);
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(80));
}
#[test]
fn from_draft_480p() {
let draft = make_draft(5, 1, Some(CommonResolution::FourEightyP), false);
let cost = KinoviSeedance2p0CostState::from_draft(&draft);
assert!(matches!(cost.resolution, KinoviOutputResolution::FourEightyP));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(39));
}
#[test]
fn from_draft_1080p_batch_4() {
let draft = make_draft(5, 4, Some(CommonResolution::TenEightyP), false);
let cost = KinoviSeedance2p0CostState::from_draft(&draft);
assert!(matches!(cost.resolution, KinoviOutputResolution::TenEightyP));
assert!(matches!(cost.batch_count, KinoviBatchCount::Four));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(933));
}
#[test]
fn from_draft_with_video_reference() {
let draft = make_draft(5, 1, None, true);
let cost = KinoviSeedance2p0CostState::from_draft(&draft);
assert!(cost.has_video_reference);
// Video refs don't affect cost yet
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(80));
}
#[test]
fn from_draft_without_video_reference() {
let draft = make_draft(5, 1, None, false);
let cost = KinoviSeedance2p0CostState::from_draft(&draft);
assert!(!cost.has_video_reference);
}
#[test]
fn from_draft_duration_15_batch_2() {
let draft = make_draft(15, 2, Some(CommonResolution::SevenTwentyP), false);
let cost = KinoviSeedance2p0CostState::from_draft(&draft);
assert_eq!(cost.duration_seconds, 15);
assert!(matches!(cost.batch_count, KinoviBatchCount::Two));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(480));
}
}
// ── Cross-check: from_draft matches from_request ──
mod cross_check_tests {
use super::*;
#[test]
fn draft_and_request_produce_same_cost() {
// 720p, 5s, batch 1
let draft = make_draft(5, 1, Some(CommonResolution::SevenTwentyP), false);
let req = make_request_state(Some(KinoviOutputResolution::SevenTwentyP), 5, KinoviBatchCount::One, false);
let draft_cost = KinoviSeedance2p0CostState::from_draft(&draft).estimate_cost();
let req_cost = KinoviSeedance2p0CostState::from_request(&req).estimate_cost();
assert_eq!(draft_cost.cost_in_usd_cents, req_cost.cost_in_usd_cents);
assert_eq!(draft_cost.cost_in_credits, req_cost.cost_in_credits);
}
#[test]
fn draft_and_request_produce_same_cost_1080p() {
let draft = make_draft(10, 2, Some(CommonResolution::TenEightyP), false);
let req = make_request_state(Some(KinoviOutputResolution::TenEightyP), 10, KinoviBatchCount::Two, false);
let draft_cost = KinoviSeedance2p0CostState::from_draft(&draft).estimate_cost();
let req_cost = KinoviSeedance2p0CostState::from_request(&req).estimate_cost();
assert_eq!(draft_cost.cost_in_usd_cents, req_cost.cost_in_usd_cents);
assert_eq!(draft_cost.cost_in_credits, req_cost.cost_in_credits);
}
}
// ── Credits spot checks ──
mod credits_tests {
use super::*;
#[test]
fn credits_720p() {
assert_eq!(credits(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One), 200);
}
#[test]
fn credits_480p() {
assert_eq!(credits(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One), 75);
}
#[test]
fn credits_1080p() {
assert_eq!(credits(KinoviOutputResolution::TenEightyP, 5, KinoviBatchCount::One), 450);
}
}
// ── Helpers ──
fn usd_cents(
resolution: KinoviOutputResolution,
duration_seconds: u8,
batch_count: KinoviBatchCount,
) -> u64 {
KinoviSeedance2p0CostState { resolution, duration_seconds, batch_count, has_video_reference: false }
.estimate_cost()
.cost_in_usd_cents
.unwrap()
}
fn credits(
resolution: KinoviOutputResolution,
duration_seconds: u8,
batch_count: KinoviBatchCount,
) -> u64 {
KinoviSeedance2p0CostState { resolution, duration_seconds, batch_count, has_video_reference: false }
.estimate_cost()
.cost_in_credits
.unwrap()
}
/// Build a draft via the builder to test from_draft().
fn make_draft(
duration_seconds: u16,
video_batch_count: u16,
resolution: Option<CommonResolution>,
with_video_ref: bool,
) -> KinoviSeedance2p0DraftState {
let reference_videos = if with_video_ref {
Some(VideoListRef::Urls(vec!["https://example.com/video.mp4".to_string()]))
} else {
None
};
let builder = GenerateVideoRequestBuilder {
provider: Provider::Seedance2Pro,
resolution,
reference_videos,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(video_batch_count),
..Default::default()
};
match builder.build2().expect("build2 should succeed") {
VideoGenerationDraftOrRequest::Draft(
VideoGenerationDraftRequest::KinoviSeedance2p0(draft)
) => draft,
_ => panic!("expected KinoviSeedance2p0 draft"),
}
}
/// Build a request state for from_request() tests.
fn make_request_state(
resolution: Option<KinoviOutputResolution>,
duration_seconds: u8,
batch_count: KinoviBatchCount,
with_video_ref: bool,
) -> KinoviSeedance2p0RequestState {
let reference_video_urls = if with_video_ref {
Some(vec!["https://cdn.seedance2-pro.com/video.mp4".to_string()])
} else {
None
};
KinoviSeedance2p0RequestState {
request: KinoviGenerateVideoRequest {
model_type: KinoviModelType::Seedance2Pro,
prompt: "test".to_string(),
aspect_ratio: KinoviAspectRatio::Landscape16x9,
output_resolution: resolution,
duration_seconds,
batch_count,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
reference_video_urls,
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
},
}
}
}
@@ -0,0 +1,98 @@
use seedance2pro_client::requests::generate_video::generate_video::{KinoviAspectRatio, KinoviBatchCount, KinoviGenerateVideoRequest, KinoviModelType, KinoviOutputResolution};
use crate::api::audio_list_ref::AudioListRef;
use crate::api::character_list_ref::CharacterListRef;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::video_list_ref::VideoListRef;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video_v2::providers::kinovi::resolve::{
audio_list_ref_into_urls_or_tokens, image_list_ref_into_urls_or_tokens,
resolve_and_upload_list, resolve_and_upload_single, resolve_character_tokens,
video_list_ref_into_urls_or_tokens,
};
use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0::request::KinoviSeedance2p0RequestState;
use crate::generate::generate_video_v2::video_generation_draft_context::VideoGenerationDraftContext;
#[derive(Debug, Clone)]
pub struct KinoviSeedance2p0DraftState {
// Materialized / finalized types
pub prompt: String,
pub aspect_ratio: KinoviAspectRatio,
pub resolution: Option<KinoviOutputResolution>,
pub duration_seconds: u8,
pub batch_count: KinoviBatchCount,
// Pending types that need to be queried.
pub unhandled_request_state: Option<KinoviSeedance2p0RemainingItems>,
}
#[derive(Debug, Clone)]
pub struct KinoviSeedance2p0RemainingItems {
pub start_frame: Option<ImageRef>,
pub end_frame: Option<ImageRef>,
pub reference_images: Option<ImageListRef>,
pub reference_videos: Option<VideoListRef>,
pub reference_audio: Option<AudioListRef>,
pub reference_character_tokens: Option<CharacterListRef>,
}
impl KinoviSeedance2p0DraftState {
pub async fn to_request(
&mut self,
draft_context: &VideoGenerationDraftContext<'_>,
) -> Result<KinoviSeedance2p0RequestState, ArtcraftRouterError> {
let client = draft_context.get_seedance2pro_client_ref()?;
let session = &client.session;
let mut start_frame_url = None;
let mut end_frame_url = None;
let mut reference_image_urls = None;
let mut reference_video_urls = None;
let mut reference_audio_urls = None;
let mut character_ids = None;
if let Some(remaining) = self.unhandled_request_state.take() {
let map = draft_context.media_file_to_artcraft_url_map;
start_frame_url = resolve_and_upload_single(session, remaining.start_frame, map).await?;
end_frame_url = resolve_and_upload_single(session, remaining.end_frame, map).await?;
reference_image_urls = resolve_and_upload_list(
session, remaining.reference_images.map(image_list_ref_into_urls_or_tokens), map,
).await?;
reference_video_urls = resolve_and_upload_list(
session, remaining.reference_videos.map(video_list_ref_into_urls_or_tokens), map,
).await?;
reference_audio_urls = resolve_and_upload_list(
session, remaining.reference_audio.map(audio_list_ref_into_urls_or_tokens), map,
).await?;
character_ids = resolve_character_tokens(
remaining.reference_character_tokens.as_ref(),
draft_context,
)?;
}
let request = KinoviGenerateVideoRequest {
model_type: KinoviModelType::Seedance2Pro,
prompt: self.prompt.clone(),
aspect_ratio: self.aspect_ratio,
output_resolution: self.resolution,
duration_seconds: self.duration_seconds,
batch_count: self.batch_count,
start_frame_url,
end_frame_url,
reference_image_urls,
reference_video_urls,
reference_audio_urls,
character_ids,
use_face_blur_hack: None,
};
Ok(KinoviSeedance2p0RequestState { request })
}
}
@@ -0,0 +1,4 @@
pub mod build;
pub mod draft;
pub mod request;
pub mod cost;
@@ -0,0 +1,349 @@
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_video::generate_video_response::{GenerateVideoResponse, Seedance2proVideoResponsePayload};
use seedance2pro_client::requests::generate_video::generate_video::{generate_video, GenerateVideoArgs, KinoviGenerateVideoRequest};
#[derive(Debug, Clone)]
pub struct KinoviSeedance2p0RequestState {
/// Final materialized request; ready to fire.
pub request: KinoviGenerateVideoRequest,
}
impl KinoviSeedance2p0RequestState {
pub async fn send(&self, client: &RouterSeedance2ProClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let session = &client.session;
let args = GenerateVideoArgs {
session,
host_override: None,
request: self.request.clone(), // TODO: Yuck.
};
let response = generate_video(args)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?;
Ok(GenerateVideoResponse::Seedance2Pro(Seedance2proVideoResponsePayload {
order_id: response.order_id,
task_id: response.task_id,
}))
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::character_list_ref::CharacterListRef;
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_resolution::CommonResolution;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::provider::Provider;
use crate::api::video_list_ref::VideoListRef;
use crate::client::router_client::RouterClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
use crate::generate::generate_video_v2::video_generation_draft_context::VideoGenerationDraftContext;
use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use test_data::web::image_urls::{FOREST_BACKDROP_IMAGE_URL, JUNO_AT_LAKE_IMAGE_URL, WHITE_HOUSE_SUNSET_IMAGE_URL};
const STEAMPUNK_CLOWN_KINOVI_ID: &str = "char_1775176566518_sik0te";
const MOCHI_KINOVI_ID: &str = "char_1775177718294_g2pitx";
mod aspect_ratio_tests {
use super::*;
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn landscape() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A corgi running through a field of wildflowers at sunset.".to_string()),
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..seedance2pro_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn portrait() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A cat sitting on a windowsill watching rain.".to_string()),
aspect_ratio: Some(CommonAspectRatio::TallNineBySixteen),
..seedance2pro_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn square() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A hummingbird hovering near a flower.".to_string()),
aspect_ratio: Some(CommonAspectRatio::Square),
..seedance2pro_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
}
mod resolution_tests {
use super::*;
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn res_480p() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A shiba inu playing in autumn leaves.".to_string()),
resolution: Some(CommonResolution::FourEightyP),
..seedance2pro_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn res_720p() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A golden retriever catching a frisbee on the beach.".to_string()),
resolution: Some(CommonResolution::SevenTwentyP),
..seedance2pro_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn res_1080p() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A fox walking through a snowy forest.".to_string()),
resolution: Some(CommonResolution::TenEightyP),
..seedance2pro_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
}
mod modality_tests {
use super::*;
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn text_to_video() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A whale breaching in the open ocean at dawn, cinematic.".to_string()),
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..seedance2pro_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn keyframe_start_and_end_frame() {
let start_token = MediaFileToken::new("mf_start".to_string());
let end_token = MediaFileToken::new("mf_end".to_string());
let mut media_map = HashMap::new();
media_map.insert(start_token.clone(), JUNO_AT_LAKE_IMAGE_URL.to_string());
media_map.insert(end_token.clone(), FOREST_BACKDROP_IMAGE_URL.to_string());
let response = run_pipeline_with_media_map(GenerateVideoRequestBuilder {
prompt: Some("The dog walks from the lake to the forest.".to_string()),
start_frame: Some(ImageRef::MediaFileToken(start_token)),
end_frame: Some(ImageRef::MediaFileToken(end_token)),
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..seedance2pro_builder()
}, media_map).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn image_references() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("The dog in @2 runs through the scenery in @1 towards the building in @3.".to_string()),
reference_images: Some(ImageListRef::Urls(vec![
FOREST_BACKDROP_IMAGE_URL.to_string(),
JUNO_AT_LAKE_IMAGE_URL.to_string(),
WHITE_HOUSE_SUNSET_IMAGE_URL.to_string(),
])),
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..seedance2pro_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn video_reference() {
// NB: This URL is a pre-uploaded video on the Seedance2Pro CDN from prior tests.
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("Change @video1 to a nighttime scene with moonlight.".to_string()),
reference_videos: Some(VideoListRef::Urls(vec![
"https://static.seedance2-pro.com/materials/20260315/1773594284659-3a46d231.mp4".to_string(),
])),
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..seedance2pro_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn character_references() {
let char_clown = CharacterToken::new("char_clown".to_string());
let char_mochi = CharacterToken::new("char_mochi".to_string());
let mut character_map = HashMap::new();
character_map.insert(char_clown.clone(), STEAMPUNK_CLOWN_KINOVI_ID.to_string());
character_map.insert(char_mochi.clone(), MOCHI_KINOVI_ID.to_string());
let response = run_pipeline_with_maps(
GenerateVideoRequestBuilder {
prompt: Some("@Steampunk Clown and @Mochi are playing fetch in a sunny park.".to_string()),
reference_character_tokens: Some(CharacterListRef::CharacterTokens(vec![
char_clown,
char_mochi,
])),
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..seedance2pro_builder()
},
HashMap::new(),
character_map,
).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
// TODO: Add audio reference test once a web-accessible audio URL is available in test_data.
}
// ── Helpers ──
fn seedance2pro_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
provider: Provider::Seedance2Pro,
duration_seconds: Some(4),
video_batch_count: Some(1),
..Default::default()
}
}
fn get_seedance2pro_client() -> RouterClient {
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/seedance2pro_cookies.txt")
.expect("Failed to read seedance2pro cookies");
let session = Seedance2ProSession::from_cookies_string(cookies.trim().to_string());
RouterClient::Seedance2Pro(RouterSeedance2ProClient::new(session))
}
/// Run the full pipeline: builder → build2 → finalize → send.
async fn run_pipeline(builder: GenerateVideoRequestBuilder) -> GenerateVideoResponse {
let client = get_seedance2pro_client();
let draft_or_request = builder.build2().expect("build2 should succeed");
let draft = match draft_or_request {
VideoGenerationDraftOrRequest::Draft(d) => d,
_ => panic!("expected Draft variant"),
};
let draft_context = VideoGenerationDraftContext {
client: Some(&client),
..Default::default()
};
let request = draft.finalize(draft_context).await.expect("finalize should succeed");
let response = request.send_request(&client).await.expect("send_request should succeed");
match &response {
GenerateVideoResponse::Seedance2Pro(p) => {
println!("task_id={}, order_id={}", p.task_id, p.order_id);
}
other => println!("response: {:?}", other),
}
response
}
/// Run the full pipeline with a media file token map for resolving references.
async fn run_pipeline_with_media_map(
builder: GenerateVideoRequestBuilder,
media_map: HashMap<MediaFileToken, String>,
) -> GenerateVideoResponse {
let client = get_seedance2pro_client();
let draft_or_request = builder.build2().expect("build2 should succeed");
let draft = match draft_or_request {
VideoGenerationDraftOrRequest::Draft(d) => d,
_ => panic!("expected Draft variant"),
};
let draft_context = VideoGenerationDraftContext {
client: Some(&client),
media_file_to_artcraft_url_map: Some(&media_map),
..Default::default()
};
let request = draft.finalize(draft_context).await.expect("finalize should succeed");
let response = request.send_request(&client).await.expect("send_request should succeed");
match &response {
GenerateVideoResponse::Seedance2Pro(p) => {
println!("task_id={}, order_id={}", p.task_id, p.order_id);
}
other => println!("response: {:?}", other),
}
response
}
/// Run the full pipeline with both a media map and character map.
async fn run_pipeline_with_maps(
builder: GenerateVideoRequestBuilder,
media_map: HashMap<MediaFileToken, String>,
character_map: HashMap<CharacterToken, String>,
) -> GenerateVideoResponse {
let client = get_seedance2pro_client();
let draft_or_request = builder.build2().expect("build2 should succeed");
let draft = match draft_or_request {
VideoGenerationDraftOrRequest::Draft(d) => d,
_ => panic!("expected Draft variant"),
};
let draft_context = VideoGenerationDraftContext {
client: Some(&client),
media_file_to_artcraft_url_map: Some(&media_map),
character_token_to_kinovi_id_map: Some(&character_map),
};
let request = draft.finalize(draft_context).await.expect("finalize should succeed");
let response = request.send_request(&client).await.expect("send_request should succeed");
match &response {
GenerateVideoResponse::Seedance2Pro(p) => {
println!("task_id={}, order_id={}", p.task_id, p.order_id);
}
other => println!("response: {:?}", other),
}
response
}
}
@@ -0,0 +1,608 @@
use seedance2pro_client::requests::generate_video::generate_video::{KinoviAspectRatio, KinoviBatchCount, KinoviOutputResolution};
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_resolution::CommonResolution;
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_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0_fast::draft::{KinoviSeedance2p0FastDraftState, KinoviSeedance2p0FastRemainingItems};
use crate::generate::generate_video_v2::video_generation_draft::VideoGenerationDraftRequest;
use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
pub fn build_kinovi_seedance_2p0_fast(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let draft = do_build_kinovi_seedance_2p0_fast(builder)?;
Ok(VideoGenerationDraftOrRequest::Draft(VideoGenerationDraftRequest::KinoviSeedance2p0Fast(draft)))
}
fn do_build_kinovi_seedance_2p0_fast(mut builder: GenerateVideoRequestBuilder) -> Result<KinoviSeedance2p0FastDraftState, ArtcraftRouterError> {
let strategy = builder.request_mismatch_mitigation_strategy;
let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio.take(), strategy)?;
let resolution = plan_output_resolution(builder.resolution.take(), strategy)?;
let batch_count = plan_batch_count(builder.video_batch_count.take(), strategy)?;
let duration_seconds = plan_duration(builder.duration_seconds.take(), strategy)?;
let prompt = builder.prompt.take().unwrap_or_default();
let unhandled_request_state = KinoviSeedance2p0FastRemainingItems {
start_frame: builder.start_frame.take(),
end_frame: builder.end_frame.take(),
reference_images: builder.reference_images.take(),
reference_videos: builder.reference_videos.take(),
reference_audio: builder.reference_audio.take(),
reference_character_tokens: builder.reference_character_tokens.take(),
};
Ok(KinoviSeedance2p0FastDraftState {
aspect_ratio,
resolution,
batch_count,
duration_seconds,
prompt,
unhandled_request_state: Some(unhandled_request_state),
})
}
// ── Plan helpers ──
// 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<KinoviAspectRatio, ArtcraftRouterError> {
match aspect_ratio {
// No preference or auto — default to landscape
None
| Some(CommonAspectRatio::Auto)
| Some(CommonAspectRatio::Auto2k)
| Some(CommonAspectRatio::Auto4k) => Ok(KinoviAspectRatio::Landscape16x9),
// Direct mappings
Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => {
Ok(KinoviAspectRatio::Landscape16x9)
}
Some(CommonAspectRatio::TallNineBySixteen) | Some(CommonAspectRatio::Tall) => {
Ok(KinoviAspectRatio::Portrait9x16)
}
Some(CommonAspectRatio::Square) | Some(CommonAspectRatio::SquareHd) => {
Ok(KinoviAspectRatio::Square1x1)
}
Some(CommonAspectRatio::WideFourByThree) => Ok(KinoviAspectRatio::Standard4x3),
Some(CommonAspectRatio::TallThreeByFour) => Ok(KinoviAspectRatio::Portrait3x4),
// Mismatches — apply strategy
Some(unsupported) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "aspect_ratio",
value: format!("{:?}", unsupported),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(nearest_aspect_ratio(unsupported))
}
},
}
}
/// Pick the nearest supported aspect ratio by AR value (width / height).
fn nearest_aspect_ratio(aspect_ratio: CommonAspectRatio) -> KinoviAspectRatio {
match aspect_ratio {
CommonAspectRatio::WideFiveByFour => KinoviAspectRatio::Standard4x3, // 1.25, nearest 1.33
CommonAspectRatio::WideThreeByTwo => KinoviAspectRatio::Standard4x3, // 1.50, nearest 1.33
CommonAspectRatio::WideTwentyOneByNine => KinoviAspectRatio::Landscape16x9, // 2.33, nearest 1.78
CommonAspectRatio::TallFourByFive => KinoviAspectRatio::Portrait3x4, // 0.80, nearest 0.75
CommonAspectRatio::TallTwoByThree => KinoviAspectRatio::Portrait3x4, // 0.67, nearest 0.75
CommonAspectRatio::TallNineByTwentyOne => KinoviAspectRatio::Portrait9x16, // 0.43, nearest 0.56
_ => KinoviAspectRatio::Square1x1,
}
}
// Seedance 2.0 Fast supports output resolutions: 480p and 720p only.
// 1080p is NOT supported — downgrade to 720p or error based on strategy.
fn plan_output_resolution(
resolution: Option<CommonResolution>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<KinoviOutputResolution>, ArtcraftRouterError> {
match resolution {
None => Ok(None),
// Direct mappings
Some(CommonResolution::FourEightyP) => Ok(Some(KinoviOutputResolution::FourEightyP)),
Some(CommonResolution::SevenTwentyP) => Ok(Some(KinoviOutputResolution::SevenTwentyP)),
// 1080p is not supported for Fast — handle via strategy
Some(CommonResolution::TenEightyP) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "resolution",
value: format!("{:?}", CommonResolution::TenEightyP),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
// 1080p not available — downgrade to 720p (highest supported)
Ok(Some(KinoviOutputResolution::SevenTwentyP))
}
},
// Other unsupported resolutions
Some(unsupported) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "resolution",
value: format!("{:?}", unsupported),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => {
Ok(Some(match unsupported {
CommonResolution::HalfK => KinoviOutputResolution::FourEightyP,
_ => KinoviOutputResolution::SevenTwentyP,
}))
}
RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(Some(match unsupported {
CommonResolution::HalfK => KinoviOutputResolution::FourEightyP,
_ => KinoviOutputResolution::SevenTwentyP,
}))
}
},
}
}
// Seedance2p0Fast supports batch counts of 1, 2, and 4 only.
fn plan_batch_count(
video_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<KinoviBatchCount, ArtcraftRouterError> {
let count = video_batch_count.unwrap_or(1);
match count {
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 => Ok(KinoviBatchCount::One),
2 => Ok(KinoviBatchCount::Two),
4 => Ok(KinoviBatchCount::Four),
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "video_batch_count",
value: format!("{}", count),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => {
Ok(if count < 4 { KinoviBatchCount::Four } else { KinoviBatchCount::Four })
}
RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(if count < 4 { KinoviBatchCount::Two } else { KinoviBatchCount::Four })
}
},
}
}
// Seedance2p0Fast supports duration of 4–15 seconds.
fn plan_duration(
duration_seconds: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<u8, ArtcraftRouterError> {
const MIN: u16 = 4;
const MAX: u16 = 15;
const DEFAULT: u8 = 5;
match duration_seconds {
None => Ok(DEFAULT),
Some(d) if d >= MIN && d <= MAX => Ok(d as u8),
Some(d) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "duration_seconds",
value: format!("{}", d),
}))
}
_ => Ok(d.clamp(MIN, MAX) as u8),
},
}
}
#[cfg(test)]
mod tests {
use seedance2pro_client::requests::generate_video::generate_video::{KinoviAspectRatio, KinoviBatchCount, KinoviOutputResolution};
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::audio_list_ref::AudioListRef;
use crate::api::character_list_ref::CharacterListRef;
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_resolution::CommonResolution;
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::api::video_list_ref::VideoListRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0_fast::draft::KinoviSeedance2p0FastDraftState;
use crate::generate::generate_video_v2::video_generation_draft::VideoGenerationDraftRequest;
use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use super::*;
// ── Materialized field conversions ──
mod materialized_field_conversions {
use super::*;
#[test]
fn prompt_is_passed_through() {
let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(seedance2pro_fast_builder()));
assert_eq!(draft.prompt, "a cat dancing");
}
#[test]
fn prompt_defaults_to_empty() {
let builder = GenerateVideoRequestBuilder { prompt: None, ..seedance2pro_fast_builder() };
let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder));
assert_eq!(draft.prompt, "");
}
#[test]
fn duration_seconds_converted() {
let builder = GenerateVideoRequestBuilder { duration_seconds: Some(10), ..seedance2pro_fast_builder() };
let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder));
assert_eq!(draft.duration_seconds, 10);
}
#[test]
fn duration_defaults_to_5() {
let builder = GenerateVideoRequestBuilder { duration_seconds: None, ..seedance2pro_fast_builder() };
let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder));
assert_eq!(draft.duration_seconds, 5);
}
#[test]
fn duration_clamped_to_max() {
let builder = GenerateVideoRequestBuilder {
duration_seconds: Some(99),
..seedance2pro_fast_builder()
};
let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder));
assert_eq!(draft.duration_seconds, 15);
}
#[test]
fn batch_count_one() {
let builder = GenerateVideoRequestBuilder { video_batch_count: Some(1), ..seedance2pro_fast_builder() };
let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder));
assert!(matches!(draft.batch_count, KinoviBatchCount::One));
}
#[test]
fn batch_count_two() {
let builder = GenerateVideoRequestBuilder { video_batch_count: Some(2), ..seedance2pro_fast_builder() };
let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder));
assert!(matches!(draft.batch_count, KinoviBatchCount::Two));
}
#[test]
fn batch_count_four() {
let builder = GenerateVideoRequestBuilder { video_batch_count: Some(4), ..seedance2pro_fast_builder() };
let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder));
assert!(matches!(draft.batch_count, KinoviBatchCount::Four));
}
}
// ── Aspect ratio conversions ──
mod aspect_ratio_conversions {
use super::*;
#[test]
fn aspect_ratio_wide() {
let builder = GenerateVideoRequestBuilder {
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..seedance2pro_fast_builder()
};
let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder));
assert!(matches!(draft.aspect_ratio, KinoviAspectRatio::Landscape16x9));
}
#[test]
fn aspect_ratio_tall() {
let builder = GenerateVideoRequestBuilder {
aspect_ratio: Some(CommonAspectRatio::TallNineBySixteen),
..seedance2pro_fast_builder()
};
let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder));
assert!(matches!(draft.aspect_ratio, KinoviAspectRatio::Portrait9x16));
}
#[test]
fn aspect_ratio_square() {
let builder = GenerateVideoRequestBuilder {
aspect_ratio: Some(CommonAspectRatio::Square),
..seedance2pro_fast_builder()
};
let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder));
assert!(matches!(draft.aspect_ratio, KinoviAspectRatio::Square1x1));
}
#[test]
fn aspect_ratio_defaults_to_landscape() {
let builder = GenerateVideoRequestBuilder { aspect_ratio: None, ..seedance2pro_fast_builder() };
let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder));
assert!(matches!(draft.aspect_ratio, KinoviAspectRatio::Landscape16x9));
}
}
// ── Resolution conversions ──
mod resolution_conversions {
use super::*;
#[test]
fn resolution_480p() {
let builder = GenerateVideoRequestBuilder {
resolution: Some(CommonResolution::FourEightyP),
..seedance2pro_fast_builder()
};
let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder));
assert!(matches!(draft.resolution, Some(KinoviOutputResolution::FourEightyP)));
}
#[test]
fn resolution_720p() {
let builder = GenerateVideoRequestBuilder {
resolution: Some(CommonResolution::SevenTwentyP),
..seedance2pro_fast_builder()
};
let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder));
assert!(matches!(draft.resolution, Some(KinoviOutputResolution::SevenTwentyP)));
}
#[test]
fn resolution_1080p_downgrades_to_720p() {
let builder = GenerateVideoRequestBuilder {
resolution: Some(CommonResolution::TenEightyP),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayLessDowngrade,
..seedance2pro_fast_builder()
};
let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder));
assert!(matches!(draft.resolution, Some(KinoviOutputResolution::SevenTwentyP)));
}
#[test]
fn resolution_1080p_upgrade_also_downgrades_to_720p() {
let builder = GenerateVideoRequestBuilder {
resolution: Some(CommonResolution::TenEightyP),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
..seedance2pro_fast_builder()
};
let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder));
assert!(matches!(draft.resolution, Some(KinoviOutputResolution::SevenTwentyP)));
}
#[test]
fn resolution_1080p_error_out() {
let builder = GenerateVideoRequestBuilder {
resolution: Some(CommonResolution::TenEightyP),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..seedance2pro_fast_builder()
};
assert!(build_kinovi_seedance_2p0_fast(builder).is_err());
}
#[test]
fn resolution_none() {
let builder = GenerateVideoRequestBuilder { resolution: None, ..seedance2pro_fast_builder() };
let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder));
assert!(draft.resolution.is_none());
}
#[test]
fn unsupported_resolution_error_out() {
let builder = GenerateVideoRequestBuilder {
resolution: Some(CommonResolution::FourK),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..seedance2pro_fast_builder()
};
assert!(build_kinovi_seedance_2p0_fast(builder).is_err());
}
#[test]
fn unsupported_resolution_rounds_to_720p() {
let builder = GenerateVideoRequestBuilder {
resolution: Some(CommonResolution::FourK),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
..seedance2pro_fast_builder()
};
let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder));
assert!(matches!(draft.resolution, Some(KinoviOutputResolution::SevenTwentyP)));
}
}
// ── unhandled_request_state: media refs are placed there ──
mod unhandled_request_state {
use super::*;
#[test]
fn unhandled_state_is_present() {
let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(seedance2pro_fast_builder()));
assert!(draft.unhandled_request_state.is_some());
}
#[test]
fn start_frame_placed_in_unhandled() {
let builder = GenerateVideoRequestBuilder {
start_frame: Some(ImageRef::Url("https://example.com/start.jpg".to_string())),
..seedance2pro_fast_builder()
};
let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder));
let remaining = draft.unhandled_request_state.unwrap();
assert!(matches!(remaining.start_frame, Some(ImageRef::Url(url)) if url == "https://example.com/start.jpg"));
}
#[test]
fn end_frame_placed_in_unhandled() {
let builder = GenerateVideoRequestBuilder {
end_frame: Some(ImageRef::Url("https://example.com/end.jpg".to_string())),
..seedance2pro_fast_builder()
};
let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder));
let remaining = draft.unhandled_request_state.unwrap();
assert!(matches!(remaining.end_frame, Some(ImageRef::Url(url)) if url == "https://example.com/end.jpg"));
}
#[test]
fn start_frame_media_token_placed_in_unhandled() {
let builder = GenerateVideoRequestBuilder {
start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_test123".to_string()))),
..seedance2pro_fast_builder()
};
let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder));
let remaining = draft.unhandled_request_state.unwrap();
assert!(matches!(remaining.start_frame, Some(ImageRef::MediaFileToken(t)) if t.as_str() == "mf_test123"));
}
#[test]
fn reference_images_placed_in_unhandled() {
let builder = GenerateVideoRequestBuilder {
reference_images: Some(ImageListRef::Urls(vec![
"https://example.com/ref1.jpg".to_string(),
"https://example.com/ref2.jpg".to_string(),
])),
..seedance2pro_fast_builder()
};
let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder));
let remaining = draft.unhandled_request_state.unwrap();
match remaining.reference_images {
Some(ImageListRef::Urls(urls)) => {
assert_eq!(urls.len(), 2);
assert_eq!(urls[0], "https://example.com/ref1.jpg");
assert_eq!(urls[1], "https://example.com/ref2.jpg");
}
_ => panic!("expected Urls variant"),
}
}
#[test]
fn reference_videos_placed_in_unhandled() {
let builder = GenerateVideoRequestBuilder {
reference_videos: Some(VideoListRef::Urls(vec![
"https://example.com/vid.mp4".to_string(),
])),
..seedance2pro_fast_builder()
};
let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder));
let remaining = draft.unhandled_request_state.unwrap();
assert!(matches!(remaining.reference_videos, Some(VideoListRef::Urls(urls)) if urls.len() == 1));
}
#[test]
fn reference_audio_placed_in_unhandled() {
let builder = GenerateVideoRequestBuilder {
reference_audio: Some(AudioListRef::Urls(vec![
"https://example.com/audio.mp3".to_string(),
])),
..seedance2pro_fast_builder()
};
let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder));
let remaining = draft.unhandled_request_state.unwrap();
assert!(matches!(remaining.reference_audio, Some(AudioListRef::Urls(urls)) if urls.len() == 1));
}
#[test]
fn character_tokens_placed_in_unhandled() {
let builder = GenerateVideoRequestBuilder {
reference_character_tokens: Some(CharacterListRef::CharacterTokens(vec![
CharacterToken::new("char_abc".to_string()),
CharacterToken::new("char_def".to_string()),
])),
..seedance2pro_fast_builder()
};
let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder));
let remaining = draft.unhandled_request_state.unwrap();
match remaining.reference_character_tokens {
Some(CharacterListRef::CharacterTokens(tokens)) => {
assert_eq!(tokens.len(), 2);
assert_eq!(tokens[0].as_str(), "char_abc");
assert_eq!(tokens[1].as_str(), "char_def");
}
_ => panic!("expected CharacterTokens variant"),
}
}
#[test]
fn empty_refs_are_none_in_unhandled() {
let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(seedance2pro_fast_builder()));
let remaining = draft.unhandled_request_state.unwrap();
assert!(remaining.start_frame.is_none());
assert!(remaining.end_frame.is_none());
assert!(remaining.reference_images.is_none());
assert!(remaining.reference_videos.is_none());
assert!(remaining.reference_audio.is_none());
assert!(remaining.reference_character_tokens.is_none());
}
}
// ── Full combination ──
#[test]
fn full_request_all_fields() {
let builder = GenerateVideoRequestBuilder {
prompt: Some("full test".to_string()),
aspect_ratio: Some(CommonAspectRatio::TallNineBySixteen),
resolution: Some(CommonResolution::SevenTwentyP),
duration_seconds: Some(10),
video_batch_count: Some(4),
start_frame: Some(ImageRef::Url("https://example.com/start.jpg".to_string())),
end_frame: Some(ImageRef::Url("https://example.com/end.jpg".to_string())),
reference_images: Some(ImageListRef::Urls(vec!["https://example.com/ref.jpg".to_string()])),
reference_videos: Some(VideoListRef::Urls(vec!["https://example.com/vid.mp4".to_string()])),
reference_audio: Some(AudioListRef::Urls(vec!["https://example.com/audio.mp3".to_string()])),
reference_character_tokens: Some(CharacterListRef::CharacterTokens(vec![
CharacterToken::new("char_xyz".to_string()),
])),
..seedance2pro_fast_builder()
};
let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder));
assert_eq!(draft.prompt, "full test");
assert!(matches!(draft.aspect_ratio, KinoviAspectRatio::Portrait9x16));
assert!(matches!(draft.resolution, Some(KinoviOutputResolution::SevenTwentyP)));
assert_eq!(draft.duration_seconds, 10);
assert!(matches!(draft.batch_count, KinoviBatchCount::Four));
let remaining = draft.unhandled_request_state.unwrap();
assert!(remaining.start_frame.is_some());
assert!(remaining.end_frame.is_some());
assert!(remaining.reference_images.is_some());
assert!(remaining.reference_videos.is_some());
assert!(remaining.reference_audio.is_some());
assert!(remaining.reference_character_tokens.is_some());
}
// ── Helpers ──
fn seedance2pro_fast_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: CommonVideoModel::Seedance2p0Fast,
provider: Provider::Seedance2Pro,
prompt: Some("a cat dancing".to_string()),
duration_seconds: Some(5),
video_batch_count: Some(1),
..Default::default()
}
}
fn unwrap_draft(result: Result<VideoGenerationDraftOrRequest, ArtcraftRouterError>) -> KinoviSeedance2p0FastDraftState {
match result.expect("build should succeed") {
VideoGenerationDraftOrRequest::Draft(
VideoGenerationDraftRequest::KinoviSeedance2p0Fast(draft)
) => draft,
_ => panic!("expected KinoviSeedance2p0Fast draft"),
}
}
}
@@ -0,0 +1,415 @@
use seedance2pro_client::requests::generate_video::generate_video::{KinoviAspectRatio, KinoviBatchCount, KinoviGenerateVideoRequest, KinoviModelType, KinoviOutputResolution};
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0_fast::draft::KinoviSeedance2p0FastDraftState;
use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0_fast::request::KinoviSeedance2p0FastRequestState;
pub struct KinoviSeedance2p0FastCostState {
pub resolution: KinoviOutputResolution,
pub duration_seconds: u8,
pub batch_count: KinoviBatchCount,
/// Forward-looking: Kinovi charges for this. We need to take it into
/// account in the future.
pub has_video_reference: bool,
}
impl KinoviSeedance2p0FastCostState {
pub fn from_request(request: &KinoviSeedance2p0FastRequestState) -> Self {
Self {
resolution: request.request.output_resolution.unwrap_or(KinoviOutputResolution::SevenTwentyP),
duration_seconds: request.request.duration_seconds,
batch_count: request.request.batch_count,
has_video_reference: request.request.reference_video_urls
.as_ref()
.is_some_and(|urls| !urls.is_empty()),
}
}
pub fn from_draft(draft: &KinoviSeedance2p0FastDraftState) -> Self {
let has_video_reference = draft.unhandled_request_state
.as_ref()
.and_then(|rem| rem.reference_videos.as_ref())
.is_some();
Self {
resolution: draft.resolution.unwrap_or(KinoviOutputResolution::SevenTwentyP),
duration_seconds: draft.duration_seconds,
batch_count: draft.batch_count,
has_video_reference,
}
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
let request = KinoviGenerateVideoRequest {
// Pricing factors
model_type: KinoviModelType::Seedance2Fast,
output_resolution: Some(self.resolution),
duration_seconds: self.duration_seconds,
batch_count: self.batch_count,
// NB: has_video_reference is tracked but does not affect price yet.
// When Kinovi starts charging for video references, add cost here.
// No impact on price
prompt: String::new(),
aspect_ratio: KinoviAspectRatio::Portrait9x16,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
reference_video_urls: None,
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
};
let cost_in_credits = request.estimate_credits();
let cost_in_usd_cents = request.estimate_cost_in_usd_cents();
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_credits as u64),
cost_in_usd_cents: Some(cost_in_usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::common_resolution::CommonResolution;
use crate::api::common_video_model::CommonVideoModel;
use crate::api::provider::Provider;
use crate::api::video_list_ref::VideoListRef;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video_v2::video_generation_draft::VideoGenerationDraftRequest;
use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
// ── Direct estimate_cost() tests ──
// These must match the reference implementation values exactly.
// -- 720p (legacy pricing: 220 credits/$1, 28 credits/sec) --
mod pricing_720p {
use super::*;
#[test]
fn cost_720p_batch_1() {
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::One), 51);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One), 64);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 10, KinoviBatchCount::One), 127);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One), 191);
}
#[test]
fn cost_720p_batch_2() {
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Two), 127);
}
#[test]
fn cost_720p_batch_4() {
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Four), 255);
}
}
// -- 480p (new pricing: 193 credits/$1, 10 credits/sec) --
mod pricing_480p {
use super::*;
#[test]
fn cost_480p_batch_1() {
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 4, KinoviBatchCount::One), 21);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One), 26);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 10, KinoviBatchCount::One), 52);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 15, KinoviBatchCount::One), 78);
}
#[test]
fn cost_480p_batch_2() {
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Two), 52);
}
#[test]
fn cost_480p_batch_4() {
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Four), 104);
}
}
// -- Relative pricing --
mod relative_pricing_tests {
use super::*;
#[test]
fn cost_480p_cheaper_than_720p() {
let c480 = usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One);
let c720 = usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One);
assert!(c480 < c720, "480p ({}) should be cheaper than 720p ({})", c480, c720);
}
#[test]
fn cost_scales_with_duration() {
let c4 = usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::One);
let c10 = usd_cents(KinoviOutputResolution::SevenTwentyP, 10, KinoviBatchCount::One);
let c15 = usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One);
assert!(c4 < c10);
assert!(c10 < c15);
}
#[test]
fn cost_scales_with_batch() {
let b1 = usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One);
let b2 = usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Two);
let b4 = usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Four);
assert!(b1 < b2);
assert!(b2 < b4);
}
}
// -- Video reference does NOT affect cost (yet) --
#[test]
fn video_reference_does_not_affect_cost() {
let base = KinoviSeedance2p0FastCostState {
resolution: KinoviOutputResolution::SevenTwentyP,
duration_seconds: 5,
batch_count: KinoviBatchCount::One,
has_video_reference: false,
};
let without = base.estimate_cost();
let with = KinoviSeedance2p0FastCostState { has_video_reference: true, ..base }.estimate_cost();
assert_eq!(without.cost_in_usd_cents, with.cost_in_usd_cents);
assert_eq!(without.cost_in_credits, with.cost_in_credits);
}
// ── from_request() tests ──
mod from_request_tests {
use super::*;
#[test]
fn from_request_720p() {
let req = make_request_state(Some(KinoviOutputResolution::SevenTwentyP), 5, KinoviBatchCount::One, false);
let cost = KinoviSeedance2p0FastCostState::from_request(&req);
assert!(matches!(cost.resolution, KinoviOutputResolution::SevenTwentyP));
assert_eq!(cost.duration_seconds, 5);
assert!(matches!(cost.batch_count, KinoviBatchCount::One));
assert!(!cost.has_video_reference);
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(64));
}
#[test]
fn from_request_none_defaults_to_720p() {
let req = make_request_state(None, 5, KinoviBatchCount::One, false);
let cost = KinoviSeedance2p0FastCostState::from_request(&req);
assert!(matches!(cost.resolution, KinoviOutputResolution::SevenTwentyP));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(64));
}
#[test]
fn from_request_480p() {
let req = make_request_state(Some(KinoviOutputResolution::FourEightyP), 5, KinoviBatchCount::One, false);
let cost = KinoviSeedance2p0FastCostState::from_request(&req);
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(26));
}
#[test]
fn from_request_with_video_reference() {
let req = make_request_state(Some(KinoviOutputResolution::SevenTwentyP), 5, KinoviBatchCount::One, true);
let cost = KinoviSeedance2p0FastCostState::from_request(&req);
assert!(cost.has_video_reference);
// Video refs don't affect cost yet
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(64));
}
#[test]
fn from_request_without_video_reference() {
let req = make_request_state(Some(KinoviOutputResolution::SevenTwentyP), 5, KinoviBatchCount::One, false);
let cost = KinoviSeedance2p0FastCostState::from_request(&req);
assert!(!cost.has_video_reference);
}
}
// ── from_draft() tests ──
mod from_draft_tests {
use super::*;
#[test]
fn from_draft_720p_default() {
let draft = make_draft(5, 1, None, false);
let cost = KinoviSeedance2p0FastCostState::from_draft(&draft);
assert!(matches!(cost.resolution, KinoviOutputResolution::SevenTwentyP));
assert_eq!(cost.duration_seconds, 5);
assert!(matches!(cost.batch_count, KinoviBatchCount::One));
assert!(!cost.has_video_reference);
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(64));
}
#[test]
fn from_draft_480p() {
let draft = make_draft(5, 1, Some(CommonResolution::FourEightyP), false);
let cost = KinoviSeedance2p0FastCostState::from_draft(&draft);
assert!(matches!(cost.resolution, KinoviOutputResolution::FourEightyP));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(26));
}
#[test]
fn from_draft_with_video_reference() {
let draft = make_draft(5, 1, None, true);
let cost = KinoviSeedance2p0FastCostState::from_draft(&draft);
assert!(cost.has_video_reference);
// Video refs don't affect cost yet
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(64));
}
#[test]
fn from_draft_without_video_reference() {
let draft = make_draft(5, 1, None, false);
let cost = KinoviSeedance2p0FastCostState::from_draft(&draft);
assert!(!cost.has_video_reference);
}
#[test]
fn from_draft_duration_15_batch_2() {
let draft = make_draft(15, 2, Some(CommonResolution::SevenTwentyP), false);
let cost = KinoviSeedance2p0FastCostState::from_draft(&draft);
assert_eq!(cost.duration_seconds, 15);
assert!(matches!(cost.batch_count, KinoviBatchCount::Two));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(382));
}
}
// ── Cross-check: from_draft matches from_request ──
mod cross_check_tests {
use super::*;
#[test]
fn draft_and_request_produce_same_cost() {
// 720p, 5s, batch 1
let draft = make_draft(5, 1, Some(CommonResolution::SevenTwentyP), false);
let req = make_request_state(Some(KinoviOutputResolution::SevenTwentyP), 5, KinoviBatchCount::One, false);
let draft_cost = KinoviSeedance2p0FastCostState::from_draft(&draft).estimate_cost();
let req_cost = KinoviSeedance2p0FastCostState::from_request(&req).estimate_cost();
assert_eq!(draft_cost.cost_in_usd_cents, req_cost.cost_in_usd_cents);
assert_eq!(draft_cost.cost_in_credits, req_cost.cost_in_credits);
}
#[test]
fn draft_and_request_produce_same_cost_480p() {
let draft = make_draft(10, 2, Some(CommonResolution::FourEightyP), false);
let req = make_request_state(Some(KinoviOutputResolution::FourEightyP), 10, KinoviBatchCount::Two, false);
let draft_cost = KinoviSeedance2p0FastCostState::from_draft(&draft).estimate_cost();
let req_cost = KinoviSeedance2p0FastCostState::from_request(&req).estimate_cost();
assert_eq!(draft_cost.cost_in_usd_cents, req_cost.cost_in_usd_cents);
assert_eq!(draft_cost.cost_in_credits, req_cost.cost_in_credits);
}
}
// ── Credits spot checks ──
mod credits_tests {
use super::*;
#[test]
fn credits_720p() {
assert_eq!(credits(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One), 140);
}
#[test]
fn credits_480p() {
assert_eq!(credits(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One), 50);
}
}
// ── Helpers ──
fn usd_cents(
resolution: KinoviOutputResolution,
duration_seconds: u8,
batch_count: KinoviBatchCount,
) -> u64 {
KinoviSeedance2p0FastCostState { resolution, duration_seconds, batch_count, has_video_reference: false }
.estimate_cost()
.cost_in_usd_cents
.unwrap()
}
fn credits(
resolution: KinoviOutputResolution,
duration_seconds: u8,
batch_count: KinoviBatchCount,
) -> u64 {
KinoviSeedance2p0FastCostState { resolution, duration_seconds, batch_count, has_video_reference: false }
.estimate_cost()
.cost_in_credits
.unwrap()
}
/// Build a draft via the builder to test from_draft().
fn make_draft(
duration_seconds: u16,
video_batch_count: u16,
resolution: Option<CommonResolution>,
with_video_ref: bool,
) -> KinoviSeedance2p0FastDraftState {
let reference_videos = if with_video_ref {
Some(VideoListRef::Urls(vec!["https://example.com/video.mp4".to_string()]))
} else {
None
};
let builder = GenerateVideoRequestBuilder {
model: CommonVideoModel::Seedance2p0Fast,
provider: Provider::Seedance2Pro,
resolution,
reference_videos,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(video_batch_count),
..Default::default()
};
match builder.build2().expect("build2 should succeed") {
VideoGenerationDraftOrRequest::Draft(
VideoGenerationDraftRequest::KinoviSeedance2p0Fast(draft)
) => draft,
_ => panic!("expected KinoviSeedance2p0Fast draft"),
}
}
/// Build a request state for from_request() tests.
fn make_request_state(
resolution: Option<KinoviOutputResolution>,
duration_seconds: u8,
batch_count: KinoviBatchCount,
with_video_ref: bool,
) -> KinoviSeedance2p0FastRequestState {
let reference_video_urls = if with_video_ref {
Some(vec!["https://cdn.seedance2-pro.com/video.mp4".to_string()])
} else {
None
};
KinoviSeedance2p0FastRequestState {
request: KinoviGenerateVideoRequest {
model_type: KinoviModelType::Seedance2Fast,
prompt: "test".to_string(),
aspect_ratio: KinoviAspectRatio::Landscape16x9,
output_resolution: resolution,
duration_seconds,
batch_count,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
reference_video_urls,
reference_audio_urls: None,
character_ids: None,
use_face_blur_hack: None,
},
}
}
}
@@ -0,0 +1,98 @@
use seedance2pro_client::requests::generate_video::generate_video::{KinoviAspectRatio, KinoviBatchCount, KinoviGenerateVideoRequest, KinoviModelType, KinoviOutputResolution};
use crate::api::audio_list_ref::AudioListRef;
use crate::api::character_list_ref::CharacterListRef;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::video_list_ref::VideoListRef;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video_v2::providers::kinovi::resolve::{
audio_list_ref_into_urls_or_tokens, image_list_ref_into_urls_or_tokens,
resolve_and_upload_list, resolve_and_upload_single, resolve_character_tokens,
video_list_ref_into_urls_or_tokens,
};
use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0_fast::request::KinoviSeedance2p0FastRequestState;
use crate::generate::generate_video_v2::video_generation_draft_context::VideoGenerationDraftContext;
#[derive(Debug, Clone)]
pub struct KinoviSeedance2p0FastDraftState {
// Materialized / finalized types
pub prompt: String,
pub aspect_ratio: KinoviAspectRatio,
pub resolution: Option<KinoviOutputResolution>,
pub duration_seconds: u8,
pub batch_count: KinoviBatchCount,
// Pending types that need to be queried.
pub unhandled_request_state: Option<KinoviSeedance2p0FastRemainingItems>,
}
#[derive(Debug, Clone)]
pub struct KinoviSeedance2p0FastRemainingItems {
pub start_frame: Option<ImageRef>,
pub end_frame: Option<ImageRef>,
pub reference_images: Option<ImageListRef>,
pub reference_videos: Option<VideoListRef>,
pub reference_audio: Option<AudioListRef>,
pub reference_character_tokens: Option<CharacterListRef>,
}
impl KinoviSeedance2p0FastDraftState {
pub async fn to_request(
&mut self,
draft_context: &VideoGenerationDraftContext<'_>,
) -> Result<KinoviSeedance2p0FastRequestState, ArtcraftRouterError> {
let client = draft_context.get_seedance2pro_client_ref()?;
let session = &client.session;
let mut start_frame_url = None;
let mut end_frame_url = None;
let mut reference_image_urls = None;
let mut reference_video_urls = None;
let mut reference_audio_urls = None;
let mut character_ids = None;
if let Some(remaining) = self.unhandled_request_state.take() {
let map = draft_context.media_file_to_artcraft_url_map;
start_frame_url = resolve_and_upload_single(session, remaining.start_frame, map).await?;
end_frame_url = resolve_and_upload_single(session, remaining.end_frame, map).await?;
reference_image_urls = resolve_and_upload_list(
session, remaining.reference_images.map(image_list_ref_into_urls_or_tokens), map,
).await?;
reference_video_urls = resolve_and_upload_list(
session, remaining.reference_videos.map(video_list_ref_into_urls_or_tokens), map,
).await?;
reference_audio_urls = resolve_and_upload_list(
session, remaining.reference_audio.map(audio_list_ref_into_urls_or_tokens), map,
).await?;
character_ids = resolve_character_tokens(
remaining.reference_character_tokens.as_ref(),
draft_context,
)?;
}
let request = KinoviGenerateVideoRequest {
model_type: KinoviModelType::Seedance2Fast,
prompt: self.prompt.clone(),
aspect_ratio: self.aspect_ratio,
output_resolution: self.resolution,
duration_seconds: self.duration_seconds,
batch_count: self.batch_count,
start_frame_url,
end_frame_url,
reference_image_urls,
reference_video_urls,
reference_audio_urls,
character_ids,
use_face_blur_hack: None,
};
Ok(KinoviSeedance2p0FastRequestState { request })
}
}
@@ -0,0 +1,4 @@
pub mod build;
pub mod draft;
pub mod request;
pub mod cost;
@@ -0,0 +1,258 @@
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_video::generate_video_response::{GenerateVideoResponse, Seedance2proVideoResponsePayload};
use seedance2pro_client::requests::generate_video::generate_video::{generate_video, GenerateVideoArgs, KinoviGenerateVideoRequest};
#[derive(Debug, Clone)]
pub struct KinoviSeedance2p0FastRequestState {
/// Final materialized request; ready to fire.
pub request: KinoviGenerateVideoRequest,
}
impl KinoviSeedance2p0FastRequestState {
pub async fn send(&self, client: &RouterSeedance2ProClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let session = &client.session;
let args = GenerateVideoArgs {
session,
host_override: None,
request: self.request.clone(), // TODO: Yuck.
};
let response = generate_video(args)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?;
Ok(GenerateVideoResponse::Seedance2Pro(Seedance2proVideoResponsePayload {
order_id: response.order_id,
task_id: response.task_id,
}))
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::character_list_ref::CharacterListRef;
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_resolution::CommonResolution;
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::api::video_list_ref::VideoListRef;
use crate::client::router_client::RouterClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
use crate::generate::generate_video_v2::video_generation_draft_context::VideoGenerationDraftContext;
use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use test_data::web::image_urls::{FOREST_BACKDROP_IMAGE_URL, JUNO_AT_LAKE_IMAGE_URL, WHITE_HOUSE_SUNSET_IMAGE_URL};
mod aspect_ratio_tests {
use super::*;
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn landscape() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A corgi running through a field of wildflowers at sunset.".to_string()),
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..seedance2pro_fast_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn portrait() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A cat sitting on a windowsill watching rain.".to_string()),
aspect_ratio: Some(CommonAspectRatio::TallNineBySixteen),
..seedance2pro_fast_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn square() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A hummingbird hovering near a flower.".to_string()),
aspect_ratio: Some(CommonAspectRatio::Square),
..seedance2pro_fast_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
}
mod resolution_tests {
use super::*;
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn res_480p() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A shiba inu playing in autumn leaves.".to_string()),
resolution: Some(CommonResolution::FourEightyP),
..seedance2pro_fast_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn res_720p() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A golden retriever catching a frisbee on the beach.".to_string()),
resolution: Some(CommonResolution::SevenTwentyP),
..seedance2pro_fast_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
}
mod modality_tests {
use super::*;
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn text_to_video() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A whale breaching in the open ocean at dawn, cinematic.".to_string()),
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..seedance2pro_fast_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn keyframe_start_and_end_frame() {
let start_token = MediaFileToken::new("mf_start".to_string());
let end_token = MediaFileToken::new("mf_end".to_string());
let mut media_map = HashMap::new();
media_map.insert(start_token.clone(), JUNO_AT_LAKE_IMAGE_URL.to_string());
media_map.insert(end_token.clone(), FOREST_BACKDROP_IMAGE_URL.to_string());
let response = run_pipeline_with_media_map(GenerateVideoRequestBuilder {
prompt: Some("The dog walks from the lake to the forest.".to_string()),
start_frame: Some(ImageRef::MediaFileToken(start_token)),
end_frame: Some(ImageRef::MediaFileToken(end_token)),
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..seedance2pro_fast_builder()
}, media_map).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn image_references() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("The dog in @2 runs through the scenery in @1 towards the building in @3.".to_string()),
reference_images: Some(ImageListRef::Urls(vec![
FOREST_BACKDROP_IMAGE_URL.to_string(),
JUNO_AT_LAKE_IMAGE_URL.to_string(),
WHITE_HOUSE_SUNSET_IMAGE_URL.to_string(),
])),
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
..seedance2pro_fast_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
}
// ── Helpers ──
fn seedance2pro_fast_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: CommonVideoModel::Seedance2p0Fast,
provider: Provider::Seedance2Pro,
duration_seconds: Some(4),
video_batch_count: Some(1),
..Default::default()
}
}
fn get_seedance2pro_client() -> RouterClient {
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/seedance2pro_cookies.txt")
.expect("Failed to read seedance2pro cookies");
let session = Seedance2ProSession::from_cookies_string(cookies.trim().to_string());
RouterClient::Seedance2Pro(RouterSeedance2ProClient::new(session))
}
/// Run the full pipeline: builder -> build2 -> finalize -> send.
async fn run_pipeline(builder: GenerateVideoRequestBuilder) -> GenerateVideoResponse {
let client = get_seedance2pro_client();
let draft_or_request = builder.build2().expect("build2 should succeed");
let draft = match draft_or_request {
VideoGenerationDraftOrRequest::Draft(d) => d,
_ => panic!("expected Draft variant"),
};
let draft_context = VideoGenerationDraftContext {
client: Some(&client),
..Default::default()
};
let request = draft.finalize(draft_context).await.expect("finalize should succeed");
let response = request.send_request(&client).await.expect("send_request should succeed");
match &response {
GenerateVideoResponse::Seedance2Pro(p) => {
println!("task_id={}, order_id={}", p.task_id, p.order_id);
}
other => println!("response: {:?}", other),
}
response
}
/// Run the full pipeline with a media file token map for resolving references.
async fn run_pipeline_with_media_map(
builder: GenerateVideoRequestBuilder,
media_map: HashMap<MediaFileToken, String>,
) -> GenerateVideoResponse {
let client = get_seedance2pro_client();
let draft_or_request = builder.build2().expect("build2 should succeed");
let draft = match draft_or_request {
VideoGenerationDraftOrRequest::Draft(d) => d,
_ => panic!("expected Draft variant"),
};
let draft_context = VideoGenerationDraftContext {
client: Some(&client),
media_file_to_artcraft_url_map: Some(&media_map),
..Default::default()
};
let request = draft.finalize(draft_context).await.expect("finalize should succeed");
let response = request.send_request(&client).await.expect("send_request should succeed");
match &response {
GenerateVideoResponse::Seedance2Pro(p) => {
println!("task_id={}, order_id={}", p.task_id, p.order_id);
}
other => println!("response: {:?}", other),
}
response
}
}
@@ -0,0 +1,38 @@
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::utils::download_file::download_file;
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use seedance2pro_client::requests::prepare_file_upload::prepare_file_upload::{prepare_file_upload, PrepareFileUploadArgs};
use seedance2pro_client::requests::upload_file::upload_file::{upload_file, UploadFileArgs};
use url_utils::extension::extract_extension_from_url::{extract_extension_from_url_str, ExtractExtensions};
/// Downloads a file from a source URL and re-uploads it to seedance2pro CDN.
pub(crate) async fn upload_to_seedance2pro(
session: &Seedance2ProSession,
source_url: &str,
) -> Result<String, ArtcraftRouterError> {
let extension = extract_extension_from_url_str(source_url, &ExtractExtensions::All)
.map(|ext| ext.without_period().to_string())
.unwrap_or_else(|| "jpg".to_string());
let file_bytes = download_file(source_url).await?;
let prepare_response = prepare_file_upload(PrepareFileUploadArgs {
session,
extension,
host_override: None,
})
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?;
let upload_response = upload_file(UploadFileArgs {
upload_url: prepare_response.upload_url,
file_bytes,
host_override: None,
})
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?;
Ok(upload_response.public_url)
}
@@ -0,0 +1,2 @@
pub mod artcraft;
pub mod kinovi;
@@ -0,0 +1,44 @@
use crate::client::router_client::RouterClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0::cost::KinoviSeedance2p0CostState;
use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0::draft::KinoviSeedance2p0DraftState;
use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0_fast::cost::KinoviSeedance2p0FastCostState;
use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0_fast::draft::KinoviSeedance2p0FastDraftState;
use crate::generate::generate_video_v2::video_generation_draft_context::VideoGenerationDraftContext;
use crate::generate::generate_video_v2::video_generation_request::VideoGenerationRequest;
/**
* Wrapper for all video generation draft requests.
*/
#[derive(Clone, Debug)]
pub enum VideoGenerationDraftRequest {
KinoviSeedance2p0(KinoviSeedance2p0DraftState),
KinoviSeedance2p0Fast(KinoviSeedance2p0FastDraftState),
}
impl VideoGenerationDraftRequest {
/// Return a cost estimate to fulfill the request.
pub fn estimate_cost(&self) -> Result<VideoGenerationCostEstimate, ArtcraftRouterError> {
match self {
VideoGenerationDraftRequest::KinoviSeedance2p0(draft) => Ok(KinoviSeedance2p0CostState::from_draft(draft).estimate_cost()),
VideoGenerationDraftRequest::KinoviSeedance2p0Fast(draft) => Ok(KinoviSeedance2p0FastCostState::from_draft(draft).estimate_cost()),
}
}
/// Finalize the draft request before generation
/// This may involve uploading media to the provider.
pub async fn finalize(self, draft_context: VideoGenerationDraftContext<'_>) -> Result<VideoGenerationRequest, ArtcraftRouterError> {
match self {
VideoGenerationDraftRequest::KinoviSeedance2p0(mut draft) => {
let result = draft.to_request(&draft_context).await?;
Ok(VideoGenerationRequest::KinoviSeedance2p0(result))
},
VideoGenerationDraftRequest::KinoviSeedance2p0Fast(mut draft) => {
let result = draft.to_request(&draft_context).await?;
Ok(VideoGenerationRequest::KinoviSeedance2p0Fast(result))
},
}
}
}
@@ -0,0 +1,39 @@
use crate::client::router_client::RouterClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use std::collections::HashMap;
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::media_files::MediaFileToken;
#[derive(Clone, Default)]
pub struct VideoGenerationDraftContext<'a> {
/// Optional: the router client, needed for providers that require authentication.
pub client: Option<&'a RouterClient>,
/// Optional context: a map of Media File Tokens to their ArtCraft URLs
/// Only needed if we have to fetch these assets and upload them to another provider.
pub media_file_to_artcraft_url_map: Option<&'a HashMap<MediaFileToken, String>>,
/// Optional context: a map of Character Tokens to their respective Kinovi IDs
/// Only necessary if using Kinovi characters
pub character_token_to_kinovi_id_map: Option<&'a HashMap<CharacterToken, String>>,
}
impl <'a> VideoGenerationDraftContext<'a> {
pub fn get_seedance2pro_client_ref(&self) -> Result<&RouterSeedance2ProClient, ArtcraftRouterError> {
let client = self.client.ok_or(ArtcraftRouterError::Client(ClientError::RouterClientNotProvided))?;
client.get_seedance2pro_client_ref()
.map_err(|err| ArtcraftRouterError::Client(err))
}
pub fn get_media_file_to_artcraft_url_map(&self) -> Result<&HashMap<MediaFileToken, String>, ArtcraftRouterError> {
self.media_file_to_artcraft_url_map
.ok_or_else(|| ArtcraftRouterError::Client(ClientError::MediaFileToUrlMapNotProvided))
}
pub fn get_character_token_to_kinovi_map(&self) -> Result<&HashMap<CharacterToken, String>, ArtcraftRouterError> {
self.character_token_to_kinovi_id_map
.ok_or_else(|| ArtcraftRouterError::Client(ClientError::CharacterTokenToKinoviCharacterIdNotProvided))
}
}
@@ -0,0 +1,19 @@
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video_v2::video_generation_draft::VideoGenerationDraftRequest;
use crate::generate::generate_video_v2::video_generation_request::VideoGenerationRequest;
pub enum VideoGenerationDraftOrRequest {
Draft(VideoGenerationDraftRequest),
Request(VideoGenerationRequest),
}
impl VideoGenerationDraftOrRequest {
pub fn estimate_cost(&self) -> Result<VideoGenerationCostEstimate, ArtcraftRouterError> {
match self {
VideoGenerationDraftOrRequest::Draft(draft) => draft.estimate_cost(),
VideoGenerationDraftOrRequest::Request(request) => request.estimate_cost(),
}
}
}

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