Compare commits

...
Author SHA1 Message Date
Brandon Thomas c4853df057 vidu reference 2026-07-02 03:03:48 -04:00
Brandon Thomas 40b05b82b8 vidu q3 2026-07-02 02:34:19 -04:00
27 changed files with 2145 additions and 0 deletions
@@ -8,3 +8,5 @@ pub mod veo_3_fast;
pub mod veo_3p1;
pub mod veo_3p1_fast;
pub mod veo_3p1_lite;
pub mod vidu_q3;
pub mod vidu_q3_turbo;
@@ -0,0 +1,197 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::video::image::vidu_q3::raw_request::{
ViduQ3ImageToVideoInput, ViduQ3ImageToVideoOutput,
};
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
#[derive(Clone, Debug)]
pub struct ViduQ3ImageToVideoRequest {
/// Text prompt (max 2000 characters). Optional server-side; pass an empty
/// string for a pure image-to-video generation.
pub prompt: String,
/// URL of the image used as the starting frame (URL or base64).
pub image_url: String,
/// Optional URL of an ending frame, for start→end transition videos.
pub end_image_url: Option<String>,
/// Duration in seconds. Valid range 1–16; fal's default is `5` when `None`.
pub duration: Option<u8>,
/// Seed for reproducibility. Random when `None`.
pub seed: Option<i64>,
/// Output resolution. fal's default is `720p` when `None`. Note fal rejects
/// `360p` when `end_image_url` is set.
pub resolution: Option<ViduQ3ImageToVideoResolution>,
/// Whether to generate audio. fal's server default is `true` when `None`.
pub audio: Option<bool>,
}
/// Vidu Q3 resolutions. 720p/1080p bill at 2.2× the 360p/540p per-second rate.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum ViduQ3ImageToVideoResolution {
ThreeSixtyP,
FiveFortyP,
SevenTwentyP,
TenEightyP,
}
impl ViduQ3ImageToVideoResolution {
/// 720p and 1080p are billed at the higher (2.2×) per-second rate.
pub fn is_high_res(&self) -> bool {
matches!(self, Self::SevenTwentyP | Self::TenEightyP)
}
fn to_str(&self) -> &'static str {
match self {
Self::ThreeSixtyP => "360p",
Self::FiveFortyP => "540p",
Self::SevenTwentyP => "720p",
Self::TenEightyP => "1080p",
}
}
}
impl FalEndpoint for ViduQ3ImageToVideoRequest {
const ENDPOINT: &str = "fal-ai/vidu/q3/image-to-video";
type RawRequest = ViduQ3ImageToVideoInput;
type RawResponse = ViduQ3ImageToVideoOutput;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
Ok(Self::RawRequest {
prompt: self.prompt.clone(),
image_url: self.image_url.clone(),
end_image_url: self.end_image_url.clone(),
duration: self.duration,
seed: self.seed,
resolution: self.resolution.map(|r| r.to_str().to_string()),
audio: self.audio,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
use errors::AnyhowResult;
use std::fs::read_to_string;
use test_data::web::image_urls::{JUNO_AT_LAKE_IMAGE_URL, TALL_MOCHI_WITH_GLASSES_IMAGE_URL};
// ── Real requests (manually run; require a live key and cost money) ──
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_image_to_video_webhook() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = ViduQ3ImageToVideoRequest {
prompt: "the lake comes alive with gentle ripples and dappled sunlight".to_string(),
image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(),
end_image_url: None,
duration: Some(5),
seed: None,
resolution: Some(ViduQ3ImageToVideoResolution::SevenTwentyP),
audio: Some(false),
};
let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?;
println!("Webhook result: {:?}", result);
assert!(result.request_id.is_some() || result.gateway_request_id.is_some());
Ok(())
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_image_to_video_with_end_frame_queue() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = ViduQ3ImageToVideoRequest {
prompt: "a smooth transition between the two frames".to_string(),
image_url: TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(),
end_image_url: Some(JUNO_AT_LAKE_IMAGE_URL.to_string()),
duration: Some(5),
seed: Some(1234),
resolution: Some(ViduQ3ImageToVideoResolution::SevenTwentyP),
audio: Some(false),
};
let result = request.send_queue_request(&api_key).await?;
println!("Queue result — request_id: {}", result.request_id);
assert!(!result.request_id.is_empty());
Ok(())
}
// ── Wire-shape sanity (no API calls) ──
#[test]
fn raw_request_maps_all_fields() {
let request = ViduQ3ImageToVideoRequest {
prompt: "p".to_string(),
image_url: "https://example.com/start.png".to_string(),
end_image_url: Some("https://example.com/end.png".to_string()),
duration: Some(7),
seed: Some(42),
resolution: Some(ViduQ3ImageToVideoResolution::TenEightyP),
audio: Some(false),
};
let raw = request.to_raw_request().unwrap();
assert_eq!(raw.prompt, "p");
assert_eq!(raw.image_url, "https://example.com/start.png");
assert_eq!(raw.end_image_url.as_deref(), Some("https://example.com/end.png"));
assert_eq!(raw.duration, Some(7));
assert_eq!(raw.seed, Some(42));
assert_eq!(raw.resolution.as_deref(), Some("1080p"));
assert_eq!(raw.audio, Some(false));
}
#[test]
fn raw_request_omits_unset_optionals() {
let request = ViduQ3ImageToVideoRequest {
prompt: "p".to_string(),
image_url: "https://example.com/start.png".to_string(),
end_image_url: None,
duration: None,
seed: None,
resolution: None,
audio: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(
json,
serde_json::json!({ "prompt": "p", "image_url": "https://example.com/start.png" }),
);
}
#[test]
fn seed_serializes_when_set() {
let request = ViduQ3ImageToVideoRequest {
prompt: "p".to_string(),
image_url: "https://example.com/start.png".to_string(),
end_image_url: None,
duration: None,
seed: Some(777),
resolution: None,
audio: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(json.get("seed").and_then(|s| s.as_i64()), Some(777));
}
#[test]
fn endpoint_path_is_canonical() {
assert_eq!(
ViduQ3ImageToVideoRequest::ENDPOINT,
"fal-ai/vidu/q3/image-to-video",
);
}
// NB: Pricing tests are in cost.rs
}
@@ -0,0 +1,78 @@
use crate::requests::api::video::image::vidu_q3::api::{
ViduQ3ImageToVideoRequest, ViduQ3ImageToVideoResolution,
};
use crate::requests::api::video::text::vidu_q3::cost::{vidu_q3_cost_cents, vidu_q3_rate_tenth_cents_per_sec};
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
// Image-to-video shares the Vidu Q3 per-second pricing (see the text module for
// the canonical rate table):
// 360p / 540p: $0.07/sec
// 720p / 1080p: $0.154/sec (2.2×)
// Pricing depends on resolution and duration only.
impl FalRequestCostCalculator for ViduQ3ImageToVideoRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
// fal defaults when unset: duration = 5s, resolution = 720p.
let duration_secs = u64::from(self.duration.unwrap_or(5));
let is_high_res = self.resolution
.unwrap_or(ViduQ3ImageToVideoResolution::SevenTwentyP)
.is_high_res();
let rate = vidu_q3_rate_tenth_cents_per_sec(is_high_res);
vidu_q3_cost_cents(rate, duration_secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_request(
duration: Option<u8>,
resolution: Option<ViduQ3ImageToVideoResolution>,
) -> ViduQ3ImageToVideoRequest {
ViduQ3ImageToVideoRequest {
prompt: "test".to_string(),
image_url: "https://example.com/x.png".to_string(),
end_image_url: None,
duration,
seed: None,
resolution,
audio: Some(true),
}
}
mod cost_table {
use super::*;
// (duration, resolution, expected_cents)
const COST_TABLE: &[(Option<u8>, Option<ViduQ3ImageToVideoResolution>, u64)] = &[
(Some(5), Some(ViduQ3ImageToVideoResolution::ThreeSixtyP), 35),
(Some(5), Some(ViduQ3ImageToVideoResolution::FiveFortyP), 35),
(Some(5), Some(ViduQ3ImageToVideoResolution::SevenTwentyP), 77),
(Some(5), Some(ViduQ3ImageToVideoResolution::TenEightyP), 77),
(Some(10), Some(ViduQ3ImageToVideoResolution::SevenTwentyP), 154),
// Defaults: duration=None→5s, resolution=None→720p
(None, None, 77),
];
#[test]
fn matches_cost_table() {
for &(duration, resolution, expected) in COST_TABLE {
let got = make_request(duration, resolution).calculate_cost_in_cents();
assert_eq!(got, expected, "duration={duration:?} resolution={resolution:?}");
}
}
/// An end frame does not change the bill (only resolution + duration do).
#[test]
fn cost_is_independent_of_end_frame() {
let without = make_request(Some(8), Some(ViduQ3ImageToVideoResolution::SevenTwentyP)).calculate_cost_in_cents();
let with = ViduQ3ImageToVideoRequest {
end_image_url: Some("https://example.com/end.png".to_string()),
..make_request(Some(8), Some(ViduQ3ImageToVideoResolution::SevenTwentyP))
}.calculate_cost_in_cents();
assert_eq!(without, with);
}
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -0,0 +1,47 @@
use serde::{Deserialize, Serialize};
/// Over-the-wire input shape for `fal-ai/vidu/q3/image-to-video`.
/// fal's schema: <https://fal.ai/models/fal-ai/vidu/q3/image-to-video/api>
///
/// NB: this endpoint has no `aspect_ratio` (it is derived from the input
/// image). A `prompt` is optional server-side but always sent here.
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct ViduQ3ImageToVideoInput {
/// Text prompt (max 2000 characters). Optional server-side.
pub prompt: String,
/// URL of the image used as the starting frame (URL or base64).
pub image_url: String,
/// Optional URL of an ending frame, for start→end transition videos.
#[serde(skip_serializing_if = "Option::is_none")]
pub end_image_url: Option<String>,
/// Duration in seconds. Range 1–16. fal default: 5.
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<u8>,
/// Seed for reproducibility. Random when omitted.
#[serde(skip_serializing_if = "Option::is_none")]
pub seed: Option<i64>,
/// Output resolution.
/// Possible values: "360p", "540p", "720p", "1080p". fal default: "720p".
/// (360p is unavailable when `end_image_url` is set.)
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<String>,
/// Whether to generate audio for the video. fal default: true.
#[serde(skip_serializing_if = "Option::is_none")]
pub audio: Option<bool>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ViduQ3ImageToVideoVideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ViduQ3ImageToVideoOutput {
pub video: ViduQ3ImageToVideoVideoFile,
}
@@ -0,0 +1,197 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::video::image::vidu_q3_turbo::raw_request::{
ViduQ3TurboImageToVideoInput, ViduQ3TurboImageToVideoOutput,
};
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
#[derive(Clone, Debug)]
pub struct ViduQ3TurboImageToVideoRequest {
/// Text prompt (max 2000 characters). Optional server-side; pass an empty
/// string for a pure image-to-video generation.
pub prompt: String,
/// URL of the image used as the starting frame (URL or base64).
pub image_url: String,
/// Optional URL of an ending frame, for start→end transition videos.
pub end_image_url: Option<String>,
/// Duration in seconds. Valid range 1–16; fal's default is `5` when `None`.
pub duration: Option<u8>,
/// Seed for reproducibility. Random when `None`.
pub seed: Option<i64>,
/// Output resolution. fal's default is `720p` when `None`. Note fal rejects
/// `360p` when `end_image_url` is set.
pub resolution: Option<ViduQ3TurboImageToVideoResolution>,
/// Whether to generate audio. fal's server default is `true` when `None`.
pub audio: Option<bool>,
}
/// Vidu Q3 Turbo resolutions. 720p/1080p bill at 2.2× the 360p/540p rate.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum ViduQ3TurboImageToVideoResolution {
ThreeSixtyP,
FiveFortyP,
SevenTwentyP,
TenEightyP,
}
impl ViduQ3TurboImageToVideoResolution {
/// 720p and 1080p are billed at the higher (2.2×) per-second rate.
pub fn is_high_res(&self) -> bool {
matches!(self, Self::SevenTwentyP | Self::TenEightyP)
}
fn to_str(&self) -> &'static str {
match self {
Self::ThreeSixtyP => "360p",
Self::FiveFortyP => "540p",
Self::SevenTwentyP => "720p",
Self::TenEightyP => "1080p",
}
}
}
impl FalEndpoint for ViduQ3TurboImageToVideoRequest {
const ENDPOINT: &str = "fal-ai/vidu/q3/image-to-video/turbo";
type RawRequest = ViduQ3TurboImageToVideoInput;
type RawResponse = ViduQ3TurboImageToVideoOutput;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
Ok(Self::RawRequest {
prompt: self.prompt.clone(),
image_url: self.image_url.clone(),
end_image_url: self.end_image_url.clone(),
duration: self.duration,
seed: self.seed,
resolution: self.resolution.map(|r| r.to_str().to_string()),
audio: self.audio,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
use errors::AnyhowResult;
use std::fs::read_to_string;
use test_data::web::image_urls::{JUNO_AT_LAKE_IMAGE_URL, TALL_MOCHI_WITH_GLASSES_IMAGE_URL};
// ── Real requests (manually run; require a live key and cost money) ──
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_image_to_video_webhook() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = ViduQ3TurboImageToVideoRequest {
prompt: "the lake comes alive with gentle ripples and dappled sunlight".to_string(),
image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(),
end_image_url: None,
duration: Some(5),
seed: None,
resolution: Some(ViduQ3TurboImageToVideoResolution::SevenTwentyP),
audio: Some(false),
};
let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?;
println!("Webhook result: {:?}", result);
assert!(result.request_id.is_some() || result.gateway_request_id.is_some());
Ok(())
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_image_to_video_with_end_frame_queue() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = ViduQ3TurboImageToVideoRequest {
prompt: "a smooth transition between the two frames".to_string(),
image_url: TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(),
end_image_url: Some(JUNO_AT_LAKE_IMAGE_URL.to_string()),
duration: Some(5),
seed: Some(1234),
resolution: Some(ViduQ3TurboImageToVideoResolution::SevenTwentyP),
audio: Some(false),
};
let result = request.send_queue_request(&api_key).await?;
println!("Queue result — request_id: {}", result.request_id);
assert!(!result.request_id.is_empty());
Ok(())
}
// ── Wire-shape sanity (no API calls) ──
#[test]
fn raw_request_maps_all_fields() {
let request = ViduQ3TurboImageToVideoRequest {
prompt: "p".to_string(),
image_url: "https://example.com/start.png".to_string(),
end_image_url: Some("https://example.com/end.png".to_string()),
duration: Some(7),
seed: Some(42),
resolution: Some(ViduQ3TurboImageToVideoResolution::TenEightyP),
audio: Some(false),
};
let raw = request.to_raw_request().unwrap();
assert_eq!(raw.prompt, "p");
assert_eq!(raw.image_url, "https://example.com/start.png");
assert_eq!(raw.end_image_url.as_deref(), Some("https://example.com/end.png"));
assert_eq!(raw.duration, Some(7));
assert_eq!(raw.seed, Some(42));
assert_eq!(raw.resolution.as_deref(), Some("1080p"));
assert_eq!(raw.audio, Some(false));
}
#[test]
fn raw_request_omits_unset_optionals() {
let request = ViduQ3TurboImageToVideoRequest {
prompt: "p".to_string(),
image_url: "https://example.com/start.png".to_string(),
end_image_url: None,
duration: None,
seed: None,
resolution: None,
audio: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(
json,
serde_json::json!({ "prompt": "p", "image_url": "https://example.com/start.png" }),
);
}
#[test]
fn seed_serializes_when_set() {
let request = ViduQ3TurboImageToVideoRequest {
prompt: "p".to_string(),
image_url: "https://example.com/start.png".to_string(),
end_image_url: None,
duration: None,
seed: Some(777),
resolution: None,
audio: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(json.get("seed").and_then(|s| s.as_i64()), Some(777));
}
#[test]
fn endpoint_path_is_canonical() {
assert_eq!(
ViduQ3TurboImageToVideoRequest::ENDPOINT,
"fal-ai/vidu/q3/image-to-video/turbo",
);
}
// NB: Pricing tests are in cost.rs
}
@@ -0,0 +1,80 @@
use crate::requests::api::video::image::vidu_q3_turbo::api::{
ViduQ3TurboImageToVideoRequest, ViduQ3TurboImageToVideoResolution,
};
use crate::requests::api::video::text::vidu_q3_turbo::cost::{
vidu_q3_turbo_cost_cents, vidu_q3_turbo_rate_tenth_cents_per_sec,
};
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
// Image-to-video shares the Vidu Q3 Turbo per-second pricing (see the text
// module for the canonical rate table):
// 360p / 540p: $0.035/sec
// 720p / 1080p: $0.077/sec (2.2×)
// Pricing depends on resolution and duration only.
impl FalRequestCostCalculator for ViduQ3TurboImageToVideoRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
// fal defaults when unset: duration = 5s, resolution = 720p.
let duration_secs = u64::from(self.duration.unwrap_or(5));
let is_high_res = self.resolution
.unwrap_or(ViduQ3TurboImageToVideoResolution::SevenTwentyP)
.is_high_res();
let rate = vidu_q3_turbo_rate_tenth_cents_per_sec(is_high_res);
vidu_q3_turbo_cost_cents(rate, duration_secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_request(
duration: Option<u8>,
resolution: Option<ViduQ3TurboImageToVideoResolution>,
) -> ViduQ3TurboImageToVideoRequest {
ViduQ3TurboImageToVideoRequest {
prompt: "test".to_string(),
image_url: "https://example.com/x.png".to_string(),
end_image_url: None,
duration,
seed: None,
resolution,
audio: Some(true),
}
}
mod cost_table {
use super::*;
// (duration, resolution, expected_cents)
const COST_TABLE: &[(Option<u8>, Option<ViduQ3TurboImageToVideoResolution>, u64)] = &[
(Some(5), Some(ViduQ3TurboImageToVideoResolution::ThreeSixtyP), 18), // 17.5 → 18
(Some(5), Some(ViduQ3TurboImageToVideoResolution::FiveFortyP), 18),
(Some(5), Some(ViduQ3TurboImageToVideoResolution::SevenTwentyP), 39), // 38.5 → 39
(Some(5), Some(ViduQ3TurboImageToVideoResolution::TenEightyP), 39),
(Some(10), Some(ViduQ3TurboImageToVideoResolution::SevenTwentyP), 77),
// Defaults: duration=None→5s, resolution=None→720p
(None, None, 39),
];
#[test]
fn matches_cost_table() {
for &(duration, resolution, expected) in COST_TABLE {
let got = make_request(duration, resolution).calculate_cost_in_cents();
assert_eq!(got, expected, "duration={duration:?} resolution={resolution:?}");
}
}
/// An end frame does not change the bill (only resolution + duration do).
#[test]
fn cost_is_independent_of_end_frame() {
let without = make_request(Some(8), Some(ViduQ3TurboImageToVideoResolution::SevenTwentyP)).calculate_cost_in_cents();
let with = ViduQ3TurboImageToVideoRequest {
end_image_url: Some("https://example.com/end.png".to_string()),
..make_request(Some(8), Some(ViduQ3TurboImageToVideoResolution::SevenTwentyP))
}.calculate_cost_in_cents();
assert_eq!(without, with);
}
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -0,0 +1,47 @@
use serde::{Deserialize, Serialize};
/// Over-the-wire input shape for `fal-ai/vidu/q3/image-to-video/turbo`.
/// fal's schema: <https://fal.ai/models/fal-ai/vidu/q3/image-to-video/turbo/api>
///
/// NB: this endpoint has no `aspect_ratio` (derived from the input image).
/// A `prompt` is optional server-side but always sent here.
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct ViduQ3TurboImageToVideoInput {
/// Text prompt (max 2000 characters). Optional server-side.
pub prompt: String,
/// URL of the image used as the starting frame (URL or base64).
pub image_url: String,
/// Optional URL of an ending frame, for start→end transition videos.
#[serde(skip_serializing_if = "Option::is_none")]
pub end_image_url: Option<String>,
/// Duration in seconds. Range 1–16. fal default: 5.
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<u8>,
/// Seed for reproducibility. Random when omitted.
#[serde(skip_serializing_if = "Option::is_none")]
pub seed: Option<i64>,
/// Output resolution.
/// Possible values: "360p", "540p", "720p", "1080p". fal default: "720p".
/// (360p is unavailable when `end_image_url` is set.)
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<String>,
/// Whether to generate audio for the video. fal default: true.
#[serde(skip_serializing_if = "Option::is_none")]
pub audio: Option<bool>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ViduQ3TurboImageToVideoVideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ViduQ3TurboImageToVideoOutput {
pub video: ViduQ3TurboImageToVideoVideoFile,
}
@@ -1,2 +1,4 @@
pub mod veo_3p1;
pub mod veo_3p1_fast;
pub mod vidu_q3_reference_to_video;
pub mod vidu_q3_reference_to_video_mix;
@@ -0,0 +1,232 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::video::reference::vidu_q3_reference_to_video::raw_request::{
ViduQ3ReferenceToVideoInput, ViduQ3ReferenceToVideoOutput,
};
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
#[derive(Clone, Debug)]
pub struct ViduQ3ReferenceToVideoRequest {
/// Text prompt (max 2000 characters).
pub prompt: String,
/// Reference image URLs (1 to 4) used for subject consistency.
pub reference_image_urls: Vec<String>,
/// Duration in seconds. Valid range 1–16; fal's default is `5` when `None`.
pub duration: Option<u8>,
/// Seed for reproducibility. Random when `None`.
pub seed: Option<i64>,
/// Aspect ratio. fal's default is `16:9` when `None`.
pub aspect_ratio: Option<ViduQ3ReferenceToVideoAspectRatio>,
/// Output resolution. fal's default is `720p` when `None`.
pub resolution: Option<ViduQ3ReferenceToVideoResolution>,
/// Whether to generate audio. fal's server default is `true` when `None`.
pub audio: Option<bool>,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum ViduQ3ReferenceToVideoAspectRatio {
SixteenByNine,
NineBySixteen,
FourByThree,
ThreeByFour,
Square,
}
impl ViduQ3ReferenceToVideoAspectRatio {
fn to_str(&self) -> &'static str {
match self {
Self::SixteenByNine => "16:9",
Self::NineBySixteen => "9:16",
Self::FourByThree => "4:3",
Self::ThreeByFour => "3:4",
Self::Square => "1:1",
}
}
}
/// Vidu Q3 resolutions. 720p/1080p bill at 2.2× the 360p/540p per-second rate.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum ViduQ3ReferenceToVideoResolution {
ThreeSixtyP,
FiveFortyP,
SevenTwentyP,
TenEightyP,
}
impl ViduQ3ReferenceToVideoResolution {
/// 720p and 1080p are billed at the higher (2.2×) per-second rate.
pub fn is_high_res(&self) -> bool {
matches!(self, Self::SevenTwentyP | Self::TenEightyP)
}
fn to_str(&self) -> &'static str {
match self {
Self::ThreeSixtyP => "360p",
Self::FiveFortyP => "540p",
Self::SevenTwentyP => "720p",
Self::TenEightyP => "1080p",
}
}
}
impl FalEndpoint for ViduQ3ReferenceToVideoRequest {
const ENDPOINT: &str = "fal-ai/vidu/q3/reference-to-video";
type RawRequest = ViduQ3ReferenceToVideoInput;
type RawResponse = ViduQ3ReferenceToVideoOutput;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
Ok(Self::RawRequest {
prompt: self.prompt.clone(),
reference_image_urls: self.reference_image_urls.clone(),
duration: self.duration,
seed: self.seed,
aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()),
resolution: self.resolution.map(|r| r.to_str().to_string()),
audio: self.audio,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
use errors::AnyhowResult;
use std::fs::read_to_string;
use test_data::web::image_urls::{JUNO_AT_LAKE_IMAGE_URL, TALL_MOCHI_WITH_GLASSES_IMAGE_URL};
// ── Real requests (manually run; require a live key and cost money) ──
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_reference_to_video_webhook() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = ViduQ3ReferenceToVideoRequest {
prompt: "the two dogs run side by side across a sunlit meadow".to_string(),
reference_image_urls: vec![
TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(),
JUNO_AT_LAKE_IMAGE_URL.to_string(),
],
duration: Some(5),
seed: None,
aspect_ratio: Some(ViduQ3ReferenceToVideoAspectRatio::SixteenByNine),
resolution: Some(ViduQ3ReferenceToVideoResolution::SevenTwentyP),
audio: Some(false),
};
let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?;
println!("Webhook result: {:?}", result);
assert!(result.request_id.is_some() || result.gateway_request_id.is_some());
Ok(())
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_reference_to_video_queue() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = ViduQ3ReferenceToVideoRequest {
prompt: "the dogs play together in a snowy field".to_string(),
reference_image_urls: vec![TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string()],
duration: Some(5),
seed: Some(1234),
aspect_ratio: None,
resolution: Some(ViduQ3ReferenceToVideoResolution::ThreeSixtyP),
audio: Some(false),
};
let result = request.send_queue_request(&api_key).await?;
println!("Queue result — request_id: {}", result.request_id);
assert!(!result.request_id.is_empty());
Ok(())
}
// ── Wire-shape sanity (no API calls) ──
#[test]
fn raw_request_maps_all_fields() {
let request = ViduQ3ReferenceToVideoRequest {
prompt: "p".to_string(),
reference_image_urls: vec!["https://example.com/a.png".to_string(), "https://example.com/b.png".to_string()],
duration: Some(12),
seed: Some(42),
aspect_ratio: Some(ViduQ3ReferenceToVideoAspectRatio::FourByThree),
resolution: Some(ViduQ3ReferenceToVideoResolution::TenEightyP),
audio: Some(false),
};
let raw = request.to_raw_request().unwrap();
assert_eq!(raw.prompt, "p");
assert_eq!(raw.reference_image_urls.len(), 2);
assert_eq!(raw.duration, Some(12));
assert_eq!(raw.seed, Some(42));
assert_eq!(raw.aspect_ratio.as_deref(), Some("4:3"));
assert_eq!(raw.resolution.as_deref(), Some("1080p"));
assert_eq!(raw.audio, Some(false));
}
#[test]
fn raw_request_omits_unset_optionals() {
let request = ViduQ3ReferenceToVideoRequest {
prompt: "p".to_string(),
reference_image_urls: vec!["https://example.com/a.png".to_string()],
duration: None,
seed: None,
aspect_ratio: None,
resolution: None,
audio: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(
json,
serde_json::json!({ "prompt": "p", "reference_image_urls": ["https://example.com/a.png"] }),
);
}
#[test]
fn seed_serializes_when_set() {
let request = ViduQ3ReferenceToVideoRequest {
prompt: "p".to_string(),
reference_image_urls: vec!["https://example.com/a.png".to_string()],
duration: None,
seed: Some(777),
aspect_ratio: None,
resolution: None,
audio: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(json.get("seed").and_then(|s| s.as_i64()), Some(777));
}
#[test]
fn every_aspect_ratio_maps_to_wire_string() {
for (variant, expected) in [
(ViduQ3ReferenceToVideoAspectRatio::SixteenByNine, "16:9"),
(ViduQ3ReferenceToVideoAspectRatio::NineBySixteen, "9:16"),
(ViduQ3ReferenceToVideoAspectRatio::FourByThree, "4:3"),
(ViduQ3ReferenceToVideoAspectRatio::ThreeByFour, "3:4"),
(ViduQ3ReferenceToVideoAspectRatio::Square, "1:1"),
] {
assert_eq!(variant.to_str(), expected);
}
}
#[test]
fn endpoint_path_is_canonical() {
assert_eq!(
ViduQ3ReferenceToVideoRequest::ENDPOINT,
"fal-ai/vidu/q3/reference-to-video",
);
}
// NB: Pricing tests are in cost.rs
}
@@ -0,0 +1,84 @@
use crate::requests::api::video::reference::vidu_q3_reference_to_video::api::{
ViduQ3ReferenceToVideoRequest, ViduQ3ReferenceToVideoResolution,
};
use crate::requests::api::video::text::vidu_q3::cost::{vidu_q3_cost_cents, vidu_q3_rate_tenth_cents_per_sec};
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
// Reference-to-video shares the Vidu Q3 per-second pricing (see the text module
// for the canonical rate table):
// 360p / 540p: $0.07/sec
// 720p / 1080p: $0.154/sec (2.2×)
// Pricing depends on resolution and duration only.
impl FalRequestCostCalculator for ViduQ3ReferenceToVideoRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
// fal defaults when unset: duration = 5s, resolution = 720p.
let duration_secs = u64::from(self.duration.unwrap_or(5));
let is_high_res = self.resolution
.unwrap_or(ViduQ3ReferenceToVideoResolution::SevenTwentyP)
.is_high_res();
let rate = vidu_q3_rate_tenth_cents_per_sec(is_high_res);
vidu_q3_cost_cents(rate, duration_secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_request(
duration: Option<u8>,
resolution: Option<ViduQ3ReferenceToVideoResolution>,
) -> ViduQ3ReferenceToVideoRequest {
ViduQ3ReferenceToVideoRequest {
prompt: "test".to_string(),
reference_image_urls: vec!["https://example.com/a.png".to_string()],
duration,
seed: None,
aspect_ratio: None,
resolution,
audio: Some(true),
}
}
mod cost_table {
use super::*;
// (duration, resolution, expected_cents)
const COST_TABLE: &[(Option<u8>, Option<ViduQ3ReferenceToVideoResolution>, u64)] = &[
(Some(5), Some(ViduQ3ReferenceToVideoResolution::ThreeSixtyP), 35),
(Some(5), Some(ViduQ3ReferenceToVideoResolution::FiveFortyP), 35),
(Some(5), Some(ViduQ3ReferenceToVideoResolution::SevenTwentyP), 77),
(Some(5), Some(ViduQ3ReferenceToVideoResolution::TenEightyP), 77),
(Some(10), Some(ViduQ3ReferenceToVideoResolution::SevenTwentyP), 154),
(Some(3), Some(ViduQ3ReferenceToVideoResolution::SevenTwentyP), 47), // 46.2 → 47
// Defaults: duration=None→5s, resolution=None→720p
(None, None, 77),
];
#[test]
fn matches_cost_table() {
for &(duration, resolution, expected) in COST_TABLE {
let got = make_request(duration, resolution).calculate_cost_in_cents();
assert_eq!(got, expected, "duration={duration:?} resolution={resolution:?}");
}
}
/// The number of reference images does not affect the bill.
#[test]
fn cost_is_independent_of_image_count() {
let one = make_request(Some(8), Some(ViduQ3ReferenceToVideoResolution::SevenTwentyP)).calculate_cost_in_cents();
let four = ViduQ3ReferenceToVideoRequest {
reference_image_urls: vec![
"https://example.com/a.png".to_string(),
"https://example.com/b.png".to_string(),
"https://example.com/c.png".to_string(),
"https://example.com/d.png".to_string(),
],
..make_request(Some(8), Some(ViduQ3ReferenceToVideoResolution::SevenTwentyP))
}.calculate_cost_in_cents();
assert_eq!(one, four);
}
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -0,0 +1,44 @@
use serde::{Deserialize, Serialize};
/// Over-the-wire input shape for `fal-ai/vidu/q3/reference-to-video`.
/// fal's schema: <https://fal.ai/models/fal-ai/vidu/q3/reference-to-video/api>
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct ViduQ3ReferenceToVideoInput {
/// Text prompt (max 2000 characters).
pub prompt: String,
/// URLs of the reference images (1 to 4) used for subject consistency.
pub reference_image_urls: Vec<String>,
/// Duration in seconds. Range 1–16. fal default: 5.
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<u8>,
/// Seed for reproducibility. Random when omitted.
#[serde(skip_serializing_if = "Option::is_none")]
pub seed: Option<i64>,
/// Aspect ratio.
/// Possible values: "16:9", "9:16", "4:3", "3:4", "1:1". fal default: "16:9".
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
/// Output resolution.
/// Possible values: "360p", "540p", "720p", "1080p". fal default: "720p".
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<String>,
/// Whether to generate audio for the video. fal default: true.
#[serde(skip_serializing_if = "Option::is_none")]
pub audio: Option<bool>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ViduQ3ReferenceToVideoVideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ViduQ3ReferenceToVideoOutput {
pub video: ViduQ3ReferenceToVideoVideoFile,
}
@@ -0,0 +1,219 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::video::reference::vidu_q3_reference_to_video_mix::raw_request::{
ViduQ3ReferenceToVideoMixInput, ViduQ3ReferenceToVideoMixOutput,
};
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
#[derive(Clone, Debug)]
pub struct ViduQ3ReferenceToVideoMixRequest {
/// Text prompt (max 2000 characters).
pub prompt: String,
/// Reference image URLs (1 to 4) used for subject consistency.
pub reference_image_urls: Vec<String>,
/// Duration in seconds. Valid range 1–16; fal's default is `5` when `None`.
pub duration: Option<u8>,
/// Seed for reproducibility. Random when `None`.
pub seed: Option<i64>,
/// Aspect ratio. fal's default is `16:9` when `None`.
pub aspect_ratio: Option<ViduQ3ReferenceToVideoMixAspectRatio>,
/// Output resolution. fal's default is `720p` when `None`.
pub resolution: Option<ViduQ3ReferenceToVideoMixResolution>,
/// Whether to generate audio. fal's server default is `true` when `None`.
pub audio: Option<bool>,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum ViduQ3ReferenceToVideoMixAspectRatio {
SixteenByNine,
NineBySixteen,
FourByThree,
ThreeByFour,
Square,
}
impl ViduQ3ReferenceToVideoMixAspectRatio {
fn to_str(&self) -> &'static str {
match self {
Self::SixteenByNine => "16:9",
Self::NineBySixteen => "9:16",
Self::FourByThree => "4:3",
Self::ThreeByFour => "3:4",
Self::Square => "1:1",
}
}
}
/// Vidu Q3 resolutions. 720p/1080p bill at 2.2× the 360p/540p per-second rate.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum ViduQ3ReferenceToVideoMixResolution {
ThreeSixtyP,
FiveFortyP,
SevenTwentyP,
TenEightyP,
}
impl ViduQ3ReferenceToVideoMixResolution {
/// 720p and 1080p are billed at the higher (2.2×) per-second rate.
pub fn is_high_res(&self) -> bool {
matches!(self, Self::SevenTwentyP | Self::TenEightyP)
}
fn to_str(&self) -> &'static str {
match self {
Self::ThreeSixtyP => "360p",
Self::FiveFortyP => "540p",
Self::SevenTwentyP => "720p",
Self::TenEightyP => "1080p",
}
}
}
impl FalEndpoint for ViduQ3ReferenceToVideoMixRequest {
const ENDPOINT: &str = "fal-ai/vidu/q3/reference-to-video/mix";
type RawRequest = ViduQ3ReferenceToVideoMixInput;
type RawResponse = ViduQ3ReferenceToVideoMixOutput;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
Ok(Self::RawRequest {
prompt: self.prompt.clone(),
reference_image_urls: self.reference_image_urls.clone(),
duration: self.duration,
seed: self.seed,
aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()),
resolution: self.resolution.map(|r| r.to_str().to_string()),
audio: self.audio,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
use errors::AnyhowResult;
use std::fs::read_to_string;
use test_data::web::image_urls::{JUNO_AT_LAKE_IMAGE_URL, TALL_MOCHI_WITH_GLASSES_IMAGE_URL};
// ── Real requests (manually run; require a live key and cost money) ──
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_reference_to_video_mix_webhook() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = ViduQ3ReferenceToVideoMixRequest {
prompt: "the two dogs run side by side across a sunlit meadow".to_string(),
reference_image_urls: vec![
TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(),
JUNO_AT_LAKE_IMAGE_URL.to_string(),
],
duration: Some(5),
seed: None,
aspect_ratio: Some(ViduQ3ReferenceToVideoMixAspectRatio::SixteenByNine),
resolution: Some(ViduQ3ReferenceToVideoMixResolution::SevenTwentyP),
audio: Some(false),
};
let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?;
println!("Webhook result: {:?}", result);
assert!(result.request_id.is_some() || result.gateway_request_id.is_some());
Ok(())
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_reference_to_video_mix_queue() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = ViduQ3ReferenceToVideoMixRequest {
prompt: "the dogs play together in a snowy field".to_string(),
reference_image_urls: vec![TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string()],
duration: Some(5),
seed: Some(1234),
aspect_ratio: None,
resolution: Some(ViduQ3ReferenceToVideoMixResolution::ThreeSixtyP),
audio: Some(false),
};
let result = request.send_queue_request(&api_key).await?;
println!("Queue result — request_id: {}", result.request_id);
assert!(!result.request_id.is_empty());
Ok(())
}
// ── Wire-shape sanity (no API calls) ──
#[test]
fn raw_request_maps_all_fields() {
let request = ViduQ3ReferenceToVideoMixRequest {
prompt: "p".to_string(),
reference_image_urls: vec!["https://example.com/a.png".to_string(), "https://example.com/b.png".to_string()],
duration: Some(12),
seed: Some(42),
aspect_ratio: Some(ViduQ3ReferenceToVideoMixAspectRatio::FourByThree),
resolution: Some(ViduQ3ReferenceToVideoMixResolution::TenEightyP),
audio: Some(false),
};
let raw = request.to_raw_request().unwrap();
assert_eq!(raw.prompt, "p");
assert_eq!(raw.reference_image_urls.len(), 2);
assert_eq!(raw.duration, Some(12));
assert_eq!(raw.seed, Some(42));
assert_eq!(raw.aspect_ratio.as_deref(), Some("4:3"));
assert_eq!(raw.resolution.as_deref(), Some("1080p"));
assert_eq!(raw.audio, Some(false));
}
#[test]
fn raw_request_omits_unset_optionals() {
let request = ViduQ3ReferenceToVideoMixRequest {
prompt: "p".to_string(),
reference_image_urls: vec!["https://example.com/a.png".to_string()],
duration: None,
seed: None,
aspect_ratio: None,
resolution: None,
audio: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(
json,
serde_json::json!({ "prompt": "p", "reference_image_urls": ["https://example.com/a.png"] }),
);
}
#[test]
fn seed_serializes_when_set() {
let request = ViduQ3ReferenceToVideoMixRequest {
prompt: "p".to_string(),
reference_image_urls: vec!["https://example.com/a.png".to_string()],
duration: None,
seed: Some(777),
aspect_ratio: None,
resolution: None,
audio: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(json.get("seed").and_then(|s| s.as_i64()), Some(777));
}
#[test]
fn endpoint_path_is_canonical() {
assert_eq!(
ViduQ3ReferenceToVideoMixRequest::ENDPOINT,
"fal-ai/vidu/q3/reference-to-video/mix",
);
}
// NB: Pricing tests are in cost.rs
}
@@ -0,0 +1,84 @@
use crate::requests::api::video::reference::vidu_q3_reference_to_video_mix::api::{
ViduQ3ReferenceToVideoMixRequest, ViduQ3ReferenceToVideoMixResolution,
};
use crate::requests::api::video::text::vidu_q3::cost::{vidu_q3_cost_cents, vidu_q3_rate_tenth_cents_per_sec};
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
// The "mix" variant shares the Vidu Q3 per-second pricing (see the text module
// for the canonical rate table):
// 360p / 540p: $0.07/sec
// 720p / 1080p: $0.154/sec (2.2×)
// Pricing depends on resolution and duration only.
impl FalRequestCostCalculator for ViduQ3ReferenceToVideoMixRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
// fal defaults when unset: duration = 5s, resolution = 720p.
let duration_secs = u64::from(self.duration.unwrap_or(5));
let is_high_res = self.resolution
.unwrap_or(ViduQ3ReferenceToVideoMixResolution::SevenTwentyP)
.is_high_res();
let rate = vidu_q3_rate_tenth_cents_per_sec(is_high_res);
vidu_q3_cost_cents(rate, duration_secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_request(
duration: Option<u8>,
resolution: Option<ViduQ3ReferenceToVideoMixResolution>,
) -> ViduQ3ReferenceToVideoMixRequest {
ViduQ3ReferenceToVideoMixRequest {
prompt: "test".to_string(),
reference_image_urls: vec!["https://example.com/a.png".to_string()],
duration,
seed: None,
aspect_ratio: None,
resolution,
audio: Some(true),
}
}
mod cost_table {
use super::*;
// (duration, resolution, expected_cents) — identical to base Vidu Q3.
const COST_TABLE: &[(Option<u8>, Option<ViduQ3ReferenceToVideoMixResolution>, u64)] = &[
(Some(5), Some(ViduQ3ReferenceToVideoMixResolution::ThreeSixtyP), 35),
(Some(5), Some(ViduQ3ReferenceToVideoMixResolution::FiveFortyP), 35),
(Some(5), Some(ViduQ3ReferenceToVideoMixResolution::SevenTwentyP), 77),
(Some(5), Some(ViduQ3ReferenceToVideoMixResolution::TenEightyP), 77),
(Some(10), Some(ViduQ3ReferenceToVideoMixResolution::SevenTwentyP), 154),
(Some(3), Some(ViduQ3ReferenceToVideoMixResolution::SevenTwentyP), 47), // 46.2 → 47
// Defaults: duration=None→5s, resolution=None→720p
(None, None, 77),
];
#[test]
fn matches_cost_table() {
for &(duration, resolution, expected) in COST_TABLE {
let got = make_request(duration, resolution).calculate_cost_in_cents();
assert_eq!(got, expected, "duration={duration:?} resolution={resolution:?}");
}
}
/// The number of reference images does not affect the bill.
#[test]
fn cost_is_independent_of_image_count() {
let one = make_request(Some(8), Some(ViduQ3ReferenceToVideoMixResolution::SevenTwentyP)).calculate_cost_in_cents();
let four = ViduQ3ReferenceToVideoMixRequest {
reference_image_urls: vec![
"https://example.com/a.png".to_string(),
"https://example.com/b.png".to_string(),
"https://example.com/c.png".to_string(),
"https://example.com/d.png".to_string(),
],
..make_request(Some(8), Some(ViduQ3ReferenceToVideoMixResolution::SevenTwentyP))
}.calculate_cost_in_cents();
assert_eq!(one, four);
}
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -0,0 +1,47 @@
use serde::{Deserialize, Serialize};
/// Over-the-wire input shape for `fal-ai/vidu/q3/reference-to-video/mix`.
/// fal's schema: <https://fal.ai/models/fal-ai/vidu/q3/reference-to-video/mix/api>
///
/// The "mix" variant shares the base reference-to-video input schema; it
/// differs only in the server-side generation behavior.
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct ViduQ3ReferenceToVideoMixInput {
/// Text prompt (max 2000 characters).
pub prompt: String,
/// URLs of the reference images (1 to 4) used for subject consistency.
pub reference_image_urls: Vec<String>,
/// Duration in seconds. Range 1–16. fal default: 5.
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<u8>,
/// Seed for reproducibility. Random when omitted.
#[serde(skip_serializing_if = "Option::is_none")]
pub seed: Option<i64>,
/// Aspect ratio.
/// Possible values: "16:9", "9:16", "4:3", "3:4", "1:1". fal default: "16:9".
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
/// Output resolution.
/// Possible values: "360p", "540p", "720p", "1080p". fal default: "720p".
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<String>,
/// Whether to generate audio for the video. fal default: true.
#[serde(skip_serializing_if = "Option::is_none")]
pub audio: Option<bool>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ViduQ3ReferenceToVideoMixVideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ViduQ3ReferenceToVideoMixOutput {
pub video: ViduQ3ReferenceToVideoMixVideoFile,
}
@@ -8,3 +8,5 @@ pub mod veo_3_fast;
pub mod veo_3p1;
pub mod veo_3p1_fast;
pub mod veo_3p1_lite;
pub mod vidu_q3;
pub mod vidu_q3_turbo;
@@ -0,0 +1,225 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::video::text::vidu_q3::raw_request::{
ViduQ3TextToVideoInput, ViduQ3TextToVideoOutput,
};
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
#[derive(Clone, Debug)]
pub struct ViduQ3TextToVideoRequest {
/// Text prompt (max 2000 characters).
pub prompt: String,
/// Duration in seconds. Valid range 1–16; fal's default is `5` when `None`.
pub duration: Option<u8>,
/// Seed for reproducibility. Random when `None`.
pub seed: Option<i64>,
/// Aspect ratio. fal's default is `16:9` when `None`.
pub aspect_ratio: Option<ViduQ3TextToVideoAspectRatio>,
/// Output resolution. fal's default is `720p` when `None`.
pub resolution: Option<ViduQ3TextToVideoResolution>,
/// Whether to generate audio. fal's server default is `true` when `None`.
pub audio: Option<bool>,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum ViduQ3TextToVideoAspectRatio {
SixteenByNine,
NineBySixteen,
FourByThree,
ThreeByFour,
Square,
}
impl ViduQ3TextToVideoAspectRatio {
fn to_str(&self) -> &'static str {
match self {
Self::SixteenByNine => "16:9",
Self::NineBySixteen => "9:16",
Self::FourByThree => "4:3",
Self::ThreeByFour => "3:4",
Self::Square => "1:1",
}
}
}
/// Vidu Q3 resolutions. 720p/1080p bill at 2.2× the 360p/540p per-second rate.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum ViduQ3TextToVideoResolution {
ThreeSixtyP,
FiveFortyP,
SevenTwentyP,
TenEightyP,
}
impl ViduQ3TextToVideoResolution {
/// 720p and 1080p are billed at the higher (2.2×) per-second rate.
pub fn is_high_res(&self) -> bool {
matches!(self, Self::SevenTwentyP | Self::TenEightyP)
}
fn to_str(&self) -> &'static str {
match self {
Self::ThreeSixtyP => "360p",
Self::FiveFortyP => "540p",
Self::SevenTwentyP => "720p",
Self::TenEightyP => "1080p",
}
}
}
impl FalEndpoint for ViduQ3TextToVideoRequest {
const ENDPOINT: &str = "fal-ai/vidu/q3/text-to-video";
type RawRequest = ViduQ3TextToVideoInput;
type RawResponse = ViduQ3TextToVideoOutput;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
Ok(Self::RawRequest {
prompt: self.prompt.clone(),
duration: self.duration,
seed: self.seed,
aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()),
resolution: self.resolution.map(|r| r.to_str().to_string()),
audio: self.audio,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
use errors::AnyhowResult;
use std::fs::read_to_string;
// ── Real requests (manually run; require a live key and cost money) ──
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_text_to_video_webhook() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = ViduQ3TextToVideoRequest {
prompt: "a golden retriever puppy chases butterflies through a sunlit meadow".to_string(),
duration: Some(5),
seed: None,
aspect_ratio: Some(ViduQ3TextToVideoAspectRatio::SixteenByNine),
resolution: Some(ViduQ3TextToVideoResolution::SevenTwentyP),
audio: Some(true),
};
let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?;
println!("Webhook result: {:?}", result);
assert!(result.request_id.is_some() || result.gateway_request_id.is_some());
Ok(())
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_text_to_video_queue() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = ViduQ3TextToVideoRequest {
prompt: "a wave crashes against a rocky shoreline at sunset".to_string(),
duration: Some(5),
seed: Some(1234),
aspect_ratio: Some(ViduQ3TextToVideoAspectRatio::SixteenByNine),
resolution: Some(ViduQ3TextToVideoResolution::ThreeSixtyP),
audio: Some(false),
};
let result = request.send_queue_request(&api_key).await?;
println!("Queue result — request_id: {}", result.request_id);
assert!(!result.request_id.is_empty());
Ok(())
}
// ── Wire-shape sanity (no API calls) ──
#[test]
fn raw_request_maps_all_fields() {
let request = ViduQ3TextToVideoRequest {
prompt: "p".to_string(),
duration: Some(12),
seed: Some(42),
aspect_ratio: Some(ViduQ3TextToVideoAspectRatio::FourByThree),
resolution: Some(ViduQ3TextToVideoResolution::TenEightyP),
audio: Some(false),
};
let raw = request.to_raw_request().unwrap();
assert_eq!(raw.prompt, "p");
assert_eq!(raw.duration, Some(12));
assert_eq!(raw.seed, Some(42));
assert_eq!(raw.aspect_ratio.as_deref(), Some("4:3"));
assert_eq!(raw.resolution.as_deref(), Some("1080p"));
assert_eq!(raw.audio, Some(false));
}
#[test]
fn raw_request_omits_unset_optionals() {
let request = ViduQ3TextToVideoRequest {
prompt: "minimal".to_string(),
duration: None,
seed: None,
aspect_ratio: None,
resolution: None,
audio: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(json, serde_json::json!({ "prompt": "minimal" }));
}
#[test]
fn seed_serializes_when_set() {
let request = ViduQ3TextToVideoRequest {
prompt: "p".to_string(),
duration: None,
seed: Some(777),
aspect_ratio: None,
resolution: None,
audio: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(json.get("seed").and_then(|s| s.as_i64()), Some(777));
}
#[test]
fn every_aspect_ratio_maps_to_wire_string() {
for (variant, expected) in [
(ViduQ3TextToVideoAspectRatio::SixteenByNine, "16:9"),
(ViduQ3TextToVideoAspectRatio::NineBySixteen, "9:16"),
(ViduQ3TextToVideoAspectRatio::FourByThree, "4:3"),
(ViduQ3TextToVideoAspectRatio::ThreeByFour, "3:4"),
(ViduQ3TextToVideoAspectRatio::Square, "1:1"),
] {
assert_eq!(variant.to_str(), expected);
}
}
#[test]
fn every_resolution_maps_to_wire_string() {
for (variant, expected, high) in [
(ViduQ3TextToVideoResolution::ThreeSixtyP, "360p", false),
(ViduQ3TextToVideoResolution::FiveFortyP, "540p", false),
(ViduQ3TextToVideoResolution::SevenTwentyP, "720p", true),
(ViduQ3TextToVideoResolution::TenEightyP, "1080p", true),
] {
assert_eq!(variant.to_str(), expected);
assert_eq!(variant.is_high_res(), high);
}
}
#[test]
fn endpoint_path_is_canonical() {
assert_eq!(ViduQ3TextToVideoRequest::ENDPOINT, "fal-ai/vidu/q3/text-to-video");
}
// NB: Pricing tests are in cost.rs
}
@@ -0,0 +1,125 @@
use crate::requests::api::video::text::vidu_q3::api::{
ViduQ3TextToVideoRequest, ViduQ3TextToVideoResolution,
};
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
// Vidu Q3 pricing (see https://fal.ai/models/fal-ai/vidu/q3/text-to-video):
// "0.07 $ per video second for 360p and 540p, cost will be 2.2x for 720p and
// 1080p resolution."
// 360p / 540p: $0.07/sec
// 720p / 1080p: $0.154/sec (2.2 × $0.07)
//
// Pricing depends on resolution and duration only (audio/aspect ratio are
// free). Rates are stored in tenths-of-a-cent per second (both exact), then
// rounded UP to whole cents so the user is never undercharged. Shared by the
// text + image modalities.
const RATE_LOW_RES_TENTH_CENTS: u64 = 70; // $0.07/sec (360p/540p)
const RATE_HIGH_RES_TENTH_CENTS: u64 = 154; // $0.154/sec (720p/1080p)
/// Per-second rate in tenths-of-a-cent for Vidu Q3. `is_high_res` selects the
/// 2.2× 720p/1080p tier.
pub(crate) fn vidu_q3_rate_tenth_cents_per_sec(is_high_res: bool) -> u64 {
if is_high_res { RATE_HIGH_RES_TENTH_CENTS } else { RATE_LOW_RES_TENTH_CENTS }
}
/// ceil(rate_tenth_cents × seconds / 10) → whole cents.
pub(crate) fn vidu_q3_cost_cents(rate_tenth_cents_per_sec: u64, duration_secs: u64) -> UsdCents {
(rate_tenth_cents_per_sec * duration_secs + 9) / 10
}
impl FalRequestCostCalculator for ViduQ3TextToVideoRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
// fal defaults when unset: duration = 5s, resolution = 720p.
let duration_secs = u64::from(self.duration.unwrap_or(5));
let is_high_res = self.resolution
.unwrap_or(ViduQ3TextToVideoResolution::SevenTwentyP)
.is_high_res();
let rate = vidu_q3_rate_tenth_cents_per_sec(is_high_res);
vidu_q3_cost_cents(rate, duration_secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::requests::api::video::text::vidu_q3::api::ViduQ3TextToVideoAspectRatio;
fn make_request(
duration: Option<u8>,
resolution: Option<ViduQ3TextToVideoResolution>,
audio: Option<bool>,
) -> ViduQ3TextToVideoRequest {
ViduQ3TextToVideoRequest {
prompt: "test".to_string(),
duration,
seed: None,
aspect_ratio: Some(ViduQ3TextToVideoAspectRatio::SixteenByNine),
resolution,
audio,
}
}
mod cost_table {
use super::*;
// (duration, resolution, expected_cents)
// Math: ceil(rate × secs / 10) where rate (tenth-cents) = 70 (360p/540p)
// or 154 (720p/1080p).
const COST_TABLE: &[(Option<u8>, Option<ViduQ3TextToVideoResolution>, u64)] = &[
// 360p / 540p → $0.07/s
(Some(5), Some(ViduQ3TextToVideoResolution::ThreeSixtyP), 35),
(Some(5), Some(ViduQ3TextToVideoResolution::FiveFortyP), 35),
(Some(16), Some(ViduQ3TextToVideoResolution::ThreeSixtyP), 112),
// 720p / 1080p → $0.154/s
(Some(5), Some(ViduQ3TextToVideoResolution::SevenTwentyP), 77),
(Some(5), Some(ViduQ3TextToVideoResolution::TenEightyP), 77),
(Some(10), Some(ViduQ3TextToVideoResolution::SevenTwentyP), 154),
(Some(3), Some(ViduQ3TextToVideoResolution::SevenTwentyP), 47), // 46.2 → 47
// Defaults: duration=None→5s, resolution=None→720p
(None, None, 77),
(Some(1), Some(ViduQ3TextToVideoResolution::ThreeSixtyP), 7),
];
#[test]
fn matches_cost_table() {
for &(duration, resolution, expected) in COST_TABLE {
let got = make_request(duration, resolution, Some(true)).calculate_cost_in_cents();
assert_eq!(got, expected, "duration={duration:?} resolution={resolution:?}");
}
}
#[test]
fn high_res_is_more_expensive_than_low_res() {
let low = make_request(Some(8), Some(ViduQ3TextToVideoResolution::FiveFortyP), Some(true)).calculate_cost_in_cents();
let high = make_request(Some(8), Some(ViduQ3TextToVideoResolution::SevenTwentyP), Some(true)).calculate_cost_in_cents();
assert!(high > low, "high={high}¢ should exceed low={low}¢");
}
#[test]
fn cost_scales_linearly_with_duration() {
let five = make_request(Some(5), Some(ViduQ3TextToVideoResolution::ThreeSixtyP), Some(true)).calculate_cost_in_cents();
let ten = make_request(Some(10), Some(ViduQ3TextToVideoResolution::ThreeSixtyP), Some(true)).calculate_cost_in_cents();
assert_eq!(ten, five * 2);
}
/// Audio and aspect ratio do not affect the bill (only resolution + duration do).
#[test]
fn cost_ignores_audio_and_aspect_ratio() {
let baseline = make_request(Some(8), Some(ViduQ3TextToVideoResolution::SevenTwentyP), Some(true)).calculate_cost_in_cents();
for audio in [None, Some(false), Some(true)] {
for ar in [
ViduQ3TextToVideoAspectRatio::SixteenByNine,
ViduQ3TextToVideoAspectRatio::Square,
ViduQ3TextToVideoAspectRatio::ThreeByFour,
] {
let cost = ViduQ3TextToVideoRequest {
aspect_ratio: Some(ar),
..make_request(Some(8), Some(ViduQ3TextToVideoResolution::SevenTwentyP), audio)
}.calculate_cost_in_cents();
assert_eq!(cost, baseline, "audio={audio:?} ar={ar:?}");
}
}
}
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -0,0 +1,41 @@
use serde::{Deserialize, Serialize};
/// Over-the-wire input shape for `fal-ai/vidu/q3/text-to-video`.
/// fal's schema: <https://fal.ai/models/fal-ai/vidu/q3/text-to-video/api>
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct ViduQ3TextToVideoInput {
/// Text prompt (max 2000 characters).
pub prompt: String,
/// Duration in seconds. Range 1–16. fal default: 5.
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<u8>,
/// Seed for reproducibility. Random when omitted.
#[serde(skip_serializing_if = "Option::is_none")]
pub seed: Option<i64>,
/// Aspect ratio.
/// Possible values: "16:9", "9:16", "4:3", "3:4", "1:1". fal default: "16:9".
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
/// Output resolution.
/// Possible values: "360p", "540p", "720p", "1080p". fal default: "720p".
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<String>,
/// Whether to generate audio for the video. fal default: true.
#[serde(skip_serializing_if = "Option::is_none")]
pub audio: Option<bool>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ViduQ3TextToVideoVideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ViduQ3TextToVideoOutput {
pub video: ViduQ3TextToVideoVideoFile,
}
@@ -0,0 +1,202 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::video::text::vidu_q3_turbo::raw_request::{
ViduQ3TurboTextToVideoInput, ViduQ3TurboTextToVideoOutput,
};
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
#[derive(Clone, Debug)]
pub struct ViduQ3TurboTextToVideoRequest {
/// Text prompt (max 2000 characters).
pub prompt: String,
/// Duration in seconds. Valid range 1–16; fal's default is `5` when `None`.
pub duration: Option<u8>,
/// Seed for reproducibility. Random when `None`.
pub seed: Option<i64>,
/// Aspect ratio. fal's default is `16:9` when `None`.
pub aspect_ratio: Option<ViduQ3TurboTextToVideoAspectRatio>,
/// Output resolution. fal's default is `720p` when `None`.
pub resolution: Option<ViduQ3TurboTextToVideoResolution>,
/// Whether to generate audio. fal's server default is `true` when `None`.
pub audio: Option<bool>,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum ViduQ3TurboTextToVideoAspectRatio {
SixteenByNine,
NineBySixteen,
FourByThree,
ThreeByFour,
Square,
}
impl ViduQ3TurboTextToVideoAspectRatio {
fn to_str(&self) -> &'static str {
match self {
Self::SixteenByNine => "16:9",
Self::NineBySixteen => "9:16",
Self::FourByThree => "4:3",
Self::ThreeByFour => "3:4",
Self::Square => "1:1",
}
}
}
/// Vidu Q3 Turbo resolutions. 720p/1080p bill at 2.2× the 360p/540p rate.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum ViduQ3TurboTextToVideoResolution {
ThreeSixtyP,
FiveFortyP,
SevenTwentyP,
TenEightyP,
}
impl ViduQ3TurboTextToVideoResolution {
/// 720p and 1080p are billed at the higher (2.2×) per-second rate.
pub fn is_high_res(&self) -> bool {
matches!(self, Self::SevenTwentyP | Self::TenEightyP)
}
fn to_str(&self) -> &'static str {
match self {
Self::ThreeSixtyP => "360p",
Self::FiveFortyP => "540p",
Self::SevenTwentyP => "720p",
Self::TenEightyP => "1080p",
}
}
}
impl FalEndpoint for ViduQ3TurboTextToVideoRequest {
const ENDPOINT: &str = "fal-ai/vidu/q3/text-to-video/turbo";
type RawRequest = ViduQ3TurboTextToVideoInput;
type RawResponse = ViduQ3TurboTextToVideoOutput;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
Ok(Self::RawRequest {
prompt: self.prompt.clone(),
duration: self.duration,
seed: self.seed,
aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()),
resolution: self.resolution.map(|r| r.to_str().to_string()),
audio: self.audio,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
use errors::AnyhowResult;
use std::fs::read_to_string;
// ── Real requests (manually run; require a live key and cost money) ──
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_text_to_video_webhook() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = ViduQ3TurboTextToVideoRequest {
prompt: "a golden retriever puppy chases butterflies through a sunlit meadow".to_string(),
duration: Some(5),
seed: None,
aspect_ratio: Some(ViduQ3TurboTextToVideoAspectRatio::SixteenByNine),
resolution: Some(ViduQ3TurboTextToVideoResolution::SevenTwentyP),
audio: Some(true),
};
let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?;
println!("Webhook result: {:?}", result);
assert!(result.request_id.is_some() || result.gateway_request_id.is_some());
Ok(())
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_text_to_video_queue() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = ViduQ3TurboTextToVideoRequest {
prompt: "a wave crashes against a rocky shoreline at sunset".to_string(),
duration: Some(5),
seed: Some(1234),
aspect_ratio: Some(ViduQ3TurboTextToVideoAspectRatio::SixteenByNine),
resolution: Some(ViduQ3TurboTextToVideoResolution::ThreeSixtyP),
audio: Some(false),
};
let result = request.send_queue_request(&api_key).await?;
println!("Queue result — request_id: {}", result.request_id);
assert!(!result.request_id.is_empty());
Ok(())
}
// ── Wire-shape sanity (no API calls) ──
#[test]
fn raw_request_maps_all_fields() {
let request = ViduQ3TurboTextToVideoRequest {
prompt: "p".to_string(),
duration: Some(12),
seed: Some(42),
aspect_ratio: Some(ViduQ3TurboTextToVideoAspectRatio::FourByThree),
resolution: Some(ViduQ3TurboTextToVideoResolution::TenEightyP),
audio: Some(false),
};
let raw = request.to_raw_request().unwrap();
assert_eq!(raw.prompt, "p");
assert_eq!(raw.duration, Some(12));
assert_eq!(raw.seed, Some(42));
assert_eq!(raw.aspect_ratio.as_deref(), Some("4:3"));
assert_eq!(raw.resolution.as_deref(), Some("1080p"));
assert_eq!(raw.audio, Some(false));
}
#[test]
fn raw_request_omits_unset_optionals() {
let request = ViduQ3TurboTextToVideoRequest {
prompt: "minimal".to_string(),
duration: None,
seed: None,
aspect_ratio: None,
resolution: None,
audio: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(json, serde_json::json!({ "prompt": "minimal" }));
}
#[test]
fn seed_serializes_when_set() {
let request = ViduQ3TurboTextToVideoRequest {
prompt: "p".to_string(),
duration: None,
seed: Some(777),
aspect_ratio: None,
resolution: None,
audio: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(json.get("seed").and_then(|s| s.as_i64()), Some(777));
}
#[test]
fn endpoint_path_is_canonical() {
assert_eq!(
ViduQ3TurboTextToVideoRequest::ENDPOINT,
"fal-ai/vidu/q3/text-to-video/turbo",
);
}
// NB: Pricing tests are in cost.rs
}
@@ -0,0 +1,131 @@
use crate::requests::api::video::text::vidu_q3_turbo::api::{
ViduQ3TurboTextToVideoRequest, ViduQ3TurboTextToVideoResolution,
};
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
// Vidu Q3 Turbo pricing (see https://fal.ai/models/fal-ai/vidu/q3/text-to-video/turbo):
// "0.035 $ per video second for 360p and 540p, cost will be 2.2x for 720p
// and 1080p resolution."
// 360p / 540p: $0.035/sec
// 720p / 1080p: $0.077/sec (2.2 × $0.035)
//
// Half the Vidu Q3 rate. Pricing depends on resolution and duration only.
// Rates are stored in tenths-of-a-cent per second (both exact), then rounded
// UP to whole cents so the user is never undercharged. Shared by text + image.
const RATE_LOW_RES_TENTH_CENTS: u64 = 35; // $0.035/sec (360p/540p)
const RATE_HIGH_RES_TENTH_CENTS: u64 = 77; // $0.077/sec (720p/1080p)
/// Per-second rate in tenths-of-a-cent for Vidu Q3 Turbo. `is_high_res`
/// selects the 2.2× 720p/1080p tier.
pub(crate) fn vidu_q3_turbo_rate_tenth_cents_per_sec(is_high_res: bool) -> u64 {
if is_high_res { RATE_HIGH_RES_TENTH_CENTS } else { RATE_LOW_RES_TENTH_CENTS }
}
/// ceil(rate_tenth_cents × seconds / 10) → whole cents.
pub(crate) fn vidu_q3_turbo_cost_cents(rate_tenth_cents_per_sec: u64, duration_secs: u64) -> UsdCents {
(rate_tenth_cents_per_sec * duration_secs + 9) / 10
}
impl FalRequestCostCalculator for ViduQ3TurboTextToVideoRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
// fal defaults when unset: duration = 5s, resolution = 720p.
let duration_secs = u64::from(self.duration.unwrap_or(5));
let is_high_res = self.resolution
.unwrap_or(ViduQ3TurboTextToVideoResolution::SevenTwentyP)
.is_high_res();
let rate = vidu_q3_turbo_rate_tenth_cents_per_sec(is_high_res);
vidu_q3_turbo_cost_cents(rate, duration_secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::requests::api::video::text::vidu_q3_turbo::api::ViduQ3TurboTextToVideoAspectRatio;
fn make_request(
duration: Option<u8>,
resolution: Option<ViduQ3TurboTextToVideoResolution>,
audio: Option<bool>,
) -> ViduQ3TurboTextToVideoRequest {
ViduQ3TurboTextToVideoRequest {
prompt: "test".to_string(),
duration,
seed: None,
aspect_ratio: Some(ViduQ3TurboTextToVideoAspectRatio::SixteenByNine),
resolution,
audio,
}
}
mod cost_table {
use super::*;
// (duration, resolution, expected_cents)
// Math: ceil(rate × secs / 10) where rate (tenth-cents) = 35 (360p/540p)
// or 77 (720p/1080p).
const COST_TABLE: &[(Option<u8>, Option<ViduQ3TurboTextToVideoResolution>, u64)] = &[
// 360p / 540p → $0.035/s (rounds up)
(Some(5), Some(ViduQ3TurboTextToVideoResolution::ThreeSixtyP), 18), // 17.5 → 18
(Some(5), Some(ViduQ3TurboTextToVideoResolution::FiveFortyP), 18),
(Some(16), Some(ViduQ3TurboTextToVideoResolution::ThreeSixtyP), 56),
(Some(1), Some(ViduQ3TurboTextToVideoResolution::ThreeSixtyP), 4), // 3.5 → 4
// 720p / 1080p → $0.077/s (rounds up)
(Some(5), Some(ViduQ3TurboTextToVideoResolution::SevenTwentyP), 39), // 38.5 → 39
(Some(5), Some(ViduQ3TurboTextToVideoResolution::TenEightyP), 39),
(Some(10), Some(ViduQ3TurboTextToVideoResolution::SevenTwentyP), 77),
(Some(3), Some(ViduQ3TurboTextToVideoResolution::SevenTwentyP), 24), // 23.1 → 24
// Defaults: duration=None→5s, resolution=None→720p
(None, None, 39),
];
#[test]
fn matches_cost_table() {
for &(duration, resolution, expected) in COST_TABLE {
let got = make_request(duration, resolution, Some(true)).calculate_cost_in_cents();
assert_eq!(got, expected, "duration={duration:?} resolution={resolution:?}");
}
}
/// Turbo is roughly half the price of Q3 at the same settings (both round
/// up to whole cents, so it is not exactly half at every cell).
#[test]
fn turbo_high_res_rate_is_half_of_q3() {
use crate::requests::api::video::text::vidu_q3::cost::vidu_q3_rate_tenth_cents_per_sec;
assert_eq!(
vidu_q3_turbo_rate_tenth_cents_per_sec(true) * 2,
vidu_q3_rate_tenth_cents_per_sec(true),
);
assert_eq!(
vidu_q3_turbo_rate_tenth_cents_per_sec(false) * 2,
vidu_q3_rate_tenth_cents_per_sec(false),
);
}
#[test]
fn high_res_is_more_expensive_than_low_res() {
let low = make_request(Some(8), Some(ViduQ3TurboTextToVideoResolution::FiveFortyP), Some(true)).calculate_cost_in_cents();
let high = make_request(Some(8), Some(ViduQ3TurboTextToVideoResolution::SevenTwentyP), Some(true)).calculate_cost_in_cents();
assert!(high > low, "high={high}¢ should exceed low={low}¢");
}
/// Audio and aspect ratio do not affect the bill.
#[test]
fn cost_ignores_audio_and_aspect_ratio() {
let baseline = make_request(Some(8), Some(ViduQ3TurboTextToVideoResolution::SevenTwentyP), Some(true)).calculate_cost_in_cents();
for audio in [None, Some(false), Some(true)] {
for ar in [
ViduQ3TurboTextToVideoAspectRatio::SixteenByNine,
ViduQ3TurboTextToVideoAspectRatio::Square,
] {
let cost = ViduQ3TurboTextToVideoRequest {
aspect_ratio: Some(ar),
..make_request(Some(8), Some(ViduQ3TurboTextToVideoResolution::SevenTwentyP), audio)
}.calculate_cost_in_cents();
assert_eq!(cost, baseline, "audio={audio:?} ar={ar:?}");
}
}
}
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -0,0 +1,41 @@
use serde::{Deserialize, Serialize};
/// Over-the-wire input shape for `fal-ai/vidu/q3/text-to-video/turbo`.
/// fal's schema: <https://fal.ai/models/fal-ai/vidu/q3/text-to-video/turbo/api>
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct ViduQ3TurboTextToVideoInput {
/// Text prompt (max 2000 characters).
pub prompt: String,
/// Duration in seconds. Range 1–16. fal default: 5.
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<u8>,
/// Seed for reproducibility. Random when omitted.
#[serde(skip_serializing_if = "Option::is_none")]
pub seed: Option<i64>,
/// Aspect ratio.
/// Possible values: "16:9", "9:16", "4:3", "3:4", "1:1". fal default: "16:9".
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
/// Output resolution.
/// Possible values: "360p", "540p", "720p", "1080p". fal default: "720p".
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<String>,
/// Whether to generate audio for the video. fal default: true.
#[serde(skip_serializing_if = "Option::is_none")]
pub audio: Option<bool>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ViduQ3TurboTextToVideoVideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ViduQ3TurboTextToVideoOutput {
pub video: ViduQ3TurboTextToVideoVideoFile,
}