mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-05 17:58:55 +00:00
add binding
This commit is contained in:
1 parent
d598896e40
commit
fab358e25e
3 files changed
+937
-20
No files matched your search
@@ -0,0 +1,738 @@
|
||||
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,
|
||||
};
|
||||
|
||||
// ── 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). 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 seconds = u64::from(self.duration_seconds)
|
||||
+ u64::from(self.total_input_seconds.unwrap_or(0));
|
||||
(rate, seconds)
|
||||
} else {
|
||||
let rate = match self.output_resolution {
|
||||
Some(KinoviSeedance2p5OutputResolution::FourEightyP) => 26u64,
|
||||
Some(KinoviSeedance2p5OutputResolution::SevenTwentyP) | None => 59u64,
|
||||
};
|
||||
(rate, u64::from(self.duration_seconds))
|
||||
};
|
||||
|
||||
let total_credits = credits_per_second * billed_seconds;
|
||||
KinoviFractionalGenerationCost::from_kinovi_credits(total_credits as f64)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Response ──
|
||||
|
||||
pub struct GenerateSeedance2p5Response {
|
||||
pub task_id: String,
|
||||
pub order_id: String,
|
||||
pub task_ids: Option<Vec<String>>,
|
||||
pub order_ids: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
// ── Entry point ──
|
||||
|
||||
pub async fn generate_seedance_2p5(
|
||||
args: GenerateSeedance2p5Args<'_>,
|
||||
) -> Result<GenerateSeedance2p5Response, KinoviWebError> {
|
||||
let raw_response = workflow_run_task(WorkflowRunTaskArgs {
|
||||
request: to_raw_request(args.request),
|
||||
session: args.session,
|
||||
host_override: args.host_override,
|
||||
}).await?;
|
||||
|
||||
Ok(GenerateSeedance2p5Response {
|
||||
task_id: raw_response.task_id,
|
||||
order_id: raw_response.order_id,
|
||||
task_ids: raw_response.task_ids,
|
||||
order_ids: raw_response.order_ids,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Mapping helpers ──
|
||||
|
||||
fn to_raw_request(req: GenerateSeedance2p5Request) -> WorkflowRunTaskRequest {
|
||||
let (
|
||||
aspect_ratio,
|
||||
start_frame_url,
|
||||
end_frame_url,
|
||||
reference_image_urls,
|
||||
reference_video_urls,
|
||||
reference_audio_urls,
|
||||
) = match req.modality {
|
||||
KinoviSeedance2p5Modality::Keyframe { start_frame_url, end_frame_url } => (
|
||||
// Keyframe is always "Adaptive"; the raw layer omits the aspect ratio
|
||||
// for 2.5 keyframe requests, so this placeholder never hits the wire.
|
||||
KinoviAspectRatioRaw::Landscape16x9,
|
||||
Some(start_frame_url),
|
||||
end_frame_url,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
),
|
||||
KinoviSeedance2p5Modality::Reference {
|
||||
aspect_ratio,
|
||||
reference_image_urls,
|
||||
reference_video_urls,
|
||||
reference_audio_urls,
|
||||
} => (
|
||||
map_aspect_ratio(aspect_ratio),
|
||||
None,
|
||||
None,
|
||||
reference_image_urls,
|
||||
reference_video_urls,
|
||||
reference_audio_urls,
|
||||
),
|
||||
};
|
||||
|
||||
WorkflowRunTaskRequest {
|
||||
model_type: KinoviModelTypeRaw::Seedance2p5,
|
||||
prompt: req.prompt,
|
||||
aspect_ratio,
|
||||
output_resolution: req.output_resolution.map(map_output_resolution),
|
||||
duration_seconds: req.duration_seconds,
|
||||
batch_count: KinoviBatchCountRaw::One,
|
||||
start_frame_url,
|
||||
end_frame_url,
|
||||
reference_image_urls,
|
||||
reference_video_urls,
|
||||
reference_audio_urls,
|
||||
character_ids: None,
|
||||
use_face_blur_hack: req.use_face_blur_hack,
|
||||
bitrate: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_aspect_ratio(ar: Option<KinoviSeedance2p5AspectRatio>) -> KinoviAspectRatioRaw {
|
||||
match ar {
|
||||
Some(KinoviSeedance2p5AspectRatio::Landscape16x9) => KinoviAspectRatioRaw::Landscape16x9,
|
||||
Some(KinoviSeedance2p5AspectRatio::UltraWide21x9) => KinoviAspectRatioRaw::UltraWide21x9,
|
||||
Some(KinoviSeedance2p5AspectRatio::Portrait9x16) => KinoviAspectRatioRaw::Portrait9x16,
|
||||
Some(KinoviSeedance2p5AspectRatio::Square1x1) => KinoviAspectRatioRaw::Square1x1,
|
||||
Some(KinoviSeedance2p5AspectRatio::Standard4x3) => KinoviAspectRatioRaw::Landscape4x3,
|
||||
Some(KinoviSeedance2p5AspectRatio::Portrait3x4) => KinoviAspectRatioRaw::Portrait3x4,
|
||||
None => KinoviAspectRatioRaw::Landscape16x9,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_output_resolution(res: KinoviSeedance2p5OutputResolution) -> KinoviOutputResolutionRaw {
|
||||
match res {
|
||||
KinoviSeedance2p5OutputResolution::FourEightyP => KinoviOutputResolutionRaw::FourEightyP,
|
||||
KinoviSeedance2p5OutputResolution::SevenTwentyP => KinoviOutputResolutionRaw::SevenTwentyP,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ──
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::creds::kinovi_web_session::KinoviWebSession;
|
||||
use crate::test_utils::get_test_cookies::get_test_cookies;
|
||||
use crate::test_utils::setup_test_logging::setup_test_logging;
|
||||
use errors::AnyhowResult;
|
||||
use log::LevelFilter;
|
||||
|
||||
mod pricing_tests {
|
||||
use super::*;
|
||||
|
||||
const FLOAT_TOLERANCE: f64 = 1e-9;
|
||||
|
||||
// ── No video references: output-duration billing ──
|
||||
//
|
||||
// 480p = 26 credits/sec, 720p = 59 credits/sec.
|
||||
|
||||
mod without_video_references {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn table_480p() {
|
||||
assert_eq!(t2v_480(1).calculate_costs().kinovi_credits, 26.0);
|
||||
assert_eq!(t2v_480(5).calculate_costs().kinovi_credits, 130.0);
|
||||
assert_eq!(t2v_480(10).calculate_costs().kinovi_credits, 260.0);
|
||||
assert_eq!(t2v_480(15).calculate_costs().kinovi_credits, 390.0);
|
||||
assert_eq!(t2v_480(30).calculate_costs().kinovi_credits, 780.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_720p() {
|
||||
assert_eq!(t2v_720(1).calculate_costs().kinovi_credits, 59.0);
|
||||
assert_eq!(t2v_720(5).calculate_costs().kinovi_credits, 295.0);
|
||||
assert_eq!(t2v_720(10).calculate_costs().kinovi_credits, 590.0);
|
||||
assert_eq!(t2v_720(15).calculate_costs().kinovi_credits, 885.0);
|
||||
assert_eq!(t2v_720(30).calculate_costs().kinovi_credits, 1770.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_resolution_is_720p() {
|
||||
let default = t2v_720(10).calculate_costs();
|
||||
let mut explicit = t2v_720(10);
|
||||
explicit.output_resolution = Some(KinoviSeedance2p5OutputResolution::SevenTwentyP);
|
||||
assert_eq!(default, explicit.calculate_costs());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyframe_modality_uses_no_reference_rates() {
|
||||
let mut request = keyframe_request(10);
|
||||
request.output_resolution = Some(KinoviSeedance2p5OutputResolution::FourEightyP);
|
||||
assert_eq!(request.calculate_costs().kinovi_credits, 260.0);
|
||||
|
||||
let request_720 = keyframe_request(10);
|
||||
assert_eq!(request_720.calculate_costs().kinovi_credits, 590.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_and_audio_references_do_not_change_the_rate() {
|
||||
let mut request = t2v_480(10);
|
||||
set_reference_urls(
|
||||
&mut request,
|
||||
Some(vec!["https://example.com/a.png".to_string(), "https://example.com/b.jpg".to_string()]),
|
||||
None,
|
||||
Some(vec!["https://example.com/ref.wav".to_string()]),
|
||||
);
|
||||
assert_eq!(request.calculate_costs().kinovi_credits, 260.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_video_reference_list_counts_as_no_references() {
|
||||
let mut request = t2v_480(10);
|
||||
set_reference_urls(&mut request, None, Some(Vec::new()), None);
|
||||
assert_eq!(request.calculate_costs().kinovi_credits, 260.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn total_input_seconds_is_ignored_without_video_references() {
|
||||
let mut request = t2v_480(10);
|
||||
request.total_input_seconds = Some(60);
|
||||
assert_eq!(request.calculate_costs().kinovi_credits, 260.0);
|
||||
}
|
||||
}
|
||||
|
||||
// ── With video references: input seconds are billed too ──
|
||||
//
|
||||
// 480p = 16 credits/sec, 720p = 35 credits/sec, over
|
||||
// (output duration + total_input_seconds) billed seconds.
|
||||
|
||||
mod with_video_references {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn thirty_second_output_with_ten_second_reference_bills_forty_seconds() {
|
||||
// The canonical example: 30s output + 10s of input video = 40 billed seconds.
|
||||
let request = video_ref_480(30, Some(10));
|
||||
assert_eq!(request.calculate_costs().kinovi_credits, (16 * 40) as f64);
|
||||
|
||||
let request = video_ref_720(30, Some(10));
|
||||
assert_eq!(request.calculate_costs().kinovi_credits, (35 * 40) as f64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_seven_second_references_bill_forty_four_seconds() {
|
||||
// Two 7s references (14s total input) + 30s output = 44 billed seconds.
|
||||
let mut request = video_ref_480(30, Some(14));
|
||||
set_reference_urls(
|
||||
&mut request,
|
||||
None,
|
||||
Some(vec![
|
||||
"https://example.com/ref1.mp4".to_string(),
|
||||
"https://example.com/ref2.mp4".to_string(),
|
||||
]),
|
||||
None,
|
||||
);
|
||||
assert_eq!(request.calculate_costs().kinovi_credits, (16 * 44) as f64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_480p_with_ten_input_seconds() {
|
||||
assert_eq!(video_ref_480(5, Some(10)).calculate_costs().kinovi_credits, (16 * 15) as f64);
|
||||
assert_eq!(video_ref_480(10, Some(10)).calculate_costs().kinovi_credits, (16 * 20) as f64);
|
||||
assert_eq!(video_ref_480(30, Some(10)).calculate_costs().kinovi_credits, (16 * 40) as f64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_720p_with_ten_input_seconds() {
|
||||
assert_eq!(video_ref_720(5, Some(10)).calculate_costs().kinovi_credits, (35 * 15) as f64);
|
||||
assert_eq!(video_ref_720(10, Some(10)).calculate_costs().kinovi_credits, (35 * 20) as f64);
|
||||
assert_eq!(video_ref_720(30, Some(10)).calculate_costs().kinovi_credits, (35 * 40) as f64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_total_input_seconds_bills_output_duration_only() {
|
||||
assert_eq!(video_ref_480(10, None).calculate_costs().kinovi_credits, 160.0);
|
||||
assert_eq!(video_ref_720(10, None).calculate_costs().kinovi_credits, 350.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_references_still_use_video_reference_rates() {
|
||||
let mut request = video_ref_480(10, Some(5));
|
||||
set_reference_urls(
|
||||
&mut request,
|
||||
Some(vec!["https://example.com/a.png".to_string()]),
|
||||
Some(vec!["https://example.com/ref.mp4".to_string()]),
|
||||
Some(vec!["https://example.com/ref.wav".to_string()]),
|
||||
);
|
||||
assert_eq!(request.calculate_costs().kinovi_credits, (16 * 15) as f64);
|
||||
}
|
||||
}
|
||||
|
||||
// ── USD cents conversion ──
|
||||
//
|
||||
// usd_cents = credits × 100 / 243, computed on integer hundredths.
|
||||
|
||||
mod usd_cents {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cents_480p_5s() {
|
||||
// 130 credits; 13000/243 = 53.4979 cents.
|
||||
let cost = t2v_480(5).calculate_costs();
|
||||
assert_eq!(cost.usd_cents_rounded_up, 54);
|
||||
assert_eq!(cost.usd_cents_rounded_down, 53);
|
||||
assert!((cost.usd_cents_fractional - (13000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cents_720p_30s() {
|
||||
// 1770 credits; 177000/243 = 728.3951 cents.
|
||||
let cost = t2v_720(30).calculate_costs();
|
||||
assert_eq!(cost.usd_cents_rounded_up, 729);
|
||||
assert_eq!(cost.usd_cents_rounded_down, 728);
|
||||
assert!((cost.usd_cents_fractional - (177000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cents_720p_video_ref_40_billed_seconds() {
|
||||
// 35 × 40 = 1400 credits; 140000/243 = 576.1317 cents.
|
||||
let cost = video_ref_720(30, Some(10)).calculate_costs();
|
||||
assert_eq!(cost.usd_cents_rounded_up, 577);
|
||||
assert_eq!(cost.usd_cents_rounded_down, 576);
|
||||
assert!((cost.usd_cents_fractional - (140000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Relative pricing ──
|
||||
|
||||
mod relative_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn video_reference_rate_is_cheaper_per_second() {
|
||||
// 16 < 26 and 35 < 59: the with-references rate is lower per billed
|
||||
// second (the input seconds are where the money goes).
|
||||
assert!(video_ref_480(10, None).calculate_costs().kinovi_credits
|
||||
< t2v_480(10).calculate_costs().kinovi_credits);
|
||||
assert!(video_ref_720(10, None).calculate_costs().kinovi_credits
|
||||
< t2v_720(10).calculate_costs().kinovi_credits);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_scales_linearly_with_duration() {
|
||||
for duration in [1u8, 5, 10, 30] {
|
||||
assert_eq!(
|
||||
t2v_480(duration).calculate_costs().kinovi_credits,
|
||||
(26 * u64::from(duration)) as f64,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Aspect ratio doesn't affect cost ──
|
||||
|
||||
#[test]
|
||||
fn aspect_ratio_does_not_affect_credits() {
|
||||
let baseline = t2v_720(10).calculate_costs().kinovi_credits;
|
||||
|
||||
let ratios = [
|
||||
KinoviSeedance2p5AspectRatio::Landscape16x9,
|
||||
KinoviSeedance2p5AspectRatio::UltraWide21x9,
|
||||
KinoviSeedance2p5AspectRatio::Portrait9x16,
|
||||
KinoviSeedance2p5AspectRatio::Square1x1,
|
||||
KinoviSeedance2p5AspectRatio::Standard4x3,
|
||||
KinoviSeedance2p5AspectRatio::Portrait3x4,
|
||||
];
|
||||
|
||||
for ar in &ratios {
|
||||
let mut request = t2v_720(10);
|
||||
request.modality = KinoviSeedance2p5Modality::Reference {
|
||||
aspect_ratio: Some(*ar),
|
||||
reference_image_urls: None,
|
||||
reference_video_urls: None,
|
||||
reference_audio_urls: None,
|
||||
};
|
||||
assert_eq!(
|
||||
request.calculate_costs().kinovi_credits, baseline,
|
||||
"Aspect ratio {:?} should not change credits from baseline {}", ar, baseline,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
fn text_to_video_request(
|
||||
duration_seconds: u8,
|
||||
output_resolution: Option<KinoviSeedance2p5OutputResolution>,
|
||||
) -> GenerateSeedance2p5Request {
|
||||
GenerateSeedance2p5Request {
|
||||
prompt: String::new(),
|
||||
modality: KinoviSeedance2p5Modality::Reference {
|
||||
aspect_ratio: None,
|
||||
reference_image_urls: None,
|
||||
reference_video_urls: None,
|
||||
reference_audio_urls: None,
|
||||
},
|
||||
output_resolution,
|
||||
duration_seconds,
|
||||
total_input_seconds: None,
|
||||
use_face_blur_hack: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn t2v_480(duration: u8) -> GenerateSeedance2p5Request {
|
||||
text_to_video_request(duration, Some(KinoviSeedance2p5OutputResolution::FourEightyP))
|
||||
}
|
||||
|
||||
fn t2v_720(duration: u8) -> GenerateSeedance2p5Request {
|
||||
text_to_video_request(duration, None)
|
||||
}
|
||||
|
||||
fn keyframe_request(duration: u8) -> GenerateSeedance2p5Request {
|
||||
GenerateSeedance2p5Request {
|
||||
prompt: String::new(),
|
||||
modality: KinoviSeedance2p5Modality::Keyframe {
|
||||
start_frame_url: "https://example.com/start.png".to_string(),
|
||||
end_frame_url: Some("https://example.com/end.png".to_string()),
|
||||
},
|
||||
output_resolution: None,
|
||||
duration_seconds: duration,
|
||||
total_input_seconds: None,
|
||||
use_face_blur_hack: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn video_ref_480(duration: u8, total_input_seconds: Option<u8>) -> GenerateSeedance2p5Request {
|
||||
let mut request = t2v_480(duration);
|
||||
set_reference_urls(&mut request, None, Some(vec!["https://example.com/ref.mp4".to_string()]), None);
|
||||
request.total_input_seconds = total_input_seconds;
|
||||
request
|
||||
}
|
||||
|
||||
fn video_ref_720(duration: u8, total_input_seconds: Option<u8>) -> GenerateSeedance2p5Request {
|
||||
let mut request = t2v_720(duration);
|
||||
set_reference_urls(&mut request, None, Some(vec!["https://example.com/ref.mp4".to_string()]), None);
|
||||
request.total_input_seconds = total_input_seconds;
|
||||
request
|
||||
}
|
||||
|
||||
fn set_reference_urls(
|
||||
request: &mut GenerateSeedance2p5Request,
|
||||
images: Option<Vec<String>>,
|
||||
videos: Option<Vec<String>>,
|
||||
audio: Option<Vec<String>>,
|
||||
) {
|
||||
let KinoviSeedance2p5Modality::Reference {
|
||||
reference_image_urls,
|
||||
reference_video_urls,
|
||||
reference_audio_urls,
|
||||
..
|
||||
} = &mut request.modality else {
|
||||
panic!("set_reference_urls requires the Reference modality");
|
||||
};
|
||||
*reference_image_urls = images;
|
||||
*reference_video_urls = videos;
|
||||
*reference_audio_urls = audio;
|
||||
}
|
||||
}
|
||||
|
||||
mod raw_mapping_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn keyframe_maps_frames_and_no_references() {
|
||||
let raw = to_raw_request(GenerateSeedance2p5Request {
|
||||
prompt: "Car drives into the sunset".to_string(),
|
||||
modality: KinoviSeedance2p5Modality::Keyframe {
|
||||
start_frame_url: "https://example.com/start.png".to_string(),
|
||||
end_frame_url: Some("https://example.com/end.png".to_string()),
|
||||
},
|
||||
output_resolution: Some(KinoviSeedance2p5OutputResolution::FourEightyP),
|
||||
duration_seconds: 8,
|
||||
total_input_seconds: None,
|
||||
use_face_blur_hack: None,
|
||||
});
|
||||
|
||||
assert!(matches!(raw.model_type, KinoviModelTypeRaw::Seedance2p5));
|
||||
assert_eq!(raw.start_frame_url.as_deref(), Some("https://example.com/start.png"));
|
||||
assert_eq!(raw.end_frame_url.as_deref(), Some("https://example.com/end.png"));
|
||||
assert!(raw.reference_image_urls.is_none());
|
||||
assert!(raw.reference_video_urls.is_none());
|
||||
assert!(raw.reference_audio_urls.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_maps_references_and_no_frames() {
|
||||
let raw = to_raw_request(GenerateSeedance2p5Request {
|
||||
prompt: "The t-rex @image1 eats the banana".to_string(),
|
||||
modality: KinoviSeedance2p5Modality::Reference {
|
||||
aspect_ratio: Some(KinoviSeedance2p5AspectRatio::UltraWide21x9),
|
||||
reference_image_urls: Some(vec!["https://example.com/a.png".to_string()]),
|
||||
reference_video_urls: Some(vec!["https://example.com/ref.mp4".to_string()]),
|
||||
reference_audio_urls: Some(vec!["https://example.com/ref.wav".to_string()]),
|
||||
},
|
||||
output_resolution: None,
|
||||
duration_seconds: 8,
|
||||
total_input_seconds: Some(7),
|
||||
use_face_blur_hack: None,
|
||||
});
|
||||
|
||||
assert!(matches!(raw.aspect_ratio, KinoviAspectRatioRaw::UltraWide21x9));
|
||||
assert!(raw.start_frame_url.is_none());
|
||||
assert!(raw.end_frame_url.is_none());
|
||||
assert_eq!(raw.reference_image_urls.as_deref().map(|urls| urls.len()), Some(1));
|
||||
assert_eq!(raw.reference_video_urls.as_deref().map(|urls| urls.len()), Some(1));
|
||||
assert_eq!(raw.reference_audio_urls.as_deref().map(|urls| urls.len()), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_aspect_ratio_defaults_to_16x9() {
|
||||
let raw = to_raw_request(GenerateSeedance2p5Request {
|
||||
prompt: "Lightning hits a building".to_string(),
|
||||
modality: KinoviSeedance2p5Modality::Reference {
|
||||
aspect_ratio: None,
|
||||
reference_image_urls: None,
|
||||
reference_video_urls: None,
|
||||
reference_audio_urls: None,
|
||||
},
|
||||
output_resolution: None,
|
||||
duration_seconds: 5,
|
||||
total_input_seconds: None,
|
||||
use_face_blur_hack: None,
|
||||
});
|
||||
|
||||
assert!(matches!(raw.aspect_ratio, KinoviAspectRatioRaw::Landscape16x9));
|
||||
}
|
||||
}
|
||||
|
||||
mod real_requests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_text_to_video_480p() -> AnyhowResult<()> {
|
||||
setup_test_logging(LevelFilter::Trace);
|
||||
let session = test_session()?;
|
||||
let result = generate_seedance_2p5(GenerateSeedance2p5Args {
|
||||
session: &session,
|
||||
host_override: None,
|
||||
request: GenerateSeedance2p5Request {
|
||||
prompt: "Lightning hits a building".to_string(),
|
||||
modality: KinoviSeedance2p5Modality::Reference {
|
||||
aspect_ratio: Some(KinoviSeedance2p5AspectRatio::Landscape16x9),
|
||||
reference_image_urls: None,
|
||||
reference_video_urls: None,
|
||||
reference_audio_urls: None,
|
||||
},
|
||||
output_resolution: Some(KinoviSeedance2p5OutputResolution::FourEightyP),
|
||||
duration_seconds: 5,
|
||||
total_input_seconds: None,
|
||||
use_face_blur_hack: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("t2v 480p — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
assert!(!result.task_id.is_empty());
|
||||
assert!(!result.order_id.is_empty());
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_keyframe_to_video_adaptive_480p() -> AnyhowResult<()> {
|
||||
setup_test_logging(LevelFilter::Trace);
|
||||
let session = test_session()?;
|
||||
let result = generate_seedance_2p5(GenerateSeedance2p5Args {
|
||||
session: &session,
|
||||
host_override: None,
|
||||
request: GenerateSeedance2p5Request {
|
||||
prompt: "Car drives into the sunset".to_string(),
|
||||
modality: KinoviSeedance2p5Modality::Keyframe {
|
||||
start_frame_url: "https://static.seedance2-pro.com/materials/20260807/1786128486168-a2bf6132.png".to_string(),
|
||||
end_frame_url: Some("https://static.seedance2-pro.com/materials/20260807/1786128493608-d708413a.png".to_string()),
|
||||
},
|
||||
output_resolution: Some(KinoviSeedance2p5OutputResolution::FourEightyP),
|
||||
duration_seconds: 8,
|
||||
total_input_seconds: None,
|
||||
use_face_blur_hack: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("keyframe 480p — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
assert!(!result.task_id.is_empty());
|
||||
assert!(!result.order_id.is_empty());
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_reference_to_video_480p() -> AnyhowResult<()> {
|
||||
setup_test_logging(LevelFilter::Trace);
|
||||
let session = test_session()?;
|
||||
let result = generate_seedance_2p5(GenerateSeedance2p5Args {
|
||||
session: &session,
|
||||
host_override: None,
|
||||
request: GenerateSeedance2p5Request {
|
||||
prompt: "The t-rex @image1 eats the banana @image2".to_string(),
|
||||
modality: KinoviSeedance2p5Modality::Reference {
|
||||
aspect_ratio: Some(KinoviSeedance2p5AspectRatio::UltraWide21x9),
|
||||
reference_image_urls: Some(vec![
|
||||
"https://static.seedance2-pro.com/materials/20260807/1786128628611-d6ca7afd.png".to_string(),
|
||||
"https://static.seedance2-pro.com/materials/20260807/1786128638331-5395ff1c.jpg".to_string(),
|
||||
]),
|
||||
reference_video_urls: None,
|
||||
reference_audio_urls: None,
|
||||
},
|
||||
output_resolution: Some(KinoviSeedance2p5OutputResolution::FourEightyP),
|
||||
duration_seconds: 8,
|
||||
total_input_seconds: None,
|
||||
use_face_blur_hack: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("reference 480p — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
assert!(!result.task_id.is_empty());
|
||||
assert!(!result.order_id.is_empty());
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn test_session() -> AnyhowResult<KinoviWebSession> {
|
||||
let cookies = get_test_cookies()?;
|
||||
Ok(KinoviWebSession::from_cookies_string(cookies))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,4 +2,5 @@ pub mod generate_happy_horse_1p0;
|
||||
pub mod generate_seedance_2p0;
|
||||
pub mod generate_seedance_2p0_fast;
|
||||
pub mod generate_seedance_2p0_mini;
|
||||
pub mod generate_seedance_2p5;
|
||||
pub mod generate_seedance_2p5_preview;
|
||||
+198
-20
@@ -216,6 +216,8 @@ pub enum KinoviModelTypeRaw {
|
||||
Seedance2Mini,
|
||||
/// Seedance 2.5 Preview (480p/720p only).
|
||||
Seedance2p5Preview,
|
||||
/// Seedance 2.5 (480p/720p only).
|
||||
Seedance2p5,
|
||||
/// Happy Horse 1.0.
|
||||
HappyHorse1p0,
|
||||
}
|
||||
@@ -227,27 +229,29 @@ impl KinoviModelTypeRaw {
|
||||
Self::Seedance2Fast => "seedance2-fast",
|
||||
Self::Seedance2Mini => "seedance2.0-mini",
|
||||
Self::Seedance2p5Preview => "seedance2-5-preview",
|
||||
Self::Seedance2p5 => "seedance2-5",
|
||||
Self::HappyHorse1p0 => "happyhorse1.0",
|
||||
}
|
||||
}
|
||||
|
||||
/// The tRPC `businessType` for this model. Seedance 2.0 Mini and 2.5
|
||||
/// Preview use their own business types; every other model uses the
|
||||
/// shared one.
|
||||
/// The tRPC `businessType` for this model. Seedance 2.0 Mini and the 2.5
|
||||
/// family use their own business types; every other model uses the shared
|
||||
/// one. (Seedance 2.5 reuses the *preview* business type on the wire —
|
||||
/// only the `model` string differs.)
|
||||
fn business_type(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Seedance2Mini => "seedance20-mini-video-generation",
|
||||
Self::Seedance2p5Preview => "seedance25-preview-video-generation",
|
||||
Self::Seedance2p5Preview | Self::Seedance2p5 => "seedance25-preview-video-generation",
|
||||
Self::HappyHorse1p0 => "happyhorse-video-generation",
|
||||
Self::Seedance2Pro | Self::Seedance2Fast => "wan22-video-generation",
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the aspect ratio is sent in an `aspectRatio` field (true)
|
||||
/// rather than the `resolution` field. Mini, 2.5 Preview, and Happy Horse
|
||||
/// use `aspectRatio`.
|
||||
/// rather than the `resolution` field. Mini, the 2.5 family, and Happy
|
||||
/// Horse use `aspectRatio`.
|
||||
fn uses_aspect_ratio_field(&self) -> bool {
|
||||
matches!(self, Self::Seedance2Mini | Self::Seedance2p5Preview | Self::HappyHorse1p0)
|
||||
matches!(self, Self::Seedance2Mini | Self::Seedance2p5Preview | Self::Seedance2p5 | Self::HappyHorse1p0)
|
||||
}
|
||||
|
||||
/// Whether this model uses Happy Horse's `happyhorseMode` (t2v/i2v)
|
||||
@@ -258,10 +262,11 @@ impl KinoviModelTypeRaw {
|
||||
|
||||
/// Whether reference URLs are sent in split `imageUrls` / `videoUrls`
|
||||
/// fields — with `uploadedUrls` mirroring just the images — rather than
|
||||
/// one combined `uploadedUrls` list. Seedance 2.5 Preview uses the split
|
||||
/// shape.
|
||||
/// one combined `uploadedUrls` list. The 2.5 family uses the split shape.
|
||||
/// (In 2.5 keyframe mode the start/end frames go in `imageUrls` with
|
||||
/// `uploadedUrls` mirroring them.)
|
||||
fn uses_split_reference_url_fields(&self) -> bool {
|
||||
matches!(self, Self::Seedance2p5Preview)
|
||||
matches!(self, Self::Seedance2p5Preview | Self::Seedance2p5)
|
||||
}
|
||||
|
||||
/// Whether `mode` is always "reference", even with no references attached.
|
||||
@@ -270,11 +275,25 @@ impl KinoviModelTypeRaw {
|
||||
matches!(self, Self::Seedance2p5Preview)
|
||||
}
|
||||
|
||||
/// Whether `mode` is "keyframe" only when a start/end frame is attached,
|
||||
/// and "reference" otherwise (including plain text-to-video). Seedance 2.5
|
||||
/// sends text-to-video as mode "reference".
|
||||
fn uses_reference_mode_unless_keyframes(&self) -> bool {
|
||||
matches!(self, Self::Seedance2p5)
|
||||
}
|
||||
|
||||
/// Whether keyframe mode omits the aspect ratio entirely: Seedance 2.5
|
||||
/// keyframe (image-to-video) only supports "Adaptive", which is expressed
|
||||
/// by sending no `aspectRatio` field at all.
|
||||
fn omits_aspect_ratio_in_keyframe_mode(&self) -> bool {
|
||||
matches!(self, Self::Seedance2p5)
|
||||
}
|
||||
|
||||
/// Whether the default field values (`outputResolution: "720p"`,
|
||||
/// `faceBlurMode: "off"`) are always sent explicitly rather than omitted.
|
||||
/// Seedance 2.5 Preview always sends both.
|
||||
/// The 2.5 family always sends both.
|
||||
fn always_sends_default_fields(&self) -> bool {
|
||||
matches!(self, Self::Seedance2p5Preview)
|
||||
matches!(self, Self::Seedance2p5Preview | Self::Seedance2p5)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -446,20 +465,41 @@ fn build_batch_request(req: WorkflowRunTaskRequest) -> BatchRequest {
|
||||
|
||||
let is_reference_mode = has_reference_images || has_reference_videos || has_reference_audio || has_characters;
|
||||
|
||||
let video_input_mode = if is_reference_mode || req.model_type.always_uses_reference_mode() {
|
||||
let has_keyframes = req.start_frame_url.is_some() || req.end_frame_url.is_some();
|
||||
|
||||
let video_input_mode = if req.model_type.always_uses_reference_mode() {
|
||||
"reference"
|
||||
} else if req.model_type.uses_reference_mode_unless_keyframes() {
|
||||
// Seedance 2.5: text-to-video is sent as "reference"; "keyframe" only
|
||||
// when a start/end frame is attached.
|
||||
if has_keyframes { "keyframe" } else { "reference" }
|
||||
} else if is_reference_mode {
|
||||
"reference"
|
||||
} else {
|
||||
"keyframe"
|
||||
};
|
||||
|
||||
// Reference URLs. Seedance 2.5 Preview sends split `imageUrls` /
|
||||
// `videoUrls` fields with `uploadedUrls` mirroring just the images; other
|
||||
// Reference URLs. The 2.5 family sends split `imageUrls` / `videoUrls`
|
||||
// fields with `uploadedUrls` mirroring just the images (in 2.5 keyframe
|
||||
// mode the start/end frames ride in `imageUrls` + `uploadedUrls`); other
|
||||
// models combine videos + images into `uploadedUrls` in reference mode, or
|
||||
// send the start/end frames there in keyframe mode.
|
||||
let (image_urls, video_urls, uploaded_urls): (Option<Vec<String>>, Option<Vec<String>>, Option<Vec<String>>) = if req.model_type.uses_split_reference_url_fields() {
|
||||
let image_urls = req.reference_image_urls.filter(|urls| !urls.is_empty());
|
||||
let video_urls = req.reference_video_urls.filter(|urls| !urls.is_empty());
|
||||
(image_urls.clone(), video_urls, image_urls)
|
||||
if video_input_mode == "keyframe" {
|
||||
let mut urls = Vec::new();
|
||||
if let Some(url) = req.start_frame_url {
|
||||
urls.push(url);
|
||||
}
|
||||
if let Some(url) = req.end_frame_url {
|
||||
urls.push(url);
|
||||
}
|
||||
let urls = if urls.is_empty() { None } else { Some(urls) };
|
||||
(urls.clone(), None, urls)
|
||||
} else {
|
||||
let image_urls = req.reference_image_urls.filter(|urls| !urls.is_empty());
|
||||
let video_urls = req.reference_video_urls.filter(|urls| !urls.is_empty());
|
||||
(image_urls.clone(), video_urls, image_urls)
|
||||
}
|
||||
} else if is_reference_mode {
|
||||
let mut urls = Vec::new();
|
||||
if let Some(video_urls) = req.reference_video_urls {
|
||||
@@ -506,9 +546,13 @@ fn build_batch_request(req: WorkflowRunTaskRequest) -> BatchRequest {
|
||||
let duration = format!("{}s", req.duration_seconds);
|
||||
|
||||
// The aspect ratio is sent as a ratio string (e.g. "16:9"). Seedance Pro/Fast
|
||||
// carry it in the `resolution` field; Mini and Happy Horse use `aspectRatio`.
|
||||
// carry it in the `resolution` field; Mini, the 2.5 family, and Happy Horse
|
||||
// use `aspectRatio`. Seedance 2.5 keyframe mode only supports "Adaptive",
|
||||
// which is expressed by omitting the field entirely.
|
||||
let aspect_ratio_value = req.aspect_ratio.as_aspect_ratio_str();
|
||||
let (resolution, aspect_ratio) = if req.model_type.uses_aspect_ratio_field() {
|
||||
let (resolution, aspect_ratio) = if video_input_mode == "keyframe" && req.model_type.omits_aspect_ratio_in_keyframe_mode() {
|
||||
(None, None)
|
||||
} else if req.model_type.uses_aspect_ratio_field() {
|
||||
(None, Some(aspect_ratio_value))
|
||||
} else {
|
||||
(Some(aspect_ratio_value.to_string()), None)
|
||||
@@ -893,6 +937,140 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Seedance 2.5 request shape ──
|
||||
//
|
||||
// Seedance 2.5 reuses the *preview* businessType
|
||||
// (`seedance25-preview-video-generation`) with model `seedance2-5`. Unlike
|
||||
// Preview it has a keyframe (image-to-video) mode: `mode` is "keyframe"
|
||||
// only when a start/end frame is attached (text-to-video goes out as
|
||||
// "reference"), keyframe requests omit `aspectRatio` entirely (the only
|
||||
// choice is "Adaptive"), and the frames ride in `imageUrls` with
|
||||
// `uploadedUrls` mirroring them. Reference mode matches Preview's split
|
||||
// URL shape. Mirrors
|
||||
// external/requests/sites/kinovi.ai/2026-08-07-seedance2p5/.
|
||||
|
||||
mod seedance_2p5_shape_tests {
|
||||
use super::*;
|
||||
|
||||
fn base_request(prompt: &str, duration_seconds: u8) -> WorkflowRunTaskRequest {
|
||||
WorkflowRunTaskRequest {
|
||||
model_type: KinoviModelTypeRaw::Seedance2p5,
|
||||
prompt: prompt.to_string(),
|
||||
aspect_ratio: KinoviAspectRatioRaw::Landscape16x9,
|
||||
output_resolution: Some(KinoviOutputResolutionRaw::FourEightyP),
|
||||
duration_seconds,
|
||||
batch_count: KinoviBatchCountRaw::One,
|
||||
start_frame_url: None,
|
||||
end_frame_url: None,
|
||||
reference_image_urls: None,
|
||||
reference_video_urls: None,
|
||||
reference_audio_urls: None,
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_to_video_matches_observed_request() {
|
||||
// Mirrors 1_request_text_to_video.txt: mode "reference", aspectRatio
|
||||
// "16:9", 480p, 5s, no URL fields.
|
||||
let body = build_batch_request(base_request("Lightning hits a building", 5));
|
||||
let json = serde_json::to_string(&body).unwrap();
|
||||
assert!(json.contains(r#""businessType":"seedance25-preview-video-generation""#), "{json}");
|
||||
assert!(json.contains(r#""model":"seedance2-5""#), "{json}");
|
||||
assert!(json.contains(r#""mode":"reference""#), "{json}");
|
||||
assert!(json.contains(r#""aspectRatio":"16:9""#), "{json}");
|
||||
assert!(json.contains(r#""duration":"5s""#), "{json}");
|
||||
assert!(json.contains(r#""outputResolution":"480p""#), "{json}");
|
||||
assert!(json.contains(r#""faceBlurMode":"off""#), "{json}");
|
||||
assert!(json.contains(r#""contentMode":"normal""#), "{json}");
|
||||
// No ratio-string `resolution` field, and no URL fields.
|
||||
assert!(!json.contains(r#""resolution":"#), "{json}");
|
||||
assert!(!json.contains("imageUrls"), "{json}");
|
||||
assert!(!json.contains("videoUrls"), "{json}");
|
||||
assert!(!json.contains("uploadedUrls"), "{json}");
|
||||
assert!(!json.contains("audioUrls"), "{json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyframe_with_both_frames_matches_observed_request() {
|
||||
// Mirrors 2_request_keyframe_to_video.txt: mode "keyframe", NO
|
||||
// aspectRatio (Adaptive), frames in imageUrls + uploadedUrls.
|
||||
let mut request = base_request("Car drives into the sunset", 8);
|
||||
request.start_frame_url = Some("https://static.seedance2-pro.com/materials/start.png".to_string());
|
||||
request.end_frame_url = Some("https://static.seedance2-pro.com/materials/end.png".to_string());
|
||||
|
||||
let body = build_batch_request(request);
|
||||
let json = serde_json::to_string(&body).unwrap();
|
||||
assert!(json.contains(r#""mode":"keyframe""#), "{json}");
|
||||
// Adaptive: neither the `aspectRatio` nor `resolution` field is sent.
|
||||
assert!(!json.contains("aspectRatio"), "{json}");
|
||||
assert!(!json.contains(r#""resolution":"#), "{json}");
|
||||
let expected_urls = r#"["https://static.seedance2-pro.com/materials/start.png","https://static.seedance2-pro.com/materials/end.png"]"#;
|
||||
assert!(json.contains(&format!(r#""imageUrls":{expected_urls}"#)), "{json}");
|
||||
assert!(json.contains(&format!(r#""uploadedUrls":{expected_urls}"#)), "{json}");
|
||||
assert!(!json.contains("videoUrls"), "{json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyframe_with_start_frame_only_matches_observed_request() {
|
||||
// Mirrors 4_request_keyframe_to_video_2.txt: a single start frame.
|
||||
let mut request = base_request("The cars drive by", 5);
|
||||
request.start_frame_url = Some("https://static.seedance2-pro.com/materials/start.jpg".to_string());
|
||||
|
||||
let body = build_batch_request(request);
|
||||
let json = serde_json::to_string(&body).unwrap();
|
||||
assert!(json.contains(r#""mode":"keyframe""#), "{json}");
|
||||
assert!(!json.contains("aspectRatio"), "{json}");
|
||||
let expected_urls = r#"["https://static.seedance2-pro.com/materials/start.jpg"]"#;
|
||||
assert!(json.contains(&format!(r#""imageUrls":{expected_urls}"#)), "{json}");
|
||||
assert!(json.contains(&format!(r#""uploadedUrls":{expected_urls}"#)), "{json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_with_all_reference_types_matches_observed_request() {
|
||||
// Mirrors 3_request_reference_to_video.txt: mode "reference",
|
||||
// aspectRatio "21:9", split imageUrls / videoUrls / audioUrls with
|
||||
// uploadedUrls mirroring just the images.
|
||||
let mut request = base_request("The t-rex @image1 eats the banana @image2", 8);
|
||||
request.aspect_ratio = KinoviAspectRatioRaw::UltraWide21x9;
|
||||
request.reference_image_urls = Some(vec![
|
||||
"https://static.seedance2-pro.com/materials/a.png".to_string(),
|
||||
"https://static.seedance2-pro.com/materials/b.jpg".to_string(),
|
||||
]);
|
||||
request.reference_video_urls = Some(vec!["https://static.seedance2-pro.com/materials/ref.mp4".to_string()]);
|
||||
request.reference_audio_urls = Some(vec!["https://static.seedance2-pro.com/materials/ref.wav".to_string()]);
|
||||
|
||||
let body = build_batch_request(request);
|
||||
let json = serde_json::to_string(&body).unwrap();
|
||||
assert!(json.contains(r#""mode":"reference""#), "{json}");
|
||||
assert!(json.contains(r#""aspectRatio":"21:9""#), "{json}");
|
||||
let expected_images = r#"["https://static.seedance2-pro.com/materials/a.png","https://static.seedance2-pro.com/materials/b.jpg"]"#;
|
||||
assert!(json.contains(&format!(r#""imageUrls":{expected_images}"#)), "{json}");
|
||||
assert!(json.contains(&format!(r#""uploadedUrls":{expected_images}"#)), "{json}");
|
||||
assert!(json.contains(r#""videoUrls":["https://static.seedance2-pro.com/materials/ref.mp4"]"#), "{json}");
|
||||
assert!(json.contains(r#""audioUrls":["https://static.seedance2-pro.com/materials/ref.wav"]"#), "{json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_720p_and_face_blur_off_are_sent_explicitly() {
|
||||
let mut request = base_request("a corgi", 5);
|
||||
request.output_resolution = None;
|
||||
|
||||
let body = build_batch_request(request);
|
||||
let json = serde_json::to_string(&body).unwrap();
|
||||
assert!(json.contains(r#""outputResolution":"720p""#), "{json}");
|
||||
assert!(json.contains(r#""faceBlurMode":"off""#), "{json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_string_and_business_type() {
|
||||
assert_eq!(KinoviModelTypeRaw::Seedance2p5.as_api_str(), "seedance2-5");
|
||||
assert_eq!(KinoviModelTypeRaw::Seedance2p5.business_type(), "seedance25-preview-video-generation");
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2026-06-24 request-shape change ──
|
||||
//
|
||||
// The aspect ratio is now a ratio string ("16:9"). Seedance Pro/Fast keep it
|
||||
|
||||
Reference in new issue
Block a user