mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-13 18:24:28 -05:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f0154efe4 | ||
|
|
845de872ca | ||
|
|
1dcda3063a | ||
|
|
fab358e25e |
@@ -92,6 +92,9 @@ pub enum RouterVideoModel {
|
||||
#[serde(rename = "seedance_2p5_preview")]
|
||||
Seedance2p5Preview,
|
||||
|
||||
#[serde(rename = "seedance_2p5")]
|
||||
Seedance2p5,
|
||||
|
||||
#[serde(rename = "happy_horse_1p0")]
|
||||
HappyHorse1p0,
|
||||
|
||||
@@ -156,6 +159,13 @@ mod tests {
|
||||
assert_serde_round_trip(RouterVideoModel::Seedance2p5Preview, "seedance_2p5_preview");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seedance_2p5_serializes() {
|
||||
// NB: This string must match `CommonVideoModel` — the two enums convert
|
||||
// via serde string round-trip in the omni handlers.
|
||||
assert_serde_round_trip(RouterVideoModel::Seedance2p5, "seedance_2p5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn veo_3p1_lite_and_vidu_variants_serialize() {
|
||||
// NB: These strings must match `CommonVideoModel` — the two enums convert
|
||||
|
||||
+1
-1
@@ -71,7 +71,7 @@ pub(crate) async fn resolve_and_upload_audio_ref(
|
||||
})?
|
||||
}
|
||||
};
|
||||
upload_to_kinovi_web(session, &source_url).await
|
||||
upload_to_kinovi_web(session, &source_url, None).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+11
@@ -78,7 +78,9 @@ use crate::generate::generate_video::providers::fal::vidu_q3::build::build_fal_v
|
||||
use crate::generate::generate_video::providers::fal::vidu_q3_turbo::build::build_fal_vidu_q3_turbo;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p0_fast::build::build_kinovi_seedance_2p0_fast;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p0_mini::build::build_kinovi_seedance_2p0_mini;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p5::build::build_artcraft_seedance_2p5;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p5_preview::build::build_artcraft_seedance_2p5_preview;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p5::build::build_kinovi_seedance_2p5;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p5_preview::build::build_kinovi_seedance_2p5_preview;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
|
||||
@@ -138,6 +140,12 @@ pub struct GenerateVideoRequestBuilder {
|
||||
/// so it's best to be explicit.
|
||||
pub generate_audio: Option<bool>,
|
||||
|
||||
/// CALCULATION-ONLY (never sent to any provider): the total seconds of
|
||||
/// reference video input, summed across all reference videos and rounded
|
||||
/// up to whole seconds per file. Models that bill reference-video input
|
||||
/// seconds (e.g. Seedance 2.5) add this to the billed duration.
|
||||
pub total_reference_video_input_seconds: Option<u16>,
|
||||
|
||||
/// If the request is a mismatch with the (model/provider), how to mitigate it.
|
||||
pub request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy,
|
||||
|
||||
@@ -166,6 +174,7 @@ impl Default for GenerateVideoRequestBuilder {
|
||||
duration_seconds: None,
|
||||
video_batch_count: None,
|
||||
generate_audio: None,
|
||||
total_reference_video_input_seconds: None,
|
||||
idempotency_token: None,
|
||||
}
|
||||
}
|
||||
@@ -201,6 +210,7 @@ impl GenerateVideoRequestBuilder {
|
||||
(RouterProvider::Artcraft, RouterVideoModel::Seedance2p0BytePlusMini) => build_artcraft_seedance_2p0_bp_mini(self),
|
||||
(RouterProvider::Artcraft, RouterVideoModel::Seedance2p0BytePlusUltraMini) => build_artcraft_seedance_2p0_bpu_mini(self),
|
||||
(RouterProvider::Artcraft, RouterVideoModel::Seedance2p5Preview) => build_artcraft_seedance_2p5_preview(self),
|
||||
(RouterProvider::Artcraft, RouterVideoModel::Seedance2p5) => build_artcraft_seedance_2p5(self),
|
||||
(RouterProvider::Artcraft, RouterVideoModel::PreviewModel) => build_artcraft_preview_model(self),
|
||||
(RouterProvider::Artcraft, RouterVideoModel::PreviewModelFast) => build_artcraft_preview_model_fast(self),
|
||||
(RouterProvider::Artcraft, RouterVideoModel::Seedance10Lite) => build_artcraft_seedance_1p0_lite(self),
|
||||
@@ -252,6 +262,7 @@ impl GenerateVideoRequestBuilder {
|
||||
(RouterProvider::KinoviWeb, RouterVideoModel::Seedance2p0BytePlusMini) => build_kinovi_seedance_2p0_mini(self),
|
||||
(RouterProvider::KinoviWeb, RouterVideoModel::Seedance2p0BytePlusUltraMini) => build_kinovi_seedance_2p0_mini(self),
|
||||
(RouterProvider::KinoviWeb, RouterVideoModel::Seedance2p5Preview) => build_kinovi_seedance_2p5_preview(self),
|
||||
(RouterProvider::KinoviWeb, RouterVideoModel::Seedance2p5) => build_kinovi_seedance_2p5(self),
|
||||
_ => self.unsupported_provider_and_model(),
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -32,6 +32,7 @@ pub mod seedance_2p0_bpu_fast;
|
||||
pub mod seedance_2p0_mini;
|
||||
pub mod seedance_2p0_bp_mini;
|
||||
pub mod seedance_2p0_bpu_mini;
|
||||
pub mod seedance_2p5;
|
||||
pub mod seedance_2p5_preview;
|
||||
pub mod sora_2;
|
||||
pub mod sora_2_pro;
|
||||
|
||||
+244
@@ -0,0 +1,244 @@
|
||||
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
|
||||
|
||||
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::providers::artcraft::build_common::{
|
||||
build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport,
|
||||
};
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p5::request::ArtcraftSeedance2p5RequestState;
|
||||
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_2p5(mut builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
let strategy = builder.request_mismatch_mitigation_strategy;
|
||||
|
||||
// Parity with the Kinovi build: keyframes and reference media are mutually
|
||||
// exclusive, an end frame requires a start frame, and there is no
|
||||
// character support. Erroring here keeps the billing estimate consistent
|
||||
// with the execution build.
|
||||
let has_keyframes = builder.start_frame.is_some() || builder.end_frame.is_some();
|
||||
let has_references = builder.reference_images.is_some()
|
||||
|| builder.reference_videos.is_some()
|
||||
|| builder.reference_audio.is_some();
|
||||
|
||||
if has_keyframes && has_references {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "reference_images",
|
||||
value: "Seedance 2.5 cannot combine keyframes with reference media; send one or the other".to_string(),
|
||||
}));
|
||||
}
|
||||
if builder.end_frame.is_some() && builder.start_frame.is_none() {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "end_frame",
|
||||
value: "Seedance 2.5 requires a start frame when an end frame is set".to_string(),
|
||||
}));
|
||||
}
|
||||
if builder.reference_character_tokens.is_some() {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "reference_character_tokens",
|
||||
value: "Seedance 2.5 does not support character references".to_string(),
|
||||
}));
|
||||
}
|
||||
|
||||
// 2.5 allows 4-30 second durations (longer than the shared 4-15 range in
|
||||
// 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 mut request = build_artcraft_omni_video_request(
|
||||
builder,
|
||||
CommonVideoModelEnum::Seedance2p5,
|
||||
SupportedResolutions::Fast,
|
||||
UltraWideSupport::Supported,
|
||||
)?;
|
||||
request.duration_seconds = duration_seconds;
|
||||
request.video_batch_count = Some(1);
|
||||
|
||||
let state = ArtcraftSeedance2p5RequestState { request, total_input_seconds };
|
||||
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p5(state)))
|
||||
}
|
||||
|
||||
// Seedance 2.5 supports durations of 4-30 seconds.
|
||||
fn plan_duration(
|
||||
duration_seconds: Option<u16>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Option<u16>, ArtcraftRouterError> {
|
||||
const MIN: u16 = 4;
|
||||
const MAX: u16 = 30;
|
||||
match duration_seconds {
|
||||
None => Ok(None),
|
||||
Some(d) if d >= MIN && d <= MAX => Ok(Some(d)),
|
||||
Some(d) => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "duration_seconds",
|
||||
value: format!("{}", d),
|
||||
}))
|
||||
}
|
||||
_ => Ok(Some(d.clamp(MIN, MAX))),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
|
||||
use tokens::tokens::characters::CharacterToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::api::character_list_ref::CharacterListRef;
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
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;
|
||||
|
||||
use super::*;
|
||||
|
||||
mod field_conversions {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn model_is_correct() {
|
||||
let req = unwrap_request(builder_with(|_| {}));
|
||||
assert!(matches!(req.request.model, Some(CommonVideoModelEnum::Seedance2p5)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_30_is_passed_through() {
|
||||
let req = unwrap_request(builder_with(|b| { b.duration_seconds = Some(30); }));
|
||||
assert_eq!(req.request.duration_seconds, Some(30));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_clamped_to_range() {
|
||||
let req = unwrap_request(builder_with(|b| { b.duration_seconds = Some(99); }));
|
||||
assert_eq!(req.request.duration_seconds, Some(30));
|
||||
|
||||
let req = unwrap_request(builder_with(|b| { b.duration_seconds = Some(2); }));
|
||||
assert_eq!(req.request.duration_seconds, Some(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
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]
|
||||
fn total_input_seconds_is_carried_through() {
|
||||
let req = unwrap_request(builder_with(|b| { b.total_reference_video_input_seconds = Some(14); }));
|
||||
assert_eq!(req.total_input_seconds, Some(14));
|
||||
}
|
||||
}
|
||||
|
||||
mod modality_rules {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn keyframes_are_accepted() {
|
||||
let req = unwrap_request(builder_with(|b| {
|
||||
b.start_frame = Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_start".to_string())));
|
||||
b.end_frame = Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_end".to_string())));
|
||||
}));
|
||||
assert!(req.request.start_frame_image_media_token.is_some());
|
||||
assert!(req.request.end_frame_image_media_token.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_frame_without_start_frame_errors() {
|
||||
let result = build_artcraft_seedance_2p5(builder_with(|b| {
|
||||
b.end_frame = Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_end".to_string())));
|
||||
}));
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyframes_plus_references_error() {
|
||||
let result = build_artcraft_seedance_2p5(builder_with(|b| {
|
||||
b.start_frame = Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_start".to_string())));
|
||||
b.reference_images = Some(ImageListRef::MediaFileTokens(vec![
|
||||
MediaFileToken::new("mf_ref".to_string()),
|
||||
]));
|
||||
}));
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_references_error() {
|
||||
let result = build_artcraft_seedance_2p5(builder_with(|b| {
|
||||
b.reference_character_tokens = Some(CharacterListRef::CharacterTokens(vec![
|
||||
CharacterToken::new("char_abc".to_string()),
|
||||
]));
|
||||
}));
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
mod resolution_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn res_480p() {
|
||||
let req = unwrap_request(builder_with(|b| { b.resolution = Some(RouterResolution::FourEightyP); }));
|
||||
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::FourEightyP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn res_1080p_downgrades_to_720p() {
|
||||
let req = unwrap_request(builder_with(|b| { b.resolution = Some(RouterResolution::TenEightyP); }));
|
||||
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP));
|
||||
}
|
||||
}
|
||||
|
||||
fn builder_with(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder {
|
||||
let mut builder = GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Seedance2p5,
|
||||
provider: RouterProvider::Artcraft,
|
||||
duration_seconds: Some(5),
|
||||
video_batch_count: Some(1),
|
||||
..Default::default()
|
||||
};
|
||||
f(&mut builder);
|
||||
builder
|
||||
}
|
||||
|
||||
fn unwrap_request(builder: GenerateVideoRequestBuilder) -> ArtcraftSeedance2p5RequestState {
|
||||
let result = build_artcraft_seedance_2p5(builder).expect("build should succeed");
|
||||
match result {
|
||||
VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p5(state)) => state,
|
||||
_ => panic!("expected ArtcraftSeedance2p5 request"),
|
||||
}
|
||||
}
|
||||
}
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
use enums::common::generation::common_resolution::CommonResolution;
|
||||
use kinovi_web_client::generate::video::generate_seedance_2p5::MAX_BILLED_INPUT_SECONDS;
|
||||
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_common::seedance_2p5_usd_cents;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p5::request::ArtcraftSeedance2p5RequestState;
|
||||
|
||||
/// Seedance 2.5 pricing depends on the resolution, the output duration, and
|
||||
/// — 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). Input seconds are clamped to
|
||||
/// [`MAX_BILLED_INPUT_SECONDS`] — the model accepts at most 30 seconds of
|
||||
/// video. No batching.
|
||||
pub struct ArtcraftSeedance2p5CostState {
|
||||
pub resolution: CommonResolution,
|
||||
pub duration_seconds: u16,
|
||||
pub has_video_references: bool,
|
||||
pub total_input_seconds: u16,
|
||||
}
|
||||
|
||||
impl ArtcraftSeedance2p5CostState {
|
||||
pub fn from_request(request: &ArtcraftSeedance2p5RequestState) -> Self {
|
||||
let resolution = request.request.resolution
|
||||
.unwrap_or(CommonResolution::SevenTwentyP);
|
||||
let duration_seconds = request.request.duration_seconds.unwrap_or(5);
|
||||
|
||||
let has_video_references = request.request.reference_video_media_tokens
|
||||
.as_ref()
|
||||
.is_some_and(|tokens| !tokens.is_empty());
|
||||
|
||||
Self {
|
||||
resolution,
|
||||
duration_seconds,
|
||||
has_video_references,
|
||||
total_input_seconds: request.total_input_seconds.unwrap_or(0),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
|
||||
let total_input_seconds = self.total_input_seconds
|
||||
.min(u16::from(MAX_BILLED_INPUT_SECONDS));
|
||||
|
||||
let usd_cents = seedance_2p5_usd_cents(
|
||||
self.resolution,
|
||||
self.duration_seconds,
|
||||
self.has_video_references,
|
||||
total_input_seconds,
|
||||
);
|
||||
|
||||
// ArtCraft credits: 100 credits = $1.00, so credits = cents.
|
||||
VideoGenerationCostEstimate {
|
||||
cost_in_credits: Some(usd_cents),
|
||||
cost_in_usd_cents: Some(usd_cents),
|
||||
is_free: false,
|
||||
is_unlimited: false,
|
||||
is_rate_limited: false,
|
||||
has_watermark: false,
|
||||
failures_are_refunded: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
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::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
mod pricing_without_video_references {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn table_480p() {
|
||||
// 11.76954733 ¢/s, rounded up.
|
||||
assert_eq!(cents(Some(RouterResolution::FourEightyP), 4), 48);
|
||||
assert_eq!(cents(Some(RouterResolution::FourEightyP), 5), 59);
|
||||
assert_eq!(cents(Some(RouterResolution::FourEightyP), 10), 118);
|
||||
assert_eq!(cents(Some(RouterResolution::FourEightyP), 30), 354);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_720p() {
|
||||
// 26.70781893 ¢/s, rounded up.
|
||||
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 4), 107);
|
||||
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 5), 134);
|
||||
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 10), 268);
|
||||
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 30), 802);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_defaults_to_720p() {
|
||||
assert_eq!(cents(None, 10), cents(Some(RouterResolution::SevenTwentyP), 10));
|
||||
}
|
||||
}
|
||||
|
||||
mod pricing_with_video_references {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn thirty_second_output_with_ten_input_seconds_bills_forty() {
|
||||
// 7.24279835 ¢/s × 40 = 289.71 → 290¢.
|
||||
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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fourteen_input_seconds_bill_forty_four() {
|
||||
// 7.24279835 ¢/s × 44 = 318.68 → 319¢.
|
||||
assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 30, Some(14)), 319);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_input_seconds_bill_output_duration_only() {
|
||||
// 7.24279835 ¢/s × 10 = 72.43 → 73¢.
|
||||
assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 10, None), 73);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_seconds_clamp_to_max_billed_input_seconds() {
|
||||
// 200 input seconds clamp to 30: 7.24279835 ¢/s × (30+30) = 434.57 → 435¢.
|
||||
assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 30, Some(200)), 435);
|
||||
assert_eq!(
|
||||
cents_with_video_refs(Some(RouterResolution::FourEightyP), 30, Some(200)),
|
||||
cents_with_video_refs(Some(RouterResolution::FourEightyP), 30, Some(30)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
mod credits_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn credits_equal_usd_cents() {
|
||||
for dur in [4u16, 10, 30] {
|
||||
let estimate = estimate_with(|b| {
|
||||
b.resolution = Some(RouterResolution::FourEightyP);
|
||||
b.duration_seconds = Some(dur);
|
||||
});
|
||||
assert_eq!(estimate.cost_in_credits, estimate.cost_in_usd_cents);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
fn estimate_with(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> VideoGenerationCostEstimate {
|
||||
let mut builder = GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Seedance2p5,
|
||||
provider: RouterProvider::Artcraft,
|
||||
duration_seconds: Some(5),
|
||||
video_batch_count: Some(1),
|
||||
..Default::default()
|
||||
};
|
||||
f(&mut builder);
|
||||
builder.build2()
|
||||
.expect("build should succeed")
|
||||
.estimate_cost()
|
||||
.expect("estimate should succeed")
|
||||
}
|
||||
|
||||
fn cents(resolution: Option<RouterResolution>, duration_seconds: u16) -> u64 {
|
||||
estimate_with(|b| {
|
||||
b.resolution = resolution;
|
||||
b.duration_seconds = Some(duration_seconds);
|
||||
}).cost_in_usd_cents.unwrap()
|
||||
}
|
||||
|
||||
fn cents_with_video_refs(
|
||||
resolution: Option<RouterResolution>,
|
||||
duration_seconds: u16,
|
||||
total_input_seconds: Option<u16>,
|
||||
) -> u64 {
|
||||
estimate_with(|b| {
|
||||
b.resolution = resolution;
|
||||
b.duration_seconds = Some(duration_seconds);
|
||||
b.reference_videos = Some(VideoListRef::MediaFileTokens(vec![
|
||||
MediaFileToken::new("mf_ref".to_string()),
|
||||
]));
|
||||
b.total_reference_video_input_seconds = total_input_seconds;
|
||||
}).cost_in_usd_cents.unwrap()
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod build;
|
||||
pub mod cost;
|
||||
pub mod request;
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
|
||||
|
||||
use crate::client::router_artcraft_client::RouterArtcraftClient;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
|
||||
use crate::generate::generate_video::providers::artcraft::request_common::send_artcraft_omni_video_request;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ArtcraftSeedance2p5RequestState {
|
||||
pub request: OmniGenVideoCostAndGenerateRequest,
|
||||
|
||||
/// Calculation-only: total seconds of reference video input, billed on top
|
||||
/// of the output duration when reference videos are attached. Not part of
|
||||
/// the omni request wire shape.
|
||||
pub total_input_seconds: Option<u16>,
|
||||
}
|
||||
|
||||
impl ArtcraftSeedance2p5RequestState {
|
||||
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
|
||||
send_artcraft_omni_video_request(&self.request, client).await
|
||||
}
|
||||
}
|
||||
+40
@@ -141,6 +141,46 @@ pub fn seedance_2p5_preview_usd_cents(
|
||||
(cents_per_second * duration_seconds as f64).ceil() as u64
|
||||
}
|
||||
|
||||
/// Seedance 2.5 — 480p price, USD cents per second.
|
||||
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 — 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;
|
||||
|
||||
/// ArtCraft's price (USD cents) for Seedance 2.5.
|
||||
///
|
||||
/// Only 480p and 720p are offered; any other resolution prices at 720p.
|
||||
/// 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. The
|
||||
/// fractional total is rounded UP to a whole cent. No batching.
|
||||
pub fn seedance_2p5_usd_cents(
|
||||
resolution: CommonResolution,
|
||||
duration_seconds: u16,
|
||||
has_video_references: bool,
|
||||
total_input_seconds: u16,
|
||||
) -> u64 {
|
||||
let (cents_per_second, billed_seconds) = if has_video_references {
|
||||
let rate = match resolution {
|
||||
CommonResolution::FourEightyP => SEEDANCE_2P5_VIDEO_REFERENCE_CENTS_PER_SECOND_480P,
|
||||
// Everything else (including 720p and unsupported resolutions) prices at 720p.
|
||||
_ => SEEDANCE_2P5_VIDEO_REFERENCE_CENTS_PER_SECOND_720P,
|
||||
};
|
||||
(rate, u64::from(duration_seconds) + u64::from(total_input_seconds))
|
||||
} else {
|
||||
let rate = match resolution {
|
||||
CommonResolution::FourEightyP => SEEDANCE_2P5_CENTS_PER_SECOND_480P,
|
||||
_ => SEEDANCE_2P5_CENTS_PER_SECOND_720P,
|
||||
};
|
||||
(rate, u64::from(duration_seconds))
|
||||
};
|
||||
|
||||
(cents_per_second * billed_seconds as f64).ceil() as u64
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn mini_usd_cents(
|
||||
resolution: CommonResolution,
|
||||
|
||||
+2
-1
@@ -37,7 +37,8 @@ impl KinoviHappyHorse1p0DraftState {
|
||||
|
||||
if let Some(remaining) = self.unhandled_request_state.take() {
|
||||
let map = draft_context.media_file_to_artcraft_url_map;
|
||||
start_frame_url = resolve_and_upload_single(session, remaining.start_frame, map).await?;
|
||||
let predownloaded = draft_context.predownloaded_media_paths;
|
||||
start_frame_url = resolve_and_upload_single(session, remaining.start_frame, map, predownloaded).await?;
|
||||
}
|
||||
|
||||
let request = GenerateHappyHorse1p0Request {
|
||||
|
||||
+1
@@ -2,6 +2,7 @@ pub mod happy_horse_1p0;
|
||||
pub mod seedance_2p0;
|
||||
pub mod seedance_2p0_fast;
|
||||
pub mod seedance_2p0_mini;
|
||||
pub mod seedance_2p5;
|
||||
pub mod seedance_2p5_preview;
|
||||
pub(crate) mod resolve;
|
||||
pub(crate) mod upload;
|
||||
|
||||
+18
-2
@@ -1,4 +1,5 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use kinovi_web_client::creds::kinovi_web_session::KinoviWebSession;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
@@ -45,13 +46,15 @@ pub(crate) async fn resolve_and_upload_single(
|
||||
session: &KinoviWebSession,
|
||||
image_ref: Option<ImageRef>,
|
||||
maybe_map: Option<&HashMap<MediaFileToken, String>>,
|
||||
maybe_predownloaded: Option<&HashMap<String, PathBuf>>,
|
||||
) -> Result<Option<String>, ArtcraftRouterError> {
|
||||
let source_url = match image_ref {
|
||||
None => return Ok(None),
|
||||
Some(ImageRef::Url(url)) => url,
|
||||
Some(ImageRef::MediaFileToken(token)) => resolve_token(maybe_map, &token)?,
|
||||
};
|
||||
Ok(Some(upload_to_kinovi_web(session, &source_url).await?))
|
||||
let local_path = predownloaded_path(maybe_predownloaded, &source_url);
|
||||
Ok(Some(upload_to_kinovi_web(session, &source_url, local_path).await?))
|
||||
}
|
||||
|
||||
/// Resolve a list of refs to URLs and upload each to KinoviWeb CDN.
|
||||
@@ -60,6 +63,7 @@ pub(crate) async fn resolve_and_upload_list(
|
||||
session: &KinoviWebSession,
|
||||
urls_or_tokens: Option<UrlsOrTokens>,
|
||||
maybe_map: Option<&HashMap<MediaFileToken, String>>,
|
||||
maybe_predownloaded: Option<&HashMap<String, PathBuf>>,
|
||||
) -> Result<Option<Vec<String>>, ArtcraftRouterError> {
|
||||
let source_urls = match urls_or_tokens {
|
||||
None => return Ok(None),
|
||||
@@ -71,11 +75,23 @@ pub(crate) async fn resolve_and_upload_list(
|
||||
|
||||
let mut uploaded = Vec::with_capacity(source_urls.len());
|
||||
for url in &source_urls {
|
||||
uploaded.push(upload_to_kinovi_web(session, url).await?);
|
||||
let local_path = predownloaded_path(maybe_predownloaded, url);
|
||||
uploaded.push(upload_to_kinovi_web(session, url, local_path).await?);
|
||||
}
|
||||
Ok(Some(uploaded))
|
||||
}
|
||||
|
||||
/// Look up a caller-predownloaded local copy of the source file (see
|
||||
/// `VideoGenerationDraftContext::predownloaded_media_paths`).
|
||||
fn predownloaded_path<'a>(
|
||||
maybe_predownloaded: Option<&'a HashMap<String, PathBuf>>,
|
||||
source_url: &str,
|
||||
) -> Option<&'a Path> {
|
||||
maybe_predownloaded
|
||||
.and_then(|paths| paths.get(source_url))
|
||||
.map(PathBuf::as_path)
|
||||
}
|
||||
|
||||
/// Map character tokens to their Kinovi character IDs, preserving order.
|
||||
pub(crate) fn resolve_character_tokens(
|
||||
character_list_ref: Option<&CharacterListRef>,
|
||||
|
||||
+6
-5
@@ -59,20 +59,21 @@ impl KinoviSeedance2p0DraftState {
|
||||
|
||||
if let Some(remaining) = self.unhandled_request_state.take() {
|
||||
let map = draft_context.media_file_to_artcraft_url_map;
|
||||
let predownloaded = draft_context.predownloaded_media_paths;
|
||||
|
||||
start_frame_url = resolve_and_upload_single(session, remaining.start_frame, map).await?;
|
||||
end_frame_url = resolve_and_upload_single(session, remaining.end_frame, map).await?;
|
||||
start_frame_url = resolve_and_upload_single(session, remaining.start_frame, map, predownloaded).await?;
|
||||
end_frame_url = resolve_and_upload_single(session, remaining.end_frame, map, predownloaded).await?;
|
||||
|
||||
reference_image_urls = resolve_and_upload_list(
|
||||
session, remaining.reference_images.map(image_list_ref_into_urls_or_tokens), map,
|
||||
session, remaining.reference_images.map(image_list_ref_into_urls_or_tokens), map, predownloaded,
|
||||
).await?;
|
||||
|
||||
reference_video_urls = resolve_and_upload_list(
|
||||
session, remaining.reference_videos.map(video_list_ref_into_urls_or_tokens), map,
|
||||
session, remaining.reference_videos.map(video_list_ref_into_urls_or_tokens), map, predownloaded,
|
||||
).await?;
|
||||
|
||||
reference_audio_urls = resolve_and_upload_list(
|
||||
session, remaining.reference_audio.map(audio_list_ref_into_urls_or_tokens), map,
|
||||
session, remaining.reference_audio.map(audio_list_ref_into_urls_or_tokens), map, predownloaded,
|
||||
).await?;
|
||||
|
||||
character_ids = resolve_character_tokens(
|
||||
|
||||
+6
-5
@@ -56,20 +56,21 @@ impl KinoviSeedance2p0FastDraftState {
|
||||
|
||||
if let Some(remaining) = self.unhandled_request_state.take() {
|
||||
let map = draft_context.media_file_to_artcraft_url_map;
|
||||
let predownloaded = draft_context.predownloaded_media_paths;
|
||||
|
||||
start_frame_url = resolve_and_upload_single(session, remaining.start_frame, map).await?;
|
||||
end_frame_url = resolve_and_upload_single(session, remaining.end_frame, map).await?;
|
||||
start_frame_url = resolve_and_upload_single(session, remaining.start_frame, map, predownloaded).await?;
|
||||
end_frame_url = resolve_and_upload_single(session, remaining.end_frame, map, predownloaded).await?;
|
||||
|
||||
reference_image_urls = resolve_and_upload_list(
|
||||
session, remaining.reference_images.map(image_list_ref_into_urls_or_tokens), map,
|
||||
session, remaining.reference_images.map(image_list_ref_into_urls_or_tokens), map, predownloaded,
|
||||
).await?;
|
||||
|
||||
reference_video_urls = resolve_and_upload_list(
|
||||
session, remaining.reference_videos.map(video_list_ref_into_urls_or_tokens), map,
|
||||
session, remaining.reference_videos.map(video_list_ref_into_urls_or_tokens), map, predownloaded,
|
||||
).await?;
|
||||
|
||||
reference_audio_urls = resolve_and_upload_list(
|
||||
session, remaining.reference_audio.map(audio_list_ref_into_urls_or_tokens), map,
|
||||
session, remaining.reference_audio.map(audio_list_ref_into_urls_or_tokens), map, predownloaded,
|
||||
).await?;
|
||||
|
||||
character_ids = resolve_character_tokens(
|
||||
|
||||
+6
-5
@@ -56,20 +56,21 @@ impl KinoviSeedance2p0MiniDraftState {
|
||||
|
||||
if let Some(remaining) = self.unhandled_request_state.take() {
|
||||
let map = draft_context.media_file_to_artcraft_url_map;
|
||||
let predownloaded = draft_context.predownloaded_media_paths;
|
||||
|
||||
start_frame_url = resolve_and_upload_single(session, remaining.start_frame, map).await?;
|
||||
end_frame_url = resolve_and_upload_single(session, remaining.end_frame, map).await?;
|
||||
start_frame_url = resolve_and_upload_single(session, remaining.start_frame, map, predownloaded).await?;
|
||||
end_frame_url = resolve_and_upload_single(session, remaining.end_frame, map, predownloaded).await?;
|
||||
|
||||
reference_image_urls = resolve_and_upload_list(
|
||||
session, remaining.reference_images.map(image_list_ref_into_urls_or_tokens), map,
|
||||
session, remaining.reference_images.map(image_list_ref_into_urls_or_tokens), map, predownloaded,
|
||||
).await?;
|
||||
|
||||
reference_video_urls = resolve_and_upload_list(
|
||||
session, remaining.reference_videos.map(video_list_ref_into_urls_or_tokens), map,
|
||||
session, remaining.reference_videos.map(video_list_ref_into_urls_or_tokens), map, predownloaded,
|
||||
).await?;
|
||||
|
||||
reference_audio_urls = resolve_and_upload_list(
|
||||
session, remaining.reference_audio.map(audio_list_ref_into_urls_or_tokens), map,
|
||||
session, remaining.reference_audio.map(audio_list_ref_into_urls_or_tokens), map, predownloaded,
|
||||
).await?;
|
||||
|
||||
character_ids = resolve_character_tokens(
|
||||
|
||||
+453
@@ -0,0 +1,453 @@
|
||||
use kinovi_web_client::generate::video::generate_seedance_2p5::{
|
||||
KinoviSeedance2p5AspectRatio as KinoviAspectRatio,
|
||||
KinoviSeedance2p5OutputResolution as KinoviOutputResolution,
|
||||
};
|
||||
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
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::providers::kinovi::seedance_2p5::draft::{KinoviSeedance2p5DraftState, KinoviSeedance2p5RemainingItems};
|
||||
use crate::generate::generate_video::video_generation_draft::VideoGenerationDraftRequest;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
|
||||
pub fn build_kinovi_seedance_2p5(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
let draft = do_build_kinovi_seedance_2p5(builder)?;
|
||||
Ok(VideoGenerationDraftOrRequest::Draft(VideoGenerationDraftRequest::KinoviSeedance2p5(draft)))
|
||||
}
|
||||
|
||||
fn do_build_kinovi_seedance_2p5(mut builder: GenerateVideoRequestBuilder) -> Result<KinoviSeedance2p5DraftState, ArtcraftRouterError> {
|
||||
let strategy = builder.request_mismatch_mitigation_strategy;
|
||||
|
||||
let has_keyframes = builder.start_frame.is_some() || builder.end_frame.is_some();
|
||||
let has_references = builder.reference_images.is_some()
|
||||
|| builder.reference_videos.is_some()
|
||||
|| builder.reference_audio.is_some();
|
||||
|
||||
// Keyframe (image-to-video) and reference modes are mutually exclusive on
|
||||
// the Seedance 2.5 API. Dropping either set would change the meaning of
|
||||
// the request, so this errors regardless of strategy.
|
||||
if has_keyframes && has_references {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "reference_images",
|
||||
value: "Seedance 2.5 cannot combine keyframes with reference media; send one or the other".to_string(),
|
||||
}));
|
||||
}
|
||||
|
||||
// An end frame requires a start frame.
|
||||
if builder.end_frame.is_some() && builder.start_frame.is_none() {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "end_frame",
|
||||
value: "Seedance 2.5 requires a start frame when an end frame is set".to_string(),
|
||||
}));
|
||||
}
|
||||
|
||||
// Kinovi character references are not supported by the 2.5 API.
|
||||
if builder.reference_character_tokens.is_some() {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "reference_character_tokens",
|
||||
value: "Seedance 2.5 does not support character references".to_string(),
|
||||
}));
|
||||
}
|
||||
|
||||
// Keyframe mode only supports "Adaptive" (the aspect ratio follows the
|
||||
// input frames), so any explicit aspect ratio preference is ignored there.
|
||||
// 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)?;
|
||||
plan_batch_count(builder.video_batch_count.take(), strategy)?;
|
||||
let duration_seconds = plan_duration(builder.duration_seconds.take(), strategy)?;
|
||||
let prompt = builder.prompt.take().unwrap_or_default();
|
||||
|
||||
let total_input_seconds = builder.total_reference_video_input_seconds
|
||||
.map(|seconds| u8::try_from(seconds).unwrap_or(u8::MAX));
|
||||
|
||||
// NB: `builder.bitrate` is intentionally ignored — 2.5 has no bitrate
|
||||
// option (and bitrate never affects cost).
|
||||
|
||||
let unhandled_request_state = KinoviSeedance2p5RemainingItems {
|
||||
start_frame: builder.start_frame.take(),
|
||||
end_frame: builder.end_frame.take(),
|
||||
reference_images: builder.reference_images.take(),
|
||||
reference_videos: builder.reference_videos.take(),
|
||||
reference_audio: builder.reference_audio.take(),
|
||||
};
|
||||
|
||||
Ok(KinoviSeedance2p5DraftState {
|
||||
aspect_ratio,
|
||||
resolution,
|
||||
duration_seconds,
|
||||
prompt,
|
||||
total_input_seconds,
|
||||
unhandled_request_state: Some(unhandled_request_state),
|
||||
})
|
||||
}
|
||||
|
||||
// ── Plan helpers ──
|
||||
|
||||
// Seedance 2.5 reference mode supports all six aspect ratios:
|
||||
// 16:9, 21:9, 9:16, 1:1, 4:3, 3:4. All supported ratios cost the same, so
|
||||
// both upgrade and downgrade pick the nearest match. (Keyframe mode is
|
||||
// always Adaptive and ignores this entirely.)
|
||||
fn plan_aspect_ratio(
|
||||
aspect_ratio: Option<RouterAspectRatio>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<KinoviAspectRatio, ArtcraftRouterError> {
|
||||
match aspect_ratio {
|
||||
// No preference or auto — default to landscape
|
||||
None
|
||||
| Some(RouterAspectRatio::Auto)
|
||||
| Some(RouterAspectRatio::Auto2k)
|
||||
| Some(RouterAspectRatio::Auto4k) => Ok(KinoviAspectRatio::Landscape16x9),
|
||||
|
||||
// Direct mappings
|
||||
Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => {
|
||||
Ok(KinoviAspectRatio::Landscape16x9)
|
||||
}
|
||||
Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => {
|
||||
Ok(KinoviAspectRatio::Portrait9x16)
|
||||
}
|
||||
Some(RouterAspectRatio::Square) | Some(RouterAspectRatio::SquareHd) => {
|
||||
Ok(KinoviAspectRatio::Square1x1)
|
||||
}
|
||||
Some(RouterAspectRatio::WideFourByThree) => Ok(KinoviAspectRatio::Standard4x3),
|
||||
Some(RouterAspectRatio::TallThreeByFour) => Ok(KinoviAspectRatio::Portrait3x4),
|
||||
Some(RouterAspectRatio::WideTwentyOneByNine) => Ok(KinoviAspectRatio::UltraWide21x9),
|
||||
|
||||
// Mismatches — apply strategy
|
||||
Some(unsupported) => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "aspect_ratio",
|
||||
value: format!("{:?}", unsupported),
|
||||
}))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade
|
||||
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
|
||||
Ok(nearest_aspect_ratio(unsupported))
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick the nearest supported aspect ratio by AR value (width / height).
|
||||
fn nearest_aspect_ratio(aspect_ratio: RouterAspectRatio) -> KinoviAspectRatio {
|
||||
match aspect_ratio {
|
||||
RouterAspectRatio::WideFiveByFour => KinoviAspectRatio::Standard4x3, // 1.25, nearest 1.33
|
||||
RouterAspectRatio::WideThreeByTwo => KinoviAspectRatio::Standard4x3, // 1.50, nearest 1.33
|
||||
RouterAspectRatio::TallFourByFive => KinoviAspectRatio::Portrait3x4, // 0.80, nearest 0.75
|
||||
RouterAspectRatio::TallTwoByThree => KinoviAspectRatio::Portrait3x4, // 0.67, nearest 0.75
|
||||
RouterAspectRatio::TallNineByTwentyOne => KinoviAspectRatio::Portrait9x16, // 0.43, nearest 0.56
|
||||
_ => KinoviAspectRatio::Square1x1,
|
||||
}
|
||||
}
|
||||
|
||||
// Seedance 2.5 supports output resolutions: 480p and 720p only.
|
||||
// 1080p (and higher) is NOT supported — downgrade to 720p or error based on strategy.
|
||||
fn plan_output_resolution(
|
||||
resolution: Option<RouterResolution>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Option<KinoviOutputResolution>, ArtcraftRouterError> {
|
||||
match resolution {
|
||||
None => Ok(None),
|
||||
|
||||
// Direct mappings
|
||||
Some(RouterResolution::FourEightyP) => Ok(Some(KinoviOutputResolution::FourEightyP)),
|
||||
Some(RouterResolution::SevenTwentyP) => Ok(Some(KinoviOutputResolution::SevenTwentyP)),
|
||||
|
||||
// 1080p is not supported — handle via strategy
|
||||
Some(RouterResolution::TenEightyP) => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "resolution",
|
||||
value: format!("{:?}", RouterResolution::TenEightyP),
|
||||
}))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade
|
||||
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
|
||||
// 1080p not available — downgrade to 720p (highest supported)
|
||||
Ok(Some(KinoviOutputResolution::SevenTwentyP))
|
||||
}
|
||||
},
|
||||
|
||||
// Other unsupported resolutions
|
||||
Some(unsupported) => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "resolution",
|
||||
value: format!("{:?}", unsupported),
|
||||
}))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade
|
||||
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
|
||||
Ok(Some(match unsupported {
|
||||
RouterResolution::HalfK => KinoviOutputResolution::FourEightyP,
|
||||
_ => KinoviOutputResolution::SevenTwentyP,
|
||||
}))
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 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(()),
|
||||
// Over the maximum of 1.
|
||||
_ => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "video_batch_count",
|
||||
value: format!("{}", count),
|
||||
}))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade
|
||||
| RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Seedance 2.5 supports durations of 4–30 seconds.
|
||||
fn plan_duration(
|
||||
duration_seconds: Option<u16>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<u8, ArtcraftRouterError> {
|
||||
const MIN: u16 = 4;
|
||||
const MAX: u16 = 30;
|
||||
const DEFAULT: u8 = 5;
|
||||
match duration_seconds {
|
||||
None => Ok(DEFAULT),
|
||||
Some(d) if d >= MIN && d <= MAX => Ok(d as u8),
|
||||
Some(d) => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "duration_seconds",
|
||||
value: format!("{}", d),
|
||||
}))
|
||||
}
|
||||
_ => Ok(d.clamp(MIN, MAX) as u8),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use kinovi_web_client::generate::video::generate_seedance_2p5::{
|
||||
KinoviSeedance2p5AspectRatio as KinoviAspectRatio,
|
||||
KinoviSeedance2p5OutputResolution as KinoviOutputResolution,
|
||||
};
|
||||
use tokens::tokens::characters::CharacterToken;
|
||||
|
||||
use crate::api::character_list_ref::CharacterListRef;
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
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::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p5::draft::KinoviSeedance2p5DraftState;
|
||||
use crate::generate::generate_video::video_generation_draft::VideoGenerationDraftRequest;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
|
||||
use super::*;
|
||||
|
||||
mod materialized_field_conversions {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn prompt_is_passed_through() {
|
||||
let draft = unwrap_draft(build_kinovi_seedance_2p5(base_builder()));
|
||||
assert_eq!(draft.prompt, "a cat dancing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_defaults_to_5() {
|
||||
let builder = GenerateVideoRequestBuilder { duration_seconds: None, ..base_builder() };
|
||||
let draft = unwrap_draft(build_kinovi_seedance_2p5(builder));
|
||||
assert_eq!(draft.duration_seconds, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_clamped_to_range() {
|
||||
let over = GenerateVideoRequestBuilder { duration_seconds: Some(99), ..base_builder() };
|
||||
assert_eq!(unwrap_draft(build_kinovi_seedance_2p5(over)).duration_seconds, 30);
|
||||
|
||||
let under = GenerateVideoRequestBuilder { duration_seconds: Some(2), ..base_builder() };
|
||||
assert_eq!(unwrap_draft(build_kinovi_seedance_2p5(under)).duration_seconds, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn total_input_seconds_is_carried_through() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
total_reference_video_input_seconds: Some(14),
|
||||
..base_builder()
|
||||
};
|
||||
let draft = unwrap_draft(build_kinovi_seedance_2p5(builder));
|
||||
assert_eq!(draft.total_input_seconds, Some(14));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn total_input_seconds_saturates_at_u8_max() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
total_reference_video_input_seconds: Some(9_999),
|
||||
..base_builder()
|
||||
};
|
||||
let draft = unwrap_draft(build_kinovi_seedance_2p5(builder));
|
||||
assert_eq!(draft.total_input_seconds, Some(u8::MAX));
|
||||
}
|
||||
}
|
||||
|
||||
mod modality_rules {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn keyframes_are_accepted() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
start_frame: Some(ImageRef::Url("https://example.com/start.jpg".to_string())),
|
||||
end_frame: Some(ImageRef::Url("https://example.com/end.jpg".to_string())),
|
||||
..base_builder()
|
||||
};
|
||||
let draft = unwrap_draft(build_kinovi_seedance_2p5(builder));
|
||||
let remaining = draft.unhandled_request_state.unwrap();
|
||||
assert!(remaining.start_frame.is_some());
|
||||
assert!(remaining.end_frame.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_frame_alone_is_accepted() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
start_frame: Some(ImageRef::Url("https://example.com/start.jpg".to_string())),
|
||||
..base_builder()
|
||||
};
|
||||
assert!(build_kinovi_seedance_2p5(builder).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_frame_without_start_frame_errors() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
end_frame: Some(ImageRef::Url("https://example.com/end.jpg".to_string())),
|
||||
..base_builder()
|
||||
};
|
||||
assert!(build_kinovi_seedance_2p5(builder).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyframes_plus_references_error() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
start_frame: Some(ImageRef::Url("https://example.com/start.jpg".to_string())),
|
||||
reference_images: Some(ImageListRef::Urls(vec!["https://example.com/ref.jpg".to_string()])),
|
||||
..base_builder()
|
||||
};
|
||||
assert!(build_kinovi_seedance_2p5(builder).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_references_error() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
reference_character_tokens: Some(CharacterListRef::CharacterTokens(vec![
|
||||
CharacterToken::new("char_abc".to_string()),
|
||||
])),
|
||||
..base_builder()
|
||||
};
|
||||
assert!(build_kinovi_seedance_2p5(builder).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_media_lands_in_unhandled() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
reference_images: Some(ImageListRef::Urls(vec!["https://example.com/ref.jpg".to_string()])),
|
||||
reference_videos: Some(VideoListRef::Urls(vec!["https://example.com/vid.mp4".to_string()])),
|
||||
..base_builder()
|
||||
};
|
||||
let draft = unwrap_draft(build_kinovi_seedance_2p5(builder));
|
||||
let remaining = draft.unhandled_request_state.unwrap();
|
||||
assert!(remaining.reference_images.is_some());
|
||||
assert!(remaining.reference_videos.is_some());
|
||||
}
|
||||
}
|
||||
|
||||
mod aspect_ratio_and_resolution {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn direct_aspect_ratio_mappings() {
|
||||
let expected = [
|
||||
(RouterAspectRatio::WideSixteenByNine, KinoviAspectRatio::Landscape16x9),
|
||||
(RouterAspectRatio::WideTwentyOneByNine, KinoviAspectRatio::UltraWide21x9),
|
||||
(RouterAspectRatio::TallNineBySixteen, KinoviAspectRatio::Portrait9x16),
|
||||
(RouterAspectRatio::Square, KinoviAspectRatio::Square1x1),
|
||||
(RouterAspectRatio::WideFourByThree, KinoviAspectRatio::Standard4x3),
|
||||
(RouterAspectRatio::TallThreeByFour, KinoviAspectRatio::Portrait3x4),
|
||||
];
|
||||
for (input, output) in expected {
|
||||
let builder = GenerateVideoRequestBuilder { aspect_ratio: Some(input), ..base_builder() };
|
||||
let draft = unwrap_draft(build_kinovi_seedance_2p5(builder));
|
||||
assert!(
|
||||
std::mem::discriminant(&draft.aspect_ratio) == std::mem::discriminant(&output),
|
||||
"aspect ratio {input:?} mapped wrong",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolution_480p_and_720p() {
|
||||
let builder = GenerateVideoRequestBuilder { resolution: Some(RouterResolution::FourEightyP), ..base_builder() };
|
||||
assert!(matches!(unwrap_draft(build_kinovi_seedance_2p5(builder)).resolution, Some(KinoviOutputResolution::FourEightyP)));
|
||||
|
||||
let builder = GenerateVideoRequestBuilder { resolution: Some(RouterResolution::SevenTwentyP), ..base_builder() };
|
||||
assert!(matches!(unwrap_draft(build_kinovi_seedance_2p5(builder)).resolution, Some(KinoviOutputResolution::SevenTwentyP)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolution_1080p_downgrades_to_720p() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
resolution: Some(RouterResolution::TenEightyP),
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayLessDowngrade,
|
||||
..base_builder()
|
||||
};
|
||||
assert!(matches!(unwrap_draft(build_kinovi_seedance_2p5(builder)).resolution, Some(KinoviOutputResolution::SevenTwentyP)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolution_1080p_error_out() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
resolution: Some(RouterResolution::TenEightyP),
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
|
||||
..base_builder()
|
||||
};
|
||||
assert!(build_kinovi_seedance_2p5(builder).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
fn base_builder() -> GenerateVideoRequestBuilder {
|
||||
GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Seedance2p5,
|
||||
provider: RouterProvider::KinoviWeb,
|
||||
prompt: Some("a cat dancing".to_string()),
|
||||
duration_seconds: Some(5),
|
||||
video_batch_count: Some(1),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn unwrap_draft(result: Result<VideoGenerationDraftOrRequest, ArtcraftRouterError>) -> KinoviSeedance2p5DraftState {
|
||||
match result.expect("build should succeed") {
|
||||
VideoGenerationDraftOrRequest::Draft(
|
||||
VideoGenerationDraftRequest::KinoviSeedance2p5(draft)
|
||||
) => draft,
|
||||
_ => panic!("expected KinoviSeedance2p5 draft"),
|
||||
}
|
||||
}
|
||||
}
|
||||
+219
@@ -0,0 +1,219 @@
|
||||
use kinovi_web_client::generate::video::generate_seedance_2p5::{
|
||||
GenerateSeedance2p5Request, KinoviSeedance2p5Modality, KinoviSeedance2p5OutputResolution,
|
||||
MAX_BILLED_INPUT_SECONDS,
|
||||
};
|
||||
|
||||
use crate::api::video_list_ref::VideoListRef;
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p5::draft::KinoviSeedance2p5DraftState;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p5::request::KinoviSeedance2p5RequestState;
|
||||
|
||||
/// Seedance 2.5 pricing depends on the resolution, the output duration, and
|
||||
/// — 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). Input seconds are clamped to
|
||||
/// [`MAX_BILLED_INPUT_SECONDS`] — the model accepts at most 30 seconds of
|
||||
/// video.
|
||||
pub struct KinoviSeedance2p5CostState {
|
||||
pub resolution: Option<KinoviSeedance2p5OutputResolution>,
|
||||
pub duration_seconds: u8,
|
||||
pub has_video_references: bool,
|
||||
pub total_input_seconds: Option<u8>,
|
||||
}
|
||||
|
||||
impl KinoviSeedance2p5CostState {
|
||||
pub fn from_request(request: &KinoviSeedance2p5RequestState) -> Self {
|
||||
let has_video_references = matches!(
|
||||
&request.request.modality,
|
||||
KinoviSeedance2p5Modality::Reference { reference_video_urls: Some(urls), .. } if !urls.is_empty()
|
||||
);
|
||||
|
||||
Self {
|
||||
resolution: request.request.output_resolution,
|
||||
duration_seconds: request.request.duration_seconds,
|
||||
has_video_references,
|
||||
total_input_seconds: request.request.total_input_seconds,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_draft(draft: &KinoviSeedance2p5DraftState) -> Self {
|
||||
let has_video_references = draft.unhandled_request_state
|
||||
.as_ref()
|
||||
.and_then(|remaining| remaining.reference_videos.as_ref())
|
||||
.is_some_and(|videos| match videos {
|
||||
VideoListRef::MediaFileTokens(tokens) => !tokens.is_empty(),
|
||||
VideoListRef::Urls(urls) => !urls.is_empty(),
|
||||
});
|
||||
|
||||
Self {
|
||||
resolution: draft.resolution,
|
||||
duration_seconds: draft.duration_seconds,
|
||||
has_video_references,
|
||||
total_input_seconds: draft.total_input_seconds,
|
||||
}
|
||||
}
|
||||
|
||||
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, and the input seconds affect the price.
|
||||
let modality = if self.has_video_references {
|
||||
KinoviSeedance2p5Modality::Reference {
|
||||
aspect_ratio: None,
|
||||
reference_image_urls: None,
|
||||
reference_video_urls: Some(vec!["https://pricing-only.invalid/ref.mp4".to_string()]),
|
||||
reference_audio_urls: None,
|
||||
}
|
||||
} else {
|
||||
KinoviSeedance2p5Modality::Reference {
|
||||
aspect_ratio: None,
|
||||
reference_image_urls: None,
|
||||
reference_video_urls: None,
|
||||
reference_audio_urls: None,
|
||||
}
|
||||
};
|
||||
|
||||
// NB: The kinovi calculator clamps too; clamping here as well keeps this
|
||||
// state's math correct even if it's read directly.
|
||||
let total_input_seconds = self.total_input_seconds
|
||||
.map(|seconds| seconds.min(MAX_BILLED_INPUT_SECONDS));
|
||||
|
||||
let pricing_request = GenerateSeedance2p5Request {
|
||||
prompt: String::new(),
|
||||
modality,
|
||||
output_resolution: self.resolution,
|
||||
duration_seconds: self.duration_seconds,
|
||||
total_input_seconds,
|
||||
use_face_blur_hack: None,
|
||||
};
|
||||
|
||||
let costs = pricing_request.calculate_costs();
|
||||
// 2.5 bills whole credits, so the rounding below is exact; the USD cents
|
||||
// (the authoritative charge) are rounded up.
|
||||
let cost_in_credits = costs.kinovi_credits.round() as u64;
|
||||
let cost_in_usd_cents = costs.usd_cents_rounded_up;
|
||||
|
||||
VideoGenerationCostEstimate {
|
||||
cost_in_credits: Some(cost_in_credits),
|
||||
cost_in_usd_cents: Some(cost_in_usd_cents),
|
||||
is_free: false,
|
||||
is_unlimited: false,
|
||||
is_rate_limited: false,
|
||||
has_watermark: false,
|
||||
failures_are_refunded: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use kinovi_web_client::generate::video::generate_seedance_2p5::KinoviSeedance2p5OutputResolution as KinoviOutputResolution;
|
||||
|
||||
use super::*;
|
||||
|
||||
// ── Credits without video references (26/sec at 480p, 59/sec at 720p) ──
|
||||
|
||||
mod credits_without_video_references {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn credits_480p() {
|
||||
assert_eq!(credits(Some(KinoviOutputResolution::FourEightyP), 5, false, None), 130);
|
||||
assert_eq!(credits(Some(KinoviOutputResolution::FourEightyP), 10, false, None), 260);
|
||||
assert_eq!(credits(Some(KinoviOutputResolution::FourEightyP), 30, false, None), 780);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credits_720p() {
|
||||
assert_eq!(credits(Some(KinoviOutputResolution::SevenTwentyP), 5, false, None), 295);
|
||||
assert_eq!(credits(Some(KinoviOutputResolution::SevenTwentyP), 30, false, None), 1770);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_resolution_is_720p() {
|
||||
assert_eq!(credits(None, 10, false, None), credits(Some(KinoviOutputResolution::SevenTwentyP), 10, false, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_seconds_ignored_without_video_references() {
|
||||
assert_eq!(credits(Some(KinoviOutputResolution::FourEightyP), 10, false, Some(60)), 260);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Credits with video references (16/sec at 480p, 35/sec at 720p,
|
||||
// over output duration + input seconds) ──
|
||||
|
||||
mod credits_with_video_references {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fourteen_input_seconds_bill_forty_four() {
|
||||
assert_eq!(credits(Some(KinoviOutputResolution::FourEightyP), 30, true, Some(14)), 16 * 44);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_input_seconds_bill_output_duration_only() {
|
||||
assert_eq!(credits(Some(KinoviOutputResolution::FourEightyP), 10, true, None), 160);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_seconds_clamp_to_max_billed_input_seconds() {
|
||||
// 200 input seconds clamp to 30: 30s output + 30 = 60 billed seconds.
|
||||
assert_eq!(credits(Some(KinoviOutputResolution::FourEightyP), 30, true, Some(200)), 16 * 60);
|
||||
assert_eq!(
|
||||
credits(Some(KinoviOutputResolution::FourEightyP), 30, true, Some(200)),
|
||||
credits(Some(KinoviOutputResolution::FourEightyP), 30, true, Some(30)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── USD cents (rounded up) ──
|
||||
|
||||
#[test]
|
||||
fn usd_cents_are_rounded_up() {
|
||||
// 130 credits → 13000/243 = 53.4979 → 54¢.
|
||||
let estimate = cost_state(Some(KinoviOutputResolution::FourEightyP), 5, false, None).estimate_cost();
|
||||
assert_eq!(estimate.cost_in_usd_cents, Some(54));
|
||||
}
|
||||
|
||||
// ── Estimate flags ──
|
||||
|
||||
#[test]
|
||||
fn estimate_flags() {
|
||||
let estimate = cost_state(Some(KinoviOutputResolution::FourEightyP), 5, false, None).estimate_cost();
|
||||
assert!(!estimate.is_free);
|
||||
assert!(!estimate.is_unlimited);
|
||||
assert!(!estimate.is_rate_limited);
|
||||
assert!(!estimate.has_watermark);
|
||||
assert!(estimate.failures_are_refunded.is_none());
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
fn cost_state(
|
||||
resolution: Option<KinoviOutputResolution>,
|
||||
duration_seconds: u8,
|
||||
has_video_references: bool,
|
||||
total_input_seconds: Option<u8>,
|
||||
) -> KinoviSeedance2p5CostState {
|
||||
KinoviSeedance2p5CostState { resolution, duration_seconds, has_video_references, total_input_seconds }
|
||||
}
|
||||
|
||||
fn credits(
|
||||
resolution: Option<KinoviOutputResolution>,
|
||||
duration_seconds: u8,
|
||||
has_video_references: bool,
|
||||
total_input_seconds: Option<u8>,
|
||||
) -> u64 {
|
||||
cost_state(resolution, duration_seconds, has_video_references, total_input_seconds)
|
||||
.estimate_cost()
|
||||
.cost_in_credits
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
use kinovi_web_client::generate::video::generate_seedance_2p5::{
|
||||
GenerateSeedance2p5Request, KinoviSeedance2p5AspectRatio, KinoviSeedance2p5Modality,
|
||||
KinoviSeedance2p5OutputResolution,
|
||||
};
|
||||
|
||||
use crate::api::audio_list_ref::AudioListRef;
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::video_list_ref::VideoListRef;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::client_error::ClientError;
|
||||
use crate::generate::generate_video::providers::kinovi::resolve::{
|
||||
audio_list_ref_into_urls_or_tokens, image_list_ref_into_urls_or_tokens,
|
||||
resolve_and_upload_list, resolve_and_upload_single, video_list_ref_into_urls_or_tokens,
|
||||
};
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p5::request::KinoviSeedance2p5RequestState;
|
||||
use crate::generate::generate_video::video_generation_draft_context::VideoGenerationDraftContext;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct KinoviSeedance2p5DraftState {
|
||||
pub prompt: String,
|
||||
|
||||
/// Aspect ratio for the reference (text/reference-to-video) modality.
|
||||
/// Keyframe mode is always "Adaptive" and ignores this.
|
||||
pub aspect_ratio: KinoviSeedance2p5AspectRatio,
|
||||
|
||||
pub resolution: Option<KinoviSeedance2p5OutputResolution>,
|
||||
|
||||
pub duration_seconds: u8,
|
||||
|
||||
/// 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 unhandled_request_state: Option<KinoviSeedance2p5RemainingItems>,
|
||||
}
|
||||
|
||||
/// Media inputs not yet resolved/uploaded. The build step guarantees that
|
||||
/// keyframes and reference media are mutually exclusive.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct KinoviSeedance2p5RemainingItems {
|
||||
pub start_frame: Option<ImageRef>,
|
||||
pub end_frame: Option<ImageRef>,
|
||||
pub reference_images: Option<ImageListRef>,
|
||||
pub reference_videos: Option<VideoListRef>,
|
||||
pub reference_audio: Option<AudioListRef>,
|
||||
}
|
||||
|
||||
impl KinoviSeedance2p5DraftState {
|
||||
pub async fn to_request(
|
||||
&mut self,
|
||||
draft_context: &VideoGenerationDraftContext<'_>,
|
||||
) -> Result<KinoviSeedance2p5RequestState, ArtcraftRouterError> {
|
||||
let client = draft_context.get_kinovi_web_client_ref()?;
|
||||
let session = &client.session;
|
||||
|
||||
let mut start_frame_url = None;
|
||||
let mut end_frame_url = None;
|
||||
let mut reference_image_urls = None;
|
||||
let mut reference_video_urls = None;
|
||||
let mut reference_audio_urls = None;
|
||||
|
||||
if let Some(remaining) = self.unhandled_request_state.take() {
|
||||
let map = draft_context.media_file_to_artcraft_url_map;
|
||||
let predownloaded = draft_context.predownloaded_media_paths;
|
||||
|
||||
start_frame_url = resolve_and_upload_single(session, remaining.start_frame, map, predownloaded).await?;
|
||||
end_frame_url = resolve_and_upload_single(session, remaining.end_frame, map, predownloaded).await?;
|
||||
|
||||
reference_image_urls = resolve_and_upload_list(
|
||||
session, remaining.reference_images.map(image_list_ref_into_urls_or_tokens), map, predownloaded,
|
||||
).await?;
|
||||
|
||||
reference_video_urls = resolve_and_upload_list(
|
||||
session, remaining.reference_videos.map(video_list_ref_into_urls_or_tokens), map, predownloaded,
|
||||
).await?;
|
||||
|
||||
reference_audio_urls = resolve_and_upload_list(
|
||||
session, remaining.reference_audio.map(audio_list_ref_into_urls_or_tokens), map, predownloaded,
|
||||
).await?;
|
||||
}
|
||||
|
||||
let modality = match start_frame_url {
|
||||
Some(start_frame_url) => KinoviSeedance2p5Modality::Keyframe {
|
||||
start_frame_url,
|
||||
end_frame_url,
|
||||
},
|
||||
None => {
|
||||
// The build step rejects an end frame without a start frame, but a
|
||||
// failed resolution could recreate the situation — never send an
|
||||
// orphaned end frame as a reference request.
|
||||
if end_frame_url.is_some() {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "end_frame",
|
||||
value: "Seedance 2.5 requires a start frame when an end frame is set".to_string(),
|
||||
}));
|
||||
}
|
||||
KinoviSeedance2p5Modality::Reference {
|
||||
aspect_ratio: Some(self.aspect_ratio),
|
||||
reference_image_urls,
|
||||
reference_video_urls,
|
||||
reference_audio_urls,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let request = GenerateSeedance2p5Request {
|
||||
prompt: self.prompt.clone(),
|
||||
modality,
|
||||
output_resolution: self.resolution,
|
||||
duration_seconds: self.duration_seconds,
|
||||
total_input_seconds: self.total_input_seconds,
|
||||
use_face_blur_hack: None,
|
||||
};
|
||||
|
||||
Ok(KinoviSeedance2p5RequestState { request })
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
pub mod build;
|
||||
pub mod draft;
|
||||
pub mod request;
|
||||
pub mod cost;
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
use kinovi_web_client::generate::video::generate_seedance_2p5::{
|
||||
generate_seedance_2p5, GenerateSeedance2p5Args, GenerateSeedance2p5Request,
|
||||
};
|
||||
|
||||
use crate::client::router_kinovi_web_client::RouterKinoviWebClient;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::provider_error::ProviderError;
|
||||
use crate::generate::generate_video::generate_video_response::{GenerateVideoResponse, KinoviWebVideoResponsePayload};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct KinoviSeedance2p5RequestState {
|
||||
/// Final materialized request; ready to fire.
|
||||
pub request: GenerateSeedance2p5Request,
|
||||
}
|
||||
|
||||
impl KinoviSeedance2p5RequestState {
|
||||
pub async fn send(&self, client: &RouterKinoviWebClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
|
||||
let session = &client.session;
|
||||
|
||||
let args = GenerateSeedance2p5Args {
|
||||
session,
|
||||
host_override: None,
|
||||
request: self.request.clone(),
|
||||
};
|
||||
|
||||
let response = generate_seedance_2p5(args)
|
||||
.await
|
||||
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::KinoviWeb(err)))?;
|
||||
|
||||
Ok(GenerateVideoResponse::KinoviWeb(KinoviWebVideoResponsePayload {
|
||||
order_id: response.order_id,
|
||||
task_id: response.task_id,
|
||||
maybe_order_ids: response.order_ids,
|
||||
maybe_task_ids: response.task_ids,
|
||||
}))
|
||||
}
|
||||
}
|
||||
+4
-3
@@ -47,17 +47,18 @@ impl KinoviSeedance2p5PreviewDraftState {
|
||||
|
||||
if let Some(remaining) = self.unhandled_request_state.take() {
|
||||
let map = draft_context.media_file_to_artcraft_url_map;
|
||||
let predownloaded = draft_context.predownloaded_media_paths;
|
||||
|
||||
reference_image_urls = resolve_and_upload_list(
|
||||
session, remaining.reference_images.map(image_list_ref_into_urls_or_tokens), map,
|
||||
session, remaining.reference_images.map(image_list_ref_into_urls_or_tokens), map, predownloaded,
|
||||
).await?;
|
||||
|
||||
reference_video_urls = resolve_and_upload_list(
|
||||
session, remaining.reference_videos.map(video_list_ref_into_urls_or_tokens), map,
|
||||
session, remaining.reference_videos.map(video_list_ref_into_urls_or_tokens), map, predownloaded,
|
||||
).await?;
|
||||
|
||||
reference_audio_urls = resolve_and_upload_list(
|
||||
session, remaining.reference_audio.map(audio_list_ref_into_urls_or_tokens), map,
|
||||
session, remaining.reference_audio.map(audio_list_ref_into_urls_or_tokens), map, predownloaded,
|
||||
).await?;
|
||||
}
|
||||
|
||||
|
||||
+24
-2
@@ -1,3 +1,7 @@
|
||||
use std::path::Path;
|
||||
|
||||
use log::{info, warn};
|
||||
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::provider_error::ProviderError;
|
||||
use crate::utils::download_file::download_file;
|
||||
@@ -6,16 +10,34 @@ use kinovi_web_client::requests::prepare_file_upload::prepare_file_upload::{prep
|
||||
use kinovi_web_client::requests::upload_file::upload_file::{upload_file, UploadFileArgs};
|
||||
use url_utils::extension::extract_extension_from_url::{extract_extension_from_url_str, ExtractExtensions};
|
||||
|
||||
/// Downloads a file from a source URL and re-uploads it to kinovi_web CDN.
|
||||
/// Re-uploads a file to the kinovi_web CDN, reading it from
|
||||
/// `maybe_local_path` when the caller already downloaded it (e.g. reference
|
||||
/// videos probed for billing), or downloading it from the source URL
|
||||
/// otherwise.
|
||||
pub(crate) async fn upload_to_kinovi_web(
|
||||
session: &KinoviWebSession,
|
||||
source_url: &str,
|
||||
maybe_local_path: Option<&Path>,
|
||||
) -> Result<String, ArtcraftRouterError> {
|
||||
let extension = extract_extension_from_url_str(source_url, &ExtractExtensions::All)
|
||||
.map(|ext| ext.without_period().to_string())
|
||||
.unwrap_or_else(|| "jpg".to_string());
|
||||
|
||||
let file_bytes = download_file(source_url).await?;
|
||||
let file_bytes = match maybe_local_path {
|
||||
Some(local_path) => match std::fs::read(local_path) {
|
||||
Ok(bytes) => {
|
||||
info!("Reusing predownloaded file for kinovi upload: {}", source_url);
|
||||
bytes
|
||||
}
|
||||
Err(err) => {
|
||||
// The file should exist; fall back to downloading rather than fail.
|
||||
warn!("Failed to read predownloaded file {:?} ({:?}); re-downloading {}",
|
||||
local_path, err, source_url);
|
||||
download_file(source_url).await?
|
||||
}
|
||||
},
|
||||
None => download_file(source_url).await?,
|
||||
};
|
||||
|
||||
let prepare_response = prepare_file_upload(PrepareFileUploadArgs {
|
||||
session,
|
||||
|
||||
+9
@@ -9,6 +9,8 @@ use crate::generate::generate_video::providers::kinovi::seedance_2p0_fast::cost:
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p0_fast::draft::KinoviSeedance2p0FastDraftState;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p0_mini::cost::KinoviSeedance2p0MiniCostState;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p0_mini::draft::KinoviSeedance2p0MiniDraftState;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p5::cost::KinoviSeedance2p5CostState;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p5::draft::KinoviSeedance2p5DraftState;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p5_preview::cost::KinoviSeedance2p5PreviewCostState;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p5_preview::draft::KinoviSeedance2p5PreviewDraftState;
|
||||
use crate::generate::generate_video::video_generation_draft_context::VideoGenerationDraftContext;
|
||||
@@ -24,6 +26,7 @@ pub enum VideoGenerationDraftRequest {
|
||||
KinoviSeedance2p0Fast(KinoviSeedance2p0FastDraftState),
|
||||
KinoviSeedance2p0Mini(KinoviSeedance2p0MiniDraftState),
|
||||
KinoviSeedance2p5Preview(KinoviSeedance2p5PreviewDraftState),
|
||||
KinoviSeedance2p5(KinoviSeedance2p5DraftState),
|
||||
}
|
||||
|
||||
impl VideoGenerationDraftRequest {
|
||||
@@ -35,6 +38,7 @@ impl VideoGenerationDraftRequest {
|
||||
Self::KinoviSeedance2p0Fast(_) => RouterProvider::KinoviWeb,
|
||||
Self::KinoviSeedance2p0Mini(_) => RouterProvider::KinoviWeb,
|
||||
Self::KinoviSeedance2p5Preview(_) => RouterProvider::KinoviWeb,
|
||||
Self::KinoviSeedance2p5(_) => RouterProvider::KinoviWeb,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +50,7 @@ impl VideoGenerationDraftRequest {
|
||||
VideoGenerationDraftRequest::KinoviSeedance2p0Fast(draft) => Ok(KinoviSeedance2p0FastCostState::from_draft(draft).estimate_cost()),
|
||||
VideoGenerationDraftRequest::KinoviSeedance2p0Mini(draft) => Ok(KinoviSeedance2p0MiniCostState::from_draft(draft).estimate_cost()),
|
||||
VideoGenerationDraftRequest::KinoviSeedance2p5Preview(draft) => Ok(KinoviSeedance2p5PreviewCostState::from_draft(draft).estimate_cost()),
|
||||
VideoGenerationDraftRequest::KinoviSeedance2p5(draft) => Ok(KinoviSeedance2p5CostState::from_draft(draft).estimate_cost()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +78,10 @@ impl VideoGenerationDraftRequest {
|
||||
let result = draft.to_request(&draft_context).await?;
|
||||
Ok(VideoGenerationRequest::KinoviSeedance2p5Preview(result))
|
||||
},
|
||||
VideoGenerationDraftRequest::KinoviSeedance2p5(mut draft) => {
|
||||
let result = draft.to_request(&draft_context).await?;
|
||||
Ok(VideoGenerationRequest::KinoviSeedance2p5(result))
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+8
@@ -4,6 +4,7 @@ use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::client_error::ClientError;
|
||||
use std::collections::HashMap;
|
||||
use std::fmt::Debug;
|
||||
use std::path::PathBuf;
|
||||
use tokens::tokens::characters::CharacterToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
@@ -19,6 +20,12 @@ pub struct VideoGenerationDraftContext<'a> {
|
||||
/// Optional context: a map of Character Tokens to their respective Kinovi IDs
|
||||
/// Only necessary if using Kinovi characters
|
||||
pub character_token_to_kinovi_id_map: Option<&'a HashMap<CharacterToken, String>>,
|
||||
|
||||
/// Optional context: source URL → local file path for media the caller has
|
||||
/// already downloaded (e.g. reference videos probed for billing). Uploads
|
||||
/// read these files instead of downloading the same bytes again. The
|
||||
/// caller must keep the files alive until the upload completes.
|
||||
pub predownloaded_media_paths: Option<&'a HashMap<String, PathBuf>>,
|
||||
}
|
||||
|
||||
impl <'a> VideoGenerationDraftContext<'a> {
|
||||
@@ -45,6 +52,7 @@ impl Debug for VideoGenerationDraftContext<'_> {
|
||||
.field("client", &self.client.is_some())
|
||||
.field("media_file_to_artcraft_url_map", &self.media_file_to_artcraft_url_map)
|
||||
.field("character_token_to_kinovi_id_map", &self.character_token_to_kinovi_id_map)
|
||||
.field("predownloaded_media_paths", &self.predownloaded_media_paths)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
+18
@@ -55,6 +55,8 @@ use crate::generate::generate_video::providers::artcraft::seedance_2p0_bpu_fast:
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bpu_fast::request::ArtcraftSeedance2p0BytePlusUltraFastRequestState;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0_mini::cost::ArtcraftSeedance2p0MiniCostState;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0_mini::request::ArtcraftSeedance2p0MiniRequestState;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p5::cost::ArtcraftSeedance2p5CostState;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p5::request::ArtcraftSeedance2p5RequestState;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p5_preview::cost::ArtcraftSeedance2p5PreviewCostState;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p5_preview::request::ArtcraftSeedance2p5PreviewRequestState;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bp_mini::cost::ArtcraftSeedance2p0BytePlusMiniCostState;
|
||||
@@ -141,6 +143,8 @@ use crate::generate::generate_video::providers::kinovi::seedance_2p0_fast::cost:
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p0_fast::request::KinoviSeedance2p0FastRequestState;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p0_mini::cost::KinoviSeedance2p0MiniCostState;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p0_mini::request::KinoviSeedance2p0MiniRequestState;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p5::cost::KinoviSeedance2p5CostState;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p5::request::KinoviSeedance2p5RequestState;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p5_preview::cost::KinoviSeedance2p5PreviewCostState;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p5_preview::request::KinoviSeedance2p5PreviewRequestState;
|
||||
|
||||
@@ -173,6 +177,7 @@ pub enum VideoGenerationRequest {
|
||||
ArtcraftSeedance2p0BytePlusUltraFast(ArtcraftSeedance2p0BytePlusUltraFastRequestState),
|
||||
ArtcraftSeedance2p0Mini(ArtcraftSeedance2p0MiniRequestState),
|
||||
ArtcraftSeedance2p5Preview(ArtcraftSeedance2p5PreviewRequestState),
|
||||
ArtcraftSeedance2p5(ArtcraftSeedance2p5RequestState),
|
||||
ArtcraftSeedance2p0BytePlusMini(ArtcraftSeedance2p0BytePlusMiniRequestState),
|
||||
ArtcraftSeedance2p0BytePlusUltraMini(ArtcraftSeedance2p0BytePlusUltraMiniRequestState),
|
||||
ArtcraftSora2(ArtcraftSora2RequestState),
|
||||
@@ -216,6 +221,7 @@ pub enum VideoGenerationRequest {
|
||||
KinoviSeedance2p0Fast(KinoviSeedance2p0FastRequestState),
|
||||
KinoviSeedance2p0Mini(KinoviSeedance2p0MiniRequestState),
|
||||
KinoviSeedance2p5Preview(KinoviSeedance2p5PreviewRequestState),
|
||||
KinoviSeedance2p5(KinoviSeedance2p5RequestState),
|
||||
}
|
||||
|
||||
impl VideoGenerationRequest {
|
||||
@@ -249,6 +255,7 @@ impl VideoGenerationRequest {
|
||||
Self::ArtcraftSeedance2p0BytePlusUltraFast(_) => RouterProvider::Artcraft,
|
||||
Self::ArtcraftSeedance2p0Mini(_) => RouterProvider::Artcraft,
|
||||
Self::ArtcraftSeedance2p5Preview(_) => RouterProvider::Artcraft,
|
||||
Self::ArtcraftSeedance2p5(_) => RouterProvider::Artcraft,
|
||||
Self::ArtcraftSeedance2p0BytePlusMini(_) => RouterProvider::Artcraft,
|
||||
Self::ArtcraftSeedance2p0BytePlusUltraMini(_) => RouterProvider::Artcraft,
|
||||
Self::ArtcraftSora2(_) => RouterProvider::Artcraft,
|
||||
@@ -292,6 +299,7 @@ impl VideoGenerationRequest {
|
||||
Self::KinoviSeedance2p0Fast(_) => RouterProvider::KinoviWeb,
|
||||
Self::KinoviSeedance2p0Mini(_) => RouterProvider::KinoviWeb,
|
||||
Self::KinoviSeedance2p5Preview(_) => RouterProvider::KinoviWeb,
|
||||
Self::KinoviSeedance2p5(_) => RouterProvider::KinoviWeb,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -325,6 +333,7 @@ impl VideoGenerationRequest {
|
||||
VideoGenerationRequest::ArtcraftSeedance2p0BytePlusUltraFast(request) => Ok(ArtcraftSeedance2p0BytePlusUltraFastCostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::ArtcraftSeedance2p0Mini(request) => Ok(ArtcraftSeedance2p0MiniCostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::ArtcraftSeedance2p5Preview(request) => Ok(ArtcraftSeedance2p5PreviewCostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::ArtcraftSeedance2p5(request) => Ok(ArtcraftSeedance2p5CostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::ArtcraftSeedance2p0BytePlusMini(request) => Ok(ArtcraftSeedance2p0BytePlusMiniCostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::ArtcraftSeedance2p0BytePlusUltraMini(request) => Ok(ArtcraftSeedance2p0BytePlusUltraMiniCostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::ArtcraftSora2(request) => Ok(ArtcraftSora2CostState::from_request(request).estimate_cost()),
|
||||
@@ -368,6 +377,7 @@ impl VideoGenerationRequest {
|
||||
VideoGenerationRequest::KinoviSeedance2p0Fast(request) => Ok(KinoviSeedance2p0FastCostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::KinoviSeedance2p0Mini(request) => Ok(KinoviSeedance2p0MiniCostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::KinoviSeedance2p5Preview(request) => Ok(KinoviSeedance2p5PreviewCostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::KinoviSeedance2p5(request) => Ok(KinoviSeedance2p5CostState::from_request(request).estimate_cost()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -483,6 +493,10 @@ impl VideoGenerationRequest {
|
||||
let client_ref = client.get_artcraft_client_ref()?;
|
||||
request.send(client_ref).await
|
||||
},
|
||||
VideoGenerationRequest::ArtcraftSeedance2p5(request) => {
|
||||
let client_ref = client.get_artcraft_client_ref()?;
|
||||
request.send(client_ref).await
|
||||
},
|
||||
VideoGenerationRequest::ArtcraftSeedance2p0BytePlusMini(request) => {
|
||||
let client_ref = client.get_artcraft_client_ref()?;
|
||||
request.send(client_ref).await
|
||||
@@ -655,6 +669,10 @@ impl VideoGenerationRequest {
|
||||
let client_ref = client.get_kinovi_web_client_ref()?;
|
||||
request.send(client_ref).await
|
||||
},
|
||||
VideoGenerationRequest::KinoviSeedance2p5(request) => {
|
||||
let client_ref = client.get_kinovi_web_client_ref()?;
|
||||
request.send(client_ref).await
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,6 +355,7 @@ pub fn base_video_request() -> GenerateVideoRequestBuilder {
|
||||
duration_seconds: None,
|
||||
video_batch_count: None,
|
||||
generate_audio: None,
|
||||
total_reference_video_input_seconds: None,
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
|
||||
idempotency_token: None,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,757 @@
|
||||
use crate::creds::kinovi_web_session::KinoviWebSession;
|
||||
use crate::error::kinovi_web_error::KinoviWebError;
|
||||
use crate::cost::kinovi_fractional_generation_cost::KinoviFractionalGenerationCost;
|
||||
use crate::requests::kinovi_host::KinoviHost;
|
||||
use crate::requests::workflow_run_task::workflow_run_task::{
|
||||
workflow_run_task, KinoviAspectRatioRaw, KinoviBatchCountRaw, KinoviModelTypeRaw,
|
||||
KinoviOutputResolutionRaw, WorkflowRunTaskArgs, WorkflowRunTaskRequest,
|
||||
};
|
||||
|
||||
// ── Constants ──
|
||||
|
||||
/// Seedance 2.5 supports at most 30 seconds of video: reference-video input
|
||||
/// seconds beyond this are clamped for billing.
|
||||
pub const MAX_BILLED_INPUT_SECONDS: u8 = 30;
|
||||
|
||||
// ── Args ──
|
||||
|
||||
pub struct GenerateSeedance2p5Args<'a> {
|
||||
pub request: GenerateSeedance2p5Request,
|
||||
pub session: &'a KinoviWebSession,
|
||||
pub host_override: Option<KinoviHost>,
|
||||
}
|
||||
|
||||
// ── Request ──
|
||||
|
||||
/// Seedance 2.5 supports keyframe (image-to-video) and reference
|
||||
/// (text-to-video / reference-to-video) modalities, and 480p/720p output.
|
||||
///
|
||||
/// On the wire it reuses the 2.5 *Preview* business type
|
||||
/// (`seedance25-preview-video-generation`) with model `seedance2-5`;
|
||||
/// text-to-video is sent as mode "reference".
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GenerateSeedance2p5Request {
|
||||
pub prompt: String,
|
||||
|
||||
pub modality: KinoviSeedance2p5Modality,
|
||||
|
||||
pub output_resolution: Option<KinoviSeedance2p5OutputResolution>,
|
||||
|
||||
pub duration_seconds: u8,
|
||||
|
||||
/// CALCULATION-ONLY (never sent on the wire): the total seconds of
|
||||
/// reference video input, summed across all reference videos. When video
|
||||
/// references are attached, Kinovi bills the input seconds on top of the
|
||||
/// output duration — see the pricing notes on [`calculate_costs`].
|
||||
///
|
||||
/// [`calculate_costs`]: GenerateSeedance2p5Request::calculate_costs
|
||||
pub total_input_seconds: Option<u8>,
|
||||
|
||||
/// Controls `faceBlurMode`: true sends "on"; false or None sends "off"
|
||||
/// (the model always sends the field, like 2.5 Preview).
|
||||
pub use_face_blur_hack: Option<bool>,
|
||||
}
|
||||
|
||||
// ── Modality ──
|
||||
|
||||
/// The generation modality. The aspect ratio choice lives here because the
|
||||
/// two modalities support disjoint aspect ratio sets.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum KinoviSeedance2p5Modality {
|
||||
/// Image-to-video (start frame + optional end frame).
|
||||
///
|
||||
/// The aspect ratio is always "Adaptive" — the only choice the API allows
|
||||
/// in this modality, expressed on the wire by omitting the `aspectRatio`
|
||||
/// field entirely — so there is nothing to configure.
|
||||
Keyframe {
|
||||
start_frame_url: String,
|
||||
end_frame_url: Option<String>,
|
||||
},
|
||||
|
||||
/// Text-to-video / reference-to-video. "Adaptive" is NOT supported here;
|
||||
/// `None` defaults to 16:9.
|
||||
Reference {
|
||||
aspect_ratio: Option<KinoviSeedance2p5AspectRatio>,
|
||||
/// Reference images, referenced in prompts as @image1, @image2, etc.
|
||||
reference_image_urls: Option<Vec<String>>,
|
||||
/// Reference videos, referenced in prompts as @video1, @video2, etc.
|
||||
reference_video_urls: Option<Vec<String>>,
|
||||
/// Reference audio, referenced in prompts as @audio1, @audio2, etc.
|
||||
reference_audio_urls: Option<Vec<String>>,
|
||||
},
|
||||
}
|
||||
|
||||
// ── Enums ──
|
||||
|
||||
/// Aspect ratios for the reference (text/reference-to-video) modality.
|
||||
/// There is deliberately no "Adaptive" variant: Adaptive is keyframe-only
|
||||
/// (and there it is the only choice).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum KinoviSeedance2p5AspectRatio {
|
||||
Landscape16x9,
|
||||
UltraWide21x9,
|
||||
Portrait9x16,
|
||||
Square1x1,
|
||||
Standard4x3,
|
||||
Portrait3x4,
|
||||
}
|
||||
|
||||
/// Output resolution. Seedance 2.5 supports only 480p and 720p for now.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum KinoviSeedance2p5OutputResolution {
|
||||
FourEightyP,
|
||||
SevenTwentyP,
|
||||
}
|
||||
|
||||
// ── Pricing ──
|
||||
//
|
||||
// Seedance 2.5 credit pricing (whole credits per billed second):
|
||||
//
|
||||
// | Resolution | No video refs | With video refs |
|
||||
// |------------|---------------|-----------------|
|
||||
// | 480p | 26 | 16 |
|
||||
// | 720p | 59 | 35 |
|
||||
//
|
||||
// Without video references (text-to-video, keyframe, and reference mode with
|
||||
// only image/audio references), billed seconds = output duration.
|
||||
//
|
||||
// With video references, the rate drops but the billed seconds are the
|
||||
// output duration PLUS the total seconds of reference video input
|
||||
// (`total_input_seconds`, summed across all reference videos and clamped to
|
||||
// [`MAX_BILLED_INPUT_SECONDS`]). E.g. a 30s output with a 10s video
|
||||
// reference bills as 40 seconds; a 30s output with two 7s references (14s
|
||||
// total input) bills as 44 seconds.
|
||||
//
|
||||
// Default resolution (None) is 720p.
|
||||
// Credit package: 525,000 credits for $2,159.0909 (~243 credits/$1).
|
||||
|
||||
impl GenerateSeedance2p5Request {
|
||||
/// Calculate the cost of this generation request, in Kinovi credits and
|
||||
/// USD cents.
|
||||
pub fn calculate_costs(&self) -> KinoviFractionalGenerationCost {
|
||||
let has_video_references = matches!(
|
||||
&self.modality,
|
||||
KinoviSeedance2p5Modality::Reference { reference_video_urls: Some(urls), .. } if !urls.is_empty()
|
||||
);
|
||||
|
||||
let (credits_per_second, billed_seconds) = if has_video_references {
|
||||
let rate = match self.output_resolution {
|
||||
Some(KinoviSeedance2p5OutputResolution::FourEightyP) => 16u64,
|
||||
Some(KinoviSeedance2p5OutputResolution::SevenTwentyP) | None => 35u64,
|
||||
};
|
||||
let input_seconds = self.total_input_seconds
|
||||
.unwrap_or(MAX_BILLED_INPUT_SECONDS)
|
||||
.min(MAX_BILLED_INPUT_SECONDS);
|
||||
let seconds = u64::from(self.duration_seconds) + u64::from(input_seconds);
|
||||
(rate, seconds)
|
||||
} else {
|
||||
let rate = match self.output_resolution {
|
||||
Some(KinoviSeedance2p5OutputResolution::FourEightyP) => 26u64,
|
||||
Some(KinoviSeedance2p5OutputResolution::SevenTwentyP) | None => 59u64,
|
||||
};
|
||||
(rate, u64::from(self.duration_seconds))
|
||||
};
|
||||
|
||||
let total_credits = credits_per_second * billed_seconds;
|
||||
KinoviFractionalGenerationCost::from_kinovi_credits(total_credits as f64)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Response ──
|
||||
|
||||
pub struct GenerateSeedance2p5Response {
|
||||
pub task_id: String,
|
||||
pub order_id: String,
|
||||
pub task_ids: Option<Vec<String>>,
|
||||
pub order_ids: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
// ── Entry point ──
|
||||
|
||||
pub async fn generate_seedance_2p5(
|
||||
args: GenerateSeedance2p5Args<'_>,
|
||||
) -> Result<GenerateSeedance2p5Response, KinoviWebError> {
|
||||
let raw_response = workflow_run_task(WorkflowRunTaskArgs {
|
||||
request: to_raw_request(args.request),
|
||||
session: args.session,
|
||||
host_override: args.host_override,
|
||||
}).await?;
|
||||
|
||||
Ok(GenerateSeedance2p5Response {
|
||||
task_id: raw_response.task_id,
|
||||
order_id: raw_response.order_id,
|
||||
task_ids: raw_response.task_ids,
|
||||
order_ids: raw_response.order_ids,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Mapping helpers ──
|
||||
|
||||
fn to_raw_request(req: GenerateSeedance2p5Request) -> WorkflowRunTaskRequest {
|
||||
let (
|
||||
aspect_ratio,
|
||||
start_frame_url,
|
||||
end_frame_url,
|
||||
reference_image_urls,
|
||||
reference_video_urls,
|
||||
reference_audio_urls,
|
||||
) = match req.modality {
|
||||
KinoviSeedance2p5Modality::Keyframe { start_frame_url, end_frame_url } => (
|
||||
// Keyframe is always "Adaptive"; the raw layer omits the aspect ratio
|
||||
// for 2.5 keyframe requests, so this placeholder never hits the wire.
|
||||
KinoviAspectRatioRaw::Landscape16x9,
|
||||
Some(start_frame_url),
|
||||
end_frame_url,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
),
|
||||
KinoviSeedance2p5Modality::Reference {
|
||||
aspect_ratio,
|
||||
reference_image_urls,
|
||||
reference_video_urls,
|
||||
reference_audio_urls,
|
||||
} => (
|
||||
map_aspect_ratio(aspect_ratio),
|
||||
None,
|
||||
None,
|
||||
reference_image_urls,
|
||||
reference_video_urls,
|
||||
reference_audio_urls,
|
||||
),
|
||||
};
|
||||
|
||||
WorkflowRunTaskRequest {
|
||||
model_type: KinoviModelTypeRaw::Seedance2p5,
|
||||
prompt: req.prompt,
|
||||
aspect_ratio,
|
||||
output_resolution: req.output_resolution.map(map_output_resolution),
|
||||
duration_seconds: req.duration_seconds,
|
||||
batch_count: KinoviBatchCountRaw::One,
|
||||
start_frame_url,
|
||||
end_frame_url,
|
||||
reference_image_urls,
|
||||
reference_video_urls,
|
||||
reference_audio_urls,
|
||||
character_ids: None,
|
||||
use_face_blur_hack: req.use_face_blur_hack,
|
||||
bitrate: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_aspect_ratio(ar: Option<KinoviSeedance2p5AspectRatio>) -> KinoviAspectRatioRaw {
|
||||
match ar {
|
||||
Some(KinoviSeedance2p5AspectRatio::Landscape16x9) => KinoviAspectRatioRaw::Landscape16x9,
|
||||
Some(KinoviSeedance2p5AspectRatio::UltraWide21x9) => KinoviAspectRatioRaw::UltraWide21x9,
|
||||
Some(KinoviSeedance2p5AspectRatio::Portrait9x16) => KinoviAspectRatioRaw::Portrait9x16,
|
||||
Some(KinoviSeedance2p5AspectRatio::Square1x1) => KinoviAspectRatioRaw::Square1x1,
|
||||
Some(KinoviSeedance2p5AspectRatio::Standard4x3) => KinoviAspectRatioRaw::Landscape4x3,
|
||||
Some(KinoviSeedance2p5AspectRatio::Portrait3x4) => KinoviAspectRatioRaw::Portrait3x4,
|
||||
None => KinoviAspectRatioRaw::Landscape16x9,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_output_resolution(res: KinoviSeedance2p5OutputResolution) -> KinoviOutputResolutionRaw {
|
||||
match res {
|
||||
KinoviSeedance2p5OutputResolution::FourEightyP => KinoviOutputResolutionRaw::FourEightyP,
|
||||
KinoviSeedance2p5OutputResolution::SevenTwentyP => KinoviOutputResolutionRaw::SevenTwentyP,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ──
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::creds::kinovi_web_session::KinoviWebSession;
|
||||
use crate::test_utils::get_test_cookies::get_test_cookies;
|
||||
use crate::test_utils::setup_test_logging::setup_test_logging;
|
||||
use errors::AnyhowResult;
|
||||
use log::LevelFilter;
|
||||
|
||||
mod pricing_tests {
|
||||
use super::*;
|
||||
|
||||
const FLOAT_TOLERANCE: f64 = 1e-9;
|
||||
|
||||
// ── No video references: output-duration billing ──
|
||||
//
|
||||
// 480p = 26 credits/sec, 720p = 59 credits/sec.
|
||||
|
||||
mod without_video_references {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn table_480p() {
|
||||
assert_eq!(t2v_480(1).calculate_costs().kinovi_credits, 26.0);
|
||||
assert_eq!(t2v_480(5).calculate_costs().kinovi_credits, 130.0);
|
||||
assert_eq!(t2v_480(10).calculate_costs().kinovi_credits, 260.0);
|
||||
assert_eq!(t2v_480(15).calculate_costs().kinovi_credits, 390.0);
|
||||
assert_eq!(t2v_480(30).calculate_costs().kinovi_credits, 780.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_720p() {
|
||||
assert_eq!(t2v_720(1).calculate_costs().kinovi_credits, 59.0);
|
||||
assert_eq!(t2v_720(5).calculate_costs().kinovi_credits, 295.0);
|
||||
assert_eq!(t2v_720(10).calculate_costs().kinovi_credits, 590.0);
|
||||
assert_eq!(t2v_720(15).calculate_costs().kinovi_credits, 885.0);
|
||||
assert_eq!(t2v_720(30).calculate_costs().kinovi_credits, 1770.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_resolution_is_720p() {
|
||||
let default = t2v_720(10).calculate_costs();
|
||||
let mut explicit = t2v_720(10);
|
||||
explicit.output_resolution = Some(KinoviSeedance2p5OutputResolution::SevenTwentyP);
|
||||
assert_eq!(default, explicit.calculate_costs());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyframe_modality_uses_no_reference_rates() {
|
||||
let mut request = keyframe_request(10);
|
||||
request.output_resolution = Some(KinoviSeedance2p5OutputResolution::FourEightyP);
|
||||
assert_eq!(request.calculate_costs().kinovi_credits, 260.0);
|
||||
|
||||
let request_720 = keyframe_request(10);
|
||||
assert_eq!(request_720.calculate_costs().kinovi_credits, 590.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_and_audio_references_do_not_change_the_rate() {
|
||||
let mut request = t2v_480(10);
|
||||
set_reference_urls(
|
||||
&mut request,
|
||||
Some(vec!["https://example.com/a.png".to_string(), "https://example.com/b.jpg".to_string()]),
|
||||
None,
|
||||
Some(vec!["https://example.com/ref.wav".to_string()]),
|
||||
);
|
||||
assert_eq!(request.calculate_costs().kinovi_credits, 260.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_video_reference_list_counts_as_no_references() {
|
||||
let mut request = t2v_480(10);
|
||||
set_reference_urls(&mut request, None, Some(Vec::new()), None);
|
||||
assert_eq!(request.calculate_costs().kinovi_credits, 260.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn total_input_seconds_is_ignored_without_video_references() {
|
||||
let mut request = t2v_480(10);
|
||||
request.total_input_seconds = Some(60);
|
||||
assert_eq!(request.calculate_costs().kinovi_credits, 260.0);
|
||||
}
|
||||
}
|
||||
|
||||
// ── With video references: input seconds are billed too ──
|
||||
//
|
||||
// 480p = 16 credits/sec, 720p = 35 credits/sec, over
|
||||
// (output duration + total_input_seconds) billed seconds.
|
||||
|
||||
mod with_video_references {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn thirty_second_output_with_ten_second_reference_bills_forty_seconds() {
|
||||
// The canonical example: 30s output + 10s of input video = 40 billed seconds.
|
||||
let request = video_ref_480(30, Some(10));
|
||||
assert_eq!(request.calculate_costs().kinovi_credits, (16 * 40) as f64);
|
||||
|
||||
let request = video_ref_720(30, Some(10));
|
||||
assert_eq!(request.calculate_costs().kinovi_credits, (35 * 40) as f64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_seven_second_references_bill_forty_four_seconds() {
|
||||
// Two 7s references (14s total input) + 30s output = 44 billed seconds.
|
||||
let mut request = video_ref_480(30, Some(14));
|
||||
set_reference_urls(
|
||||
&mut request,
|
||||
None,
|
||||
Some(vec![
|
||||
"https://example.com/ref1.mp4".to_string(),
|
||||
"https://example.com/ref2.mp4".to_string(),
|
||||
]),
|
||||
None,
|
||||
);
|
||||
assert_eq!(request.calculate_costs().kinovi_credits, (16 * 44) as f64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_480p_with_ten_input_seconds() {
|
||||
assert_eq!(video_ref_480(5, Some(10)).calculate_costs().kinovi_credits, (16 * 15) as f64);
|
||||
assert_eq!(video_ref_480(10, Some(10)).calculate_costs().kinovi_credits, (16 * 20) as f64);
|
||||
assert_eq!(video_ref_480(30, Some(10)).calculate_costs().kinovi_credits, (16 * 40) as f64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_720p_with_ten_input_seconds() {
|
||||
assert_eq!(video_ref_720(5, Some(10)).calculate_costs().kinovi_credits, (35 * 15) as f64);
|
||||
assert_eq!(video_ref_720(10, Some(10)).calculate_costs().kinovi_credits, (35 * 20) as f64);
|
||||
assert_eq!(video_ref_720(30, Some(10)).calculate_costs().kinovi_credits, (35 * 40) as f64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_total_input_seconds_bills_output_duration_only() {
|
||||
assert_eq!(video_ref_480(10, None).calculate_costs().kinovi_credits, 160.0);
|
||||
assert_eq!(video_ref_720(10, None).calculate_costs().kinovi_credits, 350.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_seconds_clamp_to_max_billed_input_seconds() {
|
||||
// 200 input seconds clamp to 30: 30s output + 30 = 60 billed seconds.
|
||||
assert_eq!(video_ref_480(30, Some(200)).calculate_costs().kinovi_credits, (16 * 60) as f64);
|
||||
assert_eq!(
|
||||
video_ref_480(30, Some(200)).calculate_costs(),
|
||||
video_ref_480(30, Some(MAX_BILLED_INPUT_SECONDS)).calculate_costs(),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_references_still_use_video_reference_rates() {
|
||||
let mut request = video_ref_480(10, Some(5));
|
||||
set_reference_urls(
|
||||
&mut request,
|
||||
Some(vec!["https://example.com/a.png".to_string()]),
|
||||
Some(vec!["https://example.com/ref.mp4".to_string()]),
|
||||
Some(vec!["https://example.com/ref.wav".to_string()]),
|
||||
);
|
||||
assert_eq!(request.calculate_costs().kinovi_credits, (16 * 15) as f64);
|
||||
}
|
||||
}
|
||||
|
||||
// ── USD cents conversion ──
|
||||
//
|
||||
// usd_cents = credits × 100 / 243, computed on integer hundredths.
|
||||
|
||||
mod usd_cents {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cents_480p_5s() {
|
||||
// 130 credits; 13000/243 = 53.4979 cents.
|
||||
let cost = t2v_480(5).calculate_costs();
|
||||
assert_eq!(cost.usd_cents_rounded_up, 54);
|
||||
assert_eq!(cost.usd_cents_rounded_down, 53);
|
||||
assert!((cost.usd_cents_fractional - (13000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cents_720p_30s() {
|
||||
// 1770 credits; 177000/243 = 728.3951 cents.
|
||||
let cost = t2v_720(30).calculate_costs();
|
||||
assert_eq!(cost.usd_cents_rounded_up, 729);
|
||||
assert_eq!(cost.usd_cents_rounded_down, 728);
|
||||
assert!((cost.usd_cents_fractional - (177000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cents_720p_video_ref_40_billed_seconds() {
|
||||
// 35 × 40 = 1400 credits; 140000/243 = 576.1317 cents.
|
||||
let cost = video_ref_720(30, Some(10)).calculate_costs();
|
||||
assert_eq!(cost.usd_cents_rounded_up, 577);
|
||||
assert_eq!(cost.usd_cents_rounded_down, 576);
|
||||
assert!((cost.usd_cents_fractional - (140000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Relative pricing ──
|
||||
|
||||
mod relative_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn video_reference_rate_is_cheaper_per_second() {
|
||||
// 16 < 26 and 35 < 59: the with-references rate is lower per billed
|
||||
// second (the input seconds are where the money goes).
|
||||
assert!(video_ref_480(10, None).calculate_costs().kinovi_credits
|
||||
< t2v_480(10).calculate_costs().kinovi_credits);
|
||||
assert!(video_ref_720(10, None).calculate_costs().kinovi_credits
|
||||
< t2v_720(10).calculate_costs().kinovi_credits);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_scales_linearly_with_duration() {
|
||||
for duration in [1u8, 5, 10, 30] {
|
||||
assert_eq!(
|
||||
t2v_480(duration).calculate_costs().kinovi_credits,
|
||||
(26 * u64::from(duration)) as f64,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Aspect ratio doesn't affect cost ──
|
||||
|
||||
#[test]
|
||||
fn aspect_ratio_does_not_affect_credits() {
|
||||
let baseline = t2v_720(10).calculate_costs().kinovi_credits;
|
||||
|
||||
let ratios = [
|
||||
KinoviSeedance2p5AspectRatio::Landscape16x9,
|
||||
KinoviSeedance2p5AspectRatio::UltraWide21x9,
|
||||
KinoviSeedance2p5AspectRatio::Portrait9x16,
|
||||
KinoviSeedance2p5AspectRatio::Square1x1,
|
||||
KinoviSeedance2p5AspectRatio::Standard4x3,
|
||||
KinoviSeedance2p5AspectRatio::Portrait3x4,
|
||||
];
|
||||
|
||||
for ar in &ratios {
|
||||
let mut request = t2v_720(10);
|
||||
request.modality = KinoviSeedance2p5Modality::Reference {
|
||||
aspect_ratio: Some(*ar),
|
||||
reference_image_urls: None,
|
||||
reference_video_urls: None,
|
||||
reference_audio_urls: None,
|
||||
};
|
||||
assert_eq!(
|
||||
request.calculate_costs().kinovi_credits, baseline,
|
||||
"Aspect ratio {:?} should not change credits from baseline {}", ar, baseline,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
fn text_to_video_request(
|
||||
duration_seconds: u8,
|
||||
output_resolution: Option<KinoviSeedance2p5OutputResolution>,
|
||||
) -> GenerateSeedance2p5Request {
|
||||
GenerateSeedance2p5Request {
|
||||
prompt: String::new(),
|
||||
modality: KinoviSeedance2p5Modality::Reference {
|
||||
aspect_ratio: None,
|
||||
reference_image_urls: None,
|
||||
reference_video_urls: None,
|
||||
reference_audio_urls: None,
|
||||
},
|
||||
output_resolution,
|
||||
duration_seconds,
|
||||
total_input_seconds: None,
|
||||
use_face_blur_hack: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn t2v_480(duration: u8) -> GenerateSeedance2p5Request {
|
||||
text_to_video_request(duration, Some(KinoviSeedance2p5OutputResolution::FourEightyP))
|
||||
}
|
||||
|
||||
fn t2v_720(duration: u8) -> GenerateSeedance2p5Request {
|
||||
text_to_video_request(duration, None)
|
||||
}
|
||||
|
||||
fn keyframe_request(duration: u8) -> GenerateSeedance2p5Request {
|
||||
GenerateSeedance2p5Request {
|
||||
prompt: String::new(),
|
||||
modality: KinoviSeedance2p5Modality::Keyframe {
|
||||
start_frame_url: "https://example.com/start.png".to_string(),
|
||||
end_frame_url: Some("https://example.com/end.png".to_string()),
|
||||
},
|
||||
output_resolution: None,
|
||||
duration_seconds: duration,
|
||||
total_input_seconds: None,
|
||||
use_face_blur_hack: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn video_ref_480(duration: u8, total_input_seconds: Option<u8>) -> GenerateSeedance2p5Request {
|
||||
let mut request = t2v_480(duration);
|
||||
set_reference_urls(&mut request, None, Some(vec!["https://example.com/ref.mp4".to_string()]), None);
|
||||
request.total_input_seconds = total_input_seconds;
|
||||
request
|
||||
}
|
||||
|
||||
fn video_ref_720(duration: u8, total_input_seconds: Option<u8>) -> GenerateSeedance2p5Request {
|
||||
let mut request = t2v_720(duration);
|
||||
set_reference_urls(&mut request, None, Some(vec!["https://example.com/ref.mp4".to_string()]), None);
|
||||
request.total_input_seconds = total_input_seconds;
|
||||
request
|
||||
}
|
||||
|
||||
fn set_reference_urls(
|
||||
request: &mut GenerateSeedance2p5Request,
|
||||
images: Option<Vec<String>>,
|
||||
videos: Option<Vec<String>>,
|
||||
audio: Option<Vec<String>>,
|
||||
) {
|
||||
let KinoviSeedance2p5Modality::Reference {
|
||||
reference_image_urls,
|
||||
reference_video_urls,
|
||||
reference_audio_urls,
|
||||
..
|
||||
} = &mut request.modality else {
|
||||
panic!("set_reference_urls requires the Reference modality");
|
||||
};
|
||||
*reference_image_urls = images;
|
||||
*reference_video_urls = videos;
|
||||
*reference_audio_urls = audio;
|
||||
}
|
||||
}
|
||||
|
||||
mod raw_mapping_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn keyframe_maps_frames_and_no_references() {
|
||||
let raw = to_raw_request(GenerateSeedance2p5Request {
|
||||
prompt: "Car drives into the sunset".to_string(),
|
||||
modality: KinoviSeedance2p5Modality::Keyframe {
|
||||
start_frame_url: "https://example.com/start.png".to_string(),
|
||||
end_frame_url: Some("https://example.com/end.png".to_string()),
|
||||
},
|
||||
output_resolution: Some(KinoviSeedance2p5OutputResolution::FourEightyP),
|
||||
duration_seconds: 8,
|
||||
total_input_seconds: None,
|
||||
use_face_blur_hack: None,
|
||||
});
|
||||
|
||||
assert!(matches!(raw.model_type, KinoviModelTypeRaw::Seedance2p5));
|
||||
assert_eq!(raw.start_frame_url.as_deref(), Some("https://example.com/start.png"));
|
||||
assert_eq!(raw.end_frame_url.as_deref(), Some("https://example.com/end.png"));
|
||||
assert!(raw.reference_image_urls.is_none());
|
||||
assert!(raw.reference_video_urls.is_none());
|
||||
assert!(raw.reference_audio_urls.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_maps_references_and_no_frames() {
|
||||
let raw = to_raw_request(GenerateSeedance2p5Request {
|
||||
prompt: "The t-rex @image1 eats the banana".to_string(),
|
||||
modality: KinoviSeedance2p5Modality::Reference {
|
||||
aspect_ratio: Some(KinoviSeedance2p5AspectRatio::UltraWide21x9),
|
||||
reference_image_urls: Some(vec!["https://example.com/a.png".to_string()]),
|
||||
reference_video_urls: Some(vec!["https://example.com/ref.mp4".to_string()]),
|
||||
reference_audio_urls: Some(vec!["https://example.com/ref.wav".to_string()]),
|
||||
},
|
||||
output_resolution: None,
|
||||
duration_seconds: 8,
|
||||
total_input_seconds: Some(7),
|
||||
use_face_blur_hack: None,
|
||||
});
|
||||
|
||||
assert!(matches!(raw.aspect_ratio, KinoviAspectRatioRaw::UltraWide21x9));
|
||||
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));
|
||||
assert_eq!(raw.reference_video_urls.as_deref().map(|urls| urls.len()), Some(1));
|
||||
assert_eq!(raw.reference_audio_urls.as_deref().map(|urls| urls.len()), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_aspect_ratio_defaults_to_16x9() {
|
||||
let raw = to_raw_request(GenerateSeedance2p5Request {
|
||||
prompt: "Lightning hits a building".to_string(),
|
||||
modality: KinoviSeedance2p5Modality::Reference {
|
||||
aspect_ratio: None,
|
||||
reference_image_urls: None,
|
||||
reference_video_urls: None,
|
||||
reference_audio_urls: None,
|
||||
},
|
||||
output_resolution: None,
|
||||
duration_seconds: 5,
|
||||
total_input_seconds: None,
|
||||
use_face_blur_hack: None,
|
||||
});
|
||||
|
||||
assert!(matches!(raw.aspect_ratio, KinoviAspectRatioRaw::Landscape16x9));
|
||||
}
|
||||
}
|
||||
|
||||
mod real_requests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_text_to_video_480p() -> AnyhowResult<()> {
|
||||
setup_test_logging(LevelFilter::Trace);
|
||||
let session = test_session()?;
|
||||
let result = generate_seedance_2p5(GenerateSeedance2p5Args {
|
||||
session: &session,
|
||||
host_override: None,
|
||||
request: GenerateSeedance2p5Request {
|
||||
prompt: "Lightning hits a building".to_string(),
|
||||
modality: KinoviSeedance2p5Modality::Reference {
|
||||
aspect_ratio: Some(KinoviSeedance2p5AspectRatio::Landscape16x9),
|
||||
reference_image_urls: None,
|
||||
reference_video_urls: None,
|
||||
reference_audio_urls: None,
|
||||
},
|
||||
output_resolution: Some(KinoviSeedance2p5OutputResolution::FourEightyP),
|
||||
duration_seconds: 5,
|
||||
total_input_seconds: None,
|
||||
use_face_blur_hack: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("t2v 480p — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
assert!(!result.task_id.is_empty());
|
||||
assert!(!result.order_id.is_empty());
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_keyframe_to_video_adaptive_480p() -> AnyhowResult<()> {
|
||||
setup_test_logging(LevelFilter::Trace);
|
||||
let session = test_session()?;
|
||||
let result = generate_seedance_2p5(GenerateSeedance2p5Args {
|
||||
session: &session,
|
||||
host_override: None,
|
||||
request: GenerateSeedance2p5Request {
|
||||
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(),
|
||||
end_frame_url: Some("https://static.seedance2-pro.com/materials/20260807/1786128493608-d708413a.png".to_string()),
|
||||
},
|
||||
output_resolution: Some(KinoviSeedance2p5OutputResolution::FourEightyP),
|
||||
duration_seconds: 8,
|
||||
total_input_seconds: None,
|
||||
use_face_blur_hack: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("keyframe 480p — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
assert!(!result.task_id.is_empty());
|
||||
assert!(!result.order_id.is_empty());
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_reference_to_video_480p() -> AnyhowResult<()> {
|
||||
setup_test_logging(LevelFilter::Trace);
|
||||
let session = test_session()?;
|
||||
let result = generate_seedance_2p5(GenerateSeedance2p5Args {
|
||||
session: &session,
|
||||
host_override: None,
|
||||
request: GenerateSeedance2p5Request {
|
||||
prompt: "The t-rex @image1 eats the banana @image2".to_string(),
|
||||
modality: KinoviSeedance2p5Modality::Reference {
|
||||
aspect_ratio: Some(KinoviSeedance2p5AspectRatio::UltraWide21x9),
|
||||
reference_image_urls: Some(vec![
|
||||
"https://static.seedance2-pro.com/materials/20260807/1786128628611-d6ca7afd.png".to_string(),
|
||||
"https://static.seedance2-pro.com/materials/20260807/1786128638331-5395ff1c.jpg".to_string(),
|
||||
]),
|
||||
reference_video_urls: None,
|
||||
reference_audio_urls: None,
|
||||
},
|
||||
output_resolution: Some(KinoviSeedance2p5OutputResolution::FourEightyP),
|
||||
duration_seconds: 8,
|
||||
total_input_seconds: None,
|
||||
use_face_blur_hack: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("reference 480p — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
assert!(!result.task_id.is_empty());
|
||||
assert!(!result.order_id.is_empty());
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn test_session() -> AnyhowResult<KinoviWebSession> {
|
||||
let cookies = get_test_cookies()?;
|
||||
Ok(KinoviWebSession::from_cookies_string(cookies))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,4 +2,5 @@ pub mod generate_happy_horse_1p0;
|
||||
pub mod generate_seedance_2p0;
|
||||
pub mod generate_seedance_2p0_fast;
|
||||
pub mod generate_seedance_2p0_mini;
|
||||
pub mod generate_seedance_2p5;
|
||||
pub mod generate_seedance_2p5_preview;
|
||||
|
||||
+198
-20
@@ -216,6 +216,8 @@ pub enum KinoviModelTypeRaw {
|
||||
Seedance2Mini,
|
||||
/// Seedance 2.5 Preview (480p/720p only).
|
||||
Seedance2p5Preview,
|
||||
/// Seedance 2.5 (480p/720p only).
|
||||
Seedance2p5,
|
||||
/// Happy Horse 1.0.
|
||||
HappyHorse1p0,
|
||||
}
|
||||
@@ -227,27 +229,29 @@ impl KinoviModelTypeRaw {
|
||||
Self::Seedance2Fast => "seedance2-fast",
|
||||
Self::Seedance2Mini => "seedance2.0-mini",
|
||||
Self::Seedance2p5Preview => "seedance2-5-preview",
|
||||
Self::Seedance2p5 => "seedance2-5",
|
||||
Self::HappyHorse1p0 => "happyhorse1.0",
|
||||
}
|
||||
}
|
||||
|
||||
/// The tRPC `businessType` for this model. Seedance 2.0 Mini and 2.5
|
||||
/// Preview use their own business types; every other model uses the
|
||||
/// shared one.
|
||||
/// The tRPC `businessType` for this model. Seedance 2.0 Mini and the 2.5
|
||||
/// family use their own business types; every other model uses the shared
|
||||
/// one. (Seedance 2.5 reuses the *preview* business type on the wire —
|
||||
/// only the `model` string differs.)
|
||||
fn business_type(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Seedance2Mini => "seedance20-mini-video-generation",
|
||||
Self::Seedance2p5Preview => "seedance25-preview-video-generation",
|
||||
Self::Seedance2p5Preview | Self::Seedance2p5 => "seedance25-preview-video-generation",
|
||||
Self::HappyHorse1p0 => "happyhorse-video-generation",
|
||||
Self::Seedance2Pro | Self::Seedance2Fast => "wan22-video-generation",
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the aspect ratio is sent in an `aspectRatio` field (true)
|
||||
/// rather than the `resolution` field. Mini, 2.5 Preview, and Happy Horse
|
||||
/// use `aspectRatio`.
|
||||
/// rather than the `resolution` field. Mini, the 2.5 family, and Happy
|
||||
/// Horse use `aspectRatio`.
|
||||
fn uses_aspect_ratio_field(&self) -> bool {
|
||||
matches!(self, Self::Seedance2Mini | Self::Seedance2p5Preview | Self::HappyHorse1p0)
|
||||
matches!(self, Self::Seedance2Mini | Self::Seedance2p5Preview | Self::Seedance2p5 | Self::HappyHorse1p0)
|
||||
}
|
||||
|
||||
/// Whether this model uses Happy Horse's `happyhorseMode` (t2v/i2v)
|
||||
@@ -258,10 +262,11 @@ impl KinoviModelTypeRaw {
|
||||
|
||||
/// Whether reference URLs are sent in split `imageUrls` / `videoUrls`
|
||||
/// fields — with `uploadedUrls` mirroring just the images — rather than
|
||||
/// one combined `uploadedUrls` list. Seedance 2.5 Preview uses the split
|
||||
/// shape.
|
||||
/// one combined `uploadedUrls` list. The 2.5 family uses the split shape.
|
||||
/// (In 2.5 keyframe mode the start/end frames go in `imageUrls` with
|
||||
/// `uploadedUrls` mirroring them.)
|
||||
fn uses_split_reference_url_fields(&self) -> bool {
|
||||
matches!(self, Self::Seedance2p5Preview)
|
||||
matches!(self, Self::Seedance2p5Preview | Self::Seedance2p5)
|
||||
}
|
||||
|
||||
/// Whether `mode` is always "reference", even with no references attached.
|
||||
@@ -270,11 +275,25 @@ impl KinoviModelTypeRaw {
|
||||
matches!(self, Self::Seedance2p5Preview)
|
||||
}
|
||||
|
||||
/// Whether `mode` is "keyframe" only when a start/end frame is attached,
|
||||
/// and "reference" otherwise (including plain text-to-video). Seedance 2.5
|
||||
/// sends text-to-video as mode "reference".
|
||||
fn uses_reference_mode_unless_keyframes(&self) -> bool {
|
||||
matches!(self, Self::Seedance2p5)
|
||||
}
|
||||
|
||||
/// Whether keyframe mode omits the aspect ratio entirely: Seedance 2.5
|
||||
/// keyframe (image-to-video) only supports "Adaptive", which is expressed
|
||||
/// by sending no `aspectRatio` field at all.
|
||||
fn omits_aspect_ratio_in_keyframe_mode(&self) -> bool {
|
||||
matches!(self, Self::Seedance2p5)
|
||||
}
|
||||
|
||||
/// Whether the default field values (`outputResolution: "720p"`,
|
||||
/// `faceBlurMode: "off"`) are always sent explicitly rather than omitted.
|
||||
/// Seedance 2.5 Preview always sends both.
|
||||
/// The 2.5 family always sends both.
|
||||
fn always_sends_default_fields(&self) -> bool {
|
||||
matches!(self, Self::Seedance2p5Preview)
|
||||
matches!(self, Self::Seedance2p5Preview | Self::Seedance2p5)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -446,20 +465,41 @@ fn build_batch_request(req: WorkflowRunTaskRequest) -> BatchRequest {
|
||||
|
||||
let is_reference_mode = has_reference_images || has_reference_videos || has_reference_audio || has_characters;
|
||||
|
||||
let video_input_mode = if is_reference_mode || req.model_type.always_uses_reference_mode() {
|
||||
let has_keyframes = req.start_frame_url.is_some() || req.end_frame_url.is_some();
|
||||
|
||||
let video_input_mode = if req.model_type.always_uses_reference_mode() {
|
||||
"reference"
|
||||
} else if req.model_type.uses_reference_mode_unless_keyframes() {
|
||||
// Seedance 2.5: text-to-video is sent as "reference"; "keyframe" only
|
||||
// when a start/end frame is attached.
|
||||
if has_keyframes { "keyframe" } else { "reference" }
|
||||
} else if is_reference_mode {
|
||||
"reference"
|
||||
} else {
|
||||
"keyframe"
|
||||
};
|
||||
|
||||
// Reference URLs. Seedance 2.5 Preview sends split `imageUrls` /
|
||||
// `videoUrls` fields with `uploadedUrls` mirroring just the images; other
|
||||
// Reference URLs. The 2.5 family sends split `imageUrls` / `videoUrls`
|
||||
// fields with `uploadedUrls` mirroring just the images (in 2.5 keyframe
|
||||
// mode the start/end frames ride in `imageUrls` + `uploadedUrls`); other
|
||||
// models combine videos + images into `uploadedUrls` in reference mode, or
|
||||
// send the start/end frames there in keyframe mode.
|
||||
let (image_urls, video_urls, uploaded_urls): (Option<Vec<String>>, Option<Vec<String>>, Option<Vec<String>>) = if req.model_type.uses_split_reference_url_fields() {
|
||||
let image_urls = req.reference_image_urls.filter(|urls| !urls.is_empty());
|
||||
let video_urls = req.reference_video_urls.filter(|urls| !urls.is_empty());
|
||||
(image_urls.clone(), video_urls, image_urls)
|
||||
if video_input_mode == "keyframe" {
|
||||
let mut urls = Vec::new();
|
||||
if let Some(url) = req.start_frame_url {
|
||||
urls.push(url);
|
||||
}
|
||||
if let Some(url) = req.end_frame_url {
|
||||
urls.push(url);
|
||||
}
|
||||
let urls = if urls.is_empty() { None } else { Some(urls) };
|
||||
(urls.clone(), None, urls)
|
||||
} else {
|
||||
let image_urls = req.reference_image_urls.filter(|urls| !urls.is_empty());
|
||||
let video_urls = req.reference_video_urls.filter(|urls| !urls.is_empty());
|
||||
(image_urls.clone(), video_urls, image_urls)
|
||||
}
|
||||
} else if is_reference_mode {
|
||||
let mut urls = Vec::new();
|
||||
if let Some(video_urls) = req.reference_video_urls {
|
||||
@@ -506,9 +546,13 @@ fn build_batch_request(req: WorkflowRunTaskRequest) -> BatchRequest {
|
||||
let duration = format!("{}s", req.duration_seconds);
|
||||
|
||||
// The aspect ratio is sent as a ratio string (e.g. "16:9"). Seedance Pro/Fast
|
||||
// carry it in the `resolution` field; Mini and Happy Horse use `aspectRatio`.
|
||||
// carry it in the `resolution` field; Mini, the 2.5 family, and Happy Horse
|
||||
// use `aspectRatio`. Seedance 2.5 keyframe mode only supports "Adaptive",
|
||||
// which is expressed by omitting the field entirely.
|
||||
let aspect_ratio_value = req.aspect_ratio.as_aspect_ratio_str();
|
||||
let (resolution, aspect_ratio) = if req.model_type.uses_aspect_ratio_field() {
|
||||
let (resolution, aspect_ratio) = if video_input_mode == "keyframe" && req.model_type.omits_aspect_ratio_in_keyframe_mode() {
|
||||
(None, None)
|
||||
} else if req.model_type.uses_aspect_ratio_field() {
|
||||
(None, Some(aspect_ratio_value))
|
||||
} else {
|
||||
(Some(aspect_ratio_value.to_string()), None)
|
||||
@@ -893,6 +937,140 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Seedance 2.5 request shape ──
|
||||
//
|
||||
// Seedance 2.5 reuses the *preview* businessType
|
||||
// (`seedance25-preview-video-generation`) with model `seedance2-5`. Unlike
|
||||
// Preview it has a keyframe (image-to-video) mode: `mode` is "keyframe"
|
||||
// only when a start/end frame is attached (text-to-video goes out as
|
||||
// "reference"), keyframe requests omit `aspectRatio` entirely (the only
|
||||
// choice is "Adaptive"), and the frames ride in `imageUrls` with
|
||||
// `uploadedUrls` mirroring them. Reference mode matches Preview's split
|
||||
// URL shape. Mirrors
|
||||
// external/requests/sites/kinovi.ai/2026-08-07-seedance2p5/.
|
||||
|
||||
mod seedance_2p5_shape_tests {
|
||||
use super::*;
|
||||
|
||||
fn base_request(prompt: &str, duration_seconds: u8) -> WorkflowRunTaskRequest {
|
||||
WorkflowRunTaskRequest {
|
||||
model_type: KinoviModelTypeRaw::Seedance2p5,
|
||||
prompt: prompt.to_string(),
|
||||
aspect_ratio: KinoviAspectRatioRaw::Landscape16x9,
|
||||
output_resolution: Some(KinoviOutputResolutionRaw::FourEightyP),
|
||||
duration_seconds,
|
||||
batch_count: KinoviBatchCountRaw::One,
|
||||
start_frame_url: None,
|
||||
end_frame_url: None,
|
||||
reference_image_urls: None,
|
||||
reference_video_urls: None,
|
||||
reference_audio_urls: None,
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_to_video_matches_observed_request() {
|
||||
// Mirrors 1_request_text_to_video.txt: mode "reference", aspectRatio
|
||||
// "16:9", 480p, 5s, no URL fields.
|
||||
let body = build_batch_request(base_request("Lightning hits a building", 5));
|
||||
let json = serde_json::to_string(&body).unwrap();
|
||||
assert!(json.contains(r#""businessType":"seedance25-preview-video-generation""#), "{json}");
|
||||
assert!(json.contains(r#""model":"seedance2-5""#), "{json}");
|
||||
assert!(json.contains(r#""mode":"reference""#), "{json}");
|
||||
assert!(json.contains(r#""aspectRatio":"16:9""#), "{json}");
|
||||
assert!(json.contains(r#""duration":"5s""#), "{json}");
|
||||
assert!(json.contains(r#""outputResolution":"480p""#), "{json}");
|
||||
assert!(json.contains(r#""faceBlurMode":"off""#), "{json}");
|
||||
assert!(json.contains(r#""contentMode":"normal""#), "{json}");
|
||||
// No ratio-string `resolution` field, and no URL fields.
|
||||
assert!(!json.contains(r#""resolution":"#), "{json}");
|
||||
assert!(!json.contains("imageUrls"), "{json}");
|
||||
assert!(!json.contains("videoUrls"), "{json}");
|
||||
assert!(!json.contains("uploadedUrls"), "{json}");
|
||||
assert!(!json.contains("audioUrls"), "{json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyframe_with_both_frames_matches_observed_request() {
|
||||
// Mirrors 2_request_keyframe_to_video.txt: mode "keyframe", NO
|
||||
// aspectRatio (Adaptive), frames in imageUrls + uploadedUrls.
|
||||
let mut request = base_request("Car drives into the sunset", 8);
|
||||
request.start_frame_url = Some("https://static.seedance2-pro.com/materials/start.png".to_string());
|
||||
request.end_frame_url = Some("https://static.seedance2-pro.com/materials/end.png".to_string());
|
||||
|
||||
let body = build_batch_request(request);
|
||||
let json = serde_json::to_string(&body).unwrap();
|
||||
assert!(json.contains(r#""mode":"keyframe""#), "{json}");
|
||||
// Adaptive: neither the `aspectRatio` nor `resolution` field is sent.
|
||||
assert!(!json.contains("aspectRatio"), "{json}");
|
||||
assert!(!json.contains(r#""resolution":"#), "{json}");
|
||||
let expected_urls = r#"["https://static.seedance2-pro.com/materials/start.png","https://static.seedance2-pro.com/materials/end.png"]"#;
|
||||
assert!(json.contains(&format!(r#""imageUrls":{expected_urls}"#)), "{json}");
|
||||
assert!(json.contains(&format!(r#""uploadedUrls":{expected_urls}"#)), "{json}");
|
||||
assert!(!json.contains("videoUrls"), "{json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyframe_with_start_frame_only_matches_observed_request() {
|
||||
// Mirrors 4_request_keyframe_to_video_2.txt: a single start frame.
|
||||
let mut request = base_request("The cars drive by", 5);
|
||||
request.start_frame_url = Some("https://static.seedance2-pro.com/materials/start.jpg".to_string());
|
||||
|
||||
let body = build_batch_request(request);
|
||||
let json = serde_json::to_string(&body).unwrap();
|
||||
assert!(json.contains(r#""mode":"keyframe""#), "{json}");
|
||||
assert!(!json.contains("aspectRatio"), "{json}");
|
||||
let expected_urls = r#"["https://static.seedance2-pro.com/materials/start.jpg"]"#;
|
||||
assert!(json.contains(&format!(r#""imageUrls":{expected_urls}"#)), "{json}");
|
||||
assert!(json.contains(&format!(r#""uploadedUrls":{expected_urls}"#)), "{json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_with_all_reference_types_matches_observed_request() {
|
||||
// Mirrors 3_request_reference_to_video.txt: mode "reference",
|
||||
// aspectRatio "21:9", split imageUrls / videoUrls / audioUrls with
|
||||
// uploadedUrls mirroring just the images.
|
||||
let mut request = base_request("The t-rex @image1 eats the banana @image2", 8);
|
||||
request.aspect_ratio = KinoviAspectRatioRaw::UltraWide21x9;
|
||||
request.reference_image_urls = Some(vec![
|
||||
"https://static.seedance2-pro.com/materials/a.png".to_string(),
|
||||
"https://static.seedance2-pro.com/materials/b.jpg".to_string(),
|
||||
]);
|
||||
request.reference_video_urls = Some(vec!["https://static.seedance2-pro.com/materials/ref.mp4".to_string()]);
|
||||
request.reference_audio_urls = Some(vec!["https://static.seedance2-pro.com/materials/ref.wav".to_string()]);
|
||||
|
||||
let body = build_batch_request(request);
|
||||
let json = serde_json::to_string(&body).unwrap();
|
||||
assert!(json.contains(r#""mode":"reference""#), "{json}");
|
||||
assert!(json.contains(r#""aspectRatio":"21:9""#), "{json}");
|
||||
let expected_images = r#"["https://static.seedance2-pro.com/materials/a.png","https://static.seedance2-pro.com/materials/b.jpg"]"#;
|
||||
assert!(json.contains(&format!(r#""imageUrls":{expected_images}"#)), "{json}");
|
||||
assert!(json.contains(&format!(r#""uploadedUrls":{expected_images}"#)), "{json}");
|
||||
assert!(json.contains(r#""videoUrls":["https://static.seedance2-pro.com/materials/ref.mp4"]"#), "{json}");
|
||||
assert!(json.contains(r#""audioUrls":["https://static.seedance2-pro.com/materials/ref.wav"]"#), "{json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_720p_and_face_blur_off_are_sent_explicitly() {
|
||||
let mut request = base_request("a corgi", 5);
|
||||
request.output_resolution = None;
|
||||
|
||||
let body = build_batch_request(request);
|
||||
let json = serde_json::to_string(&body).unwrap();
|
||||
assert!(json.contains(r#""outputResolution":"720p""#), "{json}");
|
||||
assert!(json.contains(r#""faceBlurMode":"off""#), "{json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_string_and_business_type() {
|
||||
assert_eq!(KinoviModelTypeRaw::Seedance2p5.as_api_str(), "seedance2-5");
|
||||
assert_eq!(KinoviModelTypeRaw::Seedance2p5.business_type(), "seedance25-preview-video-generation");
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2026-06-24 request-shape change ──
|
||||
//
|
||||
// The aspect ratio is now a ratio string ("16:9"). Seedance Pro/Fast keep it
|
||||
|
||||
+1
@@ -90,6 +90,7 @@ fn video_model_to_common_model_type(model: RouterVideoModel) -> Option<CommonMod
|
||||
RouterVideoModel::GrokImagineVideo => Some(CommonModelType::GrokImagineVideo),
|
||||
RouterVideoModel::GrokImagineVideo1p5 => Some(CommonModelType::GrokImagineVideo1p5),
|
||||
RouterVideoModel::Seedance2p5Preview => Some(CommonModelType::Seedance2p5Preview),
|
||||
RouterVideoModel::Seedance2p5 => Some(CommonModelType::Seedance2p5),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -58,6 +58,7 @@ pub(super) async fn handle_artcraft_video_via_router(
|
||||
duration_seconds: request.duration_seconds,
|
||||
video_batch_count: request.video_batch_count,
|
||||
generate_audio: request.generate_audio,
|
||||
total_reference_video_input_seconds: None,
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
|
||||
idempotency_token: None,
|
||||
negative_prompt: None,
|
||||
|
||||
@@ -220,6 +220,8 @@ pub enum InferenceModelType {
|
||||
Seedance2p0BytePlusUltraMini,
|
||||
#[serde(rename = "seedance_2p5_preview")]
|
||||
Seedance2p5Preview,
|
||||
#[serde(rename = "seedance_2p5")]
|
||||
Seedance2p5,
|
||||
#[serde(rename = "sora_2")]
|
||||
Sora2,
|
||||
#[serde(rename = "sora_2_pro")]
|
||||
@@ -396,6 +398,7 @@ impl InferenceModelType {
|
||||
Self::Seedance2p0BytePlusMini => "seedance_2p0_bp_mini",
|
||||
Self::Seedance2p0BytePlusUltraMini => "seedance_2p0_bpu_mini",
|
||||
Self::Seedance2p5Preview => "seedance_2p5_preview",
|
||||
Self::Seedance2p5 => "seedance_2p5",
|
||||
Self::Sora2 => "sora_2",
|
||||
Self::Sora2Pro => "sora_2_pro",
|
||||
Self::Veo2 => "veo_2",
|
||||
@@ -532,6 +535,7 @@ impl InferenceModelType {
|
||||
"seedance_2p0_bp_mini" => Ok(Self::Seedance2p0BytePlusMini),
|
||||
"seedance_2p0_bpu_mini" => Ok(Self::Seedance2p0BytePlusUltraMini),
|
||||
"seedance_2p5_preview" => Ok(Self::Seedance2p5Preview),
|
||||
"seedance_2p5" => Ok(Self::Seedance2p5),
|
||||
"sora_2" => Ok(Self::Sora2),
|
||||
"sora_2_pro" => Ok(Self::Sora2Pro),
|
||||
"veo_2" => Ok(Self::Veo2),
|
||||
@@ -672,6 +676,7 @@ impl InferenceModelType {
|
||||
Self::Seedance2p0BytePlusMini,
|
||||
Self::Seedance2p0BytePlusUltraMini,
|
||||
Self::Seedance2p5Preview,
|
||||
Self::Seedance2p5,
|
||||
Self::Sora2,
|
||||
Self::Sora2Pro,
|
||||
Self::Veo2,
|
||||
@@ -795,6 +800,7 @@ impl InferenceModelType {
|
||||
CommonModelType::Seedance2p0BytePlusMini => Self::Seedance2p0BytePlusMini,
|
||||
CommonModelType::Seedance2p0BytePlusUltraMini => Self::Seedance2p0BytePlusUltraMini,
|
||||
CommonModelType::Seedance2p5Preview => Self::Seedance2p5Preview,
|
||||
CommonModelType::Seedance2p5 => Self::Seedance2p5,
|
||||
CommonModelType::Sora2 => Self::Sora2,
|
||||
CommonModelType::Sora2Pro => Self::Sora2Pro,
|
||||
CommonModelType::Veo2 => Self::Veo2,
|
||||
@@ -940,6 +946,7 @@ mod tests {
|
||||
assert_serialization(InferenceModelType::Seedance2p0BytePlusMini, "seedance_2p0_bp_mini");
|
||||
assert_serialization(InferenceModelType::Seedance2p0BytePlusUltraMini, "seedance_2p0_bpu_mini");
|
||||
assert_serialization(InferenceModelType::Seedance2p5Preview, "seedance_2p5_preview");
|
||||
assert_serialization(InferenceModelType::Seedance2p5, "seedance_2p5");
|
||||
assert_serialization(InferenceModelType::Sora2, "sora_2");
|
||||
assert_serialization(InferenceModelType::Sora2Pro, "sora_2_pro");
|
||||
assert_serialization(InferenceModelType::Veo2, "veo_2");
|
||||
@@ -1072,6 +1079,7 @@ mod tests {
|
||||
assert_eq!(InferenceModelType::Seedance2p0BytePlusMini.to_str(), "seedance_2p0_bp_mini");
|
||||
assert_eq!(InferenceModelType::Seedance2p0BytePlusUltraMini.to_str(), "seedance_2p0_bpu_mini");
|
||||
assert_eq!(InferenceModelType::Seedance2p5Preview.to_str(), "seedance_2p5_preview");
|
||||
assert_eq!(InferenceModelType::Seedance2p5.to_str(), "seedance_2p5");
|
||||
assert_eq!(InferenceModelType::Sora2.to_str(), "sora_2");
|
||||
assert_eq!(InferenceModelType::Sora2Pro.to_str(), "sora_2_pro");
|
||||
assert_eq!(InferenceModelType::Veo2.to_str(), "veo_2");
|
||||
@@ -1206,6 +1214,7 @@ mod tests {
|
||||
assert_eq!(InferenceModelType::from_str("seedance_2p0_bp_mini").unwrap(), InferenceModelType::Seedance2p0BytePlusMini);
|
||||
assert_eq!(InferenceModelType::from_str("seedance_2p0_bpu_mini").unwrap(), InferenceModelType::Seedance2p0BytePlusUltraMini);
|
||||
assert_eq!(InferenceModelType::from_str("seedance_2p5_preview").unwrap(), InferenceModelType::Seedance2p5Preview);
|
||||
assert_eq!(InferenceModelType::from_str("seedance_2p5").unwrap(), InferenceModelType::Seedance2p5);
|
||||
assert_eq!(InferenceModelType::from_str("sora_2").unwrap(), InferenceModelType::Sora2);
|
||||
assert_eq!(InferenceModelType::from_str("sora_2_pro").unwrap(), InferenceModelType::Sora2Pro);
|
||||
assert_eq!(InferenceModelType::from_str("veo_2").unwrap(), InferenceModelType::Veo2);
|
||||
|
||||
@@ -191,6 +191,8 @@ pub enum CommonModelType {
|
||||
Seedance2p0BytePlusUltraMini,
|
||||
#[serde(rename = "seedance_2p5_preview")]
|
||||
Seedance2p5Preview,
|
||||
#[serde(rename = "seedance_2p5")]
|
||||
Seedance2p5,
|
||||
#[serde(rename = "sora_2")]
|
||||
Sora2,
|
||||
#[serde(rename = "sora_2_pro")]
|
||||
@@ -351,6 +353,7 @@ impl CommonModelType {
|
||||
Self::Seedance2p0BytePlusMini => "seedance_2p0_bp_mini",
|
||||
Self::Seedance2p0BytePlusUltraMini => "seedance_2p0_bpu_mini",
|
||||
Self::Seedance2p5Preview => "seedance_2p5_preview",
|
||||
Self::Seedance2p5 => "seedance_2p5",
|
||||
Self::Sora2 => "sora_2",
|
||||
Self::Sora2Pro => "sora_2_pro",
|
||||
Self::Veo2 => "veo_2",
|
||||
@@ -470,6 +473,7 @@ impl CommonModelType {
|
||||
"seedance_2p0_bp_mini" => Ok(Self::Seedance2p0BytePlusMini),
|
||||
"seedance_2p0_bpu_mini" => Ok(Self::Seedance2p0BytePlusUltraMini),
|
||||
"seedance_2p5_preview" => Ok(Self::Seedance2p5Preview),
|
||||
"seedance_2p5" => Ok(Self::Seedance2p5),
|
||||
"sora_2" => Ok(Self::Sora2),
|
||||
"sora_2_pro" => Ok(Self::Sora2Pro),
|
||||
"veo_2" => Ok(Self::Veo2),
|
||||
@@ -593,6 +597,7 @@ impl CommonModelType {
|
||||
Self::Seedance2p0BytePlusMini,
|
||||
Self::Seedance2p0BytePlusUltraMini,
|
||||
Self::Seedance2p5Preview,
|
||||
Self::Seedance2p5,
|
||||
Self::Sora2,
|
||||
Self::Sora2Pro,
|
||||
Self::Veo2,
|
||||
@@ -713,6 +718,7 @@ impl CommonModelType {
|
||||
Self::Seedance2p0BytePlusMini => CommonModelClass::Video,
|
||||
Self::Seedance2p0BytePlusUltraMini => CommonModelClass::Video,
|
||||
Self::Seedance2p5Preview => CommonModelClass::Video,
|
||||
Self::Seedance2p5 => CommonModelClass::Video,
|
||||
Self::Sora2 => CommonModelClass::Video,
|
||||
Self::Sora2Pro => CommonModelClass::Video,
|
||||
Self::Veo2 => CommonModelClass::Video,
|
||||
@@ -842,6 +848,7 @@ mod tests {
|
||||
assert_serialization(CommonModelType::Seedance2p0BytePlusMini, "seedance_2p0_bp_mini");
|
||||
assert_serialization(CommonModelType::Seedance2p0BytePlusUltraMini, "seedance_2p0_bpu_mini");
|
||||
assert_serialization(CommonModelType::Seedance2p5Preview, "seedance_2p5_preview");
|
||||
assert_serialization(CommonModelType::Seedance2p5, "seedance_2p5");
|
||||
assert_serialization(CommonModelType::Sora2, "sora_2");
|
||||
assert_serialization(CommonModelType::Sora2Pro, "sora_2_pro");
|
||||
assert_serialization(CommonModelType::Veo2, "veo_2");
|
||||
@@ -956,6 +963,7 @@ mod tests {
|
||||
assert_eq!(CommonModelType::Seedance2p0BytePlusMini.to_str(), "seedance_2p0_bp_mini");
|
||||
assert_eq!(CommonModelType::Seedance2p0BytePlusUltraMini.to_str(), "seedance_2p0_bpu_mini");
|
||||
assert_eq!(CommonModelType::Seedance2p5Preview.to_str(), "seedance_2p5_preview");
|
||||
assert_eq!(CommonModelType::Seedance2p5.to_str(), "seedance_2p5");
|
||||
assert_eq!(CommonModelType::Sora2.to_str(), "sora_2");
|
||||
assert_eq!(CommonModelType::Sora2Pro.to_str(), "sora_2_pro");
|
||||
assert_eq!(CommonModelType::Veo2.to_str(), "veo_2");
|
||||
@@ -1072,6 +1080,7 @@ mod tests {
|
||||
assert_eq!(CommonModelType::from_str("seedance_2p0_bp_mini").unwrap(), CommonModelType::Seedance2p0BytePlusMini);
|
||||
assert_eq!(CommonModelType::from_str("seedance_2p0_bpu_mini").unwrap(), CommonModelType::Seedance2p0BytePlusUltraMini);
|
||||
assert_eq!(CommonModelType::from_str("seedance_2p5_preview").unwrap(), CommonModelType::Seedance2p5Preview);
|
||||
assert_eq!(CommonModelType::from_str("seedance_2p5").unwrap(), CommonModelType::Seedance2p5);
|
||||
assert_eq!(CommonModelType::from_str("sora_2").unwrap(), CommonModelType::Sora2);
|
||||
assert_eq!(CommonModelType::from_str("sora_2_pro").unwrap(), CommonModelType::Sora2Pro);
|
||||
assert_eq!(CommonModelType::from_str("veo_2").unwrap(), CommonModelType::Veo2);
|
||||
@@ -1116,7 +1125,7 @@ mod tests {
|
||||
#[test]
|
||||
fn all_variants() {
|
||||
let mut variants = CommonModelType::all_variants();
|
||||
assert_eq!(variants.len(), 104);
|
||||
assert_eq!(variants.len(), 105);
|
||||
// Image models
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::Flux1Dev));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::Flux1Schnell));
|
||||
@@ -1188,6 +1197,7 @@ mod tests {
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::Seedance2p0BytePlusMini));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::Seedance2p0BytePlusUltraMini));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::Seedance2p5Preview));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::Seedance2p5));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::Sora2));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::Sora2Pro));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::Veo2));
|
||||
|
||||
@@ -98,6 +98,9 @@ pub enum CommonVideoModel {
|
||||
#[serde(rename = "seedance_2p5_preview")]
|
||||
Seedance2p5Preview,
|
||||
|
||||
#[serde(rename = "seedance_2p5")]
|
||||
Seedance2p5,
|
||||
|
||||
#[serde(rename = "sora_2")]
|
||||
Sora2,
|
||||
|
||||
@@ -173,6 +176,7 @@ impl CommonVideoModel {
|
||||
Self::Seedance2p0BytePlusMini => CommonModelType::Seedance2p0BytePlusMini,
|
||||
Self::Seedance2p0BytePlusUltraMini => CommonModelType::Seedance2p0BytePlusUltraMini,
|
||||
Self::Seedance2p5Preview => CommonModelType::Seedance2p5Preview,
|
||||
Self::Seedance2p5 => CommonModelType::Seedance2p5,
|
||||
Self::Sora2 => CommonModelType::Sora2,
|
||||
Self::Sora2Pro => CommonModelType::Sora2Pro,
|
||||
Self::Veo2 => CommonModelType::Veo2,
|
||||
@@ -227,6 +231,7 @@ mod tests {
|
||||
assert_serialization(CommonVideoModel::Seedance2p0BytePlusMini, "seedance_2p0_bp_mini");
|
||||
assert_serialization(CommonVideoModel::Seedance2p0BytePlusUltraMini, "seedance_2p0_bpu_mini");
|
||||
assert_serialization(CommonVideoModel::Seedance2p5Preview, "seedance_2p5_preview");
|
||||
assert_serialization(CommonVideoModel::Seedance2p5, "seedance_2p5");
|
||||
assert_serialization(CommonVideoModel::Sora2, "sora_2");
|
||||
assert_serialization(CommonVideoModel::Sora2Pro, "sora_2_pro");
|
||||
assert_serialization(CommonVideoModel::Veo2, "veo_2");
|
||||
@@ -274,6 +279,7 @@ mod tests {
|
||||
("seedance_2p0_bp_mini", CommonVideoModel::Seedance2p0BytePlusMini),
|
||||
("seedance_2p0_bpu_mini", CommonVideoModel::Seedance2p0BytePlusUltraMini),
|
||||
("seedance_2p5_preview", CommonVideoModel::Seedance2p5Preview),
|
||||
("seedance_2p5", CommonVideoModel::Seedance2p5),
|
||||
("sora_2", CommonVideoModel::Sora2),
|
||||
("sora_2_pro", CommonVideoModel::Sora2Pro),
|
||||
("veo_2", CommonVideoModel::Veo2),
|
||||
@@ -328,6 +334,7 @@ mod tests {
|
||||
CommonVideoModel::Seedance2p0BytePlusMini,
|
||||
CommonVideoModel::Seedance2p0BytePlusUltraMini,
|
||||
CommonVideoModel::Seedance2p5Preview,
|
||||
CommonVideoModel::Seedance2p5,
|
||||
CommonVideoModel::Sora2,
|
||||
CommonVideoModel::Sora2Pro,
|
||||
CommonVideoModel::Veo2,
|
||||
@@ -381,6 +388,7 @@ mod tests {
|
||||
(CommonVideoModel::Seedance2p0BytePlusMini, CommonModelType::Seedance2p0BytePlusMini),
|
||||
(CommonVideoModel::Seedance2p0BytePlusUltraMini, CommonModelType::Seedance2p0BytePlusUltraMini),
|
||||
(CommonVideoModel::Seedance2p5Preview, CommonModelType::Seedance2p5Preview),
|
||||
(CommonVideoModel::Seedance2p5, CommonModelType::Seedance2p5),
|
||||
(CommonVideoModel::Sora2, CommonModelType::Sora2),
|
||||
(CommonVideoModel::Sora2Pro, CommonModelType::Sora2Pro),
|
||||
(CommonVideoModel::Veo2, CommonModelType::Veo2),
|
||||
|
||||
+41
@@ -8,6 +8,47 @@ use enums::common::generation::model_creator::ModelCreator;
|
||||
pub fn seedance_2p5_video_models() -> Vec<OmniGenVideoModelDetails> {
|
||||
let mut models = Vec::new();
|
||||
|
||||
models.push(OmniGenVideoModelDetails {
|
||||
model: CommonVideoModel::Seedance2p5,
|
||||
model_creator: Some(ModelCreator::Bytedance),
|
||||
full_name: Some("Seedance 2.5".to_string()),
|
||||
extra_info: Some("Seedance 2.5. Supports videos up to 30 seconds, start/end keyframes (adaptive aspect ratio), and image/video/audio references, with 480p/720p output. Video references bill their input seconds on top of the output duration.".to_string()),
|
||||
extra_info_short: Some("Seedance 2.5; up to 30s videos".to_string()),
|
||||
text_prompt_supported: Some(true),
|
||||
text_prompt_max_length: Some(10_000),
|
||||
starting_keyframe_supported: Some(true),
|
||||
ending_keyframe_supported: Some(true),
|
||||
image_references_supported: Some(true),
|
||||
image_references_max: Some(30),
|
||||
audio_references_supported: Some(true),
|
||||
audio_references_max: Some(10),
|
||||
audio_references_max_total_duration_seconds: Some(30),
|
||||
video_references_supported: Some(true),
|
||||
video_references_max: Some(10),
|
||||
video_references_max_total_duration_seconds: Some(30),
|
||||
character_references_supported: Some(false),
|
||||
aspect_ratio_options: Some(vec![
|
||||
CommonAspectRatio::WideTwentyOneByNine,
|
||||
CommonAspectRatio::WideSixteenByNine,
|
||||
CommonAspectRatio::WideFourByThree,
|
||||
CommonAspectRatio::Square,
|
||||
CommonAspectRatio::TallThreeByFour,
|
||||
CommonAspectRatio::TallNineBySixteen,
|
||||
]),
|
||||
aspect_ratio_default: Some(CommonAspectRatio::WideSixteenByNine),
|
||||
resolution_options: Some(vec![
|
||||
CommonResolution::FourEightyP,
|
||||
CommonResolution::SevenTwentyP,
|
||||
]),
|
||||
resolution_default: Some(CommonResolution::SevenTwentyP),
|
||||
batch_size_options: Some(vec![1]),
|
||||
batch_size_default: Some(1),
|
||||
duration_seconds_min: Some(4),
|
||||
duration_seconds_max: Some(30),
|
||||
duration_seconds_default: Some(5),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
models.push(OmniGenVideoModelDetails {
|
||||
model: CommonVideoModel::Seedance2p5Preview,
|
||||
model_creator: Some(ModelCreator::Bytedance),
|
||||
|
||||
@@ -3,3 +3,4 @@ pub mod generation_debug_logs;
|
||||
pub mod job_failure_test;
|
||||
pub mod map_kinovi_web_web_errors;
|
||||
pub mod payments_error_test;
|
||||
pub mod probed_reference_videos;
|
||||
|
||||
+271
@@ -0,0 +1,271 @@
|
||||
//! Download + ffprobe reference videos for input-seconds billing.
|
||||
//!
|
||||
//! Models that bill reference-video input seconds (e.g. Seedance 2.5) need
|
||||
//! ACCURATE durations. The `media_files` row's stored duration isn't always
|
||||
//! set and isn't always correct, so every reference video is downloaded and
|
||||
//! ffprobed — the database is never trusted for billing.
|
||||
//!
|
||||
//! The downloaded files are kept on disk inside [`ProbedReferenceVideos`]
|
||||
//! and handed to the router (via `predownloaded_media_paths` on the draft
|
||||
//! context) so the subsequent provider upload reuses the bytes instead of
|
||||
//! downloading them a second time. Dropping the struct deletes the files, so
|
||||
//! callers must keep it alive until the provider upload completes.
|
||||
//!
|
||||
//! Two phases so callers never hold a pooled DB connection across the slow
|
||||
//! download/probe work:
|
||||
//!
|
||||
//! 1. [`fetch_reference_video_sources`] — DB fetch (CDN URLs) on the
|
||||
//! caller's connection.
|
||||
//! 2. [`download_and_probe_reference_videos`] — downloads each unique file
|
||||
//! once and ffprobes it. Callers should DROP their pooled connection
|
||||
//! before this phase and re-acquire after.
|
||||
//!
|
||||
//! Billing math: each file's duration is rounded UP to a whole second, and
|
||||
//! counted once per reference (the same file referenced twice bills twice).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::convert::TryFrom;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use actix_web::HttpRequest;
|
||||
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
|
||||
use ffmpeg_utils::ffprobe::ffprobe_get_info::ffprobe_get_info;
|
||||
use log::{error, info, warn};
|
||||
use mysql_queries::queries::media_files::get::batch_get_media_files_by_tokens::batch_get_media_files_by_tokens_with_connection;
|
||||
use server_environment::ServerEnvironment;
|
||||
use sqlx::pool::PoolConnection;
|
||||
use sqlx::MySql;
|
||||
use tempfile::NamedTempFile;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::common_responses::media::media_links_builder::MediaLinksBuilder;
|
||||
use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain;
|
||||
use crate::util::http_download_url_to_tempfile::http_download_url_to_tempfile;
|
||||
|
||||
/// Downloaded + ffprobed reference videos.
|
||||
///
|
||||
/// Keep this alive until the provider upload completes — dropping it deletes
|
||||
/// the downloaded temp files.
|
||||
pub struct ProbedReferenceVideos {
|
||||
/// Sum of per-file ffprobed durations, each rounded UP to a whole second,
|
||||
/// counted once per reference (duplicates bill per reference).
|
||||
pub total_input_seconds: u16,
|
||||
|
||||
/// Source CDN URL → local temp file path, one entry per unique file.
|
||||
/// Pass to the router as `predownloaded_media_paths` so uploads reuse the
|
||||
/// already-downloaded bytes.
|
||||
local_paths_by_url: HashMap<String, PathBuf>,
|
||||
|
||||
/// Owns the downloaded files; they are deleted when this struct drops.
|
||||
_temp_files: Vec<NamedTempFile>,
|
||||
}
|
||||
|
||||
impl ProbedReferenceVideos {
|
||||
pub fn local_paths_by_url(&self) -> &HashMap<String, PathBuf> {
|
||||
&self.local_paths_by_url
|
||||
}
|
||||
}
|
||||
|
||||
/// One requested reference video (duplicates preserved, in request order).
|
||||
pub struct ReferenceVideoSource {
|
||||
pub media_token: MediaFileToken,
|
||||
pub cdn_url: String,
|
||||
}
|
||||
|
||||
/// Phase 1 (DB): resolve each reference video token to its CDN URL.
|
||||
///
|
||||
/// Returns one entry per requested token, in order, with duplicates
|
||||
/// preserved (the same file referenced twice bills twice). Unknown tokens
|
||||
/// are a 400.
|
||||
pub async fn fetch_reference_video_sources(
|
||||
video_tokens: &[MediaFileToken],
|
||||
http_request: &HttpRequest,
|
||||
server_environment: ServerEnvironment,
|
||||
mysql_connection: &mut PoolConnection<MySql>,
|
||||
) -> Result<Vec<ReferenceVideoSource>, CommonWebError> {
|
||||
const CAN_SEE_DELETED: bool = false;
|
||||
|
||||
if video_tokens.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut unique_tokens: Vec<MediaFileToken> = Vec::new();
|
||||
for token in video_tokens {
|
||||
if !unique_tokens.contains(token) {
|
||||
unique_tokens.push(token.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let media_files = batch_get_media_files_by_tokens_with_connection(
|
||||
mysql_connection,
|
||||
&unique_tokens,
|
||||
CAN_SEE_DELETED,
|
||||
).await.map_err(|err| {
|
||||
error!("Error getting reference video media files by tokens: {:?}", err);
|
||||
CommonWebError::from_anyhow_error(err)
|
||||
})?;
|
||||
|
||||
if media_files.len() != unique_tokens.len() {
|
||||
warn!("Only {} of {} unique reference video media files could be found",
|
||||
media_files.len(), unique_tokens.len());
|
||||
return Err(CommonWebError::BadInputWithSimpleMessage(
|
||||
"not all reference video media files could be found".to_string()));
|
||||
}
|
||||
|
||||
let media_domain = get_media_domain(http_request);
|
||||
|
||||
let cdn_urls_by_token: HashMap<MediaFileToken, String> = media_files
|
||||
.into_iter()
|
||||
.map(|file| {
|
||||
let bucket_path = MediaFileBucketPath::from_object_hash(
|
||||
&file.public_bucket_directory_hash,
|
||||
file.maybe_public_bucket_prefix.as_deref(),
|
||||
file.maybe_public_bucket_extension.as_deref());
|
||||
|
||||
let media_links = MediaLinksBuilder::from_media_path_and_env(
|
||||
media_domain,
|
||||
server_environment,
|
||||
&bucket_path);
|
||||
|
||||
(file.token, media_links.cdn_url.to_string())
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(video_tokens
|
||||
.iter()
|
||||
.filter_map(|token| {
|
||||
cdn_urls_by_token.get(token).map(|cdn_url| ReferenceVideoSource {
|
||||
media_token: token.clone(),
|
||||
cdn_url: cdn_url.clone(),
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Phase 2 (no DB): download each unique file once, ffprobe it, and sum the
|
||||
/// billed input seconds. Callers must not hold a pooled DB connection across
|
||||
/// this call.
|
||||
pub async fn download_and_probe_reference_videos(
|
||||
sources: &[ReferenceVideoSource],
|
||||
) -> Result<ProbedReferenceVideos, CommonWebError> {
|
||||
let mut temp_files: Vec<NamedTempFile> = Vec::new();
|
||||
let mut local_paths_by_url: HashMap<String, PathBuf> = HashMap::new();
|
||||
let mut seconds_by_url: HashMap<String, u64> = HashMap::new();
|
||||
|
||||
for source in sources {
|
||||
if seconds_by_url.contains_key(&source.cdn_url) {
|
||||
continue; // Already downloaded and probed this file.
|
||||
}
|
||||
|
||||
let temp_file = download_reference_video(source).await?;
|
||||
let duration_millis = probe_video_duration_millis(source, &temp_file)?;
|
||||
|
||||
info!("Probed reference video {} at {}ms", source.media_token, duration_millis);
|
||||
|
||||
seconds_by_url.insert(source.cdn_url.clone(), duration_millis.div_ceil(1_000));
|
||||
local_paths_by_url.insert(source.cdn_url.clone(), temp_file.path().to_path_buf());
|
||||
temp_files.push(temp_file);
|
||||
}
|
||||
|
||||
let total_input_seconds = total_billed_seconds(sources, &seconds_by_url);
|
||||
|
||||
Ok(ProbedReferenceVideos {
|
||||
total_input_seconds,
|
||||
local_paths_by_url,
|
||||
_temp_files: temp_files,
|
||||
})
|
||||
}
|
||||
|
||||
/// Sum per-reference (so duplicates bill per reference), saturating at
|
||||
/// `u16::MAX`.
|
||||
fn total_billed_seconds(
|
||||
sources: &[ReferenceVideoSource],
|
||||
seconds_by_url: &HashMap<String, u64>,
|
||||
) -> u16 {
|
||||
let total: u64 = sources
|
||||
.iter()
|
||||
.filter_map(|source| seconds_by_url.get(&source.cdn_url))
|
||||
.sum();
|
||||
u16::try_from(total).unwrap_or(u16::MAX)
|
||||
}
|
||||
|
||||
async fn download_reference_video(source: &ReferenceVideoSource) -> Result<NamedTempFile, CommonWebError> {
|
||||
let mut temp_file = NamedTempFile::new().map_err(|err| {
|
||||
error!("Failed to create temp file for reference video {}: {:?}", source.media_token, err);
|
||||
CommonWebError::server_error_with_message("failed to probe reference video duration")
|
||||
})?;
|
||||
|
||||
http_download_url_to_tempfile(&source.cdn_url, &mut temp_file)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
error!("Failed to download reference video {} for probing: {:?}", source.media_token, err);
|
||||
CommonWebError::server_error_with_message("failed to probe reference video duration")
|
||||
})?;
|
||||
|
||||
Ok(temp_file)
|
||||
}
|
||||
|
||||
fn probe_video_duration_millis(
|
||||
source: &ReferenceVideoSource,
|
||||
temp_file: &NamedTempFile,
|
||||
) -> Result<u64, CommonWebError> {
|
||||
let video_info = ffprobe_get_info(&temp_file.path()).map_err(|err| {
|
||||
error!("ffprobe failed for reference video {}: {:?}", source.media_token, err);
|
||||
CommonWebError::server_error_with_message("failed to probe reference video duration")
|
||||
})?;
|
||||
|
||||
video_info.duration
|
||||
.map(|duration| duration.millis as u64)
|
||||
.ok_or_else(|| {
|
||||
error!("ffprobe returned no duration for reference video {}", source.media_token);
|
||||
CommonWebError::server_error_with_message("failed to probe reference video duration")
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn durations_round_up_per_file_and_sum() {
|
||||
// 6.2s → 7, 7.0s → 7: total 14.
|
||||
let sources = [source("m_a", "https://cdn/a.mp4"), source("m_b", "https://cdn/b.mp4")];
|
||||
let seconds = seconds_map(&[("https://cdn/a.mp4", 6_200), ("https://cdn/b.mp4", 7_000)]);
|
||||
assert_eq!(total_billed_seconds(&sources, &seconds), 14);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_references_bill_per_reference() {
|
||||
// The same 9.001s file referenced twice bills 10 + 10 = 20.
|
||||
let sources = [source("m_a", "https://cdn/a.mp4"), source("m_a", "https://cdn/a.mp4")];
|
||||
let seconds = seconds_map(&[("https://cdn/a.mp4", 9_001)]);
|
||||
assert_eq!(total_billed_seconds(&sources, &seconds), 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_sources_sum_to_zero() {
|
||||
assert_eq!(total_billed_seconds(&[], &HashMap::new()), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn total_saturates_at_u16_max() {
|
||||
let sources = [source("m_a", "https://cdn/a.mp4")];
|
||||
let seconds = seconds_map(&[("https://cdn/a.mp4", u64::from(u32::MAX) * 1_000)]);
|
||||
assert_eq!(total_billed_seconds(&sources, &seconds), u16::MAX);
|
||||
}
|
||||
|
||||
fn source(token: &str, url: &str) -> ReferenceVideoSource {
|
||||
ReferenceVideoSource {
|
||||
media_token: MediaFileToken::new(token.to_string()),
|
||||
cdn_url: url.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn seconds_map(entries: &[(&str, u64)]) -> HashMap<String, u64> {
|
||||
entries
|
||||
.iter()
|
||||
.map(|(url, millis)| (url.to_string(), millis.div_ceil(1_000)))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
+2
@@ -55,6 +55,7 @@ pub async fn estimate_video_cost_handler(
|
||||
duration_seconds: request.duration_seconds,
|
||||
video_batch_count: request.video_batch_count,
|
||||
generate_audio: request.generate_audio,
|
||||
total_reference_video_input_seconds: None,
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayLessDowngrade,
|
||||
idempotency_token: None,
|
||||
};
|
||||
@@ -156,6 +157,7 @@ fn map_video_model(model: CommonVideoModel) -> Result<RouterVideoModel, HandlerE
|
||||
CommonVideoModel::Seedance2p0BytePlusMini => RouterVideoModel::Seedance2p0BytePlusMini,
|
||||
CommonVideoModel::Seedance2p0BytePlusUltraMini => RouterVideoModel::Seedance2p0BytePlusUltraMini,
|
||||
CommonVideoModel::Seedance2p5Preview => RouterVideoModel::Seedance2p5Preview,
|
||||
CommonVideoModel::Seedance2p5 => RouterVideoModel::Seedance2p5,
|
||||
CommonVideoModel::HappyHorse1p0 => RouterVideoModel::HappyHorse1p0,
|
||||
CommonVideoModel::MinimaxH3 => RouterVideoModel::MinimaxH3,
|
||||
CommonVideoModel::MinimaxH3Turbo => RouterVideoModel::MinimaxH3Turbo,
|
||||
|
||||
+3
@@ -66,6 +66,9 @@ pub fn hydrate_to_router_request(
|
||||
duration_seconds: request.duration_seconds,
|
||||
video_batch_count: request.video_batch_count,
|
||||
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,
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
|
||||
idempotency_token: request.idempotency_token.clone(),
|
||||
})
|
||||
|
||||
+35
-1
@@ -34,6 +34,9 @@ use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken;
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::endpoints::generate::common::generation_debug_logs::GenerationDebugLogContext;
|
||||
use crate::http_server::endpoints::generate::common::payments_error_test::payments_error_test;
|
||||
use crate::http_server::endpoints::generate::common::probed_reference_videos::{
|
||||
download_and_probe_reference_videos, fetch_reference_video_sources, ProbedReferenceVideos,
|
||||
};
|
||||
use crate::http_server::endpoints::omni_api::generate::video::check_request::check_request;
|
||||
use crate::http_server::endpoints::omni_api::generate::video::ingest_url_inputs::ingest_url_inputs;
|
||||
use crate::http_server::endpoints::omni_api::generate::video::helpers::hydrate_router_request::hydrate_to_router_request;
|
||||
@@ -181,9 +184,39 @@ pub async fn omni_api_video_generate_handler(
|
||||
&mut mysql_connection,
|
||||
).await?;
|
||||
|
||||
// ==================== REFERENCE VIDEO INPUT SECONDS ==================== //
|
||||
|
||||
// Seedance 2.5 bills reference-video input seconds on top of the output
|
||||
// duration. Stored durations can't be trusted for billing, so every
|
||||
// reference video is downloaded and ffprobed; the downloaded files are
|
||||
// kept and handed to the pipeline so the Kinovi upload reuses them
|
||||
// instead of downloading the same bytes twice.
|
||||
let mut maybe_probed_reference_videos: Option<ProbedReferenceVideos> = None;
|
||||
|
||||
if matches!(request.model, Some(CommonVideoModel::Seedance2p5)) {
|
||||
if let Some(video_tokens) = request.reference_video_media_tokens.as_deref().filter(|tokens| !tokens.is_empty()) {
|
||||
let video_sources = fetch_reference_video_sources(
|
||||
video_tokens,
|
||||
&http_request,
|
||||
server_state.server_environment,
|
||||
&mut mysql_connection,
|
||||
).await?;
|
||||
|
||||
// Downloads + ffprobe are slow — release the pool slot across them,
|
||||
// then re-acquire for billing.
|
||||
drop(mysql_connection);
|
||||
let probed = download_and_probe_reference_videos(&video_sources).await?;
|
||||
mysql_connection = server_state.mysql_pool.acquire().await?;
|
||||
|
||||
maybe_probed_reference_videos = Some(probed);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== HYDRATE ROUTER REQUEST ==================== //
|
||||
|
||||
let router_builder = hydrate_to_router_request(&request)?;
|
||||
let mut router_builder = hydrate_to_router_request(&request)?;
|
||||
router_builder.total_reference_video_input_seconds =
|
||||
maybe_probed_reference_videos.as_ref().map(|probed| probed.total_input_seconds);
|
||||
|
||||
// ==================== PIPELINE DISPATCH ==================== //
|
||||
|
||||
@@ -238,6 +271,7 @@ pub async fn omni_api_video_generate_handler(
|
||||
kinovi_character_id_map: &kinovi_character_id_map,
|
||||
kinovi_account,
|
||||
debug_log_context: &debug_log_context,
|
||||
predownloaded_media_paths: maybe_probed_reference_videos.as_ref().map(|probed| probed.local_paths_by_url()),
|
||||
mysql_connection,
|
||||
}).await;
|
||||
|
||||
|
||||
+11
@@ -1,6 +1,7 @@
|
||||
use std::convert::TryFrom;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use log::{error, info, warn};
|
||||
use artcraft_router::api::router_video_model::RouterVideoModel;
|
||||
@@ -35,6 +36,11 @@ pub struct RunPipelineV2Args<'a> {
|
||||
pub kinovi_character_id_map: &'a Option<HashMap<CharacterToken, String>>,
|
||||
pub kinovi_account: KinoviAccount,
|
||||
pub debug_log_context: &'a GenerationDebugLogContext<'a>,
|
||||
|
||||
/// Source URL → local file path for reference videos the handler already
|
||||
/// downloaded (for input-seconds billing). The Kinovi upload reads these
|
||||
/// instead of downloading the same bytes again.
|
||||
pub predownloaded_media_paths: Option<&'a HashMap<String, PathBuf>>,
|
||||
/// The handler's open connection. The pipeline uses it for its remaining
|
||||
/// pre-request DB writes (billing, outbound-request debug log) and releases
|
||||
/// it BEFORE the external provider call.
|
||||
@@ -54,6 +60,7 @@ pub async fn run_pipeline_v2(args: RunPipelineV2Args<'_>) -> Result<PipelineResu
|
||||
kinovi_character_id_map,
|
||||
kinovi_account,
|
||||
debug_log_context,
|
||||
predownloaded_media_paths,
|
||||
mut mysql_connection,
|
||||
} = args;
|
||||
|
||||
@@ -81,6 +88,7 @@ pub async fn run_pipeline_v2(args: RunPipelineV2Args<'_>) -> Result<PipelineResu
|
||||
RouterVideoModel::Seedance2p0BytePlusMini => RouterProvider::KinoviWeb,
|
||||
RouterVideoModel::Seedance2p0BytePlusUltraMini => RouterProvider::KinoviWeb,
|
||||
RouterVideoModel::Seedance2p5Preview => RouterProvider::KinoviWeb,
|
||||
RouterVideoModel::Seedance2p5 => RouterProvider::KinoviWeb,
|
||||
//RouterVideoModel::Seedance2p0Ultra => RouterProvider::GmiCloud,
|
||||
//RouterVideoModel::Seedance2p0UltraFast => RouterProvider::GmiCloud,
|
||||
RouterVideoModel::GrokImagineVideo => RouterProvider::GrokApi,
|
||||
@@ -181,6 +189,7 @@ pub async fn run_pipeline_v2(args: RunPipelineV2Args<'_>) -> Result<PipelineResu
|
||||
media_file_to_url_map.as_ref(),
|
||||
kinovi_character_id_map.as_ref(),
|
||||
kinovi_account,
|
||||
predownloaded_media_paths,
|
||||
).await;
|
||||
|
||||
// 5. On failure, refund wallet for Kinovi requests.
|
||||
@@ -219,6 +228,7 @@ async fn upload_and_generate(
|
||||
media_file_urls_by_token: Option<&HashMap<MediaFileToken, String>>,
|
||||
kinovi_character_ids: Option<&HashMap<CharacterToken, String>>,
|
||||
kinovi_account: KinoviAccount,
|
||||
predownloaded_media_paths: Option<&HashMap<String, PathBuf>>,
|
||||
) -> Result<GenerateVideoResponse, CommonWebError> {
|
||||
|
||||
let provider = draft_or_request.get_provider();
|
||||
@@ -231,6 +241,7 @@ async fn upload_and_generate(
|
||||
client: Some(&client),
|
||||
media_file_to_artcraft_url_map: media_file_urls_by_token,
|
||||
character_token_to_kinovi_id_map: kinovi_character_ids,
|
||||
predownloaded_media_paths,
|
||||
};
|
||||
|
||||
draft.finalize(draft_context)
|
||||
|
||||
+75
-6
@@ -1,11 +1,21 @@
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::endpoints::omni_gen::shared_utils::map_router_cost_error::map_router_cost_error;
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::helpers::hydrate_router_request::hydrate_to_router_request;
|
||||
use actix_web::web::Json;
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::web::{self, Json};
|
||||
use actix_web::HttpRequest;
|
||||
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
|
||||
use artcraft_api_defs::omni_gen::cost_response::omni_gen_video_cost_response::OmniGenVideoCostResponse;
|
||||
use artcraft_router::api::router_provider::RouterProvider;
|
||||
use enums::common::generation::common_video_model::CommonVideoModel;
|
||||
use log::warn;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::endpoints::generate::common::probed_reference_videos::{
|
||||
download_and_probe_reference_videos, fetch_reference_video_sources,
|
||||
};
|
||||
use crate::http_server::endpoints::omni_gen::shared_utils::map_router_cost_error::map_router_cost_error;
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::helpers::hydrate_router_request::hydrate_to_router_request;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
/// Estimate the cost of a video generation.
|
||||
#[utoipa::path(
|
||||
@@ -20,8 +30,9 @@ use artcraft_router::api::router_provider::RouterProvider;
|
||||
),
|
||||
)]
|
||||
pub async fn omni_gen_video_cost_handler(
|
||||
_http_request: HttpRequest,
|
||||
http_request: HttpRequest,
|
||||
request: Json<OmniGenVideoCostAndGenerateRequest>,
|
||||
maybe_server_state: Option<web::Data<Arc<ServerState>>>,
|
||||
) -> Result<Json<OmniGenVideoCostResponse>, CommonWebError> {
|
||||
// NB: Deliberately no input validation here. The UI polls this endpoint
|
||||
// while the user is still composing the request (no prompt typed, nothing
|
||||
@@ -31,6 +42,21 @@ pub async fn omni_gen_video_cost_handler(
|
||||
|
||||
builder.provider = RouterProvider::Artcraft; // NB: User is paying for ArtCraft credits / generation
|
||||
|
||||
// Seedance 2.5 bills reference-video input seconds on top of the output
|
||||
// duration; probe the combined runtime so the quote matches what
|
||||
// generation will bill. Best-effort: the UI polls mid-composition (tokens
|
||||
// may not resolve yet), so a probe failure quotes without input seconds
|
||||
// rather than failing — the generate endpoint bills the accurate total.
|
||||
if matches!(request.model, Some(CommonVideoModel::Seedance2p5)) {
|
||||
if let (Some(server_state), Some(video_tokens)) = (
|
||||
maybe_server_state.as_ref(),
|
||||
request.reference_video_media_tokens.as_deref().filter(|tokens| !tokens.is_empty()),
|
||||
) {
|
||||
builder.total_reference_video_input_seconds =
|
||||
probe_input_seconds_best_effort(&http_request, server_state, video_tokens).await;
|
||||
}
|
||||
}
|
||||
|
||||
let estimate = builder.build2()
|
||||
.map_err(map_router_cost_error)?
|
||||
.estimate_cost()
|
||||
@@ -48,6 +74,47 @@ pub async fn omni_gen_video_cost_handler(
|
||||
}))
|
||||
}
|
||||
|
||||
/// Probe the combined reference-video runtime for the quote, failing open.
|
||||
/// The downloaded files are dropped after probing — only the generate
|
||||
/// endpoint keeps them for the provider upload.
|
||||
async fn probe_input_seconds_best_effort(
|
||||
http_request: &HttpRequest,
|
||||
server_state: &ServerState,
|
||||
video_tokens: &[MediaFileToken],
|
||||
) -> Option<u16> {
|
||||
let video_sources = {
|
||||
// Scoped so the pool slot is released before the (slow) download+probe.
|
||||
let mut mysql_connection = match server_state.mysql_pool.acquire().await {
|
||||
Ok(connection) => connection,
|
||||
Err(err) => {
|
||||
warn!("Cost quote: failed to acquire connection for reference video probe: {:?}", err);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
match fetch_reference_video_sources(
|
||||
video_tokens,
|
||||
http_request,
|
||||
server_state.server_environment,
|
||||
&mut mysql_connection,
|
||||
).await {
|
||||
Ok(sources) => sources,
|
||||
Err(err) => {
|
||||
warn!("Cost quote: failed to fetch reference video sources: {:?}", err);
|
||||
return None;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
match download_and_probe_reference_videos(&video_sources).await {
|
||||
Ok(probed) => Some(probed.total_input_seconds),
|
||||
Err(err) => {
|
||||
warn!("Cost quote: failed to probe reference video durations: {:?}", err);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use actix_http::StatusCode;
|
||||
@@ -156,7 +223,9 @@ mod tests {
|
||||
let http_request = TestRequest::post()
|
||||
.uri("/v1/omni_gen/cost/video")
|
||||
.to_http_request();
|
||||
omni_gen_video_cost_handler(http_request, Json(body))
|
||||
// No ServerState in unit tests: the reference-video probe is skipped and
|
||||
// quotes come from the router alone.
|
||||
omni_gen_video_cost_handler(http_request, Json(body), None)
|
||||
.await
|
||||
.map(Json::into_inner)
|
||||
}
|
||||
|
||||
+3
@@ -66,6 +66,9 @@ pub fn hydrate_to_router_request(
|
||||
duration_seconds: request.duration_seconds,
|
||||
video_batch_count: request.video_batch_count,
|
||||
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,
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
|
||||
idempotency_token: request.idempotency_token.clone(),
|
||||
})
|
||||
|
||||
+35
-1
@@ -26,6 +26,9 @@ use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken;
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::endpoints::generate::common::generation_debug_logs::GenerationDebugLogContext;
|
||||
use crate::http_server::endpoints::generate::common::payments_error_test::payments_error_test;
|
||||
use crate::http_server::endpoints::generate::common::probed_reference_videos::{
|
||||
download_and_probe_reference_videos, fetch_reference_video_sources, ProbedReferenceVideos,
|
||||
};
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::first_party_minimax_h3::enqueue_first_party_minimax_h3_job::{
|
||||
enqueue_first_party_minimax_h3_job, first_party_minimax_h3_model, EnqueueFirstPartyMinimaxH3JobArgs,
|
||||
};
|
||||
@@ -164,9 +167,39 @@ pub async fn omni_gen_video_generate_handler(
|
||||
&mut mysql_connection,
|
||||
).await?;
|
||||
|
||||
// ==================== REFERENCE VIDEO INPUT SECONDS ==================== //
|
||||
|
||||
// Seedance 2.5 bills reference-video input seconds on top of the output
|
||||
// duration. Stored durations can't be trusted for billing, so every
|
||||
// reference video is downloaded and ffprobed; the downloaded files are
|
||||
// kept and handed to the pipeline so the Kinovi upload reuses them
|
||||
// instead of downloading the same bytes twice.
|
||||
let mut maybe_probed_reference_videos: Option<ProbedReferenceVideos> = None;
|
||||
|
||||
if matches!(request.model, Some(CommonVideoModel::Seedance2p5)) {
|
||||
if let Some(video_tokens) = request.reference_video_media_tokens.as_deref().filter(|tokens| !tokens.is_empty()) {
|
||||
let video_sources = fetch_reference_video_sources(
|
||||
video_tokens,
|
||||
&http_request,
|
||||
server_state.server_environment,
|
||||
&mut mysql_connection,
|
||||
).await?;
|
||||
|
||||
// Downloads + ffprobe are slow — release the pool slot across them,
|
||||
// then re-acquire for billing.
|
||||
drop(mysql_connection);
|
||||
let probed = download_and_probe_reference_videos(&video_sources).await?;
|
||||
mysql_connection = server_state.mysql_pool.acquire().await?;
|
||||
|
||||
maybe_probed_reference_videos = Some(probed);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== HYDRATE ROUTER REQUEST ==================== //
|
||||
|
||||
let router_builder = hydrate_to_router_request(&request)?;
|
||||
let mut router_builder = hydrate_to_router_request(&request)?;
|
||||
router_builder.total_reference_video_input_seconds =
|
||||
maybe_probed_reference_videos.as_ref().map(|probed| probed.total_input_seconds);
|
||||
|
||||
// ==================== PIPELINE DISPATCH ==================== //
|
||||
|
||||
@@ -249,6 +282,7 @@ pub async fn omni_gen_video_generate_handler(
|
||||
kinovi_character_id_map: &kinovi_character_id_map,
|
||||
kinovi_account,
|
||||
debug_log_context: &debug_log_context,
|
||||
predownloaded_media_paths: maybe_probed_reference_videos.as_ref().map(|probed| probed.local_paths_by_url()),
|
||||
mysql_connection,
|
||||
}).await;
|
||||
|
||||
|
||||
+12
@@ -1,6 +1,7 @@
|
||||
use std::convert::TryFrom;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use log::{error, info, warn};
|
||||
use artcraft_router::api::router_video_model::RouterVideoModel;
|
||||
@@ -35,6 +36,12 @@ pub struct RunPipelineV2Args<'a> {
|
||||
pub kinovi_character_id_map: &'a Option<HashMap<CharacterToken, String>>,
|
||||
pub kinovi_account: KinoviAccount,
|
||||
pub debug_log_context: &'a GenerationDebugLogContext<'a>,
|
||||
|
||||
/// Source URL → local file path for reference videos the handler already
|
||||
/// downloaded (for input-seconds billing). The Kinovi upload reads these
|
||||
/// instead of downloading the same bytes again.
|
||||
pub predownloaded_media_paths: Option<&'a HashMap<String, PathBuf>>,
|
||||
|
||||
/// The handler's open connection. The pipeline uses it for its remaining
|
||||
/// pre-request DB writes (billing, outbound-request debug log) and releases
|
||||
/// it BEFORE the external provider call.
|
||||
@@ -54,6 +61,7 @@ pub async fn run_pipeline_v2(args: RunPipelineV2Args<'_>) -> Result<PipelineResu
|
||||
kinovi_character_id_map,
|
||||
kinovi_account,
|
||||
debug_log_context,
|
||||
predownloaded_media_paths,
|
||||
mut mysql_connection,
|
||||
} = args;
|
||||
|
||||
@@ -81,6 +89,7 @@ pub async fn run_pipeline_v2(args: RunPipelineV2Args<'_>) -> Result<PipelineResu
|
||||
RouterVideoModel::Seedance2p0BytePlusMini => RouterProvider::KinoviWeb,
|
||||
RouterVideoModel::Seedance2p0BytePlusUltraMini => RouterProvider::KinoviWeb,
|
||||
RouterVideoModel::Seedance2p5Preview => RouterProvider::KinoviWeb,
|
||||
RouterVideoModel::Seedance2p5 => RouterProvider::KinoviWeb,
|
||||
//RouterVideoModel::Seedance2p0Ultra => RouterProvider::GmiCloud,
|
||||
//RouterVideoModel::Seedance2p0UltraFast => RouterProvider::GmiCloud,
|
||||
RouterVideoModel::GrokImagineVideo => RouterProvider::GrokApi,
|
||||
@@ -181,6 +190,7 @@ pub async fn run_pipeline_v2(args: RunPipelineV2Args<'_>) -> Result<PipelineResu
|
||||
media_file_to_url_map.as_ref(),
|
||||
kinovi_character_id_map.as_ref(),
|
||||
kinovi_account,
|
||||
predownloaded_media_paths,
|
||||
).await;
|
||||
|
||||
// 5. On failure, refund wallet for Kinovi requests.
|
||||
@@ -219,6 +229,7 @@ async fn upload_and_generate(
|
||||
media_file_urls_by_token: Option<&HashMap<MediaFileToken, String>>,
|
||||
kinovi_character_ids: Option<&HashMap<CharacterToken, String>>,
|
||||
kinovi_account: KinoviAccount,
|
||||
predownloaded_media_paths: Option<&HashMap<String, PathBuf>>,
|
||||
) -> Result<GenerateVideoResponse, CommonWebError> {
|
||||
|
||||
let provider = draft_or_request.get_provider();
|
||||
@@ -231,6 +242,7 @@ async fn upload_and_generate(
|
||||
client: Some(&client),
|
||||
media_file_to_artcraft_url_map: media_file_urls_by_token,
|
||||
character_token_to_kinovi_id_map: kinovi_character_ids,
|
||||
predownloaded_media_paths,
|
||||
};
|
||||
|
||||
draft.finalize(draft_context)
|
||||
|
||||
+23
-11
@@ -38,9 +38,19 @@ pub async fn lookup_media_files_as_cdn_url_list_and_map(
|
||||
});
|
||||
}
|
||||
|
||||
// The same media file can be referenced more than once in a request (e.g.
|
||||
// the same video as @video1 and @video2), so fetch each unique token once
|
||||
// and expand back over the requested order below.
|
||||
let mut unique_tokens: Vec<MediaFileToken> = Vec::new();
|
||||
for token in tokens {
|
||||
if !unique_tokens.contains(token) {
|
||||
unique_tokens.push(token.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let result = batch_get_media_files_by_tokens_with_connection(
|
||||
mysql_connection,
|
||||
tokens,
|
||||
&unique_tokens,
|
||||
CAN_SEE_DELETED,
|
||||
).await;
|
||||
|
||||
@@ -52,10 +62,10 @@ pub async fn lookup_media_files_as_cdn_url_list_and_map(
|
||||
}
|
||||
};
|
||||
|
||||
if media_files.len() != tokens.len() {
|
||||
warn!("Wrong number of media files returned for tokens: {} found for {} tokens", media_files.len(), tokens.len());
|
||||
if media_files.len() != unique_tokens.len() {
|
||||
warn!("Wrong number of media files returned for tokens: {} found for {} unique tokens", media_files.len(), unique_tokens.len());
|
||||
|
||||
let requested: HashSet<&MediaFileToken> = HashSet::from_iter(tokens.iter());
|
||||
let requested: HashSet<&MediaFileToken> = HashSet::from_iter(unique_tokens.iter());
|
||||
let returned: HashSet<&MediaFileToken> = HashSet::from_iter(media_files.iter().map(|m| &m.token));
|
||||
|
||||
let diff = requested.difference(&returned)
|
||||
@@ -63,13 +73,12 @@ pub async fn lookup_media_files_as_cdn_url_list_and_map(
|
||||
.collect::<Vec<&MediaFileToken>>();
|
||||
|
||||
return Err(CommonWebError::BadInputWithSimpleMessage(
|
||||
format!("Not all media files could be found. Media files found: {}, tokens provided: {}, in original: {:?}, req {:?}, ret {:?}",
|
||||
media_files.len(), tokens.len(), diff, requested, returned)));
|
||||
format!("Not all media files could be found. Media files found: {}, unique tokens provided: {}, in original: {:?}, req {:?}, ret {:?}",
|
||||
media_files.len(), unique_tokens.len(), diff, requested, returned)));
|
||||
}
|
||||
|
||||
let media_domain = get_media_domain(http_request);
|
||||
|
||||
let mut ordered_url_list = Vec::with_capacity(media_files.len());
|
||||
let mut token_to_url_map = HashMap::with_capacity(media_files.len());
|
||||
|
||||
for file in media_files {
|
||||
@@ -83,12 +92,15 @@ pub async fn lookup_media_files_as_cdn_url_list_and_map(
|
||||
server_environment,
|
||||
&public_bucket_path);
|
||||
|
||||
let url = media_links.cdn_url.to_string();
|
||||
|
||||
ordered_url_list.push(url.clone());
|
||||
token_to_url_map.insert(file.token, url);
|
||||
token_to_url_map.insert(file.token, media_links.cdn_url.to_string());
|
||||
}
|
||||
|
||||
// In requested order, with duplicates preserved.
|
||||
let ordered_url_list = tokens
|
||||
.iter()
|
||||
.filter_map(|token| token_to_url_map.get(token).cloned())
|
||||
.collect();
|
||||
|
||||
Ok(MediaFilesAsCdnUrlListAndMap {
|
||||
ordered_url_list,
|
||||
token_to_url_map,
|
||||
|
||||
Reference in New Issue
Block a user