more omni gen video models, client bindings, tests (#1365)

This commit is contained in:
Brandon Thomas
2026-04-14 04:48:59 -04:00
committed by GitHub
parent 2bbeb3de78
commit 7e36149d13
43 changed files with 2036 additions and 259 deletions
Generated
+1
View File
@@ -770,6 +770,7 @@ dependencies = [
"anyhow",
"artcraft_api_defs",
"artcraft_client",
"enums",
"fal_client",
"log",
"muapi_client",
@@ -16,7 +16,8 @@ pub struct GenerateVeo2ImageToVideoRequest {
/// Optional text prompt.
pub prompt: Option<String>,
/// **NB(bt): ASPECT RATIO IS ACTUALLY NOT USED IN VEO 2 IMAGE-TO-VIDEO**
/// Optional: aspect ratio of the generated video.
pub aspect_ratio: Option<GenerateVeo2AspectRatio>,
@@ -21,6 +21,9 @@ pub struct GenerateVeo3FastImageToVideoRequest {
pub resolution: Option<GenerateVeo3FastResolution>,
/// Optional: aspect ratio of the generated video.
pub aspect_ratio: Option<GenerateVeo3FastAspectRatio>,
/// Optional: duration of the generated video.
pub duration: Option<GenerateVeo3FastDuration>,
/// Optional: Generate an audio track along with the video
@@ -34,6 +37,14 @@ pub enum GenerateVeo3FastResolution {
TenEightyP, // 1080p
}
#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum GenerateVeo3FastAspectRatio {
Auto,
SixteenByNine,
NineBySixteen,
}
#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum GenerateVeo3FastDuration {
@@ -42,6 +42,10 @@ pub struct GenerateVeo3ImageToVideoRequest {
pub enum GenerateVeo3AspectRatio {
WideSixteenNine, // 16:9
TallNineSixteen, // 9:16
/// NB: DO NOT REMOVE. This is an unsupported resolution for Veo 3, but we can't
/// impact existing callers by removing it. The handler will convert this to a
/// supported resolution or 400 the request instead.
Square, // 1:1
}
@@ -120,6 +120,9 @@ pub struct OmniGenImageModelDetails {
#[serde(skip_serializing_if = "Option::is_none")]
pub batch_size_default: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
pub is_disabled: Option<bool>,
}
impl Default for OmniGenImageModelDetails {
@@ -145,6 +148,7 @@ impl Default for OmniGenImageModelDetails {
batch_size_max: None,
batch_size_options: None,
batch_size_default: None,
is_disabled: None,
}
}
}
@@ -163,6 +163,9 @@ pub struct OmniGenVideoModelDetails {
#[serde(skip_serializing_if = "Option::is_none")]
pub batch_size_default: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
pub is_disabled: Option<bool>,
}
impl Default for OmniGenVideoModelDetails {
@@ -202,6 +205,7 @@ impl Default for OmniGenVideoModelDetails {
batch_size_max: None,
batch_size_options: None,
batch_size_default: None,
is_disabled: None,
}
}
}
@@ -2,6 +2,7 @@ pub mod analytics;
pub mod credits;
pub mod generate;
pub mod jobs;
pub mod omni_gen;
pub mod media_files;
pub mod prompts;
pub mod stripe_artcraft;
@@ -0,0 +1 @@
pub mod video;
@@ -0,0 +1 @@
pub mod omni_gen_video;
@@ -0,0 +1,21 @@
use crate::credentials::storyteller_credential_set::StorytellerCredentialSet;
use crate::error::storyteller_error::StorytellerError;
use crate::utils::api_host::ApiHost;
use crate::utils::basic_json_post_request::basic_json_post_request;
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use artcraft_api_defs::omni_gen::generate_response::omni_gen_video_generate_response::OmniGenVideoGenerateResponse;
pub const OMNI_GEN_VIDEO_GENERATE_PATH: &str = "/v1/omni_gen/generate/video";
pub async fn omni_gen_video_generate(
api_host: &ApiHost,
maybe_creds: Option<&StorytellerCredentialSet>,
request: OmniGenVideoCostAndGenerateRequest,
) -> Result<OmniGenVideoGenerateResponse, StorytellerError> {
Ok(basic_json_post_request(
api_host,
OMNI_GEN_VIDEO_GENERATE_PATH,
maybe_creds,
request,
).await?)
}
@@ -0,0 +1 @@
pub mod generate;
@@ -17,6 +17,7 @@ workspace-build-acceleration.workspace = true
# Internal
artcraft_api_defs.workspace = true
enums.workspace = true
artcraft_client.workspace = true
fal_client.workspace = true
muapi_client.workspace = true
@@ -6,31 +6,72 @@ use crate::generate::generate_video::generate_video_response::{
};
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_veo_2::PlanArtcraftVeo2;
use artcraft_api_defs::generate::video::generate_veo_2_image_to_video::GenerateVeo2ImageToVideoRequest;
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use artcraft_client::endpoints::generate::video::generate_veo_2_image_to_video::generate_veo_2_image_to_video;
use artcraft_client::endpoints::omni_gen::generate::video::omni_gen_video::omni_gen_video_generate;
use enums::common::generation::common_video_model::CommonVideoModel;
pub async fn execute_artcraft_veo_2(
plan: &PlanArtcraftVeo2<'_>,
artcraft_client: &RouterArtcraftClient,
) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let request = GenerateVeo2ImageToVideoRequest {
uuid_idempotency_token: plan.idempotency_token.clone(),
media_file_token: Some(plan.start_frame.to_owned()),
prompt: plan.prompt.map(|p| p.to_string()),
aspect_ratio: plan.aspect_ratio.clone(),
duration: plan.duration.clone(),
let inference_job_token = match plan.start_frame {
Some(media_file_token) => {
// Image-to-video: use the legacy dedicated endpoint.
let request = GenerateVeo2ImageToVideoRequest {
uuid_idempotency_token: plan.idempotency_token.clone(),
media_file_token: Some(media_file_token.to_owned()),
prompt: plan.prompt.map(|p| p.to_string()),
aspect_ratio: plan.aspect_ratio.clone(),
duration: plan.duration.clone(),
};
let response = generate_veo_2_image_to_video(
&artcraft_client.api_host,
Some(&artcraft_client.credentials),
request,
)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Storyteller(err)))?;
response.inference_job_token
}
None => {
// Text-to-video: use the omni-gen unified endpoint.
let request = OmniGenVideoCostAndGenerateRequest {
idempotency_token: Some(plan.idempotency_token.clone()),
model: Some(CommonVideoModel::Veo2),
prompt: plan.prompt.map(|p| p.to_string()),
negative_prompt: None,
start_frame_image_media_token: None,
end_frame_image_media_token: None,
reference_image_media_tokens: None,
reference_video_media_tokens: None,
reference_audio_media_tokens: None,
reference_character_tokens: None,
resolution: None,
aspect_ratio: None,
quality: None,
duration_seconds: None,
video_batch_count: None,
generate_audio: None,
};
let response = omni_gen_video_generate(
&artcraft_client.api_host,
Some(&artcraft_client.credentials),
request,
)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Storyteller(err)))?;
response.inference_job_token
}
};
let response = generate_veo_2_image_to_video(
&artcraft_client.api_host,
Some(&artcraft_client.credentials),
request,
)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Storyteller(err)))?;
let all_tokens = vec![response.inference_job_token.clone()];
let all_tokens = vec![inference_job_token.clone()];
Ok(GenerateVideoResponse::Artcraft(ArtcraftVideoResponsePayload {
inference_job_token: response.inference_job_token,
inference_job_token,
all_inference_job_tokens: all_tokens,
}))
}
@@ -6,33 +6,74 @@ use crate::generate::generate_video::generate_video_response::{
};
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_veo_3::PlanArtcraftVeo3;
use artcraft_api_defs::generate::video::generate_veo_3_image_to_video::GenerateVeo3ImageToVideoRequest;
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use artcraft_client::endpoints::generate::video::generate_veo_3_image_to_video::generate_veo_3_image_to_video;
use artcraft_client::endpoints::omni_gen::generate::video::omni_gen_video::omni_gen_video_generate;
use enums::common::generation::common_video_model::CommonVideoModel;
pub async fn execute_artcraft_veo_3(
plan: &PlanArtcraftVeo3<'_>,
artcraft_client: &RouterArtcraftClient,
) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let request = GenerateVeo3ImageToVideoRequest {
uuid_idempotency_token: plan.idempotency_token.clone(),
media_file_token: Some(plan.start_frame.to_owned()),
prompt: plan.prompt.map(|p| p.to_string()),
aspect_ratio: plan.aspect_ratio,
resolution: plan.resolution,
duration: plan.duration,
generate_audio: plan.generate_audio,
let inference_job_token = match plan.start_frame {
Some(media_file_token) => {
// Image-to-video: use the legacy dedicated endpoint.
let request = GenerateVeo3ImageToVideoRequest {
uuid_idempotency_token: plan.idempotency_token.clone(),
media_file_token: Some(media_file_token.to_owned()),
prompt: plan.prompt.map(|p| p.to_string()),
aspect_ratio: plan.aspect_ratio,
resolution: plan.resolution,
duration: plan.duration,
generate_audio: plan.generate_audio,
};
let response = generate_veo_3_image_to_video(
&artcraft_client.api_host,
Some(&artcraft_client.credentials),
request,
)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Storyteller(err)))?;
response.inference_job_token
}
None => {
// Text-to-video: use the omni-gen unified endpoint.
let request = OmniGenVideoCostAndGenerateRequest {
idempotency_token: Some(plan.idempotency_token.clone()),
model: Some(CommonVideoModel::Veo3),
prompt: plan.prompt.map(|p| p.to_string()),
negative_prompt: None,
start_frame_image_media_token: None,
end_frame_image_media_token: None,
reference_image_media_tokens: None,
reference_video_media_tokens: None,
reference_audio_media_tokens: None,
reference_character_tokens: None,
resolution: None,
aspect_ratio: None,
quality: None,
duration_seconds: None,
video_batch_count: None,
generate_audio: plan.generate_audio,
};
let response = omni_gen_video_generate(
&artcraft_client.api_host,
Some(&artcraft_client.credentials),
request,
)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Storyteller(err)))?;
response.inference_job_token
}
};
let response = generate_veo_3_image_to_video(
&artcraft_client.api_host,
Some(&artcraft_client.credentials),
request,
)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Storyteller(err)))?;
let all_tokens = vec![response.inference_job_token.clone()];
let all_tokens = vec![inference_job_token.clone()];
Ok(GenerateVideoResponse::Artcraft(ArtcraftVideoResponsePayload {
inference_job_token: response.inference_job_token,
inference_job_token,
all_inference_job_tokens: all_tokens,
}))
}
@@ -14,8 +14,9 @@ pub async fn execute_artcraft_veo_3_fast(
) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let request = GenerateVeo3FastImageToVideoRequest {
uuid_idempotency_token: plan.idempotency_token.clone(),
media_file_token: Some(plan.start_frame.to_owned()),
media_file_token: plan.start_frame.map(|t| t.to_owned()),
prompt: plan.prompt.map(|p| p.to_string()),
aspect_ratio: plan.aspect_ratio,
resolution: plan.resolution,
duration: plan.duration,
generate_audio: plan.generate_audio,
@@ -4,28 +4,50 @@ use crate::errors::provider_error::ProviderError;
use crate::generate::generate_video::generate_video_response::{
FalVideoResponsePayload, GenerateVideoResponse,
};
use crate::generate::generate_video::plan::fal::plan_generate_video_fal_veo_2::PlanFalVeo2;
use crate::generate::generate_video::plan::fal::plan_generate_video_fal_veo_2::{
FalVeo2Mode, PlanFalVeo2,
};
use fal_client::requests::webhook::video::image::enqueue_veo_2_image_to_video_webhook::{
enqueue_veo_2_image_to_video_webhook, Veo2Args,
enqueue_veo_2_image_to_video_webhook, Veo2Args, Veo2AspectRatio,
};
use fal_client::requests::webhook::video::text::enqueue_veo_2_text_to_video_webhook::{
enqueue_veo_2_text_to_video_webhook, Veo2TextToVideoArgs,
};
pub async fn execute_fal_veo_2(
plan: &PlanFalVeo2,
fal_client: &RouterFalClient,
) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let args = Veo2Args {
image_url: plan.start_frame_url.as_str(),
prompt: plan.prompt.as_str(),
api_key: &fal_client.api_key,
duration: plan.duration,
aspect_ratio: plan.aspect_ratio,
webhook_url: fal_client.webhook_url.as_str(),
let webhook_response = match &plan.mode {
FalVeo2Mode::TextToVideo => {
let args = Veo2TextToVideoArgs {
prompt: plan.prompt.as_str(),
negative_prompt: plan.negative_prompt.as_deref(),
api_key: &fal_client.api_key,
duration: plan.duration,
aspect_ratio: plan.aspect_ratio.unwrap_or(Veo2AspectRatio::Auto),
webhook_url: fal_client.webhook_url.as_str(),
};
enqueue_veo_2_text_to_video_webhook(args)
.await
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?
}
FalVeo2Mode::ImageToVideo { image_url } => {
// Image-to-video does not support aspect_ratio — the output
// inherits the source image's aspect ratio.
let args = Veo2Args {
image_url: image_url.as_str(),
prompt: plan.prompt.as_str(),
api_key: &fal_client.api_key,
duration: plan.duration,
webhook_url: fal_client.webhook_url.as_str(),
};
enqueue_veo_2_image_to_video_webhook(args)
.await
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?
}
};
let webhook_response = enqueue_veo_2_image_to_video_webhook(args)
.await
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?;
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
request_id: webhook_response.request_id,
gateway_request_id: webhook_response.gateway_request_id,
@@ -4,30 +4,106 @@ use crate::errors::provider_error::ProviderError;
use crate::generate::generate_video::generate_video_response::{
FalVideoResponsePayload, GenerateVideoResponse,
};
use crate::generate::generate_video::plan::fal::plan_generate_video_fal_veo_3::PlanFalVeo3;
use fal_client::requests::webhook::video::image::enqueue_veo_3_image_to_video_webhook::{
enqueue_veo_3_image_to_video_webhook, Veo3Args,
use crate::generate::generate_video::plan::fal::plan_generate_video_fal_veo_3::{
FalVeo3Duration, FalVeo3I2vAspectRatio, FalVeo3Mode, FalVeo3Resolution,
FalVeo3T2vAspectRatio, PlanFalVeo3,
};
use fal_client::requests::webhook::video::image::enqueue_veo_3_image_to_video_webhook::{
enqueue_veo_3_image_to_video_webhook, Veo3Args, Veo3I2vAspectRatio, Veo3I2vDuration,
Veo3I2vResolution,
};
use fal_client::requests::webhook::video::text::enqueue_veo_3_text_to_video_webhook::{
enqueue_veo_3_text_to_video_webhook, Veo3T2vAspectRatio, Veo3T2vDuration, Veo3T2vResolution,
Veo3TextToVideoArgs,
};
fn to_i2v_duration(d: FalVeo3Duration) -> Veo3I2vDuration {
match d {
FalVeo3Duration::Default => Veo3I2vDuration::Default,
FalVeo3Duration::FourSeconds => Veo3I2vDuration::FourSeconds,
FalVeo3Duration::SixSeconds => Veo3I2vDuration::SixSeconds,
FalVeo3Duration::EightSeconds => Veo3I2vDuration::EightSeconds,
}
}
fn to_t2v_duration(d: FalVeo3Duration) -> Veo3T2vDuration {
match d {
FalVeo3Duration::Default => Veo3T2vDuration::Default,
FalVeo3Duration::FourSeconds => Veo3T2vDuration::FourSeconds,
FalVeo3Duration::SixSeconds => Veo3T2vDuration::SixSeconds,
FalVeo3Duration::EightSeconds => Veo3T2vDuration::EightSeconds,
}
}
fn to_i2v_resolution(r: FalVeo3Resolution) -> Veo3I2vResolution {
match r {
FalVeo3Resolution::Default => Veo3I2vResolution::Default,
FalVeo3Resolution::SevenTwentyP => Veo3I2vResolution::SevenTwentyP,
FalVeo3Resolution::TenEightyP => Veo3I2vResolution::TenEightyP,
}
}
fn to_t2v_resolution(r: FalVeo3Resolution) -> Veo3T2vResolution {
match r {
FalVeo3Resolution::Default => Veo3T2vResolution::Default,
FalVeo3Resolution::SevenTwentyP => Veo3T2vResolution::SevenTwentyP,
FalVeo3Resolution::TenEightyP => Veo3T2vResolution::TenEightyP,
}
}
fn to_t2v_aspect_ratio(ar: FalVeo3T2vAspectRatio) -> Veo3T2vAspectRatio {
match ar {
FalVeo3T2vAspectRatio::Default => Veo3T2vAspectRatio::Default,
FalVeo3T2vAspectRatio::WideSixteenNine => Veo3T2vAspectRatio::WideSixteenNine,
FalVeo3T2vAspectRatio::TallNineSixteen => Veo3T2vAspectRatio::TallNineSixteen,
}
}
fn to_i2v_aspect_ratio(ar: FalVeo3I2vAspectRatio) -> Veo3I2vAspectRatio {
match ar {
FalVeo3I2vAspectRatio::Auto => Veo3I2vAspectRatio::Auto,
FalVeo3I2vAspectRatio::WideSixteenNine => Veo3I2vAspectRatio::WideSixteenNine,
FalVeo3I2vAspectRatio::TallNineSixteen => Veo3I2vAspectRatio::TallNineSixteen,
}
}
pub async fn execute_fal_veo_3(
plan: &PlanFalVeo3,
fal_client: &RouterFalClient,
) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let args = Veo3Args {
image_url: plan.start_frame_url.as_str(),
prompt: plan.prompt.as_str(),
duration: plan.duration,
aspect_ratio: plan.aspect_ratio,
resolution: plan.resolution,
generate_audio: plan.generate_audio,
api_key: &fal_client.api_key,
webhook_url: fal_client.webhook_url.as_str(),
let webhook_response = match &plan.mode {
FalVeo3Mode::TextToVideo => {
let args = Veo3TextToVideoArgs {
prompt: plan.prompt.as_str(),
negative_prompt: plan.negative_prompt.as_deref(),
api_key: &fal_client.api_key,
duration: to_t2v_duration(plan.duration),
aspect_ratio: plan.t2v_aspect_ratio.map(to_t2v_aspect_ratio).unwrap_or(Veo3T2vAspectRatio::Default),
resolution: to_t2v_resolution(plan.resolution),
generate_audio: plan.generate_audio,
webhook_url: fal_client.webhook_url.as_str(),
};
enqueue_veo_3_text_to_video_webhook(args)
.await
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?
}
FalVeo3Mode::ImageToVideo { image_url } => {
let args = Veo3Args {
image_url: image_url.as_str(),
prompt: plan.prompt.as_str(),
duration: to_i2v_duration(plan.duration),
aspect_ratio: plan.i2v_aspect_ratio.map(to_i2v_aspect_ratio).unwrap_or(Veo3I2vAspectRatio::Auto),
resolution: to_i2v_resolution(plan.resolution),
generate_audio: plan.generate_audio,
api_key: &fal_client.api_key,
webhook_url: fal_client.webhook_url.as_str(),
};
enqueue_veo_3_image_to_video_webhook(args)
.await
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?
}
};
let webhook_response = enqueue_veo_3_image_to_video_webhook(args)
.await
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?;
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
request_id: webhook_response.request_id,
gateway_request_id: webhook_response.gateway_request_id,
@@ -16,6 +16,7 @@ pub async fn execute_fal_veo_3_fast(
let args = Veo3FastArgs {
prompt: plan.prompt.as_str(),
image_url: plan.start_frame_url.as_str(),
aspect_ratio: plan.aspect_ratio,
duration: plan.duration,
api_key: &fal_client.api_key,
resolution: plan.resolution,
@@ -13,7 +13,10 @@ use tokens::tokens::media_files::MediaFileToken;
#[derive(Debug, Clone)]
pub struct PlanArtcraftVeo2<'a> {
pub prompt: Option<&'a str>,
pub start_frame: &'a MediaFileToken,
/// Required for execution. May be None on the cost path when the omni-gen
/// distillation hydrated the media token to a URL (which the Artcraft
/// provider can't use but cost estimation doesn't need).
pub start_frame: Option<&'a MediaFileToken>,
pub aspect_ratio: Option<GenerateVeo2AspectRatio>,
pub duration: Option<GenerateVeo2Duration>,
pub idempotency_token: String,
@@ -25,16 +28,13 @@ pub fn plan_generate_video_artcraft_veo_2<'a>(
let strategy = request.request_mismatch_mitigation_strategy;
let start_frame = match request.start_frame {
Some(ImageRef::MediaFileToken(t)) => t,
Some(ImageRef::Url(_)) => {
return Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
}
None => {
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "start_frame",
value: "Veo 2 requires a starting frame".to_string(),
}))
}
Some(ImageRef::MediaFileToken(t)) => Some(t),
// Omni-gen distillation hydrates media tokens to URLs before running the
// Artcraft cost path. Cost only depends on duration, so URL-form refs are
// accepted and dropped.
Some(ImageRef::Url(_)) => None,
// No start_frame = text-to-video mode.
None => None,
};
if request.end_frame.is_some() {
@@ -44,7 +44,14 @@ pub fn plan_generate_video_artcraft_veo_2<'a>(
}));
}
let aspect_ratio = plan_aspect_ratio(request.aspect_ratio, strategy)?;
// Aspect ratio only applies to text-to-video; image-to-video inherits
// the source frame's aspect ratio.
let is_image_mode = request.start_frame.is_some();
let aspect_ratio = if is_image_mode {
None
} else {
plan_aspect_ratio(request.aspect_ratio, strategy)?
};
let duration = plan_duration(request.duration_seconds, strategy)?;
Ok(VideoGenerationPlan::ArtcraftVeo2(PlanArtcraftVeo2 {
@@ -14,10 +14,16 @@ use tokens::tokens::media_files::MediaFileToken;
#[derive(Debug, Clone)]
pub struct PlanArtcraftVeo3<'a> {
pub prompt: Option<&'a str>,
pub start_frame: &'a MediaFileToken,
/// Required for image-to-video. None for text-to-video or when the
/// omni-gen distillation hydrated the token to a URL.
pub start_frame: Option<&'a MediaFileToken>,
pub aspect_ratio: Option<GenerateVeo3AspectRatio>,
pub resolution: Option<GenerateVeo3Resolution>,
pub duration: Option<GenerateVeo3Duration>,
/// The raw requested duration in seconds, preserved for cost estimation.
/// The `duration` field above maps everything to `EightSeconds` for the
/// legacy endpoint, but cost should reflect the actual requested value.
pub duration_seconds_raw: Option<u16>,
pub generate_audio: Option<bool>,
pub idempotency_token: String,
}
@@ -28,16 +34,13 @@ pub fn plan_generate_video_artcraft_veo_3<'a>(
let strategy = request.request_mismatch_mitigation_strategy;
let start_frame = match request.start_frame {
Some(ImageRef::MediaFileToken(t)) => t,
Some(ImageRef::Url(_)) => {
return Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
}
None => {
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "start_frame",
value: "Veo 3 requires a starting frame".to_string(),
}))
}
Some(ImageRef::MediaFileToken(t)) => Some(t),
// Omni-gen distillation hydrates media tokens to URLs before running the
// Artcraft cost path. Cost only depends on duration/audio, so URL-form
// refs are accepted and dropped.
Some(ImageRef::Url(_)) => None,
// No start_frame = text-to-video mode.
None => None,
};
if request.end_frame.is_some() {
@@ -47,7 +50,14 @@ pub fn plan_generate_video_artcraft_veo_3<'a>(
}));
}
let aspect_ratio = plan_aspect_ratio(request.aspect_ratio, strategy)?;
// Aspect ratio only applies to text-to-video; image-to-video inherits
// the source frame's aspect ratio.
let is_image_mode = request.start_frame.is_some();
let aspect_ratio = if is_image_mode {
None
} else {
plan_aspect_ratio(request.aspect_ratio, strategy)?
};
let resolution = plan_resolution(request.resolution, strategy)?;
let duration = plan_duration(request.duration_seconds, strategy)?;
@@ -57,6 +67,7 @@ pub fn plan_generate_video_artcraft_veo_3<'a>(
aspect_ratio,
resolution,
duration,
duration_seconds_raw: request.duration_seconds,
generate_audio: request.generate_audio,
idempotency_token: request.get_or_generate_idempotency_token(),
}))
@@ -112,14 +123,19 @@ fn plan_resolution(
}
}
// Legacy storyteller endpoint for Veo 3 only exposes EightSeconds.
// The legacy storyteller endpoint for Veo 3 only exposes EightSeconds, but
// the artcraft plan stores the raw duration_seconds from the omni request so
// that cost estimation works for all supported durations (4/6/8).
fn plan_duration(
duration_seconds: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<GenerateVeo3Duration>, ArtcraftRouterError> {
match duration_seconds {
None => Ok(None),
Some(8) => Ok(Some(GenerateVeo3Duration::EightSeconds)),
// The api_defs enum only has EightSeconds; all durations map to it for
// the legacy endpoint. The actual seconds are tracked via
// `duration_seconds_raw` below for cost estimation.
Some(4) | Some(6) | Some(8) => Ok(Some(GenerateVeo3Duration::EightSeconds)),
Some(other) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
@@ -127,18 +143,25 @@ fn plan_duration(
value: format!("{}", other),
}))
}
_ => Ok(Some(GenerateVeo3Duration::EightSeconds)),
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Some(GenerateVeo3Duration::EightSeconds)),
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(GenerateVeo3Duration::EightSeconds)),
},
}
}
impl PlanArtcraftVeo3<'_> {
/// Mirrors the Fal client default (8s) used by the legacy generate handler.
/// Duration in seconds for cost estimation. Uses the raw requested
/// duration, clamped to {4, 6, 8}, defaulting to 8s.
pub fn duration_seconds_for_cost(&self) -> u64 {
8
match self.duration_seconds_raw {
Some(s) if s <= 4 => 4,
Some(s) if s <= 6 => 6,
_ => 8, // None, 7, 8, or above → 8s
}
}
/// Legacy Veo 3 generate handler defaults `generate_audio` to false.
/// The omni path defaults to true (matching the fal plan builder).
pub fn generate_audio_for_cost(&self) -> bool {
self.generate_audio.unwrap_or(false)
}
@@ -1,3 +1,4 @@
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_resolution::CommonResolution;
use crate::api::image_ref::ImageRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
@@ -6,14 +7,15 @@ use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use artcraft_api_defs::generate::video::generate_veo_3_fast_image_to_video::{
GenerateVeo3FastDuration, GenerateVeo3FastResolution,
GenerateVeo3FastAspectRatio, GenerateVeo3FastDuration, GenerateVeo3FastResolution,
};
use tokens::tokens::media_files::MediaFileToken;
#[derive(Debug, Clone)]
pub struct PlanArtcraftVeo3Fast<'a> {
pub prompt: Option<&'a str>,
pub start_frame: &'a MediaFileToken,
pub start_frame: Option<&'a MediaFileToken>,
pub aspect_ratio: Option<GenerateVeo3FastAspectRatio>,
pub resolution: Option<GenerateVeo3FastResolution>,
pub duration: Option<GenerateVeo3FastDuration>,
pub generate_audio: Option<bool>,
@@ -26,16 +28,9 @@ pub fn plan_generate_video_artcraft_veo_3_fast<'a>(
let strategy = request.request_mismatch_mitigation_strategy;
let start_frame = match request.start_frame {
Some(ImageRef::MediaFileToken(t)) => t,
Some(ImageRef::Url(_)) => {
return Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
}
None => {
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "start_frame",
value: "Veo 3 Fast requires a starting frame".to_string(),
}))
}
Some(ImageRef::MediaFileToken(t)) => Some(t),
Some(ImageRef::Url(_)) => None,
None => None,
};
if request.end_frame.is_some() {
@@ -45,12 +40,14 @@ pub fn plan_generate_video_artcraft_veo_3_fast<'a>(
}));
}
let aspect_ratio = plan_aspect_ratio(request.aspect_ratio, strategy)?;
let resolution = plan_resolution(request.resolution, strategy)?;
let duration = plan_duration(request.duration_seconds, strategy)?;
Ok(VideoGenerationPlan::ArtcraftVeo3Fast(PlanArtcraftVeo3Fast {
prompt: request.prompt,
start_frame,
aspect_ratio,
resolution,
duration,
generate_audio: request.generate_audio,
@@ -58,6 +55,33 @@ pub fn plan_generate_video_artcraft_veo_3_fast<'a>(
}))
}
fn plan_aspect_ratio(
aspect_ratio: Option<CommonAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<GenerateVeo3FastAspectRatio>, ArtcraftRouterError> {
use GenerateVeo3FastAspectRatio as Ar;
match aspect_ratio {
None
| Some(CommonAspectRatio::Auto)
| Some(CommonAspectRatio::Auto2k)
| Some(CommonAspectRatio::Auto3k)
| Some(CommonAspectRatio::Auto4k) => Ok(Some(Ar::Auto)),
Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => Ok(Some(Ar::SixteenByNine)),
Some(CommonAspectRatio::TallNineBySixteen) | Some(CommonAspectRatio::Tall) => Ok(Some(Ar::NineBySixteen)),
Some(unsupported) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "aspect_ratio",
value: format!("{:?}", unsupported),
}))
}
_ => Ok(Some(Ar::Auto)),
},
}
}
fn plan_resolution(
resolution: Option<CommonResolution>,
strategy: RequestMismatchMitigationStrategy,
@@ -9,11 +9,22 @@ use fal_client::requests::webhook::video::image::enqueue_veo_2_image_to_video_we
Veo2AspectRatio, Veo2Duration,
};
#[derive(Debug, Clone)]
pub enum FalVeo2Mode {
TextToVideo,
ImageToVideo {
image_url: String,
},
}
#[derive(Debug, Clone)]
pub struct PlanFalVeo2 {
pub prompt: String,
pub start_frame_url: String,
pub aspect_ratio: Veo2AspectRatio,
pub negative_prompt: Option<String>,
pub mode: FalVeo2Mode,
/// Only set for text-to-video. Image-to-video inherits the source frame's
/// aspect ratio and doesn't accept this parameter.
pub aspect_ratio: Option<Veo2AspectRatio>,
pub duration: Veo2Duration,
}
@@ -22,35 +33,37 @@ pub fn plan_generate_video_fal_veo_2<'a>(
) -> Result<VideoGenerationPlan<'a>, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
let start_frame_url = resolve_required_image_url(request.start_frame, "start_frame")?;
if request.end_frame.is_some() {
return Err(unsupported("end_frame", "Veo 2 does not support an ending frame"));
}
let aspect_ratio = plan_aspect_ratio(request.aspect_ratio, strategy)?;
let mode = match request.start_frame {
Some(ImageRef::Url(url)) => FalVeo2Mode::ImageToVideo {
image_url: url.to_string(),
},
Some(ImageRef::MediaFileToken(_)) => {
return Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls));
}
None => FalVeo2Mode::TextToVideo,
};
// Aspect ratio only applies to text-to-video; image-to-video inherits
// the source frame's aspect ratio.
let aspect_ratio = match &mode {
FalVeo2Mode::TextToVideo => Some(plan_aspect_ratio(request.aspect_ratio, strategy)?),
FalVeo2Mode::ImageToVideo { .. } => None,
};
let duration = plan_duration(request.duration_seconds, strategy)?;
Ok(VideoGenerationPlan::FalVeo2(PlanFalVeo2 {
prompt: request.prompt.unwrap_or("").to_string(),
start_frame_url,
negative_prompt: request.negative_prompt.map(|s| s.to_string()),
mode,
aspect_ratio,
duration,
}))
}
fn resolve_required_image_url(
image_ref: Option<ImageRef<'_>>,
field: &'static str,
) -> Result<String, ArtcraftRouterError> {
match image_ref {
Some(ImageRef::Url(url)) => Ok(url.to_string()),
Some(ImageRef::MediaFileToken(_)) => {
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
}
None => Err(unsupported(field, "Veo 2 requires a starting frame")),
}
}
fn plan_aspect_ratio(
aspect_ratio: Option<CommonAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
@@ -60,6 +73,7 @@ fn plan_aspect_ratio(
None
| Some(CommonAspectRatio::Auto)
| Some(CommonAspectRatio::Auto2k)
| Some(CommonAspectRatio::Auto3k)
| Some(CommonAspectRatio::Auto4k) => Ok(Ar::Auto),
Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => Ok(Ar::WideSixteenNine),
@@ -7,16 +7,65 @@ use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use fal_client::requests::webhook::video::image::enqueue_veo_3_image_to_video_webhook::{
Veo3AspectRatio, Veo3Duration, Veo3Resolution,
Veo3I2vAspectRatio, Veo3I2vDuration, Veo3I2vResolution,
};
use fal_client::requests::webhook::video::text::enqueue_veo_3_text_to_video_webhook::{
Veo3T2vAspectRatio, Veo3T2vDuration, Veo3T2vResolution,
};
#[derive(Debug, Clone)]
pub enum FalVeo3Mode {
TextToVideo,
ImageToVideo {
image_url: String,
},
}
/// Router-level duration shared between both modes.
#[derive(Debug, Clone, Copy)]
pub enum FalVeo3Duration {
Default,
FourSeconds,
SixSeconds,
EightSeconds,
}
/// Router-level resolution shared between both modes.
#[derive(Debug, Clone, Copy)]
pub enum FalVeo3Resolution {
Default,
SevenTwentyP,
TenEightyP,
}
/// Router-level aspect ratio for text-to-video only (no Auto, no Square).
#[derive(Debug, Clone, Copy)]
pub enum FalVeo3T2vAspectRatio {
Default,
WideSixteenNine,
TallNineSixteen,
}
/// Router-level aspect ratio for image-to-video only (has Auto, no Square).
#[derive(Debug, Clone, Copy)]
pub enum FalVeo3I2vAspectRatio {
Auto,
WideSixteenNine,
TallNineSixteen,
}
#[derive(Debug, Clone)]
pub struct PlanFalVeo3 {
pub prompt: String,
pub start_frame_url: String,
pub aspect_ratio: Veo3AspectRatio,
pub resolution: Veo3Resolution,
pub duration: Veo3Duration,
pub negative_prompt: Option<String>,
pub mode: FalVeo3Mode,
/// Only set for text-to-video. Image-to-video inherits the source frame's
/// aspect ratio and uses `i2v_aspect_ratio` instead.
pub t2v_aspect_ratio: Option<FalVeo3T2vAspectRatio>,
/// Only set for image-to-video.
pub i2v_aspect_ratio: Option<FalVeo3I2vAspectRatio>,
pub resolution: FalVeo3Resolution,
pub duration: FalVeo3Duration,
pub generate_audio: bool,
}
@@ -25,58 +74,88 @@ pub fn plan_generate_video_fal_veo_3<'a>(
) -> Result<VideoGenerationPlan<'a>, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
let start_frame_url = resolve_required_image_url(request.start_frame)?;
if request.end_frame.is_some() {
return Err(unsupported("end_frame", "Veo 3 does not support an ending frame"));
}
let aspect_ratio = plan_aspect_ratio(request.aspect_ratio, strategy)?;
let mode = match request.start_frame {
Some(ImageRef::Url(url)) => FalVeo3Mode::ImageToVideo {
image_url: url.to_string(),
},
Some(ImageRef::MediaFileToken(_)) => {
return Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls));
}
None => FalVeo3Mode::TextToVideo,
};
let (t2v_aspect_ratio, i2v_aspect_ratio) = match &mode {
FalVeo3Mode::TextToVideo => {
(Some(plan_t2v_aspect_ratio(request.aspect_ratio, strategy)?), None)
}
FalVeo3Mode::ImageToVideo { .. } => {
(None, Some(plan_i2v_aspect_ratio(request.aspect_ratio, strategy)?))
}
};
let resolution = plan_resolution(request.resolution, strategy)?;
let duration = plan_duration(request.duration_seconds, strategy)?;
let generate_audio = request.generate_audio.unwrap_or(true);
Ok(VideoGenerationPlan::FalVeo3(PlanFalVeo3 {
prompt: request.prompt.unwrap_or("").to_string(),
start_frame_url,
aspect_ratio,
negative_prompt: request.negative_prompt.map(|s| s.to_string()),
mode,
t2v_aspect_ratio,
i2v_aspect_ratio,
resolution,
duration,
generate_audio,
}))
}
fn resolve_required_image_url(
image_ref: Option<ImageRef<'_>>,
) -> Result<String, ArtcraftRouterError> {
match image_ref {
Some(ImageRef::Url(url)) => Ok(url.to_string()),
Some(ImageRef::MediaFileToken(_)) => {
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
}
None => Err(unsupported("start_frame", "Veo 3 requires a starting frame")),
/// Text-to-video: only 16:9 and 9:16 (no Auto, no Square).
fn plan_t2v_aspect_ratio(
aspect_ratio: Option<CommonAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<FalVeo3T2vAspectRatio, ArtcraftRouterError> {
use FalVeo3T2vAspectRatio as Ar;
match aspect_ratio {
None => Ok(Ar::Default),
Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => Ok(Ar::WideSixteenNine),
Some(CommonAspectRatio::TallNineBySixteen) | Some(CommonAspectRatio::Tall) => Ok(Ar::TallNineSixteen),
// Everything else (Auto*, Square, unsupported) → Default (16:9).
Some(_) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(unsupported("aspect_ratio", &format!("{:?}", aspect_ratio.unwrap())))
}
_ => Ok(Ar::Default),
},
}
}
fn plan_aspect_ratio(
/// Image-to-video: Auto, 16:9, 9:16 (no Square).
fn plan_i2v_aspect_ratio(
aspect_ratio: Option<CommonAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Veo3AspectRatio, ArtcraftRouterError> {
use Veo3AspectRatio as Ar;
) -> Result<FalVeo3I2vAspectRatio, ArtcraftRouterError> {
use FalVeo3I2vAspectRatio as Ar;
match aspect_ratio {
None
| Some(CommonAspectRatio::Auto)
| Some(CommonAspectRatio::Auto2k)
| Some(CommonAspectRatio::Auto4k) => Ok(Ar::Default),
| Some(CommonAspectRatio::Auto3k)
| Some(CommonAspectRatio::Auto4k) => Ok(Ar::Auto),
Some(CommonAspectRatio::Square) | Some(CommonAspectRatio::SquareHd) => Ok(Ar::Square),
Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => Ok(Ar::WideSixteenNine),
Some(CommonAspectRatio::TallNineBySixteen) | Some(CommonAspectRatio::Tall) => Ok(Ar::TallNineSixteen),
Some(unsupported_ar) => match strategy {
// Everything else (Square, unsupported) → Auto.
Some(_) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(unsupported("aspect_ratio", &format!("{:?}", unsupported_ar)))
Err(unsupported("aspect_ratio", &format!("{:?}", aspect_ratio.unwrap())))
}
_ => Ok(Ar::Default),
_ => Ok(Ar::Auto),
},
}
}
@@ -84,8 +163,8 @@ fn plan_aspect_ratio(
fn plan_resolution(
resolution: Option<CommonResolution>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Veo3Resolution, ArtcraftRouterError> {
use Veo3Resolution as R;
) -> Result<FalVeo3Resolution, ArtcraftRouterError> {
use FalVeo3Resolution as R;
match resolution {
None => Ok(R::Default),
Some(CommonResolution::SevenTwentyP) => Ok(R::SevenTwentyP),
@@ -103,18 +182,18 @@ fn plan_resolution(
fn plan_duration(
duration_seconds: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Veo3Duration, ArtcraftRouterError> {
) -> Result<FalVeo3Duration, ArtcraftRouterError> {
match duration_seconds {
None => Ok(Veo3Duration::Default),
Some(4) => Ok(Veo3Duration::FourSeconds),
Some(6) => Ok(Veo3Duration::SixSeconds),
Some(8) => Ok(Veo3Duration::EightSeconds),
None => Ok(FalVeo3Duration::Default),
Some(4) => Ok(FalVeo3Duration::FourSeconds),
Some(6) => Ok(FalVeo3Duration::SixSeconds),
Some(8) => Ok(FalVeo3Duration::EightSeconds),
Some(other) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(unsupported("duration_seconds", &format!("{}", other)))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Veo3Duration::EightSeconds),
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Veo3Duration::FourSeconds),
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(FalVeo3Duration::EightSeconds),
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(FalVeo3Duration::FourSeconds),
},
}
}
@@ -129,9 +208,9 @@ fn unsupported(field: &'static str, value: &str) -> ArtcraftRouterError {
impl PlanFalVeo3 {
pub fn duration_seconds_for_cost(&self) -> u64 {
match self.duration {
Veo3Duration::Default | Veo3Duration::EightSeconds => 8,
Veo3Duration::SixSeconds => 6,
Veo3Duration::FourSeconds => 4,
FalVeo3Duration::Default | FalVeo3Duration::EightSeconds => 8,
FalVeo3Duration::SixSeconds => 6,
FalVeo3Duration::FourSeconds => 4,
}
}
}
@@ -5,14 +5,16 @@ use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request::GenerateVideoRequest;
use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use crate::api::common_aspect_ratio::CommonAspectRatio;
use fal_client::requests::webhook::video::image::enqueue_veo_3_fast_image_to_video_webhook::{
Veo3FastDuration, Veo3FastResolution,
Veo3FastAspectRatio, Veo3FastDuration, Veo3FastResolution,
};
#[derive(Debug, Clone)]
pub struct PlanFalVeo3Fast {
pub prompt: String,
pub start_frame_url: String,
pub aspect_ratio: Veo3FastAspectRatio,
pub resolution: Veo3FastResolution,
pub duration: Veo3FastDuration,
pub generate_audio: bool,
@@ -28,6 +30,7 @@ pub fn plan_generate_video_fal_veo_3_fast<'a>(
return Err(unsupported("end_frame", "Veo 3 Fast does not support an ending frame"));
}
let aspect_ratio = plan_aspect_ratio(request.aspect_ratio, strategy)?;
let resolution = plan_resolution(request.resolution, strategy)?;
let duration = plan_duration(request.duration_seconds, strategy)?;
let generate_audio = request.generate_audio.unwrap_or(true);
@@ -35,6 +38,7 @@ pub fn plan_generate_video_fal_veo_3_fast<'a>(
Ok(VideoGenerationPlan::FalVeo3Fast(PlanFalVeo3Fast {
prompt: request.prompt.unwrap_or("").to_string(),
start_frame_url,
aspect_ratio,
resolution,
duration,
generate_audio,
@@ -53,6 +57,30 @@ fn resolve_required_image_url(
}
}
fn plan_aspect_ratio(
aspect_ratio: Option<CommonAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Veo3FastAspectRatio, ArtcraftRouterError> {
use Veo3FastAspectRatio as Ar;
match aspect_ratio {
None
| Some(CommonAspectRatio::Auto)
| Some(CommonAspectRatio::Auto2k)
| Some(CommonAspectRatio::Auto3k)
| Some(CommonAspectRatio::Auto4k) => Ok(Ar::Auto),
Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => Ok(Ar::WideSixteenNine),
Some(CommonAspectRatio::TallNineBySixteen) | Some(CommonAspectRatio::Tall) => Ok(Ar::TallNineSixteen),
Some(unsupported_ar) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(unsupported("aspect_ratio", &format!("{:?}", unsupported_ar)))
}
_ => Ok(Ar::Auto),
},
}
}
fn plan_resolution(
resolution: Option<CommonResolution>,
strategy: RequestMismatchMitigationStrategy,
@@ -7,10 +7,6 @@ pub struct Veo2ImageToVideoInput {
pub prompt: String,
/// Options: "auto", "16:9", "9:16"
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
/// Options: "4s", "6s", "8s"
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<String>,
@@ -7,6 +7,10 @@ pub struct Veo3FastImageToVideoInput {
pub prompt: String,
/// Options: "auto", "16:9", "9:16"
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
/// Options: "720p", "1080p"
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<String>,
@@ -0,0 +1,34 @@
use crate::requests::api::fal_request::FalRequest;
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Veo2TextToVideoInput {
pub prompt: String,
/// Options: "16:9", "9:16", "1:1"
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
/// Options: "5s", "6s", "7s", "8s"
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub negative_prompt: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo2TextToVideoVideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo2TextToVideoOutput {
pub video: Veo2TextToVideoVideoFile,
}
pub fn veo_2_text_to_video(
params: Veo2TextToVideoInput,
) -> FalRequest<Veo2TextToVideoInput, Veo2TextToVideoOutput> {
FalRequest::new("fal-ai/veo2", params)
}
@@ -0,0 +1,41 @@
use crate::requests::api::fal_request::FalRequest;
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Veo3TextToVideoInput {
pub prompt: String,
/// Options: "16:9", "9:16"
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
/// Options: "720p", "1080p"
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<String>,
/// Options: "4s", "6s", "8s"
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub generate_audio: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub negative_prompt: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo3TextToVideoVideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo3TextToVideoOutput {
pub video: Veo3TextToVideoVideoFile,
}
pub fn veo_3_text_to_video(
params: Veo3TextToVideoInput,
) -> FalRequest<Veo3TextToVideoInput, Veo3TextToVideoOutput> {
FalRequest::new("fal-ai/veo3", params)
}
@@ -7,5 +7,7 @@ pub mod http_pixverse_v5_text_to_video;
pub mod http_seedance_1p5_pro_text_to_video;
pub mod http_sora_2_pro_text_to_video;
pub mod http_sora_2_text_to_video;
pub mod http_veo_2_text_to_video;
pub mod http_veo_3_text_to_video;
pub mod http_veo_3p1_fast_text_to_video;
pub mod http_veo_3p1_text_to_video;
@@ -1,21 +1,22 @@
use crate::creds::fal_api_key::FalApiKey;
use crate::error::classify_fal_error::classify_fal_error;
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
use crate::requests::http::video::image::http_veo_2_image_to_video::{veo_2_image_to_video, Veo2ImageToVideoInput};
use crate::requests::api::webhook_response::WebhookResponse;
use crate::requests::http::video::image::http_veo_2_image_to_video::{veo_2_image_to_video, Veo2ImageToVideoInput};
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
use reqwest::IntoUrl;
/// Args for Veo 2 image-to-video. Note: image-to-video does NOT support
/// aspect_ratio — the output inherits the source image's aspect ratio.
pub struct Veo2Args<'a, U: IntoUrl, V: IntoUrl> {
pub image_url: U,
pub webhook_url: V,
pub prompt: &'a str,
pub api_key: &'a FalApiKey,
pub duration: Veo2Duration,
pub aspect_ratio: Veo2AspectRatio,
}
#[derive(Copy, Clone, Debug)]
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum Veo2Duration {
Default, // NB: Default is 5 seconds
FiveSeconds,
@@ -24,6 +25,9 @@ pub enum Veo2Duration {
EightSeconds,
}
/// Aspect ratio enum — used by the text-to-video endpoint only.
/// Kept here because it's the canonical location that the text-to-video
/// webhook and the router plan both re-export from.
#[derive(Copy, Clone, Debug)]
pub enum Veo2AspectRatio {
Auto,
@@ -59,22 +63,13 @@ pub async fn enqueue_veo_2_image_to_video_webhook<U: IntoUrl, V: IntoUrl>(
Veo2Duration::SevenSeconds => Some("7s".to_string()),
Veo2Duration::EightSeconds => Some("8s".to_string()),
};
let aspect_ratio = match args.aspect_ratio {
Veo2AspectRatio::Auto => Some("auto".to_string()),
Veo2AspectRatio::AutoPreferPortrait => Some("auto_prefer_portrait".to_string()),
Veo2AspectRatio::WideSixteenNine => Some("16:9".to_string()),
Veo2AspectRatio::TallNineSixteen => Some("9:16".to_string()),
};
let image_url = args.image_url.as_str().to_string();
let request = Veo2ImageToVideoInput {
image_url,
prompt: args.prompt.to_string(),
aspect_ratio,
duration,
// Maybe expose these later
};
let result = veo_2_image_to_video(request)
@@ -89,26 +84,24 @@ pub async fn enqueue_veo_2_image_to_video_webhook<U: IntoUrl, V: IntoUrl>(
#[cfg(test)]
mod tests {
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::webhook::video::image::enqueue_veo_2_image_to_video_webhook::{enqueue_veo_2_image_to_video_webhook, Veo2Args, Veo2AspectRatio, Veo2Duration};
use crate::requests::webhook::video::image::enqueue_veo_2_image_to_video_webhook::{enqueue_veo_2_image_to_video_webhook, Veo2Args, Veo2Duration};
use errors::AnyhowResult;
use std::fs::read_to_string;
use test_data::web::image_urls::MOUNTAIN_TREE_IMAGE_URL;
#[tokio::test]
#[ignore]
async fn test_kling21_pro_video() -> AnyhowResult<()> {
let image_url = "https://cdn-2.fakeyou.com/media/3/4/h/f/s/34hfsmt8e38rvne6mwa4pwbxr6292sgy/image_34hfsmt8e38rvne6mwa4pwbxr6292sgy.png";
async fn test_veo_2_image_to_video() -> AnyhowResult<()> {
// XXX: Don't commit secrets!
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let args = Veo2Args {
image_url: image_url,
image_url: MOUNTAIN_TREE_IMAGE_URL,
prompt: "a shot of the mountains as the sun sets and reveals the moon and stars",
api_key: &api_key,
duration: Veo2Duration::Default,
aspect_ratio: Veo2AspectRatio::WideSixteenNine,
webhook_url: "https://example.com/webhook",
};
@@ -9,6 +9,7 @@ use reqwest::IntoUrl;
pub struct Veo3FastArgs<'a, U: IntoUrl, V: IntoUrl> {
pub prompt: &'a str,
pub image_url: U,
pub aspect_ratio: Veo3FastAspectRatio,
pub duration: Veo3FastDuration,
pub api_key: &'a FalApiKey,
pub resolution: Veo3FastResolution,
@@ -24,6 +25,13 @@ pub enum Veo3FastDuration {
EightSeconds,
}
#[derive(Copy, Clone, Debug)]
pub enum Veo3FastAspectRatio {
Auto,
WideSixteenNine, // 16:9
TallNineSixteen, // 9:16
}
#[derive(Copy, Clone, Debug)]
pub enum Veo3FastResolution {
Default,
@@ -62,7 +70,13 @@ pub async fn enqueue_veo_3_fast_image_to_video_webhook<U: IntoUrl, V: IntoUrl>(
Veo3FastDuration::EightSeconds => Some("8s".to_string()),
};
let resolution= match args.resolution {
let aspect_ratio = match args.aspect_ratio {
Veo3FastAspectRatio::Auto => Some("auto".to_string()),
Veo3FastAspectRatio::WideSixteenNine => Some("16:9".to_string()),
Veo3FastAspectRatio::TallNineSixteen => Some("9:16".to_string()),
};
let resolution = match args.resolution {
Veo3FastResolution::Default => None,
Veo3FastResolution::SevenTwentyP => Some("720p".to_string()),
Veo3FastResolution::TenEightyP => Some("1080p".to_string()),
@@ -73,6 +87,7 @@ pub async fn enqueue_veo_3_fast_image_to_video_webhook<U: IntoUrl, V: IntoUrl>(
let request = Veo3FastImageToVideoInput {
image_url,
prompt: args.prompt.to_string(),
aspect_ratio,
resolution,
duration,
generate_audio: Some(args.generate_audio),
@@ -90,7 +105,7 @@ pub async fn enqueue_veo_3_fast_image_to_video_webhook<U: IntoUrl, V: IntoUrl>(
#[cfg(test)]
mod tests {
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::webhook::video::image::enqueue_veo_3_fast_image_to_video_webhook::{enqueue_veo_3_fast_image_to_video_webhook, Veo3FastArgs, Veo3FastDuration, Veo3FastResolution};
use crate::requests::webhook::video::image::enqueue_veo_3_fast_image_to_video_webhook::{enqueue_veo_3_fast_image_to_video_webhook, Veo3FastArgs, Veo3FastAspectRatio, Veo3FastDuration, Veo3FastResolution};
use errors::AnyhowResult;
use std::fs::read_to_string;
use test_data::web::image_urls::ERNEST_GHOST_TREX_IMAGE_URL;
@@ -109,6 +124,7 @@ mod tests {
image_url: image_url,
prompt: "man is standing next to a ghost and t-rex, they begin to chase him as the camera pulls back to show the wider scene",
api_key: &api_key,
aspect_ratio: Veo3FastAspectRatio::WideSixteenNine,
duration: Veo3FastDuration::EightSeconds,
generate_audio: true,
resolution: Veo3FastResolution::TenEightyP,
@@ -9,32 +9,35 @@ use reqwest::IntoUrl;
pub struct Veo3Args<'a, U: IntoUrl, V: IntoUrl> {
pub image_url: U,
pub prompt: &'a str,
pub duration: Veo3Duration,
pub aspect_ratio: Veo3AspectRatio,
pub resolution: Veo3Resolution,
pub duration: Veo3I2vDuration,
pub aspect_ratio: Veo3I2vAspectRatio,
pub resolution: Veo3I2vResolution,
pub generate_audio: bool,
pub api_key: &'a FalApiKey,
pub webhook_url: V,
}
/// Duration for Veo 3 image-to-video. Default is 8 seconds.
#[derive(Copy, Clone, Debug)]
pub enum Veo3Duration {
pub enum Veo3I2vDuration {
Default, // Default is 8 seconds
FourSeconds,
SixSeconds,
EightSeconds,
}
/// Aspect ratio for Veo 3 image-to-video.
/// Supports Auto (inherit source), 16:9, and 9:16. No Square.
#[derive(Copy, Clone, Debug)]
pub enum Veo3AspectRatio {
Default,
WideSixteenNine,
TallNineSixteen,
Square,
pub enum Veo3I2vAspectRatio {
Auto,
WideSixteenNine, // 16:9
TallNineSixteen, // 9:16
}
/// Resolution for Veo 3 image-to-video.
#[derive(Copy, Clone, Debug)]
pub enum Veo3Resolution {
pub enum Veo3I2vResolution {
Default,
SevenTwentyP,
TenEightyP,
@@ -48,14 +51,14 @@ impl <U: IntoUrl, V: IntoUrl> FalRequestCostCalculator for Veo3Args<'_, U, V> {
// For example, a 5s video with audio on will cost $2."
match (self.duration, self.generate_audio) {
(Veo3Duration::FourSeconds, false) => 80,
(Veo3Duration::SixSeconds, false) => 120,
(Veo3Duration::EightSeconds, false) => 160,
(Veo3Duration::Default, false) => 160, // NB: Default is 8 seconds
(Veo3Duration::FourSeconds, true) => 160,
(Veo3Duration::SixSeconds, true) => 240,
(Veo3Duration::EightSeconds, true) => 320,
(Veo3Duration::Default, true) => 320, // NB: Default is 8 seconds320
(Veo3I2vDuration::FourSeconds, false) => 80,
(Veo3I2vDuration::SixSeconds, false) => 120,
(Veo3I2vDuration::EightSeconds, false) => 160,
(Veo3I2vDuration::Default, false) => 160,
(Veo3I2vDuration::FourSeconds, true) => 160,
(Veo3I2vDuration::SixSeconds, true) => 240,
(Veo3I2vDuration::EightSeconds, true) => 320,
(Veo3I2vDuration::Default, true) => 320,
}
}
}
@@ -67,23 +70,22 @@ pub async fn enqueue_veo_3_image_to_video_webhook<U: IntoUrl, V: IntoUrl>(
args: Veo3Args<'_, U, V>
) -> Result<WebhookResponse, FalErrorPlus> {
let duration = match args.duration {
Veo3Duration::Default => None,
Veo3Duration::FourSeconds => Some("4s".to_string()),
Veo3Duration::SixSeconds => Some("6s".to_string()),
Veo3Duration::EightSeconds => Some("8s".to_string()),
Veo3I2vDuration::Default => None,
Veo3I2vDuration::FourSeconds => Some("4s".to_string()),
Veo3I2vDuration::SixSeconds => Some("6s".to_string()),
Veo3I2vDuration::EightSeconds => Some("8s".to_string()),
};
let aspect_ratio = match args.aspect_ratio {
Veo3AspectRatio::Default => None,
Veo3AspectRatio::WideSixteenNine => Some("16:9".to_string()),
Veo3AspectRatio::TallNineSixteen => Some("9:16".to_string()),
Veo3AspectRatio::Square => Some("1:1".to_string()),
Veo3I2vAspectRatio::Auto => Some("auto".to_string()),
Veo3I2vAspectRatio::WideSixteenNine => Some("16:9".to_string()),
Veo3I2vAspectRatio::TallNineSixteen => Some("9:16".to_string()),
};
let resolution= match args.resolution {
Veo3Resolution::Default => None,
Veo3Resolution::SevenTwentyP => Some("720p".to_string()),
Veo3Resolution::TenEightyP => Some("1080p".to_string()),
let resolution = match args.resolution {
Veo3I2vResolution::Default => None,
Veo3I2vResolution::SevenTwentyP => Some("720p".to_string()),
Veo3I2vResolution::TenEightyP => Some("1080p".to_string()),
};
let image_url = args.image_url.as_str().to_string();
@@ -109,7 +111,10 @@ pub async fn enqueue_veo_3_image_to_video_webhook<U: IntoUrl, V: IntoUrl>(
#[cfg(test)]
mod tests {
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::webhook::video::image::enqueue_veo_3_image_to_video_webhook::{enqueue_veo_3_image_to_video_webhook, Veo3Args, Veo3AspectRatio, Veo3Duration, Veo3Resolution};
use crate::requests::webhook::video::image::enqueue_veo_3_image_to_video_webhook::{
enqueue_veo_3_image_to_video_webhook, Veo3Args, Veo3I2vAspectRatio, Veo3I2vDuration,
Veo3I2vResolution,
};
use errors::AnyhowResult;
use std::fs::read_to_string;
use test_data::web::image_urls::TREX_SKELETON_IMAGE_URL;
@@ -117,24 +122,21 @@ mod tests {
#[tokio::test]
#[ignore]
async fn test() -> AnyhowResult<()> {
// XXX: Don't commit secrets!
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let args = Veo3Args {
image_url: TREX_SKELETON_IMAGE_URL,
prompt: "the t-rex skeleton starts walking towards the camera and roars",
api_key: &api_key,
duration: Veo3Duration::EightSeconds,
aspect_ratio: Veo3AspectRatio::WideSixteenNine,
resolution: Veo3Resolution::TenEightyP,
duration: Veo3I2vDuration::EightSeconds,
aspect_ratio: Veo3I2vAspectRatio::WideSixteenNine,
resolution: Veo3I2vResolution::TenEightyP,
generate_audio: true,
webhook_url: "https://example.com/webhook",
};
let result = enqueue_veo_3_image_to_video_webhook(args).await?;
let _result = enqueue_veo_3_image_to_video_webhook(args).await?;
Ok(())
}
}
@@ -0,0 +1,103 @@
use crate::creds::fal_api_key::FalApiKey;
use crate::error::classify_fal_error::classify_fal_error;
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::webhook_response::WebhookResponse;
use crate::requests::http::video::text::http_veo_2_text_to_video::{
veo_2_text_to_video, Veo2TextToVideoInput,
};
use crate::requests::traits::fal_request_cost_calculator_trait::{
FalRequestCostCalculator, UsdCents,
};
// Re-use the same duration/aspect-ratio enums as image-to-video.
pub use crate::requests::webhook::video::image::enqueue_veo_2_image_to_video_webhook::{
Veo2AspectRatio, Veo2Duration,
};
use reqwest::IntoUrl;
pub struct Veo2TextToVideoArgs<'a, V: IntoUrl> {
pub prompt: &'a str,
pub negative_prompt: Option<&'a str>,
pub duration: Veo2Duration,
pub aspect_ratio: Veo2AspectRatio,
pub webhook_url: V,
pub api_key: &'a FalApiKey,
}
impl<V: IntoUrl> FalRequestCostCalculator for Veo2TextToVideoArgs<'_, V> {
fn calculate_cost_in_cents(&self) -> UsdCents {
// Same pricing as image-to-video:
// "For 5s video your request will cost $2.50.
// For every additional second you will be charged $0.50."
match self.duration {
Veo2Duration::Default => 250,
Veo2Duration::FiveSeconds => 250,
Veo2Duration::SixSeconds => 300,
Veo2Duration::SevenSeconds => 350,
Veo2Duration::EightSeconds => 400,
}
}
}
/// Veo 2 Text-to-Video
/// https://fal.ai/models/fal-ai/veo2
pub async fn enqueue_veo_2_text_to_video_webhook<V: IntoUrl>(
args: Veo2TextToVideoArgs<'_, V>,
) -> Result<WebhookResponse, FalErrorPlus> {
let duration = match args.duration {
Veo2Duration::Default => None,
Veo2Duration::FiveSeconds => Some("5s".to_string()),
Veo2Duration::SixSeconds => Some("6s".to_string()),
Veo2Duration::SevenSeconds => Some("7s".to_string()),
Veo2Duration::EightSeconds => Some("8s".to_string()),
};
let aspect_ratio = match args.aspect_ratio {
Veo2AspectRatio::Auto => None, // Let the API default (16:9)
Veo2AspectRatio::AutoPreferPortrait => Some("9:16".to_string()),
Veo2AspectRatio::WideSixteenNine => Some("16:9".to_string()),
Veo2AspectRatio::TallNineSixteen => Some("9:16".to_string()),
};
let request = Veo2TextToVideoInput {
prompt: args.prompt.to_string(),
aspect_ratio,
duration,
negative_prompt: args.negative_prompt.map(|s| s.to_string()),
};
let result = veo_2_text_to_video(request)
.with_api_key(&args.api_key.0)
.queue_webhook(args.webhook_url)
.await;
result.map_err(|err| classify_fal_error(err))
}
#[cfg(test)]
mod tests {
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::webhook::video::text::enqueue_veo_2_text_to_video_webhook::{
enqueue_veo_2_text_to_video_webhook, Veo2TextToVideoArgs, Veo2AspectRatio, Veo2Duration,
};
use errors::AnyhowResult;
use std::fs::read_to_string;
#[tokio::test]
#[ignore]
async fn test_veo_2_text_to_video() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let args = Veo2TextToVideoArgs {
prompt: "a drone shot of a coastal sunset with waves crashing on rocks",
negative_prompt: None,
api_key: &api_key,
duration: Veo2Duration::FiveSeconds,
aspect_ratio: Veo2AspectRatio::WideSixteenNine,
webhook_url: "https://example.com/webhook",
};
let _result = enqueue_veo_2_text_to_video_webhook(args).await?;
Ok(())
}
}
@@ -0,0 +1,137 @@
use crate::creds::fal_api_key::FalApiKey;
use crate::error::classify_fal_error::classify_fal_error;
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::webhook_response::WebhookResponse;
use crate::requests::http::video::text::http_veo_3_text_to_video::{
veo_3_text_to_video, Veo3TextToVideoInput,
};
use crate::requests::traits::fal_request_cost_calculator_trait::{
FalRequestCostCalculator, UsdCents,
};
use reqwest::IntoUrl;
pub struct Veo3TextToVideoArgs<'a, V: IntoUrl> {
pub prompt: &'a str,
pub negative_prompt: Option<&'a str>,
pub duration: Veo3T2vDuration,
pub aspect_ratio: Veo3T2vAspectRatio,
pub resolution: Veo3T2vResolution,
pub generate_audio: bool,
pub webhook_url: V,
pub api_key: &'a FalApiKey,
}
/// Duration for Veo 3 text-to-video. Default is 8 seconds.
#[derive(Copy, Clone, Debug)]
pub enum Veo3T2vDuration {
Default, // Default is 8 seconds
FourSeconds,
SixSeconds,
EightSeconds,
}
/// Aspect ratio for Veo 3 text-to-video.
/// Only 16:9 and 9:16 are supported. No Auto, no Square.
#[derive(Copy, Clone, Debug)]
pub enum Veo3T2vAspectRatio {
Default, // Default is 16:9
WideSixteenNine, // 16:9
TallNineSixteen, // 9:16
}
/// Resolution for Veo 3 text-to-video.
#[derive(Copy, Clone, Debug)]
pub enum Veo3T2vResolution {
Default,
SevenTwentyP,
TenEightyP,
}
impl<V: IntoUrl> FalRequestCostCalculator for Veo3TextToVideoArgs<'_, V> {
fn calculate_cost_in_cents(&self) -> UsdCents {
// $0.20/sec audio off, $0.40/sec audio on.
match (self.duration, self.generate_audio) {
(Veo3T2vDuration::FourSeconds, false) => 80,
(Veo3T2vDuration::SixSeconds, false) => 120,
(Veo3T2vDuration::EightSeconds, false) => 160,
(Veo3T2vDuration::Default, false) => 160,
(Veo3T2vDuration::FourSeconds, true) => 160,
(Veo3T2vDuration::SixSeconds, true) => 240,
(Veo3T2vDuration::EightSeconds, true) => 320,
(Veo3T2vDuration::Default, true) => 320,
}
}
}
/// Veo 3 Text-to-Video
/// https://fal.ai/models/fal-ai/veo3
pub async fn enqueue_veo_3_text_to_video_webhook<V: IntoUrl>(
args: Veo3TextToVideoArgs<'_, V>,
) -> Result<WebhookResponse, FalErrorPlus> {
let duration = match args.duration {
Veo3T2vDuration::Default => None,
Veo3T2vDuration::FourSeconds => Some("4s".to_string()),
Veo3T2vDuration::SixSeconds => Some("6s".to_string()),
Veo3T2vDuration::EightSeconds => Some("8s".to_string()),
};
let aspect_ratio = match args.aspect_ratio {
Veo3T2vAspectRatio::Default => None,
Veo3T2vAspectRatio::WideSixteenNine => Some("16:9".to_string()),
Veo3T2vAspectRatio::TallNineSixteen => Some("9:16".to_string()),
};
let resolution = match args.resolution {
Veo3T2vResolution::Default => None,
Veo3T2vResolution::SevenTwentyP => Some("720p".to_string()),
Veo3T2vResolution::TenEightyP => Some("1080p".to_string()),
};
let request = Veo3TextToVideoInput {
prompt: args.prompt.to_string(),
aspect_ratio,
resolution,
duration,
generate_audio: Some(args.generate_audio),
negative_prompt: args.negative_prompt.map(|s| s.to_string()),
};
let result = veo_3_text_to_video(request)
.with_api_key(&args.api_key.0)
.queue_webhook(args.webhook_url)
.await;
result.map_err(|err| classify_fal_error(err))
}
#[cfg(test)]
mod tests {
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::webhook::video::text::enqueue_veo_3_text_to_video_webhook::{
enqueue_veo_3_text_to_video_webhook, Veo3TextToVideoArgs, Veo3T2vAspectRatio,
Veo3T2vDuration, Veo3T2vResolution,
};
use errors::AnyhowResult;
use std::fs::read_to_string;
#[tokio::test]
#[ignore]
async fn test_veo_3_text_to_video() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let args = Veo3TextToVideoArgs {
prompt: "a drone shot of a coastal sunset with waves crashing on rocks",
negative_prompt: None,
api_key: &api_key,
duration: Veo3T2vDuration::EightSeconds,
aspect_ratio: Veo3T2vAspectRatio::WideSixteenNine,
resolution: Veo3T2vResolution::TenEightyP,
generate_audio: true,
webhook_url: "https://example.com/webhook",
};
let _result = enqueue_veo_3_text_to_video_webhook(args).await?;
Ok(())
}
}
@@ -7,5 +7,7 @@ pub mod enqueue_pixverse_v5_text_to_video_webhook;
pub mod enqueue_seedance_1p5_pro_text_to_video_webhook;
pub mod enqueue_sora_2_pro_text_to_video_webhook;
pub mod enqueue_sora_2_text_to_video_webhook;
pub mod enqueue_veo_2_text_to_video_webhook;
pub mod enqueue_veo_3_text_to_video_webhook;
pub mod enqueue_veo_3p1_fast_text_to_video_webhook;
pub mod enqueue_veo_3p1_text_to_video_webhook;
@@ -79,11 +79,13 @@ fn build_omni_gen_video_models() -> Vec<OmniGenVideoModelDetails> {
..Default::default()
});
// TODO(bt,2026-04-10): Veo 2 image-to-video doesn't support aspect ratio
models.push(OmniGenVideoModelDetails {
model: CommonVideoModel::Veo2,
full_name: Some("Google 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,
@@ -105,8 +107,9 @@ fn build_omni_gen_video_models() -> Vec<OmniGenVideoModelDetails> {
aspect_ratio_options: Some(vec![
CommonAspectRatio::WideSixteenByNine,
CommonAspectRatio::TallNineBySixteen,
CommonAspectRatio::Square,
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,
@@ -148,13 +148,9 @@ pub async fn generate_veo_2_image_to_video_handler(
.map(|prompt| prompt.trim())
.unwrap_or_else(|| "");
let aspect_ratio = match &request.aspect_ratio {
Some(GenerateVeo2AspectRatio::Auto) => Veo2AspectRatio::Auto,
Some(GenerateVeo2AspectRatio::AutoPreferPortrait) => Veo2AspectRatio::AutoPreferPortrait,
Some(GenerateVeo2AspectRatio::WideSixteenNine) => Veo2AspectRatio::WideSixteenNine,
Some(GenerateVeo2AspectRatio::TallNineSixteen) => Veo2AspectRatio::TallNineSixteen,
None => Veo2AspectRatio::WideSixteenNine, // Default to 16:9
};
if let Some(aspect_ratio) = &request.aspect_ratio {
warn!("Aspect ratio is not read in Veo 2 image-to-video");
}
let duration = match &request.duration {
Some(GenerateVeo2Duration::FiveSeconds) => Veo2Duration::FiveSeconds,
@@ -169,7 +165,6 @@ pub async fn generate_veo_2_image_to_video_handler(
webhook_url: &server_state.fal.webhook_url,
duration,
prompt,
aspect_ratio,
api_key: &server_state.fal.api_key,
};
@@ -222,12 +217,7 @@ pub async fn generate_veo_2_image_to_video_handler(
maybe_negative_prompt: None,
maybe_other_args: None,
maybe_generation_mode: Some(CommonGenerationMode::Keyframe), // TODO: This endpoint only supports keyframes for now
maybe_aspect_ratio: request.aspect_ratio.as_ref().map(|ar| match ar {
GenerateVeo2AspectRatio::Auto => CommonAspectRatio::Auto,
GenerateVeo2AspectRatio::AutoPreferPortrait => CommonAspectRatio::Auto,
GenerateVeo2AspectRatio::WideSixteenNine => CommonAspectRatio::WideSixteenByNine,
GenerateVeo2AspectRatio::TallNineSixteen => CommonAspectRatio::TallNineBySixteen,
}),
maybe_aspect_ratio: None, // NB: The aspect ratio is not read for Veo 2 image-to-video
maybe_resolution: None,
maybe_batch_count: None,
maybe_generate_audio: None,
@@ -11,7 +11,7 @@ use crate::state::server_state::ServerState;
use actix_web::web::Json;
use actix_web::{web, HttpRequest};
use anyhow::anyhow;
use artcraft_api_defs::generate::video::generate_veo_3_fast_image_to_video::{GenerateVeo3FastDuration, GenerateVeo3FastImageToVideoRequest, GenerateVeo3FastImageToVideoResponse, GenerateVeo3FastResolution};
use artcraft_api_defs::generate::video::generate_veo_3_fast_image_to_video::{GenerateVeo3FastAspectRatio, GenerateVeo3FastDuration, GenerateVeo3FastImageToVideoRequest, GenerateVeo3FastImageToVideoResponse, GenerateVeo3FastResolution};
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
use enums::by_table::prompts::prompt_type::PromptType;
use enums::common::generation_provider::GenerationProvider;
@@ -19,7 +19,7 @@ use enums::common::generation::common_model_type::CommonModelType;
use enums::common::visibility::Visibility;
use enums::common::generation::common_generation_mode::CommonGenerationMode;
use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator;
use fal_client::requests::webhook::video::image::enqueue_veo_3_fast_image_to_video_webhook::{enqueue_veo_3_fast_image_to_video_webhook, Veo3FastArgs, Veo3FastDuration, Veo3FastResolution};
use fal_client::requests::webhook::video::image::enqueue_veo_3_fast_image_to_video_webhook::{enqueue_veo_3_fast_image_to_video_webhook, Veo3FastArgs, Veo3FastAspectRatio, Veo3FastDuration, Veo3FastResolution};
use http_server_common::request::get_request_ip::get_request_ip;
use log::{error, info, warn};
use mysql_queries::queries::generic_inference::fal::insert_generic_inference_job_for_fal_queue::insert_generic_inference_job_for_fal_queue;
@@ -141,6 +141,13 @@ pub async fn generate_veo_3_fast_image_to_video_handler(
.map(|prompt| prompt.trim())
.unwrap_or_else(|| "");
let aspect_ratio = match &request.aspect_ratio {
Some(GenerateVeo3FastAspectRatio::Auto) => Veo3FastAspectRatio::Auto,
Some(GenerateVeo3FastAspectRatio::SixteenByNine) => Veo3FastAspectRatio::WideSixteenNine,
Some(GenerateVeo3FastAspectRatio::NineBySixteen) => Veo3FastAspectRatio::TallNineSixteen,
None => Veo3FastAspectRatio::Auto,
};
let resolution = match &request.resolution {
Some(GenerateVeo3FastResolution::SevenTwentyP) => Veo3FastResolution::SevenTwentyP,
Some(GenerateVeo3FastResolution::TenEightyP) => Veo3FastResolution::TenEightyP,
@@ -158,6 +165,7 @@ pub async fn generate_veo_3_fast_image_to_video_handler(
let args = Veo3FastArgs {
image_url: media_links.cdn_url,
prompt,
aspect_ratio,
duration,
resolution,
generate_audio,
@@ -214,7 +222,11 @@ pub async fn generate_veo_3_fast_image_to_video_handler(
maybe_negative_prompt: None,
maybe_other_args: None,
maybe_generation_mode: Some(CommonGenerationMode::Keyframe), // TODO: This endpoint only supports keyframes for now
maybe_aspect_ratio: None,
maybe_aspect_ratio: request.aspect_ratio.as_ref().map(|ar| match ar {
GenerateVeo3FastAspectRatio::Auto => enums::common::generation::common_aspect_ratio::CommonAspectRatio::Auto,
GenerateVeo3FastAspectRatio::SixteenByNine => enums::common::generation::common_aspect_ratio::CommonAspectRatio::WideSixteenByNine,
GenerateVeo3FastAspectRatio::NineBySixteen => enums::common::generation::common_aspect_ratio::CommonAspectRatio::TallNineBySixteen,
}),
maybe_resolution: None,
maybe_batch_count: None,
maybe_generate_audio: Some(generate_audio),
@@ -14,13 +14,13 @@ use anyhow::anyhow;
use artcraft_api_defs::generate::video::generate_veo_3_image_to_video::{GenerateVeo3AspectRatio, GenerateVeo3Duration, GenerateVeo3ImageToVideoRequest, GenerateVeo3ImageToVideoResponse, GenerateVeo3Resolution};
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
use enums::by_table::prompts::prompt_type::PromptType;
use enums::common::generation_provider::GenerationProvider;
use enums::common::generation::common_model_type::CommonModelType;
use enums::common::visibility::Visibility;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_generation_mode::CommonGenerationMode;
use enums::common::generation::common_model_type::CommonModelType;
use enums::common::generation_provider::GenerationProvider;
use enums::common::visibility::Visibility;
use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator;
use fal_client::requests::webhook::video::image::enqueue_veo_3_image_to_video_webhook::{enqueue_veo_3_image_to_video_webhook, Veo3Args, Veo3AspectRatio, Veo3Duration, Veo3Resolution};
use fal_client::requests::webhook::video::image::enqueue_veo_3_image_to_video_webhook::{enqueue_veo_3_image_to_video_webhook, Veo3Args, Veo3I2vAspectRatio, Veo3I2vDuration, Veo3I2vResolution};
use http_server_common::request::get_request_ip::get_request_ip;
use log::{error, info, warn};
use mysql_queries::queries::generic_inference::fal::insert_generic_inference_job_for_fal_queue::insert_generic_inference_job_for_fal_queue;
@@ -143,21 +143,22 @@ pub async fn generate_veo_3_image_to_video_handler(
.unwrap_or_else(|| "");
let aspect_ratio = match &request.aspect_ratio {
Some(GenerateVeo3AspectRatio::WideSixteenNine) => Veo3AspectRatio::WideSixteenNine,
Some(GenerateVeo3AspectRatio::TallNineSixteen) => Veo3AspectRatio::TallNineSixteen,
Some(GenerateVeo3AspectRatio::Square) => Veo3AspectRatio::Square,
None => Veo3AspectRatio::WideSixteenNine,
Some(GenerateVeo3AspectRatio::WideSixteenNine) => Veo3I2vAspectRatio::WideSixteenNine,
Some(GenerateVeo3AspectRatio::TallNineSixteen) => Veo3I2vAspectRatio::TallNineSixteen,
// Square is not supported by Veo 3 i2v — fall back to Auto.
Some(GenerateVeo3AspectRatio::Square) => Veo3I2vAspectRatio::Auto,
None => Veo3I2vAspectRatio::WideSixteenNine,
};
let resolution = match &request.resolution {
Some(GenerateVeo3Resolution::SevenTwentyP) => Veo3Resolution::SevenTwentyP,
Some(GenerateVeo3Resolution::TenEightyP) => Veo3Resolution::TenEightyP,
None => Veo3Resolution::Default,
Some(GenerateVeo3Resolution::SevenTwentyP) => Veo3I2vResolution::SevenTwentyP,
Some(GenerateVeo3Resolution::TenEightyP) => Veo3I2vResolution::TenEightyP,
None => Veo3I2vResolution::Default,
};
let duration = match &request.duration {
Some(GenerateVeo3Duration::EightSeconds) => Veo3Duration::EightSeconds,
None => Veo3Duration::EightSeconds,
Some(GenerateVeo3Duration::EightSeconds) => Veo3I2vDuration::EightSeconds,
None => Veo3I2vDuration::EightSeconds,
};
let generate_audio = request.generate_audio
@@ -1 +1,3 @@
mod test_seedance_1p5_pro;
mod test_veo_2;
mod test_veo_3;
@@ -1,4 +1,4 @@
//! Tests for omni-gen video distillation against the Seedance 1.5 Pro model.
//! Tests for omni-gen video against the Seedance 1.5 Pro model.
//!
//! Seedance 1.5 Pro supports both text-to-video and image-to-video (via
//! start_frame / end_frame). Pricing is based on resolution × duration ×
@@ -0,0 +1,442 @@
//! Tests for omni-gen video against the Veo 2 model.
//!
//! Veo 2 supports both text-to-video and image-to-video (via start_frame).
//!
//! Pricing: $2.50 for the first 5 seconds, +$0.50 per additional second.
//! 5s → 250¢, 6s → 300¢, 7s → 350¢, 8s → 400¢
//!
//! Supported aspect ratios: Auto, WideSixteenNine, TallNineSixteen.
//! Unsupported ratios fall back to Auto under PayMoreUpgrade.
//!
//! Supported durations: 5–8 seconds. Over-max clamps to 8s (PayMoreUpgrade).
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_video_model::CommonVideoModel;
use tokens::tokens::media_files::MediaFileToken;
use url::Url;
use crate::http_server::endpoints::omni_gen::generate::video::distill_video_request::{
distill_video_request, DistilledVideoRequest,
};
// ── Helpers ────────────────────────────────────────────────────────────────
fn make_request(
prompt: Option<&str>,
aspect_ratio: Option<CommonAspectRatio>,
duration_seconds: Option<u16>,
start_frame_token: Option<MediaFileToken>,
) -> OmniGenVideoCostAndGenerateRequest {
OmniGenVideoCostAndGenerateRequest {
idempotency_token: Some("11111111-1111-1111-1111-111111111111".to_string()),
model: Some(CommonVideoModel::Veo2),
prompt: prompt.map(|s| s.to_string()),
negative_prompt: None,
start_frame_image_media_token: start_frame_token,
end_frame_image_media_token: None,
reference_image_media_tokens: None,
reference_video_media_tokens: None,
reference_audio_media_tokens: None,
reference_character_tokens: None,
resolution: None,
aspect_ratio,
quality: None,
duration_seconds,
video_batch_count: None,
generate_audio: None,
}
}
fn fake_token(name: &str) -> (MediaFileToken, HashMap<MediaFileToken, Url>) {
let token = MediaFileToken::new_from_str(name);
let url = Url::parse(&format!("https://fake.example.com/{}.png", name)).unwrap();
let mut map = HashMap::new();
map.insert(token.clone(), url);
(token, map)
}
fn distill_text(request: &OmniGenVideoCostAndGenerateRequest) -> DistilledVideoRequest {
let empty: HashMap<MediaFileToken, Url> = HashMap::new();
distill_video_request(request, Some(&empty))
.expect("distill_video_request should succeed for Veo 2 (text)")
}
fn distill_image(
request: &OmniGenVideoCostAndGenerateRequest,
hydration: &HashMap<MediaFileToken, Url>,
) -> DistilledVideoRequest {
distill_video_request(request, Some(hydration))
.expect("distill_video_request should succeed for Veo 2 (image)")
}
// ────────────────────────────────────────────────────────────────────────────
// COST
// ────────────────────────────────────────────────────────────────────────────
mod cost {
use super::*;
mod text {
use super::*;
fn cost(duration: Option<u16>) -> u64 {
let request = make_request(Some("p"), None, duration, None);
distill_text(&request).cost.cost_in_usd_cents.unwrap()
}
#[test]
fn default_duration_costs_250() { assert_eq!(cost(None), 250); }
#[test]
fn five_seconds_costs_250() { assert_eq!(cost(Some(5)), 250); }
#[test]
fn six_seconds_costs_300() { assert_eq!(cost(Some(6)), 300); }
#[test]
fn seven_seconds_costs_350() { assert_eq!(cost(Some(7)), 350); }
#[test]
fn eight_seconds_costs_400() { assert_eq!(cost(Some(8)), 400); }
#[test]
fn duration_above_8_clamps_to_400() { assert_eq!(cost(Some(15)), 400); }
#[test]
fn cost_independent_of_aspect_ratio() {
let ars = [
None,
Some(CommonAspectRatio::Auto),
Some(CommonAspectRatio::WideSixteenByNine),
Some(CommonAspectRatio::TallNineBySixteen),
Some(CommonAspectRatio::Square),
];
for ar in ars {
let request = make_request(Some("p"), ar, None, None);
assert_eq!(distill_text(&request).cost.cost_in_usd_cents.unwrap(), 250);
}
}
#[test]
fn cost_metadata_flags_are_default() {
let d = distill_text(&make_request(Some("p"), None, None, None));
assert!(!d.cost.is_free);
assert!(!d.cost.is_unlimited);
assert!(!d.cost.is_rate_limited);
assert!(!d.cost.has_watermark);
}
}
mod image {
use super::*;
fn cost(duration: Option<u16>) -> u64 {
let (start, map) = fake_token("mf_start0000000000000000000000");
let request = make_request(Some("p"), None, duration, Some(start));
distill_image(&request, &map).cost.cost_in_usd_cents.unwrap()
}
#[test]
fn default_duration_costs_250() { assert_eq!(cost(None), 250); }
#[test]
fn five_seconds_costs_250() { assert_eq!(cost(Some(5)), 250); }
#[test]
fn six_seconds_costs_300() { assert_eq!(cost(Some(6)), 300); }
#[test]
fn seven_seconds_costs_350() { assert_eq!(cost(Some(7)), 350); }
#[test]
fn eight_seconds_costs_400() { assert_eq!(cost(Some(8)), 400); }
#[test]
fn duration_above_8_clamps_to_400() { assert_eq!(cost(Some(15)), 400); }
#[test]
fn image_and_text_cost_match() {
for dur in [5u16, 6, 7, 8] {
let text = distill_text(&make_request(Some("p"), None, Some(dur), None))
.cost.cost_in_usd_cents.unwrap();
let (start, map) = fake_token("mf_start0000000000000000000000");
let img = distill_image(
&make_request(Some("p"), None, Some(dur), Some(start)),
&map,
).cost.cost_in_usd_cents.unwrap();
assert_eq!(text, img, "text/image cost diverged at dur={}", dur);
}
}
}
}
// ────────────────────────────────────────────────────────────────────────────
// PLAN
// ────────────────────────────────────────────────────────────────────────────
mod plan {
use super::*;
use artcraft_router::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use artcraft_router::generate::generate_video::plan::fal::plan_generate_video_fal_veo_2::{
FalVeo2Mode, PlanFalVeo2,
};
use fal_client::requests::webhook::video::image::enqueue_veo_2_image_to_video_webhook::{
Veo2AspectRatio, Veo2Duration,
};
fn with_text_plan<F: FnOnce(&PlanFalVeo2)>(
request: &OmniGenVideoCostAndGenerateRequest,
assertion: F,
) {
let distilled = distill_text(request);
match distilled.plan() {
VideoGenerationPlan::FalVeo2(plan) => assertion(plan),
other => panic!("expected FalVeo2, got {:?}", other),
}
}
fn with_image_plan<F: FnOnce(&PlanFalVeo2)>(
request: &OmniGenVideoCostAndGenerateRequest,
hydration: &HashMap<MediaFileToken, Url>,
assertion: F,
) {
let distilled = distill_image(request, hydration);
match distilled.plan() {
VideoGenerationPlan::FalVeo2(plan) => assertion(plan),
other => panic!("expected FalVeo2, got {:?}", other),
}
}
mod text {
use super::*;
// ── Mode detection ────────────────────────────────────────────────────
#[test]
fn text_mode_is_text_to_video() {
with_text_plan(&make_request(Some("p"), None, None, None), |plan| {
assert!(matches!(plan.mode, FalVeo2Mode::TextToVideo));
});
}
// ── Prompt passthrough ────────────────────────────────────────────────
#[test]
fn prompt_is_passed_through() {
with_text_plan(&make_request(Some("a horse on mars"), None, None, None), |plan| {
assert_eq!(plan.prompt, "a horse on mars");
});
}
#[test]
fn missing_prompt_is_empty_string() {
with_text_plan(&make_request(None, None, None, None), |plan| {
assert_eq!(plan.prompt, "");
});
}
// ── Aspect ratio mappings ─────────────────────────────────────────────
#[test]
fn default_aspect_ratio_is_auto() {
with_text_plan(&make_request(Some("p"), None, None, None), |plan| {
assert!(matches!(plan.aspect_ratio, Some(Veo2AspectRatio::Auto)));
});
}
#[test]
fn auto_yields_auto() {
for ar in [CommonAspectRatio::Auto, CommonAspectRatio::Auto2k, CommonAspectRatio::Auto4k] {
with_text_plan(&make_request(Some("p"), Some(ar), None, None), |plan| {
assert!(matches!(plan.aspect_ratio, Some(Veo2AspectRatio::Auto)));
});
}
}
#[test]
fn wide_16x9_yields_wide() {
for ar in [CommonAspectRatio::WideSixteenByNine, CommonAspectRatio::Wide] {
with_text_plan(&make_request(Some("p"), Some(ar), None, None), |plan| {
assert!(matches!(plan.aspect_ratio, Some(Veo2AspectRatio::WideSixteenNine)));
});
}
}
#[test]
fn tall_9x16_yields_tall() {
for ar in [CommonAspectRatio::TallNineBySixteen, CommonAspectRatio::Tall] {
with_text_plan(&make_request(Some("p"), Some(ar), None, None), |plan| {
assert!(matches!(plan.aspect_ratio, Some(Veo2AspectRatio::TallNineSixteen)));
});
}
}
#[test]
fn unsupported_aspect_ratio_falls_back_to_auto() {
for ar in [
CommonAspectRatio::Square, CommonAspectRatio::SquareHd,
CommonAspectRatio::WideFourByThree, CommonAspectRatio::TallThreeByFour,
] {
with_text_plan(&make_request(Some("p"), Some(ar), None, None), |plan| {
assert!(matches!(plan.aspect_ratio, Some(Veo2AspectRatio::Auto)));
});
}
}
// ── Duration mappings ─────────────────────────────────────────────────
#[test]
fn default_duration_is_default() {
with_text_plan(&make_request(Some("p"), None, None, None), |plan| {
assert!(matches!(plan.duration, Veo2Duration::Default));
});
}
#[test]
fn duration_5_yields_five() {
with_text_plan(&make_request(Some("p"), None, Some(5), None), |plan| {
assert!(matches!(plan.duration, Veo2Duration::FiveSeconds));
});
}
#[test]
fn duration_6_yields_six() {
with_text_plan(&make_request(Some("p"), None, Some(6), None), |plan| {
assert!(matches!(plan.duration, Veo2Duration::SixSeconds));
});
}
#[test]
fn duration_7_yields_seven() {
with_text_plan(&make_request(Some("p"), None, Some(7), None), |plan| {
assert!(matches!(plan.duration, Veo2Duration::SevenSeconds));
});
}
#[test]
fn duration_8_yields_eight() {
with_text_plan(&make_request(Some("p"), None, Some(8), None), |plan| {
assert!(matches!(plan.duration, Veo2Duration::EightSeconds));
});
}
#[test]
fn duration_above_8_clamps_to_eight() {
with_text_plan(&make_request(Some("p"), None, Some(20), None), |plan| {
assert!(matches!(plan.duration, Veo2Duration::EightSeconds));
});
}
}
mod image {
use super::*;
// ── Mode detection / start frame hydration ────────────────────────────
#[test]
fn start_frame_triggers_image_to_video() {
let (start, map) = fake_token("mf_start0000000000000000000000");
let request = make_request(Some("p"), None, None, Some(start));
with_image_plan(&request, &map, |plan| {
match &plan.mode {
FalVeo2Mode::ImageToVideo { image_url } => {
assert!(image_url.starts_with("https://fake.example.com/"));
}
other => panic!("expected ImageToVideo, got {:?}", other),
}
});
}
#[test]
fn start_frame_url_matches_hydration_map_exactly() {
let token = MediaFileToken::new_from_str("mf_myframe0000000000000000000000");
let expected_url = "https://cdn.example.com/frames/my_frame.png";
let mut map = HashMap::new();
map.insert(token.clone(), Url::parse(expected_url).unwrap());
let request = make_request(Some("p"), None, None, Some(token));
with_image_plan(&request, &map, |plan| {
match &plan.mode {
FalVeo2Mode::ImageToVideo { image_url } => {
assert_eq!(image_url, expected_url);
}
other => panic!("expected ImageToVideo, got {:?}", other),
}
});
}
// ── Prompt passthrough in image mode ──────────────────────────────────
#[test]
fn prompt_is_passed_through_in_image_mode() {
let (start, map) = fake_token("mf_start0000000000000000000000");
let request = make_request(Some("a horse on mars"), None, None, Some(start));
with_image_plan(&request, &map, |plan| {
assert_eq!(plan.prompt, "a horse on mars");
});
}
// ── Aspect ratio is None in image mode (i2v inherits source AR) ────────
#[test]
fn aspect_ratio_is_none_in_image_mode() {
let (start, map) = fake_token("mf_start0000000000000000000000");
let request = make_request(Some("p"), Some(CommonAspectRatio::WideSixteenByNine), None, Some(start));
with_image_plan(&request, &map, |plan| {
assert!(plan.aspect_ratio.is_none(), "image-to-video should not have aspect_ratio");
});
}
#[test]
fn aspect_ratio_is_none_in_image_mode_even_with_tall() {
let (start, map) = fake_token("mf_start0000000000000000000000");
let request = make_request(Some("p"), Some(CommonAspectRatio::TallNineBySixteen), None, Some(start));
with_image_plan(&request, &map, |plan| {
assert!(plan.aspect_ratio.is_none());
});
}
// ── Duration pass-throughs in image mode ──────────────────────────────
#[test]
fn duration_5_in_image_mode() {
let (start, map) = fake_token("mf_start0000000000000000000000");
let request = make_request(Some("p"), None, Some(5), Some(start));
with_image_plan(&request, &map, |plan| {
assert!(matches!(plan.duration, Veo2Duration::FiveSeconds));
});
}
#[test]
fn duration_8_in_image_mode() {
let (start, map) = fake_token("mf_start0000000000000000000000");
let request = make_request(Some("p"), None, Some(8), Some(start));
with_image_plan(&request, &map, |plan| {
assert!(matches!(plan.duration, Veo2Duration::EightSeconds));
});
}
#[test]
fn duration_above_8_clamps_in_image_mode() {
let (start, map) = fake_token("mf_start0000000000000000000000");
let request = make_request(Some("p"), None, Some(20), Some(start));
with_image_plan(&request, &map, |plan| {
assert!(matches!(plan.duration, Veo2Duration::EightSeconds));
});
}
#[test]
fn default_duration_in_image_mode() {
let (start, map) = fake_token("mf_start0000000000000000000000");
let request = make_request(Some("p"), None, None, Some(start));
with_image_plan(&request, &map, |plan| {
assert!(matches!(plan.duration, Veo2Duration::Default));
});
}
}
}
}
@@ -0,0 +1,589 @@
//! Tests for omni-gen video against the Veo 3 model.
//!
//! Veo 3 supports both text-to-video and image-to-video (via start_frame).
//!
//! Pricing: $0.20/sec (audio off) or $0.40/sec (audio on).
//! Default: 8s, audio off (artcraft legacy) → 160¢.
//! 4s no-audio → 80¢, 6s no-audio → 120¢, 8s no-audio → 160¢
//! 4s audio → 160¢, 6s audio → 240¢, 8s audio → 320¢
//!
//! Supported aspect ratios (text mode only): Default, 16:9, 9:16, Square.
//! Image-to-video inherits the source frame's aspect ratio.
//!
//! Supported resolutions: 720p, 1080p.
//! Supported durations: 4s, 6s, 8s. Over-max clamps to 8s (PayMoreUpgrade).
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_resolution::CommonResolution;
use enums::common::generation::common_video_model::CommonVideoModel;
use tokens::tokens::media_files::MediaFileToken;
use url::Url;
use crate::http_server::endpoints::omni_gen::generate::video::distill_video_request::{
distill_video_request, DistilledVideoRequest,
};
// ── Helpers ────────────────────────────────────────────────────────────────
fn make_request(
prompt: Option<&str>,
aspect_ratio: Option<CommonAspectRatio>,
resolution: Option<CommonResolution>,
duration_seconds: Option<u16>,
generate_audio: Option<bool>,
start_frame_token: Option<MediaFileToken>,
) -> OmniGenVideoCostAndGenerateRequest {
OmniGenVideoCostAndGenerateRequest {
idempotency_token: Some("11111111-1111-1111-1111-111111111111".to_string()),
model: Some(CommonVideoModel::Veo3),
prompt: prompt.map(|s| s.to_string()),
negative_prompt: None,
start_frame_image_media_token: start_frame_token,
end_frame_image_media_token: None,
reference_image_media_tokens: None,
reference_video_media_tokens: None,
reference_audio_media_tokens: None,
reference_character_tokens: None,
resolution,
aspect_ratio,
quality: None,
duration_seconds,
video_batch_count: None,
generate_audio,
}
}
fn fake_token(name: &str) -> (MediaFileToken, HashMap<MediaFileToken, Url>) {
let token = MediaFileToken::new_from_str(name);
let url = Url::parse(&format!("https://fake.example.com/{}.png", name)).unwrap();
let mut map = HashMap::new();
map.insert(token.clone(), url);
(token, map)
}
fn distill_text(request: &OmniGenVideoCostAndGenerateRequest) -> DistilledVideoRequest {
let empty: HashMap<MediaFileToken, Url> = HashMap::new();
distill_video_request(request, Some(&empty))
.expect("distill should succeed for Veo 3 (text)")
}
fn distill_image(
request: &OmniGenVideoCostAndGenerateRequest,
hydration: &HashMap<MediaFileToken, Url>,
) -> DistilledVideoRequest {
distill_video_request(request, Some(hydration))
.expect("distill should succeed for Veo 3 (image)")
}
// ────────────────────────────────────────────────────────────────────────────
// COST
// ────────────────────────────────────────────────────────────────────────────
//
// Artcraft cost path: always 8s (legacy). $0.20/sec no-audio, $0.40/sec audio.
// Default audio = false (artcraft legacy).
mod cost {
use super::*;
mod text {
use super::*;
fn cost(duration: Option<u16>, audio: Option<bool>) -> u64 {
let request = make_request(Some("p"), None, None, duration, audio, None);
distill_text(&request).cost.cost_in_usd_cents.unwrap()
}
// ── No audio: $0.20/sec ──────────────────────────────────────────────
#[test]
fn default_no_audio_costs_160() { assert_eq!(cost(None, None), 160); } // 8s × 20¢
#[test]
fn four_seconds_no_audio_costs_80() { assert_eq!(cost(Some(4), Some(false)), 80); }
#[test]
fn six_seconds_no_audio_costs_120() { assert_eq!(cost(Some(6), Some(false)), 120); }
#[test]
fn eight_seconds_no_audio_costs_160() { assert_eq!(cost(Some(8), Some(false)), 160); }
// ── Audio on: $0.40/sec ───────────────────────────────────────────────
#[test]
fn four_seconds_audio_on_costs_160() { assert_eq!(cost(Some(4), Some(true)), 160); }
#[test]
fn six_seconds_audio_on_costs_240() { assert_eq!(cost(Some(6), Some(true)), 240); }
#[test]
fn eight_seconds_audio_on_costs_320() { assert_eq!(cost(Some(8), Some(true)), 320); }
#[test]
fn default_duration_audio_on_costs_320() { assert_eq!(cost(None, Some(true)), 320); }
// ── Over-max clamps to 8s ─────────────────────────────────────────────
#[test]
fn duration_above_8_clamps_to_160_no_audio() { assert_eq!(cost(Some(20), Some(false)), 160); }
#[test]
fn duration_above_8_clamps_to_320_audio() { assert_eq!(cost(Some(20), Some(true)), 320); }
// ── Cost is independent of aspect ratio ───────────────────────────────
#[test]
fn cost_independent_of_aspect_ratio() {
for ar in [None, Some(CommonAspectRatio::Auto), Some(CommonAspectRatio::WideSixteenByNine), Some(CommonAspectRatio::TallNineBySixteen)] {
let request = make_request(Some("p"), ar, None, None, None, None);
assert_eq!(distill_text(&request).cost.cost_in_usd_cents.unwrap(), 160);
}
}
// ── Cost is independent of resolution ─────────────────────────────────
#[test]
fn cost_independent_of_resolution() {
for res in [None, Some(CommonResolution::SevenTwentyP), Some(CommonResolution::TenEightyP)] {
let request = make_request(Some("p"), None, res, None, None, None);
assert_eq!(distill_text(&request).cost.cost_in_usd_cents.unwrap(), 160);
}
}
// ── Metadata flags ────────────────────────────────────────────────────
#[test]
fn cost_metadata_flags_are_default() {
let d = distill_text(&make_request(Some("p"), None, None, None, None, None));
assert!(!d.cost.is_free);
assert!(!d.cost.is_unlimited);
assert!(!d.cost.is_rate_limited);
assert!(!d.cost.has_watermark);
}
}
mod image {
use super::*;
fn cost_img(duration: Option<u16>, audio: Option<bool>) -> u64 {
let (start, map) = fake_token("mf_start0000000000000000000000");
let request = make_request(Some("p"), None, None, duration, audio, Some(start));
distill_image(&request, &map).cost.cost_in_usd_cents.unwrap()
}
#[test]
fn default_costs_160() { assert_eq!(cost_img(None, None), 160); }
#[test]
fn four_seconds_no_audio_costs_80() { assert_eq!(cost_img(Some(4), Some(false)), 80); }
#[test]
fn six_seconds_audio_on_costs_240() { assert_eq!(cost_img(Some(6), Some(true)), 240); }
#[test]
fn eight_seconds_audio_on_costs_320() { assert_eq!(cost_img(Some(8), Some(true)), 320); }
#[test]
fn image_and_text_cost_match() {
let text = distill_text(&make_request(Some("p"), None, None, None, None, None))
.cost.cost_in_usd_cents.unwrap();
let (start, map) = fake_token("mf_start0000000000000000000000");
let img = distill_image(
&make_request(Some("p"), None, None, None, None, Some(start)),
&map,
).cost.cost_in_usd_cents.unwrap();
assert_eq!(text, img);
}
}
}
// ────────────────────────────────────────────────────────────────────────────
// PLAN
// ────────────────────────────────────────────────────────────────────────────
mod plan {
use super::*;
use artcraft_router::generate::generate_video::video_generation_plan::VideoGenerationPlan;
use artcraft_router::generate::generate_video::plan::fal::plan_generate_video_fal_veo_3::{
FalVeo3Duration, FalVeo3I2vAspectRatio, FalVeo3Mode, FalVeo3Resolution,
FalVeo3T2vAspectRatio, PlanFalVeo3,
};
fn with_text_plan<F: FnOnce(&PlanFalVeo3)>(
request: &OmniGenVideoCostAndGenerateRequest,
assertion: F,
) {
let distilled = distill_text(request);
match distilled.plan() {
VideoGenerationPlan::FalVeo3(plan) => assertion(plan),
other => panic!("expected FalVeo3, got {:?}", other),
}
}
fn with_image_plan<F: FnOnce(&PlanFalVeo3)>(
request: &OmniGenVideoCostAndGenerateRequest,
hydration: &HashMap<MediaFileToken, Url>,
assertion: F,
) {
let distilled = distill_image(request, hydration);
match distilled.plan() {
VideoGenerationPlan::FalVeo3(plan) => assertion(plan),
other => panic!("expected FalVeo3, got {:?}", other),
}
}
mod text {
use super::*;
// ── Mode detection ────────────────────────────────────────────────────
#[test]
fn text_mode_is_text_to_video() {
with_text_plan(&make_request(Some("p"), None, None, None, None, None), |plan| {
assert!(matches!(plan.mode, FalVeo3Mode::TextToVideo));
});
}
// ── Prompt passthrough ────────────────────────────────────────────────
#[test]
fn prompt_is_passed_through() {
with_text_plan(&make_request(Some("a horse on mars"), None, None, None, None, None), |plan| {
assert_eq!(plan.prompt, "a horse on mars");
});
}
#[test]
fn missing_prompt_is_empty_string() {
with_text_plan(&make_request(None, None, None, None, None, None), |plan| {
assert_eq!(plan.prompt, "");
});
}
// ── Aspect ratio mappings (text mode only) ────────────────────────────
#[test]
fn default_aspect_ratio_is_default() {
with_text_plan(&make_request(Some("p"), None, None, None, None, None), |plan| {
assert!(matches!(plan.t2v_aspect_ratio, Some(FalVeo3T2vAspectRatio::Default)));
});
}
#[test]
fn wide_16x9_yields_wide() {
for ar in [CommonAspectRatio::WideSixteenByNine, CommonAspectRatio::Wide] {
with_text_plan(&make_request(Some("p"), Some(ar), None, None, None, None), |plan| {
assert!(matches!(plan.t2v_aspect_ratio, Some(FalVeo3T2vAspectRatio::WideSixteenNine)));
});
}
}
#[test]
fn tall_9x16_yields_tall() {
for ar in [CommonAspectRatio::TallNineBySixteen, CommonAspectRatio::Tall] {
with_text_plan(&make_request(Some("p"), Some(ar), None, None, None, None), |plan| {
assert!(matches!(plan.t2v_aspect_ratio, Some(FalVeo3T2vAspectRatio::TallNineSixteen)));
});
}
}
#[test]
fn square_falls_back_to_default() {
// Veo 3 t2v has no Square — falls back to Default (16:9).
for ar in [CommonAspectRatio::Square, CommonAspectRatio::SquareHd] {
with_text_plan(&make_request(Some("p"), Some(ar), None, None, None, None), |plan| {
assert!(matches!(plan.t2v_aspect_ratio, Some(FalVeo3T2vAspectRatio::Default)));
});
}
}
#[test]
fn auto_falls_back_to_default() {
// Veo 3 t2v has no Auto — falls back to Default (16:9).
for ar in [CommonAspectRatio::Auto, CommonAspectRatio::Auto2k, CommonAspectRatio::Auto4k] {
with_text_plan(&make_request(Some("p"), Some(ar), None, None, None, None), |plan| {
assert!(matches!(plan.t2v_aspect_ratio, Some(FalVeo3T2vAspectRatio::Default)));
});
}
}
#[test]
fn unsupported_aspect_ratio_falls_back_to_default() {
for ar in [CommonAspectRatio::WideFourByThree, CommonAspectRatio::TallThreeByFour, CommonAspectRatio::WideFiveByFour] {
with_text_plan(&make_request(Some("p"), Some(ar), None, None, None, None), |plan| {
assert!(matches!(plan.t2v_aspect_ratio, Some(FalVeo3T2vAspectRatio::Default)));
});
}
}
// ── Duration mappings ─────────────────────────────────────────────────
#[test]
fn default_duration_is_default() {
with_text_plan(&make_request(Some("p"), None, None, None, None, None), |plan| {
assert!(matches!(plan.duration, FalVeo3Duration::Default));
});
}
#[test]
fn duration_4_yields_four() {
with_text_plan(&make_request(Some("p"), None, None, Some(4), None, None), |plan| {
assert!(matches!(plan.duration, FalVeo3Duration::FourSeconds));
});
}
#[test]
fn duration_6_yields_six() {
with_text_plan(&make_request(Some("p"), None, None, Some(6), None, None), |plan| {
assert!(matches!(plan.duration, FalVeo3Duration::SixSeconds));
});
}
#[test]
fn duration_8_yields_eight() {
with_text_plan(&make_request(Some("p"), None, None, Some(8), None, None), |plan| {
assert!(matches!(plan.duration, FalVeo3Duration::EightSeconds));
});
}
#[test]
fn duration_above_8_clamps_to_eight() {
with_text_plan(&make_request(Some("p"), None, None, Some(20), None, None), |plan| {
assert!(matches!(plan.duration, FalVeo3Duration::EightSeconds));
});
}
// ── Resolution mappings ───────────────────────────────────────────────
#[test]
fn default_resolution_is_default() {
with_text_plan(&make_request(Some("p"), None, None, None, None, None), |plan| {
assert!(matches!(plan.resolution, FalVeo3Resolution::Default));
});
}
#[test]
fn seven_twenty_p_yields_seven_twenty_p() {
with_text_plan(&make_request(Some("p"), None, Some(CommonResolution::SevenTwentyP), None, None, None), |plan| {
assert!(matches!(plan.resolution, FalVeo3Resolution::SevenTwentyP));
});
}
#[test]
fn ten_eighty_p_yields_ten_eighty_p() {
with_text_plan(&make_request(Some("p"), None, Some(CommonResolution::TenEightyP), None, None, None), |plan| {
assert!(matches!(plan.resolution, FalVeo3Resolution::TenEightyP));
});
}
// ── Generate audio passthrough ────────────────────────────────────────
#[test]
fn default_audio_is_true() {
// Fal plan defaults generate_audio to true.
with_text_plan(&make_request(Some("p"), None, None, None, None, None), |plan| {
assert!(plan.generate_audio);
});
}
#[test]
fn audio_true_passes_through() {
with_text_plan(&make_request(Some("p"), None, None, None, Some(true), None), |plan| {
assert!(plan.generate_audio);
});
}
#[test]
fn audio_false_passes_through() {
with_text_plan(&make_request(Some("p"), None, None, None, Some(false), None), |plan| {
assert!(!plan.generate_audio);
});
}
}
mod image {
use super::*;
// ── Mode detection / start frame hydration ────────────────────────────
#[test]
fn start_frame_triggers_image_to_video() {
let (start, map) = fake_token("mf_start0000000000000000000000");
let request = make_request(Some("p"), None, None, None, None, Some(start));
with_image_plan(&request, &map, |plan| {
match &plan.mode {
FalVeo3Mode::ImageToVideo { image_url } => {
assert!(image_url.starts_with("https://fake.example.com/"));
}
other => panic!("expected ImageToVideo, got {:?}", other),
}
});
}
#[test]
fn start_frame_url_matches_hydration_map_exactly() {
let token = MediaFileToken::new_from_str("mf_myframe0000000000000000000000");
let expected_url = "https://cdn.example.com/frames/my_frame.png";
let mut map = HashMap::new();
map.insert(token.clone(), Url::parse(expected_url).unwrap());
let request = make_request(Some("p"), None, None, None, None, Some(token));
with_image_plan(&request, &map, |plan| {
match &plan.mode {
FalVeo3Mode::ImageToVideo { image_url } => {
assert_eq!(image_url, expected_url);
}
other => panic!("expected ImageToVideo, got {:?}", other),
}
});
}
// ── Aspect ratio is None in image mode ────────────────────────────────
// ── Aspect ratio in image mode: uses i2v (has Auto, no Square) ─────────
#[test]
fn t2v_aspect_ratio_is_none_in_image_mode() {
let (start, map) = fake_token("mf_start0000000000000000000000");
let request = make_request(Some("p"), Some(CommonAspectRatio::WideSixteenByNine), None, None, None, Some(start));
with_image_plan(&request, &map, |plan| {
assert!(plan.t2v_aspect_ratio.is_none());
});
}
#[test]
fn i2v_default_aspect_ratio_is_auto() {
let (start, map) = fake_token("mf_start0000000000000000000000");
let request = make_request(Some("p"), None, None, None, None, Some(start));
with_image_plan(&request, &map, |plan| {
assert!(matches!(plan.i2v_aspect_ratio, Some(FalVeo3I2vAspectRatio::Auto)));
});
}
#[test]
fn i2v_wide_16x9_yields_wide() {
let (start, map) = fake_token("mf_start0000000000000000000000");
let request = make_request(Some("p"), Some(CommonAspectRatio::WideSixteenByNine), None, None, None, Some(start));
with_image_plan(&request, &map, |plan| {
assert!(matches!(plan.i2v_aspect_ratio, Some(FalVeo3I2vAspectRatio::WideSixteenNine)));
});
}
#[test]
fn i2v_tall_9x16_yields_tall() {
let (start, map) = fake_token("mf_start0000000000000000000000");
let request = make_request(Some("p"), Some(CommonAspectRatio::TallNineBySixteen), None, None, None, Some(start));
with_image_plan(&request, &map, |plan| {
assert!(matches!(plan.i2v_aspect_ratio, Some(FalVeo3I2vAspectRatio::TallNineSixteen)));
});
}
#[test]
fn i2v_auto_yields_auto() {
for ar in [CommonAspectRatio::Auto, CommonAspectRatio::Auto2k, CommonAspectRatio::Auto4k] {
let (start, map) = fake_token("mf_start0000000000000000000000");
let request = make_request(Some("p"), Some(ar), None, None, None, Some(start));
with_image_plan(&request, &map, |plan| {
assert!(matches!(plan.i2v_aspect_ratio, Some(FalVeo3I2vAspectRatio::Auto)));
});
}
}
#[test]
fn i2v_square_falls_back_to_auto() {
for ar in [CommonAspectRatio::Square, CommonAspectRatio::SquareHd] {
let (start, map) = fake_token("mf_start0000000000000000000000");
let request = make_request(Some("p"), Some(ar), None, None, None, Some(start));
with_image_plan(&request, &map, |plan| {
assert!(matches!(plan.i2v_aspect_ratio, Some(FalVeo3I2vAspectRatio::Auto)));
});
}
}
#[test]
fn i2v_unsupported_falls_back_to_auto() {
for ar in [CommonAspectRatio::WideFourByThree, CommonAspectRatio::TallThreeByFour] {
let (start, map) = fake_token("mf_start0000000000000000000000");
let request = make_request(Some("p"), Some(ar), None, None, None, Some(start));
with_image_plan(&request, &map, |plan| {
assert!(matches!(plan.i2v_aspect_ratio, Some(FalVeo3I2vAspectRatio::Auto)));
});
}
}
// ── Prompt passthrough in image mode ──────────────────────────────────
#[test]
fn prompt_is_passed_through_in_image_mode() {
let (start, map) = fake_token("mf_start0000000000000000000000");
let request = make_request(Some("a horse on mars"), None, None, None, None, Some(start));
with_image_plan(&request, &map, |plan| {
assert_eq!(plan.prompt, "a horse on mars");
});
}
// ── Duration pass-throughs in image mode ──────────────────────────────
#[test]
fn duration_4_in_image_mode() {
let (start, map) = fake_token("mf_start0000000000000000000000");
let request = make_request(Some("p"), None, None, Some(4), None, Some(start));
with_image_plan(&request, &map, |plan| {
assert!(matches!(plan.duration, FalVeo3Duration::FourSeconds));
});
}
#[test]
fn duration_8_in_image_mode() {
let (start, map) = fake_token("mf_start0000000000000000000000");
let request = make_request(Some("p"), None, None, Some(8), None, Some(start));
with_image_plan(&request, &map, |plan| {
assert!(matches!(plan.duration, FalVeo3Duration::EightSeconds));
});
}
#[test]
fn default_duration_in_image_mode() {
let (start, map) = fake_token("mf_start0000000000000000000000");
let request = make_request(Some("p"), None, None, None, None, Some(start));
with_image_plan(&request, &map, |plan| {
assert!(matches!(plan.duration, FalVeo3Duration::Default));
});
}
// ── Resolution in image mode ──────────────────────────────────────────
#[test]
fn resolution_passes_through_in_image_mode() {
let (start, map) = fake_token("mf_start0000000000000000000000");
let request = make_request(Some("p"), None, Some(CommonResolution::TenEightyP), None, None, Some(start));
with_image_plan(&request, &map, |plan| {
assert!(matches!(plan.resolution, FalVeo3Resolution::TenEightyP));
});
}
// ── Audio in image mode ───────────────────────────────────────────────
#[test]
fn audio_passes_through_in_image_mode() {
let (start, map) = fake_token("mf_start0000000000000000000000");
let request = make_request(Some("p"), None, None, None, Some(false), Some(start));
with_image_plan(&request, &map, |plan| {
assert!(!plan.generate_audio);
});
}
#[test]
fn default_audio_is_true_in_image_mode() {
let (start, map) = fake_token("mf_start0000000000000000000000");
let request = make_request(Some("p"), None, None, None, None, Some(start));
with_image_plan(&request, &map, |plan| {
assert!(plan.generate_audio);
});
}
}
}
}