mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-22 01:29:31 -04:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4853df057 | ||
|
|
40b05b82b8 |
@@ -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;
|
||||
+47
@@ -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;
|
||||
|
||||
+232
@@ -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
|
||||
}
|
||||
+84
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod api;
|
||||
pub mod cost;
|
||||
pub mod raw_request;
|
||||
+44
@@ -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,
|
||||
}
|
||||
+219
@@ -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
|
||||
}
|
||||
+84
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod api;
|
||||
pub mod cost;
|
||||
pub mod raw_request;
|
||||
+47
@@ -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;
|
||||
+41
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user