diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/cost_and_generate_requests/omni_gen_video_cost_and_generate_request.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/cost_and_generate_requests/omni_gen_video_cost_and_generate_request.rs index 07f91a32b8..56822ac8e8 100644 --- a/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/cost_and_generate_requests/omni_gen_video_cost_and_generate_request.rs +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/cost_and_generate_requests/omni_gen_video_cost_and_generate_request.rs @@ -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, + + /// 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, +} + +/// 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, + + /// 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, } diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/build_common.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/build_common.rs index b3a0c740f2..b443e755ec 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/build_common.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/build_common.rs @@ -82,6 +82,7 @@ pub fn build_artcraft_omni_video_request( negative_prompt: None, generate_audio: None, quality: None, + estimate_only: None, }) } diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/grok_imagine_video_1p5/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/grok_imagine_video_1p5/request.rs index 1072d50040..cb999d871e 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/grok_imagine_video_1p5/request.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/grok_imagine_video_1p5/request.rs @@ -63,6 +63,7 @@ mod tests { duration_seconds: Some(5), video_batch_count: Some(1), generate_audio: None, + estimate_only: None, }, }; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p5/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p5/cost.rs index f101f14aaf..a448b874e0 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p5/cost.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/kinovi/seedance_2p5/cost.rs @@ -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] diff --git a/crates/api_clients/kinovi_web_client/src/generate/video/generate_seedance_2p5.rs b/crates/api_clients/kinovi_web_client/src/generate/video/generate_seedance_2p5.rs index be0aca2a0c..791902679e 100644 --- a/crates/api_clients/kinovi_web_client/src/generate/video/generate_seedance_2p5.rs +++ b/crates/api_clients/kinovi_web_client/src/generate/video/generate_seedance_2p5.rs @@ -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); } diff --git a/crates/service/web/storyteller_web/src/docs/api_doc.rs b/crates/service/web/storyteller_web/src/docs/api_doc.rs index f971335e9f..63a9891177 100644 --- a/crates/service/web/storyteller_web/src/docs/api_doc.rs +++ b/crates/service/web/storyteller_web/src/docs/api_doc.rs @@ -984,6 +984,7 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux OmniGenSplatModelsResponse, OmniGenSplatProviderModelDetails, OmniGenVideoCostAndGenerateRequest, + EstimateFields, OmniGenVideoCostResponse, OmniGenVideoGenerateResponse, OmniGenVideoModelDetails, diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/common/probed_reference_videos.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/common/probed_reference_videos.rs index 461fcd20d7..55e20dac4f 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/common/probed_reference_videos.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/common/probed_reference_videos.rs @@ -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 { let mut temp_files: Vec = Vec::new(); let mut local_paths_by_url: HashMap = HashMap::new(); let mut seconds_by_url: HashMap = 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 { let mut temp_file = NamedTempFile::new().map_err(|err| { error!("Failed to create temp file for reference video {}: {:?}", source.media_token, err); diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/video/omni_api_video_generate_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/video/omni_api_video_generate_handler.rs index 548091d555..11a40cb0cd 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/video/omni_api_video_generate_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_api/generate/video/omni_api_video_generate_handler.rs @@ -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); diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/cost/video/omni_gen_video_cost_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/cost/video/omni_gen_video_cost_handler.rs index 79ea9069aa..48eeb8770c 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/cost/video/omni_gen_video_cost_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/cost/video/omni_gen_video_cost_handler.rs @@ -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, } } } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/first_party_minimax_h3/minimax_h3_ultra_cost.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/first_party_minimax_h3/minimax_h3_ultra_cost.rs index 89682a96e1..cc3b61591d 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/first_party_minimax_h3/minimax_h3_ultra_cost.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/first_party_minimax_h3/minimax_h3_ultra_cost.rs @@ -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) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/helpers/hydrate_router_request.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/helpers/hydrate_router_request.rs index 12761ec91f..ca5182c536 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/helpers/hydrate_router_request.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/helpers/hydrate_router_request.rs @@ -171,6 +171,7 @@ mod tests { duration_seconds: None, video_batch_count: None, generate_audio: None, + estimate_only: None, } } } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/omni_gen_video_generate_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/omni_gen_video_generate_handler.rs index aab7391dde..7af8452fe8 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/omni_gen_video_generate_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/omni_gen_video_generate_handler.rs @@ -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); diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/shared_utils/video/validate_seedance_2p0_limits.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/shared_utils/video/validate_seedance_2p0_limits.rs index 52f995f0b3..6fdcbca54d 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/shared_utils/video/validate_seedance_2p0_limits.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/shared_utils/video/validate_seedance_2p0_limits.rs @@ -81,6 +81,7 @@ mod tests { duration_seconds: Some(5), video_batch_count: Some(1), generate_audio: None, + estimate_only: None, } } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/shared_utils/video/validate_when_image_required.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/shared_utils/video/validate_when_image_required.rs index 3b6a84b709..b90593c740 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/shared_utils/video/validate_when_image_required.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/shared_utils/video/validate_when_image_required.rs @@ -61,6 +61,7 @@ mod tests { duration_seconds: Some(5), video_batch_count: Some(1), generate_audio: None, + estimate_only: None, } } diff --git a/frontend/apps/artcraft-webapp/src/lib/cost-estimate-api.ts b/frontend/apps/artcraft-webapp/src/lib/cost-estimate-api.ts index e5c790412f..a0d30da19f 100644 --- a/frontend/apps/artcraft-webapp/src/lib/cost-estimate-api.ts +++ b/frontend/apps/artcraft-webapp/src/lib/cost-estimate-api.ts @@ -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; diff --git a/frontend/apps/artcraft-webapp/src/pages/create-video/create-video.tsx b/frontend/apps/artcraft-webapp/src/pages/create-video/create-video.tsx index cef69939a8..408e218d17 100644 --- a/frontend/apps/artcraft-webapp/src/pages/create-video/create-video.tsx +++ b/frontend/apps/artcraft-webapp/src/pages/create-video/create-video.tsx @@ -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 diff --git a/frontend/libs/api/src/lib/OmniGenApi.ts b/frontend/libs/api/src/lib/OmniGenApi.ts index 6cddc972c2..b75b30db90 100644 --- a/frontend/libs/api/src/lib/OmniGenApi.ts +++ b/frontend/libs/api/src/lib/OmniGenApi.ts @@ -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 {