fix seedance 2.5 estimates (#1863)

This commit is contained in:
hanashi authored and GitHub committed 2026-08-08 00:46:06 -04:00
1 parent c888a494b9
commit b7f354face
17 files changed
+166 -37

No files matched your search

@@ -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>,
}
@@ -82,6 +82,7 @@ pub fn build_artcraft_omni_video_request(
negative_prompt: None,
generate_audio: None,
quality: None,
estimate_only: None,
})
}
@@ -63,6 +63,7 @@ mod tests {
duration_seconds: Some(5),
video_batch_count: Some(1),
generate_audio: None,
estimate_only: None,
},
};
@@ -158,8 +158,11 @@ mod tests {
}
#[test]
fn missing_input_seconds_bill_output_duration_only() {
assert_eq!(credits(Some(KinoviOutputResolution::FourEightyP), 10, true, None), 160);
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]
@@ -393,9 +393,11 @@ mod tests {
}
#[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);
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]
@@ -464,10 +466,12 @@ mod tests {
#[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
// 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, None).calculate_costs().kinovi_credits
assert!(video_ref_720(10, Some(0)).calculate_costs().kinovi_credits
< t2v_720(10).calculate_costs().kinovi_credits);
}
@@ -984,6 +984,7 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
OmniGenSplatModelsResponse,
OmniGenSplatProviderModelDetails,
OmniGenVideoCostAndGenerateRequest,
EstimateFields,
OmniGenVideoCostResponse,
OmniGenVideoGenerateResponse,
OmniGenVideoModelDetails,
@@ -22,6 +22,10 @@
//!
//! 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;
@@ -30,6 +34,7 @@ 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;
@@ -146,9 +151,14 @@ pub async fn fetch_reference_video_sources(
/// 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],
) -> Result<ProbedReferenceVideos, CommonWebError> {
) -> 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();
@@ -158,23 +168,32 @@ pub async fn download_and_probe_reference_videos(
continue; // Already downloaded and probed this file.
}
let temp_file = download_reference_video(source).await?;
let duration_millis = probe_video_duration_millis(source, &temp_file)?;
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)
}
};
info!("Probed reference video {} at {}ms", source.media_token, duration_millis);
seconds_by_url.insert(source.cdn_url.clone(), duration_millis.div_ceil(1_000));
local_paths_by_url.insert(source.cdn_url.clone(), temp_file.path().to_path_buf());
temp_files.push(temp_file);
seconds_by_url.insert(source.cdn_url.clone(), billed_seconds);
}
let total_input_seconds = total_billed_seconds(sources, &seconds_by_url);
Ok(ProbedReferenceVideos {
ProbedReferenceVideos {
total_input_seconds,
local_paths_by_url,
_temp_files: temp_files,
})
}
}
/// Sum per-reference (so duplicates bill per reference), saturating at
@@ -190,6 +209,14 @@ fn total_billed_seconds(
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);
@@ -203,9 +203,10 @@ pub async fn omni_api_video_generate_handler(
).await?;
// Downloads + ffprobe are slow — release the pool slot across them,
// then re-acquire for billing.
// 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?;
let probed = download_and_probe_reference_videos(&video_sources).await;
mysql_connection = server_state.mysql_pool.acquire().await?;
maybe_probed_reference_videos = Some(probed);
@@ -1,3 +1,4 @@
use std::convert::TryFrom;
use std::sync::Arc;
use actix_web::web::{self, Json};
@@ -43,12 +44,20 @@ pub async fn omni_gen_video_cost_handler(
builder.provider = RouterProvider::Artcraft; // NB: User is paying for ArtCraft credits / generation
// Seedance 2.5 bills reference-video input seconds on top of the output
// duration; probe the combined runtime so the quote matches what
// generation will bill. Best-effort: the UI polls mid-composition (tokens
// may not resolve yet), so a probe failure quotes without input seconds
// rather than failing — the generate endpoint bills the accurate total.
// 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)) {
if let (Some(server_state), Some(video_tokens)) = (
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()),
) {
@@ -74,6 +83,12 @@ 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.
@@ -106,13 +121,8 @@ async fn probe_input_seconds_best_effort(
}
};
match download_and_probe_reference_videos(&video_sources).await {
Ok(probed) => Some(probed.total_input_seconds),
Err(err) => {
warn!("Cost quote: failed to probe reference video durations: {:?}", err);
None
}
}
// Infallible: unmeasurable files bill at the 30-second worst case.
Some(download_and_probe_reference_videos(&video_sources).await.total_input_seconds)
}
#[cfg(test)]
@@ -249,6 +259,7 @@ mod tests {
duration_seconds: None,
video_batch_count: None,
generate_audio: None,
estimate_only: None,
}
}
}
@@ -90,6 +90,7 @@ mod tests {
duration_seconds,
video_batch_count: None,
generate_audio: None,
estimate_only: None,
};
estimate_minimax_h3_ultra_cost_usd_cents(&request)
}
@@ -171,6 +171,7 @@ mod tests {
duration_seconds: None,
video_batch_count: None,
generate_audio: None,
estimate_only: None,
}
}
}
@@ -186,9 +186,10 @@ pub async fn omni_gen_video_generate_handler(
).await?;
// Downloads + ffprobe are slow — release the pool slot across them,
// then re-acquire for billing.
// 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?;
let probed = download_and_probe_reference_videos(&video_sources).await;
mysql_connection = server_state.mysql_pool.acquire().await?;
maybe_probed_reference_videos = Some(probed);
@@ -81,6 +81,7 @@ mod tests {
duration_seconds: Some(5),
video_batch_count: Some(1),
generate_audio: None,
estimate_only: None,
}
}
@@ -61,6 +61,7 @@ mod tests {
duration_seconds: Some(5),
video_batch_count: Some(1),
generate_audio: None,
estimate_only: None,
}
}
@@ -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;
@@ -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
+10
View File
@@ -38,6 +38,15 @@ export interface OmniGenImageGenerateResponse {
// ── Video request / response types ───────────────────────────────────────
// Frontend-supplied hints for cost estimation only. IGNORED by generation —
// the backend measures inputs itself (e.g. ffprobing reference videos) when
// it bills. Seedance 2.5 bills reference-video input seconds, so its cost
// quote needs the combined input duration.
export interface OmniGenEstimateFields {
total_input_video_duration_millis?: number | null;
total_input_audio_duration_millis?: number | null;
}
export interface OmniGenVideoRequest {
model: string;
prompt?: string | null;
@@ -56,6 +65,7 @@ export interface OmniGenVideoRequest {
reference_audio_media_tokens?: string[] | null;
reference_character_tokens?: string[] | null;
negative_prompt?: string | null;
estimate_only?: OmniGenEstimateFields | null;
}
export interface OmniGenVideoCostResponse {