Compare commits

..
Author SHA1 Message Date
Tanish Manku 2b369cfae3 below-fold: fluid stage width + release slate strip
Stage width policy for everything under the poster: the content cap
is now a root token, --stage-max: clamp(1280px, 72vw, 1600px) —
sections genuinely widen on big monitors instead of donating
everything past 1280px to margin. All four cap sites (section shell,
hero bands, footer) consume the token; live-tunable via the new Stage
group (stage-width.tsx writes the root var on knob changes).

The announce strip becomes a release slate: "Open-source AI studio"
on the left; on the right a mono release stamp (New · 2026.09) and
model chips — Seedance 2.5, Nano Banana 2 — that deep-link into the
webapp surface where each model runs (+ more → the webapp). Data
lives in RELEASE_SLATE (landing-data.ts): one place to update per
release.

Claude-Session: https://claude.ai/code/session_013vwLpYJbWEqTJg9PGV6B6H
2026-09-14 19:58:19 -07:00
Tanish Manku d11a9dc65d hero: revert the slate/specimen experiment — the poster is settled
Scope correction: "under the wordmark" meant the announce strip
("Open-source AI studio") and everything below, not the hero poster's
own sales stack. The viewfinder slate, specimen headline, and proof
strip are removed and the hero returns to its settled form.

Kept from the detour: the pre-flight WebGL probe (real robustness
fix) and the 35-clip showcase pool.

Claude-Session: https://claude.ai/code/session_013vwLpYJbWEqTJg9PGV6B6H
2026-09-14 19:46:51 -07:00
Tanish Manku 26f1e29c23 hero: cut flank telemetry (filler on a product site); clip pool -> 35
The flank stat stacks are gone — live galaxy readouts expressed
nothing about ArtCraft to a visitor; the card spiral itself is the
expressive element, and this is a product site with a creative blend,
not a creative showcase. The telemetry channel shrinks to the scene
clock (still driving the slate timecode and doubling as the chrome's
liveness gate); the flank tunables and the galaxy's extra per-frame
publishes go with it.

Showcase pool: 17 -> 35 clips (001-035 all verified 200 on the CDN;
036 404s). At 51 cards that's ~1.5 cards per clip — most cards now
unique on screen.

Claude-Session: https://claude.ai/code/session_013vwLpYJbWEqTJg9PGV6B6H
2026-09-14 18:29:20 -07:00
Tanish Manku af55f7eb6f hero: pre-flight WebGL probe — context failure degrades silently
When WebGL context creation fails (exhausted drivers, blocklisted
GPU, webgl.disabled), THREE.WebGLRenderer throws inside r3f's async
init — an unhandled REJECTION that the render-phase CanvasBoundary
never catches, surfacing as a page error instead of the designed
static fallback.

Both r3f canvas gates (galaxy, comparator blocking scene) now consult
a shared cached probe (src/lib/webgl-support.ts) before mounting the
<Canvas>: no context available → the canvas never mounts, the page's
static layers stand, and the slate chrome already hides itself via
the dead-instrument rule. The probe releases its test context through
WEBGL_lose_context so it never eats a context slot.

Verified with Playwright Firefox: webgl.disabled=true → zero
canvases, zero page errors, copy/ruler fully functional; normal run →
both canvases mount, zero errors.

Claude-Session: https://claude.ai/code/session_013vwLpYJbWEqTJg9PGV6B6H
2026-09-14 18:18:25 -07:00
Tanish Manku 505c45ffdb hero: viewfinder slate — fluid specimen copy, live flank telemetry
The sales layer under the wordmark, redesigned against the
center-blob-with-scalable-margins failure mode: on wide stages the
composition now *uses* the width instead of donating it to margin.

- Fluid poster type: the headline/subline scale with the viewport
  (cqw-style clamp; knobs Headline max rem / vw slope) so their ratio
  to the ARTCRAFT wordmark is a designed constant at every width. The
  serif phrase breaks as a unit, giving a clean two-line lockup when
  the stage outgrows one line. Sizes ride CSS variables on a `contents`
  wrapper — RevealManager ends reveals with clearProps:"all", which
  wipes inline styles on the [data-reveal] elements themselves.

- Specimen life on the display letters (variable Archivo): an intro
  settle (letters arrive at the wordmark's poster extreme, wdth 125 /
  wght 700, and cascade down to the display setting on the copy beat;
  clock-driven so the tuner replay rewinds it) and a cursor field
  afterwards (gaussian width-bulge under the pointer — the galaxy's
  mouse-weight idea spoken in typography).

- Viewfinder slate chrome, the product's own metaphor (a viewport
  where shots are composed): corner framing brackets + a REC/SC/TC
  timecode line running on the galaxy's scene clock (lg+), and live
  telemetry chips at the far flanks (xl+) — cards riding, clips
  playing, FPS, spin θ, lens (boost) — real values published by the
  galaxy frame loop through src/lib/hero-telemetry.ts and written
  imperatively at a throttled rate. Chrome hides entirely while the
  galaxy isn't running (reduced motion, WebGL failure): a dead
  instrument gets no slate. Right edge clears the scroll ruler's rail.

- Proof line promoted to a full-width ruled strip at the poster's
  bottom edge (justify-between at md+) — the composition's horizontal
  anchor; extra right padding under an active ruler so it clears the
  section queue.

Everything is tunable under the new "Hero slate" group (documented in
TUNER.md). Verified via Playwright Firefox screenshots at 3360×1380
and 1440×860.

Claude-Session: https://claude.ai/code/session_013vwLpYJbWEqTJg9PGV6B6H
2026-09-14 18:06:43 -07:00
433 changed files with 2954 additions and 15921 deletions

No files matched your search

@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO media_files\nSET\n token = ?,\n\n media_class = ?,\n media_type = ?,\n maybe_project_type = ?,\n\n origin_category = ?,\n origin_product_category = ?,\n\n maybe_engine_category = ?,\n maybe_animation_type = ?,\n\n maybe_mime_type = ?,\n file_size_bytes = ?,\n\n checksum_sha2 = ?,\n\n maybe_prompt_token = ?,\n maybe_batch_token = ?,\n\n maybe_title = ?,\n maybe_duration_millis = ?,\n maybe_frame_width = ?,\n maybe_frame_height = ?,\n\n public_bucket_directory_hash = ?,\n maybe_public_bucket_prefix = ?,\n maybe_public_bucket_extension = ?,\n\n maybe_creator_user_token = ?,\n maybe_creator_anonymous_visitor_token = ?,\n creator_ip_address = ?,\n\n creator_set_visibility = ?,\n\n maybe_scene_source_media_file_token = ?,\n\n is_intermediate_system_file = ?,\n is_user_upload = ?,\n\n maybe_generation_provider = ?,\n\n maybe_creator_file_synthetic_id = ?,\n maybe_creator_category_synthetic_id = ?,\n\n maybe_origin_model_type = NULL,\n maybe_origin_model_token = NULL\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 30
},
"nullable": []
},
"hash": "026a3ab39a1ba09a891cebd840ab1c956e1bcedd07006ee34473bd4f1d0ff67a"
}
Generated
-4
View File
@@ -1348,7 +1348,6 @@ dependencies = [
"hostname 0.4.0",
"log",
"server_environment",
"tempfile",
"workspace-build-acceleration",
]
@@ -3240,7 +3239,6 @@ dependencies = [
"ffprobe",
"log",
"tempdir",
"tempfile",
"test_utils",
"workspace-build-acceleration",
]
@@ -7921,12 +7919,10 @@ dependencies = [
"easyenv",
"enums",
"errors",
"ffmpeg_utils",
"hashing",
"jobs_common",
"kinovi_web_client",
"log",
"mimetypes",
"mysql_queries",
"pager",
"rand 0.9.2",
@@ -6,7 +6,6 @@ use enums::common::generation::common_bitrate::CommonBitrate;
use enums::common::generation::common_quality::CommonQuality;
use enums::common::generation::common_resolution::CommonResolution;
use enums::common::generation::common_video_model::CommonVideoModel;
use enums::common::generation::common_video_output_format::CommonVideoOutputFormat;
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::media_files::MediaFileToken;
@@ -80,11 +79,6 @@ pub struct OmniApiVideoGenerateRequest {
/// Not all models support this; models that don't simply ignore it.
pub bitrate: Option<CommonBitrate>,
/// Output container for Seedance 2.5 (including Ultra). Other models ignore it.
/// Use "mp4" or "mov" (QuickTime). Omitted or null defaults to MP4.
#[serde(rename = "output_format", skip_serializing_if = "Option::is_none")]
pub maybe_output_format: Option<CommonVideoOutputFormat>,
/// The quality to use.
pub quality: Option<CommonQuality>,
@@ -6,7 +6,6 @@ use enums::common::generation::common_bitrate::CommonBitrate;
use enums::common::generation::common_quality::CommonQuality;
use enums::common::generation::common_resolution::CommonResolution;
use enums::common::generation::common_video_model::CommonVideoModel;
use enums::common::generation::common_video_output_format::CommonVideoOutputFormat;
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::media_files::MediaFileToken;
@@ -56,11 +55,6 @@ pub struct OmniGenVideoCostAndGenerateRequest {
/// Not all models support this; models that don't simply ignore it.
pub bitrate: Option<CommonBitrate>,
/// Output container for Seedance 2.5 (including Ultra). Other models ignore it.
/// Use "mp4" or "mov" (QuickTime). Omitted or null defaults to MP4.
#[serde(rename = "output_format", skip_serializing_if = "Option::is_none")]
pub maybe_output_format: Option<CommonVideoOutputFormat>,
/// The quality to use.
pub quality: Option<CommonQuality>,
@@ -2,7 +2,6 @@ use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_quality::CommonQuality;
use enums::common::generation::common_resolution::CommonResolution;
use enums::common::generation::common_video_model::CommonVideoModel;
use enums::common::generation::common_video_output_format::CommonVideoOutputFormat;
use enums::common::generation::model_creator::ModelCreator;
use enums::common::generation_provider::GenerationProvider;
use serde_derive::{Deserialize, Serialize};
@@ -157,12 +156,6 @@ pub struct OmniGenVideoModelDetails {
#[serde(skip_serializing_if = "Option::is_none")]
pub bitrate_default: Option<CommonBitrate>,
#[serde(skip_serializing_if = "Option::is_none")]
pub output_format_options: Option<Vec<CommonVideoOutputFormat>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub output_format_default: Option<CommonVideoOutputFormat>,
#[serde(skip_serializing_if = "Option::is_none")]
pub quality_options: Option<Vec<CommonQuality>>,
@@ -236,8 +229,6 @@ impl Default for OmniGenVideoModelDetails {
resolution_default: None,
bitrate_options: None,
bitrate_default: None,
output_format_options: None,
output_format_default: None,
quality_options: None,
default_quality: None,
duration_seconds_min: None,
@@ -8,9 +8,6 @@ use serde_derive::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(from = "String", into = "String")]
pub enum ApiClientQuality {
Auto,
Max,
XHigh,
High,
Medium,
Low,
@@ -21,9 +18,6 @@ pub enum ApiClientQuality {
impl From<String> for ApiClientQuality {
fn from(value: String) -> Self {
match value.as_str() {
"auto" => Self::Auto,
"max" => Self::Max,
"xhigh" => Self::XHigh,
"high" => Self::High,
"medium" => Self::Medium,
"low" => Self::Low,
@@ -35,9 +29,6 @@ impl From<String> for ApiClientQuality {
impl From<ApiClientQuality> for String {
fn from(value: ApiClientQuality) -> Self {
match value {
ApiClientQuality::Auto => "auto".to_string(),
ApiClientQuality::Max => "max".to_string(),
ApiClientQuality::XHigh => "xhigh".to_string(),
ApiClientQuality::High => "high".to_string(),
ApiClientQuality::Medium => "medium".to_string(),
ApiClientQuality::Low => "low".to_string(),
@@ -67,9 +58,6 @@ mod tests {
#[test]
fn every_known_variant_round_trips() {
let all = [
(ApiClientQuality::Auto, "auto"),
(ApiClientQuality::Max, "max"),
(ApiClientQuality::XHigh, "xhigh"),
(ApiClientQuality::High, "high"),
(ApiClientQuality::Medium, "medium"),
(ApiClientQuality::Low, "low"),
@@ -41,7 +41,7 @@ pub async fn upload_media_file_from_file<P: AsRef<Path>>(
Err(err) => Err(StorytellerError::Api(err)),
}
}
Some(FileExtension::Mp4 | FileExtension::Mov) => {
Some(FileExtension::Mp4) => {
match upload_video_media_file_from_file(UploadVideoFromFileArgs {
api_host,
maybe_creds,
@@ -13,6 +13,5 @@ pub mod router_quality;
pub mod router_resolution;
pub mod router_splat_model;
pub mod router_video_model;
pub mod router_video_output_format;
pub mod video_list_ref;
pub mod video_ref;
@@ -1,20 +1,10 @@
use serde_derive::{Deserialize, Serialize};
/// Common quality levels you can specify when enqueuing a generation.
/// Not every model will use this; models that don't simply ignore it, and models that only
/// offer a subset of tiers clamp to the nearest tier they support.
///
/// Serialized names mirror the server's `CommonQuality` so the two convert via serde.
/// Not every model will use this; models that don't simply ignore it.
#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum RouterQuality {
/// Let the model choose. Models without a native "auto" use their default tier.
Auto,
/// Highest tier (GPT Image 2.5 `max`).
Max,
/// Between High and Max (GPT Image 2.5 `xhigh`).
#[serde(rename = "xhigh")]
XHigh,
High,
Medium,
Low,
@@ -97,12 +97,6 @@ pub enum RouterVideoModel {
#[serde(rename = "happy_horse_1p0")]
HappyHorse1p0,
#[serde(rename = "wan_3p0")]
Wan3p0,
#[serde(rename = "wan_3p0_prime")]
Wan3p0Prime,
#[serde(rename = "sora_2")]
Sora2,
@@ -1,10 +0,0 @@
use serde_derive::{Deserialize, Serialize};
/// Output video container. Supported by Seedance 2.5 on Kinovi and Artcraft.
/// Other models/providers ignore this option.
#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum RouterVideoOutputFormat {
Mp4,
Mov,
}
@@ -151,11 +151,8 @@ fn to_resolution_enum(r: RouterResolution) -> CommonResolutionEnum {
fn to_quality_enum(q: RouterQuality) -> CommonQualityEnum {
match q {
RouterQuality::Auto => CommonQualityEnum::Auto,
RouterQuality::Max => CommonQualityEnum::Max,
RouterQuality::XHigh => CommonQualityEnum::XHigh,
RouterQuality::High => CommonQualityEnum::High,
RouterQuality::Medium => CommonQualityEnum::Medium,
RouterQuality::Low => CommonQualityEnum::Low,
RouterQuality::Medium => CommonQualityEnum::Medium,
RouterQuality::High => CommonQualityEnum::High,
}
}
@@ -36,7 +36,7 @@ impl ArtcraftGptImage1CostState {
}
pub fn estimate_cost(&self) -> ImageGenerationCostEstimate {
let quality = clamp_to_supported_quality(self.quality);
let quality = self.quality.unwrap_or(CommonQualityEnum::High);
let is_square = is_square_or_none(self.aspect_ratio);
let output_cost_per_image: u64 = match (quality, is_square) {
@@ -45,8 +45,6 @@ impl ArtcraftGptImage1CostState {
(CommonQualityEnum::Medium, false) => 7,
(CommonQualityEnum::High, true) => 17,
(CommonQualityEnum::High, false) => 25,
// Clamped away above.
(CommonQualityEnum::Auto | CommonQualityEnum::XHigh | CommonQualityEnum::Max, _) => unreachable!(),
};
let output_cost = output_cost_per_image * self.num_images as u64;
@@ -65,20 +63,6 @@ impl ArtcraftGptImage1CostState {
}
}
/// GPT Image 1 only offers low / medium / high. `auto` prices as the default (high) and the
/// higher GPT Image 2.5 tiers clamp down to high, matching the Fal request planner.
fn clamp_to_supported_quality(quality: Option<CommonQualityEnum>) -> CommonQualityEnum {
match quality {
Some(CommonQualityEnum::Low) => CommonQualityEnum::Low,
Some(CommonQualityEnum::Medium) => CommonQualityEnum::Medium,
Some(CommonQualityEnum::High)
| Some(CommonQualityEnum::XHigh)
| Some(CommonQualityEnum::Max)
| Some(CommonQualityEnum::Auto)
| None => CommonQualityEnum::High,
}
}
fn is_square_or_none(aspect_ratio: Option<CommonAspectRatioEnum>) -> bool {
use CommonAspectRatioEnum as Ar;
matches!(
@@ -37,7 +37,7 @@ impl ArtcraftGptImage1p5CostState {
}
pub fn estimate_cost(&self) -> ImageGenerationCostEstimate {
let quality = clamp_to_supported_quality(self.quality);
let quality = self.quality.unwrap_or(CommonQualityEnum::High);
let size = size_bucket(self.aspect_ratio);
let cost_per_image: u64 = match (quality, size) {
@@ -50,8 +50,6 @@ impl ArtcraftGptImage1p5CostState {
(CommonQualityEnum::High, SizeBucket::Square) => 14,
(CommonQualityEnum::High, _) => 20,
// Clamped away above.
(CommonQualityEnum::Auto | CommonQualityEnum::XHigh | CommonQualityEnum::Max, _) => unreachable!(),
};
let cost_in_usd_cents = cost_per_image * self.num_images as u64;
@@ -67,20 +65,6 @@ impl ArtcraftGptImage1p5CostState {
}
}
/// GPT Image 1.5 only offers low / medium / high. `auto` prices as the default (high) and the
/// higher GPT Image 2.5 tiers clamp down to high, matching the Fal request planner.
fn clamp_to_supported_quality(quality: Option<CommonQualityEnum>) -> CommonQualityEnum {
match quality {
Some(CommonQualityEnum::Low) => CommonQualityEnum::Low,
Some(CommonQualityEnum::Medium) => CommonQualityEnum::Medium,
Some(CommonQualityEnum::High)
| Some(CommonQualityEnum::XHigh)
| Some(CommonQualityEnum::Max)
| Some(CommonQualityEnum::Auto)
| None => CommonQualityEnum::High,
}
}
fn size_bucket(aspect_ratio: Option<CommonAspectRatioEnum>) -> SizeBucket {
use CommonAspectRatioEnum as Ar;
match aspect_ratio {
@@ -42,7 +42,7 @@ impl ArtcraftGptImage2CostState {
}
pub fn estimate_cost(&self) -> ImageGenerationCostEstimate {
let quality = clamp_to_supported_quality(self.quality);
let quality = self.quality.unwrap_or(CommonQualityEnum::High);
let size = size_bucket(self.aspect_ratio);
let cost_per_image: u64 = match (quality, size) {
@@ -62,8 +62,6 @@ impl ArtcraftGptImage2CostState {
| (CommonQualityEnum::High, SizeBucket::Portrait4x3) => 15,
(CommonQualityEnum::High, SizeBucket::Landscape16x9)
| (CommonQualityEnum::High, SizeBucket::Portrait16x9) => 16,
// Clamped away above.
(CommonQualityEnum::Auto | CommonQualityEnum::XHigh | CommonQualityEnum::Max, _) => unreachable!(),
};
let cost_in_usd_cents = cost_per_image * self.num_images as u64;
@@ -81,20 +79,6 @@ impl ArtcraftGptImage2CostState {
/// Mirrors v1's `plan_image_size` for GPT Image 2 — see the v1 file
/// `plan_generate_image_artcraft_gpt_image_2.rs::plan_image_size`.
/// GPT Image 2 only offers low / medium / high. `auto` prices as the default (high) and the
/// higher GPT Image 2.5 tiers clamp down to high, matching the Fal request planner.
fn clamp_to_supported_quality(quality: Option<CommonQualityEnum>) -> CommonQualityEnum {
match quality {
Some(CommonQualityEnum::Low) => CommonQualityEnum::Low,
Some(CommonQualityEnum::Medium) => CommonQualityEnum::Medium,
Some(CommonQualityEnum::High)
| Some(CommonQualityEnum::XHigh)
| Some(CommonQualityEnum::Max)
| Some(CommonQualityEnum::Auto)
| None => CommonQualityEnum::High,
}
}
fn size_bucket(aspect_ratio: Option<CommonAspectRatioEnum>) -> SizeBucket {
use CommonAspectRatioEnum as Ar;
match aspect_ratio {
@@ -8,27 +8,25 @@ use fal_client::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_image
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
/// Artcraft price per output image, in hundredths of a US cent, by output dimensions and
/// quality (`[low, medium, high, xhigh, max]`). Each cell is the `fal_client` GPT Image 2.5
/// estimate for that size and tier with a 10% markup, rounded up. Rows are stored
/// landscape-first; portrait orientations of the same dimensions price identically.
/// Dimensions come from the aspect ratio preset and resolution tier the same way the Fal
/// request builder derives them.
const OUTPUT_IMAGE_PRICE_TABLE: &[(u32, u32, [u64; 5])] = &[
/// quality (`[low, medium, high]`). Rows are stored landscape-first; portrait orientations of
/// the same dimensions price identically. Dimensions come from the aspect ratio preset and
/// resolution tier the same way the Fal request builder derives them.
const OUTPUT_IMAGE_PRICE_TABLE: &[(u32, u32, [u64; 3])] = &[
// 1:1
(1024, 1024, [ 65, 152, 582, 1034, 2327]),
(2048, 2048, [103, 239, 919, 1633, 3672]),
(2880, 2880, [151, 352, 1357, 2412, 5426]),
(1024, 1024, [ 65, 152, 582]),
(2048, 2048, [103, 239, 919]),
(2880, 2880, [151, 352, 1357]),
// 4:3
(1024, 768, [ 46, 105, 400, 710, 1597]),
(2048, 1536, [ 73, 170, 652, 1159, 2605]),
(3072, 2304, [120, 279, 1073, 1906, 4287]),
(3312, 2480, [134, 311, 1194, 2122, 4773]),
(1024, 768, [ 46, 105, 400]),
(2048, 1536, [ 73, 170, 652]),
(3072, 2304, [120, 279, 1073]),
(3312, 2480, [134, 311, 1194]),
// 16:9
(1088, 608, [ 32, 75, 285, 506, 1139]),
(1920, 1080, [ 49, 114, 436, 775, 1743]),
(2048, 1152, [ 53, 121, 467, 830, 1865]),
(3072, 1728, [ 87, 204, 783, 1390, 3127]),
(3840, 2160, [124, 286, 1102, 1957, 4404]),
(1088, 608, [ 32, 75, 285]),
(1920, 1080, [ 49, 114, 436]),
(2048, 1152, [ 53, 121, 467]),
(3072, 1728, [ 87, 204, 783]),
(3840, 2160, [124, 286, 1102]),
];
/// Artcraft price per reference image on an edit request, in hundredths of a US cent.
@@ -57,8 +55,7 @@ impl ArtcraftGptImage2p5CostInputs {
}
/// Per-image price rounded up to whole cents, times the batch size. Quality defaults to
/// high, and `auto` is priced as high (the documented default the model falls back to).
/// Without an aspect ratio, text-to-image prices as landscape 4:3 and edit as auto,
/// high. Without an aspect ratio, text-to-image prices as landscape 4:3 and edit as auto,
/// matching the Fal defaults the request would fall back to.
pub fn estimate_cost(&self) -> ImageGenerationCostEstimate {
let quality = self.quality.unwrap_or(CommonQualityEnum::High);
@@ -94,13 +91,11 @@ fn output_image_price(quality: CommonQualityEnum, width: u32, height: u32) -> u6
match quality {
CommonQualityEnum::Low => prices[0],
CommonQualityEnum::Medium => prices[1],
CommonQualityEnum::High | CommonQualityEnum::Auto => prices[2],
CommonQualityEnum::XHigh => prices[3],
CommonQualityEnum::Max => prices[4],
CommonQualityEnum::High => prices[2],
}
}
fn lookup_price_row(width: u32, height: u32) -> Option<[u64; 5]> {
fn lookup_price_row(width: u32, height: u32) -> Option<[u64; 3]> {
let long_edge = width.max(height);
let short_edge = width.min(height);
OUTPUT_IMAGE_PRICE_TABLE.iter()
@@ -181,18 +176,6 @@ mod tests {
(Some(Ar::Auto4k), Some(R::OneK), [2, 3, 12]),
];
// (aspect_ratio, resolution, [xhigh, max]) — cents per image, text-to-image
const TEXT_GRID_UPPER_TIERS: &[(Option<Ar>, Option<R>, [u64; 2])] = &[
(None, None, [ 8, 16]), // landscape 4:3 default
(Some(Ar::Square), None, [11, 24]),
(Some(Ar::Square), Some(R::TwoK), [17, 37]),
(Some(Ar::Square), Some(R::FourK), [25, 55]),
(Some(Ar::WideSixteenByNine), None, [ 8, 18]),
(Some(Ar::WideSixteenByNine), Some(R::OneK), [ 6, 12]),
(Some(Ar::TallNineBySixteen), Some(R::FourK), [20, 45]),
(Some(Ar::Auto), None, [20, 45]),
];
mod text_to_image_tests {
use super::*;
@@ -206,32 +189,11 @@ mod tests {
}
}
#[test]
fn upper_tier_grid() {
for &(aspect_ratio, resolution, expected) in TEXT_GRID_UPPER_TIERS {
for (quality, expected) in [Q::XHigh, Q::Max].into_iter().zip(expected) {
let actual = cents(make_inputs(0, Some(quality), aspect_ratio, resolution, 1));
assert_eq!(actual, expected, "{aspect_ratio:?} {resolution:?} {quality:?}");
}
}
}
#[test]
fn quality_defaults_to_high() {
assert_eq!(cents(make_inputs(0, None, Some(Ar::Square), None, 1)), 6);
}
#[test]
fn auto_prices_as_high() {
for &(aspect_ratio, resolution, _) in TEXT_GRID {
assert_eq!(
cents(make_inputs(0, Some(Q::Auto), aspect_ratio, resolution, 1)),
cents(make_inputs(0, Some(Q::High), aspect_ratio, resolution, 1)),
"{aspect_ratio:?} {resolution:?}",
);
}
}
#[test]
fn batch_multiplies_per_image_cents() {
for &(aspect_ratio, resolution, _) in TEXT_GRID {
@@ -303,7 +265,7 @@ mod tests {
#[test]
fn prices_rise_with_quality_and_size() {
for &(_, _, prices) in OUTPUT_IMAGE_PRICE_TABLE {
assert!(prices.windows(2).all(|pair| pair[0] < pair[1]), "{prices:?}");
assert!(prices[0] < prices[1] && prices[1] < prices[2], "{prices:?}");
}
assert!(output_image_price(Q::High, 1024, 1024) < output_image_price(Q::High, 2048, 2048));
assert!(output_image_price(Q::High, 2048, 2048) < output_image_price(Q::High, 2880, 2880));
@@ -100,17 +100,11 @@ fn plan_num_images(
}
}
/// This endpoint only offers low / medium / high. `auto` falls back to Fal's default (`high`)
/// and the higher GPT Image 2.5 tiers (`xhigh`, `max`) clamp down to `high`.
fn plan_quality(quality: Option<RouterQuality>) -> PlannedQuality {
match quality {
Some(RouterQuality::Low) => PlannedQuality::Low,
Some(RouterQuality::Medium) => PlannedQuality::Medium,
Some(RouterQuality::High)
| Some(RouterQuality::XHigh)
| Some(RouterQuality::Max)
| Some(RouterQuality::Auto)
| None => PlannedQuality::High,
Some(RouterQuality::High) | None => PlannedQuality::High,
}
}
@@ -343,10 +337,6 @@ mod tests {
(Some(RouterQuality::Low), "Some(Low)"),
(Some(RouterQuality::Medium), "Some(Medium)"),
(Some(RouterQuality::High), "Some(High)"),
// Tiers this endpoint lacks clamp to high.
(Some(RouterQuality::Auto), "Some(High)"),
(Some(RouterQuality::XHigh), "Some(High)"),
(Some(RouterQuality::Max), "Some(High)"),
];
for (quality, expected) in cases {
@@ -100,17 +100,11 @@ fn plan_num_images(
}
}
/// This endpoint only offers low / medium / high. `auto` falls back to Fal's default (`high`)
/// and the higher GPT Image 2.5 tiers (`xhigh`, `max`) clamp down to `high`.
fn plan_quality(quality: Option<RouterQuality>) -> PlannedQuality {
match quality {
Some(RouterQuality::Low) => PlannedQuality::Low,
Some(RouterQuality::Medium) => PlannedQuality::Medium,
Some(RouterQuality::High)
| Some(RouterQuality::XHigh)
| Some(RouterQuality::Max)
| Some(RouterQuality::Auto)
| None => PlannedQuality::High,
Some(RouterQuality::High) | None => PlannedQuality::High,
}
}
@@ -343,10 +337,6 @@ mod tests {
(Some(RouterQuality::Low), "Some(Low)"),
(Some(RouterQuality::Medium), "Some(Medium)"),
(Some(RouterQuality::High), "Some(High)"),
// Tiers this endpoint lacks clamp to high.
(Some(RouterQuality::Auto), "Some(High)"),
(Some(RouterQuality::XHigh), "Some(High)"),
(Some(RouterQuality::Max), "Some(High)"),
];
for (quality, expected) in cases {
@@ -106,17 +106,11 @@ fn plan_num_images(
}
}
/// This endpoint only offers low / medium / high. `auto` falls back to Fal's default (`high`)
/// and the higher GPT Image 2.5 tiers (`xhigh`, `max`) clamp down to `high`.
fn plan_quality(quality: Option<RouterQuality>) -> PlannedQuality {
match quality {
Some(RouterQuality::Low) => PlannedQuality::Low,
Some(RouterQuality::Medium) => PlannedQuality::Medium,
Some(RouterQuality::High)
| Some(RouterQuality::XHigh)
| Some(RouterQuality::Max)
| Some(RouterQuality::Auto)
| None => PlannedQuality::High,
Some(RouterQuality::High) | None => PlannedQuality::High,
}
}
@@ -368,10 +362,6 @@ mod tests {
(Some(RouterQuality::Low), "Some(Low)"),
(Some(RouterQuality::Medium), "Some(Medium)"),
(Some(RouterQuality::High), "Some(High)"),
// Tiers this endpoint lacks clamp to high.
(Some(RouterQuality::Auto), "Some(High)"),
(Some(RouterQuality::XHigh), "Some(High)"),
(Some(RouterQuality::Max), "Some(High)"),
];
for (quality, expected) in cases {
@@ -82,16 +82,13 @@ fn plan_num_images(
}
}
/// GPT Image 2.5 supports every router tier natively. Fal's default (`high`) applies when
/// unspecified.
/// The router only exposes low / medium / high; GPT Image 2.5's `xhigh` and `max` tiers are
/// not reachable through it. Fal's default (`high`) applies when unspecified.
fn plan_quality(quality: Option<RouterQuality>) -> GptImage2p5Quality {
match quality {
Some(RouterQuality::Auto) => GptImage2p5Quality::Auto,
Some(RouterQuality::Max) => GptImage2p5Quality::Max,
Some(RouterQuality::XHigh) => GptImage2p5Quality::XHigh,
Some(RouterQuality::High) | None => GptImage2p5Quality::High,
Some(RouterQuality::Medium) => GptImage2p5Quality::Medium,
Some(RouterQuality::Low) => GptImage2p5Quality::Low,
Some(RouterQuality::Medium) => GptImage2p5Quality::Medium,
Some(RouterQuality::High) | None => GptImage2p5Quality::High,
}
}
@@ -223,12 +220,9 @@ mod tests {
fn quality_maps_exhaustively_for_text_and_edit() {
let cases = [
(None, "Some(High)"),
(Some(RouterQuality::Auto), "Some(Auto)"),
(Some(RouterQuality::Max), "Some(Max)"),
(Some(RouterQuality::XHigh), "Some(XHigh)"),
(Some(RouterQuality::High), "Some(High)"),
(Some(RouterQuality::Medium), "Some(Medium)"),
(Some(RouterQuality::Low), "Some(Low)"),
(Some(RouterQuality::Medium), "Some(Medium)"),
(Some(RouterQuality::High), "Some(High)"),
];
for (quality, expected) in cases {
let text = unwrap_t2i(plan_gpt_image_2p5_params(&GenerateImageRequestBuilder { quality, ..base_builder() }));
@@ -53,9 +53,6 @@ mod tests {
(Some(Q::Low), Some(Ar::Square), None, 1, 1),
(Some(Q::Medium), Some(Ar::Square), None, 1, 2),
(Some(Q::High), Some(Ar::Square), None, 1, 6),
(Some(Q::Auto), Some(Ar::Square), None, 1, 6), // auto is priced as high
(Some(Q::XHigh), Some(Ar::Square), None, 1, 10),
(Some(Q::Max), Some(Ar::Square), None, 1, 22),
(Some(Q::High), Some(Ar::Square), None, 4, 24),
(Some(Q::High), Some(Ar::SquareHd), None, 1, 9),
(Some(Q::High), Some(Ar::Square), Some(R::TwoK), 1, 9),
@@ -53,9 +53,6 @@ mod tests {
(Some(Q::Low), Some(Ar::Square), None, 1, 1),
(Some(Q::Medium), Some(Ar::Square), None, 1, 2),
(Some(Q::High), Some(Ar::Square), None, 1, 6),
(Some(Q::Auto), Some(Ar::Square), None, 1, 6), // auto is priced as high
(Some(Q::XHigh), Some(Ar::Square), None, 1, 10),
(Some(Q::Max), Some(Ar::Square), None, 1, 22),
(Some(Q::High), Some(Ar::Square), None, 4, 24),
(Some(Q::High), Some(Ar::SquareHd), None, 1, 9),
(Some(Q::High), Some(Ar::Square), Some(R::TwoK), 1, 9),
@@ -122,14 +122,10 @@ pub(crate) fn plan_quality(quality: Option<RouterQuality>) -> Option<GenerateMid
// Kinovi pricing — pass through whatever the caller asked for, and let
// `None` ride Midjourney's server-side default.
match quality {
// `auto` has no Midjourney equivalent; leave it to the provider default.
None | Some(RouterQuality::Auto) => None,
None => None,
Some(RouterQuality::Low) => Some(GenerateMidjourneyV7Quality::Quarter),
Some(RouterQuality::Medium) => Some(GenerateMidjourneyV7Quality::Half),
// Midjourney tops out at full quality; the higher GPT Image tiers clamp to it.
Some(RouterQuality::High)
| Some(RouterQuality::XHigh)
| Some(RouterQuality::Max) => Some(GenerateMidjourneyV7Quality::Full),
Some(RouterQuality::High) => Some(GenerateMidjourneyV7Quality::Full),
}
}
@@ -110,14 +110,10 @@ pub(crate) fn plan_aspect_ratio(
pub(crate) fn plan_quality(quality: Option<RouterQuality>) -> Option<GenerateMidjourneyV7NijiQuality> {
match quality {
// `auto` has no Midjourney equivalent; leave it to the provider default.
None | Some(RouterQuality::Auto) => None,
None => None,
Some(RouterQuality::Low) => Some(GenerateMidjourneyV7NijiQuality::Quarter),
Some(RouterQuality::Medium) => Some(GenerateMidjourneyV7NijiQuality::Half),
// Midjourney tops out at full quality; the higher GPT Image tiers clamp to it.
Some(RouterQuality::High)
| Some(RouterQuality::XHigh)
| Some(RouterQuality::Max) => Some(GenerateMidjourneyV7NijiQuality::Full),
Some(RouterQuality::High) => Some(GenerateMidjourneyV7NijiQuality::Full),
}
}
@@ -110,14 +110,10 @@ pub(crate) fn plan_aspect_ratio(
pub(crate) fn plan_quality(quality: Option<RouterQuality>) -> Option<GenerateMidjourneyV8Quality> {
match quality {
// `auto` has no Midjourney equivalent; leave it to the provider default.
None | Some(RouterQuality::Auto) => None,
None => None,
Some(RouterQuality::Low) => Some(GenerateMidjourneyV8Quality::Quarter),
Some(RouterQuality::Medium) => Some(GenerateMidjourneyV8Quality::Half),
// Midjourney tops out at full quality; the higher GPT Image tiers clamp to it.
Some(RouterQuality::High)
| Some(RouterQuality::XHigh)
| Some(RouterQuality::Max) => Some(GenerateMidjourneyV8Quality::Full),
Some(RouterQuality::High) => Some(GenerateMidjourneyV8Quality::Full),
}
}
@@ -1,12 +1,9 @@
use crate::generate::generate_video::providers::kinovi::wan_3p0::build::build_kinovi_wan_3p0;
use crate::generate::generate_video::providers::artcraft::wan_3p0::build::build_artcraft_wan_3p0;
use crate::api::audio_list_ref::AudioListRef;
use crate::api::character_list_ref::CharacterListRef;
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_bitrate::RouterBitrate;
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
use crate::api::router_video_output_format::RouterVideoOutputFormat;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::router_provider::RouterProvider;
@@ -128,10 +125,6 @@ pub struct GenerateVideoRequestBuilder {
/// Not all models support this; models that don't simply ignore it.
pub bitrate: Option<RouterBitrate>,
/// Output container for Seedance 2.5 on Kinovi and Artcraft.
/// None keeps the provider default; unsupported models/providers ignore it.
pub maybe_output_format: Option<RouterVideoOutputFormat>,
/// How many seconds to generate.
pub duration_seconds: Option<u16>,
@@ -144,12 +137,10 @@ pub struct GenerateVideoRequestBuilder {
/// so it's best to be explicit.
pub generate_audio: Option<bool>,
/// Local pricing/validation metadata (never sent to a provider): total seconds of
/// CALCULATION-ONLY (never sent to any provider): the total seconds of
/// reference video input, summed across all reference videos and rounded
/// up to whole seconds per file. Models that bill reference-video input
/// seconds (e.g. Seedance 2.5) add this to the billed duration. Wan uses
/// the ceiling of the combined measured duration to validate its output
/// budget; reference seconds do not affect Wan pricing.
/// seconds (e.g. Seedance 2.5) add this to the billed duration.
pub total_reference_video_input_seconds: Option<u16>,
/// If the request is a mismatch with the (model/provider), how to mitigate it.
@@ -177,7 +168,6 @@ impl Default for GenerateVideoRequestBuilder {
resolution: None,
aspect_ratio: None,
bitrate: None,
maybe_output_format: None,
duration_seconds: None,
video_batch_count: None,
generate_audio: None,
@@ -196,7 +186,6 @@ impl GenerateVideoRequestBuilder {
(RouterProvider::Artcraft, RouterVideoModel::GrokImagineVideo1p5) => build_artcraft_grok_imagine_video_1p5(self),
(RouterProvider::Artcraft, RouterVideoModel::Flux3) => build_artcraft_flux_3(self),
(RouterProvider::Artcraft, RouterVideoModel::Flux3Draft) => build_artcraft_flux_3_draft(self),
(RouterProvider::Artcraft, RouterVideoModel::Wan3p0 | RouterVideoModel::Wan3p0Prime) => build_artcraft_wan_3p0(self),
(RouterProvider::Artcraft, RouterVideoModel::HappyHorse1p0) => build_artcraft_happy_horse_1p0(self),
(RouterProvider::Artcraft, RouterVideoModel::Kling16Pro) => build_artcraft_kling_1_6_pro(self),
(RouterProvider::Artcraft, RouterVideoModel::Kling21Master) => build_artcraft_kling_2_1_master(self),
@@ -259,7 +248,6 @@ impl GenerateVideoRequestBuilder {
(RouterProvider::GrokApi, RouterVideoModel::GrokImagineVideo) => build_grok_api_grok_imagine_video(self),
(RouterProvider::GrokApi, RouterVideoModel::GrokImagineVideo1p5) => build_grok_api_grok_imagine_video_1p5(self),
// Kinovi
(RouterProvider::KinoviWeb, RouterVideoModel::Wan3p0 | RouterVideoModel::Wan3p0Prime) => build_kinovi_wan_3p0(self),
(RouterProvider::KinoviWeb, RouterVideoModel::HappyHorse1p0) => build_kinovi_happy_horse_1p0(self),
(RouterProvider::KinoviWeb, RouterVideoModel::Seedance2p0) => build_kinovi_seedance_2p0(self),
(RouterProvider::KinoviWeb, RouterVideoModel::Seedance2p0Fast) => build_kinovi_seedance_2p0_fast(self),
@@ -3,12 +3,10 @@ use enums::common::generation::common_aspect_ratio::CommonAspectRatio as CommonA
use enums::common::generation::common_bitrate::CommonBitrate as CommonBitrateEnum;
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
use enums::common::generation::common_video_output_format::CommonVideoOutputFormat;
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_bitrate::RouterBitrate;
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_output_format::RouterVideoOutputFormat;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
@@ -57,13 +55,6 @@ pub fn build_artcraft_omni_video_request(
let duration_seconds = plan_duration(builder.duration_seconds.take(), strategy)?;
let bitrate = plan_bitrate(builder.bitrate.take());
let prompt = builder.prompt.take();
let supports_output_format = matches!(model, CommonVideoModelEnum::Seedance2p5 | CommonVideoModelEnum::Seedance2p5Ultra);
let maybe_output_format = builder.maybe_output_format
.filter(|_| supports_output_format)
.map(|format| match format {
RouterVideoOutputFormat::Mp4 => CommonVideoOutputFormat::Mp4,
RouterVideoOutputFormat::Mov => CommonVideoOutputFormat::Mov,
});
let start_frame = resolve_image_ref(builder.start_frame.take())?;
let end_frame = resolve_image_ref(builder.end_frame.take())?;
@@ -86,7 +77,6 @@ pub fn build_artcraft_omni_video_request(
resolution,
aspect_ratio,
bitrate,
maybe_output_format,
duration_seconds: duration_seconds.map(|d| d as u16),
video_batch_count: Some(batch_count),
negative_prompt: None,
@@ -264,6 +254,30 @@ pub fn plan_batch_count(
}
}
/// Batch counts for the Seedance 2.0 Mini family: 1-8 are all valid
/// (wider than the shared 1/2/4 planning); higher counts clamp to 8 per the
/// mitigation strategy.
pub fn plan_mini_batch_count(
video_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<u16, ArtcraftRouterError> {
let count = video_batch_count.unwrap_or(1);
match count {
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1..=8 => Ok(count),
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "video_batch_count",
value: format!("{}", count),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(8),
},
}
}
/// Translate the router-facing bitrate into the API `CommonBitrate`.
///
/// Bitrate does not affect cost. An unset value leaves the field `None` (the
@@ -303,71 +317,6 @@ pub fn plan_duration(
mod tests {
use super::*;
mod output_format {
use super::*;
use crate::api::router_provider::RouterProvider;
use crate::api::router_video_model::RouterVideoModel;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
#[test]
fn seedance_2p5_preserves_output_format_and_audio_over_http() {
for model in [RouterVideoModel::Seedance2p5, RouterVideoModel::Seedance2p5Ultra] {
for maybe_output_format in [None, Some(RouterVideoOutputFormat::Mp4), Some(RouterVideoOutputFormat::Mov)] {
for generate_audio in [None, Some(true), Some(false)] {
let builder = GenerateVideoRequestBuilder {
provider: RouterProvider::Artcraft,
model,
maybe_output_format,
generate_audio,
..Default::default()
};
let request = match builder.build2().unwrap() {
VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p5(state)) => state.request,
VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p5Ultra(state)) => state.request,
other => panic!("Unexpected request: {:?}", other),
};
let json = serde_json::to_value(&request).unwrap();
let expected = match maybe_output_format {
None => None,
Some(RouterVideoOutputFormat::Mp4) => Some("mp4"),
Some(RouterVideoOutputFormat::Mov) => Some("mov"),
};
assert_eq!(json.get("output_format").and_then(|v| v.as_str()), expected);
if expected.is_none() {
assert!(json.get("output_format").is_none());
}
let decoded: OmniGenVideoCostAndGenerateRequest = serde_json::from_value(json).unwrap();
assert_eq!(decoded.maybe_output_format, request.maybe_output_format);
assert_eq!(decoded.generate_audio, generate_audio);
}
}
}
}
#[test]
fn unsupported_models_ignore_output_format_even_with_error_strategy() {
for model in [RouterVideoModel::Seedance2p0, RouterVideoModel::Seedance2p0Fast, RouterVideoModel::Seedance2p5Preview] {
for format in [RouterVideoOutputFormat::Mp4, RouterVideoOutputFormat::Mov] {
let builder = GenerateVideoRequestBuilder {
provider: RouterProvider::Artcraft,
model,
maybe_output_format: Some(format),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..Default::default()
};
let request = match builder.build2().unwrap() {
VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0(state)) => state.request,
VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0Fast(state)) => state.request,
VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p5Preview(state)) => state.request,
other => panic!("Unexpected request: {:?}", other),
};
assert!(serde_json::to_value(request).unwrap().get("output_format").is_none());
}
}
}
}
mod bitrate_translation {
use super::*;
@@ -59,7 +59,6 @@ mod tests {
resolution: None,
aspect_ratio: None,
bitrate: None,
maybe_output_format: None,
quality: None,
duration_seconds: Some(5),
video_batch_count: Some(1),
@@ -1,259 +0,0 @@
//! Public ArtCraft batch planning and retail price snapshots for Kinovi models.
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::router_provider::RouterProvider;
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
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::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
const MODELS: [RouterVideoModel; 12] = [
RouterVideoModel::Seedance2p5,
RouterVideoModel::Seedance2p0,
RouterVideoModel::Seedance2p0Fast,
RouterVideoModel::Seedance2p0Mini,
RouterVideoModel::Seedance2p5Ultra,
RouterVideoModel::Seedance2p0BytePlus,
RouterVideoModel::Seedance2p0BytePlusFast,
RouterVideoModel::Seedance2p0BytePlusMini,
RouterVideoModel::Seedance2p0BytePlusUltra,
RouterVideoModel::Seedance2p0BytePlusUltraFast,
RouterVideoModel::Seedance2p0BytePlusUltraMini,
RouterVideoModel::HappyHorse1p0,
];
mod batch_planning {
use super::*;
#[test]
fn batches_one_through_four_survive_http_serialization() {
for model in MODELS {
for strategy in [
RequestMismatchMitigationStrategy::ErrorOut,
RequestMismatchMitigationStrategy::PayMoreUpgrade,
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
for batch in [None, Some(1), Some(2), Some(3), Some(4)] {
let mut builder = builder(model, RouterResolution::SevenTwentyP, batch);
builder.request_mismatch_mitigation_strategy = strategy;
let request = http_request(builder.build2().unwrap());
let json = serde_json::to_value(&request).unwrap();
assert_eq!(json["video_batch_count"], batch.unwrap_or(1), "{model:?}");
let decoded: OmniGenVideoCostAndGenerateRequest = serde_json::from_value(json).unwrap();
assert_eq!(decoded.video_batch_count, Some(batch.unwrap_or(1)));
}
}
}
}
#[test]
fn zero_is_rejected_under_every_strategy() {
for model in MODELS {
for strategy in [
RequestMismatchMitigationStrategy::ErrorOut,
RequestMismatchMitigationStrategy::PayMoreUpgrade,
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let mut builder = builder(model, RouterResolution::SevenTwentyP, Some(0));
builder.request_mismatch_mitigation_strategy = strategy;
assert!(matches!(builder.build2(), Err(ArtcraftRouterError::Client(
ClientError::UserRequestedZeroGenerations
))), "{model:?}");
}
}
}
#[test]
fn batches_above_four_follow_the_mitigation_strategy() {
for model in MODELS {
for batch in [5, 6, 7, 8, u16::MAX] {
let builder = builder(model, RouterResolution::SevenTwentyP, Some(batch));
assert!(matches!(builder.clone().build2(), Err(ArtcraftRouterError::Client(
ClientError::ModelDoesNotSupportOption { field: "video_batch_count", .. }
))), "{model:?}, batch {batch}");
for strategy in [
RequestMismatchMitigationStrategy::PayMoreUpgrade,
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let mut builder = builder.clone();
builder.request_mismatch_mitigation_strategy = strategy;
let request = http_request(builder.build2().unwrap());
assert_eq!(request.video_batch_count, Some(4), "{model:?}, batch {batch}");
}
}
}
}
}
mod retail_prices {
use super::*;
use RouterResolution::{FourEightyP, FourK, SevenTwentyP, TenEightyP};
use RouterVideoModel::{HappyHorse1p0, Seedance2p0, Seedance2p0Fast, Seedance2p0Mini, Seedance2p5,
Seedance2p5Ultra, Seedance2p0BytePlus, Seedance2p0BytePlusFast, Seedance2p0BytePlusMini, Seedance2p0BytePlusUltra, Seedance2p0BytePlusUltraFast, Seedance2p0BytePlusUltraMini,
};
#[test]
fn five_second_prices_for_every_resolution_and_batch() {
// Literal USD cents / ArtCraft credits for batches [1, 2, 3, 4].
// Pin the final rounded totals, not a multiple of an already-rounded price.
for (model, resolution, expected) in [
(Seedance2p5, FourEightyP, [59, 118, 177, 236]),
(Seedance2p5, SevenTwentyP, [134, 268, 401, 535]),
(Seedance2p5, TenEightyP, [309, 617, 925, 1233]),
(Seedance2p5Ultra, FourEightyP, [70, 140, 209, 279]),
(Seedance2p5Ultra, SevenTwentyP, [158, 316, 474, 632]),
(Seedance2p5Ultra, TenEightyP, [337, 673, 1009, 1345]),
(Seedance2p0, FourEightyP, [39, 78, 117, 155]),
(Seedance2p0, SevenTwentyP, [93, 185, 278, 370]),
(Seedance2p0, TenEightyP, [233, 466, 699, 933]),
(Seedance2p0, FourK, [463, 925, 1388, 1850]),
(Seedance2p0Fast, FourEightyP, [36, 71, 107, 143]),
(Seedance2p0Fast, SevenTwentyP, [64, 127, 191, 255]),
(Seedance2p0Mini, FourEightyP, [18, 35, 52, 69]),
(Seedance2p0Mini, SevenTwentyP, [45, 89, 134, 178]),
(Seedance2p0BytePlus, FourEightyP, [50, 100, 150, 200]),
(Seedance2p0BytePlus, SevenTwentyP, [125, 250, 375, 500]),
(Seedance2p0BytePlus, TenEightyP, [250, 500, 750, 1000]),
(Seedance2p0BytePlus, FourK, [475, 950, 1425, 1900]),
(Seedance2p0BytePlusUltra, FourEightyP, [50, 100, 150, 200]),
(Seedance2p0BytePlusUltra, SevenTwentyP, [125, 250, 375, 500]),
(Seedance2p0BytePlusUltra, TenEightyP, [250, 500, 750, 1000]),
(Seedance2p0BytePlusUltra, FourK, [475, 950, 1425, 1900]),
(Seedance2p0BytePlusFast, FourEightyP, [45, 90, 135, 180]),
(Seedance2p0BytePlusFast, SevenTwentyP, [100, 200, 300, 400]),
(Seedance2p0BytePlusUltraFast, FourEightyP, [45, 90, 135, 180]),
(Seedance2p0BytePlusUltraFast, SevenTwentyP, [100, 200, 300, 400]),
(Seedance2p0BytePlusMini, FourEightyP, [18, 36, 54, 71]),
(Seedance2p0BytePlusMini, SevenTwentyP, [46, 91, 137, 182]),
(Seedance2p0BytePlusUltraMini, FourEightyP, [18, 36, 54, 71]),
(Seedance2p0BytePlusUltraMini, SevenTwentyP, [46, 91, 137, 182]),
(HappyHorse1p0, SevenTwentyP, [85, 171, 256, 342]),
(HappyHorse1p0, TenEightyP, [171, 342, 513, 684]),
] {
assert_batch_prices(builder(model, resolution, None), expected);
}
}
#[test]
fn video_reference_prices_for_every_resolution_and_batch() {
// Five output seconds, plus ten measured input seconds for Seedance 2.5.
for (model, resolution, expected) in [
(Seedance2p5, FourEightyP, [109, 218, 326, 435]),
(Seedance2p5, SevenTwentyP, [238, 476, 713, 951]),
(Seedance2p5, TenEightyP, [578, 1155, 1733, 2310]),
(Seedance2p5Ultra, FourEightyP, [129, 257, 386, 514]),
(Seedance2p5Ultra, SevenTwentyP, [281, 562, 843, 1124]),
(Seedance2p5Ultra, TenEightyP, [628, 1256, 1884, 2511]),
(Seedance2p0, FourEightyP, [45, 89, 133, 177]),
(Seedance2p0, SevenTwentyP, [111, 221, 331, 441]),
(Seedance2p0, TenEightyP, [256, 511, 767, 1022]),
(Seedance2p0, FourK, [569, 1138, 1707, 2276]),
(Seedance2p0Fast, FourEightyP, [43, 85, 127, 169]),
(Seedance2p0Fast, SevenTwentyP, [78, 156, 234, 312]),
// Preserve Mini's existing floating-point round-up behavior.
(Seedance2p0Mini, FourEightyP, [22, 44, 66, 88]),
(Seedance2p0Mini, SevenTwentyP, [54, 108, 161, 215]),
(Seedance2p0BytePlus, FourEightyP, [51, 102, 153, 204]),
(Seedance2p0BytePlus, SevenTwentyP, [129, 257, 386, 514]),
(Seedance2p0BytePlus, TenEightyP, [289, 578, 867, 1156]),
(Seedance2p0BytePlus, FourK, [595, 1190, 1785, 2380]),
(Seedance2p0BytePlusUltra, FourEightyP, [51, 102, 153, 204]),
(Seedance2p0BytePlusUltra, SevenTwentyP, [129, 257, 386, 514]),
(Seedance2p0BytePlusUltra, TenEightyP, [289, 578, 867, 1156]),
(Seedance2p0BytePlusUltra, FourK, [595, 1190, 1785, 2380]),
(Seedance2p0BytePlusFast, FourEightyP, [49, 97, 145, 193]),
(Seedance2p0BytePlusFast, SevenTwentyP, [103, 205, 308, 410]),
(Seedance2p0BytePlusUltraFast, FourEightyP, [49, 97, 145, 193]),
(Seedance2p0BytePlusUltraFast, SevenTwentyP, [103, 205, 308, 410]),
(Seedance2p0BytePlusMini, FourEightyP, [23, 45, 68, 90]),
(Seedance2p0BytePlusMini, SevenTwentyP, [55, 110, 165, 219]),
(Seedance2p0BytePlusUltraMini, FourEightyP, [23, 45, 68, 90]),
(Seedance2p0BytePlusUltraMini, SevenTwentyP, [55, 110, 165, 219]),
] {
let mut builder = builder(model, resolution, None);
builder.reference_videos = Some(VideoListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_reference".to_string()),
]));
builder.total_reference_video_input_seconds = Some(10);
assert_batch_prices(builder, expected);
}
}
#[test]
fn seedance_2p5_input_duration_clamping_and_fallback_scale_with_batch() {
for (model, resolution, minimum, maximum) in [
(Seedance2p5, FourEightyP, [66, 131, 196, 261], [254, 507, 761, 1014]),
(Seedance2p5, SevenTwentyP, [143, 286, 428, 571], [555, 1110, 1664, 2219]),
(Seedance2p5, TenEightyP, [347, 693, 1040, 1386], [1348, 2695, 4043, 5390]),
(Seedance2p5Ultra, FourEightyP, [78, 155, 232, 309], [300, 600, 899, 1199]),
(Seedance2p5Ultra, SevenTwentyP, [169, 338, 506, 675], [656, 1311, 1967, 2622]),
(Seedance2p5Ultra, TenEightyP, [377, 754, 1130, 1507], [1465, 2930, 4394, 5859]),
] {
for (input_seconds, expected) in [
(Some(1), minimum), (Some(4), minimum),
(None, maximum), (Some(0), maximum), (Some(30), maximum), (Some(99), maximum),
] {
let mut builder = builder(model, resolution, None);
builder.reference_videos = Some(VideoListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_reference".to_string()),
]));
builder.total_reference_video_input_seconds = input_seconds;
assert_batch_prices(builder, expected);
}
}
}
}
fn builder(model: RouterVideoModel, resolution: RouterResolution, batch: Option<u16>) -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
provider: RouterProvider::Artcraft,
model,
resolution: Some(resolution),
duration_seconds: Some(5),
video_batch_count: batch,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..Default::default()
}
}
fn assert_batch_prices(builder: GenerateVideoRequestBuilder, expected: [u64; 4]) {
for (index, expected_price) in expected.into_iter().enumerate() {
let batch = index as u16 + 1;
let mut builder = builder.clone();
builder.video_batch_count = Some(batch);
let context = format!("{:?}, {:?}, batch {batch}", builder.model, builder.resolution);
let request = builder.build2().unwrap();
let cost = request.estimate_cost().unwrap();
assert_eq!(cost.cost_in_credits, Some(expected_price), "{context}");
assert_eq!(cost.cost_in_usd_cents, Some(expected_price), "{context}");
assert_eq!(http_request(request).video_batch_count, Some(batch), "{context}");
}
}
fn http_request(request: VideoGenerationDraftOrRequest) -> OmniGenVideoCostAndGenerateRequest {
match request {
VideoGenerationDraftOrRequest::Request(request) => match request {
VideoGenerationRequest::ArtcraftSeedance2p5(state) => state.request,
VideoGenerationRequest::ArtcraftSeedance2p0(state) => state.request,
VideoGenerationRequest::ArtcraftSeedance2p0Fast(state) => state.request,
VideoGenerationRequest::ArtcraftSeedance2p0Mini(state) => state.request,
VideoGenerationRequest::ArtcraftSeedance2p5Ultra(state) => state.request,
VideoGenerationRequest::ArtcraftSeedance2p0BytePlus(state) => state.request,
VideoGenerationRequest::ArtcraftSeedance2p0BytePlusFast(state) => state.request,
VideoGenerationRequest::ArtcraftSeedance2p0BytePlusMini(state) => state.request,
VideoGenerationRequest::ArtcraftSeedance2p0BytePlusUltra(state) => state.request,
VideoGenerationRequest::ArtcraftSeedance2p0BytePlusUltraFast(state) => state.request,
VideoGenerationRequest::ArtcraftSeedance2p0BytePlusUltraMini(state) => state.request,
VideoGenerationRequest::ArtcraftHappyHorse1p0(state) => state.request,
other => panic!("Unexpected request: {other:?}"),
},
other => panic!("Expected ArtCraft request: {other:?}"),
}
}
@@ -2,8 +2,6 @@ pub(crate) mod build_common;
pub(crate) mod seedance_common;
#[cfg(test)]
mod parity_tests;
#[cfg(test)]
mod kinovi_batch_tests;
pub mod grok_imagine_video;
pub mod grok_imagine_video_1p5;
pub mod flux_3;
@@ -45,4 +43,4 @@ pub mod veo_3p1_fast;
pub mod veo_3p1_lite;
pub mod vidu_q3;
pub mod vidu_q3_turbo;
pub mod wan_3p0;
@@ -3,21 +3,27 @@ use enums::common::generation::common_video_model::CommonVideoModel as CommonVid
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::providers::artcraft::build_common::{
build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport,
build_artcraft_omni_video_request, plan_mini_batch_count, SupportedResolutions, UltraWideSupport,
};
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bp_mini::request::ArtcraftSeedance2p0BytePlusMiniRequestState;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
pub fn build_artcraft_seedance_2p0_bp_mini(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
pub fn build_artcraft_seedance_2p0_bp_mini(mut builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
// Seedance 2.0 Mini supports 480p and 720p only (higher resolutions downgrade
// to 720p), and all six aspect ratios including 21:9.
let request = build_artcraft_omni_video_request(
// Mini supports batches of 1-8, wider than the shared 1/2/4 planning.
// Take the batch out before the shared build and set it after.
let strategy = builder.request_mismatch_mitigation_strategy;
let batch_count = plan_mini_batch_count(builder.video_batch_count.take(), strategy)?;
let mut request = build_artcraft_omni_video_request(
builder,
CommonVideoModelEnum::Seedance2p0BytePlusMini,
SupportedResolutions::Fast,
UltraWideSupport::Supported,
)?;
request.video_batch_count = Some(batch_count);
let state = ArtcraftSeedance2p0BytePlusMiniRequestState { request };
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0BytePlusMini(state)))
@@ -81,17 +87,18 @@ mod tests {
}
#[test]
fn batch_count_up_to_four_passed_through() {
for batch in 1..=4u16 {
fn batch_count_up_to_eight_passed_through() {
// Mini supports batches of 1-8; execution and billing must agree.
for batch in 1..=8u16 {
let req = unwrap_request(builder_with(move |b| { b.video_batch_count = Some(batch); }));
assert_eq!(req.request.video_batch_count, Some(batch));
}
}
#[test]
fn batch_count_above_four_clamps_to_four() {
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(8); }));
assert_eq!(req.request.video_batch_count, Some(4));
fn batch_count_above_eight_clamps_to_eight() {
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(9); }));
assert_eq!(req.request.video_batch_count, Some(8));
}
#[test]
@@ -108,13 +108,18 @@ mod tests {
mod batch_pricing {
use super::*;
/// ArtCraft exposes batches of 1-4 and bills the entire planned batch.
/// Mini bills batches of 1-8 in full (the platform's 1/2/4 cap does not
/// apply). Once, batch 8 executed in full but billed as batch 4.
#[test]
fn every_batch_size_720p_5s() {
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, false), 46);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 2, false), 91);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 3, false), 137);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 4, false), 182);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 5, false), 228);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 6, false), 273);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 7, false), 319);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, false), 364);
}
#[test]
@@ -123,16 +128,23 @@ mod tests {
assert_eq!(cents(RouterResolution::FourEightyP, 5, 2, false), 36);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 3, false), 54);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 4, false), 71);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 5, false), 89);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 6, false), 107);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 7, false), 125);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 8, false), 142);
}
#[test]
fn batch_four_with_video_reference() {
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 4, true), 219);
fn batch_eight_with_video_reference() {
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, true), 438);
}
#[test]
fn batch_above_four_clamps_to_four() {
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, false), 182);
fn batch_above_eight_clamps_to_eight() {
assert_eq!(
cents(RouterResolution::SevenTwentyP, 5, 9, false),
cents(RouterResolution::SevenTwentyP, 5, 8, false),
);
}
}
@@ -148,7 +160,7 @@ mod tests {
];
for res in resolutions {
for dur in [4u16, 5, 10, 15] {
for batch in 1..=4 {
for batch in [1u16, 2, 4] {
for has_ref in [false, true] {
let cost = build_cost(res, dur, batch, has_ref);
assert_eq!(cost.cost_in_credits, cost.cost_in_usd_cents);
@@ -3,21 +3,27 @@ use enums::common::generation::common_video_model::CommonVideoModel as CommonVid
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::providers::artcraft::build_common::{
build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport,
build_artcraft_omni_video_request, plan_mini_batch_count, SupportedResolutions, UltraWideSupport,
};
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bpu_mini::request::ArtcraftSeedance2p0BytePlusUltraMiniRequestState;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
pub fn build_artcraft_seedance_2p0_bpu_mini(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
pub fn build_artcraft_seedance_2p0_bpu_mini(mut builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
// Seedance 2.0 Mini supports 480p and 720p only (higher resolutions downgrade
// to 720p), and all six aspect ratios including 21:9.
let request = build_artcraft_omni_video_request(
// Mini supports batches of 1-8, wider than the shared 1/2/4 planning.
// Take the batch out before the shared build and set it after.
let strategy = builder.request_mismatch_mitigation_strategy;
let batch_count = plan_mini_batch_count(builder.video_batch_count.take(), strategy)?;
let mut request = build_artcraft_omni_video_request(
builder,
CommonVideoModelEnum::Seedance2p0BytePlusUltraMini,
SupportedResolutions::Fast,
UltraWideSupport::Supported,
)?;
request.video_batch_count = Some(batch_count);
let state = ArtcraftSeedance2p0BytePlusUltraMiniRequestState { request };
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0BytePlusUltraMini(state)))
@@ -81,17 +87,18 @@ mod tests {
}
#[test]
fn batch_count_up_to_four_passed_through() {
for batch in 1..=4u16 {
fn batch_count_up_to_eight_passed_through() {
// Mini supports batches of 1-8; execution and billing must agree.
for batch in 1..=8u16 {
let req = unwrap_request(builder_with(move |b| { b.video_batch_count = Some(batch); }));
assert_eq!(req.request.video_batch_count, Some(batch));
}
}
#[test]
fn batch_count_above_four_clamps_to_four() {
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(8); }));
assert_eq!(req.request.video_batch_count, Some(4));
fn batch_count_above_eight_clamps_to_eight() {
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(9); }));
assert_eq!(req.request.video_batch_count, Some(8));
}
#[test]
@@ -108,13 +108,18 @@ mod tests {
mod batch_pricing {
use super::*;
/// ArtCraft exposes batches of 1-4 and bills the entire planned batch.
/// Mini bills batches of 1-8 in full (the platform's 1/2/4 cap does not
/// apply). Once, batch 8 executed in full but billed as batch 4.
#[test]
fn every_batch_size_720p_5s() {
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, false), 46);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 2, false), 91);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 3, false), 137);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 4, false), 182);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 5, false), 228);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 6, false), 273);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 7, false), 319);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, false), 364);
}
#[test]
@@ -123,16 +128,23 @@ mod tests {
assert_eq!(cents(RouterResolution::FourEightyP, 5, 2, false), 36);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 3, false), 54);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 4, false), 71);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 5, false), 89);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 6, false), 107);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 7, false), 125);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 8, false), 142);
}
#[test]
fn batch_four_with_video_reference() {
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 4, true), 219);
fn batch_eight_with_video_reference() {
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, true), 438);
}
#[test]
fn batch_above_four_clamps_to_four() {
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, false), 182);
fn batch_above_eight_clamps_to_eight() {
assert_eq!(
cents(RouterResolution::SevenTwentyP, 5, 9, false),
cents(RouterResolution::SevenTwentyP, 5, 8, false),
);
}
}
@@ -148,7 +160,7 @@ mod tests {
];
for res in resolutions {
for dur in [4u16, 5, 10, 15] {
for batch in 1..=4 {
for batch in [1u16, 2, 4] {
for has_ref in [false, true] {
let cost = build_cost(res, dur, batch, has_ref);
assert_eq!(cost.cost_in_credits, cost.cost_in_usd_cents);
@@ -3,21 +3,27 @@ use enums::common::generation::common_video_model::CommonVideoModel as CommonVid
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::providers::artcraft::build_common::{
build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport,
build_artcraft_omni_video_request, plan_mini_batch_count, SupportedResolutions, UltraWideSupport,
};
use crate::generate::generate_video::providers::artcraft::seedance_2p0_mini::request::ArtcraftSeedance2p0MiniRequestState;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
pub fn build_artcraft_seedance_2p0_mini(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
pub fn build_artcraft_seedance_2p0_mini(mut builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
// Seedance 2.0 Mini supports 480p and 720p only (higher resolutions downgrade
// to 720p), and all six aspect ratios including 21:9.
let request = build_artcraft_omni_video_request(
// Mini supports batches of 1-8, wider than the shared 1/2/4 planning.
// Take the batch out before the shared build and set it after.
let strategy = builder.request_mismatch_mitigation_strategy;
let batch_count = plan_mini_batch_count(builder.video_batch_count.take(), strategy)?;
let mut request = build_artcraft_omni_video_request(
builder,
CommonVideoModelEnum::Seedance2p0Mini,
SupportedResolutions::Fast,
UltraWideSupport::Supported,
)?;
request.video_batch_count = Some(batch_count);
let state = ArtcraftSeedance2p0MiniRequestState { request };
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0Mini(state)))
@@ -81,17 +87,18 @@ mod tests {
}
#[test]
fn batch_count_up_to_four_passed_through() {
for batch in 1..=4u16 {
fn batch_count_up_to_eight_passed_through() {
// Mini supports batches of 1-8; execution and billing must agree.
for batch in 1..=8u16 {
let req = unwrap_request(builder_with(move |b| { b.video_batch_count = Some(batch); }));
assert_eq!(req.request.video_batch_count, Some(batch));
}
}
#[test]
fn batch_count_above_four_clamps_to_four() {
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(8); }));
assert_eq!(req.request.video_batch_count, Some(4));
fn batch_count_above_eight_clamps_to_eight() {
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(9); }));
assert_eq!(req.request.video_batch_count, Some(8));
}
#[test]
@@ -108,13 +108,18 @@ mod tests {
mod batch_pricing {
use super::*;
/// ArtCraft exposes batches of 1-4 and bills the entire planned batch.
/// Mini bills batches of 1-8 in full (the platform's 1/2/4 cap does not
/// apply). Once, batch 8 executed in full but billed as batch 4.
#[test]
fn every_batch_size_720p_5s() {
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, false), 45);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 2, false), 89);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 3, false), 134);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 4, false), 178);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 5, false), 223);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 6, false), 267);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 7, false), 312);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, false), 356);
}
#[test]
@@ -123,16 +128,23 @@ mod tests {
assert_eq!(cents(RouterResolution::FourEightyP, 5, 2, false), 35);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 3, false), 52);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 4, false), 69);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 5, false), 87);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 6, false), 104);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 7, false), 121);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 8, false), 138);
}
#[test]
fn batch_four_with_video_reference() {
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 4, true), 215);
fn batch_eight_with_video_reference() {
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, true), 429);
}
#[test]
fn batch_above_four_clamps_to_four() {
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, false), 178);
fn batch_above_eight_clamps_to_eight() {
assert_eq!(
cents(RouterResolution::SevenTwentyP, 5, 9, false),
cents(RouterResolution::SevenTwentyP, 5, 8, false),
);
}
}
@@ -148,7 +160,7 @@ mod tests {
];
for res in resolutions {
for dur in [4u16, 5, 10, 15] {
for batch in 1..=4 {
for batch in [1u16, 2, 4] {
for has_ref in [false, true] {
let cost = build_cost(res, dur, batch, has_ref);
assert_eq!(cost.cost_in_credits, cost.cost_in_usd_cents);
@@ -43,11 +43,12 @@ pub fn build_artcraft_seedance_2p5(mut builder: GenerateVideoRequestBuilder) ->
}
// 2.5 allows 4-30 second durations (longer than the shared 4-15 range in
// build_common). Plan duration here and use the shared 1-4 batch planner.
// build_common) and generates a single video per request. Plan both here,
// leaving None for the shared builder, then set them after.
let duration_seconds = plan_duration(builder.duration_seconds.take(), strategy)?;
plan_batch_count(builder.video_batch_count.take(), strategy)?;
let total_input_seconds = builder.total_reference_video_input_seconds.take();
let generate_audio = builder.generate_audio.take();
let mut request = build_artcraft_omni_video_request(
builder,
@@ -56,7 +57,7 @@ pub fn build_artcraft_seedance_2p5(mut builder: GenerateVideoRequestBuilder) ->
UltraWideSupport::Supported,
)?;
request.duration_seconds = duration_seconds;
request.generate_audio = generate_audio;
request.video_batch_count = Some(1);
let state = ArtcraftSeedance2p5RequestState { request, total_input_seconds };
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p5(state)))
@@ -84,6 +85,28 @@ fn plan_duration(
}
}
// Seedance 2.5 generates a single video per request (no batching).
fn plan_batch_count(
video_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<(), ArtcraftRouterError> {
let count = video_batch_count.unwrap_or(1);
match count {
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 => Ok(()),
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "video_batch_count",
value: format!("{}", count),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(()),
},
}
}
#[cfg(test)]
mod tests {
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
@@ -128,11 +151,9 @@ mod tests {
}
#[test]
fn batch_counts_one_through_four_are_preserved() {
for batch_count in 1..=4 {
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(batch_count); }));
assert_eq!(req.request.video_batch_count, Some(batch_count));
}
fn batch_count_is_always_one() {
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(4); }));
assert_eq!(req.request.video_batch_count, Some(1));
}
#[test]
@@ -8,11 +8,10 @@ use crate::generate::generate_video::providers::artcraft::seedance_2p5::request:
/// — when reference videos are attached — the total seconds of reference
/// video input, which are billed on top of the output duration (at a lower
/// per-second rate). The TOTAL input duration clamps to the 4..=30 second
/// billing range. Batch count multiplies the total before rounding.
/// billing range. No batching.
pub struct ArtcraftSeedance2p5CostState {
pub resolution: CommonResolution,
pub duration_seconds: u16,
pub batch_count: u16,
pub has_video_references: bool,
pub maybe_total_input_seconds: Option<u16>,
}
@@ -30,7 +29,6 @@ impl ArtcraftSeedance2p5CostState {
Self {
resolution,
duration_seconds,
batch_count: request.request.video_batch_count.unwrap_or(1),
has_video_references,
maybe_total_input_seconds: request.total_input_seconds,
}
@@ -45,7 +43,6 @@ impl ArtcraftSeedance2p5CostState {
let usd_cents = seedance_2p5_usd_cents(
self.resolution,
self.duration_seconds,
self.batch_count,
self.has_video_references,
maybe_total_input_seconds,
);
@@ -97,11 +94,11 @@ mod tests {
#[test]
fn table_1080p() {
// 61.62732357 ¢/s, rounded up.
assert_eq!(cents(Some(RouterResolution::TenEightyP), 4), 247);
assert_eq!(cents(Some(RouterResolution::TenEightyP), 5), 309);
assert_eq!(cents(Some(RouterResolution::TenEightyP), 10), 617);
assert_eq!(cents(Some(RouterResolution::TenEightyP), 30), 1849);
// 45.85869386 ¢/s, rounded up.
assert_eq!(cents(Some(RouterResolution::TenEightyP), 4), 184);
assert_eq!(cents(Some(RouterResolution::TenEightyP), 5), 230);
assert_eq!(cents(Some(RouterResolution::TenEightyP), 10), 459);
assert_eq!(cents(Some(RouterResolution::TenEightyP), 30), 1376);
}
#[test]
@@ -124,16 +121,16 @@ mod tests {
assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 30, Some(10)), 290);
// 15.84362140 ¢/s × 40 = 633.74 → 634¢.
assert_eq!(cents_with_video_refs(Some(RouterResolution::SevenTwentyP), 30, Some(10)), 634);
// 38.49728574 ¢/s × 40 = 1539.89 → 1540¢.
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 30, Some(10)), 1540);
// 27.39973680 ¢/s × 40 = 1095.99 → 1096¢.
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 30, Some(10)), 1096);
}
#[test]
fn table_1080p_with_ten_input_seconds() {
// 38.49728574 ¢/s over (output + 10) billed seconds, rounded up.
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 5, Some(10)), 578);
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 10, Some(10)), 770);
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 30, Some(10)), 1540);
// 27.39973680 ¢/s over (output + 10) billed seconds, rounded up.
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 5, Some(10)), 411);
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 10, Some(10)), 548);
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 30, Some(10)), 1096);
}
#[test]
@@ -43,11 +43,12 @@ pub fn build_artcraft_seedance_2p5_u(mut builder: GenerateVideoRequestBuilder) -
}
// 2.5 allows 4-30 second durations (longer than the shared 4-15 range in
// build_common). Plan duration here and use the shared 1-4 batch planner.
// build_common) and generates a single video per request. Plan both here,
// leaving None for the shared builder, then set them after.
let duration_seconds = plan_duration(builder.duration_seconds.take(), strategy)?;
plan_batch_count(builder.video_batch_count.take(), strategy)?;
let total_input_seconds = builder.total_reference_video_input_seconds.take();
let generate_audio = builder.generate_audio.take();
let mut request = build_artcraft_omni_video_request(
builder,
@@ -56,7 +57,7 @@ pub fn build_artcraft_seedance_2p5_u(mut builder: GenerateVideoRequestBuilder) -
UltraWideSupport::Supported,
)?;
request.duration_seconds = duration_seconds;
request.generate_audio = generate_audio;
request.video_batch_count = Some(1);
let state = ArtcraftSeedance2p5UltraRequestState { request, total_input_seconds };
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p5Ultra(state)))
@@ -84,6 +85,28 @@ fn plan_duration(
}
}
// Seedance 2.5 generates a single video per request (no batching).
fn plan_batch_count(
video_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<(), ArtcraftRouterError> {
let count = video_batch_count.unwrap_or(1);
match count {
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 => Ok(()),
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "video_batch_count",
value: format!("{}", count),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(()),
},
}
}
#[cfg(test)]
mod tests {
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
@@ -128,11 +151,9 @@ mod tests {
}
#[test]
fn batch_counts_one_through_four_are_preserved() {
for batch_count in 1..=4 {
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(batch_count); }));
assert_eq!(req.request.video_batch_count, Some(batch_count));
}
fn batch_count_is_always_one() {
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(4); }));
assert_eq!(req.request.video_batch_count, Some(1));
}
#[test]
@@ -9,11 +9,10 @@ use crate::generate::generate_video::providers::artcraft::seedance_2p5_u::reques
/// reference video input, which are billed on top of the output duration (at
/// a lower per-second rate). Input seconds are clamped to
/// [`MAX_BILLED_INPUT_SECONDS`] — the model accepts at most 30 seconds of
/// video. Batch count multiplies the total before rounding.
/// video. No batching.
pub struct ArtcraftSeedance2p5UltraCostState {
pub resolution: CommonResolution,
pub duration_seconds: u16,
pub batch_count: u16,
pub has_video_references: bool,
pub maybe_total_input_seconds: Option<u16>,
}
@@ -31,7 +30,6 @@ impl ArtcraftSeedance2p5UltraCostState {
Self {
resolution,
duration_seconds,
batch_count: request.request.video_batch_count.unwrap_or(1),
has_video_references,
maybe_total_input_seconds: request.total_input_seconds,
}
@@ -46,7 +44,6 @@ impl ArtcraftSeedance2p5UltraCostState {
let usd_cents = seedance_2p5_ultra_usd_cents(
self.resolution,
self.duration_seconds,
self.batch_count,
self.has_video_references,
maybe_total_input_seconds,
);
@@ -98,11 +95,11 @@ mod tests {
#[test]
fn table_1080p() {
// 67.22980753 ¢/s, rounded up.
assert_eq!(cents(Some(RouterResolution::TenEightyP), 4), 269);
assert_eq!(cents(Some(RouterResolution::TenEightyP), 5), 337);
assert_eq!(cents(Some(RouterResolution::TenEightyP), 10), 673);
assert_eq!(cents(Some(RouterResolution::TenEightyP), 30), 2017);
// 50.10486922 ¢/s, rounded up.
assert_eq!(cents(Some(RouterResolution::TenEightyP), 4), 201);
assert_eq!(cents(Some(RouterResolution::TenEightyP), 5), 251);
assert_eq!(cents(Some(RouterResolution::TenEightyP), 10), 502);
assert_eq!(cents(Some(RouterResolution::TenEightyP), 30), 1504);
}
#[test]
@@ -144,16 +141,16 @@ mod tests {
assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 30, Some(10)), 343);
// 18.72427984 ¢/s × 40 = 748.97 → 749¢.
assert_eq!(cents_with_video_refs(Some(RouterResolution::SevenTwentyP), 30, Some(10)), 749);
// 41.84487580 ¢/s × 40 = 1673.80 → 1674¢.
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 30, Some(10)), 1674);
// 29.93674947 ¢/s × 40 = 1197.47 → 1198¢.
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 30, Some(10)), 1198);
}
#[test]
fn table_1080p_with_ten_input_seconds() {
// 41.84487580 ¢/s over (output + 10) billed seconds, rounded up.
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 5, Some(10)), 628);
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 10, Some(10)), 837);
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 30, Some(10)), 1674);
// 29.93674947 ¢/s over (output + 10) billed seconds, rounded up.
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 5, Some(10)), 450);
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 10, Some(10)), 599);
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 30, Some(10)), 1198);
}
#[test]
@@ -137,15 +137,14 @@ pub fn seedance_2p5_preview_usd_cents(
const SEEDANCE_2P5_CENTS_PER_SECOND_480P: f64 = 11.76954733;
/// Seedance 2.5 — 720p price, USD cents per second.
const SEEDANCE_2P5_CENTS_PER_SECOND_720P: f64 = 26.70781893;
/// Seedance 2.5 — 1080p price, USD cents per second (updated 2026-09-17).
const SEEDANCE_2P5_CENTS_PER_SECOND_1080P: f64 = 61.62732357;
/// Seedance 2.5 — 1080p price, USD cents per second.
const SEEDANCE_2P5_CENTS_PER_SECOND_1080P: f64 = 45.85869386;
/// Seedance 2.5 — 480p price with video references, USD cents per second.
const SEEDANCE_2P5_VIDEO_REFERENCE_CENTS_PER_SECOND_480P: f64 = 7.24279835;
/// Seedance 2.5 — 720p price with video references, USD cents per second.
const SEEDANCE_2P5_VIDEO_REFERENCE_CENTS_PER_SECOND_720P: f64 = 15.84362140;
/// Seedance 2.5 — 1080p price with video references, USD cents per second
/// (updated 2026-09-17).
const SEEDANCE_2P5_VIDEO_REFERENCE_CENTS_PER_SECOND_1080P: f64 = 38.49728574;
/// Seedance 2.5 — 1080p price with video references, USD cents per second.
const SEEDANCE_2P5_VIDEO_REFERENCE_CENTS_PER_SECOND_1080P: f64 = 27.39973680;
/// ArtCraft's price (USD cents) for Seedance 2.5.
///
@@ -155,11 +154,10 @@ const SEEDANCE_2P5_VIDEO_REFERENCE_CENTS_PER_SECOND_1080P: f64 = 38.49728574;
/// seconds = output duration. With video references, the per-second rate
/// drops but the billed seconds are the output duration PLUS the total
/// seconds of reference video input (clamped to the 4..=30 second billing
/// range). Multiply by batch count before rounding UP to a whole cent.
/// range). The fractional total is rounded UP to a whole cent. No batching.
pub fn seedance_2p5_usd_cents(
resolution: CommonResolution,
duration_seconds: u16,
batch_count: u16,
has_video_references: bool,
maybe_total_input_seconds: Option<u16>,
) -> u64 {
@@ -194,22 +192,21 @@ pub fn seedance_2p5_usd_cents(
(rate, u64::from(duration_seconds))
};
(cents_per_second * billed_seconds as f64 * batch_count as f64).ceil() as u64
(cents_per_second * billed_seconds as f64).ceil() as u64
}
/// Seedance 2.5 Ultra — 480p price, USD cents per second.
const SEEDANCE_2P5_ULTRA_CENTS_PER_SECOND_480P: f64 = 13.90946502;
/// Seedance 2.5 Ultra — 720p price, USD cents per second.
const SEEDANCE_2P5_ULTRA_CENTS_PER_SECOND_720P: f64 = 31.56378601;
/// Seedance 2.5 Ultra — 1080p price, USD cents per second (updated 2026-09-17).
const SEEDANCE_2P5_ULTRA_CENTS_PER_SECOND_1080P: f64 = 67.22980753;
/// Seedance 2.5 Ultra — 1080p price, USD cents per second.
const SEEDANCE_2P5_ULTRA_CENTS_PER_SECOND_1080P: f64 = 50.10486922;
/// Seedance 2.5 Ultra — 480p price with video references, USD cents per second.
const SEEDANCE_2P5_ULTRA_VIDEO_REFERENCE_CENTS_PER_SECOND_480P: f64 = 8.55967078;
/// Seedance 2.5 Ultra — 720p price with video references, USD cents per second.
const SEEDANCE_2P5_ULTRA_VIDEO_REFERENCE_CENTS_PER_SECOND_720P: f64 = 18.72427984;
/// Seedance 2.5 Ultra — 1080p price with video references, USD cents per
/// second (updated 2026-09-17).
const SEEDANCE_2P5_ULTRA_VIDEO_REFERENCE_CENTS_PER_SECOND_1080P: f64 = 41.84487580;
/// Seedance 2.5 Ultra — 1080p price with video references, USD cents per second.
const SEEDANCE_2P5_ULTRA_VIDEO_REFERENCE_CENTS_PER_SECOND_1080P: f64 = 29.93674947;
/// ArtCraft's price (USD cents) for Seedance 2.5 Ultra.
///
@@ -218,12 +215,11 @@ const SEEDANCE_2P5_ULTRA_VIDEO_REFERENCE_CENTS_PER_SECOND_1080P: f64 = 41.844875
/// this fallback is only a failsafe). Without video references, billed
/// seconds = output duration. With video references, the per-second rate
/// drops but the billed seconds are the output duration PLUS the total
/// seconds of reference video input. Multiply by batch count before rounding
/// UP to a whole cent.
/// seconds of reference video input. The fractional total is rounded UP to
/// a whole cent. No batching.
pub fn seedance_2p5_ultra_usd_cents(
resolution: CommonResolution,
duration_seconds: u16,
batch_count: u16,
has_video_references: bool,
maybe_total_input_seconds: Option<u16>,
) -> u64 {
@@ -258,7 +254,7 @@ pub fn seedance_2p5_ultra_usd_cents(
(rate, u64::from(duration_seconds))
};
(cents_per_second * billed_seconds as f64 * batch_count as f64).ceil() as u64
(cents_per_second * billed_seconds as f64).ceil() as u64
}
#[allow(clippy::too_many_arguments)]
@@ -1,61 +0,0 @@
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_resolution::CommonResolution;
use enums::common::generation::common_video_model::CommonVideoModel;
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::providers::artcraft::resolve::{resolve_image_ref, resolve_image_list_ref, resolve_video_list_ref, resolve_audio_list_ref};
use crate::generate::generate_video::providers::artcraft::wan_3p0::request::ArtcraftWan3p0RequestState;
use crate::generate::generate_video::providers::wan_3p0_common::normalize_wan_3p0_builder;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
pub fn build_artcraft_wan_3p0(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let builder = normalize_wan_3p0_builder(builder)?;
let prime = matches!(builder.model, RouterVideoModel::Wan3p0Prime);
let aspect = match builder.aspect_ratio.unwrap() {
RouterAspectRatio::WideSixteenByNine => CommonAspectRatio::WideSixteenByNine,
RouterAspectRatio::TallNineBySixteen => CommonAspectRatio::TallNineBySixteen,
RouterAspectRatio::WideFourByThree => CommonAspectRatio::WideFourByThree,
RouterAspectRatio::TallThreeByFour => CommonAspectRatio::TallThreeByFour,
RouterAspectRatio::Square => CommonAspectRatio::Square,
_ => unreachable!("normalized Wan aspect ratio"),
};
let resolution = match builder.resolution.unwrap() {
RouterResolution::FourEightyP => CommonResolution::FourEightyP,
RouterResolution::SevenTwentyP => CommonResolution::SevenTwentyP,
RouterResolution::TenEightyP => CommonResolution::TenEightyP,
_ => unreachable!("normalized Wan resolution"),
};
let idempotency_token = builder.get_or_generate_idempotency_token();
let request = OmniGenVideoCostAndGenerateRequest {
model: Some(if prime { CommonVideoModel::Wan3p0Prime } else { CommonVideoModel::Wan3p0 }),
idempotency_token: Some(idempotency_token),
prompt: builder.prompt,
start_frame_image_media_token: resolve_image_ref(builder.start_frame)?,
end_frame_image_media_token: resolve_image_ref(builder.end_frame)?,
reference_image_media_tokens: resolve_image_list_ref(builder.reference_images)?,
reference_video_media_tokens: resolve_video_list_ref(builder.reference_videos)?,
reference_audio_media_tokens: resolve_audio_list_ref(builder.reference_audio)?,
resolution: Some(resolution),
aspect_ratio: Some(aspect),
duration_seconds: builder.duration_seconds,
video_batch_count: Some(1),
generate_audio: builder.generate_audio,
reference_character_tokens: None,
negative_prompt: None,
bitrate: None,
maybe_output_format: None,
quality: None,
estimate_only: None,
};
let state = ArtcraftWan3p0RequestState { request };
Ok(VideoGenerationDraftOrRequest::Request(if prime {
VideoGenerationRequest::ArtcraftWan3p0Prime(state)
} else {
VideoGenerationRequest::ArtcraftWan3p0(state)
}))
}
@@ -1,88 +0,0 @@
use enums::common::generation::common_resolution::CommonResolution;
use enums::common::generation::common_video_model::CommonVideoModel;
use crate::generate::generate_video::providers::artcraft::wan_3p0::request::ArtcraftWan3p0RequestState;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
// ArtCraft USD cents per output second, in 480p/720p/1080p order.
// Pricing snapshot: 2026-09-18. Review alongside the Wan provider pricing
// after September 23; see kinovi_web_client/costs_wan_3p0.md.
const WAN_CENTS_PER_SECOND: [f64; 3] = [4.82858056, 9.65716111, 19.30948396];
const PRIME_CENTS_PER_SECOND: [f64; 3] = [9.12495283, 18.78695219, 37.57390438];
pub struct ArtcraftWan3p0CostState {
pub is_prime: bool,
pub resolution: CommonResolution,
pub duration_seconds: u16,
}
impl ArtcraftWan3p0CostState {
pub fn from_request(state: &ArtcraftWan3p0RequestState) -> Self {
Self {
is_prime: matches!(state.request.model, Some(CommonVideoModel::Wan3p0Prime)),
resolution: state.request.resolution.unwrap_or(CommonResolution::SevenTwentyP),
duration_seconds: state.request.duration_seconds.unwrap_or(5),
}
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
let rates = if self.is_prime { PRIME_CENTS_PER_SECOND } else { WAN_CENTS_PER_SECOND };
let index = match self.resolution {
CommonResolution::FourEightyP => 0,
CommonResolution::TenEightyP => 2,
_ => 1,
};
let cents = (rates[index] * f64::from(self.duration_seconds)).ceil() as u64;
VideoGenerationCostEstimate {
cost_in_credits: Some(cents), cost_in_usd_cents: Some(cents),
is_free: false, is_unlimited: false, is_rate_limited: false,
has_watermark: false, failures_are_refunded: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn regular_wan_prices_are_fixed_per_resolution_and_duration() {
let expected = [
(CommonResolution::FourEightyP, [10, 25, 49]),
(CommonResolution::SevenTwentyP, [20, 49, 97]),
(CommonResolution::TenEightyP, [39, 97, 194]),
];
for (resolution, prices) in expected {
for (duration, price) in [(2, prices[0]), (5, prices[1]), (10, prices[2])] {
let cost = ArtcraftWan3p0CostState {
is_prime: false,
resolution,
duration_seconds: duration,
}
.estimate_cost();
assert_eq!(cost.cost_in_credits, Some(price));
assert_eq!(cost.cost_in_usd_cents, Some(price));
}
}
}
#[test]
fn prime_wan_prices_are_fixed_per_resolution_and_duration() {
let expected = [
(CommonResolution::FourEightyP, [19, 46, 92]),
(CommonResolution::SevenTwentyP, [38, 94, 188]),
(CommonResolution::TenEightyP, [76, 188, 376]),
];
for (resolution, prices) in expected {
for (duration, price) in [(2, prices[0]), (5, prices[1]), (10, prices[2])] {
let cost = ArtcraftWan3p0CostState {
is_prime: true,
resolution,
duration_seconds: duration,
}
.estimate_cost();
assert_eq!(cost.cost_in_credits, Some(price));
assert_eq!(cost.cost_in_usd_cents, Some(price));
}
}
}
}
@@ -1,3 +0,0 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -1,16 +0,0 @@
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
use crate::generate::generate_video::providers::artcraft::request_common::send_artcraft_omni_video_request;
#[derive(Clone, Debug)]
pub struct ArtcraftWan3p0RequestState {
pub request: OmniGenVideoCostAndGenerateRequest,
}
impl ArtcraftWan3p0RequestState {
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
send_artcraft_omni_video_request(&self.request, client).await
}
}
@@ -134,7 +134,6 @@ fn plan_batch_count(
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 => Ok(None),
2 => Ok(Some(KinoviHappyHorse1p0BatchCount::Two)),
3 => Ok(Some(KinoviHappyHorse1p0BatchCount::Three)),
4 => Ok(Some(KinoviHappyHorse1p0BatchCount::Four)),
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
@@ -144,10 +143,10 @@ fn plan_batch_count(
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => {
Ok(Some(KinoviHappyHorse1p0BatchCount::Four))
Ok(Some(if count >= 4 { KinoviHappyHorse1p0BatchCount::Four } else { KinoviHappyHorse1p0BatchCount::Two }))
}
RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(Some(KinoviHappyHorse1p0BatchCount::Four))
Ok(Some(if count <= 2 { KinoviHappyHorse1p0BatchCount::Two } else { KinoviHappyHorse1p0BatchCount::Four }))
}
},
}
@@ -178,24 +177,6 @@ mod tests {
// ── Materialized field conversions ──
#[test]
fn supported_batches_reach_cost_estimation() {
use crate::generate::generate_video::providers::kinovi::happy_horse_1p0::cost::KinoviHappyHorse1p0CostState;
for count in 1..=4 {
let builder = GenerateVideoRequestBuilder {
video_batch_count: Some(count),
duration_seconds: Some(4),
resolution: Some(RouterResolution::SevenTwentyP),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..happy_horse_builder()
};
let draft = unwrap_draft(build_kinovi_happy_horse_1p0(builder));
let cost = KinoviHappyHorse1p0CostState::from_draft(&draft).estimate_cost();
assert_eq!(cost.cost_in_credits, Some((132.0 * f64::from(count)).round() as u64), "batch {count}");
}
}
mod materialized_field_conversions {
use super::*;
@@ -6,4 +6,3 @@ pub mod seedance_2p5;
pub mod seedance_2p5_preview;
pub(crate) mod resolve;
pub(crate) mod upload;
pub mod wan_3p0;
@@ -47,7 +47,6 @@ fn do_build_kinovi_seedance_2p0(mut builder: GenerateVideoRequestBuilder) -> Res
duration_seconds,
bitrate,
prompt,
maybe_generate_audio: builder.generate_audio,
unhandled_request_state: Some(unhandled_request_state),
})
}
@@ -153,7 +152,7 @@ fn plan_output_resolution(
}
}
// Seedance2p0 supports batch counts of 1 through 8.
// Seedance2p0 supports batch counts of 1 through 4.
fn plan_batch_count(
video_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
@@ -165,11 +164,7 @@ fn plan_batch_count(
2 => Ok(KinoviBatchCount::Two),
3 => Ok(KinoviBatchCount::Three),
4 => Ok(KinoviBatchCount::Four),
5 => Ok(KinoviBatchCount::Five),
6 => Ok(KinoviBatchCount::Six),
7 => Ok(KinoviBatchCount::Seven),
8 => Ok(KinoviBatchCount::Eight),
// 9 and above:
// 5 and above:
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
@@ -177,8 +172,8 @@ fn plan_batch_count(
value: format!("{}", count),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(KinoviBatchCount::Eight),
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(KinoviBatchCount::Eight),
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(KinoviBatchCount::Four),
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(KinoviBatchCount::Four),
},
}
}
@@ -243,24 +238,6 @@ mod tests {
// ── Materialized field conversions ──
#[test]
fn supported_batches_reach_cost_estimation() {
use crate::generate::generate_video::providers::kinovi::seedance_2p0::cost::KinoviSeedance2p0CostState;
for count in 1..=8 {
let builder = GenerateVideoRequestBuilder {
video_batch_count: Some(count),
duration_seconds: Some(4),
resolution: Some(RouterResolution::FourEightyP),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..kinovi_web_builder()
};
let draft = unwrap_draft(build_kinovi_seedance_2p0(builder));
let cost = KinoviSeedance2p0CostState::from_draft(&draft).estimate_cost();
assert_eq!(cost.cost_in_credits, Some((60.0 * f64::from(count)).round() as u64), "batch {count}");
}
}
mod materialized_field_conversions {
use super::*;
@@ -65,7 +65,6 @@ impl KinoviSeedance2p0CostState {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
};
// Enterprise tier: what generations actually cost us (our discounted
@@ -277,7 +276,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
}
.calculate_enterprise_costs();
(costs.total_cost.kinovi_credits.round() as u64, costs.total_cost.usd_cents_rounded_up)
@@ -656,7 +654,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}
}
@@ -28,8 +28,6 @@ pub struct KinoviSeedance2p0DraftState {
pub batch_count: KinoviSeedance2p0BatchCount,
pub bitrate: Option<KinoviSeedance2p0Bitrate>,
pub maybe_generate_audio: Option<bool>,
// Pending types that need to be queried.
pub unhandled_request_state: Option<KinoviSeedance2p0RemainingItems>,
}
@@ -98,7 +96,6 @@ impl KinoviSeedance2p0DraftState {
character_ids,
use_face_blur_hack: None,
bitrate: self.bitrate,
maybe_generate_audio: self.maybe_generate_audio,
};
Ok(KinoviSeedance2p0RequestState { request })
@@ -47,7 +47,6 @@ fn do_build_kinovi_seedance_2p0_fast(mut builder: GenerateVideoRequestBuilder) -
duration_seconds,
bitrate,
prompt,
maybe_generate_audio: builder.generate_audio,
unhandled_request_state: Some(unhandled_request_state),
})
}
@@ -167,7 +166,7 @@ fn plan_output_resolution(
}
}
// Seedance2p0Fast supports batch counts of 1 through 8.
// Seedance2p0Fast supports batch counts of 1 through 4.
fn plan_batch_count(
video_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
@@ -179,11 +178,7 @@ fn plan_batch_count(
2 => Ok(KinoviBatchCount::Two),
3 => Ok(KinoviBatchCount::Three),
4 => Ok(KinoviBatchCount::Four),
5 => Ok(KinoviBatchCount::Five),
6 => Ok(KinoviBatchCount::Six),
7 => Ok(KinoviBatchCount::Seven),
8 => Ok(KinoviBatchCount::Eight),
// 9 and above:
// 5 and above:
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
@@ -191,8 +186,8 @@ fn plan_batch_count(
value: format!("{}", count),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(KinoviBatchCount::Eight),
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(KinoviBatchCount::Eight),
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(KinoviBatchCount::Four),
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(KinoviBatchCount::Four),
},
}
}
@@ -259,24 +254,6 @@ mod tests {
// ── Materialized field conversions ──
#[test]
fn supported_batches_reach_cost_estimation() {
use crate::generate::generate_video::providers::kinovi::seedance_2p0_fast::cost::KinoviSeedance2p0FastCostState;
for count in 1..=8 {
let builder = GenerateVideoRequestBuilder {
video_batch_count: Some(count),
duration_seconds: Some(4),
resolution: Some(RouterResolution::FourEightyP),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..kinovi_web_fast_builder()
};
let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder));
let cost = KinoviSeedance2p0FastCostState::from_draft(&draft).estimate_cost();
assert_eq!(cost.cost_in_credits, Some((42.0 * f64::from(count)).round() as u64), "batch {count}");
}
}
mod materialized_field_conversions {
use super::*;
@@ -65,7 +65,6 @@ impl KinoviSeedance2p0FastCostState {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
};
// Enterprise tier: what generations actually cost us (our discounted
@@ -451,7 +450,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}
}
@@ -25,7 +25,6 @@ pub struct KinoviSeedance2p0FastDraftState {
pub duration_seconds: u8,
pub batch_count: KinoviSeedance2p0FastBatchCount,
pub bitrate: Option<KinoviSeedance2p0FastBitrate>,
pub maybe_generate_audio: Option<bool>,
pub unhandled_request_state: Option<KinoviSeedance2p0FastRemainingItems>,
}
@@ -94,7 +93,6 @@ impl KinoviSeedance2p0FastDraftState {
character_ids,
use_face_blur_hack: None,
bitrate: self.bitrate,
maybe_generate_audio: self.maybe_generate_audio,
};
Ok(KinoviSeedance2p0FastRequestState { request })
@@ -47,7 +47,6 @@ fn do_build_kinovi_seedance_2p0_mini(mut builder: GenerateVideoRequestBuilder) -
duration_seconds,
bitrate,
prompt,
maybe_generate_audio: builder.generate_audio,
unhandled_request_state: Some(unhandled_request_state),
})
}
@@ -65,7 +65,6 @@ impl KinoviSeedance2p0MiniCostState {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
};
// Enterprise tier: what generations actually cost us (our discounted
@@ -381,7 +380,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}
}
@@ -25,7 +25,6 @@ pub struct KinoviSeedance2p0MiniDraftState {
pub duration_seconds: u8,
pub batch_count: KinoviSeedance2p0MiniBatchCount,
pub bitrate: Option<KinoviSeedance2p0MiniBitrate>,
pub maybe_generate_audio: Option<bool>,
pub unhandled_request_state: Option<KinoviSeedance2p0MiniRemainingItems>,
}
@@ -94,7 +93,6 @@ impl KinoviSeedance2p0MiniDraftState {
character_ids,
use_face_blur_hack: None,
bitrate: self.bitrate,
maybe_generate_audio: self.maybe_generate_audio,
};
Ok(KinoviSeedance2p0MiniRequestState { request })
@@ -1,15 +1,12 @@
use kinovi_web_client::generate::video::generate_seedance_2p5::{
KinoviSeedance2p5AspectRatio as KinoviAspectRatio,
KinoviSeedance2p5BatchCount as KinoviBatchCount,
KinoviSeedance2p5Bitrate,
KinoviSeedance2p5OutputFormat,
KinoviSeedance2p5OutputResolution as KinoviOutputResolution,
};
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_bitrate::RouterBitrate;
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_output_format::RouterVideoOutputFormat;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
@@ -62,7 +59,7 @@ fn do_build_kinovi_seedance_2p5(mut builder: GenerateVideoRequestBuilder) -> Res
// The planned value below only applies to reference mode.
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)?;
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();
@@ -83,18 +80,12 @@ fn do_build_kinovi_seedance_2p5(mut builder: GenerateVideoRequestBuilder) -> Res
};
Ok(KinoviSeedance2p5DraftState {
batch_count,
aspect_ratio,
resolution,
duration_seconds,
prompt,
total_input_seconds,
maybe_bitrate,
maybe_output_format: builder.maybe_output_format.map(|format| match format {
RouterVideoOutputFormat::Mp4 => KinoviSeedance2p5OutputFormat::Mp4,
RouterVideoOutputFormat::Mov => KinoviSeedance2p5OutputFormat::Mov,
}),
maybe_generate_audio: builder.generate_audio,
unhandled_request_state: Some(unhandled_request_state),
})
}
@@ -212,23 +203,16 @@ fn plan_output_resolution(
}
}
// Seedance 2.5 supports batches of 1–8 videos.
// Seedance 2.5 generates a single video per request (no batching).
fn plan_batch_count(
video_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<KinoviBatchCount, ArtcraftRouterError> {
) -> Result<(), 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),
3 => Ok(KinoviBatchCount::Three),
4 => Ok(KinoviBatchCount::Four),
5 => Ok(KinoviBatchCount::Five),
6 => Ok(KinoviBatchCount::Six),
7 => Ok(KinoviBatchCount::Seven),
8 => Ok(KinoviBatchCount::Eight),
// Over the maximum of 8.
1 => Ok(()),
// Over the maximum of 1.
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
@@ -237,7 +221,7 @@ fn plan_batch_count(
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(KinoviBatchCount::Eight),
| RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(()),
},
}
}
@@ -291,7 +275,6 @@ mod tests {
use crate::generate::generate_video::video_generation_draft::VideoGenerationDraftRequest;
use crate::generate::generate_video::video_generation_draft_context::VideoGenerationDraftContext;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
use super::*;
@@ -328,60 +311,6 @@ mod tests {
}
}
#[tokio::test]
async fn output_options_survive_router_finalization() {
let client = RouterClient::KinoviWeb(RouterKinoviWebClient::new(
KinoviWebSession::from_cookies_string(String::new()),
));
let context = VideoGenerationDraftContext { client: Some(&client), ..Default::default() };
for model in [RouterVideoModel::Seedance2p0, RouterVideoModel::Seedance2p0Fast, RouterVideoModel::Seedance2p0Mini, RouterVideoModel::Seedance2p5] {
for generate_audio in [None, Some(false), Some(true)] {
for maybe_output_format in [None, Some(RouterVideoOutputFormat::Mp4), Some(RouterVideoOutputFormat::Mov)] {
let builder = GenerateVideoRequestBuilder {
model, generate_audio, maybe_output_format, ..base_builder()
};
let VideoGenerationDraftOrRequest::Draft(draft) = builder.build2().unwrap() else {
panic!("expected Kinovi draft");
};
let finalized = draft.finalize(context.clone()).await.unwrap();
let actual_audio = match finalized {
VideoGenerationRequest::KinoviSeedance2p0(state) => state.request.maybe_generate_audio,
VideoGenerationRequest::KinoviSeedance2p0Fast(state) => state.request.maybe_generate_audio,
VideoGenerationRequest::KinoviSeedance2p0Mini(state) => state.request.maybe_generate_audio,
VideoGenerationRequest::KinoviSeedance2p5(state) => {
let actual_format = state.request.maybe_output_format.map(|format| match format {
KinoviSeedance2p5OutputFormat::Mp4 => RouterVideoOutputFormat::Mp4,
KinoviSeedance2p5OutputFormat::Mov => RouterVideoOutputFormat::Mov,
});
assert_eq!(actual_format, maybe_output_format);
state.request.maybe_generate_audio
}
_ => panic!("expected a Seedance request"),
};
assert_eq!(actual_audio, generate_audio);
}
}
}
}
#[test]
fn supported_batches_reach_cost_estimation() {
use crate::generate::generate_video::providers::kinovi::seedance_2p5::cost::KinoviSeedance2p5CostState;
for count in 1..=8 {
let builder = GenerateVideoRequestBuilder {
video_batch_count: Some(count),
duration_seconds: Some(4),
resolution: Some(RouterResolution::FourEightyP),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..base_builder()
};
let draft = unwrap_draft(build_kinovi_seedance_2p5(builder));
let cost = KinoviSeedance2p5CostState::from_draft(&draft).estimate_cost();
assert_eq!(cost.cost_in_credits, Some((104.0 * f64::from(count)).round() as u64), "batch {count}");
}
}
mod materialized_field_conversions {
use super::*;
@@ -1,5 +1,5 @@
use kinovi_web_client::generate::video::generate_seedance_2p5::{
GenerateSeedance2p5Request, KinoviSeedance2p5BatchCount, KinoviSeedance2p5Modality, KinoviSeedance2p5OutputResolution,
GenerateSeedance2p5Request, KinoviSeedance2p5Modality, KinoviSeedance2p5OutputResolution,
MAX_BILLED_INPUT_SECONDS, MIN_BILLED_INPUT_SECONDS,
};
use kinovi_web_client::pricing::kinovi_cost_calculator_trait::KinoviCostCalculatorTrait;
@@ -18,7 +18,6 @@ use crate::generate::generate_video::providers::kinovi::seedance_2p5::request::K
pub struct KinoviSeedance2p5CostState {
pub resolution: Option<KinoviSeedance2p5OutputResolution>,
pub duration_seconds: u8,
pub batch_count: Option<KinoviSeedance2p5BatchCount>,
pub has_video_references: bool,
pub total_input_seconds: Option<u8>,
}
@@ -33,7 +32,6 @@ impl KinoviSeedance2p5CostState {
Self {
resolution: request.request.output_resolution,
duration_seconds: request.request.duration_seconds,
batch_count: request.request.batch_count,
has_video_references,
total_input_seconds: request.request.total_input_seconds,
}
@@ -51,7 +49,6 @@ impl KinoviSeedance2p5CostState {
Self {
resolution: draft.resolution,
duration_seconds: draft.duration_seconds,
batch_count: Some(draft.batch_count),
has_video_references,
total_input_seconds: draft.total_input_seconds,
}
@@ -60,7 +57,7 @@ impl KinoviSeedance2p5CostState {
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
// Delegate to the kinovi_web_client calculator via a pricing-only
// request. Only the modality's video references, the resolution, the
// duration, batch count, and the input seconds affect the price.
// duration, and the input seconds affect the price.
let modality = if self.has_video_references {
KinoviSeedance2p5Modality::Reference {
aspect_ratio: None,
@@ -92,19 +89,16 @@ impl KinoviSeedance2p5CostState {
modality,
output_resolution: self.resolution,
duration_seconds: self.duration_seconds,
batch_count: self.batch_count,
total_input_seconds,
use_face_blur_hack: None,
maybe_bitrate: None,
maybe_output_format: None,
maybe_generate_audio: None,
};
// Enterprise tier: what generations actually cost us (our discounted
// per-model credit rate at our bulk credit purchase rate).
let costs = pricing_request.calculate_enterprise_costs();
// 2.5 bills whole credits at 480p/720p, so the rounding below is exact
// there; 1080p bills fractional credits (136.23/sec) and rounds to the
// there; 1080p bills fractional credits (103.25/sec) and rounds to the
// nearest credit. The USD cents (the authoritative charge) are rounded
// up.
let cost_in_credits = costs.kinovi_credits.round() as u64;
@@ -129,7 +123,7 @@ mod tests {
use super::*;
// ── Credits without video references (26/sec at 480p, 59/sec at 720p,
// 136.23/sec at 1080p enterprise) ──
// 103.25/sec at 1080p enterprise) ──
mod credits_without_video_references {
use super::*;
@@ -149,11 +143,11 @@ mod tests {
#[test]
fn credits_1080p() {
// 136.23/sec enterprise; fractional totals round to the nearest credit
// (681.15 → 681, 1362.3 → 1362, 4086.9 → 4087).
assert_eq!(credits(Some(KinoviOutputResolution::TenEightyP), 5, false, None), 681);
assert_eq!(credits(Some(KinoviOutputResolution::TenEightyP), 10, false, None), 1362);
assert_eq!(credits(Some(KinoviOutputResolution::TenEightyP), 30, false, None), 4087);
// 103.25/sec enterprise; fractional totals round to the nearest credit
// (516.25 → 516, 1032.5 → 1033, 3097.5 → 3098).
assert_eq!(credits(Some(KinoviOutputResolution::TenEightyP), 5, false, None), 516);
assert_eq!(credits(Some(KinoviOutputResolution::TenEightyP), 10, false, None), 1033);
assert_eq!(credits(Some(KinoviOutputResolution::TenEightyP), 30, false, None), 3098);
}
#[test]
@@ -168,7 +162,7 @@ mod tests {
}
// ── Credits with video references (16/sec at 480p, 35/sec at 720p,
// 81.4/sec at 1080p enterprise, over output duration + input seconds) ──
// 61.69/sec at 1080p enterprise, over output duration + input seconds) ──
mod credits_with_video_references {
use super::*;
@@ -177,8 +171,8 @@ mod tests {
fn thirty_second_output_with_ten_input_seconds_bills_forty() {
assert_eq!(credits(Some(KinoviOutputResolution::FourEightyP), 30, true, Some(10)), 16 * 40);
assert_eq!(credits(Some(KinoviOutputResolution::SevenTwentyP), 30, true, Some(10)), 35 * 40);
// 81.4/sec enterprise × 40 = 3256 credits.
assert_eq!(credits(Some(KinoviOutputResolution::TenEightyP), 30, true, Some(10)), 3256);
// 61.69/sec enterprise × 40 = 2467.6 → 2468 credits.
assert_eq!(credits(Some(KinoviOutputResolution::TenEightyP), 30, true, Some(10)), 2468);
}
#[test]
@@ -234,7 +228,7 @@ mod tests {
has_video_references: bool,
total_input_seconds: Option<u8>,
) -> KinoviSeedance2p5CostState {
KinoviSeedance2p5CostState { batch_count: None, resolution, duration_seconds, has_video_references, total_input_seconds }
KinoviSeedance2p5CostState { resolution, duration_seconds, has_video_references, total_input_seconds }
}
fn credits(
@@ -1,6 +1,6 @@
use kinovi_web_client::generate::video::generate_seedance_2p5::{
GenerateSeedance2p5Request, KinoviSeedance2p5BatchCount, KinoviSeedance2p5AspectRatio, KinoviSeedance2p5Bitrate, KinoviSeedance2p5Modality,
KinoviSeedance2p5OutputFormat, KinoviSeedance2p5OutputResolution,
GenerateSeedance2p5Request, KinoviSeedance2p5AspectRatio, KinoviSeedance2p5Bitrate, KinoviSeedance2p5Modality,
KinoviSeedance2p5OutputResolution,
};
use crate::api::audio_list_ref::AudioListRef;
@@ -27,15 +27,12 @@ pub struct KinoviSeedance2p5DraftState {
pub resolution: Option<KinoviSeedance2p5OutputResolution>,
pub duration_seconds: u8,
pub batch_count: KinoviSeedance2p5BatchCount,
/// Calculation-only: total seconds of reference video input, summed across
/// all reference videos (each rounded up to a whole second).
pub total_input_seconds: Option<u8>,
pub maybe_bitrate: Option<KinoviSeedance2p5Bitrate>,
pub maybe_output_format: Option<KinoviSeedance2p5OutputFormat>,
pub maybe_generate_audio: Option<bool>,
pub unhandled_request_state: Option<KinoviSeedance2p5RemainingItems>,
}
@@ -114,12 +111,9 @@ impl KinoviSeedance2p5DraftState {
modality,
output_resolution: self.resolution,
duration_seconds: self.duration_seconds,
batch_count: Some(self.batch_count),
total_input_seconds: self.total_input_seconds,
use_face_blur_hack: None,
maybe_bitrate: self.maybe_bitrate,
maybe_output_format: self.maybe_output_format,
maybe_generate_audio: self.maybe_generate_audio,
};
Ok(KinoviSeedance2p5RequestState { request })
@@ -1,6 +1,5 @@
use kinovi_web_client::generate::video::generate_seedance_2p5_preview::{
KinoviSeedance2p5PreviewAspectRatio as KinoviAspectRatio,
KinoviSeedance2p5PreviewBatchCount as KinoviBatchCount,
KinoviSeedance2p5PreviewOutputResolution as KinoviOutputResolution,
};
@@ -48,7 +47,7 @@ fn do_build_kinovi_seedance_2p5_preview(mut builder: GenerateVideoRequestBuilder
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)?;
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();
@@ -62,7 +61,6 @@ fn do_build_kinovi_seedance_2p5_preview(mut builder: GenerateVideoRequestBuilder
};
Ok(KinoviSeedance2p5PreviewDraftState {
batch_count,
aspect_ratio,
resolution,
duration_seconds,
@@ -179,23 +177,16 @@ fn plan_output_resolution(
}
}
// Seedance 2.5 Preview supports batches of 1–8 videos.
// Seedance 2.5 Preview generates a single video per request (no batching).
fn plan_batch_count(
video_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<KinoviBatchCount, ArtcraftRouterError> {
) -> Result<(), 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),
3 => Ok(KinoviBatchCount::Three),
4 => Ok(KinoviBatchCount::Four),
5 => Ok(KinoviBatchCount::Five),
6 => Ok(KinoviBatchCount::Six),
7 => Ok(KinoviBatchCount::Seven),
8 => Ok(KinoviBatchCount::Eight),
// Over the maximum of 8.
1 => Ok(()),
// Over the maximum of 1.
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
@@ -204,7 +195,7 @@ fn plan_batch_count(
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(KinoviBatchCount::Eight),
| RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(()),
},
}
}
@@ -257,24 +248,6 @@ mod tests {
use super::*;
#[test]
fn supported_batches_reach_cost_estimation() {
use crate::generate::generate_video::providers::kinovi::seedance_2p5_preview::cost::KinoviSeedance2p5PreviewCostState;
for count in 1..=8 {
let builder = GenerateVideoRequestBuilder {
video_batch_count: Some(count),
duration_seconds: Some(4),
resolution: Some(RouterResolution::FourEightyP),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..preview_builder()
};
let draft = unwrap_draft(build_kinovi_seedance_2p5_preview(builder));
let cost = KinoviSeedance2p5PreviewCostState::from_draft(&draft).estimate_cost();
assert_eq!(cost.cost_in_credits, Some((168.52 * f64::from(count)).round() as u64), "batch {count}");
}
}
mod materialized_field_conversions {
use super::*;
@@ -373,20 +346,19 @@ mod tests {
}
#[test]
fn batch_over_eight_downgrades_to_eight() {
fn batch_over_one_downgrades_to_single() {
let builder = GenerateVideoRequestBuilder {
video_batch_count: Some(9),
video_batch_count: Some(4),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayLessDowngrade,
..preview_builder()
};
let draft = unwrap_draft(build_kinovi_seedance_2p5_preview(builder));
assert!(matches!(draft.batch_count, KinoviBatchCount::Eight));
assert!(build_kinovi_seedance_2p5_preview(builder).is_ok());
}
#[test]
fn batch_over_eight_error_out() {
fn batch_over_one_error_out() {
let builder = GenerateVideoRequestBuilder {
video_batch_count: Some(9),
video_batch_count: Some(4),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..preview_builder()
};
@@ -1,18 +1,18 @@
use kinovi_web_client::pricing::kinovi_cost_calculator_trait::KinoviCostCalculatorTrait;
use kinovi_web_client::generate::video::generate_seedance_2p5_preview::{
GenerateSeedance2p5PreviewRequest, KinoviSeedance2p5PreviewBatchCount, KinoviSeedance2p5PreviewOutputResolution,
GenerateSeedance2p5PreviewRequest, KinoviSeedance2p5PreviewOutputResolution,
};
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video::providers::kinovi::seedance_2p5_preview::draft::KinoviSeedance2p5PreviewDraftState;
use crate::generate::generate_video::providers::kinovi::seedance_2p5_preview::request::KinoviSeedance2p5PreviewRequestState;
/// Resolution, duration, and batch count determine the price.
/// Seedance 2.5 Preview has no video-reference surcharge.
/// Only the resolution and duration matter — Seedance 2.5 Preview has no
/// video-reference surcharge and no batching, so references never change the
/// price.
pub struct KinoviSeedance2p5PreviewCostState {
pub resolution: Option<KinoviSeedance2p5PreviewOutputResolution>,
pub duration_seconds: u8,
pub batch_count: Option<KinoviSeedance2p5PreviewBatchCount>,
}
impl KinoviSeedance2p5PreviewCostState {
@@ -20,7 +20,6 @@ impl KinoviSeedance2p5PreviewCostState {
Self {
resolution: request.request.output_resolution,
duration_seconds: request.request.duration_seconds,
batch_count: request.request.batch_count,
}
}
@@ -28,7 +27,6 @@ impl KinoviSeedance2p5PreviewCostState {
Self {
resolution: draft.resolution,
duration_seconds: draft.duration_seconds,
batch_count: Some(draft.batch_count),
}
}
@@ -36,7 +34,6 @@ impl KinoviSeedance2p5PreviewCostState {
let pricing_request = GenerateSeedance2p5PreviewRequest {
output_resolution: self.resolution,
duration_seconds: self.duration_seconds,
batch_count: self.batch_count,
// No impact on price (references never affect 2.5 Preview pricing)
prompt: String::new(),
@@ -142,7 +139,7 @@ mod tests {
// ── Helpers ──
fn cost_state(resolution: Option<KinoviOutputResolution>, duration_seconds: u8) -> KinoviSeedance2p5PreviewCostState {
KinoviSeedance2p5PreviewCostState { batch_count: None, resolution, duration_seconds }
KinoviSeedance2p5PreviewCostState { resolution, duration_seconds }
}
fn credits(resolution: Option<KinoviOutputResolution>, duration_seconds: u8) -> u64 {
@@ -1,5 +1,5 @@
use kinovi_web_client::generate::video::generate_seedance_2p5_preview::{
GenerateSeedance2p5PreviewRequest, KinoviSeedance2p5PreviewBatchCount, KinoviSeedance2p5PreviewAspectRatio,
GenerateSeedance2p5PreviewRequest, KinoviSeedance2p5PreviewAspectRatio,
KinoviSeedance2p5PreviewOutputResolution,
};
@@ -20,7 +20,6 @@ pub struct KinoviSeedance2p5PreviewDraftState {
pub aspect_ratio: KinoviSeedance2p5PreviewAspectRatio,
pub resolution: Option<KinoviSeedance2p5PreviewOutputResolution>,
pub duration_seconds: u8,
pub batch_count: KinoviSeedance2p5PreviewBatchCount,
pub unhandled_request_state: Option<KinoviSeedance2p5PreviewRemainingItems>,
}
@@ -68,7 +67,6 @@ impl KinoviSeedance2p5PreviewDraftState {
aspect_ratio: Some(self.aspect_ratio),
output_resolution: self.resolution,
duration_seconds: self.duration_seconds,
batch_count: Some(self.batch_count),
reference_image_urls,
reference_video_urls,
reference_audio_urls,
@@ -19,7 +19,9 @@ pub(crate) async fn upload_to_kinovi_web(
source_url: &str,
maybe_local_path: Option<&Path>,
) -> Result<String, ArtcraftRouterError> {
let extension = upload_extension(source_url, maybe_local_path);
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 = match maybe_local_path {
Some(local_path) => match std::fs::read(local_path) {
@@ -56,25 +58,3 @@ pub(crate) async fn upload_to_kinovi_web(
Ok(upload_response.public_url)
}
/// Keep the reference container when uploading, including QuickTime/MOV.
fn upload_extension(source_url: &str, maybe_local_path: Option<&Path>) -> String {
extract_extension_from_url_str(source_url, &ExtractExtensions::All)
.map(|ext| ext.without_period().to_string())
.or_else(|| maybe_local_path
.and_then(Path::extension)
.and_then(|ext| ext.to_str())
.map(str::to_ascii_lowercase))
.unwrap_or_else(|| "jpg".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn quicktime_upload_keeps_mov_extension() {
assert_eq!(upload_extension("https://example.com/reference.MOV?token=test", None), "mov");
assert_eq!(upload_extension("https://example.com/reference.mov", Some(Path::new("/tmp/cached.bin"))), "mov");
assert_eq!(upload_extension("https://example.com/reference", Some(Path::new("/tmp/reference.MOV"))), "mov");
}
}
@@ -1,18 +0,0 @@
use crate::api::router_video_model::RouterVideoModel;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::providers::kinovi::wan_3p0::draft::KinoviWan3p0DraftState;
use crate::generate::generate_video::providers::wan_3p0_common::normalize_wan_3p0_builder;
use crate::generate::generate_video::video_generation_draft::VideoGenerationDraftRequest;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
pub fn build_kinovi_wan_3p0(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let builder = normalize_wan_3p0_builder(builder)?;
let prime = matches!(builder.model, RouterVideoModel::Wan3p0Prime);
let draft = KinoviWan3p0DraftState { builder };
Ok(VideoGenerationDraftOrRequest::Draft(if prime {
VideoGenerationDraftRequest::KinoviWan3p0Prime(draft)
} else {
VideoGenerationDraftRequest::KinoviWan3p0(draft)
}))
}
@@ -1,29 +0,0 @@
use kinovi_web_client::pricing::cost::kinovi_fractional_generation_cost::KinoviFractionalGenerationCost;
use crate::generate::generate_video::providers::kinovi::wan_3p0::draft::KinoviWan3p0DraftState;
use crate::generate::generate_video::providers::kinovi::wan_3p0::request::KinoviWan3p0RequestState;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
pub struct KinoviWan3p0CostState {
costs: KinoviFractionalGenerationCost,
}
impl KinoviWan3p0CostState {
pub fn from_draft(draft: &KinoviWan3p0DraftState) -> Self {
// Media does not affect Wan pricing. Build a pricing-only request without
// uploading anything, keeping the planned model, duration and resolution.
Self::from_request(&draft.with_resolved_media(None, None, None, None, None))
}
pub fn from_request(request: &KinoviWan3p0RequestState) -> Self {
Self { costs: request.request.calculate_enterprise_costs() }
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
VideoGenerationCostEstimate {
cost_in_credits: Some(self.costs.kinovi_credits.round() as u64),
cost_in_usd_cents: Some(self.costs.usd_cents_rounded_up),
is_free: false, is_unlimited: false, is_rate_limited: false,
has_watermark: false, failures_are_refunded: None,
}
}
}
@@ -1,126 +0,0 @@
use kinovi_web_client::generate::video::generate_wan_3p0_text_to_video::GenerateWan3p0TextToVideoRequest;
use kinovi_web_client::generate::video::generate_wan_3p0_image_to_video::GenerateWan3p0ImageToVideoRequest;
use kinovi_web_client::generate::video::generate_wan_3p0_ref_to_video::GenerateWan3p0RefToVideoRequest;
use kinovi_web_client::generate::video::generate_wan_3p0_prime_text_to_video::GenerateWan3p0PrimeTextToVideoRequest;
use kinovi_web_client::generate::video::generate_wan_3p0_prime_image_to_video::GenerateWan3p0PrimeImageToVideoRequest;
use kinovi_web_client::generate::video::generate_wan_3p0_prime_ref_to_video::GenerateWan3p0PrimeRefToVideoRequest;
use kinovi_web_client::generate::video::wan_3p0::{KinoviWan3p0AspectRatio, KinoviWan3p0OutputResolution};
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::providers::kinovi::resolve::{audio_list_ref_into_urls_or_tokens, image_list_ref_into_urls_or_tokens, video_list_ref_into_urls_or_tokens, resolve_and_upload_list, resolve_and_upload_single};
use crate::generate::generate_video::providers::kinovi::wan_3p0::request::{KinoviWan3p0Request, KinoviWan3p0RequestState};
use crate::generate::generate_video::providers::wan_3p0_common::{modality, Wan3p0Modality};
use crate::generate::generate_video::video_generation_draft_context::VideoGenerationDraftContext;
#[derive(Clone, Debug)]
pub struct KinoviWan3p0DraftState {
/// Settings are normalized before this draft is constructed.
pub(crate) builder: GenerateVideoRequestBuilder,
}
impl KinoviWan3p0DraftState {
pub async fn to_request(&self, context: &VideoGenerationDraftContext<'_>) -> Result<KinoviWan3p0RequestState, ArtcraftRouterError> {
let client = context.get_kinovi_web_client_ref()?;
let session = &client.session;
let map = context.media_file_to_artcraft_url_map;
let paths = context.predownloaded_media_paths;
let builder = &self.builder;
let start = resolve_and_upload_single(session, builder.start_frame.clone(), map, paths).await?;
let end = resolve_and_upload_single(session, builder.end_frame.clone(), map, paths).await?;
let images = resolve_and_upload_list(session, builder.reference_images.clone().map(image_list_ref_into_urls_or_tokens), map, paths).await?;
let videos = resolve_and_upload_list(session, builder.reference_videos.clone().map(video_list_ref_into_urls_or_tokens), map, paths).await?;
let audio = resolve_and_upload_list(session, builder.reference_audio.clone().map(audio_list_ref_into_urls_or_tokens), map, paths).await?;
Ok(self.with_resolved_media(start, end, images, videos, audio))
}
pub(crate) fn with_resolved_media(
&self,
maybe_start: Option<String>,
maybe_end: Option<String>,
maybe_images: Option<Vec<String>>,
maybe_videos: Option<Vec<String>>,
maybe_audio: Option<Vec<String>>,
) -> KinoviWan3p0RequestState {
let builder = &self.builder;
let aspect = match builder.aspect_ratio.unwrap() {
RouterAspectRatio::WideSixteenByNine => KinoviWan3p0AspectRatio::Landscape16x9,
RouterAspectRatio::TallNineBySixteen => KinoviWan3p0AspectRatio::Portrait9x16,
RouterAspectRatio::WideFourByThree => KinoviWan3p0AspectRatio::Landscape4x3,
RouterAspectRatio::TallThreeByFour => KinoviWan3p0AspectRatio::Portrait3x4,
RouterAspectRatio::Square => KinoviWan3p0AspectRatio::Square1x1,
_ => unreachable!("normalized Wan aspect ratio"),
};
let resolution = match builder.resolution.unwrap() {
RouterResolution::FourEightyP => KinoviWan3p0OutputResolution::FourEightyP,
RouterResolution::SevenTwentyP => KinoviWan3p0OutputResolution::SevenTwentyP,
RouterResolution::TenEightyP => KinoviWan3p0OutputResolution::TenEightyP,
_ => unreachable!("normalized Wan resolution"),
};
let request = match (matches!(builder.model, RouterVideoModel::Wan3p0Prime), modality(builder)) {
(false, Wan3p0Modality::Text) => KinoviWan3p0Request::Text(GenerateWan3p0TextToVideoRequest {
prompt: builder.prompt.clone().unwrap_or_default(),
maybe_aspect_ratio: Some(aspect),
maybe_output_resolution: Some(resolution),
duration_seconds: builder.duration_seconds.unwrap() as u8,
maybe_generate_audio: builder.generate_audio,
..Default::default()
}),
(false, Wan3p0Modality::Image) => KinoviWan3p0Request::Image(GenerateWan3p0ImageToVideoRequest {
prompt: builder.prompt.clone().unwrap_or_default(),
maybe_aspect_ratio: Some(aspect),
maybe_output_resolution: Some(resolution),
duration_seconds: builder.duration_seconds.unwrap() as u8,
maybe_generate_audio: builder.generate_audio,
maybe_start_frame_url: maybe_start,
maybe_end_frame_url: maybe_end,
..Default::default()
}),
(false, Wan3p0Modality::Reference) => KinoviWan3p0Request::Reference(GenerateWan3p0RefToVideoRequest {
prompt: builder.prompt.clone().unwrap_or_default(),
maybe_aspect_ratio: Some(aspect),
maybe_output_resolution: Some(resolution),
duration_seconds: builder.duration_seconds.unwrap() as u8,
maybe_generate_audio: builder.generate_audio,
maybe_reference_image_urls: maybe_images,
maybe_reference_video_urls: maybe_videos,
maybe_reference_audio_urls: maybe_audio,
maybe_total_reference_video_duration_millis: builder.total_reference_video_input_seconds.map(|seconds| u32::from(seconds) * 1000),
..Default::default()
}),
(true, Wan3p0Modality::Text) => KinoviWan3p0Request::PrimeText(GenerateWan3p0PrimeTextToVideoRequest {
prompt: builder.prompt.clone().unwrap_or_default(),
maybe_aspect_ratio: Some(aspect),
maybe_output_resolution: Some(resolution),
duration_seconds: builder.duration_seconds.unwrap() as u8,
maybe_generate_audio: builder.generate_audio,
..Default::default()
}),
(true, Wan3p0Modality::Image) => KinoviWan3p0Request::PrimeImage(GenerateWan3p0PrimeImageToVideoRequest {
prompt: builder.prompt.clone().unwrap_or_default(),
maybe_aspect_ratio: Some(aspect),
maybe_output_resolution: Some(resolution),
duration_seconds: builder.duration_seconds.unwrap() as u8,
maybe_generate_audio: builder.generate_audio,
maybe_start_frame_url: maybe_start,
maybe_end_frame_url: maybe_end,
..Default::default()
}),
(true, Wan3p0Modality::Reference) => KinoviWan3p0Request::PrimeReference(GenerateWan3p0PrimeRefToVideoRequest {
prompt: builder.prompt.clone().unwrap_or_default(),
maybe_aspect_ratio: Some(aspect),
maybe_output_resolution: Some(resolution),
duration_seconds: builder.duration_seconds.unwrap() as u8,
maybe_generate_audio: builder.generate_audio,
maybe_reference_image_urls: maybe_images,
maybe_reference_video_urls: maybe_videos,
maybe_reference_audio_urls: maybe_audio,
maybe_total_reference_video_duration_millis: builder.total_reference_video_input_seconds.map(|seconds| u32::from(seconds) * 1000),
..Default::default()
}),
};
KinoviWan3p0RequestState { request }
}
}
@@ -1,4 +0,0 @@
pub mod build;
pub mod cost;
pub mod draft;
pub mod request;
@@ -1,61 +0,0 @@
use kinovi_web_client::generate::video::generate_wan_3p0_text_to_video::{generate_wan_3p0_text_to_video, GenerateWan3p0TextToVideoArgs, GenerateWan3p0TextToVideoRequest};
use kinovi_web_client::generate::video::generate_wan_3p0_image_to_video::{generate_wan_3p0_image_to_video, GenerateWan3p0ImageToVideoArgs, GenerateWan3p0ImageToVideoRequest};
use kinovi_web_client::generate::video::generate_wan_3p0_ref_to_video::{generate_wan_3p0_ref_to_video, GenerateWan3p0RefToVideoArgs, GenerateWan3p0RefToVideoRequest};
use kinovi_web_client::generate::video::generate_wan_3p0_prime_text_to_video::{generate_wan_3p0_prime_text_to_video, GenerateWan3p0PrimeTextToVideoArgs, GenerateWan3p0PrimeTextToVideoRequest};
use kinovi_web_client::generate::video::generate_wan_3p0_prime_image_to_video::{generate_wan_3p0_prime_image_to_video, GenerateWan3p0PrimeImageToVideoArgs, GenerateWan3p0PrimeImageToVideoRequest};
use kinovi_web_client::generate::video::generate_wan_3p0_prime_ref_to_video::{generate_wan_3p0_prime_ref_to_video, GenerateWan3p0PrimeRefToVideoArgs, GenerateWan3p0PrimeRefToVideoRequest};
use kinovi_web_client::pricing::cost::kinovi_fractional_generation_cost::KinoviFractionalGenerationCost;
use kinovi_web_client::pricing::kinovi_cost_calculator_trait::KinoviCostCalculatorTrait;
use crate::client::router_kinovi_web_client::RouterKinoviWebClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_video::generate_video_response::{GenerateVideoResponse, KinoviWebVideoResponsePayload};
#[derive(Clone, Debug)]
pub struct KinoviWan3p0RequestState {
pub request: KinoviWan3p0Request,
}
/// Provider bindings selected internally from the two public Wan models.
#[derive(Clone, Debug)]
pub enum KinoviWan3p0Request {
Text(GenerateWan3p0TextToVideoRequest),
Image(GenerateWan3p0ImageToVideoRequest),
Reference(GenerateWan3p0RefToVideoRequest),
PrimeText(GenerateWan3p0PrimeTextToVideoRequest),
PrimeImage(GenerateWan3p0PrimeImageToVideoRequest),
PrimeReference(GenerateWan3p0PrimeRefToVideoRequest),
}
impl KinoviWan3p0RequestState {
pub async fn send(&self, client: &RouterKinoviWebClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let session = &client.session;
let response = match &self.request {
KinoviWan3p0Request::Text(request) => generate_wan_3p0_text_to_video(GenerateWan3p0TextToVideoArgs { request: request.clone(), session, maybe_host_override: None }).await,
KinoviWan3p0Request::Image(request) => generate_wan_3p0_image_to_video(GenerateWan3p0ImageToVideoArgs { request: request.clone(), session, maybe_host_override: None }).await,
KinoviWan3p0Request::Reference(request) => generate_wan_3p0_ref_to_video(GenerateWan3p0RefToVideoArgs { request: request.clone(), session, maybe_host_override: None }).await,
KinoviWan3p0Request::PrimeText(request) => generate_wan_3p0_prime_text_to_video(GenerateWan3p0PrimeTextToVideoArgs { request: request.clone(), session, maybe_host_override: None }).await,
KinoviWan3p0Request::PrimeImage(request) => generate_wan_3p0_prime_image_to_video(GenerateWan3p0PrimeImageToVideoArgs { request: request.clone(), session, maybe_host_override: None }).await,
KinoviWan3p0Request::PrimeReference(request) => generate_wan_3p0_prime_ref_to_video(GenerateWan3p0PrimeRefToVideoArgs { request: request.clone(), session, maybe_host_override: None }).await,
}.map_err(|err| ArtcraftRouterError::Provider(ProviderError::KinoviWeb(err)))?;
Ok(GenerateVideoResponse::KinoviWeb(KinoviWebVideoResponsePayload {
order_id: response.order_id,
task_id: response.task_id,
maybe_order_ids: response.order_ids,
maybe_task_ids: response.task_ids,
}))
}
}
impl KinoviWan3p0Request {
pub fn calculate_enterprise_costs(&self) -> KinoviFractionalGenerationCost {
match self {
Self::Text(request) => request.calculate_enterprise_costs(),
Self::Image(request) => request.calculate_enterprise_costs(),
Self::Reference(request) => request.calculate_enterprise_costs(),
Self::PrimeText(request) => request.calculate_enterprise_costs(),
Self::PrimeImage(request) => request.calculate_enterprise_costs(),
Self::PrimeReference(request) => request.calculate_enterprise_costs(),
}
}
}
@@ -3,4 +3,3 @@ pub mod fal;
pub mod first_party;
pub mod grok_api;
pub mod kinovi;
pub mod wan_3p0_common;
@@ -1,213 +0,0 @@
//! Request planning shared by the ArtCraft and Kinovi Wan providers.
use crate::api::audio_list_ref::AudioListRef;
use crate::api::character_list_ref::CharacterListRef;
use crate::api::image_list_ref::ImageListRef;
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_resolution::RouterResolution;
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;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Wan3p0Modality {
Text,
Image,
Reference,
}
/// Materialize the same settings for execution and ArtCraft cost estimation.
pub fn normalize_wan_3p0_builder(
mut builder: GenerateVideoRequestBuilder,
) -> Result<GenerateVideoRequestBuilder, ArtcraftRouterError> {
let strict = matches!(builder.request_mismatch_mitigation_strategy, RequestMismatchMitigationStrategy::ErrorOut);
builder.reference_images = builder.reference_images.filter(|images| match images {
ImageListRef::MediaFileTokens(items) => !items.is_empty(),
ImageListRef::Urls(items) => !items.is_empty(),
});
builder.reference_videos = builder.reference_videos.filter(|videos| match videos {
VideoListRef::MediaFileTokens(items) => !items.is_empty(),
VideoListRef::Urls(items) => !items.is_empty(),
});
builder.reference_audio = builder.reference_audio.filter(|audio| match audio {
AudioListRef::MediaFileTokens(items) => !items.is_empty(),
AudioListRef::Urls(items) => !items.is_empty(),
});
builder.reference_character_tokens = builder.reference_character_tokens.filter(|characters| match characters {
CharacterListRef::CharacterTokens(items) => !items.is_empty(),
});
let has_keyframes = builder.start_frame.is_some() || builder.end_frame.is_some();
if has_keyframes && modality(&builder) == Wan3p0Modality::Reference {
return Err(unsupported("reference_images", "Wan cannot combine keyframes with reference media"));
}
if builder.start_frame.is_none() && builder.end_frame.is_some() {
if strict {
return Err(unsupported("end_frame", "Wan requires a starting frame"));
}
// Kinovi requires the first keyframe. With adaptation enabled, use the
// only supplied image as that keyframe, preserving image-to-video routing.
builder.start_frame = builder.end_frame.take();
}
if builder.reference_character_tokens.is_some() {
return Err(unsupported("reference_character_tokens", "Wan character references are not supported"));
}
let image_count = builder.reference_images.as_ref().map_or(0, |images| match images {
ImageListRef::MediaFileTokens(items) => items.len(),
ImageListRef::Urls(items) => items.len(),
});
let video_count = builder.reference_videos.as_ref().map_or(0, |videos| match videos {
VideoListRef::MediaFileTokens(items) => items.len(),
VideoListRef::Urls(items) => items.len(),
});
let audio_count = builder.reference_audio.as_ref().map_or(0, |audio| match audio {
AudioListRef::MediaFileTokens(items) => items.len(),
AudioListRef::Urls(items) => items.len(),
});
for (field, count, limit) in [("reference_images", image_count, 10), ("reference_videos", video_count, 5), ("reference_audio", audio_count, 5)] {
if count > limit {
return Err(unsupported(field, format!("Wan supports at most {limit} references")));
}
}
let duration = builder.duration_seconds.unwrap_or(5);
if strict && !(2..=30).contains(&duration) {
return Err(unsupported("duration_seconds", "Wan supports 2 through 30 seconds"));
}
builder.duration_seconds = Some(duration.clamp(2, 30));
match builder.video_batch_count.unwrap_or(1) {
0 => return Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 => {},
_ if strict => return Err(unsupported("video_batch_count", "Wan supports one video per request")),
_ => {},
}
builder.video_batch_count = Some(1);
builder.aspect_ratio = Some(match builder.aspect_ratio {
None | Some(RouterAspectRatio::Auto | RouterAspectRatio::Auto2k | RouterAspectRatio::Auto3k | RouterAspectRatio::Auto4k | RouterAspectRatio::Wide) => RouterAspectRatio::WideSixteenByNine,
Some(RouterAspectRatio::Tall) => RouterAspectRatio::TallNineBySixteen,
Some(RouterAspectRatio::SquareHd) => RouterAspectRatio::Square,
Some(ratio @ (RouterAspectRatio::WideSixteenByNine | RouterAspectRatio::TallNineBySixteen | RouterAspectRatio::WideFourByThree | RouterAspectRatio::TallThreeByFour | RouterAspectRatio::Square)) => ratio,
Some(ratio) if strict => return Err(unsupported("aspect_ratio", format!("{ratio:?}"))),
Some(RouterAspectRatio::WideTwentyOneByNine) => RouterAspectRatio::WideSixteenByNine,
Some(RouterAspectRatio::WideFiveByFour | RouterAspectRatio::WideThreeByTwo) => RouterAspectRatio::WideFourByThree,
Some(RouterAspectRatio::TallFourByFive | RouterAspectRatio::TallTwoByThree) => RouterAspectRatio::TallThreeByFour,
Some(RouterAspectRatio::TallNineByTwentyOne) => RouterAspectRatio::TallNineBySixteen,
});
builder.resolution = Some(match builder.resolution {
None => RouterResolution::SevenTwentyP,
Some(resolution @ (RouterResolution::FourEightyP | RouterResolution::SevenTwentyP | RouterResolution::TenEightyP)) => resolution,
Some(resolution) if strict => return Err(unsupported("resolution", format!("{resolution:?}"))),
Some(RouterResolution::HalfK) => match builder.request_mismatch_mitigation_strategy {
RequestMismatchMitigationStrategy::PayMoreUpgrade => RouterResolution::SevenTwentyP,
_ => RouterResolution::FourEightyP,
},
Some(RouterResolution::OneK) => match builder.request_mismatch_mitigation_strategy {
RequestMismatchMitigationStrategy::PayLessDowngrade => RouterResolution::SevenTwentyP,
_ => RouterResolution::TenEightyP,
},
Some(_) => RouterResolution::TenEightyP,
});
if video_count > 0 {
// Without measurements, each reference still consumes at least one second.
let seconds = builder.total_reference_video_input_seconds.unwrap_or(video_count as u16);
if seconds < video_count as u16 || seconds > 15 || seconds + builder.duration_seconds.unwrap() > 30 {
return Err(unsupported("duration_seconds", "Wan reference videos must total at most 15s, and reference plus output duration must not exceed 30s"));
}
}
Ok(builder)
}
pub fn modality(builder: &GenerateVideoRequestBuilder) -> Wan3p0Modality {
if builder.reference_images.is_some() || builder.reference_videos.is_some() || builder.reference_audio.is_some() {
Wan3p0Modality::Reference
} else if builder.start_frame.is_some() || builder.end_frame.is_some() {
Wan3p0Modality::Image
} else {
Wan3p0Modality::Text
}
}
fn unsupported(field: &'static str, value: impl Into<String>) -> ArtcraftRouterError {
ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { field, value: value.into() })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::image_ref::ImageRef;
use crate::api::router_video_model::RouterVideoModel;
fn builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: RouterVideoModel::Wan3p0,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
..Default::default()
}
}
#[test]
fn modality_selects_text_keyframe_or_reference_endpoint() {
let text = builder();
assert_eq!(modality(&text), Wan3p0Modality::Text);
let keyframe = GenerateVideoRequestBuilder {
start_frame: Some(ImageRef::Url("https://example.com/start.png".to_string())),
..text.clone()
};
assert_eq!(modality(&keyframe), Wan3p0Modality::Image);
let reference = GenerateVideoRequestBuilder {
reference_images: Some(ImageListRef::Urls(vec!["https://example.com/ref.png".to_string()])),
..text
};
assert_eq!(modality(&reference), Wan3p0Modality::Reference);
}
#[test]
fn normalization_defaults_and_adapts_settings_for_kinovi() {
let normalized = normalize_wan_3p0_builder(GenerateVideoRequestBuilder {
model: RouterVideoModel::Wan3p0Prime,
duration_seconds: Some(99),
video_batch_count: Some(4),
aspect_ratio: Some(RouterAspectRatio::WideTwentyOneByNine),
resolution: Some(RouterResolution::FourK),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
..Default::default()
})
.unwrap();
assert_eq!(normalized.duration_seconds, Some(30));
assert_eq!(normalized.video_batch_count, Some(1));
assert!(matches!(normalized.aspect_ratio, Some(RouterAspectRatio::WideSixteenByNine)));
assert!(matches!(normalized.resolution, Some(RouterResolution::TenEightyP)));
}
#[test]
fn strict_mode_rejects_unsupported_shape() {
let result = normalize_wan_3p0_builder(GenerateVideoRequestBuilder {
resolution: Some(RouterResolution::FourK),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..builder()
});
assert!(result.is_err());
}
#[test]
fn reference_video_budget_uses_measured_total_when_available() {
let result = normalize_wan_3p0_builder(GenerateVideoRequestBuilder {
duration_seconds: Some(20),
total_reference_video_input_seconds: Some(11),
reference_videos: Some(VideoListRef::Urls(vec!["https://example.com/ref.mp4".to_string()])),
..builder()
});
assert!(result.is_err());
let result = normalize_wan_3p0_builder(GenerateVideoRequestBuilder {
duration_seconds: Some(20),
total_reference_video_input_seconds: Some(10),
reference_videos: Some(VideoListRef::Urls(vec!["https://example.com/ref.mp4".to_string()])),
..builder()
});
assert!(result.is_ok());
}
}
@@ -1,5 +1,3 @@
use crate::generate::generate_video::providers::kinovi::wan_3p0::cost::KinoviWan3p0CostState;
use crate::generate::generate_video::providers::kinovi::wan_3p0::draft::KinoviWan3p0DraftState;
use crate::api::router_provider::RouterProvider;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
@@ -23,8 +21,6 @@ use crate::generate::generate_video::video_generation_request::VideoGenerationRe
*/
#[derive(Clone, Debug)]
pub enum VideoGenerationDraftRequest {
KinoviWan3p0(KinoviWan3p0DraftState),
KinoviWan3p0Prime(KinoviWan3p0DraftState),
KinoviHappyHorse1p0(KinoviHappyHorse1p0DraftState),
KinoviSeedance2p0(KinoviSeedance2p0DraftState),
KinoviSeedance2p0Fast(KinoviSeedance2p0FastDraftState),
@@ -37,8 +33,6 @@ impl VideoGenerationDraftRequest {
pub fn get_provider(&self) -> RouterProvider {
match self {
Self::KinoviWan3p0(_) => RouterProvider::KinoviWeb,
Self::KinoviWan3p0Prime(_) => RouterProvider::KinoviWeb,
Self::KinoviHappyHorse1p0(_) => RouterProvider::KinoviWeb,
Self::KinoviSeedance2p0(_) => RouterProvider::KinoviWeb,
Self::KinoviSeedance2p0Fast(_) => RouterProvider::KinoviWeb,
@@ -51,8 +45,6 @@ impl VideoGenerationDraftRequest {
/// Return a cost estimate to fulfill the request.
pub fn estimate_cost(&self) -> Result<VideoGenerationCostEstimate, ArtcraftRouterError> {
match self {
VideoGenerationDraftRequest::KinoviWan3p0(state) => Ok(KinoviWan3p0CostState::from_draft(state).estimate_cost()),
VideoGenerationDraftRequest::KinoviWan3p0Prime(state) => Ok(KinoviWan3p0CostState::from_draft(state).estimate_cost()),
VideoGenerationDraftRequest::KinoviHappyHorse1p0(draft) => Ok(KinoviHappyHorse1p0CostState::from_draft(draft).estimate_cost()),
VideoGenerationDraftRequest::KinoviSeedance2p0(draft) => Ok(KinoviSeedance2p0CostState::from_draft(draft).estimate_cost()),
VideoGenerationDraftRequest::KinoviSeedance2p0Fast(draft) => Ok(KinoviSeedance2p0FastCostState::from_draft(draft).estimate_cost()),
@@ -66,12 +58,6 @@ impl VideoGenerationDraftRequest {
/// This may involve uploading media to the provider.
pub async fn finalize(self, draft_context: VideoGenerationDraftContext<'_>) -> Result<VideoGenerationRequest, ArtcraftRouterError> {
match self {
VideoGenerationDraftRequest::KinoviWan3p0(draft) => {
Ok(VideoGenerationRequest::KinoviWan3p0(draft.to_request(&draft_context).await?))
},
VideoGenerationDraftRequest::KinoviWan3p0Prime(draft) => {
Ok(VideoGenerationRequest::KinoviWan3p0Prime(draft.to_request(&draft_context).await?))
},
VideoGenerationDraftRequest::KinoviHappyHorse1p0(mut draft) => {
let result = draft.to_request(&draft_context).await?;
Ok(VideoGenerationRequest::KinoviHappyHorse1p0(result))
@@ -1,7 +1,3 @@
use crate::generate::generate_video::providers::kinovi::wan_3p0::cost::KinoviWan3p0CostState;
use crate::generate::generate_video::providers::kinovi::wan_3p0::request::KinoviWan3p0RequestState;
use crate::generate::generate_video::providers::artcraft::wan_3p0::cost::ArtcraftWan3p0CostState;
use crate::generate::generate_video::providers::artcraft::wan_3p0::request::ArtcraftWan3p0RequestState;
use crate::api::router_provider::RouterProvider;
use crate::client::router_client::RouterClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
@@ -152,8 +148,6 @@ pub enum VideoGenerationRequest {
ArtcraftGrokImagineVideo1p5(ArtcraftGrokImagineVideo1p5RequestState),
ArtcraftFlux3(ArtcraftFlux3RequestState),
ArtcraftFlux3Draft(ArtcraftFlux3DraftRequestState),
ArtcraftWan3p0(ArtcraftWan3p0RequestState),
ArtcraftWan3p0Prime(ArtcraftWan3p0RequestState),
ArtcraftHappyHorse1p0(ArtcraftHappyHorse1p0RequestState),
ArtcraftKling16Pro(ArtcraftKling16ProRequestState),
ArtcraftKling21Master(ArtcraftKling21MasterRequestState),
@@ -213,8 +207,6 @@ pub enum VideoGenerationRequest {
FalViduQ3Turbo(FalViduQ3TurboRequestState),
GrokApiGrokImagineVideo(GrokApiGrokImagineVideoRequestState),
GrokApiGrokImagineVideo1p5(GrokApiGrokImagineVideo1p5RequestState),
KinoviWan3p0(KinoviWan3p0RequestState),
KinoviWan3p0Prime(KinoviWan3p0RequestState),
KinoviHappyHorse1p0(KinoviHappyHorse1p0RequestState),
KinoviSeedance2p0(KinoviSeedance2p0RequestState),
KinoviSeedance2p0Fast(KinoviSeedance2p0FastRequestState),
@@ -229,8 +221,6 @@ impl VideoGenerationRequest {
match self {
Self::ArtcraftGrokImagineVideo(_) => RouterProvider::Artcraft,
Self::ArtcraftGrokImagineVideo1p5(_) => RouterProvider::Artcraft,
Self::ArtcraftWan3p0(_) => RouterProvider::Artcraft,
Self::ArtcraftWan3p0Prime(_) => RouterProvider::Artcraft,
Self::ArtcraftHappyHorse1p0(_) => RouterProvider::Artcraft,
Self::ArtcraftKling16Pro(_) => RouterProvider::Artcraft,
Self::ArtcraftKling21Master(_) => RouterProvider::Artcraft,
@@ -292,8 +282,6 @@ impl VideoGenerationRequest {
Self::FalViduQ3Turbo(_) => RouterProvider::Fal,
Self::GrokApiGrokImagineVideo(_) => RouterProvider::GrokApi,
Self::GrokApiGrokImagineVideo1p5(_) => RouterProvider::GrokApi,
Self::KinoviWan3p0(_) => RouterProvider::KinoviWeb,
Self::KinoviWan3p0Prime(_) => RouterProvider::KinoviWeb,
Self::KinoviHappyHorse1p0(_) => RouterProvider::KinoviWeb,
Self::KinoviSeedance2p0(_) => RouterProvider::KinoviWeb,
Self::KinoviSeedance2p0Fast(_) => RouterProvider::KinoviWeb,
@@ -308,8 +296,6 @@ impl VideoGenerationRequest {
match self {
VideoGenerationRequest::ArtcraftGrokImagineVideo(request) => Ok(ArtcraftGrokImagineVideoCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftGrokImagineVideo1p5(request) => ArtcraftGrokImagineVideo1p5CostState::from_request(request).estimate_cost(),
VideoGenerationRequest::ArtcraftWan3p0(state) => Ok(ArtcraftWan3p0CostState::from_request(state).estimate_cost()),
VideoGenerationRequest::ArtcraftWan3p0Prime(state) => Ok(ArtcraftWan3p0CostState::from_request(state).estimate_cost()),
VideoGenerationRequest::ArtcraftHappyHorse1p0(request) => Ok(ArtcraftHappyHorse1p0CostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftKling16Pro(request) => Ok(ArtcraftKling16ProCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftKling21Master(request) => Ok(ArtcraftKling21MasterCostState::from_request(request).estimate_cost()),
@@ -371,8 +357,6 @@ impl VideoGenerationRequest {
VideoGenerationRequest::FalViduQ3Turbo(request) => Ok(FalViduQ3TurboCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::GrokApiGrokImagineVideo(request) => Ok(GrokApiGrokImagineVideoCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::GrokApiGrokImagineVideo1p5(request) => GrokApiGrokImagineVideo1p5CostState::from_request(request).estimate_cost(),
VideoGenerationRequest::KinoviWan3p0(state) => Ok(KinoviWan3p0CostState::from_request(state).estimate_cost()),
VideoGenerationRequest::KinoviWan3p0Prime(state) => Ok(KinoviWan3p0CostState::from_request(state).estimate_cost()),
VideoGenerationRequest::KinoviHappyHorse1p0(request) => Ok(KinoviHappyHorse1p0CostState::from_request(request).estimate_cost()),
VideoGenerationRequest::KinoviSeedance2p0(request) => Ok(KinoviSeedance2p0CostState::from_request(request).estimate_cost()),
VideoGenerationRequest::KinoviSeedance2p0Fast(request) => Ok(KinoviSeedance2p0FastCostState::from_request(request).estimate_cost()),
@@ -394,12 +378,6 @@ impl VideoGenerationRequest {
let client_ref = client.get_artcraft_client_ref()?;
request.send(client_ref).await
},
VideoGenerationRequest::ArtcraftWan3p0(request) => {
request.send(client.get_artcraft_client_ref()?).await
},
VideoGenerationRequest::ArtcraftWan3p0Prime(request) => {
request.send(client.get_artcraft_client_ref()?).await
},
VideoGenerationRequest::ArtcraftHappyHorse1p0(request) => {
let client_ref = client.get_artcraft_client_ref()?;
request.send(client_ref).await
@@ -644,12 +622,6 @@ impl VideoGenerationRequest {
let client_ref = client.get_grok_api_client_ref()?;
request.send(client_ref).await
},
VideoGenerationRequest::KinoviWan3p0(request) => {
request.send(client.get_kinovi_web_client_ref()?).await
},
VideoGenerationRequest::KinoviWan3p0Prime(request) => {
request.send(client.get_kinovi_web_client_ref()?).await
},
VideoGenerationRequest::KinoviHappyHorse1p0(request) => {
let client_ref = client.get_kinovi_web_client_ref()?;
request.send(client_ref).await
@@ -352,7 +352,6 @@ pub fn base_video_request() -> GenerateVideoRequestBuilder {
resolution: None,
aspect_ratio: None,
bitrate: None,
maybe_output_format: None,
duration_seconds: None,
video_batch_count: None,
generate_audio: None,
@@ -1,96 +0,0 @@
# Wan 3.0 pricing and September 23 promotion
Last verified: **2026-09-18**. Recheck after **2026-09-23** (starting September 24).
The code and tests intentionally use the promotional Wan 3.0 rates below.
Prices stay at these values until a reviewed code change; there is no automatic
date-based switch. Kinovi advertises 30% off until September 23 without specifying
the cutoff time or timezone. Confirm the live prices when updating.
[Kinovi promotion and pricing](https://kinovi.ai/models/wan3-text-to-video)
## Rates currently used
All values are **Kinovi credits per output second**. Each family's rates apply
to text-to-video, image-to-video, and reference-to-video. Reference media does
not add billed input seconds. Sources for regular Wan:
[text](https://kinovi.ai/models/wan3-text-to-video),
[image](https://kinovi.ai/models/wan3-image-to-video),
[reference](https://kinovi.ai/models/wan3-ref-to-video).
| Family | Resolution | Credits/second | Current status |
|---------------|------------|----------------|--------------------------------|
| Wan 3.0 | 480p | 9.98 | Promotional until Sep 23 |
| Wan 3.0 | 720p | 19.96 | Promotional until Sep 23 |
| Wan 3.0 | 1080p | 39.91 | Promotional until Sep 23 |
| Wan 3.0 Prime | 480p | 18.86 | No advertised Sep 23 promotion |
| Wan 3.0 Prime | 720p | 38.83 | No advertised Sep 23 promotion |
| Wan 3.0 Prime | 1080p | 77.66 | No advertised Sep 23 promotion |
Prime sources:
[text](https://kinovi.ai/models/wan3-prime-text-to-video),
[image](https://kinovi.ai/models/wan3-prime-image-to-video),
[reference](https://kinovi.ai/models/wan3-prime-ref-to-video).
Prime 480p for 30 seconds is **565.8 credits**; the original supplied reference
example's 568.8 was a typo. Recheck Prime independently; the regular Wan
promotion ending does not establish a Prime price change.
The promotional rates already include the discount. Total credits are the
selected rate multiplied by output seconds. Both pricing tiers currently use
the same credit rates; only their credit purchase rates differ. The client uses
192.98 credits/USD for Consumer and 243.16 for Enterprise, defined in
[pricing constants](src/pricing/constants.rs).
[KinoviPricingTier::cost_from_credits](src/pricing/kinovi_pricing_tier.rs) rounds
the total to hundredths of a credit and calculates fractional, floor, and ceiling
USD cents. Use those purchase rates for client costs; Kinovi's displayed USD
prices use a different conversion.
## Advertised non-promotional rates to recheck
These are the crossed-out prices shown alongside the promotion on September 18.
They are **reference values for the later update**, not active calculator rates
or a guarantee of what Kinovi will charge after the promotion.
The same values appear on the regular Wan
[text](https://kinovi.ai/models/wan3-text-to-video),
[image](https://kinovi.ai/models/wan3-image-to-video), and
[reference](https://kinovi.ai/models/wan3-ref-to-video) pricing pages.
| Resolution | Current promotional credits/second | Advertised non-promotional credits/second |
|------------|------------------------------------|-------------------------------------------|
| 480p | 9.98 | 14.2571 |
| 720p | 19.96 | 28.5143 |
| 1080p | 39.91 | 57.0143 |
## How to update after September 23
1. Reopen the six model pages above and confirm the promotion ended, the exact
credit rates, and total-credit rounding. Check consumer quotes for multiple
durations and every resolution; the web client uses the consumer workflow.
Record the verification date and evidence in this document.
2. Update `WAN_CREDIT_HUNDREDTHS_PER_SECOND` and `Wan3p0Model::calculate_costs`
in [wan_3p0.rs](src/generate/video/wan_3p0.rs). The current table is
`[998, 1996, 3991]` in 480p/720p/1080p order. **The advertised replacement
rates have four decimal places:** the existing hundredths representation
cannot preserve them. If confirmed, use a sufficient fixed-point scale
(for example 10,000), rename the constants to match their units, and adjust
the final divisor. Preserve rate precision until after multiplying by
duration; verify provider rounding before changing the shared credit
conversion. If both families change scale, rescale the Prime integers while
preserving Prime's monetary rates unless a separate price change is confirmed.
3. Update the literal expectations in
[wan_3p0_tests.rs](src/generate/video/wan_3p0_tests.rs): `WAN_CREDITS`,
`WAN_CENTS`, the `199.6` regular `reference_media_tests!` argument (720p/10s),
and the `99.8` regular image `check!` argument (720p/5s). The default-price
and seed/audio tests already read the literal tables. Keep expected prices
independent of production rate constants and assert both pricing tiers.
Update Prime expectations only if its verified monetary rates change.
4. Update this document and the dated comments beside the production constants
and test tables. Keep the promotional snapshot as historical context.
5. Run from the repository root:
```sh
cargo test --offline -p kinovi_web_client --lib wan_3p0_tests
cargo test --offline -p kinovi_web_client --lib
```
The Wan HTTP tests use a local server and need permission to bind localhost.
These commands leave the existing ignored live-generation tests ignored.
@@ -49,7 +49,6 @@ pub enum KinoviHappyHorse1p0OutputResolution {
pub enum KinoviHappyHorse1p0BatchCount {
One,
Two,
Three,
Four,
}
@@ -80,7 +79,6 @@ impl GenerateHappyHorse1p0Request {
let batch_multiplier: u64 = match self.batch_count {
None | Some(KinoviHappyHorse1p0BatchCount::One) => 1,
Some(KinoviHappyHorse1p0BatchCount::Two) => 2,
Some(KinoviHappyHorse1p0BatchCount::Three) => 3,
Some(KinoviHappyHorse1p0BatchCount::Four) => 4,
};
@@ -132,8 +130,6 @@ pub async fn generate_happy_horse_1p0(
character_ids: None,
use_face_blur_hack: Some(false),
bitrate: None,
maybe_output_format: None,
maybe_generate_audio: None,
};
let raw_response = workflow_run_task(WorkflowRunTaskArgs {
@@ -176,7 +172,6 @@ fn map_batch_count(bc: Option<KinoviHappyHorse1p0BatchCount>) -> KinoviBatchCoun
match bc {
Some(KinoviHappyHorse1p0BatchCount::One) | None => KinoviBatchCountRaw::One,
Some(KinoviHappyHorse1p0BatchCount::Two) => KinoviBatchCountRaw::Two,
Some(KinoviHappyHorse1p0BatchCount::Three) => KinoviBatchCountRaw::Three,
Some(KinoviHappyHorse1p0BatchCount::Four) => KinoviBatchCountRaw::Four,
}
}
@@ -278,68 +273,11 @@ mod tests {
mod batch_tests {
use super::*;
const BATCHES: [KinoviHappyHorse1p0BatchCount; 4] = [
KinoviHappyHorse1p0BatchCount::One,
KinoviHappyHorse1p0BatchCount::Two,
KinoviHappyHorse1p0BatchCount::Three,
KinoviHappyHorse1p0BatchCount::Four,
];
#[test]
fn fixed_five_second_batch_prices() {
// Each pair is (credits, USD cents rounded up), for batches 1 through 4.
let cases = [
(KinoviHappyHorse1p0OutputResolution::SevenTwentyP, [
(165, 68), (330, 136), (495, 204), (660, 272),
]),
(KinoviHappyHorse1p0OutputResolution::TenEightyP, [
(330, 136), (660, 272), (990, 408), (1320, 544),
]),
];
for (resolution, prices) in cases {
for (batch, (credits, usd_cents)) in BATCHES.into_iter().zip(prices) {
let cost = make_request(5, Some(resolution), Some(batch)).calculate_costs();
assert_eq!(
(cost.kinovi_credits, cost.usd_cents_rounded_up),
(credits, usd_cents),
"{resolution:?} {batch:?}",
);
}
}
}
#[test]
fn all_batches_scale_every_resolution_and_round_the_total() {
let batches = [
(None, 1),
(Some(KinoviHappyHorse1p0BatchCount::One), 1),
(Some(KinoviHappyHorse1p0BatchCount::Two), 2),
(Some(KinoviHappyHorse1p0BatchCount::Three), 3),
(Some(KinoviHappyHorse1p0BatchCount::Four), 4),
];
for resolution in [
None,
Some(KinoviHappyHorse1p0OutputResolution::SevenTwentyP),
Some(KinoviHappyHorse1p0OutputResolution::TenEightyP),
] {
for duration in [3, 5, 15] {
let single = make_request(duration, resolution, None).calculate_costs();
for (batch, count) in batches {
let batched = make_request(duration, resolution, batch).calculate_costs();
let context = format!("{resolution:?} {duration}s {batch:?}");
let expected_credits = single.kinovi_credits * count;
assert_eq!(batched.kinovi_credits, expected_credits, "{context}: credits");
assert!(
(batched.usd_cents_fractional - single.usd_cents_fractional * count as f64).abs() < 1e-8,
"{context}: fractional cents",
);
// Happy Horse currently uses the legacy 243 credits/dollar conversion.
let numerator = expected_credits * 100;
assert_eq!(batched.usd_cents_rounded_up, numerator.div_ceil(243), "{context}: rounded up");
assert_eq!(batched.usd_cents_rounded_down, numerator / 243, "{context}: rounded down");
}
}
}
fn batch_1_is_base() {
let base = r720(5).calculate_costs().kinovi_credits;
let explicit = make_request(5, None, Some(KinoviHappyHorse1p0BatchCount::One)).calculate_costs().kinovi_credits;
assert_eq!(base, explicit);
}
#[test]
@@ -38,8 +38,6 @@ pub struct GenerateSeedance2p0Request {
/// Output video bitrate. None defaults to "standard"; `High` requests a
/// higher bitrate. Does not affect cost.
pub bitrate: Option<KinoviSeedance2p0Bitrate>,
/// Whether to generate audio. None leaves the provider default unchanged.
pub maybe_generate_audio: Option<bool>,
}
// ── Enums ──
@@ -68,10 +66,6 @@ pub enum KinoviSeedance2p0BatchCount {
Two,
Three,
Four,
Five,
Six,
Seven,
Eight,
}
#[derive(Debug, Clone, Copy)]
@@ -170,10 +164,6 @@ impl KinoviCostCalculatorTrait for GenerateSeedance2p0Request {
Some(KinoviSeedance2p0BatchCount::Two) => 2.0,
Some(KinoviSeedance2p0BatchCount::Three) => 3.0,
Some(KinoviSeedance2p0BatchCount::Four) => 4.0,
Some(KinoviSeedance2p0BatchCount::Five) => 5.0,
Some(KinoviSeedance2p0BatchCount::Six) => 6.0,
Some(KinoviSeedance2p0BatchCount::Seven) => 7.0,
Some(KinoviSeedance2p0BatchCount::Eight) => 8.0,
};
let output_seconds = f64::from(self.duration_seconds) * batch_multiplier;
@@ -231,8 +221,6 @@ pub async fn generate_seedance_2p0(
character_ids: req.character_ids,
use_face_blur_hack: req.use_face_blur_hack,
bitrate: map_bitrate(req.bitrate),
maybe_output_format: None,
maybe_generate_audio: req.maybe_generate_audio,
};
let raw_response = workflow_run_task(WorkflowRunTaskArgs {
@@ -282,10 +270,6 @@ fn map_batch_count(bc: Option<KinoviSeedance2p0BatchCount>) -> KinoviBatchCountR
Some(KinoviSeedance2p0BatchCount::Two) => KinoviBatchCountRaw::Two,
Some(KinoviSeedance2p0BatchCount::Three) => KinoviBatchCountRaw::Three,
Some(KinoviSeedance2p0BatchCount::Four) => KinoviBatchCountRaw::Four,
Some(KinoviSeedance2p0BatchCount::Five) => KinoviBatchCountRaw::Five,
Some(KinoviSeedance2p0BatchCount::Six) => KinoviBatchCountRaw::Six,
Some(KinoviSeedance2p0BatchCount::Seven) => KinoviBatchCountRaw::Seven,
Some(KinoviSeedance2p0BatchCount::Eight) => KinoviBatchCountRaw::Eight,
}
}
@@ -624,165 +608,18 @@ mod tests {
mod batch_tests {
use super::*;
use crate::test_utils::assert_batch_cost::assert_batch_cost;
const BATCHES: [KinoviSeedance2p0BatchCount; 8] = [
KinoviSeedance2p0BatchCount::One,
KinoviSeedance2p0BatchCount::Two,
KinoviSeedance2p0BatchCount::Three,
KinoviSeedance2p0BatchCount::Four,
KinoviSeedance2p0BatchCount::Five,
KinoviSeedance2p0BatchCount::Six,
KinoviSeedance2p0BatchCount::Seven,
KinoviSeedance2p0BatchCount::Eight,
];
// Each pair is (credits, USD cents rounded up), for batches 1 through 8.
// Pin literal totals so rate changes fail independently of scaling checks.
#[test]
fn fixed_five_second_batch_prices_without_video_reference() {
let cases = [
(KinoviSeedance2p0OutputResolution::FourEightyP, KinoviPricingTier::Consumer, [
(75.0, 39), (150.0, 78), (225.0, 117), (300.0, 156),
(375.0, 195), (450.0, 234), (525.0, 273), (600.0, 311),
]),
(KinoviSeedance2p0OutputResolution::SevenTwentyP, KinoviPricingTier::Consumer, [
(200.0, 104), (400.0, 208), (600.0, 311), (800.0, 415),
(1000.0, 519), (1200.0, 622), (1400.0, 726), (1600.0, 830),
]),
(KinoviSeedance2p0OutputResolution::TenEightyP, KinoviPricingTier::Consumer, [
(450.0, 234), (900.0, 467), (1350.0, 700), (1800.0, 933),
(2250.0, 1166), (2700.0, 1400), (3150.0, 1633), (3600.0, 1866),
]),
(KinoviSeedance2p0OutputResolution::FourK, KinoviPricingTier::Consumer, [
(1000.0, 519), (2000.0, 1037), (3000.0, 1555), (4000.0, 2073),
(5000.0, 2591), (6000.0, 3110), (7000.0, 3628), (8000.0, 4146),
]),
(KinoviSeedance2p0OutputResolution::FourEightyP, KinoviPricingTier::Enterprise, [
(75.0, 31), (150.0, 62), (225.0, 93), (300.0, 124),
(375.0, 155), (450.0, 186), (525.0, 216), (600.0, 247),
]),
(KinoviSeedance2p0OutputResolution::SevenTwentyP, KinoviPricingTier::Enterprise, [
(189.5, 78), (379.0, 156), (568.5, 234), (758.0, 312),
(947.5, 390), (1137.0, 468), (1326.5, 546), (1516.0, 624),
]),
(KinoviSeedance2p0OutputResolution::TenEightyP, KinoviPricingTier::Enterprise, [
(450.0, 186), (900.0, 371), (1350.0, 556), (1800.0, 741),
(2250.0, 926), (2700.0, 1111), (3150.0, 1296), (3600.0, 1481),
]),
(KinoviSeedance2p0OutputResolution::FourK, KinoviPricingTier::Enterprise, [
(1000.0, 412), (2000.0, 823), (3000.0, 1234), (4000.0, 1646),
(5000.0, 2057), (6000.0, 2468), (7000.0, 2879), (8000.0, 3291),
]),
];
for (resolution, tier, prices) in cases {
for (batch, (credits, usd_cents)) in BATCHES.into_iter().zip(prices) {
let request = build_request(5, Some(resolution), Some(batch));
let cost = request.calculate_costs(tier).total_cost;
assert_eq!(
(cost.kinovi_credits, cost.usd_cents_rounded_up),
(credits, usd_cents),
"{resolution:?} {tier:?} {batch:?}",
);
}
}
}
#[test]
fn fixed_five_second_batch_prices_with_video_reference() {
let cases = [
(KinoviSeedance2p0OutputResolution::FourEightyP, KinoviPricingTier::Consumer, [
(95.0, 50), (190.0, 99), (285.0, 148), (380.0, 197),
(475.0, 247), (570.0, 296), (665.0, 345), (760.0, 394),
]),
(KinoviSeedance2p0OutputResolution::SevenTwentyP, KinoviPricingTier::Consumer, [
(240.0, 125), (480.0, 249), (720.0, 374), (960.0, 498),
(1200.0, 622), (1440.0, 747), (1680.0, 871), (1920.0, 995),
]),
(KinoviSeedance2p0OutputResolution::TenEightyP, KinoviPricingTier::Consumer, [
(540.0, 280), (1080.0, 560), (1620.0, 840), (2160.0, 1120),
(2700.0, 1400), (3240.0, 1679), (3780.0, 1959), (4320.0, 2239),
]),
(KinoviSeedance2p0OutputResolution::FourK, KinoviPricingTier::Consumer, [
(1200.0, 622), (2400.0, 1244), (3600.0, 1866), (4800.0, 2488),
(6000.0, 3110), (7200.0, 3731), (8400.0, 4353), (9600.0, 4975),
]),
(KinoviSeedance2p0OutputResolution::FourEightyP, KinoviPricingTier::Enterprise, [
(95.0, 40), (190.0, 79), (285.0, 118), (380.0, 157),
(475.0, 196), (570.0, 235), (665.0, 274), (760.0, 313),
]),
(KinoviSeedance2p0OutputResolution::SevenTwentyP, KinoviPricingTier::Enterprise, [
(227.4, 94), (454.8, 188), (682.2, 281), (909.6, 375),
(1137.0, 468), (1364.4, 562), (1591.8, 655), (1819.2, 749),
]),
(KinoviSeedance2p0OutputResolution::TenEightyP, KinoviPricingTier::Enterprise, [
(540.0, 223), (1080.0, 445), (1620.0, 667), (2160.0, 889),
(2700.0, 1111), (3240.0, 1333), (3780.0, 1555), (4320.0, 1777),
]),
(KinoviSeedance2p0OutputResolution::FourK, KinoviPricingTier::Enterprise, [
(1200.0, 494), (2400.0, 988), (3600.0, 1481), (4800.0, 1975),
(6000.0, 2468), (7200.0, 2962), (8400.0, 3455), (9600.0, 3949),
]),
];
for (resolution, tier, prices) in cases {
for (batch, (credits, usd_cents)) in BATCHES.into_iter().zip(prices) {
let request = build_request(5, Some(resolution), Some(batch));
let request = with_video_ref(request);
let cost = request.calculate_costs(tier).total_cost;
assert_eq!(
(cost.kinovi_credits, cost.usd_cents_rounded_up),
(credits, usd_cents),
"{resolution:?} {tier:?} {batch:?}",
);
}
}
}
#[test]
fn all_batches_scale_every_resolution_and_surcharge_at_both_tiers() {
let batches = [
(None, 1),
(Some(KinoviSeedance2p0BatchCount::One), 1),
(Some(KinoviSeedance2p0BatchCount::Two), 2),
(Some(KinoviSeedance2p0BatchCount::Three), 3),
(Some(KinoviSeedance2p0BatchCount::Four), 4),
(Some(KinoviSeedance2p0BatchCount::Five), 5),
(Some(KinoviSeedance2p0BatchCount::Six), 6),
(Some(KinoviSeedance2p0BatchCount::Seven), 7),
(Some(KinoviSeedance2p0BatchCount::Eight), 8),
];
fn batch_multiplies_at_both_tiers() {
for tier in [KinoviPricingTier::Enterprise, KinoviPricingTier::Consumer] {
for resolution in [
None,
Some(KinoviSeedance2p0OutputResolution::FourEightyP),
Some(KinoviSeedance2p0OutputResolution::SevenTwentyP),
Some(KinoviSeedance2p0OutputResolution::TenEightyP),
Some(KinoviSeedance2p0OutputResolution::FourK),
let base = r720(5).calculate_costs(tier).total_cost.kinovi_credits;
for (batch, multiplier) in [
(KinoviSeedance2p0BatchCount::Two, 2.0),
(KinoviSeedance2p0BatchCount::Three, 3.0),
(KinoviSeedance2p0BatchCount::Four, 4.0),
] {
for duration in [4, 5, 15] {
for references in [
None,
Some(vec![]),
Some(vec!["https://example.com/ref.mp4".to_string()]),
Some(vec!["https://example.com/ref1.mp4".to_string(), "https://example.com/ref2.mp4".to_string()]),
] {
let mut request = build_request(duration, resolution, None);
request.reference_video_urls = references;
let single = request.calculate_costs(tier);
for (batch, count) in batches {
request.batch_count = batch;
let batched = request.calculate_costs(tier);
let context = format!("{tier:?} {resolution:?} {duration}s {batch:?} references={:?}", request.reference_video_urls);
assert_batch_cost(single.base_cost, batched.base_cost, count, tier, &context);
assert_batch_cost(single.total_cost, batched.total_cost, count, tier, &context);
match (single.video_reference_surcharge_cost, batched.video_reference_surcharge_cost) {
(None, None) => {},
(Some(single), Some(batched)) => assert_batch_cost(single, batched, count, tier, &context),
_ => panic!("{context}: batch count changed surcharge presence"),
}
}
}
}
let batched = build_request(5, None, Some(batch)).calculate_costs(tier).total_cost.kinovi_credits;
assert_eq!(batched, base * multiplier, "{batch:?} at {tier:?}");
}
}
}
@@ -891,7 +728,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
}
}
@@ -964,7 +800,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}).await?;
println!("t2v default — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -996,7 +831,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}).await?;
println!("t2v 1080p — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -1031,7 +865,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}).await?;
println!("t2v 21:9 — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -1064,7 +897,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}).await?;
println!("keyframe 21:9 — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -1101,7 +933,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}).await?;
println!("keyframe — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -1135,7 +966,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}).await?;
println!("keyframe start+end — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -1173,7 +1003,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}).await?;
println!("image ref — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -1209,7 +1038,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}).await?;
println!("4K image ref — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -1248,7 +1076,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}).await?;
println!("video ref — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -1284,7 +1111,6 @@ mod tests {
character_ids: Some(vec![STEAMPUNK_CLOWN_ID.to_string()]),
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}).await?;
println!("character ref — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -1316,7 +1142,6 @@ mod tests {
character_ids: Some(vec![STEAMPUNK_CLOWN_ID.to_string(), MOCHI_ID.to_string()]),
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}).await?;
println!("two characters — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -38,8 +38,6 @@ pub struct GenerateSeedance2p0FastRequest {
/// Output video bitrate. None defaults to "standard"; `High` requests a
/// higher bitrate. Does not affect cost.
pub bitrate: Option<KinoviSeedance2p0FastBitrate>,
/// Whether to generate audio. None leaves the provider default unchanged.
pub maybe_generate_audio: Option<bool>,
}
// ── Enums ──
@@ -66,10 +64,6 @@ pub enum KinoviSeedance2p0FastBatchCount {
Two,
Three,
Four,
Five,
Six,
Seven,
Eight,
}
#[derive(Debug, Clone, Copy)]
@@ -144,10 +138,6 @@ impl KinoviCostCalculatorTrait for GenerateSeedance2p0FastRequest {
Some(KinoviSeedance2p0FastBatchCount::Two) => 2.0,
Some(KinoviSeedance2p0FastBatchCount::Three) => 3.0,
Some(KinoviSeedance2p0FastBatchCount::Four) => 4.0,
Some(KinoviSeedance2p0FastBatchCount::Five) => 5.0,
Some(KinoviSeedance2p0FastBatchCount::Six) => 6.0,
Some(KinoviSeedance2p0FastBatchCount::Seven) => 7.0,
Some(KinoviSeedance2p0FastBatchCount::Eight) => 8.0,
};
let output_seconds = f64::from(self.duration_seconds) * batch_multiplier;
@@ -205,8 +195,6 @@ pub async fn generate_seedance_2p0_fast(
character_ids: req.character_ids,
use_face_blur_hack: req.use_face_blur_hack,
bitrate: map_bitrate(req.bitrate),
maybe_output_format: None,
maybe_generate_audio: req.maybe_generate_audio,
};
let raw_response = workflow_run_task(WorkflowRunTaskArgs {
@@ -252,10 +240,6 @@ fn map_batch_count(bc: Option<KinoviSeedance2p0FastBatchCount>) -> KinoviBatchCo
Some(KinoviSeedance2p0FastBatchCount::Two) => KinoviBatchCountRaw::Two,
Some(KinoviSeedance2p0FastBatchCount::Three) => KinoviBatchCountRaw::Three,
Some(KinoviSeedance2p0FastBatchCount::Four) => KinoviBatchCountRaw::Four,
Some(KinoviSeedance2p0FastBatchCount::Five) => KinoviBatchCountRaw::Five,
Some(KinoviSeedance2p0FastBatchCount::Six) => KinoviBatchCountRaw::Six,
Some(KinoviSeedance2p0FastBatchCount::Seven) => KinoviBatchCountRaw::Seven,
Some(KinoviSeedance2p0FastBatchCount::Eight) => KinoviBatchCountRaw::Eight,
}
}
@@ -540,131 +524,18 @@ mod tests {
mod batch_tests {
use super::*;
use crate::test_utils::assert_batch_cost::assert_batch_cost;
const BATCHES: [KinoviSeedance2p0FastBatchCount; 8] = [
KinoviSeedance2p0FastBatchCount::One,
KinoviSeedance2p0FastBatchCount::Two,
KinoviSeedance2p0FastBatchCount::Three,
KinoviSeedance2p0FastBatchCount::Four,
KinoviSeedance2p0FastBatchCount::Five,
KinoviSeedance2p0FastBatchCount::Six,
KinoviSeedance2p0FastBatchCount::Seven,
KinoviSeedance2p0FastBatchCount::Eight,
];
// Each pair is (credits, USD cents rounded up), for batches 1 through 8.
// Pin literal totals so rate changes fail independently of scaling checks.
#[test]
fn fixed_five_second_batch_prices_without_video_reference() {
let cases = [
(KinoviSeedance2p0FastOutputResolution::FourEightyP, KinoviPricingTier::Consumer, [
(70.0, 37), (140.0, 73), (210.0, 109), (280.0, 146),
(350.0, 182), (420.0, 218), (490.0, 254), (560.0, 291),
]),
(KinoviSeedance2p0FastOutputResolution::SevenTwentyP, KinoviPricingTier::Consumer, [
(140.0, 73), (280.0, 146), (420.0, 218), (560.0, 291),
(700.0, 363), (840.0, 436), (980.0, 508), (1120.0, 581),
]),
(KinoviSeedance2p0FastOutputResolution::FourEightyP, KinoviPricingTier::Enterprise, [
(52.5, 22), (105.0, 44), (157.5, 65), (210.0, 87),
(262.5, 108), (315.0, 130), (367.5, 152), (420.0, 173),
]),
(KinoviSeedance2p0FastOutputResolution::SevenTwentyP, KinoviPricingTier::Enterprise, [
(105.0, 44), (210.0, 87), (315.0, 130), (420.0, 173),
(525.0, 216), (630.0, 260), (735.0, 303), (840.0, 346),
]),
];
for (resolution, tier, prices) in cases {
for (batch, (credits, usd_cents)) in BATCHES.into_iter().zip(prices) {
let request = build_request(5, Some(resolution), Some(batch));
let cost = request.calculate_costs(tier).total_cost;
assert_eq!(
(cost.kinovi_credits, cost.usd_cents_rounded_up),
(credits, usd_cents),
"{resolution:?} {tier:?} {batch:?}",
);
}
}
}
#[test]
fn fixed_five_second_batch_prices_with_video_reference() {
let cases = [
(KinoviSeedance2p0FastOutputResolution::FourEightyP, KinoviPricingTier::Consumer, [
(90.0, 47), (180.0, 94), (270.0, 140), (360.0, 187),
(450.0, 234), (540.0, 280), (630.0, 327), (720.0, 374),
]),
(KinoviSeedance2p0FastOutputResolution::SevenTwentyP, KinoviPricingTier::Consumer, [
(170.0, 89), (340.0, 177), (510.0, 265), (680.0, 353),
(850.0, 441), (1020.0, 529), (1190.0, 617), (1360.0, 705),
]),
(KinoviSeedance2p0FastOutputResolution::FourEightyP, KinoviPricingTier::Enterprise, [
(67.5, 28), (135.0, 56), (202.5, 84), (270.0, 112),
(337.5, 139), (405.0, 167), (472.5, 195), (540.0, 223),
]),
(KinoviSeedance2p0FastOutputResolution::SevenTwentyP, KinoviPricingTier::Enterprise, [
(127.5, 53), (255.0, 105), (382.5, 158), (510.0, 210),
(637.5, 263), (765.0, 315), (892.5, 368), (1020.0, 420),
]),
];
for (resolution, tier, prices) in cases {
for (batch, (credits, usd_cents)) in BATCHES.into_iter().zip(prices) {
let request = build_request(5, Some(resolution), Some(batch));
let request = with_video_ref(request);
let cost = request.calculate_costs(tier).total_cost;
assert_eq!(
(cost.kinovi_credits, cost.usd_cents_rounded_up),
(credits, usd_cents),
"{resolution:?} {tier:?} {batch:?}",
);
}
}
}
#[test]
fn all_batches_scale_every_resolution_and_surcharge_at_both_tiers() {
let batches = [
(None, 1),
(Some(KinoviSeedance2p0FastBatchCount::One), 1),
(Some(KinoviSeedance2p0FastBatchCount::Two), 2),
(Some(KinoviSeedance2p0FastBatchCount::Three), 3),
(Some(KinoviSeedance2p0FastBatchCount::Four), 4),
(Some(KinoviSeedance2p0FastBatchCount::Five), 5),
(Some(KinoviSeedance2p0FastBatchCount::Six), 6),
(Some(KinoviSeedance2p0FastBatchCount::Seven), 7),
(Some(KinoviSeedance2p0FastBatchCount::Eight), 8),
];
fn batch_multiplies_at_both_tiers() {
for tier in [KinoviPricingTier::Enterprise, KinoviPricingTier::Consumer] {
for resolution in [
None,
Some(KinoviSeedance2p0FastOutputResolution::FourEightyP),
Some(KinoviSeedance2p0FastOutputResolution::SevenTwentyP),
let base = r720(5).calculate_costs(tier).total_cost.kinovi_credits;
for (batch, multiplier) in [
(KinoviSeedance2p0FastBatchCount::Two, 2.0),
(KinoviSeedance2p0FastBatchCount::Three, 3.0),
(KinoviSeedance2p0FastBatchCount::Four, 4.0),
] {
for duration in [4, 5, 15] {
for references in [
None,
Some(vec![]),
Some(vec!["https://example.com/ref.mp4".to_string()]),
Some(vec!["https://example.com/ref1.mp4".to_string(), "https://example.com/ref2.mp4".to_string()]),
] {
let mut request = build_request(duration, resolution, None);
request.reference_video_urls = references;
let single = request.calculate_costs(tier);
for (batch, count) in batches {
request.batch_count = batch;
let batched = request.calculate_costs(tier);
let context = format!("{tier:?} {resolution:?} {duration}s {batch:?} references={:?}", request.reference_video_urls);
assert_batch_cost(single.base_cost, batched.base_cost, count, tier, &context);
assert_batch_cost(single.total_cost, batched.total_cost, count, tier, &context);
match (single.video_reference_surcharge_cost, batched.video_reference_surcharge_cost) {
(None, None) => {},
(Some(single), Some(batched)) => assert_batch_cost(single, batched, count, tier, &context),
_ => panic!("{context}: batch count changed surcharge presence"),
}
}
}
}
let batched = build_request(5, None, Some(batch)).calculate_costs(tier).total_cost.kinovi_credits;
assert_eq!(batched, base * multiplier, "{batch:?} at {tier:?}");
}
}
}
@@ -777,7 +648,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
}
}
@@ -842,7 +712,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}).await?;
println!("t2v fast default — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -874,7 +743,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}).await?;
println!("t2v fast 480p — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -909,7 +777,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}).await?;
println!("fast t2v 21:9 480p — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -942,7 +809,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}).await?;
println!("fast keyframe 21:9 — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -979,7 +845,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}).await?;
println!("fast keyframe — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -1017,7 +882,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}).await?;
println!("fast image ref — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -1055,7 +919,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}).await?;
println!("fast video ref — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -1091,7 +954,6 @@ mod tests {
character_ids: Some(vec![MOCHI_ID.to_string()]),
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}).await?;
println!("fast character — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -1123,7 +985,6 @@ mod tests {
character_ids: Some(vec![STEAMPUNK_CLOWN_ID.to_string(), MOCHI_ID.to_string()]),
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}).await?;
println!("fast two characters — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -38,8 +38,6 @@ pub struct GenerateSeedance2p0MiniRequest {
/// Output video bitrate. None defaults to "normal"; `High` requests a
/// higher bitrate. Does not affect cost.
pub bitrate: Option<KinoviSeedance2p0MiniBitrate>,
/// Whether to generate audio. None leaves the provider default unchanged.
pub maybe_generate_audio: Option<bool>,
}
// ── Enums ──
@@ -224,8 +222,6 @@ fn to_raw_request(req: GenerateSeedance2p0MiniRequest) -> WorkflowRunTaskRequest
character_ids: req.character_ids,
use_face_blur_hack: req.use_face_blur_hack,
bitrate: map_bitrate(req.bitrate),
maybe_output_format: None,
maybe_generate_audio: req.maybe_generate_audio,
}
}
@@ -514,131 +510,24 @@ mod tests {
mod batch_tests {
use super::*;
use crate::test_utils::assert_batch_cost::assert_batch_cost;
const BATCHES: [KinoviSeedance2p0MiniBatchCount; 8] = [
KinoviSeedance2p0MiniBatchCount::One,
KinoviSeedance2p0MiniBatchCount::Two,
KinoviSeedance2p0MiniBatchCount::Three,
KinoviSeedance2p0MiniBatchCount::Four,
KinoviSeedance2p0MiniBatchCount::Five,
KinoviSeedance2p0MiniBatchCount::Six,
KinoviSeedance2p0MiniBatchCount::Seven,
KinoviSeedance2p0MiniBatchCount::Eight,
];
// Each pair is (credits, USD cents rounded up), for batches 1 through 8.
// Pin literal totals so rate changes fail independently of scaling checks.
#[test]
fn fixed_five_second_batch_prices_without_video_reference() {
let cases = [
(KinoviSeedance2p0MiniOutputResolution::FourEightyP, KinoviPricingTier::Consumer, [
(37.5, 20), (75.0, 39), (112.5, 59), (150.0, 78),
(187.5, 98), (225.0, 117), (262.5, 137), (300.0, 156),
]),
(KinoviSeedance2p0MiniOutputResolution::SevenTwentyP, KinoviPricingTier::Consumer, [
(100.0, 52), (200.0, 104), (300.0, 156), (400.0, 208),
(500.0, 260), (600.0, 311), (700.0, 363), (800.0, 415),
]),
(KinoviSeedance2p0MiniOutputResolution::FourEightyP, KinoviPricingTier::Enterprise, [
(15.0, 7), (30.0, 13), (45.0, 19), (60.0, 25),
(75.0, 31), (90.0, 38), (105.0, 44), (120.0, 50),
]),
(KinoviSeedance2p0MiniOutputResolution::SevenTwentyP, KinoviPricingTier::Enterprise, [
(40.0, 17), (80.0, 33), (120.0, 50), (160.0, 66),
(200.0, 83), (240.0, 99), (280.0, 116), (320.0, 132),
]),
];
for (resolution, tier, prices) in cases {
for (batch, (credits, usd_cents)) in BATCHES.into_iter().zip(prices) {
let request = build_request(5, Some(resolution), Some(batch));
let cost = request.calculate_costs(tier).total_cost;
assert_eq!(
(cost.kinovi_credits, cost.usd_cents_rounded_up),
(credits, usd_cents),
"{resolution:?} {tier:?} {batch:?}",
);
}
}
}
#[test]
fn fixed_five_second_batch_prices_with_video_reference() {
let cases = [
(KinoviSeedance2p0MiniOutputResolution::FourEightyP, KinoviPricingTier::Consumer, [
(47.5, 25), (95.0, 50), (142.5, 74), (190.0, 99),
(237.5, 124), (285.0, 148), (332.5, 173), (380.0, 197),
]),
(KinoviSeedance2p0MiniOutputResolution::SevenTwentyP, KinoviPricingTier::Consumer, [
(120.0, 63), (240.0, 125), (360.0, 187), (480.0, 249),
(600.0, 311), (720.0, 374), (840.0, 436), (960.0, 498),
]),
(KinoviSeedance2p0MiniOutputResolution::FourEightyP, KinoviPricingTier::Enterprise, [
(19.0, 8), (38.0, 16), (57.0, 24), (76.0, 32),
(95.0, 40), (114.0, 47), (133.0, 55), (152.0, 63),
]),
(KinoviSeedance2p0MiniOutputResolution::SevenTwentyP, KinoviPricingTier::Enterprise, [
(48.0, 20), (96.0, 40), (144.0, 60), (192.0, 79),
(240.0, 99), (288.0, 119), (336.0, 139), (384.0, 158),
]),
];
for (resolution, tier, prices) in cases {
for (batch, (credits, usd_cents)) in BATCHES.into_iter().zip(prices) {
let request = build_request(5, Some(resolution), Some(batch));
let request = with_video_ref(request);
let cost = request.calculate_costs(tier).total_cost;
assert_eq!(
(cost.kinovi_credits, cost.usd_cents_rounded_up),
(credits, usd_cents),
"{resolution:?} {tier:?} {batch:?}",
);
}
}
}
#[test]
fn all_batches_scale_every_resolution_and_surcharge_at_both_tiers() {
let batches = [
(None, 1),
(Some(KinoviSeedance2p0MiniBatchCount::One), 1),
(Some(KinoviSeedance2p0MiniBatchCount::Two), 2),
(Some(KinoviSeedance2p0MiniBatchCount::Three), 3),
(Some(KinoviSeedance2p0MiniBatchCount::Four), 4),
(Some(KinoviSeedance2p0MiniBatchCount::Five), 5),
(Some(KinoviSeedance2p0MiniBatchCount::Six), 6),
(Some(KinoviSeedance2p0MiniBatchCount::Seven), 7),
(Some(KinoviSeedance2p0MiniBatchCount::Eight), 8),
fn batch_scales_total_linearly_at_both_tiers() {
let by_count = [
(KinoviSeedance2p0MiniBatchCount::One, 1.0),
(KinoviSeedance2p0MiniBatchCount::Two, 2.0),
(KinoviSeedance2p0MiniBatchCount::Three, 3.0),
(KinoviSeedance2p0MiniBatchCount::Four, 4.0),
(KinoviSeedance2p0MiniBatchCount::Five, 5.0),
(KinoviSeedance2p0MiniBatchCount::Six, 6.0),
(KinoviSeedance2p0MiniBatchCount::Seven, 7.0),
(KinoviSeedance2p0MiniBatchCount::Eight, 8.0),
];
for tier in [KinoviPricingTier::Enterprise, KinoviPricingTier::Consumer] {
for resolution in [
None,
Some(KinoviSeedance2p0MiniOutputResolution::FourEightyP),
Some(KinoviSeedance2p0MiniOutputResolution::SevenTwentyP),
] {
for duration in [4, 5, 15] {
for references in [
None,
Some(vec![]),
Some(vec!["https://example.com/ref.mp4".to_string()]),
Some(vec!["https://example.com/ref1.mp4".to_string(), "https://example.com/ref2.mp4".to_string()]),
] {
let mut request = build_request(duration, resolution, None);
request.reference_video_urls = references;
let single = request.calculate_costs(tier);
for (batch, count) in batches {
request.batch_count = batch;
let batched = request.calculate_costs(tier);
let context = format!("{tier:?} {resolution:?} {duration}s {batch:?} references={:?}", request.reference_video_urls);
assert_batch_cost(single.base_cost, batched.base_cost, count, tier, &context);
assert_batch_cost(single.total_cost, batched.total_cost, count, tier, &context);
match (single.video_reference_surcharge_cost, batched.video_reference_surcharge_cost) {
(None, None) => {},
(Some(single), Some(batched)) => assert_batch_cost(single, batched, count, tier, &context),
_ => panic!("{context}: batch count changed surcharge presence"),
}
}
}
}
let base = r720(5).calculate_costs(tier).total_cost.kinovi_credits;
for (count, multiplier) in by_count {
let credits = build_request(5, None, Some(count)).calculate_costs(tier).total_cost.kinovi_credits;
assert_eq!(credits, base * multiplier, "batch {count:?} at {tier:?}");
}
}
}
@@ -753,7 +642,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
}
}
@@ -807,7 +695,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: Some(KinoviSeedance2p0MiniBitrate::High),
maybe_generate_audio: None,
}
}
@@ -882,7 +769,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
}
}
}
@@ -919,7 +805,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}).await?;
println!("mini t2v 720p — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -951,7 +836,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}).await?;
println!("mini t2v 480p — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -984,7 +868,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}).await?;
println!("mini reference-to-video 720p — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -1017,7 +900,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}).await?;
println!("mini video ref 480p — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -7,7 +7,7 @@ use crate::pricing::kinovi_pricing_tier::KinoviPricingTier;
use crate::requests::kinovi_host::KinoviHost;
use crate::requests::workflow_run_task::workflow_run_task::{
workflow_run_task, KinoviAspectRatioRaw, KinoviBatchCountRaw, KinoviBitrateRaw, KinoviModelTypeRaw,
KinoviOutputFormatRaw, KinoviOutputResolutionRaw, WorkflowRunTaskArgs, WorkflowRunTaskRequest,
KinoviOutputResolutionRaw, WorkflowRunTaskArgs, WorkflowRunTaskRequest,
};
// ── Constants ──
@@ -48,7 +48,6 @@ pub struct GenerateSeedance2p5Request {
pub output_resolution: Option<KinoviSeedance2p5OutputResolution>,
pub duration_seconds: u8,
pub batch_count: Option<KinoviSeedance2p5BatchCount>,
/// CALCULATION-ONLY (never sent on the wire): the total seconds of
/// reference video input, summed across all reference videos. When video
@@ -65,12 +64,6 @@ pub struct GenerateSeedance2p5Request {
/// Output video bitrate. None keeps the standard bitrate; `High` requests
/// a higher bitrate. This does not select a container or lossless codec.
pub maybe_bitrate: Option<KinoviSeedance2p5Bitrate>,
/// Output container. None leaves the provider default unchanged.
pub maybe_output_format: Option<KinoviSeedance2p5OutputFormat>,
/// Whether to generate audio. None leaves the provider default unchanged.
pub maybe_generate_audio: Option<bool>,
}
// ── Modality ──
@@ -131,26 +124,6 @@ pub enum KinoviSeedance2p5Bitrate {
High,
}
/// Output container for Seedance 2.5.
#[derive(Debug, Clone, Copy)]
pub enum KinoviSeedance2p5OutputFormat {
Mp4,
Mov,
}
/// Number of videos to generate in one request (1–8).
#[derive(Debug, Clone, Copy)]
pub enum KinoviSeedance2p5BatchCount {
One,
Two,
Three,
Four,
Five,
Six,
Seven,
Eight,
}
// ── Pricing ──
//
// Seedance 2.5 credit pricing (credits per billed second). Without video
@@ -161,7 +134,7 @@ pub enum KinoviSeedance2p5BatchCount {
// |------------|----------------------|------------------------|
// | 480p | 26 | (no discount) |
// | 720p | 59 | (no discount) |
// | 1080p | 143.4 | 136.23 |
// | 1080p | 107.55 | 103.25 |
//
// With video references, the rate drops but the billed seconds are the
// output duration PLUS the total seconds of reference video input
@@ -174,10 +147,11 @@ pub enum KinoviSeedance2p5BatchCount {
// |------------|----------------------|------------------------|
// | 480p | 16 | (no discount) |
// | 720p | 35 | (no discount) |
// | 1080p | 85.68 | 81.4 |
// | 1080p | 64.26 | 61.69 |
//
// The 1080p rates were last updated 2026-09-17 (previously 107.55 / 103.25
// and 64.26 / 61.69 credits/sec). 480p and 720p have no separately negotiated
// The 1080p enterprise rates were spot-checked against the Kinovi site
// (2026-08-17): 4s / 10s / 30s without a reference video showed 413 /
// 1,032.5 / 3,097.5 credits. 480p and 720p have no separately negotiated
// enterprise credit rate, so enterprise bills the same credits as consumer
// (the tiers still convert to USD at their own purchase rates). Default
// resolution (None) is 720p.
@@ -193,8 +167,8 @@ const SEEDANCE_2P5_720P: KinoviPricingRate = KinoviPricingRate {
maybe_enterprise_credits: None,
};
const SEEDANCE_2P5_1080P: KinoviPricingRate = KinoviPricingRate {
consumer_credits: 143.4,
maybe_enterprise_credits: Some(136.23),
consumer_credits: 107.55,
maybe_enterprise_credits: Some(103.25),
};
/// Per-second credit rates when video references are attached. These REPLACE
@@ -209,8 +183,8 @@ const SEEDANCE_2P5_720P_VIDEO_REF: KinoviPricingRate = KinoviPricingRate {
maybe_enterprise_credits: None,
};
const SEEDANCE_2P5_1080P_VIDEO_REF: KinoviPricingRate = KinoviPricingRate {
consumer_credits: 85.68,
maybe_enterprise_credits: Some(81.4),
consumer_credits: 64.26,
maybe_enterprise_credits: Some(61.69),
};
impl KinoviCostCalculatorTrait for GenerateSeedance2p5Request {
@@ -247,18 +221,7 @@ impl KinoviCostCalculatorTrait for GenerateSeedance2p5Request {
(rate, u64::from(self.duration_seconds))
};
let batch_multiplier: f64 = match self.batch_count {
None | Some(KinoviSeedance2p5BatchCount::One) => 1.0,
Some(KinoviSeedance2p5BatchCount::Two) => 2.0,
Some(KinoviSeedance2p5BatchCount::Three) => 3.0,
Some(KinoviSeedance2p5BatchCount::Four) => 4.0,
Some(KinoviSeedance2p5BatchCount::Five) => 5.0,
Some(KinoviSeedance2p5BatchCount::Six) => 6.0,
Some(KinoviSeedance2p5BatchCount::Seven) => 7.0,
Some(KinoviSeedance2p5BatchCount::Eight) => 8.0,
};
let total_credits = rate.credits(tier) * billed_seconds as f64 * batch_multiplier;
let total_credits = rate.credits(tier) * billed_seconds as f64;
tier.cost_from_credits(total_credits)
}
}
@@ -333,7 +296,7 @@ fn to_raw_request(req: GenerateSeedance2p5Request) -> WorkflowRunTaskRequest {
aspect_ratio,
output_resolution: Some(map_output_resolution(req.output_resolution)),
duration_seconds: req.duration_seconds,
batch_count: map_batch_count(req.batch_count),
batch_count: KinoviBatchCountRaw::One,
start_frame_url,
end_frame_url,
reference_image_urls,
@@ -342,11 +305,6 @@ fn to_raw_request(req: GenerateSeedance2p5Request) -> WorkflowRunTaskRequest {
character_ids: None,
use_face_blur_hack: req.use_face_blur_hack,
bitrate: map_bitrate(req.maybe_bitrate),
maybe_output_format: req.maybe_output_format.map(|format| match format {
KinoviSeedance2p5OutputFormat::Mp4 => KinoviOutputFormatRaw::Mp4,
KinoviSeedance2p5OutputFormat::Mov => KinoviOutputFormatRaw::Mov,
}),
maybe_generate_audio: req.maybe_generate_audio,
}
}
@@ -379,19 +337,6 @@ fn map_output_resolution(res: Option<KinoviSeedance2p5OutputResolution>) -> Kino
}
}
fn map_batch_count(bc: Option<KinoviSeedance2p5BatchCount>) -> KinoviBatchCountRaw {
match bc {
Some(KinoviSeedance2p5BatchCount::One) | None => KinoviBatchCountRaw::One,
Some(KinoviSeedance2p5BatchCount::Two) => KinoviBatchCountRaw::Two,
Some(KinoviSeedance2p5BatchCount::Three) => KinoviBatchCountRaw::Three,
Some(KinoviSeedance2p5BatchCount::Four) => KinoviBatchCountRaw::Four,
Some(KinoviSeedance2p5BatchCount::Five) => KinoviBatchCountRaw::Five,
Some(KinoviSeedance2p5BatchCount::Six) => KinoviBatchCountRaw::Six,
Some(KinoviSeedance2p5BatchCount::Seven) => KinoviBatchCountRaw::Seven,
Some(KinoviSeedance2p5BatchCount::Eight) => KinoviBatchCountRaw::Eight,
}
}
// ── Tests ──
#[cfg(test)]
@@ -412,7 +357,7 @@ mod tests {
// ── No video references: output-duration billing ──
//
// 480p = 26 credits/sec and 720p = 59 credits/sec at both tiers (no
// negotiated enterprise discount); 1080p = 143.4 consumer / 136.23
// negotiated enterprise discount); 1080p = 107.55 consumer / 103.25
// enterprise credits/sec.
mod without_video_references {
@@ -438,23 +383,24 @@ mod tests {
#[test]
fn table_1080p() {
// Enterprise 136.23 credits/sec (2026-09-17).
assert_eq!(t2v_1080(4).calculate_enterprise_costs().kinovi_credits, 544.92);
assert_eq!(t2v_1080(5).calculate_enterprise_costs().kinovi_credits, 681.15);
assert_eq!(t2v_1080(10).calculate_enterprise_costs().kinovi_credits, 1362.3);
assert_eq!(t2v_1080(15).calculate_enterprise_costs().kinovi_credits, 2043.45);
assert_eq!(t2v_1080(20).calculate_enterprise_costs().kinovi_credits, 2724.6);
assert_eq!(t2v_1080(25).calculate_enterprise_costs().kinovi_credits, 3405.75);
assert_eq!(t2v_1080(30).calculate_enterprise_costs().kinovi_credits, 4086.9);
// Enterprise 103.25 credits/sec — the 4s / 10s / 30s values were
// verified against the Kinovi site (413 / 1,032.5 / 3,097.5).
assert_eq!(t2v_1080(4).calculate_enterprise_costs().kinovi_credits, 413.0);
assert_eq!(t2v_1080(5).calculate_enterprise_costs().kinovi_credits, 516.25);
assert_eq!(t2v_1080(10).calculate_enterprise_costs().kinovi_credits, 1032.5);
assert_eq!(t2v_1080(15).calculate_enterprise_costs().kinovi_credits, 1548.75);
assert_eq!(t2v_1080(20).calculate_enterprise_costs().kinovi_credits, 2065.0);
assert_eq!(t2v_1080(25).calculate_enterprise_costs().kinovi_credits, 2581.25);
assert_eq!(t2v_1080(30).calculate_enterprise_costs().kinovi_credits, 3097.5);
// Consumer 143.4 credits/sec.
assert_eq!(t2v_1080(4).calculate_consumer_costs().kinovi_credits, 573.6);
assert_eq!(t2v_1080(5).calculate_consumer_costs().kinovi_credits, 717.0);
assert_eq!(t2v_1080(10).calculate_consumer_costs().kinovi_credits, 1434.0);
assert_eq!(t2v_1080(15).calculate_consumer_costs().kinovi_credits, 2151.0);
assert_eq!(t2v_1080(20).calculate_consumer_costs().kinovi_credits, 2868.0);
assert_eq!(t2v_1080(25).calculate_consumer_costs().kinovi_credits, 3585.0);
assert_eq!(t2v_1080(30).calculate_consumer_costs().kinovi_credits, 4302.0);
// Consumer 107.55 credits/sec.
assert_eq!(t2v_1080(4).calculate_consumer_costs().kinovi_credits, 430.2);
assert_eq!(t2v_1080(5).calculate_consumer_costs().kinovi_credits, 537.75);
assert_eq!(t2v_1080(10).calculate_consumer_costs().kinovi_credits, 1075.5);
assert_eq!(t2v_1080(15).calculate_consumer_costs().kinovi_credits, 1613.25);
assert_eq!(t2v_1080(20).calculate_consumer_costs().kinovi_credits, 2151.0);
assert_eq!(t2v_1080(25).calculate_consumer_costs().kinovi_credits, 2688.75);
assert_eq!(t2v_1080(30).calculate_consumer_costs().kinovi_credits, 3226.5);
}
#[test]
@@ -492,7 +438,7 @@ mod tests {
let mut request_1080 = keyframe_request(10);
request_1080.output_resolution = Some(KinoviSeedance2p5OutputResolution::TenEightyP);
assert_eq!(request_1080.calculate_enterprise_costs().kinovi_credits, 1362.3);
assert_eq!(request_1080.calculate_enterprise_costs().kinovi_credits, 1032.5);
}
#[test]
@@ -525,7 +471,7 @@ mod tests {
// ── With video references: input seconds are billed too ──
//
// 480p = 16 credits/sec and 720p = 35 credits/sec at both tiers; 1080p
// = 85.68 consumer / 81.4 enterprise credits/sec. All over
// = 64.26 consumer / 61.69 enterprise credits/sec. All over
// (output duration + total_input_seconds) billed seconds.
mod with_video_references {
@@ -577,21 +523,21 @@ mod tests {
// seconds: billed seconds = 20 + clamp(input, 4, 30). E.g. a 10s
// input video + 20s generation = 30 billed seconds.
//
// Enterprise 81.4 credits/sec.
assert_eq!(video_ref_1080(20, Some(1)).calculate_enterprise_costs().kinovi_credits, 1953.6);
assert_eq!(video_ref_1080(20, Some(5)).calculate_enterprise_costs().kinovi_credits, 2035.0);
assert_eq!(video_ref_1080(20, Some(10)).calculate_enterprise_costs().kinovi_credits, 2442.0);
assert_eq!(video_ref_1080(20, Some(15)).calculate_enterprise_costs().kinovi_credits, 2849.0);
assert_eq!(video_ref_1080(20, Some(20)).calculate_enterprise_costs().kinovi_credits, 3256.0);
assert_eq!(video_ref_1080(20, Some(30)).calculate_enterprise_costs().kinovi_credits, 4070.0);
// Enterprise 61.69 credits/sec.
assert_eq!(video_ref_1080(20, Some(1)).calculate_enterprise_costs().kinovi_credits, 1480.56);
assert_eq!(video_ref_1080(20, Some(5)).calculate_enterprise_costs().kinovi_credits, 1542.25);
assert_eq!(video_ref_1080(20, Some(10)).calculate_enterprise_costs().kinovi_credits, 1850.7);
assert_eq!(video_ref_1080(20, Some(15)).calculate_enterprise_costs().kinovi_credits, 2159.15);
assert_eq!(video_ref_1080(20, Some(20)).calculate_enterprise_costs().kinovi_credits, 2467.6);
assert_eq!(video_ref_1080(20, Some(30)).calculate_enterprise_costs().kinovi_credits, 3084.5);
// Consumer 85.68 credits/sec.
assert_eq!(video_ref_1080(20, Some(1)).calculate_consumer_costs().kinovi_credits, 2056.32);
assert_eq!(video_ref_1080(20, Some(5)).calculate_consumer_costs().kinovi_credits, 2142.0);
assert_eq!(video_ref_1080(20, Some(10)).calculate_consumer_costs().kinovi_credits, 2570.4);
assert_eq!(video_ref_1080(20, Some(15)).calculate_consumer_costs().kinovi_credits, 2998.8);
assert_eq!(video_ref_1080(20, Some(20)).calculate_consumer_costs().kinovi_credits, 3427.2);
assert_eq!(video_ref_1080(20, Some(30)).calculate_consumer_costs().kinovi_credits, 4284.0);
// Consumer 64.26 credits/sec.
assert_eq!(video_ref_1080(20, Some(1)).calculate_consumer_costs().kinovi_credits, 1542.24);
assert_eq!(video_ref_1080(20, Some(5)).calculate_consumer_costs().kinovi_credits, 1606.5);
assert_eq!(video_ref_1080(20, Some(10)).calculate_consumer_costs().kinovi_credits, 1927.8);
assert_eq!(video_ref_1080(20, Some(15)).calculate_consumer_costs().kinovi_credits, 2249.1);
assert_eq!(video_ref_1080(20, Some(20)).calculate_consumer_costs().kinovi_credits, 2570.4);
assert_eq!(video_ref_1080(20, Some(30)).calculate_consumer_costs().kinovi_credits, 3213.0);
}
#[test]
@@ -610,9 +556,9 @@ mod tests {
// 30-second maximum so the estimate never undershoots the charge.
assert_eq!(video_ref_480(10, None).calculate_consumer_costs().kinovi_credits, 640.0);
assert_eq!(video_ref_720(10, None).calculate_consumer_costs().kinovi_credits, 1400.0);
// 1080p: 40 billed seconds at 81.4 enterprise / 85.68 consumer.
assert_eq!(video_ref_1080(10, None).calculate_enterprise_costs().kinovi_credits, 3256.0);
assert_eq!(video_ref_1080(10, None).calculate_consumer_costs().kinovi_credits, 3427.2);
// 1080p: 40 billed seconds at 61.69 enterprise / 64.26 consumer.
assert_eq!(video_ref_1080(10, None).calculate_enterprise_costs().kinovi_credits, 2467.6);
assert_eq!(video_ref_1080(10, None).calculate_consumer_costs().kinovi_credits, 2570.4);
}
#[test]
@@ -685,151 +631,47 @@ mod tests {
#[test]
fn enterprise_cents_1080p_4s() {
// 544.92 credits; 54492/243.16 = 224.0994 cents.
// 413 credits; 41300/243.16 = 169.8470 cents.
let cost = t2v_1080(4).calculate_enterprise_costs();
assert_eq!(cost.usd_cents_rounded_up, 225);
assert_eq!(cost.usd_cents_rounded_down, 224);
assert!((cost.usd_cents_fractional - 224.0994).abs() < FLOAT_TOLERANCE);
assert_eq!(cost.usd_cents_rounded_up, 170);
assert_eq!(cost.usd_cents_rounded_down, 169);
assert!((cost.usd_cents_fractional - 169.8470).abs() < FLOAT_TOLERANCE);
}
#[test]
fn consumer_cents_1080p_4s() {
// 573.6 credits; 57360/192.98 = 297.2329 cents.
// 430.2 credits; 43020/192.98 = 222.9247 cents.
let cost = t2v_1080(4).calculate_consumer_costs();
assert_eq!(cost.usd_cents_rounded_up, 298);
assert_eq!(cost.usd_cents_rounded_down, 297);
assert!((cost.usd_cents_fractional - 297.2329).abs() < FLOAT_TOLERANCE);
assert_eq!(cost.usd_cents_rounded_up, 223);
assert_eq!(cost.usd_cents_rounded_down, 222);
assert!((cost.usd_cents_fractional - 222.9247).abs() < FLOAT_TOLERANCE);
}
#[test]
fn enterprise_cents_1080p_30s() {
// 4086.9 credits; 408690/243.16 = 1680.7452 cents.
// 3097.5 credits; 309750/243.16 = 1273.8526 cents.
let cost = t2v_1080(30).calculate_enterprise_costs();
assert_eq!(cost.usd_cents_rounded_up, 1681);
assert_eq!(cost.usd_cents_rounded_down, 1680);
assert!((cost.usd_cents_fractional - 1680.7452).abs() < FLOAT_TOLERANCE);
assert_eq!(cost.usd_cents_rounded_up, 1274);
assert_eq!(cost.usd_cents_rounded_down, 1273);
assert!((cost.usd_cents_fractional - 1273.8526).abs() < FLOAT_TOLERANCE);
}
#[test]
fn enterprise_cents_1080p_video_ref_30_billed_seconds() {
// 81.4 × 30 = 2442 credits; 244200/243.16 = 1004.2770 cents.
// 61.69 × 30 = 1850.7 credits; 185070/243.16 = 761.1038 cents.
let cost = video_ref_1080(20, Some(10)).calculate_enterprise_costs();
assert_eq!(cost.usd_cents_rounded_up, 1005);
assert_eq!(cost.usd_cents_rounded_down, 1004);
assert!((cost.usd_cents_fractional - 1004.2770).abs() < FLOAT_TOLERANCE);
assert_eq!(cost.usd_cents_rounded_up, 762);
assert_eq!(cost.usd_cents_rounded_down, 761);
assert!((cost.usd_cents_fractional - 761.1038).abs() < FLOAT_TOLERANCE);
}
#[test]
fn consumer_cents_1080p_video_ref_30_billed_seconds() {
// 85.68 × 30 = 2570.4 credits; 257040/192.98 = 1331.9515 cents.
// 64.26 × 30 = 1927.8 credits; 192780/192.98 = 998.9636 cents.
let cost = video_ref_1080(20, Some(10)).calculate_consumer_costs();
assert_eq!(cost.usd_cents_rounded_up, 1332);
assert_eq!(cost.usd_cents_rounded_down, 1331);
assert!((cost.usd_cents_fractional - 1331.9515).abs() < FLOAT_TOLERANCE);
}
}
mod batch_price_tests {
use super::*;
const BATCHES: [KinoviSeedance2p5BatchCount; 8] = [
KinoviSeedance2p5BatchCount::One,
KinoviSeedance2p5BatchCount::Two,
KinoviSeedance2p5BatchCount::Three,
KinoviSeedance2p5BatchCount::Four,
KinoviSeedance2p5BatchCount::Five,
KinoviSeedance2p5BatchCount::Six,
KinoviSeedance2p5BatchCount::Seven,
KinoviSeedance2p5BatchCount::Eight,
];
// Each pair is (credits, USD cents rounded up), for batches 1 through 8.
// Pin literal totals so rate changes fail independently of scaling checks.
// Video-reference cases bill 5s output + 10s input per video.
#[test]
fn fixed_five_second_batch_prices_without_video_reference() {
let cases = [
(KinoviSeedance2p5OutputResolution::FourEightyP, KinoviPricingTier::Consumer, [
(130.0, 68), (260.0, 135), (390.0, 203), (520.0, 270),
(650.0, 337), (780.0, 405), (910.0, 472), (1040.0, 539),
]),
(KinoviSeedance2p5OutputResolution::SevenTwentyP, KinoviPricingTier::Consumer, [
(295.0, 153), (590.0, 306), (885.0, 459), (1180.0, 612),
(1475.0, 765), (1770.0, 918), (2065.0, 1071), (2360.0, 1223),
]),
(KinoviSeedance2p5OutputResolution::TenEightyP, KinoviPricingTier::Consumer, [
(717.0, 372), (1434.0, 744), (2151.0, 1115), (2868.0, 1487),
(3585.0, 1858), (4302.0, 2230), (5019.0, 2601), (5736.0, 2973),
]),
(KinoviSeedance2p5OutputResolution::FourEightyP, KinoviPricingTier::Enterprise, [
(130.0, 54), (260.0, 107), (390.0, 161), (520.0, 214),
(650.0, 268), (780.0, 321), (910.0, 375), (1040.0, 428),
]),
(KinoviSeedance2p5OutputResolution::SevenTwentyP, KinoviPricingTier::Enterprise, [
(295.0, 122), (590.0, 243), (885.0, 364), (1180.0, 486),
(1475.0, 607), (1770.0, 728), (2065.0, 850), (2360.0, 971),
]),
(KinoviSeedance2p5OutputResolution::TenEightyP, KinoviPricingTier::Enterprise, [
(681.15, 281), (1362.30, 561), (2043.45, 841), (2724.60, 1121),
(3405.75, 1401), (4086.90, 1681), (4768.05, 1961), (5449.20, 2241),
]),
];
for (resolution, tier, prices) in cases {
for (batch, (credits, usd_cents)) in BATCHES.into_iter().zip(prices) {
let mut request = text_to_video_request(5, Some(resolution));
request.batch_count = Some(batch);
let cost = request.calculate_costs(tier);
assert_eq!(
(cost.kinovi_credits, cost.usd_cents_rounded_up),
(credits, usd_cents),
"{resolution:?} {tier:?} {batch:?}",
);
}
}
}
#[test]
fn fixed_five_second_batch_prices_with_video_reference() {
let cases = [
(KinoviSeedance2p5OutputResolution::FourEightyP, KinoviPricingTier::Consumer, [
(240.0, 125), (480.0, 249), (720.0, 374), (960.0, 498),
(1200.0, 622), (1440.0, 747), (1680.0, 871), (1920.0, 995),
]),
(KinoviSeedance2p5OutputResolution::SevenTwentyP, KinoviPricingTier::Consumer, [
(525.0, 273), (1050.0, 545), (1575.0, 817), (2100.0, 1089),
(2625.0, 1361), (3150.0, 1633), (3675.0, 1905), (4200.0, 2177),
]),
(KinoviSeedance2p5OutputResolution::TenEightyP, KinoviPricingTier::Consumer, [
(1285.2, 666), (2570.4, 1332), (3855.6, 1998), (5140.8, 2664),
(6426.0, 3330), (7711.2, 3996), (8996.4, 4662), (10281.6, 5328),
]),
(KinoviSeedance2p5OutputResolution::FourEightyP, KinoviPricingTier::Enterprise, [
(240.0, 99), (480.0, 198), (720.0, 297), (960.0, 395),
(1200.0, 494), (1440.0, 593), (1680.0, 691), (1920.0, 790),
]),
(KinoviSeedance2p5OutputResolution::SevenTwentyP, KinoviPricingTier::Enterprise, [
(525.0, 216), (1050.0, 432), (1575.0, 648), (2100.0, 864),
(2625.0, 1080), (3150.0, 1296), (3675.0, 1512), (4200.0, 1728),
]),
(KinoviSeedance2p5OutputResolution::TenEightyP, KinoviPricingTier::Enterprise, [
(1221.0, 503), (2442.0, 1005), (3663.0, 1507), (4884.0, 2009),
(6105.0, 2511), (7326.0, 3013), (8547.0, 3515), (9768.0, 4018),
]),
];
for (resolution, tier, prices) in cases {
for (batch, (credits, usd_cents)) in BATCHES.into_iter().zip(prices) {
let mut request = text_to_video_request(5, Some(resolution));
request.batch_count = Some(batch);
set_reference_urls(&mut request, None, Some(vec!["https://example.com/ref.mp4".to_string()]), None);
request.total_input_seconds = Some(10);
let cost = request.calculate_costs(tier);
assert_eq!(
(cost.kinovi_credits, cost.usd_cents_rounded_up),
(credits, usd_cents),
"{resolution:?} {tier:?} {batch:?}",
);
}
}
assert_eq!(cost.usd_cents_rounded_up, 999);
assert_eq!(cost.usd_cents_rounded_down, 998);
assert!((cost.usd_cents_fractional - 998.9636).abs() < FLOAT_TOLERANCE);
}
}
@@ -837,81 +679,10 @@ mod tests {
mod relative_tests {
use super::*;
use crate::test_utils::assert_batch_cost::assert_batch_cost;
#[test]
fn all_batches_scale_every_resolution_modality_and_input_duration() {
let batches = [
(None, 1),
(Some(KinoviSeedance2p5BatchCount::One), 1),
(Some(KinoviSeedance2p5BatchCount::Two), 2),
(Some(KinoviSeedance2p5BatchCount::Three), 3),
(Some(KinoviSeedance2p5BatchCount::Four), 4),
(Some(KinoviSeedance2p5BatchCount::Five), 5),
(Some(KinoviSeedance2p5BatchCount::Six), 6),
(Some(KinoviSeedance2p5BatchCount::Seven), 7),
(Some(KinoviSeedance2p5BatchCount::Eight), 8),
];
for tier in [KinoviPricingTier::Enterprise, KinoviPricingTier::Consumer] {
for resolution in [
None,
Some(KinoviSeedance2p5OutputResolution::FourEightyP),
Some(KinoviSeedance2p5OutputResolution::SevenTwentyP),
Some(KinoviSeedance2p5OutputResolution::TenEightyP),
] {
for duration in [4, 5, 30] {
let text_request = text_to_video_request(duration, resolution);
let mut keyframe = keyframe_request(duration);
keyframe.output_resolution = resolution;
let mut image_audio = text_request.clone();
set_reference_urls(
&mut image_audio,
Some(vec!["https://example.com/image.png".to_string()]),
None,
Some(vec!["https://example.com/audio.mp3".to_string()]),
);
let mut empty_videos = text_request.clone();
set_reference_urls(&mut empty_videos, None, Some(vec![]), None);
let mut requests = vec![
("text".to_string(), text_request.clone()),
("keyframe".to_string(), keyframe),
("image/audio".to_string(), image_audio),
("empty videos".to_string(), empty_videos),
];
// Include unknown/zero inputs, the minimum, ordinary input, the
// maximum, and values clamped at either end of the billed range.
for input_seconds in [None, Some(0), Some(1), Some(4), Some(10), Some(30), Some(31), Some(u8::MAX)] {
let mut request = text_request.clone();
set_reference_urls(&mut request, None, Some(vec!["https://example.com/ref.mp4".to_string()]), None);
request.total_input_seconds = input_seconds;
requests.push((format!("video input={input_seconds:?}"), request));
}
for (modality, mut request) in requests {
let single = request.calculate_costs(tier);
for (batch, count) in batches {
request.batch_count = batch;
let batched = request.calculate_costs(tier);
let context = format!("{tier:?} {resolution:?} {duration}s {batch:?} {modality}");
assert_batch_cost(single, batched, count, tier, &context);
}
}
}
}
}
}
#[test]
fn batch_multiplies_output_and_reference_input_costs() {
let mut request = video_ref_480(5, Some(10));
request.batch_count = Some(KinoviSeedance2p5BatchCount::Eight);
assert_eq!(request.calculate_consumer_costs().kinovi_credits, 1920.0);
assert_eq!(request.calculate_enterprise_costs().kinovi_credits, 1920.0);
assert!(matches!(to_raw_request(request).batch_count, KinoviBatchCountRaw::Eight));
}
#[test]
fn video_reference_rate_is_cheaper_per_second() {
// 16 < 26, 35 < 59, and 81.4 < 136.23: the with-references rate is
// 16 < 26, 35 < 59, and 61.69 < 103.25: the with-references rate is
// lower per billed second (the input seconds are where the money
// goes). Compare at the minimum billed input (1s clamps to 4) —
// unknown or zero input assumes the 30-second maximum, which would
@@ -982,7 +753,6 @@ mod tests {
output_resolution: Option<KinoviSeedance2p5OutputResolution>,
) -> GenerateSeedance2p5Request {
GenerateSeedance2p5Request {
batch_count: None,
prompt: String::new(),
modality: KinoviSeedance2p5Modality::Reference {
aspect_ratio: None,
@@ -995,8 +765,6 @@ mod tests {
total_input_seconds: None,
use_face_blur_hack: None,
maybe_bitrate: None,
maybe_output_format: None,
maybe_generate_audio: None,
}
}
@@ -1014,7 +782,6 @@ mod tests {
fn keyframe_request(duration: u8) -> GenerateSeedance2p5Request {
GenerateSeedance2p5Request {
batch_count: None,
prompt: String::new(),
modality: KinoviSeedance2p5Modality::Keyframe {
start_frame_url: "https://example.com/start.png".to_string(),
@@ -1025,8 +792,6 @@ mod tests {
total_input_seconds: None,
use_face_blur_hack: None,
maybe_bitrate: None,
maybe_output_format: None,
maybe_generate_audio: None,
}
}
@@ -1077,7 +842,6 @@ mod tests {
#[test]
fn keyframe_maps_frames_and_no_references() {
let raw = to_raw_request(GenerateSeedance2p5Request {
batch_count: None,
prompt: "Car drives into the sunset".to_string(),
modality: KinoviSeedance2p5Modality::Keyframe {
start_frame_url: "https://example.com/start.png".to_string(),
@@ -1088,8 +852,6 @@ mod tests {
total_input_seconds: None,
use_face_blur_hack: None,
maybe_bitrate: None,
maybe_output_format: None,
maybe_generate_audio: None,
});
assert!(matches!(raw.model_type, KinoviModelTypeRaw::Seedance2p5));
@@ -1104,7 +866,6 @@ mod tests {
#[test]
fn reference_maps_references_and_no_frames() {
let raw = to_raw_request(GenerateSeedance2p5Request {
batch_count: None,
prompt: "The t-rex @image1 eats the banana".to_string(),
modality: KinoviSeedance2p5Modality::Reference {
aspect_ratio: Some(KinoviSeedance2p5AspectRatio::UltraWide21x9),
@@ -1117,14 +878,10 @@ mod tests {
total_input_seconds: Some(7),
use_face_blur_hack: None,
maybe_bitrate: Some(KinoviSeedance2p5Bitrate::High),
maybe_output_format: Some(KinoviSeedance2p5OutputFormat::Mov),
maybe_generate_audio: Some(false),
});
assert!(matches!(raw.aspect_ratio, KinoviAspectRatioRaw::UltraWide21x9));
assert!(matches!(raw.bitrate, Some(KinoviBitrateRaw::High)));
assert!(matches!(raw.maybe_output_format, Some(KinoviOutputFormatRaw::Mov)));
assert_eq!(raw.maybe_generate_audio, Some(false));
assert!(raw.start_frame_url.is_none());
assert!(raw.end_frame_url.is_none());
assert_eq!(raw.reference_image_urls.as_deref().map(|urls| urls.len()), Some(1));
@@ -1135,7 +892,6 @@ mod tests {
#[test]
fn resolution_1080p_maps_to_raw_1080p() {
let raw = to_raw_request(GenerateSeedance2p5Request {
batch_count: None,
prompt: "A car driving on the beach".to_string(),
modality: KinoviSeedance2p5Modality::Reference {
aspect_ratio: None,
@@ -1148,8 +904,6 @@ mod tests {
total_input_seconds: None,
use_face_blur_hack: None,
maybe_bitrate: None,
maybe_output_format: None,
maybe_generate_audio: None,
});
assert!(matches!(raw.output_resolution, Some(KinoviOutputResolutionRaw::TenEightyP)));
@@ -1158,7 +912,6 @@ mod tests {
#[test]
fn reference_aspect_ratio_defaults_to_16x9() {
let raw = to_raw_request(GenerateSeedance2p5Request {
batch_count: None,
prompt: "Lightning hits a building".to_string(),
modality: KinoviSeedance2p5Modality::Reference {
aspect_ratio: None,
@@ -1171,8 +924,6 @@ mod tests {
total_input_seconds: None,
use_face_blur_hack: None,
maybe_bitrate: None,
maybe_output_format: None,
maybe_generate_audio: None,
});
assert!(matches!(raw.aspect_ratio, KinoviAspectRatioRaw::Landscape16x9));
@@ -1191,7 +942,6 @@ mod tests {
session: &session,
host_override: None,
request: GenerateSeedance2p5Request {
batch_count: None,
prompt: "Lightning hits a building".to_string(),
modality: KinoviSeedance2p5Modality::Reference {
aspect_ratio: Some(KinoviSeedance2p5AspectRatio::Landscape16x9),
@@ -1204,8 +954,6 @@ mod tests {
total_input_seconds: None,
use_face_blur_hack: None,
maybe_bitrate: None,
maybe_output_format: None,
maybe_generate_audio: None,
},
}).await?;
println!("t2v 480p — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -1226,7 +974,6 @@ mod tests {
session: &session,
host_override: None,
request: GenerateSeedance2p5Request {
batch_count: None,
prompt: "A car driving on the beach".to_string(),
modality: KinoviSeedance2p5Modality::Reference {
aspect_ratio: Some(KinoviSeedance2p5AspectRatio::Landscape16x9),
@@ -1239,8 +986,6 @@ mod tests {
total_input_seconds: None,
use_face_blur_hack: None,
maybe_bitrate: None,
maybe_output_format: None,
maybe_generate_audio: None,
},
}).await?;
println!("t2v 1080p — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -1259,7 +1004,6 @@ mod tests {
session: &session,
host_override: None,
request: GenerateSeedance2p5Request {
batch_count: None,
prompt: "Car drives into the sunset".to_string(),
modality: KinoviSeedance2p5Modality::Keyframe {
start_frame_url: "https://static.seedance2-pro.com/materials/20260807/1786128486168-a2bf6132.png".to_string(),
@@ -1270,8 +1014,6 @@ mod tests {
total_input_seconds: None,
use_face_blur_hack: None,
maybe_bitrate: None,
maybe_output_format: None,
maybe_generate_audio: None,
},
}).await?;
println!("keyframe 1080p — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -1290,7 +1032,6 @@ mod tests {
session: &session,
host_override: None,
request: GenerateSeedance2p5Request {
batch_count: None,
prompt: "Car drives into the sunset".to_string(),
modality: KinoviSeedance2p5Modality::Keyframe {
start_frame_url: "https://static.seedance2-pro.com/materials/20260807/1786128486168-a2bf6132.png".to_string(),
@@ -1301,8 +1042,6 @@ mod tests {
total_input_seconds: None,
use_face_blur_hack: None,
maybe_bitrate: None,
maybe_output_format: None,
maybe_generate_audio: None,
},
}).await?;
println!("keyframe 480p — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -1321,7 +1060,6 @@ mod tests {
session: &session,
host_override: None,
request: GenerateSeedance2p5Request {
batch_count: None,
prompt: "The t-rex @image1 eats the banana @image2".to_string(),
modality: KinoviSeedance2p5Modality::Reference {
aspect_ratio: Some(KinoviSeedance2p5AspectRatio::UltraWide21x9),
@@ -1337,8 +1075,6 @@ mod tests {
total_input_seconds: None,
use_face_blur_hack: None,
maybe_bitrate: None,
maybe_output_format: None,
maybe_generate_audio: None,
},
}).await?;
println!("reference 480p — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -28,7 +28,6 @@ pub struct GenerateSeedance2p5PreviewRequest {
pub aspect_ratio: Option<KinoviSeedance2p5PreviewAspectRatio>,
pub output_resolution: Option<KinoviSeedance2p5PreviewOutputResolution>,
pub duration_seconds: u8,
pub batch_count: Option<KinoviSeedance2p5PreviewBatchCount>,
/// Reference images, referenced in prompts as @image1, @image2, etc.
pub reference_image_urls: Option<Vec<String>>,
/// Reference videos, referenced in prompts as @video1, @video2, etc.
@@ -59,19 +58,6 @@ pub enum KinoviSeedance2p5PreviewOutputResolution {
SevenTwentyP,
}
/// Number of videos to generate in one request (1–8).
#[derive(Debug, Clone, Copy)]
pub enum KinoviSeedance2p5PreviewBatchCount {
One,
Two,
Three,
Four,
Five,
Six,
Seven,
Eight,
}
// ── Pricing ──
//
// Seedance 2.5 Preview credit pricing (per second of output duration):
@@ -111,18 +97,7 @@ impl KinoviCostCalculatorTrait for GenerateSeedance2p5PreviewRequest {
Some(KinoviSeedance2p5PreviewOutputResolution::FourEightyP) => SEEDANCE_2P5_PREVIEW_480P,
Some(KinoviSeedance2p5PreviewOutputResolution::SevenTwentyP) | None => SEEDANCE_2P5_PREVIEW_720P,
};
let batch_multiplier: f64 = match self.batch_count {
None | Some(KinoviSeedance2p5PreviewBatchCount::One) => 1.0,
Some(KinoviSeedance2p5PreviewBatchCount::Two) => 2.0,
Some(KinoviSeedance2p5PreviewBatchCount::Three) => 3.0,
Some(KinoviSeedance2p5PreviewBatchCount::Four) => 4.0,
Some(KinoviSeedance2p5PreviewBatchCount::Five) => 5.0,
Some(KinoviSeedance2p5PreviewBatchCount::Six) => 6.0,
Some(KinoviSeedance2p5PreviewBatchCount::Seven) => 7.0,
Some(KinoviSeedance2p5PreviewBatchCount::Eight) => 8.0,
};
let total_credits = rate.credits(tier) * f64::from(self.duration_seconds) * batch_multiplier;
let total_credits = rate.credits(tier) * f64::from(self.duration_seconds);
tier.cost_from_credits(total_credits)
}
}
@@ -164,7 +139,7 @@ fn to_raw_request(req: GenerateSeedance2p5PreviewRequest) -> WorkflowRunTaskRequ
aspect_ratio: map_aspect_ratio(req.aspect_ratio),
output_resolution: Some(map_output_resolution(req.output_resolution)),
duration_seconds: req.duration_seconds,
batch_count: map_batch_count(req.batch_count),
batch_count: KinoviBatchCountRaw::One,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: req.reference_image_urls,
@@ -173,8 +148,6 @@ fn to_raw_request(req: GenerateSeedance2p5PreviewRequest) -> WorkflowRunTaskRequ
character_ids: None,
use_face_blur_hack: req.use_face_blur_hack,
bitrate: None,
maybe_output_format: None,
maybe_generate_audio: None,
}
}
@@ -199,19 +172,6 @@ fn map_output_resolution(res: Option<KinoviSeedance2p5PreviewOutputResolution>)
}
}
fn map_batch_count(bc: Option<KinoviSeedance2p5PreviewBatchCount>) -> KinoviBatchCountRaw {
match bc {
Some(KinoviSeedance2p5PreviewBatchCount::One) | None => KinoviBatchCountRaw::One,
Some(KinoviSeedance2p5PreviewBatchCount::Two) => KinoviBatchCountRaw::Two,
Some(KinoviSeedance2p5PreviewBatchCount::Three) => KinoviBatchCountRaw::Three,
Some(KinoviSeedance2p5PreviewBatchCount::Four) => KinoviBatchCountRaw::Four,
Some(KinoviSeedance2p5PreviewBatchCount::Five) => KinoviBatchCountRaw::Five,
Some(KinoviSeedance2p5PreviewBatchCount::Six) => KinoviBatchCountRaw::Six,
Some(KinoviSeedance2p5PreviewBatchCount::Seven) => KinoviBatchCountRaw::Seven,
Some(KinoviSeedance2p5PreviewBatchCount::Eight) => KinoviBatchCountRaw::Eight,
}
}
// ── Tests ──
#[cfg(test)]
@@ -545,110 +505,6 @@ mod tests {
}
}
mod batch_tests {
use super::*;
use crate::test_utils::assert_batch_cost::assert_batch_cost;
const BATCHES: [KinoviSeedance2p5PreviewBatchCount; 8] = [
KinoviSeedance2p5PreviewBatchCount::One,
KinoviSeedance2p5PreviewBatchCount::Two,
KinoviSeedance2p5PreviewBatchCount::Three,
KinoviSeedance2p5PreviewBatchCount::Four,
KinoviSeedance2p5PreviewBatchCount::Five,
KinoviSeedance2p5PreviewBatchCount::Six,
KinoviSeedance2p5PreviewBatchCount::Seven,
KinoviSeedance2p5PreviewBatchCount::Eight,
];
// Each pair is (credits, USD cents rounded up), for batches 1 through 8.
// Pin literal totals so rate changes fail independently of scaling checks.
#[test]
fn fixed_five_second_batch_prices_with_and_without_references() {
let cases = [
(KinoviSeedance2p5PreviewOutputResolution::FourEightyP, KinoviPricingTier::Consumer, [
(230.75, 120), (461.50, 240), (692.25, 359), (923.00, 479),
(1153.75, 598), (1384.50, 718), (1615.25, 838), (1846.00, 957),
]),
(KinoviSeedance2p5PreviewOutputResolution::SevenTwentyP, KinoviPricingTier::Consumer, [
(460.15, 239), (920.30, 477), (1380.45, 716), (1840.60, 954),
(2300.75, 1193), (2760.90, 1431), (3221.05, 1670), (3681.20, 1908),
]),
(KinoviSeedance2p5PreviewOutputResolution::FourEightyP, KinoviPricingTier::Enterprise, [
(210.65, 87), (421.30, 174), (631.95, 260), (842.60, 347),
(1053.25, 434), (1263.90, 520), (1474.55, 607), (1685.20, 694),
]),
(KinoviSeedance2p5PreviewOutputResolution::SevenTwentyP, KinoviPricingTier::Enterprise, [
(421.3, 174), (842.6, 347), (1263.9, 520), (1685.2, 694),
(2106.5, 867), (2527.8, 1040), (2949.1, 1213), (3370.4, 1387),
]),
];
for (resolution, tier, prices) in cases {
for has_references in [false, true] {
for (batch, (credits, usd_cents)) in BATCHES.into_iter().zip(prices) {
let mut request = build_request(5, Some(resolution));
request.batch_count = Some(batch);
if has_references {
request = with_all_reference_types(request);
}
let cost = request.calculate_costs(tier);
assert_eq!(
(cost.kinovi_credits, cost.usd_cents_rounded_up),
(credits, usd_cents),
"{resolution:?} {tier:?} {batch:?} references={has_references}",
);
}
}
}
}
#[test]
fn all_batches_scale_every_resolution_with_and_without_references() {
let batches = [
(None, 1),
(Some(KinoviSeedance2p5PreviewBatchCount::One), 1),
(Some(KinoviSeedance2p5PreviewBatchCount::Two), 2),
(Some(KinoviSeedance2p5PreviewBatchCount::Three), 3),
(Some(KinoviSeedance2p5PreviewBatchCount::Four), 4),
(Some(KinoviSeedance2p5PreviewBatchCount::Five), 5),
(Some(KinoviSeedance2p5PreviewBatchCount::Six), 6),
(Some(KinoviSeedance2p5PreviewBatchCount::Seven), 7),
(Some(KinoviSeedance2p5PreviewBatchCount::Eight), 8),
];
for tier in [KinoviPricingTier::Enterprise, KinoviPricingTier::Consumer] {
for resolution in [
None,
Some(KinoviSeedance2p5PreviewOutputResolution::FourEightyP),
Some(KinoviSeedance2p5PreviewOutputResolution::SevenTwentyP),
] {
for duration in [4, 5, 30] {
for has_references in [false, true] {
let mut request = build_request(duration, resolution);
if has_references {
request = with_all_reference_types(request);
}
let single = request.calculate_costs(tier);
for (batch, count) in batches {
request.batch_count = batch;
let batched = request.calculate_costs(tier);
let context = format!("{tier:?} {resolution:?} {duration}s {batch:?} references={has_references}");
assert_batch_cost(single, batched, count, tier, &context);
}
}
}
}
}
}
#[test]
fn batch_costs_and_request_count_agree() {
let mut request = r480(4);
request.batch_count = Some(KinoviSeedance2p5PreviewBatchCount::Eight);
assert_eq!(request.calculate_consumer_costs().kinovi_credits, 1476.8);
assert_eq!(request.calculate_enterprise_costs().kinovi_credits, 1348.16);
assert!(matches!(to_raw_request(request).batch_count, KinoviBatchCountRaw::Eight));
}
}
// ── Helpers ──
fn build_request(
@@ -656,7 +512,6 @@ mod tests {
output_resolution: Option<KinoviSeedance2p5PreviewOutputResolution>,
) -> GenerateSeedance2p5PreviewRequest {
GenerateSeedance2p5PreviewRequest {
batch_count: None,
prompt: String::new(),
aspect_ratio: None,
output_resolution,
@@ -699,7 +554,6 @@ mod tests {
session: &session,
host_override: None,
request: GenerateSeedance2p5PreviewRequest {
batch_count: None,
prompt: "A man is running from a t-rex".to_string(),
aspect_ratio: Some(KinoviSeedance2p5PreviewAspectRatio::Landscape16x9),
output_resolution: Some(KinoviSeedance2p5PreviewOutputResolution::FourEightyP),
@@ -726,7 +580,6 @@ mod tests {
session: &session,
host_override: None,
request: GenerateSeedance2p5PreviewRequest {
batch_count: None,
prompt: "A corgi and a shiba are playing chess against one another".to_string(),
aspect_ratio: None,
output_resolution: None,
@@ -1,97 +0,0 @@
//! Wan 3.0 image-to-video through the Kinovi consumer workflow endpoint.
use crate::creds::kinovi_web_session::KinoviWebSession;
use crate::error::kinovi_web_error::KinoviWebError;
use crate::generate::video::wan_3p0::{BUSINESS_TYPE, Wan3p0ApiParams, Wan3p0Model, invalid_field};
use crate::pricing::cost::kinovi_fractional_generation_cost::KinoviFractionalGenerationCost;
use crate::pricing::kinovi_cost_calculator_trait::KinoviCostCalculatorTrait;
use crate::pricing::kinovi_pricing_tier::KinoviPricingTier;
use crate::requests::kinovi_host::KinoviHost;
use crate::requests::workflow_run_task::workflow_run_task::{workflow_run_task_custom, WorkflowRunTaskCustomArgs};
pub use crate::generate::video::wan_3p0::{KinoviWan3p0AspectRatio, KinoviWan3p0OutputResolution};
pub use crate::requests::workflow_run_task::workflow_run_task::WorkflowRunTaskResponse as GenerateWan3p0ImageToVideoResponse;
pub struct GenerateWan3p0ImageToVideoArgs<'a> {
pub request: GenerateWan3p0ImageToVideoRequest,
pub session: &'a KinoviWebSession,
pub maybe_host_override: Option<KinoviHost>,
}
#[derive(Clone, Debug)]
pub struct GenerateWan3p0ImageToVideoRequest {
/// Up to 20,000 characters; may be empty when media is supplied.
pub prompt: String,
/// None resolves to 16:9.
pub maybe_aspect_ratio: Option<KinoviWan3p0AspectRatio>,
/// None resolves to 720p for both pricing and generation.
pub maybe_output_resolution: Option<KinoviWan3p0OutputResolution>,
/// Any whole-second duration from 2 through 30 (not just the UI presets).
pub duration_seconds: u8,
/// Optional noise seed, inclusive range 0..=2147483647.
pub maybe_seed: Option<u32>,
/// None keeps Kinovi's default generated soundtrack; false disables it.
pub maybe_generate_audio: Option<bool>,
/// Required for generation; optional so costs can be estimated without media.
pub maybe_start_frame_url: Option<String>,
/// Optional final keyframe. Requires a start frame.
pub maybe_end_frame_url: Option<String>,
}
impl Default for GenerateWan3p0ImageToVideoRequest {
fn default() -> Self {
Self {
prompt: String::new(),
maybe_aspect_ratio: None,
maybe_output_resolution: None,
duration_seconds: 5,
maybe_seed: None,
maybe_generate_audio: None,
maybe_start_frame_url: None,
maybe_end_frame_url: None,
}
}
}
impl KinoviCostCalculatorTrait for GenerateWan3p0ImageToVideoRequest {
type Cost = KinoviFractionalGenerationCost;
/// Prices output seconds only. Images, video/audio references, seed, and
/// aspect ratio do not change the credit charge. Both tiers use the same
/// credit rate and their respective credit-to-USD conversion.
fn calculate_costs(&self, tier: KinoviPricingTier) -> Self::Cost {
Wan3p0Model::ImageToVideo.calculate_costs(self.maybe_output_resolution, self.duration_seconds, tier)
}
}
impl GenerateWan3p0ImageToVideoRequest {
pub(super) fn build_api_params(self) -> Result<Wan3p0ApiParams, KinoviWebError> {
if self.maybe_end_frame_url.is_some() && self.maybe_start_frame_url.is_none() {
return Err(invalid_field("maybe_end_frame_url", "", "an end frame requires a start frame"));
}
let mut params = Wan3p0ApiParams::new(
Wan3p0Model::ImageToVideo,
self.prompt,
self.maybe_aspect_ratio,
self.maybe_output_resolution,
self.duration_seconds,
self.maybe_seed,
self.maybe_generate_audio,
)?;
params.uploaded_urls = Some(self.maybe_start_frame_url.into_iter().chain(self.maybe_end_frame_url).collect());
params.validate_media(self.duration_seconds, None)?;
Ok(params)
}
}
pub async fn generate_wan_3p0_image_to_video(
args: GenerateWan3p0ImageToVideoArgs<'_>,
) -> Result<GenerateWan3p0ImageToVideoResponse, KinoviWebError> {
workflow_run_task_custom(WorkflowRunTaskCustomArgs {
business_type: BUSINESS_TYPE,
api_params: args.request.build_api_params()?,
session: args.session,
host_override: args.maybe_host_override,
})
.await
}
@@ -1,97 +0,0 @@
//! Wan 3.0 Prime image-to-video through the Kinovi consumer workflow endpoint.
use crate::creds::kinovi_web_session::KinoviWebSession;
use crate::error::kinovi_web_error::KinoviWebError;
use crate::generate::video::wan_3p0::{BUSINESS_TYPE, Wan3p0ApiParams, Wan3p0Model, invalid_field};
use crate::pricing::cost::kinovi_fractional_generation_cost::KinoviFractionalGenerationCost;
use crate::pricing::kinovi_cost_calculator_trait::KinoviCostCalculatorTrait;
use crate::pricing::kinovi_pricing_tier::KinoviPricingTier;
use crate::requests::kinovi_host::KinoviHost;
use crate::requests::workflow_run_task::workflow_run_task::{workflow_run_task_custom, WorkflowRunTaskCustomArgs};
pub use crate::generate::video::wan_3p0::{KinoviWan3p0AspectRatio, KinoviWan3p0OutputResolution};
pub use crate::requests::workflow_run_task::workflow_run_task::WorkflowRunTaskResponse as GenerateWan3p0PrimeImageToVideoResponse;
pub struct GenerateWan3p0PrimeImageToVideoArgs<'a> {
pub request: GenerateWan3p0PrimeImageToVideoRequest,
pub session: &'a KinoviWebSession,
pub maybe_host_override: Option<KinoviHost>,
}
#[derive(Clone, Debug)]
pub struct GenerateWan3p0PrimeImageToVideoRequest {
/// Up to 20,000 characters; may be empty when media is supplied.
pub prompt: String,
/// None resolves to 16:9.
pub maybe_aspect_ratio: Option<KinoviWan3p0AspectRatio>,
/// None resolves to 720p for both pricing and generation.
pub maybe_output_resolution: Option<KinoviWan3p0OutputResolution>,
/// Any whole-second duration from 2 through 30 (not just the UI presets).
pub duration_seconds: u8,
/// Optional noise seed, inclusive range 0..=2147483647.
pub maybe_seed: Option<u32>,
/// None keeps Kinovi's default generated soundtrack; false disables it.
pub maybe_generate_audio: Option<bool>,
/// Required for generation; optional so costs can be estimated without media.
pub maybe_start_frame_url: Option<String>,
/// Optional final keyframe. Requires a start frame.
pub maybe_end_frame_url: Option<String>,
}
impl Default for GenerateWan3p0PrimeImageToVideoRequest {
fn default() -> Self {
Self {
prompt: String::new(),
maybe_aspect_ratio: None,
maybe_output_resolution: None,
duration_seconds: 5,
maybe_seed: None,
maybe_generate_audio: None,
maybe_start_frame_url: None,
maybe_end_frame_url: None,
}
}
}
impl KinoviCostCalculatorTrait for GenerateWan3p0PrimeImageToVideoRequest {
type Cost = KinoviFractionalGenerationCost;
/// Prices output seconds only. Images, video/audio references, seed, and
/// aspect ratio do not change the credit charge. Both tiers use the same
/// credit rate and their respective credit-to-USD conversion.
fn calculate_costs(&self, tier: KinoviPricingTier) -> Self::Cost {
Wan3p0Model::PrimeImageToVideo.calculate_costs(self.maybe_output_resolution, self.duration_seconds, tier)
}
}
impl GenerateWan3p0PrimeImageToVideoRequest {
pub(super) fn build_api_params(self) -> Result<Wan3p0ApiParams, KinoviWebError> {
if self.maybe_end_frame_url.is_some() && self.maybe_start_frame_url.is_none() {
return Err(invalid_field("maybe_end_frame_url", "", "an end frame requires a start frame"));
}
let mut params = Wan3p0ApiParams::new(
Wan3p0Model::PrimeImageToVideo,
self.prompt,
self.maybe_aspect_ratio,
self.maybe_output_resolution,
self.duration_seconds,
self.maybe_seed,
self.maybe_generate_audio,
)?;
params.uploaded_urls = Some(self.maybe_start_frame_url.into_iter().chain(self.maybe_end_frame_url).collect());
params.validate_media(self.duration_seconds, None)?;
Ok(params)
}
}
pub async fn generate_wan_3p0_prime_image_to_video(
args: GenerateWan3p0PrimeImageToVideoArgs<'_>,
) -> Result<GenerateWan3p0PrimeImageToVideoResponse, KinoviWebError> {
workflow_run_task_custom(WorkflowRunTaskCustomArgs {
business_type: BUSINESS_TYPE,
api_params: args.request.build_api_params()?,
session: args.session,
host_override: args.maybe_host_override,
})
.await
}
@@ -1,105 +0,0 @@
//! Wan 3.0 Prime ref-to-video through the Kinovi consumer workflow endpoint.
use crate::creds::kinovi_web_session::KinoviWebSession;
use crate::error::kinovi_web_error::KinoviWebError;
use crate::generate::video::wan_3p0::{BUSINESS_TYPE, Wan3p0ApiParams, Wan3p0Model};
use crate::pricing::cost::kinovi_fractional_generation_cost::KinoviFractionalGenerationCost;
use crate::pricing::kinovi_cost_calculator_trait::KinoviCostCalculatorTrait;
use crate::pricing::kinovi_pricing_tier::KinoviPricingTier;
use crate::requests::kinovi_host::KinoviHost;
use crate::requests::workflow_run_task::workflow_run_task::{workflow_run_task_custom, WorkflowRunTaskCustomArgs};
pub use crate::generate::video::wan_3p0::{KinoviWan3p0AspectRatio, KinoviWan3p0OutputResolution};
pub use crate::requests::workflow_run_task::workflow_run_task::WorkflowRunTaskResponse as GenerateWan3p0PrimeRefToVideoResponse;
pub struct GenerateWan3p0PrimeRefToVideoArgs<'a> {
pub request: GenerateWan3p0PrimeRefToVideoRequest,
pub session: &'a KinoviWebSession,
pub maybe_host_override: Option<KinoviHost>,
}
#[derive(Clone, Debug)]
pub struct GenerateWan3p0PrimeRefToVideoRequest {
/// Up to 20,000 characters; may be empty when media is supplied.
pub prompt: String,
/// None resolves to 16:9.
pub maybe_aspect_ratio: Option<KinoviWan3p0AspectRatio>,
/// None resolves to 720p for both pricing and generation.
pub maybe_output_resolution: Option<KinoviWan3p0OutputResolution>,
/// Any whole-second duration from 2 through 30 (not just the UI presets).
pub duration_seconds: u8,
/// Optional noise seed, inclusive range 0..=2147483647.
pub maybe_seed: Option<u32>,
/// None keeps Kinovi's default generated soundtrack; false disables it.
pub maybe_generate_audio: Option<bool>,
/// Up to 10 reference images, addressed as Image 1, Image 2, etc.
pub maybe_reference_image_urls: Option<Vec<String>>,
/// Up to 5 videos, each 1-15s, totaling at most 15s.
pub maybe_reference_video_urls: Option<Vec<String>>,
/// Up to 5 audio files, each 1-15s, totaling at most 15s.
/// Actual file durations are validated by Kinovi.
pub maybe_reference_audio_urls: Option<Vec<String>>,
/// Optional measured TOTAL reference-video duration, used only for validation.
/// Reference duration + output duration must not exceed 30s. This metadata
/// is not sent or billed. Without it, Kinovi validates the actual durations.
pub maybe_total_reference_video_duration_millis: Option<u32>,
}
impl Default for GenerateWan3p0PrimeRefToVideoRequest {
fn default() -> Self {
Self {
prompt: String::new(),
maybe_aspect_ratio: None,
maybe_output_resolution: None,
duration_seconds: 5,
maybe_seed: None,
maybe_generate_audio: None,
maybe_reference_image_urls: None,
maybe_reference_video_urls: None,
maybe_reference_audio_urls: None,
maybe_total_reference_video_duration_millis: None,
}
}
}
impl KinoviCostCalculatorTrait for GenerateWan3p0PrimeRefToVideoRequest {
type Cost = KinoviFractionalGenerationCost;
/// Prices output seconds only. Images, video/audio references, seed, and
/// aspect ratio do not change the credit charge. Both tiers use the same
/// credit rate and their respective credit-to-USD conversion.
fn calculate_costs(&self, tier: KinoviPricingTier) -> Self::Cost {
Wan3p0Model::PrimeRefToVideo.calculate_costs(self.maybe_output_resolution, self.duration_seconds, tier)
}
}
impl GenerateWan3p0PrimeRefToVideoRequest {
pub(super) fn build_api_params(self) -> Result<Wan3p0ApiParams, KinoviWebError> {
let mut params = Wan3p0ApiParams::new(
Wan3p0Model::PrimeRefToVideo,
self.prompt,
self.maybe_aspect_ratio,
self.maybe_output_resolution,
self.duration_seconds,
self.maybe_seed,
self.maybe_generate_audio,
)?;
params.uploaded_urls = self.maybe_reference_image_urls;
params.video_urls = self.maybe_reference_video_urls;
params.audio_urls = self.maybe_reference_audio_urls;
params.validate_media(self.duration_seconds, self.maybe_total_reference_video_duration_millis)?;
Ok(params)
}
}
pub async fn generate_wan_3p0_prime_ref_to_video(
args: GenerateWan3p0PrimeRefToVideoArgs<'_>,
) -> Result<GenerateWan3p0PrimeRefToVideoResponse, KinoviWebError> {
workflow_run_task_custom(WorkflowRunTaskCustomArgs {
business_type: BUSINESS_TYPE,
api_params: args.request.build_api_params()?,
session: args.session,
host_override: args.maybe_host_override,
})
.await
}
@@ -1,87 +0,0 @@
//! Wan 3.0 Prime text-to-video through the Kinovi consumer workflow endpoint.
use crate::creds::kinovi_web_session::KinoviWebSession;
use crate::error::kinovi_web_error::KinoviWebError;
use crate::generate::video::wan_3p0::{BUSINESS_TYPE, Wan3p0ApiParams, Wan3p0Model};
use crate::pricing::cost::kinovi_fractional_generation_cost::KinoviFractionalGenerationCost;
use crate::pricing::kinovi_cost_calculator_trait::KinoviCostCalculatorTrait;
use crate::pricing::kinovi_pricing_tier::KinoviPricingTier;
use crate::requests::kinovi_host::KinoviHost;
use crate::requests::workflow_run_task::workflow_run_task::{workflow_run_task_custom, WorkflowRunTaskCustomArgs};
pub use crate::generate::video::wan_3p0::{KinoviWan3p0AspectRatio, KinoviWan3p0OutputResolution};
pub use crate::requests::workflow_run_task::workflow_run_task::WorkflowRunTaskResponse as GenerateWan3p0PrimeTextToVideoResponse;
pub struct GenerateWan3p0PrimeTextToVideoArgs<'a> {
pub request: GenerateWan3p0PrimeTextToVideoRequest,
pub session: &'a KinoviWebSession,
pub maybe_host_override: Option<KinoviHost>,
}
#[derive(Clone, Debug)]
pub struct GenerateWan3p0PrimeTextToVideoRequest {
/// Required prompt, up to 20,000 characters.
pub prompt: String,
/// None resolves to 16:9.
pub maybe_aspect_ratio: Option<KinoviWan3p0AspectRatio>,
/// None resolves to 720p for both pricing and generation.
pub maybe_output_resolution: Option<KinoviWan3p0OutputResolution>,
/// Any whole-second duration from 2 through 30 (not just the UI presets).
pub duration_seconds: u8,
/// Optional noise seed, inclusive range 0..=2147483647.
pub maybe_seed: Option<u32>,
/// None keeps Kinovi's default generated soundtrack; false disables it.
pub maybe_generate_audio: Option<bool>,
}
impl Default for GenerateWan3p0PrimeTextToVideoRequest {
fn default() -> Self {
Self {
prompt: String::new(),
maybe_aspect_ratio: None,
maybe_output_resolution: None,
duration_seconds: 5,
maybe_seed: None,
maybe_generate_audio: None,
}
}
}
impl KinoviCostCalculatorTrait for GenerateWan3p0PrimeTextToVideoRequest {
type Cost = KinoviFractionalGenerationCost;
/// Prices output seconds only. Images, video/audio references, seed, and
/// aspect ratio do not change the credit charge. Both tiers use the same
/// credit rate and their respective credit-to-USD conversion.
fn calculate_costs(&self, tier: KinoviPricingTier) -> Self::Cost {
Wan3p0Model::PrimeTextToVideo.calculate_costs(self.maybe_output_resolution, self.duration_seconds, tier)
}
}
impl GenerateWan3p0PrimeTextToVideoRequest {
pub(super) fn build_api_params(self) -> Result<Wan3p0ApiParams, KinoviWebError> {
let mut params = Wan3p0ApiParams::new(
Wan3p0Model::PrimeTextToVideo,
self.prompt,
self.maybe_aspect_ratio,
self.maybe_output_resolution,
self.duration_seconds,
self.maybe_seed,
self.maybe_generate_audio,
)?;
params.validate_media(self.duration_seconds, None)?;
Ok(params)
}
}
pub async fn generate_wan_3p0_prime_text_to_video(
args: GenerateWan3p0PrimeTextToVideoArgs<'_>,
) -> Result<GenerateWan3p0PrimeTextToVideoResponse, KinoviWebError> {
workflow_run_task_custom(WorkflowRunTaskCustomArgs {
business_type: BUSINESS_TYPE,
api_params: args.request.build_api_params()?,
session: args.session,
host_override: args.maybe_host_override,
})
.await
}
@@ -1,105 +0,0 @@
//! Wan 3.0 ref-to-video through the Kinovi consumer workflow endpoint.
use crate::creds::kinovi_web_session::KinoviWebSession;
use crate::error::kinovi_web_error::KinoviWebError;
use crate::generate::video::wan_3p0::{BUSINESS_TYPE, Wan3p0ApiParams, Wan3p0Model};
use crate::pricing::cost::kinovi_fractional_generation_cost::KinoviFractionalGenerationCost;
use crate::pricing::kinovi_cost_calculator_trait::KinoviCostCalculatorTrait;
use crate::pricing::kinovi_pricing_tier::KinoviPricingTier;
use crate::requests::kinovi_host::KinoviHost;
use crate::requests::workflow_run_task::workflow_run_task::{workflow_run_task_custom, WorkflowRunTaskCustomArgs};
pub use crate::generate::video::wan_3p0::{KinoviWan3p0AspectRatio, KinoviWan3p0OutputResolution};
pub use crate::requests::workflow_run_task::workflow_run_task::WorkflowRunTaskResponse as GenerateWan3p0RefToVideoResponse;
pub struct GenerateWan3p0RefToVideoArgs<'a> {
pub request: GenerateWan3p0RefToVideoRequest,
pub session: &'a KinoviWebSession,
pub maybe_host_override: Option<KinoviHost>,
}
#[derive(Clone, Debug)]
pub struct GenerateWan3p0RefToVideoRequest {
/// Up to 20,000 characters; may be empty when media is supplied.
pub prompt: String,
/// None resolves to 16:9.
pub maybe_aspect_ratio: Option<KinoviWan3p0AspectRatio>,
/// None resolves to 720p for both pricing and generation.
pub maybe_output_resolution: Option<KinoviWan3p0OutputResolution>,
/// Any whole-second duration from 2 through 30 (not just the UI presets).
pub duration_seconds: u8,
/// Optional noise seed, inclusive range 0..=2147483647.
pub maybe_seed: Option<u32>,
/// None keeps Kinovi's default generated soundtrack; false disables it.
pub maybe_generate_audio: Option<bool>,
/// Up to 10 reference images, addressed as Image 1, Image 2, etc.
pub maybe_reference_image_urls: Option<Vec<String>>,
/// Up to 5 videos, each 1-15s, totaling at most 15s.
pub maybe_reference_video_urls: Option<Vec<String>>,
/// Up to 5 audio files, each 1-15s, totaling at most 15s.
/// Actual file durations are validated by Kinovi.
pub maybe_reference_audio_urls: Option<Vec<String>>,
/// Optional measured TOTAL reference-video duration, used only for validation.
/// Reference duration + output duration must not exceed 30s. This metadata
/// is not sent or billed. Without it, Kinovi validates the actual durations.
pub maybe_total_reference_video_duration_millis: Option<u32>,
}
impl Default for GenerateWan3p0RefToVideoRequest {
fn default() -> Self {
Self {
prompt: String::new(),
maybe_aspect_ratio: None,
maybe_output_resolution: None,
duration_seconds: 5,
maybe_seed: None,
maybe_generate_audio: None,
maybe_reference_image_urls: None,
maybe_reference_video_urls: None,
maybe_reference_audio_urls: None,
maybe_total_reference_video_duration_millis: None,
}
}
}
impl KinoviCostCalculatorTrait for GenerateWan3p0RefToVideoRequest {
type Cost = KinoviFractionalGenerationCost;
/// Prices output seconds only. Images, video/audio references, seed, and
/// aspect ratio do not change the credit charge. Both tiers use the same
/// credit rate and their respective credit-to-USD conversion.
fn calculate_costs(&self, tier: KinoviPricingTier) -> Self::Cost {
Wan3p0Model::RefToVideo.calculate_costs(self.maybe_output_resolution, self.duration_seconds, tier)
}
}
impl GenerateWan3p0RefToVideoRequest {
pub(super) fn build_api_params(self) -> Result<Wan3p0ApiParams, KinoviWebError> {
let mut params = Wan3p0ApiParams::new(
Wan3p0Model::RefToVideo,
self.prompt,
self.maybe_aspect_ratio,
self.maybe_output_resolution,
self.duration_seconds,
self.maybe_seed,
self.maybe_generate_audio,
)?;
params.uploaded_urls = self.maybe_reference_image_urls;
params.video_urls = self.maybe_reference_video_urls;
params.audio_urls = self.maybe_reference_audio_urls;
params.validate_media(self.duration_seconds, self.maybe_total_reference_video_duration_millis)?;
Ok(params)
}
}
pub async fn generate_wan_3p0_ref_to_video(
args: GenerateWan3p0RefToVideoArgs<'_>,
) -> Result<GenerateWan3p0RefToVideoResponse, KinoviWebError> {
workflow_run_task_custom(WorkflowRunTaskCustomArgs {
business_type: BUSINESS_TYPE,
api_params: args.request.build_api_params()?,
session: args.session,
host_override: args.maybe_host_override,
})
.await
}
@@ -1,87 +0,0 @@
//! Wan 3.0 text-to-video through the Kinovi consumer workflow endpoint.
use crate::creds::kinovi_web_session::KinoviWebSession;
use crate::error::kinovi_web_error::KinoviWebError;
use crate::generate::video::wan_3p0::{BUSINESS_TYPE, Wan3p0ApiParams, Wan3p0Model};
use crate::pricing::cost::kinovi_fractional_generation_cost::KinoviFractionalGenerationCost;
use crate::pricing::kinovi_cost_calculator_trait::KinoviCostCalculatorTrait;
use crate::pricing::kinovi_pricing_tier::KinoviPricingTier;
use crate::requests::kinovi_host::KinoviHost;
use crate::requests::workflow_run_task::workflow_run_task::{workflow_run_task_custom, WorkflowRunTaskCustomArgs};
pub use crate::generate::video::wan_3p0::{KinoviWan3p0AspectRatio, KinoviWan3p0OutputResolution};
pub use crate::requests::workflow_run_task::workflow_run_task::WorkflowRunTaskResponse as GenerateWan3p0TextToVideoResponse;
pub struct GenerateWan3p0TextToVideoArgs<'a> {
pub request: GenerateWan3p0TextToVideoRequest,
pub session: &'a KinoviWebSession,
pub maybe_host_override: Option<KinoviHost>,
}
#[derive(Clone, Debug)]
pub struct GenerateWan3p0TextToVideoRequest {
/// Required prompt, up to 20,000 characters.
pub prompt: String,
/// None resolves to 16:9.
pub maybe_aspect_ratio: Option<KinoviWan3p0AspectRatio>,
/// None resolves to 720p for both pricing and generation.
pub maybe_output_resolution: Option<KinoviWan3p0OutputResolution>,
/// Any whole-second duration from 2 through 30 (not just the UI presets).
pub duration_seconds: u8,
/// Optional noise seed, inclusive range 0..=2147483647.
pub maybe_seed: Option<u32>,
/// None keeps Kinovi's default generated soundtrack; false disables it.
pub maybe_generate_audio: Option<bool>,
}
impl Default for GenerateWan3p0TextToVideoRequest {
fn default() -> Self {
Self {
prompt: String::new(),
maybe_aspect_ratio: None,
maybe_output_resolution: None,
duration_seconds: 5,
maybe_seed: None,
maybe_generate_audio: None,
}
}
}
impl KinoviCostCalculatorTrait for GenerateWan3p0TextToVideoRequest {
type Cost = KinoviFractionalGenerationCost;
/// Prices output seconds only. Images, video/audio references, seed, and
/// aspect ratio do not change the credit charge. Both tiers use the same
/// credit rate and their respective credit-to-USD conversion.
fn calculate_costs(&self, tier: KinoviPricingTier) -> Self::Cost {
Wan3p0Model::TextToVideo.calculate_costs(self.maybe_output_resolution, self.duration_seconds, tier)
}
}
impl GenerateWan3p0TextToVideoRequest {
pub(super) fn build_api_params(self) -> Result<Wan3p0ApiParams, KinoviWebError> {
let mut params = Wan3p0ApiParams::new(
Wan3p0Model::TextToVideo,
self.prompt,
self.maybe_aspect_ratio,
self.maybe_output_resolution,
self.duration_seconds,
self.maybe_seed,
self.maybe_generate_audio,
)?;
params.validate_media(self.duration_seconds, None)?;
Ok(params)
}
}
pub async fn generate_wan_3p0_text_to_video(
args: GenerateWan3p0TextToVideoArgs<'_>,
) -> Result<GenerateWan3p0TextToVideoResponse, KinoviWebError> {
workflow_run_task_custom(WorkflowRunTaskCustomArgs {
business_type: BUSINESS_TYPE,
api_params: args.request.build_api_params()?,
session: args.session,
host_override: args.maybe_host_override,
})
.await
}
@@ -4,13 +4,3 @@ pub mod generate_seedance_2p0_fast;
pub mod generate_seedance_2p0_mini;
pub mod generate_seedance_2p5;
pub mod generate_seedance_2p5_preview;
pub mod generate_wan_3p0_text_to_video;
pub mod generate_wan_3p0_image_to_video;
pub mod generate_wan_3p0_ref_to_video;
pub mod generate_wan_3p0_prime_text_to_video;
pub mod generate_wan_3p0_prime_image_to_video;
pub mod generate_wan_3p0_prime_ref_to_video;
pub mod wan_3p0;
#[cfg(test)]
mod wan_3p0_tests;
@@ -1,295 +0,0 @@
//! Shared options and wire format for Wan 3.0 and Wan 3.0 Prime.
//!
//! Schema: https://kinovi.ai/models/wan3-ref-to-video (checked 2026-09-18).
//! All modes bill only OUTPUT seconds; reference media does not add a surcharge.
//!
//! Output accepts any whole-second duration from 2 through 30. Reference
//! videos must each be 1-15s and total at most 15s; their total plus output
//! duration must not exceed 30s. Thus 10s of video references allows at most
//! 20s of output. Audio and image references do not consume this video budget.
//! The optional measured reference duration enables local validation; otherwise
//! Kinovi checks actual file durations. This client does not probe media URLs.
//!
//! Pricing sources (all three modalities share each family's rates):
//! - https://kinovi.ai/models/wan3-text-to-video
//! - https://kinovi.ai/models/wan3-prime-ref-to-video
//! Pricing maintenance and the September 23 promotion: `costs_wan_3p0.md`
//! at the crate root.
use serde_derive::Serialize;
use crate::error::kinovi_web_client_error::KinoviWebClientError;
use crate::error::kinovi_web_error::KinoviWebError;
use crate::pricing::cost::kinovi_fractional_generation_cost::KinoviFractionalGenerationCost;
use crate::pricing::kinovi_pricing_tier::KinoviPricingTier;
pub(super) const BUSINESS_TYPE: &str = "wan3-video-generation";
pub const MAX_SEED: u32 = 2_147_483_647;
pub const MIN_DURATION_SECONDS: u8 = 2;
pub const MAX_DURATION_SECONDS: u8 = 30;
pub const MAX_REFERENCE_VIDEO_DURATION_MILLIS: u32 = 15_000;
// Hundredths of a credit per OUTPUT second, as observed 2026-09-18.
// Regular Wan intentionally uses the promotional rates (30% off until 2026-09-23).
// TODO(2026-09-24): Recheck live pricing using costs_wan_3p0.md at the crate root.
// That note records advertised replacement rates, precision needs, and test updates.
// Keep these promotional rates until a reviewed update; no automatic date switch.
// Prime 480p is 18.86/s, so 30s costs 565.8, not 568.8 credits.
// No separate enterprise credit discount has been supplied for these models.
const WAN_CREDIT_HUNDREDTHS_PER_SECOND: [u64; 3] = [998, 1996, 3991];
const PRIME_CREDIT_HUNDREDTHS_PER_SECOND: [u64; 3] = [1886, 3883, 7766];
/// Shared by all six Wan operations. Defaults to 16:9.
/// `adaptive` is REST-only for reference-to-video and is not a Studio option.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum KinoviWan3p0AspectRatio {
#[default]
Landscape16x9,
Portrait9x16,
Landscape4x3,
Portrait3x4,
Square1x1,
}
/// Shared by regular and Prime Wan. Defaults to 720p, pinned on the wire.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum KinoviWan3p0OutputResolution {
FourEightyP,
#[default]
SevenTwentyP,
TenEightyP,
}
#[derive(Clone, Copy, Debug)]
pub(super) enum Wan3p0Model {
TextToVideo,
ImageToVideo,
RefToVideo,
PrimeTextToVideo,
PrimeImageToVideo,
PrimeRefToVideo,
}
/// Consumer tRPC shape, verified against the 2026-09-18 captures. Images
/// use `uploadedUrls`; videos use BOTH `videoUrl` and `videoUrls`.
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct Wan3p0ApiParams {
model: &'static str,
content_mode: &'static str,
face_blur_mode: &'static str,
prompt: String,
#[serde(skip_serializing_if = "Option::is_none")]
mode: Option<&'static str>,
aspect_ratio: &'static str,
duration: String,
output_resolution: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
seed: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
audio: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) uploaded_urls: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
video_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) video_urls: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) audio_urls: Option<Vec<String>>,
}
impl Wan3p0Model {
pub(super) fn calculate_costs(
self,
resolution: Option<KinoviWan3p0OutputResolution>,
duration_seconds: u8,
tier: KinoviPricingTier,
) -> KinoviFractionalGenerationCost {
let rates = match self {
Self::TextToVideo | Self::ImageToVideo | Self::RefToVideo => WAN_CREDIT_HUNDREDTHS_PER_SECOND,
Self::PrimeTextToVideo | Self::PrimeImageToVideo | Self::PrimeRefToVideo => PRIME_CREDIT_HUNDREDTHS_PER_SECOND,
};
let index = match resolution.unwrap_or_default() {
KinoviWan3p0OutputResolution::FourEightyP => 0,
KinoviWan3p0OutputResolution::SevenTwentyP => 1,
KinoviWan3p0OutputResolution::TenEightyP => 2,
};
let credit_hundredths = rates[index] * u64::from(duration_seconds);
tier.cost_from_credits(credit_hundredths as f64 / 100.0)
}
}
impl Wan3p0ApiParams {
pub(super) fn new(
model: Wan3p0Model,
prompt: String,
aspect_ratio: Option<KinoviWan3p0AspectRatio>,
resolution: Option<KinoviWan3p0OutputResolution>,
duration_seconds: u8,
seed: Option<u32>,
audio: Option<bool>,
) -> Result<Self, KinoviWebError> {
if !(MIN_DURATION_SECONDS..=MAX_DURATION_SECONDS).contains(&duration_seconds) {
return Err(invalid_field("duration_seconds", duration_seconds, "must be between 2 and 30"));
}
if let Some(seed) = seed {
if seed > MAX_SEED {
return Err(invalid_field("maybe_seed", seed, "must be between 0 and 2147483647"));
}
}
if prompt.chars().count() > 20_000 {
return Err(invalid_field("prompt", prompt.chars().count(), "must not exceed 20000 characters"));
}
let (model, mode) = match model {
Wan3p0Model::TextToVideo => ("wan3.0-text-to-video", None),
Wan3p0Model::ImageToVideo => ("wan3.0-image-to-video", Some("keyframe")),
Wan3p0Model::RefToVideo => ("wan3.0-ref-to-video", Some("reference")),
Wan3p0Model::PrimeTextToVideo => ("wan3.0-prime-text-to-video", None),
Wan3p0Model::PrimeImageToVideo => ("wan3.0-prime-image-to-video", Some("keyframe")),
Wan3p0Model::PrimeRefToVideo => ("wan3.0-prime-ref-to-video", Some("reference")),
};
Ok(Self {
model,
content_mode: "normal",
face_blur_mode: "off",
prompt,
mode,
aspect_ratio: match aspect_ratio.unwrap_or_default() {
KinoviWan3p0AspectRatio::Landscape16x9 => "16:9",
KinoviWan3p0AspectRatio::Portrait9x16 => "9:16",
KinoviWan3p0AspectRatio::Landscape4x3 => "4:3",
KinoviWan3p0AspectRatio::Portrait3x4 => "3:4",
KinoviWan3p0AspectRatio::Square1x1 => "1:1",
},
duration: format!("{duration_seconds}s"),
output_resolution: match resolution.unwrap_or_default() {
KinoviWan3p0OutputResolution::FourEightyP => "480p",
KinoviWan3p0OutputResolution::SevenTwentyP => "720p",
KinoviWan3p0OutputResolution::TenEightyP => "1080p",
},
seed,
audio,
uploaded_urls: None,
video_url: None,
video_urls: None,
audio_urls: None,
})
}
pub(super) fn validate_media(
&mut self,
duration_seconds: u8,
maybe_total_reference_video_duration_millis: Option<u32>,
) -> Result<(), KinoviWebError> {
self.uploaded_urls = self.uploaded_urls.take().filter(|urls| !urls.is_empty());
self.video_urls = self.video_urls.take().filter(|urls| !urls.is_empty());
self.audio_urls = self.audio_urls.take().filter(|urls| !urls.is_empty());
let image_count = self.uploaded_urls.as_ref().map_or(0, Vec::len);
let video_count = self.video_urls.as_ref().map_or(0, Vec::len);
let audio_count = self.audio_urls.as_ref().map_or(0, Vec::len);
let max_images = if self.mode == Some("keyframe") { 2 } else { 10 };
for (field, count, maximum) in [
("maybe_reference_image_urls", image_count, max_images),
("maybe_reference_video_urls", video_count, 5),
("maybe_reference_audio_urls", audio_count, 5),
] {
if count > maximum {
return Err(invalid_field(field, count, format!("at most {maximum} items are supported")));
}
}
if self.mode == Some("keyframe") && image_count == 0 {
return Err(invalid_field("maybe_start_frame_url", "", "image-to-video requires a start frame"));
}
if self.prompt.trim().is_empty() && image_count + video_count + audio_count == 0 {
return Err(invalid_field("prompt", "", "a prompt is required when no media is supplied"));
}
for urls in [&self.uploaded_urls, &self.video_urls, &self.audio_urls].into_iter().flatten() {
if urls.iter().any(|url| url.trim().is_empty()) {
return Err(invalid_field("media_url", "", "media URLs must not be empty"));
}
}
if video_count > 0 {
// Without measured metadata, only enforce the known lower bound:
// each reference video must be at least 1s. Kinovi validates actual
// media durations; the client does not download/probe URLs here.
let minimum_millis = video_count as u32 * 1_000;
let input_millis = maybe_total_reference_video_duration_millis.unwrap_or(minimum_millis);
if !(minimum_millis..=MAX_REFERENCE_VIDEO_DURATION_MILLIS).contains(&input_millis) {
return Err(invalid_field(
"maybe_total_reference_video_duration_millis",
input_millis,
"reference videos must each be 1-15s and total at most 15s",
));
}
if input_millis + u32::from(duration_seconds) * 1_000 > 30_000 {
return Err(invalid_field(
"duration_seconds",
duration_seconds,
"reference-video duration plus output duration must not exceed 30s",
));
}
self.video_url = self.video_urls.as_ref().and_then(|urls| urls.first()).cloned();
}
Ok(())
}
}
pub(super) fn invalid_field(field: &'static str, value: impl ToString, reason: impl Into<String>) -> KinoviWebError {
KinoviWebClientError::InvalidRequestField {
field,
raw_value: value.to_string(),
reason: reason.into(),
}
.into()
}
#[cfg(test)]
mod tests {
use super::*;
const FLOAT_TOLERANCE: f64 = 1e-9;
#[test]
fn regular_and_prime_credit_prices_match_the_promotion() {
let cases: &[(Wan3p0Model, KinoviWan3p0OutputResolution, u8, f64)] = &[
(Wan3p0Model::TextToVideo, KinoviWan3p0OutputResolution::FourEightyP, 2, 19.96),
(Wan3p0Model::ImageToVideo, KinoviWan3p0OutputResolution::SevenTwentyP, 5, 99.8),
(Wan3p0Model::RefToVideo, KinoviWan3p0OutputResolution::TenEightyP, 10, 399.1),
(Wan3p0Model::PrimeTextToVideo, KinoviWan3p0OutputResolution::FourEightyP, 2, 37.72),
(Wan3p0Model::PrimeImageToVideo, KinoviWan3p0OutputResolution::SevenTwentyP, 5, 194.15),
(Wan3p0Model::PrimeRefToVideo, KinoviWan3p0OutputResolution::TenEightyP, 10, 776.6),
];
for (model, resolution, duration, credits) in cases {
let enterprise = model.calculate_costs(Some(*resolution), *duration, KinoviPricingTier::Enterprise);
let consumer = model.calculate_costs(Some(*resolution), *duration, KinoviPricingTier::Consumer);
assert!((enterprise.kinovi_credits - credits).abs() < FLOAT_TOLERANCE);
assert!((consumer.kinovi_credits - credits).abs() < FLOAT_TOLERANCE);
assert!(consumer.usd_cents_fractional > enterprise.usd_cents_fractional);
}
}
#[test]
fn all_wan_modalities_scale_linearly_with_duration() {
for model in [
Wan3p0Model::TextToVideo,
Wan3p0Model::ImageToVideo,
Wan3p0Model::RefToVideo,
Wan3p0Model::PrimeTextToVideo,
Wan3p0Model::PrimeImageToVideo,
Wan3p0Model::PrimeRefToVideo,
] {
let two_seconds = model.calculate_costs(
Some(KinoviWan3p0OutputResolution::SevenTwentyP),
2,
KinoviPricingTier::Enterprise,
);
let ten_seconds = model.calculate_costs(
Some(KinoviWan3p0OutputResolution::SevenTwentyP),
10,
KinoviPricingTier::Enterprise,
);
assert!((ten_seconds.kinovi_credits - two_seconds.kinovi_credits * 5.0).abs() < FLOAT_TOLERANCE);
}
}
}
@@ -1,10 +0,0 @@
{
"model": "wan3.0-ref-to-video",
"contentMode": "normal",
"faceBlurMode": "off",
"prompt": "A corgi eats a watermelon",
"mode": "reference",
"aspectRatio": "16:9",
"duration": "5s",
"outputResolution": "720p"
}
@@ -1,9 +0,0 @@
{
"model": "wan3.0-text-to-video",
"contentMode": "normal",
"faceBlurMode": "off",
"prompt": "A shiba eats a pie",
"aspectRatio": "16:9",
"duration": "5s",
"outputResolution": "720p"
}
@@ -1,13 +0,0 @@
{
"model": "wan3.0-prime-image-to-video",
"contentMode": "normal",
"faceBlurMode": "off",
"prompt": "A lighthouse over the coast",
"mode": "keyframe",
"aspectRatio": "16:9",
"duration": "5s",
"outputResolution": "720p",
"uploadedUrls": [
"https://static.seedance2-pro.com/materials/20260918/1789739927378-438b78db.png"
]
}
@@ -1,21 +0,0 @@
{
"model": "wan3.0-ref-to-video",
"contentMode": "normal",
"faceBlurMode": "off",
"prompt": "The Image 2 is on the beach Video 1 . There's a lighthouse Image 1 . The shiba says Audio 1",
"mode": "reference",
"aspectRatio": "4:3",
"duration": "5s",
"outputResolution": "1080p",
"videoUrl": "https://static.seedance2-pro.com/materials/20260918/1789740067508-9b952e67.mp4",
"videoUrls": [
"https://static.seedance2-pro.com/materials/20260918/1789740067508-9b952e67.mp4"
],
"audioUrls": [
"https://static.seedance2-pro.com/materials/20260918/1789740228370-3f5d9623.wav"
],
"uploadedUrls": [
"https://static.seedance2-pro.com/materials/20260918/1789740059548-4149c254.png",
"https://static.seedance2-pro.com/materials/20260918/1789740077221-9702e67d.png"
]
}
@@ -1,14 +0,0 @@
{
"model": "wan3.0-prime-ref-to-video",
"contentMode": "normal",
"faceBlurMode": "off",
"prompt": "There's a shop full of lanterns. the camera orbits.",
"mode": "reference",
"aspectRatio": "16:9",
"duration": "10s",
"outputResolution": "480p",
"seed": 2028741236,
"uploadedUrls": [
"https://static.seedance2-pro.com/materials/20260918/1789740399001-2bb62f21.jpg"
]
}
@@ -1,9 +0,0 @@
{
"model": "wan3.0-prime-text-to-video",
"contentMode": "normal",
"faceBlurMode": "off",
"prompt": "A bowl of soup is knocked over by a knight",
"aspectRatio": "16:9",
"duration": "5s",
"outputResolution": "720p"
}
@@ -1,15 +0,0 @@
{
"model": "wan3.0-prime-image-to-video",
"contentMode": "normal",
"faceBlurMode": "off",
"prompt": "The lanterns blow in the breeze above the water",
"mode": "keyframe",
"aspectRatio": "16:9",
"duration": "4s",
"outputResolution": "1080p",
"seed": 1878107760,
"uploadedUrls": [
"https://static.seedance2-pro.com/materials/20260918/1789740716787-bf6a26f8.jpg",
"https://static.seedance2-pro.com/materials/20260918/1789740722438-0101d158.jpg"
]
}
@@ -1,523 +0,0 @@
use std::time::Duration;
use serde_json::{json, Value};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tokio::task::JoinHandle;
use tokio::time::timeout;
use super::generate_wan_3p0_image_to_video::*;
use super::generate_wan_3p0_prime_image_to_video::*;
use super::generate_wan_3p0_prime_ref_to_video::*;
use super::generate_wan_3p0_prime_text_to_video::*;
use super::generate_wan_3p0_ref_to_video::*;
use super::generate_wan_3p0_text_to_video::*;
use super::wan_3p0::{KinoviWan3p0AspectRatio, KinoviWan3p0OutputResolution, Wan3p0ApiParams};
use crate::creds::kinovi_web_session::KinoviWebSession;
use crate::error::kinovi_web_client_error::KinoviWebClientError;
use crate::error::kinovi_web_error::KinoviWebError;
use crate::pricing::kinovi_cost_calculator_trait::KinoviCostCalculatorTrait;
use crate::pricing::kinovi_pricing_tier::KinoviPricingTier;
use crate::requests::kinovi_host::KinoviHost;
const RESOLUTIONS: [KinoviWan3p0OutputResolution; 3] = [
KinoviWan3p0OutputResolution::FourEightyP,
KinoviWan3p0OutputResolution::SevenTwentyP,
KinoviWan3p0OutputResolution::TenEightyP,
];
// Literal price snapshots from the supplied observations and Kinovi's model
// pages, checked 2026-09-18. Keep independent of the calculator's rate tables.
// Columns are 480p, 720p, 1080p; all three modalities have the same rates.
// WAN_CREDITS and WAN_CENTS intentionally assert promotional prices until a
// reviewed update after 2026-09-23. See costs_wan_3p0.md at the crate root for
// advertised replacement rates and all expectations that need to change.
const WAN_CREDITS: [(u8, [f64; 3]); 9] = [
(2, [19.96, 39.92, 79.82]),
(5, [49.9, 99.8, 199.55]),
(8, [79.84, 159.68, 319.28]),
(10, [99.8, 199.6, 399.1]),
(15, [149.7, 299.4, 598.65]),
(20, [199.6, 399.2, 798.2]),
(22, [219.56, 439.12, 878.02]),
(25, [249.5, 499.0, 997.75]),
(30, [299.4, 598.8, 1197.3]),
];
const PRIME_CREDITS: [(u8, [f64; 3]); 9] = [
(2, [37.72, 77.66, 155.32]),
(5, [94.3, 194.15, 388.3]),
(8, [150.88, 310.64, 621.28]),
(10, [188.6, 388.3, 776.6]),
(15, [282.9, 582.45, 1164.9]),
(20, [377.2, 776.6, 1553.2]),
(22, [414.92, 854.26, 1708.52]),
(25, [471.5, 970.75, 1941.5]),
// The supplied Prime reference 480p/30s figure said 568.8; docs say 565.8.
(30, [565.8, 1164.9, 2329.8]),
];
// Thirty-second USD cents, (rounded down, rounded up), consumer then enterprise.
const WAN_CENTS: [[(u64, u64); 3]; 2] = [[(155, 156), (310, 311), (620, 621)], [(123, 124), (246, 247), (492, 493)]];
const PRIME_CENTS: [[(u64, u64); 3]; 2] =
[[(293, 294), (603, 604), (1207, 1208)], [(232, 233), (479, 480), (958, 959)]];
// Only sanitized apiParams are included; the original captures contain cookies.
const CAPTURES: [&str; 7] = [
include_str!("wan_3p0_test_data/01_wan_ref_to_video_without_ref.json"),
include_str!("wan_3p0_test_data/02_wan_text_to_video.json"),
include_str!("wan_3p0_test_data/03_wan_image_to_video.json"),
include_str!("wan_3p0_test_data/04_wan_ref_to_video_full.json"),
include_str!("wan_3p0_test_data/05_wan_prime_ref_to_video.json"),
include_str!("wan_3p0_test_data/06_wan_prime_text_to_video.json"),
include_str!("wan_3p0_test_data/07_wan_prime_image_to_video.json"),
];
macro_rules! operation_tests {
($module:ident, $request:ident, $args:ident, $generate:ident, $model:literal,
$credits:ident, $cents:ident, {$($field:ident: $value:expr),* $(,)?}) => {
mod $module {
use super::*;
#[test]
fn literal_credit_prices_at_every_resolution_and_observed_duration() {
for tier in [KinoviPricingTier::Consumer, KinoviPricingTier::Enterprise] {
for (duration, expected) in $credits {
for (resolution, credits) in RESOLUTIONS.into_iter().zip(expected) {
// Cost estimates work before media has been uploaded.
let request = $request {
duration_seconds: duration,
maybe_output_resolution: Some(resolution),
..Default::default()
};
assert_eq!(request.calculate_costs(tier).kinovi_credits, credits,
"{tier:?}, {resolution:?}, {duration}s");
}
}
}
}
#[test]
fn literal_usd_cent_prices_for_both_tiers() {
for (tier, expected) in [KinoviPricingTier::Consumer, KinoviPricingTier::Enterprise]
.into_iter().zip($cents)
{
for (resolution, (down, up)) in RESOLUTIONS.into_iter().zip(expected) {
let request = $request {
duration_seconds: 30,
maybe_output_resolution: Some(resolution),
..Default::default()
};
let cost = request.calculate_costs(tier);
assert_eq!((cost.usd_cents_rounded_down, cost.usd_cents_rounded_up), (down, up));
}
}
}
#[test]
fn defaults_match_the_priced_resolution() {
let request = valid_request();
assert_eq!(request.calculate_consumer_costs().kinovi_credits, $credits[1].1[1]);
let params = serde_json::to_value(request.build_api_params().unwrap()).unwrap();
assert_eq!(params["model"], $model);
assert_eq!(params["duration"], "5s");
assert_eq!(params["outputResolution"], "720p");
assert_eq!(params["aspectRatio"], "16:9");
assert!(params.get("seed").is_none());
assert!(params.get("audio").is_none());
}
#[test]
fn all_whole_second_durations_and_aspect_ratios_serialize() {
for duration in 2..=30 {
let mut request = valid_request();
request.duration_seconds = duration;
let params = serde_json::to_value(request.build_api_params().unwrap()).unwrap();
assert_eq!(params["duration"], format!("{duration}s"));
}
for (maybe_aspect_ratio, expected) in [
(KinoviWan3p0AspectRatio::Landscape16x9, "16:9"),
(KinoviWan3p0AspectRatio::Portrait9x16, "9:16"),
(KinoviWan3p0AspectRatio::Landscape4x3, "4:3"),
(KinoviWan3p0AspectRatio::Portrait3x4, "3:4"),
(KinoviWan3p0AspectRatio::Square1x1, "1:1"),
] {
let mut request = valid_request();
request.maybe_aspect_ratio = Some(maybe_aspect_ratio);
let params = serde_json::to_value(request.build_api_params().unwrap()).unwrap();
assert_eq!(params["aspectRatio"], expected);
}
for (resolution, expected) in RESOLUTIONS.into_iter().zip(["480p", "720p", "1080p"]) {
let mut request = valid_request();
request.maybe_output_resolution = Some(resolution);
let params = serde_json::to_value(request.build_api_params().unwrap()).unwrap();
assert_eq!(params["outputResolution"], expected);
}
}
#[test]
fn seed_and_audio_flags_preserve_values_without_changing_price() {
for (seed, audio) in [(0, false), (2_147_483_647, true)] {
let mut request = valid_request();
request.maybe_seed = Some(seed);
request.maybe_generate_audio = Some(audio);
assert_eq!(request.calculate_consumer_costs().kinovi_credits, $credits[1].1[1]);
let params = serde_json::to_value(request.build_api_params().unwrap()).unwrap();
assert_eq!(params["seed"], seed);
assert_eq!(params["audio"], audio);
}
}
#[test]
fn invalid_duration_seed_and_prompt_are_rejected() {
for duration in [0, 1, 31, 255] {
let mut request = valid_request();
request.duration_seconds = duration;
assert_invalid(request.build_api_params(), "duration_seconds");
}
for seed in [2_147_483_648, u32::MAX] {
let mut request = valid_request();
request.maybe_seed = Some(seed);
assert_invalid(request.build_api_params(), "maybe_seed");
}
let mut request = valid_request();
request.prompt = "猫".repeat(20_000);
assert!(request.clone().build_api_params().is_ok());
request.prompt.push('猫');
assert_invalid(request.build_api_params(), "prompt");
}
#[tokio::test]
async fn public_generate_sends_workflow_request_and_returns_task_ids() {
let (host, server) = stub_server().await;
let session = KinoviWebSession::from_cookies_string("test-session=local".to_owned());
let request = valid_request();
let params = serde_json::to_value(request.clone().build_api_params().unwrap()).unwrap();
let result = timeout(Duration::from_secs(10), $generate($args {
request,
session: &session,
maybe_host_override: Some(host),
})).await.unwrap().unwrap();
assert_eq!(result.task_id, "wan-task");
assert_eq!(result.order_id, "wan-order");
assert_eq!(result.task_ids, Some(vec!["wan-task".to_owned()]));
assert_eq!(result.order_ids, Some(vec!["wan-order".to_owned()]));
let (headers, body) = timeout(Duration::from_secs(10), server).await.unwrap().unwrap();
assert!(headers.starts_with("POST /api/trpc/workflow.runTask?batch=1 HTTP/1.1\r\n"));
assert_eq!(body, json!({"0": {"json": {
"businessType": "wan3-video-generation", "apiParams": params,
}}}));
}
fn valid_request() -> $request {
$request {
prompt: "A lighthouse on the coast".to_owned(),
$($field: $value,)*
..Default::default()
}
}
}
};
}
operation_tests!(
text,
GenerateWan3p0TextToVideoRequest,
GenerateWan3p0TextToVideoArgs,
generate_wan_3p0_text_to_video,
"wan3.0-text-to-video",
WAN_CREDITS,
WAN_CENTS,
{}
);
operation_tests!(image, GenerateWan3p0ImageToVideoRequest, GenerateWan3p0ImageToVideoArgs,
generate_wan_3p0_image_to_video, "wan3.0-image-to-video", WAN_CREDITS, WAN_CENTS,
{ maybe_start_frame_url: Some("https://example.com/start.png".to_owned()) });
operation_tests!(
reference,
GenerateWan3p0RefToVideoRequest,
GenerateWan3p0RefToVideoArgs,
generate_wan_3p0_ref_to_video,
"wan3.0-ref-to-video",
WAN_CREDITS,
WAN_CENTS,
{}
);
operation_tests!(
prime_text,
GenerateWan3p0PrimeTextToVideoRequest,
GenerateWan3p0PrimeTextToVideoArgs,
generate_wan_3p0_prime_text_to_video,
"wan3.0-prime-text-to-video",
PRIME_CREDITS,
PRIME_CENTS,
{}
);
operation_tests!(prime_image, GenerateWan3p0PrimeImageToVideoRequest, GenerateWan3p0PrimeImageToVideoArgs,
generate_wan_3p0_prime_image_to_video, "wan3.0-prime-image-to-video", PRIME_CREDITS, PRIME_CENTS,
{ maybe_start_frame_url: Some("https://example.com/start.png".to_owned()) });
operation_tests!(
prime_reference,
GenerateWan3p0PrimeRefToVideoRequest,
GenerateWan3p0PrimeRefToVideoArgs,
generate_wan_3p0_prime_ref_to_video,
"wan3.0-prime-ref-to-video",
PRIME_CREDITS,
PRIME_CENTS,
{}
);
#[test]
fn payloads_match_all_seven_consumer_captures() {
for fixture in CAPTURES {
let expected: Value = serde_json::from_str(fixture).unwrap();
assert_eq!(params_from_capture(&expected), expected);
}
// Capture 03's filename says Wan image, but its actual model is Prime.
// Exercise regular image too, using the same documented keyframe shape.
let mut regular_image: Value = serde_json::from_str(CAPTURES[2]).unwrap();
regular_image["model"] = json!("wan3.0-image-to-video");
assert_eq!(params_from_capture(&regular_image), regular_image);
}
macro_rules! reference_media_tests {
($module:ident, $request:ident, $credits:literal) => {
mod $module {
use super::*;
#[test]
fn references_are_optional_and_empty_lists_are_omitted() {
let request = $request {
prompt: "A ship at sea".to_owned(),
duration_seconds: 30,
maybe_reference_image_urls: Some(vec![]),
maybe_reference_video_urls: Some(vec![]),
maybe_reference_audio_urls: Some(vec![]),
..Default::default()
};
let params = serde_json::to_value(request.build_api_params().unwrap()).unwrap();
for field in ["uploadedUrls", "videoUrl", "videoUrls", "audioUrls"] {
assert!(params.get(field).is_none(), "{field}");
}
assert_invalid($request::default().build_api_params(), "prompt");
}
#[test]
fn reference_duration_and_output_share_thirty_seconds() {
let request = $request {
duration_seconds: 20,
maybe_reference_video_urls: Some(urls(1)),
maybe_total_reference_video_duration_millis: Some(10_000),
..Default::default()
};
assert!(request.clone().build_api_params().is_ok());
for millis in [0, 999, 10_001, 15_001, u32::MAX] {
let mut invalid = request.clone();
invalid.maybe_total_reference_video_duration_millis = Some(millis);
assert!(invalid.build_api_params().is_err(), "{millis}ms");
}
let mut request = request;
request.duration_seconds = 15;
request.maybe_total_reference_video_duration_millis = Some(15_000);
assert!(request.clone().build_api_params().is_ok());
request.duration_seconds = 16;
assert_invalid(request.clone().build_api_params(), "duration_seconds");
request.maybe_total_reference_video_duration_millis = None;
request.duration_seconds = 29;
assert!(request.clone().build_api_params().is_ok());
request.duration_seconds = 30;
assert_invalid(request.build_api_params(), "duration_seconds");
}
#[test]
fn all_reference_types_have_independent_limits_and_no_surcharge() {
let request = $request {
duration_seconds: 10,
maybe_reference_image_urls: Some(urls(10)),
maybe_reference_video_urls: Some(urls(5)),
maybe_reference_audio_urls: Some(urls(5)),
maybe_total_reference_video_duration_millis: Some(15_000),
..Default::default()
};
assert_eq!(request.calculate_consumer_costs().kinovi_credits, $credits);
let params = serde_json::to_value(request.clone().build_api_params().unwrap()).unwrap();
assert_eq!(params["uploadedUrls"], json!(urls(10)));
assert_eq!(params["videoUrls"], json!(urls(5)));
assert_eq!(params["videoUrl"], urls(1)[0]);
assert_eq!(params["audioUrls"], json!(urls(5)));
assert!(params.get("maybeTotalReferenceVideoDurationMillis").is_none());
assert!(params.get("imageUrls").is_none());
let mut invalid = request.clone();
invalid.maybe_reference_image_urls = Some(urls(11));
assert_invalid(invalid.build_api_params(), "maybe_reference_image_urls");
let mut invalid = request.clone();
invalid.maybe_reference_video_urls = Some(urls(6));
assert_invalid(invalid.build_api_params(), "maybe_reference_video_urls");
let mut invalid = request.clone();
invalid.maybe_reference_audio_urls = Some(urls(6));
assert_invalid(invalid.build_api_params(), "maybe_reference_audio_urls");
let mut invalid = request.clone();
invalid.maybe_total_reference_video_duration_millis = Some(4_999);
assert_invalid(invalid.build_api_params(), "maybe_total_reference_video_duration_millis");
let mut invalid = request;
invalid.maybe_reference_audio_urls = Some(vec![" ".to_owned()]);
assert_invalid(invalid.build_api_params(), "media_url");
}
}
};
}
reference_media_tests!(reference_media, GenerateWan3p0RefToVideoRequest, 199.6);
reference_media_tests!(prime_reference_media, GenerateWan3p0PrimeRefToVideoRequest, 388.3);
#[test]
fn keyframes_require_a_start_image_and_preserve_start_end_order() {
macro_rules! check {
($request:ident, $credits:literal) => {
let mut request = $request {
prompt: "Waves".to_owned(),
..Default::default()
};
assert_invalid(request.clone().build_api_params(), "maybe_start_frame_url");
request.maybe_end_frame_url = Some("https://example.com/end.png".to_owned());
assert_invalid(request.clone().build_api_params(), "maybe_end_frame_url");
request.maybe_start_frame_url = Some("https://example.com/start.png".to_owned());
request.prompt.clear();
assert_eq!(request.calculate_consumer_costs().kinovi_credits, $credits);
let params = serde_json::to_value(request.build_api_params().unwrap()).unwrap();
assert_eq!(params["uploadedUrls"], json!(["https://example.com/start.png", "https://example.com/end.png",]));
assert!(params.get("imageUrls").is_none());
};
}
check!(GenerateWan3p0ImageToVideoRequest, 99.8);
check!(GenerateWan3p0PrimeImageToVideoRequest, 194.15);
}
#[test]
fn text_operations_require_a_prompt() {
assert_invalid(GenerateWan3p0TextToVideoRequest::default().build_api_params(), "prompt");
assert_invalid(GenerateWan3p0PrimeTextToVideoRequest::default().build_api_params(), "prompt");
}
fn params_from_capture(value: &Value) -> Value {
macro_rules! build {
($request:ident, {$($field:ident: $extra:expr),* $(,)?}) => {
$request {
prompt: value["prompt"].as_str().unwrap().to_owned(),
maybe_aspect_ratio: Some(match value["aspectRatio"].as_str().unwrap() {
"16:9" => KinoviWan3p0AspectRatio::Landscape16x9,
"4:3" => KinoviWan3p0AspectRatio::Landscape4x3,
other => panic!("unexpected fixture aspect ratio: {other}"),
}),
maybe_output_resolution: Some(match value["outputResolution"].as_str().unwrap() {
"480p" => KinoviWan3p0OutputResolution::FourEightyP,
"720p" => KinoviWan3p0OutputResolution::SevenTwentyP,
"1080p" => KinoviWan3p0OutputResolution::TenEightyP,
other => panic!("unexpected fixture resolution: {other}"),
}),
duration_seconds: value["duration"].as_str().unwrap().trim_end_matches('s').parse().unwrap(),
maybe_seed: value["seed"].as_u64().map(|seed| seed as u32),
$($field: $extra,)*
..Default::default()
}.build_api_params().unwrap()
};
}
macro_rules! image {
($request:ident) => {
build!($request, {
maybe_start_frame_url: value["uploadedUrls"][0].as_str().map(str::to_owned),
maybe_end_frame_url: value["uploadedUrls"][1].as_str().map(str::to_owned),
})
};
}
macro_rules! reference {
($request:ident) => {
build!($request, {
maybe_reference_image_urls: capture_urls(value, "uploadedUrls"),
maybe_reference_video_urls: capture_urls(value, "videoUrls"),
maybe_reference_audio_urls: capture_urls(value, "audioUrls"),
})
};
}
let params = match value["model"].as_str().unwrap() {
"wan3.0-text-to-video" => build!(GenerateWan3p0TextToVideoRequest, {}),
"wan3.0-image-to-video" => image!(GenerateWan3p0ImageToVideoRequest),
"wan3.0-ref-to-video" => reference!(GenerateWan3p0RefToVideoRequest),
"wan3.0-prime-text-to-video" => build!(GenerateWan3p0PrimeTextToVideoRequest, {}),
"wan3.0-prime-image-to-video" => image!(GenerateWan3p0PrimeImageToVideoRequest),
"wan3.0-prime-ref-to-video" => reference!(GenerateWan3p0PrimeRefToVideoRequest),
other => panic!("unexpected fixture model: {other}"),
};
serde_json::to_value(params).unwrap()
}
async fn stub_server() -> (KinoviHost, JoinHandle<(String, Value)>) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = format!("http://{}", listener.local_addr().unwrap());
let server = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let mut bytes = Vec::new();
let (headers, body_start, body_len) = loop {
let mut chunk = [0; 4096];
let length = stream.read(&mut chunk).await.unwrap();
assert_ne!(length, 0, "request ended before headers");
bytes.extend_from_slice(&chunk[..length]);
if let Some(end) = bytes.windows(4).position(|window| window == b"\r\n\r\n") {
let headers = String::from_utf8(bytes[..end + 4].to_vec()).unwrap();
let body_len: usize = headers
.lines()
.find_map(|line| {
let (name, value) = line.split_once(':')?;
name.eq_ignore_ascii_case("content-length").then(|| value.trim().parse().unwrap())
})
.unwrap();
break (headers, end + 4, body_len);
}
};
while bytes.len() < body_start + body_len {
let mut chunk = [0; 4096];
let length = stream.read(&mut chunk).await.unwrap();
assert_ne!(length, 0, "request ended before body");
bytes.extend_from_slice(&chunk[..length]);
}
let body = serde_json::from_slice(&bytes[body_start..body_start + body_len]).unwrap();
let response = json!([{"result": {"data": {"json": {
"taskId": "wan-task", "orderId": "wan-order",
"taskIds": ["wan-task"], "orderIds": ["wan-order"], "violationWarning": false,
}}}}])
.to_string();
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
response.len(),
response,
);
stream.write_all(response.as_bytes()).await.unwrap();
(headers, body)
});
(
KinoviHost::CustomHost {
api_host: address.clone(),
cdn_host: address,
},
server,
)
}
fn capture_urls(value: &Value, field: &str) -> Option<Vec<String>> {
value[field]
.as_array()
.map(|urls| urls.iter().map(|url| url.as_str().unwrap().to_owned()).collect())
}
fn urls(count: usize) -> Vec<String> {
(0..count).map(|index| format!("https://example.com/reference-{index}")).collect()
}
fn assert_invalid(result: Result<Wan3p0ApiParams, KinoviWebError>, field: &str) {
match result.unwrap_err() {
KinoviWebError::Client(KinoviWebClientError::InvalidRequestField {
field: actual,
..
}) => {
assert_eq!(actual, field);
},
other => panic!("expected invalid {field}, got {other:?}"),
}
}
@@ -68,10 +68,6 @@ pub (super) struct ApiParams {
pub batch_count: Option<u8>,
#[serde(rename = "bitrate_mode", skip_serializing_if = "Option::is_none")]
pub bitrate_mode: Option<&'static str>,
#[serde(rename = "output_format", skip_serializing_if = "Option::is_none")]
pub maybe_output_format: Option<&'static str>,
#[serde(rename = "generate_audio", skip_serializing_if = "Option::is_none")]
pub maybe_generate_audio: Option<bool>,
}
#[derive(Deserialize, Debug)]
@@ -74,12 +74,6 @@ pub struct WorkflowRunTaskRequest {
/// Output video bitrate. None defaults to "standard" (the field is omitted);
/// `High` sends `bitrate_mode: "high"`. Does not affect cost.
pub bitrate: Option<KinoviBitrateRaw>,
/// Output container, supported only by Seedance 2.5. None omits the field.
pub maybe_output_format: Option<KinoviOutputFormatRaw>,
/// Audio generation for Seedance 2.0 and 2.5. None keeps the provider default.
pub maybe_generate_audio: Option<bool>,
}
impl std::fmt::Debug for WorkflowRunTaskRequest {
@@ -99,8 +93,6 @@ impl std::fmt::Debug for WorkflowRunTaskRequest {
.field("output_resolution", &self.output_resolution)
.field("use_face_blur_hack", &self.use_face_blur_hack)
.field("bitrate", &self.bitrate)
.field("maybe_output_format", &self.maybe_output_format)
.field("maybe_generate_audio", &self.maybe_generate_audio)
.finish()
}
}
@@ -116,22 +108,6 @@ impl std::fmt::Debug for WorkflowRunTaskArgs<'_> {
// --- Public enums ---
/// Output container for Seedance 2.5.
#[derive(Debug, Clone, Copy)]
pub enum KinoviOutputFormatRaw {
Mp4,
Mov,
}
impl KinoviOutputFormatRaw {
fn as_api_str(self) -> &'static str {
match self {
Self::Mp4 => "mp4",
Self::Mov => "mov",
}
}
}
/// Video resolution / aspect ratio.
#[derive(Debug, Clone, Copy)]
pub enum KinoviAspectRatioRaw {
@@ -613,15 +589,6 @@ fn build_batch_request(req: WorkflowRunTaskRequest) -> BatchRequest {
audio_urls,
batch_count,
bitrate_mode: req.bitrate.and_then(|bitrate| bitrate.as_api_str()),
maybe_output_format: match req.model_type {
KinoviModelTypeRaw::Seedance2p5 => req.maybe_output_format.map(KinoviOutputFormatRaw::as_api_str),
_ => None,
},
maybe_generate_audio: match req.model_type {
KinoviModelTypeRaw::Seedance2Pro | KinoviModelTypeRaw::Seedance2Fast
| KinoviModelTypeRaw::Seedance2Mini | KinoviModelTypeRaw::Seedance2p5 => req.maybe_generate_audio,
_ => None,
},
},
},
},
@@ -683,8 +650,6 @@ mod tests {
audio_urls: None,
batch_count: None,
bitrate_mode,
maybe_output_format: None,
maybe_generate_audio: None,
}
}
}
@@ -736,8 +701,6 @@ mod tests {
audio_urls: None,
batch_count: None,
bitrate_mode: None,
maybe_output_format: None,
maybe_generate_audio: None,
}
}
}
@@ -772,8 +735,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_output_format: None,
maybe_generate_audio: None,
}
}
@@ -816,30 +777,6 @@ mod tests {
assert!(json.contains(r#""batchCount":8"#), "{json}");
}
#[test]
fn batch_count_serializes_for_all_video_models() {
for model in [
KinoviModelTypeRaw::Seedance2Pro,
KinoviModelTypeRaw::Seedance2Fast,
KinoviModelTypeRaw::Seedance2Mini,
KinoviModelTypeRaw::Seedance2p5,
KinoviModelTypeRaw::Seedance2p5Preview,
KinoviModelTypeRaw::HappyHorse1p0,
] {
let batches = match model {
KinoviModelTypeRaw::HappyHorse1p0 => [(KinoviBatchCountRaw::One, None), (KinoviBatchCountRaw::Three, Some(3))],
_ => [(KinoviBatchCountRaw::One, None), (KinoviBatchCountRaw::Eight, Some(8))],
};
for (batch, expected) in batches {
let mut request = mini_request(KinoviAspectRatioRaw::Landscape16x9, None, batch);
request.model_type = model;
let body = serde_json::to_value(build_batch_request(request)).unwrap();
let params = &body["0"]["json"]["apiParams"];
assert_eq!(params.get("batchCount"), expected.map(serde_json::Value::from).as_ref(), "{model:?}");
}
}
}
#[test]
fn non_mini_still_uses_resolution_field() {
let mut req = mini_request(KinoviAspectRatioRaw::Landscape16x9, None, KinoviBatchCountRaw::One);
@@ -905,8 +842,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_output_format: None,
maybe_generate_audio: None,
}
}
@@ -1012,51 +947,6 @@ mod tests {
mod seedance_2p5_shape_tests {
use super::*;
#[test]
fn optional_output_settings_preserve_omission_and_explicit_values() {
for (format, expected) in [(None, None), (Some(KinoviOutputFormatRaw::Mp4), Some("mp4")), (Some(KinoviOutputFormatRaw::Mov), Some("mov"))] {
for audio in [None, Some(false), Some(true)] {
let mut request = base_request("A boat on a lake", 4);
request.maybe_output_format = format;
request.maybe_generate_audio = audio;
let body = serde_json::to_value(build_batch_request(request)).unwrap();
let params = &body["0"]["json"]["apiParams"];
assert_eq!(params.get("output_format"), expected.map(serde_json::Value::from).as_ref());
assert_eq!(params.get("generate_audio"), audio.map(serde_json::Value::from).as_ref());
}
}
}
#[test]
fn audio_setting_applies_to_seedance_2p0_without_output_format() {
for model in [KinoviModelTypeRaw::Seedance2Pro, KinoviModelTypeRaw::Seedance2Fast, KinoviModelTypeRaw::Seedance2Mini] {
for audio in [None, Some(false), Some(true)] {
let mut request = base_request("A boat on a lake", 4);
request.model_type = model;
request.maybe_output_format = Some(KinoviOutputFormatRaw::Mov);
request.maybe_generate_audio = audio;
let body = serde_json::to_value(build_batch_request(request)).unwrap();
let params = &body["0"]["json"]["apiParams"];
assert!(params.get("output_format").is_none());
assert_eq!(params.get("generate_audio"), audio.map(serde_json::Value::from).as_ref());
}
}
}
#[test]
fn unrelated_models_omit_unsupported_output_settings() {
for model in [KinoviModelTypeRaw::HappyHorse1p0, KinoviModelTypeRaw::Seedance2p5Preview] {
let mut request = base_request("A boat on a lake", 4);
request.model_type = model;
request.maybe_output_format = Some(KinoviOutputFormatRaw::Mov);
request.maybe_generate_audio = Some(false);
let body = serde_json::to_value(build_batch_request(request)).unwrap();
let params = &body["0"]["json"]["apiParams"];
assert!(params.get("output_format").is_none());
assert!(params.get("generate_audio").is_none());
}
}
fn base_request(prompt: &str, duration_seconds: u8) -> WorkflowRunTaskRequest {
WorkflowRunTaskRequest {
model_type: KinoviModelTypeRaw::Seedance2p5,
@@ -1073,8 +963,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_output_format: None,
maybe_generate_audio: None,
}
}
@@ -1227,8 +1115,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_output_format: None,
maybe_generate_audio: None,
}
}
@@ -1319,8 +1205,6 @@ mod tests {
use_face_blur_hack: None,
bitrate: None,
output_resolution: None,
maybe_output_format: None,
maybe_generate_audio: None,
},
};
let result = workflow_run_task(args).await?;
@@ -1355,8 +1239,6 @@ mod tests {
use_face_blur_hack: None,
bitrate: None,
output_resolution: None,
maybe_output_format: None,
maybe_generate_audio: None,
},
};
let result = workflow_run_task(args).await?;
@@ -1393,8 +1275,6 @@ mod tests {
use_face_blur_hack: None,
bitrate: None,
output_resolution: None,
maybe_output_format: None,
maybe_generate_audio: None,
},
};
let result = workflow_run_task(args).await?;
@@ -1430,8 +1310,6 @@ mod tests {
use_face_blur_hack: None,
bitrate: None,
output_resolution: None,
maybe_output_format: None,
maybe_generate_audio: None,
},
};
let result = workflow_run_task(args).await?;
@@ -1469,8 +1347,6 @@ mod tests {
use_face_blur_hack: None,
bitrate: None,
output_resolution: None,
maybe_output_format: None,
maybe_generate_audio: None,
},
};
let result = workflow_run_task(args).await?;
@@ -1525,8 +1401,6 @@ mod tests {
use_face_blur_hack: None,
bitrate: None,
output_resolution: None,
maybe_output_format: None,
maybe_generate_audio: None,
},
};
let result = workflow_run_task(args).await?;
@@ -1580,8 +1454,6 @@ mod tests {
use_face_blur_hack: None,
bitrate: None,
output_resolution: None,
maybe_output_format: None,
maybe_generate_audio: None,
},
};
let result = workflow_run_task(args).await?;
@@ -1635,8 +1507,6 @@ mod tests {
use_face_blur_hack: None,
bitrate: None,
output_resolution: None,
maybe_output_format: None,
maybe_generate_audio: None,
},
};
let result = workflow_run_task(args).await?;
@@ -1699,8 +1569,6 @@ mod tests {
use_face_blur_hack: None,
bitrate: None,
output_resolution: None,
maybe_output_format: None,
maybe_generate_audio: None,
},
};
let result = workflow_run_task(args).await?;
@@ -1756,8 +1624,6 @@ mod tests {
use_face_blur_hack: None,
bitrate: None,
output_resolution: None,
maybe_output_format: None,
maybe_generate_audio: None,
},
};
let result = workflow_run_task(args).await?;
@@ -1798,8 +1664,6 @@ mod tests {
use_face_blur_hack: None,
bitrate: None,
output_resolution: None,
maybe_output_format: None,
maybe_generate_audio: None,
},
};
let result = workflow_run_task(args).await?;
@@ -1834,8 +1698,6 @@ mod tests {
use_face_blur_hack: None,
bitrate: None,
output_resolution: None,
maybe_output_format: None,
maybe_generate_audio: None,
},
};
let result = workflow_run_task(args).await?;
@@ -1872,8 +1734,6 @@ mod tests {
use_face_blur_hack: None,
bitrate: None,
output_resolution: None,
maybe_output_format: None,
maybe_generate_audio: None,
},
};
let result = workflow_run_task(args).await?;
@@ -1911,8 +1771,6 @@ mod tests {
use_face_blur_hack: None,
bitrate: None,
output_resolution: None,
maybe_output_format: None,
maybe_generate_audio: None,
},
};
let result = workflow_run_task(args).await?;
@@ -1951,8 +1809,6 @@ mod tests {
use_face_blur_hack: Some(false),
bitrate: None,
output_resolution: Some(KinoviOutputResolutionRaw::TenEightyP),
maybe_output_format: None,
maybe_generate_audio: None,
},
};
let result = workflow_run_task(args).await?;
@@ -2005,8 +1861,6 @@ mod tests {
use_face_blur_hack: Some(false),
bitrate: None,
output_resolution: None,
maybe_output_format: None,
maybe_generate_audio: None,
},
};
let result = workflow_run_task(args).await?;
@@ -2059,8 +1913,6 @@ mod tests {
use_face_blur_hack: Some(false),
bitrate: None,
output_resolution: Some(KinoviOutputResolutionRaw::TenEightyP),
maybe_output_format: None,
maybe_generate_audio: None,
},
};
let result = workflow_run_task(args).await?;
@@ -2105,8 +1957,6 @@ mod tests {
output_resolution,
use_face_blur_hack: None,
bitrate: None,
maybe_output_format: None,
maybe_generate_audio: None,
},
}
}
@@ -2213,8 +2063,6 @@ mod tests {
use_face_blur_hack: None,
bitrate: None,
output_resolution: Some(KinoviOutputResolutionRaw::FourK),
maybe_output_format: None,
maybe_generate_audio: None,
},
};
let result = workflow_run_task(args).await?;
@@ -1,34 +0,0 @@
use crate::pricing::cost::kinovi_fractional_generation_cost::KinoviFractionalGenerationCost;
use crate::pricing::kinovi_pricing_tier::KinoviPricingTier;
const FLOAT_TOLERANCE: f64 = 1e-8;
/// Check scaling of credits and fractional cents, then independently compute
/// whole cents from the complete batch using integer arithmetic. Multiplying
/// a single video's already-rounded cents can overcharge a batch.
pub(crate) fn assert_batch_cost(
single: KinoviFractionalGenerationCost,
batched: KinoviFractionalGenerationCost,
count: u64,
tier: KinoviPricingTier,
context: &str,
) {
let expected_credit_hundredths = (single.kinovi_credits * 100.0).round() as u64 * count;
let expected_credits = expected_credit_hundredths as f64 / 100.0;
let expected_fractional_cents = single.usd_cents_fractional * count as f64;
assert_eq!(batched.kinovi_credits, expected_credits, "{context}: credits");
assert!(
(batched.usd_cents_fractional - expected_fractional_cents).abs() < FLOAT_TOLERANCE,
"{context}: fractional cents: {} != {expected_fractional_cents}",
batched.usd_cents_fractional,
);
// Credit purchase rates expressed in hundredths of a credit per dollar.
let credits_per_dollar_hundredths = match tier {
KinoviPricingTier::Consumer => 19_298,
KinoviPricingTier::Enterprise => 24_316,
};
let numerator = expected_credit_hundredths * 100;
assert_eq!(batched.usd_cents_rounded_up, numerator.div_ceil(credits_per_dollar_hundredths), "{context}: rounded up");
assert_eq!(batched.usd_cents_rounded_down, numerator / credits_per_dollar_hundredths, "{context}: rounded down");
}
@@ -1,6 +1,3 @@
#[cfg(test)]
pub (crate) mod assert_batch_cost;
#[cfg(test)]
pub (crate) mod get_test_cookies;
Loaded 100 of 433 files, more files were not shown because too many files have changed in this diff. Show more