Compare commits

...
Author SHA1 Message Date
Brandon Thomas 27e911d46b don't serve bad traffic 2026-07-06 16:51:42 -04:00
Brandon Thomas 977cd933f7 another bad path 2026-07-06 16:49:30 -04:00
Brandon Thomas 89f20d9452 black hole these requests 2026-07-06 16:31:01 -04:00
Brandon Thomas cec3b50ae4 characters API support (#1738) 2026-07-06 16:12:00 -04:00
BFlat 3040aaa0d1 Default og:image is now the ArtCraft logo banner (#1737) 2026-07-06 19:56:53 +07:00
BFlat f47e8b9b14 Default og:image is now the ArtCraft logo banner (#1736) 2026-07-06 19:52:50 +07:00
BFlat 83115e7ef9 Prompt character counters on web, seedance CJK limit waiver, and model creator mapping (#1735)
## Changes

- **Prompt character counters on web**: the website and webapp
promptboxes (image + video) now show a `123 / 2000` counter driven by
the API's `text_prompt_max_length`, matching the desktop app. It turns
red when over the limit and generation is blocked with a toast; typing
is never clamped. The webapp's fullscreen prompt editor shows the
counter too.
- **Seedance CJK waiver**: seedance video models normally use the API
limit, but the limit is waived (shows `∞`) while the prompt contains
Chinese characters. Applies on desktop and web via a shared
`effectivePromptMaxLength` helper.
- **API type parity**: added the new `model_creator` field to the web
`OmniGenImageModelInfo` / `OmniGenVideoModelInfo` types (desktop types
were already up to date).
- **Cleanup**: deleted the manual model-to-creator map
(`MODEL_TYPE_TO_CREATOR`, ~150 lines) now that creator info comes from
the API; lightbox model icons use the shared
`getCreatorIconPathForModelId` lookup like every other surface.
2026-07-06 19:42:56 +07:00
Brandon Thomas 4fbe9f54e0 more cleanup (#1734) 2026-07-04 11:53:00 -04:00
Brandon Thomas e850022fe8 further cleanup (#1733) 2026-07-04 11:43:49 -04:00
Brandon Thomas 1a528bba45 remove deprecated endpoints (#1732) 2026-07-04 10:15:59 -04:00
Brandon Thomas b625d5ff79 http header fields on alerts (#1731) 2026-07-03 20:59:52 -04:00
Brandon Thomas d8fd0bfe64 fix request logging (#1730) 2026-07-03 16:07:00 -04:00
Brandon Thomas 3cc02ae7bf admin dashboard spend (#1729) 2026-07-03 04:52:28 -04:00
Brandon Thomas 2c943b61ab improve media files user list query performance (#1728) 2026-07-03 04:10:13 -04:00
Brandon Thomas d92e6fc75c add index to media_files to aid lookups (#1727) 2026-07-03 03:55:53 -04:00
Brandon Thomas c0ca274168 cleanup dead handlers (#1726) 2026-07-03 03:08:59 -04:00
Brandon Thomas 27cc125089 trace id logging (#1725) 2026-07-03 02:28:22 -04:00
Brandon Thomas 4ce3f240c7 debug logs (#1724) 2026-07-02 22:09:38 -04:00
Brandon Thomas 3a2e3b30ec debug logs #2 (#1723) 2026-07-02 19:36:48 -04:00
Brandon Thomas 82c0a7a4d9 debug logs: new field, new queries, new APIs, new dashbord UX (#1722) 2026-07-02 16:43:00 -04:00
Brandon Thomas cbd3710f4f seedance text prompt max length (#1721) 2026-07-02 11:26:02 -04:00
BFlat 044c6d8a88 Model list cleanup: shared omni-gen hooks, dead code removal (#1719)
### New shared lib: `@storyteller/omni-gen`
- The `omni-gen-hooks.ts` file was copy-pasted between `artcraft-webapp`
and
`artcraft-website` and had drifted (website copy was missing outage
toasts,
carried a stale hardcoded display-name map, and had weaker icon
resolution).
- Extracted the newer (webapp) version into a new lib
`frontend/libs/omni-gen`
  (generated via `nx g @nx/react:library`): singleton session cache,
`useOmniGenImageModels()` / `useOmniGenVideoModels()`, outage message
constants.
- App-local toasts are injected via `setOmniGenErrorNotifier()`, called
once in
each app's `main.tsx`, since the hooks are singleton-cache based and
each app
  has its own toast component.

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

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

### Website-visible improvements (from adopting the newer hooks)
- Model outage toasts now appear on the website (previously
webapp-only).
- Current display names from the canonical map (previously-unmapped ids
like
  `gpt_image_2` and Midjourney models rendered as raw ids).
- Model taglines/descriptions now render in the website model picker.
- Midjourney and SwitchX creator icons now resolve.
2026-07-02 15:06:15 +07:00
Brandon Thomas 353c49c55f bump artcraft to 0.36 (#1718) 2026-07-02 03:45:07 -04:00
Brandon Thomas 98ea5cdb62 shink assets that are way too large (#1717) 2026-07-02 03:42:41 -04:00
Brandon Thomas 8fdc5100af vidu (#1716) 2026-07-02 03:12:15 -04:00
Brandon Thomas b782cb0c67 veo (#1715) 2026-07-02 02:18:16 -04:00
Brandon Thomas f01e81b66a artcraft 0.35 (#1714) 2026-07-02 02:02:16 -04:00
Brandon Thomas 3c0d1415e5 update pathing 2026-07-02 01:30:07 -04:00
Brandon Thomas 5e4b7f271d artcraft 0.34 (#1713) 2026-07-02 01:16:31 -04:00
Brandon Thomas 2371dfd49e move assets out of build to decrease binary size (#1712) 2026-07-02 01:15:30 -04:00
Brandon Thomas 2f20e7b3a3 fix midjourney (#1711) 2026-07-02 00:06:27 -04:00
656 changed files with 17118 additions and 14810 deletions
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO debug_logs\nSET\n event_token = ?,\n debug_log_type = ?,\n maybe_log_level = ?,\n maybe_creator_user_token = ?,\n maybe_ip_address = ?,\n maybe_url = ?,\n message = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 7
},
"nullable": []
},
"hash": "32a8d1cfec89ba2056463a33ddc501a8a99283c7c00982a7fa79f7ff382c9b22"
}
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\nUPDATE tts_results\nSET\n mod_deleted_at = CURRENT_TIMESTAMP,\n maybe_mod_user_token = ?\nWHERE\n token = ?\nLIMIT 1\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "376e36b1357952599866262c7356ba35f15ccdfb338d8ccdbdc06ccd69331fc3"
}
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\nUPDATE tts_results\nSET\n mod_deleted_at = NULL,\n maybe_mod_user_token = ?\nWHERE\n token = ?\nLIMIT 1\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "40a3301e9a3201e74d49b6b339f67291e2ae22d315188401708609ac2d28a68c"
}
@@ -1,12 +0,0 @@
{
"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"
}
@@ -1,36 +0,0 @@
{
"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"
}
@@ -1,47 +0,0 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n job_type as queue_type,\n count(*) as pending_job_count,\n NOW() as present_time\n FROM (\n SELECT\n token,\n job_type\n FROM generic_inference_jobs\n WHERE status IN (\"pending\", \"attempt_failed\")\n AND job_type IS NOT NULL\n UNION\n SELECT\n token,\n job_type\n FROM generic_inference_jobs\n WHERE status IN (\"started\")\n AND created_at > (CURDATE() - INTERVAL 15 MINUTE)\n AND job_type IS NOT NULL\n) as generic_inner\nGROUP BY queue_type\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "queue_type",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "pending_job_count",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | BINARY",
"char_set": 63,
"max_size": 21
}
},
{
"ordinal": 2,
"name": "present_time",
"type_info": {
"type": "Datetime",
"flags": "NOT_NULL | BINARY",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 0
},
"nullable": [
true,
false,
false
]
},
"hash": "73dc054488b2fa3efc923d3a90914a1450b73e99a377562484a0498ccce1676f"
}
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO tts_model_upload_jobs\nSET\n token = ?,\n uuid_idempotency_token = ?,\n creator_user_token = ?,\n creator_ip_address = ?,\n creator_set_visibility = ?,\n title = ?,\n tts_model_type = ?,\n download_url = ?,\n status = \"pending\"\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 8
},
"nullable": []
},
"hash": "886861e91c7e8af53eb856c949389a172b8d9e079f1c2070d115423907c47c50"
}
@@ -0,0 +1,102 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n users.token as `user_token: UserToken`,\n users.username,\n users.display_name,\n users.email_address,\n users.user_role_slug,\n users.is_banned,\n user_roles.can_ban_users,\n api_keys.token as `api_key_token: ApiKeyToken`\nFROM api_keys\nINNER JOIN users\n ON users.token = api_keys.owner_user_token\nLEFT OUTER JOIN user_roles\n ON users.user_role_slug = user_roles.slug\nWHERE api_keys.api_key = ?\n AND api_keys.maybe_deleted_at IS NULL\n AND users.user_deleted_at IS NULL\n AND users.mod_deleted_at IS NULL\nLIMIT 1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "user_token: UserToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "username",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 2,
"name": "display_name",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 3,
"name": "email_address",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 4,
"name": "user_role_slug",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 5,
"name": "is_banned",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 6,
"name": "can_ban_users",
"type_info": {
"type": "Tiny",
"flags": "",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 7,
"name": "api_key_token: ApiKeyToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false,
false,
false,
true,
false
]
},
"hash": "9188e235caa439d50628333c2f4722b9f1a8f575c192d4d36a2039709c412ac0"
}
@@ -1,256 +0,0 @@
{
"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"
}
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\nUPDATE tts_results\nSET\n user_deleted_at = CURRENT_TIMESTAMP\nWHERE\n token = ?\nLIMIT 1\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "961f792c24bee6009e3e8a215561db932871a53c48839b8cb80292de00e7b2e1"
}
@@ -1,135 +0,0 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n jobs.token as job_token,\n\n jobs.status,\n jobs.attempt_count,\n jobs.on_success_result_token as maybe_result_token,\n results.public_bucket_wav_audio_path as maybe_public_bucket_wav_audio_path,\n\n jobs.model_token,\n tts.tts_model_type,\n tts.title,\n\n jobs.raw_inference_text,\n\n jobs.created_at,\n jobs.updated_at\n\nFROM tts_inference_jobs as jobs\nJOIN tts_models as tts\n ON tts.token = jobs.model_token\nLEFT OUTER JOIN tts_results as results\n ON jobs.on_success_result_token = results.token\n\nWHERE jobs.token = ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "job_token",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "status",
"type_info": {
"type": "String",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | ENUM",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 2,
"name": "attempt_count",
"type_info": {
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 3
}
},
{
"ordinal": 3,
"name": "maybe_result_token",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 4,
"name": "maybe_public_bucket_wav_audio_path",
"type_info": {
"type": "VarString",
"flags": "BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 5,
"name": "model_token",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 6,
"name": "tts_model_type",
"type_info": {
"type": "String",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | ENUM",
"char_set": 224,
"max_size": 60
}
},
{
"ordinal": 7,
"name": "title",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 8,
"name": "raw_inference_text",
"type_info": {
"type": "Blob",
"flags": "NOT_NULL | BLOB | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 262140
}
},
{
"ordinal": 9,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 10,
"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,
false,
false,
true,
true,
false,
false,
false,
false,
false,
false
]
},
"hash": "a2b586dd2120f29dd26281626f7381e292c821f8e826b9bc584b92ffa45035b2"
}
@@ -1,80 +0,0 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n id as `id: u64`,\n event_token as `event_token: tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken`,\n debug_log_type as `debug_log_type: enums::by_table::debug_logs::debug_log_type::DebugLogType`,\n maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n message,\n created_at\nFROM debug_logs\nWHERE event_token = ?\nORDER BY id ASC\nLIMIT ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id: u64",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | PRIMARY_KEY | UNSIGNED | AUTO_INCREMENT",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 1,
"name": "event_token: tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "debug_log_type: enums::by_table::debug_logs::debug_log_type::DebugLogType",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 96
}
},
{
"ordinal": 3,
"name": "maybe_creator_user_token: tokens::tokens::users::UserToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 4,
"name": "message",
"type_info": {
"type": "Blob",
"flags": "NOT_NULL | BLOB | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 67108860
}
},
{
"ordinal": 5,
"name": "created_at",
"type_info": {
"type": "Datetime",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 2
},
"nullable": [
false,
false,
false,
true,
false,
false
]
},
"hash": "a62b88fec40a11952cc199383090ea338f97091e8bfc6b68aad67416685889af"
}
@@ -1,69 +0,0 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n users.username,\n users.display_name,\n users.email_gravatar_hash as gravatar_hash,\n creator_user_token,\n count(*) as uploaded_count\nFROM tts_models\nJOIN users\nON\n users.token = creator_user_token\nWHERE\n users.is_banned IS FALSE\n AND users.token NOT IN (\"U:DRB1SKEMKHT1J\")\n AND tts_models.user_deleted_at IS NULL\n AND tts_models.mod_deleted_at IS NULL\nGROUP BY creator_user_token\nORDER BY uploaded_count desc\nLIMIT 25;\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "username",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 1,
"name": "display_name",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 2,
"name": "gravatar_hash",
"type_info": {
"type": "String",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 3,
"name": "creator_user_token",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 4,
"name": "uploaded_count",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | BINARY",
"char_set": 63,
"max_size": 21
}
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
false,
false,
false
]
},
"hash": "a82fa4eb36528ebf52af2585cdda203476a4ba3645837c792830474fca2eda4f"
}
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO debug_logs\nSET\n event_token = ?,\n debug_log_type = ?,\n maybe_creator_user_token = ?,\n message = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 4
},
"nullable": []
},
"hash": "a9745ecf60b564ab096ee1dbe124221b5f288a4ae38e788e19bbd359db0b580f"
}
@@ -0,0 +1,113 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n e.maybe_user_token AS `user_token!: UserToken`,\n u.username AS `username!`,\n u.display_name AS `display_name!`,\n u.email_gravatar_hash AS `email_gravatar_hash!`,\n CAST(SUM(CASE WHEN e.amount_usd_cents > 0 THEN e.amount_usd_cents ELSE 0 END) AS UNSIGNED) AS `gross_spend_usd_cents!: u64`,\n CAST(SUM(CASE WHEN e.amount_usd_cents < 0 THEN -e.amount_usd_cents ELSE 0 END) AS UNSIGNED) AS `refund_usd_cents!: u64`,\n CAST(SUM(e.amount_usd_cents) AS SIGNED) AS `net_spend_usd_cents!: i64`,\n CAST(SUM(CASE WHEN e.amount_usd_cents > 0 THEN 1 ELSE 0 END) AS UNSIGNED) AS `payment_count!: u64`,\n CAST(COALESCE(SUM(e.maybe_credits_granted), 0) AS UNSIGNED) AS `credits_granted!: u64`\nFROM user_spend_events e\nJOIN users u ON u.token = e.maybe_user_token\nWHERE e.maybe_user_token IS NOT NULL\n AND e.is_production = TRUE\n AND e.payment_occurred_at >= ?\n AND (? IS NULL OR e.payments_namespace = ?)\nGROUP BY e.maybe_user_token, u.username, u.display_name, u.email_gravatar_hash\nORDER BY SUM(e.amount_usd_cents) DESC, e.maybe_user_token ASC\nLIMIT ? OFFSET ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "user_token!: UserToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "username!",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 2,
"name": "display_name!",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 3,
"name": "email_gravatar_hash!",
"type_info": {
"type": "String",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 4,
"name": "gross_spend_usd_cents!: u64",
"type_info": {
"type": "LongLong",
"flags": "UNSIGNED | BINARY",
"char_set": 63,
"max_size": 21
}
},
{
"ordinal": 5,
"name": "refund_usd_cents!: u64",
"type_info": {
"type": "LongLong",
"flags": "UNSIGNED | BINARY",
"char_set": 63,
"max_size": 21
}
},
{
"ordinal": 6,
"name": "net_spend_usd_cents!: i64",
"type_info": {
"type": "LongLong",
"flags": "BINARY",
"char_set": 63,
"max_size": 21
}
},
{
"ordinal": 7,
"name": "payment_count!: u64",
"type_info": {
"type": "LongLong",
"flags": "UNSIGNED | BINARY",
"char_set": 63,
"max_size": 21
}
},
{
"ordinal": 8,
"name": "credits_granted!: u64",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | UNSIGNED | BINARY",
"char_set": 63,
"max_size": 21
}
}
],
"parameters": {
"Right": 5
},
"nullable": [
true,
false,
false,
false,
true,
true,
true,
true,
false
]
},
"hash": "c2ee41eb6bc1a6a551411998155423142411010f298f0815eacc7d9c3138bdd8"
}
@@ -0,0 +1,91 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n users.token as `user_token: UserToken`,\n users.username,\n users.display_name,\n users.email_address,\n users.user_role_slug,\n users.is_banned,\n user_roles.can_ban_users\nFROM user_sessions\nINNER JOIN users\n ON users.token = user_sessions.user_token\nLEFT OUTER JOIN user_roles\n ON users.user_role_slug = user_roles.slug\nWHERE user_sessions.token = ?\n AND user_sessions.deleted_at IS NULL\n AND users.user_deleted_at IS NULL\n AND users.mod_deleted_at IS NULL\nLIMIT 1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "user_token: UserToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "username",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 2,
"name": "display_name",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 3,
"name": "email_address",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 4,
"name": "user_role_slug",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 5,
"name": "is_banned",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 6,
"name": "can_ban_users",
"type_info": {
"type": "Tiny",
"flags": "",
"char_set": 63,
"max_size": 1
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false,
false,
false,
true
]
},
"hash": "c77126adb36f9e2f0248720eb4d8c1cd9d14719f64a02678d8021e26addd2c18"
}
@@ -0,0 +1,146 @@
{
"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"
}
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\nUPDATE tts_results\nSET\n user_deleted_at = NULL\nWHERE\n token = ?\nLIMIT 1\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "dbce51eb1c80dded902c9509e50713483c7131f115e8ec10646e99e88110278b"
}
@@ -1,256 +0,0 @@
{
"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"
}
@@ -0,0 +1,146 @@
{
"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
View File
@@ -3621,6 +3621,7 @@ dependencies = [
"env_logger 0.11.9",
"log",
"storyteller_root",
"trace_id",
"workspace-build-acceleration",
]
@@ -6766,6 +6767,7 @@ version = "0.0.1"
dependencies = [
"env_logger 0.11.9",
"log",
"trace_id",
"workspace-build-acceleration",
]
@@ -8286,6 +8288,7 @@ dependencies = [
"rootly_client",
"sha2 0.11.0-rc.5",
"tokio",
"trace_id",
"workspace-build-acceleration",
]
@@ -11678,6 +11681,7 @@ dependencies = [
"tokens",
"tokio",
"toml 1.0.7+spec-1.1.0",
"trace_id",
"tts_common",
"url",
"url_config",
@@ -13316,6 +13320,17 @@ 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"
+2
View File
@@ -130,6 +130,7 @@ 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",
@@ -293,6 +294,7 @@ 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" }
@@ -0,0 +1,6 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE debug_logs
DROP COLUMN maybe_log_level;
@@ -0,0 +1,6 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE debug_logs
ADD COLUMN maybe_log_level VARCHAR(16) DEFAULT NULL;
@@ -0,0 +1,6 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE debug_logs
DROP INDEX index_maybe_log_level;
@@ -0,0 +1,6 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE debug_logs
ADD INDEX index_maybe_log_level (maybe_log_level);
@@ -0,0 +1,6 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE debug_logs
DROP COLUMN maybe_ip_address;
@@ -0,0 +1,6 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE debug_logs
ADD COLUMN maybe_ip_address VARCHAR(40) DEFAULT NULL;
@@ -0,0 +1,6 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE debug_logs
DROP COLUMN maybe_url;
@@ -0,0 +1,6 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE debug_logs
ADD COLUMN maybe_url VARCHAR(255) DEFAULT NULL;
@@ -0,0 +1,7 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE media_files
DROP INDEX idx_creator_created_at,
ALGORITHM=INPLACE, LOCK=NONE;
@@ -0,0 +1,17 @@
-- 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,9 +13,18 @@ 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,
@@ -25,6 +34,7 @@ CREATE TABLE debug_logs (
PRIMARY KEY (id),
KEY index_event_token (event_token),
KEY index_debug_log_type (debug_log_type),
KEY index_maybe_log_level (maybe_log_level),
KEY index_maybe_creator_user_token (maybe_creator_user_token),
KEY index_created_at (created_at)
@@ -364,6 +364,7 @@ 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 index_media_files_updated_at (updated_at),
KEY idx_creator_created_at (maybe_creator_user_token, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -0,0 +1,36 @@
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,
}
@@ -0,0 +1,4 @@
pub mod debug_log_entry;
pub mod moderation_list_all_debug_logs;
pub mod moderation_list_debug_logs_for_token;
pub mod moderation_list_debug_logs_for_user;
@@ -0,0 +1,76 @@
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>,
}
@@ -0,0 +1,30 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken;
use crate::moderation::debug_logs::debug_log_entry::ModerationDebugLogEntry;
pub const MODERATION_LIST_DEBUG_LOGS_FOR_TOKEN_PATH: &str = "/v1/moderation/debug_logs/list/{token}";
// ── Path params ──
#[derive(Deserialize, ToSchema)]
pub struct ModerationListDebugLogsForTokenPathInfo {
pub token: DebugLogEventToken,
}
// ── Query params ──
#[derive(Deserialize, IntoParams, ToSchema)]
pub struct ModerationListDebugLogsForTokenQueryParams {
pub limit: Option<u32>,
}
// ── Response ──
#[derive(Serialize, ToSchema)]
pub struct ModerationListDebugLogsForTokenSuccessResponse {
pub success: bool,
pub debug_logs: Vec<ModerationDebugLogEntry>,
}
@@ -0,0 +1,37 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use tokens::tokens::users::UserToken;
use crate::moderation::debug_logs::debug_log_entry::ModerationDebugLogEntry;
pub const MODERATION_LIST_DEBUG_LOGS_FOR_USER_PATH: &str = "/v1/moderation/debug_logs/user_list/{user_token}";
// ── Path params ──
#[derive(Deserialize, ToSchema)]
pub struct ModerationListDebugLogsForUserPathInfo {
pub user_token: UserToken,
}
// ── Query params ──
#[derive(Deserialize, IntoParams, ToSchema)]
pub struct ModerationListDebugLogsForUserQueryParams {
/// Cursor for pagination. Pass the `next_cursor` from a previous response.
pub cursor: Option<u64>,
/// Max rows per page (default 50, max 200).
pub limit: Option<u32>,
}
// ── Response ──
#[derive(Serialize, ToSchema)]
pub struct ModerationListDebugLogsForUserSuccessResponse {
pub success: bool,
/// Most recent first.
pub debug_logs: Vec<ModerationDebugLogEntry>,
/// Cursor for the next page. `None` if there are no more results.
pub next_cursor: Option<u64>,
}
@@ -1,4 +1,6 @@
pub mod alerts;
pub mod debug_logs;
pub mod top_spenders;
pub mod jobs;
pub mod user;
pub mod user_referrals;
@@ -0,0 +1,82 @@
use serde_derive::{Deserialize, Serialize};
use tokens::tokens::users::UserToken;
use utoipa::{IntoParams, ToSchema};
pub const MODERATOR_LIST_TOP_SPENDERS_PATH: &str =
"/v1/moderation/top_spenders/list";
/// Rolling aggregation window for the top spenders list.
#[derive(Deserialize, Serialize, Clone, Copy, ToSchema)]
pub enum TopSpendersWindow {
#[serde(rename = "24h")]
TwentyFourHours,
#[serde(rename = "7d")]
SevenDays,
#[serde(rename = "14d")]
FourteenDays,
#[serde(rename = "30d")]
ThirtyDays,
#[serde(rename = "60d")]
SixtyDays,
#[serde(rename = "90d")]
NinetyDays,
#[serde(rename = "180d")]
OneHundredEightyDays,
#[serde(rename = "365d")]
ThreeHundredSixtyFiveDays,
}
impl TopSpendersWindow {
/// Window length in hours.
pub fn as_hours(self) -> i64 {
match self {
TopSpendersWindow::TwentyFourHours => 24,
TopSpendersWindow::SevenDays => 7 * 24,
TopSpendersWindow::FourteenDays => 14 * 24,
TopSpendersWindow::ThirtyDays => 30 * 24,
TopSpendersWindow::SixtyDays => 60 * 24,
TopSpendersWindow::NinetyDays => 90 * 24,
TopSpendersWindow::OneHundredEightyDays => 180 * 24,
TopSpendersWindow::ThreeHundredSixtyFiveDays => 365 * 24,
}
}
}
#[derive(Deserialize, IntoParams)]
pub struct ModeratorListTopSpendersQueryParams {
/// Aggregation window: `24h`, `7d`, `14d`, `30d` (default), `60d`, `90d`,
/// `180d`, or `365d`.
pub window: Option<TopSpendersWindow>,
/// Row offset for pagination (page size is fixed at 100). Pass `next_offset` from the previous page.
pub offset: Option<u64>,
/// Optional namespace filter (e.g. `artcraft`). Omit to return all namespaces.
pub payments_namespace: Option<String>,
}
#[derive(Serialize, ToSchema)]
pub struct ModeratorListTopSpendersResponse {
pub success: bool,
/// The window the results were aggregated over (echoed back, post-default).
pub window: TopSpendersWindow,
pub spenders: Vec<TopSpenderEntry>,
/// Offset for the next page, or `None` if this was the last page.
pub maybe_next_offset: Option<u64>,
}
#[derive(Serialize, ToSchema)]
pub struct TopSpenderEntry {
pub user_token: UserToken,
pub username: String,
pub display_name: String,
pub email_gravatar_hash: String,
/// Sum of positive amounts in the window.
pub gross_spend_usd_cents: u64,
/// Refund/chargeback magnitude in the window (positive number).
pub refund_usd_cents: u64,
/// Gross minus refunds. Can be negative if refunds outweigh purchases.
pub net_spend_usd_cents: i64,
/// Number of positive money events in the window.
pub payment_count: u64,
/// Credits granted in the window.
pub credits_granted: u64,
}
@@ -0,0 +1 @@
pub mod list;
@@ -138,6 +138,7 @@ pub(crate) fn plan_batch_count(
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 => Ok(KinoviMidjourneyBatchCount::One),
2 => Ok(KinoviMidjourneyBatchCount::Two),
3 => Ok(KinoviMidjourneyBatchCount::Three),
4 => Ok(KinoviMidjourneyBatchCount::Four),
other => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
@@ -146,13 +147,11 @@ pub(crate) fn plan_batch_count(
value: format!("{}", other),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => {
// 3 → 4, anything > 4 → 4
Ok(if other < 3 { KinoviMidjourneyBatchCount::Two } else { KinoviMidjourneyBatchCount::Four })
}
RequestMismatchMitigationStrategy::PayLessDowngrade => {
// 3 → 2, anything > 4 → 4
Ok(if other < 4 { KinoviMidjourneyBatchCount::Two } else { KinoviMidjourneyBatchCount::Four })
// Kinovi supports batches of 1–4; only counts of 5+ reach here, so both
// mitigation strategies clamp to the maximum supported batch.
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(KinoviMidjourneyBatchCount::Four)
}
},
}
@@ -379,9 +378,10 @@ mod tests {
}
#[test]
fn one_two_four_map_directly() {
fn one_two_three_four_map_directly() {
assert_eq!(batch_for(Some(1)), KinoviMidjourneyBatchCount::One);
assert_eq!(batch_for(Some(2)), KinoviMidjourneyBatchCount::Two);
assert_eq!(batch_for(Some(3)), KinoviMidjourneyBatchCount::Three);
assert_eq!(batch_for(Some(4)), KinoviMidjourneyBatchCount::Four);
}
@@ -394,43 +394,57 @@ mod tests {
));
}
/// Kinovi now supports batches of 3, so 3 is a first-class value and the
/// mitigation strategy no longer applies to it.
#[test]
fn three_errors_under_strict() {
let builder = GenerateImageRequestBuilder {
image_batch_count: Some(3),
fn three_maps_directly_regardless_of_strategy() {
for strategy in [
RequestMismatchMitigationStrategy::ErrorOut,
RequestMismatchMitigationStrategy::PayMoreUpgrade,
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let builder = GenerateImageRequestBuilder {
image_batch_count: Some(3),
request_mismatch_mitigation_strategy: strategy,
..base_builder()
};
assert_eq!(
unwrap_direct_request(build_kinovi_midjourney_7(builder)).request.batch_count,
KinoviMidjourneyBatchCount::Three,
"strategy={:?}", strategy,
);
}
}
/// Counts above 4 are still unsupported: strict errors out, and both
/// pay-more/pay-less strategies clamp to the maximum supported batch (4).
#[test]
fn five_errors_under_strict_and_clamps_to_four_otherwise() {
let strict = GenerateImageRequestBuilder {
image_batch_count: Some(5),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..base_builder()
};
assert!(matches!(
build_kinovi_midjourney_7(builder),
build_kinovi_midjourney_7(strict),
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { .. })),
));
}
#[test]
fn three_upgrades_to_four_with_pay_more() {
let builder = GenerateImageRequestBuilder {
image_batch_count: Some(3),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
..base_builder()
};
assert_eq!(
unwrap_direct_request(build_kinovi_midjourney_7(builder)).request.batch_count,
KinoviMidjourneyBatchCount::Four,
);
}
#[test]
fn three_downgrades_to_two_with_pay_less() {
let builder = GenerateImageRequestBuilder {
image_batch_count: Some(3),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayLessDowngrade,
..base_builder()
};
assert_eq!(
unwrap_direct_request(build_kinovi_midjourney_7(builder)).request.batch_count,
KinoviMidjourneyBatchCount::Two,
);
for strategy in [
RequestMismatchMitigationStrategy::PayMoreUpgrade,
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let builder = GenerateImageRequestBuilder {
image_batch_count: Some(5),
request_mismatch_mitigation_strategy: strategy,
..base_builder()
};
assert_eq!(
unwrap_direct_request(build_kinovi_midjourney_7(builder)).request.batch_count,
KinoviMidjourneyBatchCount::Four,
"strategy={:?}", strategy,
);
}
}
}
@@ -126,6 +126,7 @@ pub(crate) fn plan_batch_count(
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 => Ok(KinoviMidjourneyBatchCount::One),
2 => Ok(KinoviMidjourneyBatchCount::Two),
3 => Ok(KinoviMidjourneyBatchCount::Three),
4 => Ok(KinoviMidjourneyBatchCount::Four),
other => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
@@ -134,11 +135,11 @@ pub(crate) fn plan_batch_count(
value: format!("{}", other),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => {
Ok(if other < 3 { KinoviMidjourneyBatchCount::Two } else { KinoviMidjourneyBatchCount::Four })
}
RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(if other < 4 { KinoviMidjourneyBatchCount::Two } else { KinoviMidjourneyBatchCount::Four })
// Kinovi supports batches of 1–4; only counts of 5+ reach here, so both
// mitigation strategies clamp to the maximum supported batch.
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(KinoviMidjourneyBatchCount::Four)
}
},
}
@@ -212,6 +213,16 @@ mod tests {
));
}
#[test]
fn batch_count_three_is_accepted() {
let builder = GenerateImageRequestBuilder { image_batch_count: Some(3), ..base_builder() };
let req = match build_kinovi_midjourney_7_niji(builder).expect("build") {
ImageGenerationDraftOrRequest::Request(ImageGenerationRequest::KinoviMidjourney7Niji(r)) => r,
_ => panic!("expected Request"),
};
assert_eq!(req.request.batch_count, KinoviMidjourneyBatchCount::Three);
}
#[test]
fn batch_count_four_is_accepted() {
let builder = GenerateImageRequestBuilder { image_batch_count: Some(4), ..base_builder() };
@@ -126,6 +126,7 @@ pub(crate) fn plan_batch_count(
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 => Ok(KinoviMidjourneyBatchCount::One),
2 => Ok(KinoviMidjourneyBatchCount::Two),
3 => Ok(KinoviMidjourneyBatchCount::Three),
4 => Ok(KinoviMidjourneyBatchCount::Four),
other => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
@@ -134,11 +135,11 @@ pub(crate) fn plan_batch_count(
value: format!("{}", other),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => {
Ok(if other < 3 { KinoviMidjourneyBatchCount::Two } else { KinoviMidjourneyBatchCount::Four })
}
RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(if other < 4 { KinoviMidjourneyBatchCount::Two } else { KinoviMidjourneyBatchCount::Four })
// Kinovi supports batches of 1–4; only counts of 5+ reach here, so both
// mitigation strategies clamp to the maximum supported batch.
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(KinoviMidjourneyBatchCount::Four)
}
},
}
@@ -201,6 +202,16 @@ mod tests {
));
}
#[test]
fn batch_count_three_is_accepted() {
let builder = GenerateImageRequestBuilder { image_batch_count: Some(3), ..base_builder() };
let req = match build_kinovi_midjourney_8(builder).expect("build") {
ImageGenerationDraftOrRequest::Request(ImageGenerationRequest::KinoviMidjourney8(r)) => r,
_ => panic!("expected Request"),
};
assert_eq!(req.request.batch_count, KinoviMidjourneyBatchCount::Three);
}
#[test]
fn batch_count_four_is_accepted() {
let builder = GenerateImageRequestBuilder { image_batch_count: Some(4), ..base_builder() };
@@ -0,0 +1,2 @@
pub mod veo_3p1;
pub mod veo_3p1_fast;
@@ -0,0 +1,288 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::video::extend::veo_3p1::raw_request::{
Veo3p1ExtendVideoInput, Veo3p1ExtendVideoOutput,
};
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
#[derive(Clone, Debug)]
pub struct Veo3p1ExtendVideoRequest {
/// Text prompt describing how the video should be extended.
pub prompt: String,
/// URL of the video to extend (720p/1080p, 16:9 or 9:16).
pub video_url: String,
/// Aspect ratio. fal's default is `auto` when `None`.
pub aspect_ratio: Option<Veo3p1ExtendVideoAspectRatio>,
/// Extension duration. fal's default is `7s` when `None`.
pub duration: Option<Veo3p1ExtendVideoDuration>,
/// Output resolution. fal's default is `720p` when `None`.
pub resolution: Option<Veo3p1ExtendVideoResolution>,
/// Optional negative prompt.
pub negative_prompt: Option<String>,
/// Whether to generate native audio. fal's server default is `true` when
/// this is `None`.
pub generate_audio: Option<bool>,
/// Seed for the random number generator.
pub seed: Option<i64>,
/// Whether to auto-rewrite prompts that fail moderation. fal's default is
/// `false` when `None`.
pub auto_fix: Option<bool>,
/// Safety tolerance. fal's default is `4` when `None`.
pub safety_tolerance: Option<Veo3p1ExtendVideoSafetyTolerance>,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1ExtendVideoAspectRatio {
Auto,
SixteenByNine,
NineBySixteen,
}
impl Veo3p1ExtendVideoAspectRatio {
fn to_str(&self) -> &'static str {
match self {
Self::Auto => "auto",
Self::SixteenByNine => "16:9",
Self::NineBySixteen => "9:16",
}
}
}
/// Extension duration. fal documents only the `7s` default for extend-video
/// (no explicit enum). We expose `7s` plus the standard Veo 3.1 family
/// durations (`4s`/`6s`/`8s`) so callers stay strongly typed; if fal rejects
/// a value, trim this enum.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1ExtendVideoDuration {
FourSeconds,
SixSeconds,
/// fal's documented default for extend-video.
SevenSeconds,
EightSeconds,
}
impl Veo3p1ExtendVideoDuration {
pub fn to_seconds(&self) -> u64 {
match self {
Self::FourSeconds => 4,
Self::SixSeconds => 6,
Self::SevenSeconds => 7,
Self::EightSeconds => 8,
}
}
fn to_str(&self) -> &'static str {
match self {
Self::FourSeconds => "4s",
Self::SixSeconds => "6s",
Self::SevenSeconds => "7s",
Self::EightSeconds => "8s",
}
}
}
/// Extend-video documents only 720p/1080p (no 4k tier), so this enum omits 4k.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1ExtendVideoResolution {
SevenTwentyP,
TenEightyP,
}
impl Veo3p1ExtendVideoResolution {
fn to_str(&self) -> &'static str {
match self {
Self::SevenTwentyP => "720p",
Self::TenEightyP => "1080p",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1ExtendVideoSafetyTolerance {
/// "1" — strictest.
Level1,
Level2,
Level3,
/// "4" — fal default.
Level4,
Level5,
/// "6" — least strict.
Level6,
}
impl Veo3p1ExtendVideoSafetyTolerance {
fn to_str(&self) -> &'static str {
match self {
Self::Level1 => "1",
Self::Level2 => "2",
Self::Level3 => "3",
Self::Level4 => "4",
Self::Level5 => "5",
Self::Level6 => "6",
}
}
}
impl FalEndpoint for Veo3p1ExtendVideoRequest {
const ENDPOINT: &str = "fal-ai/veo3.1/extend-video";
type RawRequest = Veo3p1ExtendVideoInput;
type RawResponse = Veo3p1ExtendVideoOutput;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
Ok(Self::RawRequest {
prompt: self.prompt.clone(),
video_url: self.video_url.clone(),
aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()),
duration: self.duration.map(|d| d.to_str().to_string()),
negative_prompt: self.negative_prompt.clone(),
resolution: self.resolution.map(|r| r.to_str().to_string()),
generate_audio: self.generate_audio,
seed: self.seed,
auto_fix: self.auto_fix,
safety_tolerance: self.safety_tolerance.map(|s| s.to_str().to_string()),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
use errors::AnyhowResult;
use std::fs::read_to_string;
use test_data::web::video_urls::ANGRY_SHIBA_VIDEO_URL;
// ── Real requests (manually run; require a live key and cost money) ──
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_extend_video_webhook() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Veo3p1ExtendVideoRequest {
prompt: "the scene continues naturally, keeping the same motion and style".to_string(),
video_url: ANGRY_SHIBA_VIDEO_URL.to_string(),
aspect_ratio: None,
duration: Some(Veo3p1ExtendVideoDuration::SevenSeconds),
resolution: Some(Veo3p1ExtendVideoResolution::SevenTwentyP),
negative_prompt: None,
generate_audio: Some(false),
seed: None,
auto_fix: None,
safety_tolerance: None,
};
let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?;
println!("Webhook result: {:?}", result);
assert!(result.request_id.is_some() || result.gateway_request_id.is_some());
Ok(())
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_extend_video_queue() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Veo3p1ExtendVideoRequest {
prompt: "the camera pulls back to reveal more of the landscape".to_string(),
video_url: ANGRY_SHIBA_VIDEO_URL.to_string(),
aspect_ratio: None,
duration: Some(Veo3p1ExtendVideoDuration::SevenSeconds),
resolution: Some(Veo3p1ExtendVideoResolution::SevenTwentyP),
negative_prompt: None,
generate_audio: Some(false),
seed: None,
auto_fix: None,
safety_tolerance: None,
};
let result = request.send_queue_request(&api_key).await?;
println!("Queue result — request_id: {}", result.request_id);
assert!(!result.request_id.is_empty());
Ok(())
}
// ── Wire-shape sanity (no API calls) ──
#[test]
fn raw_request_maps_all_fields() {
let request = Veo3p1ExtendVideoRequest {
prompt: "p".to_string(),
video_url: "https://example.com/in.mp4".to_string(),
aspect_ratio: Some(Veo3p1ExtendVideoAspectRatio::SixteenByNine),
duration: Some(Veo3p1ExtendVideoDuration::SevenSeconds),
resolution: Some(Veo3p1ExtendVideoResolution::TenEightyP),
negative_prompt: Some("nope".to_string()),
generate_audio: Some(true),
seed: Some(3),
auto_fix: Some(false),
safety_tolerance: Some(Veo3p1ExtendVideoSafetyTolerance::Level4),
};
let raw = request.to_raw_request().unwrap();
assert_eq!(raw.video_url, "https://example.com/in.mp4");
assert_eq!(raw.aspect_ratio.as_deref(), Some("16:9"));
assert_eq!(raw.duration.as_deref(), Some("7s"));
assert_eq!(raw.resolution.as_deref(), Some("1080p"));
assert_eq!(raw.negative_prompt.as_deref(), Some("nope"));
assert_eq!(raw.generate_audio, Some(true));
assert_eq!(raw.seed, Some(3));
assert_eq!(raw.auto_fix, Some(false));
assert_eq!(raw.safety_tolerance.as_deref(), Some("4"));
}
#[test]
fn raw_request_omits_unset_optionals() {
let request = Veo3p1ExtendVideoRequest {
prompt: "p".to_string(),
video_url: "https://example.com/in.mp4".to_string(),
aspect_ratio: None,
duration: None,
resolution: None,
negative_prompt: None,
generate_audio: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(
json,
serde_json::json!({ "prompt": "p", "video_url": "https://example.com/in.mp4" }),
);
}
#[test]
fn every_duration_maps_to_wire_string_and_seconds() {
for (variant, s, secs) in [
(Veo3p1ExtendVideoDuration::FourSeconds, "4s", 4),
(Veo3p1ExtendVideoDuration::SixSeconds, "6s", 6),
(Veo3p1ExtendVideoDuration::SevenSeconds, "7s", 7),
(Veo3p1ExtendVideoDuration::EightSeconds, "8s", 8),
] {
assert_eq!(variant.to_str(), s);
assert_eq!(variant.to_seconds(), secs);
}
}
#[test]
fn endpoint_path_is_canonical() {
assert_eq!(
Veo3p1ExtendVideoRequest::ENDPOINT,
"fal-ai/veo3.1/extend-video",
);
}
// NB: Pricing tests are in cost.rs
}
@@ -0,0 +1,109 @@
use crate::requests::api::video::extend::veo_3p1::api::{
Veo3p1ExtendVideoDuration, Veo3p1ExtendVideoRequest,
};
use crate::requests::api::video::text::veo_3p1::cost::{
veo_3p1_cost_cents, veo_3p1_rate_tenth_cents_per_sec,
};
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
// Extend-video pricing (see https://fal.ai/models/fal-ai/veo3.1/extend-video):
// $0.20/sec (audio off), $0.40/sec (audio on).
//
// Extend exposes only 720p/1080p (no 4k tier), so it always bills at the
// family's SD rate — `four_k` is fixed to `false`.
impl FalRequestCostCalculator for Veo3p1ExtendVideoRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
// fal defaults when unset: duration = 7s, audio = on. Resolution does not
// affect price here (720p and 1080p bill identically).
let duration_secs = self.duration
.unwrap_or(Veo3p1ExtendVideoDuration::SevenSeconds)
.to_seconds();
let audio_on = self.generate_audio.unwrap_or(true);
let rate = veo_3p1_rate_tenth_cents_per_sec(false, audio_on);
veo_3p1_cost_cents(rate, duration_secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::requests::api::video::extend::veo_3p1::api::Veo3p1ExtendVideoResolution;
fn make_request(
duration: Option<Veo3p1ExtendVideoDuration>,
generate_audio: Option<bool>,
) -> Veo3p1ExtendVideoRequest {
Veo3p1ExtendVideoRequest {
prompt: "test".to_string(),
video_url: "https://example.com/in.mp4".to_string(),
aspect_ratio: None,
duration,
resolution: None,
negative_prompt: None,
generate_audio,
seed: None,
auto_fix: None,
safety_tolerance: None,
}
}
mod cost_table {
use super::*;
// (duration, generate_audio, expected_cents)
const COST_TABLE: &[(
Option<Veo3p1ExtendVideoDuration>,
Option<bool>,
u64,
)] = &[
(Some(Veo3p1ExtendVideoDuration::FourSeconds), Some(false), 80),
(Some(Veo3p1ExtendVideoDuration::FourSeconds), Some(true), 160),
(Some(Veo3p1ExtendVideoDuration::SevenSeconds), Some(false), 140),
(Some(Veo3p1ExtendVideoDuration::SevenSeconds), Some(true), 280),
(Some(Veo3p1ExtendVideoDuration::EightSeconds), Some(false), 160),
(Some(Veo3p1ExtendVideoDuration::EightSeconds), Some(true), 320),
// Defaults: 7s, audio on
(None, None, 280),
(None, Some(false), 140),
];
#[test]
fn matches_cost_table() {
for &(duration, generate_audio, expected) in COST_TABLE {
let got = make_request(duration, generate_audio).calculate_cost_in_cents();
assert_eq!(got, expected, "duration={duration:?} audio={generate_audio:?}");
}
}
/// fal: "a 5s video with audio on will cost $2" → 5s × $0.40 = 200¢.
#[test]
fn matches_documented_5s_audio_on_example() {
let rate = veo_3p1_rate_tenth_cents_per_sec(false, true);
assert_eq!(veo_3p1_cost_cents(rate, 5), 200);
}
#[test]
fn default_duration_is_seven_seconds() {
// duration=None, audio=on → 7s @ $0.40 = 280¢
assert_eq!(make_request(None, None).calculate_cost_in_cents(), 280);
}
/// Resolution (720p vs 1080p) must not change the bill for extend.
#[test]
fn cost_is_independent_of_resolution() {
for audio in [Some(false), Some(true), None] {
let base = make_request(Some(Veo3p1ExtendVideoDuration::SevenSeconds), audio)
.calculate_cost_in_cents();
for res in [Veo3p1ExtendVideoResolution::SevenTwentyP, Veo3p1ExtendVideoResolution::TenEightyP] {
let cost = Veo3p1ExtendVideoRequest {
resolution: Some(res),
..make_request(Some(Veo3p1ExtendVideoDuration::SevenSeconds), audio)
}.calculate_cost_in_cents();
assert_eq!(cost, base, "audio={audio:?} res={res:?}");
}
}
}
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -0,0 +1,59 @@
use serde::{Deserialize, Serialize};
/// Over-the-wire input shape for `fal-ai/veo3.1/extend-video`.
/// Extends an existing video by generating a continuation.
/// fal's schema: <https://fal.ai/models/fal-ai/veo3.1/extend-video/api>
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Veo3p1ExtendVideoInput {
pub prompt: String,
/// URL of the video to extend. Should be 720p or 1080p, 16:9 or 9:16.
pub video_url: String,
/// Aspect ratio.
/// Possible values: "auto", "16:9", "9:16". fal default: "auto".
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
/// Duration in seconds (as a string). fal default: "7s". fal documents no
/// explicit enum for this endpoint (only the default), so it is sent as a
/// free-form string.
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<String>,
/// Optional negative prompt.
#[serde(skip_serializing_if = "Option::is_none")]
pub negative_prompt: Option<String>,
/// Output resolution. fal default: "720p" (the docs describe 720p/1080p
/// source requirements and list no 4k tier for extend).
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<String>,
/// Whether to generate native audio for the video. fal default: true.
#[serde(skip_serializing_if = "Option::is_none")]
pub generate_audio: Option<bool>,
/// Seed for the random number generator.
#[serde(skip_serializing_if = "Option::is_none")]
pub seed: Option<i64>,
/// Whether to automatically rewrite prompts that fail moderation.
/// fal default: false.
#[serde(skip_serializing_if = "Option::is_none")]
pub auto_fix: Option<bool>,
/// Safety tolerance, "1" (strictest) .. "6" (least strict). fal default: "4".
#[serde(skip_serializing_if = "Option::is_none")]
pub safety_tolerance: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo3p1ExtendVideoVideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo3p1ExtendVideoOutput {
pub video: Veo3p1ExtendVideoVideoFile,
}
@@ -0,0 +1,288 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::video::extend::veo_3p1_fast::raw_request::{
Veo3p1FastExtendVideoInput, Veo3p1FastExtendVideoOutput,
};
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
#[derive(Clone, Debug)]
pub struct Veo3p1FastExtendVideoRequest {
/// Text prompt describing how the video should be extended.
pub prompt: String,
/// URL of the video to extend (720p/1080p, 16:9 or 9:16).
pub video_url: String,
/// Aspect ratio. fal's default is `auto` when `None`.
pub aspect_ratio: Option<Veo3p1FastExtendVideoAspectRatio>,
/// Extension duration. fal's default is `7s` when `None`.
pub duration: Option<Veo3p1FastExtendVideoDuration>,
/// Output resolution. fal's default is `720p` when `None`.
pub resolution: Option<Veo3p1FastExtendVideoResolution>,
/// Optional negative prompt.
pub negative_prompt: Option<String>,
/// Whether to generate native audio. fal's server default is `true` when
/// this is `None`.
pub generate_audio: Option<bool>,
/// Seed for the random number generator.
pub seed: Option<i64>,
/// Whether to auto-rewrite prompts that fail moderation. fal's default is
/// `false` when `None`.
pub auto_fix: Option<bool>,
/// Safety tolerance. fal's default is `4` when `None`.
pub safety_tolerance: Option<Veo3p1FastExtendVideoSafetyTolerance>,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1FastExtendVideoAspectRatio {
Auto,
SixteenByNine,
NineBySixteen,
}
impl Veo3p1FastExtendVideoAspectRatio {
fn to_str(&self) -> &'static str {
match self {
Self::Auto => "auto",
Self::SixteenByNine => "16:9",
Self::NineBySixteen => "9:16",
}
}
}
/// Extension duration. fal documents only the `7s` default for extend-video
/// (no explicit enum). We expose `7s` plus the standard Veo 3.1 Fast family
/// durations (`4s`/`6s`/`8s`) so callers stay strongly typed; if fal rejects
/// a value, trim this enum.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1FastExtendVideoDuration {
FourSeconds,
SixSeconds,
/// fal's documented default for extend-video.
SevenSeconds,
EightSeconds,
}
impl Veo3p1FastExtendVideoDuration {
pub fn to_seconds(&self) -> u64 {
match self {
Self::FourSeconds => 4,
Self::SixSeconds => 6,
Self::SevenSeconds => 7,
Self::EightSeconds => 8,
}
}
fn to_str(&self) -> &'static str {
match self {
Self::FourSeconds => "4s",
Self::SixSeconds => "6s",
Self::SevenSeconds => "7s",
Self::EightSeconds => "8s",
}
}
}
/// Extend-video documents only 720p/1080p (no 4k tier), so this enum omits 4k.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1FastExtendVideoResolution {
SevenTwentyP,
TenEightyP,
}
impl Veo3p1FastExtendVideoResolution {
fn to_str(&self) -> &'static str {
match self {
Self::SevenTwentyP => "720p",
Self::TenEightyP => "1080p",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1FastExtendVideoSafetyTolerance {
/// "1" — strictest.
Level1,
Level2,
Level3,
/// "4" — fal default.
Level4,
Level5,
/// "6" — least strict.
Level6,
}
impl Veo3p1FastExtendVideoSafetyTolerance {
fn to_str(&self) -> &'static str {
match self {
Self::Level1 => "1",
Self::Level2 => "2",
Self::Level3 => "3",
Self::Level4 => "4",
Self::Level5 => "5",
Self::Level6 => "6",
}
}
}
impl FalEndpoint for Veo3p1FastExtendVideoRequest {
const ENDPOINT: &str = "fal-ai/veo3.1/fast/extend-video";
type RawRequest = Veo3p1FastExtendVideoInput;
type RawResponse = Veo3p1FastExtendVideoOutput;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
Ok(Self::RawRequest {
prompt: self.prompt.clone(),
video_url: self.video_url.clone(),
aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()),
duration: self.duration.map(|d| d.to_str().to_string()),
negative_prompt: self.negative_prompt.clone(),
resolution: self.resolution.map(|r| r.to_str().to_string()),
generate_audio: self.generate_audio,
seed: self.seed,
auto_fix: self.auto_fix,
safety_tolerance: self.safety_tolerance.map(|s| s.to_str().to_string()),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
use errors::AnyhowResult;
use std::fs::read_to_string;
use test_data::web::video_urls::ANGRY_SHIBA_VIDEO_URL;
// ── Real requests (manually run; require a live key and cost money) ──
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_extend_video_webhook() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Veo3p1FastExtendVideoRequest {
prompt: "the scene continues naturally, keeping the same motion and style".to_string(),
video_url: ANGRY_SHIBA_VIDEO_URL.to_string(),
aspect_ratio: None,
duration: Some(Veo3p1FastExtendVideoDuration::SevenSeconds),
resolution: Some(Veo3p1FastExtendVideoResolution::SevenTwentyP),
negative_prompt: None,
generate_audio: Some(false),
seed: None,
auto_fix: None,
safety_tolerance: None,
};
let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?;
println!("Webhook result: {:?}", result);
assert!(result.request_id.is_some() || result.gateway_request_id.is_some());
Ok(())
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_extend_video_queue() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Veo3p1FastExtendVideoRequest {
prompt: "the camera pulls back to reveal more of the landscape".to_string(),
video_url: ANGRY_SHIBA_VIDEO_URL.to_string(),
aspect_ratio: None,
duration: Some(Veo3p1FastExtendVideoDuration::SevenSeconds),
resolution: Some(Veo3p1FastExtendVideoResolution::SevenTwentyP),
negative_prompt: None,
generate_audio: Some(false),
seed: None,
auto_fix: None,
safety_tolerance: None,
};
let result = request.send_queue_request(&api_key).await?;
println!("Queue result — request_id: {}", result.request_id);
assert!(!result.request_id.is_empty());
Ok(())
}
// ── Wire-shape sanity (no API calls) ──
#[test]
fn raw_request_maps_all_fields() {
let request = Veo3p1FastExtendVideoRequest {
prompt: "p".to_string(),
video_url: "https://example.com/in.mp4".to_string(),
aspect_ratio: Some(Veo3p1FastExtendVideoAspectRatio::SixteenByNine),
duration: Some(Veo3p1FastExtendVideoDuration::SevenSeconds),
resolution: Some(Veo3p1FastExtendVideoResolution::TenEightyP),
negative_prompt: Some("nope".to_string()),
generate_audio: Some(true),
seed: Some(3),
auto_fix: Some(false),
safety_tolerance: Some(Veo3p1FastExtendVideoSafetyTolerance::Level4),
};
let raw = request.to_raw_request().unwrap();
assert_eq!(raw.video_url, "https://example.com/in.mp4");
assert_eq!(raw.aspect_ratio.as_deref(), Some("16:9"));
assert_eq!(raw.duration.as_deref(), Some("7s"));
assert_eq!(raw.resolution.as_deref(), Some("1080p"));
assert_eq!(raw.negative_prompt.as_deref(), Some("nope"));
assert_eq!(raw.generate_audio, Some(true));
assert_eq!(raw.seed, Some(3));
assert_eq!(raw.auto_fix, Some(false));
assert_eq!(raw.safety_tolerance.as_deref(), Some("4"));
}
#[test]
fn raw_request_omits_unset_optionals() {
let request = Veo3p1FastExtendVideoRequest {
prompt: "p".to_string(),
video_url: "https://example.com/in.mp4".to_string(),
aspect_ratio: None,
duration: None,
resolution: None,
negative_prompt: None,
generate_audio: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(
json,
serde_json::json!({ "prompt": "p", "video_url": "https://example.com/in.mp4" }),
);
}
#[test]
fn every_duration_maps_to_wire_string_and_seconds() {
for (variant, s, secs) in [
(Veo3p1FastExtendVideoDuration::FourSeconds, "4s", 4),
(Veo3p1FastExtendVideoDuration::SixSeconds, "6s", 6),
(Veo3p1FastExtendVideoDuration::SevenSeconds, "7s", 7),
(Veo3p1FastExtendVideoDuration::EightSeconds, "8s", 8),
] {
assert_eq!(variant.to_str(), s);
assert_eq!(variant.to_seconds(), secs);
}
}
#[test]
fn endpoint_path_is_canonical() {
assert_eq!(
Veo3p1FastExtendVideoRequest::ENDPOINT,
"fal-ai/veo3.1/fast/extend-video",
);
}
// NB: Pricing tests are in cost.rs
}
@@ -0,0 +1,102 @@
use crate::requests::api::video::extend::veo_3p1_fast::api::{
Veo3p1FastExtendVideoDuration, Veo3p1FastExtendVideoRequest,
};
use crate::requests::api::video::text::veo_3p1_fast::cost::{
veo_3p1_fast_cost_cents, veo_3p1_fast_rate_tenth_cents_per_sec,
};
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
// Extend-video pricing (see https://fal.ai/models/fal-ai/veo3.1/fast/extend-video):
// $0.10/sec (audio off), $0.15/sec (audio on).
//
// Extend exposes only 720p/1080p (no 4k tier), so it always bills at the
// family's SD rate — `four_k` is fixed to `false`.
impl FalRequestCostCalculator for Veo3p1FastExtendVideoRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
// fal defaults when unset: duration = 7s, audio = on. Resolution does not
// affect price here (720p and 1080p bill identically).
let duration_secs = self.duration
.unwrap_or(Veo3p1FastExtendVideoDuration::SevenSeconds)
.to_seconds();
let audio_on = self.generate_audio.unwrap_or(true);
let rate = veo_3p1_fast_rate_tenth_cents_per_sec(false, audio_on);
veo_3p1_fast_cost_cents(rate, duration_secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::requests::api::video::extend::veo_3p1_fast::api::Veo3p1FastExtendVideoResolution;
fn make_request(
duration: Option<Veo3p1FastExtendVideoDuration>,
generate_audio: Option<bool>,
) -> Veo3p1FastExtendVideoRequest {
Veo3p1FastExtendVideoRequest {
prompt: "test".to_string(),
video_url: "https://example.com/in.mp4".to_string(),
aspect_ratio: None,
duration,
resolution: None,
negative_prompt: None,
generate_audio,
seed: None,
auto_fix: None,
safety_tolerance: None,
}
}
mod cost_table {
use super::*;
// (duration, generate_audio, expected_cents)
const COST_TABLE: &[(
Option<Veo3p1FastExtendVideoDuration>,
Option<bool>,
u64,
)] = &[
(Some(Veo3p1FastExtendVideoDuration::FourSeconds), Some(false), 40),
(Some(Veo3p1FastExtendVideoDuration::FourSeconds), Some(true), 60),
(Some(Veo3p1FastExtendVideoDuration::SevenSeconds), Some(false), 70),
(Some(Veo3p1FastExtendVideoDuration::SevenSeconds), Some(true), 105),
(Some(Veo3p1FastExtendVideoDuration::EightSeconds), Some(false), 80),
(Some(Veo3p1FastExtendVideoDuration::EightSeconds), Some(true), 120),
// Defaults: 7s, audio on
(None, None, 105),
(None, Some(false), 70),
];
#[test]
fn matches_cost_table() {
for &(duration, generate_audio, expected) in COST_TABLE {
let got = make_request(duration, generate_audio).calculate_cost_in_cents();
assert_eq!(got, expected, "duration={duration:?} audio={generate_audio:?}");
}
}
#[test]
fn default_duration_is_seven_seconds() {
// duration=None, audio=on → 7s @ $0.15 = 105¢
assert_eq!(make_request(None, None).calculate_cost_in_cents(), 105);
}
/// Resolution (720p vs 1080p) must not change the bill for extend.
#[test]
fn cost_is_independent_of_resolution() {
for audio in [Some(false), Some(true), None] {
let base = make_request(Some(Veo3p1FastExtendVideoDuration::SevenSeconds), audio)
.calculate_cost_in_cents();
for res in [Veo3p1FastExtendVideoResolution::SevenTwentyP, Veo3p1FastExtendVideoResolution::TenEightyP] {
let cost = Veo3p1FastExtendVideoRequest {
resolution: Some(res),
..make_request(Some(Veo3p1FastExtendVideoDuration::SevenSeconds), audio)
}.calculate_cost_in_cents();
assert_eq!(cost, base, "audio={audio:?} res={res:?}");
}
}
}
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -0,0 +1,59 @@
use serde::{Deserialize, Serialize};
/// Over-the-wire input shape for `fal-ai/veo3.1/fast/extend-video`.
/// Extends an existing video by generating a continuation.
/// fal's schema: <https://fal.ai/models/fal-ai/veo3.1/fast/extend-video/api>
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Veo3p1FastExtendVideoInput {
pub prompt: String,
/// URL of the video to extend. Should be 720p or 1080p, 16:9 or 9:16.
pub video_url: String,
/// Aspect ratio.
/// Possible values: "auto", "16:9", "9:16". fal default: "auto".
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
/// Duration in seconds (as a string). fal default: "7s". fal documents no
/// explicit enum for this endpoint (only the default), so it is sent as a
/// free-form string.
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<String>,
/// Optional negative prompt.
#[serde(skip_serializing_if = "Option::is_none")]
pub negative_prompt: Option<String>,
/// Output resolution. fal default: "720p" (the docs describe 720p/1080p
/// source requirements and list no 4k tier for extend).
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<String>,
/// Whether to generate native audio for the video. fal default: true.
#[serde(skip_serializing_if = "Option::is_none")]
pub generate_audio: Option<bool>,
/// Seed for the random number generator.
#[serde(skip_serializing_if = "Option::is_none")]
pub seed: Option<i64>,
/// Whether to automatically rewrite prompts that fail moderation.
/// fal default: false.
#[serde(skip_serializing_if = "Option::is_none")]
pub auto_fix: Option<bool>,
/// Safety tolerance, "1" (strictest) .. "6" (least strict). fal default: "4".
#[serde(skip_serializing_if = "Option::is_none")]
pub safety_tolerance: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo3p1FastExtendVideoVideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo3p1FastExtendVideoOutput {
pub video: Veo3p1FastExtendVideoVideoFile,
}
@@ -2,3 +2,11 @@ pub mod kling_1p6_pro_image_to_video;
pub mod kling_2p5_turbo_pro_image_to_video;
pub mod kling_2p6_pro_image_to_video;
pub mod kling_3p0_standard_image_to_video;
pub mod veo_2;
pub mod veo_3;
pub mod veo_3_fast;
pub mod veo_3p1;
pub mod veo_3p1_fast;
pub mod veo_3p1_lite;
pub mod vidu_q3;
pub mod vidu_q3_turbo;
@@ -0,0 +1,158 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::video::image::veo_2::raw_request::{
Veo2ImageToVideoInput, Veo2ImageToVideoOutput,
};
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
#[derive(Clone, Debug)]
pub struct Veo2ImageToVideoRequest {
/// Text prompt describing how the image should be animated.
pub prompt: String,
/// URL of the input image to animate (720p+, 16:9 or 9:16).
pub image_url: String,
/// Video duration. fal's default is `5s` when `None`.
pub duration: Option<Veo2ImageToVideoDuration>,
}
/// Veo 2 supports 5–8 second durations (default 5s).
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo2ImageToVideoDuration {
FiveSeconds,
SixSeconds,
SevenSeconds,
EightSeconds,
}
impl Veo2ImageToVideoDuration {
pub fn to_seconds(&self) -> u64 {
match self {
Self::FiveSeconds => 5,
Self::SixSeconds => 6,
Self::SevenSeconds => 7,
Self::EightSeconds => 8,
}
}
fn to_str(&self) -> &'static str {
match self {
Self::FiveSeconds => "5s",
Self::SixSeconds => "6s",
Self::SevenSeconds => "7s",
Self::EightSeconds => "8s",
}
}
}
impl FalEndpoint for Veo2ImageToVideoRequest {
const ENDPOINT: &str = "fal-ai/veo2/image-to-video";
type RawRequest = Veo2ImageToVideoInput;
type RawResponse = Veo2ImageToVideoOutput;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
Ok(Self::RawRequest {
prompt: self.prompt.clone(),
image_url: self.image_url.clone(),
duration: self.duration.map(|d| d.to_str().to_string()),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
use errors::AnyhowResult;
use std::fs::read_to_string;
use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL;
// ── Real requests (manually run; require a live key and cost money) ──
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_image_to_video_webhook() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Veo2ImageToVideoRequest {
prompt: "the lake comes alive with gentle ripples and dappled sunlight".to_string(),
image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(),
duration: Some(Veo2ImageToVideoDuration::FiveSeconds),
};
let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?;
println!("Webhook result: {:?}", result);
assert!(result.request_id.is_some() || result.gateway_request_id.is_some());
Ok(())
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_image_to_video_queue() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Veo2ImageToVideoRequest {
prompt: "wind moves through the trees".to_string(),
image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(),
duration: Some(Veo2ImageToVideoDuration::FiveSeconds),
};
let result = request.send_queue_request(&api_key).await?;
println!("Queue result — request_id: {}", result.request_id);
assert!(!result.request_id.is_empty());
Ok(())
}
// ── Wire-shape sanity (no API calls) ──
#[test]
fn raw_request_maps_all_fields() {
let request = Veo2ImageToVideoRequest {
prompt: "p".to_string(),
image_url: "https://example.com/start.png".to_string(),
duration: Some(Veo2ImageToVideoDuration::EightSeconds),
};
let raw = request.to_raw_request().unwrap();
assert_eq!(raw.prompt, "p");
assert_eq!(raw.image_url, "https://example.com/start.png");
assert_eq!(raw.duration.as_deref(), Some("8s"));
}
#[test]
fn raw_request_omits_unset_optionals() {
let request = Veo2ImageToVideoRequest {
prompt: "p".to_string(),
image_url: "https://example.com/start.png".to_string(),
duration: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(
json,
serde_json::json!({ "prompt": "p", "image_url": "https://example.com/start.png" }),
);
}
#[test]
fn every_duration_maps_to_wire_string_and_seconds() {
for (variant, s, secs) in [
(Veo2ImageToVideoDuration::FiveSeconds, "5s", 5),
(Veo2ImageToVideoDuration::SixSeconds, "6s", 6),
(Veo2ImageToVideoDuration::SevenSeconds, "7s", 7),
(Veo2ImageToVideoDuration::EightSeconds, "8s", 8),
] {
assert_eq!(variant.to_str(), s);
assert_eq!(variant.to_seconds(), secs);
}
}
#[test]
fn endpoint_path_is_canonical() {
assert_eq!(Veo2ImageToVideoRequest::ENDPOINT, "fal-ai/veo2/image-to-video");
}
// NB: Pricing tests are in cost.rs
}
@@ -0,0 +1,62 @@
use crate::requests::api::video::image::veo_2::api::{Veo2ImageToVideoDuration, Veo2ImageToVideoRequest};
use crate::requests::api::video::text::veo_2::cost::veo_2_cost_cents;
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
// Image-to-video shares the Veo 2 pricing (see the text module for the
// canonical formula): $2.50 base for 5s, +$0.50 per additional second.
impl FalRequestCostCalculator for Veo2ImageToVideoRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
// fal default when unset: duration = 5s.
let duration_secs = self.duration
.unwrap_or(Veo2ImageToVideoDuration::FiveSeconds)
.to_seconds();
veo_2_cost_cents(duration_secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_request(duration: Option<Veo2ImageToVideoDuration>) -> Veo2ImageToVideoRequest {
Veo2ImageToVideoRequest {
prompt: "test".to_string(),
image_url: "https://example.com/x.png".to_string(),
duration,
}
}
mod cost_table {
use super::*;
// (duration, expected_cents): 250 base + 50 per second over 5s.
const COST_TABLE: &[(Option<Veo2ImageToVideoDuration>, u64)] = &[
(Some(Veo2ImageToVideoDuration::FiveSeconds), 250),
(Some(Veo2ImageToVideoDuration::SixSeconds), 300),
(Some(Veo2ImageToVideoDuration::SevenSeconds), 350),
(Some(Veo2ImageToVideoDuration::EightSeconds), 400),
// Default: duration=None → 5s
(None, 250),
];
#[test]
fn matches_cost_table() {
for &(duration, expected) in COST_TABLE {
assert_eq!(
make_request(duration).calculate_cost_in_cents(),
expected,
"duration={duration:?}",
);
}
}
/// Image-to-video must bill identically to text-to-video at every duration.
#[test]
fn matches_text_to_video_pricing() {
for (secs, expected) in [(5, 250), (6, 300), (7, 350), (8, 400)] {
assert_eq!(veo_2_cost_cents(secs), expected, "secs={secs}");
}
}
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -0,0 +1,31 @@
use serde::{Deserialize, Serialize};
/// Over-the-wire input shape for `fal-ai/veo2/image-to-video`.
/// fal's schema: <https://fal.ai/models/fal-ai/veo2/image-to-video/api>
///
/// NB: Veo 2 image-to-video has a minimal schema — only `prompt`, `image_url`,
/// and `duration`. There is no aspect_ratio, negative_prompt, seed, auto_fix,
/// enhance_prompt, resolution, or generate_audio (aspect ratio is taken from
/// the input image).
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Veo2ImageToVideoInput {
pub prompt: String,
/// URL of the input image to animate (720p+, 16:9 or 9:16).
pub image_url: String,
/// Duration in seconds (as a string).
/// Possible values: "5s", "6s", "7s", "8s". fal default: "5s".
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo2ImageToVideoVideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo2ImageToVideoOutput {
pub video: Veo2ImageToVideoVideoFile,
}
@@ -0,0 +1,276 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::video::image::veo_3::raw_request::{
Veo3ImageToVideoInput, Veo3ImageToVideoOutput,
};
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
#[derive(Clone, Debug)]
pub struct Veo3ImageToVideoRequest {
/// Text prompt describing the video to generate.
pub prompt: String,
/// URL of the image used as the starting frame.
pub image_url: String,
/// Aspect ratio. fal's default is `auto` when `None`.
pub aspect_ratio: Option<Veo3ImageToVideoAspectRatio>,
/// Video duration. fal's default is `8s` when `None`.
pub duration: Option<Veo3ImageToVideoDuration>,
/// Output resolution. fal's default is `720p` when `None`.
pub resolution: Option<Veo3ImageToVideoResolution>,
/// Whether to generate native audio. fal's server default is `true` when
/// this is `None`.
pub generate_audio: Option<bool>,
/// Optional negative prompt.
pub negative_prompt: Option<String>,
/// Seed for the random number generator.
pub seed: Option<i64>,
/// Whether to auto-rewrite prompts that fail moderation. fal's default is
/// `false` for image-to-video when `None`.
pub auto_fix: Option<bool>,
/// Safety tolerance. fal's default is `4` when `None`.
pub safety_tolerance: Option<Veo3ImageToVideoSafetyTolerance>,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3ImageToVideoAspectRatio {
Auto,
SixteenByNine,
NineBySixteen,
}
impl Veo3ImageToVideoAspectRatio {
fn to_str(&self) -> &'static str {
match self {
Self::Auto => "auto",
Self::SixteenByNine => "16:9",
Self::NineBySixteen => "9:16",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3ImageToVideoDuration {
FourSeconds,
SixSeconds,
EightSeconds,
}
impl Veo3ImageToVideoDuration {
pub fn to_seconds(&self) -> u64 {
match self {
Self::FourSeconds => 4,
Self::SixSeconds => 6,
Self::EightSeconds => 8,
}
}
fn to_str(&self) -> &'static str {
match self {
Self::FourSeconds => "4s",
Self::SixSeconds => "6s",
Self::EightSeconds => "8s",
}
}
}
/// Veo 3 exposes only 720p/1080p (no 4k tier).
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3ImageToVideoResolution {
SevenTwentyP,
TenEightyP,
}
impl Veo3ImageToVideoResolution {
fn to_str(&self) -> &'static str {
match self {
Self::SevenTwentyP => "720p",
Self::TenEightyP => "1080p",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3ImageToVideoSafetyTolerance {
/// "1" — strictest.
Level1,
Level2,
Level3,
/// "4" — fal default.
Level4,
Level5,
/// "6" — least strict.
Level6,
}
impl Veo3ImageToVideoSafetyTolerance {
fn to_str(&self) -> &'static str {
match self {
Self::Level1 => "1",
Self::Level2 => "2",
Self::Level3 => "3",
Self::Level4 => "4",
Self::Level5 => "5",
Self::Level6 => "6",
}
}
}
impl FalEndpoint for Veo3ImageToVideoRequest {
const ENDPOINT: &str = "fal-ai/veo3/image-to-video";
type RawRequest = Veo3ImageToVideoInput;
type RawResponse = Veo3ImageToVideoOutput;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
Ok(Self::RawRequest {
prompt: self.prompt.clone(),
image_url: self.image_url.clone(),
aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()),
duration: self.duration.map(|d| d.to_str().to_string()),
resolution: self.resolution.map(|r| r.to_str().to_string()),
generate_audio: self.generate_audio,
negative_prompt: self.negative_prompt.clone(),
seed: self.seed,
auto_fix: self.auto_fix,
safety_tolerance: self.safety_tolerance.map(|s| s.to_str().to_string()),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
use errors::AnyhowResult;
use std::fs::read_to_string;
use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL;
// ── Real requests (manually run; require a live key and cost money) ──
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_image_to_video_webhook() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Veo3ImageToVideoRequest {
prompt: "the lake comes alive with gentle ripples and dappled sunlight".to_string(),
image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(),
aspect_ratio: Some(Veo3ImageToVideoAspectRatio::SixteenByNine),
duration: Some(Veo3ImageToVideoDuration::FourSeconds),
resolution: Some(Veo3ImageToVideoResolution::SevenTwentyP),
generate_audio: Some(false),
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
};
let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?;
println!("Webhook result: {:?}", result);
assert!(result.request_id.is_some() || result.gateway_request_id.is_some());
Ok(())
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_image_to_video_queue() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Veo3ImageToVideoRequest {
prompt: "wind moves through the trees".to_string(),
image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(),
aspect_ratio: None,
duration: Some(Veo3ImageToVideoDuration::FourSeconds),
resolution: Some(Veo3ImageToVideoResolution::SevenTwentyP),
generate_audio: Some(false),
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
};
let result = request.send_queue_request(&api_key).await?;
println!("Queue result — request_id: {}", result.request_id);
assert!(!result.request_id.is_empty());
Ok(())
}
// ── Wire-shape sanity (no API calls) ──
#[test]
fn raw_request_maps_all_fields() {
let request = Veo3ImageToVideoRequest {
prompt: "p".to_string(),
image_url: "https://example.com/start.png".to_string(),
aspect_ratio: Some(Veo3ImageToVideoAspectRatio::Auto),
duration: Some(Veo3ImageToVideoDuration::SixSeconds),
resolution: Some(Veo3ImageToVideoResolution::TenEightyP),
generate_audio: Some(false),
negative_prompt: Some("nope".to_string()),
seed: Some(7),
auto_fix: Some(true),
safety_tolerance: Some(Veo3ImageToVideoSafetyTolerance::Level2),
};
let raw = request.to_raw_request().unwrap();
assert_eq!(raw.prompt, "p");
assert_eq!(raw.image_url, "https://example.com/start.png");
assert_eq!(raw.aspect_ratio.as_deref(), Some("auto"));
assert_eq!(raw.duration.as_deref(), Some("6s"));
assert_eq!(raw.resolution.as_deref(), Some("1080p"));
assert_eq!(raw.generate_audio, Some(false));
assert_eq!(raw.negative_prompt.as_deref(), Some("nope"));
assert_eq!(raw.seed, Some(7));
assert_eq!(raw.auto_fix, Some(true));
assert_eq!(raw.safety_tolerance.as_deref(), Some("2"));
}
#[test]
fn raw_request_omits_unset_optionals() {
let request = Veo3ImageToVideoRequest {
prompt: "p".to_string(),
image_url: "https://example.com/start.png".to_string(),
aspect_ratio: None,
duration: None,
resolution: None,
generate_audio: None,
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(
json,
serde_json::json!({ "prompt": "p", "image_url": "https://example.com/start.png" }),
);
}
#[test]
fn every_aspect_ratio_maps_to_wire_string() {
for (variant, expected) in [
(Veo3ImageToVideoAspectRatio::Auto, "auto"),
(Veo3ImageToVideoAspectRatio::SixteenByNine, "16:9"),
(Veo3ImageToVideoAspectRatio::NineBySixteen, "9:16"),
] {
assert_eq!(variant.to_str(), expected);
}
}
#[test]
fn endpoint_path_is_canonical() {
assert_eq!(Veo3ImageToVideoRequest::ENDPOINT, "fal-ai/veo3/image-to-video");
}
// NB: Pricing tests are in cost.rs
}
@@ -0,0 +1,90 @@
use crate::requests::api::video::image::veo_3::api::{
Veo3ImageToVideoDuration, Veo3ImageToVideoRequest,
};
use crate::requests::api::video::text::veo_3::cost::{veo_3_cost_cents, veo_3_rate_tenth_cents_per_sec};
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
// Image-to-video shares the Veo 3 (non-fast) per-second pricing (see the text
// module for the canonical rate table): $0.20/sec (audio off), $0.40/sec
// (audio on). Flat — resolution does not affect the price.
impl FalRequestCostCalculator for Veo3ImageToVideoRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
// fal defaults when unset: duration = 8s, audio = on.
let duration_secs = self.duration
.unwrap_or(Veo3ImageToVideoDuration::EightSeconds)
.to_seconds();
let audio_on = self.generate_audio.unwrap_or(true);
let rate = veo_3_rate_tenth_cents_per_sec(audio_on);
veo_3_cost_cents(rate, duration_secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::requests::api::video::image::veo_3::api::Veo3ImageToVideoAspectRatio;
fn make_request(
duration: Option<Veo3ImageToVideoDuration>,
generate_audio: Option<bool>,
) -> Veo3ImageToVideoRequest {
Veo3ImageToVideoRequest {
prompt: "test".to_string(),
image_url: "https://example.com/x.png".to_string(),
aspect_ratio: None,
duration,
resolution: None,
generate_audio,
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
}
}
mod cost_table {
use super::*;
// (duration, generate_audio, expected_cents)
const COST_TABLE: &[(
Option<Veo3ImageToVideoDuration>,
Option<bool>,
u64,
)] = &[
(Some(Veo3ImageToVideoDuration::FourSeconds), Some(false), 80),
(Some(Veo3ImageToVideoDuration::FourSeconds), Some(true), 160),
(Some(Veo3ImageToVideoDuration::EightSeconds), Some(false), 160),
(Some(Veo3ImageToVideoDuration::EightSeconds), Some(true), 320),
// Defaults: 8s, audio on
(None, None, 320),
];
#[test]
fn matches_cost_table() {
for &(duration, generate_audio, expected) in COST_TABLE {
let got = make_request(duration, generate_audio).calculate_cost_in_cents();
assert_eq!(got, expected, "duration={duration:?} audio={generate_audio:?}");
}
}
#[test]
fn cost_is_independent_of_aspect_ratio() {
let baseline = {
let mut r = make_request(Some(Veo3ImageToVideoDuration::EightSeconds), Some(true));
r.aspect_ratio = Some(Veo3ImageToVideoAspectRatio::Auto);
r.calculate_cost_in_cents()
};
for ar in [
Veo3ImageToVideoAspectRatio::Auto,
Veo3ImageToVideoAspectRatio::SixteenByNine,
Veo3ImageToVideoAspectRatio::NineBySixteen,
] {
let mut r = make_request(Some(Veo3ImageToVideoDuration::EightSeconds), Some(true));
r.aspect_ratio = Some(ar);
assert_eq!(r.calculate_cost_in_cents(), baseline, "ar={ar:?}");
}
}
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -0,0 +1,57 @@
use serde::{Deserialize, Serialize};
/// Over-the-wire input shape for `fal-ai/veo3/image-to-video`.
/// fal's schema: <https://fal.ai/models/fal-ai/veo3/image-to-video/api>
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Veo3ImageToVideoInput {
pub prompt: String,
/// URL of the image used as the starting frame.
pub image_url: String,
/// Aspect ratio.
/// Possible values: "auto", "16:9", "9:16". fal default: "auto".
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
/// Duration in seconds (as a string).
/// Possible values: "4s", "6s", "8s". fal default: "8s".
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<String>,
/// Output resolution.
/// Possible values: "720p", "1080p" (no 4k tier). fal default: "720p".
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<String>,
/// Whether to generate native audio for the video. fal default: true.
#[serde(skip_serializing_if = "Option::is_none")]
pub generate_audio: Option<bool>,
/// Optional negative prompt.
#[serde(skip_serializing_if = "Option::is_none")]
pub negative_prompt: Option<String>,
/// Seed for the random number generator.
#[serde(skip_serializing_if = "Option::is_none")]
pub seed: Option<i64>,
/// Whether to automatically rewrite prompts that fail moderation.
/// fal default: false (image-to-video).
#[serde(skip_serializing_if = "Option::is_none")]
pub auto_fix: Option<bool>,
/// Safety tolerance, "1" (strictest) .. "6" (least strict). fal default: "4".
#[serde(skip_serializing_if = "Option::is_none")]
pub safety_tolerance: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo3ImageToVideoVideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo3ImageToVideoOutput {
pub video: Veo3ImageToVideoVideoFile,
}
@@ -0,0 +1,279 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::video::image::veo_3_fast::raw_request::{
Veo3FastImageToVideoInput, Veo3FastImageToVideoOutput,
};
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
#[derive(Clone, Debug)]
pub struct Veo3FastImageToVideoRequest {
/// Text prompt describing the video to generate.
pub prompt: String,
/// URL of the image used as the starting frame.
pub image_url: String,
/// Aspect ratio. fal's default is `auto` when `None`.
pub aspect_ratio: Option<Veo3FastImageToVideoAspectRatio>,
/// Video duration. fal's default is `8s` when `None`.
pub duration: Option<Veo3FastImageToVideoDuration>,
/// Output resolution. fal's default is `720p` when `None`.
pub resolution: Option<Veo3FastImageToVideoResolution>,
/// Whether to generate native audio. fal's server default is `true` when
/// this is `None`.
pub generate_audio: Option<bool>,
/// Optional negative prompt.
pub negative_prompt: Option<String>,
/// Seed for the random number generator.
pub seed: Option<i64>,
/// Whether to auto-rewrite prompts that fail moderation. fal's default is
/// `false` for image-to-video when `None`.
pub auto_fix: Option<bool>,
/// Safety tolerance. fal's default is `4` when `None`.
pub safety_tolerance: Option<Veo3FastImageToVideoSafetyTolerance>,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3FastImageToVideoAspectRatio {
Auto,
SixteenByNine,
NineBySixteen,
}
impl Veo3FastImageToVideoAspectRatio {
fn to_str(&self) -> &'static str {
match self {
Self::Auto => "auto",
Self::SixteenByNine => "16:9",
Self::NineBySixteen => "9:16",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3FastImageToVideoDuration {
FourSeconds,
SixSeconds,
EightSeconds,
}
impl Veo3FastImageToVideoDuration {
pub fn to_seconds(&self) -> u64 {
match self {
Self::FourSeconds => 4,
Self::SixSeconds => 6,
Self::EightSeconds => 8,
}
}
fn to_str(&self) -> &'static str {
match self {
Self::FourSeconds => "4s",
Self::SixSeconds => "6s",
Self::EightSeconds => "8s",
}
}
}
/// Veo 3 Fast exposes only 720p/1080p (no 4k tier).
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3FastImageToVideoResolution {
SevenTwentyP,
TenEightyP,
}
impl Veo3FastImageToVideoResolution {
fn to_str(&self) -> &'static str {
match self {
Self::SevenTwentyP => "720p",
Self::TenEightyP => "1080p",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3FastImageToVideoSafetyTolerance {
/// "1" — strictest.
Level1,
Level2,
Level3,
/// "4" — fal default.
Level4,
Level5,
/// "6" — least strict.
Level6,
}
impl Veo3FastImageToVideoSafetyTolerance {
fn to_str(&self) -> &'static str {
match self {
Self::Level1 => "1",
Self::Level2 => "2",
Self::Level3 => "3",
Self::Level4 => "4",
Self::Level5 => "5",
Self::Level6 => "6",
}
}
}
impl FalEndpoint for Veo3FastImageToVideoRequest {
const ENDPOINT: &str = "fal-ai/veo3/fast/image-to-video";
type RawRequest = Veo3FastImageToVideoInput;
type RawResponse = Veo3FastImageToVideoOutput;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
Ok(Self::RawRequest {
prompt: self.prompt.clone(),
image_url: self.image_url.clone(),
aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()),
duration: self.duration.map(|d| d.to_str().to_string()),
resolution: self.resolution.map(|r| r.to_str().to_string()),
generate_audio: self.generate_audio,
negative_prompt: self.negative_prompt.clone(),
seed: self.seed,
auto_fix: self.auto_fix,
safety_tolerance: self.safety_tolerance.map(|s| s.to_str().to_string()),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
use errors::AnyhowResult;
use std::fs::read_to_string;
use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL;
// ── Real requests (manually run; require a live key and cost money) ──
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_image_to_video_webhook() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Veo3FastImageToVideoRequest {
prompt: "the lake comes alive with gentle ripples and dappled sunlight".to_string(),
image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(),
aspect_ratio: Some(Veo3FastImageToVideoAspectRatio::SixteenByNine),
duration: Some(Veo3FastImageToVideoDuration::FourSeconds),
resolution: Some(Veo3FastImageToVideoResolution::SevenTwentyP),
generate_audio: Some(false),
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
};
let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?;
println!("Webhook result: {:?}", result);
assert!(result.request_id.is_some() || result.gateway_request_id.is_some());
Ok(())
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_image_to_video_queue() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Veo3FastImageToVideoRequest {
prompt: "wind moves through the trees".to_string(),
image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(),
aspect_ratio: None,
duration: Some(Veo3FastImageToVideoDuration::FourSeconds),
resolution: Some(Veo3FastImageToVideoResolution::SevenTwentyP),
generate_audio: Some(false),
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
};
let result = request.send_queue_request(&api_key).await?;
println!("Queue result — request_id: {}", result.request_id);
assert!(!result.request_id.is_empty());
Ok(())
}
// ── Wire-shape sanity (no API calls) ──
#[test]
fn raw_request_maps_all_fields() {
let request = Veo3FastImageToVideoRequest {
prompt: "p".to_string(),
image_url: "https://example.com/start.png".to_string(),
aspect_ratio: Some(Veo3FastImageToVideoAspectRatio::Auto),
duration: Some(Veo3FastImageToVideoDuration::SixSeconds),
resolution: Some(Veo3FastImageToVideoResolution::TenEightyP),
generate_audio: Some(false),
negative_prompt: Some("nope".to_string()),
seed: Some(7),
auto_fix: Some(true),
safety_tolerance: Some(Veo3FastImageToVideoSafetyTolerance::Level2),
};
let raw = request.to_raw_request().unwrap();
assert_eq!(raw.prompt, "p");
assert_eq!(raw.image_url, "https://example.com/start.png");
assert_eq!(raw.aspect_ratio.as_deref(), Some("auto"));
assert_eq!(raw.duration.as_deref(), Some("6s"));
assert_eq!(raw.resolution.as_deref(), Some("1080p"));
assert_eq!(raw.generate_audio, Some(false));
assert_eq!(raw.negative_prompt.as_deref(), Some("nope"));
assert_eq!(raw.seed, Some(7));
assert_eq!(raw.auto_fix, Some(true));
assert_eq!(raw.safety_tolerance.as_deref(), Some("2"));
}
#[test]
fn raw_request_omits_unset_optionals() {
let request = Veo3FastImageToVideoRequest {
prompt: "p".to_string(),
image_url: "https://example.com/start.png".to_string(),
aspect_ratio: None,
duration: None,
resolution: None,
generate_audio: None,
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(
json,
serde_json::json!({ "prompt": "p", "image_url": "https://example.com/start.png" }),
);
}
#[test]
fn every_aspect_ratio_maps_to_wire_string() {
for (variant, expected) in [
(Veo3FastImageToVideoAspectRatio::Auto, "auto"),
(Veo3FastImageToVideoAspectRatio::SixteenByNine, "16:9"),
(Veo3FastImageToVideoAspectRatio::NineBySixteen, "9:16"),
] {
assert_eq!(variant.to_str(), expected);
}
}
#[test]
fn endpoint_path_is_canonical() {
assert_eq!(
Veo3FastImageToVideoRequest::ENDPOINT,
"fal-ai/veo3/fast/image-to-video",
);
}
// NB: Pricing tests are in cost.rs
}
@@ -0,0 +1,92 @@
use crate::requests::api::video::image::veo_3_fast::api::{
Veo3FastImageToVideoDuration, Veo3FastImageToVideoRequest,
};
use crate::requests::api::video::text::veo_3_fast::cost::{
veo_3_fast_cost_cents, veo_3_fast_rate_tenth_cents_per_sec,
};
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
// Image-to-video shares the Veo 3 Fast per-second pricing (see the text module
// for the canonical rate table): $0.10/sec (audio off), $0.15/sec (audio on).
// Flat — resolution does not affect the price.
impl FalRequestCostCalculator for Veo3FastImageToVideoRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
// fal defaults when unset: duration = 8s, audio = on.
let duration_secs = self.duration
.unwrap_or(Veo3FastImageToVideoDuration::EightSeconds)
.to_seconds();
let audio_on = self.generate_audio.unwrap_or(true);
let rate = veo_3_fast_rate_tenth_cents_per_sec(audio_on);
veo_3_fast_cost_cents(rate, duration_secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::requests::api::video::image::veo_3_fast::api::Veo3FastImageToVideoAspectRatio;
fn make_request(
duration: Option<Veo3FastImageToVideoDuration>,
generate_audio: Option<bool>,
) -> Veo3FastImageToVideoRequest {
Veo3FastImageToVideoRequest {
prompt: "test".to_string(),
image_url: "https://example.com/x.png".to_string(),
aspect_ratio: None,
duration,
resolution: None,
generate_audio,
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
}
}
mod cost_table {
use super::*;
// (duration, generate_audio, expected_cents)
const COST_TABLE: &[(
Option<Veo3FastImageToVideoDuration>,
Option<bool>,
u64,
)] = &[
(Some(Veo3FastImageToVideoDuration::FourSeconds), Some(false), 40),
(Some(Veo3FastImageToVideoDuration::FourSeconds), Some(true), 60),
(Some(Veo3FastImageToVideoDuration::EightSeconds), Some(false), 80),
(Some(Veo3FastImageToVideoDuration::EightSeconds), Some(true), 120),
// Defaults: 8s, audio on
(None, None, 120),
];
#[test]
fn matches_cost_table() {
for &(duration, generate_audio, expected) in COST_TABLE {
let got = make_request(duration, generate_audio).calculate_cost_in_cents();
assert_eq!(got, expected, "duration={duration:?} audio={generate_audio:?}");
}
}
#[test]
fn cost_is_independent_of_aspect_ratio() {
let baseline = {
let mut r = make_request(Some(Veo3FastImageToVideoDuration::EightSeconds), Some(true));
r.aspect_ratio = Some(Veo3FastImageToVideoAspectRatio::Auto);
r.calculate_cost_in_cents()
};
for ar in [
Veo3FastImageToVideoAspectRatio::Auto,
Veo3FastImageToVideoAspectRatio::SixteenByNine,
Veo3FastImageToVideoAspectRatio::NineBySixteen,
] {
let mut r = make_request(Some(Veo3FastImageToVideoDuration::EightSeconds), Some(true));
r.aspect_ratio = Some(ar);
assert_eq!(r.calculate_cost_in_cents(), baseline, "ar={ar:?}");
}
}
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -0,0 +1,57 @@
use serde::{Deserialize, Serialize};
/// Over-the-wire input shape for `fal-ai/veo3/fast/image-to-video`.
/// fal's schema: <https://fal.ai/models/fal-ai/veo3/fast/image-to-video/api>
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Veo3FastImageToVideoInput {
pub prompt: String,
/// URL of the image used as the starting frame.
pub image_url: String,
/// Aspect ratio.
/// Possible values: "auto", "16:9", "9:16". fal default: "auto".
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
/// Duration in seconds (as a string).
/// Possible values: "4s", "6s", "8s". fal default: "8s".
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<String>,
/// Output resolution.
/// Possible values: "720p", "1080p" (no 4k tier). fal default: "720p".
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<String>,
/// Whether to generate native audio for the video. fal default: true.
#[serde(skip_serializing_if = "Option::is_none")]
pub generate_audio: Option<bool>,
/// Optional negative prompt.
#[serde(skip_serializing_if = "Option::is_none")]
pub negative_prompt: Option<String>,
/// Seed for the random number generator.
#[serde(skip_serializing_if = "Option::is_none")]
pub seed: Option<i64>,
/// Whether to automatically rewrite prompts that fail moderation.
/// fal default: false (image-to-video).
#[serde(skip_serializing_if = "Option::is_none")]
pub auto_fix: Option<bool>,
/// Safety tolerance, "1" (strictest) .. "6" (least strict). fal default: "4".
#[serde(skip_serializing_if = "Option::is_none")]
pub safety_tolerance: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo3FastImageToVideoVideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo3FastImageToVideoOutput {
pub video: Veo3FastImageToVideoVideoFile,
}
@@ -0,0 +1,285 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::video::image::veo_3p1::raw_request::{
Veo3p1ImageToVideoInput, Veo3p1ImageToVideoOutput,
};
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
#[derive(Clone, Debug)]
pub struct Veo3p1ImageToVideoRequest {
/// Text prompt describing the video to generate.
pub prompt: String,
/// URL of the image used as the starting frame.
pub image_url: String,
/// Aspect ratio. fal's default is `auto` when `None`.
pub aspect_ratio: Option<Veo3p1ImageToVideoAspectRatio>,
/// Video duration. fal's default is `8s` when `None`.
pub duration: Option<Veo3p1ImageToVideoDuration>,
/// Output resolution. fal's default is `720p` when `None`.
pub resolution: Option<Veo3p1ImageToVideoResolution>,
/// Whether to generate native audio. fal's server default is `true` when
/// this is `None`.
pub generate_audio: Option<bool>,
/// Optional negative prompt.
pub negative_prompt: Option<String>,
/// Seed for the random number generator.
pub seed: Option<i64>,
/// Whether to auto-rewrite prompts that fail moderation. fal's default is
/// `false` for image-to-video when `None`.
pub auto_fix: Option<bool>,
/// Safety tolerance. fal's default is `4` when `None`.
pub safety_tolerance: Option<Veo3p1ImageToVideoSafetyTolerance>,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1ImageToVideoAspectRatio {
Auto,
SixteenByNine,
NineBySixteen,
}
impl Veo3p1ImageToVideoAspectRatio {
fn to_str(&self) -> &'static str {
match self {
Self::Auto => "auto",
Self::SixteenByNine => "16:9",
Self::NineBySixteen => "9:16",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1ImageToVideoDuration {
FourSeconds,
SixSeconds,
EightSeconds,
}
impl Veo3p1ImageToVideoDuration {
pub fn to_seconds(&self) -> u64 {
match self {
Self::FourSeconds => 4,
Self::SixSeconds => 6,
Self::EightSeconds => 8,
}
}
fn to_str(&self) -> &'static str {
match self {
Self::FourSeconds => "4s",
Self::SixSeconds => "6s",
Self::EightSeconds => "8s",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1ImageToVideoResolution {
SevenTwentyP,
TenEightyP,
FourK,
}
impl Veo3p1ImageToVideoResolution {
/// 4k output is billed at a higher per-second rate than 720p/1080p.
pub fn is_four_k(&self) -> bool {
matches!(self, Self::FourK)
}
fn to_str(&self) -> &'static str {
match self {
Self::SevenTwentyP => "720p",
Self::TenEightyP => "1080p",
Self::FourK => "4k",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1ImageToVideoSafetyTolerance {
/// "1" — strictest.
Level1,
Level2,
Level3,
/// "4" — fal default.
Level4,
Level5,
/// "6" — least strict.
Level6,
}
impl Veo3p1ImageToVideoSafetyTolerance {
fn to_str(&self) -> &'static str {
match self {
Self::Level1 => "1",
Self::Level2 => "2",
Self::Level3 => "3",
Self::Level4 => "4",
Self::Level5 => "5",
Self::Level6 => "6",
}
}
}
impl FalEndpoint for Veo3p1ImageToVideoRequest {
const ENDPOINT: &str = "fal-ai/veo3.1/image-to-video";
type RawRequest = Veo3p1ImageToVideoInput;
type RawResponse = Veo3p1ImageToVideoOutput;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
Ok(Self::RawRequest {
prompt: self.prompt.clone(),
image_url: self.image_url.clone(),
aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()),
duration: self.duration.map(|d| d.to_str().to_string()),
resolution: self.resolution.map(|r| r.to_str().to_string()),
generate_audio: self.generate_audio,
negative_prompt: self.negative_prompt.clone(),
seed: self.seed,
auto_fix: self.auto_fix,
safety_tolerance: self.safety_tolerance.map(|s| s.to_str().to_string()),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
use errors::AnyhowResult;
use std::fs::read_to_string;
use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL;
// ── Real requests (manually run; require a live key and cost money) ──
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_image_to_video_webhook() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Veo3p1ImageToVideoRequest {
prompt: "the lake comes alive with gentle ripples and dappled sunlight".to_string(),
image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(),
aspect_ratio: Some(Veo3p1ImageToVideoAspectRatio::SixteenByNine),
duration: Some(Veo3p1ImageToVideoDuration::FourSeconds),
resolution: Some(Veo3p1ImageToVideoResolution::SevenTwentyP),
generate_audio: Some(false),
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
};
let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?;
println!("Webhook result: {:?}", result);
assert!(result.request_id.is_some() || result.gateway_request_id.is_some());
Ok(())
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_image_to_video_queue() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Veo3p1ImageToVideoRequest {
prompt: "wind moves through the trees".to_string(),
image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(),
aspect_ratio: None,
duration: Some(Veo3p1ImageToVideoDuration::FourSeconds),
resolution: Some(Veo3p1ImageToVideoResolution::SevenTwentyP),
generate_audio: Some(false),
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
};
let result = request.send_queue_request(&api_key).await?;
println!("Queue result — request_id: {}", result.request_id);
assert!(!result.request_id.is_empty());
Ok(())
}
// ── Wire-shape sanity (no API calls) ──
#[test]
fn raw_request_maps_all_fields() {
let request = Veo3p1ImageToVideoRequest {
prompt: "p".to_string(),
image_url: "https://example.com/start.png".to_string(),
aspect_ratio: Some(Veo3p1ImageToVideoAspectRatio::Auto),
duration: Some(Veo3p1ImageToVideoDuration::SixSeconds),
resolution: Some(Veo3p1ImageToVideoResolution::TenEightyP),
generate_audio: Some(false),
negative_prompt: Some("nope".to_string()),
seed: Some(7),
auto_fix: Some(true),
safety_tolerance: Some(Veo3p1ImageToVideoSafetyTolerance::Level2),
};
let raw = request.to_raw_request().unwrap();
assert_eq!(raw.prompt, "p");
assert_eq!(raw.image_url, "https://example.com/start.png");
assert_eq!(raw.aspect_ratio.as_deref(), Some("auto"));
assert_eq!(raw.duration.as_deref(), Some("6s"));
assert_eq!(raw.resolution.as_deref(), Some("1080p"));
assert_eq!(raw.generate_audio, Some(false));
assert_eq!(raw.negative_prompt.as_deref(), Some("nope"));
assert_eq!(raw.seed, Some(7));
assert_eq!(raw.auto_fix, Some(true));
assert_eq!(raw.safety_tolerance.as_deref(), Some("2"));
}
#[test]
fn raw_request_omits_unset_optionals() {
let request = Veo3p1ImageToVideoRequest {
prompt: "p".to_string(),
image_url: "https://example.com/start.png".to_string(),
aspect_ratio: None,
duration: None,
resolution: None,
generate_audio: None,
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(
json,
serde_json::json!({ "prompt": "p", "image_url": "https://example.com/start.png" }),
);
}
#[test]
fn every_aspect_ratio_maps_to_wire_string() {
for (variant, expected) in [
(Veo3p1ImageToVideoAspectRatio::Auto, "auto"),
(Veo3p1ImageToVideoAspectRatio::SixteenByNine, "16:9"),
(Veo3p1ImageToVideoAspectRatio::NineBySixteen, "9:16"),
] {
assert_eq!(variant.to_str(), expected);
}
}
#[test]
fn endpoint_path_is_canonical() {
assert_eq!(
Veo3p1ImageToVideoRequest::ENDPOINT,
"fal-ai/veo3.1/image-to-video",
);
}
// NB: Pricing tests are in cost.rs
}
@@ -0,0 +1,112 @@
use crate::requests::api::video::image::veo_3p1::api::{
Veo3p1ImageToVideoDuration, Veo3p1ImageToVideoRequest, Veo3p1ImageToVideoResolution,
};
use crate::requests::api::video::text::veo_3p1::cost::{
veo_3p1_cost_cents, veo_3p1_rate_tenth_cents_per_sec,
};
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
// Image-to-video shares the Veo 3.1 (non-fast) per-second pricing (see the
// text module for the canonical rate table):
// 720p / 1080p: $0.20/sec (audio off), $0.40/sec (audio on)
// 4k: $0.40/sec (audio off), $0.60/sec (audio on)
impl FalRequestCostCalculator for Veo3p1ImageToVideoRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
// fal defaults when unset: duration = 8s, resolution = 720p, audio = on.
let duration_secs = self.duration
.unwrap_or(Veo3p1ImageToVideoDuration::EightSeconds)
.to_seconds();
let four_k = self.resolution
.unwrap_or(Veo3p1ImageToVideoResolution::SevenTwentyP)
.is_four_k();
let audio_on = self.generate_audio.unwrap_or(true);
let rate = veo_3p1_rate_tenth_cents_per_sec(four_k, audio_on);
veo_3p1_cost_cents(rate, duration_secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::requests::api::video::image::veo_3p1::api::Veo3p1ImageToVideoAspectRatio;
fn make_request(
duration: Option<Veo3p1ImageToVideoDuration>,
resolution: Option<Veo3p1ImageToVideoResolution>,
generate_audio: Option<bool>,
) -> Veo3p1ImageToVideoRequest {
Veo3p1ImageToVideoRequest {
prompt: "test".to_string(),
image_url: "https://example.com/x.png".to_string(),
aspect_ratio: Some(Veo3p1ImageToVideoAspectRatio::SixteenByNine),
duration,
resolution,
generate_audio,
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
}
}
mod cost_table {
use super::*;
// (duration, resolution, generate_audio, expected_cents)
const COST_TABLE: &[(
Option<Veo3p1ImageToVideoDuration>,
Option<Veo3p1ImageToVideoResolution>,
Option<bool>,
u64,
)] = &[
(Some(Veo3p1ImageToVideoDuration::FourSeconds), Some(Veo3p1ImageToVideoResolution::SevenTwentyP), Some(false), 80),
(Some(Veo3p1ImageToVideoDuration::FourSeconds), Some(Veo3p1ImageToVideoResolution::SevenTwentyP), Some(true), 160),
(Some(Veo3p1ImageToVideoDuration::EightSeconds), Some(Veo3p1ImageToVideoResolution::TenEightyP), Some(false), 160),
(Some(Veo3p1ImageToVideoDuration::EightSeconds), Some(Veo3p1ImageToVideoResolution::TenEightyP), Some(true), 320),
(Some(Veo3p1ImageToVideoDuration::EightSeconds), Some(Veo3p1ImageToVideoResolution::FourK), Some(false), 320),
(Some(Veo3p1ImageToVideoDuration::EightSeconds), Some(Veo3p1ImageToVideoResolution::FourK), Some(true), 480),
// Defaults: 8s, 720p, audio on
(None, None, None, 320),
];
#[test]
fn matches_cost_table() {
for &(duration, resolution, generate_audio, expected) in COST_TABLE {
let got = make_request(duration, resolution, generate_audio).calculate_cost_in_cents();
assert_eq!(
got, expected,
"duration={duration:?} resolution={resolution:?} audio={generate_audio:?}",
);
}
}
#[test]
fn cost_is_independent_of_aspect_ratio() {
let aspect_ratios = [
Veo3p1ImageToVideoAspectRatio::Auto,
Veo3p1ImageToVideoAspectRatio::SixteenByNine,
Veo3p1ImageToVideoAspectRatio::NineBySixteen,
];
let baseline = {
let mut r = make_request(
Some(Veo3p1ImageToVideoDuration::EightSeconds),
Some(Veo3p1ImageToVideoResolution::TenEightyP),
Some(true),
);
r.aspect_ratio = Some(aspect_ratios[0]);
r.calculate_cost_in_cents()
};
for ar in aspect_ratios {
let mut r = make_request(
Some(Veo3p1ImageToVideoDuration::EightSeconds),
Some(Veo3p1ImageToVideoResolution::TenEightyP),
Some(true),
);
r.aspect_ratio = Some(ar);
assert_eq!(r.calculate_cost_in_cents(), baseline, "ar={ar:?}");
}
}
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -0,0 +1,58 @@
use serde::{Deserialize, Serialize};
/// Over-the-wire input shape for `fal-ai/veo3.1/image-to-video`
/// (single start-frame keyframe).
/// fal's schema: <https://fal.ai/models/fal-ai/veo3.1/image-to-video/api>
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Veo3p1ImageToVideoInput {
pub prompt: String,
/// URL of the image used as the starting frame.
pub image_url: String,
/// Aspect ratio.
/// Possible values: "auto", "16:9", "9:16". fal default: "auto".
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
/// Duration in seconds (as a string).
/// Possible values: "4s", "6s", "8s". fal default: "8s".
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<String>,
/// Output resolution.
/// Possible values: "720p", "1080p", "4k". fal default: "720p".
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<String>,
/// Whether to generate native audio for the video. fal default: true.
#[serde(skip_serializing_if = "Option::is_none")]
pub generate_audio: Option<bool>,
/// Optional negative prompt.
#[serde(skip_serializing_if = "Option::is_none")]
pub negative_prompt: Option<String>,
/// Seed for the random number generator.
#[serde(skip_serializing_if = "Option::is_none")]
pub seed: Option<i64>,
/// Whether to automatically rewrite prompts that fail moderation.
/// fal default: false (image-to-video).
#[serde(skip_serializing_if = "Option::is_none")]
pub auto_fix: Option<bool>,
/// Safety tolerance, "1" (strictest) .. "6" (least strict). fal default: "4".
#[serde(skip_serializing_if = "Option::is_none")]
pub safety_tolerance: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo3p1ImageToVideoVideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo3p1ImageToVideoOutput {
pub video: Veo3p1ImageToVideoVideoFile,
}
@@ -0,0 +1,286 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::video::image::veo_3p1_fast::raw_request::{
Veo3p1FastImageToVideoInput, Veo3p1FastImageToVideoOutput,
};
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
#[derive(Clone, Debug)]
pub struct Veo3p1FastImageToVideoRequest {
/// Text prompt describing the video to generate.
pub prompt: String,
/// URL of the image used as the starting frame.
pub image_url: String,
/// Aspect ratio. fal's default is `auto` when `None`.
pub aspect_ratio: Option<Veo3p1FastImageToVideoAspectRatio>,
/// Video duration. fal's default is `8s` when `None`.
pub duration: Option<Veo3p1FastImageToVideoDuration>,
/// Output resolution. fal's default is `720p` when `None`.
pub resolution: Option<Veo3p1FastImageToVideoResolution>,
/// Whether to generate native audio. fal's server default is `true` when
/// this is `None`.
pub generate_audio: Option<bool>,
/// Optional negative prompt.
pub negative_prompt: Option<String>,
/// Seed for the random number generator.
pub seed: Option<i64>,
/// Whether to auto-rewrite prompts that fail moderation. fal's default is
/// `false` for image-to-video when `None`.
pub auto_fix: Option<bool>,
/// Safety tolerance. fal's default is `4` when `None`.
pub safety_tolerance: Option<Veo3p1FastImageToVideoSafetyTolerance>,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1FastImageToVideoAspectRatio {
Auto,
SixteenByNine,
NineBySixteen,
}
impl Veo3p1FastImageToVideoAspectRatio {
fn to_str(&self) -> &'static str {
match self {
Self::Auto => "auto",
Self::SixteenByNine => "16:9",
Self::NineBySixteen => "9:16",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1FastImageToVideoDuration {
FourSeconds,
SixSeconds,
EightSeconds,
}
impl Veo3p1FastImageToVideoDuration {
pub fn to_seconds(&self) -> u64 {
match self {
Self::FourSeconds => 4,
Self::SixSeconds => 6,
Self::EightSeconds => 8,
}
}
fn to_str(&self) -> &'static str {
match self {
Self::FourSeconds => "4s",
Self::SixSeconds => "6s",
Self::EightSeconds => "8s",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1FastImageToVideoResolution {
SevenTwentyP,
TenEightyP,
FourK,
}
impl Veo3p1FastImageToVideoResolution {
/// 4k output is billed at a higher per-second rate than 720p/1080p.
pub fn is_four_k(&self) -> bool {
matches!(self, Self::FourK)
}
fn to_str(&self) -> &'static str {
match self {
Self::SevenTwentyP => "720p",
Self::TenEightyP => "1080p",
Self::FourK => "4k",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1FastImageToVideoSafetyTolerance {
/// "1" — strictest.
Level1,
Level2,
Level3,
/// "4" — fal default.
Level4,
Level5,
/// "6" — least strict.
Level6,
}
impl Veo3p1FastImageToVideoSafetyTolerance {
fn to_str(&self) -> &'static str {
match self {
Self::Level1 => "1",
Self::Level2 => "2",
Self::Level3 => "3",
Self::Level4 => "4",
Self::Level5 => "5",
Self::Level6 => "6",
}
}
}
impl FalEndpoint for Veo3p1FastImageToVideoRequest {
const ENDPOINT: &str = "fal-ai/veo3.1/fast/image-to-video";
type RawRequest = Veo3p1FastImageToVideoInput;
type RawResponse = Veo3p1FastImageToVideoOutput;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
Ok(Self::RawRequest {
prompt: self.prompt.clone(),
image_url: self.image_url.clone(),
aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()),
duration: self.duration.map(|d| d.to_str().to_string()),
resolution: self.resolution.map(|r| r.to_str().to_string()),
generate_audio: self.generate_audio,
negative_prompt: self.negative_prompt.clone(),
seed: self.seed,
auto_fix: self.auto_fix,
safety_tolerance: self.safety_tolerance.map(|s| s.to_str().to_string()),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
use errors::AnyhowResult;
use std::fs::read_to_string;
use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL;
// ── Real requests (manually run; require a live key and cost money) ──
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_image_to_video_webhook() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Veo3p1FastImageToVideoRequest {
prompt: "the lake comes alive with gentle ripples and dappled sunlight".to_string(),
image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(),
aspect_ratio: Some(Veo3p1FastImageToVideoAspectRatio::SixteenByNine),
duration: Some(Veo3p1FastImageToVideoDuration::FourSeconds),
resolution: Some(Veo3p1FastImageToVideoResolution::SevenTwentyP),
generate_audio: Some(false),
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
};
let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?;
println!("Webhook result: {:?}", result);
assert!(result.request_id.is_some() || result.gateway_request_id.is_some());
Ok(())
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_image_to_video_queue() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Veo3p1FastImageToVideoRequest {
prompt: "wind moves through the trees".to_string(),
image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(),
aspect_ratio: None,
duration: Some(Veo3p1FastImageToVideoDuration::FourSeconds),
resolution: Some(Veo3p1FastImageToVideoResolution::SevenTwentyP),
generate_audio: Some(false),
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
};
let result = request.send_queue_request(&api_key).await?;
println!("Queue result — request_id: {}", result.request_id);
assert!(!result.request_id.is_empty());
Ok(())
}
// ── Wire-shape sanity (no API calls) ──
#[test]
fn raw_request_maps_all_fields() {
let request = Veo3p1FastImageToVideoRequest {
prompt: "p".to_string(),
image_url: "https://example.com/start.png".to_string(),
aspect_ratio: Some(Veo3p1FastImageToVideoAspectRatio::Auto),
duration: Some(Veo3p1FastImageToVideoDuration::SixSeconds),
resolution: Some(Veo3p1FastImageToVideoResolution::TenEightyP),
generate_audio: Some(false),
negative_prompt: Some("nope".to_string()),
seed: Some(7),
auto_fix: Some(true),
safety_tolerance: Some(Veo3p1FastImageToVideoSafetyTolerance::Level2),
};
let raw = request.to_raw_request().unwrap();
assert_eq!(raw.prompt, "p");
assert_eq!(raw.image_url, "https://example.com/start.png");
assert_eq!(raw.aspect_ratio.as_deref(), Some("auto"));
assert_eq!(raw.duration.as_deref(), Some("6s"));
assert_eq!(raw.resolution.as_deref(), Some("1080p"));
assert_eq!(raw.generate_audio, Some(false));
assert_eq!(raw.negative_prompt.as_deref(), Some("nope"));
assert_eq!(raw.seed, Some(7));
assert_eq!(raw.auto_fix, Some(true));
assert_eq!(raw.safety_tolerance.as_deref(), Some("2"));
}
#[test]
fn raw_request_omits_unset_optionals() {
let request = Veo3p1FastImageToVideoRequest {
prompt: "p".to_string(),
image_url: "https://example.com/start.png".to_string(),
aspect_ratio: None,
duration: None,
resolution: None,
generate_audio: None,
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
// Only the two required fields remain on the wire.
assert_eq!(
json,
serde_json::json!({ "prompt": "p", "image_url": "https://example.com/start.png" }),
);
}
#[test]
fn every_aspect_ratio_maps_to_wire_string() {
for (variant, expected) in [
(Veo3p1FastImageToVideoAspectRatio::Auto, "auto"),
(Veo3p1FastImageToVideoAspectRatio::SixteenByNine, "16:9"),
(Veo3p1FastImageToVideoAspectRatio::NineBySixteen, "9:16"),
] {
assert_eq!(variant.to_str(), expected);
}
}
#[test]
fn endpoint_path_is_canonical() {
assert_eq!(
Veo3p1FastImageToVideoRequest::ENDPOINT,
"fal-ai/veo3.1/fast/image-to-video",
);
}
// NB: Pricing tests are in cost.rs
}
@@ -0,0 +1,112 @@
use crate::requests::api::video::image::veo_3p1_fast::api::{
Veo3p1FastImageToVideoDuration, Veo3p1FastImageToVideoRequest, Veo3p1FastImageToVideoResolution,
};
use crate::requests::api::video::text::veo_3p1_fast::cost::{
veo_3p1_fast_cost_cents, veo_3p1_fast_rate_tenth_cents_per_sec,
};
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
// Image-to-video shares the Veo 3.1 Fast per-second pricing (see the text
// module for the canonical rate table):
// 720p / 1080p: $0.10/sec (audio off), $0.15/sec (audio on)
// 4k: $0.30/sec (audio off), $0.35/sec (audio on)
impl FalRequestCostCalculator for Veo3p1FastImageToVideoRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
// fal defaults when unset: duration = 8s, resolution = 720p, audio = on.
let duration_secs = self.duration
.unwrap_or(Veo3p1FastImageToVideoDuration::EightSeconds)
.to_seconds();
let four_k = self.resolution
.unwrap_or(Veo3p1FastImageToVideoResolution::SevenTwentyP)
.is_four_k();
let audio_on = self.generate_audio.unwrap_or(true);
let rate = veo_3p1_fast_rate_tenth_cents_per_sec(four_k, audio_on);
veo_3p1_fast_cost_cents(rate, duration_secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::requests::api::video::image::veo_3p1_fast::api::Veo3p1FastImageToVideoAspectRatio;
fn make_request(
duration: Option<Veo3p1FastImageToVideoDuration>,
resolution: Option<Veo3p1FastImageToVideoResolution>,
generate_audio: Option<bool>,
) -> Veo3p1FastImageToVideoRequest {
Veo3p1FastImageToVideoRequest {
prompt: "test".to_string(),
image_url: "https://example.com/x.png".to_string(),
aspect_ratio: Some(Veo3p1FastImageToVideoAspectRatio::SixteenByNine),
duration,
resolution,
generate_audio,
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
}
}
mod cost_table {
use super::*;
// (duration, resolution, generate_audio, expected_cents)
const COST_TABLE: &[(
Option<Veo3p1FastImageToVideoDuration>,
Option<Veo3p1FastImageToVideoResolution>,
Option<bool>,
u64,
)] = &[
(Some(Veo3p1FastImageToVideoDuration::FourSeconds), Some(Veo3p1FastImageToVideoResolution::SevenTwentyP), Some(false), 40),
(Some(Veo3p1FastImageToVideoDuration::FourSeconds), Some(Veo3p1FastImageToVideoResolution::SevenTwentyP), Some(true), 60),
(Some(Veo3p1FastImageToVideoDuration::EightSeconds), Some(Veo3p1FastImageToVideoResolution::TenEightyP), Some(false), 80),
(Some(Veo3p1FastImageToVideoDuration::EightSeconds), Some(Veo3p1FastImageToVideoResolution::TenEightyP), Some(true), 120),
(Some(Veo3p1FastImageToVideoDuration::EightSeconds), Some(Veo3p1FastImageToVideoResolution::FourK), Some(false), 240),
(Some(Veo3p1FastImageToVideoDuration::EightSeconds), Some(Veo3p1FastImageToVideoResolution::FourK), Some(true), 280),
// Defaults: 8s, 720p, audio on
(None, None, None, 120),
];
#[test]
fn matches_cost_table() {
for &(duration, resolution, generate_audio, expected) in COST_TABLE {
let got = make_request(duration, resolution, generate_audio).calculate_cost_in_cents();
assert_eq!(
got, expected,
"duration={duration:?} resolution={resolution:?} audio={generate_audio:?}",
);
}
}
#[test]
fn cost_is_independent_of_aspect_ratio() {
let aspect_ratios = [
Veo3p1FastImageToVideoAspectRatio::Auto,
Veo3p1FastImageToVideoAspectRatio::SixteenByNine,
Veo3p1FastImageToVideoAspectRatio::NineBySixteen,
];
let baseline = {
let mut r = make_request(
Some(Veo3p1FastImageToVideoDuration::EightSeconds),
Some(Veo3p1FastImageToVideoResolution::TenEightyP),
Some(true),
);
r.aspect_ratio = Some(aspect_ratios[0]);
r.calculate_cost_in_cents()
};
for ar in aspect_ratios {
let mut r = make_request(
Some(Veo3p1FastImageToVideoDuration::EightSeconds),
Some(Veo3p1FastImageToVideoResolution::TenEightyP),
Some(true),
);
r.aspect_ratio = Some(ar);
assert_eq!(r.calculate_cost_in_cents(), baseline, "ar={ar:?}");
}
}
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -0,0 +1,58 @@
use serde::{Deserialize, Serialize};
/// Over-the-wire input shape for `fal-ai/veo3.1/fast/image-to-video`
/// (single start-frame keyframe).
/// fal's schema: <https://fal.ai/models/fal-ai/veo3.1/fast/image-to-video/api>
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Veo3p1FastImageToVideoInput {
pub prompt: String,
/// URL of the image used as the starting frame.
pub image_url: String,
/// Aspect ratio.
/// Possible values: "auto", "16:9", "9:16". fal default: "auto".
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
/// Duration in seconds (as a string).
/// Possible values: "4s", "6s", "8s". fal default: "8s".
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<String>,
/// Output resolution.
/// Possible values: "720p", "1080p", "4k". fal default: "720p".
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<String>,
/// Whether to generate native audio for the video. fal default: true.
#[serde(skip_serializing_if = "Option::is_none")]
pub generate_audio: Option<bool>,
/// Optional negative prompt.
#[serde(skip_serializing_if = "Option::is_none")]
pub negative_prompt: Option<String>,
/// Seed for the random number generator.
#[serde(skip_serializing_if = "Option::is_none")]
pub seed: Option<i64>,
/// Whether to automatically rewrite prompts that fail moderation.
/// fal default: false (image-to-video).
#[serde(skip_serializing_if = "Option::is_none")]
pub auto_fix: Option<bool>,
/// Safety tolerance, "1" (strictest) .. "6" (least strict). fal default: "4".
#[serde(skip_serializing_if = "Option::is_none")]
pub safety_tolerance: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo3p1FastImageToVideoVideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo3p1FastImageToVideoOutput {
pub video: Veo3p1FastImageToVideoVideoFile,
}
@@ -0,0 +1,285 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::video::image::veo_3p1_lite::raw_request::{
Veo3p1LiteImageToVideoInput, Veo3p1LiteImageToVideoOutput,
};
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
#[derive(Clone, Debug)]
pub struct Veo3p1LiteImageToVideoRequest {
/// Text prompt describing the video to generate.
pub prompt: String,
/// URL of the image used as the starting frame.
pub image_url: String,
/// Aspect ratio. fal's default is `auto` when `None`.
pub aspect_ratio: Option<Veo3p1LiteImageToVideoAspectRatio>,
/// Video duration. fal's default is `8s` when `None`.
pub duration: Option<Veo3p1LiteImageToVideoDuration>,
/// Output resolution. fal's default is `720p` when `None`.
pub resolution: Option<Veo3p1LiteImageToVideoResolution>,
/// Whether to generate native audio. fal's server default is `true` when
/// this is `None`.
pub generate_audio: Option<bool>,
/// Optional negative prompt.
pub negative_prompt: Option<String>,
/// Seed for the random number generator.
pub seed: Option<i64>,
/// Whether to auto-rewrite prompts that fail moderation. fal's default is
/// `false` for image-to-video when `None`.
pub auto_fix: Option<bool>,
/// Safety tolerance. fal's default is `4` when `None`.
pub safety_tolerance: Option<Veo3p1LiteImageToVideoSafetyTolerance>,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1LiteImageToVideoAspectRatio {
Auto,
SixteenByNine,
NineBySixteen,
}
impl Veo3p1LiteImageToVideoAspectRatio {
fn to_str(&self) -> &'static str {
match self {
Self::Auto => "auto",
Self::SixteenByNine => "16:9",
Self::NineBySixteen => "9:16",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1LiteImageToVideoDuration {
FourSeconds,
SixSeconds,
EightSeconds,
}
impl Veo3p1LiteImageToVideoDuration {
pub fn to_seconds(&self) -> u64 {
match self {
Self::FourSeconds => 4,
Self::SixSeconds => 6,
Self::EightSeconds => 8,
}
}
fn to_str(&self) -> &'static str {
match self {
Self::FourSeconds => "4s",
Self::SixSeconds => "6s",
Self::EightSeconds => "8s",
}
}
}
/// Veo 3.1 Lite exposes only 720p/1080p (no 4k tier). Both resolution AND
/// audio affect the per-second price.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1LiteImageToVideoResolution {
SevenTwentyP,
TenEightyP,
}
impl Veo3p1LiteImageToVideoResolution {
/// 1080p is billed at a higher per-second rate than 720p.
pub fn is_ten_eighty_p(&self) -> bool {
matches!(self, Self::TenEightyP)
}
fn to_str(&self) -> &'static str {
match self {
Self::SevenTwentyP => "720p",
Self::TenEightyP => "1080p",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1LiteImageToVideoSafetyTolerance {
/// "1" — strictest.
Level1,
Level2,
Level3,
/// "4" — fal default.
Level4,
Level5,
/// "6" — least strict.
Level6,
}
impl Veo3p1LiteImageToVideoSafetyTolerance {
fn to_str(&self) -> &'static str {
match self {
Self::Level1 => "1",
Self::Level2 => "2",
Self::Level3 => "3",
Self::Level4 => "4",
Self::Level5 => "5",
Self::Level6 => "6",
}
}
}
impl FalEndpoint for Veo3p1LiteImageToVideoRequest {
const ENDPOINT: &str = "fal-ai/veo3.1/lite/image-to-video";
type RawRequest = Veo3p1LiteImageToVideoInput;
type RawResponse = Veo3p1LiteImageToVideoOutput;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
Ok(Self::RawRequest {
prompt: self.prompt.clone(),
image_url: self.image_url.clone(),
aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()),
duration: self.duration.map(|d| d.to_str().to_string()),
resolution: self.resolution.map(|r| r.to_str().to_string()),
generate_audio: self.generate_audio,
negative_prompt: self.negative_prompt.clone(),
seed: self.seed,
auto_fix: self.auto_fix,
safety_tolerance: self.safety_tolerance.map(|s| s.to_str().to_string()),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
use errors::AnyhowResult;
use std::fs::read_to_string;
use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL;
// ── Real requests (manually run; require a live key and cost money) ──
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_image_to_video_webhook() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Veo3p1LiteImageToVideoRequest {
prompt: "the lake comes alive with gentle ripples and dappled sunlight".to_string(),
image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(),
aspect_ratio: Some(Veo3p1LiteImageToVideoAspectRatio::SixteenByNine),
duration: Some(Veo3p1LiteImageToVideoDuration::FourSeconds),
resolution: Some(Veo3p1LiteImageToVideoResolution::SevenTwentyP),
generate_audio: Some(false),
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
};
let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?;
println!("Webhook result: {:?}", result);
assert!(result.request_id.is_some() || result.gateway_request_id.is_some());
Ok(())
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_image_to_video_queue() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Veo3p1LiteImageToVideoRequest {
prompt: "wind moves through the trees".to_string(),
image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(),
aspect_ratio: None,
duration: Some(Veo3p1LiteImageToVideoDuration::FourSeconds),
resolution: Some(Veo3p1LiteImageToVideoResolution::SevenTwentyP),
generate_audio: Some(false),
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
};
let result = request.send_queue_request(&api_key).await?;
println!("Queue result — request_id: {}", result.request_id);
assert!(!result.request_id.is_empty());
Ok(())
}
// ── Wire-shape sanity (no API calls) ──
#[test]
fn raw_request_maps_all_fields() {
let request = Veo3p1LiteImageToVideoRequest {
prompt: "p".to_string(),
image_url: "https://example.com/start.png".to_string(),
aspect_ratio: Some(Veo3p1LiteImageToVideoAspectRatio::Auto),
duration: Some(Veo3p1LiteImageToVideoDuration::SixSeconds),
resolution: Some(Veo3p1LiteImageToVideoResolution::TenEightyP),
generate_audio: Some(false),
negative_prompt: Some("nope".to_string()),
seed: Some(7),
auto_fix: Some(true),
safety_tolerance: Some(Veo3p1LiteImageToVideoSafetyTolerance::Level2),
};
let raw = request.to_raw_request().unwrap();
assert_eq!(raw.prompt, "p");
assert_eq!(raw.image_url, "https://example.com/start.png");
assert_eq!(raw.aspect_ratio.as_deref(), Some("auto"));
assert_eq!(raw.duration.as_deref(), Some("6s"));
assert_eq!(raw.resolution.as_deref(), Some("1080p"));
assert_eq!(raw.generate_audio, Some(false));
assert_eq!(raw.negative_prompt.as_deref(), Some("nope"));
assert_eq!(raw.seed, Some(7));
assert_eq!(raw.auto_fix, Some(true));
assert_eq!(raw.safety_tolerance.as_deref(), Some("2"));
}
#[test]
fn raw_request_omits_unset_optionals() {
let request = Veo3p1LiteImageToVideoRequest {
prompt: "p".to_string(),
image_url: "https://example.com/start.png".to_string(),
aspect_ratio: None,
duration: None,
resolution: None,
generate_audio: None,
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(
json,
serde_json::json!({ "prompt": "p", "image_url": "https://example.com/start.png" }),
);
}
#[test]
fn every_aspect_ratio_maps_to_wire_string() {
for (variant, expected) in [
(Veo3p1LiteImageToVideoAspectRatio::Auto, "auto"),
(Veo3p1LiteImageToVideoAspectRatio::SixteenByNine, "16:9"),
(Veo3p1LiteImageToVideoAspectRatio::NineBySixteen, "9:16"),
] {
assert_eq!(variant.to_str(), expected);
}
}
#[test]
fn endpoint_path_is_canonical() {
assert_eq!(
Veo3p1LiteImageToVideoRequest::ENDPOINT,
"fal-ai/veo3.1/lite/image-to-video",
);
}
// NB: Pricing tests are in cost.rs
}
@@ -0,0 +1,102 @@
use crate::requests::api::video::image::veo_3p1_lite::api::{
Veo3p1LiteImageToVideoDuration, Veo3p1LiteImageToVideoRequest, Veo3p1LiteImageToVideoResolution,
};
use crate::requests::api::video::text::veo_3p1_lite::cost::{
veo_3p1_lite_cost_cents, veo_3p1_lite_rate_tenth_cents_per_sec,
};
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
// Image-to-video shares the Veo 3.1 Lite per-second pricing (see the text
// module for the canonical rate table). Both resolution and audio matter:
// 720p: $0.03/sec (audio off), $0.05/sec (audio on)
// 1080p: $0.05/sec (audio off), $0.08/sec (audio on)
impl FalRequestCostCalculator for Veo3p1LiteImageToVideoRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
// fal defaults when unset: duration = 8s, resolution = 720p, audio = on.
let duration_secs = self.duration
.unwrap_or(Veo3p1LiteImageToVideoDuration::EightSeconds)
.to_seconds();
let is_1080p = self.resolution
.unwrap_or(Veo3p1LiteImageToVideoResolution::SevenTwentyP)
.is_ten_eighty_p();
let audio_on = self.generate_audio.unwrap_or(true);
let rate = veo_3p1_lite_rate_tenth_cents_per_sec(is_1080p, audio_on);
veo_3p1_lite_cost_cents(rate, duration_secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::requests::api::video::image::veo_3p1_lite::api::Veo3p1LiteImageToVideoAspectRatio;
fn make_request(
duration: Option<Veo3p1LiteImageToVideoDuration>,
resolution: Option<Veo3p1LiteImageToVideoResolution>,
generate_audio: Option<bool>,
) -> Veo3p1LiteImageToVideoRequest {
Veo3p1LiteImageToVideoRequest {
prompt: "test".to_string(),
image_url: "https://example.com/x.png".to_string(),
aspect_ratio: None,
duration,
resolution,
generate_audio,
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
}
}
mod cost_table {
use super::*;
// (duration, resolution, generate_audio, expected_cents)
const COST_TABLE: &[(
Option<Veo3p1LiteImageToVideoDuration>,
Option<Veo3p1LiteImageToVideoResolution>,
Option<bool>,
u64,
)] = &[
(Some(Veo3p1LiteImageToVideoDuration::FourSeconds), Some(Veo3p1LiteImageToVideoResolution::SevenTwentyP), Some(false), 12),
(Some(Veo3p1LiteImageToVideoDuration::FourSeconds), Some(Veo3p1LiteImageToVideoResolution::SevenTwentyP), Some(true), 20),
(Some(Veo3p1LiteImageToVideoDuration::EightSeconds), Some(Veo3p1LiteImageToVideoResolution::SevenTwentyP), Some(true), 40),
(Some(Veo3p1LiteImageToVideoDuration::EightSeconds), Some(Veo3p1LiteImageToVideoResolution::TenEightyP), Some(false), 40),
(Some(Veo3p1LiteImageToVideoDuration::EightSeconds), Some(Veo3p1LiteImageToVideoResolution::TenEightyP), Some(true), 64),
// Defaults: 8s, 720p, audio on
(None, None, None, 40),
];
#[test]
fn matches_cost_table() {
for &(duration, resolution, generate_audio, expected) in COST_TABLE {
let got = make_request(duration, resolution, generate_audio).calculate_cost_in_cents();
assert_eq!(
got, expected,
"duration={duration:?} resolution={resolution:?} audio={generate_audio:?}",
);
}
}
#[test]
fn cost_is_independent_of_aspect_ratio() {
let baseline = {
let mut r = make_request(Some(Veo3p1LiteImageToVideoDuration::EightSeconds), Some(Veo3p1LiteImageToVideoResolution::TenEightyP), Some(true));
r.aspect_ratio = Some(Veo3p1LiteImageToVideoAspectRatio::Auto);
r.calculate_cost_in_cents()
};
for ar in [
Veo3p1LiteImageToVideoAspectRatio::Auto,
Veo3p1LiteImageToVideoAspectRatio::SixteenByNine,
Veo3p1LiteImageToVideoAspectRatio::NineBySixteen,
] {
let mut r = make_request(Some(Veo3p1LiteImageToVideoDuration::EightSeconds), Some(Veo3p1LiteImageToVideoResolution::TenEightyP), Some(true));
r.aspect_ratio = Some(ar);
assert_eq!(r.calculate_cost_in_cents(), baseline, "ar={ar:?}");
}
}
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -0,0 +1,58 @@
use serde::{Deserialize, Serialize};
/// Over-the-wire input shape for `fal-ai/veo3.1/lite/image-to-video`
/// (single start-frame keyframe).
/// fal's schema: <https://fal.ai/models/fal-ai/veo3.1/lite/image-to-video/api>
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Veo3p1LiteImageToVideoInput {
pub prompt: String,
/// URL of the image used as the starting frame.
pub image_url: String,
/// Aspect ratio.
/// Possible values: "auto", "16:9", "9:16". fal default: "auto".
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
/// Duration in seconds (as a string).
/// Possible values: "4s", "6s", "8s". fal default: "8s".
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<String>,
/// Output resolution.
/// Possible values: "720p", "1080p" (no 4k tier). fal default: "720p".
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<String>,
/// Whether to generate native audio for the video. fal default: true.
#[serde(skip_serializing_if = "Option::is_none")]
pub generate_audio: Option<bool>,
/// Optional negative prompt.
#[serde(skip_serializing_if = "Option::is_none")]
pub negative_prompt: Option<String>,
/// Seed for the random number generator.
#[serde(skip_serializing_if = "Option::is_none")]
pub seed: Option<i64>,
/// Whether to automatically rewrite prompts that fail moderation.
/// fal default: false (image-to-video).
#[serde(skip_serializing_if = "Option::is_none")]
pub auto_fix: Option<bool>,
/// Safety tolerance, "1" (strictest) .. "6" (least strict). fal default: "4".
#[serde(skip_serializing_if = "Option::is_none")]
pub safety_tolerance: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo3p1LiteImageToVideoVideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo3p1LiteImageToVideoOutput {
pub video: Veo3p1LiteImageToVideoVideoFile,
}
@@ -0,0 +1,197 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::video::image::vidu_q3::raw_request::{
ViduQ3ImageToVideoInput, ViduQ3ImageToVideoOutput,
};
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
#[derive(Clone, Debug)]
pub struct ViduQ3ImageToVideoRequest {
/// Text prompt (max 2000 characters). Optional server-side; pass an empty
/// string for a pure image-to-video generation.
pub prompt: String,
/// URL of the image used as the starting frame (URL or base64).
pub image_url: String,
/// Optional URL of an ending frame, for start→end transition videos.
pub end_image_url: Option<String>,
/// Duration in seconds. Valid range 1–16; fal's default is `5` when `None`.
pub duration: Option<u8>,
/// Seed for reproducibility. Random when `None`.
pub seed: Option<i64>,
/// Output resolution. fal's default is `720p` when `None`. Note fal rejects
/// `360p` when `end_image_url` is set.
pub resolution: Option<ViduQ3ImageToVideoResolution>,
/// Whether to generate audio. fal's server default is `true` when `None`.
pub audio: Option<bool>,
}
/// Vidu Q3 resolutions. 720p/1080p bill at 2.2× the 360p/540p per-second rate.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum ViduQ3ImageToVideoResolution {
ThreeSixtyP,
FiveFortyP,
SevenTwentyP,
TenEightyP,
}
impl ViduQ3ImageToVideoResolution {
/// 720p and 1080p are billed at the higher (2.2×) per-second rate.
pub fn is_high_res(&self) -> bool {
matches!(self, Self::SevenTwentyP | Self::TenEightyP)
}
fn to_str(&self) -> &'static str {
match self {
Self::ThreeSixtyP => "360p",
Self::FiveFortyP => "540p",
Self::SevenTwentyP => "720p",
Self::TenEightyP => "1080p",
}
}
}
impl FalEndpoint for ViduQ3ImageToVideoRequest {
const ENDPOINT: &str = "fal-ai/vidu/q3/image-to-video";
type RawRequest = ViduQ3ImageToVideoInput;
type RawResponse = ViduQ3ImageToVideoOutput;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
Ok(Self::RawRequest {
prompt: self.prompt.clone(),
image_url: self.image_url.clone(),
end_image_url: self.end_image_url.clone(),
duration: self.duration,
seed: self.seed,
resolution: self.resolution.map(|r| r.to_str().to_string()),
audio: self.audio,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
use errors::AnyhowResult;
use std::fs::read_to_string;
use test_data::web::image_urls::{JUNO_AT_LAKE_IMAGE_URL, TALL_MOCHI_WITH_GLASSES_IMAGE_URL};
// ── Real requests (manually run; require a live key and cost money) ──
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_image_to_video_webhook() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = ViduQ3ImageToVideoRequest {
prompt: "the lake comes alive with gentle ripples and dappled sunlight".to_string(),
image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(),
end_image_url: None,
duration: Some(5),
seed: None,
resolution: Some(ViduQ3ImageToVideoResolution::SevenTwentyP),
audio: Some(false),
};
let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?;
println!("Webhook result: {:?}", result);
assert!(result.request_id.is_some() || result.gateway_request_id.is_some());
Ok(())
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_image_to_video_with_end_frame_queue() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = ViduQ3ImageToVideoRequest {
prompt: "a smooth transition between the two frames".to_string(),
image_url: TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(),
end_image_url: Some(JUNO_AT_LAKE_IMAGE_URL.to_string()),
duration: Some(5),
seed: Some(1234),
resolution: Some(ViduQ3ImageToVideoResolution::SevenTwentyP),
audio: Some(false),
};
let result = request.send_queue_request(&api_key).await?;
println!("Queue result — request_id: {}", result.request_id);
assert!(!result.request_id.is_empty());
Ok(())
}
// ── Wire-shape sanity (no API calls) ──
#[test]
fn raw_request_maps_all_fields() {
let request = ViduQ3ImageToVideoRequest {
prompt: "p".to_string(),
image_url: "https://example.com/start.png".to_string(),
end_image_url: Some("https://example.com/end.png".to_string()),
duration: Some(7),
seed: Some(42),
resolution: Some(ViduQ3ImageToVideoResolution::TenEightyP),
audio: Some(false),
};
let raw = request.to_raw_request().unwrap();
assert_eq!(raw.prompt, "p");
assert_eq!(raw.image_url, "https://example.com/start.png");
assert_eq!(raw.end_image_url.as_deref(), Some("https://example.com/end.png"));
assert_eq!(raw.duration, Some(7));
assert_eq!(raw.seed, Some(42));
assert_eq!(raw.resolution.as_deref(), Some("1080p"));
assert_eq!(raw.audio, Some(false));
}
#[test]
fn raw_request_omits_unset_optionals() {
let request = ViduQ3ImageToVideoRequest {
prompt: "p".to_string(),
image_url: "https://example.com/start.png".to_string(),
end_image_url: None,
duration: None,
seed: None,
resolution: None,
audio: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(
json,
serde_json::json!({ "prompt": "p", "image_url": "https://example.com/start.png" }),
);
}
#[test]
fn seed_serializes_when_set() {
let request = ViduQ3ImageToVideoRequest {
prompt: "p".to_string(),
image_url: "https://example.com/start.png".to_string(),
end_image_url: None,
duration: None,
seed: Some(777),
resolution: None,
audio: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(json.get("seed").and_then(|s| s.as_i64()), Some(777));
}
#[test]
fn endpoint_path_is_canonical() {
assert_eq!(
ViduQ3ImageToVideoRequest::ENDPOINT,
"fal-ai/vidu/q3/image-to-video",
);
}
// NB: Pricing tests are in cost.rs
}
@@ -0,0 +1,78 @@
use crate::requests::api::video::image::vidu_q3::api::{
ViduQ3ImageToVideoRequest, ViduQ3ImageToVideoResolution,
};
use crate::requests::api::video::text::vidu_q3::cost::{vidu_q3_cost_cents, vidu_q3_rate_tenth_cents_per_sec};
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
// Image-to-video shares the Vidu Q3 per-second pricing (see the text module for
// the canonical rate table):
// 360p / 540p: $0.07/sec
// 720p / 1080p: $0.154/sec (2.2×)
// Pricing depends on resolution and duration only.
impl FalRequestCostCalculator for ViduQ3ImageToVideoRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
// fal defaults when unset: duration = 5s, resolution = 720p.
let duration_secs = u64::from(self.duration.unwrap_or(5));
let is_high_res = self.resolution
.unwrap_or(ViduQ3ImageToVideoResolution::SevenTwentyP)
.is_high_res();
let rate = vidu_q3_rate_tenth_cents_per_sec(is_high_res);
vidu_q3_cost_cents(rate, duration_secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_request(
duration: Option<u8>,
resolution: Option<ViduQ3ImageToVideoResolution>,
) -> ViduQ3ImageToVideoRequest {
ViduQ3ImageToVideoRequest {
prompt: "test".to_string(),
image_url: "https://example.com/x.png".to_string(),
end_image_url: None,
duration,
seed: None,
resolution,
audio: Some(true),
}
}
mod cost_table {
use super::*;
// (duration, resolution, expected_cents)
const COST_TABLE: &[(Option<u8>, Option<ViduQ3ImageToVideoResolution>, u64)] = &[
(Some(5), Some(ViduQ3ImageToVideoResolution::ThreeSixtyP), 35),
(Some(5), Some(ViduQ3ImageToVideoResolution::FiveFortyP), 35),
(Some(5), Some(ViduQ3ImageToVideoResolution::SevenTwentyP), 77),
(Some(5), Some(ViduQ3ImageToVideoResolution::TenEightyP), 77),
(Some(10), Some(ViduQ3ImageToVideoResolution::SevenTwentyP), 154),
// Defaults: duration=None→5s, resolution=None→720p
(None, None, 77),
];
#[test]
fn matches_cost_table() {
for &(duration, resolution, expected) in COST_TABLE {
let got = make_request(duration, resolution).calculate_cost_in_cents();
assert_eq!(got, expected, "duration={duration:?} resolution={resolution:?}");
}
}
/// An end frame does not change the bill (only resolution + duration do).
#[test]
fn cost_is_independent_of_end_frame() {
let without = make_request(Some(8), Some(ViduQ3ImageToVideoResolution::SevenTwentyP)).calculate_cost_in_cents();
let with = ViduQ3ImageToVideoRequest {
end_image_url: Some("https://example.com/end.png".to_string()),
..make_request(Some(8), Some(ViduQ3ImageToVideoResolution::SevenTwentyP))
}.calculate_cost_in_cents();
assert_eq!(without, with);
}
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -0,0 +1,47 @@
use serde::{Deserialize, Serialize};
/// Over-the-wire input shape for `fal-ai/vidu/q3/image-to-video`.
/// fal's schema: <https://fal.ai/models/fal-ai/vidu/q3/image-to-video/api>
///
/// NB: this endpoint has no `aspect_ratio` (it is derived from the input
/// image). A `prompt` is optional server-side but always sent here.
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct ViduQ3ImageToVideoInput {
/// Text prompt (max 2000 characters). Optional server-side.
pub prompt: String,
/// URL of the image used as the starting frame (URL or base64).
pub image_url: String,
/// Optional URL of an ending frame, for start→end transition videos.
#[serde(skip_serializing_if = "Option::is_none")]
pub end_image_url: Option<String>,
/// Duration in seconds. Range 1–16. fal default: 5.
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<u8>,
/// Seed for reproducibility. Random when omitted.
#[serde(skip_serializing_if = "Option::is_none")]
pub seed: Option<i64>,
/// Output resolution.
/// Possible values: "360p", "540p", "720p", "1080p". fal default: "720p".
/// (360p is unavailable when `end_image_url` is set.)
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<String>,
/// Whether to generate audio for the video. fal default: true.
#[serde(skip_serializing_if = "Option::is_none")]
pub audio: Option<bool>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ViduQ3ImageToVideoVideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ViduQ3ImageToVideoOutput {
pub video: ViduQ3ImageToVideoVideoFile,
}
@@ -0,0 +1,197 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::video::image::vidu_q3_turbo::raw_request::{
ViduQ3TurboImageToVideoInput, ViduQ3TurboImageToVideoOutput,
};
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
#[derive(Clone, Debug)]
pub struct ViduQ3TurboImageToVideoRequest {
/// Text prompt (max 2000 characters). Optional server-side; pass an empty
/// string for a pure image-to-video generation.
pub prompt: String,
/// URL of the image used as the starting frame (URL or base64).
pub image_url: String,
/// Optional URL of an ending frame, for start→end transition videos.
pub end_image_url: Option<String>,
/// Duration in seconds. Valid range 1–16; fal's default is `5` when `None`.
pub duration: Option<u8>,
/// Seed for reproducibility. Random when `None`.
pub seed: Option<i64>,
/// Output resolution. fal's default is `720p` when `None`. Note fal rejects
/// `360p` when `end_image_url` is set.
pub resolution: Option<ViduQ3TurboImageToVideoResolution>,
/// Whether to generate audio. fal's server default is `true` when `None`.
pub audio: Option<bool>,
}
/// Vidu Q3 Turbo resolutions. 720p/1080p bill at 2.2× the 360p/540p rate.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum ViduQ3TurboImageToVideoResolution {
ThreeSixtyP,
FiveFortyP,
SevenTwentyP,
TenEightyP,
}
impl ViduQ3TurboImageToVideoResolution {
/// 720p and 1080p are billed at the higher (2.2×) per-second rate.
pub fn is_high_res(&self) -> bool {
matches!(self, Self::SevenTwentyP | Self::TenEightyP)
}
fn to_str(&self) -> &'static str {
match self {
Self::ThreeSixtyP => "360p",
Self::FiveFortyP => "540p",
Self::SevenTwentyP => "720p",
Self::TenEightyP => "1080p",
}
}
}
impl FalEndpoint for ViduQ3TurboImageToVideoRequest {
const ENDPOINT: &str = "fal-ai/vidu/q3/image-to-video/turbo";
type RawRequest = ViduQ3TurboImageToVideoInput;
type RawResponse = ViduQ3TurboImageToVideoOutput;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
Ok(Self::RawRequest {
prompt: self.prompt.clone(),
image_url: self.image_url.clone(),
end_image_url: self.end_image_url.clone(),
duration: self.duration,
seed: self.seed,
resolution: self.resolution.map(|r| r.to_str().to_string()),
audio: self.audio,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
use errors::AnyhowResult;
use std::fs::read_to_string;
use test_data::web::image_urls::{JUNO_AT_LAKE_IMAGE_URL, TALL_MOCHI_WITH_GLASSES_IMAGE_URL};
// ── Real requests (manually run; require a live key and cost money) ──
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_image_to_video_webhook() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = ViduQ3TurboImageToVideoRequest {
prompt: "the lake comes alive with gentle ripples and dappled sunlight".to_string(),
image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(),
end_image_url: None,
duration: Some(5),
seed: None,
resolution: Some(ViduQ3TurboImageToVideoResolution::SevenTwentyP),
audio: Some(false),
};
let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?;
println!("Webhook result: {:?}", result);
assert!(result.request_id.is_some() || result.gateway_request_id.is_some());
Ok(())
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_image_to_video_with_end_frame_queue() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = ViduQ3TurboImageToVideoRequest {
prompt: "a smooth transition between the two frames".to_string(),
image_url: TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(),
end_image_url: Some(JUNO_AT_LAKE_IMAGE_URL.to_string()),
duration: Some(5),
seed: Some(1234),
resolution: Some(ViduQ3TurboImageToVideoResolution::SevenTwentyP),
audio: Some(false),
};
let result = request.send_queue_request(&api_key).await?;
println!("Queue result — request_id: {}", result.request_id);
assert!(!result.request_id.is_empty());
Ok(())
}
// ── Wire-shape sanity (no API calls) ──
#[test]
fn raw_request_maps_all_fields() {
let request = ViduQ3TurboImageToVideoRequest {
prompt: "p".to_string(),
image_url: "https://example.com/start.png".to_string(),
end_image_url: Some("https://example.com/end.png".to_string()),
duration: Some(7),
seed: Some(42),
resolution: Some(ViduQ3TurboImageToVideoResolution::TenEightyP),
audio: Some(false),
};
let raw = request.to_raw_request().unwrap();
assert_eq!(raw.prompt, "p");
assert_eq!(raw.image_url, "https://example.com/start.png");
assert_eq!(raw.end_image_url.as_deref(), Some("https://example.com/end.png"));
assert_eq!(raw.duration, Some(7));
assert_eq!(raw.seed, Some(42));
assert_eq!(raw.resolution.as_deref(), Some("1080p"));
assert_eq!(raw.audio, Some(false));
}
#[test]
fn raw_request_omits_unset_optionals() {
let request = ViduQ3TurboImageToVideoRequest {
prompt: "p".to_string(),
image_url: "https://example.com/start.png".to_string(),
end_image_url: None,
duration: None,
seed: None,
resolution: None,
audio: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(
json,
serde_json::json!({ "prompt": "p", "image_url": "https://example.com/start.png" }),
);
}
#[test]
fn seed_serializes_when_set() {
let request = ViduQ3TurboImageToVideoRequest {
prompt: "p".to_string(),
image_url: "https://example.com/start.png".to_string(),
end_image_url: None,
duration: None,
seed: Some(777),
resolution: None,
audio: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(json.get("seed").and_then(|s| s.as_i64()), Some(777));
}
#[test]
fn endpoint_path_is_canonical() {
assert_eq!(
ViduQ3TurboImageToVideoRequest::ENDPOINT,
"fal-ai/vidu/q3/image-to-video/turbo",
);
}
// NB: Pricing tests are in cost.rs
}
@@ -0,0 +1,80 @@
use crate::requests::api::video::image::vidu_q3_turbo::api::{
ViduQ3TurboImageToVideoRequest, ViduQ3TurboImageToVideoResolution,
};
use crate::requests::api::video::text::vidu_q3_turbo::cost::{
vidu_q3_turbo_cost_cents, vidu_q3_turbo_rate_tenth_cents_per_sec,
};
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
// Image-to-video shares the Vidu Q3 Turbo per-second pricing (see the text
// module for the canonical rate table):
// 360p / 540p: $0.035/sec
// 720p / 1080p: $0.077/sec (2.2×)
// Pricing depends on resolution and duration only.
impl FalRequestCostCalculator for ViduQ3TurboImageToVideoRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
// fal defaults when unset: duration = 5s, resolution = 720p.
let duration_secs = u64::from(self.duration.unwrap_or(5));
let is_high_res = self.resolution
.unwrap_or(ViduQ3TurboImageToVideoResolution::SevenTwentyP)
.is_high_res();
let rate = vidu_q3_turbo_rate_tenth_cents_per_sec(is_high_res);
vidu_q3_turbo_cost_cents(rate, duration_secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_request(
duration: Option<u8>,
resolution: Option<ViduQ3TurboImageToVideoResolution>,
) -> ViduQ3TurboImageToVideoRequest {
ViduQ3TurboImageToVideoRequest {
prompt: "test".to_string(),
image_url: "https://example.com/x.png".to_string(),
end_image_url: None,
duration,
seed: None,
resolution,
audio: Some(true),
}
}
mod cost_table {
use super::*;
// (duration, resolution, expected_cents)
const COST_TABLE: &[(Option<u8>, Option<ViduQ3TurboImageToVideoResolution>, u64)] = &[
(Some(5), Some(ViduQ3TurboImageToVideoResolution::ThreeSixtyP), 18), // 17.5 → 18
(Some(5), Some(ViduQ3TurboImageToVideoResolution::FiveFortyP), 18),
(Some(5), Some(ViduQ3TurboImageToVideoResolution::SevenTwentyP), 39), // 38.5 → 39
(Some(5), Some(ViduQ3TurboImageToVideoResolution::TenEightyP), 39),
(Some(10), Some(ViduQ3TurboImageToVideoResolution::SevenTwentyP), 77),
// Defaults: duration=None→5s, resolution=None→720p
(None, None, 39),
];
#[test]
fn matches_cost_table() {
for &(duration, resolution, expected) in COST_TABLE {
let got = make_request(duration, resolution).calculate_cost_in_cents();
assert_eq!(got, expected, "duration={duration:?} resolution={resolution:?}");
}
}
/// An end frame does not change the bill (only resolution + duration do).
#[test]
fn cost_is_independent_of_end_frame() {
let without = make_request(Some(8), Some(ViduQ3TurboImageToVideoResolution::SevenTwentyP)).calculate_cost_in_cents();
let with = ViduQ3TurboImageToVideoRequest {
end_image_url: Some("https://example.com/end.png".to_string()),
..make_request(Some(8), Some(ViduQ3TurboImageToVideoResolution::SevenTwentyP))
}.calculate_cost_in_cents();
assert_eq!(without, with);
}
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -0,0 +1,47 @@
use serde::{Deserialize, Serialize};
/// Over-the-wire input shape for `fal-ai/vidu/q3/image-to-video/turbo`.
/// fal's schema: <https://fal.ai/models/fal-ai/vidu/q3/image-to-video/turbo/api>
///
/// NB: this endpoint has no `aspect_ratio` (derived from the input image).
/// A `prompt` is optional server-side but always sent here.
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct ViduQ3TurboImageToVideoInput {
/// Text prompt (max 2000 characters). Optional server-side.
pub prompt: String,
/// URL of the image used as the starting frame (URL or base64).
pub image_url: String,
/// Optional URL of an ending frame, for start→end transition videos.
#[serde(skip_serializing_if = "Option::is_none")]
pub end_image_url: Option<String>,
/// Duration in seconds. Range 1–16. fal default: 5.
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<u8>,
/// Seed for reproducibility. Random when omitted.
#[serde(skip_serializing_if = "Option::is_none")]
pub seed: Option<i64>,
/// Output resolution.
/// Possible values: "360p", "540p", "720p", "1080p". fal default: "720p".
/// (360p is unavailable when `end_image_url` is set.)
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<String>,
/// Whether to generate audio for the video. fal default: true.
#[serde(skip_serializing_if = "Option::is_none")]
pub audio: Option<bool>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ViduQ3TurboImageToVideoVideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ViduQ3TurboImageToVideoOutput {
pub video: ViduQ3TurboImageToVideoVideoFile,
}
@@ -0,0 +1,3 @@
pub mod veo_3p1;
pub mod veo_3p1_fast;
pub mod veo_3p1_lite;
@@ -0,0 +1,286 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::video::images::veo_3p1::raw_request::{
Veo3p1FirstLastFrameToVideoInput, Veo3p1FirstLastFrameToVideoOutput,
};
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
#[derive(Clone, Debug)]
pub struct Veo3p1FirstLastFrameToVideoRequest {
/// Text prompt describing the video to generate.
pub prompt: String,
/// URL of the image used as the first frame.
pub first_frame_url: String,
/// URL of the image used as the last frame.
pub last_frame_url: String,
/// Aspect ratio. fal's default is `auto` when `None`.
pub aspect_ratio: Option<Veo3p1FirstLastFrameToVideoAspectRatio>,
/// Video duration. fal's default is `8s` when `None`.
pub duration: Option<Veo3p1FirstLastFrameToVideoDuration>,
/// Output resolution. fal's default is `720p` when `None`.
pub resolution: Option<Veo3p1FirstLastFrameToVideoResolution>,
/// Whether to generate native audio. fal's server default is `true` when
/// this is `None`.
pub generate_audio: Option<bool>,
/// Optional negative prompt.
pub negative_prompt: Option<String>,
/// Seed for the random number generator.
pub seed: Option<i64>,
/// Whether to auto-rewrite prompts that fail moderation. fal's default is
/// `false` when `None`.
pub auto_fix: Option<bool>,
/// Safety tolerance. fal's default is `4` when `None`.
pub safety_tolerance: Option<Veo3p1FirstLastFrameToVideoSafetyTolerance>,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1FirstLastFrameToVideoAspectRatio {
Auto,
SixteenByNine,
NineBySixteen,
}
impl Veo3p1FirstLastFrameToVideoAspectRatio {
fn to_str(&self) -> &'static str {
match self {
Self::Auto => "auto",
Self::SixteenByNine => "16:9",
Self::NineBySixteen => "9:16",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1FirstLastFrameToVideoDuration {
FourSeconds,
SixSeconds,
EightSeconds,
}
impl Veo3p1FirstLastFrameToVideoDuration {
pub fn to_seconds(&self) -> u64 {
match self {
Self::FourSeconds => 4,
Self::SixSeconds => 6,
Self::EightSeconds => 8,
}
}
fn to_str(&self) -> &'static str {
match self {
Self::FourSeconds => "4s",
Self::SixSeconds => "6s",
Self::EightSeconds => "8s",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1FirstLastFrameToVideoResolution {
SevenTwentyP,
TenEightyP,
FourK,
}
impl Veo3p1FirstLastFrameToVideoResolution {
/// 4k output is billed at a higher per-second rate than 720p/1080p.
pub fn is_four_k(&self) -> bool {
matches!(self, Self::FourK)
}
fn to_str(&self) -> &'static str {
match self {
Self::SevenTwentyP => "720p",
Self::TenEightyP => "1080p",
Self::FourK => "4k",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1FirstLastFrameToVideoSafetyTolerance {
/// "1" — strictest.
Level1,
Level2,
Level3,
/// "4" — fal default.
Level4,
Level5,
/// "6" — least strict.
Level6,
}
impl Veo3p1FirstLastFrameToVideoSafetyTolerance {
fn to_str(&self) -> &'static str {
match self {
Self::Level1 => "1",
Self::Level2 => "2",
Self::Level3 => "3",
Self::Level4 => "4",
Self::Level5 => "5",
Self::Level6 => "6",
}
}
}
impl FalEndpoint for Veo3p1FirstLastFrameToVideoRequest {
const ENDPOINT: &str = "fal-ai/veo3.1/first-last-frame-to-video";
type RawRequest = Veo3p1FirstLastFrameToVideoInput;
type RawResponse = Veo3p1FirstLastFrameToVideoOutput;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
Ok(Self::RawRequest {
prompt: self.prompt.clone(),
first_frame_url: self.first_frame_url.clone(),
last_frame_url: self.last_frame_url.clone(),
aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()),
duration: self.duration.map(|d| d.to_str().to_string()),
resolution: self.resolution.map(|r| r.to_str().to_string()),
generate_audio: self.generate_audio,
negative_prompt: self.negative_prompt.clone(),
seed: self.seed,
auto_fix: self.auto_fix,
safety_tolerance: self.safety_tolerance.map(|s| s.to_str().to_string()),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
use errors::AnyhowResult;
use std::fs::read_to_string;
use test_data::web::image_urls::{JUNO_AT_LAKE_IMAGE_URL, TALL_MOCHI_WITH_GLASSES_IMAGE_URL};
// ── Real requests (manually run; require a live key and cost money) ──
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_first_last_frame_webhook() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Veo3p1FirstLastFrameToVideoRequest {
prompt: "a smooth transition from the first scene to the last".to_string(),
first_frame_url: TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(),
last_frame_url: JUNO_AT_LAKE_IMAGE_URL.to_string(),
aspect_ratio: Some(Veo3p1FirstLastFrameToVideoAspectRatio::SixteenByNine),
duration: Some(Veo3p1FirstLastFrameToVideoDuration::FourSeconds),
resolution: Some(Veo3p1FirstLastFrameToVideoResolution::SevenTwentyP),
generate_audio: Some(false),
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
};
let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?;
println!("Webhook result: {:?}", result);
assert!(result.request_id.is_some() || result.gateway_request_id.is_some());
Ok(())
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_first_last_frame_queue() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Veo3p1FirstLastFrameToVideoRequest {
prompt: "morph between the two frames".to_string(),
first_frame_url: TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(),
last_frame_url: JUNO_AT_LAKE_IMAGE_URL.to_string(),
aspect_ratio: None,
duration: Some(Veo3p1FirstLastFrameToVideoDuration::FourSeconds),
resolution: Some(Veo3p1FirstLastFrameToVideoResolution::SevenTwentyP),
generate_audio: Some(false),
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
};
let result = request.send_queue_request(&api_key).await?;
println!("Queue result — request_id: {}", result.request_id);
assert!(!result.request_id.is_empty());
Ok(())
}
// ── Wire-shape sanity (no API calls) ──
#[test]
fn raw_request_maps_all_fields() {
let request = Veo3p1FirstLastFrameToVideoRequest {
prompt: "p".to_string(),
first_frame_url: "https://example.com/first.png".to_string(),
last_frame_url: "https://example.com/last.png".to_string(),
aspect_ratio: Some(Veo3p1FirstLastFrameToVideoAspectRatio::SixteenByNine),
duration: Some(Veo3p1FirstLastFrameToVideoDuration::FourSeconds),
resolution: Some(Veo3p1FirstLastFrameToVideoResolution::SevenTwentyP),
generate_audio: Some(true),
negative_prompt: Some("nope".to_string()),
seed: Some(1),
auto_fix: Some(true),
safety_tolerance: Some(Veo3p1FirstLastFrameToVideoSafetyTolerance::Level3),
};
let raw = request.to_raw_request().unwrap();
assert_eq!(raw.first_frame_url, "https://example.com/first.png");
assert_eq!(raw.last_frame_url, "https://example.com/last.png");
assert_eq!(raw.aspect_ratio.as_deref(), Some("16:9"));
assert_eq!(raw.duration.as_deref(), Some("4s"));
assert_eq!(raw.resolution.as_deref(), Some("720p"));
assert_eq!(raw.generate_audio, Some(true));
assert_eq!(raw.negative_prompt.as_deref(), Some("nope"));
assert_eq!(raw.seed, Some(1));
assert_eq!(raw.auto_fix, Some(true));
assert_eq!(raw.safety_tolerance.as_deref(), Some("3"));
}
#[test]
fn raw_request_omits_unset_optionals() {
let request = Veo3p1FirstLastFrameToVideoRequest {
prompt: "p".to_string(),
first_frame_url: "https://example.com/first.png".to_string(),
last_frame_url: "https://example.com/last.png".to_string(),
aspect_ratio: None,
duration: None,
resolution: None,
generate_audio: None,
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(
json,
serde_json::json!({
"prompt": "p",
"first_frame_url": "https://example.com/first.png",
"last_frame_url": "https://example.com/last.png",
}),
);
}
#[test]
fn endpoint_path_is_canonical() {
assert_eq!(
Veo3p1FirstLastFrameToVideoRequest::ENDPOINT,
"fal-ai/veo3.1/first-last-frame-to-video",
);
}
// NB: Pricing tests are in cost.rs
}
@@ -0,0 +1,85 @@
use crate::requests::api::video::images::veo_3p1::api::{
Veo3p1FirstLastFrameToVideoDuration, Veo3p1FirstLastFrameToVideoRequest,
Veo3p1FirstLastFrameToVideoResolution,
};
use crate::requests::api::video::text::veo_3p1::cost::{
veo_3p1_cost_cents, veo_3p1_rate_tenth_cents_per_sec,
};
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
// First-last-frame-to-video shares the Veo 3.1 (non-fast) per-second pricing
// (see the text module for the canonical rate table):
// 720p / 1080p: $0.20/sec (audio off), $0.40/sec (audio on)
// 4k: $0.40/sec (audio off), $0.60/sec (audio on)
impl FalRequestCostCalculator for Veo3p1FirstLastFrameToVideoRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
// fal defaults when unset: duration = 8s, resolution = 720p, audio = on.
let duration_secs = self.duration
.unwrap_or(Veo3p1FirstLastFrameToVideoDuration::EightSeconds)
.to_seconds();
let four_k = self.resolution
.unwrap_or(Veo3p1FirstLastFrameToVideoResolution::SevenTwentyP)
.is_four_k();
let audio_on = self.generate_audio.unwrap_or(true);
let rate = veo_3p1_rate_tenth_cents_per_sec(four_k, audio_on);
veo_3p1_cost_cents(rate, duration_secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_request(
duration: Option<Veo3p1FirstLastFrameToVideoDuration>,
resolution: Option<Veo3p1FirstLastFrameToVideoResolution>,
generate_audio: Option<bool>,
) -> Veo3p1FirstLastFrameToVideoRequest {
Veo3p1FirstLastFrameToVideoRequest {
prompt: "test".to_string(),
first_frame_url: "https://example.com/first.png".to_string(),
last_frame_url: "https://example.com/last.png".to_string(),
aspect_ratio: None,
duration,
resolution,
generate_audio,
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
}
}
mod cost_table {
use super::*;
// (duration, resolution, generate_audio, expected_cents)
const COST_TABLE: &[(
Option<Veo3p1FirstLastFrameToVideoDuration>,
Option<Veo3p1FirstLastFrameToVideoResolution>,
Option<bool>,
u64,
)] = &[
(Some(Veo3p1FirstLastFrameToVideoDuration::FourSeconds), Some(Veo3p1FirstLastFrameToVideoResolution::SevenTwentyP), Some(false), 80),
(Some(Veo3p1FirstLastFrameToVideoDuration::SixSeconds), Some(Veo3p1FirstLastFrameToVideoResolution::SevenTwentyP), Some(true), 240),
(Some(Veo3p1FirstLastFrameToVideoDuration::EightSeconds), Some(Veo3p1FirstLastFrameToVideoResolution::TenEightyP), Some(false), 160),
(Some(Veo3p1FirstLastFrameToVideoDuration::EightSeconds), Some(Veo3p1FirstLastFrameToVideoResolution::TenEightyP), Some(true), 320),
(Some(Veo3p1FirstLastFrameToVideoDuration::EightSeconds), Some(Veo3p1FirstLastFrameToVideoResolution::FourK), Some(false), 320),
(Some(Veo3p1FirstLastFrameToVideoDuration::EightSeconds), Some(Veo3p1FirstLastFrameToVideoResolution::FourK), Some(true), 480),
(None, None, None, 320),
];
#[test]
fn matches_cost_table() {
for &(duration, resolution, generate_audio, expected) in COST_TABLE {
let got = make_request(duration, resolution, generate_audio).calculate_cost_in_cents();
assert_eq!(
got, expected,
"duration={duration:?} resolution={resolution:?} audio={generate_audio:?}",
);
}
}
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -0,0 +1,61 @@
use serde::{Deserialize, Serialize};
/// Over-the-wire input shape for `fal-ai/veo3.1/first-last-frame-to-video`
/// (start + end keyframes).
/// fal's schema: <https://fal.ai/models/fal-ai/veo3.1/first-last-frame-to-video/api>
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Veo3p1FirstLastFrameToVideoInput {
pub prompt: String,
/// URL of the image used as the first frame.
pub first_frame_url: String,
/// URL of the image used as the last frame.
pub last_frame_url: String,
/// Aspect ratio.
/// Possible values: "auto", "16:9", "9:16". fal default: "auto".
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
/// Duration in seconds (as a string).
/// Possible values: "4s", "6s", "8s". fal default: "8s".
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<String>,
/// Output resolution.
/// Possible values: "720p", "1080p", "4k". fal default: "720p".
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<String>,
/// Whether to generate native audio for the video. fal default: true.
#[serde(skip_serializing_if = "Option::is_none")]
pub generate_audio: Option<bool>,
/// Optional negative prompt.
#[serde(skip_serializing_if = "Option::is_none")]
pub negative_prompt: Option<String>,
/// Seed for the random number generator.
#[serde(skip_serializing_if = "Option::is_none")]
pub seed: Option<i64>,
/// Whether to automatically rewrite prompts that fail moderation.
/// fal default: false.
#[serde(skip_serializing_if = "Option::is_none")]
pub auto_fix: Option<bool>,
/// Safety tolerance, "1" (strictest) .. "6" (least strict). fal default: "4".
#[serde(skip_serializing_if = "Option::is_none")]
pub safety_tolerance: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo3p1FirstLastFrameToVideoVideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo3p1FirstLastFrameToVideoOutput {
pub video: Veo3p1FirstLastFrameToVideoVideoFile,
}
@@ -0,0 +1,286 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::video::images::veo_3p1_fast::raw_request::{
Veo3p1FastFirstLastFrameToVideoInput, Veo3p1FastFirstLastFrameToVideoOutput,
};
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
#[derive(Clone, Debug)]
pub struct Veo3p1FastFirstLastFrameToVideoRequest {
/// Text prompt describing the video to generate.
pub prompt: String,
/// URL of the image used as the first frame.
pub first_frame_url: String,
/// URL of the image used as the last frame.
pub last_frame_url: String,
/// Aspect ratio. fal's default is `auto` when `None`.
pub aspect_ratio: Option<Veo3p1FastFirstLastFrameToVideoAspectRatio>,
/// Video duration. fal's default is `8s` when `None`.
pub duration: Option<Veo3p1FastFirstLastFrameToVideoDuration>,
/// Output resolution. fal's default is `720p` when `None`.
pub resolution: Option<Veo3p1FastFirstLastFrameToVideoResolution>,
/// Whether to generate native audio. fal's server default is `true` when
/// this is `None`.
pub generate_audio: Option<bool>,
/// Optional negative prompt.
pub negative_prompt: Option<String>,
/// Seed for the random number generator.
pub seed: Option<i64>,
/// Whether to auto-rewrite prompts that fail moderation. fal's default is
/// `false` when `None`.
pub auto_fix: Option<bool>,
/// Safety tolerance. fal's default is `4` when `None`.
pub safety_tolerance: Option<Veo3p1FastFirstLastFrameToVideoSafetyTolerance>,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1FastFirstLastFrameToVideoAspectRatio {
Auto,
SixteenByNine,
NineBySixteen,
}
impl Veo3p1FastFirstLastFrameToVideoAspectRatio {
fn to_str(&self) -> &'static str {
match self {
Self::Auto => "auto",
Self::SixteenByNine => "16:9",
Self::NineBySixteen => "9:16",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1FastFirstLastFrameToVideoDuration {
FourSeconds,
SixSeconds,
EightSeconds,
}
impl Veo3p1FastFirstLastFrameToVideoDuration {
pub fn to_seconds(&self) -> u64 {
match self {
Self::FourSeconds => 4,
Self::SixSeconds => 6,
Self::EightSeconds => 8,
}
}
fn to_str(&self) -> &'static str {
match self {
Self::FourSeconds => "4s",
Self::SixSeconds => "6s",
Self::EightSeconds => "8s",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1FastFirstLastFrameToVideoResolution {
SevenTwentyP,
TenEightyP,
FourK,
}
impl Veo3p1FastFirstLastFrameToVideoResolution {
/// 4k output is billed at a higher per-second rate than 720p/1080p.
pub fn is_four_k(&self) -> bool {
matches!(self, Self::FourK)
}
fn to_str(&self) -> &'static str {
match self {
Self::SevenTwentyP => "720p",
Self::TenEightyP => "1080p",
Self::FourK => "4k",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1FastFirstLastFrameToVideoSafetyTolerance {
/// "1" — strictest.
Level1,
Level2,
Level3,
/// "4" — fal default.
Level4,
Level5,
/// "6" — least strict.
Level6,
}
impl Veo3p1FastFirstLastFrameToVideoSafetyTolerance {
fn to_str(&self) -> &'static str {
match self {
Self::Level1 => "1",
Self::Level2 => "2",
Self::Level3 => "3",
Self::Level4 => "4",
Self::Level5 => "5",
Self::Level6 => "6",
}
}
}
impl FalEndpoint for Veo3p1FastFirstLastFrameToVideoRequest {
const ENDPOINT: &str = "fal-ai/veo3.1/fast/first-last-frame-to-video";
type RawRequest = Veo3p1FastFirstLastFrameToVideoInput;
type RawResponse = Veo3p1FastFirstLastFrameToVideoOutput;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
Ok(Self::RawRequest {
prompt: self.prompt.clone(),
first_frame_url: self.first_frame_url.clone(),
last_frame_url: self.last_frame_url.clone(),
aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()),
duration: self.duration.map(|d| d.to_str().to_string()),
resolution: self.resolution.map(|r| r.to_str().to_string()),
generate_audio: self.generate_audio,
negative_prompt: self.negative_prompt.clone(),
seed: self.seed,
auto_fix: self.auto_fix,
safety_tolerance: self.safety_tolerance.map(|s| s.to_str().to_string()),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
use errors::AnyhowResult;
use std::fs::read_to_string;
use test_data::web::image_urls::{JUNO_AT_LAKE_IMAGE_URL, TALL_MOCHI_WITH_GLASSES_IMAGE_URL};
// ── Real requests (manually run; require a live key and cost money) ──
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_first_last_frame_webhook() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Veo3p1FastFirstLastFrameToVideoRequest {
prompt: "a smooth transition from the first scene to the last".to_string(),
first_frame_url: TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(),
last_frame_url: JUNO_AT_LAKE_IMAGE_URL.to_string(),
aspect_ratio: Some(Veo3p1FastFirstLastFrameToVideoAspectRatio::SixteenByNine),
duration: Some(Veo3p1FastFirstLastFrameToVideoDuration::FourSeconds),
resolution: Some(Veo3p1FastFirstLastFrameToVideoResolution::SevenTwentyP),
generate_audio: Some(false),
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
};
let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?;
println!("Webhook result: {:?}", result);
assert!(result.request_id.is_some() || result.gateway_request_id.is_some());
Ok(())
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_first_last_frame_queue() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Veo3p1FastFirstLastFrameToVideoRequest {
prompt: "morph between the two frames".to_string(),
first_frame_url: TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(),
last_frame_url: JUNO_AT_LAKE_IMAGE_URL.to_string(),
aspect_ratio: None,
duration: Some(Veo3p1FastFirstLastFrameToVideoDuration::FourSeconds),
resolution: Some(Veo3p1FastFirstLastFrameToVideoResolution::SevenTwentyP),
generate_audio: Some(false),
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
};
let result = request.send_queue_request(&api_key).await?;
println!("Queue result — request_id: {}", result.request_id);
assert!(!result.request_id.is_empty());
Ok(())
}
// ── Wire-shape sanity (no API calls) ──
#[test]
fn raw_request_maps_all_fields() {
let request = Veo3p1FastFirstLastFrameToVideoRequest {
prompt: "p".to_string(),
first_frame_url: "https://example.com/first.png".to_string(),
last_frame_url: "https://example.com/last.png".to_string(),
aspect_ratio: Some(Veo3p1FastFirstLastFrameToVideoAspectRatio::SixteenByNine),
duration: Some(Veo3p1FastFirstLastFrameToVideoDuration::FourSeconds),
resolution: Some(Veo3p1FastFirstLastFrameToVideoResolution::SevenTwentyP),
generate_audio: Some(true),
negative_prompt: Some("nope".to_string()),
seed: Some(1),
auto_fix: Some(true),
safety_tolerance: Some(Veo3p1FastFirstLastFrameToVideoSafetyTolerance::Level3),
};
let raw = request.to_raw_request().unwrap();
assert_eq!(raw.first_frame_url, "https://example.com/first.png");
assert_eq!(raw.last_frame_url, "https://example.com/last.png");
assert_eq!(raw.aspect_ratio.as_deref(), Some("16:9"));
assert_eq!(raw.duration.as_deref(), Some("4s"));
assert_eq!(raw.resolution.as_deref(), Some("720p"));
assert_eq!(raw.generate_audio, Some(true));
assert_eq!(raw.negative_prompt.as_deref(), Some("nope"));
assert_eq!(raw.seed, Some(1));
assert_eq!(raw.auto_fix, Some(true));
assert_eq!(raw.safety_tolerance.as_deref(), Some("3"));
}
#[test]
fn raw_request_omits_unset_optionals() {
let request = Veo3p1FastFirstLastFrameToVideoRequest {
prompt: "p".to_string(),
first_frame_url: "https://example.com/first.png".to_string(),
last_frame_url: "https://example.com/last.png".to_string(),
aspect_ratio: None,
duration: None,
resolution: None,
generate_audio: None,
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(
json,
serde_json::json!({
"prompt": "p",
"first_frame_url": "https://example.com/first.png",
"last_frame_url": "https://example.com/last.png",
}),
);
}
#[test]
fn endpoint_path_is_canonical() {
assert_eq!(
Veo3p1FastFirstLastFrameToVideoRequest::ENDPOINT,
"fal-ai/veo3.1/fast/first-last-frame-to-video",
);
}
// NB: Pricing tests are in cost.rs
}
@@ -0,0 +1,85 @@
use crate::requests::api::video::images::veo_3p1_fast::api::{
Veo3p1FastFirstLastFrameToVideoDuration, Veo3p1FastFirstLastFrameToVideoRequest,
Veo3p1FastFirstLastFrameToVideoResolution,
};
use crate::requests::api::video::text::veo_3p1_fast::cost::{
veo_3p1_fast_cost_cents, veo_3p1_fast_rate_tenth_cents_per_sec,
};
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
// First-last-frame-to-video shares the Veo 3.1 Fast per-second pricing (see the
// text module for the canonical rate table):
// 720p / 1080p: $0.10/sec (audio off), $0.15/sec (audio on)
// 4k: $0.30/sec (audio off), $0.35/sec (audio on)
impl FalRequestCostCalculator for Veo3p1FastFirstLastFrameToVideoRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
// fal defaults when unset: duration = 8s, resolution = 720p, audio = on.
let duration_secs = self.duration
.unwrap_or(Veo3p1FastFirstLastFrameToVideoDuration::EightSeconds)
.to_seconds();
let four_k = self.resolution
.unwrap_or(Veo3p1FastFirstLastFrameToVideoResolution::SevenTwentyP)
.is_four_k();
let audio_on = self.generate_audio.unwrap_or(true);
let rate = veo_3p1_fast_rate_tenth_cents_per_sec(four_k, audio_on);
veo_3p1_fast_cost_cents(rate, duration_secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_request(
duration: Option<Veo3p1FastFirstLastFrameToVideoDuration>,
resolution: Option<Veo3p1FastFirstLastFrameToVideoResolution>,
generate_audio: Option<bool>,
) -> Veo3p1FastFirstLastFrameToVideoRequest {
Veo3p1FastFirstLastFrameToVideoRequest {
prompt: "test".to_string(),
first_frame_url: "https://example.com/first.png".to_string(),
last_frame_url: "https://example.com/last.png".to_string(),
aspect_ratio: None,
duration,
resolution,
generate_audio,
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
}
}
mod cost_table {
use super::*;
// (duration, resolution, generate_audio, expected_cents)
const COST_TABLE: &[(
Option<Veo3p1FastFirstLastFrameToVideoDuration>,
Option<Veo3p1FastFirstLastFrameToVideoResolution>,
Option<bool>,
u64,
)] = &[
(Some(Veo3p1FastFirstLastFrameToVideoDuration::FourSeconds), Some(Veo3p1FastFirstLastFrameToVideoResolution::SevenTwentyP), Some(false), 40),
(Some(Veo3p1FastFirstLastFrameToVideoDuration::SixSeconds), Some(Veo3p1FastFirstLastFrameToVideoResolution::SevenTwentyP), Some(true), 90),
(Some(Veo3p1FastFirstLastFrameToVideoDuration::EightSeconds), Some(Veo3p1FastFirstLastFrameToVideoResolution::TenEightyP), Some(false), 80),
(Some(Veo3p1FastFirstLastFrameToVideoDuration::EightSeconds), Some(Veo3p1FastFirstLastFrameToVideoResolution::TenEightyP), Some(true), 120),
(Some(Veo3p1FastFirstLastFrameToVideoDuration::EightSeconds), Some(Veo3p1FastFirstLastFrameToVideoResolution::FourK), Some(false), 240),
(Some(Veo3p1FastFirstLastFrameToVideoDuration::EightSeconds), Some(Veo3p1FastFirstLastFrameToVideoResolution::FourK), Some(true), 280),
(None, None, None, 120),
];
#[test]
fn matches_cost_table() {
for &(duration, resolution, generate_audio, expected) in COST_TABLE {
let got = make_request(duration, resolution, generate_audio).calculate_cost_in_cents();
assert_eq!(
got, expected,
"duration={duration:?} resolution={resolution:?} audio={generate_audio:?}",
);
}
}
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -0,0 +1,61 @@
use serde::{Deserialize, Serialize};
/// Over-the-wire input shape for `fal-ai/veo3.1/fast/first-last-frame-to-video`
/// (start + end keyframes).
/// fal's schema: <https://fal.ai/models/fal-ai/veo3.1/fast/first-last-frame-to-video/api>
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Veo3p1FastFirstLastFrameToVideoInput {
pub prompt: String,
/// URL of the image used as the first frame.
pub first_frame_url: String,
/// URL of the image used as the last frame.
pub last_frame_url: String,
/// Aspect ratio.
/// Possible values: "auto", "16:9", "9:16". fal default: "auto".
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
/// Duration in seconds (as a string).
/// Possible values: "4s", "6s", "8s". fal default: "8s".
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<String>,
/// Output resolution.
/// Possible values: "720p", "1080p", "4k". fal default: "720p".
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<String>,
/// Whether to generate native audio for the video. fal default: true.
#[serde(skip_serializing_if = "Option::is_none")]
pub generate_audio: Option<bool>,
/// Optional negative prompt.
#[serde(skip_serializing_if = "Option::is_none")]
pub negative_prompt: Option<String>,
/// Seed for the random number generator.
#[serde(skip_serializing_if = "Option::is_none")]
pub seed: Option<i64>,
/// Whether to automatically rewrite prompts that fail moderation.
/// fal default: false.
#[serde(skip_serializing_if = "Option::is_none")]
pub auto_fix: Option<bool>,
/// Safety tolerance, "1" (strictest) .. "6" (least strict). fal default: "4".
#[serde(skip_serializing_if = "Option::is_none")]
pub safety_tolerance: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo3p1FastFirstLastFrameToVideoVideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo3p1FastFirstLastFrameToVideoOutput {
pub video: Veo3p1FastFirstLastFrameToVideoVideoFile,
}
@@ -0,0 +1,286 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::video::images::veo_3p1_lite::raw_request::{
Veo3p1LiteFirstLastFrameToVideoInput, Veo3p1LiteFirstLastFrameToVideoOutput,
};
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
#[derive(Clone, Debug)]
pub struct Veo3p1LiteFirstLastFrameToVideoRequest {
/// Text prompt describing the video to generate.
pub prompt: String,
/// URL of the image used as the first frame.
pub first_frame_url: String,
/// URL of the image used as the last frame.
pub last_frame_url: String,
/// Aspect ratio. fal's default is `auto` when `None`.
pub aspect_ratio: Option<Veo3p1LiteFirstLastFrameToVideoAspectRatio>,
/// Video duration. fal's default is `8s` when `None`.
pub duration: Option<Veo3p1LiteFirstLastFrameToVideoDuration>,
/// Output resolution. fal's default is `720p` when `None`.
pub resolution: Option<Veo3p1LiteFirstLastFrameToVideoResolution>,
/// Whether to generate native audio. fal's server default is `true` when
/// this is `None`.
pub generate_audio: Option<bool>,
/// Optional negative prompt.
pub negative_prompt: Option<String>,
/// Seed for the random number generator.
pub seed: Option<i64>,
/// Whether to auto-rewrite prompts that fail moderation. fal's default is
/// `false` when `None`.
pub auto_fix: Option<bool>,
/// Safety tolerance. fal's default is `4` when `None`.
pub safety_tolerance: Option<Veo3p1LiteFirstLastFrameToVideoSafetyTolerance>,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1LiteFirstLastFrameToVideoAspectRatio {
Auto,
SixteenByNine,
NineBySixteen,
}
impl Veo3p1LiteFirstLastFrameToVideoAspectRatio {
fn to_str(&self) -> &'static str {
match self {
Self::Auto => "auto",
Self::SixteenByNine => "16:9",
Self::NineBySixteen => "9:16",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1LiteFirstLastFrameToVideoDuration {
FourSeconds,
SixSeconds,
EightSeconds,
}
impl Veo3p1LiteFirstLastFrameToVideoDuration {
pub fn to_seconds(&self) -> u64 {
match self {
Self::FourSeconds => 4,
Self::SixSeconds => 6,
Self::EightSeconds => 8,
}
}
fn to_str(&self) -> &'static str {
match self {
Self::FourSeconds => "4s",
Self::SixSeconds => "6s",
Self::EightSeconds => "8s",
}
}
}
/// Veo 3.1 Lite exposes only 720p/1080p (no 4k tier). Both resolution AND
/// audio affect the per-second price.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1LiteFirstLastFrameToVideoResolution {
SevenTwentyP,
TenEightyP,
}
impl Veo3p1LiteFirstLastFrameToVideoResolution {
/// 1080p is billed at a higher per-second rate than 720p.
pub fn is_ten_eighty_p(&self) -> bool {
matches!(self, Self::TenEightyP)
}
fn to_str(&self) -> &'static str {
match self {
Self::SevenTwentyP => "720p",
Self::TenEightyP => "1080p",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Veo3p1LiteFirstLastFrameToVideoSafetyTolerance {
/// "1" — strictest.
Level1,
Level2,
Level3,
/// "4" — fal default.
Level4,
Level5,
/// "6" — least strict.
Level6,
}
impl Veo3p1LiteFirstLastFrameToVideoSafetyTolerance {
fn to_str(&self) -> &'static str {
match self {
Self::Level1 => "1",
Self::Level2 => "2",
Self::Level3 => "3",
Self::Level4 => "4",
Self::Level5 => "5",
Self::Level6 => "6",
}
}
}
impl FalEndpoint for Veo3p1LiteFirstLastFrameToVideoRequest {
const ENDPOINT: &str = "fal-ai/veo3.1/lite/first-last-frame-to-video";
type RawRequest = Veo3p1LiteFirstLastFrameToVideoInput;
type RawResponse = Veo3p1LiteFirstLastFrameToVideoOutput;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
Ok(Self::RawRequest {
prompt: self.prompt.clone(),
first_frame_url: self.first_frame_url.clone(),
last_frame_url: self.last_frame_url.clone(),
aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()),
duration: self.duration.map(|d| d.to_str().to_string()),
resolution: self.resolution.map(|r| r.to_str().to_string()),
generate_audio: self.generate_audio,
negative_prompt: self.negative_prompt.clone(),
seed: self.seed,
auto_fix: self.auto_fix,
safety_tolerance: self.safety_tolerance.map(|s| s.to_str().to_string()),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
use errors::AnyhowResult;
use std::fs::read_to_string;
use test_data::web::image_urls::{JUNO_AT_LAKE_IMAGE_URL, TALL_MOCHI_WITH_GLASSES_IMAGE_URL};
// ── Real requests (manually run; require a live key and cost money) ──
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_first_last_frame_webhook() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Veo3p1LiteFirstLastFrameToVideoRequest {
prompt: "a smooth transition from the first scene to the last".to_string(),
first_frame_url: TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(),
last_frame_url: JUNO_AT_LAKE_IMAGE_URL.to_string(),
aspect_ratio: Some(Veo3p1LiteFirstLastFrameToVideoAspectRatio::SixteenByNine),
duration: Some(Veo3p1LiteFirstLastFrameToVideoDuration::FourSeconds),
resolution: Some(Veo3p1LiteFirstLastFrameToVideoResolution::SevenTwentyP),
generate_audio: Some(false),
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
};
let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?;
println!("Webhook result: {:?}", result);
assert!(result.request_id.is_some() || result.gateway_request_id.is_some());
Ok(())
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_first_last_frame_queue() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Veo3p1LiteFirstLastFrameToVideoRequest {
prompt: "morph between the two frames".to_string(),
first_frame_url: TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string(),
last_frame_url: JUNO_AT_LAKE_IMAGE_URL.to_string(),
aspect_ratio: None,
duration: Some(Veo3p1LiteFirstLastFrameToVideoDuration::FourSeconds),
resolution: Some(Veo3p1LiteFirstLastFrameToVideoResolution::SevenTwentyP),
generate_audio: Some(false),
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
};
let result = request.send_queue_request(&api_key).await?;
println!("Queue result — request_id: {}", result.request_id);
assert!(!result.request_id.is_empty());
Ok(())
}
// ── Wire-shape sanity (no API calls) ──
#[test]
fn raw_request_maps_all_fields() {
let request = Veo3p1LiteFirstLastFrameToVideoRequest {
prompt: "p".to_string(),
first_frame_url: "https://example.com/first.png".to_string(),
last_frame_url: "https://example.com/last.png".to_string(),
aspect_ratio: Some(Veo3p1LiteFirstLastFrameToVideoAspectRatio::SixteenByNine),
duration: Some(Veo3p1LiteFirstLastFrameToVideoDuration::FourSeconds),
resolution: Some(Veo3p1LiteFirstLastFrameToVideoResolution::SevenTwentyP),
generate_audio: Some(true),
negative_prompt: Some("nope".to_string()),
seed: Some(1),
auto_fix: Some(true),
safety_tolerance: Some(Veo3p1LiteFirstLastFrameToVideoSafetyTolerance::Level3),
};
let raw = request.to_raw_request().unwrap();
assert_eq!(raw.first_frame_url, "https://example.com/first.png");
assert_eq!(raw.last_frame_url, "https://example.com/last.png");
assert_eq!(raw.aspect_ratio.as_deref(), Some("16:9"));
assert_eq!(raw.duration.as_deref(), Some("4s"));
assert_eq!(raw.resolution.as_deref(), Some("720p"));
assert_eq!(raw.generate_audio, Some(true));
assert_eq!(raw.negative_prompt.as_deref(), Some("nope"));
assert_eq!(raw.seed, Some(1));
assert_eq!(raw.auto_fix, Some(true));
assert_eq!(raw.safety_tolerance.as_deref(), Some("3"));
}
#[test]
fn raw_request_omits_unset_optionals() {
let request = Veo3p1LiteFirstLastFrameToVideoRequest {
prompt: "p".to_string(),
first_frame_url: "https://example.com/first.png".to_string(),
last_frame_url: "https://example.com/last.png".to_string(),
aspect_ratio: None,
duration: None,
resolution: None,
generate_audio: None,
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(
json,
serde_json::json!({
"prompt": "p",
"first_frame_url": "https://example.com/first.png",
"last_frame_url": "https://example.com/last.png",
}),
);
}
#[test]
fn endpoint_path_is_canonical() {
assert_eq!(
Veo3p1LiteFirstLastFrameToVideoRequest::ENDPOINT,
"fal-ai/veo3.1/lite/first-last-frame-to-video",
);
}
// NB: Pricing tests are in cost.rs
}
@@ -0,0 +1,85 @@
use crate::requests::api::video::images::veo_3p1_lite::api::{
Veo3p1LiteFirstLastFrameToVideoDuration, Veo3p1LiteFirstLastFrameToVideoRequest,
Veo3p1LiteFirstLastFrameToVideoResolution,
};
use crate::requests::api::video::text::veo_3p1_lite::cost::{
veo_3p1_lite_cost_cents, veo_3p1_lite_rate_tenth_cents_per_sec,
};
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
// First-last-frame-to-video shares the Veo 3.1 Lite per-second pricing (see the
// text module for the canonical rate table). Both resolution and audio matter:
// 720p: $0.03/sec (audio off), $0.05/sec (audio on)
// 1080p: $0.05/sec (audio off), $0.08/sec (audio on)
impl FalRequestCostCalculator for Veo3p1LiteFirstLastFrameToVideoRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
// fal defaults when unset: duration = 8s, resolution = 720p, audio = on.
let duration_secs = self.duration
.unwrap_or(Veo3p1LiteFirstLastFrameToVideoDuration::EightSeconds)
.to_seconds();
let is_1080p = self.resolution
.unwrap_or(Veo3p1LiteFirstLastFrameToVideoResolution::SevenTwentyP)
.is_ten_eighty_p();
let audio_on = self.generate_audio.unwrap_or(true);
let rate = veo_3p1_lite_rate_tenth_cents_per_sec(is_1080p, audio_on);
veo_3p1_lite_cost_cents(rate, duration_secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_request(
duration: Option<Veo3p1LiteFirstLastFrameToVideoDuration>,
resolution: Option<Veo3p1LiteFirstLastFrameToVideoResolution>,
generate_audio: Option<bool>,
) -> Veo3p1LiteFirstLastFrameToVideoRequest {
Veo3p1LiteFirstLastFrameToVideoRequest {
prompt: "test".to_string(),
first_frame_url: "https://example.com/first.png".to_string(),
last_frame_url: "https://example.com/last.png".to_string(),
aspect_ratio: None,
duration,
resolution,
generate_audio,
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
}
}
mod cost_table {
use super::*;
// (duration, resolution, generate_audio, expected_cents)
const COST_TABLE: &[(
Option<Veo3p1LiteFirstLastFrameToVideoDuration>,
Option<Veo3p1LiteFirstLastFrameToVideoResolution>,
Option<bool>,
u64,
)] = &[
(Some(Veo3p1LiteFirstLastFrameToVideoDuration::FourSeconds), Some(Veo3p1LiteFirstLastFrameToVideoResolution::SevenTwentyP), Some(false), 12),
(Some(Veo3p1LiteFirstLastFrameToVideoDuration::FourSeconds), Some(Veo3p1LiteFirstLastFrameToVideoResolution::SevenTwentyP), Some(true), 20),
(Some(Veo3p1LiteFirstLastFrameToVideoDuration::SixSeconds), Some(Veo3p1LiteFirstLastFrameToVideoResolution::TenEightyP), Some(true), 48),
(Some(Veo3p1LiteFirstLastFrameToVideoDuration::EightSeconds), Some(Veo3p1LiteFirstLastFrameToVideoResolution::TenEightyP), Some(false), 40),
(Some(Veo3p1LiteFirstLastFrameToVideoDuration::EightSeconds), Some(Veo3p1LiteFirstLastFrameToVideoResolution::TenEightyP), Some(true), 64),
// Defaults: 8s, 720p, audio on
(None, None, None, 40),
];
#[test]
fn matches_cost_table() {
for &(duration, resolution, generate_audio, expected) in COST_TABLE {
let got = make_request(duration, resolution, generate_audio).calculate_cost_in_cents();
assert_eq!(
got, expected,
"duration={duration:?} resolution={resolution:?} audio={generate_audio:?}",
);
}
}
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -0,0 +1,61 @@
use serde::{Deserialize, Serialize};
/// Over-the-wire input shape for `fal-ai/veo3.1/lite/first-last-frame-to-video`
/// (start + end keyframes).
/// fal's schema: <https://fal.ai/models/fal-ai/veo3.1/lite/first-last-frame-to-video/api>
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Veo3p1LiteFirstLastFrameToVideoInput {
pub prompt: String,
/// URL of the image used as the first frame.
pub first_frame_url: String,
/// URL of the image used as the last frame.
pub last_frame_url: String,
/// Aspect ratio.
/// Possible values: "auto", "16:9", "9:16". fal default: "auto".
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
/// Duration in seconds (as a string).
/// Possible values: "4s", "6s", "8s". fal default: "8s".
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<String>,
/// Output resolution.
/// Possible values: "720p", "1080p" (no 4k tier). fal default: "720p".
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<String>,
/// Whether to generate native audio for the video. fal default: true.
#[serde(skip_serializing_if = "Option::is_none")]
pub generate_audio: Option<bool>,
/// Optional negative prompt.
#[serde(skip_serializing_if = "Option::is_none")]
pub negative_prompt: Option<String>,
/// Seed for the random number generator.
#[serde(skip_serializing_if = "Option::is_none")]
pub seed: Option<i64>,
/// Whether to automatically rewrite prompts that fail moderation.
/// fal default: false.
#[serde(skip_serializing_if = "Option::is_none")]
pub auto_fix: Option<bool>,
/// Safety tolerance, "1" (strictest) .. "6" (least strict). fal default: "4".
#[serde(skip_serializing_if = "Option::is_none")]
pub safety_tolerance: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo3p1LiteFirstLastFrameToVideoVideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Veo3p1LiteFirstLastFrameToVideoOutput {
pub video: Veo3p1LiteFirstLastFrameToVideoVideoFile,
}

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