Compare commits

...
Author SHA1 Message Date
Hanashi c31ddb32c7 improve code 2026-08-07 00:58:08 -04:00
Hanashi 25ede499d2 adjust endpoints 2026-08-07 00:21:39 -04:00
Hanashi a6f24d6aca endpoints wip 2026-08-06 20:47:10 -04:00
hanashi 9020f0aa5b minimax turbo (#1852) 2026-08-06 18:21:28 -04:00
BFlat c2e3c27a72 Model list: names, icons, descriptions for new omni gen models (MiniM… (#1851)
…ax H3, Flux 3, Veo 3.1 Lite, Vidu Q3, 3D/world)
2026-08-06 23:32:37 +07:00
BFlat 22847961e1 Admin dashboard: bulk ban (#1850) 2026-08-06 22:58:37 +07:00
BFlat f72981875c Website: beta sign up remove scripts (#1849) 2026-08-06 13:07:13 +07:00
BFlat 39e89c76e8 Website: beta sign up page (#1848) 2026-08-06 12:43:47 +07:00
hanashi a473be78bd new endpoint for media files by job token (#1846) 2026-08-04 23:13:30 -04:00
hanashi 36cfba0000 media files - record job token (#1845) 2026-08-04 22:09:08 -04:00
hanashi a5f59d7995 Flux3 (#1844) 2026-08-04 15:12:32 -04:00
bflatastic 6329045350 Hotfix: mention autocomplete popover colors + darker chips on mobile 2026-08-04 23:19:49 +07:00
bflatastic cbfed0017b Hotfix: mobile mention popover positioning, chip replace closing early & duplicating text 2026-08-04 23:08:46 +07:00
bflatastic 099e3cb27b Hotfix: mobile mention autocomplete taps not selecting 2026-08-04 22:51:47 +07:00
BFlat 9c18f746d4 Character preview & mobile drawer improvements (#1843)
- Characters modal: new eye button on character cards to view the image
full size
- Mention chip menu: tapping the avatar thumbnail also opens the
full-size view
- Card buttons (view/edit/delete) are always visible on touch devices
instead of hover-only
- Mobile: Characters open in a bottom drawer with a 2-column grid,
matching the other settings drawers
- All mobile drawers now have a close (X) button
- All mobile drawers can be pulled down to dismiss - the sheet follows
your thumb and closes from where you let go
2026-08-04 22:38:32 +07:00
BFlat e4fc9446d8 Mention fixes (promptbox) (#1842)
- Mobile: mention autocomplete list can now be scrolled - dragging no
longer selects an item, only a tap does
- Fixed taps on autocomplete items sometimes not registering on
Android/iOS
- Deleting the space after a mention now reopens the autocomplete next
to the mention instead of at the top of the screen
- Clicking a mention chip while its menu is open now closes the menu
instead of closing and reopening it (desktop + mobile)
2026-08-04 21:08:21 +07:00
hanashi 37b7f3e88f Minimax (#1840) 2026-08-04 05:31:17 -04:00
hanashi 60b4148179 web + API for omni gen endpoints (#1836) 2026-08-03 16:22:16 -04:00
hanashi a4d9a4d199 this fixes another set of negative margins (#1834) 2026-08-02 18:22:04 -04:00
hanashi 2b8cc5ef8d margins for video references were costing us money, fixing (#1833) 2026-08-02 17:29:06 -04:00
hanashi 9122be6752 we're losing money on seedance in this configuration (need to fix) (#1832) 2026-08-02 17:02:49 -04:00
hanashi 31054141cc costs for seedance 2.5 are costing us money (#1831)
we're losing money on this model, gotta plug it (there are other holes
to fix too)
2026-08-02 15:58:08 -04:00
BFlat 6b1b4c7b0d temporarily hide downloads (#1830) 2026-08-02 10:57:54 +07:00
BFlat 5f46ee0e7e Added missing model names & icons mappings (#1829)
- Added missing display names for Seedance 2.5 Preview and ~25 other
models (Vidu Q3, Veo 3.1 Lite, Seedream 5.0 Pro, new Hunyuan 3D 3.1
variants, Marble 1.x, and more) so the task queue and history show
proper names instead of raw IDs.
- Vidu, Recraft, and SeedEdit models now show their brand icons instead
of the generic one.
2026-08-02 07:47:43 +07:00
BFlat 9cdad367c0 model selector 2.5 coming soon removal and reference deplicated tokens bug fix (#1828)
## Fixes & improvements

### Model picker
- Removed the "Seedance 2.5 - SOON" placeholder from the video model
picker; it was confusing now that Seedance 2.5 Preview is live.

### "Not all media files could be found" error
- Fixed generations failing when the same media file ended up twice in
one reference list (e.g. added as two references, or the same file
uploaded twice).
- Requests now automatically drop duplicate media references before
sending - applies to image, video, audio, mesh, and splat generation on
both the web app and the desktop app.

### Gallery picker UX
- Media already added to a slot is now greyed out with an "Added" badge
in the library picker, so the same file can't be added twice to the same
field.
- Applies per slot across the create pages (video, image, audio, 3D
object, world) on web and desktop - references, start frame, end frame,
video refs, audio refs, and multi-view slots.
- Reusing the same image across different slots (e.g. start *and* end
frame, or two 3D views) is still allowed.
2026-08-02 07:32:41 +07:00
hanashi c026a7c572 seedance 2.5 preview in Omni API (#1827) 2026-08-01 16:07:05 -04:00
hanashi 62051aa49f create material (#1826) 2026-08-01 10:48:19 -04:00
268 changed files with 13017 additions and 643 deletions
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nUPDATE generic_inference_jobs\nSET\n status = ?,\n on_success_result_entity_type = ?,\n on_success_result_entity_token = ?,\n failure_reason = NULL,\n internal_debugging_failure_reason = NULL,\n success_execution_millis = ?,\n success_inference_execution_millis = ?,\n retry_at = NULL,\n successfully_completed_at = NOW()\nWHERE token = ?\nAND status NOT IN ('complete_success', 'complete_failure')\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 6
},
"nullable": []
},
"hash": "03919ba8286d18980ecaa0038df76f43f5388db0d3838181fa8f15f4cbb932af"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nUPDATE generic_inference_jobs\nSET\n status = ?,\n assigned_worker = ?,\n last_assigned_worker = ?,\n assigned_cluster = ?,\n first_started_at = NOW()\nWHERE token = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 5
},
"nullable": []
},
"hash": "258ac40d3fab39f110e1e82d50e73542423bfe9a21f8127e6cd83214c1726e25"
}
@@ -1,6 +1,6 @@
{
"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 ",
"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 users.maybe_feature_flags,\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": [
{
@@ -72,6 +72,26 @@
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 7,
"name": "maybe_feature_flags",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 8,
"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": {
@@ -84,8 +104,10 @@
false,
false,
false,
true
true,
true,
false
]
},
"hash": "c77126adb36f9e2f0248720eb4d8c1cd9d14719f64a02678d8021e26addd2c18"
}
"hash": "62b3e2e058331a6819acd98c634b3bf2d10aa38d43b49ab69fb8637497beec2f"
}
@@ -0,0 +1,36 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n token as `job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,\n maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`\nFROM generic_inference_jobs\nWHERE job_type = ?\nAND status = 'pending'\nORDER BY id ASC\nLIMIT 1\nFOR UPDATE SKIP LOCKED\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "maybe_prompt_token: tokens::tokens::prompts::PromptToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
true
]
},
"hash": "6ca0fcdf495624cf347f612586868f6dea93f52d9f942a92dcd2b3e6aba7c63c"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nUPDATE generic_inference_jobs\nSET\n status = ?,\n frontend_failure_category = ?,\n failure_reason = ?,\n internal_debugging_failure_reason = ?,\n success_execution_millis = ?,\n success_inference_execution_millis = ?\nWHERE token = ?\nAND status NOT IN ('complete_success', 'complete_failure')\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 7
},
"nullable": []
},
"hash": "96599b4854248d84188b092db84edb50dc5402bb7771fd8ea96a96ad43e732c0"
}
@@ -0,0 +1,179 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n m.token as `token: tokens::tokens::media_files::MediaFileToken`,\n\n m.media_class as `media_class: enums::by_table::media_files::media_file_class::MediaFileClass`,\n m.media_type as `media_type: enums::by_table::media_files::media_file_type::MediaFileType`,\n\n m.maybe_batch_token as `maybe_batch_token: tokens::tokens::batch_generations::BatchGenerationToken`,\n m.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,\n\n m.public_bucket_directory_hash,\n m.maybe_public_bucket_prefix,\n m.maybe_public_bucket_extension,\n\n media_file_cover_image.public_bucket_directory_hash as maybe_file_cover_image_public_bucket_hash,\n media_file_cover_image.maybe_public_bucket_prefix as maybe_file_cover_image_public_bucket_prefix,\n media_file_cover_image.maybe_public_bucket_extension as maybe_file_cover_image_public_bucket_extension,\n\n m.maybe_origin_filename,\n m.maybe_duration_millis as `maybe_duration_millis: u64`,\n\n m.created_at,\n m.updated_at\n\nFROM media_files AS m\nLEFT OUTER JOIN media_files as media_file_cover_image\n ON media_file_cover_image.token = m.maybe_cover_image_media_file_token\nWHERE\n m.maybe_source_job_token = ?\n AND m.maybe_creator_user_token = ?\n AND m.user_deleted_at IS NULL\n AND m.mod_deleted_at IS NULL\n AND NOT m.is_intermediate_system_file\nORDER BY m.id ASC\nLIMIT ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token: tokens::tokens::media_files::MediaFileToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "media_class: enums::by_table::media_files::media_file_class::MediaFileClass",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 2,
"name": "media_type: enums::by_table::media_files::media_file_type::MediaFileType",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 3,
"name": "maybe_batch_token: tokens::tokens::batch_generations::BatchGenerationToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 4,
"name": "maybe_prompt_token: tokens::tokens::prompts::PromptToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 5,
"name": "public_bucket_directory_hash",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 6,
"name": "maybe_public_bucket_prefix",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 7,
"name": "maybe_public_bucket_extension",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 8,
"name": "maybe_file_cover_image_public_bucket_hash",
"type_info": {
"type": "VarString",
"flags": "BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 9,
"name": "maybe_file_cover_image_public_bucket_prefix",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 10,
"name": "maybe_file_cover_image_public_bucket_extension",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 11,
"name": "maybe_origin_filename",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 12,
"name": "maybe_duration_millis: u64",
"type_info": {
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 10
}
},
{
"ordinal": 13,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 14,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP | ON_UPDATE_NOW",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 3
},
"nullable": [
false,
false,
false,
true,
true,
false,
true,
true,
true,
true,
true,
true,
true,
false,
false
]
},
"hash": "cd04f4f7b48ae9da53b6a210111f8e3edff94dee30263e22b45d5e29dbe9245a"
}
@@ -1,6 +1,6 @@
{
"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 ",
"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 users.maybe_feature_flags\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": [
{
@@ -75,12 +75,12 @@
},
{
"ordinal": 7,
"name": "api_key_token: ApiKeyToken",
"name": "maybe_feature_flags",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
"max_size": 1020
}
}
],
@@ -95,8 +95,8 @@
false,
false,
true,
false
true
]
},
"hash": "9188e235caa439d50628333c2f4722b9f1a8f575c192d4d36a2039709c412ac0"
}
"hash": "cf2794c87868e77dfaa45bdbe630ae276dbb8c2d041442ba465af0b7a6d61af6"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\n INSERT INTO media_files\n SET\n token = ?,\n\n media_class = ?,\n media_type = ?,\n\n is_user_upload = ?,\n is_intermediate_system_file = ?,\n\n origin_category = ?,\n origin_product_category = ?,\n maybe_origin_model_type = ?,\n maybe_origin_model_token = ?,\n maybe_origin_filename = ?,\n\n maybe_batch_token = ?,\n maybe_source_job_token = ?,\n\n maybe_mime_type = ?,\n file_size_bytes = ?,\n maybe_duration_millis = ?,\n maybe_audio_encoding = ?,\n maybe_video_encoding = ?,\n maybe_frame_width = ?,\n maybe_frame_height = ?,\n maybe_prompt_token = ?,\n checksum_sha2 = ?,\n\n maybe_engine_category = ?,\n\n maybe_title = ?,\n maybe_text_transcript = ?,\n\n maybe_scene_source_media_file_token = ?,\n\n public_bucket_directory_hash = ?,\n maybe_public_bucket_prefix = ?,\n maybe_public_bucket_extension = ?,\n\n maybe_creator_user_token = ?,\n maybe_creator_anonymous_visitor_token = ?,\n\n creator_ip_address = ?,\n creator_set_visibility = ?,\n\n maybe_creator_file_synthetic_id = ?,\n maybe_creator_category_synthetic_id = ?,\n\n extra_file_modification_info = ?,\n\n maybe_generation_provider = ?,\n\n platform_type = ?,\n\n maybe_cover_image_media_file_token = ?,\n\n maybe_mod_user_token = ?,\n is_generated_on_prem = ?,\n generated_by_worker = ?,\n generated_by_cluster = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 42
},
"nullable": []
},
"hash": "d88098343686ded52a71fc45a1b96d28ea3003975d6e7d20eee2022aa31d8189"
}
@@ -0,0 +1,124 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n token as `job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,\n status as `status: enums::common::job_status_plus::JobStatusPlus`,\n job_type as `job_type: enums::by_table::generic_inference_jobs::inference_job_type::InferenceJobType`,\n maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n maybe_creator_anonymous_visitor_token as `maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken`,\n creator_ip_address,\n maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,\n maybe_debug_log_event_token as `maybe_debug_log_event_token: tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken`,\n platform_type as `maybe_platform_type: enums::common::platform_type::PlatformType`,\n on_success_result_entity_token as `maybe_result_media_file_token: tokens::tokens::media_files::MediaFileToken`\nFROM generic_inference_jobs\nWHERE token = ?\nAND job_type IN ('artcraft_minimax_h3_turbo', 'artcraft_minimax_h3_ultra')\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "status: enums::common::job_status_plus::JobStatusPlus",
"type_info": {
"type": "String",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | ENUM",
"char_set": 224,
"max_size": 76
}
},
{
"ordinal": 2,
"name": "job_type: enums::by_table::generic_inference_jobs::inference_job_type::InferenceJobType",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"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": "maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 5,
"name": "creator_ip_address",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 160
}
},
{
"ordinal": 6,
"name": "maybe_prompt_token: tokens::tokens::prompts::PromptToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 7,
"name": "maybe_debug_log_event_token: tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 8,
"name": "maybe_platform_type: enums::common::platform_type::PlatformType",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 9,
"name": "maybe_result_media_file_token: tokens::tokens::media_files::MediaFileToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
true,
true,
true,
false,
true,
true,
true,
true
]
},
"hash": "f1d41ad17cb01f5aef0ce2eea042ccb3e5e649365a0befaf0f46492b75523442"
}
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\n INSERT INTO media_files\n SET\n token = ?,\n\n media_class = ?,\n media_type = ?,\n\n is_user_upload = ?,\n is_intermediate_system_file = ?,\n\n origin_category = ?,\n origin_product_category = ?,\n maybe_origin_model_type = ?,\n maybe_origin_model_token = ?,\n maybe_origin_filename = ?,\n\n maybe_batch_token = ?,\n\n maybe_mime_type = ?,\n file_size_bytes = ?,\n maybe_duration_millis = ?,\n maybe_audio_encoding = ?,\n maybe_video_encoding = ?,\n maybe_frame_width = ?,\n maybe_frame_height = ?,\n maybe_prompt_token = ?,\n checksum_sha2 = ?,\n\n maybe_engine_category = ?,\n\n maybe_title = ?,\n maybe_text_transcript = ?,\n\n maybe_scene_source_media_file_token = ?,\n\n public_bucket_directory_hash = ?,\n maybe_public_bucket_prefix = ?,\n maybe_public_bucket_extension = ?,\n\n maybe_creator_user_token = ?,\n maybe_creator_anonymous_visitor_token = ?,\n\n creator_ip_address = ?,\n creator_set_visibility = ?,\n\n maybe_creator_file_synthetic_id = ?,\n maybe_creator_category_synthetic_id = ?,\n\n extra_file_modification_info = ?,\n\n maybe_generation_provider = ?,\n\n platform_type = ?,\n\n maybe_cover_image_media_file_token = ?,\n\n maybe_mod_user_token = ?,\n is_generated_on_prem = ?,\n generated_by_worker = ?,\n generated_by_cluster = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 41
},
"nullable": []
},
"hash": "f5bc4a956a20f6c0998585092a0922301d53c95ae8ea9cb808ae2dbcf8d0bb32"
}
@@ -0,0 +1,7 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE media_files
DROP INDEX index_maybe_source_job_token,
DROP COLUMN maybe_source_job_token;
@@ -0,0 +1,13 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
-- The inference job ('jinf_...') whose completion produced this media file.
-- Only set for files written as job outputs; uploads and other origins stay NULL.
ALTER TABLE media_files
ADD COLUMN maybe_source_job_token VARCHAR(32) DEFAULT NULL;
-- For "outputs of this job"-style lookups.
-- The column is all-NULL at creation time, so the index build is cheap.
ALTER TABLE media_files
ADD INDEX index_maybe_source_job_token (maybe_source_job_token);
@@ -102,6 +102,10 @@ CREATE TABLE media_files (
-- If the media file is generated as part of a batch, this designates the batch.
maybe_batch_token VARCHAR(32) DEFAULT NULL,
-- The inference job ('jinf_...') whose completion produced this media file.
-- Only set for files written as job outputs; uploads and other origins stay NULL.
maybe_source_job_token VARCHAR(32) DEFAULT NULL,
-- ========== METADATA ==========
-- If the file is a user upload (not including studio renders).
@@ -327,6 +331,7 @@ CREATE TABLE media_files (
KEY fk_maybe_origin_model_token (maybe_origin_model_token),
KEY fk_maybe_origin_model_type_and_token (maybe_origin_model_type, maybe_origin_model_token),
KEY index_maybe_batch_token (maybe_batch_token),
KEY index_maybe_source_job_token (maybe_source_job_token),
KEY index_is_user_upload (is_user_upload),
KEY index_is_intermediate_system_file (is_intermediate_system_file),
KEY index_maybe_scene_source_media_file_token (maybe_scene_source_media_file_token),
@@ -0,0 +1,29 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use enums::by_table::generic_inference_jobs::frontend_failure_category::FrontendFailureCategory;
/// Request body for `POST /v1/internal/minimax_jobs/job/{job_token}/failure`.
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct MarkMinimaxJobFailureRequest {
/// Optional failure class the frontend can localize and present.
pub maybe_frontend_failure_category: Option<FrontendFailureCategory>,
/// Optional failure reason we can show the user (512 characters max).
pub maybe_user_failure_reason: Option<String>,
/// Optional internal-only stack trace or error (512 characters max).
pub maybe_internal_debugging_failure_reason: Option<String>,
/// Optional total wall-clock runtime of the failed attempt, in milliseconds.
pub execution_duration_millis: Option<u64>,
/// Optional inference-only runtime of the failed attempt, in milliseconds.
pub inference_duration_millis: Option<u64>,
}
/// Response body for `POST /v1/internal/minimax_jobs/job/{job_token}/failure`.
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct MarkMinimaxJobFailureResponse {
pub success: bool,
}
@@ -0,0 +1,29 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use tokens::tokens::media_files::MediaFileToken;
// NB: The success request is form-multipart (video bytes + optional
// `duration_millis` / `width` / `height` metadata fields), so its form struct
// lives with the actix handler; only the response shape is declared here.
/// Response body for `POST /v1/internal/minimax_jobs/job/{job_token}/success`.
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct MarkMinimaxJobSuccessResponse {
pub success: bool,
/// The media file created from the uploaded video.
pub media_file_token: MediaFileToken,
/// Video duration stored on the media file record, in milliseconds
/// (worker-supplied, or probed from the file when not supplied).
pub maybe_duration_millis: Option<u64>,
/// Frame width stored on the media file record, in pixels
/// (worker-supplied, or probed from the file when not supplied).
pub maybe_width: Option<u32>,
/// Frame height stored on the media file record, in pixels
/// (worker-supplied, or probed from the file when not supplied).
pub maybe_height: Option<u32>,
}
@@ -0,0 +1,33 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
/// The first-party Minimax models a worker can request jobs for.
///
/// This is deliberately its own enum (not `CommonVideoModel`) so the internal
/// worker API surface stays small and stable.
#[derive(Copy, Clone, Debug, Eq, PartialEq, Serialize, Deserialize, ToSchema)]
pub enum MinimaxWorkerModel {
#[serde(rename = "minimax_h3_turbo")]
MinimaxH3Turbo,
#[serde(rename = "minimax_h3_ultra")]
MinimaxH3Ultra,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn serde_round_trip() {
let cases = [
(MinimaxWorkerModel::MinimaxH3Turbo, "\"minimax_h3_turbo\""),
(MinimaxWorkerModel::MinimaxH3Ultra, "\"minimax_h3_ultra\""),
];
for (variant, json) in cases {
assert_eq!(serde_json::to_string(&variant).unwrap(), json);
let parsed: MinimaxWorkerModel = serde_json::from_str(json).unwrap();
assert_eq!(parsed, variant);
}
}
}
@@ -0,0 +1,4 @@
pub mod mark_minimax_job_failure;
pub mod mark_minimax_job_success;
pub mod minimax_worker_model;
pub mod obtain_minimax_job;
@@ -0,0 +1,85 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use enums::by_table::prompt_context_items::prompt_context_semantic_type::PromptContextSemanticType;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_bitrate::CommonBitrate;
use enums::common::generation::common_generation_mode::CommonGenerationMode;
use enums::common::generation::common_resolution::CommonResolution;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::prompts::PromptToken;
use crate::common::responses::media_links::MediaLinks;
use crate::internal::minimax_jobs::minimax_worker_model::MinimaxWorkerModel;
/// Request body for `POST /v1/internal/minimax_jobs/obtain_job`.
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct ObtainMinimaxJobRequest {
/// Which model the worker wants a job for.
pub model: MinimaxWorkerModel,
/// The worker's hostname (linux hostname, k8s pod name), if reported.
pub worker_hostname: Option<String>,
/// The cluster the worker runs in (e.g. "runpod", "lambdalabs"), if reported.
pub cluster_name: Option<String>,
}
/// Response body for `POST /v1/internal/minimax_jobs/obtain_job`.
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct ObtainMinimaxJobResponse {
pub success: bool,
/// The obtained job, or `None` when no pending job is available.
pub maybe_job: Option<ObtainedMinimaxJob>,
}
/// A job the worker now holds a lock on (status was moved to `started`).
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct ObtainedMinimaxJob {
pub job_token: InferenceJobToken,
/// Echo of the requested model.
pub model: MinimaxWorkerModel,
/// The prompt and inputs the worker needs to run inference. Only `None`
/// when the job was somehow enqueued without a prompt record (or the
/// prompt lookup failed) — workers should fail such jobs via the failure
/// endpoint.
pub maybe_prompt: Option<MinimaxJobPromptDetails>,
}
/// The prompt (text + generation settings) and its attached media references.
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct MinimaxJobPromptDetails {
pub prompt_token: PromptToken,
pub maybe_positive_prompt: Option<String>,
pub maybe_negative_prompt: Option<String>,
pub maybe_generation_mode: Option<CommonGenerationMode>,
pub maybe_aspect_ratio: Option<CommonAspectRatio>,
pub maybe_resolution: Option<CommonResolution>,
pub maybe_bitrate: Option<CommonBitrate>,
pub maybe_generate_audio: Option<bool>,
pub maybe_duration_seconds: Option<u32>,
/// Image / video / audio references attached to the prompt, with full CDN
/// links, in their original order.
pub media_references: Vec<MinimaxJobMediaReference>,
}
/// One media input attached to the prompt (start frame, end frame, or an
/// image / video / audio reference), with full CDN links.
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct MinimaxJobMediaReference {
pub media_file_token: MediaFileToken,
/// The role the media plays (start frame, end frame, image / video / audio
/// reference, etc.).
pub semantic_type: PromptContextSemanticType,
/// Full CDN links to the media file.
pub media_links: MediaLinks,
}
@@ -0,0 +1,4 @@
//! Internal-facing API surfaces (worker fleets, etc.). These are
//! authenticated with internal API keys, never consumer credentials.
pub mod minimax_jobs;
@@ -5,6 +5,7 @@ pub mod common;
pub mod credits;
pub mod folders;
pub mod generate;
pub mod internal;
pub mod jobs;
pub mod media_file;
pub mod moderation;
@@ -0,0 +1,61 @@
//! Types for `GET /v1/media_files/by_job/{job_token}` — list the media files
//! produced by an inference job.
use chrono::{DateTime, Utc};
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_type::MediaFileType;
use tokens::tokens::batch_generations::BatchGenerationToken;
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::prompts::PromptToken;
use crate::common::responses::media_file_cover_image_details::MediaFileCoverImageDetails;
use crate::common::responses::media_links::MediaLinks;
#[derive(Serialize, Deserialize, ToSchema)]
pub struct ListMediaFilesByJobSuccessResponse {
pub success: bool,
/// The job's output files, oldest first.
pub media_files: Vec<JobMediaFileInfo>,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct JobMediaFileInfo {
pub token: MediaFileToken,
/// The coarse-grained class of media file: image, video, etc.
pub media_class: MediaFileClass,
/// Type of media will dictate which fields are populated and what
/// the frontend should display (eg. video player vs audio player).
/// This is closer in meaning to a "mime type".
pub media_type: MediaFileType,
/// If the file was generated as part of a batch, this is the token for the batch.
pub maybe_batch_token: Option<BatchGenerationToken>,
/// The foreign key to the prompt used to generate the media, if applicable.
pub maybe_prompt_token: Option<PromptToken>,
/// Rich CDN links to the media, including thumbnails, previews, and more.
pub media_links: MediaLinks,
/// Information about the cover image. Many media files do not require a cover image,
/// e.g. image files, video files with thumbnails, audio files, etc.
/// 3D files require them.
pub cover_image: MediaFileCoverImageDetails,
/// The original filename for uploaded files, if they were provided.
/// In the future we'll provide our own internal optional filenames.
pub maybe_original_filename: Option<String>,
/// Duration for audio and video files, if available.
/// Measured in milliseconds.
pub maybe_duration_millis: Option<u64>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
@@ -0,0 +1 @@
pub mod list_media_files_by_job;
@@ -1,4 +1,5 @@
pub mod delete_media_file;
pub mod job;
pub mod list;
pub mod list_batch_generated_media_files;
pub mod list_session_project_media_files;
@@ -14,6 +14,12 @@ pub enum RouterVideoModel {
#[serde(rename = "grok_imagine_video_1p5")]
GrokImagineVideo1p5,
#[serde(rename = "flux_3")]
Flux3,
#[serde(rename = "flux_3_draft")]
Flux3Draft,
#[serde(rename = "kling_1p6_pro")]
Kling16Pro,
@@ -35,6 +41,15 @@ pub enum RouterVideoModel {
#[serde(rename = "kling_3p0_pro")]
Kling3p0Pro,
#[serde(rename = "minimax_h3")]
MinimaxH3,
#[serde(rename = "minimax_h3_turbo")]
MinimaxH3Turbo,
#[serde(rename = "minimax_h3_ultra")]
MinimaxH3Ultra,
#[serde(rename = "seedance_1p0_lite")]
Seedance10Lite,
@@ -149,4 +164,27 @@ mod tests {
assert_serde_round_trip(RouterVideoModel::ViduQ3, "vidu_q3");
assert_serde_round_trip(RouterVideoModel::ViduQ3Turbo, "vidu_q3_turbo");
}
#[test]
fn flux_3_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::Flux3, "flux_3");
assert_serde_round_trip(RouterVideoModel::Flux3Draft, "flux_3_draft");
}
#[test]
fn minimax_h3_serializes() {
// NB: This string must match `CommonVideoModel` — the two enums convert
// via serde string round-trip in the omni handlers.
assert_serde_round_trip(RouterVideoModel::MinimaxH3, "minimax_h3");
}
#[test]
fn minimax_h3_turbo_and_ultra_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::MinimaxH3Turbo, "minimax_h3_turbo");
assert_serde_round_trip(RouterVideoModel::MinimaxH3Ultra, "minimax_h3_ultra");
}
}
@@ -16,7 +16,9 @@ impl ArtcraftQwenEdit2511AnglesCostState {
}
pub fn estimate_cost(&self) -> ImageGenerationCostEstimate {
let cost_in_usd_cents = 4u64 * self.num_images as u64;
// 4.6¢ per output image (held in hundredths of a cent, rounded up to
// whole cents across the batch).
let cost_in_usd_cents = (460u64 * self.num_images as u64).div_ceil(100);
ImageGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
cost_in_usd_cents: Some(cost_in_usd_cents),
@@ -59,14 +61,14 @@ mod tests {
}
#[test]
fn one_image_costs_4_cents() { assert_eq!(cost_cents(1), 4); }
fn one_image_costs_5_cents() { assert_eq!(cost_cents(1), 5); }
#[test]
fn two_images_costs_8_cents() { assert_eq!(cost_cents(2), 8); }
fn two_images_costs_10_cents() { assert_eq!(cost_cents(2), 10); }
#[test]
fn three_images_costs_12_cents() { assert_eq!(cost_cents(3), 12); }
fn three_images_costs_14_cents() { assert_eq!(cost_cents(3), 14); }
#[test]
fn four_images_costs_16_cents() { assert_eq!(cost_cents(4), 16); }
fn four_images_costs_19_cents() { assert_eq!(cost_cents(4), 19); }
}
@@ -43,6 +43,9 @@ use crate::generate::generate_video::providers::artcraft::veo_3_fast::build::bui
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::flux_3::build::build_artcraft_flux_3;
use crate::generate::generate_video::providers::artcraft::flux_3_draft::build::build_artcraft_flux_3_draft;
use crate::generate::generate_video::providers::artcraft::minimax_h3::build::build_artcraft_minimax_h3;
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;
@@ -68,6 +71,9 @@ use crate::generate::generate_video::providers::fal::veo_3_fast::build::build_fa
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::flux_3::build::build_fal_flux_3;
use crate::generate::generate_video::providers::fal::flux_3_draft::build::build_fal_flux_3_draft;
use crate::generate::generate_video::providers::fal::minimax_h3::build::build_fal_minimax_h3;
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;
@@ -172,6 +178,8 @@ impl GenerateVideoRequestBuilder {
// Artcraft
(RouterProvider::Artcraft, RouterVideoModel::GrokImagineVideo) => build_artcraft_grok_imagine_video(self),
(RouterProvider::Artcraft, RouterVideoModel::GrokImagineVideo1p5) => build_artcraft_grok_imagine_video_1p5(self),
(RouterProvider::Artcraft, RouterVideoModel::Flux3) => build_artcraft_flux_3(self),
(RouterProvider::Artcraft, RouterVideoModel::Flux3Draft) => build_artcraft_flux_3_draft(self),
(RouterProvider::Artcraft, RouterVideoModel::HappyHorse1p0) => build_artcraft_happy_horse_1p0(self),
(RouterProvider::Artcraft, RouterVideoModel::Kling16Pro) => build_artcraft_kling_1_6_pro(self),
(RouterProvider::Artcraft, RouterVideoModel::Kling21Master) => build_artcraft_kling_2_1_master(self),
@@ -180,6 +188,7 @@ impl GenerateVideoRequestBuilder {
(RouterProvider::Artcraft, RouterVideoModel::Kling2p6Pro) => build_artcraft_kling_2_6_pro(self),
(RouterProvider::Artcraft, RouterVideoModel::Kling3p0Pro) => build_artcraft_kling_3p0_pro(self),
(RouterProvider::Artcraft, RouterVideoModel::Kling3p0Standard) => build_artcraft_kling_3p0_standard(self),
(RouterProvider::Artcraft, RouterVideoModel::MinimaxH3) => build_artcraft_minimax_h3(self),
(RouterProvider::Artcraft, RouterVideoModel::Seedance2p0) => build_artcraft_seedance_2p0(self),
(RouterProvider::Artcraft, RouterVideoModel::Seedance2p0Fast) => build_artcraft_seedance_2p0_fast(self),
(RouterProvider::Artcraft, RouterVideoModel::Seedance2p0Ultra) => build_artcraft_seedance_2p0_u(self),
@@ -207,6 +216,8 @@ impl GenerateVideoRequestBuilder {
(RouterProvider::Artcraft, RouterVideoModel::ViduQ3) => build_artcraft_vidu_q3(self),
(RouterProvider::Artcraft, RouterVideoModel::ViduQ3Turbo) => build_artcraft_vidu_q3_turbo(self),
// Fal
(RouterProvider::Fal, RouterVideoModel::Flux3) => build_fal_flux_3(self),
(RouterProvider::Fal, RouterVideoModel::Flux3Draft) => build_fal_flux_3_draft(self),
(RouterProvider::Fal, RouterVideoModel::Kling16Pro) => build_fal_kling_1_6_pro(self),
(RouterProvider::Fal, RouterVideoModel::Kling21Master) => build_fal_kling_2_1_master(self),
(RouterProvider::Fal, RouterVideoModel::Kling21Pro) => build_fal_kling_2_1_pro(self),
@@ -214,6 +225,7 @@ impl GenerateVideoRequestBuilder {
(RouterProvider::Fal, RouterVideoModel::Kling2p6Pro) => build_fal_kling_2_6_pro(self),
(RouterProvider::Fal, RouterVideoModel::Kling3p0Pro) => build_fal_kling_3p0_pro(self),
(RouterProvider::Fal, RouterVideoModel::Kling3p0Standard) => build_fal_kling_3p0_standard(self),
(RouterProvider::Fal, RouterVideoModel::MinimaxH3) => build_fal_minimax_h3(self),
(RouterProvider::Fal, RouterVideoModel::Seedance10Lite) => build_fal_seedance_1p0_lite(self),
(RouterProvider::Fal, RouterVideoModel::Seedance1p5Pro) => build_fal_seedance_1p5_pro(self),
(RouterProvider::Fal, RouterVideoModel::Sora2) => build_fal_sora_2(self),
@@ -0,0 +1,64 @@
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
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::artcraft::build_common::{
build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport,
};
use crate::generate::generate_video::providers::artcraft::flux_3::request::ArtcraftFlux3RequestState;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
pub fn build_artcraft_flux_3(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let state = build_artcraft_flux_3_state(builder)?;
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftFlux3(state)))
}
pub(crate) fn build_artcraft_flux_3_state(mut builder: GenerateVideoRequestBuilder) -> Result<ArtcraftFlux3RequestState, ArtcraftRouterError> {
// Flux 3 accepts 5–20 seconds — wider than the generic helper's window —
// so pre-plan the duration and reapply it after (the helper would clamp
// 16–20s down). Also preserve generate_audio (the helper hardcodes None on
// its output).
let strategy = builder.request_mismatch_mitigation_strategy;
let final_duration = plan_flux_3_duration(builder.duration_seconds, strategy)?;
builder.duration_seconds = final_duration;
let generate_audio = builder.generate_audio;
let mut request = build_artcraft_omni_video_request(
builder,
CommonVideoModelEnum::Flux3,
SupportedResolutions::Full,
UltraWideSupport::Supported,
)?;
request.generate_audio = generate_audio;
request.duration_seconds = final_duration;
Ok(ArtcraftFlux3RequestState { request })
}
/// Flux 3 durations: 5–20 seconds (unset lets the model pick).
pub(crate) fn plan_flux_3_duration(
duration_seconds: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<u16>, ArtcraftRouterError> {
const MIN: u16 = 5;
const MAX: u16 = 20;
match duration_seconds {
None => Ok(None),
Some(d) if (MIN..=MAX).contains(&d) => Ok(Some(d)),
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, MAX)))
}
},
}
}
@@ -0,0 +1,123 @@
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
use crate::generate::generate_video::providers::artcraft::flux_3::request::ArtcraftFlux3RequestState;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
/// Per-second rates in hundredths of a US cent.
const LOW_RES_RATE_HUNDREDTH_CENTS_PER_SEC: u64 = 1_955;
const HIGH_RES_RATE_HUNDREDTH_CENTS_PER_SEC: u64 = 3_335;
#[derive(Clone, Debug)]
pub struct ArtcraftFlux3CostState {
pub duration_seconds: u64,
pub is_1080p: bool,
}
impl ArtcraftFlux3CostState {
pub fn from_request(request: &ArtcraftFlux3RequestState) -> Self {
Self {
// Flux 3 defaults None → 5s (the shortest selectable duration).
duration_seconds: request.request.duration_seconds.map(u64::from).unwrap_or(5),
is_1080p: is_1080p(request.request.resolution),
}
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
let rate = if self.is_1080p {
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,
}
}
}
/// Flux 3 renders 720p or 1080p; 1080p and above land on the higher tier,
/// while 720p and below render at 720p. The default (unset) resolution is
/// 720p.
fn is_1080p(resolution: Option<CommonResolutionEnum>) -> bool {
match resolution {
None => false, // defaults to 720p
Some(CommonResolutionEnum::TenEightyP)
| Some(CommonResolutionEnum::OneK)
| Some(CommonResolutionEnum::TwoK)
| Some(CommonResolutionEnum::ThreeK)
| Some(CommonResolutionEnum::FourK) => true,
Some(CommonResolutionEnum::HalfK)
| Some(CommonResolutionEnum::FourEightyP)
| Some(CommonResolutionEnum::SevenTwentyP) => 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::flux_3::build::build_artcraft_flux_3_state;
use crate::generate::generate_video::providers::artcraft::flux_3::cost::ArtcraftFlux3CostState;
#[test]
fn default_settings_5s_is_98() { assert_eq!(cost_cents(Some(5), None), 98); }
#[test]
fn default_duration_is_5s() { assert_eq!(cost_cents(None, None), 98); }
#[test]
fn low_res_20s_is_391() { assert_eq!(cost_cents(Some(20), Some(RouterResolution::SevenTwentyP)), 391); }
#[test]
fn four_eighty_p_lands_on_low_tier() {
assert_eq!(cost_cents(Some(5), Some(RouterResolution::FourEightyP)), 98);
}
#[test]
fn high_res_5s_is_167() { assert_eq!(cost_cents(Some(5), Some(RouterResolution::TenEightyP)), 167); }
#[test]
fn high_res_10s_is_334() { assert_eq!(cost_cents(Some(10), Some(RouterResolution::TenEightyP)), 334); }
#[test]
fn odd_duration_rounds_up_to_whole_cents() {
// 7s low res: 1955 × 7 = 13685 hundredth-cents → 137 cents.
assert_eq!(cost_cents(Some(7), None), 137);
}
#[test]
fn resolution_classifier_defaults_720p() {
assert!(!super::is_1080p(None));
assert!(super::is_1080p(Some(CommonResolutionEnum::TenEightyP)));
assert!(!super::is_1080p(Some(CommonResolutionEnum::SevenTwentyP)));
assert!(!super::is_1080p(Some(CommonResolutionEnum::FourEightyP)));
}
fn cost_cents(
duration_seconds: Option<u16>,
resolution: Option<RouterResolution>,
) -> u64 {
let b = GenerateVideoRequestBuilder {
model: RouterVideoModel::Flux3,
provider: RouterProvider::Artcraft,
prompt: Some("test".to_string()),
duration_seconds,
resolution,
..Default::default()
};
let state = build_artcraft_flux_3_state(b).unwrap();
ArtcraftFlux3CostState::from_request(&state).estimate_cost().cost_in_usd_cents.unwrap()
}
}
@@ -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 ArtcraftFlux3RequestState {
pub request: OmniGenVideoCostAndGenerateRequest,
}
impl ArtcraftFlux3RequestState {
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 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");
}
// ── Helpers ──
fn artcraft_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: RouterVideoModel::Flux3,
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,37 @@
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::flux_3::build::plan_flux_3_duration;
use crate::generate::generate_video::providers::artcraft::flux_3_draft::request::ArtcraftFlux3DraftRequestState;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
pub fn build_artcraft_flux_3_draft(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let state = build_artcraft_flux_3_draft_state(builder)?;
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftFlux3Draft(state)))
}
pub(crate) fn build_artcraft_flux_3_draft_state(mut builder: GenerateVideoRequestBuilder) -> Result<ArtcraftFlux3DraftRequestState, ArtcraftRouterError> {
// Same duration handling as full-quality Flux 3: 5–20 seconds is wider than
// the generic helper's window, so pre-plan and reapply after. Also preserve
// generate_audio (the helper hardcodes None on its output).
let strategy = builder.request_mismatch_mitigation_strategy;
let final_duration = plan_flux_3_duration(builder.duration_seconds, strategy)?;
builder.duration_seconds = final_duration;
let generate_audio = builder.generate_audio;
let mut request = build_artcraft_omni_video_request(
builder,
CommonVideoModelEnum::Flux3Draft,
SupportedResolutions::Full,
UltraWideSupport::Supported,
)?;
request.generate_audio = generate_audio;
request.duration_seconds = final_duration;
Ok(ArtcraftFlux3DraftRequestState { request })
}
@@ -0,0 +1,86 @@
use crate::generate::generate_video::providers::artcraft::flux_3_draft::request::ArtcraftFlux3DraftRequestState;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
/// Per-second rate in hundredths of a US cent. Drafts always render 720p, so
/// resolution does not affect the bill.
const GENERATION_RATE_HUNDREDTH_CENTS_PER_SEC: u64 = 690;
#[derive(Clone, Debug)]
pub struct ArtcraftFlux3DraftCostState {
pub duration_seconds: u64,
}
impl ArtcraftFlux3DraftCostState {
pub fn from_request(request: &ArtcraftFlux3DraftRequestState) -> Self {
Self {
// Flux 3 Draft defaults None → 5s (the shortest selectable duration).
duration_seconds: request.request.duration_seconds.map(u64::from).unwrap_or(5),
}
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
// Round up to the next whole cent.
let cost_in_usd_cents =
(GENERATION_RATE_HUNDREDTH_CENTS_PER_SEC * 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,
}
}
}
#[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::flux_3_draft::build::build_artcraft_flux_3_draft_state;
use crate::generate::generate_video::providers::artcraft::flux_3_draft::cost::ArtcraftFlux3DraftCostState;
#[test]
fn default_settings_5s_is_35() { assert_eq!(cost_cents(Some(5), None), 35); }
#[test]
fn default_duration_is_5s() { assert_eq!(cost_cents(None, None), 35); }
#[test]
fn twenty_seconds_is_138() { assert_eq!(cost_cents(Some(20), None), 138); }
/// Drafts always render 720p; a requested resolution does not change the
/// bill.
#[test]
fn cost_ignores_resolution() {
for resolution in [None, Some(RouterResolution::SevenTwentyP), Some(RouterResolution::TenEightyP)] {
assert_eq!(cost_cents(Some(5), resolution), 35, "resolution={resolution:?}");
}
}
#[test]
fn odd_duration_rounds_up_to_whole_cents() {
// 7s generation: 690 × 7 = 4830 hundredth-cents → 49 cents.
assert_eq!(cost_cents(Some(7), None), 49);
}
fn cost_cents(
duration_seconds: Option<u16>,
resolution: Option<RouterResolution>,
) -> u64 {
let b = GenerateVideoRequestBuilder {
model: RouterVideoModel::Flux3Draft,
provider: RouterProvider::Artcraft,
prompt: Some("test".to_string()),
duration_seconds,
resolution,
..Default::default()
};
let state = build_artcraft_flux_3_draft_state(b).unwrap();
ArtcraftFlux3DraftCostState::from_request(&state).estimate_cost().cost_in_usd_cents.unwrap()
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -0,0 +1,88 @@
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 ArtcraftFlux3DraftRequestState {
pub request: OmniGenVideoCostAndGenerateRequest,
}
impl ArtcraftFlux3DraftRequestState {
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
send_artcraft_omni_video_request(&self.request, client).await
}
}
#[cfg(test)]
mod tests {
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use artcraft_client::utils::api_host::ApiHost;
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");
}
// ── Helpers ──
fn artcraft_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: RouterVideoModel::Flux3Draft,
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
}
}
@@ -15,8 +15,8 @@ impl ArtcraftKling16ProCostState {
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
// 5s = 52¢, 10s = 103¢.
let cost_in_usd_cents: u64 = if self.is_ten_seconds { 103 } else { 52 };
// 5s = 57¢, 10s = 113¢.
let cost_in_usd_cents: u64 = if self.is_ten_seconds { 113 } else { 57 };
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
@@ -51,11 +51,11 @@ mod tests {
}
#[test]
fn five_seconds_is_52() { assert_eq!(cost_cents(Some(5)), 52); }
fn five_seconds_is_57() { assert_eq!(cost_cents(Some(5)), 57); }
#[test]
fn ten_seconds_is_103() { assert_eq!(cost_cents(Some(10)), 103); }
fn ten_seconds_is_113() { assert_eq!(cost_cents(Some(10)), 113); }
#[test]
fn default_duration_is_5s_priced_at_52() { assert_eq!(cost_cents(None), 52); }
fn default_duration_is_5s_priced_at_57() { assert_eq!(cost_cents(None), 57); }
}
@@ -13,7 +13,7 @@ impl ArtcraftKling21MasterCostState {
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
// Mirrors fal_client kling_v2p1_master: 5s = $1.40, 10s = $2.80.
let cost_in_usd_cents: u64 = if self.is_ten_seconds { 280 } else { 140 };
let cost_in_usd_cents: u64 = if self.is_ten_seconds { 322 } else { 161 };
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
@@ -48,11 +48,11 @@ mod tests {
}
#[test]
fn five_seconds_is_140() { assert_eq!(cost_cents(Some(5)), 140); }
fn five_seconds_is_161() { assert_eq!(cost_cents(Some(5)), 161); }
#[test]
fn ten_seconds_is_280() { assert_eq!(cost_cents(Some(10)), 280); }
fn ten_seconds_is_322() { assert_eq!(cost_cents(Some(10)), 322); }
#[test]
fn default_duration_is_5s_priced_at_140() { assert_eq!(cost_cents(None), 140); }
fn default_duration_is_5s_priced_at_161() { assert_eq!(cost_cents(None), 161); }
}
@@ -13,7 +13,7 @@ impl ArtcraftKling21ProCostState {
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
// Mirrors fal_client kling_v2p1_pro: 5s = 45¢, 10s = 90¢.
let cost_in_usd_cents: u64 = if self.is_ten_seconds { 90 } else { 45 };
let cost_in_usd_cents: u64 = if self.is_ten_seconds { 104 } else { 52 };
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
@@ -48,11 +48,11 @@ mod tests {
}
#[test]
fn five_seconds_is_45() { assert_eq!(cost_cents(Some(5)), 45); }
fn five_seconds_is_52() { assert_eq!(cost_cents(Some(5)), 52); }
#[test]
fn ten_seconds_is_90() { assert_eq!(cost_cents(Some(10)), 90); }
fn ten_seconds_is_104() { assert_eq!(cost_cents(Some(10)), 104); }
#[test]
fn default_duration_is_5s_priced_at_45() { assert_eq!(cost_cents(None), 45); }
fn default_duration_is_5s_priced_at_52() { assert_eq!(cost_cents(None), 52); }
}
@@ -13,7 +13,7 @@ impl ArtcraftKling2p5TurboProCostState {
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
// Mirrors fal_client kling_v2p5_turbo_pro: 5s = 35¢, 10s = 70¢.
let cost_in_usd_cents: u64 = if self.is_ten_seconds { 70 } else { 35 };
let cost_in_usd_cents: u64 = if self.is_ten_seconds { 81 } else { 41 };
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
@@ -45,11 +45,11 @@ mod tests {
}
#[test]
fn five_seconds_is_35() { assert_eq!(cost_cents(Some(5)), 35); }
fn five_seconds_is_41() { assert_eq!(cost_cents(Some(5)), 41); }
#[test]
fn ten_seconds_is_70() { assert_eq!(cost_cents(Some(10)), 70); }
fn ten_seconds_is_81() { assert_eq!(cost_cents(Some(10)), 81); }
#[test]
fn default_duration_is_5s_priced_at_35() { assert_eq!(cost_cents(None), 35); }
fn default_duration_is_5s_priced_at_41() { assert_eq!(cost_cents(None), 41); }
}
@@ -21,10 +21,10 @@ impl ArtcraftKling2p6ProCostState {
// audio off: $0.07/sec → 5s=35¢, 10s=70¢
// audio on: $0.14/sec → 5s=70¢, 10s=140¢
let cost_in_usd_cents: u64 = match (self.generate_audio, self.is_ten_seconds) {
(false, false) => 35,
(false, true) => 70,
(true, false) => 70,
(true, true) => 140,
(false, false) => 41,
(false, true) => 81,
(true, false) => 81,
(true, true) => 161,
};
VideoGenerationCostEstimate {
@@ -58,16 +58,16 @@ mod tests {
}
#[test]
fn audio_off_5s_is_35() { assert_eq!(cost_cents(Some(5), Some(false)), 35); }
fn audio_off_5s_is_41() { assert_eq!(cost_cents(Some(5), Some(false)), 41); }
#[test]
fn audio_off_10s_is_70() { assert_eq!(cost_cents(Some(10), Some(false)), 70); }
fn audio_off_10s_is_81() { assert_eq!(cost_cents(Some(10), Some(false)), 81); }
#[test]
fn audio_on_5s_is_70() { assert_eq!(cost_cents(Some(5), Some(true)), 70); }
fn audio_on_5s_is_81() { assert_eq!(cost_cents(Some(5), Some(true)), 81); }
#[test]
fn audio_on_10s_is_140() { assert_eq!(cost_cents(Some(10), Some(true)), 140); }
fn audio_on_10s_is_161() { assert_eq!(cost_cents(Some(10), Some(true)), 161); }
#[test]
fn audio_default_is_on() {
@@ -1,4 +1,8 @@
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
// The markup is applied at cent granularity with ceiling rounding.
const MARKUP_NUMERATOR: u64 = 115;
const MARKUP_DENOMINATOR: u64 = 100;
use crate::generate::generate_video::providers::artcraft::kling_3p0_pro::request::ArtcraftKling3p0ProRequestState;
#[derive(Clone, Debug)]
@@ -17,12 +21,11 @@ impl ArtcraftKling3p0ProCostState {
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
// Mirrors fal_client kling_3p0_pro:
// audio off: $0.224/sec (rate=224 in tenths-of-cents)
// audio on: $0.336/sec (rate=336)
// ceiling-divided to whole cents.
// Base rates in tenths of a cent per second; the total is
// ceiling-divided to whole cents, then the markup applied.
let rate: u64 = if self.generate_audio { 336 } else { 224 };
let cost_in_usd_cents = (rate * self.duration_seconds + 9) / 10;
let base = (rate * self.duration_seconds + 9) / 10;
let cost_in_usd_cents = base.saturating_mul(MARKUP_NUMERATOR).div_ceil(MARKUP_DENOMINATOR);
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
@@ -57,24 +60,24 @@ mod tests {
#[test]
fn audio_on_5s_is_168() {
// rate=336, (336*5 + 9) / 10 = 1689/10 = 168.
assert_eq!(cost_cents(Some(5), Some(true)), 168);
assert_eq!(cost_cents(Some(5), Some(true)), 194);
}
#[test]
fn audio_off_5s_is_112() {
// rate=224, (224*5 + 9) / 10 = 1129/10 = 112.
assert_eq!(cost_cents(Some(5), Some(false)), 112);
assert_eq!(cost_cents(Some(5), Some(false)), 129);
}
#[test]
fn audio_on_10s_is_336() {
assert_eq!(cost_cents(Some(10), Some(true)), 336);
assert_eq!(cost_cents(Some(10), Some(true)), 387);
}
#[test]
fn audio_on_15s_is_504() {
// rate=336, (336*15 + 9) / 10 = 5049/10 = 504.
assert_eq!(cost_cents(Some(15), Some(true)), 504);
assert_eq!(cost_cents(Some(15), Some(true)), 580);
}
#[test]
@@ -1,6 +1,10 @@
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video::providers::artcraft::kling_3p0_standard::request::ArtcraftKling3p0StandardRequestState;
// The markup is applied at cent granularity with ceiling rounding.
const MARKUP_NUMERATOR: u64 = 115;
const MARKUP_DENOMINATOR: u64 = 100;
#[derive(Clone, Debug)]
pub struct ArtcraftKling3p0StandardCostState {
pub duration_seconds: u64,
@@ -16,11 +20,11 @@ impl ArtcraftKling3p0StandardCostState {
}
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)
// Base rates in tenths of a cent per second; the total is
// ceiling-divided to whole cents, then the markup applied.
let rate: u64 = if self.generate_audio { 252 } else { 168 };
let cost_in_usd_cents = (rate * self.duration_seconds + 9) / 10;
let base = (rate * self.duration_seconds + 9) / 10;
let cost_in_usd_cents = base.saturating_mul(MARKUP_NUMERATOR).div_ceil(MARKUP_DENOMINATOR);
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
@@ -53,26 +57,26 @@ mod tests {
}
#[test]
fn audio_on_5s_is_126() {
// rate=252, (252*5 + 9) / 10 = 1269/10 = 126.
assert_eq!(cost_cents(Some(5), Some(true)), 126);
fn audio_on_5s_is_145() {
// rate=252, base (252*5 + 9) / 10 = 126, ×115/100 ceiled = 145.
assert_eq!(cost_cents(Some(5), Some(true)), 145);
}
#[test]
fn audio_off_5s_is_84() {
// rate=168, (168*5 + 9) / 10 = 849/10 = 84.
assert_eq!(cost_cents(Some(5), Some(false)), 84);
fn audio_off_5s_is_97() {
// rate=168, base (168*5 + 9) / 10 = 84, ×115/100 ceiled = 97.
assert_eq!(cost_cents(Some(5), Some(false)), 97);
}
#[test]
fn audio_on_10s_is_252() {
assert_eq!(cost_cents(Some(10), Some(true)), 252);
fn audio_on_10s_is_290() {
assert_eq!(cost_cents(Some(10), Some(true)), 290);
}
#[test]
fn audio_on_15s_is_378() {
// (252*15 + 9) / 10 = 3789/10 = 378.
assert_eq!(cost_cents(Some(15), Some(true)), 378);
fn audio_on_15s_is_435() {
// base (252*15 + 9) / 10 = 378, ×115/100 ceiled = 435.
assert_eq!(cost_cents(Some(15), Some(true)), 435);
}
#[test]
@@ -0,0 +1,25 @@
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::minimax_h3::request::ArtcraftMinimaxH3RequestState;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
pub fn build_artcraft_minimax_h3(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let state = build_artcraft_minimax_h3_state(builder)?;
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftMinimaxH3(state)))
}
pub(crate) fn build_artcraft_minimax_h3_state(builder: GenerateVideoRequestBuilder) -> Result<ArtcraftMinimaxH3RequestState, ArtcraftRouterError> {
let request = build_artcraft_omni_video_request(
builder,
CommonVideoModelEnum::MinimaxH3,
SupportedResolutions::Full,
UltraWideSupport::Supported,
)?;
Ok(ArtcraftMinimaxH3RequestState { request })
}
@@ -0,0 +1,158 @@
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
use crate::generate::generate_video::providers::artcraft::minimax_h3::request::ArtcraftMinimaxH3RequestState;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
/// Per-second rates in hundredths of a US cent.
const LOW_RES_RATE_HUNDREDTH_CENTS_PER_SEC: u64 = 1_840;
const HIGH_RES_RATE_HUNDREDTH_CENTS_PER_SEC: u64 = 2_990;
/// The first 5 reference images are included; each additional image bills a
/// flat fee (in hundredths of a US cent).
const INCLUDED_REFERENCE_IMAGES: u64 = 5;
const EXTRA_REFERENCE_IMAGE_HUNDREDTH_CENTS: u64 = 920;
#[derive(Clone, Debug)]
pub struct ArtcraftMinimaxH3CostState {
pub duration_seconds: u64,
pub is_2k: bool,
pub reference_image_count: u64,
}
impl ArtcraftMinimaxH3CostState {
pub fn from_request(request: &ArtcraftMinimaxH3RequestState) -> Self {
Self {
// MiniMax H3 defaults None → 5s.
duration_seconds: request.request.duration_seconds.map(u64::from).unwrap_or(5),
is_2k: is_2k(request.request.resolution),
reference_image_count: request.request.reference_image_media_tokens
.as_ref()
.map_or(0, |tokens| tokens.len() as u64),
}
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
let rate = if self.is_2k {
HIGH_RES_RATE_HUNDREDTH_CENTS_PER_SEC
} else {
LOW_RES_RATE_HUNDREDTH_CENTS_PER_SEC
};
let extra_images = self.reference_image_count.saturating_sub(INCLUDED_REFERENCE_IMAGES);
// Round up to the next whole cent.
let cost_in_usd_cents =
(rate * self.duration_seconds + extra_images * EXTRA_REFERENCE_IMAGE_HUNDREDTH_CENTS)
.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,
}
}
}
/// MiniMax H3 offers 768P and 2K output; 1080p and above land on the 2K tier,
/// while 720p and below render at 768P. The default (unset) resolution is 2K.
fn is_2k(resolution: Option<CommonResolutionEnum>) -> bool {
match resolution {
None => true, // defaults to 2K
Some(CommonResolutionEnum::TenEightyP)
| Some(CommonResolutionEnum::OneK)
| Some(CommonResolutionEnum::TwoK)
| Some(CommonResolutionEnum::ThreeK)
| Some(CommonResolutionEnum::FourK) => true,
Some(CommonResolutionEnum::HalfK)
| Some(CommonResolutionEnum::FourEightyP)
| Some(CommonResolutionEnum::SevenTwentyP) => false,
}
}
#[cfg(test)]
mod tests {
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::image_list_ref::ImageListRef;
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::minimax_h3::build::build_artcraft_minimax_h3_state;
use crate::generate::generate_video::providers::artcraft::minimax_h3::cost::ArtcraftMinimaxH3CostState;
#[test]
fn default_resolution_5s_is_150() { assert_eq!(cost_cents(Some(5), None, 0), 150); }
#[test]
fn default_duration_is_5s() { assert_eq!(cost_cents(None, None, 0), 150); }
#[test]
fn low_res_5s_is_92() { assert_eq!(cost_cents(Some(5), Some(RouterResolution::SevenTwentyP), 0), 92); }
#[test]
fn four_eighty_p_lands_on_low_tier() {
assert_eq!(cost_cents(Some(5), Some(RouterResolution::FourEightyP), 0), 92);
}
#[test]
fn low_res_15s_is_276() { assert_eq!(cost_cents(Some(15), Some(RouterResolution::SevenTwentyP), 0), 276); }
#[test]
fn high_res_10s_is_299() { assert_eq!(cost_cents(Some(10), Some(RouterResolution::TenEightyP), 0), 299); }
#[test]
fn odd_duration_rounds_up_to_whole_cents() {
// 7s high res: 2990 × 7 = 20930 hundredth-cents → 210 cents.
assert_eq!(cost_cents(Some(7), None, 0), 210);
}
#[test]
fn five_reference_images_are_included() {
assert_eq!(cost_cents(Some(5), None, 5), cost_cents(Some(5), None, 0));
}
#[test]
fn nine_reference_images_add_the_extra_image_fee() {
// 2990 × 5 + 4 × 920 = 18630 hundredth-cents → 187 cents.
assert_eq!(cost_cents(Some(5), None, 9), 187);
}
#[test]
fn resolution_classifier_defaults_2k() {
assert!(super::is_2k(None));
assert!(super::is_2k(Some(CommonResolutionEnum::TenEightyP)));
assert!(!super::is_2k(Some(CommonResolutionEnum::SevenTwentyP)));
assert!(!super::is_2k(Some(CommonResolutionEnum::FourEightyP)));
}
fn cost_cents(
duration_seconds: Option<u16>,
resolution: Option<RouterResolution>,
reference_image_count: usize,
) -> u64 {
let reference_images = if reference_image_count == 0 {
None
} else {
Some(ImageListRef::MediaFileTokens(
(0..reference_image_count)
.map(|i| MediaFileToken::new(format!("m_test_{i}")))
.collect(),
))
};
let b = GenerateVideoRequestBuilder {
model: RouterVideoModel::MinimaxH3,
provider: RouterProvider::Artcraft,
prompt: Some("test".to_string()),
duration_seconds,
resolution,
reference_images,
..Default::default()
};
let state = build_artcraft_minimax_h3_state(b).unwrap();
ArtcraftMinimaxH3CostState::from_request(&state).estimate_cost().cost_in_usd_cents.unwrap()
}
}
@@ -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 ArtcraftMinimaxH3RequestState {
pub request: OmniGenVideoCostAndGenerateRequest,
}
impl ArtcraftMinimaxH3RequestState {
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_list_ref::ImageListRef;
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 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("Image 1 is the dog. 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::MinimaxH3,
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
}
}
@@ -4,6 +4,8 @@ pub(crate) mod seedance_common;
mod parity_tests;
pub mod grok_imagine_video;
pub mod grok_imagine_video_1p5;
pub mod flux_3;
pub mod flux_3_draft;
pub mod happy_horse_1p0;
pub mod kling_1_6_pro;
pub mod kling_2_1_master;
@@ -12,6 +14,7 @@ pub mod kling_2_5_turbo_pro;
pub mod kling_2_6_pro;
pub mod kling_3p0_pro;
pub mod kling_3p0_standard;
pub mod minimax_h3;
pub mod preview_model;
pub mod preview_model_fast;
pub(crate) mod request_common;
@@ -8,6 +8,10 @@ use fal_client::requests_old::webhook::video::text::enqueue_seedance_1p5_pro_tex
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video::providers::artcraft::seedance_1p5_pro::request::ArtcraftSeedance1p5ProRequestState;
// The markup is applied at cent granularity with ceiling rounding.
const MARKUP_NUMERATOR: u64 = 115;
const MARKUP_DENOMINATOR: u64 = 100;
#[derive(Clone, Debug)]
pub struct ArtcraftSeedance1p5ProCostState {
pub duration: Option<EnqueueSeedance1p5ProTextToVideoDuration>,
@@ -37,7 +41,8 @@ impl ArtcraftSeedance1p5ProCostState {
aspect_ratio: None,
generate_audio: self.generate_audio,
};
let cost_in_usd_cents = req.calculate_cost_in_cents();
let base = req.calculate_cost_in_cents();
let cost_in_usd_cents = base.saturating_mul(MARKUP_NUMERATOR).div_ceil(MARKUP_DENOMINATOR);
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
@@ -96,14 +101,14 @@ mod tests {
// Numeric literals mirror the v1 Artcraft Seedance 1.5 Pro tests we deleted:
// resolution=None + generate_audio=None → fal client defaults (720p, audio on).
#[test]
fn default_5s_is_26() { assert_eq!(cost_cents(Some(5)), 26); }
fn default_5s_is_30() { assert_eq!(cost_cents(Some(5)), 30); }
#[test]
fn default_4s_is_26() { assert_eq!(cost_cents(Some(4)), 26); }
fn default_4s_is_30() { assert_eq!(cost_cents(Some(4)), 30); }
#[test]
fn default_10s_is_65() { assert_eq!(cost_cents(Some(10)), 65); }
fn default_10s_is_75() { assert_eq!(cost_cents(Some(10)), 75); }
#[test]
fn default_12s_is_78() { assert_eq!(cost_cents(Some(12)), 78); }
fn default_12s_is_90() { assert_eq!(cost_cents(Some(12)), 90); }
}
@@ -8,25 +8,25 @@ use crate::generate::generate_video::video_generation_cost_estimate::VideoGenera
//
// ArtCraft credits: 100 credits = $1.00. Credits always equal USD cents.
//
// The per-second USD cost varies by resolution. We compute cents directly
// from the upstream credit rates and their credit-package prices,
// then set ArtCraft credits = cents.
// The per-second USD price varies by resolution.
/// USD cents per second by resolution, derived from upstream rates:
/// 480p: 15 upstream-credits/sec ÷ 193 upstream-credits/$1 × 100 ≈ 7.772 ¢/s
/// 720p: 40 upstream-credits/sec ÷ 250 upstream-credits/$1 × 100 = 16.0 ¢/s
/// 1080p: 90 upstream-credits/sec ÷ 193 upstream-credits/$1 × 100 ≈ 46.632 ¢/s
/// USD cents per second by resolution.
///
/// We keep these as f64 because per-second rates are fractional; rounding
/// happens once at the end after multiplying by duration × batch.
///
/// NB: Derivation is historical (193 credits/$1 package). The upstream
/// package is now 525,000 credits for $2,159.0909 (~243 credits/$1), but
/// user pricing is intentionally kept at these 193-derived rates.
/// Kept as f64 because per-second rates are fractional; rounding happens
/// once at the end after multiplying by duration × batch.
const CENTS_PER_SECOND_480P: f64 = 7.772;
const CENTS_PER_SECOND_720P: f64 = 16.0;
const CENTS_PER_SECOND_1080P: f64 = 46.632;
/// USD cents per second, in hundredths of a cent, when one or more
/// reference videos are attached. Held as integer hundredths so the math is
/// exact; rounded up to whole cents once, after multiplying by
/// duration × batch.
const WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_480P: u64 = 881;
const WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_720P: u64 = 2_205;
const WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_1080P: u64 = 5_110;
const WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_4K: u64 = 11_380;
pub struct ArtcraftSeedance2p0CostState {
pub resolution: CommonResolution,
pub duration_seconds: u16,
@@ -48,6 +48,19 @@ impl ArtcraftSeedance2p0CostState {
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
if self.has_video_reference {
let usd_cents = self.with_video_reference_usd_cents();
return VideoGenerationCostEstimate {
cost_in_credits: Some(usd_cents),
cost_in_usd_cents: Some(usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
};
}
if self.resolution == CommonResolution::FourK {
let usd_cents = seedance_2p0_four_k_usd_cents(
self.duration_seconds,
@@ -85,12 +98,31 @@ impl ArtcraftSeedance2p0CostState {
failures_are_refunded: None,
}
}
/// Price when one or more reference videos are attached.
fn with_video_reference_usd_cents(&self) -> u64 {
let centi_cents_per_second = match self.resolution {
CommonResolution::FourEightyP => WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_480P,
CommonResolution::TenEightyP => WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_1080P,
CommonResolution::FourK => WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_4K,
// Everything else (including 720p) prices at 720p.
_ => WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_720P,
};
let seconds = self.duration_seconds as u64 * self.batch_count as u64;
// Round up to whole cents.
(centi_cents_per_second * seconds).div_ceil(100)
}
}
#[cfg(test)]
mod tests {
use tokens::tokens::media_files::MediaFileToken;
use crate::api::router_provider::RouterProvider;
use crate::api::router_resolution::RouterResolution;
use crate::api::video_list_ref::VideoListRef;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
// -- 720p pricing --
@@ -202,10 +234,110 @@ mod tests {
#[test]
fn explicit_4k_with_video_reference() {
assert_eq!(artcraft_4k_cents(4, 1, true), 416);
assert_eq!(artcraft_4k_cents(5, 1, true), 519);
assert_eq!(artcraft_4k_cents(10, 1, true), 1038);
assert_eq!(artcraft_4k_cents(15, 1, true), 1557);
assert_eq!(artcraft_4k_cents(4, 1, true), 456);
assert_eq!(artcraft_4k_cents(5, 1, true), 569);
assert_eq!(artcraft_4k_cents(10, 1, true), 1138);
assert_eq!(artcraft_4k_cents(15, 1, true), 1707);
}
}
// -- With-video-reference pricing --
mod video_reference_pricing {
use super::*;
#[test]
fn table_480p() {
let expected = [36, 45, 53, 62, 71, 80, 89, 97, 106, 115, 124, 133];
for (i, cents) in expected.iter().enumerate() {
let duration = (i + 4) as u16;
assert_eq!(
cost_cents_with_video_ref(Some(RouterResolution::FourEightyP), duration, 1),
*cents,
"480p {duration}s",
);
}
}
#[test]
fn table_720p() {
let expected = [89, 111, 133, 155, 177, 199, 221, 243, 265, 287, 309, 331];
for (i, cents) in expected.iter().enumerate() {
let duration = (i + 4) as u16;
assert_eq!(
cost_cents_with_video_ref(Some(RouterResolution::SevenTwentyP), duration, 1),
*cents,
"720p {duration}s",
);
}
}
#[test]
fn table_1080p() {
let expected = [205, 256, 307, 358, 409, 460, 511, 563, 614, 665, 716, 767];
for (i, cents) in expected.iter().enumerate() {
let duration = (i + 4) as u16;
assert_eq!(
cost_cents_with_video_ref(Some(RouterResolution::TenEightyP), duration, 1),
*cents,
"1080p {duration}s",
);
}
}
#[test]
fn table_4k() {
let expected = [456, 569, 683, 797, 911, 1025, 1138, 1252, 1366, 1480, 1594, 1707];
for (i, cents) in expected.iter().enumerate() {
let duration = (i + 4) as u16;
assert_eq!(
cost_cents_with_video_ref(Some(RouterResolution::FourK), duration, 1),
*cents,
"4K {duration}s",
);
}
}
/// Batches price identically to the equivalent total seconds.
#[test]
fn batch_multiplies_total_seconds() {
assert_eq!(
cost_cents_with_video_ref(Some(RouterResolution::FourEightyP), 5, 2),
cost_cents_with_video_ref(Some(RouterResolution::FourEightyP), 10, 1),
);
assert_eq!(
cost_cents_with_video_ref(Some(RouterResolution::FourK), 5, 2),
cost_cents_with_video_ref(Some(RouterResolution::FourK), 10, 1),
);
}
/// Image and audio references do NOT trigger the with-reference pricing —
/// only reference videos do. (The non-reference price is unchanged.)
#[test]
fn image_and_audio_references_price_as_no_reference() {
use crate::api::audio_list_ref::AudioListRef;
use crate::api::image_list_ref::ImageListRef;
let builder = GenerateVideoRequestBuilder {
provider: RouterProvider::Artcraft,
resolution: Some(RouterResolution::SevenTwentyP),
duration_seconds: Some(5),
video_batch_count: Some(1),
reference_images: Some(ImageListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_img".to_string()),
])),
reference_audio: Some(AudioListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_audio".to_string()),
])),
..Default::default()
};
let cents = builder.build2()
.expect("build2 should succeed")
.estimate_cost()
.expect("estimate_cost should succeed")
.cost_in_usd_cents
.unwrap();
assert_eq!(cents, cost_cents(Some(RouterResolution::SevenTwentyP), 5, 1));
}
}
@@ -308,4 +440,28 @@ mod tests {
.cost_in_usd_cents
.unwrap()
}
/// Like [`cost_cents`], with one reference video attached.
fn cost_cents_with_video_ref(
resolution: Option<RouterResolution>,
duration_seconds: u16,
video_batch_count: u16,
) -> u64 {
let builder = GenerateVideoRequestBuilder {
provider: RouterProvider::Artcraft,
resolution,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(video_batch_count),
reference_videos: Some(VideoListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_ref".to_string()),
])),
..Default::default()
};
builder.build2()
.expect("build2 should succeed")
.estimate_cost()
.expect("estimate_cost should succeed")
.cost_in_usd_cents
.unwrap()
}
}
@@ -7,21 +7,22 @@ use crate::generate::generate_video::providers::artcraft::seedance_2p0_fast::req
//
// ArtCraft credits: 100 credits = $1.00. Credits always equal USD cents.
//
// The per-second USD cost varies by resolution. We derive cents from the
// upstream credit rates and their credit-package prices, then set
// ArtCraft credits = cents.
// The per-second USD price varies by resolution.
/// USD cents per second by resolution, derived from upstream Fast rates:
/// 480p: 10 upstream-credits/sec / 193 upstream-credits/$1 * 100 ~= 5.181 c/s
/// (historical 193 credits/$1 derivation; the upstream package is now
/// ~243 credits/$1 but user pricing is intentionally unchanged)
/// 720p: 28 upstream-credits/sec / 220 upstream-credits/$1 * 100 ~= 12.727 c/s
/// USD cents per second by resolution.
///
/// We keep these as f64 because per-second rates are fractional; rounding
/// happens once at the end after multiplying by duration * batch.
/// Kept as f64 because per-second rates are fractional; rounding happens
/// once at the end after multiplying by duration * batch.
const CENTS_PER_SECOND_480P: f64 = 5.181;
const CENTS_PER_SECOND_720P: f64 = 12.727;
/// USD cents per second, in hundredths of a cent, when one or more
/// reference videos are attached. Held as integer hundredths so the math is
/// exact; rounded up to whole cents once, after multiplying by
/// duration * batch.
const WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_480P: u64 = 841;
const WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_720P: u64 = 1_560;
pub struct ArtcraftSeedance2p0FastCostState {
pub resolution: CommonResolution,
pub duration_seconds: u16,
@@ -43,6 +44,19 @@ impl ArtcraftSeedance2p0FastCostState {
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
if self.has_video_reference {
let usd_cents = self.with_video_reference_usd_cents();
return VideoGenerationCostEstimate {
cost_in_credits: Some(usd_cents),
cost_in_usd_cents: Some(usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
};
}
let cents_per_second = match self.resolution {
CommonResolution::FourEightyP => CENTS_PER_SECOND_480P,
// Everything else (including 720p and unsupported resolutions) prices at 720p.
@@ -62,13 +76,30 @@ impl ArtcraftSeedance2p0FastCostState {
failures_are_refunded: None,
}
}
/// Price when one or more reference videos are attached.
fn with_video_reference_usd_cents(&self) -> u64 {
let centi_cents_per_second = match self.resolution {
CommonResolution::FourEightyP => WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_480P,
// Everything else (including 720p and unsupported resolutions) prices at 720p.
_ => WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_720P,
};
let seconds = self.duration_seconds as u64 * self.batch_count as u64;
// Round up to whole cents.
(centi_cents_per_second * seconds).div_ceil(100)
}
}
#[cfg(test)]
mod tests {
use tokens::tokens::media_files::MediaFileToken;
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
use crate::api::router_provider::RouterProvider;
use crate::api::video_list_ref::VideoListRef;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
// -- 720p pricing --
@@ -128,6 +159,81 @@ mod tests {
}
}
// -- With-video-reference pricing --
mod video_reference_pricing {
use super::*;
#[test]
fn table_480p() {
let expected = [34, 43, 51, 59, 68, 76, 85, 93, 101, 110, 118, 127];
for (i, cents) in expected.iter().enumerate() {
let duration = (i + 4) as u16;
assert_eq!(
cost_cents_with_video_ref(Some(RouterResolution::FourEightyP), duration, 1),
*cents,
"480p {duration}s",
);
}
}
#[test]
fn table_720p() {
let expected = [63, 78, 94, 110, 125, 141, 156, 172, 188, 203, 219, 234];
for (i, cents) in expected.iter().enumerate() {
let duration = (i + 4) as u16;
assert_eq!(
cost_cents_with_video_ref(Some(RouterResolution::SevenTwentyP), duration, 1),
*cents,
"720p {duration}s",
);
}
}
/// Batches price identically to the equivalent total seconds.
#[test]
fn batch_multiplies_total_seconds() {
assert_eq!(
cost_cents_with_video_ref(Some(RouterResolution::FourEightyP), 5, 2),
cost_cents_with_video_ref(Some(RouterResolution::FourEightyP), 10, 1),
);
assert_eq!(
cost_cents_with_video_ref(Some(RouterResolution::SevenTwentyP), 5, 2),
cost_cents_with_video_ref(Some(RouterResolution::SevenTwentyP), 10, 1),
);
}
/// Image and audio references do NOT trigger the with-reference pricing --
/// only reference videos do. (The non-reference price is unchanged.)
#[test]
fn image_and_audio_references_price_as_no_reference() {
use crate::api::audio_list_ref::AudioListRef;
use crate::api::image_list_ref::ImageListRef;
let builder = GenerateVideoRequestBuilder {
model: RouterVideoModel::Seedance2p0Fast,
provider: RouterProvider::Artcraft,
resolution: Some(RouterResolution::SevenTwentyP),
duration_seconds: Some(5),
video_batch_count: Some(1),
reference_images: Some(ImageListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_img".to_string()),
])),
reference_audio: Some(AudioListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_audio".to_string()),
])),
..Default::default()
};
let cents = builder.build2()
.expect("build2 should succeed")
.estimate_cost()
.expect("estimate_cost should succeed")
.cost_in_usd_cents
.unwrap();
assert_eq!(cents, cost_cents(Some(RouterResolution::SevenTwentyP), 5, 1));
}
}
// -- Relative pricing --
mod relative_pricing_tests {
@@ -216,4 +322,29 @@ mod tests {
.cost_in_usd_cents
.unwrap()
}
/// Like [`cost_cents`], with one reference video attached.
fn cost_cents_with_video_ref(
resolution: Option<RouterResolution>,
duration_seconds: u16,
video_batch_count: u16,
) -> u64 {
let builder = GenerateVideoRequestBuilder {
model: RouterVideoModel::Seedance2p0Fast,
provider: RouterProvider::Artcraft,
resolution,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(video_batch_count),
reference_videos: Some(VideoListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_ref".to_string()),
])),
..Default::default()
};
builder.build2()
.expect("build2 should succeed")
.estimate_cost()
.expect("estimate_cost should succeed")
.cost_in_usd_cents
.unwrap()
}
}
@@ -55,12 +55,12 @@ mod tests {
#[test]
fn full_duration_table() {
assert_eq!(cents(Some(RouterResolution::FourEightyP), 4), 82);
assert_eq!(cents(Some(RouterResolution::FourEightyP), 10), 205);
assert_eq!(cents(Some(RouterResolution::FourEightyP), 15), 307);
assert_eq!(cents(Some(RouterResolution::FourEightyP), 20), 409);
assert_eq!(cents(Some(RouterResolution::FourEightyP), 25), 511);
assert_eq!(cents(Some(RouterResolution::FourEightyP), 30), 613);
assert_eq!(cents(Some(RouterResolution::FourEightyP), 4), 86);
assert_eq!(cents(Some(RouterResolution::FourEightyP), 10), 214);
assert_eq!(cents(Some(RouterResolution::FourEightyP), 15), 321);
assert_eq!(cents(Some(RouterResolution::FourEightyP), 20), 428);
assert_eq!(cents(Some(RouterResolution::FourEightyP), 25), 535);
assert_eq!(cents(Some(RouterResolution::FourEightyP), 30), 642);
}
}
@@ -69,12 +69,12 @@ mod tests {
#[test]
fn full_duration_table() {
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 4), 164);
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 10), 409);
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 15), 613);
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 20), 817);
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 25), 1021);
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 30), 1226);
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 4), 172);
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 10), 428);
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 15), 642);
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 20), 856);
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 25), 1070);
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 30), 1283);
}
#[test]
@@ -119,9 +119,9 @@ pub fn seedance_2p0_byteplus_mini_usd_cents(
// change the price.
/// Seedance 2.5 Preview — 480p price, USD cents per second.
const SEEDANCE_2P5_PREVIEW_CENTS_PER_SECOND_480P: f64 = 20.41909465;
const SEEDANCE_2P5_PREVIEW_CENTS_PER_SECOND_480P: f64 = 21.38234568;
/// Seedance 2.5 Preview — 720p price, USD cents per second.
const SEEDANCE_2P5_PREVIEW_CENTS_PER_SECOND_720P: f64 = 40.8381893;
const SEEDANCE_2P5_PREVIEW_CENTS_PER_SECOND_720P: f64 = 42.76469136;
/// ArtCraft's price (USD cents) for Seedance 2.5 Preview.
///
@@ -15,8 +15,9 @@ impl ArtcraftSora2CostState {
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
// Sora 2: $0.10/second. Default duration 4s.
let cost_in_usd_cents = self.duration_seconds * 10;
// Sora 2: 11.5¢/second (held in hundredths of a cent, rounded up to
// whole cents). Default duration 4s.
let cost_in_usd_cents = (self.duration_seconds * 1_150).div_ceil(100);
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
@@ -48,14 +49,14 @@ mod tests {
}
#[test]
fn four_seconds_is_40() { assert_eq!(cost_cents(Some(4)), 40); }
fn four_seconds_is_46() { assert_eq!(cost_cents(Some(4)), 46); }
#[test]
fn eight_seconds_is_80() { assert_eq!(cost_cents(Some(8)), 80); }
fn eight_seconds_is_92() { assert_eq!(cost_cents(Some(8)), 92); }
#[test]
fn twelve_seconds_is_120() { assert_eq!(cost_cents(Some(12)), 120); }
fn twelve_seconds_is_138() { assert_eq!(cost_cents(Some(12)), 138); }
#[test]
fn default_duration_is_4s_priced_at_40() { assert_eq!(cost_cents(None), 40); }
fn default_duration_is_4s_priced_at_46() { assert_eq!(cost_cents(None), 46); }
}
@@ -19,9 +19,10 @@ impl ArtcraftSora2ProCostState {
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
// Sora 2 Pro: $0.30/sec @ 720p, $0.50/sec @ 1080p. Default 4s.
let per_second_cents: u64 = if self.is_ten_eighty_p { 50 } else { 30 };
let cost_in_usd_cents = per_second_cents * self.duration_seconds;
// Sora 2 Pro: 34.5¢/sec @ 720p, 57.5¢/sec @ 1080p (held in hundredths
// of a cent, rounded up to whole cents). Default 4s.
let per_second_centi_cents: u64 = if self.is_ten_eighty_p { 5_750 } else { 3_450 };
let cost_in_usd_cents = (per_second_centi_cents * self.duration_seconds).div_ceil(100);
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
@@ -77,30 +78,30 @@ mod tests {
use super::*;
#[test]
fn p720_4s_is_120() { assert_eq!(cost_cents(Some(4), Some(RouterResolution::SevenTwentyP), false), 120); }
fn p720_4s_is_138() { assert_eq!(cost_cents(Some(4), Some(RouterResolution::SevenTwentyP), false), 138); }
#[test]
fn p720_8s_is_240() { assert_eq!(cost_cents(Some(8), Some(RouterResolution::SevenTwentyP), false), 240); }
fn p720_8s_is_276() { assert_eq!(cost_cents(Some(8), Some(RouterResolution::SevenTwentyP), false), 276); }
#[test]
fn p720_12s_is_360() { assert_eq!(cost_cents(Some(12), Some(RouterResolution::SevenTwentyP), false), 360); }
fn p720_12s_is_414() { assert_eq!(cost_cents(Some(12), Some(RouterResolution::SevenTwentyP), false), 414); }
#[test]
fn p1080_4s_is_200() { assert_eq!(cost_cents(Some(4), Some(RouterResolution::TenEightyP), false), 200); }
fn p1080_4s_is_230() { assert_eq!(cost_cents(Some(4), Some(RouterResolution::TenEightyP), false), 230); }
#[test]
fn p1080_12s_is_600() { assert_eq!(cost_cents(Some(12), Some(RouterResolution::TenEightyP), false), 600); }
fn p1080_12s_is_690() { assert_eq!(cost_cents(Some(12), Some(RouterResolution::TenEightyP), false), 690); }
#[test]
fn t2v_default_resolution_priced_as_1080p() {
// T2V (no start_frame) with no resolution → 1080p default → 200¢ at 4s.
assert_eq!(cost_cents(Some(4), None, false), 200);
// T2V (no start_frame) with no resolution → 1080p default → 230¢ at 4s.
assert_eq!(cost_cents(Some(4), None, false), 230);
}
#[test]
fn i2v_default_resolution_priced_as_720p() {
// I2V (with start_frame) with no resolution → 720p default → 120¢ at 4s.
assert_eq!(cost_cents(Some(4), None, true), 120);
// I2V (with start_frame) with no resolution → 720p default → 138¢ at 4s.
assert_eq!(cost_cents(Some(4), None, true), 138);
}
}
@@ -4,10 +4,10 @@ use crate::generate::generate_video::video_generation_cost_estimate::VideoGenera
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;
const SEVEN_TWENTY_P_AUDIO_OFF_RATE: u64 = 345;
const SEVEN_TWENTY_P_AUDIO_ON_RATE: u64 = 575;
const TEN_EIGHTY_P_AUDIO_OFF_RATE: u64 = 575;
const TEN_EIGHTY_P_AUDIO_ON_RATE: u64 = 920;
#[derive(Clone, Debug)]
pub struct ArtcraftVeo3p1LiteCostState {
@@ -88,28 +88,28 @@ mod tests {
}
#[test]
fn defaults_8s_720p_audio_on_is_42() { assert_eq!(cost_cents(None, None, None), 42); }
fn defaults_8s_720p_audio_on_is_46() { assert_eq!(cost_cents(None, None, None), 46); }
#[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);
fn audio_off_8s_720p_is_28() {
// 345 * 8 = 2760 hundredth-cents → 28 cents.
assert_eq!(cost_cents(Some(8), None, Some(false)), 28);
}
#[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);
fn audio_on_8s_1080p_is_74() {
// 920 * 8 = 7360 hundredth-cents → 74 cents.
assert_eq!(cost_cents(Some(8), Some(RouterResolution::TenEightyP), Some(true)), 74);
}
#[test]
fn audio_off_8s_1080p_is_42() {
assert_eq!(cost_cents(Some(8), Some(RouterResolution::TenEightyP), Some(false)), 42);
fn audio_off_8s_1080p_is_46() {
assert_eq!(cost_cents(Some(8), Some(RouterResolution::TenEightyP), Some(false)), 46);
}
#[test]
fn audio_on_4s_720p_is_21() {
assert_eq!(cost_cents(Some(4), None, Some(true)), 21);
fn audio_on_4s_720p_is_23() {
assert_eq!(cost_cents(Some(4), None, Some(true)), 23);
}
#[test]
@@ -0,0 +1,416 @@
use fal_client::requests::api::video::image::flux_3::api::Flux3ImageToVideoRequest;
use fal_client::requests::api::video::images::flux_3::api::Flux3FirstLastFrameToVideoRequest;
use fal_client::requests::api::video::text::flux_3::api::{
Flux3AspectRatio, Flux3Duration, Flux3Resolution, Flux3TextToVideoRequest,
};
use crate::api::audio_list_ref::AudioListRef;
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::flux_3::request::{
FalFlux3Mode, FalFlux3RequestState,
};
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
pub(crate) const MIN_DURATION_SECONDS: u16 = 5;
pub(crate) const MAX_DURATION_SECONDS: u16 = 20;
#[derive(Debug, Clone, Copy)]
pub(crate) enum PlanAspectRatio {
Auto,
TwentyOneByNine,
SixteenByNine,
FourByThree,
Square,
ThreeByFour,
NineBySixteen,
}
pub fn build_fal_flux_3(
builder: GenerateVideoRequestBuilder,
) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let state = build_fal_flux_3_state(builder)?;
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalFlux3(state)))
}
pub(crate) fn build_fal_flux_3_state(
builder: GenerateVideoRequestBuilder,
) -> Result<FalFlux3RequestState, ArtcraftRouterError> {
let strategy = builder.request_mismatch_mitigation_strategy;
// Flux 3 exposes no reference modality here — only text, starting keyframe,
// and start/end keyframes.
reject_references(&builder)?;
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 flexible_duration = plan_flexible_duration(builder.duration_seconds, strategy)?;
let fixed_duration = plan_fixed_duration(builder.duration_seconds, strategy)?;
let prompt = builder.prompt.clone().unwrap_or_default();
let generate_audio = builder.generate_audio;
// Modality dispatch:
// start + end frames → first-last-frame-to-video
// start frame only → image-to-video
// no media → text-to-video
let mode = match (start, end) {
(None, None) => FalFlux3Mode::TextToVideo(Flux3TextToVideoRequest {
prompt,
duration: flexible_duration,
resolution,
aspect_ratio: aspect_ratio.map(to_flux_3_aspect_ratio),
generate_audio,
safety_tolerance: Some(4), // NB: 4 is the most permissive.
}),
(Some(image_url), None) => FalFlux3Mode::ImageToVideo(Flux3ImageToVideoRequest {
prompt,
image_url,
duration: flexible_duration,
resolution,
aspect_ratio: aspect_ratio.map(to_flux_3_aspect_ratio),
generate_audio,
safety_tolerance: Some(4), // NB: 4 is the most permissive.
}),
(Some(start_image_url), Some(end_image_url)) => FalFlux3Mode::FirstLastFrameToVideo(
Flux3FirstLastFrameToVideoRequest {
prompt,
start_image_url,
end_image_url,
duration: fixed_duration,
resolution,
aspect_ratio: aspect_ratio.map(to_flux_3_aspect_ratio),
generate_audio,
safety_tolerance: Some(4), // NB: 4 is the most permissive.
},
),
(None, Some(_)) => {
return Err(unsupported(
"end_frame",
"Flux 3 requires a start_frame when end_frame is provided",
));
}
};
Ok(FalFlux3RequestState { mode })
}
// ── Input helpers (shared with the draft variant) ──
pub(crate) fn unsupported(field: &'static str, value: &str) -> ArtcraftRouterError {
ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field,
value: value.to_string(),
})
}
/// Flux 3 supports no reference inputs — no image, video, or audio references.
pub(crate) fn reject_references(builder: &GenerateVideoRequestBuilder) -> Result<(), ArtcraftRouterError> {
let has_reference_images = match &builder.reference_images {
None => false,
Some(ImageListRef::Urls(urls)) => !urls.is_empty(),
Some(ImageListRef::MediaFileTokens(tokens)) => !tokens.is_empty(),
};
if has_reference_images {
return Err(unsupported("reference_images", "Flux 3 does not support image references"));
}
let has_reference_videos = match &builder.reference_videos {
None => false,
Some(VideoListRef::Urls(urls)) => !urls.is_empty(),
Some(VideoListRef::MediaFileTokens(tokens)) => !tokens.is_empty(),
};
if has_reference_videos {
return Err(unsupported("reference_videos", "Flux 3 does not support video references"));
}
let has_reference_audio = match &builder.reference_audio {
None => false,
Some(AudioListRef::Urls(urls)) => !urls.is_empty(),
Some(AudioListRef::MediaFileTokens(tokens)) => !tokens.is_empty(),
};
if has_reference_audio {
return Err(unsupported("reference_audio", "Flux 3 does not support audio references"));
}
Ok(())
}
pub(crate) 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))
}
}
}
// ── Plan helpers (shared with the draft variant) ──
pub(crate) 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::Auto3k)
| Some(RouterAspectRatio::Auto4k) => Ok(Some(Ar::Auto)),
Some(RouterAspectRatio::WideTwentyOneByNine) => Ok(Some(Ar::TwentyOneByNine)),
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::Square) | Some(RouterAspectRatio::SquareHd) => Ok(Some(Ar::Square)),
Some(other) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(unsupported("aspect_ratio", &format!("{:?}", other)))
}
_ => Ok(Some(Ar::Auto)),
},
}
}
/// Flux 3 renders 720p or 1080p; 480p rounds up to 720p (same billing tier).
pub(crate) fn plan_resolution(
resolution: Option<RouterResolution>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<Flux3Resolution>, ArtcraftRouterError> {
match resolution {
None => Ok(None),
Some(RouterResolution::FourEightyP)
| Some(RouterResolution::SevenTwentyP) => Ok(Some(Flux3Resolution::SevenTwentyP)),
Some(RouterResolution::TenEightyP) => Ok(Some(Flux3Resolution::TenEightyP)),
Some(other) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(unsupported("resolution", &format!("{:?}", other)))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Some(Flux3Resolution::TenEightyP)),
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(Flux3Resolution::SevenTwentyP)),
},
}
}
/// Duration for the modalities that accept `auto` (text, image). `None` stays
/// unset so fal's `auto` default applies.
pub(crate) fn plan_flexible_duration(
duration_seconds: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<Flux3Duration>, ArtcraftRouterError> {
Ok(plan_fixed_duration(duration_seconds, strategy)?.map(Flux3Duration::Seconds))
}
/// Duration for the fixed-duration first-last-frame modality. `None` stays
/// unset so fal's 5-second default applies.
pub(crate) fn plan_fixed_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(unsupported("duration_seconds", &format!("{}", other)))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(Some(other.clamp(MIN_DURATION_SECONDS, MAX_DURATION_SECONDS) as u8))
}
},
}
}
// ── Leaf converters ──
pub(crate) fn to_flux_3_aspect_ratio(a: PlanAspectRatio) -> Flux3AspectRatio {
match a {
PlanAspectRatio::Auto => Flux3AspectRatio::Auto,
PlanAspectRatio::TwentyOneByNine => Flux3AspectRatio::TwentyOneByNine,
PlanAspectRatio::SixteenByNine => Flux3AspectRatio::SixteenByNine,
PlanAspectRatio::FourByThree => Flux3AspectRatio::FourByThree,
PlanAspectRatio::Square => Flux3AspectRatio::Square,
PlanAspectRatio::ThreeByFour => Flux3AspectRatio::ThreeByFour,
PlanAspectRatio::NineBySixteen => Flux3AspectRatio::NineBySixteen,
}
}
#[cfg(test)]
mod tests {
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_flux_3_state(base_builder()).expect("build");
assert!(matches!(state.mode, FalFlux3Mode::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_flux_3_state(b).expect("build");
assert!(matches!(state.mode, FalFlux3Mode::ImageToVideo(_)));
}
#[test]
fn start_and_end_frames_pick_first_last_frame() {
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_flux_3_state(b).expect("build");
let FalFlux3Mode::FirstLastFrameToVideo(req) = state.mode else {
panic!("expected FirstLastFrameToVideo");
};
assert_eq!(req.start_image_url, START_URL);
assert_eq!(req.end_image_url, 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_flux_3_state(b).is_err());
}
#[test]
fn reference_images_error() {
let mut b = base_builder();
b.reference_images = Some(ImageListRef::Urls(vec!["https://example.com/ref.png".to_string()]));
assert!(build_fal_flux_3_state(b).is_err());
}
#[test]
fn reference_videos_error() {
let mut b = base_builder();
b.reference_videos = Some(VideoListRef::Urls(vec!["https://example.com/v.mp4".to_string()]));
assert!(build_fal_flux_3_state(b).is_err());
}
#[test]
fn reference_audio_errors() {
let mut b = base_builder();
b.reference_audio = Some(AudioListRef::Urls(vec!["https://example.com/a.mp3".to_string()]));
assert!(build_fal_flux_3_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![]));
b.reference_audio = Some(AudioListRef::Urls(vec![]));
let state = build_fal_flux_3_state(b).expect("build");
assert!(matches!(state.mode, FalFlux3Mode::TextToVideo(_)));
}
}
mod plan_tests {
use super::*;
#[test]
fn duration_25_errors_under_error_out() {
let mut b = base_builder();
b.duration_seconds = Some(25);
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut;
assert!(build_fal_flux_3_state(b).is_err());
}
#[test]
fn duration_25_clamps_to_20_under_pay_less() {
let mut b = base_builder();
b.duration_seconds = Some(25);
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::PayLessDowngrade;
let state = build_fal_flux_3_state(b).expect("build");
let FalFlux3Mode::TextToVideo(req) = state.mode else {
panic!("expected TextToVideo");
};
assert_eq!(req.duration, Some(Flux3Duration::Seconds(20)));
}
#[test]
fn resolution_480p_and_720p_map_to_720p() {
for res in [RouterResolution::FourEightyP, RouterResolution::SevenTwentyP] {
let mut b = base_builder();
b.resolution = Some(res);
let state = build_fal_flux_3_state(b).expect("build");
let FalFlux3Mode::TextToVideo(req) = state.mode else {
panic!("expected TextToVideo");
};
assert_eq!(req.resolution, Some(Flux3Resolution::SevenTwentyP), "res={res:?}");
}
}
#[test]
fn auto_aspect_ratio_maps_to_flux_auto() {
let mut b = base_builder();
b.aspect_ratio = Some(RouterAspectRatio::Auto);
let state = build_fal_flux_3_state(b).expect("build");
let FalFlux3Mode::TextToVideo(req) = state.mode else {
panic!("expected TextToVideo");
};
assert_eq!(req.aspect_ratio, Some(Flux3AspectRatio::Auto));
}
#[test]
fn twenty_one_by_nine_is_supported() {
let mut b = base_builder();
b.aspect_ratio = Some(RouterAspectRatio::WideTwentyOneByNine);
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut;
let state = build_fal_flux_3_state(b).expect("build");
let FalFlux3Mode::TextToVideo(req) = state.mode else {
panic!("expected TextToVideo");
};
assert_eq!(req.aspect_ratio, Some(Flux3AspectRatio::TwentyOneByNine));
}
#[test]
fn generate_audio_propagates() {
let mut b = base_builder();
b.generate_audio = Some(false);
let state = build_fal_flux_3_state(b).expect("build");
let FalFlux3Mode::TextToVideo(req) = state.mode else {
panic!("expected TextToVideo");
};
assert_eq!(req.generate_audio, Some(false));
}
}
fn base_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: RouterVideoModel::Flux3,
provider: RouterProvider::Fal,
prompt: Some("test".to_string()),
..Default::default()
}
}
}
@@ -0,0 +1,106 @@
use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator;
use crate::generate::generate_video::providers::fal::flux_3::request::{
FalFlux3Mode, FalFlux3RequestState,
};
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
#[derive(Clone, Debug)]
pub struct FalFlux3CostState {
pub cost_in_usd_cents: u64,
}
impl FalFlux3CostState {
pub fn from_request(request: &FalFlux3RequestState) -> 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 {
FalFlux3Mode::TextToVideo(req) => req.calculate_cost_in_cents(),
FalFlux3Mode::ImageToVideo(req) => req.calculate_cost_in_cents(),
FalFlux3Mode::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::flux_3::build::build_fal_flux_3_state;
use super::*;
// Pricing (from fal_client's flux_3 cost modules):
// 720p: $0.17/s, 1080p: $0.29/s (all modalities)
// fal defaults when unset: duration = 5s (auto estimates at the 5s floor),
// resolution = 720p.
#[test]
fn t2v_default_settings_is_85() {
assert_eq!(cost_cents(base_builder(None, None)), 85);
}
#[test]
fn t2v_10s_1080p_is_290() {
assert_eq!(cost_cents(base_builder(Some(10), Some(RouterResolution::TenEightyP))), 290);
}
#[test]
fn i2v_5s_720p_is_85() {
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), 85);
}
#[test]
fn first_last_frame_20s_1080p_is_580() {
let mut b = base_builder(Some(20), Some(RouterResolution::TenEightyP));
b.start_frame = Some(ImageRef::Url("https://example.com/start.png".to_string()));
b.end_frame = Some(ImageRef::Url("https://example.com/end.png".to_string()));
assert_eq!(cost_cents(b), 580);
}
#[test]
fn estimate_cost_forwards_cents() {
let estimate = FalFlux3CostState { cost_in_usd_cents: 85 }.estimate_cost();
assert_eq!(estimate.cost_in_usd_cents, Some(85));
assert_eq!(estimate.cost_in_credits, Some(85));
assert!(!estimate.is_free);
}
fn base_builder(
duration_seconds: Option<u16>,
resolution: Option<RouterResolution>,
) -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: RouterVideoModel::Flux3,
provider: RouterProvider::Fal,
prompt: Some("test".to_string()),
duration_seconds,
resolution,
..Default::default()
}
}
fn cost_cents(builder: GenerateVideoRequestBuilder) -> u64 {
let state = build_fal_flux_3_state(builder).expect("build");
FalFlux3CostState::from_request(&state).cost_in_usd_cents
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -0,0 +1,144 @@
use std::fmt::Debug;
use std::sync::Arc;
use fal_client::requests::api::video::image::flux_3::api::Flux3ImageToVideoRequest;
use fal_client::requests::api::video::images::flux_3::api::Flux3FirstLastFrameToVideoRequest;
use fal_client::requests::api::video::text::flux_3::api::Flux3TextToVideoRequest;
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 FalFlux3Mode {
TextToVideo(Flux3TextToVideoRequest),
ImageToVideo(Flux3ImageToVideoRequest),
FirstLastFrameToVideo(Flux3FirstLastFrameToVideoRequest),
}
#[derive(Clone, Debug)]
pub struct FalFlux3RequestState {
pub mode: FalFlux3Mode,
}
impl FalFlux3RequestState {
pub async fn send(&self, client: &RouterFalClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
match &self.mode {
FalFlux3Mode::TextToVideo(request) => send_request(request, client).await,
FalFlux3Mode::ImageToVideo(request) => send_request(request, client).await,
FalFlux3Mode::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 super::*;
use fal_client::creds::fal_api_key::FalApiKey;
use fal_client::requests::api::video::text::flux_3::api::{
Flux3AspectRatio, Flux3Duration, Flux3Resolution,
};
use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL;
#[tokio::test]
#[ignore] // requires real API key, incurs cost
async fn send_text_to_video_webhook() {
let state = FalFlux3RequestState {
mode: FalFlux3Mode::TextToVideo(Flux3TextToVideoRequest {
prompt: "a calm lake at sunrise".to_string(),
duration: Some(Flux3Duration::Seconds(5)),
resolution: Some(Flux3Resolution::SevenTwentyP),
aspect_ratio: Some(Flux3AspectRatio::SixteenByNine),
generate_audio: Some(false),
safety_tolerance: Some(4), // NB: 4 is the most permissive.
}),
};
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 = FalFlux3RequestState {
mode: FalFlux3Mode::ImageToVideo(Flux3ImageToVideoRequest {
prompt: "the lake comes alive".to_string(),
image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(),
duration: Some(Flux3Duration::Seconds(5)),
resolution: Some(Flux3Resolution::SevenTwentyP),
aspect_ratio: None,
generate_audio: Some(false),
safety_tolerance: None,
}),
};
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());
}
// ── Helpers ──
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,151 @@
use fal_client::requests::api::video::image::flux_3_draft::api::Flux3DraftImageToVideoRequest;
use fal_client::requests::api::video::images::flux_3_draft::api::Flux3DraftFirstLastFrameToVideoRequest;
use fal_client::requests::api::video::text::flux_3_draft::api::Flux3DraftTextToVideoRequest;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::providers::fal::flux_3::build::{
optional_url, plan_aspect_ratio, plan_fixed_duration, plan_flexible_duration,
reject_references, to_flux_3_aspect_ratio, unsupported,
};
use crate::generate::generate_video::providers::fal::flux_3_draft::request::{
FalFlux3DraftMode, FalFlux3DraftRequestState,
};
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
pub fn build_fal_flux_3_draft(
builder: GenerateVideoRequestBuilder,
) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let state = build_fal_flux_3_draft_state(builder)?;
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalFlux3Draft(state)))
}
/// Same modality dispatch as full-quality Flux 3 (see that build module) —
/// text, starting keyframe, and start/end keyframes; no references — but
/// against the draft endpoints, which always render 720p and take no
/// resolution input, so any requested resolution is silently dropped.
pub(crate) fn build_fal_flux_3_draft_state(
builder: GenerateVideoRequestBuilder,
) -> Result<FalFlux3DraftRequestState, ArtcraftRouterError> {
let strategy = builder.request_mismatch_mitigation_strategy;
reject_references(&builder)?;
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 flexible_duration = plan_flexible_duration(builder.duration_seconds, strategy)?;
let fixed_duration = plan_fixed_duration(builder.duration_seconds, strategy)?;
let prompt = builder.prompt.clone().unwrap_or_default();
let generate_audio = builder.generate_audio;
let mode = match (start, end) {
(None, None) => FalFlux3DraftMode::TextToVideo(Flux3DraftTextToVideoRequest {
prompt,
duration: flexible_duration,
aspect_ratio: aspect_ratio.map(to_flux_3_aspect_ratio),
generate_audio,
safety_tolerance: Some(4), // NB: 4 is the most permissive.
}),
(Some(image_url), None) => FalFlux3DraftMode::ImageToVideo(Flux3DraftImageToVideoRequest {
prompt,
image_url,
duration: flexible_duration,
aspect_ratio: aspect_ratio.map(to_flux_3_aspect_ratio),
generate_audio,
safety_tolerance: Some(4), // NB: 4 is the most permissive.
}),
(Some(start_image_url), Some(end_image_url)) => FalFlux3DraftMode::FirstLastFrameToVideo(
Flux3DraftFirstLastFrameToVideoRequest {
prompt,
start_image_url,
end_image_url,
duration: fixed_duration,
aspect_ratio: aspect_ratio.map(to_flux_3_aspect_ratio),
generate_audio,
safety_tolerance: Some(4), // NB: 4 is the most permissive.
},
),
(None, Some(_)) => {
return Err(unsupported(
"end_frame",
"Flux 3 Draft requires a start_frame when end_frame is provided",
));
}
};
Ok(FalFlux3DraftRequestState { mode })
}
#[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_video_model::RouterVideoModel;
use crate::api::video_list_ref::VideoListRef;
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_flux_3_draft_state(base_builder()).expect("build");
assert!(matches!(state.mode, FalFlux3DraftMode::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_flux_3_draft_state(b).expect("build");
assert!(matches!(state.mode, FalFlux3DraftMode::ImageToVideo(_)));
}
#[test]
fn start_and_end_frames_pick_first_last_frame() {
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_flux_3_draft_state(b).expect("build");
assert!(matches!(state.mode, FalFlux3DraftMode::FirstLastFrameToVideo(_)));
}
#[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_flux_3_draft_state(b).is_err());
}
#[test]
fn reference_images_error() {
let mut b = base_builder();
b.reference_images = Some(ImageListRef::Urls(vec!["https://example.com/ref.png".to_string()]));
assert!(build_fal_flux_3_draft_state(b).is_err());
}
#[test]
fn reference_videos_error() {
let mut b = base_builder();
b.reference_videos = Some(VideoListRef::Urls(vec!["https://example.com/v.mp4".to_string()]));
assert!(build_fal_flux_3_draft_state(b).is_err());
}
}
fn base_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: RouterVideoModel::Flux3Draft,
provider: RouterProvider::Fal,
prompt: Some("test".to_string()),
..Default::default()
}
}
}
@@ -0,0 +1,76 @@
use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator;
use crate::generate::generate_video::providers::fal::flux_3_draft::request::{
FalFlux3DraftMode, FalFlux3DraftRequestState,
};
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
#[derive(Clone, Debug)]
pub struct FalFlux3DraftCostState {
pub cost_in_usd_cents: u64,
}
impl FalFlux3DraftCostState {
pub fn from_request(request: &FalFlux3DraftRequestState) -> 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 {
FalFlux3DraftMode::TextToVideo(req) => req.calculate_cost_in_cents(),
FalFlux3DraftMode::ImageToVideo(req) => req.calculate_cost_in_cents(),
FalFlux3DraftMode::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::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::flux_3_draft::build::build_fal_flux_3_draft_state;
use super::*;
// Pricing (from fal_client's flux_3_draft cost modules):
// $0.06/s (always 720p, all modalities)
// fal defaults when unset: duration = 5s (auto estimates at the 5s floor).
#[test]
fn t2v_default_settings_is_30() {
assert_eq!(cost_cents(base_builder(None)), 30);
}
#[test]
fn t2v_20s_is_120() {
assert_eq!(cost_cents(base_builder(Some(20))), 120);
}
fn base_builder(duration_seconds: Option<u16>) -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: RouterVideoModel::Flux3Draft,
provider: RouterProvider::Fal,
prompt: Some("test".to_string()),
duration_seconds,
..Default::default()
}
}
fn cost_cents(builder: GenerateVideoRequestBuilder) -> u64 {
let state = build_fal_flux_3_draft_state(builder).expect("build");
FalFlux3DraftCostState::from_request(&state).cost_in_usd_cents
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -0,0 +1,121 @@
use std::fmt::Debug;
use std::sync::Arc;
use fal_client::requests::api::video::image::flux_3_draft::api::Flux3DraftImageToVideoRequest;
use fal_client::requests::api::video::images::flux_3_draft::api::Flux3DraftFirstLastFrameToVideoRequest;
use fal_client::requests::api::video::text::flux_3_draft::api::Flux3DraftTextToVideoRequest;
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 FalFlux3DraftMode {
TextToVideo(Flux3DraftTextToVideoRequest),
ImageToVideo(Flux3DraftImageToVideoRequest),
FirstLastFrameToVideo(Flux3DraftFirstLastFrameToVideoRequest),
}
#[derive(Clone, Debug)]
pub struct FalFlux3DraftRequestState {
pub mode: FalFlux3DraftMode,
}
impl FalFlux3DraftRequestState {
pub async fn send(&self, client: &RouterFalClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
match &self.mode {
FalFlux3DraftMode::TextToVideo(request) => send_request(request, client).await,
FalFlux3DraftMode::ImageToVideo(request) => send_request(request, client).await,
FalFlux3DraftMode::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 super::*;
use fal_client::creds::fal_api_key::FalApiKey;
use fal_client::requests::api::video::text::flux_3::api::{Flux3AspectRatio, Flux3Duration};
#[tokio::test]
#[ignore] // requires real API key, incurs cost
async fn send_text_to_video_webhook() {
let state = FalFlux3DraftRequestState {
mode: FalFlux3DraftMode::TextToVideo(Flux3DraftTextToVideoRequest {
prompt: "a calm lake at sunrise".to_string(),
duration: Some(Flux3Duration::Seconds(5)),
aspect_ratio: Some(Flux3AspectRatio::SixteenByNine),
generate_audio: Some(false),
safety_tolerance: None,
}),
};
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());
}
// ── Helpers ──
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,543 @@
use fal_client::requests::api::video::image::minimax_h3::api::MinimaxH3ImageToVideoRequest;
use fal_client::requests::api::video::reference::minimax_h3::api::{
MinimaxH3ReferenceToVideoAspectRatio, MinimaxH3ReferenceToVideoRequest,
};
use fal_client::requests::api::video::text::minimax_h3::api::{
MinimaxH3Resolution, MinimaxH3TextToVideoAspectRatio, MinimaxH3TextToVideoRequest,
};
use crate::api::audio_list_ref::AudioListRef;
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::minimax_h3::request::{
FalMinimaxH3Mode, FalMinimaxH3RequestState,
};
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 = 9;
const MAX_REFERENCE_VIDEOS: usize = 3;
const MAX_REFERENCE_AUDIO: usize = 3;
const MAX_REFERENCE_FILES: usize = 12;
const MIN_DURATION_SECONDS: u16 = 5;
const MAX_DURATION_SECONDS: u16 = 15;
#[derive(Debug, Clone, Copy)]
pub(crate) enum PlanAspectRatio {
TwentyOneByNine,
SixteenByNine,
FourByThree,
Square,
ThreeByFour,
NineBySixteen,
}
pub fn build_fal_minimax_h3(
builder: GenerateVideoRequestBuilder,
) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let state = build_fal_minimax_h3_state(builder)?;
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalMinimaxH3(state)))
}
pub(crate) fn build_fal_minimax_h3_state(
builder: GenerateVideoRequestBuilder,
) -> Result<FalMinimaxH3RequestState, ArtcraftRouterError> {
let strategy = builder.request_mismatch_mitigation_strategy;
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 reference_image_urls = resolve_image_urls(builder.reference_images.clone())?;
let reference_video_urls = resolve_video_urls(builder.reference_videos.clone())?;
let reference_audio_urls = resolve_audio_urls(builder.reference_audio.clone())?;
let start_image_url = optional_url(builder.start_frame.clone())?;
let end_image_url = optional_url(builder.end_frame.clone())?;
let has_references = !reference_image_urls.is_empty()
|| !reference_video_urls.is_empty()
|| !reference_audio_urls.is_empty();
// Dispatch: any reference asset → reference-to-video; start_frame → image; else → text.
let mode = if has_references {
if start_image_url.is_some() || end_image_url.is_some() {
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "reference_images",
value: "MiniMax H3 reference-to-video cannot also accept a start_frame or end_frame".to_string(),
}));
}
check_reference_counts(
reference_image_urls.len(),
reference_video_urls.len(),
reference_audio_urls.len(),
)?;
FalMinimaxH3Mode::ReferenceToVideo(MinimaxH3ReferenceToVideoRequest {
prompt,
reference_image_urls: non_empty(reference_image_urls),
reference_video_urls: non_empty(reference_video_urls),
reference_audio_urls: non_empty(reference_audio_urls),
duration,
resolution,
aspect_ratio: aspect_ratio.map(to_reference_aspect_ratio),
})
} else if let Some(image_url) = start_image_url {
// Image-to-video has no aspect_ratio input — the output follows the start
// frame — so any requested aspect ratio is silently dropped here.
FalMinimaxH3Mode::ImageToVideo(MinimaxH3ImageToVideoRequest {
prompt,
image_url,
end_image_url,
duration,
resolution,
})
} else {
if end_image_url.is_some() {
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "end_frame",
value: "MiniMax H3 requires a start_frame when end_frame is provided".to_string(),
}));
}
FalMinimaxH3Mode::TextToVideo(MinimaxH3TextToVideoRequest {
prompt,
duration,
resolution,
aspect_ratio: aspect_ratio.map(to_t2v_aspect_ratio),
})
};
Ok(FalMinimaxH3RequestState { mode })
}
// ── Input helpers ──
/// fal's reference limits: at most 9 images, 3 videos, 3 audio clips, and 12
/// files overall; audio cannot be the only reference input.
fn check_reference_counts(
image_count: usize,
video_count: usize,
audio_count: usize,
) -> Result<(), ArtcraftRouterError> {
if image_count > MAX_REFERENCE_IMAGES {
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "reference_images",
value: format!("MiniMax H3 supports at most {MAX_REFERENCE_IMAGES} reference images, got {image_count}"),
}));
}
if video_count > MAX_REFERENCE_VIDEOS {
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "reference_videos",
value: format!("MiniMax H3 supports at most {MAX_REFERENCE_VIDEOS} reference videos, got {video_count}"),
}));
}
if audio_count > MAX_REFERENCE_AUDIO {
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "reference_audio",
value: format!("MiniMax H3 supports at most {MAX_REFERENCE_AUDIO} reference audio clips, got {audio_count}"),
}));
}
let total = image_count + video_count + audio_count;
if total > MAX_REFERENCE_FILES {
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "reference_images",
value: format!("MiniMax H3 supports at most {MAX_REFERENCE_FILES} reference files overall, got {total}"),
}));
}
if audio_count > 0 && image_count == 0 && video_count == 0 {
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "reference_audio",
value: "MiniMax H3 requires at least one reference image or video alongside reference audio".to_string(),
}));
}
Ok(())
}
fn resolve_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))
}
}
}
fn resolve_video_urls(
reference_videos: Option<VideoListRef>,
) -> Result<Vec<String>, ArtcraftRouterError> {
match reference_videos {
None => Ok(vec![]),
Some(VideoListRef::Urls(urls)) => Ok(urls),
Some(VideoListRef::MediaFileTokens(_)) => {
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
}
}
}
fn resolve_audio_urls(
reference_audio: Option<AudioListRef>,
) -> Result<Vec<String>, ArtcraftRouterError> {
match reference_audio {
None => Ok(vec![]),
Some(AudioListRef::Urls(urls)) => Ok(urls),
Some(AudioListRef::MediaFileTokens(_)) => {
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
}
}
}
fn non_empty(urls: Vec<String>) -> Option<Vec<String>> {
if urls.is_empty() { None } else { Some(urls) }
}
// ── Plan helpers ──
/// `None` (including the `Auto*` ratios) leaves the field unset so fal's
/// per-mode default applies: 16:9 for text-to-video, adaptive for
/// reference-to-video.
fn plan_aspect_ratio(
aspect_ratio: Option<RouterAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<PlanAspectRatio>, ArtcraftRouterError> {
use PlanAspectRatio as Ar;
match aspect_ratio {
None
| Some(RouterAspectRatio::Auto)
| Some(RouterAspectRatio::Auto2k)
| Some(RouterAspectRatio::Auto3k)
| Some(RouterAspectRatio::Auto4k) => Ok(None),
Some(RouterAspectRatio::WideTwentyOneByNine) => Ok(Some(Ar::TwentyOneByNine)),
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::Square) | Some(RouterAspectRatio::SquareHd) => Ok(Some(Ar::Square)),
Some(other) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "aspect_ratio",
value: format!("{:?}", other),
}))
}
_ => Ok(Some(Ar::SixteenByNine)),
},
}
}
/// MiniMax H3 offers exactly two output sizes: 768P and 2K.
fn plan_resolution(
resolution: Option<RouterResolution>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<MinimaxH3Resolution>, ArtcraftRouterError> {
match resolution {
None => Ok(None),
// 480p/720p round up to 768P, the smallest size H3 offers.
Some(RouterResolution::FourEightyP)
| Some(RouterResolution::SevenTwentyP) => Ok(Some(MinimaxH3Resolution::SevenSixtyEightP)),
Some(RouterResolution::TenEightyP)
| Some(RouterResolution::TwoK) => Ok(Some(MinimaxH3Resolution::TwoK)),
Some(other) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "resolution",
value: format!("{:?}", other),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Some(MinimaxH3Resolution::TwoK)),
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(MinimaxH3Resolution::SevenSixtyEightP)),
},
}
}
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) -> MinimaxH3TextToVideoAspectRatio {
match a {
PlanAspectRatio::TwentyOneByNine => MinimaxH3TextToVideoAspectRatio::TwentyOneByNine,
PlanAspectRatio::SixteenByNine => MinimaxH3TextToVideoAspectRatio::SixteenByNine,
PlanAspectRatio::FourByThree => MinimaxH3TextToVideoAspectRatio::FourByThree,
PlanAspectRatio::Square => MinimaxH3TextToVideoAspectRatio::Square,
PlanAspectRatio::ThreeByFour => MinimaxH3TextToVideoAspectRatio::ThreeByFour,
PlanAspectRatio::NineBySixteen => MinimaxH3TextToVideoAspectRatio::NineBySixteen,
}
}
fn to_reference_aspect_ratio(a: PlanAspectRatio) -> MinimaxH3ReferenceToVideoAspectRatio {
match a {
PlanAspectRatio::TwentyOneByNine => MinimaxH3ReferenceToVideoAspectRatio::TwentyOneByNine,
PlanAspectRatio::SixteenByNine => MinimaxH3ReferenceToVideoAspectRatio::SixteenByNine,
PlanAspectRatio::FourByThree => MinimaxH3ReferenceToVideoAspectRatio::FourByThree,
PlanAspectRatio::Square => MinimaxH3ReferenceToVideoAspectRatio::Square,
PlanAspectRatio::ThreeByFour => MinimaxH3ReferenceToVideoAspectRatio::ThreeByFour,
PlanAspectRatio::NineBySixteen => MinimaxH3ReferenceToVideoAspectRatio::NineBySixteen,
}
}
#[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_minimax_h3_state(base_builder()).expect("build");
assert!(matches!(state.mode, FalMinimaxH3Mode::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_minimax_h3_state(b).expect("build");
assert!(matches!(state.mode, FalMinimaxH3Mode::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_minimax_h3_state(b).expect("build");
let FalMinimaxH3Mode::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_minimax_h3_state(b).is_err());
}
#[test]
fn reference_images_pick_reference_to_video() {
let state = build_fal_minimax_h3_state(builder_with_reference_images(3)).expect("build");
let FalMinimaxH3Mode::ReferenceToVideo(req) = state.mode else {
panic!("expected ReferenceToVideo");
};
assert_eq!(req.reference_image_urls.map(|urls| urls.len()), Some(3));
assert_eq!(req.reference_video_urls, None);
assert_eq!(req.reference_audio_urls, None);
}
#[test]
fn reference_videos_pick_reference_to_video() {
let mut b = base_builder();
b.reference_videos = Some(VideoListRef::Urls(vec!["https://example.com/a.mp4".to_string()]));
let state = build_fal_minimax_h3_state(b).expect("build");
let FalMinimaxH3Mode::ReferenceToVideo(req) = state.mode else {
panic!("expected ReferenceToVideo");
};
assert_eq!(req.reference_video_urls.map(|urls| urls.len()), Some(1));
}
#[test]
fn ten_reference_images_error() {
assert!(build_fal_minimax_h3_state(builder_with_reference_images(10)).is_err());
}
#[test]
fn four_reference_videos_error() {
let mut b = base_builder();
b.reference_videos = Some(VideoListRef::Urls(
(0..4).map(|i| format!("https://example.com/v-{i}.mp4")).collect(),
));
assert!(build_fal_minimax_h3_state(b).is_err());
}
#[test]
fn thirteen_reference_files_error() {
// 9 images + 3 videos + 1 audio = 13 > 12 total.
let mut b = builder_with_reference_images(9);
b.reference_videos = Some(VideoListRef::Urls(
(0..3).map(|i| format!("https://example.com/v-{i}.mp4")).collect(),
));
b.reference_audio = Some(AudioListRef::Urls(vec!["https://example.com/a.mp3".to_string()]));
assert!(build_fal_minimax_h3_state(b).is_err());
}
#[test]
fn audio_only_reference_errors() {
let mut b = base_builder();
b.reference_audio = Some(AudioListRef::Urls(vec!["https://example.com/a.mp3".to_string()]));
assert!(build_fal_minimax_h3_state(b).is_err());
}
#[test]
fn reference_images_with_start_frame_error() {
let mut b = builder_with_reference_images(2);
b.start_frame = Some(ImageRef::Url(START_URL.to_string()));
assert!(build_fal_minimax_h3_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_minimax_h3_state(b).is_err());
}
#[test]
fn duration_20_clamps_to_15_under_pay_less() {
let mut b = base_builder();
b.duration_seconds = Some(20);
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::PayLessDowngrade;
let state = build_fal_minimax_h3_state(b).expect("build");
let FalMinimaxH3Mode::TextToVideo(req) = state.mode else {
panic!("expected TextToVideo");
};
assert_eq!(req.duration, Some(15));
}
#[test]
fn duration_3_clamps_to_5_under_pay_less() {
let mut b = base_builder();
b.duration_seconds = Some(3);
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::PayLessDowngrade;
let state = build_fal_minimax_h3_state(b).expect("build");
let FalMinimaxH3Mode::TextToVideo(req) = state.mode else {
panic!("expected TextToVideo");
};
assert_eq!(req.duration, Some(5));
}
#[test]
fn resolution_480p_and_720p_map_to_768p() {
for res in [RouterResolution::FourEightyP, RouterResolution::SevenTwentyP] {
let mut b = base_builder();
b.resolution = Some(res);
let state = build_fal_minimax_h3_state(b).expect("build");
let FalMinimaxH3Mode::TextToVideo(req) = state.mode else {
panic!("expected TextToVideo");
};
assert_eq!(req.resolution, Some(MinimaxH3Resolution::SevenSixtyEightP), "res={res:?}");
}
}
#[test]
fn resolution_1080p_and_2k_map_to_2k() {
for res in [RouterResolution::TenEightyP, RouterResolution::TwoK] {
let mut b = base_builder();
b.resolution = Some(res);
let state = build_fal_minimax_h3_state(b).expect("build");
let FalMinimaxH3Mode::TextToVideo(req) = state.mode else {
panic!("expected TextToVideo");
};
assert_eq!(req.resolution, Some(MinimaxH3Resolution::TwoK), "res={res:?}");
}
}
#[test]
fn resolution_4k_errors_under_error_out() {
let mut b = base_builder();
b.resolution = Some(RouterResolution::FourK);
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut;
assert!(build_fal_minimax_h3_state(b).is_err());
}
#[test]
fn twenty_one_by_nine_is_supported() {
let mut b = base_builder();
b.aspect_ratio = Some(RouterAspectRatio::WideTwentyOneByNine);
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut;
let state = build_fal_minimax_h3_state(b).expect("build");
let FalMinimaxH3Mode::TextToVideo(req) = state.mode else {
panic!("expected TextToVideo");
};
assert_eq!(req.aspect_ratio, Some(MinimaxH3TextToVideoAspectRatio::TwentyOneByNine));
}
#[test]
fn auto_aspect_ratio_is_left_unset() {
let mut b = base_builder();
b.aspect_ratio = Some(RouterAspectRatio::Auto);
let state = build_fal_minimax_h3_state(b).expect("build");
let FalMinimaxH3Mode::TextToVideo(req) = state.mode else {
panic!("expected TextToVideo");
};
assert_eq!(req.aspect_ratio, None);
}
#[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_minimax_h3_state(b).expect("build");
assert!(matches!(state.mode, FalMinimaxH3Mode::ImageToVideo(_)));
}
}
fn base_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: RouterVideoModel::MinimaxH3,
provider: RouterProvider::Fal,
prompt: Some("test".to_string()),
..Default::default()
}
}
fn builder_with_reference_images(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,129 @@
use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator;
use crate::generate::generate_video::providers::fal::minimax_h3::request::{
FalMinimaxH3Mode, FalMinimaxH3RequestState,
};
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
#[derive(Clone, Debug)]
pub struct FalMinimaxH3CostState {
pub cost_in_usd_cents: u64,
}
impl FalMinimaxH3CostState {
pub fn from_request(request: &FalMinimaxH3RequestState) -> 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 {
FalMinimaxH3Mode::TextToVideo(req) => req.calculate_cost_in_cents(),
FalMinimaxH3Mode::ImageToVideo(req) => req.calculate_cost_in_cents(),
FalMinimaxH3Mode::ReferenceToVideo(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::minimax_h3::build::build_fal_minimax_h3_state;
use super::*;
// Pricing (from fal_client's minimax_h3 cost modules):
// 768P: $0.16/sec → 5s = 80¢
// 2K: $0.26/sec → 5s = 130¢, 10s = 260¢
// Reference-to-video additionally bills 8¢ per reference image beyond the
// first 5. fal defaults when unset: duration = 5s, resolution = 2K.
#[test]
fn t2v_default_settings_is_130() {
assert_eq!(cost_cents(base_builder(None, None)), 130);
}
#[test]
fn t2v_5s_768p_is_80() {
// RouterResolution::SevenTwentyP maps to H3's 768P.
assert_eq!(cost_cents(base_builder(Some(5), Some(RouterResolution::SevenTwentyP))), 80);
}
#[test]
fn t2v_10s_2k_is_260() {
assert_eq!(cost_cents(base_builder(Some(10), Some(RouterResolution::TenEightyP))), 260);
}
#[test]
fn i2v_5s_768p_is_80() {
let mut b = base_builder(Some(5), Some(RouterResolution::FourEightyP));
b.start_frame = Some(ImageRef::Url("https://example.com/start.png".to_string()));
assert_eq!(cost_cents(b), 80);
}
#[test]
fn reference_five_images_5s_2k_is_130() {
assert_eq!(cost_cents(builder_with_references(5, Some(RouterResolution::TenEightyP))), 130);
}
#[test]
fn reference_nine_images_5s_2k_is_162() {
// 130¢ video + 4 extra images × 8¢ = 162¢.
assert_eq!(cost_cents(builder_with_references(9, Some(RouterResolution::TenEightyP))), 162);
}
#[test]
fn estimate_cost_forwards_cents() {
let estimate = FalMinimaxH3CostState { cost_in_usd_cents: 130 }.estimate_cost();
assert_eq!(estimate.cost_in_usd_cents, Some(130));
assert_eq!(estimate.cost_in_credits, Some(130));
assert!(!estimate.is_free);
}
fn base_builder(
duration_seconds: Option<u16>,
resolution: Option<RouterResolution>,
) -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: RouterVideoModel::MinimaxH3,
provider: RouterProvider::Fal,
prompt: Some("test".to_string()),
duration_seconds,
resolution,
..Default::default()
}
}
fn builder_with_references(
image_count: usize,
resolution: Option<RouterResolution>,
) -> GenerateVideoRequestBuilder {
let urls = (0..image_count)
.map(|i| format!("https://example.com/ref-{}.png", i))
.collect();
let mut b = base_builder(Some(5), resolution);
b.reference_images = Some(ImageListRef::Urls(urls));
b
}
fn cost_cents(builder: GenerateVideoRequestBuilder) -> u64 {
let state = build_fal_minimax_h3_state(builder).expect("build");
FalMinimaxH3CostState::from_request(&state).cost_in_usd_cents
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -0,0 +1,140 @@
use std::fmt::Debug;
use std::sync::Arc;
use fal_client::requests::api::video::image::minimax_h3::api::MinimaxH3ImageToVideoRequest;
use fal_client::requests::api::video::reference::minimax_h3::api::MinimaxH3ReferenceToVideoRequest;
use fal_client::requests::api::video::text::minimax_h3::api::MinimaxH3TextToVideoRequest;
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 FalMinimaxH3Mode {
TextToVideo(MinimaxH3TextToVideoRequest),
ImageToVideo(MinimaxH3ImageToVideoRequest),
ReferenceToVideo(MinimaxH3ReferenceToVideoRequest),
}
#[derive(Clone, Debug)]
pub struct FalMinimaxH3RequestState {
pub mode: FalMinimaxH3Mode,
}
impl FalMinimaxH3RequestState {
pub async fn send(&self, client: &RouterFalClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
match &self.mode {
FalMinimaxH3Mode::TextToVideo(request) => send_request(request, client).await,
FalMinimaxH3Mode::ImageToVideo(request) => send_request(request, client).await,
FalMinimaxH3Mode::ReferenceToVideo(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::text::minimax_h3::api::{
MinimaxH3Resolution, MinimaxH3TextToVideoAspectRatio,
};
use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL;
#[tokio::test]
#[ignore] // requires real API key, incurs cost
async fn send_text_to_video_webhook() {
let state = FalMinimaxH3RequestState {
mode: FalMinimaxH3Mode::TextToVideo(MinimaxH3TextToVideoRequest {
prompt: "a calm lake at sunrise".to_string(),
duration: Some(5),
resolution: Some(MinimaxH3Resolution::SevenSixtyEightP),
aspect_ratio: Some(MinimaxH3TextToVideoAspectRatio::SixteenByNine),
}),
};
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 = FalMinimaxH3RequestState {
mode: FalMinimaxH3Mode::ImageToVideo(MinimaxH3ImageToVideoRequest {
prompt: "the lake comes alive".to_string(),
image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(),
end_image_url: None,
duration: Some(5),
resolution: Some(MinimaxH3Resolution::SevenSixtyEightP),
}),
};
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());
}
// ── Helpers ──
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,3 +1,5 @@
pub mod flux_3;
pub mod flux_3_draft;
pub mod kling_1_6_pro;
pub mod kling_2_1_master;
pub mod kling_2_1_pro;
@@ -5,6 +7,7 @@ pub mod kling_2_5_turbo_pro;
pub mod kling_2_6_pro;
pub mod kling_3p0_pro;
pub mod kling_3p0_standard;
pub mod minimax_h3;
pub mod seedance_1p0_lite;
pub mod seedance_1p5_pro;
pub mod sora_2;
@@ -21,8 +21,14 @@ use crate::generate::generate_video::providers::artcraft::kling_2_6_pro::cost::A
use crate::generate::generate_video::providers::artcraft::kling_2_6_pro::request::ArtcraftKling2p6ProRequestState;
use crate::generate::generate_video::providers::artcraft::kling_3p0_pro::cost::ArtcraftKling3p0ProCostState;
use crate::generate::generate_video::providers::artcraft::kling_3p0_pro::request::ArtcraftKling3p0ProRequestState;
use crate::generate::generate_video::providers::artcraft::flux_3::cost::ArtcraftFlux3CostState;
use crate::generate::generate_video::providers::artcraft::flux_3::request::ArtcraftFlux3RequestState;
use crate::generate::generate_video::providers::artcraft::flux_3_draft::cost::ArtcraftFlux3DraftCostState;
use crate::generate::generate_video::providers::artcraft::flux_3_draft::request::ArtcraftFlux3DraftRequestState;
use crate::generate::generate_video::providers::artcraft::kling_3p0_standard::cost::ArtcraftKling3p0StandardCostState;
use crate::generate::generate_video::providers::artcraft::kling_3p0_standard::request::ArtcraftKling3p0StandardRequestState;
use crate::generate::generate_video::providers::artcraft::minimax_h3::cost::ArtcraftMinimaxH3CostState;
use crate::generate::generate_video::providers::artcraft::minimax_h3::request::ArtcraftMinimaxH3RequestState;
use crate::generate::generate_video::providers::artcraft::preview_model::cost::ArtcraftPreviewModelCostState;
use crate::generate::generate_video::providers::artcraft::preview_model::request::ArtcraftPreviewModelRequestState;
use crate::generate::generate_video::providers::artcraft::preview_model_fast::cost::ArtcraftPreviewModelFastCostState;
@@ -87,8 +93,14 @@ use crate::generate::generate_video::providers::fal::kling_2_6_pro::cost::FalKli
use crate::generate::generate_video::providers::fal::kling_2_6_pro::request::FalKling2p6ProRequestState;
use crate::generate::generate_video::providers::fal::kling_3p0_pro::cost::FalKling3p0ProCostState;
use crate::generate::generate_video::providers::fal::kling_3p0_pro::request::FalKling3p0ProRequestState;
use crate::generate::generate_video::providers::fal::flux_3::cost::FalFlux3CostState;
use crate::generate::generate_video::providers::fal::flux_3::request::FalFlux3RequestState;
use crate::generate::generate_video::providers::fal::flux_3_draft::cost::FalFlux3DraftCostState;
use crate::generate::generate_video::providers::fal::flux_3_draft::request::FalFlux3DraftRequestState;
use crate::generate::generate_video::providers::fal::kling_3p0_standard::cost::FalKling3p0StandardCostState;
use crate::generate::generate_video::providers::fal::kling_3p0_standard::request::FalKling3p0StandardRequestState;
use crate::generate::generate_video::providers::fal::minimax_h3::cost::FalMinimaxH3CostState;
use crate::generate::generate_video::providers::fal::minimax_h3::request::FalMinimaxH3RequestState;
use crate::generate::generate_video::providers::fal::seedance_1p0_lite::cost::FalSeedance10LiteCostState;
use crate::generate::generate_video::providers::fal::seedance_1p0_lite::request::FalSeedance10LiteRequestState;
use crate::generate::generate_video::providers::fal::sora_2::cost::FalSora2CostState;
@@ -136,6 +148,8 @@ use crate::generate::generate_video::providers::kinovi::seedance_2p5_preview::re
pub enum VideoGenerationRequest {
ArtcraftGrokImagineVideo(ArtcraftGrokImagineVideoRequestState),
ArtcraftGrokImagineVideo1p5(ArtcraftGrokImagineVideo1p5RequestState),
ArtcraftFlux3(ArtcraftFlux3RequestState),
ArtcraftFlux3Draft(ArtcraftFlux3DraftRequestState),
ArtcraftHappyHorse1p0(ArtcraftHappyHorse1p0RequestState),
ArtcraftKling16Pro(ArtcraftKling16ProRequestState),
ArtcraftKling21Master(ArtcraftKling21MasterRequestState),
@@ -144,6 +158,7 @@ pub enum VideoGenerationRequest {
ArtcraftKling2p6Pro(ArtcraftKling2p6ProRequestState),
ArtcraftKling3p0Pro(ArtcraftKling3p0ProRequestState),
ArtcraftKling3p0Standard(ArtcraftKling3p0StandardRequestState),
ArtcraftMinimaxH3(ArtcraftMinimaxH3RequestState),
ArtcraftPreviewModel(ArtcraftPreviewModelRequestState),
ArtcraftPreviewModelFast(ArtcraftPreviewModelFastRequestState),
ArtcraftSeedance10Lite(ArtcraftSeedance10LiteRequestState),
@@ -170,6 +185,8 @@ pub enum VideoGenerationRequest {
ArtcraftVeo3p1Lite(ArtcraftVeo3p1LiteRequestState),
ArtcraftViduQ3(ArtcraftViduQ3RequestState),
ArtcraftViduQ3Turbo(ArtcraftViduQ3TurboRequestState),
FalFlux3(FalFlux3RequestState),
FalFlux3Draft(FalFlux3DraftRequestState),
FalKling16Pro(FalKling16ProRequestState),
FalKling21Master(FalKling21MasterRequestState),
FalKling21Pro(FalKling21ProRequestState),
@@ -177,6 +194,7 @@ pub enum VideoGenerationRequest {
FalKling2p6Pro(FalKling2p6ProRequestState),
FalKling3p0Pro(FalKling3p0ProRequestState),
FalKling3p0Standard(FalKling3p0StandardRequestState),
FalMinimaxH3(FalMinimaxH3RequestState),
FalSeedance10Lite(FalSeedance10LiteRequestState),
FalSeedance1p5Pro(FalSeedance1p5ProRequestState),
FalSora2(FalSora2RequestState),
@@ -213,7 +231,10 @@ impl VideoGenerationRequest {
Self::ArtcraftKling2p5TurboPro(_) => RouterProvider::Artcraft,
Self::ArtcraftKling2p6Pro(_) => RouterProvider::Artcraft,
Self::ArtcraftKling3p0Pro(_) => RouterProvider::Artcraft,
Self::ArtcraftFlux3(_) => RouterProvider::Artcraft,
Self::ArtcraftFlux3Draft(_) => RouterProvider::Artcraft,
Self::ArtcraftKling3p0Standard(_) => RouterProvider::Artcraft,
Self::ArtcraftMinimaxH3(_) => RouterProvider::Artcraft,
Self::ArtcraftPreviewModel(_) => RouterProvider::Artcraft,
Self::ArtcraftPreviewModelFast(_) => RouterProvider::Artcraft,
Self::ArtcraftSeedance10Lite(_) => RouterProvider::Artcraft,
@@ -246,7 +267,10 @@ impl VideoGenerationRequest {
Self::FalKling2p5TurboPro(_) => RouterProvider::Fal,
Self::FalKling2p6Pro(_) => RouterProvider::Fal,
Self::FalKling3p0Pro(_) => RouterProvider::Fal,
Self::FalFlux3(_) => RouterProvider::Fal,
Self::FalFlux3Draft(_) => RouterProvider::Fal,
Self::FalKling3p0Standard(_) => RouterProvider::Fal,
Self::FalMinimaxH3(_) => RouterProvider::Fal,
Self::FalSeedance10Lite(_) => RouterProvider::Fal,
Self::FalSeedance1p5Pro(_) => RouterProvider::Fal,
Self::FalSora2(_) => RouterProvider::Fal,
@@ -283,7 +307,10 @@ impl VideoGenerationRequest {
VideoGenerationRequest::ArtcraftKling2p5TurboPro(request) => Ok(ArtcraftKling2p5TurboProCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftKling2p6Pro(request) => Ok(ArtcraftKling2p6ProCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftKling3p0Pro(request) => Ok(ArtcraftKling3p0ProCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftFlux3(request) => Ok(ArtcraftFlux3CostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftFlux3Draft(request) => Ok(ArtcraftFlux3DraftCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftKling3p0Standard(request) => Ok(ArtcraftKling3p0StandardCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftMinimaxH3(request) => Ok(ArtcraftMinimaxH3CostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftPreviewModel(request) => Ok(ArtcraftPreviewModelCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftPreviewModelFast(request) => Ok(ArtcraftPreviewModelFastCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftSeedance10Lite(request) => Ok(ArtcraftSeedance10LiteCostState::from_request(request).estimate_cost()),
@@ -316,7 +343,10 @@ impl VideoGenerationRequest {
VideoGenerationRequest::FalKling2p5TurboPro(request) => Ok(FalKling2p5TurboProCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::FalKling2p6Pro(request) => Ok(FalKling2p6ProCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::FalKling3p0Pro(request) => Ok(FalKling3p0ProCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::FalFlux3(request) => Ok(FalFlux3CostState::from_request(request).estimate_cost()),
VideoGenerationRequest::FalFlux3Draft(request) => Ok(FalFlux3DraftCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::FalKling3p0Standard(request) => Ok(FalKling3p0StandardCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::FalMinimaxH3(request) => Ok(FalMinimaxH3CostState::from_request(request).estimate_cost()),
VideoGenerationRequest::FalSeedance10Lite(request) => Ok(FalSeedance10LiteCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::FalSeedance1p5Pro(request) => Ok(FalSeedance1p5ProCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::FalSora2(request) => Ok(FalSora2CostState::from_request(request).estimate_cost()),
@@ -385,6 +415,18 @@ impl VideoGenerationRequest {
let client_ref = client.get_artcraft_client_ref()?;
request.send(client_ref).await
},
VideoGenerationRequest::ArtcraftMinimaxH3(request) => {
let client_ref = client.get_artcraft_client_ref()?;
request.send(client_ref).await
},
VideoGenerationRequest::ArtcraftFlux3(request) => {
let client_ref = client.get_artcraft_client_ref()?;
request.send(client_ref).await
},
VideoGenerationRequest::ArtcraftFlux3Draft(request) => {
let client_ref = client.get_artcraft_client_ref()?;
request.send(client_ref).await
},
VideoGenerationRequest::ArtcraftPreviewModel(request) => {
let client_ref = client.get_artcraft_client_ref()?;
request.send(client_ref).await
@@ -517,6 +559,18 @@ impl VideoGenerationRequest {
let client_ref = client.get_fal_client_ref()?;
request.send(client_ref).await
},
VideoGenerationRequest::FalMinimaxH3(request) => {
let client_ref = client.get_fal_client_ref()?;
request.send(client_ref).await
},
VideoGenerationRequest::FalFlux3(request) => {
let client_ref = client.get_fal_client_ref()?;
request.send(client_ref).await
},
VideoGenerationRequest::FalFlux3Draft(request) => {
let client_ref = client.get_fal_client_ref()?;
request.send(client_ref).await
},
VideoGenerationRequest::FalSeedance10Lite(request) => {
let client_ref = client.get_fal_client_ref()?;
request.send(client_ref).await
@@ -0,0 +1,105 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::video::enhance::flux_3_draft_enhance::raw_request::{
Flux3DraftEnhanceInput, Flux3DraftEnhanceOutput,
};
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
/// Re-render a Flux 3 Draft generation at full quality (1080p, with
/// synchronized audio) from its `draft_cache` bundle.
#[derive(Clone, Debug)]
pub struct Flux3DraftEnhanceRequest {
/// URL of the `draft_cache` bundle returned by a Flux 3 Draft generation.
pub draft_cache_url: String,
/// Safety tolerance, 0 (strictest) to 4 (most permissive). fal's default is
/// `2` when `None`.
pub safety_tolerance: Option<u8>,
/// Duration of the draft being enhanced, in seconds. NOT sent to fal — the
/// cache bundle already fixes the duration — but used by the cost
/// calculator, which otherwise cannot know the billable length. Estimated
/// at 5 seconds (the shortest draft) when `None`.
pub expected_duration_seconds: Option<u8>,
}
impl FalEndpoint for Flux3DraftEnhanceRequest {
const ENDPOINT: &str = "blackforestlabs/flux-3/draft-enhance";
type RawRequest = Flux3DraftEnhanceInput;
type RawResponse = Flux3DraftEnhanceOutput;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
Ok(Self::RawRequest {
draft_cache_url: self.draft_cache_url.clone(),
safety_tolerance: self.safety_tolerance,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
use errors::AnyhowResult;
use std::fs::read_to_string;
// ── Real requests (manually run; require a live key and cost money) ──
#[tokio::test]
#[ignore] // manually run — needs a fresh draft_cache URL from a prior draft run, and incurs cost
async fn test_draft_enhance_queue() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
// Paste a `draft_cache` URL from a recent Flux 3 Draft generation here.
let draft_cache_url = read_to_string("/tmp/flux3_draft_cache_url.txt")?;
let request = Flux3DraftEnhanceRequest {
draft_cache_url: draft_cache_url.trim().to_string(),
safety_tolerance: None,
expected_duration_seconds: Some(5),
};
let result = request.send_queue_request(&api_key).await?;
println!("Queue result — request_id: {}", result.request_id);
assert!(!result.request_id.is_empty());
Ok(())
}
// ── Wire-shape sanity (no API calls) ──
#[test]
fn raw_request_maps_all_fields() {
let request = Flux3DraftEnhanceRequest {
draft_cache_url: "https://example.com/cache.bin".to_string(),
safety_tolerance: Some(3),
expected_duration_seconds: Some(10),
};
let raw = request.to_raw_request().unwrap();
assert_eq!(raw.draft_cache_url, "https://example.com/cache.bin");
assert_eq!(raw.safety_tolerance, Some(3));
}
/// `expected_duration_seconds` is cost-calculator metadata and must never
/// reach the wire.
#[test]
fn expected_duration_is_not_serialized() {
let request = Flux3DraftEnhanceRequest {
draft_cache_url: "https://example.com/cache.bin".to_string(),
safety_tolerance: None,
expected_duration_seconds: Some(20),
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(json, serde_json::json!({
"draft_cache_url": "https://example.com/cache.bin",
}));
}
#[test]
fn endpoint_path_is_canonical() {
assert_eq!(Flux3DraftEnhanceRequest::ENDPOINT, "blackforestlabs/flux-3/draft-enhance");
}
// NB: Pricing tests are in cost.rs
}
@@ -0,0 +1,62 @@
use crate::requests::api::video::enhance::flux_3_draft_enhance::api::Flux3DraftEnhanceRequest;
use crate::requests::api::video::text::flux_3::cost::flux_3_video_cost_cents;
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
// Flux 3 draft-enhance pricing (see
// https://fal.ai/models/blackforestlabs/flux-3/draft-enhance):
// $0.29 per second of the enhanced video (full-quality 1080p render);
// synchronized audio is included at no extra cost.
//
// The request carries only a `draft_cache_url`, so the billable duration is
// taken from the request's `expected_duration_seconds` hint (5s — the
// shortest draft — when unset).
const ENHANCE_RATE_CENTS_PER_SEC: u64 = 29; // $0.29/sec
impl FalRequestCostCalculator for Flux3DraftEnhanceRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
let duration_secs = u64::from(self.expected_duration_seconds.unwrap_or(5));
flux_3_video_cost_cents(ENHANCE_RATE_CENTS_PER_SEC, duration_secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_request(expected_duration_seconds: Option<u8>) -> Flux3DraftEnhanceRequest {
Flux3DraftEnhanceRequest {
draft_cache_url: "https://example.com/cache.bin".to_string(),
safety_tolerance: None,
expected_duration_seconds,
}
}
mod cost_table {
use super::*;
// (expected_duration, expected_cents) — 29¢ × secs; None estimates at 5s.
const COST_TABLE: &[(Option<u8>, u64)] = &[
(Some(5), 145),
(Some(10), 290),
(Some(20), 580),
(None, 145),
];
#[test]
fn matches_cost_table() {
for &(duration, expected) in COST_TABLE {
let got = make_request(duration).calculate_cost_in_cents();
assert_eq!(got, expected, "duration={duration:?}");
}
}
/// Enhance matches the full-quality 1080p generation rate — drafting then
/// enhancing ($0.06 + $0.29) intentionally costs more than generating at
/// full quality directly ($0.29 at 1080p).
#[test]
fn enhance_rate_matches_full_quality_1080p_rate() {
use crate::requests::api::video::text::flux_3::cost::flux_3_standard_rate_cents_per_sec;
assert_eq!(ENHANCE_RATE_CENTS_PER_SEC, flux_3_standard_rate_cents_per_sec(true));
}
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -0,0 +1,23 @@
use serde::{Deserialize, Serialize};
/// Over-the-wire input shape for `blackforestlabs/flux-3/draft-enhance`.
/// fal's schema: <https://fal.ai/models/blackforestlabs/flux-3/draft-enhance/api>
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Flux3DraftEnhanceInput {
/// URL of the `draft_cache` bundle returned by a Flux 3 Draft generation.
pub draft_cache_url: String,
/// Safety tolerance, 0 (strictest) to 4 (most permissive). fal default: 2.
#[serde(skip_serializing_if = "Option::is_none")]
pub safety_tolerance: Option<u8>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Flux3DraftEnhanceVideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Flux3DraftEnhanceOutput {
pub video: Flux3DraftEnhanceVideoFile,
}
@@ -0,0 +1 @@
pub mod flux_3_draft_enhance;
@@ -0,0 +1,133 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::video::extend::flux_3::raw_request::{
Flux3ExtendVideoInput, Flux3ExtendVideoOutput,
};
use crate::requests::api::video::text::flux_3::api::{
Flux3AspectRatio, Flux3Duration, Flux3Resolution,
};
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
#[derive(Clone, Debug)]
pub struct Flux3ExtendVideoRequest {
/// Text prompt describing how the video should continue.
pub prompt: String,
/// URL of the video to extend.
pub video_url: String,
/// Duration of the extension in seconds. fal's default is `auto` when
/// `None`.
pub duration: Option<Flux3Duration>,
/// Output resolution. fal's default is `720p` when `None`.
pub resolution: Option<Flux3Resolution>,
/// Aspect ratio. fal's default is `auto` when `None`.
pub aspect_ratio: Option<Flux3AspectRatio>,
/// Whether to generate synchronized audio. fal's default is `true` when
/// `None`. Audio does not affect the bill.
pub generate_audio: Option<bool>,
/// Safety tolerance, 0 (strictest) to 4 (most permissive). fal's default is
/// `2` when `None`.
pub safety_tolerance: Option<u8>,
}
impl FalEndpoint for Flux3ExtendVideoRequest {
const ENDPOINT: &str = "blackforestlabs/flux-3/extend-video";
type RawRequest = Flux3ExtendVideoInput;
type RawResponse = Flux3ExtendVideoOutput;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
Ok(Self::RawRequest {
prompt: self.prompt.clone(),
video_url: self.video_url.clone(),
duration: self.duration,
resolution: self.resolution.map(|r| r.to_str().to_string()),
aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()),
generate_audio: self.generate_audio,
safety_tolerance: self.safety_tolerance,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
use errors::AnyhowResult;
use std::fs::read_to_string;
// ── Real requests (manually run; require a live key and cost money) ──
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_extend_video_queue() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Flux3ExtendVideoRequest {
prompt: "the camera keeps drifting forward as the scene continues".to_string(),
video_url: "https://storage.googleapis.com/falserverless/example_inputs/hunyuan_video_input.mp4".to_string(),
duration: Some(Flux3Duration::Seconds(5)),
resolution: Some(Flux3Resolution::SevenTwentyP),
aspect_ratio: None,
generate_audio: Some(false),
safety_tolerance: None,
};
let result = request.send_queue_request(&api_key).await?;
println!("Queue result — request_id: {}", result.request_id);
assert!(!result.request_id.is_empty());
Ok(())
}
// ── Wire-shape sanity (no API calls) ──
#[test]
fn raw_request_maps_all_fields() {
let request = Flux3ExtendVideoRequest {
prompt: "p".to_string(),
video_url: "https://example.com/source.mp4".to_string(),
duration: Some(Flux3Duration::Seconds(10)),
resolution: Some(Flux3Resolution::TenEightyP),
aspect_ratio: Some(Flux3AspectRatio::SixteenByNine),
generate_audio: Some(true),
safety_tolerance: Some(2),
};
let raw = request.to_raw_request().unwrap();
assert_eq!(raw.prompt, "p");
assert_eq!(raw.video_url, "https://example.com/source.mp4");
assert_eq!(raw.duration, Some(Flux3Duration::Seconds(10)));
assert_eq!(raw.resolution.as_deref(), Some("1080p"));
assert_eq!(raw.aspect_ratio.as_deref(), Some("16:9"));
}
#[test]
fn raw_request_omits_unset_optionals() {
let request = Flux3ExtendVideoRequest {
prompt: "minimal".to_string(),
video_url: "https://example.com/source.mp4".to_string(),
duration: None,
resolution: None,
aspect_ratio: None,
generate_audio: None,
safety_tolerance: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(json, serde_json::json!({
"prompt": "minimal",
"video_url": "https://example.com/source.mp4",
}));
}
#[test]
fn endpoint_path_is_canonical() {
assert_eq!(Flux3ExtendVideoRequest::ENDPOINT, "blackforestlabs/flux-3/extend-video");
}
// NB: Pricing tests are in cost.rs
}
@@ -0,0 +1,80 @@
use crate::requests::api::video::extend::flux_3::api::Flux3ExtendVideoRequest;
use crate::requests::api::video::text::flux_3::cost::{
flux_3_estimated_duration_secs, flux_3_is_1080p, flux_3_video_cost_cents,
};
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
// Flux 3 extend-video pricing (see https://fal.ai/models/blackforestlabs/flux-3/extend-video):
// 720p: $0.41 per second of generated video
// 1080p: $0.53 per second of generated video
//
// Extend bills higher than the other Flux 3 modalities (which are $0.17 and
// $0.29; see the text module). The source video is free; pricing depends on
// resolution and the generated extension's duration only.
const EXTEND_RATE_720P_CENTS_PER_SEC: u64 = 41; // $0.41/sec
const EXTEND_RATE_1080P_CENTS_PER_SEC: u64 = 53; // $0.53/sec
impl FalRequestCostCalculator for Flux3ExtendVideoRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
let duration_secs = flux_3_estimated_duration_secs(self.duration);
let rate = if flux_3_is_1080p(self.resolution) {
EXTEND_RATE_1080P_CENTS_PER_SEC
} else {
EXTEND_RATE_720P_CENTS_PER_SEC
};
flux_3_video_cost_cents(rate, duration_secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::requests::api::video::text::flux_3::api::{Flux3Duration, Flux3Resolution};
fn make_request(
duration: Option<Flux3Duration>,
resolution: Option<Flux3Resolution>,
) -> Flux3ExtendVideoRequest {
Flux3ExtendVideoRequest {
prompt: "test".to_string(),
video_url: "https://example.com/source.mp4".to_string(),
duration,
resolution,
aspect_ratio: None,
generate_audio: None,
safety_tolerance: None,
}
}
mod cost_table {
use super::*;
// (duration, resolution, expected_cents)
// Math: rate × secs where rate (cents) = 41 (720p) or 53 (1080p);
// None/auto durations are estimated at the 5s floor.
const COST_TABLE: &[(Option<Flux3Duration>, Option<Flux3Resolution>, u64)] = &[
(Some(Flux3Duration::Seconds(5)), Some(Flux3Resolution::SevenTwentyP), 205),
(Some(Flux3Duration::Seconds(20)), Some(Flux3Resolution::SevenTwentyP), 820),
(Some(Flux3Duration::Seconds(5)), Some(Flux3Resolution::TenEightyP), 265),
(Some(Flux3Duration::Seconds(20)), Some(Flux3Resolution::TenEightyP), 1060),
// Defaults: duration=None/auto→5s estimate, resolution=None→720p
(None, None, 205),
(Some(Flux3Duration::Auto), None, 205),
];
#[test]
fn matches_cost_table() {
for &(duration, resolution, expected) in COST_TABLE {
let got = make_request(duration, resolution).calculate_cost_in_cents();
assert_eq!(got, expected, "duration={duration:?} resolution={resolution:?}");
}
}
#[test]
fn extend_is_more_expensive_than_standard_generation() {
use crate::requests::api::video::text::flux_3::cost::flux_3_standard_rate_cents_per_sec;
assert!(EXTEND_RATE_720P_CENTS_PER_SEC > flux_3_standard_rate_cents_per_sec(false));
assert!(EXTEND_RATE_1080P_CENTS_PER_SEC > flux_3_standard_rate_cents_per_sec(true));
}
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -0,0 +1,48 @@
use serde::{Deserialize, Serialize};
use crate::requests::api::video::text::flux_3::api::Flux3Duration;
/// Over-the-wire input shape for `blackforestlabs/flux-3/extend-video`.
/// fal's schema: <https://fal.ai/models/blackforestlabs/flux-3/extend-video/api>
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Flux3ExtendVideoInput {
/// Text prompt describing how the video should continue.
pub prompt: String,
/// URL of the video to extend.
pub video_url: String,
/// Duration of the extension in seconds ("auto" or 5–20). fal default:
/// "auto".
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<Flux3Duration>,
/// Output resolution.
/// Possible values: "720p", "1080p". fal default: "720p".
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<String>,
/// Aspect ratio.
/// Possible values: "auto", "21:9", "2:1", "16:9", "4:3", "1:1", "3:4",
/// "9:16". fal default: "auto".
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
/// Whether to generate synchronized audio. fal default: true.
#[serde(skip_serializing_if = "Option::is_none")]
pub generate_audio: Option<bool>,
/// Safety tolerance, 0 (strictest) to 4 (most permissive). fal default: 2.
#[serde(skip_serializing_if = "Option::is_none")]
pub safety_tolerance: Option<u8>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Flux3ExtendVideoVideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Flux3ExtendVideoOutput {
pub video: Flux3ExtendVideoVideoFile,
}
@@ -0,0 +1,128 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::video::extend::flux_3_draft::raw_request::{
Flux3DraftExtendVideoInput, Flux3DraftExtendVideoOutput,
};
use crate::requests::api::video::text::flux_3::api::{Flux3AspectRatio, Flux3Duration};
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
/// Flux 3 Draft extend-video: lower cost, lower fidelity (always 720p). The
/// response includes a `draft_cache` that `draft-enhance` can upgrade to a
/// full-quality 1080p render.
#[derive(Clone, Debug)]
pub struct Flux3DraftExtendVideoRequest {
/// Text prompt describing how the video should continue.
pub prompt: String,
/// URL of the video to extend.
pub video_url: String,
/// Duration of the extension in seconds. fal's default is `auto` when
/// `None`.
pub duration: Option<Flux3Duration>,
/// Aspect ratio. fal's default is `auto` when `None`.
pub aspect_ratio: Option<Flux3AspectRatio>,
/// Whether to generate synchronized audio. fal's default is `true` when
/// `None`. Audio does not affect the bill.
pub generate_audio: Option<bool>,
/// Safety tolerance, 0 (strictest) to 4 (most permissive). fal's default is
/// `2` when `None`.
pub safety_tolerance: Option<u8>,
}
impl FalEndpoint for Flux3DraftExtendVideoRequest {
const ENDPOINT: &str = "blackforestlabs/flux-3/extend-video/draft";
type RawRequest = Flux3DraftExtendVideoInput;
type RawResponse = Flux3DraftExtendVideoOutput;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
Ok(Self::RawRequest {
prompt: self.prompt.clone(),
video_url: self.video_url.clone(),
duration: self.duration,
aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()),
generate_audio: self.generate_audio,
safety_tolerance: self.safety_tolerance,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
use errors::AnyhowResult;
use std::fs::read_to_string;
// ── Real requests (manually run; require a live key and cost money) ──
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_extend_video_queue() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Flux3DraftExtendVideoRequest {
prompt: "the camera keeps drifting forward as the scene continues".to_string(),
video_url: "https://storage.googleapis.com/falserverless/example_inputs/hunyuan_video_input.mp4".to_string(),
duration: Some(Flux3Duration::Seconds(5)),
aspect_ratio: None,
generate_audio: Some(false),
safety_tolerance: None,
};
let result = request.send_queue_request(&api_key).await?;
println!("Queue result — request_id: {}", result.request_id);
assert!(!result.request_id.is_empty());
Ok(())
}
// ── Wire-shape sanity (no API calls) ──
#[test]
fn raw_request_maps_all_fields() {
let request = Flux3DraftExtendVideoRequest {
prompt: "p".to_string(),
video_url: "https://example.com/source.mp4".to_string(),
duration: Some(Flux3Duration::Auto),
aspect_ratio: Some(Flux3AspectRatio::Auto),
generate_audio: Some(true),
safety_tolerance: Some(4),
};
let raw = request.to_raw_request().unwrap();
assert_eq!(raw.prompt, "p");
assert_eq!(raw.video_url, "https://example.com/source.mp4");
assert_eq!(raw.duration, Some(Flux3Duration::Auto));
assert_eq!(raw.aspect_ratio.as_deref(), Some("auto"));
assert_eq!(raw.generate_audio, Some(true));
assert_eq!(raw.safety_tolerance, Some(4));
}
#[test]
fn raw_request_omits_unset_optionals() {
let request = Flux3DraftExtendVideoRequest {
prompt: "minimal".to_string(),
video_url: "https://example.com/source.mp4".to_string(),
duration: None,
aspect_ratio: None,
generate_audio: None,
safety_tolerance: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(json, serde_json::json!({
"prompt": "minimal",
"video_url": "https://example.com/source.mp4",
}));
}
#[test]
fn endpoint_path_is_canonical() {
assert_eq!(Flux3DraftExtendVideoRequest::ENDPOINT, "blackforestlabs/flux-3/extend-video/draft");
}
// NB: Pricing tests are in cost.rs
}
@@ -0,0 +1,64 @@
use crate::requests::api::video::extend::flux_3_draft::api::Flux3DraftExtendVideoRequest;
use crate::requests::api::video::text::flux_3::cost::{
flux_3_estimated_duration_secs, flux_3_video_cost_cents,
};
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
// Flux 3 Draft extend-video pricing (see
// https://fal.ai/models/blackforestlabs/flux-3/extend-video/draft):
// $0.12 per second of generated draft video (always 720p)
//
// Draft extend bills higher than the other draft modalities ($0.06/sec; see
// the draft text module). The source video is free; pricing depends on the
// generated extension's duration only.
const DRAFT_EXTEND_RATE_CENTS_PER_SEC: u64 = 12; // $0.12/sec
impl FalRequestCostCalculator for Flux3DraftExtendVideoRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
let duration_secs = flux_3_estimated_duration_secs(self.duration);
flux_3_video_cost_cents(DRAFT_EXTEND_RATE_CENTS_PER_SEC, duration_secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::requests::api::video::text::flux_3::api::Flux3Duration;
fn make_request(duration: Option<Flux3Duration>) -> Flux3DraftExtendVideoRequest {
Flux3DraftExtendVideoRequest {
prompt: "test".to_string(),
video_url: "https://example.com/source.mp4".to_string(),
duration,
aspect_ratio: None,
generate_audio: None,
safety_tolerance: None,
}
}
mod cost_table {
use super::*;
// (duration, expected_cents) — 12¢ × secs; None/auto estimate at 5s.
const COST_TABLE: &[(Option<Flux3Duration>, u64)] = &[
(Some(Flux3Duration::Seconds(5)), 60),
(Some(Flux3Duration::Seconds(20)), 240),
(None, 60),
(Some(Flux3Duration::Auto), 60),
];
#[test]
fn matches_cost_table() {
for &(duration, expected) in COST_TABLE {
let got = make_request(duration).calculate_cost_in_cents();
assert_eq!(got, expected, "duration={duration:?}");
}
}
#[test]
fn draft_extend_is_double_the_draft_generation_rate() {
use crate::requests::api::video::text::flux_3_draft::cost::flux_3_draft_generation_rate_cents_per_sec;
assert_eq!(DRAFT_EXTEND_RATE_CENTS_PER_SEC, flux_3_draft_generation_rate_cents_per_sec() * 2);
}
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -0,0 +1,47 @@
use serde::{Deserialize, Serialize};
use crate::requests::api::video::text::flux_3::api::Flux3Duration;
/// Over-the-wire input shape for `blackforestlabs/flux-3/extend-video/draft`.
/// fal's schema: <https://fal.ai/models/blackforestlabs/flux-3/extend-video/draft/api>
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Flux3DraftExtendVideoInput {
/// Text prompt describing how the video should continue.
pub prompt: String,
/// URL of the video to extend.
pub video_url: String,
/// Duration of the extension in seconds ("auto" or 5–20). fal default:
/// "auto".
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<Flux3Duration>,
/// Aspect ratio.
/// Possible values: "auto", "21:9", "2:1", "16:9", "4:3", "1:1", "3:4",
/// "9:16". fal default: "auto".
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
/// Whether to generate synchronized audio. fal default: true.
#[serde(skip_serializing_if = "Option::is_none")]
pub generate_audio: Option<bool>,
/// Safety tolerance, 0 (strictest) to 4 (most permissive). fal default: 2.
#[serde(skip_serializing_if = "Option::is_none")]
pub safety_tolerance: Option<u8>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Flux3DraftExtendVideoVideoFile {
pub url: String,
}
/// Draft outputs carry the 720p draft video plus a durable encrypted cache
/// bundle whose URL can be passed to `draft-enhance` for a full-quality
/// 1080p render.
#[derive(Debug, Serialize, Deserialize)]
pub struct Flux3DraftExtendVideoOutput {
pub video: Flux3DraftExtendVideoVideoFile,
pub draft_cache: Flux3DraftExtendVideoVideoFile,
}
@@ -1,2 +1,4 @@
pub mod flux_3;
pub mod flux_3_draft;
pub mod veo_3p1;
pub mod veo_3p1_fast;
@@ -0,0 +1,159 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::video::image::flux_3::raw_request::{
Flux3ImageToVideoInput, Flux3ImageToVideoOutput,
};
use crate::requests::api::video::text::flux_3::api::{
Flux3AspectRatio, Flux3Duration, Flux3Resolution,
};
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
#[derive(Clone, Debug)]
pub struct Flux3ImageToVideoRequest {
/// Text prompt for video generation.
pub prompt: String,
/// URL of the image to animate.
pub image_url: String,
/// Duration in seconds. fal's default is `auto` when `None`.
pub duration: Option<Flux3Duration>,
/// Output resolution. fal's default is `720p` when `None`.
pub resolution: Option<Flux3Resolution>,
/// Aspect ratio. fal's default is `auto` when `None`.
pub aspect_ratio: Option<Flux3AspectRatio>,
/// Whether to generate synchronized audio. fal's default is `true` when
/// `None`. Audio does not affect the bill.
pub generate_audio: Option<bool>,
/// Safety tolerance, 0 (strictest) to 4 (most permissive). fal's default is
/// `2` when `None`.
pub safety_tolerance: Option<u8>,
}
impl FalEndpoint for Flux3ImageToVideoRequest {
const ENDPOINT: &str = "blackforestlabs/flux-3/image-to-video";
type RawRequest = Flux3ImageToVideoInput;
type RawResponse = Flux3ImageToVideoOutput;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
Ok(Self::RawRequest {
prompt: self.prompt.clone(),
image_url: self.image_url.clone(),
duration: self.duration,
resolution: self.resolution.map(|r| r.to_str().to_string()),
aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()),
generate_audio: self.generate_audio,
safety_tolerance: self.safety_tolerance,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
use errors::AnyhowResult;
use std::fs::read_to_string;
const EXAMPLE_IMAGE_URL: &str =
"https://storage.googleapis.com/falserverless/example_inputs/veo31-flf2v-input-1.jpeg";
// ── Real requests (manually run; require a live key and cost money) ──
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_image_to_video_webhook() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Flux3ImageToVideoRequest {
prompt: "the camera slowly pulls back, revealing the full landscape".to_string(),
image_url: EXAMPLE_IMAGE_URL.to_string(),
duration: Some(Flux3Duration::Seconds(5)),
resolution: Some(Flux3Resolution::SevenTwentyP),
aspect_ratio: None,
generate_audio: Some(false),
safety_tolerance: None,
};
let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?;
println!("Webhook result: {:?}", result);
assert!(result.request_id.is_some() || result.gateway_request_id.is_some());
Ok(())
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_image_to_video_queue() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Flux3ImageToVideoRequest {
prompt: "gentle wind moves through the scene as clouds drift overhead".to_string(),
image_url: EXAMPLE_IMAGE_URL.to_string(),
duration: Some(Flux3Duration::Seconds(5)),
resolution: Some(Flux3Resolution::SevenTwentyP),
aspect_ratio: None,
generate_audio: Some(false),
safety_tolerance: None,
};
let result = request.send_queue_request(&api_key).await?;
println!("Queue result — request_id: {}", result.request_id);
assert!(!result.request_id.is_empty());
Ok(())
}
// ── Wire-shape sanity (no API calls) ──
#[test]
fn raw_request_maps_all_fields() {
let request = Flux3ImageToVideoRequest {
prompt: "p".to_string(),
image_url: "https://example.com/frame.png".to_string(),
duration: Some(Flux3Duration::Auto),
resolution: Some(Flux3Resolution::TenEightyP),
aspect_ratio: Some(Flux3AspectRatio::Auto),
generate_audio: Some(true),
safety_tolerance: Some(0),
};
let raw = request.to_raw_request().unwrap();
assert_eq!(raw.prompt, "p");
assert_eq!(raw.image_url, "https://example.com/frame.png");
assert_eq!(raw.duration, Some(Flux3Duration::Auto));
assert_eq!(raw.resolution.as_deref(), Some("1080p"));
assert_eq!(raw.aspect_ratio.as_deref(), Some("auto"));
assert_eq!(raw.generate_audio, Some(true));
assert_eq!(raw.safety_tolerance, Some(0));
}
#[test]
fn raw_request_omits_unset_optionals() {
let request = Flux3ImageToVideoRequest {
prompt: "minimal".to_string(),
image_url: "https://example.com/frame.png".to_string(),
duration: None,
resolution: None,
aspect_ratio: None,
generate_audio: None,
safety_tolerance: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(json, serde_json::json!({
"prompt": "minimal",
"image_url": "https://example.com/frame.png",
}));
}
#[test]
fn endpoint_path_is_canonical() {
assert_eq!(Flux3ImageToVideoRequest::ENDPOINT, "blackforestlabs/flux-3/image-to-video");
}
// NB: Pricing tests are in cost.rs
}
@@ -0,0 +1,64 @@
use crate::requests::api::video::image::flux_3::api::Flux3ImageToVideoRequest;
use crate::requests::api::video::text::flux_3::cost::{
flux_3_estimated_duration_secs, flux_3_is_1080p, flux_3_standard_rate_cents_per_sec,
flux_3_video_cost_cents,
};
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
// Image-to-video shares the standard Flux 3 per-second pricing (see the text
// module for the canonical rate table):
// 720p: $0.17/sec
// 1080p: $0.29/sec
// The input image is free; pricing depends on resolution and duration only.
impl FalRequestCostCalculator for Flux3ImageToVideoRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
let duration_secs = flux_3_estimated_duration_secs(self.duration);
let rate = flux_3_standard_rate_cents_per_sec(flux_3_is_1080p(self.resolution));
flux_3_video_cost_cents(rate, duration_secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::requests::api::video::text::flux_3::api::{Flux3Duration, Flux3Resolution};
fn make_request(
duration: Option<Flux3Duration>,
resolution: Option<Flux3Resolution>,
) -> Flux3ImageToVideoRequest {
Flux3ImageToVideoRequest {
prompt: "test".to_string(),
image_url: "https://example.com/frame.png".to_string(),
duration,
resolution,
aspect_ratio: None,
generate_audio: None,
safety_tolerance: None,
}
}
mod cost_table {
use super::*;
// (duration, resolution, expected_cents)
const COST_TABLE: &[(Option<Flux3Duration>, Option<Flux3Resolution>, u64)] = &[
(Some(Flux3Duration::Seconds(5)), Some(Flux3Resolution::SevenTwentyP), 85),
(Some(Flux3Duration::Seconds(20)), Some(Flux3Resolution::SevenTwentyP), 340),
(Some(Flux3Duration::Seconds(5)), Some(Flux3Resolution::TenEightyP), 145),
(Some(Flux3Duration::Seconds(20)), Some(Flux3Resolution::TenEightyP), 580),
// Defaults: duration=None/auto→5s estimate, resolution=None→720p
(None, None, 85),
(Some(Flux3Duration::Auto), Some(Flux3Resolution::TenEightyP), 145),
];
#[test]
fn matches_cost_table() {
for &(duration, resolution, expected) in COST_TABLE {
let got = make_request(duration, resolution).calculate_cost_in_cents();
assert_eq!(got, expected, "duration={duration:?} resolution={resolution:?}");
}
}
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -0,0 +1,47 @@
use serde::{Deserialize, Serialize};
use crate::requests::api::video::text::flux_3::api::Flux3Duration;
/// Over-the-wire input shape for `blackforestlabs/flux-3/image-to-video`.
/// fal's schema: <https://fal.ai/models/blackforestlabs/flux-3/image-to-video/api>
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Flux3ImageToVideoInput {
/// Text prompt for video generation.
pub prompt: String,
/// URL of the image to animate.
pub image_url: String,
/// Duration in seconds ("auto" or 5–20). fal default: "auto".
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<Flux3Duration>,
/// Output resolution.
/// Possible values: "720p", "1080p". fal default: "720p".
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<String>,
/// Aspect ratio.
/// Possible values: "auto", "21:9", "2:1", "16:9", "4:3", "1:1", "3:4",
/// "9:16". fal default: "auto".
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
/// Whether to generate synchronized audio. fal default: true.
#[serde(skip_serializing_if = "Option::is_none")]
pub generate_audio: Option<bool>,
/// Safety tolerance, 0 (strictest) to 4 (most permissive). fal default: 2.
#[serde(skip_serializing_if = "Option::is_none")]
pub safety_tolerance: Option<u8>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Flux3ImageToVideoVideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Flux3ImageToVideoOutput {
pub video: Flux3ImageToVideoVideoFile,
}
@@ -0,0 +1,151 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::video::image::flux_3_draft::raw_request::{
Flux3DraftImageToVideoInput, Flux3DraftImageToVideoOutput,
};
use crate::requests::api::video::text::flux_3::api::{Flux3AspectRatio, Flux3Duration};
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
/// Flux 3 Draft image-to-video: lower cost, lower fidelity (always 720p). The
/// response includes a `draft_cache` that `draft-enhance` can upgrade to a
/// full-quality 1080p render.
#[derive(Clone, Debug)]
pub struct Flux3DraftImageToVideoRequest {
/// Text prompt for video generation.
pub prompt: String,
/// URL of the image to animate.
pub image_url: String,
/// Duration in seconds. fal's default is `auto` when `None`.
pub duration: Option<Flux3Duration>,
/// Aspect ratio. fal's default is `auto` when `None`.
pub aspect_ratio: Option<Flux3AspectRatio>,
/// Whether to generate synchronized audio. fal's default is `true` when
/// `None`. Audio does not affect the bill.
pub generate_audio: Option<bool>,
/// Safety tolerance, 0 (strictest) to 4 (most permissive). fal's default is
/// `2` when `None`.
pub safety_tolerance: Option<u8>,
}
impl FalEndpoint for Flux3DraftImageToVideoRequest {
const ENDPOINT: &str = "blackforestlabs/flux-3/image-to-video/draft";
type RawRequest = Flux3DraftImageToVideoInput;
type RawResponse = Flux3DraftImageToVideoOutput;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
Ok(Self::RawRequest {
prompt: self.prompt.clone(),
image_url: self.image_url.clone(),
duration: self.duration,
aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()),
generate_audio: self.generate_audio,
safety_tolerance: self.safety_tolerance,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
use errors::AnyhowResult;
use std::fs::read_to_string;
const EXAMPLE_IMAGE_URL: &str =
"https://storage.googleapis.com/falserverless/example_inputs/veo31-flf2v-input-1.jpeg";
// ── Real requests (manually run; require a live key and cost money) ──
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_image_to_video_webhook() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Flux3DraftImageToVideoRequest {
prompt: "the camera slowly pulls back, revealing the full landscape".to_string(),
image_url: EXAMPLE_IMAGE_URL.to_string(),
duration: Some(Flux3Duration::Seconds(5)),
aspect_ratio: None,
generate_audio: Some(false),
safety_tolerance: None,
};
let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?;
println!("Webhook result: {:?}", result);
assert!(result.request_id.is_some() || result.gateway_request_id.is_some());
Ok(())
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_image_to_video_queue() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Flux3DraftImageToVideoRequest {
prompt: "gentle wind moves through the scene as clouds drift overhead".to_string(),
image_url: EXAMPLE_IMAGE_URL.to_string(),
duration: Some(Flux3Duration::Seconds(5)),
aspect_ratio: None,
generate_audio: Some(false),
safety_tolerance: None,
};
let result = request.send_queue_request(&api_key).await?;
println!("Queue result — request_id: {}", result.request_id);
assert!(!result.request_id.is_empty());
Ok(())
}
// ── Wire-shape sanity (no API calls) ──
#[test]
fn raw_request_maps_all_fields() {
let request = Flux3DraftImageToVideoRequest {
prompt: "p".to_string(),
image_url: "https://example.com/frame.png".to_string(),
duration: Some(Flux3Duration::Seconds(8)),
aspect_ratio: Some(Flux3AspectRatio::NineBySixteen),
generate_audio: Some(false),
safety_tolerance: Some(1),
};
let raw = request.to_raw_request().unwrap();
assert_eq!(raw.prompt, "p");
assert_eq!(raw.image_url, "https://example.com/frame.png");
assert_eq!(raw.duration, Some(Flux3Duration::Seconds(8)));
assert_eq!(raw.aspect_ratio.as_deref(), Some("9:16"));
assert_eq!(raw.generate_audio, Some(false));
assert_eq!(raw.safety_tolerance, Some(1));
}
#[test]
fn raw_request_omits_unset_optionals() {
let request = Flux3DraftImageToVideoRequest {
prompt: "minimal".to_string(),
image_url: "https://example.com/frame.png".to_string(),
duration: None,
aspect_ratio: None,
generate_audio: None,
safety_tolerance: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(json, serde_json::json!({
"prompt": "minimal",
"image_url": "https://example.com/frame.png",
}));
}
#[test]
fn endpoint_path_is_canonical() {
assert_eq!(Flux3DraftImageToVideoRequest::ENDPOINT, "blackforestlabs/flux-3/image-to-video/draft");
}
// NB: Pricing tests are in cost.rs
}
@@ -0,0 +1,54 @@
use crate::requests::api::video::image::flux_3_draft::api::Flux3DraftImageToVideoRequest;
use crate::requests::api::video::text::flux_3::cost::{
flux_3_estimated_duration_secs, flux_3_video_cost_cents,
};
use crate::requests::api::video::text::flux_3_draft::cost::flux_3_draft_generation_rate_cents_per_sec;
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
// Draft image-to-video shares the Flux 3 Draft generation pricing (see the
// draft text module for the canonical rate):
// $0.06 per second of generated draft video (always 720p)
impl FalRequestCostCalculator for Flux3DraftImageToVideoRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
let duration_secs = flux_3_estimated_duration_secs(self.duration);
flux_3_video_cost_cents(flux_3_draft_generation_rate_cents_per_sec(), duration_secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::requests::api::video::text::flux_3::api::Flux3Duration;
fn make_request(duration: Option<Flux3Duration>) -> Flux3DraftImageToVideoRequest {
Flux3DraftImageToVideoRequest {
prompt: "test".to_string(),
image_url: "https://example.com/frame.png".to_string(),
duration,
aspect_ratio: None,
generate_audio: None,
safety_tolerance: None,
}
}
mod cost_table {
use super::*;
// (duration, expected_cents) — 6¢ × secs; None/auto estimate at 5s.
const COST_TABLE: &[(Option<Flux3Duration>, u64)] = &[
(Some(Flux3Duration::Seconds(5)), 30),
(Some(Flux3Duration::Seconds(20)), 120),
(None, 30),
(Some(Flux3Duration::Auto), 30),
];
#[test]
fn matches_cost_table() {
for &(duration, expected) in COST_TABLE {
let got = make_request(duration).calculate_cost_in_cents();
assert_eq!(got, expected, "duration={duration:?}");
}
}
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -0,0 +1,46 @@
use serde::{Deserialize, Serialize};
use crate::requests::api::video::text::flux_3::api::Flux3Duration;
/// Over-the-wire input shape for `blackforestlabs/flux-3/image-to-video/draft`.
/// fal's schema: <https://fal.ai/models/blackforestlabs/flux-3/image-to-video/draft/api>
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Flux3DraftImageToVideoInput {
/// Text prompt for video generation.
pub prompt: String,
/// URL of the image to animate.
pub image_url: String,
/// Duration in seconds ("auto" or 5–20). fal default: "auto".
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<Flux3Duration>,
/// Aspect ratio.
/// Possible values: "auto", "21:9", "2:1", "16:9", "4:3", "1:1", "3:4",
/// "9:16". fal default: "auto".
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
/// Whether to generate synchronized audio. fal default: true.
#[serde(skip_serializing_if = "Option::is_none")]
pub generate_audio: Option<bool>,
/// Safety tolerance, 0 (strictest) to 4 (most permissive). fal default: 2.
#[serde(skip_serializing_if = "Option::is_none")]
pub safety_tolerance: Option<u8>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Flux3DraftImageToVideoVideoFile {
pub url: String,
}
/// Draft outputs carry the 720p draft video plus a durable encrypted cache
/// bundle whose URL can be passed to `draft-enhance` for a full-quality
/// 1080p render.
#[derive(Debug, Serialize, Deserialize)]
pub struct Flux3DraftImageToVideoOutput {
pub video: Flux3DraftImageToVideoVideoFile,
pub draft_cache: Flux3DraftImageToVideoVideoFile,
}
@@ -0,0 +1,139 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::video::image::minimax_h3::raw_request::{
MinimaxH3ImageToVideoInput, MinimaxH3ImageToVideoOutput,
};
use crate::requests::api::video::text::minimax_h3::api::MinimaxH3Resolution;
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
#[derive(Clone, Debug)]
pub struct MinimaxH3ImageToVideoRequest {
/// Text prompt (1 to 7000 characters).
pub prompt: String,
/// URL of the image to use as the first frame. The output aspect ratio
/// follows this image.
pub image_url: String,
/// Optional URL of the image to use as the last frame, for first-to-last
/// keyframe generation.
pub end_image_url: Option<String>,
/// Duration in seconds. Valid range 5–15; fal's default is `5` when `None`.
pub duration: Option<u8>,
/// Output resolution. fal's default is `2K` when `None`.
pub resolution: Option<MinimaxH3Resolution>,
}
impl FalEndpoint for MinimaxH3ImageToVideoRequest {
const ENDPOINT: &str = "minimax/h3/image-to-video";
type RawRequest = MinimaxH3ImageToVideoInput;
type RawResponse = MinimaxH3ImageToVideoOutput;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
Ok(Self::RawRequest {
prompt: self.prompt.clone(),
image_url: self.image_url.clone(),
end_image_url: self.end_image_url.clone(),
duration: self.duration,
resolution: self.resolution.map(|r| r.to_str().to_string()),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
use errors::AnyhowResult;
use std::fs::read_to_string;
const EXAMPLE_IMAGE_URL: &str =
"https://storage.googleapis.com/falserverless/example_inputs/hailuo23/pro_i2v_in.jpg";
// ── Real requests (manually run; require a live key and cost money) ──
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_image_to_video_webhook() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = MinimaxH3ImageToVideoRequest {
prompt: "the camera slowly pulls back, revealing the full landscape".to_string(),
image_url: EXAMPLE_IMAGE_URL.to_string(),
end_image_url: None,
duration: Some(5),
resolution: Some(MinimaxH3Resolution::SevenSixtyEightP),
};
let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?;
println!("Webhook result: {:?}", result);
assert!(result.request_id.is_some() || result.gateway_request_id.is_some());
Ok(())
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_image_to_video_queue() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = MinimaxH3ImageToVideoRequest {
prompt: "gentle wind moves through the scene as clouds drift overhead".to_string(),
image_url: EXAMPLE_IMAGE_URL.to_string(),
end_image_url: None,
duration: Some(5),
resolution: Some(MinimaxH3Resolution::SevenSixtyEightP),
};
let result = request.send_queue_request(&api_key).await?;
println!("Queue result — request_id: {}", result.request_id);
assert!(!result.request_id.is_empty());
Ok(())
}
// ── Wire-shape sanity (no API calls) ──
#[test]
fn raw_request_maps_all_fields() {
let request = MinimaxH3ImageToVideoRequest {
prompt: "p".to_string(),
image_url: "https://example.com/first.png".to_string(),
end_image_url: Some("https://example.com/last.png".to_string()),
duration: Some(12),
resolution: Some(MinimaxH3Resolution::TwoK),
};
let raw = request.to_raw_request().unwrap();
assert_eq!(raw.prompt, "p");
assert_eq!(raw.image_url, "https://example.com/first.png");
assert_eq!(raw.end_image_url.as_deref(), Some("https://example.com/last.png"));
assert_eq!(raw.duration, Some(12));
assert_eq!(raw.resolution.as_deref(), Some("2K"));
}
#[test]
fn raw_request_omits_unset_optionals() {
let request = MinimaxH3ImageToVideoRequest {
prompt: "minimal".to_string(),
image_url: "https://example.com/first.png".to_string(),
end_image_url: None,
duration: None,
resolution: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(json, serde_json::json!({
"prompt": "minimal",
"image_url": "https://example.com/first.png",
}));
}
#[test]
fn endpoint_path_is_canonical() {
assert_eq!(MinimaxH3ImageToVideoRequest::ENDPOINT, "minimax/h3/image-to-video");
}
// NB: Pricing tests are in cost.rs
}
@@ -0,0 +1,78 @@
use crate::requests::api::video::image::minimax_h3::api::MinimaxH3ImageToVideoRequest;
use crate::requests::api::video::text::minimax_h3::api::MinimaxH3Resolution;
use crate::requests::api::video::text::minimax_h3::cost::{
minimax_h3_rate_cents_per_sec, minimax_h3_video_cost_cents,
};
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
// Image-to-video shares the MiniMax H3 per-second pricing (see the text module
// for the canonical rate table):
// 768P: $0.16/sec
// 2K: $0.26/sec
// Pricing depends on resolution and duration only (start/end frames are free).
impl FalRequestCostCalculator for MinimaxH3ImageToVideoRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
// fal defaults when unset: duration = 5s, resolution = 2K.
let duration_secs = u64::from(self.duration.unwrap_or(5));
let is_2k = self.resolution
.unwrap_or(MinimaxH3Resolution::TwoK)
.is_2k();
let rate = minimax_h3_rate_cents_per_sec(is_2k);
minimax_h3_video_cost_cents(rate, duration_secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_request(
duration: Option<u8>,
resolution: Option<MinimaxH3Resolution>,
) -> MinimaxH3ImageToVideoRequest {
MinimaxH3ImageToVideoRequest {
prompt: "test".to_string(),
image_url: "https://example.com/first.png".to_string(),
end_image_url: None,
duration,
resolution,
}
}
mod cost_table {
use super::*;
// (duration, resolution, expected_cents)
const COST_TABLE: &[(Option<u8>, Option<MinimaxH3Resolution>, u64)] = &[
// 768P → $0.16/s
(Some(5), Some(MinimaxH3Resolution::SevenSixtyEightP), 80),
(Some(15), Some(MinimaxH3Resolution::SevenSixtyEightP), 240),
// 2K → $0.26/s
(Some(5), Some(MinimaxH3Resolution::TwoK), 130),
(Some(15), Some(MinimaxH3Resolution::TwoK), 390),
// Defaults: duration=None→5s, resolution=None→2K
(None, None, 130),
];
#[test]
fn matches_cost_table() {
for &(duration, resolution, expected) in COST_TABLE {
let got = make_request(duration, resolution).calculate_cost_in_cents();
assert_eq!(got, expected, "duration={duration:?} resolution={resolution:?}");
}
}
/// The end frame does not affect the bill (only resolution + duration do).
#[test]
fn cost_ignores_end_image() {
let without = make_request(Some(8), Some(MinimaxH3Resolution::TwoK)).calculate_cost_in_cents();
let with = MinimaxH3ImageToVideoRequest {
end_image_url: Some("https://example.com/last.png".to_string()),
..make_request(Some(8), Some(MinimaxH3Resolution::TwoK))
}.calculate_cost_in_cents();
assert_eq!(with, without);
}
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -0,0 +1,37 @@
use serde::{Deserialize, Serialize};
/// Over-the-wire input shape for `minimax/h3/image-to-video`.
/// fal's schema: <https://fal.ai/models/minimax/h3/image-to-video/api>
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct MinimaxH3ImageToVideoInput {
/// Text prompt (1 to 7000 characters).
pub prompt: String,
/// URL of the image to use as the first frame. The output aspect ratio
/// follows this image.
pub image_url: String,
/// Optional URL of the image to use as the last frame, for first-to-last
/// keyframe generation.
#[serde(skip_serializing_if = "Option::is_none")]
pub end_image_url: Option<String>,
/// Duration in seconds. Range 5–15. fal default: 5.
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<u8>,
/// Output resolution.
/// Possible values: "768P", "2K". fal default: "2K".
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct MinimaxH3ImageToVideoVideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct MinimaxH3ImageToVideoOutput {
pub video: MinimaxH3ImageToVideoVideoFile,
}
@@ -1,7 +1,10 @@
pub mod flux_3;
pub mod flux_3_draft;
pub mod kling_1p6_pro_image_to_video;
pub mod kling_2p5_turbo_pro_image_to_video;
pub mod kling_2p6_pro_image_to_video;
pub mod kling_3p0_standard_image_to_video;
pub mod minimax_h3;
pub mod veo_2;
pub mod veo_3;
pub mod veo_3_fast;
@@ -0,0 +1,148 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::video::images::flux_3::raw_request::{
Flux3FirstLastFrameToVideoInput, Flux3FirstLastFrameToVideoOutput,
};
use crate::requests::api::video::text::flux_3::api::{Flux3AspectRatio, Flux3Resolution};
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
#[derive(Clone, Debug)]
pub struct Flux3FirstLastFrameToVideoRequest {
/// Text prompt for video generation.
pub prompt: String,
/// URL of the first frame.
pub start_image_url: String,
/// URL of the last frame.
pub end_image_url: String,
/// Duration in seconds. Valid range 5–20; fal's default is `5` when `None`.
pub duration: Option<u8>,
/// Output resolution. fal's default is `720p` when `None`.
pub resolution: Option<Flux3Resolution>,
/// Aspect ratio. fal's default is `auto` when `None`.
pub aspect_ratio: Option<Flux3AspectRatio>,
/// Whether to generate synchronized audio. fal's default is `true` when
/// `None`. Audio does not affect the bill.
pub generate_audio: Option<bool>,
/// Safety tolerance, 0 (strictest) to 4 (most permissive). fal's default is
/// `2` when `None`.
pub safety_tolerance: Option<u8>,
}
impl FalEndpoint for Flux3FirstLastFrameToVideoRequest {
const ENDPOINT: &str = "blackforestlabs/flux-3/first-last-frame-to-video";
type RawRequest = Flux3FirstLastFrameToVideoInput;
type RawResponse = Flux3FirstLastFrameToVideoOutput;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
Ok(Self::RawRequest {
prompt: self.prompt.clone(),
start_image_url: self.start_image_url.clone(),
end_image_url: self.end_image_url.clone(),
duration: self.duration,
resolution: self.resolution.map(|r| r.to_str().to_string()),
aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()),
generate_audio: self.generate_audio,
safety_tolerance: self.safety_tolerance,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
use errors::AnyhowResult;
use std::fs::read_to_string;
const START_IMAGE_URL: &str =
"https://storage.googleapis.com/falserverless/example_inputs/veo31-flf2v-input-1.jpeg";
const END_IMAGE_URL: &str =
"https://storage.googleapis.com/falserverless/example_inputs/veo31-flf2v-input-2.jpeg";
// ── Real requests (manually run; require a live key and cost money) ──
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_first_last_frame_to_video_queue() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Flux3FirstLastFrameToVideoRequest {
prompt: "the scene smoothly transitions from the first frame to the last".to_string(),
start_image_url: START_IMAGE_URL.to_string(),
end_image_url: END_IMAGE_URL.to_string(),
duration: Some(5),
resolution: Some(Flux3Resolution::SevenTwentyP),
aspect_ratio: None,
generate_audio: Some(false),
safety_tolerance: None,
};
let result = request.send_queue_request(&api_key).await?;
println!("Queue result — request_id: {}", result.request_id);
assert!(!result.request_id.is_empty());
Ok(())
}
// ── Wire-shape sanity (no API calls) ──
#[test]
fn raw_request_maps_all_fields() {
let request = Flux3FirstLastFrameToVideoRequest {
prompt: "p".to_string(),
start_image_url: "https://example.com/first.png".to_string(),
end_image_url: "https://example.com/last.png".to_string(),
duration: Some(12),
resolution: Some(Flux3Resolution::TenEightyP),
aspect_ratio: Some(Flux3AspectRatio::FourByThree),
generate_audio: Some(true),
safety_tolerance: Some(3),
};
let raw = request.to_raw_request().unwrap();
assert_eq!(raw.prompt, "p");
assert_eq!(raw.start_image_url, "https://example.com/first.png");
assert_eq!(raw.end_image_url, "https://example.com/last.png");
assert_eq!(raw.duration, Some(12));
assert_eq!(raw.resolution.as_deref(), Some("1080p"));
assert_eq!(raw.aspect_ratio.as_deref(), Some("4:3"));
assert_eq!(raw.generate_audio, Some(true));
assert_eq!(raw.safety_tolerance, Some(3));
}
#[test]
fn raw_request_omits_unset_optionals() {
let request = Flux3FirstLastFrameToVideoRequest {
prompt: "minimal".to_string(),
start_image_url: "https://example.com/first.png".to_string(),
end_image_url: "https://example.com/last.png".to_string(),
duration: None,
resolution: None,
aspect_ratio: None,
generate_audio: None,
safety_tolerance: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(json, serde_json::json!({
"prompt": "minimal",
"start_image_url": "https://example.com/first.png",
"end_image_url": "https://example.com/last.png",
}));
}
#[test]
fn endpoint_path_is_canonical() {
assert_eq!(
Flux3FirstLastFrameToVideoRequest::ENDPOINT,
"blackforestlabs/flux-3/first-last-frame-to-video");
}
// NB: Pricing tests are in cost.rs
}
@@ -0,0 +1,64 @@
use crate::requests::api::video::images::flux_3::api::Flux3FirstLastFrameToVideoRequest;
use crate::requests::api::video::text::flux_3::cost::{
flux_3_is_1080p, flux_3_standard_rate_cents_per_sec, flux_3_video_cost_cents,
};
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
// First-last-frame-to-video shares the standard Flux 3 per-second pricing
// (see the text module for the canonical rate table):
// 720p: $0.17/sec
// 1080p: $0.29/sec
// The input frames are free; pricing depends on resolution and duration only.
impl FalRequestCostCalculator for Flux3FirstLastFrameToVideoRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
// fal default when unset: duration = 5s.
let duration_secs = u64::from(self.duration.unwrap_or(5));
let rate = flux_3_standard_rate_cents_per_sec(flux_3_is_1080p(self.resolution));
flux_3_video_cost_cents(rate, duration_secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::requests::api::video::text::flux_3::api::Flux3Resolution;
fn make_request(
duration: Option<u8>,
resolution: Option<Flux3Resolution>,
) -> Flux3FirstLastFrameToVideoRequest {
Flux3FirstLastFrameToVideoRequest {
prompt: "test".to_string(),
start_image_url: "https://example.com/first.png".to_string(),
end_image_url: "https://example.com/last.png".to_string(),
duration,
resolution,
aspect_ratio: None,
generate_audio: None,
safety_tolerance: None,
}
}
mod cost_table {
use super::*;
// (duration, resolution, expected_cents)
const COST_TABLE: &[(Option<u8>, Option<Flux3Resolution>, u64)] = &[
(Some(5), Some(Flux3Resolution::SevenTwentyP), 85),
(Some(20), Some(Flux3Resolution::SevenTwentyP), 340),
(Some(5), Some(Flux3Resolution::TenEightyP), 145),
(Some(20), Some(Flux3Resolution::TenEightyP), 580),
// Defaults: duration=None→5s, resolution=None→720p
(None, None, 85),
];
#[test]
fn matches_cost_table() {
for &(duration, resolution, expected) in COST_TABLE {
let got = make_request(duration, resolution).calculate_cost_in_cents();
assert_eq!(got, expected, "duration={duration:?} resolution={resolution:?}");
}
}
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -0,0 +1,48 @@
use serde::{Deserialize, Serialize};
/// Over-the-wire input shape for `blackforestlabs/flux-3/first-last-frame-to-video`.
/// fal's schema: <https://fal.ai/models/blackforestlabs/flux-3/first-last-frame-to-video/api>
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Flux3FirstLastFrameToVideoInput {
/// Text prompt for video generation.
pub prompt: String,
/// URL of the first frame.
pub start_image_url: String,
/// URL of the last frame.
pub end_image_url: String,
/// Duration in seconds (5–20). fal default: 5.
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<u8>,
/// Output resolution.
/// Possible values: "720p", "1080p". fal default: "720p".
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<String>,
/// Aspect ratio.
/// Possible values: "auto", "21:9", "2:1", "16:9", "4:3", "1:1", "3:4",
/// "9:16". fal default: "auto".
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
/// Whether to generate synchronized audio. fal default: true.
#[serde(skip_serializing_if = "Option::is_none")]
pub generate_audio: Option<bool>,
/// Safety tolerance, 0 (strictest) to 4 (most permissive). fal default: 2.
#[serde(skip_serializing_if = "Option::is_none")]
pub safety_tolerance: Option<u8>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Flux3FirstLastFrameToVideoVideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Flux3FirstLastFrameToVideoOutput {
pub video: Flux3FirstLastFrameToVideoVideoFile,
}
@@ -0,0 +1,143 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::video::images::flux_3_draft::raw_request::{
Flux3DraftFirstLastFrameToVideoInput, Flux3DraftFirstLastFrameToVideoOutput,
};
use crate::requests::api::video::text::flux_3::api::Flux3AspectRatio;
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
/// Flux 3 Draft first-last-frame-to-video: lower cost, lower fidelity (always
/// 720p). The response includes a `draft_cache` that `draft-enhance` can
/// upgrade to a full-quality 1080p render.
#[derive(Clone, Debug)]
pub struct Flux3DraftFirstLastFrameToVideoRequest {
/// Text prompt for video generation.
pub prompt: String,
/// URL of the first frame.
pub start_image_url: String,
/// URL of the last frame.
pub end_image_url: String,
/// Duration in seconds. Valid range 5–20; fal's default is `5` when `None`.
pub duration: Option<u8>,
/// Aspect ratio. fal's default is `auto` when `None`.
pub aspect_ratio: Option<Flux3AspectRatio>,
/// Whether to generate synchronized audio. fal's default is `true` when
/// `None`. Audio does not affect the bill.
pub generate_audio: Option<bool>,
/// Safety tolerance, 0 (strictest) to 4 (most permissive). fal's default is
/// `2` when `None`.
pub safety_tolerance: Option<u8>,
}
impl FalEndpoint for Flux3DraftFirstLastFrameToVideoRequest {
const ENDPOINT: &str = "blackforestlabs/flux-3/first-last-frame-to-video/draft";
type RawRequest = Flux3DraftFirstLastFrameToVideoInput;
type RawResponse = Flux3DraftFirstLastFrameToVideoOutput;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
Ok(Self::RawRequest {
prompt: self.prompt.clone(),
start_image_url: self.start_image_url.clone(),
end_image_url: self.end_image_url.clone(),
duration: self.duration,
aspect_ratio: self.aspect_ratio.map(|ar| ar.to_str().to_string()),
generate_audio: self.generate_audio,
safety_tolerance: self.safety_tolerance,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
use errors::AnyhowResult;
use std::fs::read_to_string;
const START_IMAGE_URL: &str =
"https://storage.googleapis.com/falserverless/example_inputs/veo31-flf2v-input-1.jpeg";
const END_IMAGE_URL: &str =
"https://storage.googleapis.com/falserverless/example_inputs/veo31-flf2v-input-2.jpeg";
// ── Real requests (manually run; require a live key and cost money) ──
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_first_last_frame_to_video_queue() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Flux3DraftFirstLastFrameToVideoRequest {
prompt: "the scene smoothly transitions from the first frame to the last".to_string(),
start_image_url: START_IMAGE_URL.to_string(),
end_image_url: END_IMAGE_URL.to_string(),
duration: Some(5),
aspect_ratio: None,
generate_audio: Some(false),
safety_tolerance: None,
};
let result = request.send_queue_request(&api_key).await?;
println!("Queue result — request_id: {}", result.request_id);
assert!(!result.request_id.is_empty());
Ok(())
}
// ── Wire-shape sanity (no API calls) ──
#[test]
fn raw_request_maps_all_fields() {
let request = Flux3DraftFirstLastFrameToVideoRequest {
prompt: "p".to_string(),
start_image_url: "https://example.com/first.png".to_string(),
end_image_url: "https://example.com/last.png".to_string(),
duration: Some(7),
aspect_ratio: Some(Flux3AspectRatio::Square),
generate_audio: Some(false),
safety_tolerance: Some(2),
};
let raw = request.to_raw_request().unwrap();
assert_eq!(raw.prompt, "p");
assert_eq!(raw.start_image_url, "https://example.com/first.png");
assert_eq!(raw.end_image_url, "https://example.com/last.png");
assert_eq!(raw.duration, Some(7));
assert_eq!(raw.aspect_ratio.as_deref(), Some("1:1"));
assert_eq!(raw.generate_audio, Some(false));
assert_eq!(raw.safety_tolerance, Some(2));
}
#[test]
fn raw_request_omits_unset_optionals() {
let request = Flux3DraftFirstLastFrameToVideoRequest {
prompt: "minimal".to_string(),
start_image_url: "https://example.com/first.png".to_string(),
end_image_url: "https://example.com/last.png".to_string(),
duration: None,
aspect_ratio: None,
generate_audio: None,
safety_tolerance: None,
};
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
assert_eq!(json, serde_json::json!({
"prompt": "minimal",
"start_image_url": "https://example.com/first.png",
"end_image_url": "https://example.com/last.png",
}));
}
#[test]
fn endpoint_path_is_canonical() {
assert_eq!(
Flux3DraftFirstLastFrameToVideoRequest::ENDPOINT,
"blackforestlabs/flux-3/first-last-frame-to-video/draft");
}
// NB: Pricing tests are in cost.rs
}

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