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
375 changed files with 2885 additions and 14589 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,
@@ -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,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,
}
@@ -4,7 +4,6 @@ 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;
@@ -126,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>,
@@ -173,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,
@@ -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,3 +43,4 @@ pub mod veo_3p1_fast;
pub mod veo_3p1_lite;
pub mod vidu_q3;
pub mod vidu_q3_turbo;
@@ -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)]
@@ -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::*;
@@ -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");
}
}
@@ -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,245 +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()
}
@@ -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;
@@ -4,8 +4,7 @@ use serde_json::{json, Value};
#[derive(Debug)]
pub enum ThumbnailTaskInputMimeType {
MP4,
MOV,
MP4
}
pub enum ThumbnailTaskOutputMimeType {
@@ -18,7 +17,6 @@ impl ThumbnailTaskInputMimeType {
fn as_str(&self) -> &str {
match self {
ThumbnailTaskInputMimeType::MP4 => "video/mp4",
ThumbnailTaskInputMimeType::MOV => "video/quicktime",
}
}
@@ -28,7 +26,7 @@ impl ThumbnailTaskInputMimeType {
fn to_output_mimetypes(&self) -> Vec<ThumbnailTaskOutputMimeType> {
match self {
ThumbnailTaskInputMimeType::MP4 | ThumbnailTaskInputMimeType::MOV => vec![
ThumbnailTaskInputMimeType::MP4 => vec![
ThumbnailTaskOutputMimeType::JPEG,
ThumbnailTaskOutputMimeType::GIF,
],
@@ -213,21 +211,3 @@ impl ThumbnailTask{
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn quicktime_thumbnail_uses_correct_source_mime_type() {
let task = ThumbnailTaskBuilder::new_for_source_mimetype(ThumbnailTaskInputMimeType::MOV)
.with_bucket("test-bucket")
.with_path("video.mov")
.with_event_id("test-event")
.build(ThumbnailTaskOutputMimeType::JPEG)
.unwrap();
let json = task.request_json();
assert_eq!(json["input"]["type"], "video/quicktime");
assert_eq!(json["input"]["path"], "video.mov");
}
}
@@ -120,7 +120,6 @@ pub async fn run(state: &ArtcraftState, args: GenerateVideoArgs) -> anyhow::Resu
resolution: None,
aspect_ratio: None,
bitrate: None,
maybe_output_format: None,
quality: None,
duration_seconds: Some(duration),
video_batch_count: None,
@@ -428,7 +428,6 @@ async fn generate(
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
session,
host_override: None,
@@ -459,7 +458,6 @@ async fn generate(
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
session,
host_override: None,
+1 -1
View File
@@ -31,4 +31,4 @@ hostname.workspace = true
log = "0.4.14"
[dev-dependencies]
tempfile.workspace = true
# None yet
+3 -20
View File
@@ -5,8 +5,6 @@ use log::{info, warn};
use errors::{anyhow, AnyhowResult};
use server_environment::ServerEnvironment;
use crate::config_search_directories::{config_search_directories, find_repository_root};
pub struct BootstrapArgs<'a, P: AsRef<Path>> {
/// The name of the application or service
pub app_name: &'a str,
@@ -16,7 +14,6 @@ pub struct BootstrapArgs<'a, P: AsRef<Path>> {
pub default_logging_override: Option<&'a str>,
/// Where to look for env conf files.
/// Relative paths are also searched from an enclosing artcraft or artcraft-services checkout.
pub config_search_directories: &'a [P],
/// If true, ignore the root '.env' file.
@@ -116,16 +113,12 @@ pub fn bootstrap<P: AsRef<Path>>(args: BootstrapArgs<'_, P>) -> AnyhowResult<Con
}
fn load_env_config_files<P: AsRef<Path>>(server_environment: ServerEnvironment, args: &BootstrapArgs<'_, P>) -> AnyhowResult<()> {
let maybe_current_directory = std::env::current_dir().ok();
let maybe_repository_root = maybe_current_directory.as_deref().and_then(find_repository_root);
let search_directories = config_search_directories(args.config_search_directories, maybe_repository_root);
let secrets_filename = format!("{}.development-secrets.env", &args.app_name);
let env_config_file_names = match server_environment {
ServerEnvironment::Development => vec![
format!("{}.common.env", &args.app_name),
format!("{}.development.env", &args.app_name),
secrets_filename.clone(), // NB: .gitignore these files
format!("{}.development-secrets.env", &args.app_name), // NB: .gitignore these files
],
ServerEnvironment::Production => vec![
format!("{}.common.env", &args.app_name),
@@ -136,19 +129,9 @@ fn load_env_config_files<P: AsRef<Path>>(server_environment: ServerEnvironment,
for env_config_file in env_config_file_names.into_iter() {
info!("Loading environment variable config file: {}", &env_config_file);
let mut was_read = easyenv::maybe_read_env_config_from_filename_and_paths(
let was_read = easyenv::maybe_read_env_config_from_filename_and_paths(
&env_config_file,
&search_directories)?;
// Development backups fill missing variables, even when a local config file exists.
// dotenv preserves values already set in the environment or earlier config files.
if server_environment == ServerEnvironment::Development && env_config_file == secrets_filename {
if let Some(root) = maybe_repository_root {
was_read |= easyenv::maybe_read_env_config_from_filename_and_paths(
&env_config_file,
&[root.join("secrets/artcraft-backup")])?;
}
}
args.config_search_directories)?;
if was_read {
info!("Environment config file `{}` was read.", &env_config_file);
@@ -1,33 +0,0 @@
use std::path::{Path, PathBuf};
/// Keep caller-supplied paths first, then resolve relative paths from this checkout.
pub(crate) fn config_search_directories<P: AsRef<Path>>(
paths: &[P],
maybe_repository_root: Option<&Path>,
) -> Vec<PathBuf> {
let mut directories = paths.iter()
.map(|path| path.as_ref().to_path_buf())
.collect::<Vec<_>>();
if let Some(root) = maybe_repository_root {
for path in paths {
if path.as_ref().is_relative() {
let directory = root.join(path);
if !directories.contains(&directory) {
directories.push(directory);
}
}
}
}
directories
}
/// Use the enclosing checkout, never a sibling repository's configuration.
pub(crate) fn find_repository_root(current_directory: &Path) -> Option<&Path> {
current_directory.ancestors().find(|directory| {
matches!(directory.file_name().and_then(|name| name.to_str()), Some("artcraft" | "artcraft-services"))
&& directory.join("Cargo.toml").is_file()
&& directory.join("crates").is_dir()
})
}
+1 -3
View File
@@ -18,6 +18,4 @@
#![allow(dead_code)]
#![allow(non_snake_case)]
pub mod bootstrap;
mod config_search_directories;
pub mod bootstrap;
@@ -1,131 +0,0 @@
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use bootstrap::bootstrap::{bootstrap, BootstrapArgs};
use tempfile::TempDir;
const CONFIG_DIRECTORY: &str = "crates/service/job/test_job/config";
const BACKUP_DIRECTORY: &str = "secrets/artcraft-backup";
const CHILD_PROCESS: &str = "BOOTSTRAP_CONFIG_TEST_CHILD";
const VALUE: &str = "BOOTSTRAP_CONFIG_TEST_VALUE";
const MISSING_VALUE: &str = "BOOTSTRAP_CONFIG_TEST_MISSING_VALUE";
const COMMON_VALUE: &str = "BOOTSTRAP_CONFIG_TEST_COMMON_VALUE";
mod development {
use super::*;
#[test]
fn loads_checkout_configs_and_backup_secrets_from_roots_and_subdirectories() {
for name in ["artcraft", "artcraft-services"] {
let (_temporary, root) = checkout(name);
write_config(&root, CONFIG_DIRECTORY, "common", &format!("{COMMON_VALUE}=common\n"));
write_config(&root, BACKUP_DIRECTORY, "development-secrets", &format!("{VALUE}=backup\n"));
// The other checkout must not supply this process's secrets.
let sibling_name = if name == "artcraft" { "artcraft-services" } else { "artcraft" };
let sibling = root.parent().unwrap().join(sibling_name);
write_config(&sibling, BACKUP_DIRECTORY, "development-secrets", &format!("{VALUE}=wrong-checkout\n"));
// Nested directories can themselves be named artcraft without being the repo root.
let nested = root.join("crates/api_clients/artcraft/test_client");
fs::create_dir_all(&nested).unwrap();
for directory in [&root, &root.join(CONFIG_DIRECTORY), &nested] {
run_bootstrap(directory, "development", "backup", "common", false);
}
}
}
#[test]
fn environment_and_local_configs_take_precedence_but_backup_fills_missing_values() {
let (_temporary, root) = checkout("artcraft-services");
write_config(&root, CONFIG_DIRECTORY, "development-secrets", &format!("{VALUE}=local\n"));
write_config(&root, BACKUP_DIRECTORY, "development-secrets",
&format!("{VALUE}=backup\n{MISSING_VALUE}=backup-only\n"));
run_bootstrap(&root, "development", "local", "", true);
run_bootstrap(&root, "development", "environment", "", true);
}
}
mod deployment {
use super::*;
#[test]
fn production_does_not_read_development_secrets() {
let (_temporary, root) = checkout("artcraft-services");
write_config(&root, CONFIG_DIRECTORY, "production", &format!("{VALUE}=production\n"));
write_config(&root, BACKUP_DIRECTORY, "development-secrets", &format!("{MISSING_VALUE}=backup-only\n"));
run_bootstrap(&root.join(CONFIG_DIRECTORY), "production", "production", "", false);
}
#[test]
fn configured_paths_still_work_outside_named_checkouts() {
let temporary = TempDir::new().unwrap();
let root = temporary.path().join("deployment");
write_config(&root, "config", "development-secrets", &format!("{VALUE}=deployment\n"));
write_config(&root, BACKUP_DIRECTORY, "development-secrets", &format!("{MISSING_VALUE}=backup-only\n"));
run_bootstrap(&root, "development", "deployment", "", false);
}
}
// Run bootstrap in a subprocess so environment and logger changes cannot affect other tests.
#[test]
fn bootstrap_child_process() {
if env::var_os(CHILD_PROCESS).is_none() {
return;
}
bootstrap(BootstrapArgs {
app_name: "bootstrap-test",
default_logging_override: None,
config_search_directories: &[".", "./config", CONFIG_DIRECTORY],
ignore_legacy_dot_env_file: true,
}).unwrap();
assert_eq!(env::var(VALUE).unwrap(), env::var("EXPECTED_VALUE").unwrap());
assert_eq!(env::var(COMMON_VALUE).unwrap_or_default(), env::var("EXPECTED_COMMON_VALUE").unwrap());
if env::var("EXPECT_BACKUP_VALUE").unwrap() == "true" {
assert_eq!(env::var(MISSING_VALUE).unwrap(), "backup-only");
} else {
assert!(env::var_os(MISSING_VALUE).is_none());
}
}
fn run_bootstrap(directory: &Path, environment: &str, expected: &str, expected_common: &str, expect_backup: bool) {
let mut command = Command::new(env::current_exe().unwrap());
command.args(["--exact", "bootstrap_child_process", "--nocapture"])
.current_dir(directory)
.env_clear()
.env(CHILD_PROCESS, "1")
.env("RUST_LOG", "error")
.env("SERVER_ENVIRONMENT", environment)
.env("EXPECTED_VALUE", expected)
.env("EXPECTED_COMMON_VALUE", expected_common)
.env("EXPECT_BACKUP_VALUE", expect_backup.to_string());
if expected == "environment" {
command.env(VALUE, "environment");
}
let output = command.output().unwrap();
assert!(output.status.success(), "bootstrap failed in {directory:?}:\n{}\n{}",
String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr));
}
fn checkout(name: &str) -> (TempDir, PathBuf) {
let temporary = TempDir::new().unwrap();
let root = temporary.path().join(name);
fs::create_dir_all(root.join(CONFIG_DIRECTORY)).unwrap();
fs::write(root.join("Cargo.toml"), "[workspace]\n").unwrap();
(temporary, root)
}
fn write_config(root: &Path, directory: &str, suffix: &str, contents: &str) {
let directory = root.join(directory);
fs::create_dir_all(&directory).unwrap();
fs::write(directory.join(format!("bootstrap-test.{suffix}.env")), contents).unwrap();
}
-1
View File
@@ -27,7 +27,6 @@ errors.workspace = true
anyhow.workspace = true
ffprobe = "0.4.0"
log.workspace = true
tempfile.workspace = true
[dev-dependencies]
tempdir.workspace = true
@@ -1,25 +1,18 @@
use std::io::Write;
use errors::AnyhowResult;
use std::path::Path;
use std::str::FromStr;
use errors::AnyhowResult;
use ffprobe::FfProbe;
use tempfile::NamedTempFile;
#[derive(Debug, Default)]
pub struct VideoInfo {
pub dimensions: Option<VideoDimensions>,
pub duration: Option<VideoDuration>,
pub frame_rate: Option<VideoFrameRate>,
}
#[derive(Debug)]
pub struct VideoDimensions {
pub width: u64,
pub height: u64,
}
#[derive(Debug)]
pub struct VideoDuration {
/// We have to convert ffprobe's seconds (with decimal) into milliseconds.
/// We use a u32 as this can hold 49 days of milliseconds.
@@ -29,7 +22,6 @@ pub struct VideoDuration {
pub seconds_original: String,
}
#[derive(Debug)]
pub struct VideoFrameRate {
pub fps: f32,
/// This is the original value returned by ffprobe (for debugging).
@@ -41,41 +33,56 @@ pub fn ffprobe_get_info(
) -> AnyhowResult<VideoInfo>
{
let result = ffprobe::ffprobe(video_path)?;
Ok(info_from_probe(result))
}
/// Probe downloaded/uploaded bytes without changing their container or audio.
/// A seekable temporary file lets ffprobe inspect MOV metadata at the end of the file.
pub fn ffprobe_get_info_from_bytes(bytes: &[u8]) -> AnyhowResult<VideoInfo> {
let mut file = NamedTempFile::new()?;
file.write_all(bytes)?;
ffprobe_get_info(file.path())
}
let mut maybe_dimensions = None;
let mut maybe_duration = None;
let mut maybe_frame_rate = None;
fn info_from_probe(result: FfProbe) -> VideoInfo {
let Some(stream) = result.streams.iter().find(|stream| {
stream.codec_type.as_deref() == Some("video") && stream.disposition.attached_pic == 0
}) else {
return VideoInfo::default();
let video_streams = result.streams.iter()
.filter(|stream| stream.codec_type.as_deref() == Some("video"));
for stream in video_streams {
// Grab the first video stream with dimensions and duration.
if let (Some(width), Some(height), Some(duration)) =
(stream.width, stream.height, stream.duration.as_deref())
{
maybe_dimensions = Some(VideoDimensions {
width: width.unsigned_abs(),
height: height.unsigned_abs(),
});
maybe_duration = Some(duration.to_string());
// avg_frame_rate vs r_frame_rate: https://github.com/eugeneware/ffprobe/issues/7
maybe_frame_rate = Some(stream.avg_frame_rate.to_string());
}
}
let maybe_duration = match maybe_duration {
None => None,
Some(duration) => {
let millis = parse_seconds(&duration)?;
Some(VideoDuration {
millis,
seconds_original: duration,
})
}
};
// MOV files can carry duration only at the container level. Keep dimensions
// even when the stream-level duration is absent.
let dimensions = stream.width.zip(stream.height)
.filter(|(width, height)| *width > 0 && *height > 0)
.map(|(width, height)| VideoDimensions { width: width as u64, height: height as u64 });
let duration = [stream.duration.as_deref(), result.format.duration.as_deref()]
.into_iter().flatten()
.find_map(|seconds| parse_seconds(seconds).ok().map(|millis| VideoDuration {
millis,
seconds_original: seconds.to_string(),
}));
let frame_rate = [&stream.avg_frame_rate, &stream.r_frame_rate].into_iter()
.find_map(|rate| parse_fps(rate).ok()
.filter(|fps| fps.is_finite() && *fps > 0.0)
.map(|fps| VideoFrameRate { fps, fps_original: rate.to_string() }));
let maybe_frame_rate = match maybe_frame_rate {
None => None,
Some(frame_rate) => {
let fps = parse_fps(&frame_rate)?;
Some(VideoFrameRate {
fps,
fps_original: frame_rate,
})
}
};
VideoInfo { dimensions, duration, frame_rate }
Ok(VideoInfo {
dimensions: maybe_dimensions,
duration: maybe_duration,
frame_rate: maybe_frame_rate,
})
}
fn parse_seconds(ffprobe_seconds: &str) -> AnyhowResult<u32> {
@@ -125,56 +132,6 @@ pub mod tests {
assert_eq!(info.frame_rate.unwrap().fps, 30.0);
}
mod quicktime {
use super::super::{ffprobe_get_info_from_bytes, info_from_probe};
use ffprobe::{FfProbe, Format, Stream};
use std::process::Command;
use tempdir::TempDir;
#[test]
fn container_duration_does_not_discard_dimensions() {
let info = info_from_probe(FfProbe {
streams: vec![Stream {
codec_type: Some("video".into()),
width: Some(320),
height: Some(180),
duration: None,
avg_frame_rate: "0/0".into(),
r_frame_rate: "24/1".into(),
..Default::default()
}],
format: Format { duration: Some("1.250".into()), ..Default::default() },
});
let dimensions = info.dimensions.unwrap();
assert_eq!((dimensions.width, dimensions.height), (320, 180));
assert_eq!(info.duration.unwrap().millis, 1250);
assert_eq!(info.frame_rate.unwrap().fps, 24.0);
}
#[test]
fn probes_mov_with_pcm_audio_without_modifying_bytes() {
let dir = TempDir::new("quicktime-metadata-test").unwrap();
let path = dir.path().join("reference.mov");
let output = Command::new("ffmpeg").args([
"-hide_banner", "-loglevel", "error", "-nostdin",
"-f", "lavfi", "-i", "color=c=blue:s=160x90:r=24",
"-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000",
"-t", "1", "-c:v", "libx264", "-pix_fmt", "yuv420p",
"-c:a", "pcm_s16le", "-ac", "2",
]).arg(&path).output().unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
let bytes = std::fs::read(&path).unwrap();
let info = ffprobe_get_info_from_bytes(&bytes).unwrap();
let dimensions = info.dimensions.unwrap();
assert_eq!((dimensions.width, dimensions.height), (160, 90));
assert_eq!(info.duration.unwrap().millis, 1000);
assert_eq!(info.frame_rate.unwrap().fps, 24.0);
assert_eq!(std::fs::read(&path).unwrap(), bytes);
let probe = ffprobe::ffprobe(&path).unwrap();
assert!(probe.streams.iter().any(|s| s.codec_name.as_deref() == Some("pcm_s16le")));
}
}
mod parse_seconds {
use super::*;
@@ -22,7 +22,6 @@ const KNOWN_AUDIO_EXTENSIONS: &[Extension] = &[
/// Known video extensions.
const KNOWN_VIDEO_EXTENSIONS: &[Extension] = &[
Extension::from_static("mp4", ".mp4"),
Extension::from_static("mov", ".mov"),
Extension::from_static("webm", ".webm"),
];
@@ -40,7 +39,7 @@ pub enum ExtractExtensions {
/// Accept only known audio extensions (wav, mp3).
KnownAudio,
/// Accept only known video extensions (mp4, mov, webm).
/// Accept only known video extensions (mp4, webm).
KnownVideo,
/// Accept any known media extension (image, audio, or video).
@@ -159,14 +158,6 @@ mod tests {
assert_eq!(ext.unwrap().without_period(), "mp4");
}
#[test]
fn quicktime_is_a_known_video_and_media_extension() {
for accept in [ExtractExtensions::KnownVideo, ExtractExtensions::KnownMedia] {
let ext = extract_extension_from_url_str("https://example.com/clip.MOV?token=test", &accept);
assert_eq!(ext.unwrap().without_period(), "mov");
}
}
#[test]
fn known_video_rejects_mp3() {
let ext = extract_extension_from_url_str("https://example.com/song.mp3", &ExtractExtensions::KnownVideo);
@@ -62,8 +62,6 @@ pub struct InsertMediaFileFromUploadArgs<'a> {
pub maybe_title: Option<&'a str>,
pub maybe_duration_millis: Option<u64>,
pub maybe_frame_width: Option<u32>,
pub maybe_frame_height: Option<u32>,
pub maybe_scene_source_media_file_token: Option<&'a MediaFileToken>,
pub is_intermediate_system_file: bool,
@@ -157,8 +155,6 @@ SET
maybe_title = ?,
maybe_duration_millis = ?,
maybe_frame_width = ?,
maybe_frame_height = ?,
public_bucket_directory_hash = ?,
maybe_public_bucket_prefix = ?,
@@ -209,8 +205,6 @@ SET
args.maybe_title,
args.maybe_duration_millis,
args.maybe_frame_width,
args.maybe_frame_height,
args.public_bucket_directory_hash,
args.maybe_public_bucket_prefix,
@@ -57,10 +57,6 @@ pub enum UserFeatureFlag {
/// Access to Minimax with debugging priority (admins)
#[serde(rename = "minimax_p")]
MinimaxPriority,
/// Access to QuickTime video output options
#[serde(rename = "use_qt")]
CanUseQuicktime,
}
// TODO(bt, 2022-12-21): This desperately needs MySQL integration tests!
@@ -83,7 +79,6 @@ impl UserFeatureFlag {
Self::ApiKey => "api_key",
Self::Minimax => "minimax",
Self::MinimaxPriority => "minimax_p",
Self::CanUseQuicktime => "use_qt",
}
}
@@ -100,7 +95,6 @@ impl UserFeatureFlag {
"api_key" => Ok(Self::ApiKey),
"minimax" => Ok(Self::Minimax),
"minimax_p" => Ok(Self::MinimaxPriority),
"use_qt" => Ok(Self::CanUseQuicktime),
_ => Err(format!("invalid value: {:?}", value)),
}
}
@@ -118,7 +112,6 @@ impl UserFeatureFlag {
Self::ApiKey => "API Keys",
Self::Minimax => "Minimax",
Self::MinimaxPriority => "Minimax (Debugging Priority)",
Self::CanUseQuicktime => "QuickTime Video Output",
}
}
@@ -135,7 +128,6 @@ impl UserFeatureFlag {
Self::ApiKey => "Access to API key creation",
Self::Minimax => "Access to Minimax",
Self::MinimaxPriority => "Access to Minimax with debugging priority (admins)",
Self::CanUseQuicktime => "Access to QuickTime video output options for Seedance 2.5",
}
}
@@ -154,7 +146,6 @@ impl UserFeatureFlag {
Self::ApiKey,
Self::Minimax,
Self::MinimaxPriority,
Self::CanUseQuicktime,
])
}
}
@@ -180,7 +171,6 @@ mod tests {
assert_serialization(UserFeatureFlag::ApiKey, "api_key");
assert_serialization(UserFeatureFlag::Minimax, "minimax");
assert_serialization(UserFeatureFlag::MinimaxPriority, "minimax_p");
assert_serialization(UserFeatureFlag::CanUseQuicktime, "use_qt");
}
#[test]
@@ -196,7 +186,6 @@ mod tests {
assert_eq!(UserFeatureFlag::ApiKey.to_str(), "api_key");
assert_eq!(UserFeatureFlag::Minimax.to_str(), "minimax");
assert_eq!(UserFeatureFlag::MinimaxPriority.to_str(), "minimax_p");
assert_eq!(UserFeatureFlag::CanUseQuicktime.to_str(), "use_qt");
}
#[test]
@@ -212,14 +201,13 @@ mod tests {
assert_eq!(UserFeatureFlag::from_str("api_key").unwrap(), UserFeatureFlag::ApiKey);
assert_eq!(UserFeatureFlag::from_str("minimax").unwrap(), UserFeatureFlag::Minimax);
assert_eq!(UserFeatureFlag::from_str("minimax_p").unwrap(), UserFeatureFlag::MinimaxPriority);
assert_eq!(UserFeatureFlag::from_str("use_qt").unwrap(), UserFeatureFlag::CanUseQuicktime);
assert!(UserFeatureFlag::from_str("foo").is_err());
}
#[test]
fn all_variants() {
let mut variants = UserFeatureFlag::all_variants();
assert_eq!(variants.len(), 12);
assert_eq!(variants.len(), 11);
assert_eq!(variants.pop_first(), Some(UserFeatureFlag::ExploreMedia));
assert_eq!(variants.pop_first(), Some(UserFeatureFlag::Studio));
assert_eq!(variants.pop_first(), Some(UserFeatureFlag::Upload3d));
@@ -231,7 +219,6 @@ mod tests {
assert_eq!(variants.pop_first(), Some(UserFeatureFlag::ApiKey));
assert_eq!(variants.pop_first(), Some(UserFeatureFlag::Minimax));
assert_eq!(variants.pop_first(), Some(UserFeatureFlag::MinimaxPriority));
assert_eq!(variants.pop_first(), Some(UserFeatureFlag::CanUseQuicktime));
assert_eq!(variants.pop_first(), None);
}
}
@@ -1,162 +0,0 @@
use std::collections::BTreeSet;
#[cfg(test)]
use strum::EnumCount;
#[cfg(test)]
use strum::EnumIter;
use utoipa::ToSchema;
use crate::error::enum_error::EnumError;
/// Maximum serialized string length for database storage.
pub const MAX_LENGTH: usize = 16;
/// Output video containers for generation. Unset options retain the provider default.
/// Models without output-format support ignore this option.
///
/// NB: Keep the max serialized length to 16 characters.
#[cfg_attr(test, derive(EnumIter, EnumCount))]
#[derive(Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum CommonVideoOutputFormat {
#[serde(rename = "mp4")]
Mp4,
#[serde(rename = "mov")]
Mov,
}
impl_enum_display_and_debug_using_to_str!(CommonVideoOutputFormat);
impl_mysql_enum_coders!(CommonVideoOutputFormat);
impl_mysql_from_row!(CommonVideoOutputFormat);
impl CommonVideoOutputFormat {
pub fn to_str(&self) -> &'static str {
match self {
Self::Mp4 => "mp4",
Self::Mov => "mov",
}
}
pub fn from_str(value: &str) -> Result<Self, EnumError> {
match value {
"mp4" => Ok(Self::Mp4),
"mov" => Ok(Self::Mov),
_ => Err(EnumError::CouldNotConvertFromString(value.to_string())),
}
}
pub fn all_variants() -> BTreeSet<Self> {
BTreeSet::from([
Self::Mp4,
Self::Mov,
])
}
}
#[cfg(test)]
mod tests {
use crate::common::generation::common_video_output_format::CommonVideoOutputFormat;
use crate::common::generation::common_video_output_format::MAX_LENGTH;
use crate::test_helpers::assert_serialization;
mod explicit_checks {
use super::*;
use crate::error::enum_error::EnumError;
#[test]
fn test_serialization() {
assert_serialization(CommonVideoOutputFormat::Mp4, "mp4");
assert_serialization(CommonVideoOutputFormat::Mov, "mov");
}
#[test]
fn to_str() {
assert_eq!(CommonVideoOutputFormat::Mp4.to_str(), "mp4");
assert_eq!(CommonVideoOutputFormat::Mov.to_str(), "mov");
}
#[test]
fn from_str() {
assert_eq!(CommonVideoOutputFormat::from_str("mp4").unwrap(), CommonVideoOutputFormat::Mp4);
assert_eq!(CommonVideoOutputFormat::from_str("mov").unwrap(), CommonVideoOutputFormat::Mov);
}
#[test]
fn from_str_err() {
let result = CommonVideoOutputFormat::from_str("invalid");
assert!(result.is_err());
if let Err(EnumError::CouldNotConvertFromString(value)) = result {
assert_eq!(value, "invalid");
} else {
panic!("Expected EnumError::CouldNotConvertFromString");
}
}
#[test]
fn all_variants() {
let mut variants = CommonVideoOutputFormat::all_variants();
assert_eq!(variants.len(), 2);
assert_eq!(variants.pop_first(), Some(CommonVideoOutputFormat::Mp4));
assert_eq!(variants.pop_first(), Some(CommonVideoOutputFormat::Mov));
assert_eq!(variants.pop_first(), None);
}
}
mod mechanical_checks {
use super::*;
#[test]
fn variant_length() {
use strum::IntoEnumIterator;
assert_eq!(CommonVideoOutputFormat::all_variants().len(), CommonVideoOutputFormat::iter().len());
}
#[test]
fn round_trip() {
for variant in CommonVideoOutputFormat::all_variants() {
assert_eq!(variant, CommonVideoOutputFormat::from_str(variant.to_str()).unwrap());
assert_eq!(variant, CommonVideoOutputFormat::from_str(&format!("{}", variant)).unwrap());
assert_eq!(variant, CommonVideoOutputFormat::from_str(&format!("{:?}", variant)).unwrap());
}
}
#[test]
fn serialized_length_ok_for_database() {
for variant in CommonVideoOutputFormat::all_variants() {
let serialized = variant.to_str();
assert!(serialized.len() > 0, "variant {:?} is too short", variant);
assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long via to_str()", variant);
}
for variant in CommonVideoOutputFormat::all_variants() {
let json = serde_json::to_string(&variant).unwrap().replace('"', "");
assert!(json.len() <= MAX_LENGTH, "variant {:?} is too long via JSON: {:?}", variant, json);
}
}
#[test]
fn serialized_names_must_not_contain_dots() {
for variant in CommonVideoOutputFormat::all_variants() {
let to_str_value = variant.to_str();
assert!(!to_str_value.contains('.'), "to_str() for {:?} contains a dot: {:?}", variant, to_str_value);
let json_value = serde_json::to_string(&variant).unwrap().replace('"', "");
assert!(!json_value.contains('.'), "JSON serialization for {:?} contains a dot: {:?}", variant, json_value);
}
}
#[test]
fn serialized_names_must_only_contain_lowercase_alphanumeric_and_underscore() {
let valid_pattern = regex::Regex::new(r"^[a-z0-9_]+$").unwrap();
for variant in CommonVideoOutputFormat::all_variants() {
let to_str_value = variant.to_str();
assert!(valid_pattern.is_match(to_str_value),
"to_str() for {:?} contains invalid characters: {:?} (only a-z, 0-9, _ allowed)", variant, to_str_value);
let json_value = serde_json::to_string(&variant).unwrap().replace('"', "");
assert!(valid_pattern.is_match(&json_value),
"JSON serialization for {:?} contains invalid characters: {:?} (only a-z, 0-9, _ allowed)", variant, json_value);
}
}
}
}
@@ -15,4 +15,3 @@ pub mod model_creator;
pub mod common_resolution;
pub mod common_splat_model;
pub mod common_video_model;
pub mod common_video_output_format;
@@ -27,8 +27,6 @@ cloud_storage.workspace = true
concurrency.workspace = true
easyenv.workspace = true
enums.workspace = true
mimetypes.workspace = true
ffmpeg_utils.workspace = true
errors.workspace = true
hashing = { path = "../../../lib/files/hashing" }
jobs_common = { path = "../../../lib/jobs_common" }
@@ -9,8 +9,6 @@ use enums::by_table::media_files::media_file_origin_product_category::MediaFileO
use enums::by_table::media_files::media_file_type::MediaFileType;
use enums::common::generation_provider::GenerationProvider;
use errors::AnyhowResult;
use ffmpeg_utils::ffprobe::ffprobe_get_info::{ffprobe_get_info_from_bytes, VideoInfo};
use mimetypes::mimetype_for_bytes::get_mimetype_for_bytes;
use hashing::sha256::sha256_hash_bytes::sha256_hash_bytes;
use mysql_queries::queries::generic_inference::api_providers::kinovi_web::list_pending_kinovi_web_video_jobs::PendingKinoviWebJob;
use mysql_queries::queries::generic_inference::job::select_inference_job_status_for_update::select_inference_job_status_for_update;
@@ -23,6 +21,7 @@ use crate::job_dependencies::JobDependencies;
use crate::jobs::order_processing_job::is_job_status_terminal::is_job_status_terminal;
const PREFIX: &str = "artcraft_";
const SUFFIX: &str = ".mp4";
/// Download the completed video, upload to bucket, create media file record, and mark job done.
pub async fn process_successful_video_job(
@@ -53,7 +52,7 @@ pub async fn process_successful_video_job(
);
// Download the video bytes.
let video_bytes: Vec<u8> = match deps.download_client.get(video_url).send().await.and_then(|resp| resp.error_for_status()) {
let video_bytes: Vec<u8> = match deps.download_client.get(video_url).send().await {
Ok(resp) => match resp.bytes().await {
Ok(bytes) => bytes.to_vec(),
Err(err) => {
@@ -83,22 +82,12 @@ pub async fn process_successful_video_job(
order.order_id
);
let (suffix, mimetype, media_file_type) = video_metadata(&video_bytes)?;
let (video_bytes, probe_result) = tokio::task::spawn_blocking(move || {
let info = ffprobe_get_info_from_bytes(&video_bytes);
(video_bytes, info)
}).await?;
let video_info = probe_result.unwrap_or_else(|err| {
warn!("Could not probe video for order {}: {:?}", order.order_id, err);
VideoInfo::default()
});
// Hash the video.
let checksum = sha256_hash_bytes(&video_bytes)
.map_err(|err| anyhow!("error hashing video: {:?}", err))?;
// Build the bucket path.
let bucket_path = MediaFileBucketPath::generate_new(Some(PREFIX), Some(suffix));
let bucket_path = MediaFileBucketPath::generate_new(Some(PREFIX), Some(SUFFIX));
let object_path = bucket_path.get_full_object_path_str();
@@ -110,7 +99,7 @@ pub async fn process_successful_video_job(
// Upload to public bucket.
let upload_result = deps
.public_bucket_client
.upload_file_with_content_type_process(object_path, &video_bytes, mimetype)
.upload_file_with_content_type_process(object_path, &video_bytes, "video/mp4")
.await;
if let Err(err) = upload_result {
@@ -128,15 +117,10 @@ pub async fn process_successful_video_job(
order.order_id
);
// Prefer the downloaded file's dimensions; provider fields are a fallback.
let maybe_result = order.results.iter().find(|result| result.url == video_url);
let maybe_frame_width = video_info.dimensions.as_ref()
.and_then(|d| u32::try_from(d.width).ok())
.or_else(|| maybe_result.and_then(|result| result.maybe_width));
let maybe_frame_height = video_info.dimensions.as_ref()
.and_then(|d| u32::try_from(d.height).ok())
.or_else(|| maybe_result.and_then(|result| result.maybe_height));
let maybe_duration_millis = video_info.duration.map(|duration| u64::from(duration.millis));
// Optionally extract frame dimensions from the order results.
// Persist the integer dimensions when the API provides them, NULL otherwise.
let maybe_frame_width = order.results.first().and_then(|r| r.maybe_width);
let maybe_frame_height = order.results.first().and_then(|r| r.maybe_height);
// Insert media file record.
let media_file_result = MediaFileInsertBuilder::new()
@@ -146,7 +130,6 @@ pub async fn process_successful_video_job(
.file_size_bytes(video_bytes.len() as u64)
.maybe_creator_anonymous_visitor(job.maybe_creator_anonymous_visitor_token.as_ref())
.maybe_creator_user(job.maybe_creator_user_token.as_ref())
.maybe_duration_millis(maybe_duration_millis)
.maybe_frame_height(maybe_frame_height)
.maybe_frame_width(maybe_frame_width)
.maybe_generation_provider(Some(GenerationProvider::Artcraft))
@@ -156,8 +139,8 @@ pub async fn process_successful_video_job(
.media_file_class(MediaFileClass::Video)
.media_file_origin_category(MediaFileOriginCategory::Inference)
.media_file_origin_product_category(MediaFileOriginProductCategory::VideoGeneration)
.media_file_type(media_file_type)
.mime_type(mimetype)
.media_file_type(MediaFileType::Mp4)
.mime_type("video/mp4")
.public_bucket_directory_hash(&bucket_path)
.insert_pool(&deps.mysql_pool)
.await;
@@ -263,34 +246,3 @@ async fn finalize_success(
Ok(())
}
/// Detect the actual container instead of labeling every provider result as MP4.
fn video_metadata(bytes: &[u8]) -> AnyhowResult<(&'static str, &'static str, MediaFileType)> {
match get_mimetype_for_bytes(bytes) {
Some("video/quicktime") => Ok((".mov", "video/quicktime", MediaFileType::Mov)),
Some("video/mp4") => Ok((".mp4", "video/mp4", MediaFileType::Mp4)),
other => Err(anyhow!("Unsupported Kinovi video result MIME type: {:?}", other)),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_quicktime_result() {
let bytes = b"\x00\x00\x00\x14ftypqt \x00\x00\x02\x00qt ";
assert_eq!(video_metadata(bytes).unwrap(), (".mov", "video/quicktime", MediaFileType::Mov));
}
#[test]
fn detects_mp4_result() {
let bytes = b"\x00\x00\x00\x20ftypisom\x00\x00\x02\x00isomiso2avc1mp41";
assert_eq!(video_metadata(bytes).unwrap(), (".mp4", "video/mp4", MediaFileType::Mp4));
}
#[test]
fn rejects_error_page_as_video() {
assert!(video_metadata(b"<html>Download failed</html>").is_err());
}
}
@@ -27,7 +27,7 @@ pub fn happy_horse_video_models() -> Vec<OmniGenVideoModelDetails> {
CommonResolution::TenEightyP,
]),
resolution_default: Some(CommonResolution::SevenTwentyP),
batch_size_options: Some(vec![1, 2, 3, 4]),
batch_size_options: Some(vec![1, 2, 4]),
batch_size_default: Some(1),
duration_seconds_min: Some(3),
duration_seconds_max: Some(15),
@@ -3,7 +3,6 @@ use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_bitrate::CommonBitrate;
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;
/// The Seedance 2.5 family of video models.
@@ -49,9 +48,7 @@ pub fn seedance_2p5_video_models() -> Vec<OmniGenVideoModelDetails> {
CommonBitrate::High,
]),
bitrate_default: Some(CommonBitrate::Normal),
output_format_options: Some(vec![CommonVideoOutputFormat::Mp4, CommonVideoOutputFormat::Mov]),
output_format_default: Some(CommonVideoOutputFormat::Mp4),
batch_size_options: Some(vec![1, 2, 3, 4]),
batch_size_options: Some(vec![1]),
batch_size_default: Some(1),
duration_seconds_min: Some(4),
duration_seconds_max: Some(30),
@@ -98,12 +95,7 @@ pub fn seedance_2p5_video_models() -> Vec<OmniGenVideoModelDetails> {
CommonBitrate::High,
]),
bitrate_default: Some(CommonBitrate::Normal),
output_format_options: Some(vec![
CommonVideoOutputFormat::Mp4,
CommonVideoOutputFormat::Mov
]),
output_format_default: Some(CommonVideoOutputFormat::Mp4),
batch_size_options: Some(vec![1, 2, 3, 4]),
batch_size_options: Some(vec![1]),
batch_size_default: Some(1),
duration_seconds_min: Some(4),
duration_seconds_max: Some(30),
@@ -159,23 +151,3 @@ pub fn seedance_2p5_video_models() -> Vec<OmniGenVideoModelDetails> {
models
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn output_format_config_matches_supported_models() {
for model in seedance_2p5_video_models() {
let json = serde_json::to_value(&model).unwrap();
if matches!(model.model, CommonVideoModel::Seedance2p5 | CommonVideoModel::Seedance2p5Ultra) {
assert_eq!(json["output_format_options"], json!(["mp4", "mov"]));
assert_eq!(json["output_format_default"], "mp4");
} else {
assert!(json.get("output_format_options").is_none());
assert!(json.get("output_format_default").is_none());
}
}
}
}
@@ -156,31 +156,3 @@ fn build_omni_gen_video_model_providers() -> Vec<OmniGenVideoModelProviderDetail
providers
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn kinovi_batch_options_are_one_through_four() {
let models = build_omni_gen_video_models();
for model in [
CommonVideoModel::Seedance2p5,
CommonVideoModel::Seedance2p0,
CommonVideoModel::Seedance2p0Fast,
CommonVideoModel::Seedance2p0Mini,
CommonVideoModel::Seedance2p5Ultra,
CommonVideoModel::Seedance2p0BytePlus,
CommonVideoModel::Seedance2p0BytePlusFast,
CommonVideoModel::Seedance2p0BytePlusMini,
CommonVideoModel::Seedance2p0BytePlusUltra,
CommonVideoModel::Seedance2p0BytePlusUltraFast,
CommonVideoModel::Seedance2p0BytePlusUltraMini,
CommonVideoModel::HappyHorse1p0,
] {
let details = models.iter().find(|details| details.model == model).unwrap();
assert_eq!(details.batch_size_options.as_deref(), Some([1, 2, 3, 4].as_slice()), "{model:?}");
assert_eq!(details.batch_size_default, Some(1), "{model:?}");
}
}
}
@@ -310,7 +310,6 @@ use enums::common::generation::common_model_class::CommonModelClass;
use enums::common::generation::common_model_type::CommonModelType;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_bitrate::CommonBitrate;
use enums::common::generation::common_video_output_format::CommonVideoOutputFormat;
use enums::common::generation::common_generation_mode::CommonGenerationMode;
use enums::common::generation::common_resolution::CommonResolution;
use enums::common::generation_provider::GenerationProvider;
@@ -817,7 +816,6 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
CommonBitrate,
CommonGenerationMode,
CommonResolution,
CommonVideoOutputFormat,
PromptContextSemanticType,
PromptType,
MediaFileOriginModelType,
@@ -52,7 +52,6 @@ pub async fn estimate_video_cost_handler(
resolution: router_resolution,
aspect_ratio: router_aspect_ratio,
bitrate: None,
maybe_output_format: None,
duration_seconds: request.duration_seconds,
video_batch_count: request.video_batch_count,
generate_audio: request.generate_audio,
@@ -258,8 +258,6 @@ pub async fn upload_snapshot_media_file_handler(
maybe_mime_type: Some(&mimetype),
file_size_bytes: file_size_bytes as u64,
maybe_duration_millis: None,
maybe_frame_width: None,
maybe_frame_height: None,
sha256_checksum: &hash,
maybe_title: Some("Snapshot"),
maybe_scene_source_media_file_token: None,
@@ -153,8 +153,6 @@ pub async fn save_new_project(args: SaveNewProjectArgs<'_>) -> Result<MediaFileT
maybe_mime_type: Some(PROJECT_MIMETYPE),
file_size_bytes: file_bytes.len() as u64,
maybe_duration_millis: None,
maybe_frame_width: None,
maybe_frame_height: None,
sha256_checksum: &sha256_checksum,
maybe_title: maybe_title.as_deref(),
maybe_scene_source_media_file_token: None,
@@ -332,8 +332,6 @@ pub async fn upload_audio_media_file_handler(
maybe_mime_type: Some(&mimetype),
file_size_bytes: file_size_bytes as u64,
maybe_duration_millis,
maybe_frame_width: None,
maybe_frame_height: None,
sha256_checksum: &hash,
maybe_title: maybe_title.as_deref(),
maybe_scene_source_media_file_token: None,
@@ -1,4 +1,3 @@
use std::convert::TryFrom;
use std::collections::HashSet;
use std::sync::Arc;
@@ -12,7 +11,6 @@ use enums::by_table::media_files::media_file_origin_category::MediaFileOriginCat
use enums::by_table::media_files::media_file_type::MediaFileType;
use enums::common::visibility::Visibility;
use hashing::sha256::sha256_hash_bytes::sha256_hash_bytes;
use ffmpeg_utils::ffprobe::ffprobe_get_info::{ffprobe_get_info_from_bytes, VideoInfo};
use http_server_common::request::get_request_ip::get_request_ip;
use media::decode_basic_audio_info::decode_basic_audio_bytes_info;
use mimetypes::mimetype_for_bytes::get_mimetype_for_bytes;
@@ -196,7 +194,7 @@ pub async fn process_upload_media_file(
let do_audio_decode = match mimetype {
// TODO: Revisit when Safari can send us this metadata consistently
"audio/mp4" | "video/mp4" | "video/quicktime" => false,
"audio/mp4" | "video/mp4" => false,
"audio/opus" => {
// TODO/FIXME(bt, 2023-05-19): Symphonia is currently broken for Firefox's opus.
// We're on an off-master branch that may resolve the problem in the future, but for now
@@ -282,18 +280,6 @@ pub async fn process_upload_media_file(
};
let media_file_class = media_file_type.to_media_class();
let video_info = if media_file_class == MediaFileClass::Video {
ffprobe_get_info_from_bytes(&bytes).unwrap_or_else(|err| {
warn!("Could not probe uploaded video: {:?}", err);
VideoInfo::default()
})
} else {
VideoInfo::default()
};
if let Some(duration) = &video_info.duration {
maybe_duration_millis = Some(u64::from(duration.millis));
}
let upload_type = match upload_media_request.media_source {
MediaFileUploadSource::Unknown => UploadType::Filesystem,
@@ -363,8 +349,6 @@ pub async fn process_upload_media_file(
.mime_type(mime_type)
.file_size_bytes(file_size_bytes as u64)
.maybe_duration_millis(maybe_duration_millis)
.maybe_frame_width(video_info.dimensions.as_ref().and_then(|d| u32::try_from(d.width).ok()))
.maybe_frame_height(video_info.dimensions.as_ref().and_then(|d| u32::try_from(d.height).ok()))
.checksum_sha2(&hash)
.maybe_title(upload_media_request.title.as_deref())
.maybe_origin_filename(upload_media_request.file_name.as_deref())
@@ -33,7 +33,7 @@ static ALLOWED_MIME_TYPES : Lazy<HashSet<&'static str>> = Lazy::new(|| {
// Video
"video/mp4",
"video/webm",
"video/quicktime",
//"video/quicktime",
// Image
"image/gif",
"image/jpeg",
@@ -304,8 +304,6 @@ pub async fn upload_image_media_file_handler(
maybe_batch_token: maybe_batch_token.as_ref(),
file_size_bytes: file_size_bytes as u64,
maybe_duration_millis: None,
maybe_frame_width: None,
maybe_frame_height: None,
sha256_checksum: &hash,
maybe_title: maybe_title.as_deref(),
maybe_scene_source_media_file_token: None,
@@ -224,8 +224,6 @@ pub async fn upload_new_engine_asset_media_file_handler(
maybe_mime_type: Some(file_info.mimetype),
file_size_bytes: file_info.file_size_bytes as u64,
maybe_duration_millis: file_info.maybe_duration_millis,
maybe_frame_width: None,
maybe_frame_height: None,
sha256_checksum: &file_info.sha256_checksum,
maybe_scene_source_media_file_token: None,
is_intermediate_system_file: false, // NB: is_user_upload = TRUE
@@ -240,8 +240,6 @@ pub async fn upload_new_scene_media_file_handler(
maybe_mime_type: Some(MIMETYPE),
file_size_bytes: file_size_bytes as u64,
maybe_duration_millis: None,
maybe_frame_width: None,
maybe_frame_height: None,
sha256_checksum: &hash,
maybe_title: maybe_title.as_deref(),
maybe_scene_source_media_file_token: None,
@@ -271,8 +271,6 @@ pub async fn upload_pmx_media_file_handler(
maybe_mime_type: Some("application/octet-stream"),
file_size_bytes: pmx_details.file_size_bytes,
maybe_duration_millis,
maybe_frame_width: None,
maybe_frame_height: None,
sha256_checksum: &pmx_details.sha256_checksum,
maybe_scene_source_media_file_token: None,
is_intermediate_system_file: false, // NB: is_user_upload = TRUE
Loaded 100 of 375 files, more files were not shown because too many files have changed in this diff. Show more