Compare commits

...
Author SHA1 Message Date
Hanashi 2cfcb6131d default launch scripts to dev server 2026-08-08 19:16:32 -04:00
Hanashi 480c073c54 devex: easier to target backend in dev 2026-08-08 16:40:02 -04:00
hanashi 9646a791aa minimax feature (#1865) 2026-08-08 16:34:49 -04:00
BFlat 4d839d3b8b Library/video promptbox: bulk "Add tags" action + default video mode to Seedance 2.5 (#1864)
- Select multiple items in the library and add tags to all of them at
once
- Tag suggestions pop up as you type, based on your existing tags
- Fixed the selection bar looking squished (buttons no longer wrap onto
two lines)
- Video generation now defaults to Seedance 2.5 (was 2.0)
- Omni Reference is the default video input mode for everyone
2026-08-08 15:41:13 +07:00
hanashi b7f354face fix seedance 2.5 estimates (#1863) 2026-08-08 00:46:06 -04:00
hanashi c888a494b9 seedance 2.5 bindings (#1862) 2026-08-07 23:41:44 -04:00
Tanish Manku a048458956 3D: Animation clip blending (#1861) 2026-08-07 18:48:54 -07:00
81 changed files with 3994 additions and 330 deletions
@@ -69,4 +69,23 @@ pub struct OmniGenVideoCostAndGenerateRequest {
/// Some models will default this to true, others will default it to false,
/// so it's best to be explicit.
pub generate_audio: Option<bool>,
/// Frontend-supplied hints for cost estimation only. IGNORED by
/// generation — the generate endpoints measure inputs themselves (e.g.
/// ffprobing reference videos) and bill from those measurements.
pub estimate_only: Option<EstimateFields>,
}
/// These fields are ignored by generation. They're only used by the frontend
/// to aid in cost estimation (e.g. Seedance 2.5 bills reference-video input
/// seconds, so the cost quote needs the combined input duration without the
/// server downloading and probing the files on every poll).
#[derive(Clone, Copy, Serialize, Deserialize, ToSchema, Debug, Default)]
pub struct EstimateFields {
/// Combined duration of all reference video inputs, in milliseconds.
pub total_input_video_duration_millis: Option<u32>,
/// Combined duration of all reference audio inputs, in milliseconds.
/// (No model bills audio input seconds yet; accepted for future models.)
pub total_input_audio_duration_millis: Option<u32>,
}
@@ -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
@@ -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)]
@@ -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(),
}
}
@@ -82,6 +82,7 @@ pub fn build_artcraft_omni_video_request(
negative_prompt: None,
generate_audio: None,
quality: None,
estimate_only: None,
})
}
@@ -63,6 +63,7 @@ mod tests {
duration_seconds: Some(5),
video_batch_count: Some(1),
generate_audio: None,
estimate_only: None,
},
};
@@ -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;
@@ -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"),
}
}
}
@@ -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()
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -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
}
}
@@ -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,
@@ -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 {
@@ -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;
@@ -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>,
@@ -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(
@@ -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(
@@ -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(
@@ -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"),
}
}
}
@@ -0,0 +1,222 @@
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_the_worst_case_maximum() {
// Unknown input duration with video references attached: the kinovi
// estimator assumes the 30-second maximum so the provider-cost
// estimate never undershoots the actual charge.
assert_eq!(credits(Some(KinoviOutputResolution::FourEightyP), 10, true, None), 16 * 40);
}
#[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()
}
}
@@ -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 })
}
}
@@ -0,0 +1,4 @@
pub mod build;
pub mod draft;
pub mod request;
pub mod cost;
@@ -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,
}))
}
}
@@ -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?;
}
@@ -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,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))
},
}
}
}
@@ -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()
}
}
@@ -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,761 @@
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_the_worst_case_maximum() {
// Unknown input duration with video references attached: assume the
// 30-second maximum so the estimate never undershoots the charge.
assert_eq!(video_ref_480(10, None).calculate_costs().kinovi_credits, (16 * 40) as f64);
assert_eq!(video_ref_720(10, None).calculate_costs().kinovi_credits, (35 * 40) as f64);
}
#[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). Compare at
// zero input seconds — the unknown-duration default assumes the
// 30-second maximum, which would swamp the rate comparison.
assert!(video_ref_480(10, Some(0)).calculate_costs().kinovi_credits
< t2v_480(10).calculate_costs().kinovi_credits);
assert!(video_ref_720(10, Some(0)).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;
@@ -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
@@ -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),
}
}
@@ -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);
@@ -49,6 +49,14 @@ pub enum UserFeatureFlag {
/// Access to API key creation
#[serde(rename = "api_key")]
ApiKey,
/// Access to Minimax
#[serde(rename = "minimax")]
Minimax,
/// Access to Minimax with debugging priority (admins)
#[serde(rename = "minimax_p")]
MinimaxPriority,
}
// TODO(bt, 2022-12-21): This desperately needs MySQL integration tests!
@@ -69,6 +77,8 @@ impl UserFeatureFlag {
Self::HappyHorseRateLimit => "hh_rl",
Self::ReferralsProgram => "referrals",
Self::ApiKey => "api_key",
Self::Minimax => "minimax",
Self::MinimaxPriority => "minimax_p",
}
}
@@ -83,6 +93,8 @@ impl UserFeatureFlag {
"hh_rl" => Ok(Self::HappyHorseRateLimit),
"referrals" => Ok(Self::ReferralsProgram),
"api_key" => Ok(Self::ApiKey),
"minimax" => Ok(Self::Minimax),
"minimax_p" => Ok(Self::MinimaxPriority),
_ => Err(format!("invalid value: {:?}", value)),
}
}
@@ -98,6 +110,8 @@ impl UserFeatureFlag {
Self::HappyHorseRateLimit => "Happy Horse (with Rate Limit)",
Self::ReferralsProgram => "Referrals Program",
Self::ApiKey => "API Keys",
Self::Minimax => "Minimax",
Self::MinimaxPriority => "Minimax (Debugging Priority)",
}
}
@@ -112,6 +126,8 @@ impl UserFeatureFlag {
Self::HappyHorseRateLimit => "Access to Happy Horse (with a rate limit)",
Self::ReferralsProgram => "Access to the referrals program",
Self::ApiKey => "Access to API key creation",
Self::Minimax => "Access to Minimax",
Self::MinimaxPriority => "Access to Minimax with debugging priority (admins)",
}
}
@@ -128,6 +144,8 @@ impl UserFeatureFlag {
Self::HappyHorseRateLimit,
Self::ReferralsProgram,
Self::ApiKey,
Self::Minimax,
Self::MinimaxPriority,
])
}
}
@@ -151,6 +169,8 @@ mod tests {
assert_serialization(UserFeatureFlag::HappyHorseRateLimit, "hh_rl");
assert_serialization(UserFeatureFlag::ReferralsProgram, "referrals");
assert_serialization(UserFeatureFlag::ApiKey, "api_key");
assert_serialization(UserFeatureFlag::Minimax, "minimax");
assert_serialization(UserFeatureFlag::MinimaxPriority, "minimax_p");
}
#[test]
@@ -164,6 +184,8 @@ mod tests {
assert_eq!(UserFeatureFlag::HappyHorseRateLimit.to_str(), "hh_rl");
assert_eq!(UserFeatureFlag::ReferralsProgram.to_str(), "referrals");
assert_eq!(UserFeatureFlag::ApiKey.to_str(), "api_key");
assert_eq!(UserFeatureFlag::Minimax.to_str(), "minimax");
assert_eq!(UserFeatureFlag::MinimaxPriority.to_str(), "minimax_p");
}
#[test]
@@ -177,13 +199,15 @@ mod tests {
assert_eq!(UserFeatureFlag::from_str("hh_rl").unwrap(), UserFeatureFlag::HappyHorseRateLimit);
assert_eq!(UserFeatureFlag::from_str("referrals").unwrap(), UserFeatureFlag::ReferralsProgram);
assert_eq!(UserFeatureFlag::from_str("api_key").unwrap(), UserFeatureFlag::ApiKey);
assert_eq!(UserFeatureFlag::from_str("minimax").unwrap(), UserFeatureFlag::Minimax);
assert_eq!(UserFeatureFlag::from_str("minimax_p").unwrap(), UserFeatureFlag::MinimaxPriority);
assert!(UserFeatureFlag::from_str("foo").is_err());
}
#[test]
fn all_variants() {
let mut variants = UserFeatureFlag::all_variants();
assert_eq!(variants.len(), 9);
assert_eq!(variants.len(), 11);
assert_eq!(variants.pop_first(), Some(UserFeatureFlag::ExploreMedia));
assert_eq!(variants.pop_first(), Some(UserFeatureFlag::Studio));
assert_eq!(variants.pop_first(), Some(UserFeatureFlag::Upload3d));
@@ -193,6 +217,8 @@ mod tests {
assert_eq!(variants.pop_first(), Some(UserFeatureFlag::HappyHorseRateLimit));
assert_eq!(variants.pop_first(), Some(UserFeatureFlag::ReferralsProgram));
assert_eq!(variants.pop_first(), Some(UserFeatureFlag::ApiKey));
assert_eq!(variants.pop_first(), Some(UserFeatureFlag::Minimax));
assert_eq!(variants.pop_first(), Some(UserFeatureFlag::MinimaxPriority));
assert_eq!(variants.pop_first(), None);
}
}
@@ -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),
@@ -46,5 +46,81 @@ pub fn minimax_video_models() -> Vec<OmniGenVideoModelDetails> {
..Default::default()
});
models.push(OmniGenVideoModelDetails {
model: CommonVideoModel::MinimaxH3Turbo,
model_creator: Some(ModelCreator::Hailuo),
full_name: Some("MiniMax H3 Turbo".to_string()),
text_prompt_supported: Some(true),
text_prompt_max_length: Some(7000),
starting_keyframe_supported: Some(true),
ending_keyframe_supported: Some(true),
image_references_supported: Some(true),
image_references_max: Some(9),
video_references_supported: Some(true),
video_references_max: Some(3),
video_references_max_total_duration_seconds: Some(15),
audio_references_supported: Some(true),
audio_references_max: Some(3),
audio_references_max_total_duration_seconds: Some(15),
aspect_ratio_options: Some(vec![
CommonAspectRatio::WideTwentyOneByNine,
CommonAspectRatio::WideSixteenByNine,
CommonAspectRatio::WideFourByThree,
CommonAspectRatio::Square,
CommonAspectRatio::TallThreeByFour,
CommonAspectRatio::TallNineBySixteen,
]),
aspect_ratio_default: Some(CommonAspectRatio::WideSixteenByNine),
// The model renders 768P or 2K; 720p and below land on 768P, 1080p and
// above land on 2K.
resolution_options: Some(vec![
CommonResolution::SevenTwentyP,
CommonResolution::TwoK,
]),
resolution_default: Some(CommonResolution::TwoK),
duration_seconds_min: Some(5),
duration_seconds_max: Some(15),
duration_seconds_default: Some(5),
..Default::default()
});
models.push(OmniGenVideoModelDetails {
model: CommonVideoModel::MinimaxH3Ultra,
model_creator: Some(ModelCreator::Hailuo),
full_name: Some("MiniMax H3 Ultra".to_string()),
text_prompt_supported: Some(true),
text_prompt_max_length: Some(7000),
starting_keyframe_supported: Some(true),
ending_keyframe_supported: Some(true),
image_references_supported: Some(true),
image_references_max: Some(9),
video_references_supported: Some(true),
video_references_max: Some(3),
video_references_max_total_duration_seconds: Some(15),
audio_references_supported: Some(true),
audio_references_max: Some(3),
audio_references_max_total_duration_seconds: Some(15),
aspect_ratio_options: Some(vec![
CommonAspectRatio::WideTwentyOneByNine,
CommonAspectRatio::WideSixteenByNine,
CommonAspectRatio::WideFourByThree,
CommonAspectRatio::Square,
CommonAspectRatio::TallThreeByFour,
CommonAspectRatio::TallNineBySixteen,
]),
aspect_ratio_default: Some(CommonAspectRatio::WideSixteenByNine),
// The model renders 768P or 2K; 720p and below land on 768P, 1080p and
// above land on 2K.
resolution_options: Some(vec![
CommonResolution::SevenTwentyP,
CommonResolution::TwoK,
]),
resolution_default: Some(CommonResolution::TwoK),
duration_seconds_min: Some(5),
duration_seconds_max: Some(15),
duration_seconds_default: Some(5),
..Default::default()
});
models
}
@@ -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),
@@ -107,6 +107,14 @@ fn build_omni_gen_video_model_providers() -> Vec<OmniGenVideoModelProviderDetail
model: CommonVideoModel::MinimaxH3,
overrides: None,
},
OmniGenVideoProviderModelDetails {
model: CommonVideoModel::MinimaxH3Turbo,
overrides: None,
},
OmniGenVideoProviderModelDetails {
model: CommonVideoModel::MinimaxH3Ultra,
overrides: None,
},
OmniGenVideoProviderModelDetails {
model: CommonVideoModel::Flux3,
overrides: None,
@@ -984,6 +984,7 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
OmniGenSplatModelsResponse,
OmniGenSplatProviderModelDetails,
OmniGenVideoCostAndGenerateRequest,
EstimateFields,
OmniGenVideoCostResponse,
OmniGenVideoGenerateResponse,
OmniGenVideoModelDetails,
@@ -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;
@@ -0,0 +1,298 @@
//! 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).
//!
//! Probing NEVER fails a generation: a file whose download or ffprobe fails
//! is billed at the worst-case [`MAX_BILLED_INPUT_SECONDS`] instead (and is
//! not kept on disk — the provider upload re-downloads it itself).
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 kinovi_web_client::generate::video::generate_seedance_2p5::MAX_BILLED_INPUT_SECONDS;
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.
///
/// Infallible by design: a file that can't be downloaded or probed is billed
/// at the worst-case [`MAX_BILLED_INPUT_SECONDS`] rather than failing the
/// generation, and its temp file isn't kept (the provider upload re-downloads
/// files that aren't in the predownloaded map).
pub async fn download_and_probe_reference_videos(
sources: &[ReferenceVideoSource],
) -> ProbedReferenceVideos {
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 billed_seconds = match download_and_probe_one(source).await {
Ok((temp_file, duration_millis)) => {
info!("Probed reference video {} at {}ms", source.media_token, duration_millis);
local_paths_by_url.insert(source.cdn_url.clone(), temp_file.path().to_path_buf());
temp_files.push(temp_file);
duration_millis.div_ceil(1_000)
}
Err(err) => {
warn!(
"Failed to download/probe reference video {}; billing the {}s worst case: {:?}",
source.media_token, MAX_BILLED_INPUT_SECONDS, err,
);
u64::from(MAX_BILLED_INPUT_SECONDS)
}
};
seconds_by_url.insert(source.cdn_url.clone(), billed_seconds);
}
let total_input_seconds = total_billed_seconds(sources, &seconds_by_url);
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_and_probe_one(
source: &ReferenceVideoSource,
) -> Result<(NamedTempFile, u64), CommonWebError> {
let temp_file = download_reference_video(source).await?;
let duration_millis = probe_video_duration_millis(source, &temp_file)?;
Ok((temp_file, duration_millis))
}
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()
}
}
@@ -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,
@@ -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(),
})
@@ -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,40 @@ 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. Probing never fails the generation:
// unmeasurable files bill at the 30-second worst case.
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 +272,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;
@@ -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)
@@ -1,11 +1,22 @@
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::convert::TryFrom;
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 +31,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 +43,29 @@ 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, so the quote needs the combined input duration. Prefer the
// frontend-supplied `estimate_only` hint (cheap — the UI polls this
// endpoint while composing); fall back to a best-effort server-side
// download + ffprobe when it's absent. Either way this only shapes the
// QUOTE: the generate endpoint always measures the inputs itself and
// bills from its own measurement.
if matches!(request.model, Some(CommonVideoModel::Seedance2p5)) {
let frontend_input_seconds = request.estimate_only
.and_then(|estimate| estimate.total_input_video_duration_millis)
.map(millis_to_whole_seconds);
if let Some(input_seconds) = frontend_input_seconds {
builder.total_reference_video_input_seconds = Some(input_seconds);
} else 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 +83,48 @@ pub async fn omni_gen_video_cost_handler(
}))
}
/// Round a frontend-supplied millisecond duration UP to whole seconds,
/// saturating to `u16` (the router clamps to the model's max regardless).
fn millis_to_whole_seconds(millis: u32) -> u16 {
u16::try_from(u64::from(millis).div_ceil(1_000)).unwrap_or(u16::MAX)
}
/// 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;
}
}
};
// Infallible: unmeasurable files bill at the 30-second worst case.
Some(download_and_probe_reference_videos(&video_sources).await.total_input_seconds)
}
#[cfg(test)]
mod tests {
use actix_http::StatusCode;
@@ -156,7 +233,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)
}
@@ -180,6 +259,7 @@ mod tests {
duration_seconds: None,
video_batch_count: None,
generate_audio: None,
estimate_only: None,
}
}
}
@@ -90,6 +90,7 @@ mod tests {
duration_seconds,
video_batch_count: None,
generate_audio: None,
estimate_only: None,
};
estimate_minimax_h3_ultra_cost_usd_cents(&request)
}
@@ -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(),
})
@@ -168,6 +171,7 @@ mod tests {
duration_seconds: None,
video_batch_count: None,
generate_audio: None,
estimate_only: None,
}
}
}
@@ -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,40 @@ 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. Probing never fails the generation:
// unmeasurable files bill at the 30-second worst case.
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 +283,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;
@@ -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)
@@ -13,6 +13,13 @@ use artcraft_api_defs::omni_gen::models::omni_gen_video_models::{
use enums::common::generation::common_video_model::CommonVideoModel;
use log::warn;
/// Models hidden behind the Minimax feature flags. MinimaxH3 itself stays
/// available to everyone.
const MINIMAX_GATED_MODELS: [CommonVideoModel; 2] = [
CommonVideoModel::MinimaxH3Turbo,
CommonVideoModel::MinimaxH3Ultra,
];
/// List available video models.
#[utoipa::path(
get,
@@ -25,14 +32,60 @@ use log::warn;
),
)]
pub async fn omni_gen_video_models_handler(
_http_request: HttpRequest,
_server_state: web::Data<Arc<ServerState>>,
http_request: HttpRequest,
server_state: web::Data<Arc<ServerState>>,
_query: Query<OmniGenVideoModelsQuery>,
) -> Result<Json<OmniGenVideoModelsResponse>, CommonWebError> {
let mut response = (*OMNI_GEN_VIDEO_MODELS_AND_PROVIDERS).clone();
if !can_see_minimax(&http_request, &server_state).await? {
remove_models(&mut response, &MINIMAX_GATED_MODELS);
}
Ok(Json(response))
}
/// Whether the (optional) logged-in user holds a Minimax feature flag.
/// Anonymous requests see the ungated model list.
pub async fn can_see_minimax(
http_request: &HttpRequest,
server_state: &ServerState,
) -> Result<bool, CommonWebError> {
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
let maybe_user_session = server_state
.session_checker
.maybe_get_user_session_from_connection(&http_request, &mut mysql_connection)
.await
.map_err(|e| {
warn!("Session checker error: {:?}", e);
CommonWebError::from(e)
})?;
let user_session = match maybe_user_session {
Some(session) => session,
None => return Ok(false),
};
let user_feature_flags =
UserSessionFeatureFlags::new(user_session.maybe_feature_flags.as_deref());
let can_use_minimax = user_feature_flags.can_use_minimax();
let can_use_minimax_priority = user_feature_flags.can_use_minimax_priority();
Ok(can_use_minimax || can_use_minimax_priority)
}
/// Strip the given models from both the top-level model list and every
/// provider's model list.
fn remove_models(response: &mut OmniGenVideoModelsResponse, hidden_models: &[CommonVideoModel]) {
response.models.retain(|details| !hidden_models.contains(&details.model));
for provider in &mut response.providers {
provider.models.retain(|details| !hidden_models.contains(&details.model));
}
}
// NB: Keeping this for future reference if we need to feature gate other models.
pub async fn can_see_happy_horse(
http_request: &HttpRequest,
@@ -81,6 +81,7 @@ mod tests {
duration_seconds: Some(5),
video_batch_count: Some(1),
generate_audio: None,
estimate_only: None,
}
}
@@ -61,6 +61,7 @@ mod tests {
duration_seconds: Some(5),
video_batch_count: Some(1),
generate_audio: None,
estimate_only: None,
}
}
@@ -101,6 +101,14 @@ impl UserSessionFeatureFlags {
pub fn can_use_referrals_program(&self) -> bool {
self.feature_flags.contains(&UserFeatureFlag::ReferralsProgram)
}
pub fn can_use_minimax(&self) -> bool {
self.feature_flags.contains(&UserFeatureFlag::Minimax)
}
pub fn can_use_minimax_priority(&self) -> bool {
self.feature_flags.contains(&UserFeatureFlag::MinimaxPriority)
}
}
#[cfg(test)]
@@ -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,
@@ -137,7 +137,17 @@ export interface VideoCostParams {
hasEndFrame: boolean;
isReferenceMode: boolean;
referenceImageCount: number;
referenceVideoCount?: number;
generateAudio?: boolean;
/**
* Combined duration of the attached reference videos, in milliseconds.
* Models like Seedance 2.5 bill input seconds on top of the output
* duration, so the quote needs this (estimate-only; generation measures
* the real files server-side).
*/
totalInputVideoDurationMillis?: number | null;
/** Combined duration of the attached reference audio, in milliseconds. */
totalInputAudioDurationMillis?: number | null;
}
export function useVideoCostEstimate(params: VideoCostParams): number | null {
@@ -163,8 +173,15 @@ export function useVideoCostEstimate(params: VideoCostParams): number | null {
};
// Wire up frame/reference tokens based on mode
if (params.isReferenceMode && params.referenceImageCount > 0) {
body.reference_image_media_tokens = new Array(params.referenceImageCount).fill("placeholder");
if (params.isReferenceMode) {
if (params.referenceImageCount > 0) {
body.reference_image_media_tokens = new Array(params.referenceImageCount).fill("placeholder");
}
// Video references change the rate on models that bill input seconds
// (e.g. Seedance 2.5), so the quote must know they're attached.
if ((params.referenceVideoCount ?? 0) > 0) {
body.reference_video_media_tokens = new Array(params.referenceVideoCount).fill("placeholder");
}
} else {
if (params.hasStartFrame) {
body.start_frame_image_media_token = "placeholder";
@@ -174,6 +191,17 @@ export function useVideoCostEstimate(params: VideoCostParams): number | null {
}
}
// Estimate-only hints (ignored by generation).
if (
params.totalInputVideoDurationMillis != null ||
params.totalInputAudioDurationMillis != null
) {
body.estimate_only = {
total_input_video_duration_millis: params.totalInputVideoDurationMillis ?? null,
total_input_audio_duration_millis: params.totalInputAudioDurationMillis ?? null,
};
}
const api = new OmniGenApi();
api.estimateVideoCost(body).then(
(response) => {
@@ -200,7 +228,10 @@ export function useVideoCostEstimate(params: VideoCostParams): number | null {
params.hasEndFrame,
params.isReferenceMode,
params.referenceImageCount,
params.referenceVideoCount,
params.generateAudio,
params.totalInputVideoDurationMillis,
params.totalInputAudioDurationMillis,
]);
return credits;
+9 -5
View File
@@ -21,11 +21,15 @@ if (import.meta.env.DEV) {
StorytellerApiHostStore.getInstance().setApiSchemeAndHost(
window.location.origin,
);
// NB: This is for Brandon to test with storyteller-web locally:
// (disabled — it overrides the origin above and points at localhost:12345,
// which breaks dev unless a local backend is running. Re-enable only when
// testing against a local storyteller-web.)
//StorytellerApiHostStore.getInstance().setDevelopment();
// Backend devs: launch with USE_LOCAL_API=1 (see
// script/website/unix_frontend_webapp_dev.sh) to point API calls at a
// local storyteller-web (http://localhost:12345). When unset — the
// frontend-dev default — this branch never runs and API calls hit
// production. Do not comment this in or out; set the env var instead.
const useLocalApi = import.meta.env.VITE_USE_LOCAL_API;
if (useLocalApi === "1" || useLocalApi === "true") {
StorytellerApiHostStore.getInstance().setDevelopment();
}
} catch (e) {
console.warn("Failed to set dev API host override", e);
}
@@ -187,10 +187,11 @@ export const useCreateVideoStore = create<CreateVideoState>()(
}),
{
name: "artcraft-video-batches",
// Bumped to 1 when reference became the default input mode: the
// migration runs once for pre-existing persisted state and resets the
// stored mode so everyone lands on the new default.
version: 1,
// Bumped to 1 when reference became the default input mode, and to 2
// when Seedance 2.5 replaced 2.0 as the default model: each migration
// runs once for pre-existing persisted state so everyone lands on the
// new defaults.
version: 2,
migrate: (persisted, version) => {
const p = (persisted ?? {}) as {
batches?: VideoBatch[];
@@ -200,6 +201,16 @@ export const useCreateVideoStore = create<CreateVideoState>()(
if (version < 1) {
ui.inputMode = "reference";
}
if (version < 2) {
// Seedance 2.5 replaced 2.0 as the default: users still on the old
// default follow it (null resolves to DEFAULT_MODEL_ID at read
// time); explicit picks of other models are left alone. Everyone
// lands back on Omni Reference as the default input mode.
if (ui.selectedModelId === "seedance_2p0") {
ui.selectedModelId = null;
}
ui.inputMode = "reference";
}
return { batches: p.batches ?? [], ui };
},
// Persist prompt + lightweight settings alongside pending batches so a
@@ -88,7 +88,7 @@ import { toast } from "../../components/toast/toast";
// ── Constants ────────────────────────────────────────────────────────────
const DEFAULT_MODEL_ID = "seedance_2p0";
const DEFAULT_MODEL_ID = "seedance_2p5";
// Models where character @-mentions are intentionally held back in the UI for now,
// even though the API reports character_references_supported. Remove an entry to enable.
@@ -562,7 +562,19 @@ export default function CreateVideo() {
const lightbox = useLightboxNav(flatItems);
// Cost estimate
// Cost estimate. The input durations are estimate-only hints: models like
// Seedance 2.5 bill reference-video input seconds, and sending the
// durations lets the quote match what generation will bill (generation
// itself measures the real files server-side).
const totalInputVideoDurationMillis =
isReferenceMode && referenceVideos.length > 0
? referenceVideos.reduce((sum, vid) => sum + vid.duration, 0) * 1000
: undefined;
const totalInputAudioDurationMillis =
isReferenceMode && referenceAudios.length > 0
? referenceAudios.reduce((sum, aud) => sum + aud.duration, 0) * 1000
: undefined;
const estimatedCredits = useVideoCostEstimate({
model: selectedModel?.model ?? "",
aspectRatio: selectedSize,
@@ -574,7 +586,10 @@ export default function CreateVideo() {
hasEndFrame: !isReferenceMode && hasEndFrame && !!endFrameImage,
isReferenceMode,
referenceImageCount: isReferenceMode ? referenceImages.length : 0,
referenceVideoCount: isReferenceMode ? referenceVideos.length : 0,
generateAudio: hasSound ? generateWithSound : undefined,
totalInputVideoDurationMillis,
totalInputAudioDurationMillis,
});
// Character @-mentions are driven by the model's capability flag (set by the
@@ -622,10 +637,10 @@ export default function CreateVideo() {
activeCharacters,
]);
// Seedance 2.0 models support @-mention references in the prompt — once at
// Seedance 2.x models support @-mention references in the prompt — once at
// least one ref is attached, surface that in the placeholder.
const isSeedance2Model = (selectedModel?.model ?? "").startsWith(
"seedance_2p0",
"seedance_2",
);
const hasUploadedRefs =
referenceImages.length > 0 ||
@@ -36,6 +36,10 @@ interface LibraryTagsState {
loadTagMedia: (tagToken: string, reset?: boolean) => Promise<void>;
renameTag: (tagToken: string, newValue: string) => Promise<void>;
deleteTag: (tagToken: string) => Promise<void>;
/** Add tags to many media files at once (bulk selection bar). */
bulkAddTags: (mediaFileTokens: string[], tags: string[]) => Promise<boolean>;
/** Drop cached media for tags whose contents changed; the open tag reloads. */
invalidateTagMedia: (tagTokens: string[]) => void;
/** Merge canonical tags returned by add/set calls (fresh use counts). */
upsertTags: (tags: TagDetails[]) => void;
setRenameTarget: (tagToken: string | null) => void;
@@ -193,6 +197,40 @@ export const useLibraryTagsStore = create<LibraryTagsState>((set, get) => ({
}
},
bulkAddTags: async (mediaFileTokens, tags) => {
try {
const res = await tagsApi.BulkAddTags({ mediaFileTokens, tags });
if (!res.success || !res.data) {
toast.error(res.errorMessage || "Failed to add tags.");
return false;
}
// Fresh use counts for the sidebar; tagged listings must refetch.
get().upsertTags(res.data.tags);
get().invalidateTagMedia(res.data.tags.map((t) => t.tag_token));
const count = res.data.accepted_media_file_tokens.length;
toast.success(`Tagged ${count} ${count === 1 ? "item" : "items"}`);
return true;
} catch (err) {
toast.error(`Failed to add tags: ${errMsg(err)}`);
return false;
}
},
invalidateTagMedia: (tagTokens) => {
if (tagTokens.length === 0) return;
set((s) => {
const nextMedia = { ...s.tagMediaItems };
const nextHasMore = { ...s.tagHasMore };
for (const token of tagTokens) {
delete nextMedia[token];
delete nextHasMore[token];
}
return { tagMediaItems: nextMedia, tagHasMore: nextHasMore };
});
const active = get().activeTagToken;
if (active && tagTokens.includes(active)) get().loadTagMedia(active, true);
},
upsertTags: (incoming) => {
if (incoming.length === 0) return;
set((s) => {
@@ -25,6 +25,10 @@ import {
FOLDER_DROP_EVENT,
type GalleryItem,
} from "@storyteller/ui-gallery-modal";
import {
TagChipInput,
type TagSuggestion,
} from "@storyteller/ui-lightbox-modal";
import { PLACEHOLDER_IMAGES, is3DModelUrl } from "@storyteller/common";
import { is3DMediaClass } from "@storyteller/ui-generation-list";
import {
@@ -2077,6 +2081,11 @@ const SEND_TO_TARGETS: {
{ destination: "video", label: "Create video", icon: faVideo },
];
// Shared styling for the bar's action pills. `whitespace-nowrap` keeps labels
// on a single line — the bar grows instead of squishing buttons into two rows.
const BAR_BUTTON_CLASS =
"flex items-center gap-2 whitespace-nowrap rounded-full bg-ui-controls/60 px-3 py-1.5 text-sm font-medium text-white hover:bg-ui-controls/90 transition-colors";
interface BulkSelectionBarProps {
allItems: GalleryItem[];
folders: UiFolder[];
@@ -2100,6 +2109,9 @@ function BulkSelectionBar({
const navigate = useNavigate();
const [popoverOpen, setPopoverOpen] = useState(false);
const [sendToOpen, setSendToOpen] = useState(false);
const [tagsOpen, setTagsOpen] = useState(false);
const [tagChips, setTagChips] = useState<string[]>([]);
const [addingTags, setAddingTags] = useState(false);
const [downloadProgress, setDownloadProgress] = useState<{
done: number;
total: number;
@@ -2113,11 +2125,22 @@ function BulkSelectionBar({
[folders],
);
// Close the popover when navigating so it doesn't dangle over the new view.
// Close the popovers when navigating so they don't dangle over the new view.
useEffect(() => {
setPopoverOpen(false);
setTagsOpen(false);
}, [activeFolderId]);
// Autocomplete for the Add-tags popover: the user's tags, most-used first.
const storeTags = useLibraryTagsStore((s) => s.tags);
const tagSuggestions = useMemo(
(): TagSuggestion[] =>
[...storeTags]
.sort(compareTagsByUseCount)
.map((t) => ({ value: t.value, useCount: t.useCount })),
[storeTags],
);
// Resolve selected items from everything loaded (root library + folder/tag
// caches) — the selection survives navigation, so selected items may not be
// part of the currently displayed view.
@@ -2173,6 +2196,20 @@ function BulkSelectionBar({
}
};
const handleAddTags = async () => {
if (addingTags || tagChips.length === 0) return;
setAddingTags(true);
const ok = await useLibraryTagsStore
.getState()
.bulkAddTags(Array.from(ids), tagChips);
setAddingTags(false);
if (ok) {
setTagsOpen(false);
setTagChips([]);
clear();
}
};
return (
<div
data-no-marquee
@@ -2194,7 +2231,7 @@ function BulkSelectionBar({
</div>
)}
</div>
<span className="px-1 text-sm font-medium text-white/80">
<span className="whitespace-nowrap px-1 text-sm font-medium text-white/80">
{ids.size} selected
</span>
@@ -2206,7 +2243,7 @@ function BulkSelectionBar({
type="button"
title="Send to prompt"
onClick={() => setSendToOpen((v) => !v)}
className="flex items-center gap-2 rounded-full bg-ui-controls/60 px-3 py-1.5 text-sm font-medium text-white hover:bg-ui-controls/90 transition-colors"
className={BAR_BUTTON_CLASS}
>
<FontAwesomeIcon
icon={hasPromptImages ? faImage : faVideo}
@@ -2259,7 +2296,7 @@ function BulkSelectionBar({
clear();
navigate(`/frame-extractor?media=${token}`);
}}
className="flex items-center gap-2 rounded-full bg-ui-controls/60 px-3 py-1.5 text-sm font-medium text-white hover:bg-ui-controls/90 transition-colors"
className={BAR_BUTTON_CLASS}
>
<FontAwesomeIcon icon={faPhotoFilm} className="text-xs" />
<span className="hidden sm:inline">Extract frames</span>
@@ -2270,11 +2307,13 @@ function BulkSelectionBar({
<div className="relative">
<button
type="button"
title="Add to folder"
onClick={() => setPopoverOpen((v) => !v)}
className="flex items-center gap-2 rounded-full bg-ui-controls/60 px-3 py-1.5 text-sm font-medium text-white hover:bg-ui-controls/90 transition-colors"
className={BAR_BUTTON_CLASS}
>
<FontAwesomeIcon icon={faFolderPlus} className="text-xs" />
Add to folder
{/* Icon-only on phones — five labeled buttons overflow the bar. */}
<span className="hidden sm:inline">Add to folder</span>
</button>
{popoverOpen && (
<>
@@ -2334,11 +2373,61 @@ function BulkSelectionBar({
)}
</div>
{/* Add tags */}
<div className="relative">
<button
type="button"
title="Add tags"
onClick={() => {
setTagsOpen((v) => !v);
// Lazy-load the user's tags for autocomplete on first open.
if (!useLibraryTagsStore.getState().tagsLoaded) {
void useLibraryTagsStore.getState().loadTags();
}
}}
className={BAR_BUTTON_CLASS}
>
<FontAwesomeIcon icon={faTags} className="text-xs" />
<span className="hidden sm:inline">Add tags</span>
</button>
{tagsOpen && (
<>
<div
className="fixed inset-0 z-[59]"
onClick={() => setTagsOpen(false)}
/>
<div className="absolute bottom-full right-0 z-[60] mb-2 w-72 rounded-lg border border-ui-panel-border bg-ui-panel p-2 shadow-xl">
<div className="px-2 py-1 text-[11px] font-semibold uppercase tracking-wider text-white/40">
Add tags
</div>
<TagChipInput
chips={tagChips}
suggestions={tagSuggestions}
dropUp
onAdd={(values) => setTagChips((prev) => [...prev, ...values])}
onRemove={(value) =>
setTagChips((prev) => prev.filter((c) => c !== value))
}
/>
<Button
variant="primary"
disabled={tagChips.length === 0}
loading={addingTags}
onClick={handleAddTags}
className="mt-2 w-full justify-center rounded-md text-sm py-1.5"
>
Tag {ids.size} {ids.size === 1 ? "item" : "items"}
</Button>
</div>
</>
)}
</div>
<button
type="button"
onClick={handleDownloadSelected}
disabled={isDownloading}
className="flex items-center gap-2 rounded-full bg-ui-controls/60 px-3 py-1.5 text-sm font-medium text-white hover:bg-ui-controls/90 transition-colors disabled:opacity-60"
className={`${BAR_BUTTON_CLASS} disabled:opacity-60`}
>
<FontAwesomeIcon
icon={isDownloading ? faSpinnerThird : faArrowDownToLine}
@@ -2352,7 +2441,7 @@ function BulkSelectionBar({
<button
type="button"
onClick={onDeleteSelected}
className="flex items-center gap-2 rounded-full bg-red/90 px-3 py-1.5 text-sm font-medium text-white hover:bg-red transition-colors"
className="flex items-center gap-2 whitespace-nowrap rounded-full bg-red/90 px-3 py-1.5 text-sm font-medium text-white hover:bg-red transition-colors"
>
<FontAwesomeIcon icon={faTrashCan} className="text-xs" />
Delete
+9 -2
View File
@@ -21,8 +21,15 @@ if (import.meta.env.DEV) {
StorytellerApiHostStore.getInstance().setApiSchemeAndHost(
window.location.origin,
);
// NB: This is for Brandon to test with storyteller-web locally:
StorytellerApiHostStore.getInstance().setDevelopment();
// Backend devs: launch with USE_LOCAL_API=1 (see
// script/website/unix_frontend_website_dev.sh) to point API calls at a
// local storyteller-web (http://localhost:12345). When unset — the
// frontend-dev default — this branch never runs and API calls hit
// production. Do not comment this in or out; set the env var instead.
const useLocalApi = import.meta.env.VITE_USE_LOCAL_API;
if (useLocalApi === "1" || useLocalApi === "true") {
StorytellerApiHostStore.getInstance().setDevelopment();
}
} catch (e) {
console.warn("Failed to set dev API host override", e);
}
+10
View File
@@ -38,6 +38,15 @@ export interface OmniGenImageGenerateResponse {
// ── Video request / response types ───────────────────────────────────────
// Frontend-supplied hints for cost estimation only. IGNORED by generation —
// the backend measures inputs itself (e.g. ffprobing reference videos) when
// it bills. Seedance 2.5 bills reference-video input seconds, so its cost
// quote needs the combined input duration.
export interface OmniGenEstimateFields {
total_input_video_duration_millis?: number | null;
total_input_audio_duration_millis?: number | null;
}
export interface OmniGenVideoRequest {
model: string;
prompt?: string | null;
@@ -56,6 +65,7 @@ export interface OmniGenVideoRequest {
reference_audio_media_tokens?: string[] | null;
reference_character_tokens?: string[] | null;
negative_prompt?: string | null;
estimate_only?: OmniGenEstimateFields | null;
}
export interface OmniGenVideoCostResponse {
@@ -14,6 +14,9 @@ interface TagChipInputProps {
suggestions: TagSuggestion[];
/** Read-only mode: chips render without remove affordance or input. */
disabled?: boolean;
/** Open the suggestion dropdown above the input instead of below — for
* hosts anchored to the bottom of the viewport (e.g. the bulk action bar). */
dropUp?: boolean;
onAdd: (values: string[]) => void;
onRemove: (value: string) => void;
}
@@ -30,6 +33,7 @@ export function TagChipInput({
chips,
suggestions,
disabled,
dropUp,
onAdd,
onRemove,
}: TagChipInputProps) {
@@ -187,7 +191,11 @@ export function TagChipInput({
</div>
{dropdownOpen && (
<div className="absolute left-0 right-0 top-full z-30 mt-1 max-h-48 overflow-y-auto rounded-lg border border-ui-panel-border bg-ui-panel p-1 shadow-xl">
<div
className={`absolute left-0 right-0 z-30 max-h-48 overflow-y-auto rounded-lg border border-ui-panel-border bg-ui-panel p-1 shadow-xl ${
dropUp ? "bottom-full mb-1" : "top-full mt-1"
}`}
>
{filtered.map((suggestion, index) => (
<button
key={suggestion.value.toLowerCase()}
+26 -3
View File
@@ -306,9 +306,32 @@ then one row per character is the right model.
`DEFAULT_CLIP_DURATION` (1 s) instead of adopting the clip's natural length — long clips were
eating the whole row. The strip's on-timeline width is the **authoritative play window**
(`LaneRuntime.stripDuration` in `evaluateAt`); the clip's natural length only drives the loop
modulo / final-frame clamp inside it, so trimming a strip shorter genuinely cuts playback and a
non-loop strip trimmed longer than the clip freezes on the last frame. `resolveClipDuration` now
only SHRINKS an `autoDuration` strip when the clip is shorter than the default.
modulo / final-frame clamp inside it, so trimming a strip shorter genuinely cuts playback.
Strips default to **loop: true** — dragging a strip wider than the clip keeps the motion
cycling (a walk keeps walking, per tester expectation/old-studio behavior); the loop chip opts
a strip into play-once/hold-last-frame instead. `resolveClipDuration` only SHRINKS an
`autoDuration` strip when the clip is shorter than the default.
- **Gap transitions (opt-in blending)**: `ClipStrip.transitionEasing` on the LEADING strip
(presence = enabled; mirrors keyframes storing easing "into the next") cross-fades the strip's
exit pose into the next strip's entry pose across the gap between them. `evaluateAt` enables
BOTH actions pinned at their exit/entry frames with weights `(1−w, w)` from
`cubicBezierYForX` — weights sum to 1 so the mixer computes a proper lerp; this is the one
deliberate exception to the one-winner rule, and it's continuous at both gap edges (w=0 at the
leading strip's end, w=1 at the next strip's start). Gaps without a transition keep the rest
pose; leading/trailing row gaps never blend. UI: a `data-transition-chip` button at EVERY
consecutive-strip boundary (ghost = off, solid = on) — auto-placement packs strips flush, so
enabling on a flush/too-tight boundary first calls `ensureTransitionGap`
(`DEFAULT_TRANSITION_GAP` = 0.3s: shift the next strip right, bounded by its follower/timeline
end, then trim the leading strip if shifting isn't enough; a too-packed row rejects with a
toast and mutates nothing). Click enables with `DEFAULT_EASING` and opens the generalized
`MotionPopover` (`easing`/`onChange`/`footer` props — keyframes use the same component) with a
"Remove transition" footer; popover selection rides `timelineEasingClipLaneId` — bridge-pruned
both when the lane vanishes AND when it loses its transition with the id intact (Cancel /
undo-of-Save revert the easing wholesale; an open popover would otherwise re-create it on the
next curve drag) — and the keyframe popover takes render priority so the two never coexist.
Edits ride the Save/Cancel session; serialization needs no special casing (`transitionEasing`
rides `TimelineData` through every wholesale-clone path: scene JSON save/load, Save/Cancel
snapshots, SaveTimelineAction, RemoveClipLaneAction restore).
- **Strip selection + delete UX**: strips are click-to-select
(`timelineSelectedClipLaneId` store field, `data-clip-strip` exempts the pointerdown from the
click-away deselect). The **× renders only on the selected strip** (always-visible was too easy
@@ -206,6 +206,9 @@ interface PageSceneState {
// Motion popover (opened from the curve chip BETWEEN two keyframes).
// Distinct from timelineSelectedKeyframeId, which drives selection/delete.
timelineEasingKeyframeId: string | null;
// Leading clip lane whose gap-transition curve is being edited in the
// Motion popover (opened from the chip in the gap between two strips).
timelineEasingClipLaneId: string | null;
// Object whose timeline row should be scrolled into view. Set (with
// timelineExpanded) after a clip is added; consumed and cleared by an
// effect in TimelineEditor. A store field because TimelineEditor is
@@ -332,6 +335,7 @@ interface PageSceneState {
setTimelineSelectedKeyframe: (id: string | null) => void;
setTimelineSelectedClipLane: (id: string | null) => void;
setTimelineEasingKeyframe: (id: string | null) => void;
setTimelineEasingClipLane: (id: string | null) => void;
setTimelineRevealObjectUuid: (uuid: string | null) => void;
setTimelineFocusSelected: (focus: boolean) => void;
setProducedArtifact: (artifact: ProducedArtifact | null) => void;
@@ -433,6 +437,7 @@ export const usePageSceneStore = create<PageSceneState>((set, get) => ({
timelineSelectedKeyframeId: null,
timelineSelectedClipLaneId: null,
timelineEasingKeyframeId: null,
timelineEasingClipLaneId: null,
timelineRevealObjectUuid: null,
timelineFocusSelected: false,
producedArtifact: null,
@@ -546,6 +551,7 @@ export const usePageSceneStore = create<PageSceneState>((set, get) => ({
setTimelineSelectedClipLane: (id) =>
set({ timelineSelectedClipLaneId: id }),
setTimelineEasingKeyframe: (id) => set({ timelineEasingKeyframeId: id }),
setTimelineEasingClipLane: (id) => set({ timelineEasingClipLaneId: id }),
setTimelineRevealObjectUuid: (uuid) =>
set({ timelineRevealObjectUuid: uuid }),
setTimelineFocusSelected: (focus) => set({ timelineFocusSelected: focus }),
@@ -76,6 +76,10 @@ export function setKeyframeEasing(
// row has no free slot. Strips start at the compact DEFAULT_CLIP_DURATION
// width (the play window — trim wider to reveal more of the clip); once the
// GLB loads the strip shrinks if the clip is shorter (autoDuration).
// Strips default to LOOPING: dragging a strip wider than the clip's natural
// length keeps the motion cycling (a walk keeps walking), matching the old
// studio's behavior — the strip's loop chip opts into play-once/hold-last-
// frame instead.
// A successful add expands the timeline and scrolls its row list to the
// character (timelineRevealObjectUuid, consumed by TimelineEditor) so the
// new strip is immediately visible.
@@ -92,7 +96,7 @@ export function addClipToCharacter(
name: item.name ?? "Animation",
startTime,
duration: DEFAULT_CLIP_DURATION,
loop: false,
loop: true,
autoDuration: true,
});
if (laneId) {
@@ -125,7 +129,8 @@ export function addBakedClipToObject(
name: clip.name || `Clip ${clipIndex + 1}`,
startTime: atTime,
duration: Math.min(clip.duration || DEFAULT_CLIP_DURATION, DEFAULT_CLIP_DURATION),
loop: false,
// Loop by default, same as library strips (see addClipToCharacter).
loop: true,
});
}
@@ -153,6 +158,26 @@ export function setClipLoop(
editor.timelineController.setClipLoop(laneId, loop);
}
// Set (or clear, with null) the opt-in pose transition from `laneId`'s strip
// into the next strip on its row.
export function setClipTransitionEasing(
editor: Editor,
laneId: string,
easing: EasingSpec | null,
): void {
editor.timelineController.setClipTransitionEasing(laneId, easing);
}
// Open a gap after `laneId`'s strip for a transition to play in, when its
// boundary with the next strip is flush/too tight. Returns false when the
// row is too packed to make room (callers toast).
export function ensureClipTransitionGap(
editor: Editor,
laneId: string,
): boolean {
return editor.timelineController.ensureTransitionGap(laneId);
}
// Individually undoable (RemoveClipLaneAction) — both the strip's × button
// and Del/Backspace route through here.
export function removeClipLane(editor: Editor, laneId: string): void {
@@ -1,16 +1,15 @@
import { useContext, useRef, useState } from "react";
import { EngineContext } from "../../contexts/EngineContext/EngineContext";
import { setKeyframeEasing } from "../../actions";
import { ReactNode, useRef, useState } from "react";
import {
EASING_PRESETS,
type EasingPresetName,
type EasingSpec,
type Keyframe,
} from "../../engine/timeline/types";
// The Motion popover edits a keyframe's easing curve (the interpolation
// INTO the next keyframe). Presets across the top, an editable cubic-bezier
// curve below with two draggable handles and a dotted linear reference.
// The Motion popover edits a cubic-bezier easing curve. Presets across the
// top, an editable curve below with two draggable handles and a dotted
// linear reference. Presentational: callers own the wiring — keyframe
// easing (TimelineEditor) and clip gap-transitions (TimelineEditor via the
// gap chips in TimelineClipRow) both reuse it through `easing`/`onChange`.
const BOX = 150; // svg drawing box (px), domain [0,1]×[0,1]
const PAD = 14; // padding so handles/labels aren't clipped
@@ -28,21 +27,26 @@ const PRESETS: { name: EasingPresetName; label: string }[] = [
];
interface MotionPopoverProps {
keyframe: Keyframe;
easing: EasingSpec;
onChange: (next: EasingSpec) => void;
// Horizontal anchor as a percentage across the editor (playhead/keyframe pos).
leftPercent: number;
title?: string;
// Optional extra row under the curve (e.g. "Remove transition").
footer?: ReactNode;
}
export const MotionPopover = ({ keyframe, leftPercent }: MotionPopoverProps) => {
const editor = useContext(EngineContext);
export const MotionPopover = ({
easing,
onChange,
leftPercent,
title = "Motion",
footer,
}: MotionPopoverProps) => {
const svgRef = useRef<SVGSVGElement>(null);
const [dragging, setDragging] = useState<null | "p1" | "p2">(null);
const easing = keyframe.easing;
const commit = (next: EasingSpec) => {
if (editor) setKeyframeEasing(editor, keyframe.id, next);
};
const commit = onChange;
const pointerToNorm = (e: React.PointerEvent) => {
const svg = svgRef.current;
@@ -76,7 +80,7 @@ export const MotionPopover = ({ keyframe, leftPercent }: MotionPopoverProps) =>
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<div className="mb-2 text-xs font-semibold text-base-fg/80">Motion</div>
<div className="mb-2 text-xs font-semibold text-base-fg/80">{title}</div>
<div className="mb-3 grid grid-cols-4 gap-1">
{PRESETS.map((p) => {
const preset = EASING_PRESETS[p.name];
@@ -182,6 +186,7 @@ export const MotionPopover = ({ keyframe, leftPercent }: MotionPopoverProps) =>
}}
/>
</svg>
{footer}
</div>
);
};
@@ -2,6 +2,7 @@ import { useContext, useEffect, useRef, useState } from "react";
import ReactDOM from "react-dom";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faBezierCurve,
faFilm,
faPlus,
faRepeat,
@@ -10,16 +11,21 @@ import {
import { EngineContext } from "../../contexts/EngineContext/EngineContext";
import {
addBakedClipToObject,
ensureClipTransitionGap,
moveClipLane,
removeClipLane,
resizeClipLane,
setClipLoop,
setClipTransitionEasing,
} from "../../actions";
import {
usePageSceneStore,
type ClipLane,
} from "../../PageSceneStore";
import { DEFAULT_TIMELINE_FPS } from "../../engine/timeline/types";
import {
DEFAULT_EASING,
DEFAULT_TIMELINE_FPS,
} from "../../engine/timeline/types";
import { ToastTypes } from "../../enums";
import { fractionToTime, quantizeToFrame, timeToFraction } from "./timelineUtils";
@@ -84,6 +90,34 @@ export const TimelineClipRow = ({
};
}, [pickerAnchor]);
// Opt-in gap transition: first click on a boundary chip enables the blend
// (default curve) and opens the Motion popover; on an active chip it
// toggles the popover. Flush/too-tight boundaries (the default — auto-
// placement packs strips back-to-back) first get a small gap opened for
// the blend to play in; a row too packed to make room rejects with a
// toast. Keyframe-easing selection is cleared so the two popovers never
// contend.
const toggleTransition = (lane: ClipLane) => {
if (!editor) return;
const store = usePageSceneStore.getState();
if (store.timelineEasingClipLaneId === lane.id) {
store.setTimelineEasingClipLane(null);
return;
}
if (!lane.strip.transitionEasing) {
if (!ensureClipTransitionGap(editor, lane.id)) {
editor.adapter.showToast(
ToastTypes.WARNING,
"No room on this row to open a gap for the transition.",
);
return;
}
setClipTransitionEasing(editor, lane.id, DEFAULT_EASING);
}
store.setTimelineEasingKeyframe(null);
store.setTimelineEasingClipLane(lane.id);
};
const addBaked = (clipIndex: number) => {
setPickerAnchor(null);
if (!editor) return;
@@ -295,6 +329,47 @@ export const TimelineClipRow = ({
</div>
);
})}
{/* Opt-in gap transitions: a chip at every consecutive-strip
boundary enables/edits the pose blend across the gap
(transitionEasing on the LEADING strip). Auto-placement packs
strips flush, so the chip renders even at a zero gap — enabling
there first opens a small gap (toggleTransition) for the blend
to play in. data-transition-chip exempts it from the popover's
click-away. */}
{[...lanes]
.sort((a, b) => a.strip.startTime - b.strip.startTime)
.map((lane, i, sorted) => {
const next = sorted[i + 1];
if (!next) return null;
const gapStart = lane.strip.startTime + lane.strip.duration;
const gapEnd = next.strip.startTime;
const active = !!lane.strip.transitionEasing;
const midPct =
timeToFraction((gapStart + gapEnd) / 2, duration) * 100;
return (
<button
key={`transition-${lane.id}`}
type="button"
data-transition-chip
title={
active
? "Edit transition into the next clip"
: "Blend into the next clip"
}
className={`absolute top-1/2 z-10 flex h-4 w-4 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border transition-colors ${
active
? "border-brand-primary bg-brand-primary text-white"
: "border-dashed border-white/30 bg-black/30 text-white/40 hover:border-white/60 hover:text-white/80"
}`}
style={{ left: `${midPct}%` }}
onPointerDown={(e) => e.stopPropagation()}
onClick={() => toggleTransition(lane)}
>
<FontAwesomeIcon icon={faBezierCurve} className="h-2 w-2" />
</button>
);
})}
</div>
{/* spacer to match the keyframe row's add-button column (keeps lane
@@ -20,9 +20,14 @@ import {
removeClipLane,
saveTimeline,
seekTimeline,
setClipTransitionEasing,
setKeyframeEasing,
} from "../../actions";
import { usePageSceneStore } from "../../PageSceneStore";
import { DEFAULT_TIMELINE_FPS } from "../../engine/timeline/types";
import {
DEFAULT_EASING,
DEFAULT_TIMELINE_FPS,
} from "../../engine/timeline/types";
import {
formatTimecode,
formatTimecodeFrames,
@@ -125,6 +130,34 @@ export const TimelineEditor = () => {
}
}
// Clip gap-transition popover: anchored at the midpoint of the gap between
// the leading strip (which stores transitionEasing) and the next strip on
// its row. Same lane-area coordinate system as the keyframe popover.
const easingClipLaneId = usePageSceneStore((s) => s.timelineEasingClipLaneId);
const clipTransitionAnchor = (() => {
if (!easingClipLaneId) return null;
const lead = clipLanes.find((l) => l.id === easingClipLaneId);
if (!lead) return null;
const leadEnd = lead.strip.startTime + lead.strip.duration;
// Epsilon matches TimelineController.ensureTransitionGap: a strip
// trimmed flush can put leadEnd a float hair past the neighbour's start.
const next = clipLanes
.filter(
(l) =>
l.objectUuid === lead.objectUuid &&
l.id !== lead.id &&
l.strip.startTime >= leadEnd - 1e-6,
)
.sort((a, b) => a.strip.startTime - b.strip.startTime)[0];
if (!next) return null;
return {
laneId: lead.id,
easing: lead.strip.transitionEasing ?? DEFAULT_EASING,
leftPercent:
timeToFraction((leadEnd + next.strip.startTime) / 2, duration) * 100,
};
})();
// Selected keyframe: Delete/Backspace removes it (capture phase, so the
// engine's document-level Delete — which deletes the selected scene
// object — never sees the key), and clicking anywhere that isn't a
@@ -268,6 +301,25 @@ export const TimelineEditor = () => {
document.removeEventListener("pointerdown", onPointerDown, true);
}, [easingKeyframeId]);
// Same dismissal for the clip gap-transition popover (its chips carry
// data-transition-chip).
useEffect(() => {
if (!easingClipLaneId) return undefined;
const onPointerDown = (e: PointerEvent) => {
const target = e.target as HTMLElement | null;
if (
target?.closest("#motion-popover") ||
target?.closest("[data-transition-chip]")
) {
return;
}
usePageSceneStore.getState().setTimelineEasingClipLane(null);
};
document.addEventListener("pointerdown", onPointerDown, true);
return () =>
document.removeEventListener("pointerdown", onPointerDown, true);
}, [easingClipLaneId]);
const playheadFraction = timeToFraction(playhead, duration);
const togglePlay = () => {
@@ -496,18 +548,61 @@ export const TimelineEditor = () => {
</div>
{/* Anchor the popover in the lane area (same geometry as the
playhead overlay) so its left % lines up with the easing chip. */}
{easingAnchor && (
playhead overlay) so its left % lines up with the easing chip.
Keyframe easing takes priority over a clip transition so the two
popovers (which share #motion-popover) never render together. */}
{easingAnchor ? (
<div
className="pointer-events-none absolute inset-y-0"
style={{ left: LANE_LEFT, right: LANE_RIGHT }}
>
<MotionPopover
keyframe={easingAnchor.keyframe}
easing={easingAnchor.keyframe.easing}
onChange={(next) =>
editor &&
setKeyframeEasing(editor, easingAnchor.keyframe.id, next)
}
leftPercent={easingAnchor.leftPercent}
/>
</div>
)}
) : clipTransitionAnchor ? (
<div
className="pointer-events-none absolute inset-y-0"
style={{ left: LANE_LEFT, right: LANE_RIGHT }}
>
<MotionPopover
title="Transition"
easing={clipTransitionAnchor.easing}
onChange={(next) =>
editor &&
setClipTransitionEasing(
editor,
clipTransitionAnchor.laneId,
next,
)
}
leftPercent={clipTransitionAnchor.leftPercent}
footer={
<button
type="button"
className="mt-2 w-full rounded-md border border-ui-controls-border/60 px-2 py-1.5 text-[11px] text-base-fg/80 transition-colors hover:bg-ui-controls/40"
onClick={() => {
if (editor) {
setClipTransitionEasing(
editor,
clipTransitionAnchor.laneId,
null,
);
}
usePageSceneStore.getState().setTimelineEasingClipLane(null);
}}
>
Remove transition
</button>
}
/>
</div>
) : null}
</div>
{/* footer */}
@@ -170,6 +170,21 @@ export class EngineStoreBridge {
) {
store.setTimelineSelectedClipLane(null);
}
// The transition popover must close not only when its lane vanishes
// but also when the lane LOSES its transition while surviving —
// Cancel and undo-of-a-Save revert transitionEasing wholesale with
// lane ids intact, and a popover left open would silently re-create
// the reverted transition on the next curve drag. (The enable flow
// is safe: toggleTransition sets the easing BEFORE selecting the
// lane, so this prune never races it.)
if (store.timelineEasingClipLaneId) {
const easingLane = e.clipLanes.find(
(l) => l.id === store.timelineEasingClipLaneId,
);
if (!easingLane || !easingLane.strip.transitionEasing) {
store.setTimelineEasingClipLane(null);
}
}
}),
bus.subscribe(TimelinePlayheadEvent, (e) => {
const store = usePageSceneStore.getState();
@@ -15,7 +15,8 @@
import * as THREE from "three";
import type Editor from "../editor";
import type { ClipLane } from "../timeline/types";
import type { ClipLane, EasingSpec } from "../timeline/types";
import { cubicBezierYForX } from "../timeline/interpolation";
// Per-lane runtime state. `action` is null while the clip GLB is still
// loading — or TERMINALLY, when the lane can't bind (see `boundRoot`).
@@ -38,6 +39,8 @@ interface LaneRuntime {
// uuid differs from boundRoot (object appeared, vanished, or was replaced
// under the same uuid) — never on a timer or per-refresh retry loop.
boundRoot: THREE.Object3D | null;
// Opt-in transition curve into the NEXT strip on the row (see ClipStrip).
transitionEasing?: EasingSpec;
}
// Captured local transform of one node, for rest-pose restoration.
@@ -78,10 +81,11 @@ export class CharacterAnimationManager {
if (!rt) {
void this.addLane(lane);
} else {
// Placement and trim can change without a reload.
// Placement, trim and transition can change without a reload.
rt.startTime = lane.strip.startTime;
rt.loop = lane.strip.loop;
rt.stripDuration = lane.strip.duration;
rt.transitionEasing = lane.strip.transitionEasing;
}
}
this.pruneMixers();
@@ -93,48 +97,93 @@ export class CharacterAnimationManager {
// Characters that have a clip covering this playhead. Everyone else is
// reset to their bind (T) pose below.
const posed = new Set<string>();
// Pick ONE winning lane per character first. Strip intervals are closed
// at both ends so seek-to-end still holds a final pose, but strips are
// deliberately snapped flush (resolveFreeStart), so at a shared boundary
// — A ends exactly where B starts, and scrubs/records land on exact
// frame times — both would qualify. Enabling both blends A's last frame
// with B's first at half weight each, a garbage pose the deterministic
// recorder would encode into video. The LATER strip (smaller local time)
// wins the boundary.
const winners = new Map<string, LaneRuntime>();
// Process per character. Inside a strip, exactly ONE lane wins: strip
// intervals are closed at both ends (seek-to-end holds a final pose) and
// strips snap flush, so at a shared boundary the LATER strip (smaller
// local time) wins — enabling both would blend A's last frame with B's
// first at half weight each, a garbage pose the deterministic recorder
// would encode into video. In a GAP, the OPT-IN transition on the
// leading strip (transitionEasing) cross-fades its exit pose into the
// next strip's entry pose — the one deliberate two-action case; weights
// sum to 1, so the mixer computes a proper lerp.
const byCharacter = new Map<string, LaneRuntime[]>();
for (const rt of this.lanes.values()) {
const action = rt.action;
if (!action) continue;
const local = playhead - rt.startTime;
if (local < 0 || local > rt.stripDuration) {
action.enabled = false;
continue;
}
const incumbent = winners.get(rt.characterUuid);
if (!incumbent) {
winners.set(rt.characterUuid, rt);
continue;
}
if (local < playhead - incumbent.startTime) {
incumbent.action!.enabled = false;
winners.set(rt.characterUuid, rt);
} else {
action.enabled = false;
}
if (!rt.action) continue;
const list = byCharacter.get(rt.characterUuid);
if (list) list.push(rt);
else byCharacter.set(rt.characterUuid, [rt]);
}
for (const rt of winners.values()) {
let local = playhead - rt.startTime;
// The strip's on-timeline width is the play window; the clip's natural
// length only drives the loop modulo / final-frame clamp inside it.
const dur = rt.clipDuration;
if (rt.loop && dur > 0) local = local % dur;
else local = Math.min(local, dur);
const action = rt.action!;
action.enabled = true;
action.paused = true; // we drive time ourselves; don't auto-advance
action.time = local;
action.setEffectiveWeight(1);
posed.add(rt.characterUuid);
for (const [uuid, lanes] of byCharacter) {
lanes.sort((a, b) => a.startTime - b.startTime);
let winner: LaneRuntime | null = null;
for (const rt of lanes) {
const local = playhead - rt.startTime;
if (local < 0 || local > rt.stripDuration) continue;
if (!winner || local < playhead - winner.startTime) winner = rt;
}
// Gap: find the bracketing strips; blend only when the leading one
// opted in and the gap has real width.
let blendPrev: LaneRuntime | null = null;
let blendNext: LaneRuntime | null = null;
let blendWeight = 0;
if (!winner) {
let prev: LaneRuntime | null = null;
let next: LaneRuntime | null = null;
for (const rt of lanes) {
if (rt.startTime + rt.stripDuration <= playhead) prev = rt;
if (next === null && rt.startTime >= playhead) next = rt;
}
if (prev?.transitionEasing && next) {
const prevEnd = prev.startTime + prev.stripDuration;
const span = next.startTime - prevEnd;
if (span > 1e-6) {
const progress = (playhead - prevEnd) / span;
blendWeight = Math.max(
0,
Math.min(1, cubicBezierYForX(prev.transitionEasing, progress)),
);
blendPrev = prev;
blendNext = next;
}
}
}
for (const rt of lanes) {
const action = rt.action!;
if (rt === winner) {
let local = playhead - rt.startTime;
// The strip's on-timeline width is the play window; the clip's
// natural length only drives the loop modulo / final-frame clamp.
const dur = rt.clipDuration;
if (rt.loop && dur > 0) local = local % dur;
else local = Math.min(local, dur);
action.enabled = true;
action.paused = true; // we drive time ourselves; don't auto-advance
action.time = local;
action.setEffectiveWeight(1);
posed.add(uuid);
} else if (rt === blendPrev || rt === blendNext) {
// Pinned poses: the leading strip holds its EXIT frame (loop-wrap
// included, matching what its own end displays), the next strip
// its ENTRY frame.
const dur = rt.clipDuration;
const exit =
rt.loop && dur > 0
? rt.stripDuration % dur
: Math.min(rt.stripDuration, dur);
action.enabled = true;
action.paused = true;
action.time = rt === blendPrev ? exit : 0;
action.setEffectiveWeight(
rt === blendPrev ? 1 - blendWeight : blendWeight,
);
posed.add(uuid);
} else {
action.enabled = false;
}
}
}
// Gaps between strips (and before the first / after the last) have no active
// clip. A disabled action leaves the skeleton frozen on its last evaluated
@@ -209,6 +258,7 @@ export class CharacterAnimationManager {
clipDuration: lane.strip.duration,
stripDuration: lane.strip.duration,
boundRoot: null,
transitionEasing: lane.strip.transitionEasing,
};
this.lanes.set(lane.id, rt);
@@ -16,6 +16,7 @@ import {
DEFAULT_EASING,
DEFAULT_TIMELINE_DURATION,
DEFAULT_TIMELINE_FPS,
DEFAULT_TRANSITION_GAP,
type ClipLane,
type ClipStrip,
type EasingSpec,
@@ -356,6 +357,73 @@ export class TimelineController {
this.emitChanged();
}
// Set (or clear, with null) the opt-in pose transition from `laneId`'s
// strip into the NEXT strip on its row. Rides the Save/Cancel session
// like every other clip edit.
setClipTransitionEasing(laneId: string, easing: EasingSpec | null): void {
if (!this.timeline) return;
const lane = this.timeline.clipLanes.find((l) => l.id === laneId);
if (!lane) return;
if (easing) lane.strip.transitionEasing = { ...easing };
else delete lane.strip.transitionEasing;
this.syncClipLanes();
this.evaluate();
this.emitChanged();
}
// Guarantee at least `gap` seconds of daylight between `laneId`'s strip and
// the NEXT strip on its row, so an enabled transition has room to play.
// Auto-placement packs strips flush (resolveFreeStart), so this is what
// makes the boundary chip usable without hand-dragging a gap open first.
// Makes room by shifting the next strip right (bounded by the strip after
// it / the timeline end), then by trimming the leading strip when shifting
// alone isn't enough. Returns false — mutating nothing — when there is no
// next strip or the row is too packed to make room (callers surface that).
// Rides the Save/Cancel session like every other clip edit.
ensureTransitionGap(laneId: string, gap = DEFAULT_TRANSITION_GAP): boolean {
if (!this.timeline) return false;
const lead = this.timeline.clipLanes.find((l) => l.id === laneId);
if (!lead) return false;
const leadEnd = lead.strip.startTime + lead.strip.duration;
// Epsilon: a strip trimmed flush against its neighbour can put leadEnd a
// float-rounding hair PAST the neighbour's start; `>= leadEnd` alone
// would then miss it and misreport "no next strip".
const next = this.timeline.clipLanes
.filter(
(l) =>
l.objectUuid === lead.objectUuid &&
l.id !== lead.id &&
l.strip.startTime >= leadEnd - 1e-6,
)
.sort((a, b) => a.strip.startTime - b.strip.startTime)[0];
if (!next) return false;
const needed = gap - (next.strip.startTime - leadEnd);
if (needed <= 0) return true;
const followBound = this.nextStartAfter(
lead.objectUuid,
next.id,
next.strip.startTime,
);
const shiftAvail = Math.max(
0,
followBound - (next.strip.startTime + next.strip.duration),
);
const minDur = 1 / (this.timeline.fps || DEFAULT_TIMELINE_FPS);
const trimAvail = Math.max(0, lead.strip.duration - minDur);
if (shiftAvail + trimAvail < needed) return false;
const shift = Math.min(needed, shiftAvail);
next.strip.startTime += shift;
const trim = needed - shift;
if (trim > 0) {
lead.strip.duration -= trim;
lead.strip.autoDuration = false;
}
this.syncClipLanes();
this.evaluate();
this.emitChanged();
return true;
}
// Toggle whether a clip loops for the remainder of its strip.
setClipLoop(laneId: string, loop: boolean): void {
if (!this.timeline) return;
@@ -51,6 +51,13 @@ export interface ClipStrip {
// A user trim clears this so a hand-set duration survives reloads. Baked
// strips know their length up front and never set this.
autoDuration?: boolean;
// OPT-IN transition into the FOLLOWING strip on the same row: when set,
// the gap between this strip's end and the next strip's start blends this
// strip's exit pose into the next strip's entry pose along this curve
// (same cubic-bezier semantics as keyframe easing). Absent = the gap
// shows the rest pose, as always. Lives on the LEADING strip, mirroring
// how keyframes store the easing "into the next".
transitionEasing?: EasingSpec;
}
// One animation lane under an object; each lane holds a single clip strip.
@@ -80,6 +87,9 @@ export const EASING_PRESETS: Record<EasingPresetName, EasingSpec> = {
};
export const DEFAULT_EASING: EasingSpec = EASING_PRESETS.easeInOut;
// Gap (seconds) auto-opened between two flush strips when the user enables a
// transition at their boundary — the blend needs room on the timeline to play.
export const DEFAULT_TRANSITION_GAP = 0.3;
export const DEFAULT_TIMELINE_DURATION = 10; // seconds
export const DEFAULT_TIMELINE_FPS = 30;
@@ -95,6 +95,7 @@ export const getModelDisplayName = (
seedance_2p0_bpu: "Seedance 2.0 Plus Ultra",
seedance_2p0_bpu_fast: "Seedance 2.0 Plus Ultra Fast",
seedance_2p0_bpu_mini: "Seedance 2.0 Plus Ultra Mini",
seedance_2p5: "Seedance 2.5",
seedance_2p5_preview: "Seedance 2.5 Preview",
// Seedance — dot-normalized aliases
seedance_1_0_lite: "Seedance 1.0 Lite",
+88 -168
View File
File diff suppressed because it is too large Load Diff
@@ -24,6 +24,19 @@ frontend_npm_install
export VITE_ENVIRONMENT_TYPE="production"
# # Backend devs: launch with USE_LOCAL_API=1 (or `export USE_LOCAL_API=1` once
# # in your shell profile) to point the frontend at a local storyteller-web on
# # http://localhost:12345. Unset/0 — the frontend-dev default — hits
# # production. This replaces the comment/uncomment dance around
# # StorytellerApiHostStore.setDevelopment() in src/main.tsx.
# export VITE_USE_LOCAL_API="${USE_LOCAL_API:-0}"
# if [[ "${VITE_USE_LOCAL_API}" == "1" ]]; then
# echo "USE_LOCAL_API=1 — API calls will target http://localhost:12345"
# else
# echo "API calls will target production (set USE_LOCAL_API=1 for a local backend)"
# fi
export VITE_USE_LOCAL_API="true"
nx dev artcraft-webapp
popd || exit
@@ -24,6 +24,19 @@ frontend_npm_install
export VITE_ENVIRONMENT_TYPE="production"
# # Backend devs: launch with USE_LOCAL_API=1 (or `export USE_LOCAL_API=1` once
# # in your shell profile) to point the frontend at a local storyteller-web on
# # http://localhost:12345. Unset/0 — the frontend-dev default — hits
# # production. This replaces the comment/uncomment dance around
# # StorytellerApiHostStore.setDevelopment() in src/main.tsx.
# export VITE_USE_LOCAL_API="${USE_LOCAL_API:-0}"
# if [[ "${VITE_USE_LOCAL_API}" == "1" ]]; then
# echo "USE_LOCAL_API=1 — API calls will target http://localhost:12345"
# else
# echo "API calls will target production (set USE_LOCAL_API=1 for a local backend)"
# fi
export VITE_USE_LOCAL_API="true"
nx dev artcraft-website
popd || exit