Compare commits

...
Author SHA1 Message Date
Brandon Thomas ec620de195 frontend model list changes too 2026-09-28 02:30:08 -04:00
Brandon Thomas 10a7d1fee2 remove unused models 2026-09-28 02:24:48 -04:00
34 changed files with 645 additions and 731 deletions

No files matched your search

@@ -1,22 +1,22 @@
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::providers::artcraft::build_common::{
build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport,
};
use crate::generate::generate_video::providers::veo_3p1_common::{plan_veo_3p1_duration, Veo3p1Modality, Veo3p1Variant};
use crate::generate::generate_video::providers::artcraft::veo_3p1::request::ArtcraftVeo3p1RequestState;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
pub fn build_artcraft_veo_3p1(mut builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
// Veo 3.1 accepts only {4,6,8} seconds; pre-plan so cost parity holds for
// strategies that error or clamp. Also preserve generate_audio (omni-gen
// helper hardcodes None on output).
// Pre-plan the duration fal will actually generate (it depends on the
// modality; see `veo_3p1_common`) so the price matches. Also preserve
// generate_audio (omni-gen helper hardcodes None on output).
let strategy = builder.request_mismatch_mitigation_strategy;
let final_duration = plan_veo_3p1_duration(builder.duration_seconds, strategy)?;
let modality = Veo3p1Modality::of(&builder);
let final_duration = plan_veo_3p1_duration(Veo3p1Variant::Standard, modality, builder.duration_seconds, strategy)?;
builder.duration_seconds = final_duration;
let generate_audio = builder.generate_audio;
@@ -31,23 +31,3 @@ pub fn build_artcraft_veo_3p1(mut builder: GenerateVideoRequestBuilder) -> Resul
let state = ArtcraftVeo3p1RequestState { request };
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftVeo3p1(state)))
}
pub(crate) fn plan_veo_3p1_duration(
duration_seconds: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<u16>, ArtcraftRouterError> {
match duration_seconds {
None => Ok(None),
Some(4) | Some(6) | Some(8) => Ok(duration_seconds),
Some(other) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "duration_seconds",
value: format!("{}", other),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Some(8)),
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(4)),
},
}
}
@@ -14,7 +14,8 @@ impl ArtcraftVeo3p1CostState {
pub fn from_request(request: &ArtcraftVeo3p1RequestState) -> Self {
Self {
// v1 legacy Veo 3.1 multi-function handler defaults None → 6s.
duration_seconds: request.request.duration_seconds.map(u64::from).unwrap_or(6),
// NB: fal defaults Veo 3.1 to 8s (the builder plans it explicitly anyway).
duration_seconds: request.request.duration_seconds.map(u64::from).unwrap_or(8),
// v1 legacy Veo 3.1 handler defaults generate_audio to true.
generate_audio: request.request.generate_audio.unwrap_or(true),
is_4k: request.request.resolution == Some(CommonResolutionEnum::FourK),
@@ -85,8 +86,9 @@ mod tests {
fn audio_off_4s_is_96() { assert_eq!(cost_cents(Some(4), Some(false)), 96); }
#[test]
fn default_duration_is_6s() {
assert_eq!(cost_cents(None, Some(true)), 288);
fn default_duration_is_8s() {
// fal defaults Veo 3.1 to 8s.
assert_eq!(cost_cents(None, Some(true)), cost_cents(Some(8), Some(true)));
}
#[test]
@@ -5,15 +5,16 @@ use crate::generate::generate_video::generate_video_request_builder::GenerateVid
use crate::generate::generate_video::providers::artcraft::build_common::{
build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport,
};
use crate::generate::generate_video::providers::artcraft::veo_3p1::build::plan_veo_3p1_duration;
use crate::generate::generate_video::providers::veo_3p1_common::{plan_veo_3p1_duration, Veo3p1Modality, Veo3p1Variant};
use crate::generate::generate_video::providers::artcraft::veo_3p1_fast::request::ArtcraftVeo3p1FastRequestState;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
pub fn build_artcraft_veo_3p1_fast(mut builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
// Same duration constraints as Veo 3.1 (4/6/8). See veo_3p1::build for rationale.
// Pre-plan the duration fal will actually generate so the price matches.
let strategy = builder.request_mismatch_mitigation_strategy;
let final_duration = plan_veo_3p1_duration(builder.duration_seconds, strategy)?;
let modality = Veo3p1Modality::of(&builder);
let final_duration = plan_veo_3p1_duration(Veo3p1Variant::Fast, modality, builder.duration_seconds, strategy)?;
builder.duration_seconds = final_duration;
let generate_audio = builder.generate_audio;
@@ -14,7 +14,8 @@ impl ArtcraftVeo3p1FastCostState {
pub fn from_request(request: &ArtcraftVeo3p1FastRequestState) -> Self {
Self {
// v1 legacy Veo 3.1 Fast multi-function handler defaults None → 6s.
duration_seconds: request.request.duration_seconds.map(u64::from).unwrap_or(6),
// NB: fal defaults Veo 3.1 to 8s (the builder plans it explicitly anyway).
duration_seconds: request.request.duration_seconds.map(u64::from).unwrap_or(8),
// v1 legacy Veo 3.1 Fast handler defaults generate_audio to true.
generate_audio: request.request.generate_audio.unwrap_or(true),
is_4k: request.request.resolution == Some(CommonResolutionEnum::FourK),
@@ -85,8 +86,9 @@ mod tests {
fn audio_off_4s_is_44() { assert_eq!(cost_cents(Some(4), Some(false)), 44); }
#[test]
fn default_duration_is_6s() {
assert_eq!(cost_cents(None, Some(true)), 99);
fn default_duration_is_8s() {
// fal defaults Veo 3.1 to 8s.
assert_eq!(cost_cents(None, Some(true)), cost_cents(Some(8), Some(true)));
}
#[test]
@@ -5,6 +5,7 @@ use crate::generate::generate_video::generate_video_request_builder::GenerateVid
use crate::generate::generate_video::providers::artcraft::build_common::{
build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport,
};
use crate::generate::generate_video::providers::veo_3p1_common::{plan_veo_3p1_duration, Veo3p1Modality, Veo3p1Variant};
use crate::generate::generate_video::providers::artcraft::veo_3p1_lite::request::ArtcraftVeo3p1LiteRequestState;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
@@ -14,7 +15,13 @@ pub fn build_artcraft_veo_3p1_lite(builder: GenerateVideoRequestBuilder) -> Resu
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftVeo3p1Lite(state)))
}
pub(crate) fn build_artcraft_veo_3p1_lite_state(builder: GenerateVideoRequestBuilder) -> Result<ArtcraftVeo3p1LiteRequestState, ArtcraftRouterError> {
pub(crate) fn build_artcraft_veo_3p1_lite_state(mut builder: GenerateVideoRequestBuilder) -> Result<ArtcraftVeo3p1LiteRequestState, ArtcraftRouterError> {
// Pre-plan the duration fal will actually generate (it depends on the
// modality; see `veo_3p1_common`) so the price matches.
let strategy = builder.request_mismatch_mitigation_strategy;
let modality = Veo3p1Modality::of(&builder);
builder.duration_seconds = plan_veo_3p1_duration(Veo3p1Variant::Lite, modality, builder.duration_seconds, strategy)?;
// Veo 3.1 Lite supports `generate_audio` — preserve it so cost can read it back.
// build_artcraft_omni_video_request hardcodes generate_audio = None on its output.
let generate_audio = builder.generate_audio;
@@ -19,6 +19,7 @@ use fal_client::requests::api::video::text::veo_3p1::api::{
Veo3p1TextToVideoResolution,
};
use crate::generate::generate_video::providers::veo_3p1_common::{plan_veo_3p1_duration, Veo3p1Modality, Veo3p1Variant};
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::router_aspect_ratio::RouterAspectRatio;
@@ -139,14 +140,19 @@ pub(crate) fn build_fal_veo_3p1_state(
prompt,
image_urls,
aspect_ratio: aspect_ratio.and_then(to_reference_aspect_ratio),
duration: plan_duration(builder.duration_seconds, strategy)?.map(to_reference_duration),
duration: plan_duration(builder.duration_seconds, Veo3p1Modality::ReferenceToVideo, strategy)?.map(to_reference_duration),
resolution: resolution.map(to_reference_resolution),
generate_audio,
auto_fix: None,
safety_tolerance: None,
})
} else {
let duration = plan_duration(builder.duration_seconds, strategy)?;
let modality = match (&start, &end) {
(Some(_), Some(_)) => Veo3p1Modality::FirstLastFrameToVideo,
(Some(_), None) => Veo3p1Modality::ImageToVideo,
(None, _) => Veo3p1Modality::TextToVideo,
};
let duration = plan_duration(builder.duration_seconds, modality, strategy)?;
match (start, end) {
(None, None) => FalVeo3p1Mode::TextToVideo(Veo3p1TextToVideoRequest {
prompt,
@@ -276,57 +282,31 @@ fn plan_resolution(
}
}
/// fal's accepted durations depend on the modality; see `veo_3p1_common`.
fn plan_duration(
duration_seconds: Option<u16>,
modality: Veo3p1Modality,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<PlanDuration>, ArtcraftRouterError> {
match duration_seconds {
None => Ok(None),
Some(4) => Ok(Some(PlanDuration::Four)),
Some(6) => Ok(Some(PlanDuration::Six)),
Some(8) => Ok(Some(PlanDuration::Eight)),
Some(other) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(unsupported("duration_seconds", &format!("{}", other)))
}
// Nearest supported duration above/below, clamped to the 4s–8s range.
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Some(match other {
0..=4 => PlanDuration::Four,
5..=6 => PlanDuration::Six,
_ => PlanDuration::Eight,
})),
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(match other {
0..=5 => PlanDuration::Four,
6..=7 => PlanDuration::Six,
_ => PlanDuration::Eight,
})),
},
}
plan_veo_3p1_duration(Veo3p1Variant::Standard, modality, duration_seconds, strategy)?
.map(|seconds| match seconds {
4 => Ok(PlanDuration::Four),
6 => Ok(PlanDuration::Six),
8 => Ok(PlanDuration::Eight),
other => Err(unsupported("duration_seconds", &format!("{}", other))),
})
.transpose()
}
/// Extend-video also supports fal's 7s default in addition to 4/6/8.
/// fal's extend-video endpoints only accept 7s; see `veo_3p1_common`.
fn plan_extend_duration(
duration_seconds: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<Veo3p1ExtendVideoDuration>, ArtcraftRouterError> {
use Veo3p1ExtendVideoDuration as D;
match duration_seconds {
match plan_veo_3p1_duration(Veo3p1Variant::Standard, Veo3p1Modality::ExtendVideo, duration_seconds, strategy)? {
None => Ok(None),
Some(4) => Ok(Some(D::FourSeconds)),
Some(6) => Ok(Some(D::SixSeconds)),
Some(7) => Ok(Some(D::SevenSeconds)),
Some(8) => Ok(Some(D::EightSeconds)),
Some(other) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(unsupported("duration_seconds", &format!("{}", other)))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => {
Ok(Some(if other < 5 { D::FourSeconds } else if other == 5 { D::SixSeconds } else { D::EightSeconds }))
}
RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(Some(if other <= 5 { D::FourSeconds } else { D::EightSeconds }))
}
},
Some(7) => Ok(Some(Veo3p1ExtendVideoDuration::SevenSeconds)),
Some(other) => Err(unsupported("duration_seconds", &format!("{}", other))),
}
}
@@ -592,14 +572,15 @@ mod tests {
}
#[test]
fn extend_duration_5s_pay_more_upgrades_to_6() {
fn extend_duration_is_always_7s() {
// fal's extend-video endpoint only accepts "7s".
let mut b = base_builder();
b.reference_videos = Some(VideoListRef::Urls(vec![REFERENCE_VIDEO_URL.to_string()]));
b.duration_seconds = Some(5);
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::PayMoreUpgrade;
match unwrap_mode(b) {
FalVeo3p1Mode::ExtendVideo(r) => {
assert!(matches!(r.duration, Some(Veo3p1ExtendVideoDuration::SixSeconds)));
assert!(matches!(r.duration, Some(Veo3p1ExtendVideoDuration::SevenSeconds)));
}
_ => panic!("expected extend"),
}
@@ -681,14 +662,15 @@ mod tests {
}
#[test]
fn reference_maps_duration_and_resolution() {
fn reference_duration_is_always_8s_and_maps_resolution() {
// fal's reference-to-video endpoint only accepts "8s" (it rejected 6s in production).
let mut b = base_builder();
b.reference_images = Some(ImageListRef::Urls(vec![REFERENCE_IMAGE_URL_A.to_string()]));
b.duration_seconds = Some(6);
b.resolution = Some(RouterResolution::FourK);
match unwrap_mode(b) {
FalVeo3p1Mode::ReferenceToVideo(r) => {
assert!(matches!(r.duration, Some(Veo3p1ReferenceToVideoDuration::SixSeconds)));
assert!(matches!(r.duration, Some(Veo3p1ReferenceToVideoDuration::EightSeconds)));
assert!(matches!(r.resolution, Some(Veo3p1ReferenceToVideoResolution::FourK)));
}
_ => panic!("expected reference"),
@@ -148,8 +148,8 @@ mod tests {
}
#[test]
fn reference_4s_audio_off_720p_is_80() {
assert_eq!(cost_cents(Modality::Reference, Some(4), Some(RouterResolution::SevenTwentyP), Some(false)), 80);
fn reference_4s_request_is_billed_as_8s_audio_off_720p_is_160() {
assert_eq!(cost_cents(Modality::Reference, Some(4), Some(RouterResolution::SevenTwentyP), Some(false)), 160);
}
#[test]
@@ -172,8 +172,8 @@ mod tests {
}
#[test]
fn extend_8s_audio_on_is_320() {
assert_eq!(cost_cents(Modality::Extend, Some(8), None, Some(true)), 320);
fn extend_8s_request_is_billed_as_7s_audio_on_is_280() {
assert_eq!(cost_cents(Modality::Extend, Some(8), None, Some(true)), 280);
}
#[test]
@@ -185,9 +185,10 @@ mod tests {
#[test]
fn t2v_i2v_flf_and_reference_price_identically() {
let expected = cost_cents(Modality::TextToVideo, Some(6), Some(RouterResolution::TenEightyP), Some(true));
// NB: At 8s, the only duration reference-to-video accepts.
let expected = cost_cents(Modality::TextToVideo, Some(8), Some(RouterResolution::TenEightyP), Some(true));
for modality in [Modality::ImageToVideo, Modality::FirstLastFrame, Modality::Reference] {
assert_eq!(cost_cents(modality, Some(6), Some(RouterResolution::TenEightyP), Some(true)), expected);
assert_eq!(cost_cents(modality, Some(8), Some(RouterResolution::TenEightyP), Some(true)), expected);
}
}
@@ -19,6 +19,7 @@ use fal_client::requests::api::video::text::veo_3p1_fast::api::{
Veo3p1FastTextToVideoResolution,
};
use crate::generate::generate_video::providers::veo_3p1_common::{plan_veo_3p1_duration, Veo3p1Modality, Veo3p1Variant};
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::router_aspect_ratio::RouterAspectRatio;
@@ -141,14 +142,19 @@ pub(crate) fn build_fal_veo_3p1_fast_state(
prompt,
image_urls,
aspect_ratio: aspect_ratio.and_then(to_reference_aspect_ratio),
duration: plan_duration(builder.duration_seconds, strategy)?.map(to_reference_duration),
duration: plan_duration(builder.duration_seconds, Veo3p1Modality::ReferenceToVideo, strategy)?.map(to_reference_duration),
resolution: resolution.map(to_reference_resolution),
generate_audio,
auto_fix: None,
safety_tolerance: None,
})
} else {
let duration = plan_duration(builder.duration_seconds, strategy)?;
let modality = match (&start, &end) {
(Some(_), Some(_)) => Veo3p1Modality::FirstLastFrameToVideo,
(Some(_), None) => Veo3p1Modality::ImageToVideo,
(None, _) => Veo3p1Modality::TextToVideo,
};
let duration = plan_duration(builder.duration_seconds, modality, strategy)?;
match (start, end) {
(None, None) => FalVeo3p1FastMode::TextToVideo(Veo3p1FastTextToVideoRequest {
prompt,
@@ -278,57 +284,31 @@ fn plan_resolution(
}
}
/// fal's accepted durations depend on the modality; see `veo_3p1_common`.
fn plan_duration(
duration_seconds: Option<u16>,
modality: Veo3p1Modality,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<PlanDuration>, ArtcraftRouterError> {
match duration_seconds {
None => Ok(None),
Some(4) => Ok(Some(PlanDuration::Four)),
Some(6) => Ok(Some(PlanDuration::Six)),
Some(8) => Ok(Some(PlanDuration::Eight)),
Some(other) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(unsupported("duration_seconds", &format!("{}", other)))
}
// Nearest supported duration above/below, clamped to the 4s–8s range.
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Some(match other {
0..=4 => PlanDuration::Four,
5..=6 => PlanDuration::Six,
_ => PlanDuration::Eight,
})),
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(match other {
0..=5 => PlanDuration::Four,
6..=7 => PlanDuration::Six,
_ => PlanDuration::Eight,
})),
},
}
plan_veo_3p1_duration(Veo3p1Variant::Fast, modality, duration_seconds, strategy)?
.map(|seconds| match seconds {
4 => Ok(PlanDuration::Four),
6 => Ok(PlanDuration::Six),
8 => Ok(PlanDuration::Eight),
other => Err(unsupported("duration_seconds", &format!("{}", other))),
})
.transpose()
}
/// Extend-video also supports fal's 7s default in addition to 4/6/8.
/// fal's extend-video endpoints only accept 7s; see `veo_3p1_common`.
fn plan_extend_duration(
duration_seconds: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<Veo3p1FastExtendVideoDuration>, ArtcraftRouterError> {
use Veo3p1FastExtendVideoDuration as D;
match duration_seconds {
match plan_veo_3p1_duration(Veo3p1Variant::Fast, Veo3p1Modality::ExtendVideo, duration_seconds, strategy)? {
None => Ok(None),
Some(4) => Ok(Some(D::FourSeconds)),
Some(6) => Ok(Some(D::SixSeconds)),
Some(7) => Ok(Some(D::SevenSeconds)),
Some(8) => Ok(Some(D::EightSeconds)),
Some(other) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(unsupported("duration_seconds", &format!("{}", other)))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => {
Ok(Some(if other < 5 { D::FourSeconds } else if other == 5 { D::SixSeconds } else { D::EightSeconds }))
}
RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(Some(if other <= 5 { D::FourSeconds } else { D::EightSeconds }))
}
},
Some(7) => Ok(Some(Veo3p1FastExtendVideoDuration::SevenSeconds)),
Some(other) => Err(unsupported("duration_seconds", &format!("{}", other))),
}
}
@@ -155,8 +155,8 @@ mod tests {
}
#[test]
fn reference_4s_audio_off_720p_is_40() {
assert_eq!(cost_cents(Modality::Reference, Some(4), Some(RouterResolution::SevenTwentyP), Some(false)), 40);
fn reference_4s_request_is_billed_as_8s_audio_off_720p_is_80() {
assert_eq!(cost_cents(Modality::Reference, Some(4), Some(RouterResolution::SevenTwentyP), Some(false)), 80);
}
}
@@ -174,8 +174,8 @@ mod tests {
}
#[test]
fn extend_8s_audio_on_is_120() {
assert_eq!(cost_cents(Modality::Extend, Some(8), None, Some(true)), 120);
fn extend_8s_request_is_billed_as_7s_audio_on_is_105() {
assert_eq!(cost_cents(Modality::Extend, Some(8), None, Some(true)), 105);
}
#[test]
@@ -187,9 +187,10 @@ mod tests {
#[test]
fn t2v_i2v_flf_and_reference_price_identically() {
let expected = cost_cents(Modality::TextToVideo, Some(6), Some(RouterResolution::TenEightyP), Some(true));
// NB: At 8s, the only duration reference-to-video accepts.
let expected = cost_cents(Modality::TextToVideo, Some(8), Some(RouterResolution::TenEightyP), Some(true));
for modality in [Modality::ImageToVideo, Modality::FirstLastFrame, Modality::Reference] {
assert_eq!(cost_cents(modality, Some(6), Some(RouterResolution::TenEightyP), Some(true)), expected);
assert_eq!(cost_cents(modality, Some(8), Some(RouterResolution::TenEightyP), Some(true)), expected);
}
}
@@ -11,6 +11,7 @@ use fal_client::requests::api::video::text::veo_3p1_lite::api::{
Veo3p1LiteTextToVideoResolution,
};
use crate::generate::generate_video::providers::veo_3p1_common::{plan_veo_3p1_duration, Veo3p1Modality, Veo3p1Variant};
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::router_aspect_ratio::RouterAspectRatio;
@@ -77,11 +78,17 @@ pub(crate) fn build_fal_veo_3p1_lite_state(
let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio, strategy)?;
let resolution = plan_resolution(builder.resolution, strategy)?;
let duration = plan_duration(builder.duration_seconds, strategy)?;
let prompt = builder.prompt.clone().unwrap_or_default();
let negative_prompt = builder.negative_prompt.clone();
let generate_audio = builder.generate_audio;
let modality = match (&start, &end) {
(Some(_), Some(_)) => Veo3p1Modality::FirstLastFrameToVideo,
(Some(_), None) => Veo3p1Modality::ImageToVideo,
(None, _) => Veo3p1Modality::TextToVideo,
};
let duration = plan_duration(builder.duration_seconds, modality, strategy)?;
let mode = match (start, end) {
(None, None) => FalVeo3p1LiteMode::TextToVideo(Veo3p1LiteTextToVideoRequest {
prompt,
@@ -209,31 +216,20 @@ fn plan_resolution(
}
}
/// fal's accepted durations depend on the modality; see `veo_3p1_common`.
fn plan_duration(
duration_seconds: Option<u16>,
modality: Veo3p1Modality,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<PlanDuration>, ArtcraftRouterError> {
match duration_seconds {
None => Ok(None),
Some(4) => Ok(Some(PlanDuration::Four)),
Some(6) => Ok(Some(PlanDuration::Six)),
Some(8) => Ok(Some(PlanDuration::Eight)),
Some(other) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(unsupported("duration_seconds", &format!("{}", other)))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Some(match other {
0..=4 => PlanDuration::Four,
5..=6 => PlanDuration::Six,
_ => PlanDuration::Eight,
})),
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(match other {
0..=5 => PlanDuration::Four,
6..=7 => PlanDuration::Six,
_ => PlanDuration::Eight,
})),
},
}
plan_veo_3p1_duration(Veo3p1Variant::Lite, modality, duration_seconds, strategy)?
.map(|seconds| match seconds {
4 => Ok(PlanDuration::Four),
6 => Ok(PlanDuration::Six),
8 => Ok(PlanDuration::Eight),
other => Err(unsupported("duration_seconds", &format!("{}", other))),
})
.transpose()
}
fn unsupported(field: &'static str, value: &str) -> ArtcraftRouterError {
@@ -114,16 +114,18 @@ mod tests {
}
#[test]
fn flf_4s_audio_off_720p_is_12() {
assert_eq!(cost_cents(Some(4), Some(RouterResolution::SevenTwentyP), Some(false), 2), 12);
fn flf_4s_request_is_billed_as_8s_audio_off_720p_is_24() {
// Lite's first-last-frame endpoint only accepts "8s".
assert_eq!(cost_cents(Some(4), Some(RouterResolution::SevenTwentyP), Some(false), 2), 24);
}
}
#[test]
fn all_three_modes_price_identically() {
let t2v = cost_cents(Some(6), Some(RouterResolution::TenEightyP), Some(true), 0);
let i2v = cost_cents(Some(6), Some(RouterResolution::TenEightyP), Some(true), 1);
let flf = cost_cents(Some(6), Some(RouterResolution::TenEightyP), Some(true), 2);
// NB: At 8s, the only duration first-last-frame accepts.
let t2v = cost_cents(Some(8), Some(RouterResolution::TenEightyP), Some(true), 0);
let i2v = cost_cents(Some(8), Some(RouterResolution::TenEightyP), Some(true), 1);
let flf = cost_cents(Some(8), Some(RouterResolution::TenEightyP), Some(true), 2);
assert_eq!(t2v, i2v);
assert_eq!(i2v, flf);
}
@@ -3,4 +3,5 @@ pub mod fal;
pub mod first_party;
pub mod grok_api;
pub mod kinovi;
pub mod veo_3p1_common;
pub mod wan_3p0_common;
@@ -0,0 +1,203 @@
//! Veo 3.1 duration rules, shared by the Artcraft provider (which prices a
//! request) and the fal provider (which executes it) so both always agree on
//! the duration that will actually be generated and billed.
//!
//! fal's Veo 3.1 endpoints don't all accept the same durations. From fal's
//! OpenAPI schemas (checked 2026-09-28):
//!
//! | Modality | Veo 3.1 / 3.1 Fast | Veo 3.1 Lite |
//! |---------------------------|--------------------|--------------|
//! | text-to-video | 4s, 6s, 8s | 4s, 6s, 8s |
//! | image-to-video | 4s, 6s, 8s | 4s, 6s, 8s |
//! | first-last-frame-to-video | 4s, 6s, 8s | 8s only |
//! | reference-to-video | 8s only | (none) |
//! | extend-video | 7s only | (none) |
//!
//! Sending another value fails at fal, eg. `Input should be '8s'`.
use crate::api::image_list_ref::ImageListRef;
use crate::api::video_list_ref::VideoListRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub(crate) enum Veo3p1Variant {
Standard,
Fast,
Lite,
}
/// The fal endpoint a request goes to. Mirrors the fal builders' dispatch
/// order: reference videos, then reference images, then frames.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub(crate) enum Veo3p1Modality {
TextToVideo,
ImageToVideo,
FirstLastFrameToVideo,
ReferenceToVideo,
ExtendVideo,
}
impl Veo3p1Modality {
pub(crate) fn of(builder: &GenerateVideoRequestBuilder) -> Self {
if has_videos(&builder.reference_videos) {
Veo3p1Modality::ExtendVideo
} else if has_images(&builder.reference_images) {
Veo3p1Modality::ReferenceToVideo
} else {
match (builder.start_frame.is_some(), builder.end_frame.is_some()) {
(true, true) => Veo3p1Modality::FirstLastFrameToVideo,
(true, false) => Veo3p1Modality::ImageToVideo,
// (false, true) is rejected by the fal builders.
(false, _) => Veo3p1Modality::TextToVideo,
}
}
}
}
/// The durations (seconds, ascending) fal accepts for this variant and modality.
pub(crate) fn veo_3p1_allowed_durations(variant: Veo3p1Variant, modality: Veo3p1Modality) -> &'static [u16] {
match (variant, modality) {
(_, Veo3p1Modality::ExtendVideo) => &[7],
(_, Veo3p1Modality::ReferenceToVideo) => &[8],
(Veo3p1Variant::Lite, Veo3p1Modality::FirstLastFrameToVideo) => &[8],
_ => &[4, 6, 8],
}
}
/// Plan the duration to send for a request.
///
/// A supported duration passes through. Otherwise `ErrorOut` fails, and the
/// other strategies pick the nearest supported duration above
/// (`PayMoreUpgrade`) or below (`PayLessDowngrade`), clamped to the range.
/// With no requested duration, returns fal's default for the endpoint (the
/// longest allowed: 8s, or 7s for extend-video), explicitly, so the price is
/// computed from the duration that will actually be generated.
pub(crate) fn plan_veo_3p1_duration(
variant: Veo3p1Variant,
modality: Veo3p1Modality,
duration_seconds: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<u16>, ArtcraftRouterError> {
let allowed = veo_3p1_allowed_durations(variant, modality);
let shortest = allowed[0];
let longest = allowed[allowed.len() - 1];
let requested = match duration_seconds {
None => return Ok(Some(longest)),
Some(requested) => requested,
};
if allowed.contains(&requested) {
return Ok(Some(requested));
}
match strategy {
RequestMismatchMitigationStrategy::ErrorOut => Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "duration_seconds",
value: format!("{} (supported for this input: {:?})", requested, allowed),
})),
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Some(
allowed.iter().copied().find(|&allowed| allowed >= requested).unwrap_or(longest))),
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(
allowed.iter().copied().rev().find(|&allowed| allowed <= requested).unwrap_or(shortest))),
}
}
fn has_images(refs: &Option<ImageListRef>) -> bool {
match refs {
None => false,
Some(ImageListRef::Urls(urls)) => !urls.is_empty(),
Some(ImageListRef::MediaFileTokens(tokens)) => !tokens.is_empty(),
}
}
fn has_videos(refs: &Option<VideoListRef>) -> bool {
match refs {
None => false,
Some(VideoListRef::Urls(urls)) => !urls.is_empty(),
Some(VideoListRef::MediaFileTokens(tokens)) => !tokens.is_empty(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use RequestMismatchMitigationStrategy::{ErrorOut, PayLessDowngrade, PayMoreUpgrade};
use Veo3p1Modality::*;
use Veo3p1Variant::*;
mod allowed_durations {
use super::*;
#[test]
fn standard_and_fast() {
for variant in [Standard, Fast] {
assert_eq!(veo_3p1_allowed_durations(variant, TextToVideo), &[4, 6, 8]);
assert_eq!(veo_3p1_allowed_durations(variant, ImageToVideo), &[4, 6, 8]);
assert_eq!(veo_3p1_allowed_durations(variant, FirstLastFrameToVideo), &[4, 6, 8]);
assert_eq!(veo_3p1_allowed_durations(variant, ReferenceToVideo), &[8]);
assert_eq!(veo_3p1_allowed_durations(variant, ExtendVideo), &[7]);
}
}
#[test]
fn lite() {
assert_eq!(veo_3p1_allowed_durations(Lite, TextToVideo), &[4, 6, 8]);
assert_eq!(veo_3p1_allowed_durations(Lite, ImageToVideo), &[4, 6, 8]);
assert_eq!(veo_3p1_allowed_durations(Lite, FirstLastFrameToVideo), &[8]);
}
}
mod planning {
use super::*;
#[test]
fn supported_durations_pass_through() {
for seconds in [4, 6, 8] {
assert_eq!(plan_veo_3p1_duration(Fast, TextToVideo, Some(seconds), ErrorOut).unwrap(), Some(seconds));
}
}
#[test]
fn reference_to_video_is_always_eight_seconds() {
// The production bug: 4s/6s sent to reference-to-video ("Input should be '8s'").
for seconds in [4, 6] {
assert_eq!(plan_veo_3p1_duration(Fast, ReferenceToVideo, Some(seconds), PayMoreUpgrade).unwrap(), Some(8));
assert_eq!(plan_veo_3p1_duration(Fast, ReferenceToVideo, Some(seconds), PayLessDowngrade).unwrap(), Some(8));
assert!(plan_veo_3p1_duration(Fast, ReferenceToVideo, Some(seconds), ErrorOut).is_err());
}
assert_eq!(plan_veo_3p1_duration(Fast, ReferenceToVideo, None, ErrorOut).unwrap(), Some(8));
}
#[test]
fn extend_video_is_always_seven_seconds() {
assert_eq!(plan_veo_3p1_duration(Standard, ExtendVideo, Some(8), PayMoreUpgrade).unwrap(), Some(7));
assert_eq!(plan_veo_3p1_duration(Standard, ExtendVideo, Some(4), PayMoreUpgrade).unwrap(), Some(7));
assert_eq!(plan_veo_3p1_duration(Standard, ExtendVideo, None, ErrorOut).unwrap(), Some(7));
}
#[test]
fn lite_first_last_frame_is_always_eight_seconds() {
assert_eq!(plan_veo_3p1_duration(Lite, FirstLastFrameToVideo, Some(4), PayMoreUpgrade).unwrap(), Some(8));
}
#[test]
fn unsupported_durations_round_to_the_nearest_supported_one() {
assert_eq!(plan_veo_3p1_duration(Fast, TextToVideo, Some(5), PayMoreUpgrade).unwrap(), Some(6));
assert_eq!(plan_veo_3p1_duration(Fast, TextToVideo, Some(7), PayMoreUpgrade).unwrap(), Some(8));
assert_eq!(plan_veo_3p1_duration(Fast, TextToVideo, Some(12), PayMoreUpgrade).unwrap(), Some(8));
assert_eq!(plan_veo_3p1_duration(Fast, TextToVideo, Some(5), PayLessDowngrade).unwrap(), Some(4));
assert_eq!(plan_veo_3p1_duration(Fast, TextToVideo, Some(2), PayLessDowngrade).unwrap(), Some(4));
assert!(plan_veo_3p1_duration(Fast, TextToVideo, Some(5), ErrorOut).is_err());
}
#[test]
fn missing_duration_is_fals_default() {
// fal defaults every Veo 3.1 endpoint to 8s, except extend-video (7s).
for modality in [TextToVideo, ImageToVideo, FirstLastFrameToVideo, ReferenceToVideo] {
assert_eq!(plan_veo_3p1_duration(Standard, modality, None, ErrorOut).unwrap(), Some(8), "{:?}", modality);
}
assert_eq!(plan_veo_3p1_duration(Standard, ExtendVideo, None, ErrorOut).unwrap(), Some(7));
}
}
}
@@ -37,19 +37,6 @@ fn build_omni_gen_mesh_models() -> Vec<OmniGenMeshModelDetails> {
..Default::default()
});
// Hunyuan 3D 2.1: image-to-3D only (exactly one input image).
models.push(OmniGenMeshModelDetails {
model: CommonMeshModel::Hunyuan3d2p1,
model_creator: Some(ModelCreator::Tencent),
full_name: Some("Hunyuan 3D 2.1".to_string()),
image_input_supported: Some(true),
mesh_output_types: Some(vec![
CommonMeshOutputType::Normal,
CommonMeshOutputType::Geometry,
]),
..Default::default()
});
// Hunyuan 3D 3: text and/or image input with multi-view support and full
// output shaping controls (output type, polygon type, face count, PBR).
models.push(OmniGenMeshModelDetails {
@@ -224,10 +211,6 @@ fn build_omni_gen_mesh_model_providers() -> Vec<OmniGenMeshModelProviderDetails>
model: CommonMeshModel::Hunyuan3d2p0,
overrides: None,
},
OmniGenMeshProviderModelDetails {
model: CommonMeshModel::Hunyuan3d2p1,
overrides: None,
},
OmniGenMeshProviderModelDetails {
model: CommonMeshModel::Hunyuan3d3,
overrides: None,
@@ -269,3 +252,22 @@ fn build_omni_gen_mesh_model_providers() -> Vec<OmniGenMeshModelProviderDetails>
providers
}
#[cfg(test)]
mod tests {
use super::*;
use crate::http_server::endpoints::omni_gen::shared_utils::mesh::resolve_mesh_model::{mesh_model_status, MeshModelStatus};
/// Replaced models (see `resolve_mesh_model`) must not be offered.
#[test]
fn only_available_models_are_listed() {
for details in build_omni_gen_mesh_models() {
assert_eq!(mesh_model_status(details.model), MeshModelStatus::Available, "{:?}", details.model);
}
for provider in build_omni_gen_mesh_model_providers() {
for details in provider.models {
assert_eq!(mesh_model_status(details.model), MeshModelStatus::Available, "{:?}", details.model);
}
}
}
}
@@ -7,26 +7,6 @@ use enums::common::generation::model_creator::ModelCreator;
pub fn kling_video_models() -> Vec<OmniGenVideoModelDetails> {
let mut models = Vec::new();
models.push(OmniGenVideoModelDetails {
model: CommonVideoModel::Kling16Pro,
model_creator: Some(ModelCreator::Kling),
full_name: Some("Kling 1.6 Pro".to_string()),
text_prompt_supported: Some(true),
starting_keyframe_supported: Some(true),
ending_keyframe_supported: Some(true),
image_references_supported: Some(true), // NB: 1.6 Elements!
image_references_max: Some(4), // NB: 1.6 Elements!
aspect_ratio_options: Some(vec![
CommonAspectRatio::WideSixteenByNine,
CommonAspectRatio::Square,
CommonAspectRatio::TallNineBySixteen,
]),
aspect_ratio_default: Some(CommonAspectRatio::WideSixteenByNine),
duration_seconds_options: Some(vec![5, 10]),
duration_seconds_default: Some(5),
..Default::default()
});
// TODO: Kling 2.5 doesn't let you control aspect ratio for image-to-video
models.push(OmniGenVideoModelDetails {
model: CommonVideoModel::Kling2p5TurboPro,
@@ -72,59 +52,6 @@ pub fn kling_video_models() -> Vec<OmniGenVideoModelDetails> {
pub fn kling_disabled_video_models() -> Vec<OmniGenVideoModelDetails> {
let mut models = Vec::new();
models.push(OmniGenVideoModelDetails {
is_disabled: Some(true), // TODO: Temporarily disable
model: CommonVideoModel::Kling16Pro,
model_creator: Some(ModelCreator::Kling),
full_name: Some("Kling 1.6 Pro".to_string()),
starting_keyframe_supported: Some(true),
ending_keyframe_supported: Some(true),
aspect_ratio_options: Some(vec![
CommonAspectRatio::Square,
CommonAspectRatio::WideSixteenByNine,
CommonAspectRatio::TallNineBySixteen,
]),
aspect_ratio_default: Some(CommonAspectRatio::WideSixteenByNine),
duration_seconds_options: Some(vec![5, 10]),
duration_seconds_default: Some(5),
..Default::default()
});
models.push(OmniGenVideoModelDetails {
is_disabled: Some(true), // TODO: Temporarily disable
model: CommonVideoModel::Kling21Pro,
model_creator: Some(ModelCreator::Kling),
full_name: Some("Kling 2.1 Pro".to_string()),
starting_keyframe_supported: Some(true),
ending_keyframe_supported: Some(true),
aspect_ratio_options: Some(vec![
CommonAspectRatio::Square,
CommonAspectRatio::WideSixteenByNine,
CommonAspectRatio::TallNineBySixteen,
]),
aspect_ratio_default: Some(CommonAspectRatio::WideSixteenByNine),
duration_seconds_options: Some(vec![5, 10]),
duration_seconds_default: Some(5),
..Default::default()
});
models.push(OmniGenVideoModelDetails {
is_disabled: Some(true), // TODO: Temporarily disable
model: CommonVideoModel::Kling21Master,
model_creator: Some(ModelCreator::Kling),
full_name: Some("Kling 2.1 Master".to_string()),
starting_keyframe_supported: Some(true),
aspect_ratio_options: Some(vec![
CommonAspectRatio::Square,
CommonAspectRatio::WideSixteenByNine,
CommonAspectRatio::TallNineBySixteen,
]),
aspect_ratio_default: Some(CommonAspectRatio::WideSixteenByNine),
duration_seconds_options: Some(vec![5, 10]),
duration_seconds_default: Some(5),
..Default::default()
});
models.push(OmniGenVideoModelDetails {
is_disabled: Some(true), // TODO: Temporarily disable
model: CommonVideoModel::Kling2p5TurboPro,
@@ -6,7 +6,6 @@ pub mod minimax_video_models;
pub mod seedance_1x_video_models;
pub mod seedance_2p0_video_models;
pub mod seedance_2p5_video_models;
pub mod sora_video_models;
pub mod veo_video_models;
pub mod vidu_video_models;
pub mod wan_video_models;
@@ -45,33 +45,5 @@ pub fn seedance_1p5_video_models() -> Vec<OmniGenVideoModelDetails> {
pub fn seedance_1p0_video_models() -> Vec<OmniGenVideoModelDetails> {
let mut models = Vec::new();
models.push(OmniGenVideoModelDetails {
is_disabled: Some(true), // TODO: Temporarily disable
model: CommonVideoModel::Seedance10Lite,
model_creator: Some(ModelCreator::Bytedance),
full_name: Some("Seedance 1.0 Lite".to_string()),
starting_keyframe_supported: Some(true),
ending_keyframe_supported: Some(true),
aspect_ratio_options: Some(vec![
CommonAspectRatio::Auto,
CommonAspectRatio::WideTwentyOneByNine,
CommonAspectRatio::WideSixteenByNine,
CommonAspectRatio::WideFourByThree,
CommonAspectRatio::Square,
CommonAspectRatio::TallThreeByFour,
CommonAspectRatio::TallNineBySixteen,
]),
aspect_ratio_default: Some(CommonAspectRatio::WideSixteenByNine),
resolution_options: Some(vec![
CommonResolution::FourEightyP,
CommonResolution::SevenTwentyP,
CommonResolution::TenEightyP,
]),
resolution_default: Some(CommonResolution::SevenTwentyP),
duration_seconds_options: Some(vec![5, 10]),
duration_seconds_default: Some(5),
..Default::default()
});
models
}
@@ -111,52 +111,6 @@ pub fn seedance_2p5_video_models() -> Vec<OmniGenVideoModelDetails> {
..Default::default()
});
models.push(OmniGenVideoModelDetails {
model: CommonVideoModel::Seedance2p5Preview,
model_creator: Some(ModelCreator::Bytedance),
full_name: Some("Seedance 2.5 Preview".to_string()),
extra_info: Some("A preview of Seedance 2.5. Supports videos up to 30 seconds and up to 30 reference images, but only reference mode (no start/end keyframes) and 480p/720p output.".to_string()),
extra_info_short: Some("Seedance 2.5 preview; up to 30s videos".to_string()),
text_prompt_supported: Some(true),
text_prompt_max_length: Some(10_000),
starting_keyframe_supported: Some(false),
ending_keyframe_supported: Some(false),
image_references_supported: Some(true),
image_references_max: Some(30),
audio_references_supported: Some(true),
audio_references_max: Some(10),
audio_references_max_total_duration_seconds: Some(30),
video_references_supported: Some(true),
video_references_max: Some(10),
video_references_max_total_duration_seconds: Some(30),
character_references_supported: Some(false),
aspect_ratio_options: Some(vec![
CommonAspectRatio::WideTwentyOneByNine,
CommonAspectRatio::WideSixteenByNine,
CommonAspectRatio::WideFourByThree,
CommonAspectRatio::Square,
CommonAspectRatio::TallThreeByFour,
CommonAspectRatio::TallNineBySixteen,
]),
aspect_ratio_default: Some(CommonAspectRatio::WideSixteenByNine),
resolution_options: Some(vec![
CommonResolution::FourEightyP,
CommonResolution::SevenTwentyP,
]),
resolution_default: Some(CommonResolution::SevenTwentyP),
bitrate_options: Some(vec![
CommonBitrate::Normal,
CommonBitrate::High,
]),
bitrate_default: Some(CommonBitrate::Normal),
batch_size_options: Some(vec![1]),
batch_size_default: Some(1),
duration_seconds_min: Some(4),
duration_seconds_max: Some(30),
duration_seconds_default: Some(5),
..Default::default()
});
models
}
@@ -1,53 +0,0 @@
use artcraft_api_defs::omni_gen::models::omni_gen_video_models::OmniGenVideoModelDetails;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_resolution::CommonResolution;
use enums::common::generation::common_video_model::CommonVideoModel;
use enums::common::generation::model_creator::ModelCreator;
/// Sora (OpenAI) video models.
pub fn sora_video_models() -> Vec<OmniGenVideoModelDetails> {
let mut models = Vec::new();
models.push(OmniGenVideoModelDetails {
is_disabled: Some(true), // TODO: Temporarily disable
model: CommonVideoModel::Sora2,
model_creator: Some(ModelCreator::OpenAi),
full_name: Some("Sora 2".to_string()),
text_prompt_supported: Some(true),
starting_keyframe_supported: Some(true),
aspect_ratio_options: Some(vec![
CommonAspectRatio::WideSixteenByNine,
CommonAspectRatio::TallNineBySixteen,
]),
aspect_ratio_default: Some(CommonAspectRatio::WideSixteenByNine),
resolution_options: Some(vec![CommonResolution::SevenTwentyP]),
resolution_default: Some(CommonResolution::SevenTwentyP),
duration_seconds_options: Some(vec![4, 8, 12]),
duration_seconds_default: Some(4),
..Default::default()
});
models.push(OmniGenVideoModelDetails {
is_disabled: Some(true), // TODO: Temporarily disable
model: CommonVideoModel::Sora2Pro,
model_creator: Some(ModelCreator::OpenAi),
full_name: Some("Sora 2 Pro".to_string()),
text_prompt_supported: Some(true),
starting_keyframe_supported: Some(true),
aspect_ratio_options: Some(vec![
CommonAspectRatio::WideSixteenByNine,
CommonAspectRatio::TallNineBySixteen,
]),
aspect_ratio_default: Some(CommonAspectRatio::WideSixteenByNine),
resolution_options: Some(vec![
CommonResolution::SevenTwentyP,
CommonResolution::TenEightyP,
]),
resolution_default: Some(CommonResolution::TenEightyP),
duration_seconds_options: Some(vec![4, 8, 12]),
duration_seconds_default: Some(4),
..Default::default()
});
models
}
@@ -8,71 +8,6 @@ use enums::common::generation::model_creator::ModelCreator;
pub fn veo_video_models() -> Vec<OmniGenVideoModelDetails> {
let mut models = Vec::new();
// TODO(bt,2026-04-10): Veo 2 image-to-video doesn't support aspect ratio
models.push(OmniGenVideoModelDetails {
is_disabled: Some(true), // TODO: Temporarily disable
model: CommonVideoModel::Veo2,
model_creator: Some(ModelCreator::Google),
full_name: Some("Veo 2".to_string()),
text_prompt_supported: Some(true),
starting_keyframe_supported: Some(true),
// TODO(bt,2026-04-10): Veo 2 image-to-video doesn't support aspect ratio
aspect_ratio_options: Some(vec![
CommonAspectRatio::Auto,
CommonAspectRatio::WideSixteenByNine,
CommonAspectRatio::TallNineBySixteen,
]),
aspect_ratio_default: Some(CommonAspectRatio::WideSixteenByNine),
duration_seconds_min: Some(5),
duration_seconds_max: Some(8),
duration_seconds_default: Some(5),
..Default::default()
});
models.push(OmniGenVideoModelDetails {
is_disabled: Some(true), // TODO: Temporarily disable
model: CommonVideoModel::Veo3,
model_creator: Some(ModelCreator::Google),
full_name: Some("Veo 3".to_string()),
text_prompt_supported: Some(true),
starting_keyframe_supported: Some(true),
show_generate_with_sound_toggle: Some(true),
aspect_ratio_options: Some(vec![
CommonAspectRatio::WideSixteenByNine,
CommonAspectRatio::TallNineBySixteen,
CommonAspectRatio::Auto, // TODO: Only for image-to-video
]),
// TODO: image-to-video aspect ratio options
aspect_ratio_default: Some(CommonAspectRatio::WideSixteenByNine),
resolution_options: Some(vec![
CommonResolution::SevenTwentyP,
CommonResolution::TenEightyP,
]),
resolution_default: Some(CommonResolution::TenEightyP),
duration_seconds_min: Some(4),
duration_seconds_max: Some(8),
duration_seconds_default: Some(8),
..Default::default()
});
models.push(OmniGenVideoModelDetails {
model: CommonVideoModel::Veo3Fast,
model_creator: Some(ModelCreator::Google),
full_name: Some("Veo 3 Fast".to_string()),
text_prompt_supported: Some(true),
starting_keyframe_supported: Some(true),
show_generate_with_sound_toggle: Some(true),
resolution_options: Some(vec![
CommonResolution::SevenTwentyP,
CommonResolution::TenEightyP,
]),
resolution_default: Some(CommonResolution::TenEightyP),
duration_seconds_min: Some(4),
duration_seconds_max: Some(8),
duration_seconds_default: Some(8),
..Default::default()
});
models.push(OmniGenVideoModelDetails {
model: CommonVideoModel::Veo3p1,
model_creator: Some(ModelCreator::Google),
@@ -97,8 +32,8 @@ pub fn veo_video_models() -> Vec<OmniGenVideoModelDetails> {
CommonResolution::FourK,
]),
resolution_default: Some(CommonResolution::TenEightyP),
duration_seconds_min: Some(4),
duration_seconds_max: Some(8),
// fal accepts only 4s/6s/8s (reference-to-video: 8s only; see the router's veo_3p1_common).
duration_seconds_options: Some(vec![4, 6, 8]),
duration_seconds_default: Some(8),
..Default::default()
});
@@ -127,8 +62,8 @@ pub fn veo_video_models() -> Vec<OmniGenVideoModelDetails> {
CommonResolution::FourK,
]),
resolution_default: Some(CommonResolution::TenEightyP),
duration_seconds_min: Some(4),
duration_seconds_max: Some(8),
// fal accepts only 4s/6s/8s (reference-to-video: 8s only; see the router's veo_3p1_common).
duration_seconds_options: Some(vec![4, 6, 8]),
duration_seconds_default: Some(8),
..Default::default()
});
@@ -152,8 +87,8 @@ pub fn veo_video_models() -> Vec<OmniGenVideoModelDetails> {
CommonResolution::TenEightyP,
]),
resolution_default: Some(CommonResolution::SevenTwentyP),
duration_seconds_min: Some(4),
duration_seconds_max: Some(8),
// fal accepts only 4s/6s/8s (reference-to-video: 8s only; see the router's veo_3p1_common).
duration_seconds_options: Some(vec![4, 6, 8]),
duration_seconds_default: Some(8),
..Default::default()
});
@@ -12,7 +12,6 @@ use super::by_type::minimax_video_models::minimax_video_models;
use super::by_type::seedance_1x_video_models::{seedance_1p0_video_models, seedance_1p5_video_models};
use super::by_type::seedance_2p0_video_models::seedance_2p0_video_models;
use super::by_type::seedance_2p5_video_models::seedance_2p5_video_models;
use super::by_type::sora_video_models::sora_video_models;
use super::by_type::veo_video_models::veo_video_models;
use super::by_type::vidu_video_models::vidu_video_models;
@@ -39,7 +38,6 @@ fn build_omni_gen_video_models() -> Vec<OmniGenVideoModelDetails> {
models.extend(seedance_1p5_video_models());
models.extend(seedance_2p0_video_models());
models.extend(seedance_2p5_video_models());
models.extend(sora_video_models());
models.extend(veo_video_models());
models.extend(vidu_video_models());
models.extend(wan_video_models());
@@ -63,30 +61,6 @@ fn build_omni_gen_video_model_providers() -> Vec<OmniGenVideoModelProviderDetail
model: CommonVideoModel::Seedance2p0,
overrides: None,
},
OmniGenVideoProviderModelDetails {
model: CommonVideoModel::Seedance10Lite,
overrides: None,
},
OmniGenVideoProviderModelDetails {
model: CommonVideoModel::Sora2,
overrides: None,
},
OmniGenVideoProviderModelDetails {
model: CommonVideoModel::Sora2Pro,
overrides: None,
},
OmniGenVideoProviderModelDetails {
model: CommonVideoModel::Veo2,
overrides: None,
},
OmniGenVideoProviderModelDetails {
model: CommonVideoModel::Veo3,
overrides: None,
},
OmniGenVideoProviderModelDetails {
model: CommonVideoModel::Veo3Fast,
overrides: None,
},
OmniGenVideoProviderModelDetails {
model: CommonVideoModel::Veo3p1,
overrides: None,
@@ -127,18 +101,6 @@ fn build_omni_gen_video_model_providers() -> Vec<OmniGenVideoModelProviderDetail
model: CommonVideoModel::Flux3Draft,
overrides: None,
},
OmniGenVideoProviderModelDetails {
model: CommonVideoModel::Kling16Pro,
overrides: None,
},
OmniGenVideoProviderModelDetails {
model: CommonVideoModel::Kling21Pro,
overrides: None,
},
OmniGenVideoProviderModelDetails {
model: CommonVideoModel::Kling21Master,
overrides: None,
},
OmniGenVideoProviderModelDetails {
model: CommonVideoModel::Kling2p5TurboPro,
overrides: None,
@@ -164,6 +126,20 @@ fn build_omni_gen_video_model_providers() -> Vec<OmniGenVideoModelProviderDetail
#[cfg(test)]
mod tests {
use super::*;
use crate::http_server::endpoints::omni_gen::shared_utils::video::resolve_video_model::{video_model_status, VideoModelStatus};
/// Retired and replaced models (see `resolve_video_model`) must not be offered.
#[test]
fn only_available_models_are_listed() {
for details in build_omni_gen_video_models() {
assert_eq!(video_model_status(details.model), VideoModelStatus::Available, "{:?}", details.model);
}
for provider in build_omni_gen_video_model_providers() {
for details in provider.models {
assert_eq!(video_model_status(details.model), VideoModelStatus::Available, "{:?}", details.model);
}
}
}
#[test]
fn kinovi_batch_options_are_one_through_four() {
@@ -15,6 +15,7 @@ use crate::http_server::endpoints::generate::common::payments_error_test::paymen
use crate::http_server::endpoints::omni_api::generate::video::check_request::check_request;
use crate::http_server::endpoints::omni_api::generate::video::ingest_url_inputs::ingest_url_inputs;
use crate::http_server::endpoints::omni_api::shared_utils::video::validate_video_request::validate_video_request;
use crate::http_server::endpoints::omni_gen::shared_utils::video::resolve_video_model::resolve_video_model;
use crate::http_server::endpoints::omni_gen::generate::video::shared_video_generation::{
run_authenticated_video_generation, VideoGenerationAuth,
};
@@ -49,6 +50,10 @@ pub async fn omni_api_video_generate_handler(
info!("request: {:?}", request);
// Retired models get a 400; replaced ones are rewritten (see helper) before
// anything prices, validates, or bills the request.
request.model = resolve_video_model(request.model)?;
// Validate URL/media-token preconditions before any billable or DB-mutating work.
check_request(&request)?;
@@ -9,6 +9,7 @@ use artcraft_router::api::router_provider::RouterProvider;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::endpoints::omni_gen::shared_utils::map_router_cost_error::map_router_cost_error;
use crate::http_server::endpoints::omni_gen::generate::mesh::helpers::hydrate_router_request::hydrate_to_router_request;
use crate::http_server::endpoints::omni_gen::shared_utils::mesh::resolve_mesh_model::resolve_mesh_model;
use crate::state::server_state::ServerState;
/// Estimate the cost of a mesh (3D object) generation.
@@ -25,13 +26,18 @@ use crate::state::server_state::ServerState;
)]
pub async fn omni_gen_mesh_cost_handler(
_http_request: HttpRequest,
request: Json<OmniGenMeshCostAndGenerateRequest>,
mut request: Json<OmniGenMeshCostAndGenerateRequest>,
_server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<OmniGenMeshCostResponse>, CommonWebError> {
// NB: Deliberately no input validation here. The UI polls this endpoint
// while the user is still composing the request (no prompt typed, nothing
// attached), and pricing is a total function of the model and options.
// Bad requests are rejected by the generate endpoint.
//
// The one exception: replaced models are priced as their replacement (the
// model generation will actually use).
request.model = resolve_mesh_model(request.model)?;
let mut builder = hydrate_to_router_request(&request)?;
builder.provider = RouterProvider::Artcraft; // NB: User is paying for ArtCraft credits / generation
@@ -16,6 +16,7 @@ use crate::http_server::endpoints::generate::common::probed_reference_videos::{
};
use crate::http_server::endpoints::omni_gen::shared_utils::map_router_cost_error::map_router_cost_error;
use crate::http_server::endpoints::omni_gen::generate::video::helpers::hydrate_router_request::hydrate_to_router_request;
use crate::http_server::endpoints::omni_gen::shared_utils::video::resolve_video_model::resolve_video_model;
use crate::state::server_state::ServerState;
/// Estimate the cost of a video generation.
@@ -32,13 +33,18 @@ use crate::state::server_state::ServerState;
)]
pub async fn omni_gen_video_cost_handler(
http_request: HttpRequest,
request: Json<OmniGenVideoCostAndGenerateRequest>,
mut request: Json<OmniGenVideoCostAndGenerateRequest>,
maybe_server_state: Option<web::Data<Arc<ServerState>>>,
) -> Result<Json<OmniGenVideoCostResponse>, CommonWebError> {
// NB: Deliberately no input validation here. The UI polls this endpoint
// while the user is still composing the request (no prompt typed, nothing
// attached), and pricing is a total function of the model and options.
// Bad requests are rejected by the generate endpoint.
//
// The one exception: retired models get a 400, and replaced ones are priced
// as their replacement (the model generation will actually use).
request.model = resolve_video_model(request.model)?;
let mut builder = hydrate_to_router_request(&request)?;
builder.provider = RouterProvider::Artcraft; // NB: User is paying for ArtCraft credits / generation
@@ -216,7 +222,6 @@ mod tests {
CommonVideoModel::Kling3p0Pro,
CommonVideoModel::MinimaxH3,
CommonVideoModel::Seedance2p0,
CommonVideoModel::Sora2,
CommonVideoModel::Veo3p1,
CommonVideoModel::ViduQ3,
] {
@@ -228,6 +233,68 @@ mod tests {
}
}
mod retired_and_replaced_model_tests {
use super::*;
#[tokio::test]
async fn sora_models_are_rejected_with_a_400() {
for model in [CommonVideoModel::Sora2, CommonVideoModel::Sora2Pro] {
let err = post_cost_request(base_request(model))
.await
.expect_err("retired model should be rejected");
assert_eq!(err.status_code(), StatusCode::BAD_REQUEST, "{:?}", model);
}
}
#[tokio::test]
async fn replaced_models_are_quoted_as_their_replacement() {
for (model, replacement) in [
(CommonVideoModel::Seedance2p5Preview, CommonVideoModel::Seedance2p5),
(CommonVideoModel::Veo3Fast, CommonVideoModel::Veo3p1Fast),
(CommonVideoModel::Veo3, CommonVideoModel::Veo3p1),
(CommonVideoModel::Kling21Master, CommonVideoModel::Kling2p6Pro),
] {
let replaced_quote = post_cost_request(base_request(model)).await.unwrap();
let replacement_quote = post_cost_request(base_request(replacement)).await.unwrap();
assert_eq!(replaced_quote.cost_in_credits, replacement_quote.cost_in_credits, "{:?}", model);
assert!(replaced_quote.cost_in_credits.unwrap() > 0, "{:?}", model);
}
}
}
mod veo_duration_tests {
use super::*;
/// fal's Veo 3.1 reference-to-video endpoint only generates 8s, so a
/// shorter request must be quoted (and billed) as the 8s it produces.
#[tokio::test]
async fn veo_3p1_reference_requests_are_quoted_at_8s() {
for model in [CommonVideoModel::Veo3p1, CommonVideoModel::Veo3p1Fast] {
let quote = |seconds| {
let mut request = base_request(model);
request.reference_image_media_tokens = Some(vec![MediaFileToken::new_from_str("m_reference")]);
request.duration_seconds = Some(seconds);
post_cost_request(request)
};
let four = quote(4).await.unwrap();
let eight = quote(8).await.unwrap();
assert_eq!(four.cost_in_credits, eight.cost_in_credits, "{:?}", model);
}
}
#[tokio::test]
async fn veo_3p1_text_to_video_quotes_by_duration() {
let quote = |seconds| {
let mut request = base_request(CommonVideoModel::Veo3p1Fast);
request.duration_seconds = Some(seconds);
post_cost_request(request)
};
let four = quote(4).await.unwrap().cost_in_credits.unwrap();
let eight = quote(8).await.unwrap().cost_in_credits.unwrap();
assert!(four < eight);
}
}
mod error_mapping_tests {
use super::*;
@@ -35,6 +35,7 @@ use crate::http_server::endpoints::omni_gen::generate::mesh::helpers::hydrate_ro
use crate::http_server::endpoints::omni_gen::generate::mesh::insert_db_job::insert_fal_job::{insert_fal_job, InsertFalJobArgs};
use crate::http_server::endpoints::omni_gen::generate::mesh::pipeline_v2::run_pipeline_v2::{run_pipeline_v2, RunPipelineV2Args};
use crate::http_server::endpoints::omni_gen::generate::video::insert_db_job::shared_job_args::SharedJobArgs;
use crate::http_server::endpoints::omni_gen::shared_utils::mesh::resolve_mesh_model::resolve_mesh_model;
use crate::http_server::endpoints::omni_gen::shared_utils::mesh::validate_mesh_request::validate_mesh_request;
use crate::http_server::user_lookup::api_or_web_session::require_any_session_or_key::{require_any_session_or_key, AnySessionType};
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
@@ -60,12 +61,16 @@ use crate::util::lookup::lookup_media_files_as_cdn_url_list_and_map::lookup_medi
)]
pub async fn omni_gen_mesh_generate_handler(
http_request: HttpRequest,
request: Json<OmniGenMeshCostAndGenerateRequest>,
mut request: Json<OmniGenMeshCostAndGenerateRequest>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<OmniGenMeshGenerateResponse>, CommonWebError> {
info!("request: {:?}", request);
// Replaced models are rewritten (see helper) before anything prices,
// validates, or bills the request.
request.model = resolve_mesh_model(request.model)?;
// Reject doomed combos (e.g. prompt-only hunyuan 2.x, sketch without a
// prompt) before any billable or DB-mutating work — see helper for the rules.
validate_mesh_request(&request)?;
@@ -13,6 +13,7 @@ use crate::http_server::endpoints::generate::common::payments_error_test::paymen
use crate::http_server::endpoints::omni_gen::generate::video::shared_video_generation::{
run_authenticated_video_generation, VideoGenerationAuth,
};
use crate::http_server::endpoints::omni_gen::shared_utils::video::resolve_video_model::resolve_video_model;
use crate::http_server::endpoints::omni_gen::shared_utils::video::validate_video_request::validate_video_request;
use crate::http_server::user_lookup::api_or_web_session::require_any_session_or_key::{require_any_session_or_key, AnySessionType};
use crate::http_server::web_utils::get_request_platform_type::get_request_platform_type;
@@ -40,12 +41,16 @@ use crate::state::server_state::ServerState;
)]
pub async fn omni_gen_video_generate_handler(
http_request: HttpRequest,
request: Json<OmniGenVideoCostAndGenerateRequest>,
mut request: Json<OmniGenVideoCostAndGenerateRequest>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<OmniGenVideoGenerateResponse>, CommonWebError> {
info!("request: {:?}", request);
// Retired models get a 400; replaced ones are rewritten (see helper) before
// anything prices, validates, or bills the request.
request.model = resolve_video_model(request.model)?;
// Reject doomed combos (e.g. grok_imagine_video_1p5 without an image)
// before any billable or DB-mutating work — see helper for the rules.
validate_video_request(&request)?;
@@ -1 +1,2 @@
pub mod resolve_mesh_model;
pub mod validate_mesh_request;
@@ -0,0 +1,58 @@
use enums::common::generation::common_mesh_model::CommonMeshModel;
use crate::http_server::common_responses::common_web_error::CommonWebError;
/// Whether a mesh model can still be generated, and how.
///
/// The `CommonMeshModel` variants below stay in the enum forever: they're
/// stored in historical database records. They're only removed from the model
/// list (`configs::omni_gen::mesh_models`) and handled here on requests.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum MeshModelStatus {
Available,
/// Fulfilled (and priced) as a different model.
ReplacedBy(CommonMeshModel),
}
/// Resolve the requested model before any pricing, validation, or billing:
/// replaced models are rewritten to their replacement. Call this first in
/// every mesh generate/cost handler.
///
/// (No mesh model is retired outright yet. When one is, add a `Retired`
/// status that returns a 400, like `resolve_video_model`.)
pub fn resolve_mesh_model(
maybe_model: Option<CommonMeshModel>,
) -> Result<Option<CommonMeshModel>, CommonWebError> {
Ok(maybe_model.map(|model| match mesh_model_status(model) {
MeshModelStatus::Available => model,
MeshModelStatus::ReplacedBy(replacement) => replacement,
}))
}
/// Replaced models were pulled by their upstream provider; fal marks their
/// endpoints `deprecated` (checked 2026-09-28 against fal's model API).
pub fn mesh_model_status(model: CommonMeshModel) -> MeshModelStatus {
match model {
// Hunyuan 3D 2.1 endpoint deprecated (2026-09-28). Hunyuan 3D 3 also
// takes a single input image.
CommonMeshModel::Hunyuan3d2p1 => MeshModelStatus::ReplacedBy(CommonMeshModel::Hunyuan3d3),
_ => MeshModelStatus::Available,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hunyuan_3d_2p1_is_replaced_by_hunyuan_3d_3() {
assert_eq!(resolve_mesh_model(Some(CommonMeshModel::Hunyuan3d2p1)).unwrap(), Some(CommonMeshModel::Hunyuan3d3));
assert_eq!(mesh_model_status(CommonMeshModel::Hunyuan3d3), MeshModelStatus::Available);
}
#[test]
fn available_and_missing_models_pass_through() {
assert_eq!(resolve_mesh_model(Some(CommonMeshModel::Hunyuan3d2p0)).unwrap(), Some(CommonMeshModel::Hunyuan3d2p0));
assert_eq!(resolve_mesh_model(None).unwrap(), None);
}
}
@@ -1,4 +1,5 @@
pub(crate) mod validate_kinovi_batch_count;
pub mod resolve_video_model;
mod validate_seedance_2p0_limits;
pub mod validate_video_request;
mod validate_when_image_required;
@@ -0,0 +1,127 @@
use enums::common::generation::common_video_model::CommonVideoModel;
use crate::http_server::common_responses::common_web_error::CommonWebError;
/// Whether a video model can still be generated, and how.
///
/// The `CommonVideoModel` variants below stay in the enum forever: they're
/// stored in historical database records. They're only removed from the model
/// list (`configs::omni_gen::video_models`) and handled here on requests.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum VideoModelStatus {
Available,
/// Fulfilled (and priced) as a different model.
ReplacedBy(CommonVideoModel),
/// No longer offered; requests get a 400.
Retired { display_name: &'static str },
}
/// Resolve the requested model before any pricing, validation, or billing:
/// replaced models are rewritten to their replacement, and retired models are
/// rejected with a 400. Call this first in every video generate/cost handler.
pub fn resolve_video_model(
maybe_model: Option<CommonVideoModel>,
) -> Result<Option<CommonVideoModel>, CommonWebError> {
let Some(model) = maybe_model else {
return Ok(None);
};
match video_model_status(model) {
VideoModelStatus::Available => Ok(Some(model)),
VideoModelStatus::ReplacedBy(replacement) => Ok(Some(replacement)),
VideoModelStatus::Retired { display_name } => Err(CommonWebError::BadInputWithSimpleMessage(
format!("{display_name} is no longer available. Please choose a different video model."))),
}
}
/// Retired models were pulled by their upstream provider; fal marks their
/// endpoints `deprecated` (checked 2026-09-28 against fal's model API).
/// Where a newer model in the same family is available, requests are upgraded
/// to it instead of failing.
pub fn video_model_status(model: CommonVideoModel) -> VideoModelStatus {
match model {
// Sora 2 is no longer available from the API (2026-09-28).
CommonVideoModel::Sora2 => VideoModelStatus::Retired { display_name: "Sora 2" },
CommonVideoModel::Sora2Pro => VideoModelStatus::Retired { display_name: "Sora 2 Pro" },
// Veo 2, Veo 3 and Veo 3 Fast endpoints deprecated (2026-09-28).
CommonVideoModel::Veo2 => VideoModelStatus::ReplacedBy(CommonVideoModel::Veo3p1),
CommonVideoModel::Veo3 => VideoModelStatus::ReplacedBy(CommonVideoModel::Veo3p1),
CommonVideoModel::Veo3Fast => VideoModelStatus::ReplacedBy(CommonVideoModel::Veo3p1Fast),
// Kling 1.6 Pro and 2.1 Pro/Master endpoints deprecated (2026-09-28).
CommonVideoModel::Kling16Pro => VideoModelStatus::ReplacedBy(CommonVideoModel::Kling2p5TurboPro),
CommonVideoModel::Kling21Pro => VideoModelStatus::ReplacedBy(CommonVideoModel::Kling2p5TurboPro),
CommonVideoModel::Kling21Master => VideoModelStatus::ReplacedBy(CommonVideoModel::Kling2p6Pro),
// Seedance 1.0 Lite endpoint deprecated (2026-09-28).
CommonVideoModel::Seedance10Lite => VideoModelStatus::ReplacedBy(CommonVideoModel::Seedance1p5Pro),
// Seedance 2.5 Preview was superseded by Seedance 2.5 (2026-09-28). It's
// fulfilled and priced as Seedance 2.5, which is cheaper.
CommonVideoModel::Seedance2p5Preview => VideoModelStatus::ReplacedBy(CommonVideoModel::Seedance2p5),
_ => VideoModelStatus::Available,
}
}
#[cfg(test)]
mod tests {
use super::*;
mod retired {
use super::*;
#[test]
fn sora_models_are_rejected_with_a_400() {
for (model, name) in [(CommonVideoModel::Sora2, "Sora 2"), (CommonVideoModel::Sora2Pro, "Sora 2 Pro")] {
match resolve_video_model(Some(model)) {
Err(CommonWebError::BadInputWithSimpleMessage(message)) => {
assert_eq!(message, format!("{name} is no longer available. Please choose a different video model."));
}
other => panic!("expected a 400 for {:?}, got {:?}", model, other),
}
}
}
}
mod replaced {
use super::*;
#[test]
fn replaced_models_resolve_to_their_replacement() {
for (model, replacement) in [
(CommonVideoModel::Veo2, CommonVideoModel::Veo3p1),
(CommonVideoModel::Veo3, CommonVideoModel::Veo3p1),
(CommonVideoModel::Veo3Fast, CommonVideoModel::Veo3p1Fast),
(CommonVideoModel::Kling16Pro, CommonVideoModel::Kling2p5TurboPro),
(CommonVideoModel::Kling21Pro, CommonVideoModel::Kling2p5TurboPro),
(CommonVideoModel::Kling21Master, CommonVideoModel::Kling2p6Pro),
(CommonVideoModel::Seedance10Lite, CommonVideoModel::Seedance1p5Pro),
(CommonVideoModel::Seedance2p5Preview, CommonVideoModel::Seedance2p5),
] {
assert_eq!(resolve_video_model(Some(model)).unwrap(), Some(replacement), "{model:?}");
}
}
#[test]
fn replacements_are_themselves_available() {
for model in [
CommonVideoModel::Veo2, CommonVideoModel::Veo3, CommonVideoModel::Veo3Fast,
CommonVideoModel::Kling16Pro, CommonVideoModel::Kling21Pro, CommonVideoModel::Kling21Master,
CommonVideoModel::Seedance10Lite, CommonVideoModel::Seedance2p5Preview,
] {
let VideoModelStatus::ReplacedBy(replacement) = video_model_status(model) else {
panic!("{:?} should be replaced", model);
};
assert_eq!(video_model_status(replacement), VideoModelStatus::Available, "{model:?} -> {replacement:?}");
}
}
}
#[test]
fn available_and_missing_models_pass_through() {
assert_eq!(resolve_video_model(Some(CommonVideoModel::Veo3p1Fast)).unwrap(), Some(CommonVideoModel::Veo3p1Fast));
assert_eq!(resolve_video_model(Some(CommonVideoModel::Seedance2p5)).unwrap(), Some(CommonVideoModel::Seedance2p5));
assert_eq!(resolve_video_model(None).unwrap(), None);
}
}
@@ -1158,11 +1158,6 @@ export const PromptBoxVideo = ({
case "grok_imagine_video":
request.grok_aspect_ratio = getGrokAspectRatio();
break;
case "sora_2":
request.sora_orientation =
resolution === "720p" ? "landscape" : "portrait";
break;
}
if (selectedModel.supportsCommonAspectRatio) {
@@ -195,65 +195,6 @@ const ALL_MODELS: ModelConfig[] = [
tags: [ModelTag.MaskedInpainting],
}),
//////////////////////////////
// Video models
//////////////////////////////
cfg({
id: "kling_1_6_pro",
category: "video",
info: {
name: "Kling 1.6 Pro",
tauri_id: "kling_1.6_pro",
creator: mc.Kling,
},
description: "Good quality model",
badges: [{ label: "2 min." }],
capabilities: { maxGenerationCount: 1 },
}),
cfg({
id: "kling_2_1_pro",
category: "video",
info: {
name: "Kling 2.1 Pro",
tauri_id: "kling_2.1_pro",
creator: mc.Kling,
},
description: "High quality model",
badges: [{ label: "2 min." }],
capabilities: { maxGenerationCount: 1 },
}),
cfg({
id: "kling_2_1_master",
category: "video",
info: {
name: "Kling 2.1 Master",
tauri_id: "kling_2.1_master",
creator: mc.Kling,
},
description: "Master quality model ($$)",
badges: [{ label: "2 min." }],
capabilities: { maxGenerationCount: 1 },
}),
cfg({
id: "seedance_1_0_lite",
category: "video",
info: {
name: "Seedance 1.0 Lite",
tauri_id: "seedance_1.0_lite",
creator: mc.Bytedance,
},
description: "Fast and high-quality model",
badges: [{ label: "2 min." }],
capabilities: { maxGenerationCount: 1 },
}),
cfg({
id: "veo_2",
category: "video",
info: { name: "Google Veo 2", tauri_id: "veo_2", creator: mc.Google },
description: "Fast and high-quality model",
badges: [{ label: "2 min." }],
capabilities: { maxGenerationCount: 1 },
}),
];
// export const ALL_MODELS_BY_ID: Map<string, ModelConfig> = new Map(
@@ -75,54 +75,6 @@ export const VIDEO_MODELS: VideoModel[] = [
},
],
}),
new VideoModel({
id: "kling_1_6_pro",
tauriId: "kling_1p6_pro",
fullName: "Kling 1.6 Pro",
category: "video",
creator: ModelCreator.Kling,
selectorName: "Kling 1.6 Pro",
selectorDescription: "Fast video model",
selectorBadges: ["2 min."],
startFrame: true,
endFrame: true,
requiresImage: true,
progressBarTime: 300000,
maxPromptLength: 2500,
generateWithSound: false,
}),
new VideoModel({
id: "kling_2_1_pro",
tauriId: "kling_2p1_pro",
fullName: "Kling 2.1 Pro",
category: "video",
creator: ModelCreator.Kling,
selectorName: "Kling 2.1 Pro",
selectorDescription: "Fast video model",
selectorBadges: ["2 min."],
startFrame: true,
endFrame: true,
requiresImage: true,
progressBarTime: 300000,
maxPromptLength: 2500,
generateWithSound: false,
}),
new VideoModel({
id: "kling_2_1_master",
tauriId: "kling_2p1_master",
fullName: "Kling 2.1 Master",
category: "video",
creator: ModelCreator.Kling,
selectorName: "Kling 2.1 Master",
selectorDescription: "Fast video model",
selectorBadges: ["2 min."],
startFrame: true,
endFrame: false,
requiresImage: true,
progressBarTime: 300000,
maxPromptLength: 2500,
generateWithSound: false,
}),
new VideoModel({
id: "kling_2p5_turbo_pro",
tauriId: "kling_2p5_turbo_pro",
@@ -274,21 +226,6 @@ export const VIDEO_MODELS: VideoModel[] = [
maxPromptLength: 2500,
generateWithSound: true,
}),
new VideoModel({
id: "seedance_1_0_lite",
tauriId: "seedance_1p0_lite",
fullName: "Seedance 1.0 Lite",
category: "video",
creator: ModelCreator.Bytedance,
selectorName: "Seedance 1.0 Lite",
selectorDescription: "Fast video model",
selectorBadges: ["2 min."],
startFrame: true,
endFrame: false,
requiresImage: true,
progressBarTime: 100000,
maxPromptLength: 3000,
}),
new VideoModel({
id: "seedance_1p5_pro",
tauriId: "seedance_1p5_pro",
@@ -460,109 +397,6 @@ export const VIDEO_MODELS: VideoModel[] = [
progressBarTime: 300000, // ~5 minutes
maxPromptLength: Infinity,
}),
new VideoModel({
id: "sora_2",
tauriId: "sora_2",
fullName: "Sora 2",
category: "video",
creator: ModelCreator.OpenAi,
providers: [GenerationProvider.Artcraft],
selectorName: "Sora 2",
selectorDescription: "Smart video model",
selectorBadges: ["2 min."],
startFrame: false,
endFrame: false,
requiresImage: false,
sizeOptions: [
{
tauriValue: "landscape",
textLabel: "Landscape",
icon: SizeIconOption.Landscape,
},
{
tauriValue: "portrait",
textLabel: "Portrait",
icon: SizeIconOption.Portrait,
},
],
progressBarTime: 100000,
maxPromptLength: 2000,
}),
new VideoModel({
id: "sora_2_pro",
tauriId: "sora_2_pro",
fullName: "Sora 2 Pro",
category: "video",
creator: ModelCreator.OpenAi,
selectorName: "Sora 2 Pro",
selectorDescription: "Smart video model",
selectorBadges: ["2 min."],
startFrame: false,
endFrame: false,
requiresImage: false,
sizeOptions: [
{
tauriValue: "landscape",
textLabel: "Landscape",
icon: SizeIconOption.Landscape,
},
{
tauriValue: "portrait",
textLabel: "Portrait",
icon: SizeIconOption.Portrait,
},
],
progressBarTime: 100000,
maxPromptLength: 2000,
}),
new VideoModel({
id: "veo_2",
tauriId: "veo_2",
fullName: "Google Veo 2",
category: "video",
creator: ModelCreator.Google,
selectorName: "Google Veo 2",
selectorDescription: "Fast video model",
selectorBadges: ["2 min."],
startFrame: true,
endFrame: false,
requiresImage: true,
generateWithSound: false,
progressBarTime: 100000,
maxPromptLength: 5000,
}),
new VideoModel({
id: "veo_3",
tauriId: "veo_3",
fullName: "Google Veo 3",
category: "video",
creator: ModelCreator.Google,
selectorName: "Google Veo 3",
selectorDescription: "Slow, high-quality model",
selectorBadges: ["2 min."],
startFrame: true,
endFrame: false,
requiresImage: true,
generateWithSound: true,
progressBarTime: 100000,
maxPromptLength: 5000,
}),
new VideoModel({
id: "veo_3_fast",
tauriId: "veo_3_fast",
fullName: "Google Veo 3 Fast",
category: "video",
creator: ModelCreator.Google,
selectorName: "Google Veo 3 Fast",
selectorDescription: "High-quality model (faster)",
selectorBadges: ["2 min."],
startFrame: true,
endFrame: false,
requiresImage: true,
generateWithSound: true,
progressBarTime: 100000,
maxPromptLength: 5000,
}),
new VideoModel({
id: "veo_3p1",
tauriId: "veo_3p1",
@@ -19,7 +19,6 @@ describe("desktop catalog", () => {
const ids = [
"seedance_2p5",
"seedance_2p5_u",
"seedance_2p5_preview",
"seedance_2p0_mini",
"flux_3",
"minimax_h3",
@@ -34,13 +33,13 @@ describe("desktop catalog", () => {
});
it("retains offered disabled entries and excludes the MiniMax admin variants", () => {
const ids = ["sora_2", "minimax_h3_turbo", "minimax_h3_ultra"];
const ids = ["kling_3p0_pro", "minimax_h3_turbo", "minimax_h3_ultra"];
const models = buildVideoModelsFromListing(
[],
ids.map((model) => ({ model, is_disabled: true })),
ids,
);
expect(models.map((model) => model.tauriId)).toEqual(["sora_2"]);
expect(models.map((model) => model.tauriId)).toEqual(["kling_3p0_pro"]);
});
it("preserves future image options and leaves editor eligibility unchanged", () => {
@@ -72,16 +71,16 @@ describe("desktop catalog", () => {
}
});
it("keeps Sora and Marble models available through ArtCraft after hydration", () => {
it("keeps GPT Image, Veo and Marble models available through ArtCraft after hydration", () => {
const images = buildImageModelsFromListing(IMAGE_MODELS, [
{ model: "gpt_image_1", image_refs_supported: true },
]);
const videos = buildVideoModelsFromListing(VIDEO_MODELS, [
{ model: "sora_2", duration_seconds_options: [4, 8, 12] },
{ model: "veo_3p1", duration_seconds_options: [4, 6, 8] },
]);
expect(images.find((model) => model.tauriId === "gpt_image_1")?.getProviders())
.toEqual([GenerationProvider.Artcraft]);
expect(videos.find((model) => model.tauriId === "sora_2")?.getProviders())
expect(videos.find((model) => model.tauriId === "veo_3p1")?.getProviders())
.toEqual([GenerationProvider.Artcraft]);
expect(SPLAT_MODELS.map((model) => model.tauriId)).toEqual([
"marble_0p1_mini", "marble_0p1_plus",