mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-30 23:35:54 -04:00
Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c31ddb32c7 | ||
|
|
25ede499d2 | ||
|
|
a6f24d6aca | ||
|
|
9020f0aa5b | ||
|
|
c2e3c27a72 | ||
|
|
22847961e1 | ||
|
|
f72981875c | ||
|
|
39e89c76e8 | ||
|
|
a473be78bd | ||
|
|
36cfba0000 | ||
|
|
a5f59d7995 | ||
|
|
6329045350 | ||
|
|
cbfed0017b | ||
|
|
099e3cb27b | ||
|
|
9c18f746d4 | ||
|
|
e4fc9446d8 | ||
|
|
37b7f3e88f | ||
|
|
60b4148179 | ||
|
|
a4d9a4d199 | ||
|
|
2b8cc5ef8d | ||
|
|
9122be6752 | ||
|
|
31054141cc | ||
|
|
6b1b4c7b0d | ||
|
|
5f46ee0e7e | ||
|
|
9cdad367c0 | ||
|
|
c026a7c572 | ||
|
|
62051aa49f |
+12
@@ -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"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
+26
-4
@@ -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"
|
||||
}
|
||||
+36
@@ -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"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
+179
@@ -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"
|
||||
}
|
||||
+7
-7
@@ -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"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
+124
@@ -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"
|
||||
}
|
||||
-12
@@ -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"
|
||||
}
|
||||
+7
@@ -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;
|
||||
+13
@@ -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),
|
||||
|
||||
+29
@@ -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,
|
||||
}
|
||||
+29
@@ -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>,
|
||||
}
|
||||
+33
@@ -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;
|
||||
+85
@@ -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;
|
||||
|
||||
+61
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
+7
-5
@@ -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); }
|
||||
}
|
||||
|
||||
+12
@@ -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),
|
||||
|
||||
+64
@@ -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)))
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
+123
@@ -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()
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod build;
|
||||
pub mod cost;
|
||||
pub mod request;
|
||||
+104
@@ -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
|
||||
}
|
||||
}
|
||||
+37
@@ -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 })
|
||||
}
|
||||
+86
@@ -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()
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod build;
|
||||
pub mod cost;
|
||||
pub mod request;
|
||||
+88
@@ -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
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -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); }
|
||||
}
|
||||
|
||||
+4
-4
@@ -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); }
|
||||
}
|
||||
|
||||
+4
-4
@@ -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); }
|
||||
}
|
||||
|
||||
+4
-4
@@ -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); }
|
||||
}
|
||||
|
||||
+8
-8
@@ -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() {
|
||||
|
||||
+12
-9
@@ -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]
|
||||
|
||||
+19
-15
@@ -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]
|
||||
|
||||
+25
@@ -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 })
|
||||
}
|
||||
+158
@@ -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()
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod build;
|
||||
pub mod cost;
|
||||
pub mod request;
|
||||
+117
@@ -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
|
||||
}
|
||||
}
|
||||
+3
@@ -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;
|
||||
|
||||
+10
-5
@@ -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); }
|
||||
}
|
||||
|
||||
+173
-17
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+141
-10
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+12
-12
@@ -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]
|
||||
|
||||
+2
-2
@@ -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.
|
||||
///
|
||||
|
||||
+7
-6
@@ -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); }
|
||||
}
|
||||
|
||||
+13
-12
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+15
-15
@@ -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]
|
||||
|
||||
+416
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+106
@@ -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
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod build;
|
||||
pub mod cost;
|
||||
pub mod request;
|
||||
+144
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
+151
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+76
@@ -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
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod build;
|
||||
pub mod cost;
|
||||
pub mod request;
|
||||
+121
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
+543
@@ -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
|
||||
}
|
||||
}
|
||||
+129
@@ -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
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod build;
|
||||
pub mod cost;
|
||||
pub mod request;
|
||||
+140
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
+3
@@ -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;
|
||||
|
||||
+54
@@ -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
|
||||
|
||||
+105
@@ -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
|
||||
}
|
||||
+62
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod api;
|
||||
pub mod cost;
|
||||
pub mod raw_request;
|
||||
+23
@@ -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;
|
||||
+47
@@ -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;
|
||||
+46
@@ -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
Reference in New Issue
Block a user