mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-10 16:06:47 -05:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2cfcb6131d | ||
|
|
480c073c54 | ||
|
|
9646a791aa | ||
|
|
4d839d3b8b | ||
|
|
b7f354face | ||
|
|
c888a494b9 | ||
|
|
a048458956 |
+19
@@ -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
|
||||
|
||||
+1
-1
@@ -71,7 +71,7 @@ pub(crate) async fn resolve_and_upload_audio_ref(
|
||||
})?
|
||||
}
|
||||
};
|
||||
upload_to_kinovi_web(session, &source_url).await
|
||||
upload_to_kinovi_web(session, &source_url, None).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+11
@@ -78,7 +78,9 @@ use crate::generate::generate_video::providers::fal::vidu_q3::build::build_fal_v
|
||||
use crate::generate::generate_video::providers::fal::vidu_q3_turbo::build::build_fal_vidu_q3_turbo;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p0_fast::build::build_kinovi_seedance_2p0_fast;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p0_mini::build::build_kinovi_seedance_2p0_mini;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p5::build::build_artcraft_seedance_2p5;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p5_preview::build::build_artcraft_seedance_2p5_preview;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p5::build::build_kinovi_seedance_2p5;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p5_preview::build::build_kinovi_seedance_2p5_preview;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
|
||||
@@ -138,6 +140,12 @@ pub struct GenerateVideoRequestBuilder {
|
||||
/// so it's best to be explicit.
|
||||
pub generate_audio: Option<bool>,
|
||||
|
||||
/// CALCULATION-ONLY (never sent to any provider): the total seconds of
|
||||
/// reference video input, summed across all reference videos and rounded
|
||||
/// up to whole seconds per file. Models that bill reference-video input
|
||||
/// seconds (e.g. Seedance 2.5) add this to the billed duration.
|
||||
pub total_reference_video_input_seconds: Option<u16>,
|
||||
|
||||
/// If the request is a mismatch with the (model/provider), how to mitigate it.
|
||||
pub request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy,
|
||||
|
||||
@@ -166,6 +174,7 @@ impl Default for GenerateVideoRequestBuilder {
|
||||
duration_seconds: None,
|
||||
video_batch_count: None,
|
||||
generate_audio: None,
|
||||
total_reference_video_input_seconds: None,
|
||||
idempotency_token: None,
|
||||
}
|
||||
}
|
||||
@@ -201,6 +210,7 @@ impl GenerateVideoRequestBuilder {
|
||||
(RouterProvider::Artcraft, RouterVideoModel::Seedance2p0BytePlusMini) => build_artcraft_seedance_2p0_bp_mini(self),
|
||||
(RouterProvider::Artcraft, RouterVideoModel::Seedance2p0BytePlusUltraMini) => build_artcraft_seedance_2p0_bpu_mini(self),
|
||||
(RouterProvider::Artcraft, RouterVideoModel::Seedance2p5Preview) => build_artcraft_seedance_2p5_preview(self),
|
||||
(RouterProvider::Artcraft, RouterVideoModel::Seedance2p5) => build_artcraft_seedance_2p5(self),
|
||||
(RouterProvider::Artcraft, RouterVideoModel::PreviewModel) => build_artcraft_preview_model(self),
|
||||
(RouterProvider::Artcraft, RouterVideoModel::PreviewModelFast) => build_artcraft_preview_model_fast(self),
|
||||
(RouterProvider::Artcraft, RouterVideoModel::Seedance10Lite) => build_artcraft_seedance_1p0_lite(self),
|
||||
@@ -252,6 +262,7 @@ impl GenerateVideoRequestBuilder {
|
||||
(RouterProvider::KinoviWeb, RouterVideoModel::Seedance2p0BytePlusMini) => build_kinovi_seedance_2p0_mini(self),
|
||||
(RouterProvider::KinoviWeb, RouterVideoModel::Seedance2p0BytePlusUltraMini) => build_kinovi_seedance_2p0_mini(self),
|
||||
(RouterProvider::KinoviWeb, RouterVideoModel::Seedance2p5Preview) => build_kinovi_seedance_2p5_preview(self),
|
||||
(RouterProvider::KinoviWeb, RouterVideoModel::Seedance2p5) => build_kinovi_seedance_2p5(self),
|
||||
_ => self.unsupported_provider_and_model(),
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -82,6 +82,7 @@ pub fn build_artcraft_omni_video_request(
|
||||
negative_prompt: None,
|
||||
generate_audio: None,
|
||||
quality: None,
|
||||
estimate_only: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -63,6 +63,7 @@ mod tests {
|
||||
duration_seconds: Some(5),
|
||||
video_batch_count: Some(1),
|
||||
generate_audio: None,
|
||||
estimate_only: None,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
+1
@@ -32,6 +32,7 @@ pub mod seedance_2p0_bpu_fast;
|
||||
pub mod seedance_2p0_mini;
|
||||
pub mod seedance_2p0_bp_mini;
|
||||
pub mod seedance_2p0_bpu_mini;
|
||||
pub mod seedance_2p5;
|
||||
pub mod seedance_2p5_preview;
|
||||
pub mod sora_2;
|
||||
pub mod sora_2_pro;
|
||||
|
||||
+244
@@ -0,0 +1,244 @@
|
||||
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
|
||||
|
||||
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::client_error::ClientError;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::providers::artcraft::build_common::{
|
||||
build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport,
|
||||
};
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p5::request::ArtcraftSeedance2p5RequestState;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
|
||||
|
||||
pub fn build_artcraft_seedance_2p5(mut builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
let strategy = builder.request_mismatch_mitigation_strategy;
|
||||
|
||||
// Parity with the Kinovi build: keyframes and reference media are mutually
|
||||
// exclusive, an end frame requires a start frame, and there is no
|
||||
// character support. Erroring here keeps the billing estimate consistent
|
||||
// with the execution build.
|
||||
let has_keyframes = builder.start_frame.is_some() || builder.end_frame.is_some();
|
||||
let has_references = builder.reference_images.is_some()
|
||||
|| builder.reference_videos.is_some()
|
||||
|| builder.reference_audio.is_some();
|
||||
|
||||
if has_keyframes && has_references {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "reference_images",
|
||||
value: "Seedance 2.5 cannot combine keyframes with reference media; send one or the other".to_string(),
|
||||
}));
|
||||
}
|
||||
if builder.end_frame.is_some() && builder.start_frame.is_none() {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "end_frame",
|
||||
value: "Seedance 2.5 requires a start frame when an end frame is set".to_string(),
|
||||
}));
|
||||
}
|
||||
if builder.reference_character_tokens.is_some() {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "reference_character_tokens",
|
||||
value: "Seedance 2.5 does not support character references".to_string(),
|
||||
}));
|
||||
}
|
||||
|
||||
// 2.5 allows 4-30 second durations (longer than the shared 4-15 range in
|
||||
// build_common) and generates a single video per request. Plan both here,
|
||||
// leaving None for the shared builder, then set them after.
|
||||
let duration_seconds = plan_duration(builder.duration_seconds.take(), strategy)?;
|
||||
plan_batch_count(builder.video_batch_count.take(), strategy)?;
|
||||
|
||||
let total_input_seconds = builder.total_reference_video_input_seconds.take();
|
||||
|
||||
let mut request = build_artcraft_omni_video_request(
|
||||
builder,
|
||||
CommonVideoModelEnum::Seedance2p5,
|
||||
SupportedResolutions::Fast,
|
||||
UltraWideSupport::Supported,
|
||||
)?;
|
||||
request.duration_seconds = duration_seconds;
|
||||
request.video_batch_count = Some(1);
|
||||
|
||||
let state = ArtcraftSeedance2p5RequestState { request, total_input_seconds };
|
||||
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p5(state)))
|
||||
}
|
||||
|
||||
// Seedance 2.5 supports durations of 4-30 seconds.
|
||||
fn plan_duration(
|
||||
duration_seconds: Option<u16>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Option<u16>, ArtcraftRouterError> {
|
||||
const MIN: u16 = 4;
|
||||
const MAX: u16 = 30;
|
||||
match duration_seconds {
|
||||
None => Ok(None),
|
||||
Some(d) if d >= MIN && d <= MAX => Ok(Some(d)),
|
||||
Some(d) => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "duration_seconds",
|
||||
value: format!("{}", d),
|
||||
}))
|
||||
}
|
||||
_ => Ok(Some(d.clamp(MIN, MAX))),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Seedance 2.5 generates a single video per request (no batching).
|
||||
fn plan_batch_count(
|
||||
video_batch_count: Option<u16>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<(), ArtcraftRouterError> {
|
||||
let count = video_batch_count.unwrap_or(1);
|
||||
match count {
|
||||
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
|
||||
1 => Ok(()),
|
||||
_ => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "video_batch_count",
|
||||
value: format!("{}", count),
|
||||
}))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade
|
||||
| RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
|
||||
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
|
||||
use tokens::tokens::characters::CharacterToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::api::character_list_ref::CharacterListRef;
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
|
||||
|
||||
use super::*;
|
||||
|
||||
mod field_conversions {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn model_is_correct() {
|
||||
let req = unwrap_request(builder_with(|_| {}));
|
||||
assert!(matches!(req.request.model, Some(CommonVideoModelEnum::Seedance2p5)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_30_is_passed_through() {
|
||||
let req = unwrap_request(builder_with(|b| { b.duration_seconds = Some(30); }));
|
||||
assert_eq!(req.request.duration_seconds, Some(30));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_clamped_to_range() {
|
||||
let req = unwrap_request(builder_with(|b| { b.duration_seconds = Some(99); }));
|
||||
assert_eq!(req.request.duration_seconds, Some(30));
|
||||
|
||||
let req = unwrap_request(builder_with(|b| { b.duration_seconds = Some(2); }));
|
||||
assert_eq!(req.request.duration_seconds, Some(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_count_is_always_one() {
|
||||
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(4); }));
|
||||
assert_eq!(req.request.video_batch_count, Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn total_input_seconds_is_carried_through() {
|
||||
let req = unwrap_request(builder_with(|b| { b.total_reference_video_input_seconds = Some(14); }));
|
||||
assert_eq!(req.total_input_seconds, Some(14));
|
||||
}
|
||||
}
|
||||
|
||||
mod modality_rules {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn keyframes_are_accepted() {
|
||||
let req = unwrap_request(builder_with(|b| {
|
||||
b.start_frame = Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_start".to_string())));
|
||||
b.end_frame = Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_end".to_string())));
|
||||
}));
|
||||
assert!(req.request.start_frame_image_media_token.is_some());
|
||||
assert!(req.request.end_frame_image_media_token.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_frame_without_start_frame_errors() {
|
||||
let result = build_artcraft_seedance_2p5(builder_with(|b| {
|
||||
b.end_frame = Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_end".to_string())));
|
||||
}));
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyframes_plus_references_error() {
|
||||
let result = build_artcraft_seedance_2p5(builder_with(|b| {
|
||||
b.start_frame = Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_start".to_string())));
|
||||
b.reference_images = Some(ImageListRef::MediaFileTokens(vec![
|
||||
MediaFileToken::new("mf_ref".to_string()),
|
||||
]));
|
||||
}));
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_references_error() {
|
||||
let result = build_artcraft_seedance_2p5(builder_with(|b| {
|
||||
b.reference_character_tokens = Some(CharacterListRef::CharacterTokens(vec![
|
||||
CharacterToken::new("char_abc".to_string()),
|
||||
]));
|
||||
}));
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
mod resolution_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn res_480p() {
|
||||
let req = unwrap_request(builder_with(|b| { b.resolution = Some(RouterResolution::FourEightyP); }));
|
||||
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::FourEightyP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn res_1080p_downgrades_to_720p() {
|
||||
let req = unwrap_request(builder_with(|b| { b.resolution = Some(RouterResolution::TenEightyP); }));
|
||||
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP));
|
||||
}
|
||||
}
|
||||
|
||||
fn builder_with(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder {
|
||||
let mut builder = GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Seedance2p5,
|
||||
provider: RouterProvider::Artcraft,
|
||||
duration_seconds: Some(5),
|
||||
video_batch_count: Some(1),
|
||||
..Default::default()
|
||||
};
|
||||
f(&mut builder);
|
||||
builder
|
||||
}
|
||||
|
||||
fn unwrap_request(builder: GenerateVideoRequestBuilder) -> ArtcraftSeedance2p5RequestState {
|
||||
let result = build_artcraft_seedance_2p5(builder).expect("build should succeed");
|
||||
match result {
|
||||
VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p5(state)) => state,
|
||||
_ => panic!("expected ArtcraftSeedance2p5 request"),
|
||||
}
|
||||
}
|
||||
}
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
use enums::common::generation::common_resolution::CommonResolution;
|
||||
use kinovi_web_client::generate::video::generate_seedance_2p5::MAX_BILLED_INPUT_SECONDS;
|
||||
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_common::seedance_2p5_usd_cents;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p5::request::ArtcraftSeedance2p5RequestState;
|
||||
|
||||
/// Seedance 2.5 pricing depends on the resolution, the output duration, and
|
||||
/// — when reference videos are attached — the total seconds of reference
|
||||
/// video input, which are billed on top of the output duration (at a lower
|
||||
/// per-second rate). Input seconds are clamped to
|
||||
/// [`MAX_BILLED_INPUT_SECONDS`] — the model accepts at most 30 seconds of
|
||||
/// video. No batching.
|
||||
pub struct ArtcraftSeedance2p5CostState {
|
||||
pub resolution: CommonResolution,
|
||||
pub duration_seconds: u16,
|
||||
pub has_video_references: bool,
|
||||
pub total_input_seconds: u16,
|
||||
}
|
||||
|
||||
impl ArtcraftSeedance2p5CostState {
|
||||
pub fn from_request(request: &ArtcraftSeedance2p5RequestState) -> Self {
|
||||
let resolution = request.request.resolution
|
||||
.unwrap_or(CommonResolution::SevenTwentyP);
|
||||
let duration_seconds = request.request.duration_seconds.unwrap_or(5);
|
||||
|
||||
let has_video_references = request.request.reference_video_media_tokens
|
||||
.as_ref()
|
||||
.is_some_and(|tokens| !tokens.is_empty());
|
||||
|
||||
Self {
|
||||
resolution,
|
||||
duration_seconds,
|
||||
has_video_references,
|
||||
total_input_seconds: request.total_input_seconds.unwrap_or(0),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
|
||||
let total_input_seconds = self.total_input_seconds
|
||||
.min(u16::from(MAX_BILLED_INPUT_SECONDS));
|
||||
|
||||
let usd_cents = seedance_2p5_usd_cents(
|
||||
self.resolution,
|
||||
self.duration_seconds,
|
||||
self.has_video_references,
|
||||
total_input_seconds,
|
||||
);
|
||||
|
||||
// ArtCraft credits: 100 credits = $1.00, so credits = cents.
|
||||
VideoGenerationCostEstimate {
|
||||
cost_in_credits: Some(usd_cents),
|
||||
cost_in_usd_cents: Some(usd_cents),
|
||||
is_free: false,
|
||||
is_unlimited: false,
|
||||
is_rate_limited: false,
|
||||
has_watermark: false,
|
||||
failures_are_refunded: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::api::video_list_ref::VideoListRef;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
mod pricing_without_video_references {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn table_480p() {
|
||||
// 11.76954733 ¢/s, rounded up.
|
||||
assert_eq!(cents(Some(RouterResolution::FourEightyP), 4), 48);
|
||||
assert_eq!(cents(Some(RouterResolution::FourEightyP), 5), 59);
|
||||
assert_eq!(cents(Some(RouterResolution::FourEightyP), 10), 118);
|
||||
assert_eq!(cents(Some(RouterResolution::FourEightyP), 30), 354);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_720p() {
|
||||
// 26.70781893 ¢/s, rounded up.
|
||||
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 4), 107);
|
||||
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 5), 134);
|
||||
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 10), 268);
|
||||
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 30), 802);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_defaults_to_720p() {
|
||||
assert_eq!(cents(None, 10), cents(Some(RouterResolution::SevenTwentyP), 10));
|
||||
}
|
||||
}
|
||||
|
||||
mod pricing_with_video_references {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn thirty_second_output_with_ten_input_seconds_bills_forty() {
|
||||
// 7.24279835 ¢/s × 40 = 289.71 → 290¢.
|
||||
assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 30, Some(10)), 290);
|
||||
// 15.84362140 ¢/s × 40 = 633.74 → 634¢.
|
||||
assert_eq!(cents_with_video_refs(Some(RouterResolution::SevenTwentyP), 30, Some(10)), 634);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fourteen_input_seconds_bill_forty_four() {
|
||||
// 7.24279835 ¢/s × 44 = 318.68 → 319¢.
|
||||
assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 30, Some(14)), 319);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_input_seconds_bill_output_duration_only() {
|
||||
// 7.24279835 ¢/s × 10 = 72.43 → 73¢.
|
||||
assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 10, None), 73);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_seconds_clamp_to_max_billed_input_seconds() {
|
||||
// 200 input seconds clamp to 30: 7.24279835 ¢/s × (30+30) = 434.57 → 435¢.
|
||||
assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 30, Some(200)), 435);
|
||||
assert_eq!(
|
||||
cents_with_video_refs(Some(RouterResolution::FourEightyP), 30, Some(200)),
|
||||
cents_with_video_refs(Some(RouterResolution::FourEightyP), 30, Some(30)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
mod credits_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn credits_equal_usd_cents() {
|
||||
for dur in [4u16, 10, 30] {
|
||||
let estimate = estimate_with(|b| {
|
||||
b.resolution = Some(RouterResolution::FourEightyP);
|
||||
b.duration_seconds = Some(dur);
|
||||
});
|
||||
assert_eq!(estimate.cost_in_credits, estimate.cost_in_usd_cents);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
fn estimate_with(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> VideoGenerationCostEstimate {
|
||||
let mut builder = GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Seedance2p5,
|
||||
provider: RouterProvider::Artcraft,
|
||||
duration_seconds: Some(5),
|
||||
video_batch_count: Some(1),
|
||||
..Default::default()
|
||||
};
|
||||
f(&mut builder);
|
||||
builder.build2()
|
||||
.expect("build should succeed")
|
||||
.estimate_cost()
|
||||
.expect("estimate should succeed")
|
||||
}
|
||||
|
||||
fn cents(resolution: Option<RouterResolution>, duration_seconds: u16) -> u64 {
|
||||
estimate_with(|b| {
|
||||
b.resolution = resolution;
|
||||
b.duration_seconds = Some(duration_seconds);
|
||||
}).cost_in_usd_cents.unwrap()
|
||||
}
|
||||
|
||||
fn cents_with_video_refs(
|
||||
resolution: Option<RouterResolution>,
|
||||
duration_seconds: u16,
|
||||
total_input_seconds: Option<u16>,
|
||||
) -> u64 {
|
||||
estimate_with(|b| {
|
||||
b.resolution = resolution;
|
||||
b.duration_seconds = Some(duration_seconds);
|
||||
b.reference_videos = Some(VideoListRef::MediaFileTokens(vec![
|
||||
MediaFileToken::new("mf_ref".to_string()),
|
||||
]));
|
||||
b.total_reference_video_input_seconds = total_input_seconds;
|
||||
}).cost_in_usd_cents.unwrap()
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod build;
|
||||
pub mod cost;
|
||||
pub mod request;
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
|
||||
|
||||
use crate::client::router_artcraft_client::RouterArtcraftClient;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
|
||||
use crate::generate::generate_video::providers::artcraft::request_common::send_artcraft_omni_video_request;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ArtcraftSeedance2p5RequestState {
|
||||
pub request: OmniGenVideoCostAndGenerateRequest,
|
||||
|
||||
/// Calculation-only: total seconds of reference video input, billed on top
|
||||
/// of the output duration when reference videos are attached. Not part of
|
||||
/// the omni request wire shape.
|
||||
pub total_input_seconds: Option<u16>,
|
||||
}
|
||||
|
||||
impl ArtcraftSeedance2p5RequestState {
|
||||
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
|
||||
send_artcraft_omni_video_request(&self.request, client).await
|
||||
}
|
||||
}
|
||||
+40
@@ -141,6 +141,46 @@ pub fn seedance_2p5_preview_usd_cents(
|
||||
(cents_per_second * duration_seconds as f64).ceil() as u64
|
||||
}
|
||||
|
||||
/// Seedance 2.5 — 480p price, USD cents per second.
|
||||
const SEEDANCE_2P5_CENTS_PER_SECOND_480P: f64 = 11.76954733;
|
||||
/// Seedance 2.5 — 720p price, USD cents per second.
|
||||
const SEEDANCE_2P5_CENTS_PER_SECOND_720P: f64 = 26.70781893;
|
||||
/// Seedance 2.5 — 480p price with video references, USD cents per second.
|
||||
const SEEDANCE_2P5_VIDEO_REFERENCE_CENTS_PER_SECOND_480P: f64 = 7.24279835;
|
||||
/// Seedance 2.5 — 720p price with video references, USD cents per second.
|
||||
const SEEDANCE_2P5_VIDEO_REFERENCE_CENTS_PER_SECOND_720P: f64 = 15.84362140;
|
||||
|
||||
/// ArtCraft's price (USD cents) for Seedance 2.5.
|
||||
///
|
||||
/// Only 480p and 720p are offered; any other resolution prices at 720p.
|
||||
/// Without video references, billed seconds = output duration. With video
|
||||
/// references, the per-second rate drops but the billed seconds are the
|
||||
/// output duration PLUS the total seconds of reference video input. The
|
||||
/// fractional total is rounded UP to a whole cent. No batching.
|
||||
pub fn seedance_2p5_usd_cents(
|
||||
resolution: CommonResolution,
|
||||
duration_seconds: u16,
|
||||
has_video_references: bool,
|
||||
total_input_seconds: u16,
|
||||
) -> u64 {
|
||||
let (cents_per_second, billed_seconds) = if has_video_references {
|
||||
let rate = match resolution {
|
||||
CommonResolution::FourEightyP => SEEDANCE_2P5_VIDEO_REFERENCE_CENTS_PER_SECOND_480P,
|
||||
// Everything else (including 720p and unsupported resolutions) prices at 720p.
|
||||
_ => SEEDANCE_2P5_VIDEO_REFERENCE_CENTS_PER_SECOND_720P,
|
||||
};
|
||||
(rate, u64::from(duration_seconds) + u64::from(total_input_seconds))
|
||||
} else {
|
||||
let rate = match resolution {
|
||||
CommonResolution::FourEightyP => SEEDANCE_2P5_CENTS_PER_SECOND_480P,
|
||||
_ => SEEDANCE_2P5_CENTS_PER_SECOND_720P,
|
||||
};
|
||||
(rate, u64::from(duration_seconds))
|
||||
};
|
||||
|
||||
(cents_per_second * billed_seconds as f64).ceil() as u64
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn mini_usd_cents(
|
||||
resolution: CommonResolution,
|
||||
|
||||
+2
-1
@@ -37,7 +37,8 @@ impl KinoviHappyHorse1p0DraftState {
|
||||
|
||||
if let Some(remaining) = self.unhandled_request_state.take() {
|
||||
let map = draft_context.media_file_to_artcraft_url_map;
|
||||
start_frame_url = resolve_and_upload_single(session, remaining.start_frame, map).await?;
|
||||
let predownloaded = draft_context.predownloaded_media_paths;
|
||||
start_frame_url = resolve_and_upload_single(session, remaining.start_frame, map, predownloaded).await?;
|
||||
}
|
||||
|
||||
let request = GenerateHappyHorse1p0Request {
|
||||
|
||||
+1
@@ -2,6 +2,7 @@ pub mod happy_horse_1p0;
|
||||
pub mod seedance_2p0;
|
||||
pub mod seedance_2p0_fast;
|
||||
pub mod seedance_2p0_mini;
|
||||
pub mod seedance_2p5;
|
||||
pub mod seedance_2p5_preview;
|
||||
pub(crate) mod resolve;
|
||||
pub(crate) mod upload;
|
||||
|
||||
+18
-2
@@ -1,4 +1,5 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use kinovi_web_client::creds::kinovi_web_session::KinoviWebSession;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
@@ -45,13 +46,15 @@ pub(crate) async fn resolve_and_upload_single(
|
||||
session: &KinoviWebSession,
|
||||
image_ref: Option<ImageRef>,
|
||||
maybe_map: Option<&HashMap<MediaFileToken, String>>,
|
||||
maybe_predownloaded: Option<&HashMap<String, PathBuf>>,
|
||||
) -> Result<Option<String>, ArtcraftRouterError> {
|
||||
let source_url = match image_ref {
|
||||
None => return Ok(None),
|
||||
Some(ImageRef::Url(url)) => url,
|
||||
Some(ImageRef::MediaFileToken(token)) => resolve_token(maybe_map, &token)?,
|
||||
};
|
||||
Ok(Some(upload_to_kinovi_web(session, &source_url).await?))
|
||||
let local_path = predownloaded_path(maybe_predownloaded, &source_url);
|
||||
Ok(Some(upload_to_kinovi_web(session, &source_url, local_path).await?))
|
||||
}
|
||||
|
||||
/// Resolve a list of refs to URLs and upload each to KinoviWeb CDN.
|
||||
@@ -60,6 +63,7 @@ pub(crate) async fn resolve_and_upload_list(
|
||||
session: &KinoviWebSession,
|
||||
urls_or_tokens: Option<UrlsOrTokens>,
|
||||
maybe_map: Option<&HashMap<MediaFileToken, String>>,
|
||||
maybe_predownloaded: Option<&HashMap<String, PathBuf>>,
|
||||
) -> Result<Option<Vec<String>>, ArtcraftRouterError> {
|
||||
let source_urls = match urls_or_tokens {
|
||||
None => return Ok(None),
|
||||
@@ -71,11 +75,23 @@ pub(crate) async fn resolve_and_upload_list(
|
||||
|
||||
let mut uploaded = Vec::with_capacity(source_urls.len());
|
||||
for url in &source_urls {
|
||||
uploaded.push(upload_to_kinovi_web(session, url).await?);
|
||||
let local_path = predownloaded_path(maybe_predownloaded, url);
|
||||
uploaded.push(upload_to_kinovi_web(session, url, local_path).await?);
|
||||
}
|
||||
Ok(Some(uploaded))
|
||||
}
|
||||
|
||||
/// Look up a caller-predownloaded local copy of the source file (see
|
||||
/// `VideoGenerationDraftContext::predownloaded_media_paths`).
|
||||
fn predownloaded_path<'a>(
|
||||
maybe_predownloaded: Option<&'a HashMap<String, PathBuf>>,
|
||||
source_url: &str,
|
||||
) -> Option<&'a Path> {
|
||||
maybe_predownloaded
|
||||
.and_then(|paths| paths.get(source_url))
|
||||
.map(PathBuf::as_path)
|
||||
}
|
||||
|
||||
/// Map character tokens to their Kinovi character IDs, preserving order.
|
||||
pub(crate) fn resolve_character_tokens(
|
||||
character_list_ref: Option<&CharacterListRef>,
|
||||
|
||||
+6
-5
@@ -59,20 +59,21 @@ impl KinoviSeedance2p0DraftState {
|
||||
|
||||
if let Some(remaining) = self.unhandled_request_state.take() {
|
||||
let map = draft_context.media_file_to_artcraft_url_map;
|
||||
let predownloaded = draft_context.predownloaded_media_paths;
|
||||
|
||||
start_frame_url = resolve_and_upload_single(session, remaining.start_frame, map).await?;
|
||||
end_frame_url = resolve_and_upload_single(session, remaining.end_frame, map).await?;
|
||||
start_frame_url = resolve_and_upload_single(session, remaining.start_frame, map, predownloaded).await?;
|
||||
end_frame_url = resolve_and_upload_single(session, remaining.end_frame, map, predownloaded).await?;
|
||||
|
||||
reference_image_urls = resolve_and_upload_list(
|
||||
session, remaining.reference_images.map(image_list_ref_into_urls_or_tokens), map,
|
||||
session, remaining.reference_images.map(image_list_ref_into_urls_or_tokens), map, predownloaded,
|
||||
).await?;
|
||||
|
||||
reference_video_urls = resolve_and_upload_list(
|
||||
session, remaining.reference_videos.map(video_list_ref_into_urls_or_tokens), map,
|
||||
session, remaining.reference_videos.map(video_list_ref_into_urls_or_tokens), map, predownloaded,
|
||||
).await?;
|
||||
|
||||
reference_audio_urls = resolve_and_upload_list(
|
||||
session, remaining.reference_audio.map(audio_list_ref_into_urls_or_tokens), map,
|
||||
session, remaining.reference_audio.map(audio_list_ref_into_urls_or_tokens), map, predownloaded,
|
||||
).await?;
|
||||
|
||||
character_ids = resolve_character_tokens(
|
||||
|
||||
+6
-5
@@ -56,20 +56,21 @@ impl KinoviSeedance2p0FastDraftState {
|
||||
|
||||
if let Some(remaining) = self.unhandled_request_state.take() {
|
||||
let map = draft_context.media_file_to_artcraft_url_map;
|
||||
let predownloaded = draft_context.predownloaded_media_paths;
|
||||
|
||||
start_frame_url = resolve_and_upload_single(session, remaining.start_frame, map).await?;
|
||||
end_frame_url = resolve_and_upload_single(session, remaining.end_frame, map).await?;
|
||||
start_frame_url = resolve_and_upload_single(session, remaining.start_frame, map, predownloaded).await?;
|
||||
end_frame_url = resolve_and_upload_single(session, remaining.end_frame, map, predownloaded).await?;
|
||||
|
||||
reference_image_urls = resolve_and_upload_list(
|
||||
session, remaining.reference_images.map(image_list_ref_into_urls_or_tokens), map,
|
||||
session, remaining.reference_images.map(image_list_ref_into_urls_or_tokens), map, predownloaded,
|
||||
).await?;
|
||||
|
||||
reference_video_urls = resolve_and_upload_list(
|
||||
session, remaining.reference_videos.map(video_list_ref_into_urls_or_tokens), map,
|
||||
session, remaining.reference_videos.map(video_list_ref_into_urls_or_tokens), map, predownloaded,
|
||||
).await?;
|
||||
|
||||
reference_audio_urls = resolve_and_upload_list(
|
||||
session, remaining.reference_audio.map(audio_list_ref_into_urls_or_tokens), map,
|
||||
session, remaining.reference_audio.map(audio_list_ref_into_urls_or_tokens), map, predownloaded,
|
||||
).await?;
|
||||
|
||||
character_ids = resolve_character_tokens(
|
||||
|
||||
+6
-5
@@ -56,20 +56,21 @@ impl KinoviSeedance2p0MiniDraftState {
|
||||
|
||||
if let Some(remaining) = self.unhandled_request_state.take() {
|
||||
let map = draft_context.media_file_to_artcraft_url_map;
|
||||
let predownloaded = draft_context.predownloaded_media_paths;
|
||||
|
||||
start_frame_url = resolve_and_upload_single(session, remaining.start_frame, map).await?;
|
||||
end_frame_url = resolve_and_upload_single(session, remaining.end_frame, map).await?;
|
||||
start_frame_url = resolve_and_upload_single(session, remaining.start_frame, map, predownloaded).await?;
|
||||
end_frame_url = resolve_and_upload_single(session, remaining.end_frame, map, predownloaded).await?;
|
||||
|
||||
reference_image_urls = resolve_and_upload_list(
|
||||
session, remaining.reference_images.map(image_list_ref_into_urls_or_tokens), map,
|
||||
session, remaining.reference_images.map(image_list_ref_into_urls_or_tokens), map, predownloaded,
|
||||
).await?;
|
||||
|
||||
reference_video_urls = resolve_and_upload_list(
|
||||
session, remaining.reference_videos.map(video_list_ref_into_urls_or_tokens), map,
|
||||
session, remaining.reference_videos.map(video_list_ref_into_urls_or_tokens), map, predownloaded,
|
||||
).await?;
|
||||
|
||||
reference_audio_urls = resolve_and_upload_list(
|
||||
session, remaining.reference_audio.map(audio_list_ref_into_urls_or_tokens), map,
|
||||
session, remaining.reference_audio.map(audio_list_ref_into_urls_or_tokens), map, predownloaded,
|
||||
).await?;
|
||||
|
||||
character_ids = resolve_character_tokens(
|
||||
|
||||
+453
@@ -0,0 +1,453 @@
|
||||
use kinovi_web_client::generate::video::generate_seedance_2p5::{
|
||||
KinoviSeedance2p5AspectRatio as KinoviAspectRatio,
|
||||
KinoviSeedance2p5OutputResolution as KinoviOutputResolution,
|
||||
};
|
||||
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::client_error::ClientError;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p5::draft::{KinoviSeedance2p5DraftState, KinoviSeedance2p5RemainingItems};
|
||||
use crate::generate::generate_video::video_generation_draft::VideoGenerationDraftRequest;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
|
||||
pub fn build_kinovi_seedance_2p5(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
let draft = do_build_kinovi_seedance_2p5(builder)?;
|
||||
Ok(VideoGenerationDraftOrRequest::Draft(VideoGenerationDraftRequest::KinoviSeedance2p5(draft)))
|
||||
}
|
||||
|
||||
fn do_build_kinovi_seedance_2p5(mut builder: GenerateVideoRequestBuilder) -> Result<KinoviSeedance2p5DraftState, ArtcraftRouterError> {
|
||||
let strategy = builder.request_mismatch_mitigation_strategy;
|
||||
|
||||
let has_keyframes = builder.start_frame.is_some() || builder.end_frame.is_some();
|
||||
let has_references = builder.reference_images.is_some()
|
||||
|| builder.reference_videos.is_some()
|
||||
|| builder.reference_audio.is_some();
|
||||
|
||||
// Keyframe (image-to-video) and reference modes are mutually exclusive on
|
||||
// the Seedance 2.5 API. Dropping either set would change the meaning of
|
||||
// the request, so this errors regardless of strategy.
|
||||
if has_keyframes && has_references {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "reference_images",
|
||||
value: "Seedance 2.5 cannot combine keyframes with reference media; send one or the other".to_string(),
|
||||
}));
|
||||
}
|
||||
|
||||
// An end frame requires a start frame.
|
||||
if builder.end_frame.is_some() && builder.start_frame.is_none() {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "end_frame",
|
||||
value: "Seedance 2.5 requires a start frame when an end frame is set".to_string(),
|
||||
}));
|
||||
}
|
||||
|
||||
// Kinovi character references are not supported by the 2.5 API.
|
||||
if builder.reference_character_tokens.is_some() {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "reference_character_tokens",
|
||||
value: "Seedance 2.5 does not support character references".to_string(),
|
||||
}));
|
||||
}
|
||||
|
||||
// Keyframe mode only supports "Adaptive" (the aspect ratio follows the
|
||||
// input frames), so any explicit aspect ratio preference is ignored there.
|
||||
// The planned value below only applies to reference mode.
|
||||
let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio.take(), strategy)?;
|
||||
let resolution = plan_output_resolution(builder.resolution.take(), strategy)?;
|
||||
plan_batch_count(builder.video_batch_count.take(), strategy)?;
|
||||
let duration_seconds = plan_duration(builder.duration_seconds.take(), strategy)?;
|
||||
let prompt = builder.prompt.take().unwrap_or_default();
|
||||
|
||||
let total_input_seconds = builder.total_reference_video_input_seconds
|
||||
.map(|seconds| u8::try_from(seconds).unwrap_or(u8::MAX));
|
||||
|
||||
// NB: `builder.bitrate` is intentionally ignored — 2.5 has no bitrate
|
||||
// option (and bitrate never affects cost).
|
||||
|
||||
let unhandled_request_state = KinoviSeedance2p5RemainingItems {
|
||||
start_frame: builder.start_frame.take(),
|
||||
end_frame: builder.end_frame.take(),
|
||||
reference_images: builder.reference_images.take(),
|
||||
reference_videos: builder.reference_videos.take(),
|
||||
reference_audio: builder.reference_audio.take(),
|
||||
};
|
||||
|
||||
Ok(KinoviSeedance2p5DraftState {
|
||||
aspect_ratio,
|
||||
resolution,
|
||||
duration_seconds,
|
||||
prompt,
|
||||
total_input_seconds,
|
||||
unhandled_request_state: Some(unhandled_request_state),
|
||||
})
|
||||
}
|
||||
|
||||
// ── Plan helpers ──
|
||||
|
||||
// Seedance 2.5 reference mode supports all six aspect ratios:
|
||||
// 16:9, 21:9, 9:16, 1:1, 4:3, 3:4. All supported ratios cost the same, so
|
||||
// both upgrade and downgrade pick the nearest match. (Keyframe mode is
|
||||
// always Adaptive and ignores this entirely.)
|
||||
fn plan_aspect_ratio(
|
||||
aspect_ratio: Option<RouterAspectRatio>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<KinoviAspectRatio, ArtcraftRouterError> {
|
||||
match aspect_ratio {
|
||||
// No preference or auto — default to landscape
|
||||
None
|
||||
| Some(RouterAspectRatio::Auto)
|
||||
| Some(RouterAspectRatio::Auto2k)
|
||||
| Some(RouterAspectRatio::Auto4k) => Ok(KinoviAspectRatio::Landscape16x9),
|
||||
|
||||
// Direct mappings
|
||||
Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => {
|
||||
Ok(KinoviAspectRatio::Landscape16x9)
|
||||
}
|
||||
Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => {
|
||||
Ok(KinoviAspectRatio::Portrait9x16)
|
||||
}
|
||||
Some(RouterAspectRatio::Square) | Some(RouterAspectRatio::SquareHd) => {
|
||||
Ok(KinoviAspectRatio::Square1x1)
|
||||
}
|
||||
Some(RouterAspectRatio::WideFourByThree) => Ok(KinoviAspectRatio::Standard4x3),
|
||||
Some(RouterAspectRatio::TallThreeByFour) => Ok(KinoviAspectRatio::Portrait3x4),
|
||||
Some(RouterAspectRatio::WideTwentyOneByNine) => Ok(KinoviAspectRatio::UltraWide21x9),
|
||||
|
||||
// Mismatches — apply strategy
|
||||
Some(unsupported) => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "aspect_ratio",
|
||||
value: format!("{:?}", unsupported),
|
||||
}))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade
|
||||
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
|
||||
Ok(nearest_aspect_ratio(unsupported))
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick the nearest supported aspect ratio by AR value (width / height).
|
||||
fn nearest_aspect_ratio(aspect_ratio: RouterAspectRatio) -> KinoviAspectRatio {
|
||||
match aspect_ratio {
|
||||
RouterAspectRatio::WideFiveByFour => KinoviAspectRatio::Standard4x3, // 1.25, nearest 1.33
|
||||
RouterAspectRatio::WideThreeByTwo => KinoviAspectRatio::Standard4x3, // 1.50, nearest 1.33
|
||||
RouterAspectRatio::TallFourByFive => KinoviAspectRatio::Portrait3x4, // 0.80, nearest 0.75
|
||||
RouterAspectRatio::TallTwoByThree => KinoviAspectRatio::Portrait3x4, // 0.67, nearest 0.75
|
||||
RouterAspectRatio::TallNineByTwentyOne => KinoviAspectRatio::Portrait9x16, // 0.43, nearest 0.56
|
||||
_ => KinoviAspectRatio::Square1x1,
|
||||
}
|
||||
}
|
||||
|
||||
// Seedance 2.5 supports output resolutions: 480p and 720p only.
|
||||
// 1080p (and higher) is NOT supported — downgrade to 720p or error based on strategy.
|
||||
fn plan_output_resolution(
|
||||
resolution: Option<RouterResolution>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Option<KinoviOutputResolution>, ArtcraftRouterError> {
|
||||
match resolution {
|
||||
None => Ok(None),
|
||||
|
||||
// Direct mappings
|
||||
Some(RouterResolution::FourEightyP) => Ok(Some(KinoviOutputResolution::FourEightyP)),
|
||||
Some(RouterResolution::SevenTwentyP) => Ok(Some(KinoviOutputResolution::SevenTwentyP)),
|
||||
|
||||
// 1080p is not supported — handle via strategy
|
||||
Some(RouterResolution::TenEightyP) => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "resolution",
|
||||
value: format!("{:?}", RouterResolution::TenEightyP),
|
||||
}))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade
|
||||
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
|
||||
// 1080p not available — downgrade to 720p (highest supported)
|
||||
Ok(Some(KinoviOutputResolution::SevenTwentyP))
|
||||
}
|
||||
},
|
||||
|
||||
// Other unsupported resolutions
|
||||
Some(unsupported) => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "resolution",
|
||||
value: format!("{:?}", unsupported),
|
||||
}))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade
|
||||
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
|
||||
Ok(Some(match unsupported {
|
||||
RouterResolution::HalfK => KinoviOutputResolution::FourEightyP,
|
||||
_ => KinoviOutputResolution::SevenTwentyP,
|
||||
}))
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Seedance 2.5 generates a single video per request (no batching).
|
||||
fn plan_batch_count(
|
||||
video_batch_count: Option<u16>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<(), ArtcraftRouterError> {
|
||||
let count = video_batch_count.unwrap_or(1);
|
||||
match count {
|
||||
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
|
||||
1 => Ok(()),
|
||||
// Over the maximum of 1.
|
||||
_ => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "video_batch_count",
|
||||
value: format!("{}", count),
|
||||
}))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade
|
||||
| RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Seedance 2.5 supports durations of 4–30 seconds.
|
||||
fn plan_duration(
|
||||
duration_seconds: Option<u16>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<u8, ArtcraftRouterError> {
|
||||
const MIN: u16 = 4;
|
||||
const MAX: u16 = 30;
|
||||
const DEFAULT: u8 = 5;
|
||||
match duration_seconds {
|
||||
None => Ok(DEFAULT),
|
||||
Some(d) if d >= MIN && d <= MAX => Ok(d as u8),
|
||||
Some(d) => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "duration_seconds",
|
||||
value: format!("{}", d),
|
||||
}))
|
||||
}
|
||||
_ => Ok(d.clamp(MIN, MAX) as u8),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use kinovi_web_client::generate::video::generate_seedance_2p5::{
|
||||
KinoviSeedance2p5AspectRatio as KinoviAspectRatio,
|
||||
KinoviSeedance2p5OutputResolution as KinoviOutputResolution,
|
||||
};
|
||||
use tokens::tokens::characters::CharacterToken;
|
||||
|
||||
use crate::api::character_list_ref::CharacterListRef;
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::api::video_list_ref::VideoListRef;
|
||||
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p5::draft::KinoviSeedance2p5DraftState;
|
||||
use crate::generate::generate_video::video_generation_draft::VideoGenerationDraftRequest;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
|
||||
use super::*;
|
||||
|
||||
mod materialized_field_conversions {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn prompt_is_passed_through() {
|
||||
let draft = unwrap_draft(build_kinovi_seedance_2p5(base_builder()));
|
||||
assert_eq!(draft.prompt, "a cat dancing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_defaults_to_5() {
|
||||
let builder = GenerateVideoRequestBuilder { duration_seconds: None, ..base_builder() };
|
||||
let draft = unwrap_draft(build_kinovi_seedance_2p5(builder));
|
||||
assert_eq!(draft.duration_seconds, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_clamped_to_range() {
|
||||
let over = GenerateVideoRequestBuilder { duration_seconds: Some(99), ..base_builder() };
|
||||
assert_eq!(unwrap_draft(build_kinovi_seedance_2p5(over)).duration_seconds, 30);
|
||||
|
||||
let under = GenerateVideoRequestBuilder { duration_seconds: Some(2), ..base_builder() };
|
||||
assert_eq!(unwrap_draft(build_kinovi_seedance_2p5(under)).duration_seconds, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn total_input_seconds_is_carried_through() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
total_reference_video_input_seconds: Some(14),
|
||||
..base_builder()
|
||||
};
|
||||
let draft = unwrap_draft(build_kinovi_seedance_2p5(builder));
|
||||
assert_eq!(draft.total_input_seconds, Some(14));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn total_input_seconds_saturates_at_u8_max() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
total_reference_video_input_seconds: Some(9_999),
|
||||
..base_builder()
|
||||
};
|
||||
let draft = unwrap_draft(build_kinovi_seedance_2p5(builder));
|
||||
assert_eq!(draft.total_input_seconds, Some(u8::MAX));
|
||||
}
|
||||
}
|
||||
|
||||
mod modality_rules {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn keyframes_are_accepted() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
start_frame: Some(ImageRef::Url("https://example.com/start.jpg".to_string())),
|
||||
end_frame: Some(ImageRef::Url("https://example.com/end.jpg".to_string())),
|
||||
..base_builder()
|
||||
};
|
||||
let draft = unwrap_draft(build_kinovi_seedance_2p5(builder));
|
||||
let remaining = draft.unhandled_request_state.unwrap();
|
||||
assert!(remaining.start_frame.is_some());
|
||||
assert!(remaining.end_frame.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_frame_alone_is_accepted() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
start_frame: Some(ImageRef::Url("https://example.com/start.jpg".to_string())),
|
||||
..base_builder()
|
||||
};
|
||||
assert!(build_kinovi_seedance_2p5(builder).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_frame_without_start_frame_errors() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
end_frame: Some(ImageRef::Url("https://example.com/end.jpg".to_string())),
|
||||
..base_builder()
|
||||
};
|
||||
assert!(build_kinovi_seedance_2p5(builder).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyframes_plus_references_error() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
start_frame: Some(ImageRef::Url("https://example.com/start.jpg".to_string())),
|
||||
reference_images: Some(ImageListRef::Urls(vec!["https://example.com/ref.jpg".to_string()])),
|
||||
..base_builder()
|
||||
};
|
||||
assert!(build_kinovi_seedance_2p5(builder).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_references_error() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
reference_character_tokens: Some(CharacterListRef::CharacterTokens(vec![
|
||||
CharacterToken::new("char_abc".to_string()),
|
||||
])),
|
||||
..base_builder()
|
||||
};
|
||||
assert!(build_kinovi_seedance_2p5(builder).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_media_lands_in_unhandled() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
reference_images: Some(ImageListRef::Urls(vec!["https://example.com/ref.jpg".to_string()])),
|
||||
reference_videos: Some(VideoListRef::Urls(vec!["https://example.com/vid.mp4".to_string()])),
|
||||
..base_builder()
|
||||
};
|
||||
let draft = unwrap_draft(build_kinovi_seedance_2p5(builder));
|
||||
let remaining = draft.unhandled_request_state.unwrap();
|
||||
assert!(remaining.reference_images.is_some());
|
||||
assert!(remaining.reference_videos.is_some());
|
||||
}
|
||||
}
|
||||
|
||||
mod aspect_ratio_and_resolution {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn direct_aspect_ratio_mappings() {
|
||||
let expected = [
|
||||
(RouterAspectRatio::WideSixteenByNine, KinoviAspectRatio::Landscape16x9),
|
||||
(RouterAspectRatio::WideTwentyOneByNine, KinoviAspectRatio::UltraWide21x9),
|
||||
(RouterAspectRatio::TallNineBySixteen, KinoviAspectRatio::Portrait9x16),
|
||||
(RouterAspectRatio::Square, KinoviAspectRatio::Square1x1),
|
||||
(RouterAspectRatio::WideFourByThree, KinoviAspectRatio::Standard4x3),
|
||||
(RouterAspectRatio::TallThreeByFour, KinoviAspectRatio::Portrait3x4),
|
||||
];
|
||||
for (input, output) in expected {
|
||||
let builder = GenerateVideoRequestBuilder { aspect_ratio: Some(input), ..base_builder() };
|
||||
let draft = unwrap_draft(build_kinovi_seedance_2p5(builder));
|
||||
assert!(
|
||||
std::mem::discriminant(&draft.aspect_ratio) == std::mem::discriminant(&output),
|
||||
"aspect ratio {input:?} mapped wrong",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolution_480p_and_720p() {
|
||||
let builder = GenerateVideoRequestBuilder { resolution: Some(RouterResolution::FourEightyP), ..base_builder() };
|
||||
assert!(matches!(unwrap_draft(build_kinovi_seedance_2p5(builder)).resolution, Some(KinoviOutputResolution::FourEightyP)));
|
||||
|
||||
let builder = GenerateVideoRequestBuilder { resolution: Some(RouterResolution::SevenTwentyP), ..base_builder() };
|
||||
assert!(matches!(unwrap_draft(build_kinovi_seedance_2p5(builder)).resolution, Some(KinoviOutputResolution::SevenTwentyP)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolution_1080p_downgrades_to_720p() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
resolution: Some(RouterResolution::TenEightyP),
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayLessDowngrade,
|
||||
..base_builder()
|
||||
};
|
||||
assert!(matches!(unwrap_draft(build_kinovi_seedance_2p5(builder)).resolution, Some(KinoviOutputResolution::SevenTwentyP)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolution_1080p_error_out() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
resolution: Some(RouterResolution::TenEightyP),
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
|
||||
..base_builder()
|
||||
};
|
||||
assert!(build_kinovi_seedance_2p5(builder).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
fn base_builder() -> GenerateVideoRequestBuilder {
|
||||
GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Seedance2p5,
|
||||
provider: RouterProvider::KinoviWeb,
|
||||
prompt: Some("a cat dancing".to_string()),
|
||||
duration_seconds: Some(5),
|
||||
video_batch_count: Some(1),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn unwrap_draft(result: Result<VideoGenerationDraftOrRequest, ArtcraftRouterError>) -> KinoviSeedance2p5DraftState {
|
||||
match result.expect("build should succeed") {
|
||||
VideoGenerationDraftOrRequest::Draft(
|
||||
VideoGenerationDraftRequest::KinoviSeedance2p5(draft)
|
||||
) => draft,
|
||||
_ => panic!("expected KinoviSeedance2p5 draft"),
|
||||
}
|
||||
}
|
||||
}
|
||||
+222
@@ -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()
|
||||
}
|
||||
}
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
use kinovi_web_client::generate::video::generate_seedance_2p5::{
|
||||
GenerateSeedance2p5Request, KinoviSeedance2p5AspectRatio, KinoviSeedance2p5Modality,
|
||||
KinoviSeedance2p5OutputResolution,
|
||||
};
|
||||
|
||||
use crate::api::audio_list_ref::AudioListRef;
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::video_list_ref::VideoListRef;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::client_error::ClientError;
|
||||
use crate::generate::generate_video::providers::kinovi::resolve::{
|
||||
audio_list_ref_into_urls_or_tokens, image_list_ref_into_urls_or_tokens,
|
||||
resolve_and_upload_list, resolve_and_upload_single, video_list_ref_into_urls_or_tokens,
|
||||
};
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p5::request::KinoviSeedance2p5RequestState;
|
||||
use crate::generate::generate_video::video_generation_draft_context::VideoGenerationDraftContext;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct KinoviSeedance2p5DraftState {
|
||||
pub prompt: String,
|
||||
|
||||
/// Aspect ratio for the reference (text/reference-to-video) modality.
|
||||
/// Keyframe mode is always "Adaptive" and ignores this.
|
||||
pub aspect_ratio: KinoviSeedance2p5AspectRatio,
|
||||
|
||||
pub resolution: Option<KinoviSeedance2p5OutputResolution>,
|
||||
|
||||
pub duration_seconds: u8,
|
||||
|
||||
/// Calculation-only: total seconds of reference video input, summed across
|
||||
/// all reference videos (each rounded up to a whole second).
|
||||
pub total_input_seconds: Option<u8>,
|
||||
|
||||
pub unhandled_request_state: Option<KinoviSeedance2p5RemainingItems>,
|
||||
}
|
||||
|
||||
/// Media inputs not yet resolved/uploaded. The build step guarantees that
|
||||
/// keyframes and reference media are mutually exclusive.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct KinoviSeedance2p5RemainingItems {
|
||||
pub start_frame: Option<ImageRef>,
|
||||
pub end_frame: Option<ImageRef>,
|
||||
pub reference_images: Option<ImageListRef>,
|
||||
pub reference_videos: Option<VideoListRef>,
|
||||
pub reference_audio: Option<AudioListRef>,
|
||||
}
|
||||
|
||||
impl KinoviSeedance2p5DraftState {
|
||||
pub async fn to_request(
|
||||
&mut self,
|
||||
draft_context: &VideoGenerationDraftContext<'_>,
|
||||
) -> Result<KinoviSeedance2p5RequestState, ArtcraftRouterError> {
|
||||
let client = draft_context.get_kinovi_web_client_ref()?;
|
||||
let session = &client.session;
|
||||
|
||||
let mut start_frame_url = None;
|
||||
let mut end_frame_url = None;
|
||||
let mut reference_image_urls = None;
|
||||
let mut reference_video_urls = None;
|
||||
let mut reference_audio_urls = None;
|
||||
|
||||
if let Some(remaining) = self.unhandled_request_state.take() {
|
||||
let map = draft_context.media_file_to_artcraft_url_map;
|
||||
let predownloaded = draft_context.predownloaded_media_paths;
|
||||
|
||||
start_frame_url = resolve_and_upload_single(session, remaining.start_frame, map, predownloaded).await?;
|
||||
end_frame_url = resolve_and_upload_single(session, remaining.end_frame, map, predownloaded).await?;
|
||||
|
||||
reference_image_urls = resolve_and_upload_list(
|
||||
session, remaining.reference_images.map(image_list_ref_into_urls_or_tokens), map, predownloaded,
|
||||
).await?;
|
||||
|
||||
reference_video_urls = resolve_and_upload_list(
|
||||
session, remaining.reference_videos.map(video_list_ref_into_urls_or_tokens), map, predownloaded,
|
||||
).await?;
|
||||
|
||||
reference_audio_urls = resolve_and_upload_list(
|
||||
session, remaining.reference_audio.map(audio_list_ref_into_urls_or_tokens), map, predownloaded,
|
||||
).await?;
|
||||
}
|
||||
|
||||
let modality = match start_frame_url {
|
||||
Some(start_frame_url) => KinoviSeedance2p5Modality::Keyframe {
|
||||
start_frame_url,
|
||||
end_frame_url,
|
||||
},
|
||||
None => {
|
||||
// The build step rejects an end frame without a start frame, but a
|
||||
// failed resolution could recreate the situation — never send an
|
||||
// orphaned end frame as a reference request.
|
||||
if end_frame_url.is_some() {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "end_frame",
|
||||
value: "Seedance 2.5 requires a start frame when an end frame is set".to_string(),
|
||||
}));
|
||||
}
|
||||
KinoviSeedance2p5Modality::Reference {
|
||||
aspect_ratio: Some(self.aspect_ratio),
|
||||
reference_image_urls,
|
||||
reference_video_urls,
|
||||
reference_audio_urls,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let request = GenerateSeedance2p5Request {
|
||||
prompt: self.prompt.clone(),
|
||||
modality,
|
||||
output_resolution: self.resolution,
|
||||
duration_seconds: self.duration_seconds,
|
||||
total_input_seconds: self.total_input_seconds,
|
||||
use_face_blur_hack: None,
|
||||
};
|
||||
|
||||
Ok(KinoviSeedance2p5RequestState { request })
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
pub mod build;
|
||||
pub mod draft;
|
||||
pub mod request;
|
||||
pub mod cost;
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
use kinovi_web_client::generate::video::generate_seedance_2p5::{
|
||||
generate_seedance_2p5, GenerateSeedance2p5Args, GenerateSeedance2p5Request,
|
||||
};
|
||||
|
||||
use crate::client::router_kinovi_web_client::RouterKinoviWebClient;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::provider_error::ProviderError;
|
||||
use crate::generate::generate_video::generate_video_response::{GenerateVideoResponse, KinoviWebVideoResponsePayload};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct KinoviSeedance2p5RequestState {
|
||||
/// Final materialized request; ready to fire.
|
||||
pub request: GenerateSeedance2p5Request,
|
||||
}
|
||||
|
||||
impl KinoviSeedance2p5RequestState {
|
||||
pub async fn send(&self, client: &RouterKinoviWebClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
|
||||
let session = &client.session;
|
||||
|
||||
let args = GenerateSeedance2p5Args {
|
||||
session,
|
||||
host_override: None,
|
||||
request: self.request.clone(),
|
||||
};
|
||||
|
||||
let response = generate_seedance_2p5(args)
|
||||
.await
|
||||
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::KinoviWeb(err)))?;
|
||||
|
||||
Ok(GenerateVideoResponse::KinoviWeb(KinoviWebVideoResponsePayload {
|
||||
order_id: response.order_id,
|
||||
task_id: response.task_id,
|
||||
maybe_order_ids: response.order_ids,
|
||||
maybe_task_ids: response.task_ids,
|
||||
}))
|
||||
}
|
||||
}
|
||||
+4
-3
@@ -47,17 +47,18 @@ impl KinoviSeedance2p5PreviewDraftState {
|
||||
|
||||
if let Some(remaining) = self.unhandled_request_state.take() {
|
||||
let map = draft_context.media_file_to_artcraft_url_map;
|
||||
let predownloaded = draft_context.predownloaded_media_paths;
|
||||
|
||||
reference_image_urls = resolve_and_upload_list(
|
||||
session, remaining.reference_images.map(image_list_ref_into_urls_or_tokens), map,
|
||||
session, remaining.reference_images.map(image_list_ref_into_urls_or_tokens), map, predownloaded,
|
||||
).await?;
|
||||
|
||||
reference_video_urls = resolve_and_upload_list(
|
||||
session, remaining.reference_videos.map(video_list_ref_into_urls_or_tokens), map,
|
||||
session, remaining.reference_videos.map(video_list_ref_into_urls_or_tokens), map, predownloaded,
|
||||
).await?;
|
||||
|
||||
reference_audio_urls = resolve_and_upload_list(
|
||||
session, remaining.reference_audio.map(audio_list_ref_into_urls_or_tokens), map,
|
||||
session, remaining.reference_audio.map(audio_list_ref_into_urls_or_tokens), map, predownloaded,
|
||||
).await?;
|
||||
}
|
||||
|
||||
|
||||
+24
-2
@@ -1,3 +1,7 @@
|
||||
use std::path::Path;
|
||||
|
||||
use log::{info, warn};
|
||||
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::provider_error::ProviderError;
|
||||
use crate::utils::download_file::download_file;
|
||||
@@ -6,16 +10,34 @@ use kinovi_web_client::requests::prepare_file_upload::prepare_file_upload::{prep
|
||||
use kinovi_web_client::requests::upload_file::upload_file::{upload_file, UploadFileArgs};
|
||||
use url_utils::extension::extract_extension_from_url::{extract_extension_from_url_str, ExtractExtensions};
|
||||
|
||||
/// Downloads a file from a source URL and re-uploads it to kinovi_web CDN.
|
||||
/// Re-uploads a file to the kinovi_web CDN, reading it from
|
||||
/// `maybe_local_path` when the caller already downloaded it (e.g. reference
|
||||
/// videos probed for billing), or downloading it from the source URL
|
||||
/// otherwise.
|
||||
pub(crate) async fn upload_to_kinovi_web(
|
||||
session: &KinoviWebSession,
|
||||
source_url: &str,
|
||||
maybe_local_path: Option<&Path>,
|
||||
) -> Result<String, ArtcraftRouterError> {
|
||||
let extension = extract_extension_from_url_str(source_url, &ExtractExtensions::All)
|
||||
.map(|ext| ext.without_period().to_string())
|
||||
.unwrap_or_else(|| "jpg".to_string());
|
||||
|
||||
let file_bytes = download_file(source_url).await?;
|
||||
let file_bytes = match maybe_local_path {
|
||||
Some(local_path) => match std::fs::read(local_path) {
|
||||
Ok(bytes) => {
|
||||
info!("Reusing predownloaded file for kinovi upload: {}", source_url);
|
||||
bytes
|
||||
}
|
||||
Err(err) => {
|
||||
// The file should exist; fall back to downloading rather than fail.
|
||||
warn!("Failed to read predownloaded file {:?} ({:?}); re-downloading {}",
|
||||
local_path, err, source_url);
|
||||
download_file(source_url).await?
|
||||
}
|
||||
},
|
||||
None => download_file(source_url).await?,
|
||||
};
|
||||
|
||||
let prepare_response = prepare_file_upload(PrepareFileUploadArgs {
|
||||
session,
|
||||
|
||||
+9
@@ -9,6 +9,8 @@ use crate::generate::generate_video::providers::kinovi::seedance_2p0_fast::cost:
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p0_fast::draft::KinoviSeedance2p0FastDraftState;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p0_mini::cost::KinoviSeedance2p0MiniCostState;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p0_mini::draft::KinoviSeedance2p0MiniDraftState;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p5::cost::KinoviSeedance2p5CostState;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p5::draft::KinoviSeedance2p5DraftState;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p5_preview::cost::KinoviSeedance2p5PreviewCostState;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p5_preview::draft::KinoviSeedance2p5PreviewDraftState;
|
||||
use crate::generate::generate_video::video_generation_draft_context::VideoGenerationDraftContext;
|
||||
@@ -24,6 +26,7 @@ pub enum VideoGenerationDraftRequest {
|
||||
KinoviSeedance2p0Fast(KinoviSeedance2p0FastDraftState),
|
||||
KinoviSeedance2p0Mini(KinoviSeedance2p0MiniDraftState),
|
||||
KinoviSeedance2p5Preview(KinoviSeedance2p5PreviewDraftState),
|
||||
KinoviSeedance2p5(KinoviSeedance2p5DraftState),
|
||||
}
|
||||
|
||||
impl VideoGenerationDraftRequest {
|
||||
@@ -35,6 +38,7 @@ impl VideoGenerationDraftRequest {
|
||||
Self::KinoviSeedance2p0Fast(_) => RouterProvider::KinoviWeb,
|
||||
Self::KinoviSeedance2p0Mini(_) => RouterProvider::KinoviWeb,
|
||||
Self::KinoviSeedance2p5Preview(_) => RouterProvider::KinoviWeb,
|
||||
Self::KinoviSeedance2p5(_) => RouterProvider::KinoviWeb,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +50,7 @@ impl VideoGenerationDraftRequest {
|
||||
VideoGenerationDraftRequest::KinoviSeedance2p0Fast(draft) => Ok(KinoviSeedance2p0FastCostState::from_draft(draft).estimate_cost()),
|
||||
VideoGenerationDraftRequest::KinoviSeedance2p0Mini(draft) => Ok(KinoviSeedance2p0MiniCostState::from_draft(draft).estimate_cost()),
|
||||
VideoGenerationDraftRequest::KinoviSeedance2p5Preview(draft) => Ok(KinoviSeedance2p5PreviewCostState::from_draft(draft).estimate_cost()),
|
||||
VideoGenerationDraftRequest::KinoviSeedance2p5(draft) => Ok(KinoviSeedance2p5CostState::from_draft(draft).estimate_cost()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +78,10 @@ impl VideoGenerationDraftRequest {
|
||||
let result = draft.to_request(&draft_context).await?;
|
||||
Ok(VideoGenerationRequest::KinoviSeedance2p5Preview(result))
|
||||
},
|
||||
VideoGenerationDraftRequest::KinoviSeedance2p5(mut draft) => {
|
||||
let result = draft.to_request(&draft_context).await?;
|
||||
Ok(VideoGenerationRequest::KinoviSeedance2p5(result))
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+8
@@ -4,6 +4,7 @@ use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::client_error::ClientError;
|
||||
use std::collections::HashMap;
|
||||
use std::fmt::Debug;
|
||||
use std::path::PathBuf;
|
||||
use tokens::tokens::characters::CharacterToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
@@ -19,6 +20,12 @@ pub struct VideoGenerationDraftContext<'a> {
|
||||
/// Optional context: a map of Character Tokens to their respective Kinovi IDs
|
||||
/// Only necessary if using Kinovi characters
|
||||
pub character_token_to_kinovi_id_map: Option<&'a HashMap<CharacterToken, String>>,
|
||||
|
||||
/// Optional context: source URL → local file path for media the caller has
|
||||
/// already downloaded (e.g. reference videos probed for billing). Uploads
|
||||
/// read these files instead of downloading the same bytes again. The
|
||||
/// caller must keep the files alive until the upload completes.
|
||||
pub predownloaded_media_paths: Option<&'a HashMap<String, PathBuf>>,
|
||||
}
|
||||
|
||||
impl <'a> VideoGenerationDraftContext<'a> {
|
||||
@@ -45,6 +52,7 @@ impl Debug for VideoGenerationDraftContext<'_> {
|
||||
.field("client", &self.client.is_some())
|
||||
.field("media_file_to_artcraft_url_map", &self.media_file_to_artcraft_url_map)
|
||||
.field("character_token_to_kinovi_id_map", &self.character_token_to_kinovi_id_map)
|
||||
.field("predownloaded_media_paths", &self.predownloaded_media_paths)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
+18
@@ -55,6 +55,8 @@ use crate::generate::generate_video::providers::artcraft::seedance_2p0_bpu_fast:
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bpu_fast::request::ArtcraftSeedance2p0BytePlusUltraFastRequestState;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0_mini::cost::ArtcraftSeedance2p0MiniCostState;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0_mini::request::ArtcraftSeedance2p0MiniRequestState;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p5::cost::ArtcraftSeedance2p5CostState;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p5::request::ArtcraftSeedance2p5RequestState;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p5_preview::cost::ArtcraftSeedance2p5PreviewCostState;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p5_preview::request::ArtcraftSeedance2p5PreviewRequestState;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bp_mini::cost::ArtcraftSeedance2p0BytePlusMiniCostState;
|
||||
@@ -141,6 +143,8 @@ use crate::generate::generate_video::providers::kinovi::seedance_2p0_fast::cost:
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p0_fast::request::KinoviSeedance2p0FastRequestState;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p0_mini::cost::KinoviSeedance2p0MiniCostState;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p0_mini::request::KinoviSeedance2p0MiniRequestState;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p5::cost::KinoviSeedance2p5CostState;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p5::request::KinoviSeedance2p5RequestState;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p5_preview::cost::KinoviSeedance2p5PreviewCostState;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p5_preview::request::KinoviSeedance2p5PreviewRequestState;
|
||||
|
||||
@@ -173,6 +177,7 @@ pub enum VideoGenerationRequest {
|
||||
ArtcraftSeedance2p0BytePlusUltraFast(ArtcraftSeedance2p0BytePlusUltraFastRequestState),
|
||||
ArtcraftSeedance2p0Mini(ArtcraftSeedance2p0MiniRequestState),
|
||||
ArtcraftSeedance2p5Preview(ArtcraftSeedance2p5PreviewRequestState),
|
||||
ArtcraftSeedance2p5(ArtcraftSeedance2p5RequestState),
|
||||
ArtcraftSeedance2p0BytePlusMini(ArtcraftSeedance2p0BytePlusMiniRequestState),
|
||||
ArtcraftSeedance2p0BytePlusUltraMini(ArtcraftSeedance2p0BytePlusUltraMiniRequestState),
|
||||
ArtcraftSora2(ArtcraftSora2RequestState),
|
||||
@@ -216,6 +221,7 @@ pub enum VideoGenerationRequest {
|
||||
KinoviSeedance2p0Fast(KinoviSeedance2p0FastRequestState),
|
||||
KinoviSeedance2p0Mini(KinoviSeedance2p0MiniRequestState),
|
||||
KinoviSeedance2p5Preview(KinoviSeedance2p5PreviewRequestState),
|
||||
KinoviSeedance2p5(KinoviSeedance2p5RequestState),
|
||||
}
|
||||
|
||||
impl VideoGenerationRequest {
|
||||
@@ -249,6 +255,7 @@ impl VideoGenerationRequest {
|
||||
Self::ArtcraftSeedance2p0BytePlusUltraFast(_) => RouterProvider::Artcraft,
|
||||
Self::ArtcraftSeedance2p0Mini(_) => RouterProvider::Artcraft,
|
||||
Self::ArtcraftSeedance2p5Preview(_) => RouterProvider::Artcraft,
|
||||
Self::ArtcraftSeedance2p5(_) => RouterProvider::Artcraft,
|
||||
Self::ArtcraftSeedance2p0BytePlusMini(_) => RouterProvider::Artcraft,
|
||||
Self::ArtcraftSeedance2p0BytePlusUltraMini(_) => RouterProvider::Artcraft,
|
||||
Self::ArtcraftSora2(_) => RouterProvider::Artcraft,
|
||||
@@ -292,6 +299,7 @@ impl VideoGenerationRequest {
|
||||
Self::KinoviSeedance2p0Fast(_) => RouterProvider::KinoviWeb,
|
||||
Self::KinoviSeedance2p0Mini(_) => RouterProvider::KinoviWeb,
|
||||
Self::KinoviSeedance2p5Preview(_) => RouterProvider::KinoviWeb,
|
||||
Self::KinoviSeedance2p5(_) => RouterProvider::KinoviWeb,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -325,6 +333,7 @@ impl VideoGenerationRequest {
|
||||
VideoGenerationRequest::ArtcraftSeedance2p0BytePlusUltraFast(request) => Ok(ArtcraftSeedance2p0BytePlusUltraFastCostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::ArtcraftSeedance2p0Mini(request) => Ok(ArtcraftSeedance2p0MiniCostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::ArtcraftSeedance2p5Preview(request) => Ok(ArtcraftSeedance2p5PreviewCostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::ArtcraftSeedance2p5(request) => Ok(ArtcraftSeedance2p5CostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::ArtcraftSeedance2p0BytePlusMini(request) => Ok(ArtcraftSeedance2p0BytePlusMiniCostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::ArtcraftSeedance2p0BytePlusUltraMini(request) => Ok(ArtcraftSeedance2p0BytePlusUltraMiniCostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::ArtcraftSora2(request) => Ok(ArtcraftSora2CostState::from_request(request).estimate_cost()),
|
||||
@@ -368,6 +377,7 @@ impl VideoGenerationRequest {
|
||||
VideoGenerationRequest::KinoviSeedance2p0Fast(request) => Ok(KinoviSeedance2p0FastCostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::KinoviSeedance2p0Mini(request) => Ok(KinoviSeedance2p0MiniCostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::KinoviSeedance2p5Preview(request) => Ok(KinoviSeedance2p5PreviewCostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::KinoviSeedance2p5(request) => Ok(KinoviSeedance2p5CostState::from_request(request).estimate_cost()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -483,6 +493,10 @@ impl VideoGenerationRequest {
|
||||
let client_ref = client.get_artcraft_client_ref()?;
|
||||
request.send(client_ref).await
|
||||
},
|
||||
VideoGenerationRequest::ArtcraftSeedance2p5(request) => {
|
||||
let client_ref = client.get_artcraft_client_ref()?;
|
||||
request.send(client_ref).await
|
||||
},
|
||||
VideoGenerationRequest::ArtcraftSeedance2p0BytePlusMini(request) => {
|
||||
let client_ref = client.get_artcraft_client_ref()?;
|
||||
request.send(client_ref).await
|
||||
@@ -655,6 +669,10 @@ impl VideoGenerationRequest {
|
||||
let client_ref = client.get_kinovi_web_client_ref()?;
|
||||
request.send(client_ref).await
|
||||
},
|
||||
VideoGenerationRequest::KinoviSeedance2p5(request) => {
|
||||
let client_ref = client.get_kinovi_web_client_ref()?;
|
||||
request.send(client_ref).await
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,6 +355,7 @@ pub fn base_video_request() -> GenerateVideoRequestBuilder {
|
||||
duration_seconds: None,
|
||||
video_batch_count: None,
|
||||
generate_audio: None,
|
||||
total_reference_video_input_seconds: None,
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
|
||||
idempotency_token: None,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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;
|
||||
|
||||
+198
-20
@@ -216,6 +216,8 @@ pub enum KinoviModelTypeRaw {
|
||||
Seedance2Mini,
|
||||
/// Seedance 2.5 Preview (480p/720p only).
|
||||
Seedance2p5Preview,
|
||||
/// Seedance 2.5 (480p/720p only).
|
||||
Seedance2p5,
|
||||
/// Happy Horse 1.0.
|
||||
HappyHorse1p0,
|
||||
}
|
||||
@@ -227,27 +229,29 @@ impl KinoviModelTypeRaw {
|
||||
Self::Seedance2Fast => "seedance2-fast",
|
||||
Self::Seedance2Mini => "seedance2.0-mini",
|
||||
Self::Seedance2p5Preview => "seedance2-5-preview",
|
||||
Self::Seedance2p5 => "seedance2-5",
|
||||
Self::HappyHorse1p0 => "happyhorse1.0",
|
||||
}
|
||||
}
|
||||
|
||||
/// The tRPC `businessType` for this model. Seedance 2.0 Mini and 2.5
|
||||
/// Preview use their own business types; every other model uses the
|
||||
/// shared one.
|
||||
/// The tRPC `businessType` for this model. Seedance 2.0 Mini and the 2.5
|
||||
/// family use their own business types; every other model uses the shared
|
||||
/// one. (Seedance 2.5 reuses the *preview* business type on the wire —
|
||||
/// only the `model` string differs.)
|
||||
fn business_type(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Seedance2Mini => "seedance20-mini-video-generation",
|
||||
Self::Seedance2p5Preview => "seedance25-preview-video-generation",
|
||||
Self::Seedance2p5Preview | Self::Seedance2p5 => "seedance25-preview-video-generation",
|
||||
Self::HappyHorse1p0 => "happyhorse-video-generation",
|
||||
Self::Seedance2Pro | Self::Seedance2Fast => "wan22-video-generation",
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the aspect ratio is sent in an `aspectRatio` field (true)
|
||||
/// rather than the `resolution` field. Mini, 2.5 Preview, and Happy Horse
|
||||
/// use `aspectRatio`.
|
||||
/// rather than the `resolution` field. Mini, the 2.5 family, and Happy
|
||||
/// Horse use `aspectRatio`.
|
||||
fn uses_aspect_ratio_field(&self) -> bool {
|
||||
matches!(self, Self::Seedance2Mini | Self::Seedance2p5Preview | Self::HappyHorse1p0)
|
||||
matches!(self, Self::Seedance2Mini | Self::Seedance2p5Preview | Self::Seedance2p5 | Self::HappyHorse1p0)
|
||||
}
|
||||
|
||||
/// Whether this model uses Happy Horse's `happyhorseMode` (t2v/i2v)
|
||||
@@ -258,10 +262,11 @@ impl KinoviModelTypeRaw {
|
||||
|
||||
/// Whether reference URLs are sent in split `imageUrls` / `videoUrls`
|
||||
/// fields — with `uploadedUrls` mirroring just the images — rather than
|
||||
/// one combined `uploadedUrls` list. Seedance 2.5 Preview uses the split
|
||||
/// shape.
|
||||
/// one combined `uploadedUrls` list. The 2.5 family uses the split shape.
|
||||
/// (In 2.5 keyframe mode the start/end frames go in `imageUrls` with
|
||||
/// `uploadedUrls` mirroring them.)
|
||||
fn uses_split_reference_url_fields(&self) -> bool {
|
||||
matches!(self, Self::Seedance2p5Preview)
|
||||
matches!(self, Self::Seedance2p5Preview | Self::Seedance2p5)
|
||||
}
|
||||
|
||||
/// Whether `mode` is always "reference", even with no references attached.
|
||||
@@ -270,11 +275,25 @@ impl KinoviModelTypeRaw {
|
||||
matches!(self, Self::Seedance2p5Preview)
|
||||
}
|
||||
|
||||
/// Whether `mode` is "keyframe" only when a start/end frame is attached,
|
||||
/// and "reference" otherwise (including plain text-to-video). Seedance 2.5
|
||||
/// sends text-to-video as mode "reference".
|
||||
fn uses_reference_mode_unless_keyframes(&self) -> bool {
|
||||
matches!(self, Self::Seedance2p5)
|
||||
}
|
||||
|
||||
/// Whether keyframe mode omits the aspect ratio entirely: Seedance 2.5
|
||||
/// keyframe (image-to-video) only supports "Adaptive", which is expressed
|
||||
/// by sending no `aspectRatio` field at all.
|
||||
fn omits_aspect_ratio_in_keyframe_mode(&self) -> bool {
|
||||
matches!(self, Self::Seedance2p5)
|
||||
}
|
||||
|
||||
/// Whether the default field values (`outputResolution: "720p"`,
|
||||
/// `faceBlurMode: "off"`) are always sent explicitly rather than omitted.
|
||||
/// Seedance 2.5 Preview always sends both.
|
||||
/// The 2.5 family always sends both.
|
||||
fn always_sends_default_fields(&self) -> bool {
|
||||
matches!(self, Self::Seedance2p5Preview)
|
||||
matches!(self, Self::Seedance2p5Preview | Self::Seedance2p5)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -446,20 +465,41 @@ fn build_batch_request(req: WorkflowRunTaskRequest) -> BatchRequest {
|
||||
|
||||
let is_reference_mode = has_reference_images || has_reference_videos || has_reference_audio || has_characters;
|
||||
|
||||
let video_input_mode = if is_reference_mode || req.model_type.always_uses_reference_mode() {
|
||||
let has_keyframes = req.start_frame_url.is_some() || req.end_frame_url.is_some();
|
||||
|
||||
let video_input_mode = if req.model_type.always_uses_reference_mode() {
|
||||
"reference"
|
||||
} else if req.model_type.uses_reference_mode_unless_keyframes() {
|
||||
// Seedance 2.5: text-to-video is sent as "reference"; "keyframe" only
|
||||
// when a start/end frame is attached.
|
||||
if has_keyframes { "keyframe" } else { "reference" }
|
||||
} else if is_reference_mode {
|
||||
"reference"
|
||||
} else {
|
||||
"keyframe"
|
||||
};
|
||||
|
||||
// Reference URLs. Seedance 2.5 Preview sends split `imageUrls` /
|
||||
// `videoUrls` fields with `uploadedUrls` mirroring just the images; other
|
||||
// Reference URLs. The 2.5 family sends split `imageUrls` / `videoUrls`
|
||||
// fields with `uploadedUrls` mirroring just the images (in 2.5 keyframe
|
||||
// mode the start/end frames ride in `imageUrls` + `uploadedUrls`); other
|
||||
// models combine videos + images into `uploadedUrls` in reference mode, or
|
||||
// send the start/end frames there in keyframe mode.
|
||||
let (image_urls, video_urls, uploaded_urls): (Option<Vec<String>>, Option<Vec<String>>, Option<Vec<String>>) = if req.model_type.uses_split_reference_url_fields() {
|
||||
let image_urls = req.reference_image_urls.filter(|urls| !urls.is_empty());
|
||||
let video_urls = req.reference_video_urls.filter(|urls| !urls.is_empty());
|
||||
(image_urls.clone(), video_urls, image_urls)
|
||||
if video_input_mode == "keyframe" {
|
||||
let mut urls = Vec::new();
|
||||
if let Some(url) = req.start_frame_url {
|
||||
urls.push(url);
|
||||
}
|
||||
if let Some(url) = req.end_frame_url {
|
||||
urls.push(url);
|
||||
}
|
||||
let urls = if urls.is_empty() { None } else { Some(urls) };
|
||||
(urls.clone(), None, urls)
|
||||
} else {
|
||||
let image_urls = req.reference_image_urls.filter(|urls| !urls.is_empty());
|
||||
let video_urls = req.reference_video_urls.filter(|urls| !urls.is_empty());
|
||||
(image_urls.clone(), video_urls, image_urls)
|
||||
}
|
||||
} else if is_reference_mode {
|
||||
let mut urls = Vec::new();
|
||||
if let Some(video_urls) = req.reference_video_urls {
|
||||
@@ -506,9 +546,13 @@ fn build_batch_request(req: WorkflowRunTaskRequest) -> BatchRequest {
|
||||
let duration = format!("{}s", req.duration_seconds);
|
||||
|
||||
// The aspect ratio is sent as a ratio string (e.g. "16:9"). Seedance Pro/Fast
|
||||
// carry it in the `resolution` field; Mini and Happy Horse use `aspectRatio`.
|
||||
// carry it in the `resolution` field; Mini, the 2.5 family, and Happy Horse
|
||||
// use `aspectRatio`. Seedance 2.5 keyframe mode only supports "Adaptive",
|
||||
// which is expressed by omitting the field entirely.
|
||||
let aspect_ratio_value = req.aspect_ratio.as_aspect_ratio_str();
|
||||
let (resolution, aspect_ratio) = if req.model_type.uses_aspect_ratio_field() {
|
||||
let (resolution, aspect_ratio) = if video_input_mode == "keyframe" && req.model_type.omits_aspect_ratio_in_keyframe_mode() {
|
||||
(None, None)
|
||||
} else if req.model_type.uses_aspect_ratio_field() {
|
||||
(None, Some(aspect_ratio_value))
|
||||
} else {
|
||||
(Some(aspect_ratio_value.to_string()), None)
|
||||
@@ -893,6 +937,140 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Seedance 2.5 request shape ──
|
||||
//
|
||||
// Seedance 2.5 reuses the *preview* businessType
|
||||
// (`seedance25-preview-video-generation`) with model `seedance2-5`. Unlike
|
||||
// Preview it has a keyframe (image-to-video) mode: `mode` is "keyframe"
|
||||
// only when a start/end frame is attached (text-to-video goes out as
|
||||
// "reference"), keyframe requests omit `aspectRatio` entirely (the only
|
||||
// choice is "Adaptive"), and the frames ride in `imageUrls` with
|
||||
// `uploadedUrls` mirroring them. Reference mode matches Preview's split
|
||||
// URL shape. Mirrors
|
||||
// external/requests/sites/kinovi.ai/2026-08-07-seedance2p5/.
|
||||
|
||||
mod seedance_2p5_shape_tests {
|
||||
use super::*;
|
||||
|
||||
fn base_request(prompt: &str, duration_seconds: u8) -> WorkflowRunTaskRequest {
|
||||
WorkflowRunTaskRequest {
|
||||
model_type: KinoviModelTypeRaw::Seedance2p5,
|
||||
prompt: prompt.to_string(),
|
||||
aspect_ratio: KinoviAspectRatioRaw::Landscape16x9,
|
||||
output_resolution: Some(KinoviOutputResolutionRaw::FourEightyP),
|
||||
duration_seconds,
|
||||
batch_count: KinoviBatchCountRaw::One,
|
||||
start_frame_url: None,
|
||||
end_frame_url: None,
|
||||
reference_image_urls: None,
|
||||
reference_video_urls: None,
|
||||
reference_audio_urls: None,
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_to_video_matches_observed_request() {
|
||||
// Mirrors 1_request_text_to_video.txt: mode "reference", aspectRatio
|
||||
// "16:9", 480p, 5s, no URL fields.
|
||||
let body = build_batch_request(base_request("Lightning hits a building", 5));
|
||||
let json = serde_json::to_string(&body).unwrap();
|
||||
assert!(json.contains(r#""businessType":"seedance25-preview-video-generation""#), "{json}");
|
||||
assert!(json.contains(r#""model":"seedance2-5""#), "{json}");
|
||||
assert!(json.contains(r#""mode":"reference""#), "{json}");
|
||||
assert!(json.contains(r#""aspectRatio":"16:9""#), "{json}");
|
||||
assert!(json.contains(r#""duration":"5s""#), "{json}");
|
||||
assert!(json.contains(r#""outputResolution":"480p""#), "{json}");
|
||||
assert!(json.contains(r#""faceBlurMode":"off""#), "{json}");
|
||||
assert!(json.contains(r#""contentMode":"normal""#), "{json}");
|
||||
// No ratio-string `resolution` field, and no URL fields.
|
||||
assert!(!json.contains(r#""resolution":"#), "{json}");
|
||||
assert!(!json.contains("imageUrls"), "{json}");
|
||||
assert!(!json.contains("videoUrls"), "{json}");
|
||||
assert!(!json.contains("uploadedUrls"), "{json}");
|
||||
assert!(!json.contains("audioUrls"), "{json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyframe_with_both_frames_matches_observed_request() {
|
||||
// Mirrors 2_request_keyframe_to_video.txt: mode "keyframe", NO
|
||||
// aspectRatio (Adaptive), frames in imageUrls + uploadedUrls.
|
||||
let mut request = base_request("Car drives into the sunset", 8);
|
||||
request.start_frame_url = Some("https://static.seedance2-pro.com/materials/start.png".to_string());
|
||||
request.end_frame_url = Some("https://static.seedance2-pro.com/materials/end.png".to_string());
|
||||
|
||||
let body = build_batch_request(request);
|
||||
let json = serde_json::to_string(&body).unwrap();
|
||||
assert!(json.contains(r#""mode":"keyframe""#), "{json}");
|
||||
// Adaptive: neither the `aspectRatio` nor `resolution` field is sent.
|
||||
assert!(!json.contains("aspectRatio"), "{json}");
|
||||
assert!(!json.contains(r#""resolution":"#), "{json}");
|
||||
let expected_urls = r#"["https://static.seedance2-pro.com/materials/start.png","https://static.seedance2-pro.com/materials/end.png"]"#;
|
||||
assert!(json.contains(&format!(r#""imageUrls":{expected_urls}"#)), "{json}");
|
||||
assert!(json.contains(&format!(r#""uploadedUrls":{expected_urls}"#)), "{json}");
|
||||
assert!(!json.contains("videoUrls"), "{json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyframe_with_start_frame_only_matches_observed_request() {
|
||||
// Mirrors 4_request_keyframe_to_video_2.txt: a single start frame.
|
||||
let mut request = base_request("The cars drive by", 5);
|
||||
request.start_frame_url = Some("https://static.seedance2-pro.com/materials/start.jpg".to_string());
|
||||
|
||||
let body = build_batch_request(request);
|
||||
let json = serde_json::to_string(&body).unwrap();
|
||||
assert!(json.contains(r#""mode":"keyframe""#), "{json}");
|
||||
assert!(!json.contains("aspectRatio"), "{json}");
|
||||
let expected_urls = r#"["https://static.seedance2-pro.com/materials/start.jpg"]"#;
|
||||
assert!(json.contains(&format!(r#""imageUrls":{expected_urls}"#)), "{json}");
|
||||
assert!(json.contains(&format!(r#""uploadedUrls":{expected_urls}"#)), "{json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_with_all_reference_types_matches_observed_request() {
|
||||
// Mirrors 3_request_reference_to_video.txt: mode "reference",
|
||||
// aspectRatio "21:9", split imageUrls / videoUrls / audioUrls with
|
||||
// uploadedUrls mirroring just the images.
|
||||
let mut request = base_request("The t-rex @image1 eats the banana @image2", 8);
|
||||
request.aspect_ratio = KinoviAspectRatioRaw::UltraWide21x9;
|
||||
request.reference_image_urls = Some(vec![
|
||||
"https://static.seedance2-pro.com/materials/a.png".to_string(),
|
||||
"https://static.seedance2-pro.com/materials/b.jpg".to_string(),
|
||||
]);
|
||||
request.reference_video_urls = Some(vec!["https://static.seedance2-pro.com/materials/ref.mp4".to_string()]);
|
||||
request.reference_audio_urls = Some(vec!["https://static.seedance2-pro.com/materials/ref.wav".to_string()]);
|
||||
|
||||
let body = build_batch_request(request);
|
||||
let json = serde_json::to_string(&body).unwrap();
|
||||
assert!(json.contains(r#""mode":"reference""#), "{json}");
|
||||
assert!(json.contains(r#""aspectRatio":"21:9""#), "{json}");
|
||||
let expected_images = r#"["https://static.seedance2-pro.com/materials/a.png","https://static.seedance2-pro.com/materials/b.jpg"]"#;
|
||||
assert!(json.contains(&format!(r#""imageUrls":{expected_images}"#)), "{json}");
|
||||
assert!(json.contains(&format!(r#""uploadedUrls":{expected_images}"#)), "{json}");
|
||||
assert!(json.contains(r#""videoUrls":["https://static.seedance2-pro.com/materials/ref.mp4"]"#), "{json}");
|
||||
assert!(json.contains(r#""audioUrls":["https://static.seedance2-pro.com/materials/ref.wav"]"#), "{json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_720p_and_face_blur_off_are_sent_explicitly() {
|
||||
let mut request = base_request("a corgi", 5);
|
||||
request.output_resolution = None;
|
||||
|
||||
let body = build_batch_request(request);
|
||||
let json = serde_json::to_string(&body).unwrap();
|
||||
assert!(json.contains(r#""outputResolution":"720p""#), "{json}");
|
||||
assert!(json.contains(r#""faceBlurMode":"off""#), "{json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_string_and_business_type() {
|
||||
assert_eq!(KinoviModelTypeRaw::Seedance2p5.as_api_str(), "seedance2-5");
|
||||
assert_eq!(KinoviModelTypeRaw::Seedance2p5.business_type(), "seedance25-preview-video-generation");
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2026-06-24 request-shape change ──
|
||||
//
|
||||
// The aspect ratio is now a ratio string ("16:9"). Seedance Pro/Fast keep it
|
||||
|
||||
+1
@@ -90,6 +90,7 @@ fn video_model_to_common_model_type(model: RouterVideoModel) -> Option<CommonMod
|
||||
RouterVideoModel::GrokImagineVideo => Some(CommonModelType::GrokImagineVideo),
|
||||
RouterVideoModel::GrokImagineVideo1p5 => Some(CommonModelType::GrokImagineVideo1p5),
|
||||
RouterVideoModel::Seedance2p5Preview => Some(CommonModelType::Seedance2p5Preview),
|
||||
RouterVideoModel::Seedance2p5 => Some(CommonModelType::Seedance2p5),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -58,6 +58,7 @@ pub(super) async fn handle_artcraft_video_via_router(
|
||||
duration_seconds: request.duration_seconds,
|
||||
video_batch_count: request.video_batch_count,
|
||||
generate_audio: request.generate_audio,
|
||||
total_reference_video_input_seconds: None,
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
|
||||
idempotency_token: None,
|
||||
negative_prompt: None,
|
||||
|
||||
@@ -220,6 +220,8 @@ pub enum InferenceModelType {
|
||||
Seedance2p0BytePlusUltraMini,
|
||||
#[serde(rename = "seedance_2p5_preview")]
|
||||
Seedance2p5Preview,
|
||||
#[serde(rename = "seedance_2p5")]
|
||||
Seedance2p5,
|
||||
#[serde(rename = "sora_2")]
|
||||
Sora2,
|
||||
#[serde(rename = "sora_2_pro")]
|
||||
@@ -396,6 +398,7 @@ impl InferenceModelType {
|
||||
Self::Seedance2p0BytePlusMini => "seedance_2p0_bp_mini",
|
||||
Self::Seedance2p0BytePlusUltraMini => "seedance_2p0_bpu_mini",
|
||||
Self::Seedance2p5Preview => "seedance_2p5_preview",
|
||||
Self::Seedance2p5 => "seedance_2p5",
|
||||
Self::Sora2 => "sora_2",
|
||||
Self::Sora2Pro => "sora_2_pro",
|
||||
Self::Veo2 => "veo_2",
|
||||
@@ -532,6 +535,7 @@ impl InferenceModelType {
|
||||
"seedance_2p0_bp_mini" => Ok(Self::Seedance2p0BytePlusMini),
|
||||
"seedance_2p0_bpu_mini" => Ok(Self::Seedance2p0BytePlusUltraMini),
|
||||
"seedance_2p5_preview" => Ok(Self::Seedance2p5Preview),
|
||||
"seedance_2p5" => Ok(Self::Seedance2p5),
|
||||
"sora_2" => Ok(Self::Sora2),
|
||||
"sora_2_pro" => Ok(Self::Sora2Pro),
|
||||
"veo_2" => Ok(Self::Veo2),
|
||||
@@ -672,6 +676,7 @@ impl InferenceModelType {
|
||||
Self::Seedance2p0BytePlusMini,
|
||||
Self::Seedance2p0BytePlusUltraMini,
|
||||
Self::Seedance2p5Preview,
|
||||
Self::Seedance2p5,
|
||||
Self::Sora2,
|
||||
Self::Sora2Pro,
|
||||
Self::Veo2,
|
||||
@@ -795,6 +800,7 @@ impl InferenceModelType {
|
||||
CommonModelType::Seedance2p0BytePlusMini => Self::Seedance2p0BytePlusMini,
|
||||
CommonModelType::Seedance2p0BytePlusUltraMini => Self::Seedance2p0BytePlusUltraMini,
|
||||
CommonModelType::Seedance2p5Preview => Self::Seedance2p5Preview,
|
||||
CommonModelType::Seedance2p5 => Self::Seedance2p5,
|
||||
CommonModelType::Sora2 => Self::Sora2,
|
||||
CommonModelType::Sora2Pro => Self::Sora2Pro,
|
||||
CommonModelType::Veo2 => Self::Veo2,
|
||||
@@ -940,6 +946,7 @@ mod tests {
|
||||
assert_serialization(InferenceModelType::Seedance2p0BytePlusMini, "seedance_2p0_bp_mini");
|
||||
assert_serialization(InferenceModelType::Seedance2p0BytePlusUltraMini, "seedance_2p0_bpu_mini");
|
||||
assert_serialization(InferenceModelType::Seedance2p5Preview, "seedance_2p5_preview");
|
||||
assert_serialization(InferenceModelType::Seedance2p5, "seedance_2p5");
|
||||
assert_serialization(InferenceModelType::Sora2, "sora_2");
|
||||
assert_serialization(InferenceModelType::Sora2Pro, "sora_2_pro");
|
||||
assert_serialization(InferenceModelType::Veo2, "veo_2");
|
||||
@@ -1072,6 +1079,7 @@ mod tests {
|
||||
assert_eq!(InferenceModelType::Seedance2p0BytePlusMini.to_str(), "seedance_2p0_bp_mini");
|
||||
assert_eq!(InferenceModelType::Seedance2p0BytePlusUltraMini.to_str(), "seedance_2p0_bpu_mini");
|
||||
assert_eq!(InferenceModelType::Seedance2p5Preview.to_str(), "seedance_2p5_preview");
|
||||
assert_eq!(InferenceModelType::Seedance2p5.to_str(), "seedance_2p5");
|
||||
assert_eq!(InferenceModelType::Sora2.to_str(), "sora_2");
|
||||
assert_eq!(InferenceModelType::Sora2Pro.to_str(), "sora_2_pro");
|
||||
assert_eq!(InferenceModelType::Veo2.to_str(), "veo_2");
|
||||
@@ -1206,6 +1214,7 @@ mod tests {
|
||||
assert_eq!(InferenceModelType::from_str("seedance_2p0_bp_mini").unwrap(), InferenceModelType::Seedance2p0BytePlusMini);
|
||||
assert_eq!(InferenceModelType::from_str("seedance_2p0_bpu_mini").unwrap(), InferenceModelType::Seedance2p0BytePlusUltraMini);
|
||||
assert_eq!(InferenceModelType::from_str("seedance_2p5_preview").unwrap(), InferenceModelType::Seedance2p5Preview);
|
||||
assert_eq!(InferenceModelType::from_str("seedance_2p5").unwrap(), InferenceModelType::Seedance2p5);
|
||||
assert_eq!(InferenceModelType::from_str("sora_2").unwrap(), InferenceModelType::Sora2);
|
||||
assert_eq!(InferenceModelType::from_str("sora_2_pro").unwrap(), InferenceModelType::Sora2Pro);
|
||||
assert_eq!(InferenceModelType::from_str("veo_2").unwrap(), InferenceModelType::Veo2);
|
||||
|
||||
@@ -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),
|
||||
|
||||
+76
@@ -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
|
||||
}
|
||||
|
||||
+41
@@ -8,6 +8,47 @@ use enums::common::generation::model_creator::ModelCreator;
|
||||
pub fn seedance_2p5_video_models() -> Vec<OmniGenVideoModelDetails> {
|
||||
let mut models = Vec::new();
|
||||
|
||||
models.push(OmniGenVideoModelDetails {
|
||||
model: CommonVideoModel::Seedance2p5,
|
||||
model_creator: Some(ModelCreator::Bytedance),
|
||||
full_name: Some("Seedance 2.5".to_string()),
|
||||
extra_info: Some("Seedance 2.5. Supports videos up to 30 seconds, start/end keyframes (adaptive aspect ratio), and image/video/audio references, with 480p/720p output. Video references bill their input seconds on top of the output duration.".to_string()),
|
||||
extra_info_short: Some("Seedance 2.5; up to 30s videos".to_string()),
|
||||
text_prompt_supported: Some(true),
|
||||
text_prompt_max_length: Some(10_000),
|
||||
starting_keyframe_supported: Some(true),
|
||||
ending_keyframe_supported: Some(true),
|
||||
image_references_supported: Some(true),
|
||||
image_references_max: Some(30),
|
||||
audio_references_supported: Some(true),
|
||||
audio_references_max: Some(10),
|
||||
audio_references_max_total_duration_seconds: Some(30),
|
||||
video_references_supported: Some(true),
|
||||
video_references_max: Some(10),
|
||||
video_references_max_total_duration_seconds: Some(30),
|
||||
character_references_supported: Some(false),
|
||||
aspect_ratio_options: Some(vec![
|
||||
CommonAspectRatio::WideTwentyOneByNine,
|
||||
CommonAspectRatio::WideSixteenByNine,
|
||||
CommonAspectRatio::WideFourByThree,
|
||||
CommonAspectRatio::Square,
|
||||
CommonAspectRatio::TallThreeByFour,
|
||||
CommonAspectRatio::TallNineBySixteen,
|
||||
]),
|
||||
aspect_ratio_default: Some(CommonAspectRatio::WideSixteenByNine),
|
||||
resolution_options: Some(vec![
|
||||
CommonResolution::FourEightyP,
|
||||
CommonResolution::SevenTwentyP,
|
||||
]),
|
||||
resolution_default: Some(CommonResolution::SevenTwentyP),
|
||||
batch_size_options: Some(vec![1]),
|
||||
batch_size_default: Some(1),
|
||||
duration_seconds_min: Some(4),
|
||||
duration_seconds_max: Some(30),
|
||||
duration_seconds_default: Some(5),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
models.push(OmniGenVideoModelDetails {
|
||||
model: CommonVideoModel::Seedance2p5Preview,
|
||||
model_creator: Some(ModelCreator::Bytedance),
|
||||
|
||||
@@ -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;
|
||||
|
||||
+298
@@ -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()
|
||||
}
|
||||
}
|
||||
+2
@@ -55,6 +55,7 @@ pub async fn estimate_video_cost_handler(
|
||||
duration_seconds: request.duration_seconds,
|
||||
video_batch_count: request.video_batch_count,
|
||||
generate_audio: request.generate_audio,
|
||||
total_reference_video_input_seconds: None,
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayLessDowngrade,
|
||||
idempotency_token: None,
|
||||
};
|
||||
@@ -156,6 +157,7 @@ fn map_video_model(model: CommonVideoModel) -> Result<RouterVideoModel, HandlerE
|
||||
CommonVideoModel::Seedance2p0BytePlusMini => RouterVideoModel::Seedance2p0BytePlusMini,
|
||||
CommonVideoModel::Seedance2p0BytePlusUltraMini => RouterVideoModel::Seedance2p0BytePlusUltraMini,
|
||||
CommonVideoModel::Seedance2p5Preview => RouterVideoModel::Seedance2p5Preview,
|
||||
CommonVideoModel::Seedance2p5 => RouterVideoModel::Seedance2p5,
|
||||
CommonVideoModel::HappyHorse1p0 => RouterVideoModel::HappyHorse1p0,
|
||||
CommonVideoModel::MinimaxH3 => RouterVideoModel::MinimaxH3,
|
||||
CommonVideoModel::MinimaxH3Turbo => RouterVideoModel::MinimaxH3Turbo,
|
||||
|
||||
+3
@@ -66,6 +66,9 @@ pub fn hydrate_to_router_request(
|
||||
duration_seconds: request.duration_seconds,
|
||||
video_batch_count: request.video_batch_count,
|
||||
generate_audio: request.generate_audio,
|
||||
// Set by the handler after probing reference video durations (only
|
||||
// models that bill input seconds use it).
|
||||
total_reference_video_input_seconds: None,
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
|
||||
idempotency_token: request.idempotency_token.clone(),
|
||||
})
|
||||
|
||||
+36
-1
@@ -34,6 +34,9 @@ use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken;
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::endpoints::generate::common::generation_debug_logs::GenerationDebugLogContext;
|
||||
use crate::http_server::endpoints::generate::common::payments_error_test::payments_error_test;
|
||||
use crate::http_server::endpoints::generate::common::probed_reference_videos::{
|
||||
download_and_probe_reference_videos, fetch_reference_video_sources, ProbedReferenceVideos,
|
||||
};
|
||||
use crate::http_server::endpoints::omni_api::generate::video::check_request::check_request;
|
||||
use crate::http_server::endpoints::omni_api::generate::video::ingest_url_inputs::ingest_url_inputs;
|
||||
use crate::http_server::endpoints::omni_api::generate::video::helpers::hydrate_router_request::hydrate_to_router_request;
|
||||
@@ -181,9 +184,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;
|
||||
|
||||
|
||||
+11
@@ -1,6 +1,7 @@
|
||||
use std::convert::TryFrom;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use log::{error, info, warn};
|
||||
use artcraft_router::api::router_video_model::RouterVideoModel;
|
||||
@@ -35,6 +36,11 @@ pub struct RunPipelineV2Args<'a> {
|
||||
pub kinovi_character_id_map: &'a Option<HashMap<CharacterToken, String>>,
|
||||
pub kinovi_account: KinoviAccount,
|
||||
pub debug_log_context: &'a GenerationDebugLogContext<'a>,
|
||||
|
||||
/// Source URL → local file path for reference videos the handler already
|
||||
/// downloaded (for input-seconds billing). The Kinovi upload reads these
|
||||
/// instead of downloading the same bytes again.
|
||||
pub predownloaded_media_paths: Option<&'a HashMap<String, PathBuf>>,
|
||||
/// The handler's open connection. The pipeline uses it for its remaining
|
||||
/// pre-request DB writes (billing, outbound-request debug log) and releases
|
||||
/// it BEFORE the external provider call.
|
||||
@@ -54,6 +60,7 @@ pub async fn run_pipeline_v2(args: RunPipelineV2Args<'_>) -> Result<PipelineResu
|
||||
kinovi_character_id_map,
|
||||
kinovi_account,
|
||||
debug_log_context,
|
||||
predownloaded_media_paths,
|
||||
mut mysql_connection,
|
||||
} = args;
|
||||
|
||||
@@ -81,6 +88,7 @@ pub async fn run_pipeline_v2(args: RunPipelineV2Args<'_>) -> Result<PipelineResu
|
||||
RouterVideoModel::Seedance2p0BytePlusMini => RouterProvider::KinoviWeb,
|
||||
RouterVideoModel::Seedance2p0BytePlusUltraMini => RouterProvider::KinoviWeb,
|
||||
RouterVideoModel::Seedance2p5Preview => RouterProvider::KinoviWeb,
|
||||
RouterVideoModel::Seedance2p5 => RouterProvider::KinoviWeb,
|
||||
//RouterVideoModel::Seedance2p0Ultra => RouterProvider::GmiCloud,
|
||||
//RouterVideoModel::Seedance2p0UltraFast => RouterProvider::GmiCloud,
|
||||
RouterVideoModel::GrokImagineVideo => RouterProvider::GrokApi,
|
||||
@@ -181,6 +189,7 @@ pub async fn run_pipeline_v2(args: RunPipelineV2Args<'_>) -> Result<PipelineResu
|
||||
media_file_to_url_map.as_ref(),
|
||||
kinovi_character_id_map.as_ref(),
|
||||
kinovi_account,
|
||||
predownloaded_media_paths,
|
||||
).await;
|
||||
|
||||
// 5. On failure, refund wallet for Kinovi requests.
|
||||
@@ -219,6 +228,7 @@ async fn upload_and_generate(
|
||||
media_file_urls_by_token: Option<&HashMap<MediaFileToken, String>>,
|
||||
kinovi_character_ids: Option<&HashMap<CharacterToken, String>>,
|
||||
kinovi_account: KinoviAccount,
|
||||
predownloaded_media_paths: Option<&HashMap<String, PathBuf>>,
|
||||
) -> Result<GenerateVideoResponse, CommonWebError> {
|
||||
|
||||
let provider = draft_or_request.get_provider();
|
||||
@@ -231,6 +241,7 @@ async fn upload_and_generate(
|
||||
client: Some(&client),
|
||||
media_file_to_artcraft_url_map: media_file_urls_by_token,
|
||||
character_token_to_kinovi_id_map: kinovi_character_ids,
|
||||
predownloaded_media_paths,
|
||||
};
|
||||
|
||||
draft.finalize(draft_context)
|
||||
|
||||
+86
-6
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -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)
|
||||
}
|
||||
|
||||
+4
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+36
-1
@@ -26,6 +26,9 @@ use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken;
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::endpoints::generate::common::generation_debug_logs::GenerationDebugLogContext;
|
||||
use crate::http_server::endpoints::generate::common::payments_error_test::payments_error_test;
|
||||
use crate::http_server::endpoints::generate::common::probed_reference_videos::{
|
||||
download_and_probe_reference_videos, fetch_reference_video_sources, ProbedReferenceVideos,
|
||||
};
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::first_party_minimax_h3::enqueue_first_party_minimax_h3_job::{
|
||||
enqueue_first_party_minimax_h3_job, first_party_minimax_h3_model, EnqueueFirstPartyMinimaxH3JobArgs,
|
||||
};
|
||||
@@ -164,9 +167,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;
|
||||
|
||||
|
||||
+12
@@ -1,6 +1,7 @@
|
||||
use std::convert::TryFrom;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use log::{error, info, warn};
|
||||
use artcraft_router::api::router_video_model::RouterVideoModel;
|
||||
@@ -35,6 +36,12 @@ pub struct RunPipelineV2Args<'a> {
|
||||
pub kinovi_character_id_map: &'a Option<HashMap<CharacterToken, String>>,
|
||||
pub kinovi_account: KinoviAccount,
|
||||
pub debug_log_context: &'a GenerationDebugLogContext<'a>,
|
||||
|
||||
/// Source URL → local file path for reference videos the handler already
|
||||
/// downloaded (for input-seconds billing). The Kinovi upload reads these
|
||||
/// instead of downloading the same bytes again.
|
||||
pub predownloaded_media_paths: Option<&'a HashMap<String, PathBuf>>,
|
||||
|
||||
/// The handler's open connection. The pipeline uses it for its remaining
|
||||
/// pre-request DB writes (billing, outbound-request debug log) and releases
|
||||
/// it BEFORE the external provider call.
|
||||
@@ -54,6 +61,7 @@ pub async fn run_pipeline_v2(args: RunPipelineV2Args<'_>) -> Result<PipelineResu
|
||||
kinovi_character_id_map,
|
||||
kinovi_account,
|
||||
debug_log_context,
|
||||
predownloaded_media_paths,
|
||||
mut mysql_connection,
|
||||
} = args;
|
||||
|
||||
@@ -81,6 +89,7 @@ pub async fn run_pipeline_v2(args: RunPipelineV2Args<'_>) -> Result<PipelineResu
|
||||
RouterVideoModel::Seedance2p0BytePlusMini => RouterProvider::KinoviWeb,
|
||||
RouterVideoModel::Seedance2p0BytePlusUltraMini => RouterProvider::KinoviWeb,
|
||||
RouterVideoModel::Seedance2p5Preview => RouterProvider::KinoviWeb,
|
||||
RouterVideoModel::Seedance2p5 => RouterProvider::KinoviWeb,
|
||||
//RouterVideoModel::Seedance2p0Ultra => RouterProvider::GmiCloud,
|
||||
//RouterVideoModel::Seedance2p0UltraFast => RouterProvider::GmiCloud,
|
||||
RouterVideoModel::GrokImagineVideo => RouterProvider::GrokApi,
|
||||
@@ -181,6 +190,7 @@ pub async fn run_pipeline_v2(args: RunPipelineV2Args<'_>) -> Result<PipelineResu
|
||||
media_file_to_url_map.as_ref(),
|
||||
kinovi_character_id_map.as_ref(),
|
||||
kinovi_account,
|
||||
predownloaded_media_paths,
|
||||
).await;
|
||||
|
||||
// 5. On failure, refund wallet for Kinovi requests.
|
||||
@@ -219,6 +229,7 @@ async fn upload_and_generate(
|
||||
media_file_urls_by_token: Option<&HashMap<MediaFileToken, String>>,
|
||||
kinovi_character_ids: Option<&HashMap<CharacterToken, String>>,
|
||||
kinovi_account: KinoviAccount,
|
||||
predownloaded_media_paths: Option<&HashMap<String, PathBuf>>,
|
||||
) -> Result<GenerateVideoResponse, CommonWebError> {
|
||||
|
||||
let provider = draft_or_request.get_provider();
|
||||
@@ -231,6 +242,7 @@ async fn upload_and_generate(
|
||||
client: Some(&client),
|
||||
media_file_to_artcraft_url_map: media_file_urls_by_token,
|
||||
character_token_to_kinovi_id_map: kinovi_character_ids,
|
||||
predownloaded_media_paths,
|
||||
};
|
||||
|
||||
draft.finalize(draft_context)
|
||||
|
||||
+55
-2
@@ -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,
|
||||
|
||||
+1
@@ -81,6 +81,7 @@ mod tests {
|
||||
duration_seconds: Some(5),
|
||||
video_batch_count: Some(1),
|
||||
generate_audio: None,
|
||||
estimate_only: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -61,6 +61,7 @@ mod tests {
|
||||
duration_seconds: Some(5),
|
||||
video_batch_count: Some(1),
|
||||
generate_audio: None,
|
||||
estimate_only: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+8
@@ -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)]
|
||||
|
||||
+23
-11
@@ -38,9 +38,19 @@ pub async fn lookup_media_files_as_cdn_url_list_and_map(
|
||||
});
|
||||
}
|
||||
|
||||
// The same media file can be referenced more than once in a request (e.g.
|
||||
// the same video as @video1 and @video2), so fetch each unique token once
|
||||
// and expand back over the requested order below.
|
||||
let mut unique_tokens: Vec<MediaFileToken> = Vec::new();
|
||||
for token in tokens {
|
||||
if !unique_tokens.contains(token) {
|
||||
unique_tokens.push(token.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let result = batch_get_media_files_by_tokens_with_connection(
|
||||
mysql_connection,
|
||||
tokens,
|
||||
&unique_tokens,
|
||||
CAN_SEE_DELETED,
|
||||
).await;
|
||||
|
||||
@@ -52,10 +62,10 @@ pub async fn lookup_media_files_as_cdn_url_list_and_map(
|
||||
}
|
||||
};
|
||||
|
||||
if media_files.len() != tokens.len() {
|
||||
warn!("Wrong number of media files returned for tokens: {} found for {} tokens", media_files.len(), tokens.len());
|
||||
if media_files.len() != unique_tokens.len() {
|
||||
warn!("Wrong number of media files returned for tokens: {} found for {} unique tokens", media_files.len(), unique_tokens.len());
|
||||
|
||||
let requested: HashSet<&MediaFileToken> = HashSet::from_iter(tokens.iter());
|
||||
let requested: HashSet<&MediaFileToken> = HashSet::from_iter(unique_tokens.iter());
|
||||
let returned: HashSet<&MediaFileToken> = HashSet::from_iter(media_files.iter().map(|m| &m.token));
|
||||
|
||||
let diff = requested.difference(&returned)
|
||||
@@ -63,13 +73,12 @@ pub async fn lookup_media_files_as_cdn_url_list_and_map(
|
||||
.collect::<Vec<&MediaFileToken>>();
|
||||
|
||||
return Err(CommonWebError::BadInputWithSimpleMessage(
|
||||
format!("Not all media files could be found. Media files found: {}, tokens provided: {}, in original: {:?}, req {:?}, ret {:?}",
|
||||
media_files.len(), tokens.len(), diff, requested, returned)));
|
||||
format!("Not all media files could be found. Media files found: {}, unique tokens provided: {}, in original: {:?}, req {:?}, ret {:?}",
|
||||
media_files.len(), unique_tokens.len(), diff, requested, returned)));
|
||||
}
|
||||
|
||||
let media_domain = get_media_domain(http_request);
|
||||
|
||||
let mut ordered_url_list = Vec::with_capacity(media_files.len());
|
||||
let mut token_to_url_map = HashMap::with_capacity(media_files.len());
|
||||
|
||||
for file in media_files {
|
||||
@@ -83,12 +92,15 @@ pub async fn lookup_media_files_as_cdn_url_list_and_map(
|
||||
server_environment,
|
||||
&public_bucket_path);
|
||||
|
||||
let url = media_links.cdn_url.to_string();
|
||||
|
||||
ordered_url_list.push(url.clone());
|
||||
token_to_url_map.insert(file.token, url);
|
||||
token_to_url_map.insert(file.token, media_links.cdn_url.to_string());
|
||||
}
|
||||
|
||||
// In requested order, with duplicates preserved.
|
||||
let ordered_url_list = tokens
|
||||
.iter()
|
||||
.filter_map(|token| token_to_url_map.get(token).cloned())
|
||||
.collect();
|
||||
|
||||
Ok(MediaFilesAsCdnUrlListAndMap {
|
||||
ordered_url_list,
|
||||
token_to_url_map,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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();
|
||||
|
||||
+92
-42
@@ -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",
|
||||
|
||||
Generated
+88
-168
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
|
||||
|
||||
Reference in New Issue
Block a user