veo2 text-to-image was missing

This commit is contained in:
Brandon Thomas committed 2026-04-14 04:31:06 -04:00
1 parent 049a04281c
commit 5fada6b30f
16 files changed
+486 -206

No files matched your search

Generated
+1
View File
@@ -770,6 +770,7 @@ dependencies = [
"anyhow",
"artcraft_api_defs",
"artcraft_client",
"enums",
"fal_client",
"log",
"muapi_client",
@@ -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: plan.start_frame.map(|t| t.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,
}))
}
@@ -4,28 +4,49 @@ 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,
};
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,
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 } => {
let args = Veo2Args {
image_url: image_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(),
};
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,
@@ -33,12 +33,8 @@ pub fn plan_generate_video_artcraft_veo_2<'a>(
// Artcraft cost path. Cost only depends on duration, so URL-form refs are
// accepted and dropped.
Some(ImageRef::Url(_)) => None,
None => {
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "start_frame",
value: "Veo 2 requires a starting frame".to_string(),
}))
}
// No start_frame = text-to-video mode.
None => None,
};
if request.end_frame.is_some() {
@@ -9,10 +9,19 @@ 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 negative_prompt: Option<String>,
pub mode: FalVeo2Mode,
pub aspect_ratio: Veo2AspectRatio,
pub duration: Veo2Duration,
}
@@ -22,35 +31,32 @@ 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 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,
};
let aspect_ratio = plan_aspect_ratio(request.aspect_ratio, strategy)?;
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 +66,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),
@@ -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)
}
@@ -7,5 +7,6 @@ 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_3p1_fast_text_to_video;
pub mod http_veo_3p1_text_to_video;
@@ -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(())
}
}
@@ -7,5 +7,6 @@ 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_3p1_fast_text_to_video_webhook;
pub mod enqueue_veo_3p1_text_to_video_webhook;
@@ -1,7 +1,6 @@
//! Tests for omni-gen video against the Veo 2 model.
//!
//! Veo 2 is image-to-video only — a start_frame is always required.
//! There is no text-to-video mode.
//! 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¢
@@ -61,66 +60,51 @@ mod tests {
(token, map)
}
fn distill(
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")
.expect("distill_video_request should succeed for Veo 2 (image)")
}
// ────────────────────────────────────────────────────────────────────────────
// COST
// ────────────────────────────────────────────────────────────────────────────
//
// Pricing: 5s = 250¢, +50¢ per additional second above 5.
// Default duration = 5s = 250¢.
mod cost {
use super::*;
mod image {
mod text {
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(&request, &map).cost.cost_in_usd_cents.unwrap()
}
// ── Duration-based pricing ────────────────────────────────────────────
#[test]
fn default_duration_costs_250_cents() {
assert_eq!(cost(None), 250);
let request = make_request(Some("p"), None, duration, None);
distill_text(&request).cost.cost_in_usd_cents.unwrap()
}
#[test]
fn five_seconds_costs_250_cents() {
assert_eq!(cost(Some(5)), 250);
}
fn default_duration_costs_250() { assert_eq!(cost(None), 250); }
#[test]
fn six_seconds_costs_300_cents() {
assert_eq!(cost(Some(6)), 300);
}
fn five_seconds_costs_250() { assert_eq!(cost(Some(5)), 250); }
#[test]
fn seven_seconds_costs_350_cents() {
assert_eq!(cost(Some(7)), 350);
}
fn six_seconds_costs_300() { assert_eq!(cost(Some(6)), 300); }
#[test]
fn eight_seconds_costs_400_cents() {
assert_eq!(cost(Some(8)), 400);
}
fn seven_seconds_costs_350() { assert_eq!(cost(Some(7)), 350); }
#[test]
fn duration_above_8_clamps_to_400_cents() {
// PayMoreUpgrade clamps over-max to 8s.
assert_eq!(cost(Some(15)), 400);
}
fn eight_seconds_costs_400() { assert_eq!(cost(Some(8)), 400); }
// ── Cost is independent of aspect ratio ───────────────────────────────
#[test]
fn duration_above_8_clamps_to_400() { assert_eq!(cost(Some(15)), 400); }
#[test]
fn cost_independent_of_aspect_ratio() {
@@ -132,32 +116,14 @@ mod tests {
Some(CommonAspectRatio::Square),
];
for ar in ars {
let (start, map) = fake_token("mf_start0000000000000000000000");
let request = make_request(Some("p"), ar, None, Some(start));
assert_eq!(
distill(&request, &map).cost.cost_in_usd_cents.unwrap(), 250,
"expected 250¢ regardless of aspect ratio {:?}", ar,
);
let request = make_request(Some("p"), ar, None, None);
assert_eq!(distill_text(&request).cost.cost_in_usd_cents.unwrap(), 250);
}
}
// ── Cost is independent of prompt ─────────────────────────────────────
#[test]
fn cost_independent_of_prompt() {
let (s1, m1) = fake_token("mf_start0000000000000000000000");
let (s2, m2) = fake_token("mf_start0000000000000000000000");
let a = distill(&make_request(Some("a cat"), None, None, Some(s1)), &m1);
let b = distill(&make_request(None, None, None, Some(s2)), &m2);
assert_eq!(a.cost.cost_in_usd_cents, b.cost.cost_in_usd_cents);
}
// ── Metadata flags ────────────────────────────────────────────────────
#[test]
fn cost_metadata_flags_are_default() {
let (start, map) = fake_token("mf_start0000000000000000000000");
let d = distill(&make_request(Some("p"), None, None, Some(start)), &map);
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);
@@ -165,9 +131,46 @@ mod tests {
}
}
mod text {
// Veo 2 is image-to-video only; there is no text-to-video mode.
// (start_frame is required and the plan errors without it.)
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);
}
}
}
}
@@ -178,57 +181,45 @@ mod tests {
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::PlanFalVeo2;
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_plan<F: FnOnce(&PlanFalVeo2)>(
fn with_text_plan<F: FnOnce(&PlanFalVeo2)>(
request: &OmniGenVideoCostAndGenerateRequest,
hydration: &HashMap<MediaFileToken, Url>,
assertion: F,
) {
let distilled = distill(request, hydration);
let distilled = distill_text(request);
match distilled.plan() {
VideoGenerationPlan::FalVeo2(plan) => assertion(plan),
other => panic!("expected FalVeo2, got {:?}", other),
}
}
mod image {
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::*;
// ── Start frame hydration ─────────────────────────────────────────────
// ── Mode detection ────────────────────────────────────────────────────
#[test]
fn start_frame_url_matches_hydration_map() {
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_plan(&request, &map, |plan| {
assert_eq!(plan.start_frame_url, expected_url);
});
}
#[test]
fn start_frame_from_larger_hydration_map() {
let start = MediaFileToken::new_from_str("mf_start0000000000000000000000");
let extra1 = MediaFileToken::new_from_str("mf_extra1000000000000000000000000");
let extra2 = MediaFileToken::new_from_str("mf_extra2000000000000000000000000");
let start_url = "https://cdn.example.com/start.png";
let mut map = HashMap::new();
map.insert(start.clone(), Url::parse(start_url).unwrap());
map.insert(extra1, Url::parse("https://cdn.example.com/extra1.png").unwrap());
map.insert(extra2, Url::parse("https://cdn.example.com/extra2.png").unwrap());
let request = make_request(Some("p"), None, None, Some(start));
with_plan(&request, &map, |plan| {
assert_eq!(plan.start_frame_url, start_url);
fn text_mode_is_text_to_video() {
with_text_plan(&make_request(Some("p"), None, None, None), |plan| {
assert!(matches!(plan.mode, FalVeo2Mode::TextToVideo));
});
}
@@ -236,18 +227,14 @@ mod tests {
#[test]
fn prompt_is_passed_through() {
let (start, map) = fake_token("mf_start0000000000000000000000");
let request = make_request(Some("a horse on mars"), None, None, Some(start));
with_plan(&request, &map, |plan| {
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() {
let (start, map) = fake_token("mf_start0000000000000000000000");
let request = make_request(None, None, None, Some(start));
with_plan(&request, &map, |plan| {
with_text_plan(&make_request(None, None, None, None), |plan| {
assert_eq!(plan.prompt, "");
});
}
@@ -256,9 +243,7 @@ mod tests {
#[test]
fn default_aspect_ratio_is_auto() {
let (start, map) = fake_token("mf_start0000000000000000000000");
let request = make_request(Some("p"), None, None, Some(start));
with_plan(&request, &map, |plan| {
with_text_plan(&make_request(Some("p"), None, None, None), |plan| {
assert!(matches!(plan.aspect_ratio, Veo2AspectRatio::Auto));
});
}
@@ -266,31 +251,25 @@ mod tests {
#[test]
fn 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, Some(start));
with_plan(&request, &map, |plan| {
assert!(matches!(plan.aspect_ratio, Veo2AspectRatio::Auto), "expected Auto for {:?}", ar);
with_text_plan(&make_request(Some("p"), Some(ar), None, None), |plan| {
assert!(matches!(plan.aspect_ratio, Veo2AspectRatio::Auto));
});
}
}
#[test]
fn wide_16x9_yields_wide_sixteen_nine() {
fn wide_16x9_yields_wide() {
for ar in [CommonAspectRatio::WideSixteenByNine, CommonAspectRatio::Wide] {
let (start, map) = fake_token("mf_start0000000000000000000000");
let request = make_request(Some("p"), Some(ar), None, Some(start));
with_plan(&request, &map, |plan| {
with_text_plan(&make_request(Some("p"), Some(ar), None, None), |plan| {
assert!(matches!(plan.aspect_ratio, Veo2AspectRatio::WideSixteenNine));
});
}
}
#[test]
fn tall_9x16_yields_tall_nine_sixteen() {
fn tall_9x16_yields_tall() {
for ar in [CommonAspectRatio::TallNineBySixteen, CommonAspectRatio::Tall] {
let (start, map) = fake_token("mf_start0000000000000000000000");
let request = make_request(Some("p"), Some(ar), None, Some(start));
with_plan(&request, &map, |plan| {
with_text_plan(&make_request(Some("p"), Some(ar), None, None), |plan| {
assert!(matches!(plan.aspect_ratio, Veo2AspectRatio::TallNineSixteen));
});
}
@@ -298,23 +277,12 @@ mod tests {
#[test]
fn unsupported_aspect_ratio_falls_back_to_auto() {
let unsupported = [
CommonAspectRatio::Square,
CommonAspectRatio::SquareHd,
CommonAspectRatio::WideFourByThree,
CommonAspectRatio::WideFiveByFour,
CommonAspectRatio::WideThreeByTwo,
CommonAspectRatio::WideTwentyOneByNine,
CommonAspectRatio::TallThreeByFour,
CommonAspectRatio::TallFourByFive,
CommonAspectRatio::TallTwoByThree,
CommonAspectRatio::TallNineByTwentyOne,
];
for ar in unsupported {
let (start, map) = fake_token("mf_start0000000000000000000000");
let request = make_request(Some("p"), Some(ar), None, Some(start));
with_plan(&request, &map, |plan| {
assert!(matches!(plan.aspect_ratio, Veo2AspectRatio::Auto), "expected Auto fallback for {:?}", ar);
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, Veo2AspectRatio::Auto));
});
}
}
@@ -323,62 +291,143 @@ mod tests {
#[test]
fn default_duration_is_default() {
let (start, map) = fake_token("mf_start0000000000000000000000");
let request = make_request(Some("p"), None, None, Some(start));
with_plan(&request, &map, |plan| {
with_text_plan(&make_request(Some("p"), None, None, None), |plan| {
assert!(matches!(plan.duration, Veo2Duration::Default));
});
}
#[test]
fn duration_5_yields_five_seconds() {
let (start, map) = fake_token("mf_start0000000000000000000000");
let request = make_request(Some("p"), None, Some(5), Some(start));
with_plan(&request, &map, |plan| {
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_seconds() {
let (start, map) = fake_token("mf_start0000000000000000000000");
let request = make_request(Some("p"), None, Some(6), Some(start));
with_plan(&request, &map, |plan| {
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_seconds() {
let (start, map) = fake_token("mf_start0000000000000000000000");
let request = make_request(Some("p"), None, Some(7), Some(start));
with_plan(&request, &map, |plan| {
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_seconds() {
let (start, map) = fake_token("mf_start0000000000000000000000");
let request = make_request(Some("p"), None, Some(8), Some(start));
with_plan(&request, &map, |plan| {
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() {
let (start, map) = fake_token("mf_start0000000000000000000000");
let request = make_request(Some("p"), None, Some(20), Some(start));
with_plan(&request, &map, |plan| {
with_text_plan(&make_request(Some("p"), None, Some(20), None), |plan| {
assert!(matches!(plan.duration, Veo2Duration::EightSeconds));
});
}
}
mod text {
// Veo 2 is image-to-video only; there is no text-to-video mode.
// (start_frame is required and the plan errors without it.)
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 in image mode ────────────────────────────────────────
#[test]
fn aspect_ratio_passes_through_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!(matches!(plan.aspect_ratio, Veo2AspectRatio::WideSixteenNine));
});
}
// ── 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));
});
}
}
}
}