mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-05 17:58:55 +00:00
fix seedance 2.5 estimates (#1863)
This commit is contained in:
1 parent
c888a494b9
commit
b7f354face
17 files changed
+166
-37
No files matched your search
+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>,
|
||||
}
|
||||
+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,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
+5
-2
@@ -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,
|
||||
|
||||
+37
-10
@@ -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);
|
||||
|
||||
+3
-2
@@ -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);
|
||||
|
||||
+23
-12
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
+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)
|
||||
}
|
||||
|
||||
+1
@@ -171,6 +171,7 @@ mod tests {
|
||||
duration_seconds: None,
|
||||
video_batch_count: None,
|
||||
generate_audio: None,
|
||||
estimate_only: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -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);
|
||||
|
||||
+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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user