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
358 changed files with 2885 additions and 13046 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,
@@ -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,
@@ -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
@@ -233,8 +233,6 @@ pub async fn upload_scene_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: maybe_title.as_deref(),
maybe_scene_source_media_file_token: maybe_scene_source_media_file_token.as_ref(),
@@ -1,4 +1,3 @@
use std::convert::TryFrom;
use std::collections::HashSet;
use std::io::Read;
use std::path::PathBuf;
@@ -18,8 +17,9 @@ use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path:
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_type::MediaFileType;
use enums::common::visibility::Visibility;
use ffmpeg_utils::ffmpeg::ffmpeg_transcode_to_mp4::{ffmpeg_transcode_to_mp4, FfmpegTranscodeToMp4Args};
use ffmpeg_utils::ffmpeg::ffmpeg_trim_and_resample::{ffmpeg_trim_and_resample, Args};
use ffmpeg_utils::ffprobe::ffprobe_get_info::{ffprobe_get_info, VideoInfo};
use ffmpeg_utils::ffprobe::ffprobe_get_info::ffprobe_get_info;
use filesys::file_read_bytes::file_read_bytes;
use filesys::path_to_string::path_to_string;
use hashing::sha256::sha256_hash_bytes::sha256_hash_bytes;
@@ -35,7 +35,6 @@ use tokens::tokens::prompts::PromptToken;
use crate::http_server::endpoints::media_files::upload::common_utils::read_upload_session::read_upload_session;
use crate::http_server::endpoints::media_files::upload::upload_error::MediaFileUploadError;
use crate::http_server::endpoints::media_files::upload::common_utils::try_parse_generation_provider::try_parse_generation_provider;
use crate::http_server::user_lookup::user_session::session_utils::lookup::user_session_feature_flags::UserSessionFeatureFlags;
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
use crate::state::server_state::ServerState;
@@ -120,8 +119,14 @@ pub struct UploadNewVideoMediaFileSuccessResponse {
static ALLOWED_MIME_TYPES : Lazy<HashSet<&'static str>> = Lazy::new(|| {
HashSet::from([
// Video
"video/mp4",
"video/quicktime", // Preserve MOV video and audio without transcoding.
"video/mp4", // NB: Only mp4 for now.
])
});
static TRANSCODE_MIME_TYPES : Lazy<HashSet<&'static str>> = Lazy::new(|| {
HashSet::from([
// Video
"video/quicktime", // .qt quicktime files
])
});
@@ -231,7 +236,9 @@ pub async fn upload_new_video_media_file_handler(
MediaFileUploadError::BadInput("Could not determine mimetype for file".to_string())
})?;
if !ALLOWED_MIME_TYPES.contains(mimetype.as_str()) {
let needs_transcode = TRANSCODE_MIME_TYPES.contains(mimetype.as_str());
if !ALLOWED_MIME_TYPES.contains(mimetype.as_str()) && !needs_transcode {
// NB: Don't let our error message inject malicious strings
let filtered_mimetype = mimetype
.chars()
@@ -241,24 +248,10 @@ pub async fn upload_new_video_media_file_handler(
return Err(MediaFileUploadError::BadInput(format!("unpermitted mime type: {}", &filtered_mimetype)));
}
if mimetype == "video/quicktime" {
let maybe_feature_flags = match session_auth.maybe_header_session.as_ref() {
Some(session) => session.maybe_feature_flags.as_deref(),
None => session_auth.maybe_cookie_session.as_ref()
.and_then(|session| session.maybe_feature_flags.as_deref()),
};
let user_feature_flags = UserSessionFeatureFlags::new(maybe_feature_flags);
if !user_feature_flags.can_use_quicktime() {
return Err(MediaFileUploadError::NotAuthorizedVerbose(
"QuickTime uploads are not enabled for this account".to_string(),
));
}
}
// ==================== ORIGINAL FILE BOOKKEEPING ==================== //
// Hold on to the user's original file (path + mimetype + extension) so we
// can upload it as a `_original.{ext}` sibling later if we end up
// trimming or resampling it.
// transcoding and/or resampling it.
let original_file_path: PathBuf = form.file.file.path().to_path_buf();
let original_mimetype: String = mimetype.clone();
@@ -274,10 +267,49 @@ pub async fn upload_new_video_media_file_handler(
MediaFileUploadError::ServerError
})?;
// Preserve the source container and audio unless the caller requests an edit.
let mut final_upload_file_path = form.file.file.path().to_path_buf();
// ==================== OPTIONAL TRANSCODE TO MP4 ==================== //
// Some accepted source mime types (e.g. QuickTime .mov) are not the canonical
// distribution format we want for the media file record. Transcode them to
// mp4 in a tempdir so we can resample / upload mp4 instead.
// Tempdirs we need to keep alive across the upload step. They get dropped at
// function exit which cleans up the on-disk files.
let mut _transcode_tempdir_ref = None;
let mut _resample_tempdir_ref = None;
let mut final_upload_file_path = form.file.file.path().to_path_buf();
if needs_transcode {
let transcode_tempdir = server_state.temp_dir_creator.new_tempdir("ffmpeg_transcode")
.map_err(|err| {
error!("Problem creating transcode temp dir: {:?}", err);
MediaFileUploadError::ServerError
})?;
let transcoded_path = transcode_tempdir.path().join("transcoded.mp4");
info!("Transcoding {} → mp4", &original_mimetype);
ffmpeg_transcode_to_mp4(FfmpegTranscodeToMp4Args {
video_input_path: form.file.file.path(),
video_output_path: &transcoded_path,
}).map_err(|err| {
error!("Problem transcoding video to mp4: {:?}", err);
MediaFileUploadError::ServerError
})?;
// The canonical file is now the transcoded mp4. Read its bytes and pin
// its mimetype to mp4 so all the downstream metadata reflects the
// transcoded file rather than the user's original upload.
file_bytes = file_read_bytes(&transcoded_path)
.map_err(|e| {
error!("Problem reading transcoded mp4: {:?}", e);
MediaFileUploadError::ServerError
})?;
mimetype = "video/mp4".to_string();
final_upload_file_path = transcoded_path;
_transcode_tempdir_ref = Some(transcode_tempdir);
}
// ==================== OPTIONAL VIDEO RESAMPLE ==================== //
let should_resample = form.maybe_resample_fps.is_some()
@@ -298,7 +330,8 @@ pub async fn upload_new_video_media_file_handler(
let maybe_start_offset = form.maybe_trim_start_millis.map(|millis| Duration::from_millis(millis.0));
let maybe_end_offset = form.maybe_trim_end_millis.map(|millis| Duration::from_millis(millis.0));
// Explicit trimming/resampling produces MP4; the original is retained below.
// NB: Resample reads from `final_upload_file_path` (which is the transcoded
// mp4 if we transcoded above, or the user's original upload otherwise).
ffmpeg_trim_and_resample(Args {
video_input_path: &final_upload_file_path,
video_output_path: &video_output_path,
@@ -327,18 +360,23 @@ pub async fn upload_new_video_media_file_handler(
_resample_tempdir_ref = Some(frame_temp_dir); // NB: Keep from going out of scope
}
// Retain the unmodified original when the caller requests an edit.
let should_upload_original = should_resample;
// True if we touched the user's file in any way (transcoded, resampled, or both).
// We'll upload their unmodified original alongside the canonical file when so.
let should_upload_original = needs_transcode || should_resample;
// ==================== OTHER FILE METADATA ==================== //
let video_info = ffprobe_get_info(&final_upload_file_path).unwrap_or_else(|err| {
warn!("Could not probe uploaded video: {:?}", err);
VideoInfo::default()
});
let maybe_duration_millis = video_info.duration.map(|duration| u64::from(duration.millis));
let maybe_frame_width = video_info.dimensions.as_ref().and_then(|d| u32::try_from(d.width).ok());
let maybe_frame_height = video_info.dimensions.as_ref().and_then(|d| u32::try_from(d.height).ok());
let mut maybe_duration_millis = None;
match ffprobe_get_info(&final_upload_file_path) {
Ok(video_info) => {
maybe_duration_millis = video_info.duration
.map(|duration| duration.millis as u64);
}
Err(error) => {
warn!("Error reading video dimensions with ffprobe: {:?}", error);
}
}
let maybe_filename = form.file.file_name.as_deref()
.as_deref()
@@ -385,7 +423,7 @@ pub async fn upload_new_video_media_file_handler(
})?;
// ==================== UPLOAD ORIGINAL SIBLING ==================== //
// If we trimmed or resampled the user's upload, also upload the
// If we transcoded and/or resampled the user's upload, also upload the
// unmodified original alongside the canonical file as `_original.{ext}`
// (sharing the same object hash / directory). The media file record points
// at the canonical file, not this sibling.
@@ -469,8 +507,6 @@ pub async fn upload_new_video_media_file_handler(
maybe_mime_type: Some(&mimetype),
file_size_bytes: file_size_bytes as u64,
maybe_duration_millis,
maybe_frame_width,
maybe_frame_height,
sha256_checksum: &hash,
maybe_title: maybe_title.as_deref(),
maybe_scene_source_media_file_token,
@@ -489,12 +525,7 @@ pub async fn upload_new_video_media_file_handler(
info!("new media file id: {} token: {:?}", record_id, &token);
let thumbnail_mimetype = if mimetype == "video/quicktime" {
ThumbnailTaskInputMimeType::MOV
} else {
ThumbnailTaskInputMimeType::MP4
};
let thumbnail_task_result = ThumbnailTaskBuilder::new_for_source_mimetype(thumbnail_mimetype)
let thumbnail_task_result = ThumbnailTaskBuilder::new_for_source_mimetype(ThumbnailTaskInputMimeType::MP4)
.with_bucket(server_state.public_bucket_client.bucket_name().as_str())
.with_path(&*path_to_string(public_upload_path.to_full_object_pathbuf()))
.with_output_suffix("thumb")
@@ -236,8 +236,6 @@ pub async fn upload_video_media_file_handler(
maybe_mime_type: Some(mimetype),
file_size_bytes: file_size_bytes as u64,
maybe_duration_millis: Some(mp4_info.duration_millis as u64),
maybe_frame_width: None,
maybe_frame_height: None,
sha256_checksum: &hash,
maybe_title: upload_media_request.title.as_deref(),
maybe_scene_source_media_file_token: None,
@@ -128,7 +128,6 @@ fn to_omni_gen_request(request: OmniApiVideoGenerateRequest) -> OmniGenVideoCost
resolution: request.resolution,
aspect_ratio: request.aspect_ratio,
bitrate: request.bitrate,
maybe_output_format: request.maybe_output_format,
quality: request.quality,
duration_seconds: request.duration_seconds,
video_batch_count: request.video_batch_count,
@@ -137,58 +136,3 @@ fn to_omni_gen_request(request: OmniApiVideoGenerateRequest) -> OmniGenVideoCost
estimate_only: None,
}
}
#[cfg(test)]
mod tests {
use artcraft_router::api::router_provider::RouterProvider;
use enums::common::generation::common_video_model::CommonVideoModel;
use serde_json::json;
use crate::http_server::endpoints::omni_gen::generate::video::helpers::hydrate_router_request::hydrate_to_router_request;
use crate::http_server::endpoints::omni_gen::shared_utils::video::validate_video_request::validate_video_request as validate_omni_gen;
use super::*;
#[test]
fn kinovi_batches_reach_shared_generation_with_the_full_retail_price() {
// Both handlers use this hydration and ArtCraft pricing path. The API
// handler additionally converts its request into the omni_gen shape.
for (model, expected_prices) in [
(CommonVideoModel::Seedance2p5, [134, 268, 401, 535]),
(CommonVideoModel::Seedance2p0, [93, 185, 278, 370]),
(CommonVideoModel::Seedance2p0Fast, [64, 127, 191, 255]),
(CommonVideoModel::Seedance2p0Mini, [45, 89, 134, 178]),
(CommonVideoModel::Seedance2p5Ultra, [158, 316, 474, 632]),
(CommonVideoModel::Seedance2p0BytePlus, [125, 250, 375, 500]),
(CommonVideoModel::Seedance2p0BytePlusFast, [100, 200, 300, 400]),
(CommonVideoModel::Seedance2p0BytePlusMini, [46, 91, 137, 182]),
(CommonVideoModel::Seedance2p0BytePlusUltra, [125, 250, 375, 500]),
(CommonVideoModel::Seedance2p0BytePlusUltraFast, [100, 200, 300, 400]),
(CommonVideoModel::Seedance2p0BytePlusUltraMini, [46, 91, 137, 182]),
(CommonVideoModel::HappyHorse1p0, [85, 171, 256, 342]),
] {
for (index, price) in expected_prices.iter().copied().enumerate() {
let batch = index as u16 + 1;
let body = json!({
"model": model,
"duration_seconds": 5,
"resolution": "seven_twenty_p",
"video_batch_count": batch,
});
let api_request: OmniApiVideoGenerateRequest = serde_json::from_value(body.clone()).unwrap();
validate_video_request(&api_request).unwrap();
let converted_request = to_omni_gen_request(api_request);
let gen_request: OmniGenVideoCostAndGenerateRequest = serde_json::from_value(body).unwrap();
for request in [gen_request, converted_request] {
validate_omni_gen(&request).unwrap();
let builder = hydrate_to_router_request(&request).unwrap();
assert_eq!(builder.video_batch_count, Some(batch), "{model:?}");
assert!(matches!(builder.provider, RouterProvider::Artcraft));
let cost = builder.build2().unwrap().estimate_cost().unwrap();
assert_eq!(cost.cost_in_credits, Some(price), "{model:?}, batch {batch}");
assert_eq!(cost.cost_in_usd_cents, Some(price), "{model:?}, batch {batch}");
}
}
}
}
}
@@ -1,5 +1,3 @@
use std::convert::TryFrom;
use ffmpeg_utils::ffprobe::ffprobe_get_info::{ffprobe_get_info_from_bytes, VideoInfo};
use std::collections::HashSet;
use log::{info, warn};
@@ -73,15 +71,6 @@ pub async fn upload_url_to_media_file(
format!("Unpermitted mime type for URL {}: {}", url, filtered_mimetype)));
}
let video_info = if matches!(kind, MediaUploadKind::Video) {
ffprobe_get_info_from_bytes(&file_bytes).unwrap_or_else(|err| {
warn!("Could not probe uploaded video URL: {:?}", err);
VideoInfo::default()
})
} else {
VideoInfo::default()
};
// ==================== OTHER FILE METADATA ==================== //
let extension = mimetype_to_extension(&mimetype)
@@ -136,9 +125,7 @@ pub async fn upload_url_to_media_file(
maybe_batch_token: None,
file_size_bytes,
// NB: Duration would require probing the file on disk; URL inputs skip it.
maybe_duration_millis: video_info.duration.map(|duration| u64::from(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()),
maybe_duration_millis: None,
sha256_checksum: &hash,
maybe_title: None,
maybe_scene_source_media_file_token: None,
@@ -172,7 +159,6 @@ static IMAGE_MIME_TYPES: Lazy<HashSet<&'static str>> = Lazy::new(|| {
static VIDEO_MIME_TYPES: Lazy<HashSet<&'static str>> = Lazy::new(|| {
HashSet::from([
"video/mp4",
"video/quicktime",
])
});
@@ -88,7 +88,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,5 +1,4 @@
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::endpoints::omni_gen::shared_utils::video::validate_kinovi_batch_count::validate_kinovi_batch_count;
use crate::http_server::endpoints::omni_api::shared_utils::video::validate_seedance_2p0_limits::validate_seedance_2p0_limits;
use crate::http_server::endpoints::omni_api::shared_utils::video::validate_when_image_required::validate_when_image_required;
use artcraft_api_defs::omni_api::generate_requests::omni_api_video_generate_request::OmniApiVideoGenerateRequest;
@@ -8,7 +7,6 @@ use artcraft_api_defs::omni_api::generate_requests::omni_api_video_generate_requ
pub fn validate_video_request(
request: &OmniApiVideoGenerateRequest,
) -> Result<(), CommonWebError> {
validate_kinovi_batch_count(request.model, request.video_batch_count)?;
validate_when_image_required(request)?;
validate_seedance_2p0_limits(request)?;
Ok(())
@@ -70,7 +70,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),
@@ -296,8 +296,6 @@ pub async fn omni_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,
@@ -282,8 +282,6 @@ pub async fn omni_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,
@@ -1,4 +1,3 @@
use std::convert::TryFrom;
use std::collections::HashSet;
use std::io::Read;
use std::path::PathBuf;
@@ -19,8 +18,9 @@ use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path:
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_type::MediaFileType;
use enums::common::visibility::Visibility;
use ffmpeg_utils::ffmpeg::ffmpeg_transcode_to_mp4::{ffmpeg_transcode_to_mp4, FfmpegTranscodeToMp4Args};
use ffmpeg_utils::ffmpeg::ffmpeg_trim_and_resample::{ffmpeg_trim_and_resample, Args};
use ffmpeg_utils::ffprobe::ffprobe_get_info::{ffprobe_get_info, VideoInfo};
use ffmpeg_utils::ffprobe::ffprobe_get_info::ffprobe_get_info;
use filesys::file_read_bytes::file_read_bytes;
use filesys::path_to_string::path_to_string;
use hashing::sha256::sha256_hash_bytes::sha256_hash_bytes;
@@ -108,8 +108,14 @@ pub struct OmniUploadVideoMediaFileForm {
static ALLOWED_MIME_TYPES : Lazy<HashSet<&'static str>> = Lazy::new(|| {
HashSet::from([
// Video
"video/mp4",
"video/quicktime", // Preserve MOV video and audio without transcoding.
"video/mp4", // NB: Only mp4 for now.
])
});
static TRANSCODE_MIME_TYPES : Lazy<HashSet<&'static str>> = Lazy::new(|| {
HashSet::from([
// Video
"video/quicktime", // .qt quicktime files
])
});
@@ -212,7 +218,9 @@ pub async fn omni_upload_video_media_file_handler(
MediaFileUploadError::BadInput("Could not determine mimetype for file".to_string())
})?;
if !ALLOWED_MIME_TYPES.contains(mimetype.as_str()) {
let needs_transcode = TRANSCODE_MIME_TYPES.contains(mimetype.as_str());
if !ALLOWED_MIME_TYPES.contains(mimetype.as_str()) && !needs_transcode {
// NB: Don't let our error message inject malicious strings
let filtered_mimetype = mimetype
.chars()
@@ -225,7 +233,7 @@ pub async fn omni_upload_video_media_file_handler(
// ==================== ORIGINAL FILE BOOKKEEPING ==================== //
// Hold on to the user's original file (path + mimetype + extension) so we
// can upload it as a `_original.{ext}` sibling later if we end up
// trimming or resampling it.
// transcoding and/or resampling it.
let original_file_path: PathBuf = form.file.file.path().to_path_buf();
let original_mimetype: String = mimetype.clone();
@@ -241,10 +249,44 @@ pub async fn omni_upload_video_media_file_handler(
MediaFileUploadError::ServerError
})?;
// Preserve the source container and audio unless the caller requests an edit.
let mut final_upload_file_path = form.file.file.path().to_path_buf();
// ==================== OPTIONAL TRANSCODE TO MP4 ==================== //
// Some accepted source mime types (e.g. QuickTime .mov) are not the canonical
// distribution format we want for the media file record. Transcode them to
// mp4 in a tempdir so we can resample / upload mp4 instead.
let mut _transcode_tempdir_ref = None;
let mut _resample_tempdir_ref = None;
let mut final_upload_file_path = form.file.file.path().to_path_buf();
if needs_transcode {
let transcode_tempdir = server_state.temp_dir_creator.new_tempdir("ffmpeg_transcode")
.map_err(|err| {
error!("Problem creating transcode temp dir: {:?}", err);
MediaFileUploadError::ServerError
})?;
let transcoded_path = transcode_tempdir.path().join("transcoded.mp4");
info!("Transcoding {} → mp4", &original_mimetype);
ffmpeg_transcode_to_mp4(FfmpegTranscodeToMp4Args {
video_input_path: form.file.file.path(),
video_output_path: &transcoded_path,
}).map_err(|err| {
error!("Problem transcoding video to mp4: {:?}", err);
MediaFileUploadError::ServerError
})?;
file_bytes = file_read_bytes(&transcoded_path)
.map_err(|e| {
error!("Problem reading transcoded mp4: {:?}", e);
MediaFileUploadError::ServerError
})?;
mimetype = "video/mp4".to_string();
final_upload_file_path = transcoded_path;
_transcode_tempdir_ref = Some(transcode_tempdir);
}
// ==================== OPTIONAL VIDEO RESAMPLE ==================== //
let should_resample = form.maybe_resample_fps.is_some()
@@ -292,18 +334,22 @@ pub async fn omni_upload_video_media_file_handler(
_resample_tempdir_ref = Some(frame_temp_dir); // NB: Keep from going out of scope
}
// Retain the unmodified original when the caller requests an edit.
let should_upload_original = should_resample;
// True if we touched the user's file in any way (transcoded, resampled, or both).
let should_upload_original = needs_transcode || should_resample;
// ==================== OTHER FILE METADATA ==================== //
let video_info = ffprobe_get_info(&final_upload_file_path).unwrap_or_else(|err| {
warn!("Could not probe uploaded video: {:?}", err);
VideoInfo::default()
});
let maybe_duration_millis = video_info.duration.map(|duration| u64::from(duration.millis));
let maybe_frame_width = video_info.dimensions.as_ref().and_then(|d| u32::try_from(d.width).ok());
let maybe_frame_height = video_info.dimensions.as_ref().and_then(|d| u32::try_from(d.height).ok());
let mut maybe_duration_millis = None;
match ffprobe_get_info(&final_upload_file_path) {
Ok(video_info) => {
maybe_duration_millis = video_info.duration
.map(|duration| duration.millis as u64);
}
Err(error) => {
warn!("Error reading video dimensions with ffprobe: {:?}", error);
}
}
let maybe_filename = form.file.file_name.as_deref()
.as_deref()
@@ -350,7 +396,7 @@ pub async fn omni_upload_video_media_file_handler(
})?;
// ==================== UPLOAD ORIGINAL SIBLING ==================== //
// If we trimmed or resampled the user's upload, also upload the
// If we transcoded and/or resampled the user's upload, also upload the
// unmodified original alongside the canonical file as `_original.{ext}`.
if should_upload_original {
@@ -431,8 +477,6 @@ pub async fn omni_upload_video_media_file_handler(
maybe_mime_type: Some(&mimetype),
file_size_bytes: file_size_bytes as u64,
maybe_duration_millis,
maybe_frame_width,
maybe_frame_height,
sha256_checksum: &hash,
maybe_title: maybe_title.as_deref(),
maybe_scene_source_media_file_token,
@@ -451,12 +495,7 @@ pub async fn omni_upload_video_media_file_handler(
info!("new media file id: {} token: {:?}", record_id, &token);
let thumbnail_mimetype = if mimetype == "video/quicktime" {
ThumbnailTaskInputMimeType::MOV
} else {
ThumbnailTaskInputMimeType::MP4
};
let thumbnail_task_result = ThumbnailTaskBuilder::new_for_source_mimetype(thumbnail_mimetype)
let thumbnail_task_result = ThumbnailTaskBuilder::new_for_source_mimetype(ThumbnailTaskInputMimeType::MP4)
.with_bucket(server_state.public_bucket_client.bucket_name().as_str())
.with_path(&*path_to_string(public_upload_path.to_full_object_pathbuf()))
.with_output_suffix("thumb")
@@ -271,7 +271,6 @@ mod tests {
resolution: None,
aspect_ratio: None,
bitrate: None,
maybe_output_format: None,
quality: None,
duration_seconds: None,
video_batch_count: None,
@@ -6,7 +6,6 @@ use artcraft_router::api::router_aspect_ratio::RouterAspectRatio;
use artcraft_router::api::router_bitrate::RouterBitrate;
use artcraft_router::api::router_resolution::RouterResolution;
use artcraft_router::api::router_video_model::RouterVideoModel;
use artcraft_router::api::router_video_output_format::RouterVideoOutputFormat;
use artcraft_router::api::image_list_ref::ImageListRef;
use artcraft_router::api::image_ref::ImageRef;
use artcraft_router::api::router_provider::RouterProvider;
@@ -17,7 +16,6 @@ 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;
pub fn hydrate_to_router_request(
request: &OmniGenVideoCostAndGenerateRequest,
@@ -65,20 +63,9 @@ pub fn hydrate_to_router_request(
resolution,
aspect_ratio,
bitrate,
maybe_output_format: if matches!(model, RouterVideoModel::Seedance2p5 | RouterVideoModel::Seedance2p5Ultra) {
Some(match request.maybe_output_format.unwrap_or(CommonVideoOutputFormat::Mp4) {
CommonVideoOutputFormat::Mp4 => RouterVideoOutputFormat::Mp4,
CommonVideoOutputFormat::Mov => RouterVideoOutputFormat::Mov,
})
} else {
None
},
duration_seconds: request.duration_seconds,
video_batch_count: request.video_batch_count,
// Seedance 2.0 / 2.5 always generate sound. Kinovi honours an explicit
// `generate_audio: false`, and the frontend was sending one, so the flag
// is dropped here and never reaches Kinovi for these models.
generate_audio: if is_seedance_2x(&model) { None } else { request.generate_audio },
generate_audio: request.generate_audio,
// Set by the handler after probing reference video durations (only
// models that bill input seconds use it).
total_reference_video_input_seconds: None,
@@ -87,24 +74,6 @@ pub fn hydrate_to_router_request(
})
}
fn is_seedance_2x(model: &RouterVideoModel) -> bool {
matches!(
model,
RouterVideoModel::Seedance2p0
| RouterVideoModel::Seedance2p0Fast
| RouterVideoModel::Seedance2p0Mini
| RouterVideoModel::Seedance2p0BytePlus
| RouterVideoModel::Seedance2p0BytePlusFast
| RouterVideoModel::Seedance2p0BytePlusMini
| RouterVideoModel::Seedance2p0BytePlusUltra
| RouterVideoModel::Seedance2p0BytePlusUltraFast
| RouterVideoModel::Seedance2p0BytePlusUltraMini
| RouterVideoModel::Seedance2p5Preview
| RouterVideoModel::Seedance2p5
| RouterVideoModel::Seedance2p5Ultra
)
}
fn convert_model(
model: &CommonVideoModelEnum,
) -> Result<RouterVideoModel, CommonWebError> {
@@ -153,122 +122,6 @@ fn convert_bitrate(
mod tests {
use super::*;
mod output_format {
use super::*;
use artcraft_router::client::router_client::RouterClient;
use artcraft_router::client::router_kinovi_web_client::RouterKinoviWebClient;
use artcraft_router::generate::generate_video::video_generation_draft_context::VideoGenerationDraftContext;
use artcraft_router::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use artcraft_router::generate::generate_video::video_generation_request::VideoGenerationRequest;
use kinovi_web_client::creds::kinovi_web_session::KinoviWebSession;
use kinovi_web_client::generate::video::generate_seedance_2p5::KinoviSeedance2p5OutputFormat;
use serde_json::{json, Value};
#[tokio::test]
async fn artcraft_http_request_reaches_kinovi_with_format_and_without_audio_flag() {
// No media inputs or send: finalization needs a client but makes no HTTP calls.
let client = RouterClient::KinoviWeb(RouterKinoviWebClient::new(
KinoviWebSession::from_cookies_string(String::new()),
));
let context = VideoGenerationDraftContext { client: Some(&client), ..Default::default() };
for model in ["seedance_2p5", "seedance_2p5_u"] {
for output_format in [None, Some(Value::Null), Some(json!("mp4")), Some(json!("mov"))] {
for generate_audio in [None, Some(true), Some(false)] {
let mut body = json!({
"model": model,
"generate_audio": generate_audio,
});
if let Some(format) = &output_format {
body["output_format"] = format.clone();
}
let request: OmniGenVideoCostAndGenerateRequest = serde_json::from_value(body).unwrap();
let mut builder = hydrate_to_router_request(&request).unwrap();
builder.provider = RouterProvider::KinoviWeb;
// Mirror run_pipeline_v2: Ultra executes as the base model on
// its own account, while billing retains the original model.
if matches!(builder.model, RouterVideoModel::Seedance2p5Ultra) {
builder.model = RouterVideoModel::Seedance2p5;
}
let draft = match builder.build2().unwrap() {
VideoGenerationDraftOrRequest::Draft(draft) => draft,
other => panic!("Unexpected request: {:?}", other),
};
let finalized = draft.finalize(context.clone()).await.unwrap();
let request = match finalized {
VideoGenerationRequest::KinoviSeedance2p5(state) => state.request,
other => panic!("Unexpected request: {:?}", other),
};
let actual_format = request.maybe_output_format.map(|format| match format {
KinoviSeedance2p5OutputFormat::Mp4 => "mp4",
KinoviSeedance2p5OutputFormat::Mov => "mov",
});
let expected_format = output_format.as_ref().and_then(Value::as_str).unwrap_or("mp4");
assert_eq!(actual_format, Some(expected_format), "model: {model}");
assert_eq!(request.maybe_generate_audio, None, "generate_audio: {generate_audio:?}");
}
}
}
}
#[test]
fn unsupported_model_ignores_format() {
for model in ["seedance_2p0", "seedance_2p5_preview"] {
let request: OmniGenVideoCostAndGenerateRequest = serde_json::from_value(json!({
"model": model, "output_format": "mov"
})).unwrap();
assert!(hydrate_to_router_request(&request).unwrap().maybe_output_format.is_none());
}
}
#[test]
fn invalid_format_is_rejected() {
for output_format in [json!("webm"), json!("MP4"), json!(42)] {
let request = serde_json::from_value::<OmniGenVideoCostAndGenerateRequest>(json!({
"model": "seedance_2p5", "output_format": output_format,
}));
assert!(request.is_err());
}
}
}
mod generate_audio {
use super::*;
#[test]
fn seedance_2x_drops_generate_audio() {
for model in [
CommonVideoModelEnum::Seedance2p0,
CommonVideoModelEnum::Seedance2p0Fast,
CommonVideoModelEnum::Seedance2p0Mini,
CommonVideoModelEnum::Seedance2p5,
CommonVideoModelEnum::Seedance2p5Ultra,
] {
for generate_audio in [None, Some(true), Some(false)] {
let request = OmniGenVideoCostAndGenerateRequest {
model: Some(model),
generate_audio,
..base_request()
};
let builder = hydrate_to_router_request(&request).expect("hydrate should succeed");
assert_eq!(builder.generate_audio, None, "model: {model:?}, generate_audio: {generate_audio:?}");
}
}
}
#[test]
fn other_models_keep_generate_audio() {
for generate_audio in [None, Some(true), Some(false)] {
let request = OmniGenVideoCostAndGenerateRequest {
model: Some(CommonVideoModelEnum::Veo3p1),
generate_audio,
..base_request()
};
let builder = hydrate_to_router_request(&request).expect("hydrate should succeed");
assert_eq!(builder.generate_audio, generate_audio);
}
}
}
mod bitrate_hydration {
use super::*;
@@ -314,7 +167,6 @@ mod tests {
resolution: None,
aspect_ratio: None,
bitrate: None,
maybe_output_format: None,
quality: None,
duration_seconds: None,
video_batch_count: None,
@@ -29,5 +29,3 @@ mod mcp_session_tests;
mod omni_api_parity_tests;
mod seedance_2p0;
mod seedance_2p5;
mod quicktime_tests;
@@ -12,8 +12,8 @@ use enums::common::generation::common_video_model::CommonVideoModel;
use artcraft_api_keys::ArtcraftApiKey;
use crate::http_server::endpoints::omni_gen::generate::video::tests::support::{
base_generate_request, stub_traffic, to_omni_api_request, ExpectedCredits, Seconds,
TestHarness, STARTING_CREDITS,
base_generate_request, to_omni_api_request, ExpectedCredits, Seconds, TestHarness,
STARTING_CREDITS,
};
mod billing_parity {
@@ -46,26 +46,6 @@ mod billing_parity {
}
}
mod generate_audio {
use super::*;
/// Seedance 2.0 / 2.5 always produce sound. Even when a caller explicitly
/// sends `generate_audio: false`, neither endpoint forwards the flag to
/// Kinovi — the key must be absent from the captured request body so Kinovi
/// falls back to its default of sound on.
#[tokio::test]
#[cfg_attr(feature = "skip_database_tests", ignore)]
async fn seedance_2p0_never_forwards_generate_audio_on_either_endpoint() {
assert_endpoints_omit_generate_audio(CommonVideoModel::Seedance2p0).await;
}
#[tokio::test]
#[cfg_attr(feature = "skip_database_tests", ignore)]
async fn seedance_2p5_never_forwards_generate_audio_on_either_endpoint() {
assert_endpoints_omit_generate_audio(CommonVideoModel::Seedance2p5).await;
}
}
mod url_ingestion {
use super::*;
@@ -215,47 +195,3 @@ async fn assert_endpoints_bill_identically(
"{:?}: omni_api debited the wrong amount", model,
);
}
/// Run `model` with an explicit `generate_audio: false` through omni_gen
/// (session cookie) and omni_api (API key); assert both succeed and neither
/// captured Kinovi request carries a `generate_audio` key.
async fn assert_endpoints_omit_generate_audio(model: CommonVideoModel) {
let harness = TestHarness::create().await;
// omni_gen, session-cookie user.
let web_user = harness.create_funded_user(STARTING_CREDITS).await;
let mut request = base_generate_request(model);
let web_prompt = format!("audio flag test web {}", request.idempotency_token.as_deref().unwrap());
request.prompt = Some(web_prompt.clone());
request.generate_audio = Some(false);
let response = harness
.post_generate(&web_user, request)
.await
.unwrap_or_else(|err| panic!("{:?}: omni_gen generation failed: {:?}", model, err));
assert!(response.success);
// omni_api, API-key user.
let api_user = harness.create_funded_user(STARTING_CREDITS).await;
let api_key = harness.create_api_key(&api_user).await;
let mut request = base_generate_request(model);
let api_prompt = format!("audio flag test api {}", request.idempotency_token.as_deref().unwrap());
request.prompt = Some(api_prompt.clone());
request.generate_audio = Some(false);
let response = harness
.post_generate_via_api_key(&api_key, request)
.await
.unwrap_or_else(|err| panic!("{:?}: omni_api generation failed: {:?}", model, err));
assert!(response.success);
let traffic = stub_traffic().lock().unwrap();
for (endpoint, prompt) in [("omni_gen", &web_prompt), ("omni_api", &api_prompt)] {
let params = traffic.workflows.iter()
.map(|request| &request["0"]["json"]["apiParams"])
.find(|params| params["prompt"].as_str() == Some(prompt))
.unwrap_or_else(|| panic!("{:?}: no captured Kinovi request for {}", model, endpoint));
assert!(
params.get("generate_audio").is_none(),
"{:?}: {} forwarded generate_audio to Kinovi: {}", model, endpoint, params,
);
}
}
@@ -1,179 +0,0 @@
use std::process::Command;
use actix_web::cookie::Cookie;
use actix_web::http::StatusCode;
use actix_web::{test, web, App};
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_type::MediaFileType;
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 mysql_queries::queries::media_files::get::get_media_file::get_media_file;
use mysql_queries::queries::users::user::update::set_user_feature_flags::{set_user_feature_flags, SetUserFeatureFlagArgs};
use mysql_testing::fixtures::users::{create_test_user, TestUser};
use tokens::tokens::media_files::MediaFileToken;
use crate::http_server::endpoints::media_files::upload::upload_generic::upload_media_file_handler::upload_media_file_handler;
use crate::http_server::endpoints::media_files::upload::upload_video_new::upload_new_video_media_file_handler::upload_new_video_media_file_handler;
use crate::http_server::endpoints::omni_api::upload::omni_upload_video_media_file_handler::omni_upload_video_media_file_handler;
use crate::http_server::endpoints::omni_gen::generate::video::tests::support::{base_generate_request, stub_traffic, TestHarness};
const BOUNDARY: &str = "artcraft-quicktime-test-boundary";
#[actix_web::test]
#[cfg_attr(feature = "skip_database_tests", ignore)]
async fn quicktime_uploads_are_video_references_with_mp4_or_mov_output() {
let harness = TestHarness::create().await;
let user = harness.create_funded_user(10_000).await;
enable_quicktime(&harness, &user).await;
let api_key = harness.create_api_key(&user).await;
let bytes = quicktime_fixture();
let app = test::init_service(App::new()
.app_data(web::Data::new(harness.server_state.clone()))
.route("/new-video", web::post().to(upload_new_video_media_file_handler))
.route("/omni-upload", web::post().to(omni_upload_video_media_file_handler))
.route("/generic-upload", web::post().to(upload_media_file_handler))
).await;
for route in ["/new-video", "/omni-upload", "/generic-upload"] {
let idempotency = base_generate_request(CommonVideoModel::Seedance2p5).idempotency_token.unwrap();
let body = multipart_body(&idempotency, &bytes);
let request = test::TestRequest::post().uri(route)
.insert_header(("Authorization", format!("Bearer {}", api_key.as_str_be_careful())))
.insert_header(("Content-Type", format!("multipart/form-data; boundary={BOUNDARY}")))
.peer_addr("127.0.0.1:9999".parse().unwrap())
.set_payload(body).to_request();
let response = test::call_service(&app, request).await;
let status = response.status();
let body = test::read_body(response).await;
assert!(status.is_success(), "{}: {}", route, String::from_utf8_lossy(&body));
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
let token = MediaFileToken::new_from_str(json["media_file_token"].as_str().unwrap());
let media = get_media_file(&token, false, &harness.pool).await.unwrap().unwrap();
assert_eq!(media.media_class, MediaFileClass::Video);
assert_eq!(media.media_type, MediaFileType::Mov);
assert_eq!(media.maybe_mime_type.as_deref(), Some("video/quicktime"));
assert_eq!(media.maybe_public_bucket_extension.as_deref(), Some(".mov"));
assert_eq!(media.maybe_duration_millis, Some(1000));
for (format, expected) in [(None, "mp4"), (Some(CommonVideoOutputFormat::Mp4), "mp4"), (Some(CommonVideoOutputFormat::Mov), "mov")] {
let mut request = base_generate_request(CommonVideoModel::Seedance2p5);
let prompt = format!("QuickTime reference test {}", request.idempotency_token.as_deref().unwrap());
request.prompt = Some(prompt.clone());
request.reference_video_media_tokens = Some(vec![token.clone()]);
request.maybe_output_format = format;
request.generate_audio = Some(true);
request.duration_seconds = Some(4);
request.resolution = Some(CommonResolution::FourEightyP);
let response = harness.post_generate(&user, request).await.expect("submit MOV video reference");
assert!(response.success);
let traffic = stub_traffic().lock().unwrap();
let params = traffic.workflows.iter()
.map(|request| &request["0"]["json"]["apiParams"])
.find(|params| params["prompt"].as_str() == Some(&prompt))
.expect("captured Kinovi request");
assert_eq!(params["output_format"], expected);
// Seedance 2.x never forwards the audio flag; Kinovi defaults to sound on.
assert!(params.get("generate_audio").is_none(), "{}", params);
assert_eq!(params["mode"], "reference");
assert!(params["videoUrls"][0].as_str().is_some_and(|url| url.ends_with(".mov")), "{}", params);
assert_eq!(traffic.uploads.get("fixture.mov").unwrap(), &bytes);
}
}
}
#[actix_web::test]
#[cfg_attr(feature = "skip_database_tests", ignore)]
async fn new_video_quicktime_uploads_require_feature_flag_for_every_auth_type() {
let harness = TestHarness::create().await;
let mov_bytes = quicktime_fixture();
let mp4_bytes = video_fixture("mp4", "aac");
let app = test::init_service(App::new()
.app_data(web::Data::new(harness.server_state.clone()))
.route("/new-video", web::post().to(upload_new_video_media_file_handler))
).await;
for enabled in [false, true] {
let user = create_test_user(&harness.pool).await.unwrap();
if enabled {
enable_quicktime(&harness, &user).await;
}
let api_key = harness.create_api_key(&user).await;
let mcp_session = harness.create_mcp_session(&user).await;
let cookie = harness.server_state.session_cookie_manager
.create_cookie(&user.session_token, &user.user_token).unwrap();
for auth in ["anonymous", "cookie", "api_key", "mcp"] {
for (is_quicktime, bytes) in [(true, &mov_bytes), (false, &mp4_bytes)] {
let idempotency = base_generate_request(CommonVideoModel::Seedance2p5).idempotency_token.unwrap();
// Even MOV bytes claim to be MP4: authorization must use the detected
// container, not the filename or multipart Content-Type.
let body = multipart_body_for_file(&idempotency, bytes, "reference.mp4", "video/mp4");
let mut request = test::TestRequest::post().uri("/new-video")
.insert_header(("Content-Type", format!("multipart/form-data; boundary={BOUNDARY}")))
.peer_addr("127.0.0.1:9999".parse().unwrap())
.set_payload(body);
request = match auth {
"cookie" => request.cookie(Cookie::new("session", cookie.value().to_string())),
"api_key" => request.insert_header(("Authorization", format!("Bearer {}", api_key.as_str_be_careful()))),
"mcp" => request.insert_header(("Authorization", format!("Bearer {}", mcp_session.as_str()))),
_ => request,
};
let response = test::call_service(&app, request.to_request()).await;
let status = response.status();
let body = test::read_body(response).await;
let expected = if is_quicktime && (!enabled || auth == "anonymous") {
StatusCode::UNAUTHORIZED
} else {
StatusCode::OK
};
assert_eq!(status, expected,
"enabled={}, auth={}, quicktime={}: {}", enabled, auth, is_quicktime, String::from_utf8_lossy(&body));
}
}
}
}
async fn enable_quicktime(harness: &TestHarness, user: &TestUser) {
set_user_feature_flags(SetUserFeatureFlagArgs {
subject_user_token: &user.user_token,
maybe_feature_flags: Some("use_qt"),
ip_address: "127.0.0.1",
maybe_mod_user_token: None,
mysql_pool: &harness.pool,
}).await.unwrap();
}
fn quicktime_fixture() -> Vec<u8> {
video_fixture("mov", "pcm_s16le")
}
fn video_fixture(extension: &str, audio_codec: &str) -> Vec<u8> {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(format!("reference.{extension}"));
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", audio_codec, "-ac", "2",
]).arg(&path).output().unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
std::fs::read(path).unwrap()
}
fn multipart_body(idempotency_token: &str, bytes: &[u8]) -> Vec<u8> {
multipart_body_for_file(idempotency_token, bytes, "reference.mov", "video/quicktime")
}
fn multipart_body_for_file(idempotency_token: &str, bytes: &[u8], filename: &str, mimetype: &str) -> Vec<u8> {
let mut body = format!(
"--{BOUNDARY}\r\nContent-Disposition: form-data; name=\"uuid_idempotency_token\"\r\n\r\n{idempotency_token}\r\n\
--{BOUNDARY}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"{filename}\"\r\nContent-Type: {mimetype}\r\n\r\n"
).into_bytes();
body.extend_from_slice(bytes);
body.extend_from_slice(format!("\r\n--{BOUNDARY}--\r\n").as_bytes());
body
}
@@ -88,8 +88,8 @@ mod seedance_2p0_fast {
}
}
/// Every supported batch size 1-4 at every resolution (5s).
/// Batches 1-4 are preserved; larger batches are rejected before billing.
/// Every batch size 1-10 at every resolution (5s).
/// Batches over the platform max of 4 downgrade to 4 (3 upgrades to 4); execution and billing agree.
/// 1080p and 4K downgrade to 720p (and price accordingly).
#[tokio::test]
#[cfg_attr(feature = "skip_database_tests", ignore)]
@@ -101,18 +101,42 @@ mod seedance_2p0_fast {
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(2), ExpectedCredits(71)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(3), ExpectedCredits(107)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(4), ExpectedCredits(143)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(5), ExpectedCredits(143)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(6), ExpectedCredits(143)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(7), ExpectedCredits(143)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(8), ExpectedCredits(143)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(9), ExpectedCredits(143)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(10), ExpectedCredits(143)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(1), ExpectedCredits(64)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(2), ExpectedCredits(127)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(3), ExpectedCredits(191)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(4), ExpectedCredits(255)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(5), ExpectedCredits(255)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(6), ExpectedCredits(255)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(7), ExpectedCredits(255)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(8), ExpectedCredits(255)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(9), ExpectedCredits(255)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(10), ExpectedCredits(255)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(1), ExpectedCredits(64)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(2), ExpectedCredits(127)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(3), ExpectedCredits(191)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(4), ExpectedCredits(255)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(5), ExpectedCredits(255)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(6), ExpectedCredits(255)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(7), ExpectedCredits(255)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(8), ExpectedCredits(255)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(9), ExpectedCredits(255)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(10), ExpectedCredits(255)),
(Some(CommonResolution::FourK), Seconds(5), Batch(1), ExpectedCredits(64)),
(Some(CommonResolution::FourK), Seconds(5), Batch(2), ExpectedCredits(127)),
(Some(CommonResolution::FourK), Seconds(5), Batch(3), ExpectedCredits(191)),
(Some(CommonResolution::FourK), Seconds(5), Batch(4), ExpectedCredits(255)),
(Some(CommonResolution::FourK), Seconds(5), Batch(5), ExpectedCredits(255)),
(Some(CommonResolution::FourK), Seconds(5), Batch(6), ExpectedCredits(255)),
(Some(CommonResolution::FourK), Seconds(5), Batch(7), ExpectedCredits(255)),
(Some(CommonResolution::FourK), Seconds(5), Batch(8), ExpectedCredits(255)),
(Some(CommonResolution::FourK), Seconds(5), Batch(9), ExpectedCredits(255)),
(Some(CommonResolution::FourK), Seconds(5), Batch(10), ExpectedCredits(255)),
];
for (resolution, seconds, batch, expected) in cases {
@@ -132,15 +156,27 @@ mod seedance_2p0_fast {
(Some(CommonResolution::FourEightyP), Seconds(6), Batch(2), ExpectedCredits(86)),
(Some(CommonResolution::FourEightyP), Seconds(7), Batch(3), ExpectedCredits(150)),
(Some(CommonResolution::FourEightyP), Seconds(9), Batch(4), ExpectedCredits(257)),
(Some(CommonResolution::FourEightyP), Seconds(11), Batch(5), ExpectedCredits(314)),
(Some(CommonResolution::FourEightyP), Seconds(13), Batch(8), ExpectedCredits(371)),
(Some(CommonResolution::FourEightyP), Seconds(15), Batch(10), ExpectedCredits(428)),
(Some(CommonResolution::SevenTwentyP), Seconds(6), Batch(2), ExpectedCredits(153)),
(Some(CommonResolution::SevenTwentyP), Seconds(7), Batch(3), ExpectedCredits(267)),
(Some(CommonResolution::SevenTwentyP), Seconds(9), Batch(4), ExpectedCredits(458)),
(Some(CommonResolution::SevenTwentyP), Seconds(11), Batch(5), ExpectedCredits(560)),
(Some(CommonResolution::SevenTwentyP), Seconds(13), Batch(8), ExpectedCredits(662)),
(Some(CommonResolution::SevenTwentyP), Seconds(15), Batch(10), ExpectedCredits(764)),
(Some(CommonResolution::TenEightyP), Seconds(6), Batch(2), ExpectedCredits(153)),
(Some(CommonResolution::TenEightyP), Seconds(7), Batch(3), ExpectedCredits(267)),
(Some(CommonResolution::TenEightyP), Seconds(9), Batch(4), ExpectedCredits(458)),
(Some(CommonResolution::TenEightyP), Seconds(11), Batch(5), ExpectedCredits(560)),
(Some(CommonResolution::TenEightyP), Seconds(13), Batch(8), ExpectedCredits(662)),
(Some(CommonResolution::TenEightyP), Seconds(15), Batch(10), ExpectedCredits(764)),
(Some(CommonResolution::FourK), Seconds(6), Batch(2), ExpectedCredits(153)),
(Some(CommonResolution::FourK), Seconds(7), Batch(3), ExpectedCredits(267)),
(Some(CommonResolution::FourK), Seconds(9), Batch(4), ExpectedCredits(458)),
(Some(CommonResolution::FourK), Seconds(11), Batch(5), ExpectedCredits(560)),
(Some(CommonResolution::FourK), Seconds(13), Batch(8), ExpectedCredits(662)),
(Some(CommonResolution::FourK), Seconds(15), Batch(10), ExpectedCredits(764)),
];
for (resolution, seconds, batch, expected) in cases {
@@ -216,8 +252,8 @@ mod seedance_2p0_fast {
}
/// With video references Fast switches to its with-references rate card (480p 8.41, 720p 15.60 cents/s, ceil-rounded). Charges are asserted on the refunded ledger entry (the fixture media is unreachable).
/// Every supported batch size 1-4 at every resolution (5s).
/// Batches 1-4 are preserved; larger batches are rejected before billing.
/// Every batch size 1-10 at every resolution (5s).
/// Batches over the platform max of 4 downgrade to 4 (3 upgrades to 4); execution and billing agree.
#[tokio::test]
#[cfg_attr(feature = "skip_database_tests", ignore)]
async fn video_references_charge_every_batch_size_at_every_resolution() {
@@ -228,18 +264,42 @@ mod seedance_2p0_fast {
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(2), ExpectedCredits(85)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(3), ExpectedCredits(127)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(4), ExpectedCredits(169)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(5), ExpectedCredits(169)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(6), ExpectedCredits(169)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(7), ExpectedCredits(169)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(8), ExpectedCredits(169)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(9), ExpectedCredits(169)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(10), ExpectedCredits(169)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(1), ExpectedCredits(78)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(2), ExpectedCredits(156)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(3), ExpectedCredits(234)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(4), ExpectedCredits(312)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(5), ExpectedCredits(312)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(6), ExpectedCredits(312)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(7), ExpectedCredits(312)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(8), ExpectedCredits(312)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(9), ExpectedCredits(312)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(10), ExpectedCredits(312)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(1), ExpectedCredits(78)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(2), ExpectedCredits(156)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(3), ExpectedCredits(234)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(4), ExpectedCredits(312)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(5), ExpectedCredits(312)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(6), ExpectedCredits(312)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(7), ExpectedCredits(312)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(8), ExpectedCredits(312)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(9), ExpectedCredits(312)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(10), ExpectedCredits(312)),
(Some(CommonResolution::FourK), Seconds(5), Batch(1), ExpectedCredits(78)),
(Some(CommonResolution::FourK), Seconds(5), Batch(2), ExpectedCredits(156)),
(Some(CommonResolution::FourK), Seconds(5), Batch(3), ExpectedCredits(234)),
(Some(CommonResolution::FourK), Seconds(5), Batch(4), ExpectedCredits(312)),
(Some(CommonResolution::FourK), Seconds(5), Batch(5), ExpectedCredits(312)),
(Some(CommonResolution::FourK), Seconds(5), Batch(6), ExpectedCredits(312)),
(Some(CommonResolution::FourK), Seconds(5), Batch(7), ExpectedCredits(312)),
(Some(CommonResolution::FourK), Seconds(5), Batch(8), ExpectedCredits(312)),
(Some(CommonResolution::FourK), Seconds(5), Batch(9), ExpectedCredits(312)),
(Some(CommonResolution::FourK), Seconds(5), Batch(10), ExpectedCredits(312)),
];
for (resolution, seconds, batch, expected) in cases {
@@ -276,15 +336,27 @@ mod seedance_2p0_fast {
(Some(CommonResolution::FourEightyP), Seconds(6), Batch(2), ExpectedCredits(101)),
(Some(CommonResolution::FourEightyP), Seconds(7), Batch(3), ExpectedCredits(177)),
(Some(CommonResolution::FourEightyP), Seconds(9), Batch(4), ExpectedCredits(303)),
(Some(CommonResolution::FourEightyP), Seconds(11), Batch(5), ExpectedCredits(371)),
(Some(CommonResolution::FourEightyP), Seconds(13), Batch(8), ExpectedCredits(438)),
(Some(CommonResolution::FourEightyP), Seconds(15), Batch(10), ExpectedCredits(505)),
(Some(CommonResolution::SevenTwentyP), Seconds(6), Batch(2), ExpectedCredits(188)),
(Some(CommonResolution::SevenTwentyP), Seconds(7), Batch(3), ExpectedCredits(328)),
(Some(CommonResolution::SevenTwentyP), Seconds(9), Batch(4), ExpectedCredits(562)),
(Some(CommonResolution::SevenTwentyP), Seconds(11), Batch(5), ExpectedCredits(687)),
(Some(CommonResolution::SevenTwentyP), Seconds(13), Batch(8), ExpectedCredits(812)),
(Some(CommonResolution::SevenTwentyP), Seconds(15), Batch(10), ExpectedCredits(936)),
(Some(CommonResolution::TenEightyP), Seconds(6), Batch(2), ExpectedCredits(188)),
(Some(CommonResolution::TenEightyP), Seconds(7), Batch(3), ExpectedCredits(328)),
(Some(CommonResolution::TenEightyP), Seconds(9), Batch(4), ExpectedCredits(562)),
(Some(CommonResolution::TenEightyP), Seconds(11), Batch(5), ExpectedCredits(687)),
(Some(CommonResolution::TenEightyP), Seconds(13), Batch(8), ExpectedCredits(812)),
(Some(CommonResolution::TenEightyP), Seconds(15), Batch(10), ExpectedCredits(936)),
(Some(CommonResolution::FourK), Seconds(6), Batch(2), ExpectedCredits(188)),
(Some(CommonResolution::FourK), Seconds(7), Batch(3), ExpectedCredits(328)),
(Some(CommonResolution::FourK), Seconds(9), Batch(4), ExpectedCredits(562)),
(Some(CommonResolution::FourK), Seconds(11), Batch(5), ExpectedCredits(687)),
(Some(CommonResolution::FourK), Seconds(13), Batch(8), ExpectedCredits(812)),
(Some(CommonResolution::FourK), Seconds(15), Batch(10), ExpectedCredits(936)),
];
for (resolution, seconds, batch, expected) in cases {
@@ -366,8 +438,8 @@ mod seedance_2p0_bp_fast {
}
}
/// Every supported batch size 1-4 at every resolution (5s).
/// Batches 1-4 are preserved; larger batches are rejected before billing.
/// Every batch size 1-10 at every resolution (5s).
/// Batches over the platform max of 4 downgrade to 4 (3 upgrades to 4); execution and billing agree.
/// 1080p and 4K downgrade to 720p (and price accordingly).
#[tokio::test]
#[cfg_attr(feature = "skip_database_tests", ignore)]
@@ -379,18 +451,42 @@ mod seedance_2p0_bp_fast {
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(2), ExpectedCredits(90)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(3), ExpectedCredits(135)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(4), ExpectedCredits(180)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(5), ExpectedCredits(180)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(6), ExpectedCredits(180)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(7), ExpectedCredits(180)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(8), ExpectedCredits(180)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(9), ExpectedCredits(180)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(10), ExpectedCredits(180)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(1), ExpectedCredits(100)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(2), ExpectedCredits(200)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(3), ExpectedCredits(300)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(4), ExpectedCredits(400)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(5), ExpectedCredits(400)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(6), ExpectedCredits(400)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(7), ExpectedCredits(400)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(8), ExpectedCredits(400)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(9), ExpectedCredits(400)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(10), ExpectedCredits(400)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(1), ExpectedCredits(100)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(2), ExpectedCredits(200)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(3), ExpectedCredits(300)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(4), ExpectedCredits(400)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(5), ExpectedCredits(400)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(6), ExpectedCredits(400)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(7), ExpectedCredits(400)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(8), ExpectedCredits(400)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(9), ExpectedCredits(400)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(10), ExpectedCredits(400)),
(Some(CommonResolution::FourK), Seconds(5), Batch(1), ExpectedCredits(100)),
(Some(CommonResolution::FourK), Seconds(5), Batch(2), ExpectedCredits(200)),
(Some(CommonResolution::FourK), Seconds(5), Batch(3), ExpectedCredits(300)),
(Some(CommonResolution::FourK), Seconds(5), Batch(4), ExpectedCredits(400)),
(Some(CommonResolution::FourK), Seconds(5), Batch(5), ExpectedCredits(400)),
(Some(CommonResolution::FourK), Seconds(5), Batch(6), ExpectedCredits(400)),
(Some(CommonResolution::FourK), Seconds(5), Batch(7), ExpectedCredits(400)),
(Some(CommonResolution::FourK), Seconds(5), Batch(8), ExpectedCredits(400)),
(Some(CommonResolution::FourK), Seconds(5), Batch(9), ExpectedCredits(400)),
(Some(CommonResolution::FourK), Seconds(5), Batch(10), ExpectedCredits(400)),
];
for (resolution, seconds, batch, expected) in cases {
@@ -410,15 +506,27 @@ mod seedance_2p0_bp_fast {
(Some(CommonResolution::FourEightyP), Seconds(6), Batch(2), ExpectedCredits(108)),
(Some(CommonResolution::FourEightyP), Seconds(7), Batch(3), ExpectedCredits(189)),
(Some(CommonResolution::FourEightyP), Seconds(9), Batch(4), ExpectedCredits(324)),
(Some(CommonResolution::FourEightyP), Seconds(11), Batch(5), ExpectedCredits(396)),
(Some(CommonResolution::FourEightyP), Seconds(13), Batch(8), ExpectedCredits(468)),
(Some(CommonResolution::FourEightyP), Seconds(15), Batch(10), ExpectedCredits(540)),
(Some(CommonResolution::SevenTwentyP), Seconds(6), Batch(2), ExpectedCredits(240)),
(Some(CommonResolution::SevenTwentyP), Seconds(7), Batch(3), ExpectedCredits(420)),
(Some(CommonResolution::SevenTwentyP), Seconds(9), Batch(4), ExpectedCredits(720)),
(Some(CommonResolution::SevenTwentyP), Seconds(11), Batch(5), ExpectedCredits(880)),
(Some(CommonResolution::SevenTwentyP), Seconds(13), Batch(8), ExpectedCredits(1040)),
(Some(CommonResolution::SevenTwentyP), Seconds(15), Batch(10), ExpectedCredits(1200)),
(Some(CommonResolution::TenEightyP), Seconds(6), Batch(2), ExpectedCredits(240)),
(Some(CommonResolution::TenEightyP), Seconds(7), Batch(3), ExpectedCredits(420)),
(Some(CommonResolution::TenEightyP), Seconds(9), Batch(4), ExpectedCredits(720)),
(Some(CommonResolution::TenEightyP), Seconds(11), Batch(5), ExpectedCredits(880)),
(Some(CommonResolution::TenEightyP), Seconds(13), Batch(8), ExpectedCredits(1040)),
(Some(CommonResolution::TenEightyP), Seconds(15), Batch(10), ExpectedCredits(1200)),
(Some(CommonResolution::FourK), Seconds(6), Batch(2), ExpectedCredits(240)),
(Some(CommonResolution::FourK), Seconds(7), Batch(3), ExpectedCredits(420)),
(Some(CommonResolution::FourK), Seconds(9), Batch(4), ExpectedCredits(720)),
(Some(CommonResolution::FourK), Seconds(11), Batch(5), ExpectedCredits(880)),
(Some(CommonResolution::FourK), Seconds(13), Batch(8), ExpectedCredits(1040)),
(Some(CommonResolution::FourK), Seconds(15), Batch(10), ExpectedCredits(1200)),
];
for (resolution, seconds, batch, expected) in cases {
@@ -494,8 +602,8 @@ mod seedance_2p0_bp_fast {
}
/// The BytePlus Fast rate card is flat: reference videos never change the price. Charges are asserted on the refunded ledger entry (the fixture media is unreachable).
/// Every supported batch size 1-4 at every resolution (5s).
/// Batches 1-4 are preserved; larger batches are rejected before billing.
/// Every batch size 1-10 at every resolution (5s).
/// Batches over the platform max of 4 downgrade to 4 (3 upgrades to 4); execution and billing agree.
#[tokio::test]
#[cfg_attr(feature = "skip_database_tests", ignore)]
async fn video_references_charge_every_batch_size_at_every_resolution() {
@@ -506,18 +614,42 @@ mod seedance_2p0_bp_fast {
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(2), ExpectedCredits(97)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(3), ExpectedCredits(145)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(4), ExpectedCredits(193)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(5), ExpectedCredits(193)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(6), ExpectedCredits(193)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(7), ExpectedCredits(193)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(8), ExpectedCredits(193)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(9), ExpectedCredits(193)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(10), ExpectedCredits(193)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(1), ExpectedCredits(103)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(2), ExpectedCredits(205)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(3), ExpectedCredits(308)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(4), ExpectedCredits(410)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(5), ExpectedCredits(410)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(6), ExpectedCredits(410)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(7), ExpectedCredits(410)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(8), ExpectedCredits(410)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(9), ExpectedCredits(410)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(10), ExpectedCredits(410)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(1), ExpectedCredits(103)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(2), ExpectedCredits(205)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(3), ExpectedCredits(308)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(4), ExpectedCredits(410)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(5), ExpectedCredits(410)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(6), ExpectedCredits(410)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(7), ExpectedCredits(410)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(8), ExpectedCredits(410)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(9), ExpectedCredits(410)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(10), ExpectedCredits(410)),
(Some(CommonResolution::FourK), Seconds(5), Batch(1), ExpectedCredits(103)),
(Some(CommonResolution::FourK), Seconds(5), Batch(2), ExpectedCredits(205)),
(Some(CommonResolution::FourK), Seconds(5), Batch(3), ExpectedCredits(308)),
(Some(CommonResolution::FourK), Seconds(5), Batch(4), ExpectedCredits(410)),
(Some(CommonResolution::FourK), Seconds(5), Batch(5), ExpectedCredits(410)),
(Some(CommonResolution::FourK), Seconds(5), Batch(6), ExpectedCredits(410)),
(Some(CommonResolution::FourK), Seconds(5), Batch(7), ExpectedCredits(410)),
(Some(CommonResolution::FourK), Seconds(5), Batch(8), ExpectedCredits(410)),
(Some(CommonResolution::FourK), Seconds(5), Batch(9), ExpectedCredits(410)),
(Some(CommonResolution::FourK), Seconds(5), Batch(10), ExpectedCredits(410)),
];
for (resolution, seconds, batch, expected) in cases {
@@ -554,15 +686,27 @@ mod seedance_2p0_bp_fast {
(Some(CommonResolution::FourEightyP), Seconds(6), Batch(2), ExpectedCredits(116)),
(Some(CommonResolution::FourEightyP), Seconds(7), Batch(3), ExpectedCredits(203)),
(Some(CommonResolution::FourEightyP), Seconds(9), Batch(4), ExpectedCredits(348)),
(Some(CommonResolution::FourEightyP), Seconds(11), Batch(5), ExpectedCredits(425)),
(Some(CommonResolution::FourEightyP), Seconds(13), Batch(8), ExpectedCredits(502)),
(Some(CommonResolution::FourEightyP), Seconds(15), Batch(10), ExpectedCredits(579)),
(Some(CommonResolution::SevenTwentyP), Seconds(6), Batch(2), ExpectedCredits(246)),
(Some(CommonResolution::SevenTwentyP), Seconds(7), Batch(3), ExpectedCredits(431)),
(Some(CommonResolution::SevenTwentyP), Seconds(9), Batch(4), ExpectedCredits(738)),
(Some(CommonResolution::SevenTwentyP), Seconds(11), Batch(5), ExpectedCredits(902)),
(Some(CommonResolution::SevenTwentyP), Seconds(13), Batch(8), ExpectedCredits(1066)),
(Some(CommonResolution::SevenTwentyP), Seconds(15), Batch(10), ExpectedCredits(1230)),
(Some(CommonResolution::TenEightyP), Seconds(6), Batch(2), ExpectedCredits(246)),
(Some(CommonResolution::TenEightyP), Seconds(7), Batch(3), ExpectedCredits(431)),
(Some(CommonResolution::TenEightyP), Seconds(9), Batch(4), ExpectedCredits(738)),
(Some(CommonResolution::TenEightyP), Seconds(11), Batch(5), ExpectedCredits(902)),
(Some(CommonResolution::TenEightyP), Seconds(13), Batch(8), ExpectedCredits(1066)),
(Some(CommonResolution::TenEightyP), Seconds(15), Batch(10), ExpectedCredits(1230)),
(Some(CommonResolution::FourK), Seconds(6), Batch(2), ExpectedCredits(246)),
(Some(CommonResolution::FourK), Seconds(7), Batch(3), ExpectedCredits(431)),
(Some(CommonResolution::FourK), Seconds(9), Batch(4), ExpectedCredits(738)),
(Some(CommonResolution::FourK), Seconds(11), Batch(5), ExpectedCredits(902)),
(Some(CommonResolution::FourK), Seconds(13), Batch(8), ExpectedCredits(1066)),
(Some(CommonResolution::FourK), Seconds(15), Batch(10), ExpectedCredits(1230)),
];
for (resolution, seconds, batch, expected) in cases {
@@ -660,8 +804,8 @@ mod seedance_2p0_bpu_fast {
}
}
/// Every supported batch size 1-4 at every resolution (5s).
/// Batches 1-4 are preserved; larger batches are rejected before billing.
/// Every batch size 1-10 at every resolution (5s).
/// Batches over the platform max of 4 downgrade to 4 (3 upgrades to 4); execution and billing agree.
/// 1080p and 4K downgrade to 720p (and price accordingly).
#[tokio::test]
#[cfg_attr(feature = "skip_database_tests", ignore)]
@@ -673,18 +817,42 @@ mod seedance_2p0_bpu_fast {
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(2), ExpectedCredits(90)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(3), ExpectedCredits(135)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(4), ExpectedCredits(180)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(5), ExpectedCredits(180)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(6), ExpectedCredits(180)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(7), ExpectedCredits(180)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(8), ExpectedCredits(180)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(9), ExpectedCredits(180)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(10), ExpectedCredits(180)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(1), ExpectedCredits(100)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(2), ExpectedCredits(200)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(3), ExpectedCredits(300)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(4), ExpectedCredits(400)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(5), ExpectedCredits(400)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(6), ExpectedCredits(400)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(7), ExpectedCredits(400)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(8), ExpectedCredits(400)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(9), ExpectedCredits(400)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(10), ExpectedCredits(400)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(1), ExpectedCredits(100)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(2), ExpectedCredits(200)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(3), ExpectedCredits(300)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(4), ExpectedCredits(400)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(5), ExpectedCredits(400)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(6), ExpectedCredits(400)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(7), ExpectedCredits(400)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(8), ExpectedCredits(400)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(9), ExpectedCredits(400)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(10), ExpectedCredits(400)),
(Some(CommonResolution::FourK), Seconds(5), Batch(1), ExpectedCredits(100)),
(Some(CommonResolution::FourK), Seconds(5), Batch(2), ExpectedCredits(200)),
(Some(CommonResolution::FourK), Seconds(5), Batch(3), ExpectedCredits(300)),
(Some(CommonResolution::FourK), Seconds(5), Batch(4), ExpectedCredits(400)),
(Some(CommonResolution::FourK), Seconds(5), Batch(5), ExpectedCredits(400)),
(Some(CommonResolution::FourK), Seconds(5), Batch(6), ExpectedCredits(400)),
(Some(CommonResolution::FourK), Seconds(5), Batch(7), ExpectedCredits(400)),
(Some(CommonResolution::FourK), Seconds(5), Batch(8), ExpectedCredits(400)),
(Some(CommonResolution::FourK), Seconds(5), Batch(9), ExpectedCredits(400)),
(Some(CommonResolution::FourK), Seconds(5), Batch(10), ExpectedCredits(400)),
];
for (resolution, seconds, batch, expected) in cases {
@@ -704,15 +872,27 @@ mod seedance_2p0_bpu_fast {
(Some(CommonResolution::FourEightyP), Seconds(6), Batch(2), ExpectedCredits(108)),
(Some(CommonResolution::FourEightyP), Seconds(7), Batch(3), ExpectedCredits(189)),
(Some(CommonResolution::FourEightyP), Seconds(9), Batch(4), ExpectedCredits(324)),
(Some(CommonResolution::FourEightyP), Seconds(11), Batch(5), ExpectedCredits(396)),
(Some(CommonResolution::FourEightyP), Seconds(13), Batch(8), ExpectedCredits(468)),
(Some(CommonResolution::FourEightyP), Seconds(15), Batch(10), ExpectedCredits(540)),
(Some(CommonResolution::SevenTwentyP), Seconds(6), Batch(2), ExpectedCredits(240)),
(Some(CommonResolution::SevenTwentyP), Seconds(7), Batch(3), ExpectedCredits(420)),
(Some(CommonResolution::SevenTwentyP), Seconds(9), Batch(4), ExpectedCredits(720)),
(Some(CommonResolution::SevenTwentyP), Seconds(11), Batch(5), ExpectedCredits(880)),
(Some(CommonResolution::SevenTwentyP), Seconds(13), Batch(8), ExpectedCredits(1040)),
(Some(CommonResolution::SevenTwentyP), Seconds(15), Batch(10), ExpectedCredits(1200)),
(Some(CommonResolution::TenEightyP), Seconds(6), Batch(2), ExpectedCredits(240)),
(Some(CommonResolution::TenEightyP), Seconds(7), Batch(3), ExpectedCredits(420)),
(Some(CommonResolution::TenEightyP), Seconds(9), Batch(4), ExpectedCredits(720)),
(Some(CommonResolution::TenEightyP), Seconds(11), Batch(5), ExpectedCredits(880)),
(Some(CommonResolution::TenEightyP), Seconds(13), Batch(8), ExpectedCredits(1040)),
(Some(CommonResolution::TenEightyP), Seconds(15), Batch(10), ExpectedCredits(1200)),
(Some(CommonResolution::FourK), Seconds(6), Batch(2), ExpectedCredits(240)),
(Some(CommonResolution::FourK), Seconds(7), Batch(3), ExpectedCredits(420)),
(Some(CommonResolution::FourK), Seconds(9), Batch(4), ExpectedCredits(720)),
(Some(CommonResolution::FourK), Seconds(11), Batch(5), ExpectedCredits(880)),
(Some(CommonResolution::FourK), Seconds(13), Batch(8), ExpectedCredits(1040)),
(Some(CommonResolution::FourK), Seconds(15), Batch(10), ExpectedCredits(1200)),
];
for (resolution, seconds, batch, expected) in cases {
@@ -788,8 +968,8 @@ mod seedance_2p0_bpu_fast {
}
/// The BytePlus Fast rate card is flat: reference videos never change the price. Charges are asserted on the refunded ledger entry (the fixture media is unreachable).
/// Every supported batch size 1-4 at every resolution (5s).
/// Batches 1-4 are preserved; larger batches are rejected before billing.
/// Every batch size 1-10 at every resolution (5s).
/// Batches over the platform max of 4 downgrade to 4 (3 upgrades to 4); execution and billing agree.
#[tokio::test]
#[cfg_attr(feature = "skip_database_tests", ignore)]
async fn video_references_charge_every_batch_size_at_every_resolution() {
@@ -800,18 +980,42 @@ mod seedance_2p0_bpu_fast {
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(2), ExpectedCredits(97)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(3), ExpectedCredits(145)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(4), ExpectedCredits(193)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(5), ExpectedCredits(193)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(6), ExpectedCredits(193)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(7), ExpectedCredits(193)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(8), ExpectedCredits(193)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(9), ExpectedCredits(193)),
(Some(CommonResolution::FourEightyP), Seconds(5), Batch(10), ExpectedCredits(193)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(1), ExpectedCredits(103)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(2), ExpectedCredits(205)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(3), ExpectedCredits(308)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(4), ExpectedCredits(410)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(5), ExpectedCredits(410)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(6), ExpectedCredits(410)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(7), ExpectedCredits(410)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(8), ExpectedCredits(410)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(9), ExpectedCredits(410)),
(Some(CommonResolution::SevenTwentyP), Seconds(5), Batch(10), ExpectedCredits(410)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(1), ExpectedCredits(103)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(2), ExpectedCredits(205)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(3), ExpectedCredits(308)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(4), ExpectedCredits(410)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(5), ExpectedCredits(410)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(6), ExpectedCredits(410)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(7), ExpectedCredits(410)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(8), ExpectedCredits(410)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(9), ExpectedCredits(410)),
(Some(CommonResolution::TenEightyP), Seconds(5), Batch(10), ExpectedCredits(410)),
(Some(CommonResolution::FourK), Seconds(5), Batch(1), ExpectedCredits(103)),
(Some(CommonResolution::FourK), Seconds(5), Batch(2), ExpectedCredits(205)),
(Some(CommonResolution::FourK), Seconds(5), Batch(3), ExpectedCredits(308)),
(Some(CommonResolution::FourK), Seconds(5), Batch(4), ExpectedCredits(410)),
(Some(CommonResolution::FourK), Seconds(5), Batch(5), ExpectedCredits(410)),
(Some(CommonResolution::FourK), Seconds(5), Batch(6), ExpectedCredits(410)),
(Some(CommonResolution::FourK), Seconds(5), Batch(7), ExpectedCredits(410)),
(Some(CommonResolution::FourK), Seconds(5), Batch(8), ExpectedCredits(410)),
(Some(CommonResolution::FourK), Seconds(5), Batch(9), ExpectedCredits(410)),
(Some(CommonResolution::FourK), Seconds(5), Batch(10), ExpectedCredits(410)),
];
for (resolution, seconds, batch, expected) in cases {
@@ -848,15 +1052,27 @@ mod seedance_2p0_bpu_fast {
(Some(CommonResolution::FourEightyP), Seconds(6), Batch(2), ExpectedCredits(116)),
(Some(CommonResolution::FourEightyP), Seconds(7), Batch(3), ExpectedCredits(203)),
(Some(CommonResolution::FourEightyP), Seconds(9), Batch(4), ExpectedCredits(348)),
(Some(CommonResolution::FourEightyP), Seconds(11), Batch(5), ExpectedCredits(425)),
(Some(CommonResolution::FourEightyP), Seconds(13), Batch(8), ExpectedCredits(502)),
(Some(CommonResolution::FourEightyP), Seconds(15), Batch(10), ExpectedCredits(579)),
(Some(CommonResolution::SevenTwentyP), Seconds(6), Batch(2), ExpectedCredits(246)),
(Some(CommonResolution::SevenTwentyP), Seconds(7), Batch(3), ExpectedCredits(431)),
(Some(CommonResolution::SevenTwentyP), Seconds(9), Batch(4), ExpectedCredits(738)),
(Some(CommonResolution::SevenTwentyP), Seconds(11), Batch(5), ExpectedCredits(902)),
(Some(CommonResolution::SevenTwentyP), Seconds(13), Batch(8), ExpectedCredits(1066)),
(Some(CommonResolution::SevenTwentyP), Seconds(15), Batch(10), ExpectedCredits(1230)),
(Some(CommonResolution::TenEightyP), Seconds(6), Batch(2), ExpectedCredits(246)),
(Some(CommonResolution::TenEightyP), Seconds(7), Batch(3), ExpectedCredits(431)),
(Some(CommonResolution::TenEightyP), Seconds(9), Batch(4), ExpectedCredits(738)),
(Some(CommonResolution::TenEightyP), Seconds(11), Batch(5), ExpectedCredits(902)),
(Some(CommonResolution::TenEightyP), Seconds(13), Batch(8), ExpectedCredits(1066)),
(Some(CommonResolution::TenEightyP), Seconds(15), Batch(10), ExpectedCredits(1230)),
(Some(CommonResolution::FourK), Seconds(6), Batch(2), ExpectedCredits(246)),
(Some(CommonResolution::FourK), Seconds(7), Batch(3), ExpectedCredits(431)),
(Some(CommonResolution::FourK), Seconds(9), Batch(4), ExpectedCredits(738)),
(Some(CommonResolution::FourK), Seconds(11), Batch(5), ExpectedCredits(902)),
(Some(CommonResolution::FourK), Seconds(13), Batch(8), ExpectedCredits(1066)),
(Some(CommonResolution::FourK), Seconds(15), Batch(10), ExpectedCredits(1230)),
];
for (resolution, seconds, batch, expected) in cases {
Loaded 100 of 358 files, more files were not shown because too many files have changed in this diff. Show more