Compare commits

...
Author SHA1 Message Date
Brandon Thomas 65a1054579 suno client 2026-07-07 02:02:37 -04:00
Brandon Thomas 692e0ecf75 4k veo (#1743) 2026-07-07 01:27:53 -04:00
Brandon Thomas 94e1aef78d artcraft_router: Vidu and Veo models (#1742) 2026-07-06 23:51:08 -04:00
Brandon Thomas 9a4bb3b490 reduce num queries from 3 -> 1 (#1741) 2026-07-06 20:07:57 -04:00
Brandon Thomas 299d9b8d23 fix redis tx over database tx pooling issues (#1740) 2026-07-06 19:23:59 -04:00
Brandon Thomas 89eff46c6f database perf (#1739) 2026-07-06 16:53:11 -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
738 changed files with 24978 additions and 16589 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;
@@ -98,6 +98,15 @@ pub enum RouterVideoModel {
#[serde(rename = "veo_3p1_fast")]
Veo3p1Fast,
#[serde(rename = "veo_3p1_lite")]
Veo3p1Lite,
#[serde(rename = "vidu_q3")]
ViduQ3,
#[serde(rename = "vidu_q3_turbo")]
ViduQ3Turbo,
#[serde(rename = "preview_model")]
PreviewModel,
@@ -123,4 +132,13 @@ mod tests {
assert_serde_round_trip(RouterVideoModel::Seedance2p0BytePlusMini, "seedance_2p0_bp_mini");
assert_serde_round_trip(RouterVideoModel::Seedance2p0BytePlusUltraMini, "seedance_2p0_bpu_mini");
}
#[test]
fn veo_3p1_lite_and_vidu_variants_serialize() {
// NB: These strings must match `CommonVideoModel` — the two enums convert
// via serde string round-trip in the omni handlers.
assert_serde_round_trip(RouterVideoModel::Veo3p1Lite, "veo_3p1_lite");
assert_serde_round_trip(RouterVideoModel::ViduQ3, "vidu_q3");
assert_serde_round_trip(RouterVideoModel::ViduQ3Turbo, "vidu_q3_turbo");
}
}
@@ -1,6 +1,5 @@
use fal_client::requests_old::webhook::image::angle::enqueue_flux_2_lora_edit_image_angle_webhook::{
EnqueueFlux2LoraAngleImageSize, EnqueueFlux2LoraAngleNumImages,
EnqueueFlux2LoraEditImageAngleRequest,
use fal_client::requests::api::image::angle::flux_2_lora_edit_image_angle::api::{
Flux2LoraAngleImageSize, Flux2LoraAngleNumImages, Flux2LoraEditImageAngleRequest,
};
use crate::api::router_aspect_ratio::RouterAspectRatio;
@@ -27,7 +26,7 @@ pub fn build_fal_flux_2_lora_angles(
let num_images = plan_num_images(builder.image_batch_count, strategy)?;
let image_size = plan_image_size(builder.aspect_ratio, strategy)?;
let request = EnqueueFlux2LoraEditImageAngleRequest {
let request = Flux2LoraEditImageAngleRequest {
image_urls,
horizontal_angle: builder.horizontal_angle,
vertical_angle: builder.vertical_angle.map(|v| v.clamp(0.0, 60.0)),
@@ -57,8 +56,8 @@ fn resolve_image_urls(image_inputs: Option<ImageListRef>) -> Result<Vec<String>,
fn plan_num_images(
image_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<EnqueueFlux2LoraAngleNumImages, ArtcraftRouterError> {
use EnqueueFlux2LoraAngleNumImages as N;
) -> Result<Flux2LoraAngleNumImages, ArtcraftRouterError> {
use Flux2LoraAngleNumImages as N;
let count = image_batch_count.unwrap_or(1);
match count {
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
@@ -81,8 +80,8 @@ fn plan_num_images(
fn plan_image_size(
aspect_ratio: Option<RouterAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<EnqueueFlux2LoraAngleImageSize>, ArtcraftRouterError> {
use EnqueueFlux2LoraAngleImageSize as S;
) -> Result<Option<Flux2LoraAngleImageSize>, ArtcraftRouterError> {
use Flux2LoraAngleImageSize as S;
match aspect_ratio {
None => Ok(None),
@@ -118,34 +117,6 @@ mod tests {
use crate::api::router_image_model::RouterImageModel;
use crate::api::router_provider::RouterProvider;
fn base() -> GenerateImageRequestBuilder {
GenerateImageRequestBuilder {
model: RouterImageModel::Flux2LoraAngles,
provider: RouterProvider::Fal,
prompt: None,
image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/x.jpg".to_string()])),
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: None,
horizontal_angle: Some(45.0),
vertical_angle: Some(-15.0),
zoom: Some(2.0),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
}
}
fn unwrap_request(result: Result<ImageGenerationDraftOrRequest, ArtcraftRouterError>) -> EnqueueFlux2LoraEditImageAngleRequest {
let ImageGenerationDraftOrRequest::Request(
ImageGenerationRequest::FalFlux2LoraAngles(state)
) = result.expect("build should succeed") else {
panic!("expected FalFlux2LoraAngles variant")
};
state.request
}
#[test]
fn passes_through_camera_params() {
let builder = GenerateImageRequestBuilder {
@@ -207,4 +178,32 @@ mod tests {
Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations))
));
}
fn base() -> GenerateImageRequestBuilder {
GenerateImageRequestBuilder {
model: RouterImageModel::Flux2LoraAngles,
provider: RouterProvider::Fal,
prompt: None,
image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/x.jpg".to_string()])),
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: None,
horizontal_angle: Some(45.0),
vertical_angle: Some(-15.0),
zoom: Some(2.0),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
}
}
fn unwrap_request(result: Result<ImageGenerationDraftOrRequest, ArtcraftRouterError>) -> Flux2LoraEditImageAngleRequest {
let ImageGenerationDraftOrRequest::Request(
ImageGenerationRequest::FalFlux2LoraAngles(state)
) = result.expect("build should succeed") else {
panic!("expected FalFlux2LoraAngles variant")
};
state.request
}
}
@@ -32,13 +32,33 @@ impl FalFlux2LoraAnglesCostState {
#[cfg(test)]
mod tests {
use super::*;
use fal_client::requests_old::webhook::image::angle::enqueue_flux_2_lora_edit_image_angle_webhook::{
EnqueueFlux2LoraAngleNumImages, EnqueueFlux2LoraEditImageAngleRequest,
use fal_client::requests::api::image::angle::flux_2_lora_edit_image_angle::api::{
Flux2LoraAngleNumImages, Flux2LoraEditImageAngleRequest,
};
fn cost_for(n: EnqueueFlux2LoraAngleNumImages) -> ImageGenerationCostEstimate {
#[test]
fn one_image_is_2_cents() {
assert_eq!(cost_for(Flux2LoraAngleNumImages::One).cost_in_usd_cents, Some(2));
}
#[test]
fn two_images_is_4_cents() {
assert_eq!(cost_for(Flux2LoraAngleNumImages::Two).cost_in_usd_cents, Some(4));
}
#[test]
fn three_images_is_6_cents() {
assert_eq!(cost_for(Flux2LoraAngleNumImages::Three).cost_in_usd_cents, Some(6));
}
#[test]
fn four_images_is_8_cents() {
assert_eq!(cost_for(Flux2LoraAngleNumImages::Four).cost_in_usd_cents, Some(8));
}
fn cost_for(n: Flux2LoraAngleNumImages) -> ImageGenerationCostEstimate {
let state = FalFlux2LoraAnglesCostState::from_request(&FalFlux2LoraAnglesRequestState {
request: EnqueueFlux2LoraEditImageAngleRequest {
request: Flux2LoraEditImageAngleRequest {
image_urls: vec!["https://example.com/x.jpg".to_string()],
horizontal_angle: None,
vertical_angle: None,
@@ -52,24 +72,4 @@ mod tests {
});
state.estimate_cost()
}
#[test]
fn one_image_is_2_cents() {
assert_eq!(cost_for(EnqueueFlux2LoraAngleNumImages::One).cost_in_usd_cents, Some(2));
}
#[test]
fn two_images_is_4_cents() {
assert_eq!(cost_for(EnqueueFlux2LoraAngleNumImages::Two).cost_in_usd_cents, Some(4));
}
#[test]
fn three_images_is_6_cents() {
assert_eq!(cost_for(EnqueueFlux2LoraAngleNumImages::Three).cost_in_usd_cents, Some(6));
}
#[test]
fn four_images_is_8_cents() {
assert_eq!(cost_for(EnqueueFlux2LoraAngleNumImages::Four).cost_in_usd_cents, Some(8));
}
}
@@ -1,55 +1,100 @@
use std::fmt::Debug;
use std::sync::Arc;
use fal_client::requests_old::webhook::image::angle::enqueue_flux_2_lora_edit_image_angle_webhook::{
enqueue_flux_2_lora_edit_image_angle_webhook, EnqueueFlux2LoraEditImageAngleArgs,
EnqueueFlux2LoraEditImageAngleRequest,
};
use fal_client::requests::api::image::angle::flux_2_lora_edit_image_angle::api::Flux2LoraEditImageAngleRequest;
use fal_client::requests::traits::fal_endpoint_trait::FalEndpoint;
use crate::client::router_fal_client::RouterFalClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_image::generate_image_response::{
FalImageResponsePayload, GenerateImageResponse,
};
#[derive(Clone, Debug)]
pub struct FalFlux2LoraAnglesRequestState {
pub request: EnqueueFlux2LoraEditImageAngleRequest,
pub request: Flux2LoraEditImageAngleRequest,
}
impl FalFlux2LoraAnglesRequestState {
pub async fn send(&self, client: &RouterFalClient) -> Result<GenerateImageResponse, ArtcraftRouterError> {
let webhook_url = client.webhook_url.as_deref()
.ok_or(ArtcraftRouterError::Client(ClientError::WebhookUrlRequired))?;
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(self.request.clone());
let args = EnqueueFlux2LoraEditImageAngleArgs {
request: self.request.clone(),
webhook_url,
api_key: &client.api_key,
};
let webhook_response = enqueue_flux_2_lora_edit_image_angle_webhook(args)
.await
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?;
let payload = send_fal_request(&self.request, client).await?;
Ok(GenerateImageResponse::Fal(FalImageResponsePayload {
request_id: webhook_response.request_id,
gateway_request_id: webhook_response.gateway_request_id,
maybe_status_url: None,
maybe_response_url: None,
request_id: payload.request_id,
gateway_request_id: payload.gateway_request_id,
maybe_status_url: payload.status_url,
maybe_response_url: payload.response_url,
maybe_outbound_request: Some(outbound),
}))
}
}
// ── Helpers ──
struct FalResponseIds {
request_id: Option<String>,
gateway_request_id: Option<String>,
status_url: Option<String>,
response_url: Option<String>,
}
async fn send_fal_request<T: FalEndpoint>(
request: &T,
client: &RouterFalClient,
) -> Result<FalResponseIds, ArtcraftRouterError> {
if let Some(webhook_url) = &client.webhook_url {
let response = request
.send_webhook_request(&client.api_key, webhook_url)
.await?;
Ok(FalResponseIds {
request_id: response.request_id,
gateway_request_id: response.gateway_request_id,
status_url: None,
response_url: None,
})
} else {
let response = request
.send_queue_request(&client.api_key)
.await?;
Ok(FalResponseIds {
request_id: Some(response.request_id),
gateway_request_id: None,
status_url: Some(response.status_url),
response_url: Some(response.response_url),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use fal_client::creds::fal_api_key::FalApiKey;
use fal_client::requests_old::webhook::image::angle::enqueue_flux_2_lora_edit_image_angle_webhook::{
EnqueueFlux2LoraAngleImageSize, EnqueueFlux2LoraAngleNumImages,
use fal_client::requests::api::image::angle::flux_2_lora_edit_image_angle::api::{
Flux2LoraAngleImageSize, Flux2LoraAngleNumImages,
};
use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL;
#[tokio::test]
#[ignore] // requires real API key, incurs cost
async fn send_via_webhook() {
let client = client_with_webhook();
let state = FalFlux2LoraAnglesRequestState {
request: Flux2LoraEditImageAngleRequest {
image_urls: vec![JUNO_AT_LAKE_IMAGE_URL.to_string()],
horizontal_angle: Some(45.0),
vertical_angle: Some(15.0),
zoom: Some(2.0),
num_images: Some(Flux2LoraAngleNumImages::One),
image_size: Some(Flux2LoraAngleImageSize::SquareHd),
lora_scale: None,
guidance_scale: None,
num_inference_steps: None,
},
};
let response = state.send(&client).await.expect("send should succeed");
let payload = response.get_fal_payload().expect("expected Fal payload");
assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some());
}
fn read_fal_api_key() -> FalApiKey {
let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")
@@ -63,26 +108,4 @@ mod tests {
"https://example.com/fal-webhook-test".to_string(),
)
}
#[tokio::test]
#[ignore] // requires real API key, incurs cost
async fn send_via_webhook() {
let client = client_with_webhook();
let state = FalFlux2LoraAnglesRequestState {
request: EnqueueFlux2LoraEditImageAngleRequest {
image_urls: vec!["https://example.com/test.jpg".to_string()],
horizontal_angle: Some(45.0),
vertical_angle: Some(-15.0),
zoom: Some(2.0),
num_images: Some(EnqueueFlux2LoraAngleNumImages::One),
image_size: Some(EnqueueFlux2LoraAngleImageSize::SquareHd),
lora_scale: None,
guidance_scale: None,
num_inference_steps: None,
},
};
let response = state.send(&client).await.expect("send should succeed");
let payload = response.get_fal_payload().expect("expected Fal payload");
assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some());
}
}
@@ -1,6 +1,5 @@
use fal_client::requests_old::webhook::image::angle::enqueue_qwen_edit_2511_edit_image_angle_webhook::{
EnqueueQwenEdit2511AngleImageSize, EnqueueQwenEdit2511AngleNumImages,
EnqueueQwenEdit2511EditImageAngleRequest,
use fal_client::requests::api::image::angle::qwen_edit_2511_edit_image_angle::api::{
QwenEdit2511AngleImageSize, QwenEdit2511AngleNumImages, QwenEdit2511EditImageAngleRequest,
};
use crate::api::router_aspect_ratio::RouterAspectRatio;
@@ -27,7 +26,7 @@ pub fn build_fal_qwen_edit_2511_angles(
let num_images = plan_num_images(builder.image_batch_count, strategy)?;
let image_size = plan_image_size(builder.aspect_ratio, strategy)?;
let request = EnqueueQwenEdit2511EditImageAngleRequest {
let request = QwenEdit2511EditImageAngleRequest {
image_urls,
horizontal_angle: builder.horizontal_angle,
vertical_angle: builder.vertical_angle,
@@ -58,8 +57,8 @@ fn resolve_image_urls(image_inputs: Option<ImageListRef>) -> Result<Vec<String>,
fn plan_num_images(
image_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<EnqueueQwenEdit2511AngleNumImages, ArtcraftRouterError> {
use EnqueueQwenEdit2511AngleNumImages as N;
) -> Result<QwenEdit2511AngleNumImages, ArtcraftRouterError> {
use QwenEdit2511AngleNumImages as N;
let count = image_batch_count.unwrap_or(1);
match count {
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
@@ -82,8 +81,8 @@ fn plan_num_images(
fn plan_image_size(
aspect_ratio: Option<RouterAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<EnqueueQwenEdit2511AngleImageSize>, ArtcraftRouterError> {
use EnqueueQwenEdit2511AngleImageSize as S;
) -> Result<Option<QwenEdit2511AngleImageSize>, ArtcraftRouterError> {
use QwenEdit2511AngleImageSize as S;
match aspect_ratio {
None => Ok(None),
@@ -119,34 +118,6 @@ mod tests {
use crate::api::router_image_model::RouterImageModel;
use crate::api::router_provider::RouterProvider;
fn base() -> GenerateImageRequestBuilder {
GenerateImageRequestBuilder {
model: RouterImageModel::QwenEdit2511Angles,
provider: RouterProvider::Fal,
prompt: Some("test".to_string()),
image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/x.jpg".to_string()])),
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: None,
horizontal_angle: Some(45.0),
vertical_angle: Some(-15.0),
zoom: Some(2.0),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
}
}
fn unwrap_request(result: Result<ImageGenerationDraftOrRequest, ArtcraftRouterError>) -> EnqueueQwenEdit2511EditImageAngleRequest {
let ImageGenerationDraftOrRequest::Request(
ImageGenerationRequest::FalQwenEdit2511Angles(state)
) = result.expect("build should succeed") else {
panic!("expected FalQwenEdit2511Angles variant")
};
state.request
}
#[test]
fn passes_through_camera_params() {
let req = unwrap_request(build_fal_qwen_edit_2511_angles(base()));
@@ -177,7 +148,7 @@ mod tests {
#[test]
fn default_num_images_is_one() {
let req = unwrap_request(build_fal_qwen_edit_2511_angles(base()));
assert!(matches!(req.num_images, Some(EnqueueQwenEdit2511AngleNumImages::One)));
assert!(matches!(req.num_images, Some(QwenEdit2511AngleNumImages::One)));
}
#[test]
@@ -197,6 +168,34 @@ mod tests {
..base()
};
let req = unwrap_request(build_fal_qwen_edit_2511_angles(builder));
assert!(matches!(req.num_images, Some(EnqueueQwenEdit2511AngleNumImages::Four)));
assert!(matches!(req.num_images, Some(QwenEdit2511AngleNumImages::Four)));
}
fn base() -> GenerateImageRequestBuilder {
GenerateImageRequestBuilder {
model: RouterImageModel::QwenEdit2511Angles,
provider: RouterProvider::Fal,
prompt: Some("test".to_string()),
image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/x.jpg".to_string()])),
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: None,
horizontal_angle: Some(45.0),
vertical_angle: Some(-15.0),
zoom: Some(2.0),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
}
}
fn unwrap_request(result: Result<ImageGenerationDraftOrRequest, ArtcraftRouterError>) -> QwenEdit2511EditImageAngleRequest {
let ImageGenerationDraftOrRequest::Request(
ImageGenerationRequest::FalQwenEdit2511Angles(state)
) = result.expect("build should succeed") else {
panic!("expected FalQwenEdit2511Angles variant")
};
state.request
}
}
@@ -32,13 +32,39 @@ impl FalQwenEdit2511AnglesCostState {
#[cfg(test)]
mod tests {
use super::*;
use fal_client::requests_old::webhook::image::angle::enqueue_qwen_edit_2511_edit_image_angle_webhook::{
EnqueueQwenEdit2511AngleNumImages, EnqueueQwenEdit2511EditImageAngleRequest,
use fal_client::requests::api::image::angle::qwen_edit_2511_edit_image_angle::api::{
QwenEdit2511AngleNumImages, QwenEdit2511EditImageAngleRequest,
};
fn cost_for(n: EnqueueQwenEdit2511AngleNumImages) -> ImageGenerationCostEstimate {
#[test]
fn one_image_is_4_cents() {
assert_eq!(cost_for(QwenEdit2511AngleNumImages::One).cost_in_usd_cents, Some(4));
}
#[test]
fn two_images_is_8_cents() {
assert_eq!(cost_for(QwenEdit2511AngleNumImages::Two).cost_in_usd_cents, Some(8));
}
#[test]
fn three_images_is_12_cents() {
assert_eq!(cost_for(QwenEdit2511AngleNumImages::Three).cost_in_usd_cents, Some(12));
}
#[test]
fn four_images_is_16_cents() {
assert_eq!(cost_for(QwenEdit2511AngleNumImages::Four).cost_in_usd_cents, Some(16));
}
#[test]
fn credits_equal_cents() {
let cost = cost_for(QwenEdit2511AngleNumImages::Two);
assert_eq!(cost.cost_in_credits, cost.cost_in_usd_cents);
}
fn cost_for(n: QwenEdit2511AngleNumImages) -> ImageGenerationCostEstimate {
let state = FalQwenEdit2511AnglesCostState::from_request(&FalQwenEdit2511AnglesRequestState {
request: EnqueueQwenEdit2511EditImageAngleRequest {
request: QwenEdit2511EditImageAngleRequest {
image_urls: vec!["https://example.com/x.jpg".to_string()],
horizontal_angle: None,
vertical_angle: None,
@@ -53,30 +79,4 @@ mod tests {
});
state.estimate_cost()
}
#[test]
fn one_image_is_4_cents() {
assert_eq!(cost_for(EnqueueQwenEdit2511AngleNumImages::One).cost_in_usd_cents, Some(4));
}
#[test]
fn two_images_is_8_cents() {
assert_eq!(cost_for(EnqueueQwenEdit2511AngleNumImages::Two).cost_in_usd_cents, Some(8));
}
#[test]
fn three_images_is_12_cents() {
assert_eq!(cost_for(EnqueueQwenEdit2511AngleNumImages::Three).cost_in_usd_cents, Some(12));
}
#[test]
fn four_images_is_16_cents() {
assert_eq!(cost_for(EnqueueQwenEdit2511AngleNumImages::Four).cost_in_usd_cents, Some(16));
}
#[test]
fn credits_equal_cents() {
let cost = cost_for(EnqueueQwenEdit2511AngleNumImages::Two);
assert_eq!(cost.cost_in_credits, cost.cost_in_usd_cents);
}
}
@@ -1,55 +1,101 @@
use std::fmt::Debug;
use std::sync::Arc;
use fal_client::requests_old::webhook::image::angle::enqueue_qwen_edit_2511_edit_image_angle_webhook::{
enqueue_qwen_edit_2511_edit_image_angle_webhook, EnqueueQwenEdit2511EditImageAngleArgs,
EnqueueQwenEdit2511EditImageAngleRequest,
};
use fal_client::requests::api::image::angle::qwen_edit_2511_edit_image_angle::api::QwenEdit2511EditImageAngleRequest;
use fal_client::requests::traits::fal_endpoint_trait::FalEndpoint;
use crate::client::router_fal_client::RouterFalClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_image::generate_image_response::{
FalImageResponsePayload, GenerateImageResponse,
};
#[derive(Clone, Debug)]
pub struct FalQwenEdit2511AnglesRequestState {
pub request: EnqueueQwenEdit2511EditImageAngleRequest,
pub request: QwenEdit2511EditImageAngleRequest,
}
impl FalQwenEdit2511AnglesRequestState {
pub async fn send(&self, client: &RouterFalClient) -> Result<GenerateImageResponse, ArtcraftRouterError> {
let webhook_url = client.webhook_url.as_deref()
.ok_or(ArtcraftRouterError::Client(ClientError::WebhookUrlRequired))?;
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(self.request.clone());
let args = EnqueueQwenEdit2511EditImageAngleArgs {
request: self.request.clone(),
webhook_url,
api_key: &client.api_key,
};
let webhook_response = enqueue_qwen_edit_2511_edit_image_angle_webhook(args)
.await
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?;
let payload = send_fal_request(&self.request, client).await?;
Ok(GenerateImageResponse::Fal(FalImageResponsePayload {
request_id: webhook_response.request_id,
gateway_request_id: webhook_response.gateway_request_id,
maybe_status_url: None,
maybe_response_url: None,
request_id: payload.request_id,
gateway_request_id: payload.gateway_request_id,
maybe_status_url: payload.status_url,
maybe_response_url: payload.response_url,
maybe_outbound_request: Some(outbound),
}))
}
}
// ── Helpers ──
struct FalResponseIds {
request_id: Option<String>,
gateway_request_id: Option<String>,
status_url: Option<String>,
response_url: Option<String>,
}
async fn send_fal_request<T: FalEndpoint>(
request: &T,
client: &RouterFalClient,
) -> Result<FalResponseIds, ArtcraftRouterError> {
if let Some(webhook_url) = &client.webhook_url {
let response = request
.send_webhook_request(&client.api_key, webhook_url)
.await?;
Ok(FalResponseIds {
request_id: response.request_id,
gateway_request_id: response.gateway_request_id,
status_url: None,
response_url: None,
})
} else {
let response = request
.send_queue_request(&client.api_key)
.await?;
Ok(FalResponseIds {
request_id: Some(response.request_id),
gateway_request_id: None,
status_url: Some(response.status_url),
response_url: Some(response.response_url),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use fal_client::creds::fal_api_key::FalApiKey;
use fal_client::requests_old::webhook::image::angle::enqueue_qwen_edit_2511_edit_image_angle_webhook::{
EnqueueQwenEdit2511AngleImageSize, EnqueueQwenEdit2511AngleNumImages,
use fal_client::requests::api::image::angle::qwen_edit_2511_edit_image_angle::api::{
QwenEdit2511AngleImageSize, QwenEdit2511AngleNumImages,
};
use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL;
#[tokio::test]
#[ignore] // requires real API key, incurs cost
async fn send_via_webhook() {
let client = client_with_webhook();
let state = FalQwenEdit2511AnglesRequestState {
request: QwenEdit2511EditImageAngleRequest {
image_urls: vec![JUNO_AT_LAKE_IMAGE_URL.to_string()],
horizontal_angle: Some(45.0),
vertical_angle: Some(-15.0),
zoom: Some(2.0),
additional_prompt: Some("cinematic".to_string()),
num_images: Some(QwenEdit2511AngleNumImages::One),
image_size: Some(QwenEdit2511AngleImageSize::SquareHd),
lora_scale: None,
guidance_scale: None,
num_inference_steps: None,
},
};
let response = state.send(&client).await.expect("send should succeed");
let payload = response.get_fal_payload().expect("expected Fal payload");
assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some());
}
fn read_fal_api_key() -> FalApiKey {
let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")
@@ -63,27 +109,4 @@ mod tests {
"https://example.com/fal-webhook-test".to_string(),
)
}
#[tokio::test]
#[ignore] // requires real API key, incurs cost
async fn send_via_webhook() {
let client = client_with_webhook();
let state = FalQwenEdit2511AnglesRequestState {
request: EnqueueQwenEdit2511EditImageAngleRequest {
image_urls: vec!["https://example.com/test.jpg".to_string()],
horizontal_angle: Some(45.0),
vertical_angle: Some(-15.0),
zoom: Some(2.0),
additional_prompt: Some("cinematic".to_string()),
num_images: Some(EnqueueQwenEdit2511AngleNumImages::One),
image_size: Some(EnqueueQwenEdit2511AngleImageSize::SquareHd),
lora_scale: None,
guidance_scale: None,
num_inference_steps: None,
},
};
let response = state.send(&client).await.expect("send should succeed");
let payload = response.get_fal_payload().expect("expected Fal payload");
assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some());
}
}
@@ -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() };
@@ -42,6 +42,9 @@ use crate::generate::generate_video::providers::artcraft::veo_3::build::build_ar
use crate::generate::generate_video::providers::artcraft::veo_3_fast::build::build_artcraft_veo_3_fast;
use crate::generate::generate_video::providers::artcraft::veo_3p1::build::build_artcraft_veo_3p1;
use crate::generate::generate_video::providers::artcraft::veo_3p1_fast::build::build_artcraft_veo_3p1_fast;
use crate::generate::generate_video::providers::artcraft::veo_3p1_lite::build::build_artcraft_veo_3p1_lite;
use crate::generate::generate_video::providers::artcraft::vidu_q3::build::build_artcraft_vidu_q3;
use crate::generate::generate_video::providers::artcraft::vidu_q3_turbo::build::build_artcraft_vidu_q3_turbo;
use crate::generate::generate_video::providers::kinovi::happy_horse_1p0::build::build_kinovi_happy_horse_1p0;
use crate::generate::generate_video::providers::kinovi::seedance_2p0::build::build_kinovi_seedance_2p0;
use crate::generate::generate_video::providers::gmicloud::seedance_2p0_g::build::build_gmicloud_seedance_2p0_u;
@@ -64,6 +67,9 @@ use crate::generate::generate_video::providers::fal::veo_3::build::build_fal_veo
use crate::generate::generate_video::providers::fal::veo_3_fast::build::build_fal_veo_3_fast;
use crate::generate::generate_video::providers::fal::veo_3p1::build::build_fal_veo_3p1;
use crate::generate::generate_video::providers::fal::veo_3p1_fast::build::build_fal_veo_3p1_fast;
use crate::generate::generate_video::providers::fal::veo_3p1_lite::build::build_fal_veo_3p1_lite;
use crate::generate::generate_video::providers::fal::vidu_q3::build::build_fal_vidu_q3;
use crate::generate::generate_video::providers::fal::vidu_q3_turbo::build::build_fal_vidu_q3_turbo;
use crate::generate::generate_video::providers::kinovi::seedance_2p0_fast::build::build_kinovi_seedance_2p0_fast;
use crate::generate::generate_video::providers::kinovi::seedance_2p0_mini::build::build_kinovi_seedance_2p0_mini;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
@@ -194,6 +200,9 @@ impl GenerateVideoRequestBuilder {
(RouterProvider::Artcraft, RouterVideoModel::Veo3Fast) => build_artcraft_veo_3_fast(self),
(RouterProvider::Artcraft, RouterVideoModel::Veo3p1) => build_artcraft_veo_3p1(self),
(RouterProvider::Artcraft, RouterVideoModel::Veo3p1Fast) => build_artcraft_veo_3p1_fast(self),
(RouterProvider::Artcraft, RouterVideoModel::Veo3p1Lite) => build_artcraft_veo_3p1_lite(self),
(RouterProvider::Artcraft, RouterVideoModel::ViduQ3) => build_artcraft_vidu_q3(self),
(RouterProvider::Artcraft, RouterVideoModel::ViduQ3Turbo) => build_artcraft_vidu_q3_turbo(self),
// Fal
(RouterProvider::Fal, RouterVideoModel::Kling16Pro) => build_fal_kling_1_6_pro(self),
(RouterProvider::Fal, RouterVideoModel::Kling21Master) => build_fal_kling_2_1_master(self),
@@ -211,6 +220,9 @@ impl GenerateVideoRequestBuilder {
(RouterProvider::Fal, RouterVideoModel::Veo3Fast) => build_fal_veo_3_fast(self),
(RouterProvider::Fal, RouterVideoModel::Veo3p1) => build_fal_veo_3p1(self),
(RouterProvider::Fal, RouterVideoModel::Veo3p1Fast) => build_fal_veo_3p1_fast(self),
(RouterProvider::Fal, RouterVideoModel::Veo3p1Lite) => build_fal_veo_3p1_lite(self),
(RouterProvider::Fal, RouterVideoModel::ViduQ3) => build_fal_vidu_q3(self),
(RouterProvider::Fal, RouterVideoModel::ViduQ3Turbo) => build_fal_vidu_q3_turbo(self),
// GmiCloud
(RouterProvider::GmiCloud, RouterVideoModel::Seedance2p0Ultra) => build_gmicloud_seedance_2p0_u(self),
(RouterProvider::GmiCloud, RouterVideoModel::Seedance2p0UltraFast) => build_gmicloud_seedance_2p0_u_fast(self),
@@ -36,4 +36,7 @@ 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;
@@ -15,9 +15,9 @@ impl ArtcraftVeo2CostState {
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
// Mirrors fal_client veo_2: 5s = $2.50, +$0.50/s above 5s.
// 5s = $2.75, +$0.55/s above 5s.
let extra = self.duration_seconds.saturating_sub(5);
let cost_in_usd_cents = 250 + extra * 50;
let cost_in_usd_cents = 275 + extra * 55;
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
@@ -49,17 +49,17 @@ mod tests {
}
#[test]
fn default_is_5s_priced_at_250() { assert_eq!(cost_cents(None), 250); }
fn default_is_5s_priced_at_275() { assert_eq!(cost_cents(None), 275); }
#[test]
fn five_seconds_is_250() { assert_eq!(cost_cents(Some(5)), 250); }
fn five_seconds_is_275() { assert_eq!(cost_cents(Some(5)), 275); }
#[test]
fn six_seconds_is_300() { assert_eq!(cost_cents(Some(6)), 300); }
fn six_seconds_is_330() { assert_eq!(cost_cents(Some(6)), 330); }
#[test]
fn seven_seconds_is_350() { assert_eq!(cost_cents(Some(7)), 350); }
fn seven_seconds_is_385() { assert_eq!(cost_cents(Some(7)), 385); }
#[test]
fn eight_seconds_is_400() { assert_eq!(cost_cents(Some(8)), 400); }
fn eight_seconds_is_440() { assert_eq!(cost_cents(Some(8)), 440); }
}
@@ -17,8 +17,8 @@ impl ArtcraftVeo3CostState {
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
// Mirrors fal_client veo_3: $0.20/sec audio off, $0.40/sec audio on.
let per_second_cents: u64 = if self.generate_audio { 40 } else { 20 };
// $0.24/sec audio off, $0.48/sec audio on.
let per_second_cents: u64 = if self.generate_audio { 48 } else { 24 };
let cost_in_usd_cents = per_second_cents * self.duration_seconds;
VideoGenerationCostEstimate {
@@ -61,19 +61,19 @@ mod tests {
}
#[test]
fn audio_off_4s_is_80() { assert_eq!(cost_cents(Some(4), Some(false)), 80); }
fn audio_off_4s_is_96() { assert_eq!(cost_cents(Some(4), Some(false)), 96); }
#[test]
fn audio_off_6s_is_120() { assert_eq!(cost_cents(Some(6), Some(false)), 120); }
fn audio_off_6s_is_144() { assert_eq!(cost_cents(Some(6), Some(false)), 144); }
#[test]
fn audio_off_8s_is_160() { assert_eq!(cost_cents(Some(8), Some(false)), 160); }
fn audio_off_8s_is_192() { assert_eq!(cost_cents(Some(8), Some(false)), 192); }
#[test]
fn audio_on_4s_is_160() { assert_eq!(cost_cents(Some(4), Some(true)), 160); }
fn audio_on_4s_is_192() { assert_eq!(cost_cents(Some(4), Some(true)), 192); }
#[test]
fn audio_on_8s_is_320() { assert_eq!(cost_cents(Some(8), Some(true)), 320); }
fn audio_on_8s_is_384() { assert_eq!(cost_cents(Some(8), Some(true)), 384); }
#[test]
fn default_duration_is_8s() {
@@ -15,10 +15,8 @@ impl ArtcraftVeo3FastCostState {
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
// Mirrors fal_client veo_3_fast: $0.10/sec audio off, $0.15/sec audio on.
// Legacy handler always bills 8s.
let per_second_cents: u64 = if self.generate_audio { 15 } else { 10 };
let cost_in_usd_cents = per_second_cents * 8;
// Flat per-video price: 88¢ audio off, 132¢ audio on.
let cost_in_usd_cents: u64 = if self.generate_audio { 132 } else { 88 };
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
@@ -51,22 +49,22 @@ mod tests {
}
#[test]
fn audio_off_is_80() { assert_eq!(cost_cents(Some(8), Some(false)), 80); }
fn audio_off_is_88() { assert_eq!(cost_cents(Some(8), Some(false)), 88); }
#[test]
fn audio_on_is_120() { assert_eq!(cost_cents(Some(8), Some(true)), 120); }
fn audio_on_is_132() { assert_eq!(cost_cents(Some(8), Some(true)), 132); }
#[test]
fn audio_default_is_off() {
assert_eq!(cost_cents(Some(8), None), 80);
assert_eq!(cost_cents(Some(8), None), 88);
}
#[test]
fn duration_does_not_affect_cost() {
// v1 always bills 8s regardless of duration_seconds.
assert_eq!(cost_cents(Some(4), Some(false)), 80);
assert_eq!(cost_cents(Some(6), Some(false)), 80);
assert_eq!(cost_cents(Some(8), Some(false)), 80);
assert_eq!(cost_cents(None, Some(false)), 80);
assert_eq!(cost_cents(Some(4), Some(false)), 88);
assert_eq!(cost_cents(Some(6), Some(false)), 88);
assert_eq!(cost_cents(Some(8), Some(false)), 88);
assert_eq!(cost_cents(None, Some(false)), 88);
}
}
@@ -23,7 +23,7 @@ pub fn build_artcraft_veo_3p1(mut builder: GenerateVideoRequestBuilder) -> Resul
let mut request = build_artcraft_omni_video_request(
builder,
CommonVideoModelEnum::Veo3p1,
SupportedResolutions::Full,
SupportedResolutions::FullWith4k,
UltraWideSupport::Unsupported,
)?;
request.generate_audio = generate_audio;
@@ -1,3 +1,5 @@
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video::providers::artcraft::veo_3p1::request::ArtcraftVeo3p1RequestState;
@@ -5,6 +7,7 @@ use crate::generate::generate_video::providers::artcraft::veo_3p1::request::Artc
pub struct ArtcraftVeo3p1CostState {
pub duration_seconds: u64,
pub generate_audio: bool,
pub is_4k: bool,
}
impl ArtcraftVeo3p1CostState {
@@ -14,12 +17,19 @@ impl ArtcraftVeo3p1CostState {
duration_seconds: request.request.duration_seconds.map(u64::from).unwrap_or(6),
// v1 legacy Veo 3.1 handler defaults generate_audio to true.
generate_audio: request.request.generate_audio.unwrap_or(true),
is_4k: request.request.resolution == Some(CommonResolutionEnum::FourK),
}
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
// Mirrors fal_client veo_3p1: $0.20/sec audio off, $0.40/sec audio on.
let per_second_cents: u64 = if self.generate_audio { 40 } else { 20 };
// $0.24/sec audio off, $0.48/sec audio on; 4K renders bill a premium:
// $0.48/sec audio off, $0.72/sec audio on.
let per_second_cents: u64 = match (self.is_4k, self.generate_audio) {
(false, false) => 24,
(false, true) => 48,
(true, false) => 48,
(true, true) => 72,
};
let cost_in_usd_cents = per_second_cents * self.duration_seconds;
VideoGenerationCostEstimate {
@@ -36,12 +46,22 @@ impl ArtcraftVeo3p1CostState {
#[cfg(test)]
mod tests {
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
use crate::api::router_provider::RouterProvider;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
fn cost_cents(duration_seconds: Option<u16>, generate_audio: Option<bool>) -> u64 {
cost_cents_at(duration_seconds, generate_audio, None)
}
fn cost_cents_at(
duration_seconds: Option<u16>,
generate_audio: Option<bool>,
resolution: Option<RouterResolution>,
) -> u64 {
let b = GenerateVideoRequestBuilder {
resolution,
model: RouterVideoModel::Veo3p1,
provider: RouterProvider::Artcraft,
prompt: Some("test".to_string()),
@@ -53,24 +73,42 @@ mod tests {
}
#[test]
fn audio_on_4s_is_160() { assert_eq!(cost_cents(Some(4), Some(true)), 160); }
fn audio_on_4s_is_192() { assert_eq!(cost_cents(Some(4), Some(true)), 192); }
#[test]
fn audio_on_6s_is_240() { assert_eq!(cost_cents(Some(6), Some(true)), 240); }
fn audio_on_6s_is_288() { assert_eq!(cost_cents(Some(6), Some(true)), 288); }
#[test]
fn audio_on_8s_is_320() { assert_eq!(cost_cents(Some(8), Some(true)), 320); }
fn audio_on_8s_is_384() { assert_eq!(cost_cents(Some(8), Some(true)), 384); }
#[test]
fn audio_off_4s_is_80() { assert_eq!(cost_cents(Some(4), Some(false)), 80); }
fn audio_off_4s_is_96() { assert_eq!(cost_cents(Some(4), Some(false)), 96); }
#[test]
fn default_duration_is_6s() {
assert_eq!(cost_cents(None, Some(true)), 240);
assert_eq!(cost_cents(None, Some(true)), 288);
}
#[test]
fn audio_default_is_on() {
assert_eq!(cost_cents(Some(6), None), 240);
assert_eq!(cost_cents(Some(6), None), 288);
}
#[test]
fn four_k_audio_off_4s_is_192() {
assert_eq!(cost_cents_at(Some(4), Some(false), Some(RouterResolution::FourK)), 192);
}
#[test]
fn four_k_audio_on_4s_is_288() {
assert_eq!(cost_cents_at(Some(4), Some(true), Some(RouterResolution::FourK)), 288);
}
#[test]
fn non_four_k_resolutions_share_the_base_rate() {
assert_eq!(
cost_cents_at(Some(8), Some(true), Some(RouterResolution::SevenTwentyP)),
cost_cents_at(Some(8), Some(true), Some(RouterResolution::TenEightyP)),
);
}
}
@@ -20,7 +20,7 @@ pub fn build_artcraft_veo_3p1_fast(mut builder: GenerateVideoRequestBuilder) ->
let mut request = build_artcraft_omni_video_request(
builder,
CommonVideoModelEnum::Veo3p1Fast,
SupportedResolutions::Full,
SupportedResolutions::FullWith4k,
UltraWideSupport::Unsupported,
)?;
request.generate_audio = generate_audio;
@@ -1,3 +1,5 @@
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video::providers::artcraft::veo_3p1_fast::request::ArtcraftVeo3p1FastRequestState;
@@ -5,6 +7,7 @@ use crate::generate::generate_video::providers::artcraft::veo_3p1_fast::request:
pub struct ArtcraftVeo3p1FastCostState {
pub duration_seconds: u64,
pub generate_audio: bool,
pub is_4k: bool,
}
impl ArtcraftVeo3p1FastCostState {
@@ -14,13 +17,20 @@ impl ArtcraftVeo3p1FastCostState {
duration_seconds: request.request.duration_seconds.map(u64::from).unwrap_or(6),
// v1 legacy Veo 3.1 Fast handler defaults generate_audio to true.
generate_audio: request.request.generate_audio.unwrap_or(true),
is_4k: request.request.resolution == Some(CommonResolutionEnum::FourK),
}
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
// Mirrors fal_client veo_3p1_fast: $0.10/sec audio off, $0.15/sec audio on.
let per_second_cents: u64 = if self.generate_audio { 15 } else { 10 };
let cost_in_usd_cents = per_second_cents * self.duration_seconds;
// Per-second rates in hundredths of a US cent; 4K renders bill a premium.
let rate_hundredth_cents: u64 = match (self.is_4k, self.generate_audio) {
(false, false) => 1_100,
(false, true) => 1_650,
(true, false) => 3_300,
(true, true) => 3_850,
};
// Round up to the next whole cent.
let cost_in_usd_cents = (rate_hundredth_cents * self.duration_seconds).div_ceil(100);
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
@@ -36,12 +46,22 @@ impl ArtcraftVeo3p1FastCostState {
#[cfg(test)]
mod tests {
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
use crate::api::router_provider::RouterProvider;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
fn cost_cents(duration_seconds: Option<u16>, generate_audio: Option<bool>) -> u64 {
cost_cents_at(duration_seconds, generate_audio, None)
}
fn cost_cents_at(
duration_seconds: Option<u16>,
generate_audio: Option<bool>,
resolution: Option<RouterResolution>,
) -> u64 {
let b = GenerateVideoRequestBuilder {
resolution,
model: RouterVideoModel::Veo3p1Fast,
provider: RouterProvider::Artcraft,
prompt: Some("test".to_string()),
@@ -53,24 +73,42 @@ mod tests {
}
#[test]
fn audio_on_4s_is_60() { assert_eq!(cost_cents(Some(4), Some(true)), 60); }
fn audio_on_4s_is_66() { assert_eq!(cost_cents(Some(4), Some(true)), 66); }
#[test]
fn audio_on_6s_is_90() { assert_eq!(cost_cents(Some(6), Some(true)), 90); }
fn audio_on_6s_is_99() { assert_eq!(cost_cents(Some(6), Some(true)), 99); }
#[test]
fn audio_on_8s_is_120() { assert_eq!(cost_cents(Some(8), Some(true)), 120); }
fn audio_on_8s_is_132() { assert_eq!(cost_cents(Some(8), Some(true)), 132); }
#[test]
fn audio_off_4s_is_40() { assert_eq!(cost_cents(Some(4), Some(false)), 40); }
fn audio_off_4s_is_44() { assert_eq!(cost_cents(Some(4), Some(false)), 44); }
#[test]
fn default_duration_is_6s() {
assert_eq!(cost_cents(None, Some(true)), 90);
assert_eq!(cost_cents(None, Some(true)), 99);
}
#[test]
fn audio_default_is_on() {
assert_eq!(cost_cents(Some(6), None), 90);
assert_eq!(cost_cents(Some(6), None), 99);
}
#[test]
fn four_k_audio_off_4s_is_132() {
assert_eq!(cost_cents_at(Some(4), Some(false), Some(RouterResolution::FourK)), 132);
}
#[test]
fn four_k_audio_on_4s_is_154() {
assert_eq!(cost_cents_at(Some(4), Some(true), Some(RouterResolution::FourK)), 154);
}
#[test]
fn non_four_k_resolutions_share_the_base_rate() {
assert_eq!(
cost_cents_at(Some(8), Some(true), Some(RouterResolution::SevenTwentyP)),
cost_cents_at(Some(8), Some(true), Some(RouterResolution::TenEightyP)),
);
}
}
@@ -0,0 +1,29 @@
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::providers::artcraft::build_common::{
build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport,
};
use crate::generate::generate_video::providers::artcraft::veo_3p1_lite::request::ArtcraftVeo3p1LiteRequestState;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
pub fn build_artcraft_veo_3p1_lite(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let state = build_artcraft_veo_3p1_lite_state(builder)?;
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftVeo3p1Lite(state)))
}
pub(crate) fn build_artcraft_veo_3p1_lite_state(builder: GenerateVideoRequestBuilder) -> Result<ArtcraftVeo3p1LiteRequestState, ArtcraftRouterError> {
// Veo 3.1 Lite supports `generate_audio` — preserve it so cost can read it back.
// build_artcraft_omni_video_request hardcodes generate_audio = None on its output.
let generate_audio = builder.generate_audio;
let mut request = build_artcraft_omni_video_request(
builder,
CommonVideoModelEnum::Veo3p1Lite,
SupportedResolutions::NoFourEightyP,
UltraWideSupport::Unsupported,
)?;
request.generate_audio = generate_audio;
Ok(ArtcraftVeo3p1LiteRequestState { request })
}
@@ -0,0 +1,119 @@
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video::providers::artcraft::veo_3p1_lite::request::ArtcraftVeo3p1LiteRequestState;
/// Per-second rates in hundredths of a US cent.
const SEVEN_TWENTY_P_AUDIO_OFF_RATE: u64 = 315;
const SEVEN_TWENTY_P_AUDIO_ON_RATE: u64 = 525;
const TEN_EIGHTY_P_AUDIO_OFF_RATE: u64 = 525;
const TEN_EIGHTY_P_AUDIO_ON_RATE: u64 = 840;
#[derive(Clone, Debug)]
pub struct ArtcraftVeo3p1LiteCostState {
pub duration_seconds: u64,
pub is_1080p: bool,
pub generate_audio: bool,
}
impl ArtcraftVeo3p1LiteCostState {
pub fn from_request(request: &ArtcraftVeo3p1LiteRequestState) -> Self {
Self {
// Veo 3.1 Lite defaults None → 8s.
duration_seconds: request.request.duration_seconds.map(u64::from).unwrap_or(8),
is_1080p: is_1080p(request.request.resolution),
// Audio is on when unspecified.
generate_audio: request.request.generate_audio.unwrap_or(true),
}
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
let rate = match (self.is_1080p, self.generate_audio) {
(false, false) => SEVEN_TWENTY_P_AUDIO_OFF_RATE,
(false, true) => SEVEN_TWENTY_P_AUDIO_ON_RATE,
(true, false) => TEN_EIGHTY_P_AUDIO_OFF_RATE,
(true, true) => TEN_EIGHTY_P_AUDIO_ON_RATE,
};
// Round up to the next whole cent.
let cost_in_usd_cents = (rate * self.duration_seconds).div_ceil(100);
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
cost_in_usd_cents: Some(cost_in_usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
}
/// Veo 3.1 Lite renders 720p (the default) or 1080p.
fn is_1080p(resolution: Option<CommonResolutionEnum>) -> bool {
matches!(
resolution,
Some(CommonResolutionEnum::TenEightyP)
| Some(CommonResolutionEnum::TwoK)
| Some(CommonResolutionEnum::ThreeK)
| Some(CommonResolutionEnum::FourK)
)
}
#[cfg(test)]
mod tests {
use crate::api::router_provider::RouterProvider;
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::providers::artcraft::veo_3p1_lite::build::build_artcraft_veo_3p1_lite_state;
use crate::generate::generate_video::providers::artcraft::veo_3p1_lite::cost::ArtcraftVeo3p1LiteCostState;
fn cost_cents(
duration_seconds: Option<u16>,
resolution: Option<RouterResolution>,
generate_audio: Option<bool>,
) -> u64 {
let b = GenerateVideoRequestBuilder {
model: RouterVideoModel::Veo3p1Lite,
provider: RouterProvider::Artcraft,
prompt: Some("test".to_string()),
duration_seconds,
resolution,
generate_audio,
..Default::default()
};
let state = build_artcraft_veo_3p1_lite_state(b).unwrap();
ArtcraftVeo3p1LiteCostState::from_request(&state).estimate_cost().cost_in_usd_cents.unwrap()
}
#[test]
fn defaults_8s_720p_audio_on_is_42() { assert_eq!(cost_cents(None, None, None), 42); }
#[test]
fn audio_off_8s_720p_is_26() {
// 315 * 8 = 2520 hundredth-cents → 26 cents.
assert_eq!(cost_cents(Some(8), None, Some(false)), 26);
}
#[test]
fn audio_on_8s_1080p_is_68() {
// 840 * 8 = 6720 hundredth-cents → 68 cents.
assert_eq!(cost_cents(Some(8), Some(RouterResolution::TenEightyP), Some(true)), 68);
}
#[test]
fn audio_off_8s_1080p_is_42() {
assert_eq!(cost_cents(Some(8), Some(RouterResolution::TenEightyP), Some(false)), 42);
}
#[test]
fn audio_on_4s_720p_is_21() {
assert_eq!(cost_cents(Some(4), None, Some(true)), 21);
}
#[test]
fn audio_default_is_on() {
assert_eq!(cost_cents(Some(8), None, None), cost_cents(Some(8), None, Some(true)));
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -0,0 +1,104 @@
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
use crate::generate::generate_video::providers::artcraft::request_common::send_artcraft_omni_video_request;
#[derive(Clone, Debug)]
pub struct ArtcraftVeo3p1LiteRequestState {
pub request: OmniGenVideoCostAndGenerateRequest,
}
impl ArtcraftVeo3p1LiteRequestState {
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
send_artcraft_omni_video_request(&self.request, client).await
}
}
#[cfg(test)]
mod tests {
use tokens::tokens::media_files::MediaFileToken;
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use artcraft_client::utils::api_host::ApiHost;
use test_data::web::image_media_tokens::JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN;
use crate::api::image_ref::ImageRef;
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_provider::RouterProvider;
use crate::api::router_video_model::RouterVideoModel;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::client::router_client::RouterClient;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
#[tokio::test]
#[ignore] // sends a real generation to the Artcraft backend, incurs cost
async fn text_to_video() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A lighthouse beam sweeping across a foggy bay at night.".to_string()),
aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine),
..artcraft_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // sends a real generation to the Artcraft backend, incurs cost
async fn keyframe_start_frame() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("The dog turns its head toward the camera.".to_string()),
start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new(JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN.to_string()))),
..artcraft_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
// ── Helpers ──
fn artcraft_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: RouterVideoModel::Veo3p1Lite,
provider: RouterProvider::Artcraft,
duration_seconds: Some(8),
video_batch_count: Some(1),
..Default::default()
}
}
fn get_artcraft_client() -> RouterClient {
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/artcraft_cookies.txt")
.expect("Failed to read artcraft cookies");
let cookies = cookies.trim().to_string();
let credentials = StorytellerCredentialSet::parse_multi_cookie_header(&cookies)
.expect("Failed to parse cookies")
.expect("No credentials found");
RouterClient::Artcraft(RouterArtcraftClient::new(ApiHost::Storyteller, credentials))
}
async fn run_pipeline(builder: GenerateVideoRequestBuilder) -> GenerateVideoResponse {
let client = get_artcraft_client();
let draft_or_request = builder.build2().expect("build2 should succeed");
let request = match draft_or_request {
VideoGenerationDraftOrRequest::Request(r) => r,
_ => panic!("expected Request variant (Artcraft skips draft)"),
};
let response = request.send_request(&client).await.expect("send_request should succeed");
match &response {
GenerateVideoResponse::Artcraft(p) => {
println!("inference_job_token={:?}", p.inference_job_token);
println!("all_inference_job_tokens={:?}", p.all_inference_job_tokens);
}
other => println!("response: {:?}", other),
}
response
}
}
@@ -0,0 +1,29 @@
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::providers::artcraft::build_common::{
build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport,
};
use crate::generate::generate_video::providers::artcraft::vidu_q3::request::ArtcraftViduQ3RequestState;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
pub fn build_artcraft_vidu_q3(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let state = build_artcraft_vidu_q3_state(builder)?;
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftViduQ3(state)))
}
pub(crate) fn build_artcraft_vidu_q3_state(builder: GenerateVideoRequestBuilder) -> Result<ArtcraftViduQ3RequestState, ArtcraftRouterError> {
// Vidu Q3 supports `audio` — preserve it so cost can read it back.
// build_artcraft_omni_video_request hardcodes generate_audio = None on its output.
let generate_audio = builder.generate_audio;
let mut request = build_artcraft_omni_video_request(
builder,
CommonVideoModelEnum::ViduQ3,
SupportedResolutions::Full,
UltraWideSupport::Unsupported,
)?;
request.generate_audio = generate_audio;
Ok(ArtcraftViduQ3RequestState { request })
}
@@ -0,0 +1,118 @@
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video::providers::artcraft::vidu_q3::request::ArtcraftViduQ3RequestState;
/// Per-second rates in hundredths of a US cent.
const LOW_RES_RATE_HUNDREDTH_CENTS_PER_SEC: u64 = 735;
const HIGH_RES_RATE_HUNDREDTH_CENTS_PER_SEC: u64 = 1_634;
#[derive(Clone, Debug)]
pub struct ArtcraftViduQ3CostState {
pub duration_seconds: u64,
pub is_high_res: bool,
}
impl ArtcraftViduQ3CostState {
pub fn from_request(request: &ArtcraftViduQ3RequestState) -> Self {
Self {
// Vidu Q3 defaults None → 5s.
duration_seconds: request.request.duration_seconds.map(u64::from).unwrap_or(5),
is_high_res: is_high_res(request.request.resolution),
}
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
let rate = if self.is_high_res {
HIGH_RES_RATE_HUNDREDTH_CENTS_PER_SEC
} else {
LOW_RES_RATE_HUNDREDTH_CENTS_PER_SEC
};
// Round up to the next whole cent.
let cost_in_usd_cents = (rate * self.duration_seconds).div_ceil(100);
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
cost_in_usd_cents: Some(cost_in_usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
}
/// Vidu prices 720p/1080p at a higher tier than 360p/540p. The default (unset)
/// resolution is 720p.
fn is_high_res(resolution: Option<CommonResolutionEnum>) -> bool {
match resolution {
None => true, // defaults to 720p
Some(CommonResolutionEnum::SevenTwentyP)
| Some(CommonResolutionEnum::TenEightyP)
| Some(CommonResolutionEnum::OneK)
| Some(CommonResolutionEnum::TwoK)
| Some(CommonResolutionEnum::ThreeK)
| Some(CommonResolutionEnum::FourK) => true,
Some(CommonResolutionEnum::HalfK)
| Some(CommonResolutionEnum::FourEightyP) => false,
}
}
#[cfg(test)]
mod tests {
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
use crate::api::router_provider::RouterProvider;
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::providers::artcraft::vidu_q3::build::build_artcraft_vidu_q3_state;
use crate::generate::generate_video::providers::artcraft::vidu_q3::cost::ArtcraftViduQ3CostState;
fn cost_cents(duration_seconds: Option<u16>, resolution: Option<RouterResolution>) -> u64 {
let b = GenerateVideoRequestBuilder {
model: RouterVideoModel::ViduQ3,
provider: RouterProvider::Artcraft,
prompt: Some("test".to_string()),
duration_seconds,
resolution,
..Default::default()
};
let state = build_artcraft_vidu_q3_state(b).unwrap();
ArtcraftViduQ3CostState::from_request(&state).estimate_cost().cost_in_usd_cents.unwrap()
}
#[test]
fn default_resolution_5s_is_82() { assert_eq!(cost_cents(Some(5), None), 82); }
#[test]
fn default_duration_is_5s() { assert_eq!(cost_cents(None, None), 82); }
#[test]
fn high_res_10s_is_164() { assert_eq!(cost_cents(Some(10), Some(RouterResolution::TenEightyP)), 164); }
#[test]
fn low_res_5s_is_37() { assert_eq!(cost_cents(Some(5), Some(RouterResolution::FourEightyP)), 37); }
#[test]
fn low_res_10s_is_74() { assert_eq!(cost_cents(Some(10), Some(RouterResolution::FourEightyP)), 74); }
#[test]
fn seven_twenty_p_is_high_res_tier() {
assert_eq!(cost_cents(Some(8), Some(RouterResolution::SevenTwentyP)), 131);
}
#[test]
fn odd_duration_rounds_up_to_whole_cents() {
// 7s high res: 1634 * 7 = 11438 hundredth-cents → 115 cents.
assert_eq!(cost_cents(Some(7), None), 115);
}
#[test]
fn resolution_classifier_defaults_high() {
assert!(super::is_high_res(None));
assert!(super::is_high_res(Some(CommonResolutionEnum::TenEightyP)));
assert!(!super::is_high_res(Some(CommonResolutionEnum::FourEightyP)));
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -0,0 +1,117 @@
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
use crate::generate::generate_video::providers::artcraft::request_common::send_artcraft_omni_video_request;
#[derive(Clone, Debug)]
pub struct ArtcraftViduQ3RequestState {
pub request: OmniGenVideoCostAndGenerateRequest,
}
impl ArtcraftViduQ3RequestState {
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
send_artcraft_omni_video_request(&self.request, client).await
}
}
#[cfg(test)]
mod tests {
use tokens::tokens::media_files::MediaFileToken;
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use artcraft_client::utils::api_host::ApiHost;
use test_data::web::image_media_tokens::JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN;
use crate::api::image_ref::ImageRef;
use crate::api::image_list_ref::ImageListRef;
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_provider::RouterProvider;
use crate::api::router_video_model::RouterVideoModel;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::client::router_client::RouterClient;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
#[tokio::test]
#[ignore] // sends a real generation to the Artcraft backend, incurs cost
async fn text_to_video() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A red kite soaring over coastal cliffs, cinematic.".to_string()),
aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine),
..artcraft_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // sends a real generation to the Artcraft backend, incurs cost
async fn keyframe_start_frame() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("The dog stands up and walks toward the lake.".to_string()),
start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new(JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN.to_string()))),
..artcraft_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // sends a real generation to the Artcraft backend, incurs cost
async fn reference_images_to_video() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("The dog explores a mossy forest clearing.".to_string()),
reference_images: Some(ImageListRef::MediaFileTokens(vec![MediaFileToken::new(JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN.to_string())])),
..artcraft_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
// ── Helpers ──
fn artcraft_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: RouterVideoModel::ViduQ3,
provider: RouterProvider::Artcraft,
duration_seconds: Some(5),
video_batch_count: Some(1),
..Default::default()
}
}
fn get_artcraft_client() -> RouterClient {
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/artcraft_cookies.txt")
.expect("Failed to read artcraft cookies");
let cookies = cookies.trim().to_string();
let credentials = StorytellerCredentialSet::parse_multi_cookie_header(&cookies)
.expect("Failed to parse cookies")
.expect("No credentials found");
RouterClient::Artcraft(RouterArtcraftClient::new(ApiHost::Storyteller, credentials))
}
async fn run_pipeline(builder: GenerateVideoRequestBuilder) -> GenerateVideoResponse {
let client = get_artcraft_client();
let draft_or_request = builder.build2().expect("build2 should succeed");
let request = match draft_or_request {
VideoGenerationDraftOrRequest::Request(r) => r,
_ => panic!("expected Request variant (Artcraft skips draft)"),
};
let response = request.send_request(&client).await.expect("send_request should succeed");
match &response {
GenerateVideoResponse::Artcraft(p) => {
println!("inference_job_token={:?}", p.inference_job_token);
println!("all_inference_job_tokens={:?}", p.all_inference_job_tokens);
}
other => println!("response: {:?}", other),
}
response
}
}
@@ -0,0 +1,29 @@
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::providers::artcraft::build_common::{
build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport,
};
use crate::generate::generate_video::providers::artcraft::vidu_q3_turbo::request::ArtcraftViduQ3TurboRequestState;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
pub fn build_artcraft_vidu_q3_turbo(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let state = build_artcraft_vidu_q3_turbo_state(builder)?;
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftViduQ3Turbo(state)))
}
pub(crate) fn build_artcraft_vidu_q3_turbo_state(builder: GenerateVideoRequestBuilder) -> Result<ArtcraftViduQ3TurboRequestState, ArtcraftRouterError> {
// Vidu Q3 Turbo supports `audio` — preserve it so cost can read it back.
// build_artcraft_omni_video_request hardcodes generate_audio = None on its output.
let generate_audio = builder.generate_audio;
let mut request = build_artcraft_omni_video_request(
builder,
CommonVideoModelEnum::ViduQ3Turbo,
SupportedResolutions::Full,
UltraWideSupport::Unsupported,
)?;
request.generate_audio = generate_audio;
Ok(ArtcraftViduQ3TurboRequestState { request })
}
@@ -0,0 +1,104 @@
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video::providers::artcraft::vidu_q3_turbo::request::ArtcraftViduQ3TurboRequestState;
/// Per-second rates in hundredths of a US cent.
const LOW_RES_RATE_HUNDREDTH_CENTS_PER_SEC: u64 = 401;
const HIGH_RES_RATE_HUNDREDTH_CENTS_PER_SEC: u64 = 834;
#[derive(Clone, Debug)]
pub struct ArtcraftViduQ3TurboCostState {
pub duration_seconds: u64,
pub is_high_res: bool,
}
impl ArtcraftViduQ3TurboCostState {
pub fn from_request(request: &ArtcraftViduQ3TurboRequestState) -> Self {
Self {
// Vidu Q3 Turbo defaults None → 5s.
duration_seconds: request.request.duration_seconds.map(u64::from).unwrap_or(5),
is_high_res: is_high_res(request.request.resolution),
}
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
let rate = if self.is_high_res {
HIGH_RES_RATE_HUNDREDTH_CENTS_PER_SEC
} else {
LOW_RES_RATE_HUNDREDTH_CENTS_PER_SEC
};
// Round up to the next whole cent.
let cost_in_usd_cents = (rate * self.duration_seconds).div_ceil(100);
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
cost_in_usd_cents: Some(cost_in_usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
}
/// Vidu prices 720p/1080p at a higher tier than 360p/540p. The default (unset)
/// resolution is 720p.
fn is_high_res(resolution: Option<CommonResolutionEnum>) -> bool {
match resolution {
None => true, // defaults to 720p
Some(CommonResolutionEnum::SevenTwentyP)
| Some(CommonResolutionEnum::TenEightyP)
| Some(CommonResolutionEnum::OneK)
| Some(CommonResolutionEnum::TwoK)
| Some(CommonResolutionEnum::ThreeK)
| Some(CommonResolutionEnum::FourK) => true,
Some(CommonResolutionEnum::HalfK)
| Some(CommonResolutionEnum::FourEightyP) => false,
}
}
#[cfg(test)]
mod tests {
use crate::api::router_provider::RouterProvider;
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::providers::artcraft::vidu_q3_turbo::build::build_artcraft_vidu_q3_turbo_state;
use crate::generate::generate_video::providers::artcraft::vidu_q3_turbo::cost::ArtcraftViduQ3TurboCostState;
fn cost_cents(duration_seconds: Option<u16>, resolution: Option<RouterResolution>) -> u64 {
let b = GenerateVideoRequestBuilder {
model: RouterVideoModel::ViduQ3Turbo,
provider: RouterProvider::Artcraft,
prompt: Some("test".to_string()),
duration_seconds,
resolution,
..Default::default()
};
let state = build_artcraft_vidu_q3_turbo_state(b).unwrap();
ArtcraftViduQ3TurboCostState::from_request(&state).estimate_cost().cost_in_usd_cents.unwrap()
}
#[test]
fn default_resolution_5s_is_42() { assert_eq!(cost_cents(Some(5), None), 42); }
#[test]
fn default_duration_is_5s() { assert_eq!(cost_cents(None, None), 42); }
#[test]
fn high_res_10s_is_84() { assert_eq!(cost_cents(Some(10), Some(RouterResolution::TenEightyP)), 84); }
#[test]
fn low_res_5s_is_21() { assert_eq!(cost_cents(Some(5), Some(RouterResolution::FourEightyP)), 21); }
#[test]
fn low_res_10s_is_41() { assert_eq!(cost_cents(Some(10), Some(RouterResolution::FourEightyP)), 41); }
#[test]
fn odd_duration_rounds_up_to_whole_cents() {
// 7s high res: 834 * 7 = 5838 hundredth-cents → 59 cents.
assert_eq!(cost_cents(Some(7), None), 59);
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -0,0 +1,104 @@
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
use crate::generate::generate_video::providers::artcraft::request_common::send_artcraft_omni_video_request;
#[derive(Clone, Debug)]
pub struct ArtcraftViduQ3TurboRequestState {
pub request: OmniGenVideoCostAndGenerateRequest,
}
impl ArtcraftViduQ3TurboRequestState {
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
send_artcraft_omni_video_request(&self.request, client).await
}
}
#[cfg(test)]
mod tests {
use tokens::tokens::media_files::MediaFileToken;
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use artcraft_client::utils::api_host::ApiHost;
use test_data::web::image_media_tokens::JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN;
use crate::api::image_ref::ImageRef;
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_provider::RouterProvider;
use crate::api::router_video_model::RouterVideoModel;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::client::router_client::RouterClient;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
#[tokio::test]
#[ignore] // sends a real generation to the Artcraft backend, incurs cost
async fn text_to_video() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A paper boat drifting down a rainy gutter stream.".to_string()),
aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine),
..artcraft_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // sends a real generation to the Artcraft backend, incurs cost
async fn keyframe_start_frame() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("The dog shakes off water in slow motion.".to_string()),
start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new(JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN.to_string()))),
..artcraft_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
// ── Helpers ──
fn artcraft_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: RouterVideoModel::ViduQ3Turbo,
provider: RouterProvider::Artcraft,
duration_seconds: Some(5),
video_batch_count: Some(1),
..Default::default()
}
}
fn get_artcraft_client() -> RouterClient {
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/artcraft_cookies.txt")
.expect("Failed to read artcraft cookies");
let cookies = cookies.trim().to_string();
let credentials = StorytellerCredentialSet::parse_multi_cookie_header(&cookies)
.expect("Failed to parse cookies")
.expect("No credentials found");
RouterClient::Artcraft(RouterArtcraftClient::new(ApiHost::Storyteller, credentials))
}
async fn run_pipeline(builder: GenerateVideoRequestBuilder) -> GenerateVideoResponse {
let client = get_artcraft_client();
let draft_or_request = builder.build2().expect("build2 should succeed");
let request = match draft_or_request {
VideoGenerationDraftOrRequest::Request(r) => r,
_ => panic!("expected Request variant (Artcraft skips draft)"),
};
let response = request.send_request(&client).await.expect("send_request should succeed");
match &response {
GenerateVideoResponse::Artcraft(p) => {
println!("inference_job_token={:?}", p.inference_job_token);
println!("all_inference_job_tokens={:?}", p.all_inference_job_tokens);
}
other => println!("response: {:?}", other),
}
response
}
}
@@ -1,10 +1,10 @@
use fal_client::requests_old::webhook::video::image::enqueue_kling_3p0_standard_image_to_video_webhook::{
EnqueueKling3p0StandardImageToVideoAspectRatio, EnqueueKling3p0StandardImageToVideoDuration,
EnqueueKling3p0StandardImageToVideoRequest,
use fal_client::requests::api::video::image::kling_3p0_standard_image_to_video::api::{
Kling3p0StandardImageToVideoAspectRatio, Kling3p0StandardImageToVideoDuration,
Kling3p0StandardImageToVideoRequest,
};
use fal_client::requests_old::webhook::video::text::enqueue_kling_3p0_standard_text_to_video_webhook::{
EnqueueKling3p0StandardTextToVideoAspectRatio, EnqueueKling3p0StandardTextToVideoDuration,
EnqueueKling3p0StandardTextToVideoRequest,
use fal_client::requests::api::video::text::kling_3p0_standard_text_to_video::api::{
Kling3p0StandardTextToVideoAspectRatio, Kling3p0StandardTextToVideoDuration,
Kling3p0StandardTextToVideoRequest,
};
use crate::errors::artcraft_router_error::ArtcraftRouterError;
@@ -23,6 +23,13 @@ use crate::generate::generate_video::video_generation_request::VideoGenerationRe
pub fn build_fal_kling_3p0_standard(
builder: GenerateVideoRequestBuilder,
) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let state = build_fal_kling_3p0_standard_state(builder)?;
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalKling3p0Standard(state)))
}
pub(crate) fn build_fal_kling_3p0_standard_state(
builder: GenerateVideoRequestBuilder,
) -> Result<FalKling3p0StandardRequestState, ArtcraftRouterError> {
let strategy = builder.request_mismatch_mitigation_strategy;
let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio, strategy)?;
@@ -39,7 +46,7 @@ pub fn build_fal_kling_3p0_standard(
value: "Kling 3.0 Standard requires a start_frame when end_frame is provided".to_string(),
}));
}
FalKling3p0StandardMode::TextToVideo(EnqueueKling3p0StandardTextToVideoRequest {
FalKling3p0StandardMode::TextToVideo(Kling3p0StandardTextToVideoRequest {
prompt,
generate_audio,
negative_prompt,
@@ -48,7 +55,7 @@ pub fn build_fal_kling_3p0_standard(
shot_type: None,
})
}
Some(image_url) => FalKling3p0StandardMode::ImageToVideo(EnqueueKling3p0StandardImageToVideoRequest {
Some(image_url) => FalKling3p0StandardMode::ImageToVideo(Kling3p0StandardImageToVideoRequest {
prompt,
image_url,
end_image_url: optional_url(builder.end_frame.clone())?,
@@ -60,13 +67,11 @@ pub fn build_fal_kling_3p0_standard(
}),
};
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalKling3p0Standard(
FalKling3p0StandardRequestState { mode },
)))
Ok(FalKling3p0StandardRequestState { mode })
}
fn to_t2v_duration(d: PlanDuration) -> EnqueueKling3p0StandardTextToVideoDuration {
use EnqueueKling3p0StandardTextToVideoDuration as D;
fn to_t2v_duration(d: PlanDuration) -> Kling3p0StandardTextToVideoDuration {
use Kling3p0StandardTextToVideoDuration as D;
match d.0 {
3 => D::ThreeSeconds,
4 => D::FourSeconds,
@@ -84,8 +89,8 @@ fn to_t2v_duration(d: PlanDuration) -> EnqueueKling3p0StandardTextToVideoDuratio
}
}
fn to_i2v_duration(d: PlanDuration) -> EnqueueKling3p0StandardImageToVideoDuration {
use EnqueueKling3p0StandardImageToVideoDuration as D;
fn to_i2v_duration(d: PlanDuration) -> Kling3p0StandardImageToVideoDuration {
use Kling3p0StandardImageToVideoDuration as D;
match d.0 {
3 => D::ThreeSeconds,
4 => D::FourSeconds,
@@ -103,19 +108,19 @@ fn to_i2v_duration(d: PlanDuration) -> EnqueueKling3p0StandardImageToVideoDurati
}
}
fn to_t2v_aspect_ratio(a: PlanAspectRatio) -> EnqueueKling3p0StandardTextToVideoAspectRatio {
fn to_t2v_aspect_ratio(a: PlanAspectRatio) -> Kling3p0StandardTextToVideoAspectRatio {
match a {
PlanAspectRatio::Square => EnqueueKling3p0StandardTextToVideoAspectRatio::Square,
PlanAspectRatio::SixteenByNine => EnqueueKling3p0StandardTextToVideoAspectRatio::SixteenByNine,
PlanAspectRatio::NineBySixteen => EnqueueKling3p0StandardTextToVideoAspectRatio::NineBySixteen,
PlanAspectRatio::Square => Kling3p0StandardTextToVideoAspectRatio::Square,
PlanAspectRatio::SixteenByNine => Kling3p0StandardTextToVideoAspectRatio::SixteenByNine,
PlanAspectRatio::NineBySixteen => Kling3p0StandardTextToVideoAspectRatio::NineBySixteen,
}
}
fn to_i2v_aspect_ratio(a: PlanAspectRatio) -> EnqueueKling3p0StandardImageToVideoAspectRatio {
fn to_i2v_aspect_ratio(a: PlanAspectRatio) -> Kling3p0StandardImageToVideoAspectRatio {
match a {
PlanAspectRatio::Square => EnqueueKling3p0StandardImageToVideoAspectRatio::Square,
PlanAspectRatio::SixteenByNine => EnqueueKling3p0StandardImageToVideoAspectRatio::SixteenByNine,
PlanAspectRatio::NineBySixteen => EnqueueKling3p0StandardImageToVideoAspectRatio::NineBySixteen,
PlanAspectRatio::Square => Kling3p0StandardImageToVideoAspectRatio::Square,
PlanAspectRatio::SixteenByNine => Kling3p0StandardImageToVideoAspectRatio::SixteenByNine,
PlanAspectRatio::NineBySixteen => Kling3p0StandardImageToVideoAspectRatio::NineBySixteen,
}
}
@@ -127,6 +132,27 @@ mod tests {
use super::*;
#[test]
fn no_start_frame_picks_t2v() {
let state = build_fal_kling_3p0_standard_state(base_builder()).expect("build");
assert!(matches!(state.mode, FalKling3p0StandardMode::TextToVideo(_)));
}
#[test]
fn start_frame_picks_i2v() {
let mut b = base_builder();
b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string()));
let state = build_fal_kling_3p0_standard_state(b).expect("build");
assert!(matches!(state.mode, FalKling3p0StandardMode::ImageToVideo(_)));
}
#[test]
fn end_frame_without_start_frame_errors() {
let mut b = base_builder();
b.end_frame = Some(ImageRef::Url("https://example.com/end.png".to_string()));
assert!(build_fal_kling_3p0_standard_state(b).is_err());
}
fn base_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: RouterVideoModel::Kling3p0Standard,
@@ -135,29 +161,4 @@ mod tests {
..Default::default()
}
}
#[test]
fn no_start_frame_picks_t2v() {
let result = build_fal_kling_3p0_standard(base_builder()).expect("build");
if let VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalKling3p0Standard(s)) = result {
assert!(matches!(s.mode, FalKling3p0StandardMode::TextToVideo(_)));
} else { panic!("expected FalKling3p0Standard"); }
}
#[test]
fn start_frame_picks_i2v() {
let mut b = base_builder();
b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string()));
let result = build_fal_kling_3p0_standard(b).expect("build");
if let VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalKling3p0Standard(s)) = result {
assert!(matches!(s.mode, FalKling3p0StandardMode::ImageToVideo(_)));
} else { panic!("expected FalKling3p0Standard"); }
}
#[test]
fn end_frame_without_start_frame_errors() {
let mut b = base_builder();
b.end_frame = Some(ImageRef::Url("https://example.com/end.png".to_string()));
assert!(build_fal_kling_3p0_standard(b).is_err());
}
}
@@ -1,5 +1,4 @@
use fal_client::requests_old::webhook::video::image::enqueue_kling_3p0_standard_image_to_video_webhook::EnqueueKling3p0StandardImageToVideoDuration;
use fal_client::requests_old::webhook::video::text::enqueue_kling_3p0_standard_text_to_video_webhook::EnqueueKling3p0StandardTextToVideoDuration;
use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video::providers::fal::kling_3p0_standard::request::{
@@ -8,35 +7,25 @@ use crate::generate::generate_video::providers::fal::kling_3p0_standard::request
#[derive(Clone, Debug)]
pub struct FalKling3p0StandardCostState {
pub duration_seconds: u64,
pub generate_audio: bool,
pub cost_in_usd_cents: u64,
}
impl FalKling3p0StandardCostState {
pub fn from_request(request: &FalKling3p0StandardRequestState) -> Self {
let (duration_seconds, generate_audio) = match &request.mode {
FalKling3p0StandardMode::TextToVideo(req) => (
t2v_duration_seconds(req.duration),
req.generate_audio.unwrap_or(true),
),
FalKling3p0StandardMode::ImageToVideo(req) => (
i2v_duration_seconds(req.duration),
req.generate_audio.unwrap_or(true),
),
// Cost math is owned by fal_client's per-endpoint
// `FalRequestCostCalculator` implementations. The router state just
// forwards the result so router cost ≡ fal_client cost by construction.
let cost_in_usd_cents = match &request.mode {
FalKling3p0StandardMode::TextToVideo(req) => req.calculate_cost_in_cents(),
FalKling3p0StandardMode::ImageToVideo(req) => req.calculate_cost_in_cents(),
};
Self { duration_seconds, generate_audio }
Self { cost_in_usd_cents }
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
// Mirrors fal_client kling_3p0_standard:
// audio off: $0.168/sec (rate=168)
// audio on: $0.252/sec (rate=252)
let rate: u64 = if self.generate_audio { 252 } else { 168 };
let cost_in_usd_cents = (rate * self.duration_seconds + 9) / 10;
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
cost_in_usd_cents: Some(cost_in_usd_cents),
cost_in_credits: Some(self.cost_in_usd_cents),
cost_in_usd_cents: Some(self.cost_in_usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
@@ -46,64 +35,17 @@ impl FalKling3p0StandardCostState {
}
}
/// Default duration is 5s when None per Fal client.
fn t2v_duration_seconds(d: Option<EnqueueKling3p0StandardTextToVideoDuration>) -> u64 {
use EnqueueKling3p0StandardTextToVideoDuration as D;
match d {
None => 5,
Some(D::ThreeSeconds) => 3,
Some(D::FourSeconds) => 4,
Some(D::FiveSeconds) => 5,
Some(D::SixSeconds) => 6,
Some(D::SevenSeconds) => 7,
Some(D::EightSeconds) => 8,
Some(D::NineSeconds) => 9,
Some(D::TenSeconds) => 10,
Some(D::ElevenSeconds) => 11,
Some(D::TwelveSeconds) => 12,
Some(D::ThirteenSeconds) => 13,
Some(D::FourteenSeconds) => 14,
Some(D::FifteenSeconds) => 15,
}
}
fn i2v_duration_seconds(d: Option<EnqueueKling3p0StandardImageToVideoDuration>) -> u64 {
use EnqueueKling3p0StandardImageToVideoDuration as D;
match d {
None => 5,
Some(D::ThreeSeconds) => 3,
Some(D::FourSeconds) => 4,
Some(D::FiveSeconds) => 5,
Some(D::SixSeconds) => 6,
Some(D::SevenSeconds) => 7,
Some(D::EightSeconds) => 8,
Some(D::NineSeconds) => 9,
Some(D::TenSeconds) => 10,
Some(D::ElevenSeconds) => 11,
Some(D::TwelveSeconds) => 12,
Some(D::ThirteenSeconds) => 13,
Some(D::FourteenSeconds) => 14,
Some(D::FifteenSeconds) => 15,
}
}
#[cfg(test)]
mod tests {
use crate::api::router_video_model::RouterVideoModel;
use crate::api::router_provider::RouterProvider;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::providers::fal::kling_3p0_standard::build::build_fal_kling_3p0_standard_state;
fn cost_cents(duration_seconds: Option<u16>, generate_audio: Option<bool>) -> u64 {
let b = GenerateVideoRequestBuilder {
model: RouterVideoModel::Kling3p0Standard,
provider: RouterProvider::Fal,
prompt: Some("test".to_string()),
duration_seconds,
generate_audio,
..Default::default()
};
b.build2().unwrap().estimate_cost().unwrap().cost_in_usd_cents.unwrap()
}
use super::*;
// Pricing (fal): audio off $0.168/sec (rate=168 tenths-of-cents), audio on
// $0.252/sec (rate=252), rounded up to whole cents.
#[test]
fn audio_on_5s_is_126() {
@@ -127,4 +69,20 @@ mod tests {
// (168*15 + 9) / 10 = 2529/10 = 252.
assert_eq!(cost_cents(Some(15), Some(false)), 252);
}
fn cost_cents(duration_seconds: Option<u16>, generate_audio: Option<bool>) -> u64 {
let b = GenerateVideoRequestBuilder {
model: RouterVideoModel::Kling3p0Standard,
provider: RouterProvider::Fal,
prompt: Some("test".to_string()),
duration_seconds,
generate_audio,
..Default::default()
};
let state = build_fal_kling_3p0_standard_state(b).expect("build_fal_kling_3p0_standard_state");
FalKling3p0StandardCostState::from_request(&state)
.estimate_cost()
.cost_in_usd_cents
.expect("cost_in_usd_cents")
}
}
@@ -1,27 +1,20 @@
use std::fmt::Debug;
use std::sync::Arc;
use fal_client::requests_old::webhook::video::image::enqueue_kling_3p0_standard_image_to_video_webhook::{
enqueue_kling_3p0_standard_image_to_video_webhook, EnqueueKling3p0StandardImageToVideoArgs,
EnqueueKling3p0StandardImageToVideoRequest,
};
use fal_client::requests_old::webhook::video::text::enqueue_kling_3p0_standard_text_to_video_webhook::{
enqueue_kling_3p0_standard_text_to_video_webhook, EnqueueKling3p0StandardTextToVideoArgs,
EnqueueKling3p0StandardTextToVideoRequest,
};
use fal_client::requests::api::video::image::kling_3p0_standard_image_to_video::api::Kling3p0StandardImageToVideoRequest;
use fal_client::requests::api::video::text::kling_3p0_standard_text_to_video::api::Kling3p0StandardTextToVideoRequest;
use fal_client::requests::traits::fal_endpoint_trait::FalEndpoint;
use crate::client::router_fal_client::RouterFalClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_video::generate_video_response::{
FalVideoResponsePayload, GenerateVideoResponse,
};
#[derive(Clone, Debug)]
pub enum FalKling3p0StandardMode {
TextToVideo(EnqueueKling3p0StandardTextToVideoRequest),
ImageToVideo(EnqueueKling3p0StandardImageToVideoRequest),
TextToVideo(Kling3p0StandardTextToVideoRequest),
ImageToVideo(Kling3p0StandardImageToVideoRequest),
}
#[derive(Clone, Debug)]
@@ -31,38 +24,56 @@ pub struct FalKling3p0StandardRequestState {
impl FalKling3p0StandardRequestState {
pub async fn send(&self, client: &RouterFalClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let webhook_url = client.webhook_url.as_deref()
.ok_or(ArtcraftRouterError::Client(ClientError::WebhookUrlRequired))?;
let (webhook_response, outbound_request): (_, Arc<dyn Debug + Send + Sync>) = match &self.mode {
FalKling3p0StandardMode::TextToVideo(request) => {
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let args = EnqueueKling3p0StandardTextToVideoArgs {
request: request.clone(),
webhook_url,
api_key: &client.api_key,
};
(enqueue_kling_3p0_standard_text_to_video_webhook(args).await, outbound)
}
FalKling3p0StandardMode::ImageToVideo(request) => {
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let args = EnqueueKling3p0StandardImageToVideoArgs {
request: request.clone(),
webhook_url,
api_key: &client.api_key,
};
(enqueue_kling_3p0_standard_image_to_video_webhook(args).await, outbound)
}
};
match &self.mode {
FalKling3p0StandardMode::TextToVideo(request) => send_request(request, client).await,
FalKling3p0StandardMode::ImageToVideo(request) => send_request(request, client).await,
}
}
}
let webhook_response = webhook_response
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?;
// ── Helpers ──
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
request_id: webhook_response.request_id,
gateway_request_id: webhook_response.gateway_request_id,
maybe_status_url: None,
maybe_response_url: None,
maybe_outbound_request: Some(outbound_request),
}))
async fn send_request<T>(request: &T, client: &RouterFalClient) -> Result<GenerateVideoResponse, ArtcraftRouterError>
where
T: FalEndpoint + Clone + Debug + Send + Sync + 'static,
{
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let payload = send_fal_request(request, client).await?;
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
request_id: payload.request_id,
gateway_request_id: payload.gateway_request_id,
maybe_status_url: payload.status_url,
maybe_response_url: payload.response_url,
maybe_outbound_request: Some(outbound),
}))
}
struct FalResponseIds {
request_id: Option<String>,
gateway_request_id: Option<String>,
status_url: Option<String>,
response_url: Option<String>,
}
async fn send_fal_request<T: FalEndpoint>(
request: &T,
client: &RouterFalClient,
) -> Result<FalResponseIds, ArtcraftRouterError> {
if let Some(webhook_url) = &client.webhook_url {
let response = request.send_webhook_request(&client.api_key, webhook_url).await?;
Ok(FalResponseIds {
request_id: response.request_id,
gateway_request_id: response.gateway_request_id,
status_url: None,
response_url: None,
})
} else {
let response = request.send_queue_request(&client.api_key).await?;
Ok(FalResponseIds {
request_id: Some(response.request_id),
gateway_request_id: None,
status_url: Some(response.status_url),
response_url: Some(response.response_url),
})
}
}
@@ -14,3 +14,6 @@ pub mod veo_3;
pub mod veo_3_fast;
pub mod veo_3p1;
pub mod veo_3p1_fast;
pub mod veo_3p1_lite;
pub mod vidu_q3;
pub mod vidu_q3_turbo;
@@ -1,7 +1,9 @@
use fal_client::requests_old::webhook::video::image::enqueue_veo_2_image_to_video_webhook::{
Veo2AspectRatio, Veo2Duration, Veo2Request,
use fal_client::requests::api::video::image::veo_2::api::{
Veo2ImageToVideoDuration, Veo2ImageToVideoRequest,
};
use fal_client::requests::api::video::text::veo_2::api::{
Veo2TextToVideoAspectRatio, Veo2TextToVideoDuration, Veo2TextToVideoRequest,
};
use fal_client::requests_old::webhook::video::text::enqueue_veo_2_text_to_video_webhook::Veo2TextToVideoRequest;
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::image_ref::ImageRef;
@@ -13,9 +15,26 @@ use crate::generate::generate_video::providers::fal::veo_2::request::{FalVeo2Mod
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
/// Router-level duration plan shared by both modes — each mode maps it onto
/// its own fal duration enum.
#[derive(Debug, Clone, Copy)]
pub(crate) enum PlanDuration {
Five,
Six,
Seven,
Eight,
}
pub fn build_fal_veo_2(
builder: GenerateVideoRequestBuilder,
) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let state = build_fal_veo_2_state(builder)?;
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalVeo2(state)))
}
pub(crate) fn build_fal_veo_2_state(
builder: GenerateVideoRequestBuilder,
) -> Result<FalVeo2RequestState, ArtcraftRouterError> {
let strategy = builder.request_mismatch_mitigation_strategy;
if builder.end_frame.is_some() {
@@ -27,10 +46,10 @@ pub fn build_fal_veo_2(
let duration = plan_duration(builder.duration_seconds, strategy)?;
let mode = match builder.start_frame.clone() {
Some(ImageRef::Url(url)) => FalVeo2Mode::ImageToVideo(Veo2Request {
image_url: url,
Some(ImageRef::Url(url)) => FalVeo2Mode::ImageToVideo(Veo2ImageToVideoRequest {
prompt,
duration,
image_url: url,
duration: duration.map(to_i2v_duration),
}),
Some(ImageRef::MediaFileToken(_)) => {
return Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls));
@@ -39,38 +58,41 @@ pub fn build_fal_veo_2(
let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio, strategy)?;
FalVeo2Mode::TextToVideo(Veo2TextToVideoRequest {
prompt,
negative_prompt,
duration,
aspect_ratio,
duration: duration.map(to_t2v_duration),
negative_prompt,
enhance_prompt: None,
seed: None,
auto_fix: None,
})
}
};
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalVeo2(
FalVeo2RequestState { mode },
)))
Ok(FalVeo2RequestState { mode })
}
/// `None` lets fal pick its default (16:9); the old endpoint's explicit "auto"
/// no longer exists in the new API, so Auto maps to unset.
fn plan_aspect_ratio(
aspect_ratio: Option<RouterAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Veo2AspectRatio, ArtcraftRouterError> {
use Veo2AspectRatio as Ar;
) -> Result<Option<Veo2TextToVideoAspectRatio>, ArtcraftRouterError> {
use Veo2TextToVideoAspectRatio as Ar;
match aspect_ratio {
None
| Some(RouterAspectRatio::Auto)
| Some(RouterAspectRatio::Auto2k)
| Some(RouterAspectRatio::Auto3k)
| Some(RouterAspectRatio::Auto4k) => Ok(Ar::Auto),
| Some(RouterAspectRatio::Auto4k) => Ok(None),
Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Ar::WideSixteenNine),
Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Ar::TallNineSixteen),
Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Some(Ar::SixteenByNine)),
Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Some(Ar::NineBySixteen)),
Some(unsupported_ar) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(unsupported("aspect_ratio", &format!("{:?}", unsupported_ar)))
}
_ => Ok(Ar::Auto),
_ => Ok(None),
},
}
}
@@ -78,19 +100,19 @@ fn plan_aspect_ratio(
fn plan_duration(
duration_seconds: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Veo2Duration, ArtcraftRouterError> {
) -> Result<Option<PlanDuration>, ArtcraftRouterError> {
match duration_seconds {
None => Ok(Veo2Duration::Default),
Some(5) => Ok(Veo2Duration::FiveSeconds),
Some(6) => Ok(Veo2Duration::SixSeconds),
Some(7) => Ok(Veo2Duration::SevenSeconds),
Some(8) => Ok(Veo2Duration::EightSeconds),
None => Ok(None),
Some(5) => Ok(Some(PlanDuration::Five)),
Some(6) => Ok(Some(PlanDuration::Six)),
Some(7) => Ok(Some(PlanDuration::Seven)),
Some(8) => Ok(Some(PlanDuration::Eight)),
Some(other) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(unsupported("duration_seconds", &format!("{}", other)))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Veo2Duration::EightSeconds),
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Veo2Duration::FiveSeconds),
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Some(PlanDuration::Eight)),
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(PlanDuration::Five)),
},
}
}
@@ -102,45 +124,33 @@ fn unsupported(field: &'static str, value: &str) -> ArtcraftRouterError {
})
}
fn to_t2v_duration(d: PlanDuration) -> Veo2TextToVideoDuration {
match d {
PlanDuration::Five => Veo2TextToVideoDuration::FiveSeconds,
PlanDuration::Six => Veo2TextToVideoDuration::SixSeconds,
PlanDuration::Seven => Veo2TextToVideoDuration::SevenSeconds,
PlanDuration::Eight => Veo2TextToVideoDuration::EightSeconds,
}
}
fn to_i2v_duration(d: PlanDuration) -> Veo2ImageToVideoDuration {
match d {
PlanDuration::Five => Veo2ImageToVideoDuration::FiveSeconds,
PlanDuration::Six => Veo2ImageToVideoDuration::SixSeconds,
PlanDuration::Seven => Veo2ImageToVideoDuration::SevenSeconds,
PlanDuration::Eight => Veo2ImageToVideoDuration::EightSeconds,
}
}
#[cfg(test)]
mod tests {
use tokens::tokens::media_files::MediaFileToken;
use crate::api::router_video_model::RouterVideoModel;
use crate::api::router_provider::RouterProvider;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
use super::*;
fn base_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: RouterVideoModel::Veo2,
provider: RouterProvider::Fal,
prompt: Some("a corgi running".to_string()),
..Default::default()
}
}
fn t2v_request(b: GenerateVideoRequestBuilder) -> Veo2TextToVideoRequest {
match build_fal_veo_2(b).expect("build_fal_veo_2") {
VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalVeo2(state)) => match state.mode {
FalVeo2Mode::TextToVideo(r) => r,
FalVeo2Mode::ImageToVideo(_) => panic!("expected TextToVideo"),
},
_ => panic!("expected FalVeo2 request"),
}
}
fn i2v_request(b: GenerateVideoRequestBuilder) -> Veo2Request {
match build_fal_veo_2(b).expect("build_fal_veo_2") {
VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalVeo2(state)) => match state.mode {
FalVeo2Mode::ImageToVideo(r) => r,
FalVeo2Mode::TextToVideo(_) => panic!("expected ImageToVideo"),
},
_ => panic!("expected FalVeo2 request"),
}
}
mod mode_selection {
use super::*;
@@ -160,14 +170,14 @@ mod tests {
fn end_frame_errors() {
let mut b = base_builder();
b.end_frame = Some(ImageRef::Url("https://example.com/end.png".to_string()));
assert!(build_fal_veo_2(b).is_err());
assert!(build_fal_veo_2_state(b).is_err());
}
#[test]
fn media_file_token_for_start_frame_errors() {
let mut b = base_builder();
b.start_frame = Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_x".to_string())));
assert!(build_fal_veo_2(b).is_err());
assert!(build_fal_veo_2_state(b).is_err());
}
}
@@ -200,22 +210,22 @@ mod tests {
use super::*;
#[test]
fn duration_none_is_default() {
assert!(matches!(t2v_request(base_builder()).duration, Veo2Duration::Default));
fn duration_none_is_unset() {
// fal defaults to 5s when unset.
assert!(t2v_request(base_builder()).duration.is_none());
}
#[test]
fn duration_5_through_8() {
for (seconds, expected) in [
(5, Veo2Duration::FiveSeconds),
(6, Veo2Duration::SixSeconds),
(7, Veo2Duration::SevenSeconds),
(8, Veo2Duration::EightSeconds),
(5, Veo2TextToVideoDuration::FiveSeconds),
(6, Veo2TextToVideoDuration::SixSeconds),
(7, Veo2TextToVideoDuration::SevenSeconds),
(8, Veo2TextToVideoDuration::EightSeconds),
] {
let mut b = base_builder();
b.duration_seconds = Some(seconds);
let got = t2v_request(b).duration;
assert_eq!(format!("{:?}", got), format!("{:?}", expected));
assert_eq!(t2v_request(b).duration, Some(expected));
}
}
@@ -224,7 +234,7 @@ mod tests {
let mut b = base_builder();
b.duration_seconds = Some(10);
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut;
assert!(build_fal_veo_2(b).is_err());
assert!(build_fal_veo_2_state(b).is_err());
}
#[test]
@@ -232,7 +242,7 @@ mod tests {
let mut b = base_builder();
b.duration_seconds = Some(20);
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::PayMoreUpgrade;
assert!(matches!(t2v_request(b).duration, Veo2Duration::EightSeconds));
assert_eq!(t2v_request(b).duration, Some(Veo2TextToVideoDuration::EightSeconds));
}
#[test]
@@ -240,7 +250,7 @@ mod tests {
let mut b = base_builder();
b.duration_seconds = Some(2);
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::PayLessDowngrade;
assert!(matches!(t2v_request(b).duration, Veo2Duration::FiveSeconds));
assert_eq!(t2v_request(b).duration, Some(Veo2TextToVideoDuration::FiveSeconds));
}
}
@@ -249,17 +259,17 @@ mod tests {
use crate::api::router_aspect_ratio::RouterAspectRatio;
#[test]
fn t2v_auto() {
fn t2v_auto_is_unset() {
let mut b = base_builder();
b.aspect_ratio = Some(RouterAspectRatio::Auto);
assert!(matches!(t2v_request(b).aspect_ratio, Veo2AspectRatio::Auto));
assert!(t2v_request(b).aspect_ratio.is_none());
}
#[test]
fn t2v_sixteen_nine() {
let mut b = base_builder();
b.aspect_ratio = Some(RouterAspectRatio::WideSixteenByNine);
assert!(matches!(t2v_request(b).aspect_ratio, Veo2AspectRatio::WideSixteenNine));
assert_eq!(t2v_request(b).aspect_ratio, Some(Veo2TextToVideoAspectRatio::SixteenByNine));
}
#[test]
@@ -267,7 +277,7 @@ mod tests {
let mut b = base_builder();
b.aspect_ratio = Some(RouterAspectRatio::Square);
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::PayMoreUpgrade;
assert!(matches!(t2v_request(b).aspect_ratio, Veo2AspectRatio::Auto));
assert!(t2v_request(b).aspect_ratio.is_none());
}
#[test]
@@ -275,7 +285,7 @@ mod tests {
let mut b = base_builder();
b.aspect_ratio = Some(RouterAspectRatio::Square);
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut;
assert!(build_fal_veo_2(b).is_err());
assert!(build_fal_veo_2_state(b).is_err());
}
}
@@ -295,11 +305,34 @@ mod tests {
if has_start_frame {
b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string()));
}
assert!(build_fal_veo_2(b).is_ok());
assert!(build_fal_veo_2_state(b).is_ok());
combos += 1;
}
}
}
assert_eq!(combos, 4 * 5 * 2);
}
fn base_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: RouterVideoModel::Veo2,
provider: RouterProvider::Fal,
prompt: Some("a corgi running".to_string()),
..Default::default()
}
}
fn t2v_request(b: GenerateVideoRequestBuilder) -> Veo2TextToVideoRequest {
match build_fal_veo_2_state(b).expect("build_fal_veo_2_state").mode {
FalVeo2Mode::TextToVideo(r) => r,
FalVeo2Mode::ImageToVideo(_) => panic!("expected TextToVideo"),
}
}
fn i2v_request(b: GenerateVideoRequestBuilder) -> Veo2ImageToVideoRequest {
match build_fal_veo_2_state(b).expect("build_fal_veo_2_state").mode {
FalVeo2Mode::ImageToVideo(r) => r,
FalVeo2Mode::TextToVideo(_) => panic!("expected ImageToVideo"),
}
}
}
@@ -1,31 +1,29 @@
use fal_client::requests_old::webhook::video::image::enqueue_veo_2_image_to_video_webhook::Veo2Duration;
use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video::providers::fal::veo_2::request::{FalVeo2Mode, FalVeo2RequestState};
#[derive(Clone, Debug)]
pub struct FalVeo2CostState {
pub duration: Veo2Duration,
pub cost_in_usd_cents: u64,
}
impl FalVeo2CostState {
pub fn from_request(request: &FalVeo2RequestState) -> Self {
let duration = match &request.mode {
FalVeo2Mode::TextToVideo(req) => req.duration,
FalVeo2Mode::ImageToVideo(req) => req.duration,
// Cost math is owned by fal_client's per-endpoint
// `FalRequestCostCalculator` implementations. The router state just
// forwards the result so router cost ≡ fal_client cost by construction.
let cost_in_usd_cents = match &request.mode {
FalVeo2Mode::TextToVideo(req) => req.calculate_cost_in_cents(),
FalVeo2Mode::ImageToVideo(req) => req.calculate_cost_in_cents(),
};
Self { duration }
Self { cost_in_usd_cents }
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
// 5s = $2.50, +$0.50 per additional second.
let seconds = duration_seconds_for_cost(self.duration);
let extra = seconds.saturating_sub(5);
let cost_in_usd_cents = 250 + extra * 50;
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
cost_in_usd_cents: Some(cost_in_usd_cents),
cost_in_credits: Some(self.cost_in_usd_cents),
cost_in_usd_cents: Some(self.cost_in_usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
@@ -35,40 +33,18 @@ impl FalVeo2CostState {
}
}
fn duration_seconds_for_cost(d: Veo2Duration) -> u64 {
match d {
Veo2Duration::Default | Veo2Duration::FiveSeconds => 5,
Veo2Duration::SixSeconds => 6,
Veo2Duration::SevenSeconds => 7,
Veo2Duration::EightSeconds => 8,
}
}
#[cfg(test)]
mod tests {
use crate::api::router_video_model::RouterVideoModel;
use crate::api::image_ref::ImageRef;
use crate::api::router_provider::RouterProvider;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::providers::fal::veo_2::build::build_fal_veo_2_state;
fn cost_cents(duration_seconds: Option<u16>, has_start_frame: bool) -> u64 {
let mut b = GenerateVideoRequestBuilder {
model: RouterVideoModel::Veo2,
provider: RouterProvider::Fal,
prompt: Some("test".to_string()),
duration_seconds,
..Default::default()
};
if has_start_frame {
b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string()));
}
b.build2()
.expect("build2")
.estimate_cost()
.expect("estimate_cost")
.cost_in_usd_cents
.expect("cost_in_usd_cents")
}
use super::*;
// Pricing (fal): 5s = $2.50, +$0.50 per additional second. Flat across
// modalities; no audio or resolution knobs.
mod numeric_literal_pricing {
use super::*;
@@ -112,4 +88,22 @@ mod tests {
assert!(cost_cents(Some(6), false) < cost_cents(Some(7), false));
assert!(cost_cents(Some(7), false) < cost_cents(Some(8), false));
}
fn cost_cents(duration_seconds: Option<u16>, has_start_frame: bool) -> u64 {
let mut b = GenerateVideoRequestBuilder {
model: RouterVideoModel::Veo2,
provider: RouterProvider::Fal,
prompt: Some("test".to_string()),
duration_seconds,
..Default::default()
};
if has_start_frame {
b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string()));
}
let state = build_fal_veo_2_state(b).expect("build_fal_veo_2_state");
FalVeo2CostState::from_request(&state)
.estimate_cost()
.cost_in_usd_cents
.expect("cost_in_usd_cents")
}
}
@@ -1,17 +1,12 @@
use std::fmt::Debug;
use std::sync::Arc;
use fal_client::requests_old::webhook::video::image::enqueue_veo_2_image_to_video_webhook::{
enqueue_veo_2_image_to_video_webhook, Veo2Args, Veo2Request,
};
use fal_client::requests_old::webhook::video::text::enqueue_veo_2_text_to_video_webhook::{
enqueue_veo_2_text_to_video_webhook, Veo2TextToVideoArgs, Veo2TextToVideoRequest,
};
use fal_client::requests::api::video::image::veo_2::api::Veo2ImageToVideoRequest;
use fal_client::requests::api::video::text::veo_2::api::Veo2TextToVideoRequest;
use fal_client::requests::traits::fal_endpoint_trait::FalEndpoint;
use crate::client::router_fal_client::RouterFalClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_video::generate_video_response::{
FalVideoResponsePayload, GenerateVideoResponse,
};
@@ -19,7 +14,7 @@ use crate::generate::generate_video::generate_video_response::{
#[derive(Clone, Debug)]
pub enum FalVeo2Mode {
TextToVideo(Veo2TextToVideoRequest),
ImageToVideo(Veo2Request),
ImageToVideo(Veo2ImageToVideoRequest),
}
#[derive(Clone, Debug)]
@@ -29,96 +24,112 @@ pub struct FalVeo2RequestState {
impl FalVeo2RequestState {
pub async fn send(&self, client: &RouterFalClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let webhook_url = client.webhook_url.as_deref()
.ok_or(ArtcraftRouterError::Client(ClientError::WebhookUrlRequired))?;
let (webhook_response, outbound_request): (_, Arc<dyn Debug + Send + Sync>) = match &self.mode {
FalVeo2Mode::TextToVideo(request) => {
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let args = Veo2TextToVideoArgs {
request: request.clone(),
api_key: &client.api_key,
webhook_url,
};
(enqueue_veo_2_text_to_video_webhook(args).await, outbound)
}
FalVeo2Mode::ImageToVideo(request) => {
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let args = Veo2Args {
request: request.clone(),
api_key: &client.api_key,
webhook_url,
};
(enqueue_veo_2_image_to_video_webhook(args).await, outbound)
}
};
match &self.mode {
FalVeo2Mode::TextToVideo(request) => send_request(request, client).await,
FalVeo2Mode::ImageToVideo(request) => send_request(request, client).await,
}
}
}
let webhook_response = webhook_response
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?;
// ── Helpers ──
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
request_id: webhook_response.request_id,
gateway_request_id: webhook_response.gateway_request_id,
maybe_status_url: None,
maybe_response_url: None,
maybe_outbound_request: Some(outbound_request),
}))
async fn send_request<T>(request: &T, client: &RouterFalClient) -> Result<GenerateVideoResponse, ArtcraftRouterError>
where
T: FalEndpoint + Clone + Debug + Send + Sync + 'static,
{
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let payload = send_fal_request(request, client).await?;
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
request_id: payload.request_id,
gateway_request_id: payload.gateway_request_id,
maybe_status_url: payload.status_url,
maybe_response_url: payload.response_url,
maybe_outbound_request: Some(outbound),
}))
}
struct FalResponseIds {
request_id: Option<String>,
gateway_request_id: Option<String>,
status_url: Option<String>,
response_url: Option<String>,
}
async fn send_fal_request<T: FalEndpoint>(
request: &T,
client: &RouterFalClient,
) -> Result<FalResponseIds, ArtcraftRouterError> {
if let Some(webhook_url) = &client.webhook_url {
let response = request.send_webhook_request(&client.api_key, webhook_url).await?;
Ok(FalResponseIds {
request_id: response.request_id,
gateway_request_id: response.gateway_request_id,
status_url: None,
response_url: None,
})
} else {
let response = request.send_queue_request(&client.api_key).await?;
Ok(FalResponseIds {
request_id: Some(response.request_id),
gateway_request_id: None,
status_url: Some(response.status_url),
response_url: Some(response.response_url),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use fal_client::creds::fal_api_key::FalApiKey;
use fal_client::requests::api::video::image::veo_2::api::Veo2ImageToVideoDuration;
use fal_client::requests::api::video::text::veo_2::api::{
Veo2TextToVideoAspectRatio, Veo2TextToVideoDuration,
};
use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL;
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_video_model::RouterVideoModel;
use crate::api::image_ref::ImageRef;
use crate::api::router_provider::RouterProvider;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::test_helpers::get_fal_client;
#[tokio::test]
#[ignore]
#[ignore] // requires real API key, incurs cost
async fn live_text_to_video_5s() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("a peaceful lake at dawn".to_string()),
aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine),
duration_seconds: Some(5),
..fal_veo_2_builder()
}).await;
println!("response: {:?}", response);
let state = FalVeo2RequestState {
mode: FalVeo2Mode::TextToVideo(Veo2TextToVideoRequest {
prompt: "a peaceful lake at dawn".to_string(),
aspect_ratio: Some(Veo2TextToVideoAspectRatio::SixteenByNine),
duration: Some(Veo2TextToVideoDuration::FiveSeconds),
negative_prompt: None,
enhance_prompt: None,
seed: None,
auto_fix: None,
}),
};
let response = state.send(&client_with_webhook()).await.expect("send should succeed");
assert!(matches!(response, GenerateVideoResponse::Fal(_)));
}
#[tokio::test]
#[ignore]
#[ignore] // requires real API key, incurs cost
async fn live_image_to_video_7s() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("the dog leaps into the lake and splashes around.".to_string()),
start_frame: Some(ImageRef::Url(JUNO_AT_LAKE_IMAGE_URL.to_string())),
duration_seconds: Some(7),
..fal_veo_2_builder()
}).await;
println!("response: {:?}", response);
let state = FalVeo2RequestState {
mode: FalVeo2Mode::ImageToVideo(Veo2ImageToVideoRequest {
prompt: "the dog leaps into the lake and splashes around.".to_string(),
image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(),
duration: Some(Veo2ImageToVideoDuration::SevenSeconds),
}),
};
let response = state.send(&client_with_webhook()).await.expect("send should succeed");
assert!(matches!(response, GenerateVideoResponse::Fal(_)));
}
fn fal_veo_2_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: RouterVideoModel::Veo2,
provider: RouterProvider::Fal,
..Default::default()
}
fn read_fal_api_key() -> FalApiKey {
let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")
.expect("Failed to read fal_api_key.txt");
FalApiKey::from_str(secret.trim())
}
async fn run_pipeline(builder: GenerateVideoRequestBuilder) -> GenerateVideoResponse {
let client = get_fal_client();
let draft_or_request = builder.build2().expect("build2");
let request = match draft_or_request {
VideoGenerationDraftOrRequest::Request(r) => r,
_ => panic!("expected Request variant"),
};
request.send_request(&client).await.expect("send_request")
fn client_with_webhook() -> RouterFalClient {
RouterFalClient::new_with_webhook(
read_fal_api_key(),
"https://example.com/fal-webhook-test".to_string(),
)
}
}
@@ -1,8 +1,10 @@
use fal_client::requests_old::webhook::video::image::enqueue_veo_3_image_to_video_webhook::{
Veo3I2vAspectRatio, Veo3I2vDuration, Veo3I2vResolution, Veo3Request,
use fal_client::requests::api::video::image::veo_3::api::{
Veo3ImageToVideoAspectRatio, Veo3ImageToVideoDuration, Veo3ImageToVideoRequest,
Veo3ImageToVideoResolution,
};
use fal_client::requests_old::webhook::video::text::enqueue_veo_3_text_to_video_webhook::{
Veo3T2vAspectRatio, Veo3T2vDuration, Veo3T2vResolution, Veo3TextToVideoRequest,
use fal_client::requests::api::video::text::veo_3::api::{
Veo3TextToVideoAspectRatio, Veo3TextToVideoDuration, Veo3TextToVideoRequest,
Veo3TextToVideoResolution,
};
use crate::api::router_aspect_ratio::RouterAspectRatio;
@@ -16,18 +18,16 @@ use crate::generate::generate_video::providers::fal::veo_3::request::{FalVeo3Mod
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
/// Router-level resolution shared between both modes — kept here so the cost
/// calculator can reconstruct it from either mode's request.
/// Router-level resolution shared between both modes — each mode maps it onto
/// its own fal resolution enum.
#[derive(Debug, Clone, Copy)]
pub(crate) enum PlanResolution {
Default,
SevenTwentyP,
TenEightyP,
}
#[derive(Debug, Clone, Copy)]
pub(crate) enum PlanDuration {
Default,
FourSeconds,
SixSeconds,
EightSeconds,
@@ -36,6 +36,13 @@ pub(crate) enum PlanDuration {
pub fn build_fal_veo_3(
builder: GenerateVideoRequestBuilder,
) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let state = build_fal_veo_3_state(builder)?;
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalVeo3(state)))
}
pub(crate) fn build_fal_veo_3_state(
builder: GenerateVideoRequestBuilder,
) -> Result<FalVeo3RequestState, ArtcraftRouterError> {
let strategy = builder.request_mismatch_mitigation_strategy;
if builder.end_frame.is_some() {
@@ -46,18 +53,24 @@ pub fn build_fal_veo_3(
let negative_prompt = builder.negative_prompt.clone();
let resolution = plan_resolution(builder.resolution, strategy)?;
let duration = plan_duration(builder.duration_seconds, strategy)?;
let generate_audio = builder.generate_audio.unwrap_or(true);
// fal's server default is audio-on; keep the explicit `true` the old
// endpoint sent so pricing stays deterministic.
let generate_audio = Some(builder.generate_audio.unwrap_or(true));
let mode = match builder.start_frame.clone() {
Some(ImageRef::Url(url)) => {
let i2v_aspect_ratio = plan_i2v_aspect_ratio(builder.aspect_ratio, strategy)?;
FalVeo3Mode::ImageToVideo(Veo3Request {
image_url: url,
FalVeo3Mode::ImageToVideo(Veo3ImageToVideoRequest {
prompt,
duration: to_i2v_duration(duration),
image_url: url,
aspect_ratio: i2v_aspect_ratio,
resolution: to_i2v_resolution(resolution),
duration: duration.map(to_i2v_duration),
resolution: resolution.map(to_i2v_resolution),
generate_audio,
negative_prompt,
seed: None,
auto_fix: None,
safety_tolerance: None,
})
}
Some(ImageRef::MediaFileToken(_)) => {
@@ -67,35 +80,37 @@ pub fn build_fal_veo_3(
let t2v_aspect_ratio = plan_t2v_aspect_ratio(builder.aspect_ratio, strategy)?;
FalVeo3Mode::TextToVideo(Veo3TextToVideoRequest {
prompt,
negative_prompt,
duration: to_t2v_duration(duration),
aspect_ratio: t2v_aspect_ratio,
resolution: to_t2v_resolution(resolution),
duration: duration.map(to_t2v_duration),
resolution: resolution.map(to_t2v_resolution),
negative_prompt,
generate_audio,
seed: None,
auto_fix: None,
safety_tolerance: None,
})
}
};
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalVeo3(
FalVeo3RequestState { mode },
)))
Ok(FalVeo3RequestState { mode })
}
/// Text-to-video: only 16:9 and 9:16 (no Auto, no Square).
/// Text-to-video: only 16:9 and 9:16 (no Auto, no Square). `None` lets fal
/// pick its default (16:9).
fn plan_t2v_aspect_ratio(
aspect_ratio: Option<RouterAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Veo3T2vAspectRatio, ArtcraftRouterError> {
use Veo3T2vAspectRatio as Ar;
) -> Result<Option<Veo3TextToVideoAspectRatio>, ArtcraftRouterError> {
use Veo3TextToVideoAspectRatio as Ar;
match aspect_ratio {
None => Ok(Ar::Default),
Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Ar::WideSixteenNine),
Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Ar::TallNineSixteen),
None => Ok(None),
Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Some(Ar::SixteenByNine)),
Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Some(Ar::NineBySixteen)),
Some(other) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(unsupported("aspect_ratio", &format!("{:?}", other)))
}
_ => Ok(Ar::Default),
_ => Ok(None),
},
}
}
@@ -104,23 +119,23 @@ fn plan_t2v_aspect_ratio(
fn plan_i2v_aspect_ratio(
aspect_ratio: Option<RouterAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Veo3I2vAspectRatio, ArtcraftRouterError> {
use Veo3I2vAspectRatio as Ar;
) -> Result<Option<Veo3ImageToVideoAspectRatio>, ArtcraftRouterError> {
use Veo3ImageToVideoAspectRatio as Ar;
match aspect_ratio {
None
| Some(RouterAspectRatio::Auto)
| Some(RouterAspectRatio::Auto2k)
| Some(RouterAspectRatio::Auto3k)
| Some(RouterAspectRatio::Auto4k) => Ok(Ar::Auto),
| Some(RouterAspectRatio::Auto4k) => Ok(Some(Ar::Auto)),
Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Ar::WideSixteenNine),
Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Ar::TallNineSixteen),
Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Some(Ar::SixteenByNine)),
Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Some(Ar::NineBySixteen)),
Some(other) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(unsupported("aspect_ratio", &format!("{:?}", other)))
}
_ => Ok(Ar::Auto),
_ => Ok(Some(Ar::Auto)),
},
}
}
@@ -128,17 +143,17 @@ fn plan_i2v_aspect_ratio(
fn plan_resolution(
resolution: Option<RouterResolution>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<PlanResolution, ArtcraftRouterError> {
) -> Result<Option<PlanResolution>, ArtcraftRouterError> {
match resolution {
None => Ok(PlanResolution::Default),
Some(RouterResolution::SevenTwentyP) => Ok(PlanResolution::SevenTwentyP),
Some(RouterResolution::TenEightyP) => Ok(PlanResolution::TenEightyP),
None => Ok(None),
Some(RouterResolution::SevenTwentyP) => Ok(Some(PlanResolution::SevenTwentyP)),
Some(RouterResolution::TenEightyP) => Ok(Some(PlanResolution::TenEightyP)),
Some(other) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(unsupported("resolution", &format!("{:?}", other)))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(PlanResolution::TenEightyP),
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(PlanResolution::SevenTwentyP),
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Some(PlanResolution::TenEightyP)),
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(PlanResolution::SevenTwentyP)),
},
}
}
@@ -146,18 +161,18 @@ fn plan_resolution(
fn plan_duration(
duration_seconds: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<PlanDuration, ArtcraftRouterError> {
) -> Result<Option<PlanDuration>, ArtcraftRouterError> {
match duration_seconds {
None => Ok(PlanDuration::Default),
Some(4) => Ok(PlanDuration::FourSeconds),
Some(6) => Ok(PlanDuration::SixSeconds),
Some(8) => Ok(PlanDuration::EightSeconds),
None => Ok(None),
Some(4) => Ok(Some(PlanDuration::FourSeconds)),
Some(6) => Ok(Some(PlanDuration::SixSeconds)),
Some(8) => Ok(Some(PlanDuration::EightSeconds)),
Some(other) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(unsupported("duration_seconds", &format!("{}", other)))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(PlanDuration::EightSeconds),
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(PlanDuration::FourSeconds),
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Some(PlanDuration::EightSeconds)),
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(PlanDuration::FourSeconds)),
},
}
}
@@ -169,37 +184,33 @@ fn unsupported(field: &'static str, value: &str) -> ArtcraftRouterError {
})
}
fn to_t2v_duration(d: PlanDuration) -> Veo3T2vDuration {
fn to_t2v_duration(d: PlanDuration) -> Veo3TextToVideoDuration {
match d {
PlanDuration::Default => Veo3T2vDuration::Default,
PlanDuration::FourSeconds => Veo3T2vDuration::FourSeconds,
PlanDuration::SixSeconds => Veo3T2vDuration::SixSeconds,
PlanDuration::EightSeconds => Veo3T2vDuration::EightSeconds,
PlanDuration::FourSeconds => Veo3TextToVideoDuration::FourSeconds,
PlanDuration::SixSeconds => Veo3TextToVideoDuration::SixSeconds,
PlanDuration::EightSeconds => Veo3TextToVideoDuration::EightSeconds,
}
}
fn to_i2v_duration(d: PlanDuration) -> Veo3I2vDuration {
fn to_i2v_duration(d: PlanDuration) -> Veo3ImageToVideoDuration {
match d {
PlanDuration::Default => Veo3I2vDuration::Default,
PlanDuration::FourSeconds => Veo3I2vDuration::FourSeconds,
PlanDuration::SixSeconds => Veo3I2vDuration::SixSeconds,
PlanDuration::EightSeconds => Veo3I2vDuration::EightSeconds,
PlanDuration::FourSeconds => Veo3ImageToVideoDuration::FourSeconds,
PlanDuration::SixSeconds => Veo3ImageToVideoDuration::SixSeconds,
PlanDuration::EightSeconds => Veo3ImageToVideoDuration::EightSeconds,
}
}
fn to_t2v_resolution(r: PlanResolution) -> Veo3T2vResolution {
fn to_t2v_resolution(r: PlanResolution) -> Veo3TextToVideoResolution {
match r {
PlanResolution::Default => Veo3T2vResolution::Default,
PlanResolution::SevenTwentyP => Veo3T2vResolution::SevenTwentyP,
PlanResolution::TenEightyP => Veo3T2vResolution::TenEightyP,
PlanResolution::SevenTwentyP => Veo3TextToVideoResolution::SevenTwentyP,
PlanResolution::TenEightyP => Veo3TextToVideoResolution::TenEightyP,
}
}
fn to_i2v_resolution(r: PlanResolution) -> Veo3I2vResolution {
fn to_i2v_resolution(r: PlanResolution) -> Veo3ImageToVideoResolution {
match r {
PlanResolution::Default => Veo3I2vResolution::Default,
PlanResolution::SevenTwentyP => Veo3I2vResolution::SevenTwentyP,
PlanResolution::TenEightyP => Veo3I2vResolution::TenEightyP,
PlanResolution::SevenTwentyP => Veo3ImageToVideoResolution::SevenTwentyP,
PlanResolution::TenEightyP => Veo3ImageToVideoResolution::TenEightyP,
}
}
@@ -207,48 +218,9 @@ fn to_i2v_resolution(r: PlanResolution) -> Veo3I2vResolution {
mod tests {
use crate::api::router_video_model::RouterVideoModel;
use crate::api::router_provider::RouterProvider;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
use super::*;
fn base_t2v_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: RouterVideoModel::Veo3,
provider: RouterProvider::Fal,
prompt: Some("test".to_string()),
..Default::default()
}
}
fn base_i2v_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
start_frame: Some(ImageRef::Url("https://example.com/a.png".to_string())),
..base_t2v_builder()
}
}
fn t2v_request(b: GenerateVideoRequestBuilder) -> Veo3TextToVideoRequest {
match build_fal_veo_3(b).expect("build") {
VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalVeo3(state)) => match state.mode {
FalVeo3Mode::TextToVideo(r) => r,
FalVeo3Mode::ImageToVideo(_) => panic!("expected TextToVideo"),
},
_ => panic!("expected FalVeo3"),
}
}
fn i2v_request(b: GenerateVideoRequestBuilder) -> Veo3Request {
match build_fal_veo_3(b).expect("build") {
VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalVeo3(state)) => match state.mode {
FalVeo3Mode::ImageToVideo(r) => r,
FalVeo3Mode::TextToVideo(_) => panic!("expected ImageToVideo"),
},
_ => panic!("expected FalVeo3"),
}
}
use crate::generate::generate_video::providers::fal::veo_3::request::{FalVeo3Mode, FalVeo3RequestState};
mod mode_selection {
use super::*;
@@ -262,7 +234,7 @@ mod tests {
fn end_frame_errors() {
let mut b = base_t2v_builder();
b.end_frame = Some(ImageRef::Url("https://example.com/end.png".to_string()));
assert!(build_fal_veo_3(b).is_err());
assert!(build_fal_veo_3_state(b).is_err());
}
}
@@ -273,14 +245,14 @@ mod tests {
fn t2v_720p() {
let mut b = base_t2v_builder();
b.resolution = Some(RouterResolution::SevenTwentyP);
assert!(matches!(t2v_request(b).resolution, Veo3T2vResolution::SevenTwentyP));
assert_eq!(t2v_request(b).resolution, Some(Veo3TextToVideoResolution::SevenTwentyP));
}
#[test]
fn t2v_1080p() {
let mut b = base_t2v_builder();
b.resolution = Some(RouterResolution::TenEightyP);
assert!(matches!(t2v_request(b).resolution, Veo3T2vResolution::TenEightyP));
assert_eq!(t2v_request(b).resolution, Some(Veo3TextToVideoResolution::TenEightyP));
}
#[test]
@@ -288,7 +260,7 @@ mod tests {
let mut b = base_t2v_builder();
b.resolution = Some(RouterResolution::FourK);
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::PayMoreUpgrade;
assert!(matches!(t2v_request(b).resolution, Veo3T2vResolution::TenEightyP));
assert_eq!(t2v_request(b).resolution, Some(Veo3TextToVideoResolution::TenEightyP));
}
}
@@ -298,13 +270,13 @@ mod tests {
#[test]
fn t2v_4s_6s_8s() {
for (s, expected) in [
(4, Veo3T2vDuration::FourSeconds),
(6, Veo3T2vDuration::SixSeconds),
(8, Veo3T2vDuration::EightSeconds),
(4, Veo3TextToVideoDuration::FourSeconds),
(6, Veo3TextToVideoDuration::SixSeconds),
(8, Veo3TextToVideoDuration::EightSeconds),
] {
let mut b = base_t2v_builder();
b.duration_seconds = Some(s);
assert_eq!(format!("{:?}", t2v_request(b).duration), format!("{:?}", expected));
assert_eq!(t2v_request(b).duration, Some(expected));
}
}
}
@@ -316,12 +288,12 @@ mod tests {
fn t2v_sixteen_nine() {
let mut b = base_t2v_builder();
b.aspect_ratio = Some(RouterAspectRatio::WideSixteenByNine);
assert!(matches!(t2v_request(b).aspect_ratio, Veo3T2vAspectRatio::WideSixteenNine));
assert_eq!(t2v_request(b).aspect_ratio, Some(Veo3TextToVideoAspectRatio::SixteenByNine));
}
#[test]
fn i2v_auto_when_unspecified() {
assert!(matches!(i2v_request(base_i2v_builder()).aspect_ratio, Veo3I2vAspectRatio::Auto));
assert_eq!(i2v_request(base_i2v_builder()).aspect_ratio, Some(Veo3ImageToVideoAspectRatio::Auto));
}
}
@@ -330,14 +302,14 @@ mod tests {
#[test]
fn audio_defaults_to_true() {
assert!(t2v_request(base_t2v_builder()).generate_audio);
assert_eq!(t2v_request(base_t2v_builder()).generate_audio, Some(true));
}
#[test]
fn audio_false_is_passed_through() {
let mut b = base_t2v_builder();
b.generate_audio = Some(false);
assert!(!t2v_request(b).generate_audio);
assert_eq!(t2v_request(b).generate_audio, Some(false));
}
}
@@ -359,7 +331,7 @@ mod tests {
b.duration_seconds = duration;
b.aspect_ratio = aspect_ratio;
b.generate_audio = generate_audio;
assert!(build_fal_veo_3(b).is_ok());
assert!(build_fal_veo_3_state(b).is_ok());
combos += 1;
}
}
@@ -369,7 +341,33 @@ mod tests {
assert_eq!(combos, 3 * 4 * 4 * 3 * 2);
}
// Silence unused warnings for FalVeo3RequestState in this scope.
#[allow(dead_code)]
fn _silence(_: FalVeo3RequestState) {}
fn base_t2v_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: RouterVideoModel::Veo3,
provider: RouterProvider::Fal,
prompt: Some("test".to_string()),
..Default::default()
}
}
fn base_i2v_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
start_frame: Some(ImageRef::Url("https://example.com/a.png".to_string())),
..base_t2v_builder()
}
}
fn t2v_request(b: GenerateVideoRequestBuilder) -> Veo3TextToVideoRequest {
match build_fal_veo_3_state(b).expect("build_fal_veo_3_state").mode {
FalVeo3Mode::TextToVideo(r) => r,
FalVeo3Mode::ImageToVideo(_) => panic!("expected TextToVideo"),
}
}
fn i2v_request(b: GenerateVideoRequestBuilder) -> Veo3ImageToVideoRequest {
match build_fal_veo_3_state(b).expect("build_fal_veo_3_state").mode {
FalVeo3Mode::ImageToVideo(r) => r,
FalVeo3Mode::TextToVideo(_) => panic!("expected ImageToVideo"),
}
}
}
@@ -1,32 +1,29 @@
use fal_client::requests_old::webhook::video::image::enqueue_veo_3_image_to_video_webhook::Veo3I2vDuration;
use fal_client::requests_old::webhook::video::text::enqueue_veo_3_text_to_video_webhook::Veo3T2vDuration;
use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video::providers::fal::veo_3::request::{FalVeo3Mode, FalVeo3RequestState};
#[derive(Clone, Debug)]
pub struct FalVeo3CostState {
pub duration_seconds: u64,
pub generate_audio: bool,
pub cost_in_usd_cents: u64,
}
impl FalVeo3CostState {
pub fn from_request(request: &FalVeo3RequestState) -> Self {
let (duration_seconds, generate_audio) = match &request.mode {
FalVeo3Mode::TextToVideo(req) => (t2v_duration_seconds(req.duration), req.generate_audio),
FalVeo3Mode::ImageToVideo(req) => (i2v_duration_seconds(req.duration), req.generate_audio),
// Cost math is owned by fal_client's per-endpoint
// `FalRequestCostCalculator` implementations. The router state just
// forwards the result so router cost ≡ fal_client cost by construction.
let cost_in_usd_cents = match &request.mode {
FalVeo3Mode::TextToVideo(req) => req.calculate_cost_in_cents(),
FalVeo3Mode::ImageToVideo(req) => req.calculate_cost_in_cents(),
};
Self { duration_seconds, generate_audio }
Self { cost_in_usd_cents }
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
// $0.20/sec audio off, $0.40/sec audio on (720p/1080p).
let per_second_cents: u64 = if self.generate_audio { 40 } else { 20 };
let cost_in_usd_cents = per_second_cents * self.duration_seconds;
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
cost_in_usd_cents: Some(cost_in_usd_cents),
cost_in_credits: Some(self.cost_in_usd_cents),
cost_in_usd_cents: Some(self.cost_in_usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
@@ -36,43 +33,18 @@ impl FalVeo3CostState {
}
}
fn t2v_duration_seconds(d: Veo3T2vDuration) -> u64 {
match d {
Veo3T2vDuration::Default | Veo3T2vDuration::EightSeconds => 8,
Veo3T2vDuration::SixSeconds => 6,
Veo3T2vDuration::FourSeconds => 4,
}
}
fn i2v_duration_seconds(d: Veo3I2vDuration) -> u64 {
match d {
Veo3I2vDuration::Default | Veo3I2vDuration::EightSeconds => 8,
Veo3I2vDuration::SixSeconds => 6,
Veo3I2vDuration::FourSeconds => 4,
}
}
#[cfg(test)]
mod tests {
use crate::api::router_video_model::RouterVideoModel;
use crate::api::image_ref::ImageRef;
use crate::api::router_provider::RouterProvider;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::providers::fal::veo_3::build::build_fal_veo_3_state;
fn cost_cents(duration_seconds: Option<u16>, generate_audio: Option<bool>, has_start_frame: bool) -> u64 {
let mut b = GenerateVideoRequestBuilder {
model: RouterVideoModel::Veo3,
provider: RouterProvider::Fal,
prompt: Some("test".to_string()),
duration_seconds,
generate_audio,
..Default::default()
};
if has_start_frame {
b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string()));
}
b.build2().expect("build2").estimate_cost().expect("estimate_cost").cost_in_usd_cents.expect("cost")
}
use super::*;
// Pricing (fal): $0.20/sec audio off, $0.40/sec audio on. Flat across
// modalities; resolution (720p/1080p) does not change the price.
mod numeric_literal_pricing {
use super::*;
@@ -100,7 +72,7 @@ mod tests {
#[test]
fn duration_default_is_8s() {
// No duration → Default → 8s.
// No duration → fal default 8s.
assert_eq!(cost_cents(None, Some(true), false), 320);
}
@@ -120,4 +92,23 @@ mod tests {
fn audio_costs_more_than_no_audio() {
assert!(cost_cents(Some(8), Some(false), false) < cost_cents(Some(8), Some(true), false));
}
fn cost_cents(duration_seconds: Option<u16>, generate_audio: Option<bool>, has_start_frame: bool) -> u64 {
let mut b = GenerateVideoRequestBuilder {
model: RouterVideoModel::Veo3,
provider: RouterProvider::Fal,
prompt: Some("test".to_string()),
duration_seconds,
generate_audio,
..Default::default()
};
if has_start_frame {
b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string()));
}
let state = build_fal_veo_3_state(b).expect("build_fal_veo_3_state");
FalVeo3CostState::from_request(&state)
.estimate_cost()
.cost_in_usd_cents
.expect("cost_in_usd_cents")
}
}
@@ -1,17 +1,12 @@
use std::fmt::Debug;
use std::sync::Arc;
use fal_client::requests_old::webhook::video::image::enqueue_veo_3_image_to_video_webhook::{
enqueue_veo_3_image_to_video_webhook, Veo3Args, Veo3Request,
};
use fal_client::requests_old::webhook::video::text::enqueue_veo_3_text_to_video_webhook::{
enqueue_veo_3_text_to_video_webhook, Veo3TextToVideoArgs, Veo3TextToVideoRequest,
};
use fal_client::requests::api::video::image::veo_3::api::Veo3ImageToVideoRequest;
use fal_client::requests::api::video::text::veo_3::api::Veo3TextToVideoRequest;
use fal_client::requests::traits::fal_endpoint_trait::FalEndpoint;
use crate::client::router_fal_client::RouterFalClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_video::generate_video_response::{
FalVideoResponsePayload, GenerateVideoResponse,
};
@@ -19,7 +14,7 @@ use crate::generate::generate_video::generate_video_response::{
#[derive(Clone, Debug)]
pub enum FalVeo3Mode {
TextToVideo(Veo3TextToVideoRequest),
ImageToVideo(Veo3Request),
ImageToVideo(Veo3ImageToVideoRequest),
}
#[derive(Clone, Debug)]
@@ -29,97 +24,123 @@ pub struct FalVeo3RequestState {
impl FalVeo3RequestState {
pub async fn send(&self, client: &RouterFalClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let webhook_url = client.webhook_url.as_deref()
.ok_or(ArtcraftRouterError::Client(ClientError::WebhookUrlRequired))?;
let (webhook_response, outbound_request): (_, Arc<dyn Debug + Send + Sync>) = match &self.mode {
FalVeo3Mode::TextToVideo(request) => {
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let args = Veo3TextToVideoArgs {
request: request.clone(),
api_key: &client.api_key,
webhook_url,
};
(enqueue_veo_3_text_to_video_webhook(args).await, outbound)
}
FalVeo3Mode::ImageToVideo(request) => {
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let args = Veo3Args {
request: request.clone(),
api_key: &client.api_key,
webhook_url,
};
(enqueue_veo_3_image_to_video_webhook(args).await, outbound)
}
};
match &self.mode {
FalVeo3Mode::TextToVideo(request) => send_request(request, client).await,
FalVeo3Mode::ImageToVideo(request) => send_request(request, client).await,
}
}
}
let webhook_response = webhook_response
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?;
// ── Helpers ──
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
request_id: webhook_response.request_id,
gateway_request_id: webhook_response.gateway_request_id,
maybe_status_url: None,
maybe_response_url: None,
maybe_outbound_request: Some(outbound_request),
}))
async fn send_request<T>(request: &T, client: &RouterFalClient) -> Result<GenerateVideoResponse, ArtcraftRouterError>
where
T: FalEndpoint + Clone + Debug + Send + Sync + 'static,
{
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let payload = send_fal_request(request, client).await?;
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
request_id: payload.request_id,
gateway_request_id: payload.gateway_request_id,
maybe_status_url: payload.status_url,
maybe_response_url: payload.response_url,
maybe_outbound_request: Some(outbound),
}))
}
struct FalResponseIds {
request_id: Option<String>,
gateway_request_id: Option<String>,
status_url: Option<String>,
response_url: Option<String>,
}
async fn send_fal_request<T: FalEndpoint>(
request: &T,
client: &RouterFalClient,
) -> Result<FalResponseIds, ArtcraftRouterError> {
if let Some(webhook_url) = &client.webhook_url {
let response = request.send_webhook_request(&client.api_key, webhook_url).await?;
Ok(FalResponseIds {
request_id: response.request_id,
gateway_request_id: response.gateway_request_id,
status_url: None,
response_url: None,
})
} else {
let response = request.send_queue_request(&client.api_key).await?;
Ok(FalResponseIds {
request_id: Some(response.request_id),
gateway_request_id: None,
status_url: Some(response.status_url),
response_url: Some(response.response_url),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use fal_client::creds::fal_api_key::FalApiKey;
use fal_client::requests::api::video::image::veo_3::api::{
Veo3ImageToVideoDuration, Veo3ImageToVideoResolution,
};
use fal_client::requests::api::video::text::veo_3::api::{
Veo3TextToVideoDuration, Veo3TextToVideoResolution,
};
use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL;
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
use crate::api::image_ref::ImageRef;
use crate::api::router_provider::RouterProvider;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::test_helpers::get_fal_client;
#[tokio::test]
#[ignore]
#[ignore] // requires real API key, incurs cost
async fn live_text_to_video_720p_4s_audio_on() {
let r = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("a serene mountain landscape".to_string()),
resolution: Some(RouterResolution::SevenTwentyP),
duration_seconds: Some(4),
generate_audio: Some(true),
..builder()
}).await;
assert!(matches!(r, GenerateVideoResponse::Fal(_)));
let state = FalVeo3RequestState {
mode: FalVeo3Mode::TextToVideo(Veo3TextToVideoRequest {
prompt: "a serene mountain landscape".to_string(),
aspect_ratio: None,
duration: Some(Veo3TextToVideoDuration::FourSeconds),
resolution: Some(Veo3TextToVideoResolution::SevenTwentyP),
negative_prompt: None,
generate_audio: Some(true),
seed: None,
auto_fix: None,
safety_tolerance: None,
}),
};
let response = state.send(&client_with_webhook()).await.expect("send should succeed");
assert!(matches!(response, GenerateVideoResponse::Fal(_)));
}
#[tokio::test]
#[ignore]
#[ignore] // requires real API key, incurs cost
async fn live_image_to_video_1080p_8s_no_audio() {
let r = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("the dog leaps into the lake.".to_string()),
start_frame: Some(ImageRef::Url(JUNO_AT_LAKE_IMAGE_URL.to_string())),
resolution: Some(RouterResolution::TenEightyP),
duration_seconds: Some(8),
generate_audio: Some(false),
..builder()
}).await;
assert!(matches!(r, GenerateVideoResponse::Fal(_)));
}
fn builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: RouterVideoModel::Veo3,
provider: RouterProvider::Fal,
..Default::default()
}
}
async fn run_pipeline(b: GenerateVideoRequestBuilder) -> GenerateVideoResponse {
let client = get_fal_client();
let dor = b.build2().expect("build2");
let req = match dor {
VideoGenerationDraftOrRequest::Request(r) => r,
_ => panic!("expected Request"),
let state = FalVeo3RequestState {
mode: FalVeo3Mode::ImageToVideo(Veo3ImageToVideoRequest {
prompt: "the dog leaps into the lake.".to_string(),
image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(),
aspect_ratio: None,
duration: Some(Veo3ImageToVideoDuration::EightSeconds),
resolution: Some(Veo3ImageToVideoResolution::TenEightyP),
generate_audio: Some(false),
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
}),
};
req.send_request(&client).await.expect("send")
let response = state.send(&client_with_webhook()).await.expect("send should succeed");
assert!(matches!(response, GenerateVideoResponse::Fal(_)));
}
fn read_fal_api_key() -> FalApiKey {
let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")
.expect("Failed to read fal_api_key.txt");
FalApiKey::from_str(secret.trim())
}
fn client_with_webhook() -> RouterFalClient {
RouterFalClient::new_with_webhook(
read_fal_api_key(),
"https://example.com/fal-webhook-test".to_string(),
)
}
}
@@ -1,5 +1,10 @@
use fal_client::requests_old::webhook::video::image::enqueue_veo_3_fast_image_to_video_webhook::{
Veo3FastAspectRatio, Veo3FastDuration, Veo3FastRequest, Veo3FastResolution,
use fal_client::requests::api::video::image::veo_3_fast::api::{
Veo3FastImageToVideoAspectRatio, Veo3FastImageToVideoDuration, Veo3FastImageToVideoRequest,
Veo3FastImageToVideoResolution,
};
use fal_client::requests::api::video::text::veo_3_fast::api::{
Veo3FastTextToVideoAspectRatio, Veo3FastTextToVideoDuration, Veo3FastTextToVideoRequest,
Veo3FastTextToVideoResolution,
};
use crate::api::router_aspect_ratio::RouterAspectRatio;
@@ -9,69 +14,130 @@ use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigati
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::providers::fal::veo_3_fast::request::FalVeo3FastRequestState;
use crate::generate::generate_video::providers::fal::veo_3_fast::request::{
FalVeo3FastMode, FalVeo3FastRequestState,
};
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
/// Router-level resolution shared between both modes — each mode maps it onto
/// its own fal resolution enum.
#[derive(Debug, Clone, Copy)]
pub(crate) enum PlanResolution {
SevenTwentyP,
TenEightyP,
}
#[derive(Debug, Clone, Copy)]
pub(crate) enum PlanDuration {
FourSeconds,
SixSeconds,
EightSeconds,
}
pub fn build_fal_veo_3_fast(
builder: GenerateVideoRequestBuilder,
) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let state = build_fal_veo_3_fast_state(builder)?;
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalVeo3Fast(state)))
}
pub(crate) fn build_fal_veo_3_fast_state(
builder: GenerateVideoRequestBuilder,
) -> Result<FalVeo3FastRequestState, ArtcraftRouterError> {
let strategy = builder.request_mismatch_mitigation_strategy;
let start_frame_url = require_url(builder.start_frame.clone())?;
if builder.end_frame.is_some() {
return Err(unsupported("end_frame", "Veo 3 Fast does not support an ending frame"));
}
let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio, strategy)?;
let prompt = builder.prompt.clone().unwrap_or_default();
let negative_prompt = builder.negative_prompt.clone();
let resolution = plan_resolution(builder.resolution, strategy)?;
let duration = plan_duration(builder.duration_seconds, strategy)?;
let generate_audio = builder.generate_audio.unwrap_or(true);
// fal's server default is audio-on; keep the explicit `true` the old
// endpoint sent so pricing stays deterministic.
let generate_audio = Some(builder.generate_audio.unwrap_or(true));
let request = Veo3FastRequest {
prompt: builder.prompt.clone().unwrap_or_default(),
image_url: start_frame_url,
aspect_ratio,
duration,
resolution,
generate_audio,
let mode = match builder.start_frame.clone() {
Some(ImageRef::Url(url)) => {
let i2v_aspect_ratio = plan_i2v_aspect_ratio(builder.aspect_ratio, strategy)?;
FalVeo3FastMode::ImageToVideo(Veo3FastImageToVideoRequest {
prompt,
image_url: url,
aspect_ratio: i2v_aspect_ratio,
duration: duration.map(to_i2v_duration),
resolution: resolution.map(to_i2v_resolution),
generate_audio,
negative_prompt,
seed: None,
auto_fix: None,
safety_tolerance: None,
})
}
Some(ImageRef::MediaFileToken(_)) => {
return Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls));
}
None => {
let t2v_aspect_ratio = plan_t2v_aspect_ratio(builder.aspect_ratio, strategy)?;
FalVeo3FastMode::TextToVideo(Veo3FastTextToVideoRequest {
prompt,
aspect_ratio: t2v_aspect_ratio,
duration: duration.map(to_t2v_duration),
resolution: resolution.map(to_t2v_resolution),
negative_prompt,
generate_audio,
seed: None,
auto_fix: None,
safety_tolerance: None,
})
}
};
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalVeo3Fast(
FalVeo3FastRequestState { request },
)))
Ok(FalVeo3FastRequestState { mode })
}
fn require_url(image_ref: Option<ImageRef>) -> Result<String, ArtcraftRouterError> {
match image_ref {
Some(ImageRef::Url(url)) => Ok(url),
Some(ImageRef::MediaFileToken(_)) => {
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
}
None => Err(unsupported("start_frame", "Veo 3 Fast requires a starting frame")),
/// Text-to-video: only 16:9 and 9:16 (no Auto, no Square). `None` lets fal
/// pick its default (16:9).
fn plan_t2v_aspect_ratio(
aspect_ratio: Option<RouterAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<Veo3FastTextToVideoAspectRatio>, ArtcraftRouterError> {
use Veo3FastTextToVideoAspectRatio as Ar;
match aspect_ratio {
None => Ok(None),
Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Some(Ar::SixteenByNine)),
Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Some(Ar::NineBySixteen)),
Some(other) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(unsupported("aspect_ratio", &format!("{:?}", other)))
}
_ => Ok(None),
},
}
}
fn plan_aspect_ratio(
/// Image-to-video: Auto, 16:9, 9:16 (no Square).
fn plan_i2v_aspect_ratio(
aspect_ratio: Option<RouterAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Veo3FastAspectRatio, ArtcraftRouterError> {
use Veo3FastAspectRatio as Ar;
) -> Result<Option<Veo3FastImageToVideoAspectRatio>, ArtcraftRouterError> {
use Veo3FastImageToVideoAspectRatio as Ar;
match aspect_ratio {
None
| Some(RouterAspectRatio::Auto)
| Some(RouterAspectRatio::Auto2k)
| Some(RouterAspectRatio::Auto3k)
| Some(RouterAspectRatio::Auto4k) => Ok(Ar::Auto),
| Some(RouterAspectRatio::Auto4k) => Ok(Some(Ar::Auto)),
Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Ar::WideSixteenNine),
Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Ar::TallNineSixteen),
Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Some(Ar::SixteenByNine)),
Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Some(Ar::NineBySixteen)),
Some(unsupported_ar) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(unsupported("aspect_ratio", &format!("{:?}", unsupported_ar)))
}
_ => Ok(Ar::Auto),
_ => Ok(Some(Ar::Auto)),
},
}
}
@@ -79,18 +145,17 @@ fn plan_aspect_ratio(
fn plan_resolution(
resolution: Option<RouterResolution>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Veo3FastResolution, ArtcraftRouterError> {
use Veo3FastResolution as R;
) -> Result<Option<PlanResolution>, ArtcraftRouterError> {
match resolution {
None => Ok(R::Default),
Some(RouterResolution::SevenTwentyP) => Ok(R::SevenTwentyP),
Some(RouterResolution::TenEightyP) => Ok(R::TenEightyP),
None => Ok(None),
Some(RouterResolution::SevenTwentyP) => Ok(Some(PlanResolution::SevenTwentyP)),
Some(RouterResolution::TenEightyP) => Ok(Some(PlanResolution::TenEightyP)),
Some(other) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(unsupported("resolution", &format!("{:?}", other)))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(R::TenEightyP),
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(R::SevenTwentyP),
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Some(PlanResolution::TenEightyP)),
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(PlanResolution::SevenTwentyP)),
},
}
}
@@ -98,18 +163,18 @@ fn plan_resolution(
fn plan_duration(
duration_seconds: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Veo3FastDuration, ArtcraftRouterError> {
) -> Result<Option<PlanDuration>, ArtcraftRouterError> {
match duration_seconds {
None => Ok(Veo3FastDuration::Default),
Some(4) => Ok(Veo3FastDuration::FourSeconds),
Some(6) => Ok(Veo3FastDuration::SixSeconds),
Some(8) => Ok(Veo3FastDuration::EightSeconds),
None => Ok(None),
Some(4) => Ok(Some(PlanDuration::FourSeconds)),
Some(6) => Ok(Some(PlanDuration::SixSeconds)),
Some(8) => Ok(Some(PlanDuration::EightSeconds)),
Some(other) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(unsupported("duration_seconds", &format!("{}", other)))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Veo3FastDuration::EightSeconds),
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Veo3FastDuration::FourSeconds),
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Some(PlanDuration::EightSeconds)),
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(PlanDuration::FourSeconds)),
},
}
}
@@ -121,62 +186,100 @@ fn unsupported(field: &'static str, value: &str) -> ArtcraftRouterError {
})
}
fn to_t2v_duration(d: PlanDuration) -> Veo3FastTextToVideoDuration {
match d {
PlanDuration::FourSeconds => Veo3FastTextToVideoDuration::FourSeconds,
PlanDuration::SixSeconds => Veo3FastTextToVideoDuration::SixSeconds,
PlanDuration::EightSeconds => Veo3FastTextToVideoDuration::EightSeconds,
}
}
fn to_i2v_duration(d: PlanDuration) -> Veo3FastImageToVideoDuration {
match d {
PlanDuration::FourSeconds => Veo3FastImageToVideoDuration::FourSeconds,
PlanDuration::SixSeconds => Veo3FastImageToVideoDuration::SixSeconds,
PlanDuration::EightSeconds => Veo3FastImageToVideoDuration::EightSeconds,
}
}
fn to_t2v_resolution(r: PlanResolution) -> Veo3FastTextToVideoResolution {
match r {
PlanResolution::SevenTwentyP => Veo3FastTextToVideoResolution::SevenTwentyP,
PlanResolution::TenEightyP => Veo3FastTextToVideoResolution::TenEightyP,
}
}
fn to_i2v_resolution(r: PlanResolution) -> Veo3FastImageToVideoResolution {
match r {
PlanResolution::SevenTwentyP => Veo3FastImageToVideoResolution::SevenTwentyP,
PlanResolution::TenEightyP => Veo3FastImageToVideoResolution::TenEightyP,
}
}
#[cfg(test)]
mod tests {
use tokens::tokens::media_files::MediaFileToken;
use crate::api::router_video_model::RouterVideoModel;
use crate::api::router_provider::RouterProvider;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
use super::*;
fn base_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: RouterVideoModel::Veo3Fast,
provider: RouterProvider::Fal,
prompt: Some("test".to_string()),
start_frame: Some(ImageRef::Url("https://example.com/a.png".to_string())),
..Default::default()
mod mode_selection {
use super::*;
#[test]
fn no_start_frame_picks_t2v() {
let _ = t2v_request(base_t2v_builder());
}
#[test]
fn start_frame_picks_i2v() {
let _ = i2v_request(base_i2v_builder());
}
#[test]
fn end_frame_errors() {
let mut b = base_i2v_builder();
b.end_frame = Some(ImageRef::Url("https://example.com/end.png".to_string()));
assert!(build_fal_veo_3_fast_state(b).is_err());
}
#[test]
fn media_file_token_for_start_frame_errors() {
let mut b = base_t2v_builder();
b.start_frame = Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_x".to_string())));
assert!(build_fal_veo_3_fast_state(b).is_err());
}
}
fn unwrap(b: GenerateVideoRequestBuilder) -> Veo3FastRequest {
match build_fal_veo_3_fast(b).expect("build") {
VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalVeo3Fast(s)) => s.request,
_ => panic!("expected FalVeo3Fast"),
mod materialized_fields {
use super::*;
#[test]
fn i2v_prompt_passed_through() {
let mut b = base_i2v_builder();
b.prompt = Some("hello world".to_string());
assert_eq!(i2v_request(b).prompt, "hello world");
}
}
#[test]
fn missing_start_frame_errors() {
let mut b = base_builder();
b.start_frame = None;
assert!(build_fal_veo_3_fast(b).is_err());
}
#[test]
fn i2v_image_url_passed_through() {
assert_eq!(i2v_request(base_i2v_builder()).image_url, "https://example.com/a.png");
}
#[test]
fn end_frame_errors() {
let mut b = base_builder();
b.end_frame = Some(ImageRef::Url("https://example.com/end.png".to_string()));
assert!(build_fal_veo_3_fast(b).is_err());
}
#[test]
fn audio_defaults_to_true() {
assert_eq!(i2v_request(base_i2v_builder()).generate_audio, Some(true));
assert_eq!(t2v_request(base_t2v_builder()).generate_audio, Some(true));
}
#[test]
fn prompt_passed_through() {
let mut b = base_builder();
b.prompt = Some("hello world".to_string());
assert_eq!(unwrap(b).prompt, "hello world");
}
#[test]
fn audio_defaults_to_true() {
assert!(unwrap(base_builder()).generate_audio);
}
#[test]
fn audio_false_passed_through() {
let mut b = base_builder();
b.generate_audio = Some(false);
assert!(!unwrap(b).generate_audio);
#[test]
fn audio_false_passed_through() {
let mut b = base_i2v_builder();
b.generate_audio = Some(false);
assert_eq!(i2v_request(b).generate_audio, Some(false));
}
}
#[test]
@@ -191,17 +294,49 @@ mod tests {
for &duration in &durations {
for &aspect_ratio in &aspect_ratios {
for &generate_audio in &audios {
let mut b = base_builder();
b.resolution = resolution;
b.duration_seconds = duration;
b.aspect_ratio = aspect_ratio;
b.generate_audio = generate_audio;
assert!(build_fal_veo_3_fast(b).is_ok());
combos += 1;
for has_start_frame in [false, true] {
let mut b = if has_start_frame { base_i2v_builder() } else { base_t2v_builder() };
b.resolution = resolution;
b.duration_seconds = duration;
b.aspect_ratio = aspect_ratio;
b.generate_audio = generate_audio;
assert!(build_fal_veo_3_fast_state(b).is_ok());
combos += 1;
}
}
}
}
}
assert_eq!(combos, 3 * 4 * 4 * 3);
assert_eq!(combos, 3 * 4 * 4 * 3 * 2);
}
fn base_t2v_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: RouterVideoModel::Veo3Fast,
provider: RouterProvider::Fal,
prompt: Some("test".to_string()),
..Default::default()
}
}
fn base_i2v_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
start_frame: Some(ImageRef::Url("https://example.com/a.png".to_string())),
..base_t2v_builder()
}
}
fn t2v_request(b: GenerateVideoRequestBuilder) -> Veo3FastTextToVideoRequest {
match build_fal_veo_3_fast_state(b).expect("build_fal_veo_3_fast_state").mode {
FalVeo3FastMode::TextToVideo(r) => r,
FalVeo3FastMode::ImageToVideo(_) => panic!("expected TextToVideo"),
}
}
fn i2v_request(b: GenerateVideoRequestBuilder) -> Veo3FastImageToVideoRequest {
match build_fal_veo_3_fast_state(b).expect("build_fal_veo_3_fast_state").mode {
FalVeo3FastMode::ImageToVideo(r) => r,
FalVeo3FastMode::TextToVideo(_) => panic!("expected ImageToVideo"),
}
}
}
@@ -1,30 +1,31 @@
use fal_client::requests_old::webhook::video::image::enqueue_veo_3_fast_image_to_video_webhook::Veo3FastDuration;
use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video::providers::fal::veo_3_fast::request::FalVeo3FastRequestState;
use crate::generate::generate_video::providers::fal::veo_3_fast::request::{
FalVeo3FastMode, FalVeo3FastRequestState,
};
#[derive(Clone, Debug)]
pub struct FalVeo3FastCostState {
pub duration_seconds: u64,
pub generate_audio: bool,
pub cost_in_usd_cents: u64,
}
impl FalVeo3FastCostState {
pub fn from_request(request: &FalVeo3FastRequestState) -> Self {
Self {
duration_seconds: duration_seconds_for_cost(request.request.duration),
generate_audio: request.request.generate_audio,
}
// Cost math is owned by fal_client's per-endpoint
// `FalRequestCostCalculator` implementations. The router state just
// forwards the result so router cost ≡ fal_client cost by construction.
let cost_in_usd_cents = match &request.mode {
FalVeo3FastMode::TextToVideo(req) => req.calculate_cost_in_cents(),
FalVeo3FastMode::ImageToVideo(req) => req.calculate_cost_in_cents(),
};
Self { cost_in_usd_cents }
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
// $0.10/sec audio off, $0.15/sec audio on (720p/1080p).
let per_second_cents: u64 = if self.generate_audio { 15 } else { 10 };
let cost_in_usd_cents = per_second_cents * self.duration_seconds;
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
cost_in_usd_cents: Some(cost_in_usd_cents),
cost_in_credits: Some(self.cost_in_usd_cents),
cost_in_usd_cents: Some(self.cost_in_usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
@@ -34,33 +35,18 @@ impl FalVeo3FastCostState {
}
}
fn duration_seconds_for_cost(d: Veo3FastDuration) -> u64 {
match d {
Veo3FastDuration::Default | Veo3FastDuration::EightSeconds => 8,
Veo3FastDuration::SixSeconds => 6,
Veo3FastDuration::FourSeconds => 4,
}
}
#[cfg(test)]
mod tests {
use crate::api::router_video_model::RouterVideoModel;
use crate::api::image_ref::ImageRef;
use crate::api::router_provider::RouterProvider;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::providers::fal::veo_3_fast::build::build_fal_veo_3_fast_state;
fn cost_cents(duration_seconds: Option<u16>, generate_audio: Option<bool>) -> u64 {
let b = GenerateVideoRequestBuilder {
model: RouterVideoModel::Veo3Fast,
provider: RouterProvider::Fal,
prompt: Some("test".to_string()),
start_frame: Some(ImageRef::Url("https://example.com/a.png".to_string())),
duration_seconds,
generate_audio,
..Default::default()
};
b.build2().expect("build2").estimate_cost().expect("estimate_cost").cost_in_usd_cents.expect("cost")
}
use super::*;
// Pricing (fal): $0.10/sec audio off, $0.15/sec audio on. Flat across
// modalities; resolution (720p/1080p) does not change the price.
mod numeric_literal_pricing {
use super::*;
@@ -68,38 +54,62 @@ mod tests {
#[test]
fn audio_on_4s_is_60() {
// $0.15/sec × 4s = 60¢.
assert_eq!(cost_cents(Some(4), Some(true)), 60);
assert_eq!(cost_cents(Some(4), Some(true), true), 60);
}
#[test]
fn audio_on_6s_is_90() { assert_eq!(cost_cents(Some(6), Some(true)), 90); }
fn audio_on_6s_is_90() { assert_eq!(cost_cents(Some(6), Some(true), true), 90); }
#[test]
fn audio_on_8s_is_120() { assert_eq!(cost_cents(Some(8), Some(true)), 120); }
fn audio_on_8s_is_120() { assert_eq!(cost_cents(Some(8), Some(true), true), 120); }
#[test]
fn audio_off_4s_is_40() {
// $0.10/sec × 4s = 40¢.
assert_eq!(cost_cents(Some(4), Some(false)), 40);
assert_eq!(cost_cents(Some(4), Some(false), true), 40);
}
#[test]
fn audio_off_8s_is_80() { assert_eq!(cost_cents(Some(8), Some(false)), 80); }
fn audio_off_8s_is_80() { assert_eq!(cost_cents(Some(8), Some(false), true), 80); }
#[test]
fn default_duration_is_8s() {
assert_eq!(cost_cents(None, Some(true)), 120);
assert_eq!(cost_cents(None, Some(true), true), 120);
}
#[test]
fn t2v_matches_i2v() {
assert_eq!(cost_cents(Some(6), Some(true), false), cost_cents(Some(6), Some(true), true));
}
}
#[test]
fn audio_costs_more_than_no_audio() {
assert!(cost_cents(Some(8), Some(false)) < cost_cents(Some(8), Some(true)));
assert!(cost_cents(Some(8), Some(false), true) < cost_cents(Some(8), Some(true), true));
}
#[test]
fn longer_duration_costs_more() {
assert!(cost_cents(Some(4), Some(true)) < cost_cents(Some(6), Some(true)));
assert!(cost_cents(Some(6), Some(true)) < cost_cents(Some(8), Some(true)));
assert!(cost_cents(Some(4), Some(true), true) < cost_cents(Some(6), Some(true), true));
assert!(cost_cents(Some(6), Some(true), true) < cost_cents(Some(8), Some(true), true));
}
fn cost_cents(duration_seconds: Option<u16>, generate_audio: Option<bool>, has_start_frame: bool) -> u64 {
let mut b = GenerateVideoRequestBuilder {
model: RouterVideoModel::Veo3Fast,
provider: RouterProvider::Fal,
prompt: Some("test".to_string()),
duration_seconds,
generate_audio,
..Default::default()
};
if has_start_frame {
b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string()));
}
let state = build_fal_veo_3_fast_state(b).expect("build_fal_veo_3_fast_state");
FalVeo3FastCostState::from_request(&state)
.estimate_cost()
.cost_in_usd_cents
.expect("cost_in_usd_cents")
}
}
@@ -1,91 +1,123 @@
use std::fmt::Debug;
use std::sync::Arc;
use fal_client::requests_old::webhook::video::image::enqueue_veo_3_fast_image_to_video_webhook::{
enqueue_veo_3_fast_image_to_video_webhook, Veo3FastArgs, Veo3FastRequest,
};
use fal_client::requests::api::video::image::veo_3_fast::api::Veo3FastImageToVideoRequest;
use fal_client::requests::api::video::text::veo_3_fast::api::Veo3FastTextToVideoRequest;
use fal_client::requests::traits::fal_endpoint_trait::FalEndpoint;
use crate::client::router_fal_client::RouterFalClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_video::generate_video_response::{
FalVideoResponsePayload, GenerateVideoResponse,
};
#[derive(Clone, Debug)]
pub enum FalVeo3FastMode {
TextToVideo(Veo3FastTextToVideoRequest),
ImageToVideo(Veo3FastImageToVideoRequest),
}
#[derive(Clone, Debug)]
pub struct FalVeo3FastRequestState {
pub request: Veo3FastRequest,
pub mode: FalVeo3FastMode,
}
impl FalVeo3FastRequestState {
pub async fn send(&self, client: &RouterFalClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let webhook_url = client.webhook_url.as_deref()
.ok_or(ArtcraftRouterError::Client(ClientError::WebhookUrlRequired))?;
let outbound_request: Arc<dyn Debug + Send + Sync> = Arc::new(self.request.clone());
match &self.mode {
FalVeo3FastMode::TextToVideo(request) => send_request(request, client).await,
FalVeo3FastMode::ImageToVideo(request) => send_request(request, client).await,
}
}
}
let args = Veo3FastArgs {
request: self.request.clone(),
api_key: &client.api_key,
webhook_url,
};
// ── Helpers ──
let webhook_response = enqueue_veo_3_fast_image_to_video_webhook(args)
.await
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?;
async fn send_request<T>(request: &T, client: &RouterFalClient) -> Result<GenerateVideoResponse, ArtcraftRouterError>
where
T: FalEndpoint + Clone + Debug + Send + Sync + 'static,
{
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let payload = send_fal_request(request, client).await?;
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
request_id: payload.request_id,
gateway_request_id: payload.gateway_request_id,
maybe_status_url: payload.status_url,
maybe_response_url: payload.response_url,
maybe_outbound_request: Some(outbound),
}))
}
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
request_id: webhook_response.request_id,
gateway_request_id: webhook_response.gateway_request_id,
maybe_status_url: None,
maybe_response_url: None,
maybe_outbound_request: Some(outbound_request),
}))
struct FalResponseIds {
request_id: Option<String>,
gateway_request_id: Option<String>,
status_url: Option<String>,
response_url: Option<String>,
}
async fn send_fal_request<T: FalEndpoint>(
request: &T,
client: &RouterFalClient,
) -> Result<FalResponseIds, ArtcraftRouterError> {
if let Some(webhook_url) = &client.webhook_url {
let response = request.send_webhook_request(&client.api_key, webhook_url).await?;
Ok(FalResponseIds {
request_id: response.request_id,
gateway_request_id: response.gateway_request_id,
status_url: None,
response_url: None,
})
} else {
let response = request.send_queue_request(&client.api_key).await?;
Ok(FalResponseIds {
request_id: Some(response.request_id),
gateway_request_id: None,
status_url: Some(response.status_url),
response_url: Some(response.response_url),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use fal_client::creds::fal_api_key::FalApiKey;
use fal_client::requests::api::video::image::veo_3_fast::api::{
Veo3FastImageToVideoDuration, Veo3FastImageToVideoResolution,
};
use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL;
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
use crate::api::image_ref::ImageRef;
use crate::api::router_provider::RouterProvider;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::test_helpers::get_fal_client;
#[tokio::test]
#[ignore]
#[ignore] // requires real API key, incurs cost
async fn live_image_to_video_720p_4s_audio() {
let r = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("the dog leaps into the lake.".to_string()),
start_frame: Some(ImageRef::Url(JUNO_AT_LAKE_IMAGE_URL.to_string())),
resolution: Some(RouterResolution::SevenTwentyP),
duration_seconds: Some(4),
generate_audio: Some(true),
..builder()
}).await;
assert!(matches!(r, GenerateVideoResponse::Fal(_)));
}
fn builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: RouterVideoModel::Veo3Fast,
provider: RouterProvider::Fal,
..Default::default()
}
}
async fn run_pipeline(b: GenerateVideoRequestBuilder) -> GenerateVideoResponse {
let client = get_fal_client();
let dor = b.build2().expect("build2");
let req = match dor {
VideoGenerationDraftOrRequest::Request(r) => r,
_ => panic!("expected Request"),
let state = FalVeo3FastRequestState {
mode: FalVeo3FastMode::ImageToVideo(Veo3FastImageToVideoRequest {
prompt: "the dog leaps into the lake.".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(true),
negative_prompt: None,
seed: None,
auto_fix: None,
safety_tolerance: None,
}),
};
req.send_request(&client).await.expect("send")
let response = state.send(&client_with_webhook()).await.expect("send should succeed");
assert!(matches!(response, GenerateVideoResponse::Fal(_)));
}
fn read_fal_api_key() -> FalApiKey {
let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")
.expect("Failed to read fal_api_key.txt");
FalApiKey::from_str(secret.trim())
}
fn client_with_webhook() -> RouterFalClient {
RouterFalClient::new_with_webhook(
read_fal_api_key(),
"https://example.com/fal-webhook-test".to_string(),
)
}
}
@@ -1,19 +1,29 @@
use fal_client::requests_old::webhook::video::image::enqueue_veo_3p1_first_last_frame_image_to_video_webhook::{
EnqueueVeo3p1FirstLastFrameImageToVideoAspectRatio, EnqueueVeo3p1FirstLastFrameImageToVideoDurationSeconds,
EnqueueVeo3p1FirstLastFrameImageToVideoRequest, EnqueueVeo3p1FirstLastFrameImageToVideoResolution,
use fal_client::requests::api::video::extend::veo_3p1::api::{
Veo3p1ExtendVideoAspectRatio, Veo3p1ExtendVideoDuration, Veo3p1ExtendVideoRequest,
Veo3p1ExtendVideoResolution,
};
use fal_client::requests_old::webhook::video::image::enqueue_veo_3p1_image_to_video_webhook::{
EnqueueVeo3p1ImageToVideoAspectRatio, EnqueueVeo3p1ImageToVideoDurationSeconds,
EnqueueVeo3p1ImageToVideoRequest, EnqueueVeo3p1ImageToVideoResolution,
use fal_client::requests::api::video::image::veo_3p1::api::{
Veo3p1ImageToVideoAspectRatio, Veo3p1ImageToVideoDuration, Veo3p1ImageToVideoRequest,
Veo3p1ImageToVideoResolution,
};
use fal_client::requests_old::webhook::video::text::enqueue_veo_3p1_text_to_video_webhook::{
EnqueueVeo3p1TextToVideoAspectRatio, EnqueueVeo3p1TextToVideoDurationSeconds,
EnqueueVeo3p1TextToVideoRequest, EnqueueVeo3p1TextToVideoResolution,
use fal_client::requests::api::video::images::veo_3p1::api::{
Veo3p1FirstLastFrameToVideoAspectRatio, Veo3p1FirstLastFrameToVideoDuration,
Veo3p1FirstLastFrameToVideoRequest, Veo3p1FirstLastFrameToVideoResolution,
};
use fal_client::requests::api::video::reference::veo_3p1::api::{
Veo3p1ReferenceToVideoAspectRatio, Veo3p1ReferenceToVideoDuration, Veo3p1ReferenceToVideoRequest,
Veo3p1ReferenceToVideoResolution,
};
use fal_client::requests::api::video::text::veo_3p1::api::{
Veo3p1TextToVideoAspectRatio, Veo3p1TextToVideoDuration, Veo3p1TextToVideoRequest,
Veo3p1TextToVideoResolution,
};
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_resolution::RouterResolution;
use crate::api::image_ref::ImageRef;
use crate::api::video_list_ref::VideoListRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
@@ -33,6 +43,7 @@ pub(crate) enum PlanAspectRatio {
pub(crate) enum PlanResolution {
SevenTwentyP,
TenEightyP,
FourK,
}
#[derive(Debug, Clone, Copy)]
@@ -45,66 +56,143 @@ pub(crate) enum PlanDuration {
pub fn build_fal_veo_3p1(
builder: GenerateVideoRequestBuilder,
) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let state = build_veo_3p1_request_state(builder, "Veo 3.1")?;
let state = build_fal_veo_3p1_state(builder)?;
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalVeo3p1(state)))
}
/// Shared builder used by both Veo 3.1 and Veo 3.1 Fast. The two variants take
/// the exact same router-level inputs — only the downstream fal endpoint and
/// pricing differ — so they share planning code.
pub(crate) fn build_veo_3p1_request_state(
pub(crate) fn build_fal_veo_3p1_state(
builder: GenerateVideoRequestBuilder,
model_label: &'static str,
) -> Result<FalVeo3p1RequestState, ArtcraftRouterError> {
let strategy = builder.request_mismatch_mitigation_strategy;
let start = optional_url(builder.start_frame.clone())?;
let end = optional_url(builder.end_frame.clone())?;
let reference_images = reference_image_urls(builder.reference_images.clone())?;
let reference_videos = reference_video_urls(builder.reference_videos.clone())?;
let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio, strategy)?;
let resolution = plan_resolution(builder.resolution, strategy)?;
let duration = plan_duration(builder.duration_seconds, strategy)?;
let prompt = builder.prompt.clone().unwrap_or_default();
let negative_prompt = builder.negative_prompt.clone();
let generate_audio = builder.generate_audio;
let mode = match (start, end) {
(None, None) => FalVeo3p1Mode::TextToVideo(EnqueueVeo3p1TextToVideoRequest {
prompt,
duration: duration.map(to_t2v_duration),
aspect_ratio: aspect_ratio.map(to_t2v_aspect_ratio),
resolution: resolution.map(to_t2v_resolution),
generate_audio,
enhance_prompt: None,
negative_prompt,
seed: None,
auto_fix: None,
}),
(Some(image_url), None) => FalVeo3p1Mode::ImageToVideo(EnqueueVeo3p1ImageToVideoRequest {
prompt,
image_url,
duration: duration.map(to_i2v_duration),
aspect_ratio: aspect_ratio.map(to_i2v_aspect_ratio),
resolution: resolution.map(to_i2v_resolution),
generate_audio,
}),
(Some(first_frame_url), Some(last_frame_url)) => FalVeo3p1Mode::FirstLastFrame(
EnqueueVeo3p1FirstLastFrameImageToVideoRequest {
prompt,
first_frame_url,
last_frame_url,
duration: duration.map(to_flf_duration),
aspect_ratio: aspect_ratio.map(to_flf_aspect_ratio),
resolution: resolution.map(to_flf_resolution),
generate_audio,
},
),
(None, Some(_)) => {
// Modality dispatch, most specific inputs first:
// 1. reference videos → extend-video
// 2. reference images → reference-to-video
// 3. start + end frames → first-last-frame-to-video
// 4. start frame only → image-to-video
// 5. no media → text-to-video
let mode = if let Some(video_urls) = reference_videos {
if reference_images.is_some() {
return Err(unsupported(
"reference_images",
"Veo 3.1 extend-video cannot combine reference_images with a reference video",
));
}
if start.is_some() {
return Err(unsupported(
"start_frame",
"Veo 3.1 extend-video cannot combine start_frame with a reference video",
));
}
if end.is_some() {
return Err(unsupported(
"end_frame",
&format!("{} requires a start_frame when end_frame is provided", model_label),
"Veo 3.1 extend-video cannot combine end_frame with a reference video",
));
}
if video_urls.len() != 1 {
return Err(unsupported(
"reference_videos",
&format!("Veo 3.1 extend-video requires exactly 1 reference video, got {}", video_urls.len()),
));
}
let video_url = video_urls.into_iter().next().expect("checked len == 1");
FalVeo3p1Mode::ExtendVideo(Veo3p1ExtendVideoRequest {
prompt,
video_url,
aspect_ratio: aspect_ratio.map(to_extend_aspect_ratio),
duration: plan_extend_duration(builder.duration_seconds, strategy)?,
resolution: resolution.map(|r| to_extend_resolution(r, strategy)).transpose()?,
negative_prompt,
generate_audio,
seed: None,
auto_fix: None,
safety_tolerance: None,
})
} else if let Some(image_urls) = reference_images {
if start.is_some() {
return Err(unsupported(
"start_frame",
"Veo 3.1 reference-to-video cannot combine start_frame with reference_images",
));
}
if end.is_some() {
return Err(unsupported(
"end_frame",
"Veo 3.1 reference-to-video cannot combine end_frame with reference_images",
));
}
// NB: reference-to-video has no negative_prompt or seed fields on fal's
// schema — those builder inputs are dropped silently.
FalVeo3p1Mode::ReferenceToVideo(Veo3p1ReferenceToVideoRequest {
prompt,
image_urls,
aspect_ratio: aspect_ratio.and_then(to_reference_aspect_ratio),
duration: plan_duration(builder.duration_seconds, strategy)?.map(to_reference_duration),
resolution: resolution.map(to_reference_resolution),
generate_audio,
auto_fix: None,
safety_tolerance: None,
})
} else {
let duration = plan_duration(builder.duration_seconds, strategy)?;
match (start, end) {
(None, None) => FalVeo3p1Mode::TextToVideo(Veo3p1TextToVideoRequest {
prompt,
aspect_ratio: aspect_ratio.and_then(to_t2v_aspect_ratio),
duration: duration.map(to_t2v_duration),
resolution: resolution.map(to_t2v_resolution),
negative_prompt,
generate_audio,
seed: None,
auto_fix: None,
safety_tolerance: None,
}),
(Some(image_url), None) => FalVeo3p1Mode::ImageToVideo(Veo3p1ImageToVideoRequest {
prompt,
image_url,
aspect_ratio: aspect_ratio.map(to_i2v_aspect_ratio),
duration: duration.map(to_i2v_duration),
resolution: resolution.map(to_i2v_resolution),
generate_audio,
negative_prompt,
seed: None,
auto_fix: None,
safety_tolerance: None,
}),
(Some(first_frame_url), Some(last_frame_url)) => FalVeo3p1Mode::FirstLastFrameToVideo(
Veo3p1FirstLastFrameToVideoRequest {
prompt,
first_frame_url,
last_frame_url,
aspect_ratio: aspect_ratio.map(to_flf_aspect_ratio),
duration: duration.map(to_flf_duration),
resolution: resolution.map(to_flf_resolution),
generate_audio,
negative_prompt,
seed: None,
auto_fix: None,
safety_tolerance: None,
},
),
(None, Some(_)) => {
return Err(unsupported(
"end_frame",
"Veo 3.1 requires a start_frame when end_frame is provided",
));
}
}
};
Ok(FalVeo3p1RequestState { mode })
@@ -120,6 +208,30 @@ fn optional_url(image_ref: Option<ImageRef>) -> Result<Option<String>, ArtcraftR
}
}
fn reference_image_urls(refs: Option<ImageListRef>) -> Result<Option<Vec<String>>, ArtcraftRouterError> {
match refs {
None => Ok(None),
Some(ImageListRef::Urls(urls)) if urls.is_empty() => Ok(None),
Some(ImageListRef::Urls(urls)) => Ok(Some(urls)),
Some(ImageListRef::MediaFileTokens(tokens)) if tokens.is_empty() => Ok(None),
Some(ImageListRef::MediaFileTokens(_)) => {
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
}
}
}
fn reference_video_urls(refs: Option<VideoListRef>) -> Result<Option<Vec<String>>, ArtcraftRouterError> {
match refs {
None => Ok(None),
Some(VideoListRef::Urls(urls)) if urls.is_empty() => Ok(None),
Some(VideoListRef::Urls(urls)) => Ok(Some(urls)),
Some(VideoListRef::MediaFileTokens(tokens)) if tokens.is_empty() => Ok(None),
Some(VideoListRef::MediaFileTokens(_)) => {
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
}
}
}
fn plan_aspect_ratio(
aspect_ratio: Option<RouterAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
@@ -153,6 +265,7 @@ fn plan_resolution(
None => Ok(None),
Some(RouterResolution::SevenTwentyP) => Ok(Some(R::SevenTwentyP)),
Some(RouterResolution::TenEightyP) => Ok(Some(R::TenEightyP)),
Some(RouterResolution::FourK) => Ok(Some(R::FourK)),
Some(other) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(unsupported("resolution", &format!("{:?}", other)))
@@ -176,8 +289,43 @@ fn plan_duration(
RequestMismatchMitigationStrategy::ErrorOut => {
Err(unsupported("duration_seconds", &format!("{}", other)))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Some(PlanDuration::Eight)),
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(PlanDuration::Four)),
// Nearest supported duration above/below, clamped to the 4s–8s range.
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Some(match other {
0..=4 => PlanDuration::Four,
5..=6 => PlanDuration::Six,
_ => PlanDuration::Eight,
})),
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(match other {
0..=5 => PlanDuration::Four,
6..=7 => PlanDuration::Six,
_ => PlanDuration::Eight,
})),
},
}
}
/// Extend-video also supports fal's 7s default in addition to 4/6/8.
fn plan_extend_duration(
duration_seconds: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<Veo3p1ExtendVideoDuration>, ArtcraftRouterError> {
use Veo3p1ExtendVideoDuration as D;
match duration_seconds {
None => Ok(None),
Some(4) => Ok(Some(D::FourSeconds)),
Some(6) => Ok(Some(D::SixSeconds)),
Some(7) => Ok(Some(D::SevenSeconds)),
Some(8) => Ok(Some(D::EightSeconds)),
Some(other) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(unsupported("duration_seconds", &format!("{}", other)))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => {
Ok(Some(if other < 5 { D::FourSeconds } else if other == 5 { D::SixSeconds } else { D::EightSeconds }))
}
RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(Some(if other <= 5 { D::FourSeconds } else { D::EightSeconds }))
}
},
}
}
@@ -189,98 +337,145 @@ fn unsupported(field: &'static str, value: &str) -> ArtcraftRouterError {
})
}
fn to_t2v_duration(d: PlanDuration) -> EnqueueVeo3p1TextToVideoDurationSeconds {
match d {
PlanDuration::Four => EnqueueVeo3p1TextToVideoDurationSeconds::Four,
PlanDuration::Six => EnqueueVeo3p1TextToVideoDurationSeconds::Six,
PlanDuration::Eight => EnqueueVeo3p1TextToVideoDurationSeconds::Eight,
}
}
fn to_t2v_aspect_ratio(a: PlanAspectRatio) -> EnqueueVeo3p1TextToVideoAspectRatio {
// The text-to-video endpoint has no `auto` aspect ratio; omit the field and
// let fal apply its own default (16:9).
fn to_t2v_aspect_ratio(a: PlanAspectRatio) -> Option<Veo3p1TextToVideoAspectRatio> {
match a {
PlanAspectRatio::Auto => EnqueueVeo3p1TextToVideoAspectRatio::Auto,
PlanAspectRatio::SixteenByNine => EnqueueVeo3p1TextToVideoAspectRatio::SixteenByNine,
PlanAspectRatio::NineBySixteen => EnqueueVeo3p1TextToVideoAspectRatio::NineBySixteen,
PlanAspectRatio::Auto => None,
PlanAspectRatio::SixteenByNine => Some(Veo3p1TextToVideoAspectRatio::SixteenByNine),
PlanAspectRatio::NineBySixteen => Some(Veo3p1TextToVideoAspectRatio::NineBySixteen),
}
}
fn to_t2v_resolution(r: PlanResolution) -> EnqueueVeo3p1TextToVideoResolution {
match r {
PlanResolution::SevenTwentyP => EnqueueVeo3p1TextToVideoResolution::SevenTwentyP,
PlanResolution::TenEightyP => EnqueueVeo3p1TextToVideoResolution::TenEightyP,
}
}
fn to_i2v_duration(d: PlanDuration) -> EnqueueVeo3p1ImageToVideoDurationSeconds {
fn to_t2v_duration(d: PlanDuration) -> Veo3p1TextToVideoDuration {
match d {
PlanDuration::Four => EnqueueVeo3p1ImageToVideoDurationSeconds::Four,
PlanDuration::Six => EnqueueVeo3p1ImageToVideoDurationSeconds::Six,
PlanDuration::Eight => EnqueueVeo3p1ImageToVideoDurationSeconds::Eight,
PlanDuration::Four => Veo3p1TextToVideoDuration::FourSeconds,
PlanDuration::Six => Veo3p1TextToVideoDuration::SixSeconds,
PlanDuration::Eight => Veo3p1TextToVideoDuration::EightSeconds,
}
}
fn to_i2v_aspect_ratio(a: PlanAspectRatio) -> EnqueueVeo3p1ImageToVideoAspectRatio {
match a {
PlanAspectRatio::Auto => EnqueueVeo3p1ImageToVideoAspectRatio::Auto,
PlanAspectRatio::SixteenByNine => EnqueueVeo3p1ImageToVideoAspectRatio::SixteenByNine,
PlanAspectRatio::NineBySixteen => EnqueueVeo3p1ImageToVideoAspectRatio::NineBySixteen,
}
}
fn to_i2v_resolution(r: PlanResolution) -> EnqueueVeo3p1ImageToVideoResolution {
fn to_t2v_resolution(r: PlanResolution) -> Veo3p1TextToVideoResolution {
match r {
PlanResolution::SevenTwentyP => EnqueueVeo3p1ImageToVideoResolution::SevenTwentyP,
PlanResolution::TenEightyP => EnqueueVeo3p1ImageToVideoResolution::TenEightyP,
PlanResolution::SevenTwentyP => Veo3p1TextToVideoResolution::SevenTwentyP,
PlanResolution::TenEightyP => Veo3p1TextToVideoResolution::TenEightyP,
PlanResolution::FourK => Veo3p1TextToVideoResolution::FourK,
}
}
fn to_flf_duration(d: PlanDuration) -> EnqueueVeo3p1FirstLastFrameImageToVideoDurationSeconds {
fn to_i2v_aspect_ratio(a: PlanAspectRatio) -> Veo3p1ImageToVideoAspectRatio {
match a {
PlanAspectRatio::Auto => Veo3p1ImageToVideoAspectRatio::Auto,
PlanAspectRatio::SixteenByNine => Veo3p1ImageToVideoAspectRatio::SixteenByNine,
PlanAspectRatio::NineBySixteen => Veo3p1ImageToVideoAspectRatio::NineBySixteen,
}
}
fn to_i2v_duration(d: PlanDuration) -> Veo3p1ImageToVideoDuration {
match d {
PlanDuration::Four => EnqueueVeo3p1FirstLastFrameImageToVideoDurationSeconds::Four,
PlanDuration::Six => EnqueueVeo3p1FirstLastFrameImageToVideoDurationSeconds::Six,
PlanDuration::Eight => EnqueueVeo3p1FirstLastFrameImageToVideoDurationSeconds::Eight,
PlanDuration::Four => Veo3p1ImageToVideoDuration::FourSeconds,
PlanDuration::Six => Veo3p1ImageToVideoDuration::SixSeconds,
PlanDuration::Eight => Veo3p1ImageToVideoDuration::EightSeconds,
}
}
fn to_flf_aspect_ratio(a: PlanAspectRatio) -> EnqueueVeo3p1FirstLastFrameImageToVideoAspectRatio {
match a {
PlanAspectRatio::Auto => EnqueueVeo3p1FirstLastFrameImageToVideoAspectRatio::Auto,
PlanAspectRatio::SixteenByNine => EnqueueVeo3p1FirstLastFrameImageToVideoAspectRatio::SixteenByNine,
PlanAspectRatio::NineBySixteen => EnqueueVeo3p1FirstLastFrameImageToVideoAspectRatio::NineBySixteen,
}
}
fn to_flf_resolution(r: PlanResolution) -> EnqueueVeo3p1FirstLastFrameImageToVideoResolution {
fn to_i2v_resolution(r: PlanResolution) -> Veo3p1ImageToVideoResolution {
match r {
PlanResolution::SevenTwentyP => EnqueueVeo3p1FirstLastFrameImageToVideoResolution::SevenTwentyP,
PlanResolution::TenEightyP => EnqueueVeo3p1FirstLastFrameImageToVideoResolution::TenEightyP,
PlanResolution::SevenTwentyP => Veo3p1ImageToVideoResolution::SevenTwentyP,
PlanResolution::TenEightyP => Veo3p1ImageToVideoResolution::TenEightyP,
PlanResolution::FourK => Veo3p1ImageToVideoResolution::FourK,
}
}
fn to_flf_aspect_ratio(a: PlanAspectRatio) -> Veo3p1FirstLastFrameToVideoAspectRatio {
match a {
PlanAspectRatio::Auto => Veo3p1FirstLastFrameToVideoAspectRatio::Auto,
PlanAspectRatio::SixteenByNine => Veo3p1FirstLastFrameToVideoAspectRatio::SixteenByNine,
PlanAspectRatio::NineBySixteen => Veo3p1FirstLastFrameToVideoAspectRatio::NineBySixteen,
}
}
fn to_flf_duration(d: PlanDuration) -> Veo3p1FirstLastFrameToVideoDuration {
match d {
PlanDuration::Four => Veo3p1FirstLastFrameToVideoDuration::FourSeconds,
PlanDuration::Six => Veo3p1FirstLastFrameToVideoDuration::SixSeconds,
PlanDuration::Eight => Veo3p1FirstLastFrameToVideoDuration::EightSeconds,
}
}
fn to_flf_resolution(r: PlanResolution) -> Veo3p1FirstLastFrameToVideoResolution {
match r {
PlanResolution::SevenTwentyP => Veo3p1FirstLastFrameToVideoResolution::SevenTwentyP,
PlanResolution::TenEightyP => Veo3p1FirstLastFrameToVideoResolution::TenEightyP,
PlanResolution::FourK => Veo3p1FirstLastFrameToVideoResolution::FourK,
}
}
// The reference-to-video endpoint has no `auto` aspect ratio; omit the field
// and let fal apply its own default (16:9).
fn to_reference_aspect_ratio(a: PlanAspectRatio) -> Option<Veo3p1ReferenceToVideoAspectRatio> {
match a {
PlanAspectRatio::Auto => None,
PlanAspectRatio::SixteenByNine => Some(Veo3p1ReferenceToVideoAspectRatio::SixteenByNine),
PlanAspectRatio::NineBySixteen => Some(Veo3p1ReferenceToVideoAspectRatio::NineBySixteen),
}
}
fn to_reference_duration(d: PlanDuration) -> Veo3p1ReferenceToVideoDuration {
match d {
PlanDuration::Four => Veo3p1ReferenceToVideoDuration::FourSeconds,
PlanDuration::Six => Veo3p1ReferenceToVideoDuration::SixSeconds,
PlanDuration::Eight => Veo3p1ReferenceToVideoDuration::EightSeconds,
}
}
fn to_reference_resolution(r: PlanResolution) -> Veo3p1ReferenceToVideoResolution {
match r {
PlanResolution::SevenTwentyP => Veo3p1ReferenceToVideoResolution::SevenTwentyP,
PlanResolution::TenEightyP => Veo3p1ReferenceToVideoResolution::TenEightyP,
PlanResolution::FourK => Veo3p1ReferenceToVideoResolution::FourK,
}
}
fn to_extend_aspect_ratio(a: PlanAspectRatio) -> Veo3p1ExtendVideoAspectRatio {
match a {
PlanAspectRatio::Auto => Veo3p1ExtendVideoAspectRatio::Auto,
PlanAspectRatio::SixteenByNine => Veo3p1ExtendVideoAspectRatio::SixteenByNine,
PlanAspectRatio::NineBySixteen => Veo3p1ExtendVideoAspectRatio::NineBySixteen,
}
}
// Extend-video has no 4k tier; the nearest supported resolution in either
// direction is 1080p.
fn to_extend_resolution(
r: PlanResolution,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Veo3p1ExtendVideoResolution, ArtcraftRouterError> {
match r {
PlanResolution::SevenTwentyP => Ok(Veo3p1ExtendVideoResolution::SevenTwentyP),
PlanResolution::TenEightyP => Ok(Veo3p1ExtendVideoResolution::TenEightyP),
PlanResolution::FourK => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(unsupported("resolution", "Veo 3.1 extend-video does not support 4k"))
}
_ => Ok(Veo3p1ExtendVideoResolution::TenEightyP),
},
}
}
#[cfg(test)]
mod tests {
use crate::api::router_video_model::RouterVideoModel;
use crate::api::router_provider::RouterProvider;
use crate::api::router_video_model::RouterVideoModel;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
use super::*;
fn base_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: RouterVideoModel::Veo3p1,
provider: RouterProvider::Fal,
prompt: Some("test".to_string()),
..Default::default()
}
}
fn unwrap_mode(b: GenerateVideoRequestBuilder) -> FalVeo3p1Mode {
match build_fal_veo_3p1(b).expect("build") {
VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalVeo3p1(s)) => s.mode,
_ => panic!("expected FalVeo3p1"),
}
}
const START_FRAME_URL: &str = "https://example.com/a.png";
const END_FRAME_URL: &str = "https://example.com/b.png";
const REFERENCE_IMAGE_URL_A: &str = "https://example.com/ref_a.png";
const REFERENCE_IMAGE_URL_B: &str = "https://example.com/ref_b.png";
const REFERENCE_VIDEO_URL: &str = "https://example.com/in.mp4";
mod mode_selection {
use super::*;
@@ -293,23 +488,211 @@ mod tests {
#[test]
fn start_only_picks_i2v() {
let mut b = base_builder();
b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string()));
b.start_frame = Some(ImageRef::Url(START_FRAME_URL.to_string()));
assert!(matches!(unwrap_mode(b), FalVeo3p1Mode::ImageToVideo(_)));
}
#[test]
fn start_and_end_picks_first_last_frame() {
let mut b = base_builder();
b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string()));
b.end_frame = Some(ImageRef::Url("https://example.com/b.png".to_string()));
assert!(matches!(unwrap_mode(b), FalVeo3p1Mode::FirstLastFrame(_)));
b.start_frame = Some(ImageRef::Url(START_FRAME_URL.to_string()));
b.end_frame = Some(ImageRef::Url(END_FRAME_URL.to_string()));
assert!(matches!(unwrap_mode(b), FalVeo3p1Mode::FirstLastFrameToVideo(_)));
}
#[test]
fn end_only_errors() {
let mut b = base_builder();
b.end_frame = Some(ImageRef::Url("https://example.com/b.png".to_string()));
assert!(build_fal_veo_3p1(b).is_err());
b.end_frame = Some(ImageRef::Url(END_FRAME_URL.to_string()));
assert!(build_fal_veo_3p1_state(b).is_err());
}
#[test]
fn reference_images_pick_reference_to_video() {
let mut b = base_builder();
b.reference_images = Some(ImageListRef::Urls(vec![REFERENCE_IMAGE_URL_A.to_string()]));
assert!(matches!(unwrap_mode(b), FalVeo3p1Mode::ReferenceToVideo(_)));
}
#[test]
fn reference_videos_pick_extend_video() {
let mut b = base_builder();
b.reference_videos = Some(VideoListRef::Urls(vec![REFERENCE_VIDEO_URL.to_string()]));
assert!(matches!(unwrap_mode(b), FalVeo3p1Mode::ExtendVideo(_)));
}
#[test]
fn empty_reference_lists_fall_through_to_t2v() {
let mut b = base_builder();
b.reference_images = Some(ImageListRef::Urls(vec![]));
b.reference_videos = Some(VideoListRef::Urls(vec![]));
assert!(matches!(unwrap_mode(b), FalVeo3p1Mode::TextToVideo(_)));
}
#[test]
fn public_build_fn_wraps_request_enum() {
match build_fal_veo_3p1(base_builder()).expect("build") {
VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalVeo3p1(s)) => {
assert!(matches!(s.mode, FalVeo3p1Mode::TextToVideo(_)));
}
_ => panic!("expected Request(FalVeo3p1)"),
}
}
}
mod extend_video {
use super::*;
#[test]
fn extend_maps_video_url_and_7s_duration() {
let mut b = base_builder();
b.reference_videos = Some(VideoListRef::Urls(vec![REFERENCE_VIDEO_URL.to_string()]));
b.duration_seconds = Some(7);
match unwrap_mode(b) {
FalVeo3p1Mode::ExtendVideo(r) => {
assert_eq!(r.video_url, REFERENCE_VIDEO_URL);
assert!(matches!(r.duration, Some(Veo3p1ExtendVideoDuration::SevenSeconds)));
}
_ => panic!("expected extend"),
}
}
#[test]
fn extend_requires_exactly_one_video() {
let mut b = base_builder();
b.reference_videos = Some(VideoListRef::Urls(vec![
REFERENCE_VIDEO_URL.to_string(),
"https://example.com/other.mp4".to_string(),
]));
assert!(build_fal_veo_3p1_state(b).is_err());
}
#[test]
fn extend_rejects_start_frame() {
let mut b = base_builder();
b.reference_videos = Some(VideoListRef::Urls(vec![REFERENCE_VIDEO_URL.to_string()]));
b.start_frame = Some(ImageRef::Url(START_FRAME_URL.to_string()));
assert!(build_fal_veo_3p1_state(b).is_err());
}
#[test]
fn extend_rejects_end_frame() {
let mut b = base_builder();
b.reference_videos = Some(VideoListRef::Urls(vec![REFERENCE_VIDEO_URL.to_string()]));
b.end_frame = Some(ImageRef::Url(END_FRAME_URL.to_string()));
assert!(build_fal_veo_3p1_state(b).is_err());
}
#[test]
fn extend_rejects_reference_images() {
let mut b = base_builder();
b.reference_videos = Some(VideoListRef::Urls(vec![REFERENCE_VIDEO_URL.to_string()]));
b.reference_images = Some(ImageListRef::Urls(vec![REFERENCE_IMAGE_URL_A.to_string()]));
assert!(build_fal_veo_3p1_state(b).is_err());
}
#[test]
fn extend_duration_5s_pay_more_upgrades_to_6() {
let mut b = base_builder();
b.reference_videos = Some(VideoListRef::Urls(vec![REFERENCE_VIDEO_URL.to_string()]));
b.duration_seconds = Some(5);
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::PayMoreUpgrade;
match unwrap_mode(b) {
FalVeo3p1Mode::ExtendVideo(r) => {
assert!(matches!(r.duration, Some(Veo3p1ExtendVideoDuration::SixSeconds)));
}
_ => panic!("expected extend"),
}
}
#[test]
fn extend_4k_errors_with_error_out() {
let mut b = base_builder();
b.reference_videos = Some(VideoListRef::Urls(vec![REFERENCE_VIDEO_URL.to_string()]));
b.resolution = Some(RouterResolution::FourK);
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut;
assert!(build_fal_veo_3p1_state(b).is_err());
}
#[test]
fn extend_4k_maps_to_1080p_with_mitigation() {
for strategy in [
RequestMismatchMitigationStrategy::PayMoreUpgrade,
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let mut b = base_builder();
b.reference_videos = Some(VideoListRef::Urls(vec![REFERENCE_VIDEO_URL.to_string()]));
b.resolution = Some(RouterResolution::FourK);
b.request_mismatch_mitigation_strategy = strategy;
match unwrap_mode(b) {
FalVeo3p1Mode::ExtendVideo(r) => {
assert!(matches!(r.resolution, Some(Veo3p1ExtendVideoResolution::TenEightyP)));
}
_ => panic!("expected extend"),
}
}
}
}
mod reference_to_video {
use super::*;
#[test]
fn reference_maps_image_urls() {
let mut b = base_builder();
b.reference_images = Some(ImageListRef::Urls(vec![
REFERENCE_IMAGE_URL_A.to_string(),
REFERENCE_IMAGE_URL_B.to_string(),
]));
match unwrap_mode(b) {
FalVeo3p1Mode::ReferenceToVideo(r) => {
assert_eq!(r.image_urls, vec![REFERENCE_IMAGE_URL_A, REFERENCE_IMAGE_URL_B]);
}
_ => panic!("expected reference"),
}
}
#[test]
fn reference_rejects_start_frame() {
let mut b = base_builder();
b.reference_images = Some(ImageListRef::Urls(vec![REFERENCE_IMAGE_URL_A.to_string()]));
b.start_frame = Some(ImageRef::Url(START_FRAME_URL.to_string()));
assert!(build_fal_veo_3p1_state(b).is_err());
}
#[test]
fn reference_rejects_end_frame() {
let mut b = base_builder();
b.reference_images = Some(ImageListRef::Urls(vec![REFERENCE_IMAGE_URL_A.to_string()]));
b.end_frame = Some(ImageRef::Url(END_FRAME_URL.to_string()));
assert!(build_fal_veo_3p1_state(b).is_err());
}
#[test]
fn reference_auto_aspect_ratio_is_omitted() {
// reference-to-video has no `auto` wire value; Auto must map to None.
let mut b = base_builder();
b.reference_images = Some(ImageListRef::Urls(vec![REFERENCE_IMAGE_URL_A.to_string()]));
b.aspect_ratio = Some(RouterAspectRatio::Auto);
match unwrap_mode(b) {
FalVeo3p1Mode::ReferenceToVideo(r) => assert!(r.aspect_ratio.is_none()),
_ => panic!("expected reference"),
}
}
#[test]
fn reference_maps_duration_and_resolution() {
let mut b = base_builder();
b.reference_images = Some(ImageListRef::Urls(vec![REFERENCE_IMAGE_URL_A.to_string()]));
b.duration_seconds = Some(6);
b.resolution = Some(RouterResolution::FourK);
match unwrap_mode(b) {
FalVeo3p1Mode::ReferenceToVideo(r) => {
assert!(matches!(r.duration, Some(Veo3p1ReferenceToVideoDuration::SixSeconds)));
assert!(matches!(r.resolution, Some(Veo3p1ReferenceToVideoResolution::FourK)));
}
_ => panic!("expected reference"),
}
}
}
@@ -322,7 +705,7 @@ mod tests {
b.duration_seconds = Some(4);
match unwrap_mode(b) {
FalVeo3p1Mode::TextToVideo(r) => {
assert!(matches!(r.duration, Some(EnqueueVeo3p1TextToVideoDurationSeconds::Four)));
assert!(matches!(r.duration, Some(Veo3p1TextToVideoDuration::FourSeconds)));
}
_ => panic!("expected t2v"),
}
@@ -333,7 +716,33 @@ mod tests {
let mut b = base_builder();
b.duration_seconds = Some(5);
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut;
assert!(build_fal_veo_3p1(b).is_err());
assert!(build_fal_veo_3p1_state(b).is_err());
}
#[test]
fn duration_5s_pay_more_upgrades_to_6() {
let mut b = base_builder();
b.duration_seconds = Some(5);
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::PayMoreUpgrade;
match unwrap_mode(b) {
FalVeo3p1Mode::TextToVideo(r) => {
assert!(matches!(r.duration, Some(Veo3p1TextToVideoDuration::SixSeconds)));
}
_ => panic!("expected t2v"),
}
}
#[test]
fn duration_5s_pay_less_downgrades_to_4() {
let mut b = base_builder();
b.duration_seconds = Some(5);
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::PayLessDowngrade;
match unwrap_mode(b) {
FalVeo3p1Mode::TextToVideo(r) => {
assert!(matches!(r.duration, Some(Veo3p1TextToVideoDuration::FourSeconds)));
}
_ => panic!("expected t2v"),
}
}
}
@@ -346,16 +755,56 @@ mod tests {
b.resolution = Some(RouterResolution::SevenTwentyP);
match unwrap_mode(b) {
FalVeo3p1Mode::TextToVideo(r) => {
assert!(matches!(r.resolution, Some(EnqueueVeo3p1TextToVideoResolution::SevenTwentyP)));
assert!(matches!(r.resolution, Some(Veo3p1TextToVideoResolution::SevenTwentyP)));
}
_ => panic!("expected t2v"),
}
}
#[test]
fn t2v_4k_maps_to_four_k() {
let mut b = base_builder();
b.resolution = Some(RouterResolution::FourK);
match unwrap_mode(b) {
FalVeo3p1Mode::TextToVideo(r) => {
assert!(matches!(r.resolution, Some(Veo3p1TextToVideoResolution::FourK)));
}
_ => panic!("expected t2v"),
}
}
}
mod aspect_ratio_conversions {
use super::*;
#[test]
fn t2v_auto_aspect_ratio_is_omitted() {
// t2v has no `auto` wire value; Auto must map to None.
let mut b = base_builder();
b.aspect_ratio = Some(RouterAspectRatio::Auto);
match unwrap_mode(b) {
FalVeo3p1Mode::TextToVideo(r) => assert!(r.aspect_ratio.is_none()),
_ => panic!("expected t2v"),
}
}
#[test]
fn i2v_auto_aspect_ratio_maps_to_auto() {
let mut b = base_builder();
b.aspect_ratio = Some(RouterAspectRatio::Auto);
b.start_frame = Some(ImageRef::Url(START_FRAME_URL.to_string()));
match unwrap_mode(b) {
FalVeo3p1Mode::ImageToVideo(r) => {
assert!(matches!(r.aspect_ratio, Some(Veo3p1ImageToVideoAspectRatio::Auto)));
}
_ => panic!("expected i2v"),
}
}
}
#[test]
fn full_combinatorial_pass() {
let resolutions = [None, Some(RouterResolution::SevenTwentyP), Some(RouterResolution::TenEightyP)];
let resolutions = [None, Some(RouterResolution::SevenTwentyP), Some(RouterResolution::TenEightyP), Some(RouterResolution::FourK)];
let durations = [None, Some(4u16), Some(6), Some(8)];
let aspect_ratios = [None, Some(RouterAspectRatio::Auto), Some(RouterAspectRatio::WideSixteenByNine), Some(RouterAspectRatio::TallNineBySixteen)];
let audios = [None, Some(true), Some(false)];
@@ -372,18 +821,31 @@ mod tests {
b.aspect_ratio = aspect_ratio;
b.generate_audio = generate_audio;
if frames >= 1 {
b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string()));
b.start_frame = Some(ImageRef::Url(START_FRAME_URL.to_string()));
}
if frames == 2 {
b.end_frame = Some(ImageRef::Url("https://example.com/b.png".to_string()));
b.end_frame = Some(ImageRef::Url(END_FRAME_URL.to_string()));
}
assert!(build_fal_veo_3p1(b).is_ok());
assert!(build_fal_veo_3p1_state(b).is_ok());
combos += 1;
}
}
}
}
}
assert_eq!(combos, 3 * 4 * 4 * 3 * 3);
assert_eq!(combos, 4 * 4 * 4 * 3 * 3);
}
fn base_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: RouterVideoModel::Veo3p1,
provider: RouterProvider::Fal,
prompt: Some("test".to_string()),
..Default::default()
}
}
fn unwrap_mode(b: GenerateVideoRequestBuilder) -> FalVeo3p1Mode {
build_fal_veo_3p1_state(b).expect("build").mode
}
}
@@ -1,30 +1,32 @@
use fal_client::requests_old::webhook::video::image::enqueue_veo_3p1_first_last_frame_image_to_video_webhook::EnqueueVeo3p1FirstLastFrameImageToVideoDurationSeconds;
use fal_client::requests_old::webhook::video::image::enqueue_veo_3p1_image_to_video_webhook::EnqueueVeo3p1ImageToVideoDurationSeconds;
use fal_client::requests_old::webhook::video::text::enqueue_veo_3p1_text_to_video_webhook::EnqueueVeo3p1TextToVideoDurationSeconds;
use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video::providers::fal::veo_3p1::request::{FalVeo3p1Mode, FalVeo3p1RequestState};
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
#[derive(Clone, Debug)]
pub struct FalVeo3p1CostState {
pub duration_seconds: u64,
pub generate_audio: bool,
pub cost_in_usd_cents: u64,
}
impl FalVeo3p1CostState {
pub fn from_request(request: &FalVeo3p1RequestState) -> Self {
let (duration_seconds, generate_audio) = extract_duration_and_audio(&request.mode);
Self { duration_seconds, generate_audio }
// Cost math is owned by fal_client's per-endpoint
// `FalRequestCostCalculator` implementations. The router state just
// forwards the result so router cost ≡ fal_client cost by construction.
let cost_in_usd_cents = match &request.mode {
FalVeo3p1Mode::TextToVideo(req) => req.calculate_cost_in_cents(),
FalVeo3p1Mode::ImageToVideo(req) => req.calculate_cost_in_cents(),
FalVeo3p1Mode::FirstLastFrameToVideo(req) => req.calculate_cost_in_cents(),
FalVeo3p1Mode::ReferenceToVideo(req) => req.calculate_cost_in_cents(),
FalVeo3p1Mode::ExtendVideo(req) => req.calculate_cost_in_cents(),
};
Self { cost_in_usd_cents }
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
// $0.20/sec audio off, $0.40/sec audio on (720p/1080p).
let per_second_cents: u64 = if self.generate_audio { 40 } else { 20 };
let cost_in_usd_cents = per_second_cents * self.duration_seconds;
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
cost_in_usd_cents: Some(cost_in_usd_cents),
cost_in_credits: Some(self.cost_in_usd_cents),
cost_in_usd_cents: Some(self.cost_in_usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
@@ -34,107 +36,204 @@ impl FalVeo3p1CostState {
}
}
pub(crate) fn extract_duration_and_audio(mode: &FalVeo3p1Mode) -> (u64, bool) {
match mode {
FalVeo3p1Mode::TextToVideo(req) => (t2v_duration_seconds(req.duration), req.generate_audio.unwrap_or(true)),
FalVeo3p1Mode::ImageToVideo(req) => (i2v_duration_seconds(req.duration), req.generate_audio.unwrap_or(true)),
FalVeo3p1Mode::FirstLastFrame(req) => (flf_duration_seconds(req.duration), req.generate_audio.unwrap_or(true)),
}
}
fn t2v_duration_seconds(d: Option<EnqueueVeo3p1TextToVideoDurationSeconds>) -> u64 {
match d {
Some(EnqueueVeo3p1TextToVideoDurationSeconds::Four) => 4,
Some(EnqueueVeo3p1TextToVideoDurationSeconds::Six) => 6,
Some(EnqueueVeo3p1TextToVideoDurationSeconds::Eight) | None => 8,
}
}
fn i2v_duration_seconds(d: Option<EnqueueVeo3p1ImageToVideoDurationSeconds>) -> u64 {
match d {
Some(EnqueueVeo3p1ImageToVideoDurationSeconds::Four) => 4,
Some(EnqueueVeo3p1ImageToVideoDurationSeconds::Six) => 6,
Some(EnqueueVeo3p1ImageToVideoDurationSeconds::Eight) | None => 8,
}
}
fn flf_duration_seconds(d: Option<EnqueueVeo3p1FirstLastFrameImageToVideoDurationSeconds>) -> u64 {
match d {
Some(EnqueueVeo3p1FirstLastFrameImageToVideoDurationSeconds::Four) => 4,
Some(EnqueueVeo3p1FirstLastFrameImageToVideoDurationSeconds::Six) => 6,
Some(EnqueueVeo3p1FirstLastFrameImageToVideoDurationSeconds::Eight) | None => 8,
}
}
#[cfg(test)]
mod tests {
use crate::api::router_video_model::RouterVideoModel;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::router_provider::RouterProvider;
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
use crate::api::video_list_ref::VideoListRef;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::providers::fal::veo_3p1::build::build_fal_veo_3p1_state;
use super::*;
// Veo 3.1 pricing:
// 720p / 1080p: $0.20/sec (audio off), $0.40/sec (audio on)
// 4k: $0.40/sec (audio off), $0.60/sec (audio on)
// Extend-video has no 4k tier and defaults to 7s.
#[derive(Clone, Copy)]
enum Modality {
TextToVideo,
ImageToVideo,
FirstLastFrame,
Reference,
Extend,
}
mod text_to_video {
use super::*;
#[test]
fn t2v_8s_audio_on_720p_is_320() {
assert_eq!(cost_cents(Modality::TextToVideo, Some(8), Some(RouterResolution::SevenTwentyP), Some(true)), 320);
}
#[test]
fn t2v_8s_audio_off_720p_is_160() {
assert_eq!(cost_cents(Modality::TextToVideo, Some(8), Some(RouterResolution::SevenTwentyP), Some(false)), 160);
}
#[test]
fn t2v_4s_audio_on_is_160() {
assert_eq!(cost_cents(Modality::TextToVideo, Some(4), None, Some(true)), 160);
}
#[test]
fn t2v_6s_audio_on_is_240() {
assert_eq!(cost_cents(Modality::TextToVideo, Some(6), None, Some(true)), 240);
}
#[test]
fn t2v_8s_audio_on_4k_is_480() {
assert_eq!(cost_cents(Modality::TextToVideo, Some(8), Some(RouterResolution::FourK), Some(true)), 480);
}
#[test]
fn t2v_8s_audio_off_4k_is_320() {
assert_eq!(cost_cents(Modality::TextToVideo, Some(8), Some(RouterResolution::FourK), Some(false)), 320);
}
#[test]
fn t2v_defaults_are_8s_720p_audio_on_320() {
// duration=None→8s, resolution=None→720p, audio=None→on.
assert_eq!(cost_cents(Modality::TextToVideo, None, None, None), 320);
}
#[test]
fn t2v_1080p_bills_same_as_720p() {
assert_eq!(
cost_cents(Modality::TextToVideo, Some(8), Some(RouterResolution::TenEightyP), Some(true)),
cost_cents(Modality::TextToVideo, Some(8), Some(RouterResolution::SevenTwentyP), Some(true)),
);
}
}
mod image_to_video {
use super::*;
#[test]
fn i2v_6s_audio_on_is_240() {
assert_eq!(cost_cents(Modality::ImageToVideo, Some(6), None, Some(true)), 240);
}
#[test]
fn i2v_8s_audio_off_1080p_is_160() {
assert_eq!(cost_cents(Modality::ImageToVideo, Some(8), Some(RouterResolution::TenEightyP), Some(false)), 160);
}
}
mod first_last_frame_to_video {
use super::*;
#[test]
fn flf_4s_audio_on_is_160() {
assert_eq!(cost_cents(Modality::FirstLastFrame, Some(4), None, Some(true)), 160);
}
#[test]
fn flf_8s_audio_on_4k_is_480() {
assert_eq!(cost_cents(Modality::FirstLastFrame, Some(8), Some(RouterResolution::FourK), Some(true)), 480);
}
}
mod reference_to_video {
use super::*;
#[test]
fn reference_8s_audio_on_1080p_is_320() {
assert_eq!(cost_cents(Modality::Reference, Some(8), Some(RouterResolution::TenEightyP), Some(true)), 320);
}
#[test]
fn reference_4s_audio_off_720p_is_80() {
assert_eq!(cost_cents(Modality::Reference, Some(4), Some(RouterResolution::SevenTwentyP), Some(false)), 80);
}
#[test]
fn reference_8s_audio_on_4k_is_480() {
assert_eq!(cost_cents(Modality::Reference, Some(8), Some(RouterResolution::FourK), Some(true)), 480);
}
}
mod extend_video {
use super::*;
#[test]
fn extend_7s_audio_on_is_280() {
assert_eq!(cost_cents(Modality::Extend, Some(7), None, Some(true)), 280);
}
#[test]
fn extend_7s_audio_off_is_140() {
assert_eq!(cost_cents(Modality::Extend, Some(7), None, Some(false)), 140);
}
#[test]
fn extend_8s_audio_on_is_320() {
assert_eq!(cost_cents(Modality::Extend, Some(8), None, Some(true)), 320);
}
#[test]
fn extend_defaults_are_7s_audio_on_280() {
// duration=None→7s (fal's extend default), audio=None→on.
assert_eq!(cost_cents(Modality::Extend, None, None, None), 280);
}
}
#[test]
fn t2v_i2v_flf_and_reference_price_identically() {
let expected = cost_cents(Modality::TextToVideo, Some(6), Some(RouterResolution::TenEightyP), Some(true));
for modality in [Modality::ImageToVideo, Modality::FirstLastFrame, Modality::Reference] {
assert_eq!(cost_cents(modality, Some(6), Some(RouterResolution::TenEightyP), Some(true)), expected);
}
}
#[test]
fn audio_costs_more_than_no_audio() {
assert!(
cost_cents(Modality::TextToVideo, Some(8), None, Some(false))
< cost_cents(Modality::TextToVideo, Some(8), None, Some(true))
);
}
fn cost_cents(
modality: Modality,
duration_seconds: Option<u16>,
resolution: Option<RouterResolution>,
generate_audio: Option<bool>,
frames: u8,
) -> u64 {
let mut b = GenerateVideoRequestBuilder {
model: RouterVideoModel::Veo3p1,
provider: RouterProvider::Fal,
prompt: Some("test".to_string()),
duration_seconds,
resolution,
generate_audio,
..Default::default()
};
if frames >= 1 {
b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string()));
match modality {
Modality::TextToVideo => {}
Modality::ImageToVideo => {
b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string()));
}
Modality::FirstLastFrame => {
b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string()));
b.end_frame = Some(ImageRef::Url("https://example.com/b.png".to_string()));
}
Modality::Reference => {
b.reference_images = Some(ImageListRef::Urls(vec!["https://example.com/ref.png".to_string()]));
}
Modality::Extend => {
b.reference_videos = Some(VideoListRef::Urls(vec!["https://example.com/in.mp4".to_string()]));
}
}
if frames == 2 {
b.end_frame = Some(ImageRef::Url("https://example.com/b.png".to_string()));
}
b.build2().expect("build2").estimate_cost().expect("estimate_cost").cost_in_usd_cents.expect("cost")
}
mod numeric_literal_pricing {
use super::*;
#[test]
fn t2v_audio_on_4s_is_160() { assert_eq!(cost_cents(Some(4), Some(true), 0), 160); }
#[test]
fn t2v_audio_on_6s_is_240() { assert_eq!(cost_cents(Some(6), Some(true), 0), 240); }
#[test]
fn t2v_audio_on_8s_is_320() { assert_eq!(cost_cents(Some(8), Some(true), 0), 320); }
#[test]
fn t2v_audio_off_4s_is_80() { assert_eq!(cost_cents(Some(4), Some(false), 0), 80); }
#[test]
fn t2v_audio_off_8s_is_160() { assert_eq!(cost_cents(Some(8), Some(false), 0), 160); }
#[test]
fn t2v_no_duration_defaults_to_8s() {
assert_eq!(cost_cents(None, Some(true), 0), 320);
}
#[test]
fn t2v_no_audio_defaults_to_audio_on() {
assert_eq!(cost_cents(Some(6), None, 0), 240);
}
}
#[test]
fn all_three_modes_price_identically() {
let t2v = cost_cents(Some(6), Some(true), 0);
let i2v = cost_cents(Some(6), Some(true), 1);
let flf = cost_cents(Some(6), Some(true), 2);
assert_eq!(t2v, i2v);
assert_eq!(i2v, flf);
}
#[test]
fn audio_costs_more_than_no_audio() {
assert!(cost_cents(Some(8), Some(false), 0) < cost_cents(Some(8), Some(true), 0));
let state = build_fal_veo_3p1_state(b).expect("build state");
FalVeo3p1CostState::from_request(&state)
.estimate_cost()
.cost_in_usd_cents
.expect("cost")
}
}
@@ -1,30 +1,26 @@
use std::fmt::Debug;
use std::sync::Arc;
use fal_client::requests_old::webhook::video::image::enqueue_veo_3p1_first_last_frame_image_to_video_webhook::{
enqueue_veo_3p1_first_last_frame_image_to_video_webhook, EnqueueVeo3p1FirstLastFrameImageToVideoArgs,
EnqueueVeo3p1FirstLastFrameImageToVideoRequest,
};
use fal_client::requests_old::webhook::video::image::enqueue_veo_3p1_image_to_video_webhook::{
enqueue_veo_3p1_image_to_video_webhook, EnqueueVeo3p1ImageToVideoArgs, EnqueueVeo3p1ImageToVideoRequest,
};
use fal_client::requests_old::webhook::video::text::enqueue_veo_3p1_text_to_video_webhook::{
enqueue_veo_3p1_text_to_video_webhook, EnqueueVeo3p1TextToVideoArgs, EnqueueVeo3p1TextToVideoRequest,
};
use fal_client::requests::api::video::extend::veo_3p1::api::Veo3p1ExtendVideoRequest;
use fal_client::requests::api::video::image::veo_3p1::api::Veo3p1ImageToVideoRequest;
use fal_client::requests::api::video::images::veo_3p1::api::Veo3p1FirstLastFrameToVideoRequest;
use fal_client::requests::api::video::reference::veo_3p1::api::Veo3p1ReferenceToVideoRequest;
use fal_client::requests::api::video::text::veo_3p1::api::Veo3p1TextToVideoRequest;
use fal_client::requests::traits::fal_endpoint_trait::FalEndpoint;
use crate::client::router_fal_client::RouterFalClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_video::generate_video_response::{
FalVideoResponsePayload, GenerateVideoResponse,
};
#[derive(Clone, Debug)]
pub enum FalVeo3p1Mode {
TextToVideo(EnqueueVeo3p1TextToVideoRequest),
ImageToVideo(EnqueueVeo3p1ImageToVideoRequest),
FirstLastFrame(EnqueueVeo3p1FirstLastFrameImageToVideoRequest),
TextToVideo(Veo3p1TextToVideoRequest),
ImageToVideo(Veo3p1ImageToVideoRequest),
FirstLastFrameToVideo(Veo3p1FirstLastFrameToVideoRequest),
ReferenceToVideo(Veo3p1ReferenceToVideoRequest),
ExtendVideo(Veo3p1ExtendVideoRequest),
}
#[derive(Clone, Debug)]
@@ -34,66 +30,81 @@ pub struct FalVeo3p1RequestState {
impl FalVeo3p1RequestState {
pub async fn send(&self, client: &RouterFalClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let webhook_url = client.webhook_url.as_deref()
.ok_or(ArtcraftRouterError::Client(ClientError::WebhookUrlRequired))?;
let (webhook_response, outbound_request): (_, Arc<dyn Debug + Send + Sync>) = match &self.mode {
FalVeo3p1Mode::TextToVideo(request) => {
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let args = EnqueueVeo3p1TextToVideoArgs {
request: request.clone(),
webhook_url,
api_key: &client.api_key,
};
(enqueue_veo_3p1_text_to_video_webhook(args).await, outbound)
}
FalVeo3p1Mode::ImageToVideo(request) => {
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let args = EnqueueVeo3p1ImageToVideoArgs {
request: request.clone(),
webhook_url,
api_key: &client.api_key,
};
(enqueue_veo_3p1_image_to_video_webhook(args).await, outbound)
}
FalVeo3p1Mode::FirstLastFrame(request) => {
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let args = EnqueueVeo3p1FirstLastFrameImageToVideoArgs {
request: request.clone(),
webhook_url,
api_key: &client.api_key,
};
(enqueue_veo_3p1_first_last_frame_image_to_video_webhook(args).await, outbound)
}
};
match &self.mode {
FalVeo3p1Mode::TextToVideo(request) => send_request(request, client).await,
FalVeo3p1Mode::ImageToVideo(request) => send_request(request, client).await,
FalVeo3p1Mode::FirstLastFrameToVideo(request) => send_request(request, client).await,
FalVeo3p1Mode::ReferenceToVideo(request) => send_request(request, client).await,
FalVeo3p1Mode::ExtendVideo(request) => send_request(request, client).await,
}
}
}
let webhook_response = webhook_response
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?;
// ── Helpers ──
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
request_id: webhook_response.request_id,
gateway_request_id: webhook_response.gateway_request_id,
maybe_status_url: None,
maybe_response_url: None,
maybe_outbound_request: Some(outbound_request),
}))
async fn send_request<T>(request: &T, client: &RouterFalClient) -> Result<GenerateVideoResponse, ArtcraftRouterError>
where
T: FalEndpoint + Clone + Debug + Send + Sync + 'static,
{
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let payload = send_fal_request(request, client).await?;
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
request_id: payload.request_id,
gateway_request_id: payload.gateway_request_id,
maybe_status_url: payload.status_url,
maybe_response_url: payload.response_url,
maybe_outbound_request: Some(outbound),
}))
}
struct FalResponseIds {
request_id: Option<String>,
gateway_request_id: Option<String>,
status_url: Option<String>,
response_url: Option<String>,
}
async fn send_fal_request<T: FalEndpoint>(
request: &T,
client: &RouterFalClient,
) -> Result<FalResponseIds, ArtcraftRouterError> {
if let Some(webhook_url) = &client.webhook_url {
let response = request.send_webhook_request(&client.api_key, webhook_url).await?;
Ok(FalResponseIds {
request_id: response.request_id,
gateway_request_id: response.gateway_request_id,
status_url: None,
response_url: None,
})
} else {
let response = request.send_queue_request(&client.api_key).await?;
Ok(FalResponseIds {
request_id: Some(response.request_id),
gateway_request_id: None,
status_url: Some(response.status_url),
response_url: Some(response.response_url),
})
}
}
#[cfg(test)]
mod tests {
use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL;
use test_data::web::video_urls::ANGRY_SHIBA_VIDEO_URL;
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::router_provider::RouterProvider;
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
use crate::api::video_list_ref::VideoListRef;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::test_helpers::get_fal_client;
#[tokio::test]
#[ignore]
#[ignore] // requires real API key, incurs cost
async fn live_text_to_video_720p_4s() {
let r = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("a quiet meadow with wildflowers".to_string()),
@@ -105,7 +116,7 @@ mod tests {
}
#[tokio::test]
#[ignore]
#[ignore] // requires real API key, incurs cost
async fn live_image_to_video_8s() {
let r = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("the dog leaps into the lake.".to_string()),
@@ -117,7 +128,7 @@ mod tests {
}
#[tokio::test]
#[ignore]
#[ignore] // requires real API key, incurs cost
async fn live_first_last_frame() {
let r = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("a smooth transition between scenes".to_string()),
@@ -129,6 +140,32 @@ mod tests {
assert!(matches!(r, GenerateVideoResponse::Fal(_)));
}
#[tokio::test]
#[ignore] // requires real API key, incurs cost
async fn live_reference_to_video_4s() {
let r = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("the dog runs across a sunlit meadow".to_string()),
reference_images: Some(ImageListRef::Urls(vec![JUNO_AT_LAKE_IMAGE_URL.to_string()])),
duration_seconds: Some(4),
generate_audio: Some(false),
..builder()
}).await;
assert!(matches!(r, GenerateVideoResponse::Fal(_)));
}
#[tokio::test]
#[ignore] // requires real API key, incurs cost
async fn live_extend_video_7s() {
let r = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("the scene continues naturally, keeping the same motion and style".to_string()),
reference_videos: Some(VideoListRef::Urls(vec![ANGRY_SHIBA_VIDEO_URL.to_string()])),
duration_seconds: Some(7),
generate_audio: Some(false),
..builder()
}).await;
assert!(matches!(r, GenerateVideoResponse::Fal(_)));
}
fn builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: RouterVideoModel::Veo3p1,
@@ -1,19 +1,29 @@
use fal_client::requests_old::webhook::video::image::enqueue_veo_3p1_fast_first_last_frame_image_to_video_webhook::{
EnqueueVeo3p1FastFirstLastFrameImageToVideoAspectRatio, EnqueueVeo3p1FastFirstLastFrameImageToVideoDurationSeconds,
EnqueueVeo3p1FastFirstLastFrameImageToVideoRequest, EnqueueVeo3p1FastFirstLastFrameImageToVideoResolution,
use fal_client::requests::api::video::extend::veo_3p1_fast::api::{
Veo3p1FastExtendVideoAspectRatio, Veo3p1FastExtendVideoDuration, Veo3p1FastExtendVideoRequest,
Veo3p1FastExtendVideoResolution,
};
use fal_client::requests_old::webhook::video::image::enqueue_veo_3p1_fast_image_to_video_webhook::{
EnqueueVeo3p1FastImageToVideoAspectRatio, EnqueueVeo3p1FastImageToVideoDurationSeconds,
EnqueueVeo3p1FastImageToVideoRequest, EnqueueVeo3p1FastImageToVideoResolution,
use fal_client::requests::api::video::image::veo_3p1_fast::api::{
Veo3p1FastImageToVideoAspectRatio, Veo3p1FastImageToVideoDuration, Veo3p1FastImageToVideoRequest,
Veo3p1FastImageToVideoResolution,
};
use fal_client::requests_old::webhook::video::text::enqueue_veo_3p1_fast_text_to_video_webhook::{
EnqueueVeo3p1FastTextToVideoAspectRatio, EnqueueVeo3p1FastTextToVideoDurationSeconds,
EnqueueVeo3p1FastTextToVideoRequest, EnqueueVeo3p1FastTextToVideoResolution,
use fal_client::requests::api::video::images::veo_3p1_fast::api::{
Veo3p1FastFirstLastFrameToVideoAspectRatio, Veo3p1FastFirstLastFrameToVideoDuration,
Veo3p1FastFirstLastFrameToVideoRequest, Veo3p1FastFirstLastFrameToVideoResolution,
};
use fal_client::requests::api::video::reference::veo_3p1_fast::api::{
Veo3p1FastReferenceToVideoAspectRatio, Veo3p1FastReferenceToVideoDuration,
Veo3p1FastReferenceToVideoRequest, Veo3p1FastReferenceToVideoResolution,
};
use fal_client::requests::api::video::text::veo_3p1_fast::api::{
Veo3p1FastTextToVideoAspectRatio, Veo3p1FastTextToVideoDuration, Veo3p1FastTextToVideoRequest,
Veo3p1FastTextToVideoResolution,
};
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_resolution::RouterResolution;
use crate::api::image_ref::ImageRef;
use crate::api::video_list_ref::VideoListRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
@@ -35,6 +45,7 @@ pub(crate) enum PlanAspectRatio {
pub(crate) enum PlanResolution {
SevenTwentyP,
TenEightyP,
FourK,
}
#[derive(Debug, Clone, Copy)]
@@ -47,60 +58,146 @@ pub(crate) enum PlanDuration {
pub fn build_fal_veo_3p1_fast(
builder: GenerateVideoRequestBuilder,
) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let state = build_fal_veo_3p1_fast_state(builder)?;
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalVeo3p1Fast(state)))
}
pub(crate) fn build_fal_veo_3p1_fast_state(
builder: GenerateVideoRequestBuilder,
) -> Result<FalVeo3p1FastRequestState, ArtcraftRouterError> {
let strategy = builder.request_mismatch_mitigation_strategy;
let start = optional_url(builder.start_frame.clone())?;
let end = optional_url(builder.end_frame.clone())?;
let reference_images = reference_image_urls(builder.reference_images.clone())?;
let reference_videos = reference_video_urls(builder.reference_videos.clone())?;
let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio, strategy)?;
let resolution = plan_resolution(builder.resolution, strategy)?;
let duration = plan_duration(builder.duration_seconds, strategy)?;
let prompt = builder.prompt.clone().unwrap_or_default();
let negative_prompt = builder.negative_prompt.clone();
let generate_audio = builder.generate_audio;
let mode = match (start, end) {
(None, None) => FalVeo3p1FastMode::TextToVideo(EnqueueVeo3p1FastTextToVideoRequest {
prompt,
duration: duration.map(to_t2v_duration),
aspect_ratio: aspect_ratio.map(to_t2v_aspect_ratio),
resolution: resolution.map(to_t2v_resolution),
generate_audio,
enhance_prompt: None,
negative_prompt,
seed: None,
auto_fix: None,
}),
(Some(image_url), None) => FalVeo3p1FastMode::ImageToVideo(EnqueueVeo3p1FastImageToVideoRequest {
prompt,
image_url,
duration: duration.map(to_i2v_duration),
aspect_ratio: aspect_ratio.map(to_i2v_aspect_ratio),
resolution: resolution.map(to_i2v_resolution),
generate_audio,
}),
(Some(first_frame_url), Some(last_frame_url)) => FalVeo3p1FastMode::FirstLastFrame(
EnqueueVeo3p1FastFirstLastFrameImageToVideoRequest {
prompt,
first_frame_url,
last_frame_url,
duration: duration.map(to_flf_duration),
aspect_ratio: aspect_ratio.map(to_flf_aspect_ratio),
resolution: resolution.map(to_flf_resolution),
generate_audio,
},
),
(None, Some(_)) => {
// Modality dispatch, most specific inputs first:
// 1. reference videos → extend-video
// 2. reference images → reference-to-video
// 3. start + end frames → first-last-frame-to-video
// 4. start frame only → image-to-video
// 5. no media → text-to-video
let mode = if let Some(video_urls) = reference_videos {
if reference_images.is_some() {
return Err(unsupported(
"reference_images",
"Veo 3.1 Fast extend-video cannot combine reference_images with a reference video",
));
}
if start.is_some() {
return Err(unsupported(
"start_frame",
"Veo 3.1 Fast extend-video cannot combine start_frame with a reference video",
));
}
if end.is_some() {
return Err(unsupported(
"end_frame",
"Veo 3.1 Fast requires a start_frame when end_frame is provided",
"Veo 3.1 Fast extend-video cannot combine end_frame with a reference video",
));
}
if video_urls.len() != 1 {
return Err(unsupported(
"reference_videos",
&format!("Veo 3.1 Fast extend-video requires exactly 1 reference video, got {}", video_urls.len()),
));
}
let video_url = video_urls.into_iter().next().expect("checked len == 1");
FalVeo3p1FastMode::ExtendVideo(Veo3p1FastExtendVideoRequest {
prompt,
video_url,
aspect_ratio: aspect_ratio.map(to_extend_aspect_ratio),
duration: plan_extend_duration(builder.duration_seconds, strategy)?,
resolution: resolution.map(|r| to_extend_resolution(r, strategy)).transpose()?,
negative_prompt,
generate_audio,
seed: None,
auto_fix: None,
safety_tolerance: None,
})
} else if let Some(image_urls) = reference_images {
if start.is_some() {
return Err(unsupported(
"start_frame",
"Veo 3.1 Fast reference-to-video cannot combine start_frame with reference_images",
));
}
if end.is_some() {
return Err(unsupported(
"end_frame",
"Veo 3.1 Fast reference-to-video cannot combine end_frame with reference_images",
));
}
// NB: reference-to-video has no negative_prompt or seed fields on fal's
// schema — those builder inputs are dropped silently.
FalVeo3p1FastMode::ReferenceToVideo(Veo3p1FastReferenceToVideoRequest {
prompt,
image_urls,
aspect_ratio: aspect_ratio.and_then(to_reference_aspect_ratio),
duration: plan_duration(builder.duration_seconds, strategy)?.map(to_reference_duration),
resolution: resolution.map(to_reference_resolution),
generate_audio,
auto_fix: None,
safety_tolerance: None,
})
} else {
let duration = plan_duration(builder.duration_seconds, strategy)?;
match (start, end) {
(None, None) => FalVeo3p1FastMode::TextToVideo(Veo3p1FastTextToVideoRequest {
prompt,
aspect_ratio: aspect_ratio.and_then(to_t2v_aspect_ratio),
duration: duration.map(to_t2v_duration),
resolution: resolution.map(to_t2v_resolution),
negative_prompt,
generate_audio,
seed: None,
auto_fix: None,
safety_tolerance: None,
}),
(Some(image_url), None) => FalVeo3p1FastMode::ImageToVideo(Veo3p1FastImageToVideoRequest {
prompt,
image_url,
aspect_ratio: aspect_ratio.map(to_i2v_aspect_ratio),
duration: duration.map(to_i2v_duration),
resolution: resolution.map(to_i2v_resolution),
generate_audio,
negative_prompt,
seed: None,
auto_fix: None,
safety_tolerance: None,
}),
(Some(first_frame_url), Some(last_frame_url)) => FalVeo3p1FastMode::FirstLastFrameToVideo(
Veo3p1FastFirstLastFrameToVideoRequest {
prompt,
first_frame_url,
last_frame_url,
aspect_ratio: aspect_ratio.map(to_flf_aspect_ratio),
duration: duration.map(to_flf_duration),
resolution: resolution.map(to_flf_resolution),
generate_audio,
negative_prompt,
seed: None,
auto_fix: None,
safety_tolerance: None,
},
),
(None, Some(_)) => {
return Err(unsupported(
"end_frame",
"Veo 3.1 Fast requires a start_frame when end_frame is provided",
));
}
}
};
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalVeo3p1Fast(
FalVeo3p1FastRequestState { mode },
)))
Ok(FalVeo3p1FastRequestState { mode })
}
fn optional_url(image_ref: Option<ImageRef>) -> Result<Option<String>, ArtcraftRouterError> {
@@ -113,6 +210,30 @@ fn optional_url(image_ref: Option<ImageRef>) -> Result<Option<String>, ArtcraftR
}
}
fn reference_image_urls(refs: Option<ImageListRef>) -> Result<Option<Vec<String>>, ArtcraftRouterError> {
match refs {
None => Ok(None),
Some(ImageListRef::Urls(urls)) if urls.is_empty() => Ok(None),
Some(ImageListRef::Urls(urls)) => Ok(Some(urls)),
Some(ImageListRef::MediaFileTokens(tokens)) if tokens.is_empty() => Ok(None),
Some(ImageListRef::MediaFileTokens(_)) => {
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
}
}
}
fn reference_video_urls(refs: Option<VideoListRef>) -> Result<Option<Vec<String>>, ArtcraftRouterError> {
match refs {
None => Ok(None),
Some(VideoListRef::Urls(urls)) if urls.is_empty() => Ok(None),
Some(VideoListRef::Urls(urls)) => Ok(Some(urls)),
Some(VideoListRef::MediaFileTokens(tokens)) if tokens.is_empty() => Ok(None),
Some(VideoListRef::MediaFileTokens(_)) => {
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
}
}
}
fn plan_aspect_ratio(
aspect_ratio: Option<RouterAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
@@ -146,6 +267,7 @@ fn plan_resolution(
None => Ok(None),
Some(RouterResolution::SevenTwentyP) => Ok(Some(R::SevenTwentyP)),
Some(RouterResolution::TenEightyP) => Ok(Some(R::TenEightyP)),
Some(RouterResolution::FourK) => Ok(Some(R::FourK)),
Some(other) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(unsupported("resolution", &format!("{:?}", other)))
@@ -169,8 +291,43 @@ fn plan_duration(
RequestMismatchMitigationStrategy::ErrorOut => {
Err(unsupported("duration_seconds", &format!("{}", other)))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Some(PlanDuration::Eight)),
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(PlanDuration::Four)),
// Nearest supported duration above/below, clamped to the 4s–8s range.
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Some(match other {
0..=4 => PlanDuration::Four,
5..=6 => PlanDuration::Six,
_ => PlanDuration::Eight,
})),
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(match other {
0..=5 => PlanDuration::Four,
6..=7 => PlanDuration::Six,
_ => PlanDuration::Eight,
})),
},
}
}
/// Extend-video also supports fal's 7s default in addition to 4/6/8.
fn plan_extend_duration(
duration_seconds: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<Veo3p1FastExtendVideoDuration>, ArtcraftRouterError> {
use Veo3p1FastExtendVideoDuration as D;
match duration_seconds {
None => Ok(None),
Some(4) => Ok(Some(D::FourSeconds)),
Some(6) => Ok(Some(D::SixSeconds)),
Some(7) => Ok(Some(D::SevenSeconds)),
Some(8) => Ok(Some(D::EightSeconds)),
Some(other) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(unsupported("duration_seconds", &format!("{}", other)))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => {
Ok(Some(if other < 5 { D::FourSeconds } else if other == 5 { D::SixSeconds } else { D::EightSeconds }))
}
RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(Some(if other <= 5 { D::FourSeconds } else { D::EightSeconds }))
}
},
}
}
@@ -182,129 +339,367 @@ fn unsupported(field: &'static str, value: &str) -> ArtcraftRouterError {
})
}
fn to_t2v_duration(d: PlanDuration) -> EnqueueVeo3p1FastTextToVideoDurationSeconds {
match d {
PlanDuration::Four => EnqueueVeo3p1FastTextToVideoDurationSeconds::Four,
PlanDuration::Six => EnqueueVeo3p1FastTextToVideoDurationSeconds::Six,
PlanDuration::Eight => EnqueueVeo3p1FastTextToVideoDurationSeconds::Eight,
}
}
fn to_t2v_aspect_ratio(a: PlanAspectRatio) -> EnqueueVeo3p1FastTextToVideoAspectRatio {
// The text-to-video endpoint has no `auto` aspect ratio; omit the field and
// let fal apply its own default (16:9).
fn to_t2v_aspect_ratio(a: PlanAspectRatio) -> Option<Veo3p1FastTextToVideoAspectRatio> {
match a {
PlanAspectRatio::Auto => EnqueueVeo3p1FastTextToVideoAspectRatio::Auto,
PlanAspectRatio::SixteenByNine => EnqueueVeo3p1FastTextToVideoAspectRatio::SixteenByNine,
PlanAspectRatio::NineBySixteen => EnqueueVeo3p1FastTextToVideoAspectRatio::NineBySixteen,
PlanAspectRatio::Auto => None,
PlanAspectRatio::SixteenByNine => Some(Veo3p1FastTextToVideoAspectRatio::SixteenByNine),
PlanAspectRatio::NineBySixteen => Some(Veo3p1FastTextToVideoAspectRatio::NineBySixteen),
}
}
fn to_t2v_resolution(r: PlanResolution) -> EnqueueVeo3p1FastTextToVideoResolution {
match r {
PlanResolution::SevenTwentyP => EnqueueVeo3p1FastTextToVideoResolution::SevenTwentyP,
PlanResolution::TenEightyP => EnqueueVeo3p1FastTextToVideoResolution::TenEightyP,
}
}
fn to_i2v_duration(d: PlanDuration) -> EnqueueVeo3p1FastImageToVideoDurationSeconds {
fn to_t2v_duration(d: PlanDuration) -> Veo3p1FastTextToVideoDuration {
match d {
PlanDuration::Four => EnqueueVeo3p1FastImageToVideoDurationSeconds::Four,
PlanDuration::Six => EnqueueVeo3p1FastImageToVideoDurationSeconds::Six,
PlanDuration::Eight => EnqueueVeo3p1FastImageToVideoDurationSeconds::Eight,
PlanDuration::Four => Veo3p1FastTextToVideoDuration::FourSeconds,
PlanDuration::Six => Veo3p1FastTextToVideoDuration::SixSeconds,
PlanDuration::Eight => Veo3p1FastTextToVideoDuration::EightSeconds,
}
}
fn to_i2v_aspect_ratio(a: PlanAspectRatio) -> EnqueueVeo3p1FastImageToVideoAspectRatio {
match a {
PlanAspectRatio::Auto => EnqueueVeo3p1FastImageToVideoAspectRatio::Auto,
PlanAspectRatio::SixteenByNine => EnqueueVeo3p1FastImageToVideoAspectRatio::SixteenByNine,
PlanAspectRatio::NineBySixteen => EnqueueVeo3p1FastImageToVideoAspectRatio::NineBySixteen,
}
}
fn to_i2v_resolution(r: PlanResolution) -> EnqueueVeo3p1FastImageToVideoResolution {
fn to_t2v_resolution(r: PlanResolution) -> Veo3p1FastTextToVideoResolution {
match r {
PlanResolution::SevenTwentyP => EnqueueVeo3p1FastImageToVideoResolution::SevenTwentyP,
PlanResolution::TenEightyP => EnqueueVeo3p1FastImageToVideoResolution::TenEightyP,
PlanResolution::SevenTwentyP => Veo3p1FastTextToVideoResolution::SevenTwentyP,
PlanResolution::TenEightyP => Veo3p1FastTextToVideoResolution::TenEightyP,
PlanResolution::FourK => Veo3p1FastTextToVideoResolution::FourK,
}
}
fn to_flf_duration(d: PlanDuration) -> EnqueueVeo3p1FastFirstLastFrameImageToVideoDurationSeconds {
fn to_i2v_aspect_ratio(a: PlanAspectRatio) -> Veo3p1FastImageToVideoAspectRatio {
match a {
PlanAspectRatio::Auto => Veo3p1FastImageToVideoAspectRatio::Auto,
PlanAspectRatio::SixteenByNine => Veo3p1FastImageToVideoAspectRatio::SixteenByNine,
PlanAspectRatio::NineBySixteen => Veo3p1FastImageToVideoAspectRatio::NineBySixteen,
}
}
fn to_i2v_duration(d: PlanDuration) -> Veo3p1FastImageToVideoDuration {
match d {
PlanDuration::Four => EnqueueVeo3p1FastFirstLastFrameImageToVideoDurationSeconds::Four,
PlanDuration::Six => EnqueueVeo3p1FastFirstLastFrameImageToVideoDurationSeconds::Six,
PlanDuration::Eight => EnqueueVeo3p1FastFirstLastFrameImageToVideoDurationSeconds::Eight,
PlanDuration::Four => Veo3p1FastImageToVideoDuration::FourSeconds,
PlanDuration::Six => Veo3p1FastImageToVideoDuration::SixSeconds,
PlanDuration::Eight => Veo3p1FastImageToVideoDuration::EightSeconds,
}
}
fn to_flf_aspect_ratio(a: PlanAspectRatio) -> EnqueueVeo3p1FastFirstLastFrameImageToVideoAspectRatio {
match a {
PlanAspectRatio::Auto => EnqueueVeo3p1FastFirstLastFrameImageToVideoAspectRatio::Auto,
PlanAspectRatio::SixteenByNine => EnqueueVeo3p1FastFirstLastFrameImageToVideoAspectRatio::SixteenByNine,
PlanAspectRatio::NineBySixteen => EnqueueVeo3p1FastFirstLastFrameImageToVideoAspectRatio::NineBySixteen,
}
}
fn to_flf_resolution(r: PlanResolution) -> EnqueueVeo3p1FastFirstLastFrameImageToVideoResolution {
fn to_i2v_resolution(r: PlanResolution) -> Veo3p1FastImageToVideoResolution {
match r {
PlanResolution::SevenTwentyP => EnqueueVeo3p1FastFirstLastFrameImageToVideoResolution::SevenTwentyP,
PlanResolution::TenEightyP => EnqueueVeo3p1FastFirstLastFrameImageToVideoResolution::TenEightyP,
PlanResolution::SevenTwentyP => Veo3p1FastImageToVideoResolution::SevenTwentyP,
PlanResolution::TenEightyP => Veo3p1FastImageToVideoResolution::TenEightyP,
PlanResolution::FourK => Veo3p1FastImageToVideoResolution::FourK,
}
}
fn to_flf_aspect_ratio(a: PlanAspectRatio) -> Veo3p1FastFirstLastFrameToVideoAspectRatio {
match a {
PlanAspectRatio::Auto => Veo3p1FastFirstLastFrameToVideoAspectRatio::Auto,
PlanAspectRatio::SixteenByNine => Veo3p1FastFirstLastFrameToVideoAspectRatio::SixteenByNine,
PlanAspectRatio::NineBySixteen => Veo3p1FastFirstLastFrameToVideoAspectRatio::NineBySixteen,
}
}
fn to_flf_duration(d: PlanDuration) -> Veo3p1FastFirstLastFrameToVideoDuration {
match d {
PlanDuration::Four => Veo3p1FastFirstLastFrameToVideoDuration::FourSeconds,
PlanDuration::Six => Veo3p1FastFirstLastFrameToVideoDuration::SixSeconds,
PlanDuration::Eight => Veo3p1FastFirstLastFrameToVideoDuration::EightSeconds,
}
}
fn to_flf_resolution(r: PlanResolution) -> Veo3p1FastFirstLastFrameToVideoResolution {
match r {
PlanResolution::SevenTwentyP => Veo3p1FastFirstLastFrameToVideoResolution::SevenTwentyP,
PlanResolution::TenEightyP => Veo3p1FastFirstLastFrameToVideoResolution::TenEightyP,
PlanResolution::FourK => Veo3p1FastFirstLastFrameToVideoResolution::FourK,
}
}
// The reference-to-video endpoint has no `auto` aspect ratio; omit the field
// and let fal apply its own default (16:9).
fn to_reference_aspect_ratio(a: PlanAspectRatio) -> Option<Veo3p1FastReferenceToVideoAspectRatio> {
match a {
PlanAspectRatio::Auto => None,
PlanAspectRatio::SixteenByNine => Some(Veo3p1FastReferenceToVideoAspectRatio::SixteenByNine),
PlanAspectRatio::NineBySixteen => Some(Veo3p1FastReferenceToVideoAspectRatio::NineBySixteen),
}
}
fn to_reference_duration(d: PlanDuration) -> Veo3p1FastReferenceToVideoDuration {
match d {
PlanDuration::Four => Veo3p1FastReferenceToVideoDuration::FourSeconds,
PlanDuration::Six => Veo3p1FastReferenceToVideoDuration::SixSeconds,
PlanDuration::Eight => Veo3p1FastReferenceToVideoDuration::EightSeconds,
}
}
fn to_reference_resolution(r: PlanResolution) -> Veo3p1FastReferenceToVideoResolution {
match r {
PlanResolution::SevenTwentyP => Veo3p1FastReferenceToVideoResolution::SevenTwentyP,
PlanResolution::TenEightyP => Veo3p1FastReferenceToVideoResolution::TenEightyP,
PlanResolution::FourK => Veo3p1FastReferenceToVideoResolution::FourK,
}
}
fn to_extend_aspect_ratio(a: PlanAspectRatio) -> Veo3p1FastExtendVideoAspectRatio {
match a {
PlanAspectRatio::Auto => Veo3p1FastExtendVideoAspectRatio::Auto,
PlanAspectRatio::SixteenByNine => Veo3p1FastExtendVideoAspectRatio::SixteenByNine,
PlanAspectRatio::NineBySixteen => Veo3p1FastExtendVideoAspectRatio::NineBySixteen,
}
}
// Extend-video has no 4k tier; the nearest supported resolution in either
// direction is 1080p.
fn to_extend_resolution(
r: PlanResolution,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Veo3p1FastExtendVideoResolution, ArtcraftRouterError> {
match r {
PlanResolution::SevenTwentyP => Ok(Veo3p1FastExtendVideoResolution::SevenTwentyP),
PlanResolution::TenEightyP => Ok(Veo3p1FastExtendVideoResolution::TenEightyP),
PlanResolution::FourK => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(unsupported("resolution", "Veo 3.1 Fast extend-video does not support 4k"))
}
_ => Ok(Veo3p1FastExtendVideoResolution::TenEightyP),
},
}
}
#[cfg(test)]
mod tests {
use crate::api::router_video_model::RouterVideoModel;
use crate::api::router_provider::RouterProvider;
use crate::api::router_video_model::RouterVideoModel;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
use super::*;
fn base_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: RouterVideoModel::Veo3p1Fast,
provider: RouterProvider::Fal,
prompt: Some("test".to_string()),
..Default::default()
const START_FRAME_URL: &str = "https://example.com/a.png";
const END_FRAME_URL: &str = "https://example.com/b.png";
const REFERENCE_IMAGE_URL: &str = "https://example.com/ref.png";
const REFERENCE_VIDEO_URL: &str = "https://example.com/in.mp4";
mod mode_selection {
use super::*;
#[test]
fn no_frames_picks_t2v() {
assert!(matches!(unwrap_mode(base_builder()), FalVeo3p1FastMode::TextToVideo(_)));
}
#[test]
fn start_only_picks_i2v() {
let mut b = base_builder();
b.start_frame = Some(ImageRef::Url(START_FRAME_URL.to_string()));
assert!(matches!(unwrap_mode(b), FalVeo3p1FastMode::ImageToVideo(_)));
}
#[test]
fn start_and_end_picks_flf() {
let mut b = base_builder();
b.start_frame = Some(ImageRef::Url(START_FRAME_URL.to_string()));
b.end_frame = Some(ImageRef::Url(END_FRAME_URL.to_string()));
assert!(matches!(unwrap_mode(b), FalVeo3p1FastMode::FirstLastFrameToVideo(_)));
}
#[test]
fn end_only_errors() {
let mut b = base_builder();
b.end_frame = Some(ImageRef::Url(END_FRAME_URL.to_string()));
assert!(build_fal_veo_3p1_fast_state(b).is_err());
}
#[test]
fn reference_images_pick_reference_to_video() {
let mut b = base_builder();
b.reference_images = Some(ImageListRef::Urls(vec![REFERENCE_IMAGE_URL.to_string()]));
assert!(matches!(unwrap_mode(b), FalVeo3p1FastMode::ReferenceToVideo(_)));
}
#[test]
fn reference_videos_pick_extend_video() {
let mut b = base_builder();
b.reference_videos = Some(VideoListRef::Urls(vec![REFERENCE_VIDEO_URL.to_string()]));
assert!(matches!(unwrap_mode(b), FalVeo3p1FastMode::ExtendVideo(_)));
}
#[test]
fn public_build_fn_wraps_request_enum() {
match build_fal_veo_3p1_fast(base_builder()).expect("build") {
VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalVeo3p1Fast(s)) => {
assert!(matches!(s.mode, FalVeo3p1FastMode::TextToVideo(_)));
}
_ => panic!("expected Request(FalVeo3p1Fast)"),
}
}
}
fn unwrap_mode(b: GenerateVideoRequestBuilder) -> FalVeo3p1FastMode {
match build_fal_veo_3p1_fast(b).expect("build") {
VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalVeo3p1Fast(s)) => s.mode,
_ => panic!("expected FalVeo3p1Fast"),
mod extend_video {
use super::*;
#[test]
fn extend_maps_video_url_and_7s_duration() {
let mut b = base_builder();
b.reference_videos = Some(VideoListRef::Urls(vec![REFERENCE_VIDEO_URL.to_string()]));
b.duration_seconds = Some(7);
match unwrap_mode(b) {
FalVeo3p1FastMode::ExtendVideo(r) => {
assert_eq!(r.video_url, REFERENCE_VIDEO_URL);
assert!(matches!(r.duration, Some(Veo3p1FastExtendVideoDuration::SevenSeconds)));
}
_ => panic!("expected extend"),
}
}
#[test]
fn extend_requires_exactly_one_video() {
let mut b = base_builder();
b.reference_videos = Some(VideoListRef::Urls(vec![
REFERENCE_VIDEO_URL.to_string(),
"https://example.com/other.mp4".to_string(),
]));
assert!(build_fal_veo_3p1_fast_state(b).is_err());
}
#[test]
fn extend_rejects_start_frame() {
let mut b = base_builder();
b.reference_videos = Some(VideoListRef::Urls(vec![REFERENCE_VIDEO_URL.to_string()]));
b.start_frame = Some(ImageRef::Url(START_FRAME_URL.to_string()));
assert!(build_fal_veo_3p1_fast_state(b).is_err());
}
#[test]
fn extend_rejects_end_frame() {
let mut b = base_builder();
b.reference_videos = Some(VideoListRef::Urls(vec![REFERENCE_VIDEO_URL.to_string()]));
b.end_frame = Some(ImageRef::Url(END_FRAME_URL.to_string()));
assert!(build_fal_veo_3p1_fast_state(b).is_err());
}
#[test]
fn extend_rejects_reference_images() {
let mut b = base_builder();
b.reference_videos = Some(VideoListRef::Urls(vec![REFERENCE_VIDEO_URL.to_string()]));
b.reference_images = Some(ImageListRef::Urls(vec![REFERENCE_IMAGE_URL.to_string()]));
assert!(build_fal_veo_3p1_fast_state(b).is_err());
}
#[test]
fn extend_4k_errors_with_error_out() {
let mut b = base_builder();
b.reference_videos = Some(VideoListRef::Urls(vec![REFERENCE_VIDEO_URL.to_string()]));
b.resolution = Some(RouterResolution::FourK);
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut;
assert!(build_fal_veo_3p1_fast_state(b).is_err());
}
#[test]
fn extend_4k_maps_to_1080p_with_mitigation() {
for strategy in [
RequestMismatchMitigationStrategy::PayMoreUpgrade,
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let mut b = base_builder();
b.reference_videos = Some(VideoListRef::Urls(vec![REFERENCE_VIDEO_URL.to_string()]));
b.resolution = Some(RouterResolution::FourK);
b.request_mismatch_mitigation_strategy = strategy;
match unwrap_mode(b) {
FalVeo3p1FastMode::ExtendVideo(r) => {
assert!(matches!(r.resolution, Some(Veo3p1FastExtendVideoResolution::TenEightyP)));
}
_ => panic!("expected extend"),
}
}
}
}
#[test]
fn no_frames_picks_t2v() {
assert!(matches!(unwrap_mode(base_builder()), FalVeo3p1FastMode::TextToVideo(_)));
mod reference_to_video {
use super::*;
#[test]
fn reference_maps_image_urls() {
let mut b = base_builder();
b.reference_images = Some(ImageListRef::Urls(vec![REFERENCE_IMAGE_URL.to_string()]));
match unwrap_mode(b) {
FalVeo3p1FastMode::ReferenceToVideo(r) => {
assert_eq!(r.image_urls, vec![REFERENCE_IMAGE_URL]);
}
_ => panic!("expected reference"),
}
}
#[test]
fn reference_rejects_start_frame() {
let mut b = base_builder();
b.reference_images = Some(ImageListRef::Urls(vec![REFERENCE_IMAGE_URL.to_string()]));
b.start_frame = Some(ImageRef::Url(START_FRAME_URL.to_string()));
assert!(build_fal_veo_3p1_fast_state(b).is_err());
}
#[test]
fn reference_rejects_end_frame() {
let mut b = base_builder();
b.reference_images = Some(ImageListRef::Urls(vec![REFERENCE_IMAGE_URL.to_string()]));
b.end_frame = Some(ImageRef::Url(END_FRAME_URL.to_string()));
assert!(build_fal_veo_3p1_fast_state(b).is_err());
}
}
#[test]
fn start_only_picks_i2v() {
let mut b = base_builder();
b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string()));
assert!(matches!(unwrap_mode(b), FalVeo3p1FastMode::ImageToVideo(_)));
mod duration_conversions {
use super::*;
#[test]
fn duration_4s() {
let mut b = base_builder();
b.duration_seconds = Some(4);
match unwrap_mode(b) {
FalVeo3p1FastMode::TextToVideo(r) => {
assert!(matches!(r.duration, Some(Veo3p1FastTextToVideoDuration::FourSeconds)));
}
_ => panic!("expected t2v"),
}
}
#[test]
fn unsupported_duration_errors_with_error_out() {
let mut b = base_builder();
b.duration_seconds = Some(5);
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut;
assert!(build_fal_veo_3p1_fast_state(b).is_err());
}
}
#[test]
fn start_and_end_picks_flf() {
let mut b = base_builder();
b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string()));
b.end_frame = Some(ImageRef::Url("https://example.com/b.png".to_string()));
assert!(matches!(unwrap_mode(b), FalVeo3p1FastMode::FirstLastFrame(_)));
}
mod resolution_conversions {
use super::*;
#[test]
fn end_only_errors() {
let mut b = base_builder();
b.end_frame = Some(ImageRef::Url("https://example.com/b.png".to_string()));
assert!(build_fal_veo_3p1_fast(b).is_err());
#[test]
fn t2v_720p() {
let mut b = base_builder();
b.resolution = Some(RouterResolution::SevenTwentyP);
match unwrap_mode(b) {
FalVeo3p1FastMode::TextToVideo(r) => {
assert!(matches!(r.resolution, Some(Veo3p1FastTextToVideoResolution::SevenTwentyP)));
}
_ => panic!("expected t2v"),
}
}
#[test]
fn t2v_4k_maps_to_four_k() {
let mut b = base_builder();
b.resolution = Some(RouterResolution::FourK);
match unwrap_mode(b) {
FalVeo3p1FastMode::TextToVideo(r) => {
assert!(matches!(r.resolution, Some(Veo3p1FastTextToVideoResolution::FourK)));
}
_ => panic!("expected t2v"),
}
}
}
#[test]
fn full_combinatorial_pass() {
let resolutions = [None, Some(RouterResolution::SevenTwentyP), Some(RouterResolution::TenEightyP)];
let resolutions = [None, Some(RouterResolution::SevenTwentyP), Some(RouterResolution::TenEightyP), Some(RouterResolution::FourK)];
let durations = [None, Some(4u16), Some(6), Some(8)];
let aspect_ratios = [None, Some(RouterAspectRatio::Auto), Some(RouterAspectRatio::WideSixteenByNine), Some(RouterAspectRatio::TallNineBySixteen)];
let audios = [None, Some(true), Some(false)];
@@ -321,18 +716,31 @@ mod tests {
b.aspect_ratio = aspect_ratio;
b.generate_audio = generate_audio;
if frames >= 1 {
b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string()));
b.start_frame = Some(ImageRef::Url(START_FRAME_URL.to_string()));
}
if frames == 2 {
b.end_frame = Some(ImageRef::Url("https://example.com/b.png".to_string()));
b.end_frame = Some(ImageRef::Url(END_FRAME_URL.to_string()));
}
assert!(build_fal_veo_3p1_fast(b).is_ok());
assert!(build_fal_veo_3p1_fast_state(b).is_ok());
combos += 1;
}
}
}
}
}
assert_eq!(combos, 3 * 4 * 4 * 3 * 3);
assert_eq!(combos, 4 * 4 * 4 * 3 * 3);
}
fn base_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: RouterVideoModel::Veo3p1Fast,
provider: RouterProvider::Fal,
prompt: Some("test".to_string()),
..Default::default()
}
}
fn unwrap_mode(b: GenerateVideoRequestBuilder) -> FalVeo3p1FastMode {
build_fal_veo_3p1_fast_state(b).expect("build").mode
}
}
@@ -1,36 +1,34 @@
use fal_client::requests_old::webhook::video::image::enqueue_veo_3p1_fast_first_last_frame_image_to_video_webhook::EnqueueVeo3p1FastFirstLastFrameImageToVideoDurationSeconds;
use fal_client::requests_old::webhook::video::image::enqueue_veo_3p1_fast_image_to_video_webhook::EnqueueVeo3p1FastImageToVideoDurationSeconds;
use fal_client::requests_old::webhook::video::text::enqueue_veo_3p1_fast_text_to_video_webhook::EnqueueVeo3p1FastTextToVideoDurationSeconds;
use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video::providers::fal::veo_3p1_fast::request::{
FalVeo3p1FastMode, FalVeo3p1FastRequestState,
};
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
#[derive(Clone, Debug)]
pub struct FalVeo3p1FastCostState {
pub duration_seconds: u64,
pub generate_audio: bool,
pub cost_in_usd_cents: u64,
}
impl FalVeo3p1FastCostState {
pub fn from_request(request: &FalVeo3p1FastRequestState) -> Self {
let (duration_seconds, generate_audio) = match &request.mode {
FalVeo3p1FastMode::TextToVideo(req) => (t2v_duration_seconds(req.duration), req.generate_audio.unwrap_or(true)),
FalVeo3p1FastMode::ImageToVideo(req) => (i2v_duration_seconds(req.duration), req.generate_audio.unwrap_or(true)),
FalVeo3p1FastMode::FirstLastFrame(req) => (flf_duration_seconds(req.duration), req.generate_audio.unwrap_or(true)),
// Cost math is owned by fal_client's per-endpoint
// `FalRequestCostCalculator` implementations. The router state just
// forwards the result so router cost ≡ fal_client cost by construction.
let cost_in_usd_cents = match &request.mode {
FalVeo3p1FastMode::TextToVideo(req) => req.calculate_cost_in_cents(),
FalVeo3p1FastMode::ImageToVideo(req) => req.calculate_cost_in_cents(),
FalVeo3p1FastMode::FirstLastFrameToVideo(req) => req.calculate_cost_in_cents(),
FalVeo3p1FastMode::ReferenceToVideo(req) => req.calculate_cost_in_cents(),
FalVeo3p1FastMode::ExtendVideo(req) => req.calculate_cost_in_cents(),
};
Self { duration_seconds, generate_audio }
Self { cost_in_usd_cents }
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
// $0.10/sec audio off, $0.15/sec audio on (720p/1080p).
let per_second_cents: u64 = if self.generate_audio { 15 } else { 10 };
let cost_in_usd_cents = per_second_cents * self.duration_seconds;
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
cost_in_usd_cents: Some(cost_in_usd_cents),
cost_in_credits: Some(self.cost_in_usd_cents),
cost_in_usd_cents: Some(self.cost_in_usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
@@ -40,90 +38,204 @@ impl FalVeo3p1FastCostState {
}
}
fn t2v_duration_seconds(d: Option<EnqueueVeo3p1FastTextToVideoDurationSeconds>) -> u64 {
match d {
Some(EnqueueVeo3p1FastTextToVideoDurationSeconds::Four) => 4,
Some(EnqueueVeo3p1FastTextToVideoDurationSeconds::Six) => 6,
Some(EnqueueVeo3p1FastTextToVideoDurationSeconds::Eight) | None => 8,
}
}
fn i2v_duration_seconds(d: Option<EnqueueVeo3p1FastImageToVideoDurationSeconds>) -> u64 {
match d {
Some(EnqueueVeo3p1FastImageToVideoDurationSeconds::Four) => 4,
Some(EnqueueVeo3p1FastImageToVideoDurationSeconds::Six) => 6,
Some(EnqueueVeo3p1FastImageToVideoDurationSeconds::Eight) | None => 8,
}
}
fn flf_duration_seconds(d: Option<EnqueueVeo3p1FastFirstLastFrameImageToVideoDurationSeconds>) -> u64 {
match d {
Some(EnqueueVeo3p1FastFirstLastFrameImageToVideoDurationSeconds::Four) => 4,
Some(EnqueueVeo3p1FastFirstLastFrameImageToVideoDurationSeconds::Six) => 6,
Some(EnqueueVeo3p1FastFirstLastFrameImageToVideoDurationSeconds::Eight) | None => 8,
}
}
#[cfg(test)]
mod tests {
use crate::api::router_video_model::RouterVideoModel;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::router_provider::RouterProvider;
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
use crate::api::video_list_ref::VideoListRef;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::providers::fal::veo_3p1_fast::build::build_fal_veo_3p1_fast_state;
fn cost_cents(duration_seconds: Option<u16>, generate_audio: Option<bool>, frames: u8) -> u64 {
use super::*;
// Veo 3.1 Fast pricing:
// 720p / 1080p: $0.10/sec (audio off), $0.15/sec (audio on)
// 4k: $0.30/sec (audio off), $0.35/sec (audio on)
// Extend-video has no 4k tier and defaults to 7s.
#[derive(Clone, Copy)]
enum Modality {
TextToVideo,
ImageToVideo,
FirstLastFrame,
Reference,
Extend,
}
mod text_to_video {
use super::*;
#[test]
fn t2v_4s_audio_on_is_60() {
assert_eq!(cost_cents(Modality::TextToVideo, Some(4), None, Some(true)), 60);
}
#[test]
fn t2v_6s_audio_on_is_90() {
assert_eq!(cost_cents(Modality::TextToVideo, Some(6), None, Some(true)), 90);
}
#[test]
fn t2v_8s_audio_on_is_120() {
assert_eq!(cost_cents(Modality::TextToVideo, Some(8), None, Some(true)), 120);
}
#[test]
fn t2v_4s_audio_off_is_40() {
assert_eq!(cost_cents(Modality::TextToVideo, Some(4), None, Some(false)), 40);
}
#[test]
fn t2v_8s_audio_off_is_80() {
assert_eq!(cost_cents(Modality::TextToVideo, Some(8), None, Some(false)), 80);
}
#[test]
fn t2v_8s_audio_on_4k_is_280() {
assert_eq!(cost_cents(Modality::TextToVideo, Some(8), Some(RouterResolution::FourK), Some(true)), 280);
}
#[test]
fn t2v_8s_audio_off_4k_is_240() {
assert_eq!(cost_cents(Modality::TextToVideo, Some(8), Some(RouterResolution::FourK), Some(false)), 240);
}
#[test]
fn t2v_defaults_are_8s_720p_audio_on_120() {
// duration=None→8s, resolution=None→720p, audio=None→on.
assert_eq!(cost_cents(Modality::TextToVideo, None, None, None), 120);
}
#[test]
fn t2v_1080p_bills_same_as_720p() {
assert_eq!(
cost_cents(Modality::TextToVideo, Some(8), Some(RouterResolution::TenEightyP), Some(true)),
cost_cents(Modality::TextToVideo, Some(8), Some(RouterResolution::SevenTwentyP), Some(true)),
);
}
}
mod image_to_video {
use super::*;
#[test]
fn i2v_6s_audio_on_is_90() {
assert_eq!(cost_cents(Modality::ImageToVideo, Some(6), None, Some(true)), 90);
}
#[test]
fn i2v_8s_audio_off_1080p_is_80() {
assert_eq!(cost_cents(Modality::ImageToVideo, Some(8), Some(RouterResolution::TenEightyP), Some(false)), 80);
}
}
mod first_last_frame_to_video {
use super::*;
#[test]
fn flf_6s_audio_on_is_90() {
assert_eq!(cost_cents(Modality::FirstLastFrame, Some(6), None, Some(true)), 90);
}
#[test]
fn flf_8s_audio_on_4k_is_280() {
assert_eq!(cost_cents(Modality::FirstLastFrame, Some(8), Some(RouterResolution::FourK), Some(true)), 280);
}
}
mod reference_to_video {
use super::*;
#[test]
fn reference_8s_audio_on_1080p_is_120() {
assert_eq!(cost_cents(Modality::Reference, Some(8), Some(RouterResolution::TenEightyP), Some(true)), 120);
}
#[test]
fn reference_4s_audio_off_720p_is_40() {
assert_eq!(cost_cents(Modality::Reference, Some(4), Some(RouterResolution::SevenTwentyP), Some(false)), 40);
}
}
mod extend_video {
use super::*;
#[test]
fn extend_7s_audio_on_is_105() {
assert_eq!(cost_cents(Modality::Extend, Some(7), None, Some(true)), 105);
}
#[test]
fn extend_7s_audio_off_is_70() {
assert_eq!(cost_cents(Modality::Extend, Some(7), None, Some(false)), 70);
}
#[test]
fn extend_8s_audio_on_is_120() {
assert_eq!(cost_cents(Modality::Extend, Some(8), None, Some(true)), 120);
}
#[test]
fn extend_defaults_are_7s_audio_on_105() {
// duration=None→7s (fal's extend default), audio=None→on.
assert_eq!(cost_cents(Modality::Extend, None, None, None), 105);
}
}
#[test]
fn t2v_i2v_flf_and_reference_price_identically() {
let expected = cost_cents(Modality::TextToVideo, Some(6), Some(RouterResolution::TenEightyP), Some(true));
for modality in [Modality::ImageToVideo, Modality::FirstLastFrame, Modality::Reference] {
assert_eq!(cost_cents(modality, Some(6), Some(RouterResolution::TenEightyP), Some(true)), expected);
}
}
#[test]
fn audio_costs_more_than_no_audio() {
assert!(
cost_cents(Modality::TextToVideo, Some(8), None, Some(false))
< cost_cents(Modality::TextToVideo, Some(8), None, Some(true))
);
}
fn cost_cents(
modality: Modality,
duration_seconds: Option<u16>,
resolution: Option<RouterResolution>,
generate_audio: Option<bool>,
) -> u64 {
let mut b = GenerateVideoRequestBuilder {
model: RouterVideoModel::Veo3p1Fast,
provider: RouterProvider::Fal,
prompt: Some("test".to_string()),
duration_seconds,
resolution,
generate_audio,
..Default::default()
};
if frames >= 1 {
b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string()));
match modality {
Modality::TextToVideo => {}
Modality::ImageToVideo => {
b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string()));
}
Modality::FirstLastFrame => {
b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string()));
b.end_frame = Some(ImageRef::Url("https://example.com/b.png".to_string()));
}
Modality::Reference => {
b.reference_images = Some(ImageListRef::Urls(vec!["https://example.com/ref.png".to_string()]));
}
Modality::Extend => {
b.reference_videos = Some(VideoListRef::Urls(vec!["https://example.com/in.mp4".to_string()]));
}
}
if frames == 2 {
b.end_frame = Some(ImageRef::Url("https://example.com/b.png".to_string()));
}
b.build2().expect("build2").estimate_cost().expect("estimate_cost").cost_in_usd_cents.expect("cost")
}
mod numeric_literal_pricing {
use super::*;
#[test]
fn audio_on_4s_is_60() { assert_eq!(cost_cents(Some(4), Some(true), 0), 60); }
#[test]
fn audio_on_6s_is_90() { assert_eq!(cost_cents(Some(6), Some(true), 0), 90); }
#[test]
fn audio_on_8s_is_120() { assert_eq!(cost_cents(Some(8), Some(true), 0), 120); }
#[test]
fn audio_off_4s_is_40() { assert_eq!(cost_cents(Some(4), Some(false), 0), 40); }
#[test]
fn audio_off_8s_is_80() { assert_eq!(cost_cents(Some(8), Some(false), 0), 80); }
#[test]
fn no_duration_defaults_to_8s() {
assert_eq!(cost_cents(None, Some(true), 0), 120);
}
}
#[test]
fn all_three_modes_price_identically() {
let t2v = cost_cents(Some(6), Some(true), 0);
let i2v = cost_cents(Some(6), Some(true), 1);
let flf = cost_cents(Some(6), Some(true), 2);
assert_eq!(t2v, i2v);
assert_eq!(i2v, flf);
}
#[test]
fn audio_costs_more_than_no_audio() {
assert!(cost_cents(Some(8), Some(false), 0) < cost_cents(Some(8), Some(true), 0));
let state = build_fal_veo_3p1_fast_state(b).expect("build state");
FalVeo3p1FastCostState::from_request(&state)
.estimate_cost()
.cost_in_usd_cents
.expect("cost")
}
}
@@ -1,33 +1,26 @@
use std::fmt::Debug;
use std::sync::Arc;
use fal_client::requests_old::webhook::video::image::enqueue_veo_3p1_fast_first_last_frame_image_to_video_webhook::{
enqueue_veo_3p1_fast_first_last_frame_image_to_video_webhook,
EnqueueVeo3p1FastFirstLastFrameImageToVideoArgs,
EnqueueVeo3p1FastFirstLastFrameImageToVideoRequest,
};
use fal_client::requests_old::webhook::video::image::enqueue_veo_3p1_fast_image_to_video_webhook::{
enqueue_veo_3p1_fast_image_to_video_webhook, EnqueueVeo3p1FastImageToVideoArgs,
EnqueueVeo3p1FastImageToVideoRequest,
};
use fal_client::requests_old::webhook::video::text::enqueue_veo_3p1_fast_text_to_video_webhook::{
enqueue_veo_3p1_fast_text_to_video_webhook, EnqueueVeo3p1FastTextToVideoArgs,
EnqueueVeo3p1FastTextToVideoRequest,
};
use fal_client::requests::api::video::extend::veo_3p1_fast::api::Veo3p1FastExtendVideoRequest;
use fal_client::requests::api::video::image::veo_3p1_fast::api::Veo3p1FastImageToVideoRequest;
use fal_client::requests::api::video::images::veo_3p1_fast::api::Veo3p1FastFirstLastFrameToVideoRequest;
use fal_client::requests::api::video::reference::veo_3p1_fast::api::Veo3p1FastReferenceToVideoRequest;
use fal_client::requests::api::video::text::veo_3p1_fast::api::Veo3p1FastTextToVideoRequest;
use fal_client::requests::traits::fal_endpoint_trait::FalEndpoint;
use crate::client::router_fal_client::RouterFalClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_video::generate_video_response::{
FalVideoResponsePayload, GenerateVideoResponse,
};
#[derive(Clone, Debug)]
pub enum FalVeo3p1FastMode {
TextToVideo(EnqueueVeo3p1FastTextToVideoRequest),
ImageToVideo(EnqueueVeo3p1FastImageToVideoRequest),
FirstLastFrame(EnqueueVeo3p1FastFirstLastFrameImageToVideoRequest),
TextToVideo(Veo3p1FastTextToVideoRequest),
ImageToVideo(Veo3p1FastImageToVideoRequest),
FirstLastFrameToVideo(Veo3p1FastFirstLastFrameToVideoRequest),
ReferenceToVideo(Veo3p1FastReferenceToVideoRequest),
ExtendVideo(Veo3p1FastExtendVideoRequest),
}
#[derive(Clone, Debug)]
@@ -37,65 +30,80 @@ pub struct FalVeo3p1FastRequestState {
impl FalVeo3p1FastRequestState {
pub async fn send(&self, client: &RouterFalClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let webhook_url = client.webhook_url.as_deref()
.ok_or(ArtcraftRouterError::Client(ClientError::WebhookUrlRequired))?;
let (webhook_response, outbound_request): (_, Arc<dyn Debug + Send + Sync>) = match &self.mode {
FalVeo3p1FastMode::TextToVideo(request) => {
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let args = EnqueueVeo3p1FastTextToVideoArgs {
request: request.clone(),
webhook_url,
api_key: &client.api_key,
};
(enqueue_veo_3p1_fast_text_to_video_webhook(args).await, outbound)
}
FalVeo3p1FastMode::ImageToVideo(request) => {
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let args = EnqueueVeo3p1FastImageToVideoArgs {
request: request.clone(),
webhook_url,
api_key: &client.api_key,
};
(enqueue_veo_3p1_fast_image_to_video_webhook(args).await, outbound)
}
FalVeo3p1FastMode::FirstLastFrame(request) => {
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let args = EnqueueVeo3p1FastFirstLastFrameImageToVideoArgs {
request: request.clone(),
webhook_url,
api_key: &client.api_key,
};
(enqueue_veo_3p1_fast_first_last_frame_image_to_video_webhook(args).await, outbound)
}
};
match &self.mode {
FalVeo3p1FastMode::TextToVideo(request) => send_request(request, client).await,
FalVeo3p1FastMode::ImageToVideo(request) => send_request(request, client).await,
FalVeo3p1FastMode::FirstLastFrameToVideo(request) => send_request(request, client).await,
FalVeo3p1FastMode::ReferenceToVideo(request) => send_request(request, client).await,
FalVeo3p1FastMode::ExtendVideo(request) => send_request(request, client).await,
}
}
}
let webhook_response = webhook_response
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?;
// ── Helpers ──
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
request_id: webhook_response.request_id,
gateway_request_id: webhook_response.gateway_request_id,
maybe_status_url: None,
maybe_response_url: None,
maybe_outbound_request: Some(outbound_request),
}))
async fn send_request<T>(request: &T, client: &RouterFalClient) -> Result<GenerateVideoResponse, ArtcraftRouterError>
where
T: FalEndpoint + Clone + Debug + Send + Sync + 'static,
{
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let payload = send_fal_request(request, client).await?;
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
request_id: payload.request_id,
gateway_request_id: payload.gateway_request_id,
maybe_status_url: payload.status_url,
maybe_response_url: payload.response_url,
maybe_outbound_request: Some(outbound),
}))
}
struct FalResponseIds {
request_id: Option<String>,
gateway_request_id: Option<String>,
status_url: Option<String>,
response_url: Option<String>,
}
async fn send_fal_request<T: FalEndpoint>(
request: &T,
client: &RouterFalClient,
) -> Result<FalResponseIds, ArtcraftRouterError> {
if let Some(webhook_url) = &client.webhook_url {
let response = request.send_webhook_request(&client.api_key, webhook_url).await?;
Ok(FalResponseIds {
request_id: response.request_id,
gateway_request_id: response.gateway_request_id,
status_url: None,
response_url: None,
})
} else {
let response = request.send_queue_request(&client.api_key).await?;
Ok(FalResponseIds {
request_id: Some(response.request_id),
gateway_request_id: None,
status_url: Some(response.status_url),
response_url: Some(response.response_url),
})
}
}
#[cfg(test)]
mod tests {
use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL;
use test_data::web::video_urls::ANGRY_SHIBA_VIDEO_URL;
use crate::api::router_video_model::RouterVideoModel;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::router_provider::RouterProvider;
use crate::api::router_video_model::RouterVideoModel;
use crate::api::video_list_ref::VideoListRef;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::test_helpers::get_fal_client;
#[tokio::test]
#[ignore]
#[ignore] // requires real API key, incurs cost
async fn live_text_to_video_4s() {
let r = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("a candle flickering in the dark".to_string()),
@@ -106,7 +114,7 @@ mod tests {
}
#[tokio::test]
#[ignore]
#[ignore] // requires real API key, incurs cost
async fn live_image_to_video_6s() {
let r = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("the dog leaps into the lake.".to_string()),
@@ -117,6 +125,32 @@ mod tests {
assert!(matches!(r, GenerateVideoResponse::Fal(_)));
}
#[tokio::test]
#[ignore] // requires real API key, incurs cost
async fn live_reference_to_video_4s() {
let r = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("the dog runs across a sunlit meadow".to_string()),
reference_images: Some(ImageListRef::Urls(vec![JUNO_AT_LAKE_IMAGE_URL.to_string()])),
duration_seconds: Some(4),
generate_audio: Some(false),
..builder()
}).await;
assert!(matches!(r, GenerateVideoResponse::Fal(_)));
}
#[tokio::test]
#[ignore] // requires real API key, incurs cost
async fn live_extend_video_7s() {
let r = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("the scene continues naturally, keeping the same motion and style".to_string()),
reference_videos: Some(VideoListRef::Urls(vec![ANGRY_SHIBA_VIDEO_URL.to_string()])),
duration_seconds: Some(7),
generate_audio: Some(false),
..builder()
}).await;
assert!(matches!(r, GenerateVideoResponse::Fal(_)));
}
fn builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: RouterVideoModel::Veo3p1Fast,
@@ -0,0 +1,548 @@
use fal_client::requests::api::video::image::veo_3p1_lite::api::{
Veo3p1LiteImageToVideoAspectRatio, Veo3p1LiteImageToVideoDuration, Veo3p1LiteImageToVideoRequest,
Veo3p1LiteImageToVideoResolution,
};
use fal_client::requests::api::video::images::veo_3p1_lite::api::{
Veo3p1LiteFirstLastFrameToVideoAspectRatio, Veo3p1LiteFirstLastFrameToVideoDuration,
Veo3p1LiteFirstLastFrameToVideoRequest, Veo3p1LiteFirstLastFrameToVideoResolution,
};
use fal_client::requests::api::video::text::veo_3p1_lite::api::{
Veo3p1LiteTextToVideoAspectRatio, Veo3p1LiteTextToVideoDuration, Veo3p1LiteTextToVideoRequest,
Veo3p1LiteTextToVideoResolution,
};
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_resolution::RouterResolution;
use crate::api::video_list_ref::VideoListRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::providers::fal::veo_3p1_lite::request::{
FalVeo3p1LiteMode, FalVeo3p1LiteRequestState,
};
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
#[derive(Debug, Clone, Copy)]
pub(crate) enum PlanAspectRatio {
Auto,
SixteenByNine,
NineBySixteen,
}
#[derive(Debug, Clone, Copy)]
pub(crate) enum PlanResolution {
SevenTwentyP,
TenEightyP,
}
#[derive(Debug, Clone, Copy)]
pub(crate) enum PlanDuration {
Four,
Six,
Eight,
}
pub fn build_fal_veo_3p1_lite(
builder: GenerateVideoRequestBuilder,
) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let state = build_fal_veo_3p1_lite_state(builder)?;
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalVeo3p1Lite(state)))
}
pub(crate) fn build_fal_veo_3p1_lite_state(
builder: GenerateVideoRequestBuilder,
) -> Result<FalVeo3p1LiteRequestState, ArtcraftRouterError> {
let strategy = builder.request_mismatch_mitigation_strategy;
// Lite has no reference-to-video or extend-video endpoints on fal.
if has_reference_videos(&builder.reference_videos) {
return Err(unsupported(
"reference_videos",
"Veo 3.1 Lite does not support extend-video (reference videos)",
));
}
if has_reference_images(&builder.reference_images) {
return Err(unsupported(
"reference_images",
"Veo 3.1 Lite does not support reference-to-video (reference images)",
));
}
let start = optional_url(builder.start_frame.clone())?;
let end = optional_url(builder.end_frame.clone())?;
let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio, strategy)?;
let resolution = plan_resolution(builder.resolution, strategy)?;
let duration = plan_duration(builder.duration_seconds, strategy)?;
let prompt = builder.prompt.clone().unwrap_or_default();
let negative_prompt = builder.negative_prompt.clone();
let generate_audio = builder.generate_audio;
let mode = match (start, end) {
(None, None) => FalVeo3p1LiteMode::TextToVideo(Veo3p1LiteTextToVideoRequest {
prompt,
aspect_ratio: aspect_ratio.and_then(to_t2v_aspect_ratio),
duration: duration.map(to_t2v_duration),
resolution: resolution.map(to_t2v_resolution),
negative_prompt,
generate_audio,
seed: None,
auto_fix: None,
safety_tolerance: None,
}),
(Some(image_url), None) => FalVeo3p1LiteMode::ImageToVideo(Veo3p1LiteImageToVideoRequest {
prompt,
image_url,
aspect_ratio: aspect_ratio.map(to_i2v_aspect_ratio),
duration: duration.map(to_i2v_duration),
resolution: resolution.map(to_i2v_resolution),
generate_audio,
negative_prompt,
seed: None,
auto_fix: None,
safety_tolerance: None,
}),
(Some(first_frame_url), Some(last_frame_url)) => FalVeo3p1LiteMode::FirstLastFrameToVideo(
Veo3p1LiteFirstLastFrameToVideoRequest {
prompt,
first_frame_url,
last_frame_url,
aspect_ratio: aspect_ratio.map(to_flf_aspect_ratio),
duration: duration.map(to_flf_duration),
resolution: resolution.map(to_flf_resolution),
generate_audio,
negative_prompt,
seed: None,
auto_fix: None,
safety_tolerance: None,
},
),
(None, Some(_)) => {
return Err(unsupported(
"end_frame",
"Veo 3.1 Lite requires a start_frame when end_frame is provided",
));
}
};
Ok(FalVeo3p1LiteRequestState { mode })
}
fn optional_url(image_ref: Option<ImageRef>) -> Result<Option<String>, ArtcraftRouterError> {
match image_ref {
None => Ok(None),
Some(ImageRef::Url(url)) => Ok(Some(url)),
Some(ImageRef::MediaFileToken(_)) => {
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
}
}
}
fn has_reference_images(refs: &Option<ImageListRef>) -> bool {
match refs {
None => false,
Some(ImageListRef::Urls(urls)) => !urls.is_empty(),
Some(ImageListRef::MediaFileTokens(tokens)) => !tokens.is_empty(),
}
}
fn has_reference_videos(refs: &Option<VideoListRef>) -> bool {
match refs {
None => false,
Some(VideoListRef::Urls(urls)) => !urls.is_empty(),
Some(VideoListRef::MediaFileTokens(tokens)) => !tokens.is_empty(),
}
}
fn plan_aspect_ratio(
aspect_ratio: Option<RouterAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<PlanAspectRatio>, ArtcraftRouterError> {
use PlanAspectRatio as Ar;
match aspect_ratio {
None => Ok(None),
Some(RouterAspectRatio::Auto)
| Some(RouterAspectRatio::Auto2k)
| Some(RouterAspectRatio::Auto4k) => Ok(Some(Ar::Auto)),
Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Some(Ar::SixteenByNine)),
Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Some(Ar::NineBySixteen)),
Some(other) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(unsupported("aspect_ratio", &format!("{:?}", other)))
}
_ => Ok(Some(Ar::Auto)),
},
}
}
fn plan_resolution(
resolution: Option<RouterResolution>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<PlanResolution>, ArtcraftRouterError> {
use PlanResolution as R;
match resolution {
None => Ok(None),
Some(RouterResolution::SevenTwentyP) => Ok(Some(R::SevenTwentyP)),
Some(RouterResolution::TenEightyP) => Ok(Some(R::TenEightyP)),
// Lite has no 4k tier; the nearest supported resolution in either
// direction is 1080p.
Some(RouterResolution::FourK) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(unsupported("resolution", "Veo 3.1 Lite does not support 4k"))
}
_ => Ok(Some(R::TenEightyP)),
},
Some(other) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(unsupported("resolution", &format!("{:?}", other)))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Some(R::TenEightyP)),
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(R::SevenTwentyP)),
},
}
}
fn plan_duration(
duration_seconds: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<PlanDuration>, ArtcraftRouterError> {
match duration_seconds {
None => Ok(None),
Some(4) => Ok(Some(PlanDuration::Four)),
Some(6) => Ok(Some(PlanDuration::Six)),
Some(8) => Ok(Some(PlanDuration::Eight)),
Some(other) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(unsupported("duration_seconds", &format!("{}", other)))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Some(match other {
0..=4 => PlanDuration::Four,
5..=6 => PlanDuration::Six,
_ => PlanDuration::Eight,
})),
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(match other {
0..=5 => PlanDuration::Four,
6..=7 => PlanDuration::Six,
_ => PlanDuration::Eight,
})),
},
}
}
fn unsupported(field: &'static str, value: &str) -> ArtcraftRouterError {
ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field,
value: value.to_string(),
})
}
// The Lite text-to-video endpoint has no `auto` aspect ratio; omit the field
// and let fal apply its own default (16:9).
fn to_t2v_aspect_ratio(a: PlanAspectRatio) -> Option<Veo3p1LiteTextToVideoAspectRatio> {
match a {
PlanAspectRatio::Auto => None,
PlanAspectRatio::SixteenByNine => Some(Veo3p1LiteTextToVideoAspectRatio::SixteenByNine),
PlanAspectRatio::NineBySixteen => Some(Veo3p1LiteTextToVideoAspectRatio::NineBySixteen),
}
}
fn to_t2v_duration(d: PlanDuration) -> Veo3p1LiteTextToVideoDuration {
match d {
PlanDuration::Four => Veo3p1LiteTextToVideoDuration::FourSeconds,
PlanDuration::Six => Veo3p1LiteTextToVideoDuration::SixSeconds,
PlanDuration::Eight => Veo3p1LiteTextToVideoDuration::EightSeconds,
}
}
fn to_t2v_resolution(r: PlanResolution) -> Veo3p1LiteTextToVideoResolution {
match r {
PlanResolution::SevenTwentyP => Veo3p1LiteTextToVideoResolution::SevenTwentyP,
PlanResolution::TenEightyP => Veo3p1LiteTextToVideoResolution::TenEightyP,
}
}
fn to_i2v_aspect_ratio(a: PlanAspectRatio) -> Veo3p1LiteImageToVideoAspectRatio {
match a {
PlanAspectRatio::Auto => Veo3p1LiteImageToVideoAspectRatio::Auto,
PlanAspectRatio::SixteenByNine => Veo3p1LiteImageToVideoAspectRatio::SixteenByNine,
PlanAspectRatio::NineBySixteen => Veo3p1LiteImageToVideoAspectRatio::NineBySixteen,
}
}
fn to_i2v_duration(d: PlanDuration) -> Veo3p1LiteImageToVideoDuration {
match d {
PlanDuration::Four => Veo3p1LiteImageToVideoDuration::FourSeconds,
PlanDuration::Six => Veo3p1LiteImageToVideoDuration::SixSeconds,
PlanDuration::Eight => Veo3p1LiteImageToVideoDuration::EightSeconds,
}
}
fn to_i2v_resolution(r: PlanResolution) -> Veo3p1LiteImageToVideoResolution {
match r {
PlanResolution::SevenTwentyP => Veo3p1LiteImageToVideoResolution::SevenTwentyP,
PlanResolution::TenEightyP => Veo3p1LiteImageToVideoResolution::TenEightyP,
}
}
fn to_flf_aspect_ratio(a: PlanAspectRatio) -> Veo3p1LiteFirstLastFrameToVideoAspectRatio {
match a {
PlanAspectRatio::Auto => Veo3p1LiteFirstLastFrameToVideoAspectRatio::Auto,
PlanAspectRatio::SixteenByNine => Veo3p1LiteFirstLastFrameToVideoAspectRatio::SixteenByNine,
PlanAspectRatio::NineBySixteen => Veo3p1LiteFirstLastFrameToVideoAspectRatio::NineBySixteen,
}
}
fn to_flf_duration(d: PlanDuration) -> Veo3p1LiteFirstLastFrameToVideoDuration {
match d {
PlanDuration::Four => Veo3p1LiteFirstLastFrameToVideoDuration::FourSeconds,
PlanDuration::Six => Veo3p1LiteFirstLastFrameToVideoDuration::SixSeconds,
PlanDuration::Eight => Veo3p1LiteFirstLastFrameToVideoDuration::EightSeconds,
}
}
fn to_flf_resolution(r: PlanResolution) -> Veo3p1LiteFirstLastFrameToVideoResolution {
match r {
PlanResolution::SevenTwentyP => Veo3p1LiteFirstLastFrameToVideoResolution::SevenTwentyP,
PlanResolution::TenEightyP => Veo3p1LiteFirstLastFrameToVideoResolution::TenEightyP,
}
}
#[cfg(test)]
mod tests {
use crate::api::router_provider::RouterProvider;
use crate::api::router_video_model::RouterVideoModel;
use super::*;
const START_FRAME_URL: &str = "https://example.com/a.png";
const END_FRAME_URL: &str = "https://example.com/b.png";
const REFERENCE_IMAGE_URL: &str = "https://example.com/ref.png";
const REFERENCE_VIDEO_URL: &str = "https://example.com/in.mp4";
mod mode_selection {
use super::*;
#[test]
fn no_frames_picks_t2v() {
assert!(matches!(unwrap_mode(base_builder()), FalVeo3p1LiteMode::TextToVideo(_)));
}
#[test]
fn start_only_picks_i2v() {
let mut b = base_builder();
b.start_frame = Some(ImageRef::Url(START_FRAME_URL.to_string()));
assert!(matches!(unwrap_mode(b), FalVeo3p1LiteMode::ImageToVideo(_)));
}
#[test]
fn start_and_end_picks_first_last_frame() {
let mut b = base_builder();
b.start_frame = Some(ImageRef::Url(START_FRAME_URL.to_string()));
b.end_frame = Some(ImageRef::Url(END_FRAME_URL.to_string()));
assert!(matches!(unwrap_mode(b), FalVeo3p1LiteMode::FirstLastFrameToVideo(_)));
}
#[test]
fn end_only_errors() {
let mut b = base_builder();
b.end_frame = Some(ImageRef::Url(END_FRAME_URL.to_string()));
assert!(build_fal_veo_3p1_lite_state(b).is_err());
}
}
mod unsupported_modalities {
use super::*;
#[test]
fn reference_images_are_rejected() {
let mut b = base_builder();
b.reference_images = Some(ImageListRef::Urls(vec![REFERENCE_IMAGE_URL.to_string()]));
assert!(build_fal_veo_3p1_lite_state(b).is_err());
}
#[test]
fn reference_videos_are_rejected() {
let mut b = base_builder();
b.reference_videos = Some(VideoListRef::Urls(vec![REFERENCE_VIDEO_URL.to_string()]));
assert!(build_fal_veo_3p1_lite_state(b).is_err());
}
#[test]
fn empty_reference_lists_are_ignored() {
let mut b = base_builder();
b.reference_images = Some(ImageListRef::Urls(vec![]));
b.reference_videos = Some(VideoListRef::Urls(vec![]));
assert!(matches!(unwrap_mode(b), FalVeo3p1LiteMode::TextToVideo(_)));
}
}
mod duration_conversions {
use super::*;
#[test]
fn duration_4s() {
let mut b = base_builder();
b.duration_seconds = Some(4);
match unwrap_mode(b) {
FalVeo3p1LiteMode::TextToVideo(r) => {
assert!(matches!(r.duration, Some(Veo3p1LiteTextToVideoDuration::FourSeconds)));
}
_ => panic!("expected t2v"),
}
}
#[test]
fn unsupported_duration_errors_with_error_out() {
let mut b = base_builder();
b.duration_seconds = Some(5);
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut;
assert!(build_fal_veo_3p1_lite_state(b).is_err());
}
#[test]
fn duration_5s_pay_more_upgrades_to_6() {
let mut b = base_builder();
b.duration_seconds = Some(5);
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::PayMoreUpgrade;
match unwrap_mode(b) {
FalVeo3p1LiteMode::TextToVideo(r) => {
assert!(matches!(r.duration, Some(Veo3p1LiteTextToVideoDuration::SixSeconds)));
}
_ => panic!("expected t2v"),
}
}
#[test]
fn duration_5s_pay_less_downgrades_to_4() {
let mut b = base_builder();
b.duration_seconds = Some(5);
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::PayLessDowngrade;
match unwrap_mode(b) {
FalVeo3p1LiteMode::TextToVideo(r) => {
assert!(matches!(r.duration, Some(Veo3p1LiteTextToVideoDuration::FourSeconds)));
}
_ => panic!("expected t2v"),
}
}
}
mod resolution_conversions {
use super::*;
#[test]
fn t2v_720p() {
let mut b = base_builder();
b.resolution = Some(RouterResolution::SevenTwentyP);
match unwrap_mode(b) {
FalVeo3p1LiteMode::TextToVideo(r) => {
assert!(matches!(r.resolution, Some(Veo3p1LiteTextToVideoResolution::SevenTwentyP)));
}
_ => panic!("expected t2v"),
}
}
#[test]
fn four_k_errors_with_error_out() {
let mut b = base_builder();
b.resolution = Some(RouterResolution::FourK);
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut;
assert!(build_fal_veo_3p1_lite_state(b).is_err());
}
#[test]
fn four_k_maps_to_1080p_with_mitigation() {
for strategy in [
RequestMismatchMitigationStrategy::PayMoreUpgrade,
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let mut b = base_builder();
b.resolution = Some(RouterResolution::FourK);
b.request_mismatch_mitigation_strategy = strategy;
match unwrap_mode(b) {
FalVeo3p1LiteMode::TextToVideo(r) => {
assert!(matches!(r.resolution, Some(Veo3p1LiteTextToVideoResolution::TenEightyP)));
}
_ => panic!("expected t2v"),
}
}
}
}
#[test]
fn t2v_auto_aspect_ratio_is_omitted() {
// Lite t2v has no `auto` wire value; Auto must map to None.
let mut b = base_builder();
b.aspect_ratio = Some(RouterAspectRatio::Auto);
match unwrap_mode(b) {
FalVeo3p1LiteMode::TextToVideo(r) => assert!(r.aspect_ratio.is_none()),
_ => panic!("expected t2v"),
}
}
#[test]
fn i2v_auto_aspect_ratio_maps_to_auto() {
let mut b = base_builder();
b.aspect_ratio = Some(RouterAspectRatio::Auto);
b.start_frame = Some(ImageRef::Url(START_FRAME_URL.to_string()));
match unwrap_mode(b) {
FalVeo3p1LiteMode::ImageToVideo(r) => {
assert!(matches!(r.aspect_ratio, Some(Veo3p1LiteImageToVideoAspectRatio::Auto)));
}
_ => panic!("expected i2v"),
}
}
#[test]
fn full_combinatorial_pass() {
let resolutions = [None, Some(RouterResolution::SevenTwentyP), Some(RouterResolution::TenEightyP)];
let durations = [None, Some(4u16), Some(6), Some(8)];
let aspect_ratios = [None, Some(RouterAspectRatio::Auto), Some(RouterAspectRatio::WideSixteenByNine), Some(RouterAspectRatio::TallNineBySixteen)];
let audios = [None, Some(true), Some(false)];
let mut combos = 0;
for &resolution in &resolutions {
for &duration in &durations {
for &aspect_ratio in &aspect_ratios {
for &generate_audio in &audios {
for frames in [0, 1, 2] {
let mut b = base_builder();
b.resolution = resolution;
b.duration_seconds = duration;
b.aspect_ratio = aspect_ratio;
b.generate_audio = generate_audio;
if frames >= 1 {
b.start_frame = Some(ImageRef::Url(START_FRAME_URL.to_string()));
}
if frames == 2 {
b.end_frame = Some(ImageRef::Url(END_FRAME_URL.to_string()));
}
assert!(build_fal_veo_3p1_lite_state(b).is_ok());
combos += 1;
}
}
}
}
}
assert_eq!(combos, 3 * 4 * 4 * 3 * 3);
}
fn base_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: RouterVideoModel::Veo3p1Lite,
provider: RouterProvider::Fal,
prompt: Some("test".to_string()),
..Default::default()
}
}
fn unwrap_mode(b: GenerateVideoRequestBuilder) -> FalVeo3p1LiteMode {
build_fal_veo_3p1_lite_state(b).expect("build").mode
}
}
@@ -0,0 +1,174 @@
use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator;
use crate::generate::generate_video::providers::fal::veo_3p1_lite::request::{
FalVeo3p1LiteMode, FalVeo3p1LiteRequestState,
};
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
#[derive(Clone, Debug)]
pub struct FalVeo3p1LiteCostState {
pub cost_in_usd_cents: u64,
}
impl FalVeo3p1LiteCostState {
pub fn from_request(request: &FalVeo3p1LiteRequestState) -> Self {
// Cost math is owned by fal_client's per-endpoint
// `FalRequestCostCalculator` implementations. The router state just
// forwards the result so router cost ≡ fal_client cost by construction.
let cost_in_usd_cents = match &request.mode {
FalVeo3p1LiteMode::TextToVideo(req) => req.calculate_cost_in_cents(),
FalVeo3p1LiteMode::ImageToVideo(req) => req.calculate_cost_in_cents(),
FalVeo3p1LiteMode::FirstLastFrameToVideo(req) => req.calculate_cost_in_cents(),
};
Self { cost_in_usd_cents }
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
VideoGenerationCostEstimate {
cost_in_credits: Some(self.cost_in_usd_cents),
cost_in_usd_cents: Some(self.cost_in_usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
}
#[cfg(test)]
mod tests {
use crate::api::image_ref::ImageRef;
use crate::api::router_provider::RouterProvider;
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::providers::fal::veo_3p1_lite::build::build_fal_veo_3p1_lite_state;
use super::*;
// Veo 3.1 Lite pricing (both resolution and audio move the rate; no 4k):
// 720p: $0.03/sec (audio off), $0.05/sec (audio on)
// 1080p: $0.05/sec (audio off), $0.08/sec (audio on)
mod text_to_video {
use super::*;
#[test]
fn t2v_8s_audio_on_720p_is_40() {
assert_eq!(cost_cents(Some(8), Some(RouterResolution::SevenTwentyP), Some(true), 0), 40);
}
#[test]
fn t2v_8s_audio_off_720p_is_24() {
assert_eq!(cost_cents(Some(8), Some(RouterResolution::SevenTwentyP), Some(false), 0), 24);
}
#[test]
fn t2v_8s_audio_on_1080p_is_64() {
assert_eq!(cost_cents(Some(8), Some(RouterResolution::TenEightyP), Some(true), 0), 64);
}
#[test]
fn t2v_8s_audio_off_1080p_is_40() {
assert_eq!(cost_cents(Some(8), Some(RouterResolution::TenEightyP), Some(false), 0), 40);
}
#[test]
fn t2v_4s_audio_on_720p_is_20() {
assert_eq!(cost_cents(Some(4), Some(RouterResolution::SevenTwentyP), Some(true), 0), 20);
}
#[test]
fn t2v_6s_audio_on_720p_is_30() {
assert_eq!(cost_cents(Some(6), Some(RouterResolution::SevenTwentyP), Some(true), 0), 30);
}
#[test]
fn t2v_defaults_are_8s_720p_audio_on_40() {
// duration=None→8s, resolution=None→720p, audio=None→on.
assert_eq!(cost_cents(None, None, None, 0), 40);
}
}
mod image_to_video {
use super::*;
#[test]
fn i2v_8s_audio_off_720p_is_24() {
assert_eq!(cost_cents(Some(8), Some(RouterResolution::SevenTwentyP), Some(false), 1), 24);
}
#[test]
fn i2v_6s_audio_on_720p_is_30() {
assert_eq!(cost_cents(Some(6), Some(RouterResolution::SevenTwentyP), Some(true), 1), 30);
}
}
mod first_last_frame_to_video {
use super::*;
#[test]
fn flf_8s_audio_on_1080p_is_64() {
assert_eq!(cost_cents(Some(8), Some(RouterResolution::TenEightyP), Some(true), 2), 64);
}
#[test]
fn flf_4s_audio_off_720p_is_12() {
assert_eq!(cost_cents(Some(4), Some(RouterResolution::SevenTwentyP), Some(false), 2), 12);
}
}
#[test]
fn all_three_modes_price_identically() {
let t2v = cost_cents(Some(6), Some(RouterResolution::TenEightyP), Some(true), 0);
let i2v = cost_cents(Some(6), Some(RouterResolution::TenEightyP), Some(true), 1);
let flf = cost_cents(Some(6), Some(RouterResolution::TenEightyP), Some(true), 2);
assert_eq!(t2v, i2v);
assert_eq!(i2v, flf);
}
#[test]
fn audio_costs_more_than_no_audio() {
assert!(
cost_cents(Some(8), Some(RouterResolution::SevenTwentyP), Some(false), 0)
< cost_cents(Some(8), Some(RouterResolution::SevenTwentyP), Some(true), 0)
);
}
#[test]
fn ten_eighty_costs_more_than_seven_twenty() {
assert!(
cost_cents(Some(8), Some(RouterResolution::SevenTwentyP), Some(true), 0)
< cost_cents(Some(8), Some(RouterResolution::TenEightyP), Some(true), 0)
);
}
fn cost_cents(
duration_seconds: Option<u16>,
resolution: Option<RouterResolution>,
generate_audio: Option<bool>,
frames: u8,
) -> u64 {
let mut b = GenerateVideoRequestBuilder {
model: RouterVideoModel::Veo3p1Lite,
provider: RouterProvider::Fal,
prompt: Some("test".to_string()),
duration_seconds,
resolution,
generate_audio,
..Default::default()
};
if frames >= 1 {
b.start_frame = Some(ImageRef::Url("https://example.com/a.png".to_string()));
}
if frames == 2 {
b.end_frame = Some(ImageRef::Url("https://example.com/b.png".to_string()));
}
let state = build_fal_veo_3p1_lite_state(b).expect("build state");
FalVeo3p1LiteCostState::from_request(&state)
.estimate_cost()
.cost_in_usd_cents
.expect("cost")
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -0,0 +1,166 @@
use std::fmt::Debug;
use std::sync::Arc;
use fal_client::requests::api::video::image::veo_3p1_lite::api::Veo3p1LiteImageToVideoRequest;
use fal_client::requests::api::video::images::veo_3p1_lite::api::Veo3p1LiteFirstLastFrameToVideoRequest;
use fal_client::requests::api::video::text::veo_3p1_lite::api::Veo3p1LiteTextToVideoRequest;
use fal_client::requests::traits::fal_endpoint_trait::FalEndpoint;
use crate::client::router_fal_client::RouterFalClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_response::{
FalVideoResponsePayload, GenerateVideoResponse,
};
#[derive(Clone, Debug)]
pub enum FalVeo3p1LiteMode {
TextToVideo(Veo3p1LiteTextToVideoRequest),
ImageToVideo(Veo3p1LiteImageToVideoRequest),
FirstLastFrameToVideo(Veo3p1LiteFirstLastFrameToVideoRequest),
}
#[derive(Clone, Debug)]
pub struct FalVeo3p1LiteRequestState {
pub mode: FalVeo3p1LiteMode,
}
impl FalVeo3p1LiteRequestState {
pub async fn send(&self, client: &RouterFalClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
match &self.mode {
FalVeo3p1LiteMode::TextToVideo(request) => send_request(request, client).await,
FalVeo3p1LiteMode::ImageToVideo(request) => send_request(request, client).await,
FalVeo3p1LiteMode::FirstLastFrameToVideo(request) => send_request(request, client).await,
}
}
}
// ── Helpers ──
async fn send_request<T>(request: &T, client: &RouterFalClient) -> Result<GenerateVideoResponse, ArtcraftRouterError>
where
T: FalEndpoint + Clone + Debug + Send + Sync + 'static,
{
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let payload = send_fal_request(request, client).await?;
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
request_id: payload.request_id,
gateway_request_id: payload.gateway_request_id,
maybe_status_url: payload.status_url,
maybe_response_url: payload.response_url,
maybe_outbound_request: Some(outbound),
}))
}
struct FalResponseIds {
request_id: Option<String>,
gateway_request_id: Option<String>,
status_url: Option<String>,
response_url: Option<String>,
}
async fn send_fal_request<T: FalEndpoint>(
request: &T,
client: &RouterFalClient,
) -> Result<FalResponseIds, ArtcraftRouterError> {
if let Some(webhook_url) = &client.webhook_url {
let response = request.send_webhook_request(&client.api_key, webhook_url).await?;
Ok(FalResponseIds {
request_id: response.request_id,
gateway_request_id: response.gateway_request_id,
status_url: None,
response_url: None,
})
} else {
let response = request.send_queue_request(&client.api_key).await?;
Ok(FalResponseIds {
request_id: Some(response.request_id),
gateway_request_id: None,
status_url: Some(response.status_url),
response_url: Some(response.response_url),
})
}
}
#[cfg(test)]
mod tests {
use fal_client::creds::fal_api_key::FalApiKey;
use test_data::web::image_urls::{JUNO_AT_LAKE_IMAGE_URL, TALL_MOCHI_WITH_GLASSES_IMAGE_URL};
use crate::api::image_ref::ImageRef;
use crate::api::router_provider::RouterProvider;
use crate::api::router_video_model::RouterVideoModel;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::providers::fal::veo_3p1_lite::build::build_fal_veo_3p1_lite_state;
use super::*;
#[tokio::test]
#[ignore] // requires real API key, incurs cost
async fn live_text_to_video_4s() {
let state = build_state(GenerateVideoRequestBuilder {
prompt: Some("a candle flickering in the dark".to_string()),
duration_seconds: Some(4),
generate_audio: Some(false),
..builder()
});
let response = state.send(&client_with_webhook()).await.expect("send should succeed");
let payload = response.get_fal_payload().expect("expected Fal payload");
assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some());
}
#[tokio::test]
#[ignore] // requires real API key, incurs cost
async fn live_image_to_video_4s() {
let state = build_state(GenerateVideoRequestBuilder {
prompt: Some("the dog leaps into the lake.".to_string()),
start_frame: Some(ImageRef::Url(JUNO_AT_LAKE_IMAGE_URL.to_string())),
duration_seconds: Some(4),
generate_audio: Some(false),
..builder()
});
let response = state.send(&client_with_webhook()).await.expect("send should succeed");
let payload = response.get_fal_payload().expect("expected Fal payload");
assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some());
}
#[tokio::test]
#[ignore] // requires real API key, incurs cost
async fn live_first_last_frame_4s() {
let state = build_state(GenerateVideoRequestBuilder {
prompt: Some("a smooth transition between scenes".to_string()),
start_frame: Some(ImageRef::Url(TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string())),
end_frame: Some(ImageRef::Url(JUNO_AT_LAKE_IMAGE_URL.to_string())),
duration_seconds: Some(4),
generate_audio: Some(false),
..builder()
});
let response = state.send(&client_with_webhook()).await.expect("send should succeed");
let payload = response.get_fal_payload().expect("expected Fal payload");
assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some());
}
fn builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: RouterVideoModel::Veo3p1Lite,
provider: RouterProvider::Fal,
..Default::default()
}
}
fn build_state(b: GenerateVideoRequestBuilder) -> FalVeo3p1LiteRequestState {
build_fal_veo_3p1_lite_state(b).expect("build state")
}
fn read_fal_api_key() -> FalApiKey {
let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")
.expect("Failed to read fal_api_key.txt");
FalApiKey::from_str(secret.trim())
}
fn client_with_webhook() -> RouterFalClient {
RouterFalClient::new_with_webhook(
read_fal_api_key(),
"https://example.com/fal-webhook-test".to_string(),
)
}
}
@@ -0,0 +1,514 @@
use fal_client::requests::api::video::image::vidu_q3::api::{
ViduQ3ImageToVideoRequest, ViduQ3ImageToVideoResolution,
};
use fal_client::requests::api::video::reference::vidu_q3_reference_to_video::api::{
ViduQ3ReferenceToVideoAspectRatio, ViduQ3ReferenceToVideoRequest, ViduQ3ReferenceToVideoResolution,
};
use fal_client::requests::api::video::reference::vidu_q3_reference_to_video_mix::api::{
ViduQ3ReferenceToVideoMixAspectRatio, ViduQ3ReferenceToVideoMixRequest,
ViduQ3ReferenceToVideoMixResolution,
};
use fal_client::requests::api::video::text::vidu_q3::api::{
ViduQ3TextToVideoAspectRatio, ViduQ3TextToVideoRequest, ViduQ3TextToVideoResolution,
};
use crate::api::image_list_ref::ImageListRef;
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_resolution::RouterResolution;
use crate::api::video_list_ref::VideoListRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::providers::fal::kling_1_6_pro::build::optional_url;
use crate::generate::generate_video::providers::fal::vidu_q3::request::{
FalViduQ3Mode, FalViduQ3RequestState,
};
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
const MAX_REFERENCE_IMAGES: usize = 4;
const MIN_DURATION_SECONDS: u16 = 1;
const MAX_DURATION_SECONDS: u16 = 16;
#[derive(Debug, Clone, Copy)]
pub(crate) enum PlanAspectRatio {
SixteenByNine,
NineBySixteen,
FourByThree,
ThreeByFour,
Square,
}
/// Vidu also offers 360p, but no `RouterResolution` maps to it (fal bills 360p
/// and 540p identically, so 540p is always the better pick).
#[derive(Debug, Clone, Copy)]
pub(crate) enum PlanResolution {
FiveFortyP,
SevenTwentyP,
TenEightyP,
}
pub fn build_fal_vidu_q3(
builder: GenerateVideoRequestBuilder,
) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let state = build_fal_vidu_q3_state(builder)?;
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalViduQ3(state)))
}
pub(crate) fn build_fal_vidu_q3_state(
builder: GenerateVideoRequestBuilder,
) -> Result<FalViduQ3RequestState, ArtcraftRouterError> {
let strategy = builder.request_mismatch_mitigation_strategy;
reject_reference_videos(&builder.reference_videos)?;
let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio, strategy)?;
let resolution = plan_resolution(builder.resolution, strategy)?;
let duration = plan_duration(builder.duration_seconds, strategy)?;
let prompt = builder.prompt.clone().unwrap_or_default();
let audio = builder.generate_audio;
let reference_image_urls = resolve_reference_image_urls(builder.reference_images.clone())?;
let start_image_url = optional_url(builder.start_frame.clone())?;
let end_image_url = optional_url(builder.end_frame.clone())?;
// Dispatch: reference_images → reference-to-video; start_frame → image; else → text.
let mode = if !reference_image_urls.is_empty() {
if start_image_url.is_some() || end_image_url.is_some() {
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "reference_images",
value: "Vidu Q3 reference-to-video cannot also accept a start_frame or end_frame".to_string(),
}));
}
if reference_image_urls.len() > MAX_REFERENCE_IMAGES {
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "reference_images",
value: format!(
"Vidu Q3 supports at most {} reference images, got {}",
MAX_REFERENCE_IMAGES,
reference_image_urls.len(),
),
}));
}
// The plain reference endpoint keeps a single subject consistent; the /mix
// variant blends multiple subjects, so 2+ references route there.
if reference_image_urls.len() == 1 {
FalViduQ3Mode::ReferenceToVideo(ViduQ3ReferenceToVideoRequest {
prompt,
reference_image_urls,
duration,
seed: None,
aspect_ratio: aspect_ratio.map(to_reference_aspect_ratio),
resolution: resolution.map(to_reference_resolution),
audio,
})
} else {
FalViduQ3Mode::ReferenceToVideoMix(ViduQ3ReferenceToVideoMixRequest {
prompt,
reference_image_urls,
duration,
seed: None,
aspect_ratio: aspect_ratio.map(to_mix_aspect_ratio),
resolution: resolution.map(to_mix_resolution),
audio,
})
}
} else if let Some(image_url) = start_image_url {
let i2v_resolution = resolution.map(to_i2v_resolution);
check_i2v_end_frame_resolution(i2v_resolution, end_image_url.is_some())?;
// Image-to-video has no aspect_ratio input — the output follows the start
// frame — so any requested aspect ratio is silently dropped here.
FalViduQ3Mode::ImageToVideo(ViduQ3ImageToVideoRequest {
prompt,
image_url,
end_image_url,
duration,
seed: None,
resolution: i2v_resolution,
audio,
})
} else {
if end_image_url.is_some() {
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "end_frame",
value: "Vidu Q3 requires a start_frame when end_frame is provided".to_string(),
}));
}
FalViduQ3Mode::TextToVideo(ViduQ3TextToVideoRequest {
prompt,
duration,
seed: None,
aspect_ratio: aspect_ratio.map(to_t2v_aspect_ratio),
resolution: resolution.map(to_t2v_resolution),
audio,
})
};
Ok(FalViduQ3RequestState { mode })
}
// ── Input helpers ──
fn reject_reference_videos(
reference_videos: &Option<VideoListRef>,
) -> Result<(), ArtcraftRouterError> {
let has_reference_videos = match reference_videos {
None => false,
Some(VideoListRef::Urls(urls)) => !urls.is_empty(),
Some(VideoListRef::MediaFileTokens(tokens)) => !tokens.is_empty(),
};
if has_reference_videos {
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "reference_videos",
value: "Vidu Q3 does not support video references".to_string(),
}));
}
Ok(())
}
fn resolve_reference_image_urls(
reference_images: Option<ImageListRef>,
) -> Result<Vec<String>, ArtcraftRouterError> {
match reference_images {
None => Ok(vec![]),
Some(ImageListRef::Urls(urls)) => Ok(urls),
Some(ImageListRef::MediaFileTokens(_)) => {
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
}
}
}
/// fal rejects 360p image-to-video requests that include an end frame.
/// `plan_resolution` can't currently produce 360p, but guard anyway so a
/// future mapping change can't silently ship the rejected combination.
fn check_i2v_end_frame_resolution(
resolution: Option<ViduQ3ImageToVideoResolution>,
has_end_frame: bool,
) -> Result<(), ArtcraftRouterError> {
if has_end_frame && matches!(resolution, Some(ViduQ3ImageToVideoResolution::ThreeSixtyP)) {
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "resolution",
value: "Vidu Q3 image-to-video does not support 360p when an end_frame is provided".to_string(),
}));
}
Ok(())
}
// ── Plan helpers ──
fn plan_aspect_ratio(
aspect_ratio: Option<RouterAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<PlanAspectRatio>, ArtcraftRouterError> {
use PlanAspectRatio as Ar;
match aspect_ratio {
None => Ok(None),
Some(RouterAspectRatio::Square) | Some(RouterAspectRatio::SquareHd) => Ok(Some(Ar::Square)),
Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Some(Ar::SixteenByNine)),
Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Some(Ar::NineBySixteen)),
Some(RouterAspectRatio::WideFourByThree) => Ok(Some(Ar::FourByThree)),
Some(RouterAspectRatio::TallThreeByFour) => Ok(Some(Ar::ThreeByFour)),
Some(RouterAspectRatio::Auto)
| Some(RouterAspectRatio::Auto2k)
| Some(RouterAspectRatio::Auto3k)
| Some(RouterAspectRatio::Auto4k) => Ok(Some(Ar::SixteenByNine)),
Some(other) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "aspect_ratio",
value: format!("{:?}", other),
}))
}
_ => Ok(Some(Ar::SixteenByNine)),
},
}
}
fn plan_resolution(
resolution: Option<RouterResolution>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<PlanResolution>, ArtcraftRouterError> {
match resolution {
None => Ok(None),
// Vidu doesn't offer 480p; 540p is the nearest supported size (and bills
// at the same per-second rate as 360p, so nothing cheaper is lost).
Some(RouterResolution::FourEightyP) => Ok(Some(PlanResolution::FiveFortyP)),
Some(RouterResolution::SevenTwentyP) => Ok(Some(PlanResolution::SevenTwentyP)),
Some(RouterResolution::TenEightyP) => Ok(Some(PlanResolution::TenEightyP)),
Some(other) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "resolution",
value: format!("{:?}", other),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Some(PlanResolution::TenEightyP)),
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(PlanResolution::FiveFortyP)),
},
}
}
fn plan_duration(
duration_seconds: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<u8>, ArtcraftRouterError> {
match duration_seconds {
None => Ok(None),
Some(seconds) if (MIN_DURATION_SECONDS..=MAX_DURATION_SECONDS).contains(&seconds) => {
Ok(Some(seconds as u8))
}
Some(other) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "duration_seconds",
value: format!("{}", other),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(Some(other.clamp(MIN_DURATION_SECONDS, MAX_DURATION_SECONDS) as u8))
}
},
}
}
// ── Leaf converters ──
fn to_t2v_aspect_ratio(a: PlanAspectRatio) -> ViduQ3TextToVideoAspectRatio {
match a {
PlanAspectRatio::SixteenByNine => ViduQ3TextToVideoAspectRatio::SixteenByNine,
PlanAspectRatio::NineBySixteen => ViduQ3TextToVideoAspectRatio::NineBySixteen,
PlanAspectRatio::FourByThree => ViduQ3TextToVideoAspectRatio::FourByThree,
PlanAspectRatio::ThreeByFour => ViduQ3TextToVideoAspectRatio::ThreeByFour,
PlanAspectRatio::Square => ViduQ3TextToVideoAspectRatio::Square,
}
}
fn to_reference_aspect_ratio(a: PlanAspectRatio) -> ViduQ3ReferenceToVideoAspectRatio {
match a {
PlanAspectRatio::SixteenByNine => ViduQ3ReferenceToVideoAspectRatio::SixteenByNine,
PlanAspectRatio::NineBySixteen => ViduQ3ReferenceToVideoAspectRatio::NineBySixteen,
PlanAspectRatio::FourByThree => ViduQ3ReferenceToVideoAspectRatio::FourByThree,
PlanAspectRatio::ThreeByFour => ViduQ3ReferenceToVideoAspectRatio::ThreeByFour,
PlanAspectRatio::Square => ViduQ3ReferenceToVideoAspectRatio::Square,
}
}
fn to_mix_aspect_ratio(a: PlanAspectRatio) -> ViduQ3ReferenceToVideoMixAspectRatio {
match a {
PlanAspectRatio::SixteenByNine => ViduQ3ReferenceToVideoMixAspectRatio::SixteenByNine,
PlanAspectRatio::NineBySixteen => ViduQ3ReferenceToVideoMixAspectRatio::NineBySixteen,
PlanAspectRatio::FourByThree => ViduQ3ReferenceToVideoMixAspectRatio::FourByThree,
PlanAspectRatio::ThreeByFour => ViduQ3ReferenceToVideoMixAspectRatio::ThreeByFour,
PlanAspectRatio::Square => ViduQ3ReferenceToVideoMixAspectRatio::Square,
}
}
fn to_t2v_resolution(r: PlanResolution) -> ViduQ3TextToVideoResolution {
match r {
PlanResolution::FiveFortyP => ViduQ3TextToVideoResolution::FiveFortyP,
PlanResolution::SevenTwentyP => ViduQ3TextToVideoResolution::SevenTwentyP,
PlanResolution::TenEightyP => ViduQ3TextToVideoResolution::TenEightyP,
}
}
fn to_i2v_resolution(r: PlanResolution) -> ViduQ3ImageToVideoResolution {
match r {
PlanResolution::FiveFortyP => ViduQ3ImageToVideoResolution::FiveFortyP,
PlanResolution::SevenTwentyP => ViduQ3ImageToVideoResolution::SevenTwentyP,
PlanResolution::TenEightyP => ViduQ3ImageToVideoResolution::TenEightyP,
}
}
fn to_reference_resolution(r: PlanResolution) -> ViduQ3ReferenceToVideoResolution {
match r {
PlanResolution::FiveFortyP => ViduQ3ReferenceToVideoResolution::FiveFortyP,
PlanResolution::SevenTwentyP => ViduQ3ReferenceToVideoResolution::SevenTwentyP,
PlanResolution::TenEightyP => ViduQ3ReferenceToVideoResolution::TenEightyP,
}
}
fn to_mix_resolution(r: PlanResolution) -> ViduQ3ReferenceToVideoMixResolution {
match r {
PlanResolution::FiveFortyP => ViduQ3ReferenceToVideoMixResolution::FiveFortyP,
PlanResolution::SevenTwentyP => ViduQ3ReferenceToVideoMixResolution::SevenTwentyP,
PlanResolution::TenEightyP => ViduQ3ReferenceToVideoMixResolution::TenEightyP,
}
}
#[cfg(test)]
mod tests {
use crate::api::image_ref::ImageRef;
use crate::api::router_provider::RouterProvider;
use crate::api::router_video_model::RouterVideoModel;
use super::*;
const START_URL: &str = "https://example.com/start.png";
const END_URL: &str = "https://example.com/end.png";
mod dispatch_tests {
use super::*;
#[test]
fn no_inputs_picks_t2v() {
let state = build_fal_vidu_q3_state(base_builder()).expect("build");
assert!(matches!(state.mode, FalViduQ3Mode::TextToVideo(_)));
}
#[test]
fn start_frame_picks_i2v() {
let mut b = base_builder();
b.start_frame = Some(ImageRef::Url(START_URL.to_string()));
let state = build_fal_vidu_q3_state(b).expect("build");
assert!(matches!(state.mode, FalViduQ3Mode::ImageToVideo(_)));
}
#[test]
fn start_and_end_frame_picks_i2v_with_end_image_url() {
let mut b = base_builder();
b.start_frame = Some(ImageRef::Url(START_URL.to_string()));
b.end_frame = Some(ImageRef::Url(END_URL.to_string()));
let state = build_fal_vidu_q3_state(b).expect("build");
let FalViduQ3Mode::ImageToVideo(req) = state.mode else {
panic!("expected ImageToVideo");
};
assert_eq!(req.image_url, START_URL);
assert_eq!(req.end_image_url.as_deref(), Some(END_URL));
}
#[test]
fn end_frame_without_start_frame_errors() {
let mut b = base_builder();
b.end_frame = Some(ImageRef::Url(END_URL.to_string()));
assert!(build_fal_vidu_q3_state(b).is_err());
}
#[test]
fn one_reference_image_picks_reference_to_video() {
let state = build_fal_vidu_q3_state(builder_with_references(1)).expect("build");
assert!(matches!(state.mode, FalViduQ3Mode::ReferenceToVideo(_)));
}
#[test]
fn three_reference_images_pick_reference_to_video_mix() {
let state = build_fal_vidu_q3_state(builder_with_references(3)).expect("build");
let FalViduQ3Mode::ReferenceToVideoMix(req) = state.mode else {
panic!("expected ReferenceToVideoMix");
};
assert_eq!(req.reference_image_urls.len(), 3);
}
#[test]
fn five_reference_images_error() {
assert!(build_fal_vidu_q3_state(builder_with_references(5)).is_err());
}
#[test]
fn reference_images_with_start_frame_error() {
let mut b = builder_with_references(2);
b.start_frame = Some(ImageRef::Url(START_URL.to_string()));
assert!(build_fal_vidu_q3_state(b).is_err());
}
#[test]
fn reference_videos_error() {
let mut b = base_builder();
b.reference_videos = Some(VideoListRef::Urls(vec!["https://example.com/a.mp4".to_string()]));
assert!(build_fal_vidu_q3_state(b).is_err());
}
}
mod plan_tests {
use super::*;
#[test]
fn duration_20_errors_under_error_out() {
let mut b = base_builder();
b.duration_seconds = Some(20);
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut;
assert!(build_fal_vidu_q3_state(b).is_err());
}
#[test]
fn duration_20_clamps_to_16_under_pay_less() {
let mut b = base_builder();
b.duration_seconds = Some(20);
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::PayLessDowngrade;
let state = build_fal_vidu_q3_state(b).expect("build");
let FalViduQ3Mode::TextToVideo(req) = state.mode else {
panic!("expected TextToVideo");
};
assert_eq!(req.duration, Some(16));
}
#[test]
fn resolution_480p_maps_to_540p() {
let mut b = base_builder();
b.resolution = Some(RouterResolution::FourEightyP);
let state = build_fal_vidu_q3_state(b).expect("build");
let FalViduQ3Mode::TextToVideo(req) = state.mode else {
panic!("expected TextToVideo");
};
assert_eq!(req.resolution, Some(ViduQ3TextToVideoResolution::FiveFortyP));
}
#[test]
fn i2v_aspect_ratio_is_silently_dropped() {
let mut b = base_builder();
b.start_frame = Some(ImageRef::Url(START_URL.to_string()));
b.aspect_ratio = Some(RouterAspectRatio::TallNineBySixteen);
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut;
// Image-to-video has no aspect_ratio field; the request still builds.
let state = build_fal_vidu_q3_state(b).expect("build");
assert!(matches!(state.mode, FalViduQ3Mode::ImageToVideo(_)));
}
#[test]
fn i2v_360p_with_end_frame_is_rejected_by_guard() {
// 360p is unreachable from RouterResolution today, so exercise the
// guard directly against the fal-level resolution enum.
let result = check_i2v_end_frame_resolution(
Some(ViduQ3ImageToVideoResolution::ThreeSixtyP),
true,
);
assert!(result.is_err());
assert!(check_i2v_end_frame_resolution(Some(ViduQ3ImageToVideoResolution::ThreeSixtyP), false).is_ok());
assert!(check_i2v_end_frame_resolution(Some(ViduQ3ImageToVideoResolution::SevenTwentyP), true).is_ok());
}
#[test]
fn generate_audio_propagates() {
let mut b = base_builder();
b.generate_audio = Some(false);
let state = build_fal_vidu_q3_state(b).expect("build");
let FalViduQ3Mode::TextToVideo(req) = state.mode else {
panic!("expected TextToVideo");
};
assert_eq!(req.audio, Some(false));
}
}
fn base_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: RouterVideoModel::ViduQ3,
provider: RouterProvider::Fal,
prompt: Some("test".to_string()),
..Default::default()
}
}
fn builder_with_references(count: usize) -> GenerateVideoRequestBuilder {
let urls = (0..count)
.map(|i| format!("https://example.com/ref-{}.png", i))
.collect();
let mut b = base_builder();
b.reference_images = Some(ImageListRef::Urls(urls));
b
}
}
@@ -0,0 +1,141 @@
use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator;
use crate::generate::generate_video::providers::fal::vidu_q3::request::{
FalViduQ3Mode, FalViduQ3RequestState,
};
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
#[derive(Clone, Debug)]
pub struct FalViduQ3CostState {
pub cost_in_usd_cents: u64,
}
impl FalViduQ3CostState {
pub fn from_request(request: &FalViduQ3RequestState) -> Self {
// Cost math is owned by fal_client's per-endpoint
// `FalRequestCostCalculator` implementations. The router state just
// forwards the result so router cost ≡ fal_client cost by construction.
let cost_in_usd_cents = match &request.mode {
FalViduQ3Mode::TextToVideo(req) => req.calculate_cost_in_cents(),
FalViduQ3Mode::ImageToVideo(req) => req.calculate_cost_in_cents(),
FalViduQ3Mode::ReferenceToVideo(req) => req.calculate_cost_in_cents(),
FalViduQ3Mode::ReferenceToVideoMix(req) => req.calculate_cost_in_cents(),
};
Self { cost_in_usd_cents }
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
VideoGenerationCostEstimate {
cost_in_credits: Some(self.cost_in_usd_cents),
cost_in_usd_cents: Some(self.cost_in_usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
}
#[cfg(test)]
mod tests {
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::router_provider::RouterProvider;
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::providers::fal::vidu_q3::build::build_fal_vidu_q3_state;
use super::*;
// Pricing (from fal_client's vidu_q3 cost modules):
// 360p/540p: $0.07/sec → 5s = 35¢
// 720p/1080p: $0.154/sec → 5s = 77¢, 10s = 154¢ (ceil to whole cents)
// fal defaults when unset: duration = 5s, resolution = 720p.
// All four modes (text/image/reference/mix) bill identically.
//
// NB: `build2()` isn't wired up for Vidu Q3 yet, so these tests go through
// `build_fal_vidu_q3_state()` directly.
#[test]
fn t2v_default_settings_is_77() {
assert_eq!(cost_cents(base_builder(None, None)), 77);
}
#[test]
fn t2v_5s_720p_is_77() {
assert_eq!(cost_cents(base_builder(Some(5), Some(RouterResolution::SevenTwentyP))), 77);
}
#[test]
fn t2v_5s_540p_is_35() {
// RouterResolution::FourEightyP maps to Vidu's 540p.
assert_eq!(cost_cents(base_builder(Some(5), Some(RouterResolution::FourEightyP))), 35);
}
#[test]
fn t2v_10s_1080p_is_154() {
assert_eq!(cost_cents(base_builder(Some(10), Some(RouterResolution::TenEightyP))), 154);
}
#[test]
fn t2v_3s_720p_rounds_up_to_47() {
// 3 × 15.4¢ = 46.2¢ → 47¢.
assert_eq!(cost_cents(base_builder(Some(3), Some(RouterResolution::SevenTwentyP))), 47);
}
#[test]
fn i2v_5s_720p_is_77() {
let mut b = base_builder(Some(5), Some(RouterResolution::SevenTwentyP));
b.start_frame = Some(ImageRef::Url("https://example.com/start.png".to_string()));
assert_eq!(cost_cents(b), 77);
}
#[test]
fn reference_5s_540p_is_35() {
let mut b = base_builder(Some(5), Some(RouterResolution::FourEightyP));
b.reference_images = Some(ImageListRef::Urls(vec![
"https://example.com/ref-0.png".to_string(),
]));
assert_eq!(cost_cents(b), 35);
}
#[test]
fn reference_mix_10s_1080p_is_154() {
let mut b = base_builder(Some(10), Some(RouterResolution::TenEightyP));
b.reference_images = Some(ImageListRef::Urls(vec![
"https://example.com/ref-0.png".to_string(),
"https://example.com/ref-1.png".to_string(),
"https://example.com/ref-2.png".to_string(),
]));
assert_eq!(cost_cents(b), 154);
}
#[test]
fn estimate_cost_forwards_cents() {
let estimate = FalViduQ3CostState { cost_in_usd_cents: 77 }.estimate_cost();
assert_eq!(estimate.cost_in_usd_cents, Some(77));
assert_eq!(estimate.cost_in_credits, Some(77));
assert!(!estimate.is_free);
}
fn base_builder(
duration_seconds: Option<u16>,
resolution: Option<RouterResolution>,
) -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: RouterVideoModel::ViduQ3,
provider: RouterProvider::Fal,
prompt: Some("test".to_string()),
duration_seconds,
resolution,
..Default::default()
}
}
fn cost_cents(builder: GenerateVideoRequestBuilder) -> u64 {
let state = build_fal_vidu_q3_state(builder).expect("build");
FalViduQ3CostState::from_request(&state).cost_in_usd_cents
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -0,0 +1,146 @@
use std::fmt::Debug;
use std::sync::Arc;
use fal_client::requests::api::video::image::vidu_q3::api::ViduQ3ImageToVideoRequest;
use fal_client::requests::api::video::reference::vidu_q3_reference_to_video::api::ViduQ3ReferenceToVideoRequest;
use fal_client::requests::api::video::reference::vidu_q3_reference_to_video_mix::api::ViduQ3ReferenceToVideoMixRequest;
use fal_client::requests::api::video::text::vidu_q3::api::ViduQ3TextToVideoRequest;
use fal_client::requests::traits::fal_endpoint_trait::FalEndpoint;
use crate::client::router_fal_client::RouterFalClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_response::{
FalVideoResponsePayload, GenerateVideoResponse,
};
#[derive(Clone, Debug)]
pub enum FalViduQ3Mode {
TextToVideo(ViduQ3TextToVideoRequest),
ImageToVideo(ViduQ3ImageToVideoRequest),
ReferenceToVideo(ViduQ3ReferenceToVideoRequest),
ReferenceToVideoMix(ViduQ3ReferenceToVideoMixRequest),
}
#[derive(Clone, Debug)]
pub struct FalViduQ3RequestState {
pub mode: FalViduQ3Mode,
}
impl FalViduQ3RequestState {
pub async fn send(&self, client: &RouterFalClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
match &self.mode {
FalViduQ3Mode::TextToVideo(request) => send_request(request, client).await,
FalViduQ3Mode::ImageToVideo(request) => send_request(request, client).await,
FalViduQ3Mode::ReferenceToVideo(request) => send_request(request, client).await,
FalViduQ3Mode::ReferenceToVideoMix(request) => send_request(request, client).await,
}
}
}
// ── Helpers ──
async fn send_request<T>(request: &T, client: &RouterFalClient) -> Result<GenerateVideoResponse, ArtcraftRouterError>
where
T: FalEndpoint + Clone + Debug + Send + Sync + 'static,
{
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let payload = send_fal_request(request, client).await?;
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
request_id: payload.request_id,
gateway_request_id: payload.gateway_request_id,
maybe_status_url: payload.status_url,
maybe_response_url: payload.response_url,
maybe_outbound_request: Some(outbound),
}))
}
struct FalResponseIds {
request_id: Option<String>,
gateway_request_id: Option<String>,
status_url: Option<String>,
response_url: Option<String>,
}
async fn send_fal_request<T: FalEndpoint>(
request: &T,
client: &RouterFalClient,
) -> Result<FalResponseIds, ArtcraftRouterError> {
if let Some(webhook_url) = &client.webhook_url {
let response = request.send_webhook_request(&client.api_key, webhook_url).await?;
Ok(FalResponseIds {
request_id: response.request_id,
gateway_request_id: response.gateway_request_id,
status_url: None,
response_url: None,
})
} else {
let response = request.send_queue_request(&client.api_key).await?;
Ok(FalResponseIds {
request_id: Some(response.request_id),
gateway_request_id: None,
status_url: Some(response.status_url),
response_url: Some(response.response_url),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use fal_client::creds::fal_api_key::FalApiKey;
use fal_client::requests::api::video::image::vidu_q3::api::ViduQ3ImageToVideoResolution;
use fal_client::requests::api::video::text::vidu_q3::api::{
ViduQ3TextToVideoAspectRatio, ViduQ3TextToVideoResolution,
};
use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL;
fn read_fal_api_key() -> FalApiKey {
let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")
.expect("Failed to read fal_api_key.txt");
FalApiKey::from_str(secret.trim())
}
fn client_with_webhook() -> RouterFalClient {
RouterFalClient::new_with_webhook(
read_fal_api_key(),
"https://example.com/fal-webhook-test".to_string(),
)
}
#[tokio::test]
#[ignore] // requires real API key, incurs cost
async fn send_text_to_video_webhook() {
let state = FalViduQ3RequestState {
mode: FalViduQ3Mode::TextToVideo(ViduQ3TextToVideoRequest {
prompt: "a calm lake at sunrise".to_string(),
duration: Some(5),
seed: None,
aspect_ratio: Some(ViduQ3TextToVideoAspectRatio::SixteenByNine),
resolution: Some(ViduQ3TextToVideoResolution::SevenTwentyP),
audio: Some(false),
}),
};
let response = state.send(&client_with_webhook()).await.expect("send should succeed");
let payload = response.get_fal_payload().expect("expected Fal payload");
assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some());
}
#[tokio::test]
#[ignore] // requires real API key, incurs cost
async fn send_image_to_video_webhook() {
let state = FalViduQ3RequestState {
mode: FalViduQ3Mode::ImageToVideo(ViduQ3ImageToVideoRequest {
prompt: "the lake comes alive".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 response = state.send(&client_with_webhook()).await.expect("send should succeed");
let payload = response.get_fal_payload().expect("expected Fal payload");
assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some());
}
}

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