mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-20 17:02:53 -04:00
Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c65a66655a | ||
|
|
23cbb03b39 | ||
|
|
d3cb23753a | ||
|
|
47c0cd23f8 | ||
|
|
f398b3af99 | ||
|
|
8bd0e9219b | ||
|
|
298aff708e | ||
|
|
197ec95012 | ||
|
|
cbd3710f4f | ||
|
|
044c6d8a88 | ||
|
|
353c49c55f | ||
|
|
98ea5cdb62 | ||
|
|
8fdc5100af | ||
|
|
b782cb0c67 | ||
|
|
f01e81b66a | ||
|
|
3c0d1415e5 | ||
|
|
5e4b7f271d | ||
|
|
2371dfd49e | ||
|
|
2f20e7b3a3 |
+124
@@ -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"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
-80
@@ -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"
|
||||
}
|
||||
-12
@@ -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"
|
||||
}
|
||||
+124
@@ -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"
|
||||
}
|
||||
+6
@@ -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;
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
ALTER TABLE debug_logs
|
||||
DROP INDEX index_maybe_log_level;
|
||||
+6
@@ -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)
|
||||
|
||||
|
||||
+32
@@ -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;
|
||||
+72
@@ -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>,
|
||||
}
|
||||
+30
@@ -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>,
|
||||
}
|
||||
+37
@@ -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;
|
||||
|
||||
+51
-37
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+16
-5
@@ -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() };
|
||||
|
||||
+16
-5
@@ -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;
|
||||
+59
@@ -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;
|
||||
+58
@@ -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;
|
||||
+58
@@ -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;
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Over-the-wire input shape for `fal-ai/vidu/q3/image-to-video/turbo`.
|
||||
/// fal's schema: <https://fal.ai/models/fal-ai/vidu/q3/image-to-video/turbo/api>
|
||||
///
|
||||
/// NB: this endpoint has no `aspect_ratio` (derived from the input image).
|
||||
/// A `prompt` is optional server-side but always sent here.
|
||||
#[derive(Debug, Serialize, Deserialize, Default)]
|
||||
pub struct ViduQ3TurboImageToVideoInput {
|
||||
/// Text prompt (max 2000 characters). Optional server-side.
|
||||
pub prompt: String,
|
||||
|
||||
/// URL of the image used as the starting frame (URL or base64).
|
||||
pub image_url: String,
|
||||
|
||||
/// Optional URL of an ending frame, for start→end transition videos.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub end_image_url: Option<String>,
|
||||
|
||||
/// Duration in seconds. Range 1–16. fal default: 5.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub duration: Option<u8>,
|
||||
|
||||
/// Seed for reproducibility. Random when omitted.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub seed: Option<i64>,
|
||||
|
||||
/// Output resolution.
|
||||
/// Possible values: "360p", "540p", "720p", "1080p". fal default: "720p".
|
||||
/// (360p is unavailable when `end_image_url` is set.)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub resolution: Option<String>,
|
||||
|
||||
/// Whether to generate audio for the video. fal default: true.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub audio: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ViduQ3TurboImageToVideoVideoFile {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ViduQ3TurboImageToVideoOutput {
|
||||
pub video: ViduQ3TurboImageToVideoVideoFile,
|
||||
}
|
||||
@@ -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;
|
||||
+61
@@ -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;
|
||||
+61
@@ -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;
|
||||
+53
@@ -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,
|
||||
}
|
||||
+232
@@ -0,0 +1,232 @@
|
||||
use crate::error::fal_error_plus::FalErrorPlus;
|
||||
use crate::requests::api::video::reference::vidu_q3_reference_to_video::raw_request::{
|
||||
ViduQ3ReferenceToVideoInput, ViduQ3ReferenceToVideoOutput,
|
||||
};
|
||||
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ViduQ3ReferenceToVideoRequest {
|
||||
/// Text prompt (max 2000 characters).
|
||||
pub prompt: String,
|
||||
|
||||
/// Reference image URLs (1 to 4) used for subject consistency.
|
||||
pub reference_image_urls: Vec<String>,
|
||||
|
||||
/// Duration in seconds. Valid range 1–16; fal's default is `5` when `None`.
|
||||
pub duration: Option<u8>,
|
||||
|
||||
/// Seed for reproducibility. Random when `None`.
|
||||
pub seed: Option<i64>,
|
||||
|
||||
/// Aspect ratio. fal's default is `16:9` when `None`.
|
||||
pub aspect_ratio: Option<ViduQ3ReferenceToVideoAspectRatio>,
|
||||
|
||||
/// Output resolution. fal's default is `720p` when `None`.
|
||||
pub resolution: Option<ViduQ3ReferenceToVideoResolution>,
|
||||
|
||||
/// Whether to generate audio. fal's server default is `true` when `None`.
|
||||
pub audio: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
|
||||
pub enum ViduQ3ReferenceToVideoAspectRatio {
|
||||
SixteenByNine,
|
||||
NineBySixteen,
|
||||
FourByThree,
|
||||
ThreeByFour,
|
||||
Square,
|
||||
}
|
||||
|
||||
impl ViduQ3ReferenceToVideoAspectRatio {
|
||||
fn to_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::SixteenByNine => "16:9",
|
||||
Self::NineBySixteen => "9:16",
|
||||
Self::FourByThree => "4:3",
|
||||
Self::ThreeByFour => "3:4",
|
||||
Self::Square => "1:1",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Vidu Q3 resolutions. 720p/1080p bill at 2.2× the 360p/540p per-second rate.
|
||||
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
|
||||
pub enum ViduQ3ReferenceToVideoResolution {
|
||||
ThreeSixtyP,
|
||||
FiveFortyP,
|
||||
SevenTwentyP,
|
||||
TenEightyP,
|
||||
}
|
||||
|
||||
impl ViduQ3ReferenceToVideoResolution {
|
||||
/// 720p and 1080p are billed at the higher (2.2×) per-second rate.
|
||||
pub fn is_high_res(&self) -> bool {
|
||||
matches!(self, Self::SevenTwentyP | Self::TenEightyP)
|
||||
}
|
||||
|
||||
fn to_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::ThreeSixtyP => "360p",
|
||||
Self::FiveFortyP => "540p",
|
||||
Self::SevenTwentyP => "720p",
|
||||
Self::TenEightyP => "1080p",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FalEndpoint for ViduQ3ReferenceToVideoRequest {
|
||||
const ENDPOINT: &str = "fal-ai/vidu/q3/reference-to-video";
|
||||
|
||||
type RawRequest = ViduQ3ReferenceToVideoInput;
|
||||
type RawResponse = ViduQ3ReferenceToVideoOutput;
|
||||
|
||||
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
|
||||
Ok(Self::RawRequest {
|
||||
prompt: self.prompt.clone(),
|
||||
reference_image_urls: self.reference_image_urls.clone(),
|
||||
duration: self.duration,
|
||||
seed: self.seed,
|
||||
aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()),
|
||||
resolution: self.resolution.map(|r| r.to_str().to_string()),
|
||||
audio: self.audio,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::creds::fal_api_key::FalApiKey;
|
||||
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
|
||||
use errors::AnyhowResult;
|
||||
use std::fs::read_to_string;
|
||||
use test_data::web::image_urls::{JUNO_AT_LAKE_IMAGE_URL, TALL_MOCHI_WITH_GLASSES_IMAGE_URL};
|
||||
|
||||
// ── Real requests (manually run; require a live key and cost money) ──
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn test_reference_to_video_webhook() -> AnyhowResult<()> {
|
||||
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
|
||||
let api_key = FalApiKey::from_str(&secret);
|
||||
|
||||
let request = ViduQ3ReferenceToVideoRequest {
|
||||
prompt: "the two dogs run side by side across a sunlit meadow".to_string(),
|
||||
reference_image_urls: vec![
|
||||
TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(),
|
||||
JUNO_AT_LAKE_IMAGE_URL.to_string(),
|
||||
],
|
||||
duration: Some(5),
|
||||
seed: None,
|
||||
aspect_ratio: Some(ViduQ3ReferenceToVideoAspectRatio::SixteenByNine),
|
||||
resolution: Some(ViduQ3ReferenceToVideoResolution::SevenTwentyP),
|
||||
audio: Some(false),
|
||||
};
|
||||
|
||||
let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?;
|
||||
println!("Webhook result: {:?}", result);
|
||||
assert!(result.request_id.is_some() || result.gateway_request_id.is_some());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn test_reference_to_video_queue() -> AnyhowResult<()> {
|
||||
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
|
||||
let api_key = FalApiKey::from_str(&secret);
|
||||
|
||||
let request = ViduQ3ReferenceToVideoRequest {
|
||||
prompt: "the dogs play together in a snowy field".to_string(),
|
||||
reference_image_urls: vec![TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string()],
|
||||
duration: Some(5),
|
||||
seed: Some(1234),
|
||||
aspect_ratio: None,
|
||||
resolution: Some(ViduQ3ReferenceToVideoResolution::ThreeSixtyP),
|
||||
audio: Some(false),
|
||||
};
|
||||
|
||||
let result = request.send_queue_request(&api_key).await?;
|
||||
println!("Queue result — request_id: {}", result.request_id);
|
||||
assert!(!result.request_id.is_empty());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Wire-shape sanity (no API calls) ──
|
||||
|
||||
#[test]
|
||||
fn raw_request_maps_all_fields() {
|
||||
let request = ViduQ3ReferenceToVideoRequest {
|
||||
prompt: "p".to_string(),
|
||||
reference_image_urls: vec!["https://example.com/a.png".to_string(), "https://example.com/b.png".to_string()],
|
||||
duration: Some(12),
|
||||
seed: Some(42),
|
||||
aspect_ratio: Some(ViduQ3ReferenceToVideoAspectRatio::FourByThree),
|
||||
resolution: Some(ViduQ3ReferenceToVideoResolution::TenEightyP),
|
||||
audio: Some(false),
|
||||
};
|
||||
let raw = request.to_raw_request().unwrap();
|
||||
assert_eq!(raw.prompt, "p");
|
||||
assert_eq!(raw.reference_image_urls.len(), 2);
|
||||
assert_eq!(raw.duration, Some(12));
|
||||
assert_eq!(raw.seed, Some(42));
|
||||
assert_eq!(raw.aspect_ratio.as_deref(), Some("4:3"));
|
||||
assert_eq!(raw.resolution.as_deref(), Some("1080p"));
|
||||
assert_eq!(raw.audio, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_request_omits_unset_optionals() {
|
||||
let request = ViduQ3ReferenceToVideoRequest {
|
||||
prompt: "p".to_string(),
|
||||
reference_image_urls: vec!["https://example.com/a.png".to_string()],
|
||||
duration: None,
|
||||
seed: None,
|
||||
aspect_ratio: None,
|
||||
resolution: None,
|
||||
audio: None,
|
||||
};
|
||||
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
json,
|
||||
serde_json::json!({ "prompt": "p", "reference_image_urls": ["https://example.com/a.png"] }),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_serializes_when_set() {
|
||||
let request = ViduQ3ReferenceToVideoRequest {
|
||||
prompt: "p".to_string(),
|
||||
reference_image_urls: vec!["https://example.com/a.png".to_string()],
|
||||
duration: None,
|
||||
seed: Some(777),
|
||||
aspect_ratio: None,
|
||||
resolution: None,
|
||||
audio: None,
|
||||
};
|
||||
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
|
||||
assert_eq!(json.get("seed").and_then(|s| s.as_i64()), Some(777));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_aspect_ratio_maps_to_wire_string() {
|
||||
for (variant, expected) in [
|
||||
(ViduQ3ReferenceToVideoAspectRatio::SixteenByNine, "16:9"),
|
||||
(ViduQ3ReferenceToVideoAspectRatio::NineBySixteen, "9:16"),
|
||||
(ViduQ3ReferenceToVideoAspectRatio::FourByThree, "4:3"),
|
||||
(ViduQ3ReferenceToVideoAspectRatio::ThreeByFour, "3:4"),
|
||||
(ViduQ3ReferenceToVideoAspectRatio::Square, "1:1"),
|
||||
] {
|
||||
assert_eq!(variant.to_str(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_path_is_canonical() {
|
||||
assert_eq!(
|
||||
ViduQ3ReferenceToVideoRequest::ENDPOINT,
|
||||
"fal-ai/vidu/q3/reference-to-video",
|
||||
);
|
||||
}
|
||||
|
||||
// NB: Pricing tests are in cost.rs
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
use crate::requests::api::video::reference::vidu_q3_reference_to_video::api::{
|
||||
ViduQ3ReferenceToVideoRequest, ViduQ3ReferenceToVideoResolution,
|
||||
};
|
||||
use crate::requests::api::video::text::vidu_q3::cost::{vidu_q3_cost_cents, vidu_q3_rate_tenth_cents_per_sec};
|
||||
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
|
||||
|
||||
// Reference-to-video shares the Vidu Q3 per-second pricing (see the text module
|
||||
// for the canonical rate table):
|
||||
// 360p / 540p: $0.07/sec
|
||||
// 720p / 1080p: $0.154/sec (2.2×)
|
||||
// Pricing depends on resolution and duration only.
|
||||
|
||||
impl FalRequestCostCalculator for ViduQ3ReferenceToVideoRequest {
|
||||
fn calculate_cost_in_cents(&self) -> UsdCents {
|
||||
// fal defaults when unset: duration = 5s, resolution = 720p.
|
||||
let duration_secs = u64::from(self.duration.unwrap_or(5));
|
||||
let is_high_res = self.resolution
|
||||
.unwrap_or(ViduQ3ReferenceToVideoResolution::SevenTwentyP)
|
||||
.is_high_res();
|
||||
|
||||
let rate = vidu_q3_rate_tenth_cents_per_sec(is_high_res);
|
||||
vidu_q3_cost_cents(rate, duration_secs)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_request(
|
||||
duration: Option<u8>,
|
||||
resolution: Option<ViduQ3ReferenceToVideoResolution>,
|
||||
) -> ViduQ3ReferenceToVideoRequest {
|
||||
ViduQ3ReferenceToVideoRequest {
|
||||
prompt: "test".to_string(),
|
||||
reference_image_urls: vec!["https://example.com/a.png".to_string()],
|
||||
duration,
|
||||
seed: None,
|
||||
aspect_ratio: None,
|
||||
resolution,
|
||||
audio: Some(true),
|
||||
}
|
||||
}
|
||||
|
||||
mod cost_table {
|
||||
use super::*;
|
||||
|
||||
// (duration, resolution, expected_cents)
|
||||
const COST_TABLE: &[(Option<u8>, Option<ViduQ3ReferenceToVideoResolution>, u64)] = &[
|
||||
(Some(5), Some(ViduQ3ReferenceToVideoResolution::ThreeSixtyP), 35),
|
||||
(Some(5), Some(ViduQ3ReferenceToVideoResolution::FiveFortyP), 35),
|
||||
(Some(5), Some(ViduQ3ReferenceToVideoResolution::SevenTwentyP), 77),
|
||||
(Some(5), Some(ViduQ3ReferenceToVideoResolution::TenEightyP), 77),
|
||||
(Some(10), Some(ViduQ3ReferenceToVideoResolution::SevenTwentyP), 154),
|
||||
(Some(3), Some(ViduQ3ReferenceToVideoResolution::SevenTwentyP), 47), // 46.2 → 47
|
||||
// Defaults: duration=None→5s, resolution=None→720p
|
||||
(None, None, 77),
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn matches_cost_table() {
|
||||
for &(duration, resolution, expected) in COST_TABLE {
|
||||
let got = make_request(duration, resolution).calculate_cost_in_cents();
|
||||
assert_eq!(got, expected, "duration={duration:?} resolution={resolution:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The number of reference images does not affect the bill.
|
||||
#[test]
|
||||
fn cost_is_independent_of_image_count() {
|
||||
let one = make_request(Some(8), Some(ViduQ3ReferenceToVideoResolution::SevenTwentyP)).calculate_cost_in_cents();
|
||||
let four = ViduQ3ReferenceToVideoRequest {
|
||||
reference_image_urls: vec![
|
||||
"https://example.com/a.png".to_string(),
|
||||
"https://example.com/b.png".to_string(),
|
||||
"https://example.com/c.png".to_string(),
|
||||
"https://example.com/d.png".to_string(),
|
||||
],
|
||||
..make_request(Some(8), Some(ViduQ3ReferenceToVideoResolution::SevenTwentyP))
|
||||
}.calculate_cost_in_cents();
|
||||
assert_eq!(one, four);
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod api;
|
||||
pub mod cost;
|
||||
pub mod raw_request;
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Over-the-wire input shape for `fal-ai/vidu/q3/reference-to-video`.
|
||||
/// fal's schema: <https://fal.ai/models/fal-ai/vidu/q3/reference-to-video/api>
|
||||
#[derive(Debug, Serialize, Deserialize, Default)]
|
||||
pub struct ViduQ3ReferenceToVideoInput {
|
||||
/// Text prompt (max 2000 characters).
|
||||
pub prompt: String,
|
||||
|
||||
/// URLs of the reference images (1 to 4) used for subject consistency.
|
||||
pub reference_image_urls: Vec<String>,
|
||||
|
||||
/// Duration in seconds. Range 1–16. fal default: 5.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub duration: Option<u8>,
|
||||
|
||||
/// Seed for reproducibility. Random when omitted.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub seed: Option<i64>,
|
||||
|
||||
/// Aspect ratio.
|
||||
/// Possible values: "16:9", "9:16", "4:3", "3:4", "1:1". fal default: "16:9".
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub aspect_ratio: Option<String>,
|
||||
|
||||
/// Output resolution.
|
||||
/// Possible values: "360p", "540p", "720p", "1080p". fal default: "720p".
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub resolution: Option<String>,
|
||||
|
||||
/// Whether to generate audio for the video. fal default: true.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub audio: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ViduQ3ReferenceToVideoVideoFile {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ViduQ3ReferenceToVideoOutput {
|
||||
pub video: ViduQ3ReferenceToVideoVideoFile,
|
||||
}
|
||||
+219
@@ -0,0 +1,219 @@
|
||||
use crate::error::fal_error_plus::FalErrorPlus;
|
||||
use crate::requests::api::video::reference::vidu_q3_reference_to_video_mix::raw_request::{
|
||||
ViduQ3ReferenceToVideoMixInput, ViduQ3ReferenceToVideoMixOutput,
|
||||
};
|
||||
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ViduQ3ReferenceToVideoMixRequest {
|
||||
/// Text prompt (max 2000 characters).
|
||||
pub prompt: String,
|
||||
|
||||
/// Reference image URLs (1 to 4) used for subject consistency.
|
||||
pub reference_image_urls: Vec<String>,
|
||||
|
||||
/// Duration in seconds. Valid range 1–16; fal's default is `5` when `None`.
|
||||
pub duration: Option<u8>,
|
||||
|
||||
/// Seed for reproducibility. Random when `None`.
|
||||
pub seed: Option<i64>,
|
||||
|
||||
/// Aspect ratio. fal's default is `16:9` when `None`.
|
||||
pub aspect_ratio: Option<ViduQ3ReferenceToVideoMixAspectRatio>,
|
||||
|
||||
/// Output resolution. fal's default is `720p` when `None`.
|
||||
pub resolution: Option<ViduQ3ReferenceToVideoMixResolution>,
|
||||
|
||||
/// Whether to generate audio. fal's server default is `true` when `None`.
|
||||
pub audio: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
|
||||
pub enum ViduQ3ReferenceToVideoMixAspectRatio {
|
||||
SixteenByNine,
|
||||
NineBySixteen,
|
||||
FourByThree,
|
||||
ThreeByFour,
|
||||
Square,
|
||||
}
|
||||
|
||||
impl ViduQ3ReferenceToVideoMixAspectRatio {
|
||||
fn to_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::SixteenByNine => "16:9",
|
||||
Self::NineBySixteen => "9:16",
|
||||
Self::FourByThree => "4:3",
|
||||
Self::ThreeByFour => "3:4",
|
||||
Self::Square => "1:1",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Vidu Q3 resolutions. 720p/1080p bill at 2.2× the 360p/540p per-second rate.
|
||||
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
|
||||
pub enum ViduQ3ReferenceToVideoMixResolution {
|
||||
ThreeSixtyP,
|
||||
FiveFortyP,
|
||||
SevenTwentyP,
|
||||
TenEightyP,
|
||||
}
|
||||
|
||||
impl ViduQ3ReferenceToVideoMixResolution {
|
||||
/// 720p and 1080p are billed at the higher (2.2×) per-second rate.
|
||||
pub fn is_high_res(&self) -> bool {
|
||||
matches!(self, Self::SevenTwentyP | Self::TenEightyP)
|
||||
}
|
||||
|
||||
fn to_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::ThreeSixtyP => "360p",
|
||||
Self::FiveFortyP => "540p",
|
||||
Self::SevenTwentyP => "720p",
|
||||
Self::TenEightyP => "1080p",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FalEndpoint for ViduQ3ReferenceToVideoMixRequest {
|
||||
const ENDPOINT: &str = "fal-ai/vidu/q3/reference-to-video/mix";
|
||||
|
||||
type RawRequest = ViduQ3ReferenceToVideoMixInput;
|
||||
type RawResponse = ViduQ3ReferenceToVideoMixOutput;
|
||||
|
||||
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
|
||||
Ok(Self::RawRequest {
|
||||
prompt: self.prompt.clone(),
|
||||
reference_image_urls: self.reference_image_urls.clone(),
|
||||
duration: self.duration,
|
||||
seed: self.seed,
|
||||
aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()),
|
||||
resolution: self.resolution.map(|r| r.to_str().to_string()),
|
||||
audio: self.audio,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::creds::fal_api_key::FalApiKey;
|
||||
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
|
||||
use errors::AnyhowResult;
|
||||
use std::fs::read_to_string;
|
||||
use test_data::web::image_urls::{JUNO_AT_LAKE_IMAGE_URL, TALL_MOCHI_WITH_GLASSES_IMAGE_URL};
|
||||
|
||||
// ── Real requests (manually run; require a live key and cost money) ──
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn test_reference_to_video_mix_webhook() -> AnyhowResult<()> {
|
||||
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
|
||||
let api_key = FalApiKey::from_str(&secret);
|
||||
|
||||
let request = ViduQ3ReferenceToVideoMixRequest {
|
||||
prompt: "the two dogs run side by side across a sunlit meadow".to_string(),
|
||||
reference_image_urls: vec![
|
||||
TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(),
|
||||
JUNO_AT_LAKE_IMAGE_URL.to_string(),
|
||||
],
|
||||
duration: Some(5),
|
||||
seed: None,
|
||||
aspect_ratio: Some(ViduQ3ReferenceToVideoMixAspectRatio::SixteenByNine),
|
||||
resolution: Some(ViduQ3ReferenceToVideoMixResolution::SevenTwentyP),
|
||||
audio: Some(false),
|
||||
};
|
||||
|
||||
let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?;
|
||||
println!("Webhook result: {:?}", result);
|
||||
assert!(result.request_id.is_some() || result.gateway_request_id.is_some());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn test_reference_to_video_mix_queue() -> AnyhowResult<()> {
|
||||
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
|
||||
let api_key = FalApiKey::from_str(&secret);
|
||||
|
||||
let request = ViduQ3ReferenceToVideoMixRequest {
|
||||
prompt: "the dogs play together in a snowy field".to_string(),
|
||||
reference_image_urls: vec![TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string()],
|
||||
duration: Some(5),
|
||||
seed: Some(1234),
|
||||
aspect_ratio: None,
|
||||
resolution: Some(ViduQ3ReferenceToVideoMixResolution::ThreeSixtyP),
|
||||
audio: Some(false),
|
||||
};
|
||||
|
||||
let result = request.send_queue_request(&api_key).await?;
|
||||
println!("Queue result — request_id: {}", result.request_id);
|
||||
assert!(!result.request_id.is_empty());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Wire-shape sanity (no API calls) ──
|
||||
|
||||
#[test]
|
||||
fn raw_request_maps_all_fields() {
|
||||
let request = ViduQ3ReferenceToVideoMixRequest {
|
||||
prompt: "p".to_string(),
|
||||
reference_image_urls: vec!["https://example.com/a.png".to_string(), "https://example.com/b.png".to_string()],
|
||||
duration: Some(12),
|
||||
seed: Some(42),
|
||||
aspect_ratio: Some(ViduQ3ReferenceToVideoMixAspectRatio::FourByThree),
|
||||
resolution: Some(ViduQ3ReferenceToVideoMixResolution::TenEightyP),
|
||||
audio: Some(false),
|
||||
};
|
||||
let raw = request.to_raw_request().unwrap();
|
||||
assert_eq!(raw.prompt, "p");
|
||||
assert_eq!(raw.reference_image_urls.len(), 2);
|
||||
assert_eq!(raw.duration, Some(12));
|
||||
assert_eq!(raw.seed, Some(42));
|
||||
assert_eq!(raw.aspect_ratio.as_deref(), Some("4:3"));
|
||||
assert_eq!(raw.resolution.as_deref(), Some("1080p"));
|
||||
assert_eq!(raw.audio, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_request_omits_unset_optionals() {
|
||||
let request = ViduQ3ReferenceToVideoMixRequest {
|
||||
prompt: "p".to_string(),
|
||||
reference_image_urls: vec!["https://example.com/a.png".to_string()],
|
||||
duration: None,
|
||||
seed: None,
|
||||
aspect_ratio: None,
|
||||
resolution: None,
|
||||
audio: None,
|
||||
};
|
||||
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
json,
|
||||
serde_json::json!({ "prompt": "p", "reference_image_urls": ["https://example.com/a.png"] }),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_serializes_when_set() {
|
||||
let request = ViduQ3ReferenceToVideoMixRequest {
|
||||
prompt: "p".to_string(),
|
||||
reference_image_urls: vec!["https://example.com/a.png".to_string()],
|
||||
duration: None,
|
||||
seed: Some(777),
|
||||
aspect_ratio: None,
|
||||
resolution: None,
|
||||
audio: None,
|
||||
};
|
||||
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
|
||||
assert_eq!(json.get("seed").and_then(|s| s.as_i64()), Some(777));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_path_is_canonical() {
|
||||
assert_eq!(
|
||||
ViduQ3ReferenceToVideoMixRequest::ENDPOINT,
|
||||
"fal-ai/vidu/q3/reference-to-video/mix",
|
||||
);
|
||||
}
|
||||
|
||||
// NB: Pricing tests are in cost.rs
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
use crate::requests::api::video::reference::vidu_q3_reference_to_video_mix::api::{
|
||||
ViduQ3ReferenceToVideoMixRequest, ViduQ3ReferenceToVideoMixResolution,
|
||||
};
|
||||
use crate::requests::api::video::text::vidu_q3::cost::{vidu_q3_cost_cents, vidu_q3_rate_tenth_cents_per_sec};
|
||||
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
|
||||
|
||||
// The "mix" variant shares the Vidu Q3 per-second pricing (see the text module
|
||||
// for the canonical rate table):
|
||||
// 360p / 540p: $0.07/sec
|
||||
// 720p / 1080p: $0.154/sec (2.2×)
|
||||
// Pricing depends on resolution and duration only.
|
||||
|
||||
impl FalRequestCostCalculator for ViduQ3ReferenceToVideoMixRequest {
|
||||
fn calculate_cost_in_cents(&self) -> UsdCents {
|
||||
// fal defaults when unset: duration = 5s, resolution = 720p.
|
||||
let duration_secs = u64::from(self.duration.unwrap_or(5));
|
||||
let is_high_res = self.resolution
|
||||
.unwrap_or(ViduQ3ReferenceToVideoMixResolution::SevenTwentyP)
|
||||
.is_high_res();
|
||||
|
||||
let rate = vidu_q3_rate_tenth_cents_per_sec(is_high_res);
|
||||
vidu_q3_cost_cents(rate, duration_secs)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_request(
|
||||
duration: Option<u8>,
|
||||
resolution: Option<ViduQ3ReferenceToVideoMixResolution>,
|
||||
) -> ViduQ3ReferenceToVideoMixRequest {
|
||||
ViduQ3ReferenceToVideoMixRequest {
|
||||
prompt: "test".to_string(),
|
||||
reference_image_urls: vec!["https://example.com/a.png".to_string()],
|
||||
duration,
|
||||
seed: None,
|
||||
aspect_ratio: None,
|
||||
resolution,
|
||||
audio: Some(true),
|
||||
}
|
||||
}
|
||||
|
||||
mod cost_table {
|
||||
use super::*;
|
||||
|
||||
// (duration, resolution, expected_cents) — identical to base Vidu Q3.
|
||||
const COST_TABLE: &[(Option<u8>, Option<ViduQ3ReferenceToVideoMixResolution>, u64)] = &[
|
||||
(Some(5), Some(ViduQ3ReferenceToVideoMixResolution::ThreeSixtyP), 35),
|
||||
(Some(5), Some(ViduQ3ReferenceToVideoMixResolution::FiveFortyP), 35),
|
||||
(Some(5), Some(ViduQ3ReferenceToVideoMixResolution::SevenTwentyP), 77),
|
||||
(Some(5), Some(ViduQ3ReferenceToVideoMixResolution::TenEightyP), 77),
|
||||
(Some(10), Some(ViduQ3ReferenceToVideoMixResolution::SevenTwentyP), 154),
|
||||
(Some(3), Some(ViduQ3ReferenceToVideoMixResolution::SevenTwentyP), 47), // 46.2 → 47
|
||||
// Defaults: duration=None→5s, resolution=None→720p
|
||||
(None, None, 77),
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn matches_cost_table() {
|
||||
for &(duration, resolution, expected) in COST_TABLE {
|
||||
let got = make_request(duration, resolution).calculate_cost_in_cents();
|
||||
assert_eq!(got, expected, "duration={duration:?} resolution={resolution:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The number of reference images does not affect the bill.
|
||||
#[test]
|
||||
fn cost_is_independent_of_image_count() {
|
||||
let one = make_request(Some(8), Some(ViduQ3ReferenceToVideoMixResolution::SevenTwentyP)).calculate_cost_in_cents();
|
||||
let four = ViduQ3ReferenceToVideoMixRequest {
|
||||
reference_image_urls: vec![
|
||||
"https://example.com/a.png".to_string(),
|
||||
"https://example.com/b.png".to_string(),
|
||||
"https://example.com/c.png".to_string(),
|
||||
"https://example.com/d.png".to_string(),
|
||||
],
|
||||
..make_request(Some(8), Some(ViduQ3ReferenceToVideoMixResolution::SevenTwentyP))
|
||||
}.calculate_cost_in_cents();
|
||||
assert_eq!(one, four);
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod api;
|
||||
pub mod cost;
|
||||
pub mod raw_request;
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Over-the-wire input shape for `fal-ai/vidu/q3/reference-to-video/mix`.
|
||||
/// fal's schema: <https://fal.ai/models/fal-ai/vidu/q3/reference-to-video/mix/api>
|
||||
///
|
||||
/// The "mix" variant shares the base reference-to-video input schema; it
|
||||
/// differs only in the server-side generation behavior.
|
||||
#[derive(Debug, Serialize, Deserialize, Default)]
|
||||
pub struct ViduQ3ReferenceToVideoMixInput {
|
||||
/// Text prompt (max 2000 characters).
|
||||
pub prompt: String,
|
||||
|
||||
/// URLs of the reference images (1 to 4) used for subject consistency.
|
||||
pub reference_image_urls: Vec<String>,
|
||||
|
||||
/// Duration in seconds. Range 1–16. fal default: 5.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub duration: Option<u8>,
|
||||
|
||||
/// Seed for reproducibility. Random when omitted.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub seed: Option<i64>,
|
||||
|
||||
/// Aspect ratio.
|
||||
/// Possible values: "16:9", "9:16", "4:3", "3:4", "1:1". fal default: "16:9".
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub aspect_ratio: Option<String>,
|
||||
|
||||
/// Output resolution.
|
||||
/// Possible values: "360p", "540p", "720p", "1080p". fal default: "720p".
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub resolution: Option<String>,
|
||||
|
||||
/// Whether to generate audio for the video. fal default: true.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub audio: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ViduQ3ReferenceToVideoMixVideoFile {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ViduQ3ReferenceToVideoMixOutput {
|
||||
pub video: ViduQ3ReferenceToVideoMixVideoFile,
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user