Compare commits

...
Author SHA1 Message Date
Brandon Thomas c65a66655a performance 2026-07-02 16:38:11 -04:00
Brandon Thomas 23cbb03b39 codegen db 2026-07-02 16:36:17 -04:00
Brandon Thomas d3cb23753a format page 2026-07-02 16:32:26 -04:00
Brandon Thomas 47c0cd23f8 frontend updates 2026-07-02 16:27:32 -04:00
Brandon Thomas f398b3af99 sticky footers 2026-07-02 16:15:42 -04:00
Brandon Thomas 8bd0e9219b links 2026-07-02 16:11:56 -04:00
Brandon Thomas 298aff708e frontend update 2026-07-02 16:06:23 -04:00
Brandon Thomas 197ec95012 debug logs: new field, new queries, new APIs 2026-07-02 15:54:59 -04:00
Brandon Thomas cbd3710f4f seedance text prompt max length (#1721) 2026-07-02 11:26:02 -04:00
BFlat 044c6d8a88 Model list cleanup: shared omni-gen hooks, dead code removal (#1719)
### New shared lib: `@storyteller/omni-gen`
- The `omni-gen-hooks.ts` file was copy-pasted between `artcraft-webapp`
and
`artcraft-website` and had drifted (website copy was missing outage
toasts,
carried a stale hardcoded display-name map, and had weaker icon
resolution).
- Extracted the newer (webapp) version into a new lib
`frontend/libs/omni-gen`
  (generated via `nx g @nx/react:library`): singleton session cache,
`useOmniGenImageModels()` / `useOmniGenVideoModels()`, outage message
constants.
- App-local toasts are injected via `setOmniGenErrorNotifier()`, called
once in
each app's `main.tsx`, since the hooks are singleton-cache based and
each app
  has its own toast component.

### Consolidated model metadata into `@storyteller/model-list`
- `getModelDescription()` / `getModelInfo()` (tagline + info-blurb
fallbacks)
  moved to `ModelDescriptions.ts`; API values still always win.
- `getModelDisplayName()` gained an optional `fullName` param (API name
wins);
  the app-local wrapper is gone.
- Both apps' local `getModelCreatorIconPath()` replaced with the
existing,
stronger `getCreatorIconPathForModelId()` (checks `ALL_MODELS_LIST`
first,
then prefix match). The loader's duplicate `CREATOR_BY_PREFIX` map now
reuses
  the same shared `MODEL_ID_PREFIX_CREATORS` table.

### Dead code removed
- `modelReconciliation.ts`: deprecated `reconcile*()` helpers (never
called) and
the empty, retired `BACKEND_TO_TAURI_*` alias maps.
`buildModelsFromListing()`
dropped its alias-map indirection accordingly (backend ids == tauriIds
since
the 2026-07 id migration). `modelCreatorFromBackend()` moved inline into
the
  loader, its only consumer.
- Six unused static `*_PAGE_MODEL_LIST` constants in
`model-selectors-for-pages.tsx`
(superseded by the `use*PageModelList` hooks in #1710). The two
`IMAGE_TO_3D_*`
  constants stay: 3D models are not served by the omni backend.
- Untracked the accidentally committed
`apps/artcraft/tsconfig.tsbuildinfo` and
  gitignored `*.tsbuildinfo`.

### Website-visible improvements (from adopting the newer hooks)
- Model outage toasts now appear on the website (previously
webapp-only).
- Current display names from the canonical map (previously-unmapped ids
like
  `gpt_image_2` and Midjourney models rendered as raw ids).
- Model taglines/descriptions now render in the website model picker.
- Midjourney and SwitchX creator icons now resolve.
2026-07-02 15:06:15 +07:00
Brandon Thomas 353c49c55f bump artcraft to 0.36 (#1718) 2026-07-02 03:45:07 -04:00
Brandon Thomas 98ea5cdb62 shink assets that are way too large (#1717) 2026-07-02 03:42:41 -04:00
Brandon Thomas 8fdc5100af vidu (#1716) 2026-07-02 03:12:15 -04:00
Brandon Thomas b782cb0c67 veo (#1715) 2026-07-02 02:18:16 -04:00
Brandon Thomas f01e81b66a artcraft 0.35 (#1714) 2026-07-02 02:02:16 -04:00
Brandon Thomas 3c0d1415e5 update pathing 2026-07-02 01:30:07 -04:00
Brandon Thomas 5e4b7f271d artcraft 0.34 (#1713) 2026-07-02 01:16:31 -04:00
Brandon Thomas 2371dfd49e move assets out of build to decrease binary size (#1712) 2026-07-02 01:15:30 -04:00
Brandon Thomas 2f20e7b3a3 fix midjourney (#1711) 2026-07-02 00:06:27 -04:00
467 changed files with 13875 additions and 3346 deletions
@@ -0,0 +1,124 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n d.id as `id: u64`,\n d.event_token as `event_token: tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken`,\n d.debug_log_type as `debug_log_type: enums::by_table::debug_logs::debug_log_type::DebugLogType`,\n d.maybe_log_level as `maybe_log_level: enums::by_table::debug_logs::debug_log_level::DebugLogLevel`,\n d.maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n d.message,\n d.created_at,\n u.display_name as `maybe_user_display_name?`,\n u.username as `maybe_user_username?`,\n u.email_gravatar_hash as `maybe_user_gravatar_hash?`\nFROM debug_logs d\nLEFT OUTER JOIN users u\n ON u.token = d.maybe_creator_user_token\nWHERE d.maybe_creator_user_token = ?\n AND d.id < ?\nORDER BY d.id DESC\nLIMIT ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id: u64",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | PRIMARY_KEY | UNSIGNED | AUTO_INCREMENT",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 1,
"name": "event_token: tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "debug_log_type: enums::by_table::debug_logs::debug_log_type::DebugLogType",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 96
}
},
{
"ordinal": 3,
"name": "maybe_log_level: enums::by_table::debug_logs::debug_log_level::DebugLogLevel",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 4,
"name": "maybe_creator_user_token: tokens::tokens::users::UserToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 5,
"name": "message",
"type_info": {
"type": "Blob",
"flags": "NOT_NULL | BLOB | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 67108860
}
},
{
"ordinal": 6,
"name": "created_at",
"type_info": {
"type": "Datetime",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 7,
"name": "maybe_user_display_name?",
"type_info": {
"type": "VarString",
"flags": "BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 8,
"name": "maybe_user_username?",
"type_info": {
"type": "VarString",
"flags": "UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 9,
"name": "maybe_user_gravatar_hash?",
"type_info": {
"type": "String",
"flags": "BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
}
],
"parameters": {
"Right": 3
},
"nullable": [
false,
false,
false,
true,
true,
false,
false,
true,
true,
true
]
},
"hash": "1197c08c8b390fb8bffbe10ad9f7d073a58b6c9124ee11573c6723d6d5471881"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO debug_logs\nSET\n event_token = ?,\n debug_log_type = ?,\n maybe_log_level = ?,\n maybe_creator_user_token = ?,\n message = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 5
},
"nullable": []
},
"hash": "62995374005dc0553d007fb69e642d89a95cd5aaeeababd110c92c565023cfcf"
}
@@ -1,80 +0,0 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n id as `id: u64`,\n event_token as `event_token: tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken`,\n debug_log_type as `debug_log_type: enums::by_table::debug_logs::debug_log_type::DebugLogType`,\n maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n message,\n created_at\nFROM debug_logs\nWHERE event_token = ?\nORDER BY id ASC\nLIMIT ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id: u64",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | PRIMARY_KEY | UNSIGNED | AUTO_INCREMENT",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 1,
"name": "event_token: tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "debug_log_type: enums::by_table::debug_logs::debug_log_type::DebugLogType",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 96
}
},
{
"ordinal": 3,
"name": "maybe_creator_user_token: tokens::tokens::users::UserToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 4,
"name": "message",
"type_info": {
"type": "Blob",
"flags": "NOT_NULL | BLOB | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 67108860
}
},
{
"ordinal": 5,
"name": "created_at",
"type_info": {
"type": "Datetime",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 2
},
"nullable": [
false,
false,
false,
true,
false,
false
]
},
"hash": "a62b88fec40a11952cc199383090ea338f97091e8bfc6b68aad67416685889af"
}
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO debug_logs\nSET\n event_token = ?,\n debug_log_type = ?,\n maybe_creator_user_token = ?,\n message = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 4
},
"nullable": []
},
"hash": "a9745ecf60b564ab096ee1dbe124221b5f288a4ae38e788e19bbd359db0b580f"
}
@@ -0,0 +1,124 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n d.id as `id: u64`,\n d.event_token as `event_token: tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken`,\n d.debug_log_type as `debug_log_type: enums::by_table::debug_logs::debug_log_type::DebugLogType`,\n d.maybe_log_level as `maybe_log_level: enums::by_table::debug_logs::debug_log_level::DebugLogLevel`,\n d.maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n d.message,\n d.created_at,\n u.display_name as `maybe_user_display_name?`,\n u.username as `maybe_user_username?`,\n u.email_gravatar_hash as `maybe_user_gravatar_hash?`\nFROM debug_logs d\nLEFT OUTER JOIN users u\n ON u.token = d.maybe_creator_user_token\nWHERE d.event_token = ?\nORDER BY d.id ASC\nLIMIT ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id: u64",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | PRIMARY_KEY | UNSIGNED | AUTO_INCREMENT",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 1,
"name": "event_token: tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "debug_log_type: enums::by_table::debug_logs::debug_log_type::DebugLogType",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 96
}
},
{
"ordinal": 3,
"name": "maybe_log_level: enums::by_table::debug_logs::debug_log_level::DebugLogLevel",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 4,
"name": "maybe_creator_user_token: tokens::tokens::users::UserToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 5,
"name": "message",
"type_info": {
"type": "Blob",
"flags": "NOT_NULL | BLOB | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 67108860
}
},
{
"ordinal": 6,
"name": "created_at",
"type_info": {
"type": "Datetime",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 7,
"name": "maybe_user_display_name?",
"type_info": {
"type": "VarString",
"flags": "BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 8,
"name": "maybe_user_username?",
"type_info": {
"type": "VarString",
"flags": "UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 9,
"name": "maybe_user_gravatar_hash?",
"type_info": {
"type": "String",
"flags": "BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
}
],
"parameters": {
"Right": 2
},
"nullable": [
false,
false,
false,
true,
true,
false,
false,
true,
true,
true
]
},
"hash": "ae311f7fde7c73f166cd8c0a5c9965850e314936a401b24a3900585ab53a7db6"
}
@@ -0,0 +1,6 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE debug_logs
DROP COLUMN maybe_log_level;
@@ -0,0 +1,6 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE debug_logs
ADD COLUMN maybe_log_level VARCHAR(16) DEFAULT NULL;
@@ -0,0 +1,6 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE debug_logs
DROP INDEX index_maybe_log_level;
@@ -0,0 +1,6 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE debug_logs
ADD INDEX index_maybe_log_level (maybe_log_level);
@@ -13,6 +13,9 @@ CREATE TABLE debug_logs (
-- The type of debug log entry.
debug_log_type VARCHAR(24) NOT NULL,
-- The severity level of the log entry (info, warn, error, debug, trace).
maybe_log_level VARCHAR(16) DEFAULT NULL,
-- The user who created the log entry (if any).
maybe_creator_user_token VARCHAR(32) DEFAULT NULL,
@@ -25,6 +28,7 @@ CREATE TABLE debug_logs (
PRIMARY KEY (id),
KEY index_event_token (event_token),
KEY index_debug_log_type (debug_log_type),
KEY index_maybe_log_level (maybe_log_level),
KEY index_maybe_creator_user_token (maybe_creator_user_token),
KEY index_created_at (created_at)
@@ -0,0 +1,32 @@
use chrono::{DateTime, Utc};
use serde_derive::Serialize;
use utoipa::ToSchema;
use enums::by_table::debug_logs::debug_log_level::DebugLogLevel;
use enums::by_table::debug_logs::debug_log_type::DebugLogType;
use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken;
use tokens::tokens::users::UserToken;
/// A single debug log row.
/// Shared by the moderation debug-log list endpoints.
#[derive(Serialize, ToSchema)]
pub struct ModerationDebugLogEntry {
pub id: u64,
pub event_token: DebugLogEventToken,
pub debug_log_type: DebugLogType,
pub maybe_log_level: Option<DebugLogLevel>,
pub maybe_creator_user_token: Option<UserToken>,
pub message: String,
pub created_at: DateTime<Utc>,
/// The creator user, joined in when the row has a user token.
pub maybe_user: Option<ModerationDebugLogUser>,
}
/// The creator user of a debug log row.
#[derive(Serialize, ToSchema)]
pub struct ModerationDebugLogUser {
pub user_token: UserToken,
pub display_name: String,
pub username: String,
pub gravatar_hash: String,
}
@@ -0,0 +1,4 @@
pub mod debug_log_entry;
pub mod moderation_list_all_debug_logs;
pub mod moderation_list_debug_logs_for_token;
pub mod moderation_list_debug_logs_for_user;
@@ -0,0 +1,72 @@
use chrono::{DateTime, Utc};
use serde_derive::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use enums::by_table::debug_logs::debug_log_level::DebugLogLevel;
use enums::by_table::debug_logs::debug_log_type::DebugLogType;
use enums::error::enum_error::EnumError;
use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken;
use tokens::tokens::users::UserToken;
use crate::moderation::debug_logs::debug_log_entry::ModerationDebugLogUser;
pub const MODERATION_LIST_ALL_DEBUG_LOGS_PATH: &str = "/v1/moderation/debug_logs/list_all";
// ── Query params ──
#[derive(Deserialize, IntoParams, ToSchema)]
pub struct ModerationListAllDebugLogsQueryParams {
/// Optional comma-separated list of severity levels to include,
/// eg. `severities=error,warn`. Omit to include all rows.
pub severities: Option<String>,
/// Cursor for pagination. Pass the `next_cursor` from a previous response.
pub cursor: Option<u64>,
/// Max rows per page (default 50, max 200).
pub limit: Option<u32>,
}
impl ModerationListAllDebugLogsQueryParams {
/// Parse the comma-separated `severities` parameter.
/// Returns `None` when the parameter is absent or empty.
pub fn parsed_severities(&self) -> Result<Option<Vec<DebugLogLevel>>, EnumError> {
let raw = match self.severities.as_deref() {
Some(value) if !value.trim().is_empty() => value,
_ => return Ok(None),
};
let levels = raw
.split(',')
.map(str::trim)
.filter(|part| !part.is_empty())
.map(DebugLogLevel::from_str)
.collect::<Result<Vec<DebugLogLevel>, EnumError>>()?;
Ok(if levels.is_empty() { None } else { Some(levels) })
}
}
// ── Response ──
#[derive(Serialize, ToSchema)]
pub struct ModerationListAllDebugLogsSuccessResponse {
pub success: bool,
/// Most recent first.
pub debug_logs: Vec<ModerationListAllDebugLogsEntry>,
/// Cursor for the next page. `None` if there are no more results.
pub next_cursor: Option<u64>,
}
/// A debug log row with the creator user joined in (when one exists).
#[derive(Serialize, ToSchema)]
pub struct ModerationListAllDebugLogsEntry {
pub id: u64,
pub event_token: DebugLogEventToken,
pub debug_log_type: DebugLogType,
pub maybe_log_level: Option<DebugLogLevel>,
pub maybe_creator_user_token: Option<UserToken>,
pub message: String,
pub created_at: DateTime<Utc>,
pub maybe_user: Option<ModerationDebugLogUser>,
}
@@ -0,0 +1,30 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken;
use crate::moderation::debug_logs::debug_log_entry::ModerationDebugLogEntry;
pub const MODERATION_LIST_DEBUG_LOGS_FOR_TOKEN_PATH: &str = "/v1/moderation/debug_logs/list/{token}";
// ── Path params ──
#[derive(Deserialize, ToSchema)]
pub struct ModerationListDebugLogsForTokenPathInfo {
pub token: DebugLogEventToken,
}
// ── Query params ──
#[derive(Deserialize, IntoParams, ToSchema)]
pub struct ModerationListDebugLogsForTokenQueryParams {
pub limit: Option<u32>,
}
// ── Response ──
#[derive(Serialize, ToSchema)]
pub struct ModerationListDebugLogsForTokenSuccessResponse {
pub success: bool,
pub debug_logs: Vec<ModerationDebugLogEntry>,
}
@@ -0,0 +1,37 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use tokens::tokens::users::UserToken;
use crate::moderation::debug_logs::debug_log_entry::ModerationDebugLogEntry;
pub const MODERATION_LIST_DEBUG_LOGS_FOR_USER_PATH: &str = "/v1/moderation/debug_logs/user_list/{user_token}";
// ── Path params ──
#[derive(Deserialize, ToSchema)]
pub struct ModerationListDebugLogsForUserPathInfo {
pub user_token: UserToken,
}
// ── Query params ──
#[derive(Deserialize, IntoParams, ToSchema)]
pub struct ModerationListDebugLogsForUserQueryParams {
/// Cursor for pagination. Pass the `next_cursor` from a previous response.
pub cursor: Option<u64>,
/// Max rows per page (default 50, max 200).
pub limit: Option<u32>,
}
// ── Response ──
#[derive(Serialize, ToSchema)]
pub struct ModerationListDebugLogsForUserSuccessResponse {
pub success: bool,
/// Most recent first.
pub debug_logs: Vec<ModerationDebugLogEntry>,
/// Cursor for the next page. `None` if there are no more results.
pub next_cursor: Option<u64>,
}
@@ -1,4 +1,5 @@
pub mod alerts;
pub mod debug_logs;
pub mod jobs;
pub mod user;
pub mod user_referrals;
@@ -138,6 +138,7 @@ pub(crate) fn plan_batch_count(
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 => Ok(KinoviMidjourneyBatchCount::One),
2 => Ok(KinoviMidjourneyBatchCount::Two),
3 => Ok(KinoviMidjourneyBatchCount::Three),
4 => Ok(KinoviMidjourneyBatchCount::Four),
other => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
@@ -146,13 +147,11 @@ pub(crate) fn plan_batch_count(
value: format!("{}", other),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => {
// 3 → 4, anything > 4 → 4
Ok(if other < 3 { KinoviMidjourneyBatchCount::Two } else { KinoviMidjourneyBatchCount::Four })
}
RequestMismatchMitigationStrategy::PayLessDowngrade => {
// 3 → 2, anything > 4 → 4
Ok(if other < 4 { KinoviMidjourneyBatchCount::Two } else { KinoviMidjourneyBatchCount::Four })
// Kinovi supports batches of 1–4; only counts of 5+ reach here, so both
// mitigation strategies clamp to the maximum supported batch.
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(KinoviMidjourneyBatchCount::Four)
}
},
}
@@ -379,9 +378,10 @@ mod tests {
}
#[test]
fn one_two_four_map_directly() {
fn one_two_three_four_map_directly() {
assert_eq!(batch_for(Some(1)), KinoviMidjourneyBatchCount::One);
assert_eq!(batch_for(Some(2)), KinoviMidjourneyBatchCount::Two);
assert_eq!(batch_for(Some(3)), KinoviMidjourneyBatchCount::Three);
assert_eq!(batch_for(Some(4)), KinoviMidjourneyBatchCount::Four);
}
@@ -394,43 +394,57 @@ mod tests {
));
}
/// Kinovi now supports batches of 3, so 3 is a first-class value and the
/// mitigation strategy no longer applies to it.
#[test]
fn three_errors_under_strict() {
let builder = GenerateImageRequestBuilder {
image_batch_count: Some(3),
fn three_maps_directly_regardless_of_strategy() {
for strategy in [
RequestMismatchMitigationStrategy::ErrorOut,
RequestMismatchMitigationStrategy::PayMoreUpgrade,
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let builder = GenerateImageRequestBuilder {
image_batch_count: Some(3),
request_mismatch_mitigation_strategy: strategy,
..base_builder()
};
assert_eq!(
unwrap_direct_request(build_kinovi_midjourney_7(builder)).request.batch_count,
KinoviMidjourneyBatchCount::Three,
"strategy={:?}", strategy,
);
}
}
/// Counts above 4 are still unsupported: strict errors out, and both
/// pay-more/pay-less strategies clamp to the maximum supported batch (4).
#[test]
fn five_errors_under_strict_and_clamps_to_four_otherwise() {
let strict = GenerateImageRequestBuilder {
image_batch_count: Some(5),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..base_builder()
};
assert!(matches!(
build_kinovi_midjourney_7(builder),
build_kinovi_midjourney_7(strict),
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { .. })),
));
}
#[test]
fn three_upgrades_to_four_with_pay_more() {
let builder = GenerateImageRequestBuilder {
image_batch_count: Some(3),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
..base_builder()
};
assert_eq!(
unwrap_direct_request(build_kinovi_midjourney_7(builder)).request.batch_count,
KinoviMidjourneyBatchCount::Four,
);
}
#[test]
fn three_downgrades_to_two_with_pay_less() {
let builder = GenerateImageRequestBuilder {
image_batch_count: Some(3),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayLessDowngrade,
..base_builder()
};
assert_eq!(
unwrap_direct_request(build_kinovi_midjourney_7(builder)).request.batch_count,
KinoviMidjourneyBatchCount::Two,
);
for strategy in [
RequestMismatchMitigationStrategy::PayMoreUpgrade,
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let builder = GenerateImageRequestBuilder {
image_batch_count: Some(5),
request_mismatch_mitigation_strategy: strategy,
..base_builder()
};
assert_eq!(
unwrap_direct_request(build_kinovi_midjourney_7(builder)).request.batch_count,
KinoviMidjourneyBatchCount::Four,
"strategy={:?}", strategy,
);
}
}
}
@@ -126,6 +126,7 @@ pub(crate) fn plan_batch_count(
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 => Ok(KinoviMidjourneyBatchCount::One),
2 => Ok(KinoviMidjourneyBatchCount::Two),
3 => Ok(KinoviMidjourneyBatchCount::Three),
4 => Ok(KinoviMidjourneyBatchCount::Four),
other => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
@@ -134,11 +135,11 @@ pub(crate) fn plan_batch_count(
value: format!("{}", other),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => {
Ok(if other < 3 { KinoviMidjourneyBatchCount::Two } else { KinoviMidjourneyBatchCount::Four })
}
RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(if other < 4 { KinoviMidjourneyBatchCount::Two } else { KinoviMidjourneyBatchCount::Four })
// Kinovi supports batches of 1–4; only counts of 5+ reach here, so both
// mitigation strategies clamp to the maximum supported batch.
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(KinoviMidjourneyBatchCount::Four)
}
},
}
@@ -212,6 +213,16 @@ mod tests {
));
}
#[test]
fn batch_count_three_is_accepted() {
let builder = GenerateImageRequestBuilder { image_batch_count: Some(3), ..base_builder() };
let req = match build_kinovi_midjourney_7_niji(builder).expect("build") {
ImageGenerationDraftOrRequest::Request(ImageGenerationRequest::KinoviMidjourney7Niji(r)) => r,
_ => panic!("expected Request"),
};
assert_eq!(req.request.batch_count, KinoviMidjourneyBatchCount::Three);
}
#[test]
fn batch_count_four_is_accepted() {
let builder = GenerateImageRequestBuilder { image_batch_count: Some(4), ..base_builder() };
@@ -126,6 +126,7 @@ pub(crate) fn plan_batch_count(
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 => Ok(KinoviMidjourneyBatchCount::One),
2 => Ok(KinoviMidjourneyBatchCount::Two),
3 => Ok(KinoviMidjourneyBatchCount::Three),
4 => Ok(KinoviMidjourneyBatchCount::Four),
other => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
@@ -134,11 +135,11 @@ pub(crate) fn plan_batch_count(
value: format!("{}", other),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => {
Ok(if other < 3 { KinoviMidjourneyBatchCount::Two } else { KinoviMidjourneyBatchCount::Four })
}
RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(if other < 4 { KinoviMidjourneyBatchCount::Two } else { KinoviMidjourneyBatchCount::Four })
// Kinovi supports batches of 1–4; only counts of 5+ reach here, so both
// mitigation strategies clamp to the maximum supported batch.
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(KinoviMidjourneyBatchCount::Four)
}
},
}
@@ -201,6 +202,16 @@ mod tests {
));
}
#[test]
fn batch_count_three_is_accepted() {
let builder = GenerateImageRequestBuilder { image_batch_count: Some(3), ..base_builder() };
let req = match build_kinovi_midjourney_8(builder).expect("build") {
ImageGenerationDraftOrRequest::Request(ImageGenerationRequest::KinoviMidjourney8(r)) => r,
_ => panic!("expected Request"),
};
assert_eq!(req.request.batch_count, KinoviMidjourneyBatchCount::Three);
}
#[test]
fn batch_count_four_is_accepted() {
let builder = GenerateImageRequestBuilder { image_batch_count: Some(4), ..base_builder() };
@@ -0,0 +1,2 @@
pub mod veo_3p1;
pub mod veo_3p1_fast;
@@ -0,0 +1,288 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::video::extend::veo_3p1::raw_request::{
Veo3p1ExtendVideoInput, Veo3p1ExtendVideoOutput,
};
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
#[derive(Clone, Debug)]
pub struct Veo3p1ExtendVideoRequest {
/// Text prompt describing how the video should be extended.
pub prompt: String,
/// URL of the video to extend (720p/1080p, 16:9 or 9:16).
pub video_url: String,
/// Aspect ratio. fal's default is `auto` when `None`.
pub aspect_ratio: Option<Veo3p1ExtendVideoAspectRatio>,
/// Extension duration. fal's default is `7s` when `None`.
pub duration: Option<Veo3p1ExtendVideoDuration>,
/// Output resolution. fal's default is `720p` when `None`.
pub resolution: Option<Veo3p1ExtendVideoResolution>,
/// Optional negative prompt.
pub negative_prompt: Option<String>,
/// Whether to generate native audio. fal's server default is `true` when
/// this is `None`.
pub generate_audio: Option<bool>,
/// Seed for the random number generator.
pub seed: Option<i64>,
/// Whether to auto-rewrite prompts that fail moderation. fal's default is
/// `false` when `None`.
pub auto_fix: Option<bool>,
/// Safety tolerance. fal's default is `4` when `None`.
pub safety_tolerance: Option<Veo3p1ExtendVideoSafetyTolerance>,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1ExtendVideoAspectRatio {
Auto,
SixteenByNine,
NineBySixteen,
}
impl Veo3p1ExtendVideoAspectRatio {
fn to_str(&self) -> &'static str {
match self {
Self::Auto => "auto",
Self::SixteenByNine => "16:9",
Self::NineBySixteen => "9:16",
}
}
}
/// Extension duration. fal documents only the `7s` default for extend-video
/// (no explicit enum). We expose `7s` plus the standard Veo 3.1 family
/// durations (`4s`/`6s`/`8s`) so callers stay strongly typed; if fal rejects
/// a value, trim this enum.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1ExtendVideoDuration {
FourSeconds,
SixSeconds,
/// fal's documented default for extend-video.
SevenSeconds,
EightSeconds,
}
impl Veo3p1ExtendVideoDuration {
pub fn to_seconds(&self) -> u64 {
match self {
Self::FourSeconds => 4,
Self::SixSeconds => 6,
Self::SevenSeconds => 7,
Self::EightSeconds => 8,
}
}
fn to_str(&self) -> &'static str {
match self {
Self::FourSeconds => "4s",
Self::SixSeconds => "6s",
Self::SevenSeconds => "7s",
Self::EightSeconds => "8s",
}
}
}
/// Extend-video documents only 720p/1080p (no 4k tier), so this enum omits 4k.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1ExtendVideoResolution {
SevenTwentyP,
TenEightyP,
}
impl Veo3p1ExtendVideoResolution {
fn to_str(&self) -> &'static str {
match self {
Self::SevenTwentyP => "720p",
Self::TenEightyP => "1080p",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1ExtendVideoSafetyTolerance {
/// "1" — strictest.
Level1,
Level2,
Level3,
/// "4" — fal default.
Level4,
Level5,
/// "6" — least strict.
Level6,
}
impl Veo3p1ExtendVideoSafetyTolerance {
fn to_str(&self) -> &'static str {
match self {
Self::Level1 => "1",
Self::Level2 => "2",
Self::Level3 => "3",
Self::Level4 => "4",
Self::Level5 => "5",
Self::Level6 => "6",
}
}
}
impl FalEndpoint for Veo3p1ExtendVideoRequest {
const ENDPOINT: &str = "fal-ai/veo3.1/extend-video";
type RawRequest = Veo3p1ExtendVideoInput;
type RawResponse = Veo3p1ExtendVideoOutput;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
Ok(Self::RawRequest {
prompt: self.prompt.clone(),
video_url: self.video_url.clone(),
aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()),
duration: self.duration.map(|d| d.to_str().to_string()),
negative_prompt: self.negative_prompt.clone(),
resolution: self.resolution.map(|r| r.to_str().to_string()),
generate_audio: self.generate_audio,
seed: self.seed,
auto_fix: self.auto_fix,
safety_tolerance: self.safety_tolerance.map(|s| s.to_str().to_string()),
})
}
}
#[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::video_urls::ANGRY_SHIBA_VIDEO_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_extend_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 = Veo3p1ExtendVideoRequest {
prompt: "the scene continues naturally, keeping the same motion and style".to_string(),
video_url: ANGRY_SHIBA_VIDEO_URL.to_string(),
aspect_ratio: None,
duration: Some(Veo3p1ExtendVideoDuration::SevenSeconds),
resolution: Some(Veo3p1ExtendVideoResolution::SevenTwentyP),
negative_prompt: None,
generate_audio: Some(false),
seed: None,
auto_fix: None,
safety_tolerance: None,
};
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_extend_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 = Veo3p1ExtendVideoRequest {
prompt: "the camera pulls back to reveal more of the landscape".to_string(),
video_url: ANGRY_SHIBA_VIDEO_URL.to_string(),
aspect_ratio: None,
duration: Some(Veo3p1ExtendVideoDuration::SevenSeconds),
resolution: Some(Veo3p1ExtendVideoResolution::SevenTwentyP),
negative_prompt: None,
generate_audio: Some(false),
seed: None,
auto_fix: None,
safety_tolerance: None,
};
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 = Veo3p1ExtendVideoRequest {
prompt: "p".to_string(),
video_url: "https://example.com/in.mp4".to_string(),
aspect_ratio: Some(Veo3p1ExtendVideoAspectRatio::SixteenByNine),
duration: Some(Veo3p1ExtendVideoDuration::SevenSeconds),
resolution: Some(Veo3p1ExtendVideoResolution::TenEightyP),
negative_prompt: Some("nope".to_string()),
generate_audio: Some(true),
seed: Some(3),
auto_fix: Some(false),
safety_tolerance: Some(Veo3p1ExtendVideoSafetyTolerance::Level4),
};
let raw = request.to_raw_request().unwrap();
assert_eq!(raw.video_url, "https://example.com/in.mp4");
assert_eq!(raw.aspect_ratio.as_deref(), Some("16:9"));
assert_eq!(raw.duration.as_deref(), Some("7s"));
assert_eq!(raw.resolution.as_deref(), Some("1080p"));
assert_eq!(raw.negative_prompt.as_deref(), Some("nope"));
assert_eq!(raw.generate_audio, Some(true));
assert_eq!(raw.seed, Some(3));
assert_eq!(raw.auto_fix, Some(false));
assert_eq!(raw.safety_tolerance.as_deref(), Some("4"));
}
#[test]
fn raw_request_omits_unset_optionals() {
let request = Veo3p1ExtendVideoRequest {
prompt: "p".to_string(),
video_url: "https://example.com/in.mp4".to_string(),
aspect_ratio: None,
duration: None,
resolution: None,
negative_prompt: None,
generate_audio: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(
json,
serde_json::json!({ "prompt": "p", "video_url": "https://example.com/in.mp4" }),
);
}
#[test]
fn every_duration_maps_to_wire_string_and_seconds() {
for (variant, s, secs) in [
(Veo3p1ExtendVideoDuration::FourSeconds, "4s", 4),
(Veo3p1ExtendVideoDuration::SixSeconds, "6s", 6),
(Veo3p1ExtendVideoDuration::SevenSeconds, "7s", 7),
(Veo3p1ExtendVideoDuration::EightSeconds, "8s", 8),
] {
assert_eq!(variant.to_str(), s);
assert_eq!(variant.to_seconds(), secs);
}
}
#[test]
fn endpoint_path_is_canonical() {
assert_eq!(
Veo3p1ExtendVideoRequest::ENDPOINT,
"fal-ai/veo3.1/extend-video",
);
}
// NB: Pricing tests are in cost.rs
}
@@ -0,0 +1,109 @@
use crate::requests::api::video::extend::veo_3p1::api::{
Veo3p1ExtendVideoDuration, Veo3p1ExtendVideoRequest,
};
use crate::requests::api::video::text::veo_3p1::cost::{
veo_3p1_cost_cents, veo_3p1_rate_tenth_cents_per_sec,
};
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
// Extend-video pricing (see https://fal.ai/models/fal-ai/veo3.1/extend-video):
// $0.20/sec (audio off), $0.40/sec (audio on).
//
// Extend exposes only 720p/1080p (no 4k tier), so it always bills at the
// family's SD rate — `four_k` is fixed to `false`.
impl FalRequestCostCalculator for Veo3p1ExtendVideoRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
// fal defaults when unset: duration = 7s, audio = on. Resolution does not
// affect price here (720p and 1080p bill identically).
let duration_secs = self.duration
.unwrap_or(Veo3p1ExtendVideoDuration::SevenSeconds)
.to_seconds();
let audio_on = self.generate_audio.unwrap_or(true);
let rate = veo_3p1_rate_tenth_cents_per_sec(false, audio_on);
veo_3p1_cost_cents(rate, duration_secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::requests::api::video::extend::veo_3p1::api::Veo3p1ExtendVideoResolution;
fn make_request(
duration: Option<Veo3p1ExtendVideoDuration>,
generate_audio: Option<bool>,
) -> Veo3p1ExtendVideoRequest {
Veo3p1ExtendVideoRequest {
prompt: "test".to_string(),
video_url: "https://example.com/in.mp4".to_string(),
aspect_ratio: None,
duration,
resolution: None,
negative_prompt: None,
generate_audio,
seed: None,
auto_fix: None,
safety_tolerance: None,
}
}
mod cost_table {
use super::*;
// (duration, generate_audio, expected_cents)
const COST_TABLE: &[(
Option<Veo3p1ExtendVideoDuration>,
Option<bool>,
u64,
)] = &[
(Some(Veo3p1ExtendVideoDuration::FourSeconds), Some(false), 80),
(Some(Veo3p1ExtendVideoDuration::FourSeconds), Some(true), 160),
(Some(Veo3p1ExtendVideoDuration::SevenSeconds), Some(false), 140),
(Some(Veo3p1ExtendVideoDuration::SevenSeconds), Some(true), 280),
(Some(Veo3p1ExtendVideoDuration::EightSeconds), Some(false), 160),
(Some(Veo3p1ExtendVideoDuration::EightSeconds), Some(true), 320),
// Defaults: 7s, audio on
(None, None, 280),
(None, Some(false), 140),
];
#[test]
fn matches_cost_table() {
for &(duration, generate_audio, expected) in COST_TABLE {
let got = make_request(duration, generate_audio).calculate_cost_in_cents();
assert_eq!(got, expected, "duration={duration:?} audio={generate_audio:?}");
}
}
/// fal: "a 5s video with audio on will cost $2" → 5s × $0.40 = 200¢.
#[test]
fn matches_documented_5s_audio_on_example() {
let rate = veo_3p1_rate_tenth_cents_per_sec(false, true);
assert_eq!(veo_3p1_cost_cents(rate, 5), 200);
}
#[test]
fn default_duration_is_seven_seconds() {
// duration=None, audio=on → 7s @ $0.40 = 280¢
assert_eq!(make_request(None, None).calculate_cost_in_cents(), 280);
}
/// Resolution (720p vs 1080p) must not change the bill for extend.
#[test]
fn cost_is_independent_of_resolution() {
for audio in [Some(false), Some(true), None] {
let base = make_request(Some(Veo3p1ExtendVideoDuration::SevenSeconds), audio)
.calculate_cost_in_cents();
for res in [Veo3p1ExtendVideoResolution::SevenTwentyP, Veo3p1ExtendVideoResolution::TenEightyP] {
let cost = Veo3p1ExtendVideoRequest {
resolution: Some(res),
..make_request(Some(Veo3p1ExtendVideoDuration::SevenSeconds), audio)
}.calculate_cost_in_cents();
assert_eq!(cost, base, "audio={audio:?} res={res:?}");
}
}
}
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -0,0 +1,59 @@
use serde::{Deserialize, Serialize};
/// Over-the-wire input shape for `fal-ai/veo3.1/extend-video`.
/// Extends an existing video by generating a continuation.
/// fal's schema: <https://fal.ai/models/fal-ai/veo3.1/extend-video/api>
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Veo3p1ExtendVideoInput {
pub prompt: String,
/// URL of the video to extend. Should be 720p or 1080p, 16:9 or 9:16.
pub video_url: String,
/// Aspect ratio.
/// Possible values: "auto", "16:9", "9:16". fal default: "auto".
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
/// Duration in seconds (as a string). fal default: "7s". fal documents no
/// explicit enum for this endpoint (only the default), so it is sent as a
/// free-form string.
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<String>,
/// Optional negative prompt.
#[serde(skip_serializing_if = "Option::is_none")]
pub negative_prompt: Option<String>,
/// Output resolution. fal default: "720p" (the docs describe 720p/1080p
/// source requirements and list no 4k tier for extend).
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<String>,
/// Whether to generate native audio for the video. fal default: true.
#[serde(skip_serializing_if = "Option::is_none")]
pub generate_audio: Option<bool>,
/// Seed for the random number generator.
#[serde(skip_serializing_if = "Option::is_none")]
pub seed: Option<i64>,
/// Whether to automatically rewrite prompts that fail moderation.
/// fal default: false.
#[serde(skip_serializing_if = "Option::is_none")]
pub auto_fix: Option<bool>,
/// Safety tolerance, "1" (strictest) .. "6" (least strict). fal default: "4".
#[serde(skip_serializing_if = "Option::is_none")]
pub safety_tolerance: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo3p1ExtendVideoVideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo3p1ExtendVideoOutput {
pub video: Veo3p1ExtendVideoVideoFile,
}
@@ -0,0 +1,288 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::video::extend::veo_3p1_fast::raw_request::{
Veo3p1FastExtendVideoInput, Veo3p1FastExtendVideoOutput,
};
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
#[derive(Clone, Debug)]
pub struct Veo3p1FastExtendVideoRequest {
/// Text prompt describing how the video should be extended.
pub prompt: String,
/// URL of the video to extend (720p/1080p, 16:9 or 9:16).
pub video_url: String,
/// Aspect ratio. fal's default is `auto` when `None`.
pub aspect_ratio: Option<Veo3p1FastExtendVideoAspectRatio>,
/// Extension duration. fal's default is `7s` when `None`.
pub duration: Option<Veo3p1FastExtendVideoDuration>,
/// Output resolution. fal's default is `720p` when `None`.
pub resolution: Option<Veo3p1FastExtendVideoResolution>,
/// Optional negative prompt.
pub negative_prompt: Option<String>,
/// Whether to generate native audio. fal's server default is `true` when
/// this is `None`.
pub generate_audio: Option<bool>,
/// Seed for the random number generator.
pub seed: Option<i64>,
/// Whether to auto-rewrite prompts that fail moderation. fal's default is
/// `false` when `None`.
pub auto_fix: Option<bool>,
/// Safety tolerance. fal's default is `4` when `None`.
pub safety_tolerance: Option<Veo3p1FastExtendVideoSafetyTolerance>,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1FastExtendVideoAspectRatio {
Auto,
SixteenByNine,
NineBySixteen,
}
impl Veo3p1FastExtendVideoAspectRatio {
fn to_str(&self) -> &'static str {
match self {
Self::Auto => "auto",
Self::SixteenByNine => "16:9",
Self::NineBySixteen => "9:16",
}
}
}
/// Extension duration. fal documents only the `7s` default for extend-video
/// (no explicit enum). We expose `7s` plus the standard Veo 3.1 Fast family
/// durations (`4s`/`6s`/`8s`) so callers stay strongly typed; if fal rejects
/// a value, trim this enum.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1FastExtendVideoDuration {
FourSeconds,
SixSeconds,
/// fal's documented default for extend-video.
SevenSeconds,
EightSeconds,
}
impl Veo3p1FastExtendVideoDuration {
pub fn to_seconds(&self) -> u64 {
match self {
Self::FourSeconds => 4,
Self::SixSeconds => 6,
Self::SevenSeconds => 7,
Self::EightSeconds => 8,
}
}
fn to_str(&self) -> &'static str {
match self {
Self::FourSeconds => "4s",
Self::SixSeconds => "6s",
Self::SevenSeconds => "7s",
Self::EightSeconds => "8s",
}
}
}
/// Extend-video documents only 720p/1080p (no 4k tier), so this enum omits 4k.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1FastExtendVideoResolution {
SevenTwentyP,
TenEightyP,
}
impl Veo3p1FastExtendVideoResolution {
fn to_str(&self) -> &'static str {
match self {
Self::SevenTwentyP => "720p",
Self::TenEightyP => "1080p",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1FastExtendVideoSafetyTolerance {
/// "1" — strictest.
Level1,
Level2,
Level3,
/// "4" — fal default.
Level4,
Level5,
/// "6" — least strict.
Level6,
}
impl Veo3p1FastExtendVideoSafetyTolerance {
fn to_str(&self) -> &'static str {
match self {
Self::Level1 => "1",
Self::Level2 => "2",
Self::Level3 => "3",
Self::Level4 => "4",
Self::Level5 => "5",
Self::Level6 => "6",
}
}
}
impl FalEndpoint for Veo3p1FastExtendVideoRequest {
const ENDPOINT: &str = "fal-ai/veo3.1/fast/extend-video";
type RawRequest = Veo3p1FastExtendVideoInput;
type RawResponse = Veo3p1FastExtendVideoOutput;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
Ok(Self::RawRequest {
prompt: self.prompt.clone(),
video_url: self.video_url.clone(),
aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()),
duration: self.duration.map(|d| d.to_str().to_string()),
negative_prompt: self.negative_prompt.clone(),
resolution: self.resolution.map(|r| r.to_str().to_string()),
generate_audio: self.generate_audio,
seed: self.seed,
auto_fix: self.auto_fix,
safety_tolerance: self.safety_tolerance.map(|s| s.to_str().to_string()),
})
}
}
#[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::video_urls::ANGRY_SHIBA_VIDEO_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_extend_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 = Veo3p1FastExtendVideoRequest {
prompt: "the scene continues naturally, keeping the same motion and style".to_string(),
video_url: ANGRY_SHIBA_VIDEO_URL.to_string(),
aspect_ratio: None,
duration: Some(Veo3p1FastExtendVideoDuration::SevenSeconds),
resolution: Some(Veo3p1FastExtendVideoResolution::SevenTwentyP),
negative_prompt: None,
generate_audio: Some(false),
seed: None,
auto_fix: None,
safety_tolerance: None,
};
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_extend_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 = Veo3p1FastExtendVideoRequest {
prompt: "the camera pulls back to reveal more of the landscape".to_string(),
video_url: ANGRY_SHIBA_VIDEO_URL.to_string(),
aspect_ratio: None,
duration: Some(Veo3p1FastExtendVideoDuration::SevenSeconds),
resolution: Some(Veo3p1FastExtendVideoResolution::SevenTwentyP),
negative_prompt: None,
generate_audio: Some(false),
seed: None,
auto_fix: None,
safety_tolerance: None,
};
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 = Veo3p1FastExtendVideoRequest {
prompt: "p".to_string(),
video_url: "https://example.com/in.mp4".to_string(),
aspect_ratio: Some(Veo3p1FastExtendVideoAspectRatio::SixteenByNine),
duration: Some(Veo3p1FastExtendVideoDuration::SevenSeconds),
resolution: Some(Veo3p1FastExtendVideoResolution::TenEightyP),
negative_prompt: Some("nope".to_string()),
generate_audio: Some(true),
seed: Some(3),
auto_fix: Some(false),
safety_tolerance: Some(Veo3p1FastExtendVideoSafetyTolerance::Level4),
};
let raw = request.to_raw_request().unwrap();
assert_eq!(raw.video_url, "https://example.com/in.mp4");
assert_eq!(raw.aspect_ratio.as_deref(), Some("16:9"));
assert_eq!(raw.duration.as_deref(), Some("7s"));
assert_eq!(raw.resolution.as_deref(), Some("1080p"));
assert_eq!(raw.negative_prompt.as_deref(), Some("nope"));
assert_eq!(raw.generate_audio, Some(true));
assert_eq!(raw.seed, Some(3));
assert_eq!(raw.auto_fix, Some(false));
assert_eq!(raw.safety_tolerance.as_deref(), Some("4"));
}
#[test]
fn raw_request_omits_unset_optionals() {
let request = Veo3p1FastExtendVideoRequest {
prompt: "p".to_string(),
video_url: "https://example.com/in.mp4".to_string(),
aspect_ratio: None,
duration: None,
resolution: None,
negative_prompt: None,
generate_audio: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(
json,
serde_json::json!({ "prompt": "p", "video_url": "https://example.com/in.mp4" }),
);
}
#[test]
fn every_duration_maps_to_wire_string_and_seconds() {
for (variant, s, secs) in [
(Veo3p1FastExtendVideoDuration::FourSeconds, "4s", 4),
(Veo3p1FastExtendVideoDuration::SixSeconds, "6s", 6),
(Veo3p1FastExtendVideoDuration::SevenSeconds, "7s", 7),
(Veo3p1FastExtendVideoDuration::EightSeconds, "8s", 8),
] {
assert_eq!(variant.to_str(), s);
assert_eq!(variant.to_seconds(), secs);
}
}
#[test]
fn endpoint_path_is_canonical() {
assert_eq!(
Veo3p1FastExtendVideoRequest::ENDPOINT,
"fal-ai/veo3.1/fast/extend-video",
);
}
// NB: Pricing tests are in cost.rs
}
@@ -0,0 +1,102 @@
use crate::requests::api::video::extend::veo_3p1_fast::api::{
Veo3p1FastExtendVideoDuration, Veo3p1FastExtendVideoRequest,
};
use crate::requests::api::video::text::veo_3p1_fast::cost::{
veo_3p1_fast_cost_cents, veo_3p1_fast_rate_tenth_cents_per_sec,
};
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
// Extend-video pricing (see https://fal.ai/models/fal-ai/veo3.1/fast/extend-video):
// $0.10/sec (audio off), $0.15/sec (audio on).
//
// Extend exposes only 720p/1080p (no 4k tier), so it always bills at the
// family's SD rate — `four_k` is fixed to `false`.
impl FalRequestCostCalculator for Veo3p1FastExtendVideoRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
// fal defaults when unset: duration = 7s, audio = on. Resolution does not
// affect price here (720p and 1080p bill identically).
let duration_secs = self.duration
.unwrap_or(Veo3p1FastExtendVideoDuration::SevenSeconds)
.to_seconds();
let audio_on = self.generate_audio.unwrap_or(true);
let rate = veo_3p1_fast_rate_tenth_cents_per_sec(false, audio_on);
veo_3p1_fast_cost_cents(rate, duration_secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::requests::api::video::extend::veo_3p1_fast::api::Veo3p1FastExtendVideoResolution;
fn make_request(
duration: Option<Veo3p1FastExtendVideoDuration>,
generate_audio: Option<bool>,
) -> Veo3p1FastExtendVideoRequest {
Veo3p1FastExtendVideoRequest {
prompt: "test".to_string(),
video_url: "https://example.com/in.mp4".to_string(),
aspect_ratio: None,
duration,
resolution: None,
negative_prompt: None,
generate_audio,
seed: None,
auto_fix: None,
safety_tolerance: None,
}
}
mod cost_table {
use super::*;
// (duration, generate_audio, expected_cents)
const COST_TABLE: &[(
Option<Veo3p1FastExtendVideoDuration>,
Option<bool>,
u64,
)] = &[
(Some(Veo3p1FastExtendVideoDuration::FourSeconds), Some(false), 40),
(Some(Veo3p1FastExtendVideoDuration::FourSeconds), Some(true), 60),
(Some(Veo3p1FastExtendVideoDuration::SevenSeconds), Some(false), 70),
(Some(Veo3p1FastExtendVideoDuration::SevenSeconds), Some(true), 105),
(Some(Veo3p1FastExtendVideoDuration::EightSeconds), Some(false), 80),
(Some(Veo3p1FastExtendVideoDuration::EightSeconds), Some(true), 120),
// Defaults: 7s, audio on
(None, None, 105),
(None, Some(false), 70),
];
#[test]
fn matches_cost_table() {
for &(duration, generate_audio, expected) in COST_TABLE {
let got = make_request(duration, generate_audio).calculate_cost_in_cents();
assert_eq!(got, expected, "duration={duration:?} audio={generate_audio:?}");
}
}
#[test]
fn default_duration_is_seven_seconds() {
// duration=None, audio=on → 7s @ $0.15 = 105¢
assert_eq!(make_request(None, None).calculate_cost_in_cents(), 105);
}
/// Resolution (720p vs 1080p) must not change the bill for extend.
#[test]
fn cost_is_independent_of_resolution() {
for audio in [Some(false), Some(true), None] {
let base = make_request(Some(Veo3p1FastExtendVideoDuration::SevenSeconds), audio)
.calculate_cost_in_cents();
for res in [Veo3p1FastExtendVideoResolution::SevenTwentyP, Veo3p1FastExtendVideoResolution::TenEightyP] {
let cost = Veo3p1FastExtendVideoRequest {
resolution: Some(res),
..make_request(Some(Veo3p1FastExtendVideoDuration::SevenSeconds), audio)
}.calculate_cost_in_cents();
assert_eq!(cost, base, "audio={audio:?} res={res:?}");
}
}
}
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -0,0 +1,59 @@
use serde::{Deserialize, Serialize};
/// Over-the-wire input shape for `fal-ai/veo3.1/fast/extend-video`.
/// Extends an existing video by generating a continuation.
/// fal's schema: <https://fal.ai/models/fal-ai/veo3.1/fast/extend-video/api>
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Veo3p1FastExtendVideoInput {
pub prompt: String,
/// URL of the video to extend. Should be 720p or 1080p, 16:9 or 9:16.
pub video_url: String,
/// Aspect ratio.
/// Possible values: "auto", "16:9", "9:16". fal default: "auto".
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
/// Duration in seconds (as a string). fal default: "7s". fal documents no
/// explicit enum for this endpoint (only the default), so it is sent as a
/// free-form string.
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<String>,
/// Optional negative prompt.
#[serde(skip_serializing_if = "Option::is_none")]
pub negative_prompt: Option<String>,
/// Output resolution. fal default: "720p" (the docs describe 720p/1080p
/// source requirements and list no 4k tier for extend).
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<String>,
/// Whether to generate native audio for the video. fal default: true.
#[serde(skip_serializing_if = "Option::is_none")]
pub generate_audio: Option<bool>,
/// Seed for the random number generator.
#[serde(skip_serializing_if = "Option::is_none")]
pub seed: Option<i64>,
/// Whether to automatically rewrite prompts that fail moderation.
/// fal default: false.
#[serde(skip_serializing_if = "Option::is_none")]
pub auto_fix: Option<bool>,
/// Safety tolerance, "1" (strictest) .. "6" (least strict). fal default: "4".
#[serde(skip_serializing_if = "Option::is_none")]
pub safety_tolerance: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo3p1FastExtendVideoVideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo3p1FastExtendVideoOutput {
pub video: Veo3p1FastExtendVideoVideoFile,
}
@@ -2,3 +2,11 @@ pub mod kling_1p6_pro_image_to_video;
pub mod kling_2p5_turbo_pro_image_to_video;
pub mod kling_2p6_pro_image_to_video;
pub mod kling_3p0_standard_image_to_video;
pub mod veo_2;
pub mod veo_3;
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,158 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::video::image::veo_2::raw_request::{
Veo2ImageToVideoInput, Veo2ImageToVideoOutput,
};
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
#[derive(Clone, Debug)]
pub struct Veo2ImageToVideoRequest {
/// Text prompt describing how the image should be animated.
pub prompt: String,
/// URL of the input image to animate (720p+, 16:9 or 9:16).
pub image_url: String,
/// Video duration. fal's default is `5s` when `None`.
pub duration: Option<Veo2ImageToVideoDuration>,
}
/// Veo 2 supports 5–8 second durations (default 5s).
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo2ImageToVideoDuration {
FiveSeconds,
SixSeconds,
SevenSeconds,
EightSeconds,
}
impl Veo2ImageToVideoDuration {
pub fn to_seconds(&self) -> u64 {
match self {
Self::FiveSeconds => 5,
Self::SixSeconds => 6,
Self::SevenSeconds => 7,
Self::EightSeconds => 8,
}
}
fn to_str(&self) -> &'static str {
match self {
Self::FiveSeconds => "5s",
Self::SixSeconds => "6s",
Self::SevenSeconds => "7s",
Self::EightSeconds => "8s",
}
}
}
impl FalEndpoint for Veo2ImageToVideoRequest {
const ENDPOINT: &str = "fal-ai/veo2/image-to-video";
type RawRequest = Veo2ImageToVideoInput;
type RawResponse = Veo2ImageToVideoOutput;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
Ok(Self::RawRequest {
prompt: self.prompt.clone(),
image_url: self.image_url.clone(),
duration: self.duration.map(|d| d.to_str().to_string()),
})
}
}
#[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;
// ── 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 = Veo2ImageToVideoRequest {
prompt: "the lake comes alive with gentle ripples and dappled sunlight".to_string(),
image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(),
duration: Some(Veo2ImageToVideoDuration::FiveSeconds),
};
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_queue() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Veo2ImageToVideoRequest {
prompt: "wind moves through the trees".to_string(),
image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(),
duration: Some(Veo2ImageToVideoDuration::FiveSeconds),
};
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 = Veo2ImageToVideoRequest {
prompt: "p".to_string(),
image_url: "https://example.com/start.png".to_string(),
duration: Some(Veo2ImageToVideoDuration::EightSeconds),
};
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.duration.as_deref(), Some("8s"));
}
#[test]
fn raw_request_omits_unset_optionals() {
let request = Veo2ImageToVideoRequest {
prompt: "p".to_string(),
image_url: "https://example.com/start.png".to_string(),
duration: 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 every_duration_maps_to_wire_string_and_seconds() {
for (variant, s, secs) in [
(Veo2ImageToVideoDuration::FiveSeconds, "5s", 5),
(Veo2ImageToVideoDuration::SixSeconds, "6s", 6),
(Veo2ImageToVideoDuration::SevenSeconds, "7s", 7),
(Veo2ImageToVideoDuration::EightSeconds, "8s", 8),
] {
assert_eq!(variant.to_str(), s);
assert_eq!(variant.to_seconds(), secs);
}
}
#[test]
fn endpoint_path_is_canonical() {
assert_eq!(Veo2ImageToVideoRequest::ENDPOINT, "fal-ai/veo2/image-to-video");
}
// NB: Pricing tests are in cost.rs
}
@@ -0,0 +1,62 @@
use crate::requests::api::video::image::veo_2::api::{Veo2ImageToVideoDuration, Veo2ImageToVideoRequest};
use crate::requests::api::video::text::veo_2::cost::veo_2_cost_cents;
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
// Image-to-video shares the Veo 2 pricing (see the text module for the
// canonical formula): $2.50 base for 5s, +$0.50 per additional second.
impl FalRequestCostCalculator for Veo2ImageToVideoRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
// fal default when unset: duration = 5s.
let duration_secs = self.duration
.unwrap_or(Veo2ImageToVideoDuration::FiveSeconds)
.to_seconds();
veo_2_cost_cents(duration_secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_request(duration: Option<Veo2ImageToVideoDuration>) -> Veo2ImageToVideoRequest {
Veo2ImageToVideoRequest {
prompt: "test".to_string(),
image_url: "https://example.com/x.png".to_string(),
duration,
}
}
mod cost_table {
use super::*;
// (duration, expected_cents): 250 base + 50 per second over 5s.
const COST_TABLE: &[(Option<Veo2ImageToVideoDuration>, u64)] = &[
(Some(Veo2ImageToVideoDuration::FiveSeconds), 250),
(Some(Veo2ImageToVideoDuration::SixSeconds), 300),
(Some(Veo2ImageToVideoDuration::SevenSeconds), 350),
(Some(Veo2ImageToVideoDuration::EightSeconds), 400),
// Default: duration=None → 5s
(None, 250),
];
#[test]
fn matches_cost_table() {
for &(duration, expected) in COST_TABLE {
assert_eq!(
make_request(duration).calculate_cost_in_cents(),
expected,
"duration={duration:?}",
);
}
}
/// Image-to-video must bill identically to text-to-video at every duration.
#[test]
fn matches_text_to_video_pricing() {
for (secs, expected) in [(5, 250), (6, 300), (7, 350), (8, 400)] {
assert_eq!(veo_2_cost_cents(secs), expected, "secs={secs}");
}
}
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -0,0 +1,31 @@
use serde::{Deserialize, Serialize};
/// Over-the-wire input shape for `fal-ai/veo2/image-to-video`.
/// fal's schema: <https://fal.ai/models/fal-ai/veo2/image-to-video/api>
///
/// NB: Veo 2 image-to-video has a minimal schema — only `prompt`, `image_url`,
/// and `duration`. There is no aspect_ratio, negative_prompt, seed, auto_fix,
/// enhance_prompt, resolution, or generate_audio (aspect ratio is taken from
/// the input image).
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Veo2ImageToVideoInput {
pub prompt: String,
/// URL of the input image to animate (720p+, 16:9 or 9:16).
pub image_url: String,
/// Duration in seconds (as a string).
/// Possible values: "5s", "6s", "7s", "8s". fal default: "5s".
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo2ImageToVideoVideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo2ImageToVideoOutput {
pub video: Veo2ImageToVideoVideoFile,
}
@@ -0,0 +1,276 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::video::image::veo_3::raw_request::{
Veo3ImageToVideoInput, Veo3ImageToVideoOutput,
};
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
#[derive(Clone, Debug)]
pub struct Veo3ImageToVideoRequest {
/// Text prompt describing the video to generate.
pub prompt: String,
/// URL of the image used as the starting frame.
pub image_url: String,
/// Aspect ratio. fal's default is `auto` when `None`.
pub aspect_ratio: Option<Veo3ImageToVideoAspectRatio>,
/// Video duration. fal's default is `8s` when `None`.
pub duration: Option<Veo3ImageToVideoDuration>,
/// Output resolution. fal's default is `720p` when `None`.
pub resolution: Option<Veo3ImageToVideoResolution>,
/// Whether to generate native audio. fal's server default is `true` when
/// this is `None`.
pub generate_audio: Option<bool>,
/// Optional negative prompt.
pub negative_prompt: Option<String>,
/// Seed for the random number generator.
pub seed: Option<i64>,
/// Whether to auto-rewrite prompts that fail moderation. fal's default is
/// `false` for image-to-video when `None`.
pub auto_fix: Option<bool>,
/// Safety tolerance. fal's default is `4` when `None`.
pub safety_tolerance: Option<Veo3ImageToVideoSafetyTolerance>,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3ImageToVideoAspectRatio {
Auto,
SixteenByNine,
NineBySixteen,
}
impl Veo3ImageToVideoAspectRatio {
fn to_str(&self) -> &'static str {
match self {
Self::Auto => "auto",
Self::SixteenByNine => "16:9",
Self::NineBySixteen => "9:16",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3ImageToVideoDuration {
FourSeconds,
SixSeconds,
EightSeconds,
}
impl Veo3ImageToVideoDuration {
pub fn to_seconds(&self) -> u64 {
match self {
Self::FourSeconds => 4,
Self::SixSeconds => 6,
Self::EightSeconds => 8,
}
}
fn to_str(&self) -> &'static str {
match self {
Self::FourSeconds => "4s",
Self::SixSeconds => "6s",
Self::EightSeconds => "8s",
}
}
}
/// Veo 3 exposes only 720p/1080p (no 4k tier).
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3ImageToVideoResolution {
SevenTwentyP,
TenEightyP,
}
impl Veo3ImageToVideoResolution {
fn to_str(&self) -> &'static str {
match self {
Self::SevenTwentyP => "720p",
Self::TenEightyP => "1080p",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3ImageToVideoSafetyTolerance {
/// "1" — strictest.
Level1,
Level2,
Level3,
/// "4" — fal default.
Level4,
Level5,
/// "6" — least strict.
Level6,
}
impl Veo3ImageToVideoSafetyTolerance {
fn to_str(&self) -> &'static str {
match self {
Self::Level1 => "1",
Self::Level2 => "2",
Self::Level3 => "3",
Self::Level4 => "4",
Self::Level5 => "5",
Self::Level6 => "6",
}
}
}
impl FalEndpoint for Veo3ImageToVideoRequest {
const ENDPOINT: &str = "fal-ai/veo3/image-to-video";
type RawRequest = Veo3ImageToVideoInput;
type RawResponse = Veo3ImageToVideoOutput;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
Ok(Self::RawRequest {
prompt: self.prompt.clone(),
image_url: self.image_url.clone(),
aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()),
duration: self.duration.map(|d| d.to_str().to_string()),
resolution: self.resolution.map(|r| r.to_str().to_string()),
generate_audio: self.generate_audio,
negative_prompt: self.negative_prompt.clone(),
seed: self.seed,
auto_fix: self.auto_fix,
safety_tolerance: self.safety_tolerance.map(|s| s.to_str().to_string()),
})
}
}
#[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;
// ── 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 = Veo3ImageToVideoRequest {
prompt: "the lake comes alive with gentle ripples and dappled sunlight".to_string(),
image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(),
aspect_ratio: Some(Veo3ImageToVideoAspectRatio::SixteenByNine),
duration: Some(Veo3ImageToVideoDuration::FourSeconds),
resolution: Some(Veo3ImageToVideoResolution::SevenTwentyP),
generate_audio: Some(false),
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
};
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_queue() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Veo3ImageToVideoRequest {
prompt: "wind moves through the trees".to_string(),
image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(),
aspect_ratio: None,
duration: Some(Veo3ImageToVideoDuration::FourSeconds),
resolution: Some(Veo3ImageToVideoResolution::SevenTwentyP),
generate_audio: Some(false),
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
};
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 = Veo3ImageToVideoRequest {
prompt: "p".to_string(),
image_url: "https://example.com/start.png".to_string(),
aspect_ratio: Some(Veo3ImageToVideoAspectRatio::Auto),
duration: Some(Veo3ImageToVideoDuration::SixSeconds),
resolution: Some(Veo3ImageToVideoResolution::TenEightyP),
generate_audio: Some(false),
negative_prompt: Some("nope".to_string()),
seed: Some(7),
auto_fix: Some(true),
safety_tolerance: Some(Veo3ImageToVideoSafetyTolerance::Level2),
};
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.aspect_ratio.as_deref(), Some("auto"));
assert_eq!(raw.duration.as_deref(), Some("6s"));
assert_eq!(raw.resolution.as_deref(), Some("1080p"));
assert_eq!(raw.generate_audio, Some(false));
assert_eq!(raw.negative_prompt.as_deref(), Some("nope"));
assert_eq!(raw.seed, Some(7));
assert_eq!(raw.auto_fix, Some(true));
assert_eq!(raw.safety_tolerance.as_deref(), Some("2"));
}
#[test]
fn raw_request_omits_unset_optionals() {
let request = Veo3ImageToVideoRequest {
prompt: "p".to_string(),
image_url: "https://example.com/start.png".to_string(),
aspect_ratio: None,
duration: None,
resolution: None,
generate_audio: None,
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: 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 every_aspect_ratio_maps_to_wire_string() {
for (variant, expected) in [
(Veo3ImageToVideoAspectRatio::Auto, "auto"),
(Veo3ImageToVideoAspectRatio::SixteenByNine, "16:9"),
(Veo3ImageToVideoAspectRatio::NineBySixteen, "9:16"),
] {
assert_eq!(variant.to_str(), expected);
}
}
#[test]
fn endpoint_path_is_canonical() {
assert_eq!(Veo3ImageToVideoRequest::ENDPOINT, "fal-ai/veo3/image-to-video");
}
// NB: Pricing tests are in cost.rs
}
@@ -0,0 +1,90 @@
use crate::requests::api::video::image::veo_3::api::{
Veo3ImageToVideoDuration, Veo3ImageToVideoRequest,
};
use crate::requests::api::video::text::veo_3::cost::{veo_3_cost_cents, veo_3_rate_tenth_cents_per_sec};
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
// Image-to-video shares the Veo 3 (non-fast) per-second pricing (see the text
// module for the canonical rate table): $0.20/sec (audio off), $0.40/sec
// (audio on). Flat — resolution does not affect the price.
impl FalRequestCostCalculator for Veo3ImageToVideoRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
// fal defaults when unset: duration = 8s, audio = on.
let duration_secs = self.duration
.unwrap_or(Veo3ImageToVideoDuration::EightSeconds)
.to_seconds();
let audio_on = self.generate_audio.unwrap_or(true);
let rate = veo_3_rate_tenth_cents_per_sec(audio_on);
veo_3_cost_cents(rate, duration_secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::requests::api::video::image::veo_3::api::Veo3ImageToVideoAspectRatio;
fn make_request(
duration: Option<Veo3ImageToVideoDuration>,
generate_audio: Option<bool>,
) -> Veo3ImageToVideoRequest {
Veo3ImageToVideoRequest {
prompt: "test".to_string(),
image_url: "https://example.com/x.png".to_string(),
aspect_ratio: None,
duration,
resolution: None,
generate_audio,
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
}
}
mod cost_table {
use super::*;
// (duration, generate_audio, expected_cents)
const COST_TABLE: &[(
Option<Veo3ImageToVideoDuration>,
Option<bool>,
u64,
)] = &[
(Some(Veo3ImageToVideoDuration::FourSeconds), Some(false), 80),
(Some(Veo3ImageToVideoDuration::FourSeconds), Some(true), 160),
(Some(Veo3ImageToVideoDuration::EightSeconds), Some(false), 160),
(Some(Veo3ImageToVideoDuration::EightSeconds), Some(true), 320),
// Defaults: 8s, audio on
(None, None, 320),
];
#[test]
fn matches_cost_table() {
for &(duration, generate_audio, expected) in COST_TABLE {
let got = make_request(duration, generate_audio).calculate_cost_in_cents();
assert_eq!(got, expected, "duration={duration:?} audio={generate_audio:?}");
}
}
#[test]
fn cost_is_independent_of_aspect_ratio() {
let baseline = {
let mut r = make_request(Some(Veo3ImageToVideoDuration::EightSeconds), Some(true));
r.aspect_ratio = Some(Veo3ImageToVideoAspectRatio::Auto);
r.calculate_cost_in_cents()
};
for ar in [
Veo3ImageToVideoAspectRatio::Auto,
Veo3ImageToVideoAspectRatio::SixteenByNine,
Veo3ImageToVideoAspectRatio::NineBySixteen,
] {
let mut r = make_request(Some(Veo3ImageToVideoDuration::EightSeconds), Some(true));
r.aspect_ratio = Some(ar);
assert_eq!(r.calculate_cost_in_cents(), baseline, "ar={ar:?}");
}
}
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -0,0 +1,57 @@
use serde::{Deserialize, Serialize};
/// Over-the-wire input shape for `fal-ai/veo3/image-to-video`.
/// fal's schema: <https://fal.ai/models/fal-ai/veo3/image-to-video/api>
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Veo3ImageToVideoInput {
pub prompt: String,
/// URL of the image used as the starting frame.
pub image_url: String,
/// Aspect ratio.
/// Possible values: "auto", "16:9", "9:16". fal default: "auto".
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
/// Duration in seconds (as a string).
/// Possible values: "4s", "6s", "8s". fal default: "8s".
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<String>,
/// Output resolution.
/// Possible values: "720p", "1080p" (no 4k tier). fal default: "720p".
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<String>,
/// Whether to generate native audio for the video. fal default: true.
#[serde(skip_serializing_if = "Option::is_none")]
pub generate_audio: Option<bool>,
/// Optional negative prompt.
#[serde(skip_serializing_if = "Option::is_none")]
pub negative_prompt: Option<String>,
/// Seed for the random number generator.
#[serde(skip_serializing_if = "Option::is_none")]
pub seed: Option<i64>,
/// Whether to automatically rewrite prompts that fail moderation.
/// fal default: false (image-to-video).
#[serde(skip_serializing_if = "Option::is_none")]
pub auto_fix: Option<bool>,
/// Safety tolerance, "1" (strictest) .. "6" (least strict). fal default: "4".
#[serde(skip_serializing_if = "Option::is_none")]
pub safety_tolerance: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo3ImageToVideoVideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo3ImageToVideoOutput {
pub video: Veo3ImageToVideoVideoFile,
}
@@ -0,0 +1,279 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::video::image::veo_3_fast::raw_request::{
Veo3FastImageToVideoInput, Veo3FastImageToVideoOutput,
};
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
#[derive(Clone, Debug)]
pub struct Veo3FastImageToVideoRequest {
/// Text prompt describing the video to generate.
pub prompt: String,
/// URL of the image used as the starting frame.
pub image_url: String,
/// Aspect ratio. fal's default is `auto` when `None`.
pub aspect_ratio: Option<Veo3FastImageToVideoAspectRatio>,
/// Video duration. fal's default is `8s` when `None`.
pub duration: Option<Veo3FastImageToVideoDuration>,
/// Output resolution. fal's default is `720p` when `None`.
pub resolution: Option<Veo3FastImageToVideoResolution>,
/// Whether to generate native audio. fal's server default is `true` when
/// this is `None`.
pub generate_audio: Option<bool>,
/// Optional negative prompt.
pub negative_prompt: Option<String>,
/// Seed for the random number generator.
pub seed: Option<i64>,
/// Whether to auto-rewrite prompts that fail moderation. fal's default is
/// `false` for image-to-video when `None`.
pub auto_fix: Option<bool>,
/// Safety tolerance. fal's default is `4` when `None`.
pub safety_tolerance: Option<Veo3FastImageToVideoSafetyTolerance>,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3FastImageToVideoAspectRatio {
Auto,
SixteenByNine,
NineBySixteen,
}
impl Veo3FastImageToVideoAspectRatio {
fn to_str(&self) -> &'static str {
match self {
Self::Auto => "auto",
Self::SixteenByNine => "16:9",
Self::NineBySixteen => "9:16",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3FastImageToVideoDuration {
FourSeconds,
SixSeconds,
EightSeconds,
}
impl Veo3FastImageToVideoDuration {
pub fn to_seconds(&self) -> u64 {
match self {
Self::FourSeconds => 4,
Self::SixSeconds => 6,
Self::EightSeconds => 8,
}
}
fn to_str(&self) -> &'static str {
match self {
Self::FourSeconds => "4s",
Self::SixSeconds => "6s",
Self::EightSeconds => "8s",
}
}
}
/// Veo 3 Fast exposes only 720p/1080p (no 4k tier).
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3FastImageToVideoResolution {
SevenTwentyP,
TenEightyP,
}
impl Veo3FastImageToVideoResolution {
fn to_str(&self) -> &'static str {
match self {
Self::SevenTwentyP => "720p",
Self::TenEightyP => "1080p",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3FastImageToVideoSafetyTolerance {
/// "1" — strictest.
Level1,
Level2,
Level3,
/// "4" — fal default.
Level4,
Level5,
/// "6" — least strict.
Level6,
}
impl Veo3FastImageToVideoSafetyTolerance {
fn to_str(&self) -> &'static str {
match self {
Self::Level1 => "1",
Self::Level2 => "2",
Self::Level3 => "3",
Self::Level4 => "4",
Self::Level5 => "5",
Self::Level6 => "6",
}
}
}
impl FalEndpoint for Veo3FastImageToVideoRequest {
const ENDPOINT: &str = "fal-ai/veo3/fast/image-to-video";
type RawRequest = Veo3FastImageToVideoInput;
type RawResponse = Veo3FastImageToVideoOutput;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
Ok(Self::RawRequest {
prompt: self.prompt.clone(),
image_url: self.image_url.clone(),
aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()),
duration: self.duration.map(|d| d.to_str().to_string()),
resolution: self.resolution.map(|r| r.to_str().to_string()),
generate_audio: self.generate_audio,
negative_prompt: self.negative_prompt.clone(),
seed: self.seed,
auto_fix: self.auto_fix,
safety_tolerance: self.safety_tolerance.map(|s| s.to_str().to_string()),
})
}
}
#[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;
// ── 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 = Veo3FastImageToVideoRequest {
prompt: "the lake comes alive with gentle ripples and dappled sunlight".to_string(),
image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(),
aspect_ratio: Some(Veo3FastImageToVideoAspectRatio::SixteenByNine),
duration: Some(Veo3FastImageToVideoDuration::FourSeconds),
resolution: Some(Veo3FastImageToVideoResolution::SevenTwentyP),
generate_audio: Some(false),
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
};
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_queue() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Veo3FastImageToVideoRequest {
prompt: "wind moves through the trees".to_string(),
image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(),
aspect_ratio: None,
duration: Some(Veo3FastImageToVideoDuration::FourSeconds),
resolution: Some(Veo3FastImageToVideoResolution::SevenTwentyP),
generate_audio: Some(false),
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
};
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 = Veo3FastImageToVideoRequest {
prompt: "p".to_string(),
image_url: "https://example.com/start.png".to_string(),
aspect_ratio: Some(Veo3FastImageToVideoAspectRatio::Auto),
duration: Some(Veo3FastImageToVideoDuration::SixSeconds),
resolution: Some(Veo3FastImageToVideoResolution::TenEightyP),
generate_audio: Some(false),
negative_prompt: Some("nope".to_string()),
seed: Some(7),
auto_fix: Some(true),
safety_tolerance: Some(Veo3FastImageToVideoSafetyTolerance::Level2),
};
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.aspect_ratio.as_deref(), Some("auto"));
assert_eq!(raw.duration.as_deref(), Some("6s"));
assert_eq!(raw.resolution.as_deref(), Some("1080p"));
assert_eq!(raw.generate_audio, Some(false));
assert_eq!(raw.negative_prompt.as_deref(), Some("nope"));
assert_eq!(raw.seed, Some(7));
assert_eq!(raw.auto_fix, Some(true));
assert_eq!(raw.safety_tolerance.as_deref(), Some("2"));
}
#[test]
fn raw_request_omits_unset_optionals() {
let request = Veo3FastImageToVideoRequest {
prompt: "p".to_string(),
image_url: "https://example.com/start.png".to_string(),
aspect_ratio: None,
duration: None,
resolution: None,
generate_audio: None,
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: 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 every_aspect_ratio_maps_to_wire_string() {
for (variant, expected) in [
(Veo3FastImageToVideoAspectRatio::Auto, "auto"),
(Veo3FastImageToVideoAspectRatio::SixteenByNine, "16:9"),
(Veo3FastImageToVideoAspectRatio::NineBySixteen, "9:16"),
] {
assert_eq!(variant.to_str(), expected);
}
}
#[test]
fn endpoint_path_is_canonical() {
assert_eq!(
Veo3FastImageToVideoRequest::ENDPOINT,
"fal-ai/veo3/fast/image-to-video",
);
}
// NB: Pricing tests are in cost.rs
}
@@ -0,0 +1,92 @@
use crate::requests::api::video::image::veo_3_fast::api::{
Veo3FastImageToVideoDuration, Veo3FastImageToVideoRequest,
};
use crate::requests::api::video::text::veo_3_fast::cost::{
veo_3_fast_cost_cents, veo_3_fast_rate_tenth_cents_per_sec,
};
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
// Image-to-video shares the Veo 3 Fast per-second pricing (see the text module
// for the canonical rate table): $0.10/sec (audio off), $0.15/sec (audio on).
// Flat — resolution does not affect the price.
impl FalRequestCostCalculator for Veo3FastImageToVideoRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
// fal defaults when unset: duration = 8s, audio = on.
let duration_secs = self.duration
.unwrap_or(Veo3FastImageToVideoDuration::EightSeconds)
.to_seconds();
let audio_on = self.generate_audio.unwrap_or(true);
let rate = veo_3_fast_rate_tenth_cents_per_sec(audio_on);
veo_3_fast_cost_cents(rate, duration_secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::requests::api::video::image::veo_3_fast::api::Veo3FastImageToVideoAspectRatio;
fn make_request(
duration: Option<Veo3FastImageToVideoDuration>,
generate_audio: Option<bool>,
) -> Veo3FastImageToVideoRequest {
Veo3FastImageToVideoRequest {
prompt: "test".to_string(),
image_url: "https://example.com/x.png".to_string(),
aspect_ratio: None,
duration,
resolution: None,
generate_audio,
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
}
}
mod cost_table {
use super::*;
// (duration, generate_audio, expected_cents)
const COST_TABLE: &[(
Option<Veo3FastImageToVideoDuration>,
Option<bool>,
u64,
)] = &[
(Some(Veo3FastImageToVideoDuration::FourSeconds), Some(false), 40),
(Some(Veo3FastImageToVideoDuration::FourSeconds), Some(true), 60),
(Some(Veo3FastImageToVideoDuration::EightSeconds), Some(false), 80),
(Some(Veo3FastImageToVideoDuration::EightSeconds), Some(true), 120),
// Defaults: 8s, audio on
(None, None, 120),
];
#[test]
fn matches_cost_table() {
for &(duration, generate_audio, expected) in COST_TABLE {
let got = make_request(duration, generate_audio).calculate_cost_in_cents();
assert_eq!(got, expected, "duration={duration:?} audio={generate_audio:?}");
}
}
#[test]
fn cost_is_independent_of_aspect_ratio() {
let baseline = {
let mut r = make_request(Some(Veo3FastImageToVideoDuration::EightSeconds), Some(true));
r.aspect_ratio = Some(Veo3FastImageToVideoAspectRatio::Auto);
r.calculate_cost_in_cents()
};
for ar in [
Veo3FastImageToVideoAspectRatio::Auto,
Veo3FastImageToVideoAspectRatio::SixteenByNine,
Veo3FastImageToVideoAspectRatio::NineBySixteen,
] {
let mut r = make_request(Some(Veo3FastImageToVideoDuration::EightSeconds), Some(true));
r.aspect_ratio = Some(ar);
assert_eq!(r.calculate_cost_in_cents(), baseline, "ar={ar:?}");
}
}
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -0,0 +1,57 @@
use serde::{Deserialize, Serialize};
/// Over-the-wire input shape for `fal-ai/veo3/fast/image-to-video`.
/// fal's schema: <https://fal.ai/models/fal-ai/veo3/fast/image-to-video/api>
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Veo3FastImageToVideoInput {
pub prompt: String,
/// URL of the image used as the starting frame.
pub image_url: String,
/// Aspect ratio.
/// Possible values: "auto", "16:9", "9:16". fal default: "auto".
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
/// Duration in seconds (as a string).
/// Possible values: "4s", "6s", "8s". fal default: "8s".
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<String>,
/// Output resolution.
/// Possible values: "720p", "1080p" (no 4k tier). fal default: "720p".
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<String>,
/// Whether to generate native audio for the video. fal default: true.
#[serde(skip_serializing_if = "Option::is_none")]
pub generate_audio: Option<bool>,
/// Optional negative prompt.
#[serde(skip_serializing_if = "Option::is_none")]
pub negative_prompt: Option<String>,
/// Seed for the random number generator.
#[serde(skip_serializing_if = "Option::is_none")]
pub seed: Option<i64>,
/// Whether to automatically rewrite prompts that fail moderation.
/// fal default: false (image-to-video).
#[serde(skip_serializing_if = "Option::is_none")]
pub auto_fix: Option<bool>,
/// Safety tolerance, "1" (strictest) .. "6" (least strict). fal default: "4".
#[serde(skip_serializing_if = "Option::is_none")]
pub safety_tolerance: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo3FastImageToVideoVideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo3FastImageToVideoOutput {
pub video: Veo3FastImageToVideoVideoFile,
}
@@ -0,0 +1,285 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::video::image::veo_3p1::raw_request::{
Veo3p1ImageToVideoInput, Veo3p1ImageToVideoOutput,
};
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
#[derive(Clone, Debug)]
pub struct Veo3p1ImageToVideoRequest {
/// Text prompt describing the video to generate.
pub prompt: String,
/// URL of the image used as the starting frame.
pub image_url: String,
/// Aspect ratio. fal's default is `auto` when `None`.
pub aspect_ratio: Option<Veo3p1ImageToVideoAspectRatio>,
/// Video duration. fal's default is `8s` when `None`.
pub duration: Option<Veo3p1ImageToVideoDuration>,
/// Output resolution. fal's default is `720p` when `None`.
pub resolution: Option<Veo3p1ImageToVideoResolution>,
/// Whether to generate native audio. fal's server default is `true` when
/// this is `None`.
pub generate_audio: Option<bool>,
/// Optional negative prompt.
pub negative_prompt: Option<String>,
/// Seed for the random number generator.
pub seed: Option<i64>,
/// Whether to auto-rewrite prompts that fail moderation. fal's default is
/// `false` for image-to-video when `None`.
pub auto_fix: Option<bool>,
/// Safety tolerance. fal's default is `4` when `None`.
pub safety_tolerance: Option<Veo3p1ImageToVideoSafetyTolerance>,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1ImageToVideoAspectRatio {
Auto,
SixteenByNine,
NineBySixteen,
}
impl Veo3p1ImageToVideoAspectRatio {
fn to_str(&self) -> &'static str {
match self {
Self::Auto => "auto",
Self::SixteenByNine => "16:9",
Self::NineBySixteen => "9:16",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1ImageToVideoDuration {
FourSeconds,
SixSeconds,
EightSeconds,
}
impl Veo3p1ImageToVideoDuration {
pub fn to_seconds(&self) -> u64 {
match self {
Self::FourSeconds => 4,
Self::SixSeconds => 6,
Self::EightSeconds => 8,
}
}
fn to_str(&self) -> &'static str {
match self {
Self::FourSeconds => "4s",
Self::SixSeconds => "6s",
Self::EightSeconds => "8s",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1ImageToVideoResolution {
SevenTwentyP,
TenEightyP,
FourK,
}
impl Veo3p1ImageToVideoResolution {
/// 4k output is billed at a higher per-second rate than 720p/1080p.
pub fn is_four_k(&self) -> bool {
matches!(self, Self::FourK)
}
fn to_str(&self) -> &'static str {
match self {
Self::SevenTwentyP => "720p",
Self::TenEightyP => "1080p",
Self::FourK => "4k",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1ImageToVideoSafetyTolerance {
/// "1" — strictest.
Level1,
Level2,
Level3,
/// "4" — fal default.
Level4,
Level5,
/// "6" — least strict.
Level6,
}
impl Veo3p1ImageToVideoSafetyTolerance {
fn to_str(&self) -> &'static str {
match self {
Self::Level1 => "1",
Self::Level2 => "2",
Self::Level3 => "3",
Self::Level4 => "4",
Self::Level5 => "5",
Self::Level6 => "6",
}
}
}
impl FalEndpoint for Veo3p1ImageToVideoRequest {
const ENDPOINT: &str = "fal-ai/veo3.1/image-to-video";
type RawRequest = Veo3p1ImageToVideoInput;
type RawResponse = Veo3p1ImageToVideoOutput;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
Ok(Self::RawRequest {
prompt: self.prompt.clone(),
image_url: self.image_url.clone(),
aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()),
duration: self.duration.map(|d| d.to_str().to_string()),
resolution: self.resolution.map(|r| r.to_str().to_string()),
generate_audio: self.generate_audio,
negative_prompt: self.negative_prompt.clone(),
seed: self.seed,
auto_fix: self.auto_fix,
safety_tolerance: self.safety_tolerance.map(|s| s.to_str().to_string()),
})
}
}
#[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;
// ── 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 = Veo3p1ImageToVideoRequest {
prompt: "the lake comes alive with gentle ripples and dappled sunlight".to_string(),
image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(),
aspect_ratio: Some(Veo3p1ImageToVideoAspectRatio::SixteenByNine),
duration: Some(Veo3p1ImageToVideoDuration::FourSeconds),
resolution: Some(Veo3p1ImageToVideoResolution::SevenTwentyP),
generate_audio: Some(false),
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
};
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_queue() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Veo3p1ImageToVideoRequest {
prompt: "wind moves through the trees".to_string(),
image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(),
aspect_ratio: None,
duration: Some(Veo3p1ImageToVideoDuration::FourSeconds),
resolution: Some(Veo3p1ImageToVideoResolution::SevenTwentyP),
generate_audio: Some(false),
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
};
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 = Veo3p1ImageToVideoRequest {
prompt: "p".to_string(),
image_url: "https://example.com/start.png".to_string(),
aspect_ratio: Some(Veo3p1ImageToVideoAspectRatio::Auto),
duration: Some(Veo3p1ImageToVideoDuration::SixSeconds),
resolution: Some(Veo3p1ImageToVideoResolution::TenEightyP),
generate_audio: Some(false),
negative_prompt: Some("nope".to_string()),
seed: Some(7),
auto_fix: Some(true),
safety_tolerance: Some(Veo3p1ImageToVideoSafetyTolerance::Level2),
};
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.aspect_ratio.as_deref(), Some("auto"));
assert_eq!(raw.duration.as_deref(), Some("6s"));
assert_eq!(raw.resolution.as_deref(), Some("1080p"));
assert_eq!(raw.generate_audio, Some(false));
assert_eq!(raw.negative_prompt.as_deref(), Some("nope"));
assert_eq!(raw.seed, Some(7));
assert_eq!(raw.auto_fix, Some(true));
assert_eq!(raw.safety_tolerance.as_deref(), Some("2"));
}
#[test]
fn raw_request_omits_unset_optionals() {
let request = Veo3p1ImageToVideoRequest {
prompt: "p".to_string(),
image_url: "https://example.com/start.png".to_string(),
aspect_ratio: None,
duration: None,
resolution: None,
generate_audio: None,
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: 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 every_aspect_ratio_maps_to_wire_string() {
for (variant, expected) in [
(Veo3p1ImageToVideoAspectRatio::Auto, "auto"),
(Veo3p1ImageToVideoAspectRatio::SixteenByNine, "16:9"),
(Veo3p1ImageToVideoAspectRatio::NineBySixteen, "9:16"),
] {
assert_eq!(variant.to_str(), expected);
}
}
#[test]
fn endpoint_path_is_canonical() {
assert_eq!(
Veo3p1ImageToVideoRequest::ENDPOINT,
"fal-ai/veo3.1/image-to-video",
);
}
// NB: Pricing tests are in cost.rs
}
@@ -0,0 +1,112 @@
use crate::requests::api::video::image::veo_3p1::api::{
Veo3p1ImageToVideoDuration, Veo3p1ImageToVideoRequest, Veo3p1ImageToVideoResolution,
};
use crate::requests::api::video::text::veo_3p1::cost::{
veo_3p1_cost_cents, veo_3p1_rate_tenth_cents_per_sec,
};
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
// Image-to-video shares the Veo 3.1 (non-fast) per-second pricing (see the
// text module for the canonical rate table):
// 720p / 1080p: $0.20/sec (audio off), $0.40/sec (audio on)
// 4k: $0.40/sec (audio off), $0.60/sec (audio on)
impl FalRequestCostCalculator for Veo3p1ImageToVideoRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
// fal defaults when unset: duration = 8s, resolution = 720p, audio = on.
let duration_secs = self.duration
.unwrap_or(Veo3p1ImageToVideoDuration::EightSeconds)
.to_seconds();
let four_k = self.resolution
.unwrap_or(Veo3p1ImageToVideoResolution::SevenTwentyP)
.is_four_k();
let audio_on = self.generate_audio.unwrap_or(true);
let rate = veo_3p1_rate_tenth_cents_per_sec(four_k, audio_on);
veo_3p1_cost_cents(rate, duration_secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::requests::api::video::image::veo_3p1::api::Veo3p1ImageToVideoAspectRatio;
fn make_request(
duration: Option<Veo3p1ImageToVideoDuration>,
resolution: Option<Veo3p1ImageToVideoResolution>,
generate_audio: Option<bool>,
) -> Veo3p1ImageToVideoRequest {
Veo3p1ImageToVideoRequest {
prompt: "test".to_string(),
image_url: "https://example.com/x.png".to_string(),
aspect_ratio: Some(Veo3p1ImageToVideoAspectRatio::SixteenByNine),
duration,
resolution,
generate_audio,
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
}
}
mod cost_table {
use super::*;
// (duration, resolution, generate_audio, expected_cents)
const COST_TABLE: &[(
Option<Veo3p1ImageToVideoDuration>,
Option<Veo3p1ImageToVideoResolution>,
Option<bool>,
u64,
)] = &[
(Some(Veo3p1ImageToVideoDuration::FourSeconds), Some(Veo3p1ImageToVideoResolution::SevenTwentyP), Some(false), 80),
(Some(Veo3p1ImageToVideoDuration::FourSeconds), Some(Veo3p1ImageToVideoResolution::SevenTwentyP), Some(true), 160),
(Some(Veo3p1ImageToVideoDuration::EightSeconds), Some(Veo3p1ImageToVideoResolution::TenEightyP), Some(false), 160),
(Some(Veo3p1ImageToVideoDuration::EightSeconds), Some(Veo3p1ImageToVideoResolution::TenEightyP), Some(true), 320),
(Some(Veo3p1ImageToVideoDuration::EightSeconds), Some(Veo3p1ImageToVideoResolution::FourK), Some(false), 320),
(Some(Veo3p1ImageToVideoDuration::EightSeconds), Some(Veo3p1ImageToVideoResolution::FourK), Some(true), 480),
// Defaults: 8s, 720p, audio on
(None, None, None, 320),
];
#[test]
fn matches_cost_table() {
for &(duration, resolution, generate_audio, expected) in COST_TABLE {
let got = make_request(duration, resolution, generate_audio).calculate_cost_in_cents();
assert_eq!(
got, expected,
"duration={duration:?} resolution={resolution:?} audio={generate_audio:?}",
);
}
}
#[test]
fn cost_is_independent_of_aspect_ratio() {
let aspect_ratios = [
Veo3p1ImageToVideoAspectRatio::Auto,
Veo3p1ImageToVideoAspectRatio::SixteenByNine,
Veo3p1ImageToVideoAspectRatio::NineBySixteen,
];
let baseline = {
let mut r = make_request(
Some(Veo3p1ImageToVideoDuration::EightSeconds),
Some(Veo3p1ImageToVideoResolution::TenEightyP),
Some(true),
);
r.aspect_ratio = Some(aspect_ratios[0]);
r.calculate_cost_in_cents()
};
for ar in aspect_ratios {
let mut r = make_request(
Some(Veo3p1ImageToVideoDuration::EightSeconds),
Some(Veo3p1ImageToVideoResolution::TenEightyP),
Some(true),
);
r.aspect_ratio = Some(ar);
assert_eq!(r.calculate_cost_in_cents(), baseline, "ar={ar:?}");
}
}
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -0,0 +1,58 @@
use serde::{Deserialize, Serialize};
/// Over-the-wire input shape for `fal-ai/veo3.1/image-to-video`
/// (single start-frame keyframe).
/// fal's schema: <https://fal.ai/models/fal-ai/veo3.1/image-to-video/api>
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Veo3p1ImageToVideoInput {
pub prompt: String,
/// URL of the image used as the starting frame.
pub image_url: String,
/// Aspect ratio.
/// Possible values: "auto", "16:9", "9:16". fal default: "auto".
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
/// Duration in seconds (as a string).
/// Possible values: "4s", "6s", "8s". fal default: "8s".
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<String>,
/// Output resolution.
/// Possible values: "720p", "1080p", "4k". fal default: "720p".
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<String>,
/// Whether to generate native audio for the video. fal default: true.
#[serde(skip_serializing_if = "Option::is_none")]
pub generate_audio: Option<bool>,
/// Optional negative prompt.
#[serde(skip_serializing_if = "Option::is_none")]
pub negative_prompt: Option<String>,
/// Seed for the random number generator.
#[serde(skip_serializing_if = "Option::is_none")]
pub seed: Option<i64>,
/// Whether to automatically rewrite prompts that fail moderation.
/// fal default: false (image-to-video).
#[serde(skip_serializing_if = "Option::is_none")]
pub auto_fix: Option<bool>,
/// Safety tolerance, "1" (strictest) .. "6" (least strict). fal default: "4".
#[serde(skip_serializing_if = "Option::is_none")]
pub safety_tolerance: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo3p1ImageToVideoVideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo3p1ImageToVideoOutput {
pub video: Veo3p1ImageToVideoVideoFile,
}
@@ -0,0 +1,286 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::video::image::veo_3p1_fast::raw_request::{
Veo3p1FastImageToVideoInput, Veo3p1FastImageToVideoOutput,
};
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
#[derive(Clone, Debug)]
pub struct Veo3p1FastImageToVideoRequest {
/// Text prompt describing the video to generate.
pub prompt: String,
/// URL of the image used as the starting frame.
pub image_url: String,
/// Aspect ratio. fal's default is `auto` when `None`.
pub aspect_ratio: Option<Veo3p1FastImageToVideoAspectRatio>,
/// Video duration. fal's default is `8s` when `None`.
pub duration: Option<Veo3p1FastImageToVideoDuration>,
/// Output resolution. fal's default is `720p` when `None`.
pub resolution: Option<Veo3p1FastImageToVideoResolution>,
/// Whether to generate native audio. fal's server default is `true` when
/// this is `None`.
pub generate_audio: Option<bool>,
/// Optional negative prompt.
pub negative_prompt: Option<String>,
/// Seed for the random number generator.
pub seed: Option<i64>,
/// Whether to auto-rewrite prompts that fail moderation. fal's default is
/// `false` for image-to-video when `None`.
pub auto_fix: Option<bool>,
/// Safety tolerance. fal's default is `4` when `None`.
pub safety_tolerance: Option<Veo3p1FastImageToVideoSafetyTolerance>,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1FastImageToVideoAspectRatio {
Auto,
SixteenByNine,
NineBySixteen,
}
impl Veo3p1FastImageToVideoAspectRatio {
fn to_str(&self) -> &'static str {
match self {
Self::Auto => "auto",
Self::SixteenByNine => "16:9",
Self::NineBySixteen => "9:16",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1FastImageToVideoDuration {
FourSeconds,
SixSeconds,
EightSeconds,
}
impl Veo3p1FastImageToVideoDuration {
pub fn to_seconds(&self) -> u64 {
match self {
Self::FourSeconds => 4,
Self::SixSeconds => 6,
Self::EightSeconds => 8,
}
}
fn to_str(&self) -> &'static str {
match self {
Self::FourSeconds => "4s",
Self::SixSeconds => "6s",
Self::EightSeconds => "8s",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1FastImageToVideoResolution {
SevenTwentyP,
TenEightyP,
FourK,
}
impl Veo3p1FastImageToVideoResolution {
/// 4k output is billed at a higher per-second rate than 720p/1080p.
pub fn is_four_k(&self) -> bool {
matches!(self, Self::FourK)
}
fn to_str(&self) -> &'static str {
match self {
Self::SevenTwentyP => "720p",
Self::TenEightyP => "1080p",
Self::FourK => "4k",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1FastImageToVideoSafetyTolerance {
/// "1" — strictest.
Level1,
Level2,
Level3,
/// "4" — fal default.
Level4,
Level5,
/// "6" — least strict.
Level6,
}
impl Veo3p1FastImageToVideoSafetyTolerance {
fn to_str(&self) -> &'static str {
match self {
Self::Level1 => "1",
Self::Level2 => "2",
Self::Level3 => "3",
Self::Level4 => "4",
Self::Level5 => "5",
Self::Level6 => "6",
}
}
}
impl FalEndpoint for Veo3p1FastImageToVideoRequest {
const ENDPOINT: &str = "fal-ai/veo3.1/fast/image-to-video";
type RawRequest = Veo3p1FastImageToVideoInput;
type RawResponse = Veo3p1FastImageToVideoOutput;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
Ok(Self::RawRequest {
prompt: self.prompt.clone(),
image_url: self.image_url.clone(),
aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()),
duration: self.duration.map(|d| d.to_str().to_string()),
resolution: self.resolution.map(|r| r.to_str().to_string()),
generate_audio: self.generate_audio,
negative_prompt: self.negative_prompt.clone(),
seed: self.seed,
auto_fix: self.auto_fix,
safety_tolerance: self.safety_tolerance.map(|s| s.to_str().to_string()),
})
}
}
#[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;
// ── 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 = Veo3p1FastImageToVideoRequest {
prompt: "the lake comes alive with gentle ripples and dappled sunlight".to_string(),
image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(),
aspect_ratio: Some(Veo3p1FastImageToVideoAspectRatio::SixteenByNine),
duration: Some(Veo3p1FastImageToVideoDuration::FourSeconds),
resolution: Some(Veo3p1FastImageToVideoResolution::SevenTwentyP),
generate_audio: Some(false),
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
};
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_queue() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Veo3p1FastImageToVideoRequest {
prompt: "wind moves through the trees".to_string(),
image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(),
aspect_ratio: None,
duration: Some(Veo3p1FastImageToVideoDuration::FourSeconds),
resolution: Some(Veo3p1FastImageToVideoResolution::SevenTwentyP),
generate_audio: Some(false),
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
};
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 = Veo3p1FastImageToVideoRequest {
prompt: "p".to_string(),
image_url: "https://example.com/start.png".to_string(),
aspect_ratio: Some(Veo3p1FastImageToVideoAspectRatio::Auto),
duration: Some(Veo3p1FastImageToVideoDuration::SixSeconds),
resolution: Some(Veo3p1FastImageToVideoResolution::TenEightyP),
generate_audio: Some(false),
negative_prompt: Some("nope".to_string()),
seed: Some(7),
auto_fix: Some(true),
safety_tolerance: Some(Veo3p1FastImageToVideoSafetyTolerance::Level2),
};
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.aspect_ratio.as_deref(), Some("auto"));
assert_eq!(raw.duration.as_deref(), Some("6s"));
assert_eq!(raw.resolution.as_deref(), Some("1080p"));
assert_eq!(raw.generate_audio, Some(false));
assert_eq!(raw.negative_prompt.as_deref(), Some("nope"));
assert_eq!(raw.seed, Some(7));
assert_eq!(raw.auto_fix, Some(true));
assert_eq!(raw.safety_tolerance.as_deref(), Some("2"));
}
#[test]
fn raw_request_omits_unset_optionals() {
let request = Veo3p1FastImageToVideoRequest {
prompt: "p".to_string(),
image_url: "https://example.com/start.png".to_string(),
aspect_ratio: None,
duration: None,
resolution: None,
generate_audio: None,
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
// Only the two required fields remain on the wire.
assert_eq!(
json,
serde_json::json!({ "prompt": "p", "image_url": "https://example.com/start.png" }),
);
}
#[test]
fn every_aspect_ratio_maps_to_wire_string() {
for (variant, expected) in [
(Veo3p1FastImageToVideoAspectRatio::Auto, "auto"),
(Veo3p1FastImageToVideoAspectRatio::SixteenByNine, "16:9"),
(Veo3p1FastImageToVideoAspectRatio::NineBySixteen, "9:16"),
] {
assert_eq!(variant.to_str(), expected);
}
}
#[test]
fn endpoint_path_is_canonical() {
assert_eq!(
Veo3p1FastImageToVideoRequest::ENDPOINT,
"fal-ai/veo3.1/fast/image-to-video",
);
}
// NB: Pricing tests are in cost.rs
}
@@ -0,0 +1,112 @@
use crate::requests::api::video::image::veo_3p1_fast::api::{
Veo3p1FastImageToVideoDuration, Veo3p1FastImageToVideoRequest, Veo3p1FastImageToVideoResolution,
};
use crate::requests::api::video::text::veo_3p1_fast::cost::{
veo_3p1_fast_cost_cents, veo_3p1_fast_rate_tenth_cents_per_sec,
};
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
// Image-to-video shares the Veo 3.1 Fast per-second pricing (see the text
// module for the canonical rate table):
// 720p / 1080p: $0.10/sec (audio off), $0.15/sec (audio on)
// 4k: $0.30/sec (audio off), $0.35/sec (audio on)
impl FalRequestCostCalculator for Veo3p1FastImageToVideoRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
// fal defaults when unset: duration = 8s, resolution = 720p, audio = on.
let duration_secs = self.duration
.unwrap_or(Veo3p1FastImageToVideoDuration::EightSeconds)
.to_seconds();
let four_k = self.resolution
.unwrap_or(Veo3p1FastImageToVideoResolution::SevenTwentyP)
.is_four_k();
let audio_on = self.generate_audio.unwrap_or(true);
let rate = veo_3p1_fast_rate_tenth_cents_per_sec(four_k, audio_on);
veo_3p1_fast_cost_cents(rate, duration_secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::requests::api::video::image::veo_3p1_fast::api::Veo3p1FastImageToVideoAspectRatio;
fn make_request(
duration: Option<Veo3p1FastImageToVideoDuration>,
resolution: Option<Veo3p1FastImageToVideoResolution>,
generate_audio: Option<bool>,
) -> Veo3p1FastImageToVideoRequest {
Veo3p1FastImageToVideoRequest {
prompt: "test".to_string(),
image_url: "https://example.com/x.png".to_string(),
aspect_ratio: Some(Veo3p1FastImageToVideoAspectRatio::SixteenByNine),
duration,
resolution,
generate_audio,
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
}
}
mod cost_table {
use super::*;
// (duration, resolution, generate_audio, expected_cents)
const COST_TABLE: &[(
Option<Veo3p1FastImageToVideoDuration>,
Option<Veo3p1FastImageToVideoResolution>,
Option<bool>,
u64,
)] = &[
(Some(Veo3p1FastImageToVideoDuration::FourSeconds), Some(Veo3p1FastImageToVideoResolution::SevenTwentyP), Some(false), 40),
(Some(Veo3p1FastImageToVideoDuration::FourSeconds), Some(Veo3p1FastImageToVideoResolution::SevenTwentyP), Some(true), 60),
(Some(Veo3p1FastImageToVideoDuration::EightSeconds), Some(Veo3p1FastImageToVideoResolution::TenEightyP), Some(false), 80),
(Some(Veo3p1FastImageToVideoDuration::EightSeconds), Some(Veo3p1FastImageToVideoResolution::TenEightyP), Some(true), 120),
(Some(Veo3p1FastImageToVideoDuration::EightSeconds), Some(Veo3p1FastImageToVideoResolution::FourK), Some(false), 240),
(Some(Veo3p1FastImageToVideoDuration::EightSeconds), Some(Veo3p1FastImageToVideoResolution::FourK), Some(true), 280),
// Defaults: 8s, 720p, audio on
(None, None, None, 120),
];
#[test]
fn matches_cost_table() {
for &(duration, resolution, generate_audio, expected) in COST_TABLE {
let got = make_request(duration, resolution, generate_audio).calculate_cost_in_cents();
assert_eq!(
got, expected,
"duration={duration:?} resolution={resolution:?} audio={generate_audio:?}",
);
}
}
#[test]
fn cost_is_independent_of_aspect_ratio() {
let aspect_ratios = [
Veo3p1FastImageToVideoAspectRatio::Auto,
Veo3p1FastImageToVideoAspectRatio::SixteenByNine,
Veo3p1FastImageToVideoAspectRatio::NineBySixteen,
];
let baseline = {
let mut r = make_request(
Some(Veo3p1FastImageToVideoDuration::EightSeconds),
Some(Veo3p1FastImageToVideoResolution::TenEightyP),
Some(true),
);
r.aspect_ratio = Some(aspect_ratios[0]);
r.calculate_cost_in_cents()
};
for ar in aspect_ratios {
let mut r = make_request(
Some(Veo3p1FastImageToVideoDuration::EightSeconds),
Some(Veo3p1FastImageToVideoResolution::TenEightyP),
Some(true),
);
r.aspect_ratio = Some(ar);
assert_eq!(r.calculate_cost_in_cents(), baseline, "ar={ar:?}");
}
}
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -0,0 +1,58 @@
use serde::{Deserialize, Serialize};
/// Over-the-wire input shape for `fal-ai/veo3.1/fast/image-to-video`
/// (single start-frame keyframe).
/// fal's schema: <https://fal.ai/models/fal-ai/veo3.1/fast/image-to-video/api>
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Veo3p1FastImageToVideoInput {
pub prompt: String,
/// URL of the image used as the starting frame.
pub image_url: String,
/// Aspect ratio.
/// Possible values: "auto", "16:9", "9:16". fal default: "auto".
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
/// Duration in seconds (as a string).
/// Possible values: "4s", "6s", "8s". fal default: "8s".
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<String>,
/// Output resolution.
/// Possible values: "720p", "1080p", "4k". fal default: "720p".
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<String>,
/// Whether to generate native audio for the video. fal default: true.
#[serde(skip_serializing_if = "Option::is_none")]
pub generate_audio: Option<bool>,
/// Optional negative prompt.
#[serde(skip_serializing_if = "Option::is_none")]
pub negative_prompt: Option<String>,
/// Seed for the random number generator.
#[serde(skip_serializing_if = "Option::is_none")]
pub seed: Option<i64>,
/// Whether to automatically rewrite prompts that fail moderation.
/// fal default: false (image-to-video).
#[serde(skip_serializing_if = "Option::is_none")]
pub auto_fix: Option<bool>,
/// Safety tolerance, "1" (strictest) .. "6" (least strict). fal default: "4".
#[serde(skip_serializing_if = "Option::is_none")]
pub safety_tolerance: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo3p1FastImageToVideoVideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo3p1FastImageToVideoOutput {
pub video: Veo3p1FastImageToVideoVideoFile,
}
@@ -0,0 +1,285 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::video::image::veo_3p1_lite::raw_request::{
Veo3p1LiteImageToVideoInput, Veo3p1LiteImageToVideoOutput,
};
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
#[derive(Clone, Debug)]
pub struct Veo3p1LiteImageToVideoRequest {
/// Text prompt describing the video to generate.
pub prompt: String,
/// URL of the image used as the starting frame.
pub image_url: String,
/// Aspect ratio. fal's default is `auto` when `None`.
pub aspect_ratio: Option<Veo3p1LiteImageToVideoAspectRatio>,
/// Video duration. fal's default is `8s` when `None`.
pub duration: Option<Veo3p1LiteImageToVideoDuration>,
/// Output resolution. fal's default is `720p` when `None`.
pub resolution: Option<Veo3p1LiteImageToVideoResolution>,
/// Whether to generate native audio. fal's server default is `true` when
/// this is `None`.
pub generate_audio: Option<bool>,
/// Optional negative prompt.
pub negative_prompt: Option<String>,
/// Seed for the random number generator.
pub seed: Option<i64>,
/// Whether to auto-rewrite prompts that fail moderation. fal's default is
/// `false` for image-to-video when `None`.
pub auto_fix: Option<bool>,
/// Safety tolerance. fal's default is `4` when `None`.
pub safety_tolerance: Option<Veo3p1LiteImageToVideoSafetyTolerance>,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1LiteImageToVideoAspectRatio {
Auto,
SixteenByNine,
NineBySixteen,
}
impl Veo3p1LiteImageToVideoAspectRatio {
fn to_str(&self) -> &'static str {
match self {
Self::Auto => "auto",
Self::SixteenByNine => "16:9",
Self::NineBySixteen => "9:16",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1LiteImageToVideoDuration {
FourSeconds,
SixSeconds,
EightSeconds,
}
impl Veo3p1LiteImageToVideoDuration {
pub fn to_seconds(&self) -> u64 {
match self {
Self::FourSeconds => 4,
Self::SixSeconds => 6,
Self::EightSeconds => 8,
}
}
fn to_str(&self) -> &'static str {
match self {
Self::FourSeconds => "4s",
Self::SixSeconds => "6s",
Self::EightSeconds => "8s",
}
}
}
/// Veo 3.1 Lite exposes only 720p/1080p (no 4k tier). Both resolution AND
/// audio affect the per-second price.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1LiteImageToVideoResolution {
SevenTwentyP,
TenEightyP,
}
impl Veo3p1LiteImageToVideoResolution {
/// 1080p is billed at a higher per-second rate than 720p.
pub fn is_ten_eighty_p(&self) -> bool {
matches!(self, Self::TenEightyP)
}
fn to_str(&self) -> &'static str {
match self {
Self::SevenTwentyP => "720p",
Self::TenEightyP => "1080p",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1LiteImageToVideoSafetyTolerance {
/// "1" — strictest.
Level1,
Level2,
Level3,
/// "4" — fal default.
Level4,
Level5,
/// "6" — least strict.
Level6,
}
impl Veo3p1LiteImageToVideoSafetyTolerance {
fn to_str(&self) -> &'static str {
match self {
Self::Level1 => "1",
Self::Level2 => "2",
Self::Level3 => "3",
Self::Level4 => "4",
Self::Level5 => "5",
Self::Level6 => "6",
}
}
}
impl FalEndpoint for Veo3p1LiteImageToVideoRequest {
const ENDPOINT: &str = "fal-ai/veo3.1/lite/image-to-video";
type RawRequest = Veo3p1LiteImageToVideoInput;
type RawResponse = Veo3p1LiteImageToVideoOutput;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
Ok(Self::RawRequest {
prompt: self.prompt.clone(),
image_url: self.image_url.clone(),
aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()),
duration: self.duration.map(|d| d.to_str().to_string()),
resolution: self.resolution.map(|r| r.to_str().to_string()),
generate_audio: self.generate_audio,
negative_prompt: self.negative_prompt.clone(),
seed: self.seed,
auto_fix: self.auto_fix,
safety_tolerance: self.safety_tolerance.map(|s| s.to_str().to_string()),
})
}
}
#[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;
// ── 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 = Veo3p1LiteImageToVideoRequest {
prompt: "the lake comes alive with gentle ripples and dappled sunlight".to_string(),
image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(),
aspect_ratio: Some(Veo3p1LiteImageToVideoAspectRatio::SixteenByNine),
duration: Some(Veo3p1LiteImageToVideoDuration::FourSeconds),
resolution: Some(Veo3p1LiteImageToVideoResolution::SevenTwentyP),
generate_audio: Some(false),
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
};
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_queue() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Veo3p1LiteImageToVideoRequest {
prompt: "wind moves through the trees".to_string(),
image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(),
aspect_ratio: None,
duration: Some(Veo3p1LiteImageToVideoDuration::FourSeconds),
resolution: Some(Veo3p1LiteImageToVideoResolution::SevenTwentyP),
generate_audio: Some(false),
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
};
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 = Veo3p1LiteImageToVideoRequest {
prompt: "p".to_string(),
image_url: "https://example.com/start.png".to_string(),
aspect_ratio: Some(Veo3p1LiteImageToVideoAspectRatio::Auto),
duration: Some(Veo3p1LiteImageToVideoDuration::SixSeconds),
resolution: Some(Veo3p1LiteImageToVideoResolution::TenEightyP),
generate_audio: Some(false),
negative_prompt: Some("nope".to_string()),
seed: Some(7),
auto_fix: Some(true),
safety_tolerance: Some(Veo3p1LiteImageToVideoSafetyTolerance::Level2),
};
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.aspect_ratio.as_deref(), Some("auto"));
assert_eq!(raw.duration.as_deref(), Some("6s"));
assert_eq!(raw.resolution.as_deref(), Some("1080p"));
assert_eq!(raw.generate_audio, Some(false));
assert_eq!(raw.negative_prompt.as_deref(), Some("nope"));
assert_eq!(raw.seed, Some(7));
assert_eq!(raw.auto_fix, Some(true));
assert_eq!(raw.safety_tolerance.as_deref(), Some("2"));
}
#[test]
fn raw_request_omits_unset_optionals() {
let request = Veo3p1LiteImageToVideoRequest {
prompt: "p".to_string(),
image_url: "https://example.com/start.png".to_string(),
aspect_ratio: None,
duration: None,
resolution: None,
generate_audio: None,
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: 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 every_aspect_ratio_maps_to_wire_string() {
for (variant, expected) in [
(Veo3p1LiteImageToVideoAspectRatio::Auto, "auto"),
(Veo3p1LiteImageToVideoAspectRatio::SixteenByNine, "16:9"),
(Veo3p1LiteImageToVideoAspectRatio::NineBySixteen, "9:16"),
] {
assert_eq!(variant.to_str(), expected);
}
}
#[test]
fn endpoint_path_is_canonical() {
assert_eq!(
Veo3p1LiteImageToVideoRequest::ENDPOINT,
"fal-ai/veo3.1/lite/image-to-video",
);
}
// NB: Pricing tests are in cost.rs
}
@@ -0,0 +1,102 @@
use crate::requests::api::video::image::veo_3p1_lite::api::{
Veo3p1LiteImageToVideoDuration, Veo3p1LiteImageToVideoRequest, Veo3p1LiteImageToVideoResolution,
};
use crate::requests::api::video::text::veo_3p1_lite::cost::{
veo_3p1_lite_cost_cents, veo_3p1_lite_rate_tenth_cents_per_sec,
};
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
// Image-to-video shares the Veo 3.1 Lite per-second pricing (see the text
// module for the canonical rate table). Both resolution and audio matter:
// 720p: $0.03/sec (audio off), $0.05/sec (audio on)
// 1080p: $0.05/sec (audio off), $0.08/sec (audio on)
impl FalRequestCostCalculator for Veo3p1LiteImageToVideoRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
// fal defaults when unset: duration = 8s, resolution = 720p, audio = on.
let duration_secs = self.duration
.unwrap_or(Veo3p1LiteImageToVideoDuration::EightSeconds)
.to_seconds();
let is_1080p = self.resolution
.unwrap_or(Veo3p1LiteImageToVideoResolution::SevenTwentyP)
.is_ten_eighty_p();
let audio_on = self.generate_audio.unwrap_or(true);
let rate = veo_3p1_lite_rate_tenth_cents_per_sec(is_1080p, audio_on);
veo_3p1_lite_cost_cents(rate, duration_secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::requests::api::video::image::veo_3p1_lite::api::Veo3p1LiteImageToVideoAspectRatio;
fn make_request(
duration: Option<Veo3p1LiteImageToVideoDuration>,
resolution: Option<Veo3p1LiteImageToVideoResolution>,
generate_audio: Option<bool>,
) -> Veo3p1LiteImageToVideoRequest {
Veo3p1LiteImageToVideoRequest {
prompt: "test".to_string(),
image_url: "https://example.com/x.png".to_string(),
aspect_ratio: None,
duration,
resolution,
generate_audio,
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
}
}
mod cost_table {
use super::*;
// (duration, resolution, generate_audio, expected_cents)
const COST_TABLE: &[(
Option<Veo3p1LiteImageToVideoDuration>,
Option<Veo3p1LiteImageToVideoResolution>,
Option<bool>,
u64,
)] = &[
(Some(Veo3p1LiteImageToVideoDuration::FourSeconds), Some(Veo3p1LiteImageToVideoResolution::SevenTwentyP), Some(false), 12),
(Some(Veo3p1LiteImageToVideoDuration::FourSeconds), Some(Veo3p1LiteImageToVideoResolution::SevenTwentyP), Some(true), 20),
(Some(Veo3p1LiteImageToVideoDuration::EightSeconds), Some(Veo3p1LiteImageToVideoResolution::SevenTwentyP), Some(true), 40),
(Some(Veo3p1LiteImageToVideoDuration::EightSeconds), Some(Veo3p1LiteImageToVideoResolution::TenEightyP), Some(false), 40),
(Some(Veo3p1LiteImageToVideoDuration::EightSeconds), Some(Veo3p1LiteImageToVideoResolution::TenEightyP), Some(true), 64),
// Defaults: 8s, 720p, audio on
(None, None, None, 40),
];
#[test]
fn matches_cost_table() {
for &(duration, resolution, generate_audio, expected) in COST_TABLE {
let got = make_request(duration, resolution, generate_audio).calculate_cost_in_cents();
assert_eq!(
got, expected,
"duration={duration:?} resolution={resolution:?} audio={generate_audio:?}",
);
}
}
#[test]
fn cost_is_independent_of_aspect_ratio() {
let baseline = {
let mut r = make_request(Some(Veo3p1LiteImageToVideoDuration::EightSeconds), Some(Veo3p1LiteImageToVideoResolution::TenEightyP), Some(true));
r.aspect_ratio = Some(Veo3p1LiteImageToVideoAspectRatio::Auto);
r.calculate_cost_in_cents()
};
for ar in [
Veo3p1LiteImageToVideoAspectRatio::Auto,
Veo3p1LiteImageToVideoAspectRatio::SixteenByNine,
Veo3p1LiteImageToVideoAspectRatio::NineBySixteen,
] {
let mut r = make_request(Some(Veo3p1LiteImageToVideoDuration::EightSeconds), Some(Veo3p1LiteImageToVideoResolution::TenEightyP), Some(true));
r.aspect_ratio = Some(ar);
assert_eq!(r.calculate_cost_in_cents(), baseline, "ar={ar:?}");
}
}
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -0,0 +1,58 @@
use serde::{Deserialize, Serialize};
/// Over-the-wire input shape for `fal-ai/veo3.1/lite/image-to-video`
/// (single start-frame keyframe).
/// fal's schema: <https://fal.ai/models/fal-ai/veo3.1/lite/image-to-video/api>
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Veo3p1LiteImageToVideoInput {
pub prompt: String,
/// URL of the image used as the starting frame.
pub image_url: String,
/// Aspect ratio.
/// Possible values: "auto", "16:9", "9:16". fal default: "auto".
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
/// Duration in seconds (as a string).
/// Possible values: "4s", "6s", "8s". fal default: "8s".
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<String>,
/// Output resolution.
/// Possible values: "720p", "1080p" (no 4k tier). fal default: "720p".
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<String>,
/// Whether to generate native audio for the video. fal default: true.
#[serde(skip_serializing_if = "Option::is_none")]
pub generate_audio: Option<bool>,
/// Optional negative prompt.
#[serde(skip_serializing_if = "Option::is_none")]
pub negative_prompt: Option<String>,
/// Seed for the random number generator.
#[serde(skip_serializing_if = "Option::is_none")]
pub seed: Option<i64>,
/// Whether to automatically rewrite prompts that fail moderation.
/// fal default: false (image-to-video).
#[serde(skip_serializing_if = "Option::is_none")]
pub auto_fix: Option<bool>,
/// Safety tolerance, "1" (strictest) .. "6" (least strict). fal default: "4".
#[serde(skip_serializing_if = "Option::is_none")]
pub safety_tolerance: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo3p1LiteImageToVideoVideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo3p1LiteImageToVideoOutput {
pub video: Veo3p1LiteImageToVideoVideoFile,
}
@@ -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,
}
@@ -0,0 +1,3 @@
pub mod veo_3p1;
pub mod veo_3p1_fast;
pub mod veo_3p1_lite;
@@ -0,0 +1,286 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::video::images::veo_3p1::raw_request::{
Veo3p1FirstLastFrameToVideoInput, Veo3p1FirstLastFrameToVideoOutput,
};
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
#[derive(Clone, Debug)]
pub struct Veo3p1FirstLastFrameToVideoRequest {
/// Text prompt describing the video to generate.
pub prompt: String,
/// URL of the image used as the first frame.
pub first_frame_url: String,
/// URL of the image used as the last frame.
pub last_frame_url: String,
/// Aspect ratio. fal's default is `auto` when `None`.
pub aspect_ratio: Option<Veo3p1FirstLastFrameToVideoAspectRatio>,
/// Video duration. fal's default is `8s` when `None`.
pub duration: Option<Veo3p1FirstLastFrameToVideoDuration>,
/// Output resolution. fal's default is `720p` when `None`.
pub resolution: Option<Veo3p1FirstLastFrameToVideoResolution>,
/// Whether to generate native audio. fal's server default is `true` when
/// this is `None`.
pub generate_audio: Option<bool>,
/// Optional negative prompt.
pub negative_prompt: Option<String>,
/// Seed for the random number generator.
pub seed: Option<i64>,
/// Whether to auto-rewrite prompts that fail moderation. fal's default is
/// `false` when `None`.
pub auto_fix: Option<bool>,
/// Safety tolerance. fal's default is `4` when `None`.
pub safety_tolerance: Option<Veo3p1FirstLastFrameToVideoSafetyTolerance>,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1FirstLastFrameToVideoAspectRatio {
Auto,
SixteenByNine,
NineBySixteen,
}
impl Veo3p1FirstLastFrameToVideoAspectRatio {
fn to_str(&self) -> &'static str {
match self {
Self::Auto => "auto",
Self::SixteenByNine => "16:9",
Self::NineBySixteen => "9:16",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1FirstLastFrameToVideoDuration {
FourSeconds,
SixSeconds,
EightSeconds,
}
impl Veo3p1FirstLastFrameToVideoDuration {
pub fn to_seconds(&self) -> u64 {
match self {
Self::FourSeconds => 4,
Self::SixSeconds => 6,
Self::EightSeconds => 8,
}
}
fn to_str(&self) -> &'static str {
match self {
Self::FourSeconds => "4s",
Self::SixSeconds => "6s",
Self::EightSeconds => "8s",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1FirstLastFrameToVideoResolution {
SevenTwentyP,
TenEightyP,
FourK,
}
impl Veo3p1FirstLastFrameToVideoResolution {
/// 4k output is billed at a higher per-second rate than 720p/1080p.
pub fn is_four_k(&self) -> bool {
matches!(self, Self::FourK)
}
fn to_str(&self) -> &'static str {
match self {
Self::SevenTwentyP => "720p",
Self::TenEightyP => "1080p",
Self::FourK => "4k",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1FirstLastFrameToVideoSafetyTolerance {
/// "1" — strictest.
Level1,
Level2,
Level3,
/// "4" — fal default.
Level4,
Level5,
/// "6" — least strict.
Level6,
}
impl Veo3p1FirstLastFrameToVideoSafetyTolerance {
fn to_str(&self) -> &'static str {
match self {
Self::Level1 => "1",
Self::Level2 => "2",
Self::Level3 => "3",
Self::Level4 => "4",
Self::Level5 => "5",
Self::Level6 => "6",
}
}
}
impl FalEndpoint for Veo3p1FirstLastFrameToVideoRequest {
const ENDPOINT: &str = "fal-ai/veo3.1/first-last-frame-to-video";
type RawRequest = Veo3p1FirstLastFrameToVideoInput;
type RawResponse = Veo3p1FirstLastFrameToVideoOutput;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
Ok(Self::RawRequest {
prompt: self.prompt.clone(),
first_frame_url: self.first_frame_url.clone(),
last_frame_url: self.last_frame_url.clone(),
aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()),
duration: self.duration.map(|d| d.to_str().to_string()),
resolution: self.resolution.map(|r| r.to_str().to_string()),
generate_audio: self.generate_audio,
negative_prompt: self.negative_prompt.clone(),
seed: self.seed,
auto_fix: self.auto_fix,
safety_tolerance: self.safety_tolerance.map(|s| s.to_str().to_string()),
})
}
}
#[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_first_last_frame_webhook() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Veo3p1FirstLastFrameToVideoRequest {
prompt: "a smooth transition from the first scene to the last".to_string(),
first_frame_url: TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(),
last_frame_url: JUNO_AT_LAKE_IMAGE_URL.to_string(),
aspect_ratio: Some(Veo3p1FirstLastFrameToVideoAspectRatio::SixteenByNine),
duration: Some(Veo3p1FirstLastFrameToVideoDuration::FourSeconds),
resolution: Some(Veo3p1FirstLastFrameToVideoResolution::SevenTwentyP),
generate_audio: Some(false),
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
};
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_first_last_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 = Veo3p1FirstLastFrameToVideoRequest {
prompt: "morph between the two frames".to_string(),
first_frame_url: TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(),
last_frame_url: JUNO_AT_LAKE_IMAGE_URL.to_string(),
aspect_ratio: None,
duration: Some(Veo3p1FirstLastFrameToVideoDuration::FourSeconds),
resolution: Some(Veo3p1FirstLastFrameToVideoResolution::SevenTwentyP),
generate_audio: Some(false),
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
};
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 = Veo3p1FirstLastFrameToVideoRequest {
prompt: "p".to_string(),
first_frame_url: "https://example.com/first.png".to_string(),
last_frame_url: "https://example.com/last.png".to_string(),
aspect_ratio: Some(Veo3p1FirstLastFrameToVideoAspectRatio::SixteenByNine),
duration: Some(Veo3p1FirstLastFrameToVideoDuration::FourSeconds),
resolution: Some(Veo3p1FirstLastFrameToVideoResolution::SevenTwentyP),
generate_audio: Some(true),
negative_prompt: Some("nope".to_string()),
seed: Some(1),
auto_fix: Some(true),
safety_tolerance: Some(Veo3p1FirstLastFrameToVideoSafetyTolerance::Level3),
};
let raw = request.to_raw_request().unwrap();
assert_eq!(raw.first_frame_url, "https://example.com/first.png");
assert_eq!(raw.last_frame_url, "https://example.com/last.png");
assert_eq!(raw.aspect_ratio.as_deref(), Some("16:9"));
assert_eq!(raw.duration.as_deref(), Some("4s"));
assert_eq!(raw.resolution.as_deref(), Some("720p"));
assert_eq!(raw.generate_audio, Some(true));
assert_eq!(raw.negative_prompt.as_deref(), Some("nope"));
assert_eq!(raw.seed, Some(1));
assert_eq!(raw.auto_fix, Some(true));
assert_eq!(raw.safety_tolerance.as_deref(), Some("3"));
}
#[test]
fn raw_request_omits_unset_optionals() {
let request = Veo3p1FirstLastFrameToVideoRequest {
prompt: "p".to_string(),
first_frame_url: "https://example.com/first.png".to_string(),
last_frame_url: "https://example.com/last.png".to_string(),
aspect_ratio: None,
duration: None,
resolution: None,
generate_audio: None,
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(
json,
serde_json::json!({
"prompt": "p",
"first_frame_url": "https://example.com/first.png",
"last_frame_url": "https://example.com/last.png",
}),
);
}
#[test]
fn endpoint_path_is_canonical() {
assert_eq!(
Veo3p1FirstLastFrameToVideoRequest::ENDPOINT,
"fal-ai/veo3.1/first-last-frame-to-video",
);
}
// NB: Pricing tests are in cost.rs
}
@@ -0,0 +1,85 @@
use crate::requests::api::video::images::veo_3p1::api::{
Veo3p1FirstLastFrameToVideoDuration, Veo3p1FirstLastFrameToVideoRequest,
Veo3p1FirstLastFrameToVideoResolution,
};
use crate::requests::api::video::text::veo_3p1::cost::{
veo_3p1_cost_cents, veo_3p1_rate_tenth_cents_per_sec,
};
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
// First-last-frame-to-video shares the Veo 3.1 (non-fast) per-second pricing
// (see the text module for the canonical rate table):
// 720p / 1080p: $0.20/sec (audio off), $0.40/sec (audio on)
// 4k: $0.40/sec (audio off), $0.60/sec (audio on)
impl FalRequestCostCalculator for Veo3p1FirstLastFrameToVideoRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
// fal defaults when unset: duration = 8s, resolution = 720p, audio = on.
let duration_secs = self.duration
.unwrap_or(Veo3p1FirstLastFrameToVideoDuration::EightSeconds)
.to_seconds();
let four_k = self.resolution
.unwrap_or(Veo3p1FirstLastFrameToVideoResolution::SevenTwentyP)
.is_four_k();
let audio_on = self.generate_audio.unwrap_or(true);
let rate = veo_3p1_rate_tenth_cents_per_sec(four_k, audio_on);
veo_3p1_cost_cents(rate, duration_secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_request(
duration: Option<Veo3p1FirstLastFrameToVideoDuration>,
resolution: Option<Veo3p1FirstLastFrameToVideoResolution>,
generate_audio: Option<bool>,
) -> Veo3p1FirstLastFrameToVideoRequest {
Veo3p1FirstLastFrameToVideoRequest {
prompt: "test".to_string(),
first_frame_url: "https://example.com/first.png".to_string(),
last_frame_url: "https://example.com/last.png".to_string(),
aspect_ratio: None,
duration,
resolution,
generate_audio,
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
}
}
mod cost_table {
use super::*;
// (duration, resolution, generate_audio, expected_cents)
const COST_TABLE: &[(
Option<Veo3p1FirstLastFrameToVideoDuration>,
Option<Veo3p1FirstLastFrameToVideoResolution>,
Option<bool>,
u64,
)] = &[
(Some(Veo3p1FirstLastFrameToVideoDuration::FourSeconds), Some(Veo3p1FirstLastFrameToVideoResolution::SevenTwentyP), Some(false), 80),
(Some(Veo3p1FirstLastFrameToVideoDuration::SixSeconds), Some(Veo3p1FirstLastFrameToVideoResolution::SevenTwentyP), Some(true), 240),
(Some(Veo3p1FirstLastFrameToVideoDuration::EightSeconds), Some(Veo3p1FirstLastFrameToVideoResolution::TenEightyP), Some(false), 160),
(Some(Veo3p1FirstLastFrameToVideoDuration::EightSeconds), Some(Veo3p1FirstLastFrameToVideoResolution::TenEightyP), Some(true), 320),
(Some(Veo3p1FirstLastFrameToVideoDuration::EightSeconds), Some(Veo3p1FirstLastFrameToVideoResolution::FourK), Some(false), 320),
(Some(Veo3p1FirstLastFrameToVideoDuration::EightSeconds), Some(Veo3p1FirstLastFrameToVideoResolution::FourK), Some(true), 480),
(None, None, None, 320),
];
#[test]
fn matches_cost_table() {
for &(duration, resolution, generate_audio, expected) in COST_TABLE {
let got = make_request(duration, resolution, generate_audio).calculate_cost_in_cents();
assert_eq!(
got, expected,
"duration={duration:?} resolution={resolution:?} audio={generate_audio:?}",
);
}
}
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -0,0 +1,61 @@
use serde::{Deserialize, Serialize};
/// Over-the-wire input shape for `fal-ai/veo3.1/first-last-frame-to-video`
/// (start + end keyframes).
/// fal's schema: <https://fal.ai/models/fal-ai/veo3.1/first-last-frame-to-video/api>
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Veo3p1FirstLastFrameToVideoInput {
pub prompt: String,
/// URL of the image used as the first frame.
pub first_frame_url: String,
/// URL of the image used as the last frame.
pub last_frame_url: String,
/// Aspect ratio.
/// Possible values: "auto", "16:9", "9:16". fal default: "auto".
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
/// Duration in seconds (as a string).
/// Possible values: "4s", "6s", "8s". fal default: "8s".
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<String>,
/// Output resolution.
/// Possible values: "720p", "1080p", "4k". fal default: "720p".
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<String>,
/// Whether to generate native audio for the video. fal default: true.
#[serde(skip_serializing_if = "Option::is_none")]
pub generate_audio: Option<bool>,
/// Optional negative prompt.
#[serde(skip_serializing_if = "Option::is_none")]
pub negative_prompt: Option<String>,
/// Seed for the random number generator.
#[serde(skip_serializing_if = "Option::is_none")]
pub seed: Option<i64>,
/// Whether to automatically rewrite prompts that fail moderation.
/// fal default: false.
#[serde(skip_serializing_if = "Option::is_none")]
pub auto_fix: Option<bool>,
/// Safety tolerance, "1" (strictest) .. "6" (least strict). fal default: "4".
#[serde(skip_serializing_if = "Option::is_none")]
pub safety_tolerance: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo3p1FirstLastFrameToVideoVideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo3p1FirstLastFrameToVideoOutput {
pub video: Veo3p1FirstLastFrameToVideoVideoFile,
}
@@ -0,0 +1,286 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::video::images::veo_3p1_fast::raw_request::{
Veo3p1FastFirstLastFrameToVideoInput, Veo3p1FastFirstLastFrameToVideoOutput,
};
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
#[derive(Clone, Debug)]
pub struct Veo3p1FastFirstLastFrameToVideoRequest {
/// Text prompt describing the video to generate.
pub prompt: String,
/// URL of the image used as the first frame.
pub first_frame_url: String,
/// URL of the image used as the last frame.
pub last_frame_url: String,
/// Aspect ratio. fal's default is `auto` when `None`.
pub aspect_ratio: Option<Veo3p1FastFirstLastFrameToVideoAspectRatio>,
/// Video duration. fal's default is `8s` when `None`.
pub duration: Option<Veo3p1FastFirstLastFrameToVideoDuration>,
/// Output resolution. fal's default is `720p` when `None`.
pub resolution: Option<Veo3p1FastFirstLastFrameToVideoResolution>,
/// Whether to generate native audio. fal's server default is `true` when
/// this is `None`.
pub generate_audio: Option<bool>,
/// Optional negative prompt.
pub negative_prompt: Option<String>,
/// Seed for the random number generator.
pub seed: Option<i64>,
/// Whether to auto-rewrite prompts that fail moderation. fal's default is
/// `false` when `None`.
pub auto_fix: Option<bool>,
/// Safety tolerance. fal's default is `4` when `None`.
pub safety_tolerance: Option<Veo3p1FastFirstLastFrameToVideoSafetyTolerance>,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1FastFirstLastFrameToVideoAspectRatio {
Auto,
SixteenByNine,
NineBySixteen,
}
impl Veo3p1FastFirstLastFrameToVideoAspectRatio {
fn to_str(&self) -> &'static str {
match self {
Self::Auto => "auto",
Self::SixteenByNine => "16:9",
Self::NineBySixteen => "9:16",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1FastFirstLastFrameToVideoDuration {
FourSeconds,
SixSeconds,
EightSeconds,
}
impl Veo3p1FastFirstLastFrameToVideoDuration {
pub fn to_seconds(&self) -> u64 {
match self {
Self::FourSeconds => 4,
Self::SixSeconds => 6,
Self::EightSeconds => 8,
}
}
fn to_str(&self) -> &'static str {
match self {
Self::FourSeconds => "4s",
Self::SixSeconds => "6s",
Self::EightSeconds => "8s",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1FastFirstLastFrameToVideoResolution {
SevenTwentyP,
TenEightyP,
FourK,
}
impl Veo3p1FastFirstLastFrameToVideoResolution {
/// 4k output is billed at a higher per-second rate than 720p/1080p.
pub fn is_four_k(&self) -> bool {
matches!(self, Self::FourK)
}
fn to_str(&self) -> &'static str {
match self {
Self::SevenTwentyP => "720p",
Self::TenEightyP => "1080p",
Self::FourK => "4k",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1FastFirstLastFrameToVideoSafetyTolerance {
/// "1" — strictest.
Level1,
Level2,
Level3,
/// "4" — fal default.
Level4,
Level5,
/// "6" — least strict.
Level6,
}
impl Veo3p1FastFirstLastFrameToVideoSafetyTolerance {
fn to_str(&self) -> &'static str {
match self {
Self::Level1 => "1",
Self::Level2 => "2",
Self::Level3 => "3",
Self::Level4 => "4",
Self::Level5 => "5",
Self::Level6 => "6",
}
}
}
impl FalEndpoint for Veo3p1FastFirstLastFrameToVideoRequest {
const ENDPOINT: &str = "fal-ai/veo3.1/fast/first-last-frame-to-video";
type RawRequest = Veo3p1FastFirstLastFrameToVideoInput;
type RawResponse = Veo3p1FastFirstLastFrameToVideoOutput;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
Ok(Self::RawRequest {
prompt: self.prompt.clone(),
first_frame_url: self.first_frame_url.clone(),
last_frame_url: self.last_frame_url.clone(),
aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()),
duration: self.duration.map(|d| d.to_str().to_string()),
resolution: self.resolution.map(|r| r.to_str().to_string()),
generate_audio: self.generate_audio,
negative_prompt: self.negative_prompt.clone(),
seed: self.seed,
auto_fix: self.auto_fix,
safety_tolerance: self.safety_tolerance.map(|s| s.to_str().to_string()),
})
}
}
#[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_first_last_frame_webhook() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Veo3p1FastFirstLastFrameToVideoRequest {
prompt: "a smooth transition from the first scene to the last".to_string(),
first_frame_url: TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(),
last_frame_url: JUNO_AT_LAKE_IMAGE_URL.to_string(),
aspect_ratio: Some(Veo3p1FastFirstLastFrameToVideoAspectRatio::SixteenByNine),
duration: Some(Veo3p1FastFirstLastFrameToVideoDuration::FourSeconds),
resolution: Some(Veo3p1FastFirstLastFrameToVideoResolution::SevenTwentyP),
generate_audio: Some(false),
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
};
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_first_last_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 = Veo3p1FastFirstLastFrameToVideoRequest {
prompt: "morph between the two frames".to_string(),
first_frame_url: TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(),
last_frame_url: JUNO_AT_LAKE_IMAGE_URL.to_string(),
aspect_ratio: None,
duration: Some(Veo3p1FastFirstLastFrameToVideoDuration::FourSeconds),
resolution: Some(Veo3p1FastFirstLastFrameToVideoResolution::SevenTwentyP),
generate_audio: Some(false),
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
};
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 = Veo3p1FastFirstLastFrameToVideoRequest {
prompt: "p".to_string(),
first_frame_url: "https://example.com/first.png".to_string(),
last_frame_url: "https://example.com/last.png".to_string(),
aspect_ratio: Some(Veo3p1FastFirstLastFrameToVideoAspectRatio::SixteenByNine),
duration: Some(Veo3p1FastFirstLastFrameToVideoDuration::FourSeconds),
resolution: Some(Veo3p1FastFirstLastFrameToVideoResolution::SevenTwentyP),
generate_audio: Some(true),
negative_prompt: Some("nope".to_string()),
seed: Some(1),
auto_fix: Some(true),
safety_tolerance: Some(Veo3p1FastFirstLastFrameToVideoSafetyTolerance::Level3),
};
let raw = request.to_raw_request().unwrap();
assert_eq!(raw.first_frame_url, "https://example.com/first.png");
assert_eq!(raw.last_frame_url, "https://example.com/last.png");
assert_eq!(raw.aspect_ratio.as_deref(), Some("16:9"));
assert_eq!(raw.duration.as_deref(), Some("4s"));
assert_eq!(raw.resolution.as_deref(), Some("720p"));
assert_eq!(raw.generate_audio, Some(true));
assert_eq!(raw.negative_prompt.as_deref(), Some("nope"));
assert_eq!(raw.seed, Some(1));
assert_eq!(raw.auto_fix, Some(true));
assert_eq!(raw.safety_tolerance.as_deref(), Some("3"));
}
#[test]
fn raw_request_omits_unset_optionals() {
let request = Veo3p1FastFirstLastFrameToVideoRequest {
prompt: "p".to_string(),
first_frame_url: "https://example.com/first.png".to_string(),
last_frame_url: "https://example.com/last.png".to_string(),
aspect_ratio: None,
duration: None,
resolution: None,
generate_audio: None,
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(
json,
serde_json::json!({
"prompt": "p",
"first_frame_url": "https://example.com/first.png",
"last_frame_url": "https://example.com/last.png",
}),
);
}
#[test]
fn endpoint_path_is_canonical() {
assert_eq!(
Veo3p1FastFirstLastFrameToVideoRequest::ENDPOINT,
"fal-ai/veo3.1/fast/first-last-frame-to-video",
);
}
// NB: Pricing tests are in cost.rs
}
@@ -0,0 +1,85 @@
use crate::requests::api::video::images::veo_3p1_fast::api::{
Veo3p1FastFirstLastFrameToVideoDuration, Veo3p1FastFirstLastFrameToVideoRequest,
Veo3p1FastFirstLastFrameToVideoResolution,
};
use crate::requests::api::video::text::veo_3p1_fast::cost::{
veo_3p1_fast_cost_cents, veo_3p1_fast_rate_tenth_cents_per_sec,
};
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
// First-last-frame-to-video shares the Veo 3.1 Fast per-second pricing (see the
// text module for the canonical rate table):
// 720p / 1080p: $0.10/sec (audio off), $0.15/sec (audio on)
// 4k: $0.30/sec (audio off), $0.35/sec (audio on)
impl FalRequestCostCalculator for Veo3p1FastFirstLastFrameToVideoRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
// fal defaults when unset: duration = 8s, resolution = 720p, audio = on.
let duration_secs = self.duration
.unwrap_or(Veo3p1FastFirstLastFrameToVideoDuration::EightSeconds)
.to_seconds();
let four_k = self.resolution
.unwrap_or(Veo3p1FastFirstLastFrameToVideoResolution::SevenTwentyP)
.is_four_k();
let audio_on = self.generate_audio.unwrap_or(true);
let rate = veo_3p1_fast_rate_tenth_cents_per_sec(four_k, audio_on);
veo_3p1_fast_cost_cents(rate, duration_secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_request(
duration: Option<Veo3p1FastFirstLastFrameToVideoDuration>,
resolution: Option<Veo3p1FastFirstLastFrameToVideoResolution>,
generate_audio: Option<bool>,
) -> Veo3p1FastFirstLastFrameToVideoRequest {
Veo3p1FastFirstLastFrameToVideoRequest {
prompt: "test".to_string(),
first_frame_url: "https://example.com/first.png".to_string(),
last_frame_url: "https://example.com/last.png".to_string(),
aspect_ratio: None,
duration,
resolution,
generate_audio,
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
}
}
mod cost_table {
use super::*;
// (duration, resolution, generate_audio, expected_cents)
const COST_TABLE: &[(
Option<Veo3p1FastFirstLastFrameToVideoDuration>,
Option<Veo3p1FastFirstLastFrameToVideoResolution>,
Option<bool>,
u64,
)] = &[
(Some(Veo3p1FastFirstLastFrameToVideoDuration::FourSeconds), Some(Veo3p1FastFirstLastFrameToVideoResolution::SevenTwentyP), Some(false), 40),
(Some(Veo3p1FastFirstLastFrameToVideoDuration::SixSeconds), Some(Veo3p1FastFirstLastFrameToVideoResolution::SevenTwentyP), Some(true), 90),
(Some(Veo3p1FastFirstLastFrameToVideoDuration::EightSeconds), Some(Veo3p1FastFirstLastFrameToVideoResolution::TenEightyP), Some(false), 80),
(Some(Veo3p1FastFirstLastFrameToVideoDuration::EightSeconds), Some(Veo3p1FastFirstLastFrameToVideoResolution::TenEightyP), Some(true), 120),
(Some(Veo3p1FastFirstLastFrameToVideoDuration::EightSeconds), Some(Veo3p1FastFirstLastFrameToVideoResolution::FourK), Some(false), 240),
(Some(Veo3p1FastFirstLastFrameToVideoDuration::EightSeconds), Some(Veo3p1FastFirstLastFrameToVideoResolution::FourK), Some(true), 280),
(None, None, None, 120),
];
#[test]
fn matches_cost_table() {
for &(duration, resolution, generate_audio, expected) in COST_TABLE {
let got = make_request(duration, resolution, generate_audio).calculate_cost_in_cents();
assert_eq!(
got, expected,
"duration={duration:?} resolution={resolution:?} audio={generate_audio:?}",
);
}
}
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -0,0 +1,61 @@
use serde::{Deserialize, Serialize};
/// Over-the-wire input shape for `fal-ai/veo3.1/fast/first-last-frame-to-video`
/// (start + end keyframes).
/// fal's schema: <https://fal.ai/models/fal-ai/veo3.1/fast/first-last-frame-to-video/api>
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Veo3p1FastFirstLastFrameToVideoInput {
pub prompt: String,
/// URL of the image used as the first frame.
pub first_frame_url: String,
/// URL of the image used as the last frame.
pub last_frame_url: String,
/// Aspect ratio.
/// Possible values: "auto", "16:9", "9:16". fal default: "auto".
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
/// Duration in seconds (as a string).
/// Possible values: "4s", "6s", "8s". fal default: "8s".
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<String>,
/// Output resolution.
/// Possible values: "720p", "1080p", "4k". fal default: "720p".
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<String>,
/// Whether to generate native audio for the video. fal default: true.
#[serde(skip_serializing_if = "Option::is_none")]
pub generate_audio: Option<bool>,
/// Optional negative prompt.
#[serde(skip_serializing_if = "Option::is_none")]
pub negative_prompt: Option<String>,
/// Seed for the random number generator.
#[serde(skip_serializing_if = "Option::is_none")]
pub seed: Option<i64>,
/// Whether to automatically rewrite prompts that fail moderation.
/// fal default: false.
#[serde(skip_serializing_if = "Option::is_none")]
pub auto_fix: Option<bool>,
/// Safety tolerance, "1" (strictest) .. "6" (least strict). fal default: "4".
#[serde(skip_serializing_if = "Option::is_none")]
pub safety_tolerance: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo3p1FastFirstLastFrameToVideoVideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo3p1FastFirstLastFrameToVideoOutput {
pub video: Veo3p1FastFirstLastFrameToVideoVideoFile,
}
@@ -0,0 +1,286 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::video::images::veo_3p1_lite::raw_request::{
Veo3p1LiteFirstLastFrameToVideoInput, Veo3p1LiteFirstLastFrameToVideoOutput,
};
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
#[derive(Clone, Debug)]
pub struct Veo3p1LiteFirstLastFrameToVideoRequest {
/// Text prompt describing the video to generate.
pub prompt: String,
/// URL of the image used as the first frame.
pub first_frame_url: String,
/// URL of the image used as the last frame.
pub last_frame_url: String,
/// Aspect ratio. fal's default is `auto` when `None`.
pub aspect_ratio: Option<Veo3p1LiteFirstLastFrameToVideoAspectRatio>,
/// Video duration. fal's default is `8s` when `None`.
pub duration: Option<Veo3p1LiteFirstLastFrameToVideoDuration>,
/// Output resolution. fal's default is `720p` when `None`.
pub resolution: Option<Veo3p1LiteFirstLastFrameToVideoResolution>,
/// Whether to generate native audio. fal's server default is `true` when
/// this is `None`.
pub generate_audio: Option<bool>,
/// Optional negative prompt.
pub negative_prompt: Option<String>,
/// Seed for the random number generator.
pub seed: Option<i64>,
/// Whether to auto-rewrite prompts that fail moderation. fal's default is
/// `false` when `None`.
pub auto_fix: Option<bool>,
/// Safety tolerance. fal's default is `4` when `None`.
pub safety_tolerance: Option<Veo3p1LiteFirstLastFrameToVideoSafetyTolerance>,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1LiteFirstLastFrameToVideoAspectRatio {
Auto,
SixteenByNine,
NineBySixteen,
}
impl Veo3p1LiteFirstLastFrameToVideoAspectRatio {
fn to_str(&self) -> &'static str {
match self {
Self::Auto => "auto",
Self::SixteenByNine => "16:9",
Self::NineBySixteen => "9:16",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1LiteFirstLastFrameToVideoDuration {
FourSeconds,
SixSeconds,
EightSeconds,
}
impl Veo3p1LiteFirstLastFrameToVideoDuration {
pub fn to_seconds(&self) -> u64 {
match self {
Self::FourSeconds => 4,
Self::SixSeconds => 6,
Self::EightSeconds => 8,
}
}
fn to_str(&self) -> &'static str {
match self {
Self::FourSeconds => "4s",
Self::SixSeconds => "6s",
Self::EightSeconds => "8s",
}
}
}
/// Veo 3.1 Lite exposes only 720p/1080p (no 4k tier). Both resolution AND
/// audio affect the per-second price.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1LiteFirstLastFrameToVideoResolution {
SevenTwentyP,
TenEightyP,
}
impl Veo3p1LiteFirstLastFrameToVideoResolution {
/// 1080p is billed at a higher per-second rate than 720p.
pub fn is_ten_eighty_p(&self) -> bool {
matches!(self, Self::TenEightyP)
}
fn to_str(&self) -> &'static str {
match self {
Self::SevenTwentyP => "720p",
Self::TenEightyP => "1080p",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1LiteFirstLastFrameToVideoSafetyTolerance {
/// "1" — strictest.
Level1,
Level2,
Level3,
/// "4" — fal default.
Level4,
Level5,
/// "6" — least strict.
Level6,
}
impl Veo3p1LiteFirstLastFrameToVideoSafetyTolerance {
fn to_str(&self) -> &'static str {
match self {
Self::Level1 => "1",
Self::Level2 => "2",
Self::Level3 => "3",
Self::Level4 => "4",
Self::Level5 => "5",
Self::Level6 => "6",
}
}
}
impl FalEndpoint for Veo3p1LiteFirstLastFrameToVideoRequest {
const ENDPOINT: &str = "fal-ai/veo3.1/lite/first-last-frame-to-video";
type RawRequest = Veo3p1LiteFirstLastFrameToVideoInput;
type RawResponse = Veo3p1LiteFirstLastFrameToVideoOutput;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
Ok(Self::RawRequest {
prompt: self.prompt.clone(),
first_frame_url: self.first_frame_url.clone(),
last_frame_url: self.last_frame_url.clone(),
aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()),
duration: self.duration.map(|d| d.to_str().to_string()),
resolution: self.resolution.map(|r| r.to_str().to_string()),
generate_audio: self.generate_audio,
negative_prompt: self.negative_prompt.clone(),
seed: self.seed,
auto_fix: self.auto_fix,
safety_tolerance: self.safety_tolerance.map(|s| s.to_str().to_string()),
})
}
}
#[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_first_last_frame_webhook() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Veo3p1LiteFirstLastFrameToVideoRequest {
prompt: "a smooth transition from the first scene to the last".to_string(),
first_frame_url: TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(),
last_frame_url: JUNO_AT_LAKE_IMAGE_URL.to_string(),
aspect_ratio: Some(Veo3p1LiteFirstLastFrameToVideoAspectRatio::SixteenByNine),
duration: Some(Veo3p1LiteFirstLastFrameToVideoDuration::FourSeconds),
resolution: Some(Veo3p1LiteFirstLastFrameToVideoResolution::SevenTwentyP),
generate_audio: Some(false),
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
};
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_first_last_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 = Veo3p1LiteFirstLastFrameToVideoRequest {
prompt: "morph between the two frames".to_string(),
first_frame_url: TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(),
last_frame_url: JUNO_AT_LAKE_IMAGE_URL.to_string(),
aspect_ratio: None,
duration: Some(Veo3p1LiteFirstLastFrameToVideoDuration::FourSeconds),
resolution: Some(Veo3p1LiteFirstLastFrameToVideoResolution::SevenTwentyP),
generate_audio: Some(false),
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
};
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 = Veo3p1LiteFirstLastFrameToVideoRequest {
prompt: "p".to_string(),
first_frame_url: "https://example.com/first.png".to_string(),
last_frame_url: "https://example.com/last.png".to_string(),
aspect_ratio: Some(Veo3p1LiteFirstLastFrameToVideoAspectRatio::SixteenByNine),
duration: Some(Veo3p1LiteFirstLastFrameToVideoDuration::FourSeconds),
resolution: Some(Veo3p1LiteFirstLastFrameToVideoResolution::SevenTwentyP),
generate_audio: Some(true),
negative_prompt: Some("nope".to_string()),
seed: Some(1),
auto_fix: Some(true),
safety_tolerance: Some(Veo3p1LiteFirstLastFrameToVideoSafetyTolerance::Level3),
};
let raw = request.to_raw_request().unwrap();
assert_eq!(raw.first_frame_url, "https://example.com/first.png");
assert_eq!(raw.last_frame_url, "https://example.com/last.png");
assert_eq!(raw.aspect_ratio.as_deref(), Some("16:9"));
assert_eq!(raw.duration.as_deref(), Some("4s"));
assert_eq!(raw.resolution.as_deref(), Some("720p"));
assert_eq!(raw.generate_audio, Some(true));
assert_eq!(raw.negative_prompt.as_deref(), Some("nope"));
assert_eq!(raw.seed, Some(1));
assert_eq!(raw.auto_fix, Some(true));
assert_eq!(raw.safety_tolerance.as_deref(), Some("3"));
}
#[test]
fn raw_request_omits_unset_optionals() {
let request = Veo3p1LiteFirstLastFrameToVideoRequest {
prompt: "p".to_string(),
first_frame_url: "https://example.com/first.png".to_string(),
last_frame_url: "https://example.com/last.png".to_string(),
aspect_ratio: None,
duration: None,
resolution: None,
generate_audio: None,
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(
json,
serde_json::json!({
"prompt": "p",
"first_frame_url": "https://example.com/first.png",
"last_frame_url": "https://example.com/last.png",
}),
);
}
#[test]
fn endpoint_path_is_canonical() {
assert_eq!(
Veo3p1LiteFirstLastFrameToVideoRequest::ENDPOINT,
"fal-ai/veo3.1/lite/first-last-frame-to-video",
);
}
// NB: Pricing tests are in cost.rs
}
@@ -0,0 +1,85 @@
use crate::requests::api::video::images::veo_3p1_lite::api::{
Veo3p1LiteFirstLastFrameToVideoDuration, Veo3p1LiteFirstLastFrameToVideoRequest,
Veo3p1LiteFirstLastFrameToVideoResolution,
};
use crate::requests::api::video::text::veo_3p1_lite::cost::{
veo_3p1_lite_cost_cents, veo_3p1_lite_rate_tenth_cents_per_sec,
};
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
// First-last-frame-to-video shares the Veo 3.1 Lite per-second pricing (see the
// text module for the canonical rate table). Both resolution and audio matter:
// 720p: $0.03/sec (audio off), $0.05/sec (audio on)
// 1080p: $0.05/sec (audio off), $0.08/sec (audio on)
impl FalRequestCostCalculator for Veo3p1LiteFirstLastFrameToVideoRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
// fal defaults when unset: duration = 8s, resolution = 720p, audio = on.
let duration_secs = self.duration
.unwrap_or(Veo3p1LiteFirstLastFrameToVideoDuration::EightSeconds)
.to_seconds();
let is_1080p = self.resolution
.unwrap_or(Veo3p1LiteFirstLastFrameToVideoResolution::SevenTwentyP)
.is_ten_eighty_p();
let audio_on = self.generate_audio.unwrap_or(true);
let rate = veo_3p1_lite_rate_tenth_cents_per_sec(is_1080p, audio_on);
veo_3p1_lite_cost_cents(rate, duration_secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_request(
duration: Option<Veo3p1LiteFirstLastFrameToVideoDuration>,
resolution: Option<Veo3p1LiteFirstLastFrameToVideoResolution>,
generate_audio: Option<bool>,
) -> Veo3p1LiteFirstLastFrameToVideoRequest {
Veo3p1LiteFirstLastFrameToVideoRequest {
prompt: "test".to_string(),
first_frame_url: "https://example.com/first.png".to_string(),
last_frame_url: "https://example.com/last.png".to_string(),
aspect_ratio: None,
duration,
resolution,
generate_audio,
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
}
}
mod cost_table {
use super::*;
// (duration, resolution, generate_audio, expected_cents)
const COST_TABLE: &[(
Option<Veo3p1LiteFirstLastFrameToVideoDuration>,
Option<Veo3p1LiteFirstLastFrameToVideoResolution>,
Option<bool>,
u64,
)] = &[
(Some(Veo3p1LiteFirstLastFrameToVideoDuration::FourSeconds), Some(Veo3p1LiteFirstLastFrameToVideoResolution::SevenTwentyP), Some(false), 12),
(Some(Veo3p1LiteFirstLastFrameToVideoDuration::FourSeconds), Some(Veo3p1LiteFirstLastFrameToVideoResolution::SevenTwentyP), Some(true), 20),
(Some(Veo3p1LiteFirstLastFrameToVideoDuration::SixSeconds), Some(Veo3p1LiteFirstLastFrameToVideoResolution::TenEightyP), Some(true), 48),
(Some(Veo3p1LiteFirstLastFrameToVideoDuration::EightSeconds), Some(Veo3p1LiteFirstLastFrameToVideoResolution::TenEightyP), Some(false), 40),
(Some(Veo3p1LiteFirstLastFrameToVideoDuration::EightSeconds), Some(Veo3p1LiteFirstLastFrameToVideoResolution::TenEightyP), Some(true), 64),
// Defaults: 8s, 720p, audio on
(None, None, None, 40),
];
#[test]
fn matches_cost_table() {
for &(duration, resolution, generate_audio, expected) in COST_TABLE {
let got = make_request(duration, resolution, generate_audio).calculate_cost_in_cents();
assert_eq!(
got, expected,
"duration={duration:?} resolution={resolution:?} audio={generate_audio:?}",
);
}
}
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -0,0 +1,61 @@
use serde::{Deserialize, Serialize};
/// Over-the-wire input shape for `fal-ai/veo3.1/lite/first-last-frame-to-video`
/// (start + end keyframes).
/// fal's schema: <https://fal.ai/models/fal-ai/veo3.1/lite/first-last-frame-to-video/api>
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Veo3p1LiteFirstLastFrameToVideoInput {
pub prompt: String,
/// URL of the image used as the first frame.
pub first_frame_url: String,
/// URL of the image used as the last frame.
pub last_frame_url: String,
/// Aspect ratio.
/// Possible values: "auto", "16:9", "9:16". fal default: "auto".
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
/// Duration in seconds (as a string).
/// Possible values: "4s", "6s", "8s". fal default: "8s".
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<String>,
/// Output resolution.
/// Possible values: "720p", "1080p" (no 4k tier). fal default: "720p".
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<String>,
/// Whether to generate native audio for the video. fal default: true.
#[serde(skip_serializing_if = "Option::is_none")]
pub generate_audio: Option<bool>,
/// Optional negative prompt.
#[serde(skip_serializing_if = "Option::is_none")]
pub negative_prompt: Option<String>,
/// Seed for the random number generator.
#[serde(skip_serializing_if = "Option::is_none")]
pub seed: Option<i64>,
/// Whether to automatically rewrite prompts that fail moderation.
/// fal default: false.
#[serde(skip_serializing_if = "Option::is_none")]
pub auto_fix: Option<bool>,
/// Safety tolerance, "1" (strictest) .. "6" (least strict). fal default: "4".
#[serde(skip_serializing_if = "Option::is_none")]
pub safety_tolerance: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo3p1LiteFirstLastFrameToVideoVideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo3p1LiteFirstLastFrameToVideoOutput {
pub video: Veo3p1LiteFirstLastFrameToVideoVideoFile,
}
@@ -1,3 +1,6 @@
pub mod elements;
pub mod extend;
pub mod image;
pub mod text;
pub mod images;
pub mod reference;
pub mod text;
@@ -0,0 +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,275 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::video::reference::veo_3p1::raw_request::{
Veo3p1ReferenceToVideoInput, Veo3p1ReferenceToVideoOutput,
};
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
#[derive(Clone, Debug)]
pub struct Veo3p1ReferenceToVideoRequest {
/// Text prompt describing the video to generate.
pub prompt: String,
/// Reference image URLs used for consistent subject appearance.
pub image_urls: Vec<String>,
/// Aspect ratio. fal's default is `16:9` when `None`.
pub aspect_ratio: Option<Veo3p1ReferenceToVideoAspectRatio>,
/// Video duration. fal's default is `8s` when `None`.
pub duration: Option<Veo3p1ReferenceToVideoDuration>,
/// Output resolution. fal's default is `720p` when `None`.
pub resolution: Option<Veo3p1ReferenceToVideoResolution>,
/// Whether to generate native audio. fal's server default is `true` when
/// this is `None`.
pub generate_audio: Option<bool>,
/// Whether to auto-rewrite prompts that fail moderation. fal's default is
/// `false` when `None`.
pub auto_fix: Option<bool>,
/// Safety tolerance. fal's default is `4` when `None`.
pub safety_tolerance: Option<Veo3p1ReferenceToVideoSafetyTolerance>,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1ReferenceToVideoAspectRatio {
SixteenByNine,
NineBySixteen,
}
impl Veo3p1ReferenceToVideoAspectRatio {
fn to_str(&self) -> &'static str {
match self {
Self::SixteenByNine => "16:9",
Self::NineBySixteen => "9:16",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1ReferenceToVideoDuration {
FourSeconds,
SixSeconds,
EightSeconds,
}
impl Veo3p1ReferenceToVideoDuration {
pub fn to_seconds(&self) -> u64 {
match self {
Self::FourSeconds => 4,
Self::SixSeconds => 6,
Self::EightSeconds => 8,
}
}
fn to_str(&self) -> &'static str {
match self {
Self::FourSeconds => "4s",
Self::SixSeconds => "6s",
Self::EightSeconds => "8s",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1ReferenceToVideoResolution {
SevenTwentyP,
TenEightyP,
FourK,
}
impl Veo3p1ReferenceToVideoResolution {
/// 4k output is billed at a higher per-second rate than 720p/1080p.
pub fn is_four_k(&self) -> bool {
matches!(self, Self::FourK)
}
fn to_str(&self) -> &'static str {
match self {
Self::SevenTwentyP => "720p",
Self::TenEightyP => "1080p",
Self::FourK => "4k",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1ReferenceToVideoSafetyTolerance {
/// "1" — strictest.
Level1,
Level2,
Level3,
/// "4" — fal default.
Level4,
Level5,
/// "6" — least strict.
Level6,
}
impl Veo3p1ReferenceToVideoSafetyTolerance {
fn to_str(&self) -> &'static str {
match self {
Self::Level1 => "1",
Self::Level2 => "2",
Self::Level3 => "3",
Self::Level4 => "4",
Self::Level5 => "5",
Self::Level6 => "6",
}
}
}
impl FalEndpoint for Veo3p1ReferenceToVideoRequest {
const ENDPOINT: &str = "fal-ai/veo3.1/reference-to-video";
type RawRequest = Veo3p1ReferenceToVideoInput;
type RawResponse = Veo3p1ReferenceToVideoOutput;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
Ok(Self::RawRequest {
prompt: self.prompt.clone(),
image_urls: self.image_urls.clone(),
aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()),
duration: self.duration.map(|d| d.to_str().to_string()),
resolution: self.resolution.map(|r| r.to_str().to_string()),
generate_audio: self.generate_audio,
auto_fix: self.auto_fix,
safety_tolerance: self.safety_tolerance.map(|s| s.to_str().to_string()),
})
}
}
#[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 = Veo3p1ReferenceToVideoRequest {
prompt: "the two dogs run side by side across a sunlit meadow".to_string(),
image_urls: vec![
TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(),
JUNO_AT_LAKE_IMAGE_URL.to_string(),
],
aspect_ratio: Some(Veo3p1ReferenceToVideoAspectRatio::SixteenByNine),
duration: Some(Veo3p1ReferenceToVideoDuration::FourSeconds),
resolution: Some(Veo3p1ReferenceToVideoResolution::SevenTwentyP),
generate_audio: Some(false),
auto_fix: None,
safety_tolerance: None,
};
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 = Veo3p1ReferenceToVideoRequest {
prompt: "the dogs play together in a snowy field".to_string(),
image_urls: vec![TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string()],
aspect_ratio: None,
duration: Some(Veo3p1ReferenceToVideoDuration::FourSeconds),
resolution: Some(Veo3p1ReferenceToVideoResolution::SevenTwentyP),
generate_audio: Some(false),
auto_fix: None,
safety_tolerance: None,
};
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 = Veo3p1ReferenceToVideoRequest {
prompt: "p".to_string(),
image_urls: vec!["https://example.com/a.png".to_string(), "https://example.com/b.png".to_string()],
aspect_ratio: Some(Veo3p1ReferenceToVideoAspectRatio::NineBySixteen),
duration: Some(Veo3p1ReferenceToVideoDuration::SixSeconds),
resolution: Some(Veo3p1ReferenceToVideoResolution::FourK),
generate_audio: Some(true),
auto_fix: Some(false),
safety_tolerance: Some(Veo3p1ReferenceToVideoSafetyTolerance::Level5),
};
let raw = request.to_raw_request().unwrap();
assert_eq!(raw.image_urls.len(), 2);
assert_eq!(raw.aspect_ratio.as_deref(), Some("9:16"));
assert_eq!(raw.duration.as_deref(), Some("6s"));
assert_eq!(raw.resolution.as_deref(), Some("4k"));
assert_eq!(raw.generate_audio, Some(true));
assert_eq!(raw.auto_fix, Some(false));
assert_eq!(raw.safety_tolerance.as_deref(), Some("5"));
}
#[test]
fn raw_request_omits_unset_optionals() {
let request = Veo3p1ReferenceToVideoRequest {
prompt: "p".to_string(),
image_urls: vec!["https://example.com/a.png".to_string()],
aspect_ratio: None,
duration: None,
resolution: None,
generate_audio: None,
auto_fix: None,
safety_tolerance: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(
json,
serde_json::json!({ "prompt": "p", "image_urls": ["https://example.com/a.png"] }),
);
}
/// This endpoint's schema has no `negative_prompt`/`seed` fields, so they
/// must never appear on the wire regardless of other settings.
#[test]
fn raw_request_never_emits_negative_prompt_or_seed() {
let request = Veo3p1ReferenceToVideoRequest {
prompt: "p".to_string(),
image_urls: vec!["https://example.com/a.png".to_string()],
aspect_ratio: Some(Veo3p1ReferenceToVideoAspectRatio::SixteenByNine),
duration: Some(Veo3p1ReferenceToVideoDuration::EightSeconds),
resolution: Some(Veo3p1ReferenceToVideoResolution::TenEightyP),
generate_audio: Some(true),
auto_fix: Some(true),
safety_tolerance: Some(Veo3p1ReferenceToVideoSafetyTolerance::Level4),
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert!(json.get("negative_prompt").is_none(), "unexpected negative_prompt: {json}");
assert!(json.get("seed").is_none(), "unexpected seed: {json}");
}
#[test]
fn endpoint_path_is_canonical() {
assert_eq!(
Veo3p1ReferenceToVideoRequest::ENDPOINT,
"fal-ai/veo3.1/reference-to-video",
);
}
// NB: Pricing tests are in cost.rs
}
@@ -0,0 +1,112 @@
use crate::requests::api::video::reference::veo_3p1::api::{
Veo3p1ReferenceToVideoDuration, Veo3p1ReferenceToVideoRequest, Veo3p1ReferenceToVideoResolution,
};
use crate::requests::api::video::text::veo_3p1::cost::{
veo_3p1_cost_cents, veo_3p1_rate_tenth_cents_per_sec,
};
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
// Reference-to-video shares the Veo 3.1 (non-fast) per-second pricing (see the
// text module for the canonical rate table). fal's reference-to-video docs
// explicitly document the 4k tier for this endpoint:
// 720p / 1080p: $0.20/sec (audio off), $0.40/sec (audio on)
// 4k: $0.40/sec (audio off), $0.60/sec (audio on)
impl FalRequestCostCalculator for Veo3p1ReferenceToVideoRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
// fal defaults when unset: duration = 8s, resolution = 720p, audio = on.
let duration_secs = self.duration
.unwrap_or(Veo3p1ReferenceToVideoDuration::EightSeconds)
.to_seconds();
let four_k = self.resolution
.unwrap_or(Veo3p1ReferenceToVideoResolution::SevenTwentyP)
.is_four_k();
let audio_on = self.generate_audio.unwrap_or(true);
let rate = veo_3p1_rate_tenth_cents_per_sec(four_k, audio_on);
veo_3p1_cost_cents(rate, duration_secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_request(
duration: Option<Veo3p1ReferenceToVideoDuration>,
resolution: Option<Veo3p1ReferenceToVideoResolution>,
generate_audio: Option<bool>,
) -> Veo3p1ReferenceToVideoRequest {
Veo3p1ReferenceToVideoRequest {
prompt: "test".to_string(),
image_urls: vec!["https://example.com/a.png".to_string()],
aspect_ratio: None,
duration,
resolution,
generate_audio,
auto_fix: None,
safety_tolerance: None,
}
}
/// fal reference-to-video docs: "8 second video at 1080p with audio on will
/// cost $3.20" → 8s × $0.40 = 320¢.
#[test]
fn matches_documented_8s_1080p_audio_on_example() {
let cost = make_request(
Some(Veo3p1ReferenceToVideoDuration::EightSeconds),
Some(Veo3p1ReferenceToVideoResolution::TenEightyP),
Some(true),
).calculate_cost_in_cents();
assert_eq!(cost, 320);
}
mod cost_table {
use super::*;
// (duration, resolution, generate_audio, expected_cents)
const COST_TABLE: &[(
Option<Veo3p1ReferenceToVideoDuration>,
Option<Veo3p1ReferenceToVideoResolution>,
Option<bool>,
u64,
)] = &[
(Some(Veo3p1ReferenceToVideoDuration::FourSeconds), Some(Veo3p1ReferenceToVideoResolution::SevenTwentyP), Some(false), 80),
(Some(Veo3p1ReferenceToVideoDuration::FourSeconds), Some(Veo3p1ReferenceToVideoResolution::SevenTwentyP), Some(true), 160),
(Some(Veo3p1ReferenceToVideoDuration::EightSeconds), Some(Veo3p1ReferenceToVideoResolution::TenEightyP), Some(false), 160),
(Some(Veo3p1ReferenceToVideoDuration::EightSeconds), Some(Veo3p1ReferenceToVideoResolution::TenEightyP), Some(true), 320),
(Some(Veo3p1ReferenceToVideoDuration::EightSeconds), Some(Veo3p1ReferenceToVideoResolution::FourK), Some(false), 320),
(Some(Veo3p1ReferenceToVideoDuration::EightSeconds), Some(Veo3p1ReferenceToVideoResolution::FourK), Some(true), 480),
(None, None, None, 320),
];
#[test]
fn matches_cost_table() {
for &(duration, resolution, generate_audio, expected) in COST_TABLE {
let got = make_request(duration, resolution, generate_audio).calculate_cost_in_cents();
assert_eq!(
got, expected,
"duration={duration:?} resolution={resolution:?} audio={generate_audio:?}",
);
}
}
/// The number of reference images does not affect the bill.
#[test]
fn cost_is_independent_of_image_count() {
let one = Veo3p1ReferenceToVideoRequest {
image_urls: vec!["https://example.com/a.png".to_string()],
..make_request(Some(Veo3p1ReferenceToVideoDuration::EightSeconds), Some(Veo3p1ReferenceToVideoResolution::TenEightyP), Some(true))
}.calculate_cost_in_cents();
let many = Veo3p1ReferenceToVideoRequest {
image_urls: vec![
"https://example.com/a.png".to_string(),
"https://example.com/b.png".to_string(),
"https://example.com/c.png".to_string(),
],
..make_request(Some(Veo3p1ReferenceToVideoDuration::EightSeconds), Some(Veo3p1ReferenceToVideoResolution::TenEightyP), Some(true))
}.calculate_cost_in_cents();
assert_eq!(one, many);
}
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -0,0 +1,53 @@
use serde::{Deserialize, Serialize};
/// Over-the-wire input shape for `fal-ai/veo3.1/reference-to-video`.
/// Reference images drive subject/appearance consistency.
/// fal's schema: <https://fal.ai/models/fal-ai/veo3.1/reference-to-video/api>
///
/// NB: unlike the other Veo 3.1 modalities, this endpoint's schema does NOT
/// expose `negative_prompt` or `seed`.
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Veo3p1ReferenceToVideoInput {
pub prompt: String,
/// URLs of the reference images used for consistent subject appearance.
pub image_urls: Vec<String>,
/// Aspect ratio.
/// Possible values: "16:9", "9:16". fal default: "16:9".
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
/// Duration in seconds (as a string).
/// Possible values: "4s", "6s", "8s". fal default: "8s".
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<String>,
/// Output resolution.
/// Possible values: "720p", "1080p", "4k". fal default: "720p".
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<String>,
/// Whether to generate native audio for the video. fal default: true.
#[serde(skip_serializing_if = "Option::is_none")]
pub generate_audio: Option<bool>,
/// Whether to automatically rewrite prompts that fail moderation.
/// fal default: false.
#[serde(skip_serializing_if = "Option::is_none")]
pub auto_fix: Option<bool>,
/// Safety tolerance, "1" (strictest) .. "6" (least strict). fal default: "4".
#[serde(skip_serializing_if = "Option::is_none")]
pub safety_tolerance: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo3p1ReferenceToVideoVideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo3p1ReferenceToVideoOutput {
pub video: Veo3p1ReferenceToVideoVideoFile,
}
@@ -0,0 +1,275 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::video::reference::veo_3p1_fast::raw_request::{
Veo3p1FastReferenceToVideoInput, Veo3p1FastReferenceToVideoOutput,
};
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
#[derive(Clone, Debug)]
pub struct Veo3p1FastReferenceToVideoRequest {
/// Text prompt describing the video to generate.
pub prompt: String,
/// Reference image URLs used for consistent subject appearance.
pub image_urls: Vec<String>,
/// Aspect ratio. fal's default is `16:9` when `None`.
pub aspect_ratio: Option<Veo3p1FastReferenceToVideoAspectRatio>,
/// Video duration. fal's default is `8s` when `None`.
pub duration: Option<Veo3p1FastReferenceToVideoDuration>,
/// Output resolution. fal's default is `720p` when `None`.
pub resolution: Option<Veo3p1FastReferenceToVideoResolution>,
/// Whether to generate native audio. fal's server default is `true` when
/// this is `None`.
pub generate_audio: Option<bool>,
/// Whether to auto-rewrite prompts that fail moderation. fal's default is
/// `false` when `None`.
pub auto_fix: Option<bool>,
/// Safety tolerance. fal's default is `4` when `None`.
pub safety_tolerance: Option<Veo3p1FastReferenceToVideoSafetyTolerance>,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1FastReferenceToVideoAspectRatio {
SixteenByNine,
NineBySixteen,
}
impl Veo3p1FastReferenceToVideoAspectRatio {
fn to_str(&self) -> &'static str {
match self {
Self::SixteenByNine => "16:9",
Self::NineBySixteen => "9:16",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1FastReferenceToVideoDuration {
FourSeconds,
SixSeconds,
EightSeconds,
}
impl Veo3p1FastReferenceToVideoDuration {
pub fn to_seconds(&self) -> u64 {
match self {
Self::FourSeconds => 4,
Self::SixSeconds => 6,
Self::EightSeconds => 8,
}
}
fn to_str(&self) -> &'static str {
match self {
Self::FourSeconds => "4s",
Self::SixSeconds => "6s",
Self::EightSeconds => "8s",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1FastReferenceToVideoResolution {
SevenTwentyP,
TenEightyP,
FourK,
}
impl Veo3p1FastReferenceToVideoResolution {
/// 4k output is billed at a higher per-second rate than 720p/1080p.
pub fn is_four_k(&self) -> bool {
matches!(self, Self::FourK)
}
fn to_str(&self) -> &'static str {
match self {
Self::SevenTwentyP => "720p",
Self::TenEightyP => "1080p",
Self::FourK => "4k",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1FastReferenceToVideoSafetyTolerance {
/// "1" — strictest.
Level1,
Level2,
Level3,
/// "4" — fal default.
Level4,
Level5,
/// "6" — least strict.
Level6,
}
impl Veo3p1FastReferenceToVideoSafetyTolerance {
fn to_str(&self) -> &'static str {
match self {
Self::Level1 => "1",
Self::Level2 => "2",
Self::Level3 => "3",
Self::Level4 => "4",
Self::Level5 => "5",
Self::Level6 => "6",
}
}
}
impl FalEndpoint for Veo3p1FastReferenceToVideoRequest {
const ENDPOINT: &str = "fal-ai/veo3.1/fast/reference-to-video";
type RawRequest = Veo3p1FastReferenceToVideoInput;
type RawResponse = Veo3p1FastReferenceToVideoOutput;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
Ok(Self::RawRequest {
prompt: self.prompt.clone(),
image_urls: self.image_urls.clone(),
aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()),
duration: self.duration.map(|d| d.to_str().to_string()),
resolution: self.resolution.map(|r| r.to_str().to_string()),
generate_audio: self.generate_audio,
auto_fix: self.auto_fix,
safety_tolerance: self.safety_tolerance.map(|s| s.to_str().to_string()),
})
}
}
#[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 = Veo3p1FastReferenceToVideoRequest {
prompt: "the two dogs run side by side across a sunlit meadow".to_string(),
image_urls: vec![
TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(),
JUNO_AT_LAKE_IMAGE_URL.to_string(),
],
aspect_ratio: Some(Veo3p1FastReferenceToVideoAspectRatio::SixteenByNine),
duration: Some(Veo3p1FastReferenceToVideoDuration::FourSeconds),
resolution: Some(Veo3p1FastReferenceToVideoResolution::SevenTwentyP),
generate_audio: Some(false),
auto_fix: None,
safety_tolerance: None,
};
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 = Veo3p1FastReferenceToVideoRequest {
prompt: "the dogs play together in a snowy field".to_string(),
image_urls: vec![TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string()],
aspect_ratio: None,
duration: Some(Veo3p1FastReferenceToVideoDuration::FourSeconds),
resolution: Some(Veo3p1FastReferenceToVideoResolution::SevenTwentyP),
generate_audio: Some(false),
auto_fix: None,
safety_tolerance: None,
};
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 = Veo3p1FastReferenceToVideoRequest {
prompt: "p".to_string(),
image_urls: vec!["https://example.com/a.png".to_string(), "https://example.com/b.png".to_string()],
aspect_ratio: Some(Veo3p1FastReferenceToVideoAspectRatio::NineBySixteen),
duration: Some(Veo3p1FastReferenceToVideoDuration::SixSeconds),
resolution: Some(Veo3p1FastReferenceToVideoResolution::FourK),
generate_audio: Some(true),
auto_fix: Some(false),
safety_tolerance: Some(Veo3p1FastReferenceToVideoSafetyTolerance::Level5),
};
let raw = request.to_raw_request().unwrap();
assert_eq!(raw.image_urls.len(), 2);
assert_eq!(raw.aspect_ratio.as_deref(), Some("9:16"));
assert_eq!(raw.duration.as_deref(), Some("6s"));
assert_eq!(raw.resolution.as_deref(), Some("4k"));
assert_eq!(raw.generate_audio, Some(true));
assert_eq!(raw.auto_fix, Some(false));
assert_eq!(raw.safety_tolerance.as_deref(), Some("5"));
}
#[test]
fn raw_request_omits_unset_optionals() {
let request = Veo3p1FastReferenceToVideoRequest {
prompt: "p".to_string(),
image_urls: vec!["https://example.com/a.png".to_string()],
aspect_ratio: None,
duration: None,
resolution: None,
generate_audio: None,
auto_fix: None,
safety_tolerance: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(
json,
serde_json::json!({ "prompt": "p", "image_urls": ["https://example.com/a.png"] }),
);
}
/// This endpoint's schema has no `negative_prompt`/`seed` fields, so they
/// must never appear on the wire regardless of other settings.
#[test]
fn raw_request_never_emits_negative_prompt_or_seed() {
let request = Veo3p1FastReferenceToVideoRequest {
prompt: "p".to_string(),
image_urls: vec!["https://example.com/a.png".to_string()],
aspect_ratio: Some(Veo3p1FastReferenceToVideoAspectRatio::SixteenByNine),
duration: Some(Veo3p1FastReferenceToVideoDuration::EightSeconds),
resolution: Some(Veo3p1FastReferenceToVideoResolution::TenEightyP),
generate_audio: Some(true),
auto_fix: Some(true),
safety_tolerance: Some(Veo3p1FastReferenceToVideoSafetyTolerance::Level4),
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert!(json.get("negative_prompt").is_none(), "unexpected negative_prompt: {json}");
assert!(json.get("seed").is_none(), "unexpected seed: {json}");
}
#[test]
fn endpoint_path_is_canonical() {
assert_eq!(
Veo3p1FastReferenceToVideoRequest::ENDPOINT,
"fal-ai/veo3.1/fast/reference-to-video",
);
}
// NB: Pricing tests are in cost.rs
}
@@ -0,0 +1,109 @@
use crate::requests::api::video::reference::veo_3p1_fast::api::{
Veo3p1FastReferenceToVideoDuration, Veo3p1FastReferenceToVideoRequest,
Veo3p1FastReferenceToVideoResolution,
};
use crate::requests::api::video::text::veo_3p1_fast::cost::{
veo_3p1_fast_cost_cents, veo_3p1_fast_rate_tenth_cents_per_sec,
};
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
// PRICING NOTE — reference-to-video:
//
// fal's reference-to-video llms.txt documents a FLAT rate of $0.10/sec (audio
// off) / $0.15/sec (audio on) and does NOT mention a separate 4k tier — even
// though the endpoint exposes a "4k" resolution option. The sibling
// text/image/first-last endpoints DO document the 4k surcharge
// ($0.30/$0.35). Rather than risk undercharging on 4k reference generations,
// we mirror the rest of the Veo 3.1 Fast family and apply the 4k surcharge
// here too. For 720p/1080p this is identical to fal's documented flat rate;
// only 4k differs. If fal confirms reference-to-video is truly flat at 4k,
// drop the `four_k` branch and always bill the SD rate.
impl FalRequestCostCalculator for Veo3p1FastReferenceToVideoRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
// fal defaults when unset: duration = 8s, resolution = 720p, audio = on.
let duration_secs = self.duration
.unwrap_or(Veo3p1FastReferenceToVideoDuration::EightSeconds)
.to_seconds();
let four_k = self.resolution
.unwrap_or(Veo3p1FastReferenceToVideoResolution::SevenTwentyP)
.is_four_k();
let audio_on = self.generate_audio.unwrap_or(true);
let rate = veo_3p1_fast_rate_tenth_cents_per_sec(four_k, audio_on);
veo_3p1_fast_cost_cents(rate, duration_secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_request(
duration: Option<Veo3p1FastReferenceToVideoDuration>,
resolution: Option<Veo3p1FastReferenceToVideoResolution>,
generate_audio: Option<bool>,
) -> Veo3p1FastReferenceToVideoRequest {
Veo3p1FastReferenceToVideoRequest {
prompt: "test".to_string(),
image_urls: vec!["https://example.com/a.png".to_string()],
aspect_ratio: None,
duration,
resolution,
generate_audio,
auto_fix: None,
safety_tolerance: None,
}
}
mod cost_table {
use super::*;
// (duration, resolution, generate_audio, expected_cents)
const COST_TABLE: &[(
Option<Veo3p1FastReferenceToVideoDuration>,
Option<Veo3p1FastReferenceToVideoResolution>,
Option<bool>,
u64,
)] = &[
// 720p/1080p match fal's documented flat rate
(Some(Veo3p1FastReferenceToVideoDuration::FourSeconds), Some(Veo3p1FastReferenceToVideoResolution::SevenTwentyP), Some(false), 40),
(Some(Veo3p1FastReferenceToVideoDuration::FourSeconds), Some(Veo3p1FastReferenceToVideoResolution::SevenTwentyP), Some(true), 60),
(Some(Veo3p1FastReferenceToVideoDuration::EightSeconds), Some(Veo3p1FastReferenceToVideoResolution::TenEightyP), Some(false), 80),
(Some(Veo3p1FastReferenceToVideoDuration::EightSeconds), Some(Veo3p1FastReferenceToVideoResolution::TenEightyP), Some(true), 120),
// 4k applies the family surcharge (see PRICING NOTE)
(Some(Veo3p1FastReferenceToVideoDuration::EightSeconds), Some(Veo3p1FastReferenceToVideoResolution::FourK), Some(false), 240),
(Some(Veo3p1FastReferenceToVideoDuration::EightSeconds), Some(Veo3p1FastReferenceToVideoResolution::FourK), Some(true), 280),
(None, None, None, 120),
];
#[test]
fn matches_cost_table() {
for &(duration, resolution, generate_audio, expected) in COST_TABLE {
let got = make_request(duration, resolution, generate_audio).calculate_cost_in_cents();
assert_eq!(
got, expected,
"duration={duration:?} resolution={resolution:?} audio={generate_audio:?}",
);
}
}
/// The number of reference images does not affect the bill.
#[test]
fn cost_is_independent_of_image_count() {
let one = Veo3p1FastReferenceToVideoRequest {
image_urls: vec!["https://example.com/a.png".to_string()],
..make_request(Some(Veo3p1FastReferenceToVideoDuration::EightSeconds), Some(Veo3p1FastReferenceToVideoResolution::TenEightyP), Some(true))
}.calculate_cost_in_cents();
let many = Veo3p1FastReferenceToVideoRequest {
image_urls: vec![
"https://example.com/a.png".to_string(),
"https://example.com/b.png".to_string(),
"https://example.com/c.png".to_string(),
],
..make_request(Some(Veo3p1FastReferenceToVideoDuration::EightSeconds), Some(Veo3p1FastReferenceToVideoResolution::TenEightyP), Some(true))
}.calculate_cost_in_cents();
assert_eq!(one, many);
}
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -0,0 +1,53 @@
use serde::{Deserialize, Serialize};
/// Over-the-wire input shape for `fal-ai/veo3.1/fast/reference-to-video`.
/// Reference images drive subject/appearance consistency.
/// fal's schema: <https://fal.ai/models/fal-ai/veo3.1/fast/reference-to-video/api>
///
/// NB: unlike the other Veo 3.1 Fast modalities, this endpoint's schema does
/// NOT expose `negative_prompt` or `seed`.
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Veo3p1FastReferenceToVideoInput {
pub prompt: String,
/// URLs of the reference images used for consistent subject appearance.
pub image_urls: Vec<String>,
/// Aspect ratio.
/// Possible values: "16:9", "9:16". fal default: "16:9".
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
/// Duration in seconds (as a string).
/// Possible values: "4s", "6s", "8s". fal default: "8s".
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<String>,
/// Output resolution.
/// Possible values: "720p", "1080p", "4k". fal default: "720p".
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<String>,
/// Whether to generate native audio for the video. fal default: true.
#[serde(skip_serializing_if = "Option::is_none")]
pub generate_audio: Option<bool>,
/// Whether to automatically rewrite prompts that fail moderation.
/// fal default: false.
#[serde(skip_serializing_if = "Option::is_none")]
pub auto_fix: Option<bool>,
/// Safety tolerance, "1" (strictest) .. "6" (least strict). fal default: "4".
#[serde(skip_serializing_if = "Option::is_none")]
pub safety_tolerance: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo3p1FastReferenceToVideoVideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo3p1FastReferenceToVideoOutput {
pub video: Veo3p1FastReferenceToVideoVideoFile,
}
@@ -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,
}
@@ -2,3 +2,11 @@ pub mod kling_1p6_pro_text_to_video;
pub mod kling_2p5_turbo_pro_text_to_video;
pub mod kling_2p6_pro_text_to_video;
pub mod kling_3p0_standard_text_to_video;
pub mod veo_2;
pub mod veo_3;
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,221 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::video::text::veo_2::raw_request::{
Veo2TextToVideoInput, Veo2TextToVideoOutput,
};
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
#[derive(Clone, Debug)]
pub struct Veo2TextToVideoRequest {
/// Text prompt describing the video to generate.
pub prompt: String,
/// Aspect ratio. fal's default is `16:9` when `None`.
pub aspect_ratio: Option<Veo2TextToVideoAspectRatio>,
/// Video duration. fal's default is `5s` when `None`.
pub duration: Option<Veo2TextToVideoDuration>,
/// Optional negative prompt.
pub negative_prompt: Option<String>,
/// Whether to enhance the prompt before generation. fal's default is `true`
/// when `None`.
pub enhance_prompt: Option<bool>,
/// Seed for the random number generator.
pub seed: Option<i64>,
/// Whether to auto-rewrite prompts that fail moderation. fal's default is
/// `true` when `None`.
pub auto_fix: Option<bool>,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo2TextToVideoAspectRatio {
NineBySixteen,
SixteenByNine,
Square,
}
impl Veo2TextToVideoAspectRatio {
fn to_str(&self) -> &'static str {
match self {
Self::NineBySixteen => "9:16",
Self::SixteenByNine => "16:9",
Self::Square => "1:1",
}
}
}
/// Veo 2 supports 5–8 second durations (default 5s), unlike the Veo 3 family's
/// 4/6/8.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo2TextToVideoDuration {
FiveSeconds,
SixSeconds,
SevenSeconds,
EightSeconds,
}
impl Veo2TextToVideoDuration {
pub fn to_seconds(&self) -> u64 {
match self {
Self::FiveSeconds => 5,
Self::SixSeconds => 6,
Self::SevenSeconds => 7,
Self::EightSeconds => 8,
}
}
fn to_str(&self) -> &'static str {
match self {
Self::FiveSeconds => "5s",
Self::SixSeconds => "6s",
Self::SevenSeconds => "7s",
Self::EightSeconds => "8s",
}
}
}
impl FalEndpoint for Veo2TextToVideoRequest {
const ENDPOINT: &str = "fal-ai/veo2";
type RawRequest = Veo2TextToVideoInput;
type RawResponse = Veo2TextToVideoOutput;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
Ok(Self::RawRequest {
prompt: self.prompt.clone(),
aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()),
duration: self.duration.map(|d| d.to_str().to_string()),
negative_prompt: self.negative_prompt.clone(),
enhance_prompt: self.enhance_prompt,
seed: self.seed,
auto_fix: self.auto_fix,
})
}
}
#[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 = Veo2TextToVideoRequest {
prompt: "a golden retriever puppy chases butterflies through a sunlit meadow".to_string(),
aspect_ratio: Some(Veo2TextToVideoAspectRatio::SixteenByNine),
duration: Some(Veo2TextToVideoDuration::FiveSeconds),
negative_prompt: None,
enhance_prompt: None,
seed: None,
auto_fix: None,
};
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 = Veo2TextToVideoRequest {
prompt: "a wave crashes against a rocky shoreline at sunset".to_string(),
aspect_ratio: Some(Veo2TextToVideoAspectRatio::SixteenByNine),
duration: Some(Veo2TextToVideoDuration::FiveSeconds),
negative_prompt: None,
enhance_prompt: None,
seed: None,
auto_fix: None,
};
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 = Veo2TextToVideoRequest {
prompt: "p".to_string(),
aspect_ratio: Some(Veo2TextToVideoAspectRatio::Square),
duration: Some(Veo2TextToVideoDuration::EightSeconds),
negative_prompt: Some("blurry".to_string()),
enhance_prompt: Some(false),
seed: Some(42),
auto_fix: Some(false),
};
let raw = request.to_raw_request().unwrap();
assert_eq!(raw.prompt, "p");
assert_eq!(raw.aspect_ratio.as_deref(), Some("1:1"));
assert_eq!(raw.duration.as_deref(), Some("8s"));
assert_eq!(raw.negative_prompt.as_deref(), Some("blurry"));
assert_eq!(raw.enhance_prompt, Some(false));
assert_eq!(raw.seed, Some(42));
assert_eq!(raw.auto_fix, Some(false));
}
#[test]
fn raw_request_omits_unset_optionals() {
let request = Veo2TextToVideoRequest {
prompt: "minimal".to_string(),
aspect_ratio: None,
duration: None,
negative_prompt: None,
enhance_prompt: None,
seed: None,
auto_fix: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(json, serde_json::json!({ "prompt": "minimal" }));
}
#[test]
fn every_aspect_ratio_maps_to_wire_string() {
for (variant, expected) in [
(Veo2TextToVideoAspectRatio::NineBySixteen, "9:16"),
(Veo2TextToVideoAspectRatio::SixteenByNine, "16:9"),
(Veo2TextToVideoAspectRatio::Square, "1:1"),
] {
assert_eq!(variant.to_str(), expected);
}
}
#[test]
fn every_duration_maps_to_wire_string_and_seconds() {
for (variant, s, secs) in [
(Veo2TextToVideoDuration::FiveSeconds, "5s", 5),
(Veo2TextToVideoDuration::SixSeconds, "6s", 6),
(Veo2TextToVideoDuration::SevenSeconds, "7s", 7),
(Veo2TextToVideoDuration::EightSeconds, "8s", 8),
] {
assert_eq!(variant.to_str(), s);
assert_eq!(variant.to_seconds(), secs);
}
}
#[test]
fn endpoint_path_is_canonical() {
assert_eq!(Veo2TextToVideoRequest::ENDPOINT, "fal-ai/veo2");
}
// NB: Pricing tests are in cost.rs
}
@@ -0,0 +1,110 @@
use crate::requests::api::video::text::veo_2::api::{Veo2TextToVideoDuration, Veo2TextToVideoRequest};
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
// Veo 2 pricing (see https://fal.ai/models/fal-ai/veo2):
// "For 5s video your request will cost $2.50. For every additional second
// you will be charged $0.50."
//
// This is a flat base + per-extra-second model (NOT the per-second-rate model
// the Veo 3 family uses), and it does not vary by audio, resolution, or aspect
// ratio. All amounts are exact whole cents. Shared by text + image modalities.
const BASE_COST_CENTS: UsdCents = 250; // 5s
const ADDITIONAL_SECOND_COST_CENTS: UsdCents = 50;
const BASE_DURATION_SECS: u64 = 5;
/// Veo 2 cost in whole cents for a given duration in seconds.
/// `250 + (secs - 5) × 50`; durations below 5s (not reachable via the typed
/// enum) clamp to the base.
pub(crate) fn veo_2_cost_cents(duration_secs: u64) -> UsdCents {
let extra_secs = duration_secs.saturating_sub(BASE_DURATION_SECS);
BASE_COST_CENTS + extra_secs * ADDITIONAL_SECOND_COST_CENTS
}
impl FalRequestCostCalculator for Veo2TextToVideoRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
// fal default when unset: duration = 5s.
let duration_secs = self.duration
.unwrap_or(Veo2TextToVideoDuration::FiveSeconds)
.to_seconds();
veo_2_cost_cents(duration_secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::requests::api::video::text::veo_2::api::Veo2TextToVideoAspectRatio;
fn make_request(duration: Option<Veo2TextToVideoDuration>) -> Veo2TextToVideoRequest {
Veo2TextToVideoRequest {
prompt: "test".to_string(),
aspect_ratio: Some(Veo2TextToVideoAspectRatio::SixteenByNine),
duration,
negative_prompt: None,
enhance_prompt: None,
seed: None,
auto_fix: None,
}
}
/// fal: "For 5s video your request will cost $2.50" → 250¢.
#[test]
fn matches_documented_5s_example() {
assert_eq!(make_request(Some(Veo2TextToVideoDuration::FiveSeconds)).calculate_cost_in_cents(), 250);
}
/// fal i2v example: a 6-second video costs $3.00 ($2.50 + $0.50) → 300¢.
#[test]
fn matches_documented_6s_example() {
assert_eq!(make_request(Some(Veo2TextToVideoDuration::SixSeconds)).calculate_cost_in_cents(), 300);
}
mod cost_table {
use super::*;
// (duration, expected_cents): 250 base + 50 per second over 5s.
const COST_TABLE: &[(Option<Veo2TextToVideoDuration>, u64)] = &[
(Some(Veo2TextToVideoDuration::FiveSeconds), 250),
(Some(Veo2TextToVideoDuration::SixSeconds), 300),
(Some(Veo2TextToVideoDuration::SevenSeconds), 350),
(Some(Veo2TextToVideoDuration::EightSeconds), 400),
// Default: duration=None → 5s
(None, 250),
];
#[test]
fn matches_cost_table() {
for &(duration, expected) in COST_TABLE {
assert_eq!(
make_request(duration).calculate_cost_in_cents(),
expected,
"duration={duration:?}",
);
}
}
#[test]
fn each_extra_second_adds_fifty_cents() {
let five = make_request(Some(Veo2TextToVideoDuration::FiveSeconds)).calculate_cost_in_cents();
let six = make_request(Some(Veo2TextToVideoDuration::SixSeconds)).calculate_cost_in_cents();
assert_eq!(six - five, 50);
}
/// Aspect ratio, negative prompt, enhance_prompt, seed, and auto_fix must
/// not affect the bill.
#[test]
fn cost_ignores_non_billing_fields() {
let baseline = make_request(Some(Veo2TextToVideoDuration::EightSeconds)).calculate_cost_in_cents();
let embellished = Veo2TextToVideoRequest {
prompt: "different".to_string(),
aspect_ratio: Some(Veo2TextToVideoAspectRatio::Square),
duration: Some(Veo2TextToVideoDuration::EightSeconds),
negative_prompt: Some("noise".to_string()),
enhance_prompt: Some(false),
seed: Some(99),
auto_fix: Some(false),
}.calculate_cost_in_cents();
assert_eq!(baseline, embellished);
}
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -0,0 +1,48 @@
use serde::{Deserialize, Serialize};
/// Over-the-wire input shape for `fal-ai/veo2` (text-to-video).
/// fal's schema: <https://fal.ai/models/fal-ai/veo2/api>
///
/// NB: Veo 2 has no `generate_audio`, `resolution`, or `safety_tolerance`
/// fields (unlike the Veo 3 family), and it *does* carry `enhance_prompt`.
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Veo2TextToVideoInput {
pub prompt: String,
/// Aspect ratio.
/// Possible values: "9:16", "16:9", "1:1". fal default: "16:9".
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
/// Duration in seconds (as a string).
/// Possible values: "5s", "6s", "7s", "8s". fal default: "5s".
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<String>,
/// Optional negative prompt.
#[serde(skip_serializing_if = "Option::is_none")]
pub negative_prompt: Option<String>,
/// Whether to enhance the prompt before generation. fal default: true.
#[serde(skip_serializing_if = "Option::is_none")]
pub enhance_prompt: Option<bool>,
/// Seed for the random number generator.
#[serde(skip_serializing_if = "Option::is_none")]
pub seed: Option<i64>,
/// Whether to automatically rewrite prompts that fail content policy /
/// validation checks. fal default: true.
#[serde(skip_serializing_if = "Option::is_none")]
pub auto_fix: Option<bool>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo2TextToVideoVideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo2TextToVideoOutput {
pub video: Veo2TextToVideoVideoFile,
}
@@ -0,0 +1,262 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::video::text::veo_3::raw_request::{
Veo3TextToVideoInput, Veo3TextToVideoOutput,
};
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
#[derive(Clone, Debug)]
pub struct Veo3TextToVideoRequest {
/// Text prompt describing the video to generate.
pub prompt: String,
/// Aspect ratio. fal's default is `16:9` when `None`.
pub aspect_ratio: Option<Veo3TextToVideoAspectRatio>,
/// Video duration. fal's default is `8s` when `None`.
pub duration: Option<Veo3TextToVideoDuration>,
/// Output resolution. fal's default is `720p` when `None`.
pub resolution: Option<Veo3TextToVideoResolution>,
/// Optional negative prompt.
pub negative_prompt: Option<String>,
/// Whether to generate native audio. fal's server default is `true` when
/// this is `None`.
pub generate_audio: Option<bool>,
/// Seed for the random number generator.
pub seed: Option<i64>,
/// Whether to auto-rewrite prompts that fail moderation. fal's default is
/// `true` for text-to-video when `None`.
pub auto_fix: Option<bool>,
/// Safety tolerance. fal's default is `4` when `None`.
pub safety_tolerance: Option<Veo3TextToVideoSafetyTolerance>,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3TextToVideoAspectRatio {
SixteenByNine,
NineBySixteen,
}
impl Veo3TextToVideoAspectRatio {
fn to_str(&self) -> &'static str {
match self {
Self::SixteenByNine => "16:9",
Self::NineBySixteen => "9:16",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3TextToVideoDuration {
FourSeconds,
SixSeconds,
EightSeconds,
}
impl Veo3TextToVideoDuration {
pub fn to_seconds(&self) -> u64 {
match self {
Self::FourSeconds => 4,
Self::SixSeconds => 6,
Self::EightSeconds => 8,
}
}
fn to_str(&self) -> &'static str {
match self {
Self::FourSeconds => "4s",
Self::SixSeconds => "6s",
Self::EightSeconds => "8s",
}
}
}
/// Veo 3 exposes only 720p/1080p (no 4k tier).
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3TextToVideoResolution {
SevenTwentyP,
TenEightyP,
}
impl Veo3TextToVideoResolution {
fn to_str(&self) -> &'static str {
match self {
Self::SevenTwentyP => "720p",
Self::TenEightyP => "1080p",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3TextToVideoSafetyTolerance {
/// "1" — strictest.
Level1,
Level2,
Level3,
/// "4" — fal default.
Level4,
Level5,
/// "6" — least strict.
Level6,
}
impl Veo3TextToVideoSafetyTolerance {
fn to_str(&self) -> &'static str {
match self {
Self::Level1 => "1",
Self::Level2 => "2",
Self::Level3 => "3",
Self::Level4 => "4",
Self::Level5 => "5",
Self::Level6 => "6",
}
}
}
impl FalEndpoint for Veo3TextToVideoRequest {
const ENDPOINT: &str = "fal-ai/veo3";
type RawRequest = Veo3TextToVideoInput;
type RawResponse = Veo3TextToVideoOutput;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
Ok(Self::RawRequest {
prompt: self.prompt.clone(),
aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()),
duration: self.duration.map(|d| d.to_str().to_string()),
negative_prompt: self.negative_prompt.clone(),
resolution: self.resolution.map(|r| r.to_str().to_string()),
generate_audio: self.generate_audio,
seed: self.seed,
auto_fix: self.auto_fix,
safety_tolerance: self.safety_tolerance.map(|s| s.to_str().to_string()),
})
}
}
#[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 = Veo3TextToVideoRequest {
prompt: "a golden retriever puppy chases butterflies through a sunlit meadow".to_string(),
aspect_ratio: Some(Veo3TextToVideoAspectRatio::SixteenByNine),
duration: Some(Veo3TextToVideoDuration::FourSeconds),
resolution: Some(Veo3TextToVideoResolution::SevenTwentyP),
negative_prompt: None,
generate_audio: Some(true),
seed: None,
auto_fix: None,
safety_tolerance: None,
};
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 = Veo3TextToVideoRequest {
prompt: "a wave crashes against a rocky shoreline at sunset".to_string(),
aspect_ratio: Some(Veo3TextToVideoAspectRatio::SixteenByNine),
duration: Some(Veo3TextToVideoDuration::FourSeconds),
resolution: Some(Veo3TextToVideoResolution::SevenTwentyP),
negative_prompt: None,
generate_audio: Some(false),
seed: None,
auto_fix: None,
safety_tolerance: None,
};
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 = Veo3TextToVideoRequest {
prompt: "p".to_string(),
aspect_ratio: Some(Veo3TextToVideoAspectRatio::NineBySixteen),
duration: Some(Veo3TextToVideoDuration::EightSeconds),
resolution: Some(Veo3TextToVideoResolution::TenEightyP),
negative_prompt: Some("blurry".to_string()),
generate_audio: Some(true),
seed: Some(42),
auto_fix: Some(false),
safety_tolerance: Some(Veo3TextToVideoSafetyTolerance::Level6),
};
let raw = request.to_raw_request().unwrap();
assert_eq!(raw.prompt, "p");
assert_eq!(raw.aspect_ratio.as_deref(), Some("9:16"));
assert_eq!(raw.duration.as_deref(), Some("8s"));
assert_eq!(raw.resolution.as_deref(), Some("1080p"));
assert_eq!(raw.negative_prompt.as_deref(), Some("blurry"));
assert_eq!(raw.generate_audio, Some(true));
assert_eq!(raw.seed, Some(42));
assert_eq!(raw.auto_fix, Some(false));
assert_eq!(raw.safety_tolerance.as_deref(), Some("6"));
}
#[test]
fn raw_request_omits_unset_optionals() {
let request = Veo3TextToVideoRequest {
prompt: "minimal".to_string(),
aspect_ratio: None,
duration: None,
resolution: None,
negative_prompt: None,
generate_audio: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(json, serde_json::json!({ "prompt": "minimal" }));
}
#[test]
fn every_duration_maps_to_wire_string_and_seconds() {
for (variant, s, secs) in [
(Veo3TextToVideoDuration::FourSeconds, "4s", 4),
(Veo3TextToVideoDuration::SixSeconds, "6s", 6),
(Veo3TextToVideoDuration::EightSeconds, "8s", 8),
] {
assert_eq!(variant.to_str(), s);
assert_eq!(variant.to_seconds(), secs);
}
}
#[test]
fn endpoint_path_is_canonical() {
assert_eq!(Veo3TextToVideoRequest::ENDPOINT, "fal-ai/veo3");
}
// NB: Pricing tests are in cost.rs
}
@@ -0,0 +1,140 @@
use crate::requests::api::video::text::veo_3::api::{
Veo3TextToVideoDuration, Veo3TextToVideoRequest,
};
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
// Veo 3 (non-fast) pricing (see https://fal.ai/models/fal-ai/veo3):
// $0.20/sec (audio off), $0.40/sec (audio on). Flat — Veo 3 has no 4k tier
// and resolution (720p/1080p) does not change the price.
//
// Rates are stored in tenths-of-a-cent per second, multiplied by duration and
// rounded UP to whole cents so the user is never undercharged. These helpers
// are the canonical Veo 3 rate table, reused by the image-to-video modality.
const RATE_AUDIO_OFF: u64 = 200; // $0.20/sec
const RATE_AUDIO_ON: u64 = 400; // $0.40/sec
/// Per-second rate in tenths-of-a-cent for the Veo 3 (non-fast) family.
pub(crate) fn veo_3_rate_tenth_cents_per_sec(audio_on: bool) -> u64 {
if audio_on { RATE_AUDIO_ON } else { RATE_AUDIO_OFF }
}
/// ceil(rate_tenth_cents × seconds / 10) → whole cents.
pub(crate) fn veo_3_cost_cents(rate_tenth_cents_per_sec: u64, duration_secs: u64) -> UsdCents {
(rate_tenth_cents_per_sec * duration_secs + 9) / 10
}
impl FalRequestCostCalculator for Veo3TextToVideoRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
// fal defaults when unset: duration = 8s, audio = on. Resolution does not
// affect price.
let duration_secs = self.duration
.unwrap_or(Veo3TextToVideoDuration::EightSeconds)
.to_seconds();
let audio_on = self.generate_audio.unwrap_or(true);
let rate = veo_3_rate_tenth_cents_per_sec(audio_on);
veo_3_cost_cents(rate, duration_secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::requests::api::video::text::veo_3::api::{
Veo3TextToVideoAspectRatio, Veo3TextToVideoResolution, Veo3TextToVideoSafetyTolerance,
};
fn make_request(
duration: Option<Veo3TextToVideoDuration>,
generate_audio: Option<bool>,
) -> Veo3TextToVideoRequest {
Veo3TextToVideoRequest {
prompt: "test".to_string(),
aspect_ratio: Some(Veo3TextToVideoAspectRatio::SixteenByNine),
duration,
resolution: None,
negative_prompt: None,
generate_audio,
seed: None,
auto_fix: None,
safety_tolerance: None,
}
}
/// fal: "a 5s video with audio on will cost $2" → 5s × $0.40 = 200¢.
#[test]
fn matches_documented_5s_audio_on_example() {
let rate = veo_3_rate_tenth_cents_per_sec(true);
assert_eq!(veo_3_cost_cents(rate, 5), 200);
}
mod cost_table {
use super::*;
// (duration, generate_audio, expected_cents)
const COST_TABLE: &[(
Option<Veo3TextToVideoDuration>,
Option<bool>,
u64,
)] = &[
(Some(Veo3TextToVideoDuration::FourSeconds), Some(false), 80),
(Some(Veo3TextToVideoDuration::FourSeconds), Some(true), 160),
(Some(Veo3TextToVideoDuration::SixSeconds), Some(true), 240),
(Some(Veo3TextToVideoDuration::EightSeconds), Some(false), 160),
(Some(Veo3TextToVideoDuration::EightSeconds), Some(true), 320),
// Defaults: duration=None→8s, audio=None→on
(None, None, 320),
(None, Some(false), 160),
];
#[test]
fn matches_cost_table() {
for &(duration, generate_audio, expected) in COST_TABLE {
let got = make_request(duration, generate_audio).calculate_cost_in_cents();
assert_eq!(got, expected, "duration={duration:?} audio={generate_audio:?}");
}
}
#[test]
fn audio_on_costs_twice_as_much_as_off() {
for d in [Veo3TextToVideoDuration::FourSeconds, Veo3TextToVideoDuration::EightSeconds] {
let off = make_request(Some(d), Some(false)).calculate_cost_in_cents();
let on = make_request(Some(d), Some(true)).calculate_cost_in_cents();
assert_eq!(on, off * 2, "duration={d:?}");
}
}
/// Resolution (720p vs 1080p) must not change the bill for Veo 3.
#[test]
fn cost_is_independent_of_resolution() {
for audio in [Some(false), Some(true), None] {
let base = make_request(Some(Veo3TextToVideoDuration::EightSeconds), audio).calculate_cost_in_cents();
for res in [Veo3TextToVideoResolution::SevenTwentyP, Veo3TextToVideoResolution::TenEightyP] {
let cost = Veo3TextToVideoRequest {
resolution: Some(res),
..make_request(Some(Veo3TextToVideoDuration::EightSeconds), audio)
}.calculate_cost_in_cents();
assert_eq!(cost, base, "audio={audio:?} res={res:?}");
}
}
}
/// Neither aspect ratio, seed, auto_fix, nor safety tolerance affect the bill.
#[test]
fn cost_ignores_non_billing_fields() {
let baseline = make_request(Some(Veo3TextToVideoDuration::EightSeconds), Some(true)).calculate_cost_in_cents();
let embellished = Veo3TextToVideoRequest {
prompt: "different".to_string(),
aspect_ratio: Some(Veo3TextToVideoAspectRatio::NineBySixteen),
duration: Some(Veo3TextToVideoDuration::EightSeconds),
resolution: Some(Veo3TextToVideoResolution::TenEightyP),
negative_prompt: Some("noise".to_string()),
generate_audio: Some(true),
seed: Some(99),
auto_fix: Some(false),
safety_tolerance: Some(Veo3TextToVideoSafetyTolerance::Level1),
}.calculate_cost_in_cents();
assert_eq!(baseline, embellished);
}
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;

Some files were not shown because too many files have changed in this diff Show More