mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-23 18:19:30 -04:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1795e2b94 |
-12
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"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 maybe_ip_address = ?,\n maybe_url = ?,\n message = ?\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 7
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "32a8d1cfec89ba2056463a33ddc501a8a99283c7c00982a7fa79f7ff382c9b22"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nINSERT INTO voice_clone_requests\nSET\n token = ?,\n uuid_idempotency_token = ?,\n\n maybe_user_token = ?,\n email_address = ?,\n discord_username = ?,\n\n is_for_private_use = ?,\n is_for_public_use = ?,\n\n is_for_studio = ?,\n is_for_twitch_tts = ?,\n is_for_api_use = ?,\n is_for_music = ?,\n is_for_games = ?,\n is_for_other = ?,\n optional_notes_on_use = ?,\n\n is_own_voice = ?,\n is_third_party_voice = ?,\n optional_notes_on_subject = ?,\n\n has_clean_audio_recordings = ?,\n has_good_microphone = ?,\n\n optional_questions = ?,\n optional_extra_comments = ?,\n\n ip_address_creation = ?\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 22
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "4cea10944b8869419ecdfea30c58f49a87c090b9083078043177c33737ecb949"
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n COUNT(distinct token) as record_count,\n NOW() as present_time\nFROM\n(\n SELECT token\n FROM tts_inference_jobs\n WHERE status = \"started\"\n AND created_at > (CURDATE() - INTERVAL 5 MINUTE)\nUNION\n SELECT token\n FROM tts_inference_jobs\n WHERE status IN (\"pending\", \"attempt_failed\")\n) as t\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "record_count",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL | BINARY",
|
||||
"char_set": 63,
|
||||
"max_size": 21
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "present_time",
|
||||
"type_info": {
|
||||
"type": "Datetime",
|
||||
"flags": "NOT_NULL | BINARY",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "6098826a8ada75442be5b7ac1d0199694e01c5959666f59986cd8b2ed6f51882"
|
||||
}
|
||||
+256
@@ -0,0 +1,256 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n token,\n\n maybe_user_token,\n email_address,\n discord_username,\n\n is_for_private_use,\n is_for_public_use,\n\n is_for_studio,\n is_for_twitch_tts,\n is_for_api_use,\n is_for_music,\n is_for_games,\n is_for_other,\n optional_notes_on_use,\n\n is_own_voice,\n is_third_party_voice,\n optional_notes_on_subject,\n\n has_clean_audio_recordings,\n has_good_microphone,\n\n optional_questions,\n optional_extra_comments,\n\n created_at,\n updated_at\nFROM voice_clone_requests\nWHERE\n token = ?\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "token",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "maybe_user_token",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "MULTIPLE_KEY | BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "email_address",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 1020
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "discord_username",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 1020
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "is_for_private_use",
|
||||
"type_info": {
|
||||
"type": "Tiny",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "is_for_public_use",
|
||||
"type_info": {
|
||||
"type": "Tiny",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "is_for_studio",
|
||||
"type_info": {
|
||||
"type": "Tiny",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "is_for_twitch_tts",
|
||||
"type_info": {
|
||||
"type": "Tiny",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "is_for_api_use",
|
||||
"type_info": {
|
||||
"type": "Tiny",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "is_for_music",
|
||||
"type_info": {
|
||||
"type": "Tiny",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "is_for_games",
|
||||
"type_info": {
|
||||
"type": "Tiny",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "is_for_other",
|
||||
"type_info": {
|
||||
"type": "Tiny",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "optional_notes_on_use",
|
||||
"type_info": {
|
||||
"type": "Blob",
|
||||
"flags": "BLOB | BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 67108860
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 13,
|
||||
"name": "is_own_voice",
|
||||
"type_info": {
|
||||
"type": "Tiny",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 14,
|
||||
"name": "is_third_party_voice",
|
||||
"type_info": {
|
||||
"type": "Tiny",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 15,
|
||||
"name": "optional_notes_on_subject",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 1020
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 16,
|
||||
"name": "has_clean_audio_recordings",
|
||||
"type_info": {
|
||||
"type": "Tiny",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 17,
|
||||
"name": "has_good_microphone",
|
||||
"type_info": {
|
||||
"type": "Tiny",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 18,
|
||||
"name": "optional_questions",
|
||||
"type_info": {
|
||||
"type": "Blob",
|
||||
"flags": "BLOB | BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 67108860
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 19,
|
||||
"name": "optional_extra_comments",
|
||||
"type_info": {
|
||||
"type": "Blob",
|
||||
"flags": "BLOB | BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 67108860
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 20,
|
||||
"name": "created_at",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | BINARY | TIMESTAMP",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 21,
|
||||
"name": "updated_at",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | BINARY | TIMESTAMP | ON_UPDATE_NOW",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "922a7dbcfa24a2c5901d0453c229b066b3b4eea2c73fa533651bb27365f17be0"
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"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
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
-146
@@ -1,146 +0,0 @@
|
||||
{
|
||||
"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.maybe_ip_address,\n d.maybe_url,\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": "maybe_ip_address",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 160
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "maybe_url",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 1020
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "message",
|
||||
"type_info": {
|
||||
"type": "Blob",
|
||||
"flags": "NOT_NULL | BLOB | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 67108860
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "created_at",
|
||||
"type_info": {
|
||||
"type": "Datetime",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "maybe_user_display_name?",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 80
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "maybe_user_username?",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 80
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"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,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "d1ade7fcfd3efac1cd3163c392110327bb73edb74fa0d9693fee5feddb8a3223"
|
||||
}
|
||||
+256
@@ -0,0 +1,256 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n token,\n\n maybe_user_token,\n email_address,\n discord_username,\n\n is_for_private_use,\n is_for_public_use,\n\n is_for_studio,\n is_for_twitch_tts,\n is_for_api_use,\n is_for_music,\n is_for_games,\n is_for_other,\n optional_notes_on_use,\n\n is_own_voice,\n is_third_party_voice,\n optional_notes_on_subject,\n\n has_clean_audio_recordings,\n has_good_microphone,\n\n optional_questions,\n optional_extra_comments,\n\n created_at,\n updated_at\nFROM voice_clone_requests\nWHERE\n maybe_user_token = ?\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "token",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "maybe_user_token",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "MULTIPLE_KEY | BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "email_address",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 1020
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "discord_username",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 1020
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "is_for_private_use",
|
||||
"type_info": {
|
||||
"type": "Tiny",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "is_for_public_use",
|
||||
"type_info": {
|
||||
"type": "Tiny",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "is_for_studio",
|
||||
"type_info": {
|
||||
"type": "Tiny",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "is_for_twitch_tts",
|
||||
"type_info": {
|
||||
"type": "Tiny",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "is_for_api_use",
|
||||
"type_info": {
|
||||
"type": "Tiny",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "is_for_music",
|
||||
"type_info": {
|
||||
"type": "Tiny",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "is_for_games",
|
||||
"type_info": {
|
||||
"type": "Tiny",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "is_for_other",
|
||||
"type_info": {
|
||||
"type": "Tiny",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "optional_notes_on_use",
|
||||
"type_info": {
|
||||
"type": "Blob",
|
||||
"flags": "BLOB | BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 67108860
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 13,
|
||||
"name": "is_own_voice",
|
||||
"type_info": {
|
||||
"type": "Tiny",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 14,
|
||||
"name": "is_third_party_voice",
|
||||
"type_info": {
|
||||
"type": "Tiny",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 15,
|
||||
"name": "optional_notes_on_subject",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 1020
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 16,
|
||||
"name": "has_clean_audio_recordings",
|
||||
"type_info": {
|
||||
"type": "Tiny",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 17,
|
||||
"name": "has_good_microphone",
|
||||
"type_info": {
|
||||
"type": "Tiny",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 18,
|
||||
"name": "optional_questions",
|
||||
"type_info": {
|
||||
"type": "Blob",
|
||||
"flags": "BLOB | BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 67108860
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 19,
|
||||
"name": "optional_extra_comments",
|
||||
"type_info": {
|
||||
"type": "Blob",
|
||||
"flags": "BLOB | BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 67108860
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 20,
|
||||
"name": "created_at",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | BINARY | TIMESTAMP",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 21,
|
||||
"name": "updated_at",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | BINARY | TIMESTAMP | ON_UPDATE_NOW",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "e5ef2bda28221a570da6dcfa73e76cae9910a0b4ee53005a87176ecf1be73dd7"
|
||||
}
|
||||
-146
@@ -1,146 +0,0 @@
|
||||
{
|
||||
"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.maybe_ip_address,\n d.maybe_url,\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": "maybe_ip_address",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 160
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "maybe_url",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 1020
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "message",
|
||||
"type_info": {
|
||||
"type": "Blob",
|
||||
"flags": "NOT_NULL | BLOB | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 67108860
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "created_at",
|
||||
"type_info": {
|
||||
"type": "Datetime",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "maybe_user_display_name?",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 80
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "maybe_user_username?",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 80
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"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,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "e9a149ffc963a59019fdd69bd53128fad946856d432108bf9b923dfeaf2442c5"
|
||||
}
|
||||
Generated
-15
@@ -3621,7 +3621,6 @@ dependencies = [
|
||||
"env_logger 0.11.9",
|
||||
"log",
|
||||
"storyteller_root",
|
||||
"trace_id",
|
||||
"workspace-build-acceleration",
|
||||
]
|
||||
|
||||
@@ -6767,7 +6766,6 @@ version = "0.0.1"
|
||||
dependencies = [
|
||||
"env_logger 0.11.9",
|
||||
"log",
|
||||
"trace_id",
|
||||
"workspace-build-acceleration",
|
||||
]
|
||||
|
||||
@@ -8288,7 +8286,6 @@ dependencies = [
|
||||
"rootly_client",
|
||||
"sha2 0.11.0-rc.5",
|
||||
"tokio",
|
||||
"trace_id",
|
||||
"workspace-build-acceleration",
|
||||
]
|
||||
|
||||
@@ -11681,7 +11678,6 @@ dependencies = [
|
||||
"tokens",
|
||||
"tokio",
|
||||
"toml 1.0.7+spec-1.1.0",
|
||||
"trace_id",
|
||||
"tts_common",
|
||||
"url",
|
||||
"url_config",
|
||||
@@ -13320,17 +13316,6 @@ version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
||||
|
||||
[[package]]
|
||||
name = "trace_id"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"rand 0.8.5",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"tokio",
|
||||
"workspace-build-acceleration",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing"
|
||||
version = "0.1.41"
|
||||
|
||||
@@ -130,7 +130,6 @@ members = [
|
||||
"crates/schema/public/enums",
|
||||
"crates/schema/public/reusable_types", # TODO(bt, 2022-12-21): Deprecate
|
||||
"crates/schema/public/tokens",
|
||||
"crates/schema/trace_id",
|
||||
|
||||
# Services: API servers, jobs, workers, etc.
|
||||
"crates/service/job/gmicloud_job",
|
||||
@@ -294,7 +293,6 @@ subprocess_common = { path = "crates/lib/deprecated/subprocess_common" }
|
||||
test_data = { path = "crates/testing/test_data" }
|
||||
test_utils = { path = "crates/testing/test_utils" }
|
||||
tokens = { path = "crates/schema/public/tokens" }
|
||||
trace_id = { path = "crates/schema/trace_id" }
|
||||
url_utils = { path = "crates/lib/url_utils" }
|
||||
users = { path = "crates/lib/users" }
|
||||
uuid_utils = { path = "crates/lib/uuid_utils" }
|
||||
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
ALTER TABLE debug_logs
|
||||
DROP COLUMN maybe_log_level;
|
||||
@@ -1,6 +0,0 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
ALTER TABLE debug_logs
|
||||
ADD COLUMN maybe_log_level VARCHAR(16) DEFAULT NULL;
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
ALTER TABLE debug_logs
|
||||
DROP INDEX index_maybe_log_level;
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
ALTER TABLE debug_logs
|
||||
ADD INDEX index_maybe_log_level (maybe_log_level);
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
ALTER TABLE debug_logs
|
||||
DROP COLUMN maybe_ip_address;
|
||||
@@ -1,6 +0,0 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
ALTER TABLE debug_logs
|
||||
ADD COLUMN maybe_ip_address VARCHAR(40) DEFAULT NULL;
|
||||
@@ -1,6 +0,0 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
ALTER TABLE debug_logs
|
||||
DROP COLUMN maybe_url;
|
||||
@@ -1,6 +0,0 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
ALTER TABLE debug_logs
|
||||
ADD COLUMN maybe_url VARCHAR(255) DEFAULT NULL;
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
ALTER TABLE media_files
|
||||
DROP INDEX idx_creator_created_at,
|
||||
ALGORITHM=INPLACE, LOCK=NONE;
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
-- Composite index for the library / user profile list queries:
|
||||
-- WHERE maybe_creator_user_token = ? ... ORDER BY created_at DESC LIMIT n
|
||||
--
|
||||
-- Without this, the optimizer picks an index-merge intersection against huge
|
||||
-- low-selectivity indexes (e.g. index_user_deleted_at, ~4.75M NULL entries),
|
||||
-- scanning millions of index records per page load.
|
||||
--
|
||||
-- NB: ADD INDEX is online DDL (ALGORITHM=INPLACE, LOCK=NONE) — reads and writes
|
||||
-- continue during the build. Expect a few minutes of elevated I/O on a table
|
||||
-- this size (~8.5M rows).
|
||||
ALTER TABLE media_files
|
||||
ADD INDEX idx_creator_created_at (maybe_creator_user_token, created_at),
|
||||
ALGORITHM=INPLACE, LOCK=NONE;
|
||||
@@ -13,18 +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,
|
||||
|
||||
-- The client IP address of the request (if any). Wide enough for IPv6.
|
||||
maybe_ip_address VARCHAR(40) DEFAULT NULL,
|
||||
|
||||
-- The request URL (if any). Truncated to 255 characters on insert.
|
||||
maybe_url VARCHAR(255) DEFAULT NULL,
|
||||
|
||||
-- The log message body.
|
||||
message MEDIUMTEXT NOT NULL,
|
||||
|
||||
@@ -34,7 +25,6 @@ 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)
|
||||
|
||||
|
||||
@@ -364,7 +364,6 @@ CREATE TABLE media_files (
|
||||
KEY index_user_deleted_at (user_deleted_at),
|
||||
KEY index_mod_deleted_at (mod_deleted_at),
|
||||
KEY index_media_files_created_at (created_at),
|
||||
KEY index_media_files_updated_at (updated_at),
|
||||
KEY idx_creator_created_at (maybe_creator_user_token, created_at)
|
||||
KEY index_media_files_updated_at (updated_at)
|
||||
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
||||
|
||||
-36
@@ -1,36 +0,0 @@
|
||||
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>,
|
||||
/// The client IP address of the request (if recorded).
|
||||
pub maybe_ip_address: Option<String>,
|
||||
/// The request URL (if recorded; truncated to 255 chars).
|
||||
pub maybe_url: Option<String>,
|
||||
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,
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
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;
|
||||
-76
@@ -1,76 +0,0 @@
|
||||
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>,
|
||||
/// The client IP address of the request (if recorded).
|
||||
pub maybe_ip_address: Option<String>,
|
||||
/// The request URL (if recorded; truncated to 255 chars).
|
||||
pub maybe_url: Option<String>,
|
||||
pub message: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub maybe_user: Option<ModerationDebugLogUser>,
|
||||
}
|
||||
-30
@@ -1,30 +0,0 @@
|
||||
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
@@ -1,37 +0,0 @@
|
||||
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,5 +1,4 @@
|
||||
pub mod alerts;
|
||||
pub mod debug_logs;
|
||||
pub mod jobs;
|
||||
pub mod user;
|
||||
pub mod user_referrals;
|
||||
|
||||
+37
-51
@@ -138,7 +138,6 @@ 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 => {
|
||||
@@ -147,11 +146,13 @@ pub(crate) fn plan_batch_count(
|
||||
value: format!("{}", other),
|
||||
}))
|
||||
}
|
||||
// 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)
|
||||
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 })
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -378,10 +379,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_two_three_four_map_directly() {
|
||||
fn one_two_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,57 +394,43 @@ 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_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),
|
||||
fn three_errors_under_strict() {
|
||||
let builder = GenerateImageRequestBuilder {
|
||||
image_batch_count: Some(3),
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
|
||||
..base_builder()
|
||||
};
|
||||
assert!(matches!(
|
||||
build_kinovi_midjourney_7(strict),
|
||||
build_kinovi_midjourney_7(builder),
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { .. })),
|
||||
));
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
#[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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+5
-16
@@ -126,7 +126,6 @@ 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 => {
|
||||
@@ -135,11 +134,11 @@ pub(crate) fn plan_batch_count(
|
||||
value: format!("{}", other),
|
||||
}))
|
||||
}
|
||||
// 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)
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade => {
|
||||
Ok(if other < 3 { KinoviMidjourneyBatchCount::Two } else { KinoviMidjourneyBatchCount::Four })
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayLessDowngrade => {
|
||||
Ok(if other < 4 { KinoviMidjourneyBatchCount::Two } else { KinoviMidjourneyBatchCount::Four })
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -213,16 +212,6 @@ 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() };
|
||||
|
||||
+5
-16
@@ -126,7 +126,6 @@ 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 => {
|
||||
@@ -135,11 +134,11 @@ pub(crate) fn plan_batch_count(
|
||||
value: format!("{}", other),
|
||||
}))
|
||||
}
|
||||
// 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)
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade => {
|
||||
Ok(if other < 3 { KinoviMidjourneyBatchCount::Two } else { KinoviMidjourneyBatchCount::Four })
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayLessDowngrade => {
|
||||
Ok(if other < 4 { KinoviMidjourneyBatchCount::Two } else { KinoviMidjourneyBatchCount::Four })
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -202,16 +201,6 @@ 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() };
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
pub mod veo_3p1;
|
||||
pub mod veo_3p1_fast;
|
||||
@@ -1,288 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
pub mod api;
|
||||
pub mod cost;
|
||||
pub mod raw_request;
|
||||
@@ -1,59 +0,0 @@
|
||||
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,
|
||||
}
|
||||
@@ -1,288 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
pub mod api;
|
||||
pub mod cost;
|
||||
pub mod raw_request;
|
||||
-59
@@ -1,59 +0,0 @@
|
||||
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,11 +2,3 @@ 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;
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
pub mod api;
|
||||
pub mod cost;
|
||||
pub mod raw_request;
|
||||
@@ -1,31 +0,0 @@
|
||||
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,
|
||||
}
|
||||
@@ -1,276 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
pub mod api;
|
||||
pub mod cost;
|
||||
pub mod raw_request;
|
||||
@@ -1,57 +0,0 @@
|
||||
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,
|
||||
}
|
||||
@@ -1,279 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
pub mod api;
|
||||
pub mod cost;
|
||||
pub mod raw_request;
|
||||
@@ -1,57 +0,0 @@
|
||||
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,
|
||||
}
|
||||
@@ -1,285 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
pub mod api;
|
||||
pub mod cost;
|
||||
pub mod raw_request;
|
||||
@@ -1,58 +0,0 @@
|
||||
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,
|
||||
}
|
||||
@@ -1,286 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
pub mod api;
|
||||
pub mod cost;
|
||||
pub mod raw_request;
|
||||
-58
@@ -1,58 +0,0 @@
|
||||
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,
|
||||
}
|
||||
@@ -1,285 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
pub mod api;
|
||||
pub mod cost;
|
||||
pub mod raw_request;
|
||||
-58
@@ -1,58 +0,0 @@
|
||||
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,
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
pub mod api;
|
||||
pub mod cost;
|
||||
pub mod raw_request;
|
||||
@@ -1,47 +0,0 @@
|
||||
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,
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
pub mod api;
|
||||
pub mod cost;
|
||||
pub mod raw_request;
|
||||
-47
@@ -1,47 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Over-the-wire input shape for `fal-ai/vidu/q3/image-to-video/turbo`.
|
||||
/// fal's schema: <https://fal.ai/models/fal-ai/vidu/q3/image-to-video/turbo/api>
|
||||
///
|
||||
/// NB: this endpoint has no `aspect_ratio` (derived from the input image).
|
||||
/// A `prompt` is optional server-side but always sent here.
|
||||
#[derive(Debug, Serialize, Deserialize, Default)]
|
||||
pub struct ViduQ3TurboImageToVideoInput {
|
||||
/// Text prompt (max 2000 characters). Optional server-side.
|
||||
pub prompt: String,
|
||||
|
||||
/// URL of the image used as the starting frame (URL or base64).
|
||||
pub image_url: String,
|
||||
|
||||
/// Optional URL of an ending frame, for start→end transition videos.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub end_image_url: Option<String>,
|
||||
|
||||
/// Duration in seconds. Range 1–16. fal default: 5.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub duration: Option<u8>,
|
||||
|
||||
/// Seed for reproducibility. Random when omitted.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub seed: Option<i64>,
|
||||
|
||||
/// Output resolution.
|
||||
/// Possible values: "360p", "540p", "720p", "1080p". fal default: "720p".
|
||||
/// (360p is unavailable when `end_image_url` is set.)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub resolution: Option<String>,
|
||||
|
||||
/// Whether to generate audio for the video. fal default: true.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub audio: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ViduQ3TurboImageToVideoVideoFile {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ViduQ3TurboImageToVideoOutput {
|
||||
pub video: ViduQ3TurboImageToVideoVideoFile,
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
pub mod veo_3p1;
|
||||
pub mod veo_3p1_fast;
|
||||
pub mod veo_3p1_lite;
|
||||
@@ -1,286 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
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:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
pub mod api;
|
||||
pub mod cost;
|
||||
pub mod raw_request;
|
||||
@@ -1,61 +0,0 @@
|
||||
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,
|
||||
}
|
||||
@@ -1,286 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
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:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
pub mod api;
|
||||
pub mod cost;
|
||||
pub mod raw_request;
|
||||
-61
@@ -1,61 +0,0 @@
|
||||
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,
|
||||
}
|
||||
@@ -1,286 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
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:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
pub mod api;
|
||||
pub mod cost;
|
||||
pub mod raw_request;
|
||||
-61
@@ -1,61 +0,0 @@
|
||||
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,6 +1,3 @@
|
||||
pub mod elements;
|
||||
pub mod extend;
|
||||
pub mod image;
|
||||
pub mod images;
|
||||
pub mod reference;
|
||||
pub mod text;
|
||||
pub mod text;
|
||||
@@ -1,4 +0,0 @@
|
||||
pub mod veo_3p1;
|
||||
pub mod veo_3p1_fast;
|
||||
pub mod vidu_q3_reference_to_video;
|
||||
pub mod vidu_q3_reference_to_video_mix;
|
||||
@@ -1,275 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
pub mod api;
|
||||
pub mod cost;
|
||||
pub mod raw_request;
|
||||
@@ -1,53 +0,0 @@
|
||||
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,
|
||||
}
|
||||
@@ -1,275 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
pub mod api;
|
||||
pub mod cost;
|
||||
pub mod raw_request;
|
||||
-53
@@ -1,53 +0,0 @@
|
||||
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
@@ -1,232 +0,0 @@
|
||||
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
@@ -1,84 +0,0 @@
|
||||
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
@@ -1,3 +0,0 @@
|
||||
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