mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-25 03:09:30 -04:00
Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de0e48f3f1 | ||
|
|
66acb8e195 | ||
|
|
f563b06614 | ||
|
|
0a84a3ce80 | ||
|
|
91762e2f6c | ||
|
|
c62a269638 | ||
|
|
b36e4fd27b | ||
|
|
f767fc9638 | ||
|
|
224e5c17f5 | ||
|
|
534337aa3a | ||
|
|
ea4563c1a8 | ||
|
|
9646a791aa | ||
|
|
4d839d3b8b | ||
|
|
b7f354face | ||
|
|
c888a494b9 | ||
|
|
a048458956 | ||
|
|
d598896e40 | ||
|
|
af9dfa556f | ||
|
|
ea0de174c9 | ||
|
|
a1435df07d | ||
|
|
a7a32a3862 | ||
|
|
bb10c993a4 | ||
|
|
c6474edd5c | ||
|
|
9020f0aa5b | ||
|
|
c2e3c27a72 | ||
|
|
22847961e1 | ||
|
|
f72981875c | ||
|
|
39e89c76e8 | ||
|
|
a473be78bd | ||
|
|
36cfba0000 | ||
|
|
a5f59d7995 | ||
|
|
6329045350 | ||
|
|
cbfed0017b | ||
|
|
099e3cb27b | ||
|
|
9c18f746d4 | ||
|
|
e4fc9446d8 | ||
|
|
37b7f3e88f | ||
|
|
60b4148179 |
@@ -1,5 +1,25 @@
|
||||
# Testing
|
||||
|
||||
## Database Fixture Tests
|
||||
|
||||
- Live in-crate (e.g. `storyteller-web`'s omni_gen video pricing tests) and
|
||||
use the `mysql_testing` crate (`crates/schema/database/mysql_testing`) for
|
||||
guarded pools, schema setup, and account/session/wallet fixtures
|
||||
- They RUN BY DEFAULT with `cargo test -p storyteller-web` and from the IDE
|
||||
(requires a local MySQL with an `artcraft_test` database). Machines/CI
|
||||
without MySQL skip them via the off switch:
|
||||
`SQLX_OFFLINE=true cargo test -p storyteller-web --features skip_database_tests`
|
||||
- They connect ONLY via `ARTCRAFT_TEST_DATABASE_URL` (default
|
||||
`mysql://root:@localhost:3306/artcraft_test`); the guard panics on any
|
||||
database whose name lacks "test", on `storyteller`/`artcraft`, and on
|
||||
cloud hosts — see `mysql_testing/README.md`
|
||||
- Database tests run in PARALLEL: each test creates its own users, wallets,
|
||||
and rows; schema setup single-flights via a MySQL named lock. Only tests
|
||||
mutating shared/global state take
|
||||
`mysql_testing::serial::acquire_serial_test_lock().await`
|
||||
- External providers must never be called: the harness stubs Kinovi with an
|
||||
in-process HTTP server via `KINOVI_CUSTOM_API_HOST`/`KINOVI_CUSTOM_CDN_HOST`
|
||||
|
||||
## Running Tests
|
||||
|
||||
- Most crates: `cargo test -p {crate_name}`
|
||||
|
||||
+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"
|
||||
}
|
||||
+25
-3
@@ -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"
|
||||
}
|
||||
+6
-6
@@ -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"
|
||||
}
|
||||
Generated
+17
@@ -7363,6 +7363,22 @@ dependencies = [
|
||||
"workspace-build-acceleration",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mysql_testing"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bcrypt",
|
||||
"bucket_paths",
|
||||
"chrono",
|
||||
"enums",
|
||||
"log",
|
||||
"mysql_queries",
|
||||
"sqlx",
|
||||
"tokens",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mysqlclient-sys"
|
||||
version = "0.2.5"
|
||||
@@ -11646,6 +11662,7 @@ dependencies = [
|
||||
"migration",
|
||||
"mimetypes",
|
||||
"mysql_queries",
|
||||
"mysql_testing",
|
||||
"once_cell",
|
||||
"opaque_cursors",
|
||||
"pager",
|
||||
|
||||
@@ -120,6 +120,7 @@ members = [
|
||||
"crates/schema/database/elasticsearch_schema",
|
||||
"crates/schema/database/migration",
|
||||
"crates/schema/database/mysql_queries",
|
||||
"crates/schema/database/mysql_testing",
|
||||
"crates/schema/database/redis_common",
|
||||
"crates/schema/database/redis_schema",
|
||||
"crates/schema/database/sqlite_tasks",
|
||||
@@ -282,6 +283,7 @@ mimetypes = { path = "crates/lib/files/mimetypes" }
|
||||
muapi_client = { path = "crates/api_clients/muapi_client" }
|
||||
metrics = { path = "crates/lib/metrics" }
|
||||
mysql_queries = { path = "crates/schema/database/mysql_queries" }
|
||||
mysql_testing = { path = "crates/schema/database/mysql_testing" }
|
||||
pager = { path = "crates/lib/pager" }
|
||||
openai_sora_client = { path = "crates/api_clients/openai_sora_client" }
|
||||
primitives = { path = "crates/lib/data/primitives" }
|
||||
|
||||
+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),
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
# PageScene Animations — Branch Handoff
|
||||
|
||||
Branch: `frontend/pagescene-animations` (forked from `main` @ `6329045350`, 2026-08-04).
|
||||
Everything below is TypeScript-typechecked per-lib (`tsc -p <lib>/tsconfig.lib.json`); runtime
|
||||
verification status is tracked in the checklist at the bottom. Drag-and-drop has had one manual
|
||||
pass (confirmed working); most other surfaces have not been launched.
|
||||
|
||||
## What this branch does
|
||||
|
||||
Makes skeletal animations a first-class asset in the 3D editor (pagescene): browse them in the
|
||||
asset drawer, drag them onto characters or the timeline with live rig-compatibility feedback,
|
||||
play clips baked into uploaded models, preview animations everywhere models are previewed, and
|
||||
upload Mixamo FBX files (with or without mesh) end to end.
|
||||
|
||||
## Commit map
|
||||
|
||||
| Commit | What |
|
||||
|--------------|-------------------------------------------------------------------------|
|
||||
| `7af3fb9856` | Animations tab in AssetModal; AnimationsDrawer retired |
|
||||
| `1fcbc55780` | Pointer drag-and-drop with live rig-compatibility badge |
|
||||
| `79d7ade8bc` | Baked-in clips on the timeline (+ clip-row eligibility widened) |
|
||||
| `142279414e` | Animation dropdown in Viewer3D + upload preview |
|
||||
| `913905308e` | Clip fixes: vanishing character, 1 s strips, select-to-delete, undo |
|
||||
| `60251104e3` | FBX accepted at the picker, converted to GLB in-browser |
|
||||
| `0942ec4804` | Nx tsconfig references for the new ui-select dependency |
|
||||
| `d0c7b65a6e` | FBX conversion moved into a Web Worker (main-thread fallback) |
|
||||
| `8e51b6c524` | Explicit "Upload as Animation" toggle + required duration_millis |
|
||||
| `00ebeae10c` | User's uploaded animations merged into the Animations tab |
|
||||
| `e1c7a0c7b0` | Persistent skeleton-helper toggle in the outliner |
|
||||
| `30f40c0ddb` | Previewer bone toggles + mesh-less framing/mixer-binding fixes |
|
||||
| `5f7ba7bbc5` | AnimationsModal (All/Presets/Uploaded) + timeline quality-of-life |
|
||||
| `d55735620a` | Timeline ruler tick marks + strip button spacing |
|
||||
| `79875d8c76` | Upload animations from the AnimationsModal (initialCategory) |
|
||||
| *(this)* | NodeHierarchyHelper: bone toggle works for converted mesh-less GLBs |
|
||||
|
||||
## Features
|
||||
|
||||
### 1. Animations tab in the asset drawer (`AssetModal`)
|
||||
|
||||
- New left-rail section fed by `GET /v1/media_files/list_featured?filter_engine_categories=animation`
|
||||
(GLB/GLTF only — the clip loader is GLTF-based). Featured results REPLACE the 37 hardcoded
|
||||
`demoAnimationItems`; demos are only the empty-API fallback.
|
||||
- The session user's own uploaded animations (`list/user`, same filter) render AHEAD of the
|
||||
featured set. Fetched on every modal open and after each upload; fails silently for anonymous
|
||||
users (`suppressErrorToast` on the user-media fetch path).
|
||||
- Card click appends the clip to the selected character's row at the earliest free slot (toast
|
||||
when no character is selected or the row is full). The old right-docked `AnimationsDrawer` is
|
||||
deleted; the old HTML5-DnD path (`ANIMATION_CLIP_MIME`) is gone with it.
|
||||
|
||||
### 2. Drag-and-drop with rig-compatibility badge (`DndAsset`)
|
||||
|
||||
- Animation cards ride the same pointer-drag pipeline as every other asset. On drag start the
|
||||
clip's track node names are preloaded (`loadRawGlb`, cached per media id); hovering a
|
||||
character (or any skinned object) checks those names against its nodes — the exact direct-bind
|
||||
rule `CharacterAnimationManager` plays by. No retargeting (explicitly out of scope).
|
||||
- The shared `DragGhost` shows the verdict: green check (binds) / red slash (no target or rig
|
||||
mismatch); nothing while the clip is still loading. Incompatible or slot-less drops are
|
||||
rejected with a toast.
|
||||
- Drop targets: a character in the 3D scene (earliest free slot) or a timeline row
|
||||
(`data-clip-drop-uuid`; pointer-x → time via the `data-timeline-ruler` rect).
|
||||
|
||||
### 3. Timeline clips: behavior + UX
|
||||
|
||||
- **Strips default to 1 s** and the strip width is the authoritative play window
|
||||
(`LaneRuntime.stripDuration`): trimming genuinely cuts playback; loop repeats inside the
|
||||
strip; a non-loop strip wider than its clip freezes on the last frame. On load a strip only
|
||||
shrinks (clip shorter than default) — never grows.
|
||||
- **Rest-pose in gaps**: `Skeleton.pose()` was replaced with a rest-pose snapshot captured at
|
||||
mixer creation (all nodes BELOW the object root; root excluded so gizmo/keyframes never snap
|
||||
back). `pose()` rebuilds bone locals from inverse bind matrices and mis-scales cm-rigs — that
|
||||
was the "character vanishes when moving a strip" bug.
|
||||
- **Selection UX**: strips are click-to-select; × renders only on the selected strip;
|
||||
Del/Backspace deletes; removal is individually undoable (`RemoveClipLaneAction` — the one
|
||||
exception to clip edits riding the timeline Save/Cancel session).
|
||||
- **Full rows reject adds**: `resolveFreeStart` returns null instead of accepting an
|
||||
overlapping clamp.
|
||||
|
||||
### 4. Baked-in clips
|
||||
|
||||
- `ClipStrip.bakedClipIndex` marks a strip sourced from the object's own `animations[]`
|
||||
(resolved synchronously — no media fetch). Objects with baked clips get a timeline clip row
|
||||
with a "+" picker listing them.
|
||||
- Clip-row eligibility is wider than characters: any skinned object (creatures, rigged uploads)
|
||||
accepts animation drags (bind check gates); baked-only objects get a row for the picker.
|
||||
- INVARIANT: baked clips are never removed from the THREE model. Removing a baked strip only
|
||||
unschedules it; the clip stays on `object.animations` and in the picker. Library strips stay
|
||||
fully removable.
|
||||
- Outliner metadata (`hasSkeleton`, `bakedClips`) is computed in `convert_object` and refreshed
|
||||
after every GLB load (`SceneDeps.onGlbLoaded` → `refreshOutliner`).
|
||||
|
||||
### 5. Previewers (Viewer3D + upload preview)
|
||||
|
||||
- Both play baked clips: autoplay the first, dropdown with every clip + "T-pose (none)", hidden
|
||||
entirely for animation-less models.
|
||||
- Both have a bone icon toggling a skeleton overlay — default ON when the model has no mesh
|
||||
(Mixamo "without skin" exports), so animation-only files are visible. The upload thumbnail
|
||||
snapshot then captures the skeleton render.
|
||||
- The overlay adapts to the rig kind: real `THREE.Bone` joints get `THREE.SkeletonHelper`;
|
||||
mesh-less models whose joints re-imported as plain nodes get `NodeHierarchyHelper`
|
||||
(exported from `viewer-3d`) — GLTF only round-trips bone-ness through a *skin*, and a
|
||||
mesh-less export has no skin, so converted FBX animations lose `Bone`-ness entirely. The
|
||||
toggle shows for `hasBones || !hasMesh`.
|
||||
- Latent bugs fixed: upload preview discarded `gltf.animations`; its child re-parenting loop
|
||||
skipped every other child (`scene.add` mutates the array mid-forEach); its mixer bound before
|
||||
the model entered the scene (silently played nothing); camera framing NaN'd on empty
|
||||
bounding boxes (now falls back to bone world positions); camera now fits once, not per-child.
|
||||
|
||||
### 6. FBX upload support
|
||||
|
||||
- The 3D uploader accepts `.fbx` and normalizes it at the door: `FBXLoader` → `GLTFExporter`
|
||||
→ binary GLB with clips preserved. Runs in a dedicated Web Worker (transferred buffers, jobs
|
||||
naturally serialized); a broken worker environment falls back to the main thread. The raw FBX
|
||||
can never reach the backend — retry of a failed conversion re-converts.
|
||||
- Caveats: embedded textures may not survive; units are exported as parsed (cm-scaled Mixamo
|
||||
rigs are not rescaled).
|
||||
|
||||
### 7. Upload as Animation
|
||||
|
||||
- Mutually exclusive checkbox next to "Upload as Character" → `engine_category=animation`.
|
||||
Mesh-less files preselect it (preview loader reports `hasMesh:false`); a user click always
|
||||
wins. Rig-type dropdown defaults to Mixamo (characters keep Mixamo ArKit first).
|
||||
- The backend-required `maybe_duration_millis` is read from the longest clip at submit time;
|
||||
clip-less files are flagged and excluded client-side.
|
||||
|
||||
### 8. Outliner skeleton toggle (`SkeletonHelperController`)
|
||||
|
||||
- Bone icon on skinned rows toggles a persistent `SkeletonHelper` on LAYER 1 (editor-only —
|
||||
the render camera renders layer 0, so skeletons never appear in captures/recordings).
|
||||
- Persists as `userData.skeletonVisible` through scene save/load (explicit restore in the proxy
|
||||
load path). Reconciliation rides `OutlinerRefreshedEvent` — add/delete/load/new-scene and the
|
||||
post-GLB-load refresh all funnel there.
|
||||
- FK/pose-mode compatible: `PoseModeChangedEvent("pose")` suppresses the persistent helper for
|
||||
the posed character (FKHelper draws its own rig); exit restores it. The toggle state itself
|
||||
is never touched.
|
||||
|
||||
## Manual test checklist (outstanding)
|
||||
|
||||
- [ ] Prod data: does `list_featured?filter_engine_categories=animation` return rows? If yes
|
||||
they replace the demo clips in the tab; if empty, the 37 demos show.
|
||||
- [ ] Clip→rig bind smoke test on shipped characters (runtime-unverified `mixamorig:*`
|
||||
assumption; a "bound 0 tracks" console warning means retargeting becomes the real task).
|
||||
- [ ] Creature experiment: drag a clip onto a creature — badge should reflect bind result.
|
||||
- [ ] Baked clips: "+" picker on an animated object's row; add/remove/re-add; save/reload
|
||||
persistence (the `object.animations` stash on load is gated on `auto_add` — statically
|
||||
unconfirmed for every load path).
|
||||
- [ ] Clip UX round: strips at 1 s; trim semantics; select → ×/Del; undo/redo of removal;
|
||||
character stays visible when a strip moves off the playhead.
|
||||
- [ ] FBX round trip: pick `.fbx` → converts (worker; check console for worker load failures —
|
||||
falls back to main thread) → skeleton visible + clip dropdown → Upload as Animation
|
||||
preselected → appears at front of Animations tab → drag onto character → plays.
|
||||
- [ ] Converted-FBX scale: if placed objects come in ~100× too big, the converter needs a
|
||||
normalize-scale step.
|
||||
- [ ] Outliner bone toggle: on/off, FK-mode enter/exit restore, save/reload persistence, and
|
||||
absence from Capture/Record output.
|
||||
- [ ] Previewer dropdown + bone toggle across lightbox, media page, lightbox-modal,
|
||||
ImageTo3DExperience, upload modal.
|
||||
|
||||
## Debug pass + hardening round (post-review)
|
||||
|
||||
A four-agent review of the combined branch produced 30 findings (5 high / 12 medium / 13 low);
|
||||
ALL have been fixed or explicitly closed, one commit per finding (range `72cb218e..b21732d1`).
|
||||
Highlights, roughly in commit order:
|
||||
|
||||
- Highs: baked clips cloned per lane (shared-AnimationAction playback/removal corruption);
|
||||
runtime revalidation for objects recreated under the same uuid (delete→undo, reloads) — later
|
||||
hardened into LOOP-FREE terminal lane states (bound / missing / unbindable, reassessed only on
|
||||
root-instance change; the pre-fetch rig gate is sound because every load path stamps the target
|
||||
uuid only post-load); skeleton-helper sync self-heals across in-session reloads; upload modal
|
||||
can no longer report success for empty/partial submissions; ref-guarded double-submit.
|
||||
- Mediums: strip removal mirrored into the Save/Cancel `saved` snapshot (Cancel can't resurrect,
|
||||
no phantom undo); stale strip/keyframe selections pruned in the bridge + live-checked before
|
||||
swallowing Del/Backspace; one winning lane per character at flush boundaries (blend-glitch
|
||||
frame was being ENCODED into recordings); orphaned tracks/lanes filtered from scene JSON at
|
||||
save; per-clip queued-drop tokens; animation raycasts gated to the canvas element + honest
|
||||
front-most-surface occlusion; virtualized-grid thumbnail state keyed by item; one shared
|
||||
lazily-fetched animation-library store (was double-fetch + double-toast + divergent lists);
|
||||
previewer rest-pose snapshots replace `Skeleton.pose()` everywhere; all-node camera-framing
|
||||
fallback for plain-node rigs; upload-preview races (superseded-load cancellation, snapshot
|
||||
after helper creation, File-identity row statuses, retry re-entry).
|
||||
- Lows: duration-shrink re-fits strips; baked-clip picker portals out of the scrolling row list;
|
||||
timeline resets on scene switch/new scene instead of leaking across; click-to-add accepts every
|
||||
drag-eligible target; queued drops bail on a torn-down editor (`Editor.isLive`); top-level root
|
||||
lookups unified; flick-click race killed via `wasDragGesture`; converting-overlay control reset;
|
||||
conversion completions reload only the previewed entry; worker `onmessageerror` recovery +
|
||||
corrupt-vs-clip-less error messages + explicit `initialCategory` wins over mesh-less
|
||||
auto-detect; Uploaded tab pages past the 100-item cap.
|
||||
- Closed without code: the "compat cache stales on streaming characters" finding (invalidated —
|
||||
uuid-resolvable roots are always fully loaded) and the Tauri `promptSignup` gap (desktop is
|
||||
always signed in by adapter contract).
|
||||
|
||||
## Landed since the original handoff
|
||||
|
||||
- **AnimationsModal** (was deferred — now built, `5f7ba7bbc5`/`79875d8c76`): standalone modal
|
||||
with All / Presets / Uploaded tabs, opened from an "Add Animation" button next to Enter Pose
|
||||
Mode and from the "+" add-asset menu. Adding a clip expands the timeline and scrolls to the
|
||||
character (`timelineRevealObjectUuid`); the modal closes after an add unless "Reopen after
|
||||
adding" is on. Its "Upload animation" button opens `UploadModal3D` with
|
||||
`initialCategory="animation"` (anonymous users get the signup prompt), landing on the
|
||||
Uploaded tab afterwards. `AssetModal`'s animations tab was folded into this.
|
||||
- **Timeline QoL**: ruler tick marks (major/second/sub-tick), selected-object-only track
|
||||
filter, directional expand/collapse chevrons, thumbnail-less animation cards get an icon
|
||||
placeholder.
|
||||
|
||||
## Deferred (explicitly out of scope for this branch)
|
||||
|
||||
- **Split-screen preview window** (based on the `?output=` `DemoOutputOverlay`) — parked until
|
||||
explicit approval.
|
||||
- **Retargeting** (`SkeletonUtils.retargetClip`) — too complex for now; mismatched rigs are
|
||||
simply rejected by the bind check.
|
||||
- **Clip blending/layering** — one non-overlapping row per object, by design.
|
||||
|
||||
## Key files
|
||||
|
||||
| Area | Files |
|
||||
|---------------------|-------------------------------------------------------------------------------------------|
|
||||
| Drawer tab | `pagescene/src/lib/comps/AssetMenu/AssetModal.tsx`, `shared/ItemElement.tsx` |
|
||||
| Drag-and-drop | `pagescene/src/lib/DragAndDrop/DndAsset.ts`, `gallery-modal/src/lib/DragGhost.tsx` |
|
||||
| Timeline data/engine| `pagescene/src/lib/engine/timeline/types.ts`, `engine/editor/TimelineController.ts`, |
|
||||
| | `engine/animation/CharacterAnimationManager.ts`, `engine/editor/actions/RemoveClipLaneAction.ts` |
|
||||
| Timeline UI | `pagescene/src/lib/comps/Timeline/TimelineEditor.tsx`, `TimelineClipRow.tsx` |
|
||||
| Skeleton toggle | `pagescene/src/lib/engine/editor/SkeletonHelperController.ts`, `comps/Outliner/Outliner.tsx` |
|
||||
| Previewers | `viewer-3d/src/lib/viewer-3d.tsx`, `upload-modal/.../utilities/loadPreviewOnCanvas.ts` |
|
||||
| FBX + upload | `upload-modal/.../utilities/convertFbxToGlb.ts` (+ `.worker.ts`), `UploadModal3D.tsx`, |
|
||||
| | `UploadFiles3D.tsx`, `utilities/readGlbAnimationDuration.ts` |
|
||||
|
||||
The pagescene `CLAUDE.md` "Mixamo animation clips" section has been kept current with these
|
||||
changes and carries the same invariants in engine-doc form.
|
||||
+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;
|
||||
|
||||
+19
@@ -69,4 +69,23 @@ pub struct OmniGenVideoCostAndGenerateRequest {
|
||||
/// Some models will default this to true, others will default it to false,
|
||||
/// so it's best to be explicit.
|
||||
pub generate_audio: Option<bool>,
|
||||
|
||||
/// Frontend-supplied hints for cost estimation only. IGNORED by
|
||||
/// generation — the generate endpoints measure inputs themselves (e.g.
|
||||
/// ffprobing reference videos) and bill from those measurements.
|
||||
pub estimate_only: Option<EstimateFields>,
|
||||
}
|
||||
|
||||
/// These fields are ignored by generation. They're only used by the frontend
|
||||
/// to aid in cost estimation (e.g. Seedance 2.5 bills reference-video input
|
||||
/// seconds, so the cost quote needs the combined input duration without the
|
||||
/// server downloading and probing the files on every poll).
|
||||
#[derive(Clone, Copy, Serialize, Deserialize, ToSchema, Debug, Default)]
|
||||
pub struct EstimateFields {
|
||||
/// Combined duration of all reference video inputs, in milliseconds.
|
||||
pub total_input_video_duration_millis: Option<u32>,
|
||||
|
||||
/// Combined duration of all reference audio inputs, in milliseconds.
|
||||
/// (No model bills audio input seconds yet; accepted for future models.)
|
||||
pub total_input_audio_duration_millis: Option<u32>,
|
||||
}
|
||||
|
||||
-8
@@ -25,8 +25,6 @@ pub enum ApiClientVideoModel {
|
||||
Seedance2p0Fast,
|
||||
Seedance2p0BytePlus,
|
||||
Seedance2p0BytePlusFast,
|
||||
Seedance2p0Ultra,
|
||||
Seedance2p0UltraFast,
|
||||
Seedance2p0BytePlusUltra,
|
||||
Seedance2p0BytePlusUltraFast,
|
||||
Seedance2p0Mini,
|
||||
@@ -65,8 +63,6 @@ impl From<String> for ApiClientVideoModel {
|
||||
"seedance_2p0_fast" => Self::Seedance2p0Fast,
|
||||
"seedance_2p0_bp" => Self::Seedance2p0BytePlus,
|
||||
"seedance_2p0_bp_fast" => Self::Seedance2p0BytePlusFast,
|
||||
"seedance_2p0_u" => Self::Seedance2p0Ultra,
|
||||
"seedance_2p0_u_fast" => Self::Seedance2p0UltraFast,
|
||||
"seedance_2p0_bpu" => Self::Seedance2p0BytePlusUltra,
|
||||
"seedance_2p0_bpu_fast" => Self::Seedance2p0BytePlusUltraFast,
|
||||
"seedance_2p0_mini" => Self::Seedance2p0Mini,
|
||||
@@ -106,8 +102,6 @@ impl From<ApiClientVideoModel> for String {
|
||||
ApiClientVideoModel::Seedance2p0Fast => "seedance_2p0_fast".to_string(),
|
||||
ApiClientVideoModel::Seedance2p0BytePlus => "seedance_2p0_bp".to_string(),
|
||||
ApiClientVideoModel::Seedance2p0BytePlusFast => "seedance_2p0_bp_fast".to_string(),
|
||||
ApiClientVideoModel::Seedance2p0Ultra => "seedance_2p0_u".to_string(),
|
||||
ApiClientVideoModel::Seedance2p0UltraFast => "seedance_2p0_u_fast".to_string(),
|
||||
ApiClientVideoModel::Seedance2p0BytePlusUltra => "seedance_2p0_bpu".to_string(),
|
||||
ApiClientVideoModel::Seedance2p0BytePlusUltraFast => "seedance_2p0_bpu_fast".to_string(),
|
||||
ApiClientVideoModel::Seedance2p0Mini => "seedance_2p0_mini".to_string(),
|
||||
@@ -165,8 +159,6 @@ mod tests {
|
||||
(ApiClientVideoModel::Seedance2p0Fast, "seedance_2p0_fast"),
|
||||
(ApiClientVideoModel::Seedance2p0BytePlus, "seedance_2p0_bp"),
|
||||
(ApiClientVideoModel::Seedance2p0BytePlusFast, "seedance_2p0_bp_fast"),
|
||||
(ApiClientVideoModel::Seedance2p0Ultra, "seedance_2p0_u"),
|
||||
(ApiClientVideoModel::Seedance2p0UltraFast, "seedance_2p0_u_fast"),
|
||||
(ApiClientVideoModel::Seedance2p0BytePlusUltra, "seedance_2p0_bpu"),
|
||||
(ApiClientVideoModel::Seedance2p0BytePlusUltraFast, "seedance_2p0_bpu_fast"),
|
||||
(ApiClientVideoModel::Seedance2p0Mini, "seedance_2p0_mini"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -53,11 +68,7 @@ pub enum RouterVideoModel {
|
||||
#[serde(rename = "seedance_2p0_bp_fast")]
|
||||
Seedance2p0BytePlusFast,
|
||||
|
||||
#[serde(rename = "seedance_2p0_u")]
|
||||
Seedance2p0Ultra,
|
||||
|
||||
#[serde(rename = "seedance_2p0_u_fast")]
|
||||
Seedance2p0UltraFast,
|
||||
|
||||
#[serde(rename = "seedance_2p0_bpu")]
|
||||
Seedance2p0BytePlusUltra,
|
||||
@@ -77,6 +88,12 @@ pub enum RouterVideoModel {
|
||||
#[serde(rename = "seedance_2p5_preview")]
|
||||
Seedance2p5Preview,
|
||||
|
||||
#[serde(rename = "seedance_2p5")]
|
||||
Seedance2p5,
|
||||
|
||||
#[serde(rename = "seedance_2p5_u")]
|
||||
Seedance2p5Ultra,
|
||||
|
||||
#[serde(rename = "happy_horse_1p0")]
|
||||
HappyHorse1p0,
|
||||
|
||||
@@ -141,6 +158,20 @@ mod tests {
|
||||
assert_serde_round_trip(RouterVideoModel::Seedance2p5Preview, "seedance_2p5_preview");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seedance_2p5_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::Seedance2p5, "seedance_2p5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seedance_2p5_ultra_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::Seedance2p5Ultra, "seedance_2p5_u");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn veo_3p1_lite_and_vidu_variants_serialize() {
|
||||
// NB: These strings must match `CommonVideoModel` — the two enums convert
|
||||
@@ -149,4 +180,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");
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -71,7 +71,7 @@ pub(crate) async fn resolve_and_upload_audio_ref(
|
||||
})?
|
||||
}
|
||||
};
|
||||
upload_to_kinovi_web(session, &source_url).await
|
||||
upload_to_kinovi_web(session, &source_url, None).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+25
-9
@@ -26,8 +26,6 @@ use crate::generate::generate_video::providers::artcraft::seedance_1p0_lite::bui
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_1p5_pro::build::build_artcraft_seedance_1p5_pro;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0::build::build_artcraft_seedance_2p0;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0_fast::build::build_artcraft_seedance_2p0_fast;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0_u::build::build_artcraft_seedance_2p0_u;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0_u_fast::build::build_artcraft_seedance_2p0_u_fast;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bp::build::build_artcraft_seedance_2p0_bp;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bp_fast::build::build_artcraft_seedance_2p0_bp_fast;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bpu::build::build_artcraft_seedance_2p0_bpu;
|
||||
@@ -43,12 +41,13 @@ 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;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p0::build::build_kinovi_seedance_2p0;
|
||||
use crate::generate::generate_video::providers::gmicloud::seedance_2p0_g::build::build_gmicloud_seedance_2p0_u;
|
||||
use crate::generate::generate_video::providers::gmicloud::seedance_2p0_fast_g::build::build_gmicloud_seedance_2p0_u_fast;
|
||||
use crate::generate::generate_video::providers::grok_api::grok_imagine_video::build::build_grok_api_grok_imagine_video;
|
||||
use crate::generate::generate_video::providers::grok_api::grok_imagine_video_1p5::build::build_grok_api_grok_imagine_video_1p5;
|
||||
use crate::generate::generate_video::providers::fal::kling_1_6_pro::build::build_fal_kling_1_6_pro;
|
||||
@@ -68,11 +67,17 @@ 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;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p0_mini::build::build_kinovi_seedance_2p0_mini;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p5::build::build_artcraft_seedance_2p5;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p5_u::build::build_artcraft_seedance_2p5_u;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p5_preview::build::build_artcraft_seedance_2p5_preview;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p5::build::build_kinovi_seedance_2p5;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p5_preview::build::build_kinovi_seedance_2p5_preview;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
|
||||
@@ -132,6 +137,12 @@ pub struct GenerateVideoRequestBuilder {
|
||||
/// so it's best to be explicit.
|
||||
pub generate_audio: Option<bool>,
|
||||
|
||||
/// CALCULATION-ONLY (never sent to any provider): the total seconds of
|
||||
/// reference video input, summed across all reference videos and rounded
|
||||
/// up to whole seconds per file. Models that bill reference-video input
|
||||
/// seconds (e.g. Seedance 2.5) add this to the billed duration.
|
||||
pub total_reference_video_input_seconds: Option<u16>,
|
||||
|
||||
/// If the request is a mismatch with the (model/provider), how to mitigate it.
|
||||
pub request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy,
|
||||
|
||||
@@ -160,6 +171,7 @@ impl Default for GenerateVideoRequestBuilder {
|
||||
duration_seconds: None,
|
||||
video_batch_count: None,
|
||||
generate_audio: None,
|
||||
total_reference_video_input_seconds: None,
|
||||
idempotency_token: None,
|
||||
}
|
||||
}
|
||||
@@ -172,6 +184,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,10 +194,9 @@ 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),
|
||||
(RouterProvider::Artcraft, RouterVideoModel::Seedance2p0UltraFast) => build_artcraft_seedance_2p0_u_fast(self),
|
||||
(RouterProvider::Artcraft, RouterVideoModel::Seedance2p0BytePlus) => build_artcraft_seedance_2p0_bp(self),
|
||||
(RouterProvider::Artcraft, RouterVideoModel::Seedance2p0BytePlusFast) => build_artcraft_seedance_2p0_bp_fast(self),
|
||||
(RouterProvider::Artcraft, RouterVideoModel::Seedance2p0BytePlusUltra) => build_artcraft_seedance_2p0_bpu(self),
|
||||
@@ -192,6 +205,8 @@ impl GenerateVideoRequestBuilder {
|
||||
(RouterProvider::Artcraft, RouterVideoModel::Seedance2p0BytePlusMini) => build_artcraft_seedance_2p0_bp_mini(self),
|
||||
(RouterProvider::Artcraft, RouterVideoModel::Seedance2p0BytePlusUltraMini) => build_artcraft_seedance_2p0_bpu_mini(self),
|
||||
(RouterProvider::Artcraft, RouterVideoModel::Seedance2p5Preview) => build_artcraft_seedance_2p5_preview(self),
|
||||
(RouterProvider::Artcraft, RouterVideoModel::Seedance2p5) => build_artcraft_seedance_2p5(self),
|
||||
(RouterProvider::Artcraft, RouterVideoModel::Seedance2p5Ultra) => build_artcraft_seedance_2p5_u(self),
|
||||
(RouterProvider::Artcraft, RouterVideoModel::PreviewModel) => build_artcraft_preview_model(self),
|
||||
(RouterProvider::Artcraft, RouterVideoModel::PreviewModelFast) => build_artcraft_preview_model_fast(self),
|
||||
(RouterProvider::Artcraft, RouterVideoModel::Seedance10Lite) => build_artcraft_seedance_1p0_lite(self),
|
||||
@@ -207,6 +222,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 +231,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),
|
||||
@@ -226,9 +244,6 @@ impl GenerateVideoRequestBuilder {
|
||||
(RouterProvider::Fal, RouterVideoModel::Veo3p1Lite) => build_fal_veo_3p1_lite(self),
|
||||
(RouterProvider::Fal, RouterVideoModel::ViduQ3) => build_fal_vidu_q3(self),
|
||||
(RouterProvider::Fal, RouterVideoModel::ViduQ3Turbo) => build_fal_vidu_q3_turbo(self),
|
||||
// GmiCloud
|
||||
(RouterProvider::GmiCloud, RouterVideoModel::Seedance2p0Ultra) => build_gmicloud_seedance_2p0_u(self),
|
||||
(RouterProvider::GmiCloud, RouterVideoModel::Seedance2p0UltraFast) => build_gmicloud_seedance_2p0_u_fast(self),
|
||||
// Grok
|
||||
(RouterProvider::GrokApi, RouterVideoModel::GrokImagineVideo) => build_grok_api_grok_imagine_video(self),
|
||||
(RouterProvider::GrokApi, RouterVideoModel::GrokImagineVideo1p5) => build_grok_api_grok_imagine_video_1p5(self),
|
||||
@@ -240,6 +255,7 @@ impl GenerateVideoRequestBuilder {
|
||||
(RouterProvider::KinoviWeb, RouterVideoModel::Seedance2p0BytePlusMini) => build_kinovi_seedance_2p0_mini(self),
|
||||
(RouterProvider::KinoviWeb, RouterVideoModel::Seedance2p0BytePlusUltraMini) => build_kinovi_seedance_2p0_mini(self),
|
||||
(RouterProvider::KinoviWeb, RouterVideoModel::Seedance2p5Preview) => build_kinovi_seedance_2p5_preview(self),
|
||||
(RouterProvider::KinoviWeb, RouterVideoModel::Seedance2p5) => build_kinovi_seedance_2p5(self),
|
||||
_ => self.unsupported_provider_and_model(),
|
||||
}
|
||||
}
|
||||
|
||||
+28
-4
@@ -82,6 +82,7 @@ pub fn build_artcraft_omni_video_request(
|
||||
negative_prompt: None,
|
||||
generate_audio: None,
|
||||
quality: None,
|
||||
estimate_only: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -230,7 +231,7 @@ fn plan_output_resolution(
|
||||
}
|
||||
}
|
||||
|
||||
/// Batch counts: 1, 2, 4.
|
||||
/// Batch counts: 1 through 4.
|
||||
pub fn plan_batch_count(
|
||||
video_batch_count: Option<u16>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
@@ -238,7 +239,8 @@ pub fn plan_batch_count(
|
||||
let count = video_batch_count.unwrap_or(1);
|
||||
match count {
|
||||
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
|
||||
1 | 2 | 4 => Ok(count),
|
||||
1..=4 => Ok(count),
|
||||
// 5 and above:
|
||||
_ => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
@@ -247,9 +249,31 @@ pub fn plan_batch_count(
|
||||
}))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(4),
|
||||
RequestMismatchMitigationStrategy::PayLessDowngrade => {
|
||||
Ok(if count < 4 { 2 } else { 4 })
|
||||
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(4),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Batch counts for the Seedance 2.0 Mini family: 1-8 are all valid
|
||||
/// (wider than the shared 1/2/4 planning); higher counts clamp to 8 per the
|
||||
/// mitigation strategy.
|
||||
pub fn plan_mini_batch_count(
|
||||
video_batch_count: Option<u16>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<u16, ArtcraftRouterError> {
|
||||
let count = video_batch_count.unwrap_or(1);
|
||||
match count {
|
||||
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
|
||||
1..=8 => Ok(count),
|
||||
_ => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "video_batch_count",
|
||||
value: format!("{}", count),
|
||||
}))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade
|
||||
| RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(8),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
+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()
|
||||
}
|
||||
}
|
||||
+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()
|
||||
}
|
||||
}
|
||||
+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
|
||||
}
|
||||
}
|
||||
+1
@@ -63,6 +63,7 @@ mod tests {
|
||||
duration_seconds: Some(5),
|
||||
video_batch_count: Some(1),
|
||||
generate_audio: None,
|
||||
estimate_only: None,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
+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()
|
||||
}
|
||||
}
|
||||
+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
|
||||
}
|
||||
}
|
||||
+5
-2
@@ -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;
|
||||
@@ -20,8 +23,6 @@ pub mod seedance_1p0_lite;
|
||||
pub mod seedance_1p5_pro;
|
||||
pub mod seedance_2p0;
|
||||
pub mod seedance_2p0_fast;
|
||||
pub mod seedance_2p0_u_fast;
|
||||
pub mod seedance_2p0_u;
|
||||
pub mod seedance_2p0_bp;
|
||||
pub mod seedance_2p0_bp_fast;
|
||||
pub mod seedance_2p0_bpu;
|
||||
@@ -29,7 +30,9 @@ pub mod seedance_2p0_bpu_fast;
|
||||
pub mod seedance_2p0_mini;
|
||||
pub mod seedance_2p0_bp_mini;
|
||||
pub mod seedance_2p0_bpu_mini;
|
||||
pub mod seedance_2p5;
|
||||
pub mod seedance_2p5_preview;
|
||||
pub mod seedance_2p5_u;
|
||||
pub mod sora_2;
|
||||
pub mod sora_2_pro;
|
||||
pub mod veo_2;
|
||||
|
||||
+16
@@ -79,6 +79,12 @@ mod tests {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, 2), 750);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_3() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 3), 375);
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, 3), 1125);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_4() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 4), 500);
|
||||
@@ -109,6 +115,11 @@ mod tests {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 2), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_3() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 3), 150);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_4() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 4), 200);
|
||||
@@ -133,6 +144,11 @@ mod tests {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 5, 2), 500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_3() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 5, 3), 750);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_4() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 5, 4), 1000);
|
||||
|
||||
+11
@@ -76,6 +76,12 @@ mod tests {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, 2), 600);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_3() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 3), 300);
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, 3), 900);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_4() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 4), 400);
|
||||
@@ -106,6 +112,11 @@ mod tests {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 2), 90);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_3() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 3), 135);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_4() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 4), 180);
|
||||
|
||||
+17
@@ -146,6 +146,13 @@ mod tests {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, 2), 480);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_3() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 4, 3), 192);
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 3), 240);
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, 3), 720);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_4() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 4, 4), 256);
|
||||
@@ -177,6 +184,11 @@ mod tests {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 2), 78);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_3() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 3), 117);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_4() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 4), 155);
|
||||
@@ -201,6 +213,11 @@ mod tests {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 5, 2), 466);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_3() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 5, 3), 699);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_4() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 5, 4), 933);
|
||||
|
||||
+15
@@ -92,6 +92,11 @@ mod tests {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 2), 250);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_3() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 3), 375);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_4() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 4), 500);
|
||||
@@ -119,6 +124,11 @@ mod tests {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 2), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_3() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 3), 150);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_4() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 4), 200);
|
||||
@@ -141,6 +151,11 @@ mod tests {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 5, 2), 500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_3() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 5, 3), 750);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_4() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 5, 4), 1000);
|
||||
|
||||
+10
@@ -67,6 +67,11 @@ mod tests {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 2), 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_3() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 3), 300);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_4() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 4), 400);
|
||||
@@ -94,6 +99,11 @@ mod tests {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 2), 90);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_3() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 3), 135);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_4() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 4), 180);
|
||||
|
||||
+22
-7
@@ -3,21 +3,28 @@ use enums::common::generation::common_video_model::CommonVideoModel as CommonVid
|
||||
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,
|
||||
build_artcraft_omni_video_request, plan_mini_batch_count, SupportedResolutions, UltraWideSupport,
|
||||
};
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bp_mini::request::ArtcraftSeedance2p0BytePlusMiniRequestState;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
|
||||
|
||||
pub fn build_artcraft_seedance_2p0_bp_mini(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
pub fn build_artcraft_seedance_2p0_bp_mini(mut builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
// Seedance 2.0 Mini supports 480p and 720p only (higher resolutions downgrade
|
||||
// to 720p), and all six aspect ratios including 21:9.
|
||||
let request = build_artcraft_omni_video_request(
|
||||
// Mini supports batches of 1-8, wider than the shared 1/2/4 planning.
|
||||
// Take the batch out before the shared build and set it after.
|
||||
let strategy = builder.request_mismatch_mitigation_strategy;
|
||||
let batch_count = plan_mini_batch_count(builder.video_batch_count.take(), strategy)?;
|
||||
|
||||
let mut request = build_artcraft_omni_video_request(
|
||||
builder,
|
||||
CommonVideoModelEnum::Seedance2p0BytePlusMini,
|
||||
SupportedResolutions::Fast,
|
||||
UltraWideSupport::Supported,
|
||||
)?;
|
||||
request.video_batch_count = Some(batch_count);
|
||||
|
||||
let state = ArtcraftSeedance2p0BytePlusMiniRequestState { request };
|
||||
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0BytePlusMini(state)))
|
||||
}
|
||||
@@ -80,10 +87,18 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_count_clamped_to_platform_max() {
|
||||
// The ArtCraft platform caps batch at 4 and downgrades higher requests.
|
||||
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(8); }));
|
||||
assert_eq!(req.request.video_batch_count, Some(4));
|
||||
fn batch_count_up_to_eight_passed_through() {
|
||||
// Mini supports batches of 1-8; execution and billing must agree.
|
||||
for batch in 1..=8u16 {
|
||||
let req = unwrap_request(builder_with(move |b| { b.video_batch_count = Some(batch); }));
|
||||
assert_eq!(req.request.video_batch_count, Some(batch));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_count_above_eight_clamps_to_eight() {
|
||||
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(9); }));
|
||||
assert_eq!(req.request.video_batch_count, Some(8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+43
@@ -105,6 +105,49 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
mod batch_pricing {
|
||||
use super::*;
|
||||
|
||||
/// Mini bills batches of 1-8 in full (the platform's 1/2/4 cap does not
|
||||
/// apply). Once, batch 8 executed in full but billed as batch 4.
|
||||
#[test]
|
||||
fn every_batch_size_720p_5s() {
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, false), 44);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 2, false), 88);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 3, false), 131);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 4, false), 175);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 5, false), 219);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 6, false), 262);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 7, false), 306);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, false), 349);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_batch_size_480p_5s() {
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 5, 1, false), 17);
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 5, 2, false), 33);
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 5, 3, false), 50);
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 5, 4, false), 66);
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 5, 5, false), 82);
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 5, 6, false), 99);
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 5, 7, false), 115);
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 5, 8, false), 131);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_eight_with_video_reference() {
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, true), 419);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_above_eight_clamps_to_eight() {
|
||||
assert_eq!(
|
||||
cents(RouterResolution::SevenTwentyP, 5, 9, false),
|
||||
cents(RouterResolution::SevenTwentyP, 5, 8, false),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
mod credits_tests {
|
||||
use super::*;
|
||||
|
||||
|
||||
+15
@@ -92,6 +92,11 @@ mod tests {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 2), 250);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_3() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 3), 375);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_4() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 4), 500);
|
||||
@@ -119,6 +124,11 @@ mod tests {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 2), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_3() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 3), 150);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_4() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 4), 200);
|
||||
@@ -141,6 +151,11 @@ mod tests {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 5, 2), 500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_3() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 5, 3), 750);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_4() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 5, 4), 1000);
|
||||
|
||||
+10
@@ -67,6 +67,11 @@ mod tests {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 2), 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_3() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 3), 300);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_4() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 4), 400);
|
||||
@@ -94,6 +99,11 @@ mod tests {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 2), 90);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_3() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 3), 135);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_4() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 4), 180);
|
||||
|
||||
+22
-7
@@ -3,21 +3,28 @@ use enums::common::generation::common_video_model::CommonVideoModel as CommonVid
|
||||
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,
|
||||
build_artcraft_omni_video_request, plan_mini_batch_count, SupportedResolutions, UltraWideSupport,
|
||||
};
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bpu_mini::request::ArtcraftSeedance2p0BytePlusUltraMiniRequestState;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
|
||||
|
||||
pub fn build_artcraft_seedance_2p0_bpu_mini(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
pub fn build_artcraft_seedance_2p0_bpu_mini(mut builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
// Seedance 2.0 Mini supports 480p and 720p only (higher resolutions downgrade
|
||||
// to 720p), and all six aspect ratios including 21:9.
|
||||
let request = build_artcraft_omni_video_request(
|
||||
// Mini supports batches of 1-8, wider than the shared 1/2/4 planning.
|
||||
// Take the batch out before the shared build and set it after.
|
||||
let strategy = builder.request_mismatch_mitigation_strategy;
|
||||
let batch_count = plan_mini_batch_count(builder.video_batch_count.take(), strategy)?;
|
||||
|
||||
let mut request = build_artcraft_omni_video_request(
|
||||
builder,
|
||||
CommonVideoModelEnum::Seedance2p0BytePlusUltraMini,
|
||||
SupportedResolutions::Fast,
|
||||
UltraWideSupport::Supported,
|
||||
)?;
|
||||
request.video_batch_count = Some(batch_count);
|
||||
|
||||
let state = ArtcraftSeedance2p0BytePlusUltraMiniRequestState { request };
|
||||
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0BytePlusUltraMini(state)))
|
||||
}
|
||||
@@ -80,10 +87,18 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_count_clamped_to_platform_max() {
|
||||
// The ArtCraft platform caps batch at 4 and downgrades higher requests.
|
||||
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(8); }));
|
||||
assert_eq!(req.request.video_batch_count, Some(4));
|
||||
fn batch_count_up_to_eight_passed_through() {
|
||||
// Mini supports batches of 1-8; execution and billing must agree.
|
||||
for batch in 1..=8u16 {
|
||||
let req = unwrap_request(builder_with(move |b| { b.video_batch_count = Some(batch); }));
|
||||
assert_eq!(req.request.video_batch_count, Some(batch));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_count_above_eight_clamps_to_eight() {
|
||||
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(9); }));
|
||||
assert_eq!(req.request.video_batch_count, Some(8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+43
@@ -105,6 +105,49 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
mod batch_pricing {
|
||||
use super::*;
|
||||
|
||||
/// Mini bills batches of 1-8 in full (the platform's 1/2/4 cap does not
|
||||
/// apply). Once, batch 8 executed in full but billed as batch 4.
|
||||
#[test]
|
||||
fn every_batch_size_720p_5s() {
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, false), 44);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 2, false), 88);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 3, false), 131);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 4, false), 175);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 5, false), 219);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 6, false), 262);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 7, false), 306);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, false), 349);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_batch_size_480p_5s() {
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 5, 1, false), 17);
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 5, 2, false), 33);
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 5, 3, false), 50);
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 5, 4, false), 66);
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 5, 5, false), 82);
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 5, 6, false), 99);
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 5, 7, false), 115);
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 5, 8, false), 131);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_eight_with_video_reference() {
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, true), 419);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_above_eight_clamps_to_eight() {
|
||||
assert_eq!(
|
||||
cents(RouterResolution::SevenTwentyP, 5, 9, false),
|
||||
cents(RouterResolution::SevenTwentyP, 5, 8, false),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
mod credits_tests {
|
||||
use super::*;
|
||||
|
||||
|
||||
+12
@@ -122,6 +122,13 @@ mod tests {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, 2), 382);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_3() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 4, 3), 153);
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 3), 191);
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, 3), 573);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_4() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 4, 4), 204);
|
||||
@@ -153,6 +160,11 @@ mod tests {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 2), 52);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_3() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 3), 78);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_4() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 4), 104);
|
||||
|
||||
+22
-7
@@ -3,21 +3,28 @@ use enums::common::generation::common_video_model::CommonVideoModel as CommonVid
|
||||
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,
|
||||
build_artcraft_omni_video_request, plan_mini_batch_count, SupportedResolutions, UltraWideSupport,
|
||||
};
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0_mini::request::ArtcraftSeedance2p0MiniRequestState;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
|
||||
|
||||
pub fn build_artcraft_seedance_2p0_mini(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
pub fn build_artcraft_seedance_2p0_mini(mut builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
// Seedance 2.0 Mini supports 480p and 720p only (higher resolutions downgrade
|
||||
// to 720p), and all six aspect ratios including 21:9.
|
||||
let request = build_artcraft_omni_video_request(
|
||||
// Mini supports batches of 1-8, wider than the shared 1/2/4 planning.
|
||||
// Take the batch out before the shared build and set it after.
|
||||
let strategy = builder.request_mismatch_mitigation_strategy;
|
||||
let batch_count = plan_mini_batch_count(builder.video_batch_count.take(), strategy)?;
|
||||
|
||||
let mut request = build_artcraft_omni_video_request(
|
||||
builder,
|
||||
CommonVideoModelEnum::Seedance2p0Mini,
|
||||
SupportedResolutions::Fast,
|
||||
UltraWideSupport::Supported,
|
||||
)?;
|
||||
request.video_batch_count = Some(batch_count);
|
||||
|
||||
let state = ArtcraftSeedance2p0MiniRequestState { request };
|
||||
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0Mini(state)))
|
||||
}
|
||||
@@ -80,10 +87,18 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_count_clamped_to_platform_max() {
|
||||
// The ArtCraft platform caps batch at 4 and downgrades higher requests.
|
||||
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(8); }));
|
||||
assert_eq!(req.request.video_batch_count, Some(4));
|
||||
fn batch_count_up_to_eight_passed_through() {
|
||||
// Mini supports batches of 1-8; execution and billing must agree.
|
||||
for batch in 1..=8u16 {
|
||||
let req = unwrap_request(builder_with(move |b| { b.video_batch_count = Some(batch); }));
|
||||
assert_eq!(req.request.video_batch_count, Some(batch));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_count_above_eight_clamps_to_eight() {
|
||||
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(9); }));
|
||||
assert_eq!(req.request.video_batch_count, Some(8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+43
@@ -105,6 +105,49 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
mod batch_pricing {
|
||||
use super::*;
|
||||
|
||||
/// Mini bills batches of 1-8 in full (the platform's 1/2/4 cap does not
|
||||
/// apply). Once, batch 8 executed in full but billed as batch 4.
|
||||
#[test]
|
||||
fn every_batch_size_720p_5s() {
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, false), 44);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 2, false), 87);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 3, false), 130);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 4, false), 173);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 5, false), 217);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 6, false), 260);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 7, false), 303);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, false), 346);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_batch_size_480p_5s() {
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 5, 1, false), 17);
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 5, 2, false), 33);
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 5, 3, false), 49);
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 5, 4, false), 65);
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 5, 5, false), 82);
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 5, 6, false), 98);
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 5, 7, false), 114);
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 5, 8, false), 130);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_eight_with_video_reference() {
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, true), 415);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_above_eight_clamps_to_eight() {
|
||||
assert_eq!(
|
||||
cents(RouterResolution::SevenTwentyP, 5, 9, false),
|
||||
cents(RouterResolution::SevenTwentyP, 5, 8, false),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
mod credits_tests {
|
||||
use super::*;
|
||||
|
||||
|
||||
-189
@@ -1,189 +0,0 @@
|
||||
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::seedance_2p0_u::request::ArtcraftSeedance2p0UltraRequestState;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
|
||||
|
||||
pub fn build_artcraft_seedance_2p0_u(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
let request = build_artcraft_omni_video_request(
|
||||
builder,
|
||||
CommonVideoModelEnum::Seedance2p0Ultra,
|
||||
SupportedResolutions::FullWith4k,
|
||||
UltraWideSupport::Supported,
|
||||
)?;
|
||||
let state = ArtcraftSeedance2p0UltraRequestState { request };
|
||||
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0Ultra(state)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
|
||||
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
|
||||
use enums::common::generation::common_bitrate::CommonBitrate as CommonBitrateEnum;
|
||||
use crate::api::router_bitrate::RouterBitrate;
|
||||
use tokens::tokens::characters::CharacterToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::api::character_list_ref::CharacterListRef;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
|
||||
|
||||
use super::*;
|
||||
|
||||
// ── Field conversions ──
|
||||
|
||||
mod field_conversions {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn model_is_seedance_2p0() {
|
||||
let req = unwrap_request(make_builder(|_| {}));
|
||||
assert!(matches!(req.request.model, Some(CommonVideoModelEnum::Seedance2p0Ultra)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_passed_through() {
|
||||
let req = unwrap_request(make_builder(|b| { b.prompt = Some("test".to_string()); }));
|
||||
assert_eq!(req.request.prompt, Some("test".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_passed_through() {
|
||||
let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(10); }));
|
||||
assert_eq!(req.request.duration_seconds, Some(10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_clamped_to_max() {
|
||||
let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(99); }));
|
||||
assert_eq!(req.request.duration_seconds, Some(15));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_count_passed_through() {
|
||||
let req = unwrap_request(make_builder(|b| { b.video_batch_count = Some(4); }));
|
||||
assert_eq!(req.request.video_batch_count, Some(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bitrate_passed_through() {
|
||||
let req = unwrap_request(make_builder(|b| { b.bitrate = Some(RouterBitrate::High); }));
|
||||
assert_eq!(req.request.bitrate, Some(CommonBitrateEnum::High));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Resolution ──
|
||||
|
||||
mod resolution_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn res_480p() {
|
||||
let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::FourEightyP); }));
|
||||
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::FourEightyP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn res_720p() {
|
||||
let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::SevenTwentyP); }));
|
||||
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn res_1080p() {
|
||||
let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::TenEightyP); }));
|
||||
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::TenEightyP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn res_4k() {
|
||||
let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::FourK); }));
|
||||
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::FourK));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_stays_none() {
|
||||
let req = unwrap_request(make_builder(|_| {}));
|
||||
assert!(req.request.resolution.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
// ── Media tokens ──
|
||||
|
||||
mod media_token_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn start_frame_token_passed_through() {
|
||||
let token = MediaFileToken::new("mf_start".to_string());
|
||||
let req = unwrap_request(make_builder(|b| {
|
||||
b.start_frame = Some(ImageRef::MediaFileToken(token.clone()));
|
||||
}));
|
||||
assert_eq!(req.request.start_frame_image_media_token, Some(token));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_start_frame_rejected() {
|
||||
let result = build_artcraft_seedance_2p0_u(GenerateVideoRequestBuilder {
|
||||
start_frame: Some(ImageRef::Url("https://example.com".to_string())),
|
||||
..base_builder()
|
||||
});
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_image_tokens_passed_through() {
|
||||
let tokens = vec![MediaFileToken::new("mf_a".to_string()), MediaFileToken::new("mf_b".to_string())];
|
||||
let req = unwrap_request(make_builder(|b| {
|
||||
b.reference_images = Some(ImageListRef::MediaFileTokens(tokens.clone()));
|
||||
}));
|
||||
assert_eq!(req.request.reference_image_media_tokens, Some(tokens));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_tokens_passed_through() {
|
||||
let tokens = vec![CharacterToken::new("char_a".to_string()), CharacterToken::new("char_b".to_string())];
|
||||
let req = unwrap_request(make_builder(|b| {
|
||||
b.reference_character_tokens = Some(CharacterListRef::CharacterTokens(tokens.clone()));
|
||||
}));
|
||||
assert_eq!(req.request.reference_character_tokens, Some(tokens));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
fn base_builder() -> GenerateVideoRequestBuilder {
|
||||
GenerateVideoRequestBuilder {
|
||||
provider: RouterProvider::Artcraft,
|
||||
duration_seconds: Some(5),
|
||||
video_batch_count: Some(1),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn make_builder(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder {
|
||||
let mut builder = base_builder();
|
||||
f(&mut builder);
|
||||
builder
|
||||
}
|
||||
|
||||
fn unwrap_request(builder: GenerateVideoRequestBuilder) -> ArtcraftSeedance2p0UltraRequestState {
|
||||
let result = build_artcraft_seedance_2p0_u(builder).expect("build should succeed");
|
||||
match result {
|
||||
VideoGenerationDraftOrRequest::Request(
|
||||
VideoGenerationRequest::ArtcraftSeedance2p0Ultra(state)
|
||||
) => state,
|
||||
_ => panic!("expected ArtcraftSeedance2p0 request"),
|
||||
}
|
||||
}
|
||||
}
|
||||
-237
@@ -1,237 +0,0 @@
|
||||
use enums::common::generation::common_resolution::CommonResolution;
|
||||
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_common::seedance_2p0_four_k_usd_cents;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0_u::request::ArtcraftSeedance2p0UltraRequestState;
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
|
||||
// ── Pricing constants ──
|
||||
|
||||
const CENTS_PER_SECOND_480P: f64 = 5.4404;
|
||||
const CENTS_PER_SECOND_720P: f64 = 11.2;
|
||||
const CENTS_PER_SECOND_1080P: f64 = 32.6424;
|
||||
|
||||
pub struct ArtcraftSeedance2p0UltraCostState {
|
||||
pub resolution: CommonResolution,
|
||||
pub duration_seconds: u16,
|
||||
pub batch_count: u16,
|
||||
pub has_video_reference: bool,
|
||||
}
|
||||
|
||||
impl ArtcraftSeedance2p0UltraCostState {
|
||||
pub fn from_request(request: &ArtcraftSeedance2p0UltraRequestState) -> Self {
|
||||
let resolution = request.request.resolution
|
||||
.unwrap_or(CommonResolution::SevenTwentyP);
|
||||
let duration_seconds = request.request.duration_seconds.unwrap_or(5);
|
||||
let batch_count = request.request.video_batch_count.unwrap_or(1);
|
||||
let has_video_reference = request.request.reference_video_media_tokens
|
||||
.as_ref()
|
||||
.is_some_and(|tokens| !tokens.is_empty());
|
||||
|
||||
Self { resolution, duration_seconds, batch_count, has_video_reference }
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
|
||||
if self.resolution == CommonResolution::FourK {
|
||||
let usd_cents = seedance_2p0_four_k_usd_cents(
|
||||
self.duration_seconds,
|
||||
self.batch_count,
|
||||
self.has_video_reference,
|
||||
);
|
||||
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,
|
||||
CommonResolution::TenEightyP => CENTS_PER_SECOND_1080P,
|
||||
_ => CENTS_PER_SECOND_720P,
|
||||
};
|
||||
|
||||
let cents_per_video = (cents_per_second * self.duration_seconds as f64).ceil() as u64;
|
||||
let usd_cents = cents_per_video * self.batch_count as u64;
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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;
|
||||
|
||||
mod pricing_720p {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn batch_1() {
|
||||
// 11.2 * 5 = 56¢
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 1), 56);
|
||||
// 11.2 * 10 = 112¢
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 10, 1), 112);
|
||||
// 11.2 * 15 = 168¢
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, 1), 168);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_2() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 2), 112);
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 10, 2), 224);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_4() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 4), 224);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_defaults_to_720p() {
|
||||
assert_eq!(cost_cents(None, 10, 1), cost_cents(Some(RouterResolution::SevenTwentyP), 10, 1));
|
||||
}
|
||||
}
|
||||
|
||||
mod pricing_480p {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn batch_1() {
|
||||
// 5.4404 * 5 = 27.202 → ceil = 28¢
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 1), 28);
|
||||
// 5.4404 * 10 = 54.404 → ceil = 55¢
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 10, 1), 55);
|
||||
// 5.4404 * 15 = 81.606 → ceil = 82¢
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 15, 1), 82);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_4() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 10, 4), 220);
|
||||
}
|
||||
}
|
||||
|
||||
mod pricing_1080p {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn batch_1() {
|
||||
// 32.6424 * 5 = 163.212 → ceil = 164¢
|
||||
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 5, 1), 164);
|
||||
// 32.6424 * 10 = 326.424 → ceil = 327¢
|
||||
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 10, 1), 327);
|
||||
// 32.6424 * 15 = 489.636 → ceil = 490¢
|
||||
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 15, 1), 490);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_4() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 10, 4), 1308);
|
||||
}
|
||||
}
|
||||
|
||||
mod four_k_pricing {
|
||||
use enums::common::generation::common_resolution::CommonResolution;
|
||||
|
||||
fn artcraft_4k_cents(duration_seconds: u16, batch_count: u16, has_video_reference: bool) -> u64 {
|
||||
super::super::ArtcraftSeedance2p0UltraCostState {
|
||||
resolution: CommonResolution::FourK,
|
||||
duration_seconds,
|
||||
batch_count,
|
||||
has_video_reference,
|
||||
}
|
||||
.estimate_cost()
|
||||
.cost_in_usd_cents
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_4k_without_video_reference() {
|
||||
assert_eq!(artcraft_4k_cents(4, 1, false), 347);
|
||||
assert_eq!(artcraft_4k_cents(5, 1, false), 433);
|
||||
assert_eq!(artcraft_4k_cents(10, 1, false), 866);
|
||||
assert_eq!(artcraft_4k_cents(15, 1, false), 1299);
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
|
||||
mod relative_pricing {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cost_480p_cheaper_than_720p_cheaper_than_1080p() {
|
||||
let c480 = cost_cents(Some(RouterResolution::FourEightyP), 10, 1);
|
||||
let c720 = cost_cents(Some(RouterResolution::SevenTwentyP), 10, 1);
|
||||
let c1080 = cost_cents(Some(RouterResolution::TenEightyP), 10, 1);
|
||||
assert!(c480 < c720);
|
||||
assert!(c720 < c1080);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_scales_with_duration() {
|
||||
let c5 = cost_cents(None, 5, 1);
|
||||
let c10 = cost_cents(None, 10, 1);
|
||||
let c15 = cost_cents(None, 15, 1);
|
||||
assert!(c5 < c10);
|
||||
assert!(c10 < c15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_scales_with_batch() {
|
||||
let b1 = cost_cents(None, 10, 1);
|
||||
let b2 = cost_cents(None, 10, 2);
|
||||
let b4 = cost_cents(None, 10, 4);
|
||||
assert!(b1 < b2);
|
||||
assert!(b2 < b4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credits_equal_usd_cents() {
|
||||
let cost = build_cost(None, 10, 1);
|
||||
assert_eq!(cost.cost_in_credits, cost.cost_in_usd_cents);
|
||||
}
|
||||
}
|
||||
|
||||
// -- Helpers --
|
||||
|
||||
fn build_cost(
|
||||
resolution: Option<RouterResolution>,
|
||||
duration_seconds: u16,
|
||||
video_batch_count: u16,
|
||||
) -> crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Seedance2p0Ultra,
|
||||
provider: RouterProvider::Artcraft,
|
||||
resolution,
|
||||
duration_seconds: Some(duration_seconds),
|
||||
video_batch_count: Some(video_batch_count),
|
||||
..Default::default()
|
||||
};
|
||||
builder.build2().expect("build2").estimate_cost().expect("estimate_cost")
|
||||
}
|
||||
|
||||
fn cost_cents(resolution: Option<RouterResolution>, duration_seconds: u16, batch: u16) -> u64 {
|
||||
build_cost(resolution, duration_seconds, batch).cost_in_usd_cents.unwrap()
|
||||
}
|
||||
}
|
||||
-237
@@ -1,237 +0,0 @@
|
||||
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 ArtcraftSeedance2p0UltraRequestState {
|
||||
pub request: OmniGenVideoCostAndGenerateRequest,
|
||||
}
|
||||
|
||||
impl ArtcraftSeedance2p0UltraRequestState {
|
||||
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::characters::CharacterToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::api::character_list_ref::CharacterListRef;
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
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;
|
||||
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
|
||||
use artcraft_client::utils::api_host::ApiHost;
|
||||
|
||||
use test_data::web::character_tokens::{JIM, KNIGHT};
|
||||
use test_data::web::image_media_tokens::{
|
||||
FOREST_BACKDROP_PRODUCTION_MEDIA_TOKEN,
|
||||
JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN,
|
||||
WHITE_HOUSE_SUNSET_PRODUCTION_MEDIA_TOKEN,
|
||||
};
|
||||
|
||||
// -- Aspect ratio tests --
|
||||
|
||||
mod aspect_ratio_tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn landscape() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A corgi running through a field of wildflowers at sunset.".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]
|
||||
async fn portrait() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A cat sitting on a windowsill watching rain.".to_string()),
|
||||
aspect_ratio: Some(RouterAspectRatio::TallNineBySixteen),
|
||||
..artcraft_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn square() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A hummingbird hovering near a flower.".to_string()),
|
||||
aspect_ratio: Some(RouterAspectRatio::Square),
|
||||
..artcraft_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
}
|
||||
|
||||
// -- Resolution tests --
|
||||
|
||||
mod resolution_tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn res_480p() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A shiba inu playing in autumn leaves.".to_string()),
|
||||
resolution: Some(RouterResolution::FourEightyP),
|
||||
..artcraft_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn res_720p() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A golden retriever catching a frisbee on the beach.".to_string()),
|
||||
resolution: Some(RouterResolution::SevenTwentyP),
|
||||
..artcraft_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn res_1080p() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A fox walking through a snowy forest.".to_string()),
|
||||
resolution: Some(RouterResolution::TenEightyP),
|
||||
..artcraft_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
}
|
||||
|
||||
// -- Modality tests --
|
||||
|
||||
mod modality_tests {
|
||||
use super::*;
|
||||
use test_data::web::image_media_tokens::TALL_MOCHI_WITH_GLASSES_PRODUCTION_MEDIA_TOKEN;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn text_to_video() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A whale breaching in the open ocean at dawn, 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]
|
||||
async fn keyframe_start_and_end_frame() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("The dog walks from the lake to the forest.".to_string()),
|
||||
start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new(JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN.to_string()))),
|
||||
end_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new(FOREST_BACKDROP_PRODUCTION_MEDIA_TOKEN.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]
|
||||
async fn image_references() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("The dog in @2 runs through the scenery in @1 towards the building in @3.".to_string()),
|
||||
reference_images: Some(ImageListRef::MediaFileTokens(vec![
|
||||
MediaFileToken::new(FOREST_BACKDROP_PRODUCTION_MEDIA_TOKEN.to_string()),
|
||||
MediaFileToken::new(TALL_MOCHI_WITH_GLASSES_PRODUCTION_MEDIA_TOKEN.to_string()),
|
||||
MediaFileToken::new(WHITE_HOUSE_SUNSET_PRODUCTION_MEDIA_TOKEN.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]
|
||||
async fn character_references() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("@Jim and @Knight are sparring in a medieval arena.".to_string()),
|
||||
reference_character_tokens: Some(CharacterListRef::CharacterTokens(vec![
|
||||
CharacterToken::new(JIM.token.to_string()),
|
||||
CharacterToken::new(KNIGHT.token.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 {
|
||||
provider: RouterProvider::Artcraft,
|
||||
duration_seconds: Some(4),
|
||||
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
|
||||
}
|
||||
}
|
||||
-253
@@ -1,253 +0,0 @@
|
||||
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::seedance_2p0_u_fast::request::ArtcraftSeedance2p0UltraFastRequestState;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
|
||||
|
||||
pub fn build_artcraft_seedance_2p0_u_fast(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
let request = build_artcraft_omni_video_request(
|
||||
builder,
|
||||
CommonVideoModelEnum::Seedance2p0UltraFast,
|
||||
SupportedResolutions::Fast,
|
||||
UltraWideSupport::Supported,
|
||||
)?;
|
||||
let state = ArtcraftSeedance2p0UltraFastRequestState { request };
|
||||
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0UltraFast(state)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use enums::common::generation::common_aspect_ratio::CommonAspectRatio as CommonAspectRatioEnum;
|
||||
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
|
||||
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
|
||||
use enums::common::generation::common_bitrate::CommonBitrate as CommonBitrateEnum;
|
||||
use crate::api::router_bitrate::RouterBitrate;
|
||||
use tokens::tokens::characters::CharacterToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::api::character_list_ref::CharacterListRef;
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::api::video_list_ref::VideoListRef;
|
||||
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
|
||||
|
||||
use super::*;
|
||||
|
||||
// ── Field conversions ──
|
||||
|
||||
mod field_conversions {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn prompt_is_passed_through() {
|
||||
let req = unwrap_request(artcraft_fast_builder_with(|b| { b.prompt = Some("test".to_string()); }));
|
||||
assert_eq!(req.request.prompt, Some("test".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_none_stays_none() {
|
||||
let req = unwrap_request(artcraft_fast_builder_with(|_| {}));
|
||||
assert!(req.request.prompt.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_is_seedance_2p0_fast() {
|
||||
let req = unwrap_request(artcraft_fast_builder_with(|_| {}));
|
||||
assert!(matches!(req.request.model, Some(CommonVideoModelEnum::Seedance2p0UltraFast)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_passed_through() {
|
||||
let req = unwrap_request(artcraft_fast_builder_with(|b| { b.duration_seconds = Some(10); }));
|
||||
assert_eq!(req.request.duration_seconds, Some(10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_clamped_to_max() {
|
||||
let req = unwrap_request(artcraft_fast_builder_with(|b| { b.duration_seconds = Some(99); }));
|
||||
assert_eq!(req.request.duration_seconds, Some(15));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_count_passed_through() {
|
||||
let req = unwrap_request(artcraft_fast_builder_with(|b| { b.video_batch_count = Some(4); }));
|
||||
assert_eq!(req.request.video_batch_count, Some(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bitrate_passed_through() {
|
||||
let req = unwrap_request(artcraft_fast_builder_with(|b| { b.bitrate = Some(RouterBitrate::High); }));
|
||||
assert_eq!(req.request.bitrate, Some(CommonBitrateEnum::High));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Aspect ratio ──
|
||||
|
||||
mod aspect_ratio_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn wide() {
|
||||
let req = unwrap_request(artcraft_fast_builder_with(|b| {
|
||||
b.aspect_ratio = Some(RouterAspectRatio::WideSixteenByNine);
|
||||
}));
|
||||
assert_eq!(req.request.aspect_ratio, Some(CommonAspectRatioEnum::WideSixteenByNine));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tall() {
|
||||
let req = unwrap_request(artcraft_fast_builder_with(|b| {
|
||||
b.aspect_ratio = Some(RouterAspectRatio::TallNineBySixteen);
|
||||
}));
|
||||
assert_eq!(req.request.aspect_ratio, Some(CommonAspectRatioEnum::TallNineBySixteen));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn square() {
|
||||
let req = unwrap_request(artcraft_fast_builder_with(|b| {
|
||||
b.aspect_ratio = Some(RouterAspectRatio::Square);
|
||||
}));
|
||||
assert_eq!(req.request.aspect_ratio, Some(CommonAspectRatioEnum::Square));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_stays_none() {
|
||||
let req = unwrap_request(artcraft_fast_builder_with(|_| {}));
|
||||
assert!(req.request.aspect_ratio.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
// ── Resolution ──
|
||||
|
||||
mod resolution_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn res_480p() {
|
||||
let req = unwrap_request(artcraft_fast_builder_with(|b| {
|
||||
b.resolution = Some(RouterResolution::FourEightyP);
|
||||
}));
|
||||
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::FourEightyP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn res_720p() {
|
||||
let req = unwrap_request(artcraft_fast_builder_with(|b| {
|
||||
b.resolution = Some(RouterResolution::SevenTwentyP);
|
||||
}));
|
||||
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn res_1080p_downgrades_to_720p() {
|
||||
let req = unwrap_request(artcraft_fast_builder_with(|b| {
|
||||
b.resolution = Some(RouterResolution::TenEightyP);
|
||||
}));
|
||||
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn res_1080p_error_out() {
|
||||
let result = build_artcraft_seedance_2p0_u_fast(GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Seedance2p0UltraFast,
|
||||
provider: RouterProvider::Artcraft,
|
||||
resolution: Some(RouterResolution::TenEightyP),
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
|
||||
..Default::default()
|
||||
});
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_stays_none() {
|
||||
let req = unwrap_request(artcraft_fast_builder_with(|_| {}));
|
||||
assert!(req.request.resolution.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
// ── Media tokens ──
|
||||
|
||||
mod media_token_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn start_frame_token_passed_through() {
|
||||
let token = MediaFileToken::new("mf_start".to_string());
|
||||
let req = unwrap_request(artcraft_fast_builder_with(|b| {
|
||||
b.start_frame = Some(ImageRef::MediaFileToken(token.clone()));
|
||||
}));
|
||||
assert_eq!(req.request.start_frame_image_media_token, Some(token));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_start_frame_rejected() {
|
||||
let result = build_artcraft_seedance_2p0_u_fast(GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Seedance2p0UltraFast,
|
||||
provider: RouterProvider::Artcraft,
|
||||
start_frame: Some(ImageRef::Url("https://example.com".to_string())),
|
||||
..Default::default()
|
||||
});
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_image_tokens_passed_through() {
|
||||
let tokens = vec![
|
||||
MediaFileToken::new("mf_a".to_string()),
|
||||
MediaFileToken::new("mf_b".to_string()),
|
||||
];
|
||||
let req = unwrap_request(artcraft_fast_builder_with(|b| {
|
||||
b.reference_images = Some(ImageListRef::MediaFileTokens(tokens.clone()));
|
||||
}));
|
||||
assert_eq!(req.request.reference_image_media_tokens, Some(tokens));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_tokens_passed_through() {
|
||||
let tokens = vec![
|
||||
CharacterToken::new("char_a".to_string()),
|
||||
CharacterToken::new("char_b".to_string()),
|
||||
];
|
||||
let req = unwrap_request(artcraft_fast_builder_with(|b| {
|
||||
b.reference_character_tokens = Some(CharacterListRef::CharacterTokens(tokens.clone()));
|
||||
}));
|
||||
assert_eq!(req.request.reference_character_tokens, Some(tokens));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
fn artcraft_fast_builder_with(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder {
|
||||
let mut builder = GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Seedance2p0UltraFast,
|
||||
provider: RouterProvider::Artcraft,
|
||||
duration_seconds: Some(5),
|
||||
video_batch_count: Some(1),
|
||||
..Default::default()
|
||||
};
|
||||
f(&mut builder);
|
||||
builder
|
||||
}
|
||||
|
||||
fn unwrap_request(builder: GenerateVideoRequestBuilder) -> ArtcraftSeedance2p0UltraFastRequestState {
|
||||
let result = build_artcraft_seedance_2p0_u_fast(builder).expect("build should succeed");
|
||||
match result {
|
||||
VideoGenerationDraftOrRequest::Request(
|
||||
VideoGenerationRequest::ArtcraftSeedance2p0UltraFast(state)
|
||||
) => state,
|
||||
_ => panic!("expected ArtcraftSeedance2p0Fast request"),
|
||||
}
|
||||
}
|
||||
}
|
||||
-168
@@ -1,168 +0,0 @@
|
||||
use enums::common::generation::common_resolution::CommonResolution;
|
||||
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0_u_fast::request::ArtcraftSeedance2p0UltraFastRequestState;
|
||||
|
||||
// ── Pricing constants ──
|
||||
|
||||
const CENTS_PER_SECOND_480P: f64 = 3.6267;
|
||||
const CENTS_PER_SECOND_720P: f64 = 8.9089;
|
||||
|
||||
pub struct ArtcraftSeedance2p0UltraFastCostState {
|
||||
pub resolution: CommonResolution,
|
||||
pub duration_seconds: u16,
|
||||
pub batch_count: u16,
|
||||
}
|
||||
|
||||
impl ArtcraftSeedance2p0UltraFastCostState {
|
||||
pub fn from_request(request: &ArtcraftSeedance2p0UltraFastRequestState) -> Self {
|
||||
let resolution = request.request.resolution
|
||||
.unwrap_or(CommonResolution::SevenTwentyP);
|
||||
let duration_seconds = request.request.duration_seconds.unwrap_or(5);
|
||||
let batch_count = request.request.video_batch_count.unwrap_or(1);
|
||||
|
||||
Self { resolution, duration_seconds, batch_count }
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
|
||||
let cents_per_second = match self.resolution {
|
||||
CommonResolution::FourEightyP => CENTS_PER_SECOND_480P,
|
||||
// Fast model doesn't support 1080p; all non-480p resolves to 720p pricing
|
||||
_ => CENTS_PER_SECOND_720P,
|
||||
};
|
||||
|
||||
let cents_per_video = (cents_per_second * self.duration_seconds as f64).ceil() as u64;
|
||||
let usd_cents = cents_per_video * self.batch_count as u64;
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
|
||||
mod pricing_720p {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn batch_1() {
|
||||
// 8.9089 * 5 = 44.5445 → ceil = 45¢
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 1), 45);
|
||||
// 8.9089 * 10 = 89.089 → ceil = 90¢
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 10, 1), 90);
|
||||
// 8.9089 * 15 = 133.6335 → ceil = 134¢
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, 1), 134);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_2() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 10, 2), 180);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_4() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 10, 4), 360);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_defaults_to_720p() {
|
||||
assert_eq!(cost_cents(None, 10, 1), cost_cents(Some(RouterResolution::SevenTwentyP), 10, 1));
|
||||
}
|
||||
}
|
||||
|
||||
mod pricing_480p {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn batch_1() {
|
||||
// 3.6267 * 5 = 18.1335 → ceil = 19¢
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 1), 19);
|
||||
// 3.6267 * 10 = 36.267 → ceil = 37¢
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 10, 1), 37);
|
||||
// 3.6267 * 15 = 54.4005 → ceil = 55¢
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 15, 1), 55);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_4() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 10, 4), 148);
|
||||
}
|
||||
}
|
||||
|
||||
mod relative_pricing {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cost_480p_cheaper_than_720p() {
|
||||
let c480 = cost_cents(Some(RouterResolution::FourEightyP), 10, 1);
|
||||
let c720 = cost_cents(Some(RouterResolution::SevenTwentyP), 10, 1);
|
||||
assert!(c480 < c720, "480p ({c480}) should be < 720p ({c720})");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_scales_with_duration() {
|
||||
let c5 = cost_cents(None, 5, 1);
|
||||
let c10 = cost_cents(None, 10, 1);
|
||||
let c15 = cost_cents(None, 15, 1);
|
||||
assert!(c5 < c10);
|
||||
assert!(c10 < c15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fast_g_cheaper_than_standard_g() {
|
||||
let fast = cost_cents(Some(RouterResolution::SevenTwentyP), 10, 1);
|
||||
let standard = {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Seedance2p0Ultra,
|
||||
provider: RouterProvider::Artcraft,
|
||||
resolution: Some(RouterResolution::SevenTwentyP),
|
||||
duration_seconds: Some(10),
|
||||
video_batch_count: Some(1),
|
||||
..Default::default()
|
||||
};
|
||||
builder.build2().unwrap().estimate_cost().unwrap().cost_in_usd_cents.unwrap()
|
||||
};
|
||||
assert!(fast < standard, "Fast ({fast}¢) should be < Standard ({standard}¢)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credits_equal_usd_cents() {
|
||||
let cost = build_cost(None, 10, 1);
|
||||
assert_eq!(cost.cost_in_credits, cost.cost_in_usd_cents);
|
||||
}
|
||||
}
|
||||
|
||||
// -- Helpers --
|
||||
|
||||
fn build_cost(
|
||||
resolution: Option<RouterResolution>,
|
||||
duration_seconds: u16,
|
||||
video_batch_count: u16,
|
||||
) -> crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Seedance2p0UltraFast,
|
||||
provider: RouterProvider::Artcraft,
|
||||
resolution,
|
||||
duration_seconds: Some(duration_seconds),
|
||||
video_batch_count: Some(video_batch_count),
|
||||
..Default::default()
|
||||
};
|
||||
builder.build2().expect("build2").estimate_cost().expect("estimate_cost")
|
||||
}
|
||||
|
||||
fn cost_cents(resolution: Option<RouterResolution>, duration_seconds: u16, batch: u16) -> u64 {
|
||||
build_cost(resolution, duration_seconds, batch).cost_in_usd_cents.unwrap()
|
||||
}
|
||||
}
|
||||
-227
@@ -1,227 +0,0 @@
|
||||
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 ArtcraftSeedance2p0UltraFastRequestState {
|
||||
pub request: OmniGenVideoCostAndGenerateRequest,
|
||||
}
|
||||
|
||||
impl ArtcraftSeedance2p0UltraFastRequestState {
|
||||
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::characters::CharacterToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::api::character_list_ref::CharacterListRef;
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::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;
|
||||
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
|
||||
use artcraft_client::utils::api_host::ApiHost;
|
||||
|
||||
use test_data::web::character_tokens::{JIM, KNIGHT};
|
||||
use test_data::web::image_media_tokens::{
|
||||
FOREST_BACKDROP_PRODUCTION_MEDIA_TOKEN,
|
||||
JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN,
|
||||
WHITE_HOUSE_SUNSET_PRODUCTION_MEDIA_TOKEN,
|
||||
};
|
||||
|
||||
// -- Aspect ratio tests --
|
||||
|
||||
mod aspect_ratio_tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn landscape() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A corgi running through a field of wildflowers at sunset.".to_string()),
|
||||
aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine),
|
||||
..artcraft_fast_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn portrait() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A cat sitting on a windowsill watching rain.".to_string()),
|
||||
aspect_ratio: Some(RouterAspectRatio::TallNineBySixteen),
|
||||
..artcraft_fast_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn square() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A hummingbird hovering near a flower.".to_string()),
|
||||
aspect_ratio: Some(RouterAspectRatio::Square),
|
||||
..artcraft_fast_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
}
|
||||
|
||||
// -- Resolution tests --
|
||||
|
||||
mod resolution_tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn res_480p() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A shiba inu playing in autumn leaves.".to_string()),
|
||||
resolution: Some(RouterResolution::FourEightyP),
|
||||
..artcraft_fast_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn res_720p() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A golden retriever catching a frisbee on the beach.".to_string()),
|
||||
resolution: Some(RouterResolution::SevenTwentyP),
|
||||
..artcraft_fast_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
}
|
||||
|
||||
// -- Modality tests --
|
||||
|
||||
mod modality_tests {
|
||||
use super::*;
|
||||
use test_data::web::image_media_tokens::TALL_MOCHI_WITH_GLASSES_PRODUCTION_MEDIA_TOKEN;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn text_to_video() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A sky whale breaching in the open sky, pink clouds at dawn, cinematic.".to_string()),
|
||||
aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine),
|
||||
..artcraft_fast_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn keyframe_start_and_end_frame() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("The dog walks from the lake to the forest.".to_string()),
|
||||
start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new(JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN.to_string()))),
|
||||
end_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new(FOREST_BACKDROP_PRODUCTION_MEDIA_TOKEN.to_string()))),
|
||||
aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine),
|
||||
..artcraft_fast_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn image_references() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("The dog in @2 runs through the scenery in @1 towards the building in @3.".to_string()),
|
||||
reference_images: Some(ImageListRef::MediaFileTokens(vec![
|
||||
MediaFileToken::new(FOREST_BACKDROP_PRODUCTION_MEDIA_TOKEN.to_string()),
|
||||
MediaFileToken::new(TALL_MOCHI_WITH_GLASSES_PRODUCTION_MEDIA_TOKEN.to_string()),
|
||||
MediaFileToken::new(WHITE_HOUSE_SUNSET_PRODUCTION_MEDIA_TOKEN.to_string()),
|
||||
])),
|
||||
aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine),
|
||||
..artcraft_fast_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn character_references() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("@Jim and @Knight are sparring in a medieval arena.".to_string()),
|
||||
reference_character_tokens: Some(CharacterListRef::CharacterTokens(vec![
|
||||
CharacterToken::new(JIM.token.to_string()),
|
||||
CharacterToken::new(KNIGHT.token.to_string()),
|
||||
])),
|
||||
aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine),
|
||||
..artcraft_fast_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
}
|
||||
|
||||
// -- Helpers --
|
||||
|
||||
fn artcraft_fast_builder() -> GenerateVideoRequestBuilder {
|
||||
GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Seedance2p0UltraFast,
|
||||
provider: RouterProvider::Artcraft,
|
||||
duration_seconds: Some(4),
|
||||
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
|
||||
}
|
||||
}
|
||||
+244
@@ -0,0 +1,244 @@
|
||||
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::seedance_2p5::request::ArtcraftSeedance2p5RequestState;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
|
||||
|
||||
pub fn build_artcraft_seedance_2p5(mut builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
let strategy = builder.request_mismatch_mitigation_strategy;
|
||||
|
||||
// Parity with the Kinovi build: keyframes and reference media are mutually
|
||||
// exclusive, an end frame requires a start frame, and there is no
|
||||
// character support. Erroring here keeps the billing estimate consistent
|
||||
// with the execution build.
|
||||
let has_keyframes = builder.start_frame.is_some() || builder.end_frame.is_some();
|
||||
let has_references = builder.reference_images.is_some()
|
||||
|| builder.reference_videos.is_some()
|
||||
|| builder.reference_audio.is_some();
|
||||
|
||||
if has_keyframes && has_references {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "reference_images",
|
||||
value: "Seedance 2.5 cannot combine keyframes with reference media; send one or the other".to_string(),
|
||||
}));
|
||||
}
|
||||
if builder.end_frame.is_some() && builder.start_frame.is_none() {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "end_frame",
|
||||
value: "Seedance 2.5 requires a start frame when an end frame is set".to_string(),
|
||||
}));
|
||||
}
|
||||
if builder.reference_character_tokens.is_some() {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "reference_character_tokens",
|
||||
value: "Seedance 2.5 does not support character references".to_string(),
|
||||
}));
|
||||
}
|
||||
|
||||
// 2.5 allows 4-30 second durations (longer than the shared 4-15 range in
|
||||
// build_common) and generates a single video per request. Plan both here,
|
||||
// leaving None for the shared builder, then set them after.
|
||||
let duration_seconds = plan_duration(builder.duration_seconds.take(), strategy)?;
|
||||
plan_batch_count(builder.video_batch_count.take(), strategy)?;
|
||||
|
||||
let total_input_seconds = builder.total_reference_video_input_seconds.take();
|
||||
|
||||
let mut request = build_artcraft_omni_video_request(
|
||||
builder,
|
||||
CommonVideoModelEnum::Seedance2p5,
|
||||
SupportedResolutions::Fast,
|
||||
UltraWideSupport::Supported,
|
||||
)?;
|
||||
request.duration_seconds = duration_seconds;
|
||||
request.video_batch_count = Some(1);
|
||||
|
||||
let state = ArtcraftSeedance2p5RequestState { request, total_input_seconds };
|
||||
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p5(state)))
|
||||
}
|
||||
|
||||
// Seedance 2.5 supports durations of 4-30 seconds.
|
||||
fn plan_duration(
|
||||
duration_seconds: Option<u16>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Option<u16>, ArtcraftRouterError> {
|
||||
const MIN: u16 = 4;
|
||||
const MAX: u16 = 30;
|
||||
match duration_seconds {
|
||||
None => Ok(None),
|
||||
Some(d) if d >= MIN && d <= MAX => Ok(Some(d)),
|
||||
Some(d) => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "duration_seconds",
|
||||
value: format!("{}", d),
|
||||
}))
|
||||
}
|
||||
_ => Ok(Some(d.clamp(MIN, MAX))),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Seedance 2.5 generates a single video per request (no batching).
|
||||
fn plan_batch_count(
|
||||
video_batch_count: Option<u16>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<(), ArtcraftRouterError> {
|
||||
let count = video_batch_count.unwrap_or(1);
|
||||
match count {
|
||||
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
|
||||
1 => Ok(()),
|
||||
_ => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "video_batch_count",
|
||||
value: format!("{}", count),
|
||||
}))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade
|
||||
| RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
|
||||
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
|
||||
use tokens::tokens::characters::CharacterToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::api::character_list_ref::CharacterListRef;
|
||||
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::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
|
||||
|
||||
use super::*;
|
||||
|
||||
mod field_conversions {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn model_is_correct() {
|
||||
let req = unwrap_request(builder_with(|_| {}));
|
||||
assert!(matches!(req.request.model, Some(CommonVideoModelEnum::Seedance2p5)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_30_is_passed_through() {
|
||||
let req = unwrap_request(builder_with(|b| { b.duration_seconds = Some(30); }));
|
||||
assert_eq!(req.request.duration_seconds, Some(30));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_clamped_to_range() {
|
||||
let req = unwrap_request(builder_with(|b| { b.duration_seconds = Some(99); }));
|
||||
assert_eq!(req.request.duration_seconds, Some(30));
|
||||
|
||||
let req = unwrap_request(builder_with(|b| { b.duration_seconds = Some(2); }));
|
||||
assert_eq!(req.request.duration_seconds, Some(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_count_is_always_one() {
|
||||
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(4); }));
|
||||
assert_eq!(req.request.video_batch_count, Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn total_input_seconds_is_carried_through() {
|
||||
let req = unwrap_request(builder_with(|b| { b.total_reference_video_input_seconds = Some(14); }));
|
||||
assert_eq!(req.total_input_seconds, Some(14));
|
||||
}
|
||||
}
|
||||
|
||||
mod modality_rules {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn keyframes_are_accepted() {
|
||||
let req = unwrap_request(builder_with(|b| {
|
||||
b.start_frame = Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_start".to_string())));
|
||||
b.end_frame = Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_end".to_string())));
|
||||
}));
|
||||
assert!(req.request.start_frame_image_media_token.is_some());
|
||||
assert!(req.request.end_frame_image_media_token.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_frame_without_start_frame_errors() {
|
||||
let result = build_artcraft_seedance_2p5(builder_with(|b| {
|
||||
b.end_frame = Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_end".to_string())));
|
||||
}));
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyframes_plus_references_error() {
|
||||
let result = build_artcraft_seedance_2p5(builder_with(|b| {
|
||||
b.start_frame = Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_start".to_string())));
|
||||
b.reference_images = Some(ImageListRef::MediaFileTokens(vec![
|
||||
MediaFileToken::new("mf_ref".to_string()),
|
||||
]));
|
||||
}));
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_references_error() {
|
||||
let result = build_artcraft_seedance_2p5(builder_with(|b| {
|
||||
b.reference_character_tokens = Some(CharacterListRef::CharacterTokens(vec![
|
||||
CharacterToken::new("char_abc".to_string()),
|
||||
]));
|
||||
}));
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
mod resolution_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn res_480p() {
|
||||
let req = unwrap_request(builder_with(|b| { b.resolution = Some(RouterResolution::FourEightyP); }));
|
||||
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::FourEightyP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn res_1080p_downgrades_to_720p() {
|
||||
let req = unwrap_request(builder_with(|b| { b.resolution = Some(RouterResolution::TenEightyP); }));
|
||||
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP));
|
||||
}
|
||||
}
|
||||
|
||||
fn builder_with(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder {
|
||||
let mut builder = GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Seedance2p5,
|
||||
provider: RouterProvider::Artcraft,
|
||||
duration_seconds: Some(5),
|
||||
video_batch_count: Some(1),
|
||||
..Default::default()
|
||||
};
|
||||
f(&mut builder);
|
||||
builder
|
||||
}
|
||||
|
||||
fn unwrap_request(builder: GenerateVideoRequestBuilder) -> ArtcraftSeedance2p5RequestState {
|
||||
let result = build_artcraft_seedance_2p5(builder).expect("build should succeed");
|
||||
match result {
|
||||
VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p5(state)) => state,
|
||||
_ => panic!("expected ArtcraftSeedance2p5 request"),
|
||||
}
|
||||
}
|
||||
}
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
use enums::common::generation::common_resolution::CommonResolution;
|
||||
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_common::seedance_2p5_usd_cents;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p5::request::ArtcraftSeedance2p5RequestState;
|
||||
|
||||
/// Seedance 2.5 pricing depends on the resolution, the output duration, and
|
||||
/// — when reference videos are attached — the total seconds of reference
|
||||
/// video input, which are billed on top of the output duration (at a lower
|
||||
/// per-second rate). The TOTAL input duration clamps to the 4..=30 second
|
||||
/// billing range. No batching.
|
||||
pub struct ArtcraftSeedance2p5CostState {
|
||||
pub resolution: CommonResolution,
|
||||
pub duration_seconds: u16,
|
||||
pub has_video_references: bool,
|
||||
pub total_input_seconds: u16,
|
||||
}
|
||||
|
||||
impl ArtcraftSeedance2p5CostState {
|
||||
pub fn from_request(request: &ArtcraftSeedance2p5RequestState) -> Self {
|
||||
let resolution = request.request.resolution
|
||||
.unwrap_or(CommonResolution::SevenTwentyP);
|
||||
let duration_seconds = request.request.duration_seconds.unwrap_or(5);
|
||||
|
||||
let has_video_references = request.request.reference_video_media_tokens
|
||||
.as_ref()
|
||||
.is_some_and(|tokens| !tokens.is_empty());
|
||||
|
||||
Self {
|
||||
resolution,
|
||||
duration_seconds,
|
||||
has_video_references,
|
||||
total_input_seconds: request.total_input_seconds.unwrap_or(0),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
|
||||
// Input-second clamping (the 4..=30 billing range) happens inside the
|
||||
// shared pricing function.
|
||||
let total_input_seconds = self.total_input_seconds;
|
||||
|
||||
let usd_cents = seedance_2p5_usd_cents(
|
||||
self.resolution,
|
||||
self.duration_seconds,
|
||||
self.has_video_references,
|
||||
total_input_seconds,
|
||||
);
|
||||
|
||||
// ArtCraft credits: 100 credits = $1.00, so credits = cents.
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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::api::video_list_ref::VideoListRef;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
mod pricing_without_video_references {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn table_480p() {
|
||||
// 11.76954733 ¢/s, rounded up.
|
||||
assert_eq!(cents(Some(RouterResolution::FourEightyP), 4), 48);
|
||||
assert_eq!(cents(Some(RouterResolution::FourEightyP), 5), 59);
|
||||
assert_eq!(cents(Some(RouterResolution::FourEightyP), 10), 118);
|
||||
assert_eq!(cents(Some(RouterResolution::FourEightyP), 30), 354);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_720p() {
|
||||
// 26.70781893 ¢/s, rounded up.
|
||||
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 4), 107);
|
||||
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 5), 134);
|
||||
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 10), 268);
|
||||
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 30), 802);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_defaults_to_720p() {
|
||||
assert_eq!(cents(None, 10), cents(Some(RouterResolution::SevenTwentyP), 10));
|
||||
}
|
||||
}
|
||||
|
||||
mod pricing_with_video_references {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn thirty_second_output_with_ten_input_seconds_bills_forty() {
|
||||
// 7.24279835 ¢/s × 40 = 289.71 → 290¢.
|
||||
assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 30, Some(10)), 290);
|
||||
// 15.84362140 ¢/s × 40 = 633.74 → 634¢.
|
||||
assert_eq!(cents_with_video_refs(Some(RouterResolution::SevenTwentyP), 30, Some(10)), 634);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fourteen_input_seconds_bill_forty_four() {
|
||||
// 7.24279835 ¢/s × 44 = 318.68 → 319¢.
|
||||
assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 30, Some(14)), 319);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_input_seconds_bill_the_four_second_minimum() {
|
||||
// No measured input still bills the 4-second total minimum:
|
||||
// 7.24279835 ¢/s × (10+4) = 101.40 → 102¢.
|
||||
assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 10, None), 102);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_totals_under_four_seconds_clamp_to_four() {
|
||||
// 1s, 3s, and 4s totals price identically; 5s bills more.
|
||||
let at_minimum = cents_with_video_refs(Some(RouterResolution::FourEightyP), 10, Some(4));
|
||||
assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 10, Some(1)), at_minimum);
|
||||
assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 10, Some(3)), at_minimum);
|
||||
assert!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 10, Some(5)) > at_minimum);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_seconds_clamp_to_max_billed_input_seconds() {
|
||||
// 200 input seconds clamp to 30: 7.24279835 ¢/s × (30+30) = 434.57 → 435¢.
|
||||
assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 30, Some(200)), 435);
|
||||
assert_eq!(
|
||||
cents_with_video_refs(Some(RouterResolution::FourEightyP), 30, Some(200)),
|
||||
cents_with_video_refs(Some(RouterResolution::FourEightyP), 30, Some(30)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
mod credits_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn credits_equal_usd_cents() {
|
||||
for dur in [4u16, 10, 30] {
|
||||
let estimate = estimate_with(|b| {
|
||||
b.resolution = Some(RouterResolution::FourEightyP);
|
||||
b.duration_seconds = Some(dur);
|
||||
});
|
||||
assert_eq!(estimate.cost_in_credits, estimate.cost_in_usd_cents);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
fn estimate_with(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> VideoGenerationCostEstimate {
|
||||
let mut builder = GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Seedance2p5,
|
||||
provider: RouterProvider::Artcraft,
|
||||
duration_seconds: Some(5),
|
||||
video_batch_count: Some(1),
|
||||
..Default::default()
|
||||
};
|
||||
f(&mut builder);
|
||||
builder.build2()
|
||||
.expect("build should succeed")
|
||||
.estimate_cost()
|
||||
.expect("estimate should succeed")
|
||||
}
|
||||
|
||||
fn cents(resolution: Option<RouterResolution>, duration_seconds: u16) -> u64 {
|
||||
estimate_with(|b| {
|
||||
b.resolution = resolution;
|
||||
b.duration_seconds = Some(duration_seconds);
|
||||
}).cost_in_usd_cents.unwrap()
|
||||
}
|
||||
|
||||
fn cents_with_video_refs(
|
||||
resolution: Option<RouterResolution>,
|
||||
duration_seconds: u16,
|
||||
total_input_seconds: Option<u16>,
|
||||
) -> u64 {
|
||||
estimate_with(|b| {
|
||||
b.resolution = resolution;
|
||||
b.duration_seconds = Some(duration_seconds);
|
||||
b.reference_videos = Some(VideoListRef::MediaFileTokens(vec![
|
||||
MediaFileToken::new("mf_ref".to_string()),
|
||||
]));
|
||||
b.total_reference_video_input_seconds = total_input_seconds;
|
||||
}).cost_in_usd_cents.unwrap()
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
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 ArtcraftSeedance2p5RequestState {
|
||||
pub request: OmniGenVideoCostAndGenerateRequest,
|
||||
|
||||
/// Calculation-only: total seconds of reference video input, billed on top
|
||||
/// of the output duration when reference videos are attached. Not part of
|
||||
/// the omni request wire shape.
|
||||
pub total_input_seconds: Option<u16>,
|
||||
}
|
||||
|
||||
impl ArtcraftSeedance2p5RequestState {
|
||||
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
|
||||
send_artcraft_omni_video_request(&self.request, client).await
|
||||
}
|
||||
}
|
||||
+244
@@ -0,0 +1,244 @@
|
||||
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::seedance_2p5_u::request::ArtcraftSeedance2p5UltraRequestState;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
|
||||
|
||||
pub fn build_artcraft_seedance_2p5_u(mut builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
let strategy = builder.request_mismatch_mitigation_strategy;
|
||||
|
||||
// Parity with the Kinovi build: keyframes and reference media are mutually
|
||||
// exclusive, an end frame requires a start frame, and there is no
|
||||
// character support. Erroring here keeps the billing estimate consistent
|
||||
// with the execution build.
|
||||
let has_keyframes = builder.start_frame.is_some() || builder.end_frame.is_some();
|
||||
let has_references = builder.reference_images.is_some()
|
||||
|| builder.reference_videos.is_some()
|
||||
|| builder.reference_audio.is_some();
|
||||
|
||||
if has_keyframes && has_references {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "reference_images",
|
||||
value: "Seedance 2.5 Ultra cannot combine keyframes with reference media; send one or the other".to_string(),
|
||||
}));
|
||||
}
|
||||
if builder.end_frame.is_some() && builder.start_frame.is_none() {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "end_frame",
|
||||
value: "Seedance 2.5 Ultra requires a start frame when an end frame is set".to_string(),
|
||||
}));
|
||||
}
|
||||
if builder.reference_character_tokens.is_some() {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "reference_character_tokens",
|
||||
value: "Seedance 2.5 Ultra does not support character references".to_string(),
|
||||
}));
|
||||
}
|
||||
|
||||
// 2.5 allows 4-30 second durations (longer than the shared 4-15 range in
|
||||
// build_common) and generates a single video per request. Plan both here,
|
||||
// leaving None for the shared builder, then set them after.
|
||||
let duration_seconds = plan_duration(builder.duration_seconds.take(), strategy)?;
|
||||
plan_batch_count(builder.video_batch_count.take(), strategy)?;
|
||||
|
||||
let total_input_seconds = builder.total_reference_video_input_seconds.take();
|
||||
|
||||
let mut request = build_artcraft_omni_video_request(
|
||||
builder,
|
||||
CommonVideoModelEnum::Seedance2p5Ultra,
|
||||
SupportedResolutions::Fast,
|
||||
UltraWideSupport::Supported,
|
||||
)?;
|
||||
request.duration_seconds = duration_seconds;
|
||||
request.video_batch_count = Some(1);
|
||||
|
||||
let state = ArtcraftSeedance2p5UltraRequestState { request, total_input_seconds };
|
||||
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p5Ultra(state)))
|
||||
}
|
||||
|
||||
// Seedance 2.5 supports durations of 4-30 seconds.
|
||||
fn plan_duration(
|
||||
duration_seconds: Option<u16>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Option<u16>, ArtcraftRouterError> {
|
||||
const MIN: u16 = 4;
|
||||
const MAX: u16 = 30;
|
||||
match duration_seconds {
|
||||
None => Ok(None),
|
||||
Some(d) if d >= MIN && d <= MAX => Ok(Some(d)),
|
||||
Some(d) => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "duration_seconds",
|
||||
value: format!("{}", d),
|
||||
}))
|
||||
}
|
||||
_ => Ok(Some(d.clamp(MIN, MAX))),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Seedance 2.5 generates a single video per request (no batching).
|
||||
fn plan_batch_count(
|
||||
video_batch_count: Option<u16>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<(), ArtcraftRouterError> {
|
||||
let count = video_batch_count.unwrap_or(1);
|
||||
match count {
|
||||
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
|
||||
1 => Ok(()),
|
||||
_ => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "video_batch_count",
|
||||
value: format!("{}", count),
|
||||
}))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade
|
||||
| RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
|
||||
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
|
||||
use tokens::tokens::characters::CharacterToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::api::character_list_ref::CharacterListRef;
|
||||
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::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
|
||||
|
||||
use super::*;
|
||||
|
||||
mod field_conversions {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn model_is_correct() {
|
||||
let req = unwrap_request(builder_with(|_| {}));
|
||||
assert!(matches!(req.request.model, Some(CommonVideoModelEnum::Seedance2p5Ultra)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_30_is_passed_through() {
|
||||
let req = unwrap_request(builder_with(|b| { b.duration_seconds = Some(30); }));
|
||||
assert_eq!(req.request.duration_seconds, Some(30));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_clamped_to_range() {
|
||||
let req = unwrap_request(builder_with(|b| { b.duration_seconds = Some(99); }));
|
||||
assert_eq!(req.request.duration_seconds, Some(30));
|
||||
|
||||
let req = unwrap_request(builder_with(|b| { b.duration_seconds = Some(2); }));
|
||||
assert_eq!(req.request.duration_seconds, Some(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_count_is_always_one() {
|
||||
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(4); }));
|
||||
assert_eq!(req.request.video_batch_count, Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn total_input_seconds_is_carried_through() {
|
||||
let req = unwrap_request(builder_with(|b| { b.total_reference_video_input_seconds = Some(14); }));
|
||||
assert_eq!(req.total_input_seconds, Some(14));
|
||||
}
|
||||
}
|
||||
|
||||
mod modality_rules {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn keyframes_are_accepted() {
|
||||
let req = unwrap_request(builder_with(|b| {
|
||||
b.start_frame = Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_start".to_string())));
|
||||
b.end_frame = Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_end".to_string())));
|
||||
}));
|
||||
assert!(req.request.start_frame_image_media_token.is_some());
|
||||
assert!(req.request.end_frame_image_media_token.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_frame_without_start_frame_errors() {
|
||||
let result = build_artcraft_seedance_2p5_u(builder_with(|b| {
|
||||
b.end_frame = Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_end".to_string())));
|
||||
}));
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyframes_plus_references_error() {
|
||||
let result = build_artcraft_seedance_2p5_u(builder_with(|b| {
|
||||
b.start_frame = Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_start".to_string())));
|
||||
b.reference_images = Some(ImageListRef::MediaFileTokens(vec![
|
||||
MediaFileToken::new("mf_ref".to_string()),
|
||||
]));
|
||||
}));
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_references_error() {
|
||||
let result = build_artcraft_seedance_2p5_u(builder_with(|b| {
|
||||
b.reference_character_tokens = Some(CharacterListRef::CharacterTokens(vec![
|
||||
CharacterToken::new("char_abc".to_string()),
|
||||
]));
|
||||
}));
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
mod resolution_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn res_480p() {
|
||||
let req = unwrap_request(builder_with(|b| { b.resolution = Some(RouterResolution::FourEightyP); }));
|
||||
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::FourEightyP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn res_1080p_downgrades_to_720p() {
|
||||
let req = unwrap_request(builder_with(|b| { b.resolution = Some(RouterResolution::TenEightyP); }));
|
||||
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP));
|
||||
}
|
||||
}
|
||||
|
||||
fn builder_with(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder {
|
||||
let mut builder = GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Seedance2p5Ultra,
|
||||
provider: RouterProvider::Artcraft,
|
||||
duration_seconds: Some(5),
|
||||
video_batch_count: Some(1),
|
||||
..Default::default()
|
||||
};
|
||||
f(&mut builder);
|
||||
builder
|
||||
}
|
||||
|
||||
fn unwrap_request(builder: GenerateVideoRequestBuilder) -> ArtcraftSeedance2p5UltraRequestState {
|
||||
let result = build_artcraft_seedance_2p5_u(builder).expect("build should succeed");
|
||||
match result {
|
||||
VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p5Ultra(state)) => state,
|
||||
_ => panic!("expected ArtcraftSeedance2p5Ultra request"),
|
||||
}
|
||||
}
|
||||
}
|
||||
+221
@@ -0,0 +1,221 @@
|
||||
use enums::common::generation::common_resolution::CommonResolution;
|
||||
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_common::seedance_2p5_ultra_usd_cents;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p5_u::request::ArtcraftSeedance2p5UltraRequestState;
|
||||
|
||||
/// Seedance 2.5 Ultra pricing depends on the resolution, the output
|
||||
/// duration, and — when reference videos are attached — the total seconds of
|
||||
/// reference video input, which are billed on top of the output duration (at
|
||||
/// a lower per-second rate). Input seconds are clamped to
|
||||
/// [`MAX_BILLED_INPUT_SECONDS`] — the model accepts at most 30 seconds of
|
||||
/// video. No batching.
|
||||
pub struct ArtcraftSeedance2p5UltraCostState {
|
||||
pub resolution: CommonResolution,
|
||||
pub duration_seconds: u16,
|
||||
pub has_video_references: bool,
|
||||
pub total_input_seconds: u16,
|
||||
}
|
||||
|
||||
impl ArtcraftSeedance2p5UltraCostState {
|
||||
pub fn from_request(request: &ArtcraftSeedance2p5UltraRequestState) -> Self {
|
||||
let resolution = request.request.resolution
|
||||
.unwrap_or(CommonResolution::SevenTwentyP);
|
||||
let duration_seconds = request.request.duration_seconds.unwrap_or(5);
|
||||
|
||||
let has_video_references = request.request.reference_video_media_tokens
|
||||
.as_ref()
|
||||
.is_some_and(|tokens| !tokens.is_empty());
|
||||
|
||||
Self {
|
||||
resolution,
|
||||
duration_seconds,
|
||||
has_video_references,
|
||||
total_input_seconds: request.total_input_seconds.unwrap_or(0),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
|
||||
// Input-second clamping (the 4..=30 billing range) happens inside the
|
||||
// shared pricing function.
|
||||
let total_input_seconds = self.total_input_seconds;
|
||||
|
||||
let usd_cents = seedance_2p5_ultra_usd_cents(
|
||||
self.resolution,
|
||||
self.duration_seconds,
|
||||
self.has_video_references,
|
||||
total_input_seconds,
|
||||
);
|
||||
|
||||
// ArtCraft credits: 100 credits = $1.00, so credits = cents.
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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::api::video_list_ref::VideoListRef;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
mod pricing_without_video_references {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn table_480p() {
|
||||
// 13.90946502 ¢/s, rounded up.
|
||||
assert_eq!(cents(Some(RouterResolution::FourEightyP), 4), 56);
|
||||
assert_eq!(cents(Some(RouterResolution::FourEightyP), 5), 70);
|
||||
assert_eq!(cents(Some(RouterResolution::FourEightyP), 10), 140);
|
||||
assert_eq!(cents(Some(RouterResolution::FourEightyP), 30), 418);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_720p() {
|
||||
// 31.56378601 ¢/s, rounded up.
|
||||
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 4), 127);
|
||||
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 5), 158);
|
||||
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 10), 316);
|
||||
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 30), 947);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_defaults_to_720p() {
|
||||
assert_eq!(cents(None, 10), cents(Some(RouterResolution::SevenTwentyP), 10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ultra_prices_above_regular_seedance_2p5() {
|
||||
for (resolution, duration) in [
|
||||
(RouterResolution::FourEightyP, 5u16),
|
||||
(RouterResolution::FourEightyP, 30),
|
||||
(RouterResolution::SevenTwentyP, 5),
|
||||
(RouterResolution::SevenTwentyP, 30),
|
||||
] {
|
||||
let ultra = cents(Some(resolution), duration);
|
||||
let regular = estimate_for_model(RouterVideoModel::Seedance2p5, |b| {
|
||||
b.resolution = Some(resolution);
|
||||
b.duration_seconds = Some(duration);
|
||||
}).cost_in_usd_cents.unwrap();
|
||||
assert!(ultra > regular, "{resolution:?} {duration}s: ultra {ultra} <= regular {regular}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod pricing_with_video_references {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn thirty_second_output_with_ten_input_seconds_bills_forty() {
|
||||
// 8.55967078 ¢/s × 40 = 342.39 → 343¢.
|
||||
assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 30, Some(10)), 343);
|
||||
// 18.72427984 ¢/s × 40 = 748.97 → 749¢.
|
||||
assert_eq!(cents_with_video_refs(Some(RouterResolution::SevenTwentyP), 30, Some(10)), 749);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fourteen_input_seconds_bill_forty_four() {
|
||||
// 8.55967078 ¢/s × 44 = 376.63 → 377¢.
|
||||
assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 30, Some(14)), 377);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_input_seconds_bill_the_four_second_minimum() {
|
||||
// No measured input still bills the 4-second total minimum:
|
||||
// 8.55967078 ¢/s × (10+4) = 119.84 → 120¢.
|
||||
assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 10, None), 120);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_totals_under_four_seconds_clamp_to_four() {
|
||||
let at_minimum = cents_with_video_refs(Some(RouterResolution::FourEightyP), 10, Some(4));
|
||||
assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 10, Some(1)), at_minimum);
|
||||
assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 10, Some(3)), at_minimum);
|
||||
assert!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 10, Some(5)) > at_minimum);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_seconds_clamp_to_max_billed_input_seconds() {
|
||||
// 200 input seconds clamp to 30: 8.55967078 ¢/s × (30+30) = 513.58 → 514¢.
|
||||
assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 30, Some(200)), 514);
|
||||
assert_eq!(
|
||||
cents_with_video_refs(Some(RouterResolution::FourEightyP), 30, Some(200)),
|
||||
cents_with_video_refs(Some(RouterResolution::FourEightyP), 30, Some(30)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
mod credits_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn credits_equal_usd_cents() {
|
||||
for dur in [4u16, 10, 30] {
|
||||
let estimate = estimate_with(|b| {
|
||||
b.resolution = Some(RouterResolution::FourEightyP);
|
||||
b.duration_seconds = Some(dur);
|
||||
});
|
||||
assert_eq!(estimate.cost_in_credits, estimate.cost_in_usd_cents);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
fn estimate_with(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> VideoGenerationCostEstimate {
|
||||
estimate_for_model(RouterVideoModel::Seedance2p5Ultra, f)
|
||||
}
|
||||
|
||||
fn estimate_for_model(
|
||||
model: RouterVideoModel,
|
||||
f: impl FnOnce(&mut GenerateVideoRequestBuilder),
|
||||
) -> VideoGenerationCostEstimate {
|
||||
let mut builder = GenerateVideoRequestBuilder {
|
||||
model,
|
||||
provider: RouterProvider::Artcraft,
|
||||
duration_seconds: Some(5),
|
||||
video_batch_count: Some(1),
|
||||
..Default::default()
|
||||
};
|
||||
f(&mut builder);
|
||||
builder.build2()
|
||||
.expect("build should succeed")
|
||||
.estimate_cost()
|
||||
.expect("estimate should succeed")
|
||||
}
|
||||
|
||||
fn cents(resolution: Option<RouterResolution>, duration_seconds: u16) -> u64 {
|
||||
estimate_with(|b| {
|
||||
b.resolution = resolution;
|
||||
b.duration_seconds = Some(duration_seconds);
|
||||
}).cost_in_usd_cents.unwrap()
|
||||
}
|
||||
|
||||
fn cents_with_video_refs(
|
||||
resolution: Option<RouterResolution>,
|
||||
duration_seconds: u16,
|
||||
total_input_seconds: Option<u16>,
|
||||
) -> u64 {
|
||||
estimate_with(|b| {
|
||||
b.resolution = resolution;
|
||||
b.duration_seconds = Some(duration_seconds);
|
||||
b.reference_videos = Some(VideoListRef::MediaFileTokens(vec![
|
||||
MediaFileToken::new("mf_ref".to_string()),
|
||||
]));
|
||||
b.total_reference_video_input_seconds = total_input_seconds;
|
||||
}).cost_in_usd_cents.unwrap()
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod build;
|
||||
pub mod cost;
|
||||
pub mod request;
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
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 ArtcraftSeedance2p5UltraRequestState {
|
||||
pub request: OmniGenVideoCostAndGenerateRequest,
|
||||
|
||||
/// Calculation-only: total seconds of reference video input, billed on top
|
||||
/// of the output duration when reference videos are attached. Not part of
|
||||
/// the omni request wire shape.
|
||||
pub total_input_seconds: Option<u16>,
|
||||
}
|
||||
|
||||
impl ArtcraftSeedance2p5UltraRequestState {
|
||||
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
|
||||
send_artcraft_omni_video_request(&self.request, client).await
|
||||
}
|
||||
}
|
||||
+92
@@ -3,6 +3,9 @@
|
||||
//! ArtCraft credits equal USD cents (100 credits = $1.00).
|
||||
|
||||
use enums::common::generation::common_resolution::CommonResolution;
|
||||
use kinovi_web_client::generate::video::generate_seedance_2p5::{
|
||||
MAX_BILLED_INPUT_SECONDS, MIN_BILLED_INPUT_SECONDS,
|
||||
};
|
||||
|
||||
// 4K is priced uniformly across the non-Fast Seedance 2.0 models. Rates are held
|
||||
// in hundredths of a USD cent per second so the math is exact integer arithmetic
|
||||
@@ -141,6 +144,95 @@ pub fn seedance_2p5_preview_usd_cents(
|
||||
(cents_per_second * duration_seconds as f64).ceil() as u64
|
||||
}
|
||||
|
||||
/// Seedance 2.5 — 480p price, USD cents per second.
|
||||
const SEEDANCE_2P5_CENTS_PER_SECOND_480P: f64 = 11.76954733;
|
||||
/// Seedance 2.5 — 720p price, USD cents per second.
|
||||
const SEEDANCE_2P5_CENTS_PER_SECOND_720P: f64 = 26.70781893;
|
||||
/// Seedance 2.5 — 480p price with video references, USD cents per second.
|
||||
const SEEDANCE_2P5_VIDEO_REFERENCE_CENTS_PER_SECOND_480P: f64 = 7.24279835;
|
||||
/// Seedance 2.5 — 720p price with video references, USD cents per second.
|
||||
const SEEDANCE_2P5_VIDEO_REFERENCE_CENTS_PER_SECOND_720P: f64 = 15.84362140;
|
||||
|
||||
/// ArtCraft's price (USD cents) for Seedance 2.5.
|
||||
///
|
||||
/// Only 480p and 720p are offered; any other resolution prices at 720p.
|
||||
/// Without video references, billed seconds = output duration. With video
|
||||
/// references, the per-second rate drops but the billed seconds are the
|
||||
/// output duration PLUS the total seconds of reference video input (clamped
|
||||
/// to the 4..=30 second billing range). The fractional total is rounded UP
|
||||
/// to a whole cent. No batching.
|
||||
pub fn seedance_2p5_usd_cents(
|
||||
resolution: CommonResolution,
|
||||
duration_seconds: u16,
|
||||
has_video_references: bool,
|
||||
total_input_seconds: u16,
|
||||
) -> u64 {
|
||||
let (cents_per_second, billed_seconds) = if has_video_references {
|
||||
let rate = match resolution {
|
||||
CommonResolution::FourEightyP => SEEDANCE_2P5_VIDEO_REFERENCE_CENTS_PER_SECOND_480P,
|
||||
// Everything else (including 720p and unsupported resolutions) prices at 720p.
|
||||
_ => SEEDANCE_2P5_VIDEO_REFERENCE_CENTS_PER_SECOND_720P,
|
||||
};
|
||||
// The TOTAL input duration clamps to the 4..=30 second billing range
|
||||
// (three 1s videos sum to 3 and bill 4; three 3s videos bill 9).
|
||||
let billed_input_seconds = total_input_seconds
|
||||
.clamp(u16::from(MIN_BILLED_INPUT_SECONDS), u16::from(MAX_BILLED_INPUT_SECONDS));
|
||||
(rate, u64::from(duration_seconds) + u64::from(billed_input_seconds))
|
||||
} else {
|
||||
let rate = match resolution {
|
||||
CommonResolution::FourEightyP => SEEDANCE_2P5_CENTS_PER_SECOND_480P,
|
||||
_ => SEEDANCE_2P5_CENTS_PER_SECOND_720P,
|
||||
};
|
||||
(rate, u64::from(duration_seconds))
|
||||
};
|
||||
|
||||
(cents_per_second * billed_seconds as f64).ceil() as u64
|
||||
}
|
||||
|
||||
/// Seedance 2.5 Ultra — 480p price, USD cents per second.
|
||||
const SEEDANCE_2P5_ULTRA_CENTS_PER_SECOND_480P: f64 = 13.90946502;
|
||||
/// Seedance 2.5 Ultra — 720p price, USD cents per second.
|
||||
const SEEDANCE_2P5_ULTRA_CENTS_PER_SECOND_720P: f64 = 31.56378601;
|
||||
/// Seedance 2.5 Ultra — 480p price with video references, USD cents per second.
|
||||
const SEEDANCE_2P5_ULTRA_VIDEO_REFERENCE_CENTS_PER_SECOND_480P: f64 = 8.55967078;
|
||||
/// Seedance 2.5 Ultra — 720p price with video references, USD cents per second.
|
||||
const SEEDANCE_2P5_ULTRA_VIDEO_REFERENCE_CENTS_PER_SECOND_720P: f64 = 18.72427984;
|
||||
|
||||
/// ArtCraft's price (USD cents) for Seedance 2.5 Ultra.
|
||||
///
|
||||
/// Only 480p and 720p are offered; any other resolution prices at 720p.
|
||||
/// Without video references, billed seconds = output duration. With video
|
||||
/// references, the per-second rate drops but the billed seconds are the
|
||||
/// output duration PLUS the total seconds of reference video input. The
|
||||
/// fractional total is rounded UP to a whole cent. No batching.
|
||||
pub fn seedance_2p5_ultra_usd_cents(
|
||||
resolution: CommonResolution,
|
||||
duration_seconds: u16,
|
||||
has_video_references: bool,
|
||||
total_input_seconds: u16,
|
||||
) -> u64 {
|
||||
let (cents_per_second, billed_seconds) = if has_video_references {
|
||||
let rate = match resolution {
|
||||
CommonResolution::FourEightyP => SEEDANCE_2P5_ULTRA_VIDEO_REFERENCE_CENTS_PER_SECOND_480P,
|
||||
// Everything else (including 720p and unsupported resolutions) prices at 720p.
|
||||
_ => SEEDANCE_2P5_ULTRA_VIDEO_REFERENCE_CENTS_PER_SECOND_720P,
|
||||
};
|
||||
// The TOTAL input duration clamps to the 4..=30 second billing range
|
||||
// (three 1s videos sum to 3 and bill 4; three 3s videos bill 9).
|
||||
let billed_input_seconds = total_input_seconds
|
||||
.clamp(u16::from(MIN_BILLED_INPUT_SECONDS), u16::from(MAX_BILLED_INPUT_SECONDS));
|
||||
(rate, u64::from(duration_seconds) + u64::from(billed_input_seconds))
|
||||
} else {
|
||||
let rate = match resolution {
|
||||
CommonResolution::FourEightyP => SEEDANCE_2P5_ULTRA_CENTS_PER_SECOND_480P,
|
||||
_ => SEEDANCE_2P5_ULTRA_CENTS_PER_SECOND_720P,
|
||||
};
|
||||
(rate, u64::from(duration_seconds))
|
||||
};
|
||||
|
||||
(cents_per_second * billed_seconds as f64).ceil() as u64
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn mini_usd_cents(
|
||||
resolution: CommonResolution,
|
||||
|
||||
+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;
|
||||
|
||||
-2
@@ -1,2 +0,0 @@
|
||||
pub mod seedance_2p0_g;
|
||||
pub mod seedance_2p0_fast_g;
|
||||
-323
@@ -1,323 +0,0 @@
|
||||
use gmicloud_client::requests::api::video::seedance_2_0_fast_260128::api::{
|
||||
Seedance20FastRatio, Seedance20FastRequest, Seedance20FastResolution,
|
||||
};
|
||||
|
||||
use crate::api::audio_list_ref::AudioListRef;
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::video_list_ref::VideoListRef;
|
||||
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::client_error::ClientError;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::providers::gmicloud::seedance_2p0_fast_g::request::GmiCloudSeedance2p0UltraFastRequestState;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
|
||||
|
||||
pub fn build_gmicloud_seedance_2p0_u_fast(
|
||||
mut builder: GenerateVideoRequestBuilder,
|
||||
) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
let strategy = builder.request_mismatch_mitigation_strategy;
|
||||
|
||||
let ratio = plan_ratio(builder.aspect_ratio.take(), strategy)?;
|
||||
let resolution = plan_resolution(builder.resolution.take(), strategy)?;
|
||||
let duration = builder.duration_seconds.take().map(|d| (d as u8).clamp(4, 15));
|
||||
let prompt = builder.prompt.take().unwrap_or_default();
|
||||
|
||||
let first_frame = resolve_url(builder.start_frame.take())?;
|
||||
let last_frame = resolve_url(builder.end_frame.take())?;
|
||||
let reference_images = resolve_url_list_from_images(builder.reference_images.take())?;
|
||||
let reference_videos = resolve_url_list_from_videos(builder.reference_videos.take())?;
|
||||
let reference_audios = resolve_url_list_from_audios(builder.reference_audio.take())?;
|
||||
|
||||
let request = Seedance20FastRequest {
|
||||
prompt,
|
||||
duration,
|
||||
resolution,
|
||||
ratio,
|
||||
seed: None,
|
||||
watermark: Some(false),
|
||||
generate_audio: Some(true),
|
||||
web_search: None,
|
||||
first_frame,
|
||||
last_frame,
|
||||
reference_images,
|
||||
reference_videos,
|
||||
reference_audios,
|
||||
reference_asset_ids: None,
|
||||
};
|
||||
|
||||
let state = GmiCloudSeedance2p0UltraFastRequestState { request };
|
||||
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::GmiCloudSeedance2p0UltraFast(state)))
|
||||
}
|
||||
|
||||
fn plan_ratio(
|
||||
aspect_ratio: Option<RouterAspectRatio>,
|
||||
_strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Option<Seedance20FastRatio>, ArtcraftRouterError> {
|
||||
match aspect_ratio {
|
||||
None | Some(RouterAspectRatio::Auto) | Some(RouterAspectRatio::Auto2k)
|
||||
| Some(RouterAspectRatio::Auto3k) | Some(RouterAspectRatio::Auto4k) => Ok(None),
|
||||
Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Some(Seedance20FastRatio::Landscape16x9)),
|
||||
Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Some(Seedance20FastRatio::Portrait9x16)),
|
||||
Some(RouterAspectRatio::Square) | Some(RouterAspectRatio::SquareHd) => Ok(Some(Seedance20FastRatio::Square)),
|
||||
Some(RouterAspectRatio::WideFourByThree) => Ok(Some(Seedance20FastRatio::Standard4x3)),
|
||||
Some(RouterAspectRatio::TallThreeByFour) => Ok(Some(Seedance20FastRatio::Portrait3x4)),
|
||||
Some(RouterAspectRatio::WideTwentyOneByNine) | Some(RouterAspectRatio::TallNineByTwentyOne) => {
|
||||
Ok(Some(Seedance20FastRatio::UltraWide21x9))
|
||||
}
|
||||
Some(RouterAspectRatio::WideThreeByTwo) | Some(RouterAspectRatio::WideFiveByFour)
|
||||
| Some(RouterAspectRatio::TallFourByFive) | Some(RouterAspectRatio::TallTwoByThree) => {
|
||||
Ok(Some(Seedance20FastRatio::Adaptive))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn plan_resolution(
|
||||
resolution: Option<RouterResolution>,
|
||||
_strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Option<Seedance20FastResolution>, ArtcraftRouterError> {
|
||||
match resolution {
|
||||
None => Ok(None),
|
||||
Some(RouterResolution::FourEightyP) => Ok(Some(Seedance20FastResolution::FourEightyP)),
|
||||
Some(RouterResolution::SevenTwentyP) => Ok(Some(Seedance20FastResolution::SevenTwentyP)),
|
||||
// Fast model doesn't support 1080p — fall back to 720p
|
||||
Some(RouterResolution::TenEightyP) => Ok(Some(Seedance20FastResolution::SevenTwentyP)),
|
||||
Some(RouterResolution::HalfK) | Some(RouterResolution::OneK) => Ok(Some(Seedance20FastResolution::FourEightyP)),
|
||||
Some(RouterResolution::TwoK) | Some(RouterResolution::ThreeK) | Some(RouterResolution::FourK) => {
|
||||
Ok(Some(Seedance20FastResolution::SevenTwentyP))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_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::ModelDoesNotSupportOption {
|
||||
field: "start_frame/end_frame",
|
||||
value: "GmiCloud only supports image URLs, not media file tokens".to_string(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_url_list_from_images(
|
||||
list_ref: Option<ImageListRef>,
|
||||
) -> Result<Option<Vec<String>>, ArtcraftRouterError> {
|
||||
match list_ref {
|
||||
None => Ok(None),
|
||||
Some(ImageListRef::Urls(urls)) => Ok(Some(urls)),
|
||||
Some(ImageListRef::MediaFileTokens(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "reference_images",
|
||||
value: "GmiCloud only supports image URLs, not media file tokens".to_string(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_url_list_from_videos(
|
||||
list_ref: Option<VideoListRef>,
|
||||
) -> Result<Option<Vec<String>>, ArtcraftRouterError> {
|
||||
match list_ref {
|
||||
None => Ok(None),
|
||||
Some(VideoListRef::Urls(urls)) => Ok(Some(urls)),
|
||||
Some(VideoListRef::MediaFileTokens(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "reference_videos",
|
||||
value: "GmiCloud only supports video URLs, not media file tokens".to_string(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use gmicloud_client::requests::api::video::seedance_2_0_fast_260128::api::{
|
||||
Seedance20FastRatio, Seedance20FastResolution,
|
||||
};
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::api::video_list_ref::VideoListRef;
|
||||
use crate::api::audio_list_ref::AudioListRef;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
|
||||
|
||||
use super::*;
|
||||
|
||||
mod field_conversions {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn prompt_passed_through() {
|
||||
let req = unwrap_request(make_builder(|b| { b.prompt = Some("fast test".to_string()); }));
|
||||
assert_eq!(req.request.prompt, "fast test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_passed_through() {
|
||||
let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(8); }));
|
||||
assert_eq!(req.request.duration, Some(8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_clamped_to_min() {
|
||||
let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(2); }));
|
||||
assert_eq!(req.request.duration, Some(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_clamped_to_max() {
|
||||
let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(30); }));
|
||||
assert_eq!(req.request.duration, Some(15));
|
||||
}
|
||||
}
|
||||
|
||||
mod resolution_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn res_480p() {
|
||||
let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::FourEightyP); }));
|
||||
assert_eq!(req.request.resolution, Some(Seedance20FastResolution::FourEightyP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn res_720p() {
|
||||
let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::SevenTwentyP); }));
|
||||
assert_eq!(req.request.resolution, Some(Seedance20FastResolution::SevenTwentyP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn res_1080p_falls_back_to_720p() {
|
||||
let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::TenEightyP); }));
|
||||
assert_eq!(req.request.resolution, Some(Seedance20FastResolution::SevenTwentyP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_stays_none() {
|
||||
let req = unwrap_request(make_builder(|_| {}));
|
||||
assert!(req.request.resolution.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
mod aspect_ratio_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn landscape_16x9() {
|
||||
let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(RouterAspectRatio::WideSixteenByNine); }));
|
||||
assert_eq!(req.request.ratio, Some(Seedance20FastRatio::Landscape16x9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn square() {
|
||||
let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(RouterAspectRatio::Square); }));
|
||||
assert_eq!(req.request.ratio, Some(Seedance20FastRatio::Square));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_maps_to_none() {
|
||||
let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(RouterAspectRatio::Auto); }));
|
||||
assert_eq!(req.request.ratio, None);
|
||||
}
|
||||
}
|
||||
|
||||
mod media_url_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn start_frame_url_passed_through() {
|
||||
let req = unwrap_request(make_builder(|b| {
|
||||
b.start_frame = Some(ImageRef::Url("https://example.com/img.png".to_string()));
|
||||
}));
|
||||
assert_eq!(req.request.first_frame, Some("https://example.com/img.png".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_file_token_rejected() {
|
||||
let result = build_gmicloud_seedance_2p0_u_fast(GenerateVideoRequestBuilder {
|
||||
start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_x".to_string()))),
|
||||
..base_builder()
|
||||
});
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_image_urls_passed_through() {
|
||||
let urls = vec!["https://example.com/ref.png".to_string()];
|
||||
let req = unwrap_request(make_builder(|b| {
|
||||
b.reference_images = Some(ImageListRef::Urls(urls.clone()));
|
||||
}));
|
||||
assert_eq!(req.request.reference_images, Some(urls));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_video_urls_passed_through() {
|
||||
let urls = vec!["https://example.com/ref.mp4".to_string()];
|
||||
let req = unwrap_request(make_builder(|b| {
|
||||
b.reference_videos = Some(VideoListRef::Urls(urls.clone()));
|
||||
}));
|
||||
assert_eq!(req.request.reference_videos, Some(urls));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_audio_urls_passed_through() {
|
||||
let urls = vec!["https://example.com/ref.wav".to_string()];
|
||||
let req = unwrap_request(make_builder(|b| {
|
||||
b.reference_audio = Some(AudioListRef::Urls(urls.clone()));
|
||||
}));
|
||||
assert_eq!(req.request.reference_audios, Some(urls));
|
||||
}
|
||||
}
|
||||
|
||||
fn base_builder() -> GenerateVideoRequestBuilder {
|
||||
GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Seedance2p0UltraFast,
|
||||
provider: RouterProvider::GmiCloud,
|
||||
duration_seconds: Some(5),
|
||||
video_batch_count: Some(1),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn make_builder(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder {
|
||||
let mut builder = base_builder();
|
||||
f(&mut builder);
|
||||
builder
|
||||
}
|
||||
|
||||
fn unwrap_request(builder: GenerateVideoRequestBuilder) -> GmiCloudSeedance2p0UltraFastRequestState {
|
||||
let result = build_gmicloud_seedance_2p0_u_fast(builder).expect("build should succeed");
|
||||
match result {
|
||||
VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::GmiCloudSeedance2p0UltraFast(state)) => state,
|
||||
_ => panic!("expected GmiCloudSeedance2p0FastG request"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_url_list_from_audios(
|
||||
list_ref: Option<AudioListRef>,
|
||||
) -> Result<Option<Vec<String>>, ArtcraftRouterError> {
|
||||
match list_ref {
|
||||
None => Ok(None),
|
||||
Some(AudioListRef::Urls(urls)) => Ok(Some(urls)),
|
||||
Some(AudioListRef::MediaFileTokens(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "reference_audios",
|
||||
value: "GmiCloud only supports audio URLs, not media file tokens".to_string(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
-125
@@ -1,125 +0,0 @@
|
||||
use gmicloud_client::traits::gmicloud_request_cost_calculator_trait::GmiCloudRequestCostCalculator;
|
||||
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
use crate::generate::generate_video::providers::gmicloud::seedance_2p0_fast_g::request::GmiCloudSeedance2p0UltraFastRequestState;
|
||||
|
||||
pub struct GmiCloudSeedance2p0UltraFastCostState {
|
||||
request: GmiCloudSeedance2p0UltraFastRequestState,
|
||||
}
|
||||
|
||||
impl GmiCloudSeedance2p0UltraFastCostState {
|
||||
pub fn from_request(request: &GmiCloudSeedance2p0UltraFastRequestState) -> Self {
|
||||
Self { request: request.clone() }
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
|
||||
let cost_in_usd_cents = self.request.request.calculate_cost_in_cents();
|
||||
|
||||
VideoGenerationCostEstimate {
|
||||
cost_in_credits: None,
|
||||
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_resolution::RouterResolution;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
|
||||
mod pricing_720p {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_5s() {
|
||||
// 720p: 3.1 ¢/s * 5 = 15.5 → ceil = 16¢
|
||||
assert_eq!(cost_cents(None, 5), 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_10s() {
|
||||
// 3.1 * 10 = 31¢
|
||||
assert_eq!(cost_cents(None, 10), 31);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_15s() {
|
||||
// 3.1 * 15 = 46.5 → ceil = 47¢
|
||||
assert_eq!(cost_cents(None, 15), 47);
|
||||
}
|
||||
}
|
||||
|
||||
mod pricing_480p {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn p480_5s() {
|
||||
// 1.0 * 5 = 5¢
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn p480_10s() {
|
||||
// 1.0 * 10 = 10¢
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 10), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn p480_15s() {
|
||||
// 1.0 * 15 = 15¢
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 15), 15);
|
||||
}
|
||||
}
|
||||
|
||||
mod relative_pricing {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn higher_resolution_costs_more() {
|
||||
let c480 = cost_cents(Some(RouterResolution::FourEightyP), 10);
|
||||
let c720 = cost_cents(Some(RouterResolution::SevenTwentyP), 10);
|
||||
assert!(c480 < c720, "480p ({c480}) should be < 720p ({c720})");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fast_cheaper_than_standard() {
|
||||
let fast = cost_cents(Some(RouterResolution::SevenTwentyP), 10);
|
||||
let standard = {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Seedance2p0Ultra,
|
||||
provider: RouterProvider::GmiCloud,
|
||||
resolution: Some(RouterResolution::SevenTwentyP),
|
||||
duration_seconds: Some(10),
|
||||
video_batch_count: Some(1),
|
||||
..Default::default()
|
||||
};
|
||||
builder.build2().unwrap().estimate_cost().unwrap().cost_in_usd_cents.unwrap()
|
||||
};
|
||||
assert!(fast < standard, "Fast ({fast}¢) should be < Standard ({standard}¢)");
|
||||
}
|
||||
}
|
||||
|
||||
fn cost_cents(resolution: Option<RouterResolution>, duration_seconds: u16) -> u64 {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Seedance2p0UltraFast,
|
||||
provider: RouterProvider::GmiCloud,
|
||||
resolution,
|
||||
duration_seconds: Some(duration_seconds),
|
||||
video_batch_count: Some(1),
|
||||
..Default::default()
|
||||
};
|
||||
builder.build2()
|
||||
.expect("build2 should succeed")
|
||||
.estimate_cost()
|
||||
.expect("estimate_cost should succeed")
|
||||
.cost_in_usd_cents
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
-100
@@ -1,100 +0,0 @@
|
||||
use gmicloud_client::requests::api::video::seedance_2_0_fast_260128::api::Seedance20FastRequest;
|
||||
|
||||
use crate::client::router_gmicloud_client::RouterGmiCloudClient;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::provider_error::ProviderError;
|
||||
use crate::generate::generate_video::generate_video_response::{
|
||||
GenerateVideoResponse, GmiCloudVideoResponsePayload,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GmiCloudSeedance2p0UltraFastRequestState {
|
||||
pub request: Seedance20FastRequest,
|
||||
}
|
||||
|
||||
impl GmiCloudSeedance2p0UltraFastRequestState {
|
||||
pub async fn send(&self, client: &RouterGmiCloudClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
|
||||
let response = self.request.send_request(&client.api_key)
|
||||
.await
|
||||
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::GmiCloud(err)))?;
|
||||
|
||||
Ok(GenerateVideoResponse::GmiCloud(GmiCloudVideoResponsePayload {
|
||||
request_id: response.request_id,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::client::router_client::RouterClient;
|
||||
use crate::client::router_gmicloud_client::RouterGmiCloudClient;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use gmicloud_client::creds::gmicloud_api_key::GmiCloudApiKey;
|
||||
use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // requires real API key, incurs costs
|
||||
async fn test_text_to_video_720p() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A shiba inu playing in autumn leaves.".to_string()),
|
||||
aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine),
|
||||
resolution: Some(RouterResolution::SevenTwentyP),
|
||||
duration_seconds: Some(5),
|
||||
..gmicloud_fast_builder()
|
||||
}).await;
|
||||
println!("response: {:?}", response);
|
||||
assert!(matches!(response, GenerateVideoResponse::GmiCloud(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // requires real API key, incurs costs
|
||||
async fn test_image_to_video_480p() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("The dog starts running and splashing in the lake.".to_string()),
|
||||
start_frame: Some(ImageRef::Url(JUNO_AT_LAKE_IMAGE_URL.to_string())),
|
||||
resolution: Some(RouterResolution::FourEightyP),
|
||||
duration_seconds: Some(5),
|
||||
..gmicloud_fast_builder()
|
||||
}).await;
|
||||
println!("response: {:?}", response);
|
||||
assert!(matches!(response, GenerateVideoResponse::GmiCloud(_)));
|
||||
}
|
||||
|
||||
fn gmicloud_fast_builder() -> GenerateVideoRequestBuilder {
|
||||
GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Seedance2p0UltraFast,
|
||||
provider: RouterProvider::GmiCloud,
|
||||
video_batch_count: Some(1),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn get_gmicloud_client() -> RouterClient {
|
||||
let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/gmicloud_api_key.txt")
|
||||
.expect("Failed to read GmiCloud API key");
|
||||
let api_key = GmiCloudApiKey::from_str(&secret);
|
||||
RouterClient::GmiCloud(RouterGmiCloudClient::new(api_key))
|
||||
}
|
||||
|
||||
async fn run_pipeline(builder: GenerateVideoRequestBuilder) -> GenerateVideoResponse {
|
||||
let client = get_gmicloud_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 (GmiCloud skips draft)"),
|
||||
};
|
||||
let response = request.send_request(&client).await.expect("send_request should succeed");
|
||||
match &response {
|
||||
GenerateVideoResponse::GmiCloud(p) => println!("request_id={}", p.request_id),
|
||||
other => println!("response: {:?}", other),
|
||||
}
|
||||
response
|
||||
}
|
||||
}
|
||||
-355
@@ -1,355 +0,0 @@
|
||||
use gmicloud_client::requests::api::video::seedance_2_0_260128::api::{
|
||||
Seedance20Ratio, Seedance20Request, Seedance20Resolution,
|
||||
};
|
||||
|
||||
use crate::api::audio_list_ref::AudioListRef;
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::video_list_ref::VideoListRef;
|
||||
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::client_error::ClientError;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::providers::gmicloud::seedance_2p0_g::request::GmiCloudSeedance2p0UltraRequestState;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
|
||||
|
||||
pub fn build_gmicloud_seedance_2p0_u(
|
||||
mut builder: GenerateVideoRequestBuilder,
|
||||
) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
let strategy = builder.request_mismatch_mitigation_strategy;
|
||||
|
||||
let ratio = plan_ratio(builder.aspect_ratio.take(), strategy)?;
|
||||
let resolution = plan_resolution(builder.resolution.take(), strategy)?;
|
||||
let duration = builder.duration_seconds.take().map(|d| (d as u8).clamp(4, 15));
|
||||
let prompt = builder.prompt.take().unwrap_or_default();
|
||||
|
||||
let first_frame = resolve_url(builder.start_frame.take())?;
|
||||
let last_frame = resolve_url(builder.end_frame.take())?;
|
||||
let reference_images = resolve_url_list_from_images(builder.reference_images.take())?;
|
||||
let reference_videos = resolve_url_list_from_videos(builder.reference_videos.take())?;
|
||||
let reference_audios = resolve_url_list_from_audios(builder.reference_audio.take())?;
|
||||
|
||||
let request = Seedance20Request {
|
||||
prompt,
|
||||
duration,
|
||||
resolution,
|
||||
ratio,
|
||||
seed: None,
|
||||
watermark: Some(false),
|
||||
generate_audio: Some(true),
|
||||
web_search: None,
|
||||
first_frame,
|
||||
last_frame,
|
||||
reference_images,
|
||||
reference_videos,
|
||||
reference_audios,
|
||||
reference_asset_ids: None,
|
||||
};
|
||||
|
||||
let state = GmiCloudSeedance2p0UltraRequestState { request };
|
||||
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::GmiCloudSeedance2p0Ultra(state)))
|
||||
}
|
||||
|
||||
fn plan_ratio(
|
||||
aspect_ratio: Option<RouterAspectRatio>,
|
||||
_strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Option<Seedance20Ratio>, ArtcraftRouterError> {
|
||||
match aspect_ratio {
|
||||
None | Some(RouterAspectRatio::Auto) | Some(RouterAspectRatio::Auto2k)
|
||||
| Some(RouterAspectRatio::Auto3k) | Some(RouterAspectRatio::Auto4k) => Ok(None),
|
||||
Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Some(Seedance20Ratio::Landscape16x9)),
|
||||
Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Some(Seedance20Ratio::Portrait9x16)),
|
||||
Some(RouterAspectRatio::Square) | Some(RouterAspectRatio::SquareHd) => Ok(Some(Seedance20Ratio::Square)),
|
||||
Some(RouterAspectRatio::WideFourByThree) => Ok(Some(Seedance20Ratio::Standard4x3)),
|
||||
Some(RouterAspectRatio::TallThreeByFour) => Ok(Some(Seedance20Ratio::Portrait3x4)),
|
||||
Some(RouterAspectRatio::WideTwentyOneByNine) | Some(RouterAspectRatio::TallNineByTwentyOne) => {
|
||||
Ok(Some(Seedance20Ratio::UltraWide21x9))
|
||||
}
|
||||
Some(RouterAspectRatio::WideThreeByTwo) | Some(RouterAspectRatio::WideFiveByFour)
|
||||
| Some(RouterAspectRatio::TallFourByFive) | Some(RouterAspectRatio::TallTwoByThree) => {
|
||||
Ok(Some(Seedance20Ratio::Adaptive))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn plan_resolution(
|
||||
resolution: Option<RouterResolution>,
|
||||
_strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Option<Seedance20Resolution>, ArtcraftRouterError> {
|
||||
match resolution {
|
||||
None => Ok(None),
|
||||
Some(RouterResolution::FourEightyP) => Ok(Some(Seedance20Resolution::FourEightyP)),
|
||||
Some(RouterResolution::SevenTwentyP) => Ok(Some(Seedance20Resolution::SevenTwentyP)),
|
||||
Some(RouterResolution::TenEightyP) => Ok(Some(Seedance20Resolution::TenEightyP)),
|
||||
Some(RouterResolution::HalfK) | Some(RouterResolution::OneK) => Ok(Some(Seedance20Resolution::FourEightyP)),
|
||||
Some(RouterResolution::TwoK) | Some(RouterResolution::ThreeK) | Some(RouterResolution::FourK) => {
|
||||
Ok(Some(Seedance20Resolution::TenEightyP))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_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::ModelDoesNotSupportOption {
|
||||
field: "start_frame/end_frame",
|
||||
value: "GmiCloud only supports image URLs, not media file tokens".to_string(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_url_list_from_images(
|
||||
list_ref: Option<ImageListRef>,
|
||||
) -> Result<Option<Vec<String>>, ArtcraftRouterError> {
|
||||
match list_ref {
|
||||
None => Ok(None),
|
||||
Some(ImageListRef::Urls(urls)) => Ok(Some(urls)),
|
||||
Some(ImageListRef::MediaFileTokens(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "reference_images",
|
||||
value: "GmiCloud only supports image URLs, not media file tokens".to_string(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_url_list_from_videos(
|
||||
list_ref: Option<VideoListRef>,
|
||||
) -> Result<Option<Vec<String>>, ArtcraftRouterError> {
|
||||
match list_ref {
|
||||
None => Ok(None),
|
||||
Some(VideoListRef::Urls(urls)) => Ok(Some(urls)),
|
||||
Some(VideoListRef::MediaFileTokens(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "reference_videos",
|
||||
value: "GmiCloud only supports video URLs, not media file tokens".to_string(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use gmicloud_client::requests::api::video::seedance_2_0_260128::api::{
|
||||
Seedance20Ratio, Seedance20Resolution,
|
||||
};
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::api::video_list_ref::VideoListRef;
|
||||
use crate::api::audio_list_ref::AudioListRef;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
|
||||
|
||||
use super::*;
|
||||
|
||||
mod field_conversions {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn prompt_passed_through() {
|
||||
let req = unwrap_request(make_builder(|b| { b.prompt = Some("test prompt".to_string()); }));
|
||||
assert_eq!(req.request.prompt, "test prompt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_passed_through() {
|
||||
let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(10); }));
|
||||
assert_eq!(req.request.duration, Some(10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_clamped_to_min() {
|
||||
let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(1); }));
|
||||
assert_eq!(req.request.duration, Some(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_clamped_to_max() {
|
||||
let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(99); }));
|
||||
assert_eq!(req.request.duration, Some(15));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_none_stays_none() {
|
||||
let req = unwrap_request(make_builder(|b| { b.duration_seconds = None; }));
|
||||
assert_eq!(req.request.duration, None);
|
||||
}
|
||||
}
|
||||
|
||||
mod aspect_ratio_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn landscape_16x9() {
|
||||
let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(RouterAspectRatio::WideSixteenByNine); }));
|
||||
assert_eq!(req.request.ratio, Some(Seedance20Ratio::Landscape16x9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portrait_9x16() {
|
||||
let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(RouterAspectRatio::TallNineBySixteen); }));
|
||||
assert_eq!(req.request.ratio, Some(Seedance20Ratio::Portrait9x16));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn square() {
|
||||
let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(RouterAspectRatio::Square); }));
|
||||
assert_eq!(req.request.ratio, Some(Seedance20Ratio::Square));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_by_three() {
|
||||
let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(RouterAspectRatio::WideFourByThree); }));
|
||||
assert_eq!(req.request.ratio, Some(Seedance20Ratio::Standard4x3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_maps_to_adaptive() {
|
||||
let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(RouterAspectRatio::WideThreeByTwo); }));
|
||||
assert_eq!(req.request.ratio, Some(Seedance20Ratio::Adaptive));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_maps_to_none() {
|
||||
let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(RouterAspectRatio::Auto); }));
|
||||
assert_eq!(req.request.ratio, None);
|
||||
}
|
||||
}
|
||||
|
||||
mod resolution_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn res_480p() {
|
||||
let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::FourEightyP); }));
|
||||
assert_eq!(req.request.resolution, Some(Seedance20Resolution::FourEightyP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn res_720p() {
|
||||
let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::SevenTwentyP); }));
|
||||
assert_eq!(req.request.resolution, Some(Seedance20Resolution::SevenTwentyP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn res_1080p() {
|
||||
let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::TenEightyP); }));
|
||||
assert_eq!(req.request.resolution, Some(Seedance20Resolution::TenEightyP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_stays_none() {
|
||||
let req = unwrap_request(make_builder(|_| {}));
|
||||
assert!(req.request.resolution.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
mod media_url_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn start_frame_url_passed_through() {
|
||||
let req = unwrap_request(make_builder(|b| {
|
||||
b.start_frame = Some(ImageRef::Url("https://example.com/start.png".to_string()));
|
||||
}));
|
||||
assert_eq!(req.request.first_frame, Some("https://example.com/start.png".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_file_token_rejected_for_start_frame() {
|
||||
let result = build_gmicloud_seedance_2p0_u(GenerateVideoRequestBuilder {
|
||||
start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_test".to_string()))),
|
||||
..base_builder()
|
||||
});
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_image_urls_passed_through() {
|
||||
let urls = vec!["https://example.com/a.png".to_string(), "https://example.com/b.png".to_string()];
|
||||
let req = unwrap_request(make_builder(|b| {
|
||||
b.reference_images = Some(ImageListRef::Urls(urls.clone()));
|
||||
}));
|
||||
assert_eq!(req.request.reference_images, Some(urls));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_image_tokens_rejected() {
|
||||
let result = build_gmicloud_seedance_2p0_u(GenerateVideoRequestBuilder {
|
||||
reference_images: Some(ImageListRef::MediaFileTokens(vec![MediaFileToken::new("mf_a".to_string())])),
|
||||
..base_builder()
|
||||
});
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_video_urls_passed_through() {
|
||||
let urls = vec!["https://example.com/v.mp4".to_string()];
|
||||
let req = unwrap_request(make_builder(|b| {
|
||||
b.reference_videos = Some(VideoListRef::Urls(urls.clone()));
|
||||
}));
|
||||
assert_eq!(req.request.reference_videos, Some(urls));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_audio_urls_passed_through() {
|
||||
let urls = vec!["https://example.com/a.wav".to_string()];
|
||||
let req = unwrap_request(make_builder(|b| {
|
||||
b.reference_audio = Some(AudioListRef::Urls(urls.clone()));
|
||||
}));
|
||||
assert_eq!(req.request.reference_audios, Some(urls));
|
||||
}
|
||||
}
|
||||
|
||||
fn base_builder() -> GenerateVideoRequestBuilder {
|
||||
GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Seedance2p0Ultra,
|
||||
provider: RouterProvider::GmiCloud,
|
||||
duration_seconds: Some(5),
|
||||
video_batch_count: Some(1),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn make_builder(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder {
|
||||
let mut builder = base_builder();
|
||||
f(&mut builder);
|
||||
builder
|
||||
}
|
||||
|
||||
fn unwrap_request(builder: GenerateVideoRequestBuilder) -> GmiCloudSeedance2p0UltraRequestState {
|
||||
let result = build_gmicloud_seedance_2p0_u(builder).expect("build should succeed");
|
||||
match result {
|
||||
VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::GmiCloudSeedance2p0Ultra(state)) => state,
|
||||
_ => panic!("expected GmiCloudSeedance2p0G request"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_url_list_from_audios(
|
||||
list_ref: Option<AudioListRef>,
|
||||
) -> Result<Option<Vec<String>>, ArtcraftRouterError> {
|
||||
match list_ref {
|
||||
None => Ok(None),
|
||||
Some(AudioListRef::Urls(urls)) => Ok(Some(urls)),
|
||||
Some(AudioListRef::MediaFileTokens(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "reference_audios",
|
||||
value: "GmiCloud only supports audio URLs, not media file tokens".to_string(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
-133
@@ -1,133 +0,0 @@
|
||||
use gmicloud_client::traits::gmicloud_request_cost_calculator_trait::GmiCloudRequestCostCalculator;
|
||||
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
use crate::generate::generate_video::providers::gmicloud::seedance_2p0_g::request::GmiCloudSeedance2p0UltraRequestState;
|
||||
|
||||
pub struct GmiCloudSeedance2p0UltraCostState {
|
||||
request: GmiCloudSeedance2p0UltraRequestState,
|
||||
}
|
||||
|
||||
impl GmiCloudSeedance2p0UltraCostState {
|
||||
pub fn from_request(request: &GmiCloudSeedance2p0UltraRequestState) -> Self {
|
||||
Self { request: request.clone() }
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
|
||||
let cost_in_usd_cents = self.request.request.calculate_cost_in_cents();
|
||||
|
||||
VideoGenerationCostEstimate {
|
||||
cost_in_credits: None,
|
||||
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_resolution::RouterResolution;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
|
||||
mod pricing_720p {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_5s() {
|
||||
// 720p: 5.2 ¢/s * 5 = 26¢
|
||||
assert_eq!(cost_cents(None, 5), 26);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_10s() {
|
||||
assert_eq!(cost_cents(None, 10), 52);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_720p_5s() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5), 26);
|
||||
}
|
||||
}
|
||||
|
||||
mod pricing_480p {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn p480_5s() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5), 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn p480_10s() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 10), 24);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn p480_15s() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 15), 36);
|
||||
}
|
||||
}
|
||||
|
||||
mod pricing_1080p {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn p1080_5s() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 5), 58);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn p1080_10s() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 10), 116);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn p1080_15s() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 15), 174);
|
||||
}
|
||||
}
|
||||
|
||||
mod relative_pricing {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn higher_resolution_costs_more() {
|
||||
let c480 = cost_cents(Some(RouterResolution::FourEightyP), 10);
|
||||
let c720 = cost_cents(Some(RouterResolution::SevenTwentyP), 10);
|
||||
let c1080 = cost_cents(Some(RouterResolution::TenEightyP), 10);
|
||||
assert!(c480 < c720, "480p ({c480}) should be < 720p ({c720})");
|
||||
assert!(c720 < c1080, "720p ({c720}) should be < 1080p ({c1080})");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn longer_duration_costs_more() {
|
||||
let c5 = cost_cents(None, 5);
|
||||
let c10 = cost_cents(None, 10);
|
||||
let c15 = cost_cents(None, 15);
|
||||
assert!(c5 < c10);
|
||||
assert!(c10 < c15);
|
||||
}
|
||||
}
|
||||
|
||||
fn cost_cents(resolution: Option<RouterResolution>, duration_seconds: u16) -> u64 {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Seedance2p0Ultra,
|
||||
provider: RouterProvider::GmiCloud,
|
||||
resolution,
|
||||
duration_seconds: Some(duration_seconds),
|
||||
video_batch_count: Some(1),
|
||||
..Default::default()
|
||||
};
|
||||
builder.build2()
|
||||
.expect("build2 should succeed")
|
||||
.estimate_cost()
|
||||
.expect("estimate_cost should succeed")
|
||||
.cost_in_usd_cents
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
-98
@@ -1,98 +0,0 @@
|
||||
use gmicloud_client::requests::api::video::seedance_2_0_260128::api::Seedance20Request;
|
||||
|
||||
use crate::client::router_gmicloud_client::RouterGmiCloudClient;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::provider_error::ProviderError;
|
||||
use crate::generate::generate_video::generate_video_response::{
|
||||
GenerateVideoResponse, GmiCloudVideoResponsePayload,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GmiCloudSeedance2p0UltraRequestState {
|
||||
pub request: Seedance20Request,
|
||||
}
|
||||
|
||||
impl GmiCloudSeedance2p0UltraRequestState {
|
||||
pub async fn send(&self, client: &RouterGmiCloudClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
|
||||
let response = self.request.send_request(&client.api_key)
|
||||
.await
|
||||
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::GmiCloud(err)))?;
|
||||
|
||||
Ok(GenerateVideoResponse::GmiCloud(GmiCloudVideoResponsePayload {
|
||||
request_id: response.request_id,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::client::router_client::RouterClient;
|
||||
use crate::client::router_gmicloud_client::RouterGmiCloudClient;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use gmicloud_client::creds::gmicloud_api_key::GmiCloudApiKey;
|
||||
use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // requires real API key, incurs costs
|
||||
async fn test_text_to_video_720p() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A corgi running through a field of wildflowers at sunset.".to_string()),
|
||||
aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine),
|
||||
resolution: Some(RouterResolution::SevenTwentyP),
|
||||
duration_seconds: Some(5),
|
||||
..gmicloud_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::GmiCloud(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // requires real API key, incurs costs
|
||||
async fn test_image_to_video_480p() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("The dog starts running and splashing in the lake.".to_string()),
|
||||
start_frame: Some(ImageRef::Url(JUNO_AT_LAKE_IMAGE_URL.to_string())),
|
||||
resolution: Some(RouterResolution::FourEightyP),
|
||||
duration_seconds: Some(5),
|
||||
..gmicloud_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::GmiCloud(_)));
|
||||
}
|
||||
|
||||
fn gmicloud_builder() -> GenerateVideoRequestBuilder {
|
||||
GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Seedance2p0Ultra,
|
||||
provider: RouterProvider::GmiCloud,
|
||||
video_batch_count: Some(1),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn get_gmicloud_client() -> RouterClient {
|
||||
let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/gmicloud_api_key.txt")
|
||||
.expect("Failed to read GmiCloud API key");
|
||||
let api_key = GmiCloudApiKey::from_str(&secret);
|
||||
RouterClient::GmiCloud(RouterGmiCloudClient::new(api_key))
|
||||
}
|
||||
|
||||
async fn run_pipeline(builder: GenerateVideoRequestBuilder) -> GenerateVideoResponse {
|
||||
let client = get_gmicloud_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 (GmiCloud skips draft)"),
|
||||
};
|
||||
let response = request.send_request(&client).await.expect("send_request should succeed");
|
||||
match &response {
|
||||
GenerateVideoResponse::GmiCloud(p) => println!("request_id={}", p.request_id),
|
||||
other => println!("response: {:?}", other),
|
||||
}
|
||||
response
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -37,7 +37,8 @@ impl KinoviHappyHorse1p0DraftState {
|
||||
|
||||
if let Some(remaining) = self.unhandled_request_state.take() {
|
||||
let map = draft_context.media_file_to_artcraft_url_map;
|
||||
start_frame_url = resolve_and_upload_single(session, remaining.start_frame, map).await?;
|
||||
let predownloaded = draft_context.predownloaded_media_paths;
|
||||
start_frame_url = resolve_and_upload_single(session, remaining.start_frame, map, predownloaded).await?;
|
||||
}
|
||||
|
||||
let request = GenerateHappyHorse1p0Request {
|
||||
|
||||
+1
@@ -2,6 +2,7 @@ pub mod happy_horse_1p0;
|
||||
pub mod seedance_2p0;
|
||||
pub mod seedance_2p0_fast;
|
||||
pub mod seedance_2p0_mini;
|
||||
pub mod seedance_2p5;
|
||||
pub mod seedance_2p5_preview;
|
||||
pub(crate) mod resolve;
|
||||
pub(crate) mod upload;
|
||||
|
||||
+18
-2
@@ -1,4 +1,5 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use kinovi_web_client::creds::kinovi_web_session::KinoviWebSession;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
@@ -45,13 +46,15 @@ pub(crate) async fn resolve_and_upload_single(
|
||||
session: &KinoviWebSession,
|
||||
image_ref: Option<ImageRef>,
|
||||
maybe_map: Option<&HashMap<MediaFileToken, String>>,
|
||||
maybe_predownloaded: Option<&HashMap<String, PathBuf>>,
|
||||
) -> Result<Option<String>, ArtcraftRouterError> {
|
||||
let source_url = match image_ref {
|
||||
None => return Ok(None),
|
||||
Some(ImageRef::Url(url)) => url,
|
||||
Some(ImageRef::MediaFileToken(token)) => resolve_token(maybe_map, &token)?,
|
||||
};
|
||||
Ok(Some(upload_to_kinovi_web(session, &source_url).await?))
|
||||
let local_path = predownloaded_path(maybe_predownloaded, &source_url);
|
||||
Ok(Some(upload_to_kinovi_web(session, &source_url, local_path).await?))
|
||||
}
|
||||
|
||||
/// Resolve a list of refs to URLs and upload each to KinoviWeb CDN.
|
||||
@@ -60,6 +63,7 @@ pub(crate) async fn resolve_and_upload_list(
|
||||
session: &KinoviWebSession,
|
||||
urls_or_tokens: Option<UrlsOrTokens>,
|
||||
maybe_map: Option<&HashMap<MediaFileToken, String>>,
|
||||
maybe_predownloaded: Option<&HashMap<String, PathBuf>>,
|
||||
) -> Result<Option<Vec<String>>, ArtcraftRouterError> {
|
||||
let source_urls = match urls_or_tokens {
|
||||
None => return Ok(None),
|
||||
@@ -71,11 +75,23 @@ pub(crate) async fn resolve_and_upload_list(
|
||||
|
||||
let mut uploaded = Vec::with_capacity(source_urls.len());
|
||||
for url in &source_urls {
|
||||
uploaded.push(upload_to_kinovi_web(session, url).await?);
|
||||
let local_path = predownloaded_path(maybe_predownloaded, url);
|
||||
uploaded.push(upload_to_kinovi_web(session, url, local_path).await?);
|
||||
}
|
||||
Ok(Some(uploaded))
|
||||
}
|
||||
|
||||
/// Look up a caller-predownloaded local copy of the source file (see
|
||||
/// `VideoGenerationDraftContext::predownloaded_media_paths`).
|
||||
fn predownloaded_path<'a>(
|
||||
maybe_predownloaded: Option<&'a HashMap<String, PathBuf>>,
|
||||
source_url: &str,
|
||||
) -> Option<&'a Path> {
|
||||
maybe_predownloaded
|
||||
.and_then(|paths| paths.get(source_url))
|
||||
.map(PathBuf::as_path)
|
||||
}
|
||||
|
||||
/// Map character tokens to their Kinovi character IDs, preserving order.
|
||||
pub(crate) fn resolve_character_tokens(
|
||||
character_list_ref: Option<&CharacterListRef>,
|
||||
|
||||
+12
-7
@@ -149,7 +149,7 @@ fn plan_output_resolution(
|
||||
}
|
||||
}
|
||||
|
||||
// Seedance2p0 supports batch counts of 1, 2, and 4 only.
|
||||
// Seedance2p0 supports batch counts of 1 through 4.
|
||||
fn plan_batch_count(
|
||||
video_batch_count: Option<u16>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
@@ -159,7 +159,9 @@ fn plan_batch_count(
|
||||
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
|
||||
1 => Ok(KinoviBatchCount::One),
|
||||
2 => Ok(KinoviBatchCount::Two),
|
||||
3 => Ok(KinoviBatchCount::Three),
|
||||
4 => Ok(KinoviBatchCount::Four),
|
||||
// 5 and above:
|
||||
_ => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
@@ -167,12 +169,8 @@ fn plan_batch_count(
|
||||
value: format!("{}", count),
|
||||
}))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade => {
|
||||
Ok(if count < 4 { KinoviBatchCount::Four } else { KinoviBatchCount::Four })
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayLessDowngrade => {
|
||||
Ok(if count < 4 { KinoviBatchCount::Two } else { KinoviBatchCount::Four })
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(KinoviBatchCount::Four),
|
||||
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(KinoviBatchCount::Four),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -291,6 +289,13 @@ mod tests {
|
||||
assert!(matches!(draft.batch_count, KinoviBatchCount::Two));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_count_three() {
|
||||
let builder = GenerateVideoRequestBuilder { video_batch_count: Some(3), ..kinovi_web_builder() };
|
||||
let draft = unwrap_draft(build_kinovi_seedance_2p0(builder));
|
||||
assert!(matches!(draft.batch_count, KinoviBatchCount::Three));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_count_four() {
|
||||
let builder = GenerateVideoRequestBuilder { video_batch_count: Some(4), ..kinovi_web_builder() };
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user