mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-28 12:32:51 -04:00
Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2cfcb6131d | ||
|
|
480c073c54 | ||
|
|
9646a791aa | ||
|
|
4d839d3b8b | ||
|
|
b7f354face | ||
|
|
c888a494b9 | ||
|
|
a048458956 | ||
|
|
d598896e40 | ||
|
|
af9dfa556f | ||
|
|
ea0de174c9 | ||
|
|
a1435df07d | ||
|
|
a7a32a3862 | ||
|
|
bb10c993a4 | ||
|
|
c6474edd5c | ||
|
|
9020f0aa5b | ||
|
|
c2e3c27a72 | ||
|
|
22847961e1 | ||
|
|
f72981875c | ||
|
|
39e89c76e8 | ||
|
|
a473be78bd | ||
|
|
36cfba0000 | ||
|
|
a5f59d7995 |
+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"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\n INSERT INTO media_files\n SET\n token = ?,\n\n media_class = ?,\n media_type = ?,\n\n is_user_upload = ?,\n is_intermediate_system_file = ?,\n\n origin_category = ?,\n origin_product_category = ?,\n maybe_origin_model_type = ?,\n maybe_origin_model_token = ?,\n maybe_origin_filename = ?,\n\n maybe_batch_token = ?,\n maybe_source_job_token = ?,\n\n maybe_mime_type = ?,\n file_size_bytes = ?,\n maybe_duration_millis = ?,\n maybe_audio_encoding = ?,\n maybe_video_encoding = ?,\n maybe_frame_width = ?,\n maybe_frame_height = ?,\n maybe_prompt_token = ?,\n checksum_sha2 = ?,\n\n maybe_engine_category = ?,\n\n maybe_title = ?,\n maybe_text_transcript = ?,\n\n maybe_scene_source_media_file_token = ?,\n\n public_bucket_directory_hash = ?,\n maybe_public_bucket_prefix = ?,\n maybe_public_bucket_extension = ?,\n\n maybe_creator_user_token = ?,\n maybe_creator_anonymous_visitor_token = ?,\n\n creator_ip_address = ?,\n creator_set_visibility = ?,\n\n maybe_creator_file_synthetic_id = ?,\n maybe_creator_category_synthetic_id = ?,\n\n extra_file_modification_info = ?,\n\n maybe_generation_provider = ?,\n\n platform_type = ?,\n\n maybe_cover_image_media_file_token = ?,\n\n maybe_mod_user_token = ?,\n is_generated_on_prem = ?,\n generated_by_worker = ?,\n generated_by_cluster = ?\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 42
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "d88098343686ded52a71fc45a1b96d28ea3003975d6e7d20eee2022aa31d8189"
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n token as `job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,\n status as `status: enums::common::job_status_plus::JobStatusPlus`,\n job_type as `job_type: enums::by_table::generic_inference_jobs::inference_job_type::InferenceJobType`,\n maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n maybe_creator_anonymous_visitor_token as `maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken`,\n creator_ip_address,\n maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,\n maybe_debug_log_event_token as `maybe_debug_log_event_token: tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken`,\n platform_type as `maybe_platform_type: enums::common::platform_type::PlatformType`,\n on_success_result_entity_token as `maybe_result_media_file_token: tokens::tokens::media_files::MediaFileToken`\nFROM generic_inference_jobs\nWHERE token = ?\nAND job_type IN ('artcraft_minimax_h3_turbo', 'artcraft_minimax_h3_ultra')\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "status: enums::common::job_status_plus::JobStatusPlus",
|
||||
"type_info": {
|
||||
"type": "String",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | ENUM",
|
||||
"char_set": 224,
|
||||
"max_size": 76
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "job_type: enums::by_table::generic_inference_jobs::inference_job_type::InferenceJobType",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "MULTIPLE_KEY | BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "maybe_creator_user_token: tokens::tokens::users::UserToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "MULTIPLE_KEY | BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "MULTIPLE_KEY | BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "creator_ip_address",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 160
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "maybe_prompt_token: tokens::tokens::prompts::PromptToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "maybe_debug_log_event_token: tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "maybe_platform_type: enums::common::platform_type::PlatformType",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 64
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "maybe_result_media_file_token: tokens::tokens::media_files::MediaFileToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "MULTIPLE_KEY | BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "f1d41ad17cb01f5aef0ce2eea042ccb3e5e649365a0befaf0f46492b75523442"
|
||||
}
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\n INSERT INTO media_files\n SET\n token = ?,\n\n media_class = ?,\n media_type = ?,\n\n is_user_upload = ?,\n is_intermediate_system_file = ?,\n\n origin_category = ?,\n origin_product_category = ?,\n maybe_origin_model_type = ?,\n maybe_origin_model_token = ?,\n maybe_origin_filename = ?,\n\n maybe_batch_token = ?,\n\n maybe_mime_type = ?,\n file_size_bytes = ?,\n maybe_duration_millis = ?,\n maybe_audio_encoding = ?,\n maybe_video_encoding = ?,\n maybe_frame_width = ?,\n maybe_frame_height = ?,\n maybe_prompt_token = ?,\n checksum_sha2 = ?,\n\n maybe_engine_category = ?,\n\n maybe_title = ?,\n maybe_text_transcript = ?,\n\n maybe_scene_source_media_file_token = ?,\n\n public_bucket_directory_hash = ?,\n maybe_public_bucket_prefix = ?,\n maybe_public_bucket_extension = ?,\n\n maybe_creator_user_token = ?,\n maybe_creator_anonymous_visitor_token = ?,\n\n creator_ip_address = ?,\n creator_set_visibility = ?,\n\n maybe_creator_file_synthetic_id = ?,\n maybe_creator_category_synthetic_id = ?,\n\n extra_file_modification_info = ?,\n\n maybe_generation_provider = ?,\n\n platform_type = ?,\n\n maybe_cover_image_media_file_token = ?,\n\n maybe_mod_user_token = ?,\n is_generated_on_prem = ?,\n generated_by_worker = ?,\n generated_by_cluster = ?\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 41
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "f5bc4a956a20f6c0998585092a0922301d53c95ae8ea9cb808ae2dbcf8d0bb32"
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
ALTER TABLE media_files
|
||||
DROP INDEX index_maybe_source_job_token,
|
||||
DROP COLUMN maybe_source_job_token;
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
-- The inference job ('jinf_...') whose completion produced this media file.
|
||||
-- Only set for files written as job outputs; uploads and other origins stay NULL.
|
||||
ALTER TABLE media_files
|
||||
ADD COLUMN maybe_source_job_token VARCHAR(32) DEFAULT NULL;
|
||||
|
||||
-- For "outputs of this job"-style lookups.
|
||||
-- The column is all-NULL at creation time, so the index build is cheap.
|
||||
ALTER TABLE media_files
|
||||
ADD INDEX index_maybe_source_job_token (maybe_source_job_token);
|
||||
@@ -102,6 +102,10 @@ CREATE TABLE media_files (
|
||||
-- If the media file is generated as part of a batch, this designates the batch.
|
||||
maybe_batch_token VARCHAR(32) DEFAULT NULL,
|
||||
|
||||
-- The inference job ('jinf_...') whose completion produced this media file.
|
||||
-- Only set for files written as job outputs; uploads and other origins stay NULL.
|
||||
maybe_source_job_token VARCHAR(32) DEFAULT NULL,
|
||||
|
||||
-- ========== METADATA ==========
|
||||
|
||||
-- If the file is a user upload (not including studio renders).
|
||||
@@ -327,6 +331,7 @@ CREATE TABLE media_files (
|
||||
KEY fk_maybe_origin_model_token (maybe_origin_model_token),
|
||||
KEY fk_maybe_origin_model_type_and_token (maybe_origin_model_type, maybe_origin_model_token),
|
||||
KEY index_maybe_batch_token (maybe_batch_token),
|
||||
KEY index_maybe_source_job_token (maybe_source_job_token),
|
||||
KEY index_is_user_upload (is_user_upload),
|
||||
KEY index_is_intermediate_system_file (is_intermediate_system_file),
|
||||
KEY index_maybe_scene_source_media_file_token (maybe_scene_source_media_file_token),
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
@@ -44,6 +44,12 @@ pub enum RouterVideoModel {
|
||||
#[serde(rename = "minimax_h3")]
|
||||
MinimaxH3,
|
||||
|
||||
#[serde(rename = "minimax_h3_turbo")]
|
||||
MinimaxH3Turbo,
|
||||
|
||||
#[serde(rename = "minimax_h3_ultra")]
|
||||
MinimaxH3Ultra,
|
||||
|
||||
#[serde(rename = "seedance_1p0_lite")]
|
||||
Seedance10Lite,
|
||||
|
||||
@@ -86,6 +92,9 @@ pub enum RouterVideoModel {
|
||||
#[serde(rename = "seedance_2p5_preview")]
|
||||
Seedance2p5Preview,
|
||||
|
||||
#[serde(rename = "seedance_2p5")]
|
||||
Seedance2p5,
|
||||
|
||||
#[serde(rename = "happy_horse_1p0")]
|
||||
HappyHorse1p0,
|
||||
|
||||
@@ -150,6 +159,13 @@ 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 veo_3p1_lite_and_vidu_variants_serialize() {
|
||||
// NB: These strings must match `CommonVideoModel` — the two enums convert
|
||||
@@ -173,4 +189,12 @@ mod tests {
|
||||
// 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)]
|
||||
|
||||
+11
@@ -78,7 +78,9 @@ use crate::generate::generate_video::providers::fal::vidu_q3::build::build_fal_v
|
||||
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_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;
|
||||
|
||||
@@ -138,6 +140,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,
|
||||
|
||||
@@ -166,6 +174,7 @@ impl Default for GenerateVideoRequestBuilder {
|
||||
duration_seconds: None,
|
||||
video_batch_count: None,
|
||||
generate_audio: None,
|
||||
total_reference_video_input_seconds: None,
|
||||
idempotency_token: None,
|
||||
}
|
||||
}
|
||||
@@ -201,6 +210,7 @@ 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::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),
|
||||
@@ -252,6 +262,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(),
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -82,6 +82,7 @@ pub fn build_artcraft_omni_video_request(
|
||||
negative_prompt: None,
|
||||
generate_audio: None,
|
||||
quality: None,
|
||||
estimate_only: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -63,6 +63,7 @@ mod tests {
|
||||
duration_seconds: Some(5),
|
||||
video_batch_count: Some(1),
|
||||
generate_audio: None,
|
||||
estimate_only: None,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
+1
@@ -32,6 +32,7 @@ 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 sora_2;
|
||||
pub mod sora_2_pro;
|
||||
|
||||
+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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
use enums::common::generation::common_resolution::CommonResolution;
|
||||
use kinovi_web_client::generate::video::generate_seedance_2p5::MAX_BILLED_INPUT_SECONDS;
|
||||
|
||||
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). Input seconds are clamped to
|
||||
/// [`MAX_BILLED_INPUT_SECONDS`] — the model accepts at most 30 seconds of
|
||||
/// video. 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 {
|
||||
let total_input_seconds = self.total_input_seconds
|
||||
.min(u16::from(MAX_BILLED_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_output_duration_only() {
|
||||
// 7.24279835 ¢/s × 10 = 72.43 → 73¢.
|
||||
assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 10, None), 73);
|
||||
}
|
||||
|
||||
#[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()
|
||||
}
|
||||
}
|
||||
+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 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
|
||||
}
|
||||
}
|
||||
+40
@@ -141,6 +141,46 @@ 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. 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,
|
||||
};
|
||||
(rate, u64::from(duration_seconds) + u64::from(total_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
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn mini_usd_cents(
|
||||
resolution: CommonResolution,
|
||||
|
||||
+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>,
|
||||
|
||||
+6
-5
@@ -59,20 +59,21 @@ impl KinoviSeedance2p0DraftState {
|
||||
|
||||
if let Some(remaining) = self.unhandled_request_state.take() {
|
||||
let map = draft_context.media_file_to_artcraft_url_map;
|
||||
let predownloaded = draft_context.predownloaded_media_paths;
|
||||
|
||||
start_frame_url = resolve_and_upload_single(session, remaining.start_frame, map).await?;
|
||||
end_frame_url = resolve_and_upload_single(session, remaining.end_frame, map).await?;
|
||||
start_frame_url = resolve_and_upload_single(session, remaining.start_frame, map, predownloaded).await?;
|
||||
end_frame_url = resolve_and_upload_single(session, remaining.end_frame, map, predownloaded).await?;
|
||||
|
||||
reference_image_urls = resolve_and_upload_list(
|
||||
session, remaining.reference_images.map(image_list_ref_into_urls_or_tokens), map,
|
||||
session, remaining.reference_images.map(image_list_ref_into_urls_or_tokens), map, predownloaded,
|
||||
).await?;
|
||||
|
||||
reference_video_urls = resolve_and_upload_list(
|
||||
session, remaining.reference_videos.map(video_list_ref_into_urls_or_tokens), map,
|
||||
session, remaining.reference_videos.map(video_list_ref_into_urls_or_tokens), map, predownloaded,
|
||||
).await?;
|
||||
|
||||
reference_audio_urls = resolve_and_upload_list(
|
||||
session, remaining.reference_audio.map(audio_list_ref_into_urls_or_tokens), map,
|
||||
session, remaining.reference_audio.map(audio_list_ref_into_urls_or_tokens), map, predownloaded,
|
||||
).await?;
|
||||
|
||||
character_ids = resolve_character_tokens(
|
||||
|
||||
+6
-5
@@ -56,20 +56,21 @@ impl KinoviSeedance2p0FastDraftState {
|
||||
|
||||
if let Some(remaining) = self.unhandled_request_state.take() {
|
||||
let map = draft_context.media_file_to_artcraft_url_map;
|
||||
let predownloaded = draft_context.predownloaded_media_paths;
|
||||
|
||||
start_frame_url = resolve_and_upload_single(session, remaining.start_frame, map).await?;
|
||||
end_frame_url = resolve_and_upload_single(session, remaining.end_frame, map).await?;
|
||||
start_frame_url = resolve_and_upload_single(session, remaining.start_frame, map, predownloaded).await?;
|
||||
end_frame_url = resolve_and_upload_single(session, remaining.end_frame, map, predownloaded).await?;
|
||||
|
||||
reference_image_urls = resolve_and_upload_list(
|
||||
session, remaining.reference_images.map(image_list_ref_into_urls_or_tokens), map,
|
||||
session, remaining.reference_images.map(image_list_ref_into_urls_or_tokens), map, predownloaded,
|
||||
).await?;
|
||||
|
||||
reference_video_urls = resolve_and_upload_list(
|
||||
session, remaining.reference_videos.map(video_list_ref_into_urls_or_tokens), map,
|
||||
session, remaining.reference_videos.map(video_list_ref_into_urls_or_tokens), map, predownloaded,
|
||||
).await?;
|
||||
|
||||
reference_audio_urls = resolve_and_upload_list(
|
||||
session, remaining.reference_audio.map(audio_list_ref_into_urls_or_tokens), map,
|
||||
session, remaining.reference_audio.map(audio_list_ref_into_urls_or_tokens), map, predownloaded,
|
||||
).await?;
|
||||
|
||||
character_ids = resolve_character_tokens(
|
||||
|
||||
+6
-5
@@ -56,20 +56,21 @@ impl KinoviSeedance2p0MiniDraftState {
|
||||
|
||||
if let Some(remaining) = self.unhandled_request_state.take() {
|
||||
let map = draft_context.media_file_to_artcraft_url_map;
|
||||
let predownloaded = draft_context.predownloaded_media_paths;
|
||||
|
||||
start_frame_url = resolve_and_upload_single(session, remaining.start_frame, map).await?;
|
||||
end_frame_url = resolve_and_upload_single(session, remaining.end_frame, map).await?;
|
||||
start_frame_url = resolve_and_upload_single(session, remaining.start_frame, map, predownloaded).await?;
|
||||
end_frame_url = resolve_and_upload_single(session, remaining.end_frame, map, predownloaded).await?;
|
||||
|
||||
reference_image_urls = resolve_and_upload_list(
|
||||
session, remaining.reference_images.map(image_list_ref_into_urls_or_tokens), map,
|
||||
session, remaining.reference_images.map(image_list_ref_into_urls_or_tokens), map, predownloaded,
|
||||
).await?;
|
||||
|
||||
reference_video_urls = resolve_and_upload_list(
|
||||
session, remaining.reference_videos.map(video_list_ref_into_urls_or_tokens), map,
|
||||
session, remaining.reference_videos.map(video_list_ref_into_urls_or_tokens), map, predownloaded,
|
||||
).await?;
|
||||
|
||||
reference_audio_urls = resolve_and_upload_list(
|
||||
session, remaining.reference_audio.map(audio_list_ref_into_urls_or_tokens), map,
|
||||
session, remaining.reference_audio.map(audio_list_ref_into_urls_or_tokens), map, predownloaded,
|
||||
).await?;
|
||||
|
||||
character_ids = resolve_character_tokens(
|
||||
|
||||
+453
@@ -0,0 +1,453 @@
|
||||
use kinovi_web_client::generate::video::generate_seedance_2p5::{
|
||||
KinoviSeedance2p5AspectRatio as KinoviAspectRatio,
|
||||
KinoviSeedance2p5OutputResolution as KinoviOutputResolution,
|
||||
};
|
||||
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
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::kinovi::seedance_2p5::draft::{KinoviSeedance2p5DraftState, KinoviSeedance2p5RemainingItems};
|
||||
use crate::generate::generate_video::video_generation_draft::VideoGenerationDraftRequest;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
|
||||
pub fn build_kinovi_seedance_2p5(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
let draft = do_build_kinovi_seedance_2p5(builder)?;
|
||||
Ok(VideoGenerationDraftOrRequest::Draft(VideoGenerationDraftRequest::KinoviSeedance2p5(draft)))
|
||||
}
|
||||
|
||||
fn do_build_kinovi_seedance_2p5(mut builder: GenerateVideoRequestBuilder) -> Result<KinoviSeedance2p5DraftState, ArtcraftRouterError> {
|
||||
let strategy = builder.request_mismatch_mitigation_strategy;
|
||||
|
||||
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();
|
||||
|
||||
// Keyframe (image-to-video) and reference modes are mutually exclusive on
|
||||
// the Seedance 2.5 API. Dropping either set would change the meaning of
|
||||
// the request, so this errors regardless of strategy.
|
||||
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(),
|
||||
}));
|
||||
}
|
||||
|
||||
// An end frame requires a start frame.
|
||||
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(),
|
||||
}));
|
||||
}
|
||||
|
||||
// Kinovi character references are not supported by the 2.5 API.
|
||||
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(),
|
||||
}));
|
||||
}
|
||||
|
||||
// Keyframe mode only supports "Adaptive" (the aspect ratio follows the
|
||||
// input frames), so any explicit aspect ratio preference is ignored there.
|
||||
// The planned value below only applies to reference mode.
|
||||
let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio.take(), strategy)?;
|
||||
let resolution = plan_output_resolution(builder.resolution.take(), strategy)?;
|
||||
plan_batch_count(builder.video_batch_count.take(), strategy)?;
|
||||
let duration_seconds = plan_duration(builder.duration_seconds.take(), strategy)?;
|
||||
let prompt = builder.prompt.take().unwrap_or_default();
|
||||
|
||||
let total_input_seconds = builder.total_reference_video_input_seconds
|
||||
.map(|seconds| u8::try_from(seconds).unwrap_or(u8::MAX));
|
||||
|
||||
// NB: `builder.bitrate` is intentionally ignored — 2.5 has no bitrate
|
||||
// option (and bitrate never affects cost).
|
||||
|
||||
let unhandled_request_state = KinoviSeedance2p5RemainingItems {
|
||||
start_frame: builder.start_frame.take(),
|
||||
end_frame: builder.end_frame.take(),
|
||||
reference_images: builder.reference_images.take(),
|
||||
reference_videos: builder.reference_videos.take(),
|
||||
reference_audio: builder.reference_audio.take(),
|
||||
};
|
||||
|
||||
Ok(KinoviSeedance2p5DraftState {
|
||||
aspect_ratio,
|
||||
resolution,
|
||||
duration_seconds,
|
||||
prompt,
|
||||
total_input_seconds,
|
||||
unhandled_request_state: Some(unhandled_request_state),
|
||||
})
|
||||
}
|
||||
|
||||
// ── Plan helpers ──
|
||||
|
||||
// Seedance 2.5 reference mode supports all six aspect ratios:
|
||||
// 16:9, 21:9, 9:16, 1:1, 4:3, 3:4. All supported ratios cost the same, so
|
||||
// both upgrade and downgrade pick the nearest match. (Keyframe mode is
|
||||
// always Adaptive and ignores this entirely.)
|
||||
fn plan_aspect_ratio(
|
||||
aspect_ratio: Option<RouterAspectRatio>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<KinoviAspectRatio, ArtcraftRouterError> {
|
||||
match aspect_ratio {
|
||||
// No preference or auto — default to landscape
|
||||
None
|
||||
| Some(RouterAspectRatio::Auto)
|
||||
| Some(RouterAspectRatio::Auto2k)
|
||||
| Some(RouterAspectRatio::Auto4k) => Ok(KinoviAspectRatio::Landscape16x9),
|
||||
|
||||
// Direct mappings
|
||||
Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => {
|
||||
Ok(KinoviAspectRatio::Landscape16x9)
|
||||
}
|
||||
Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => {
|
||||
Ok(KinoviAspectRatio::Portrait9x16)
|
||||
}
|
||||
Some(RouterAspectRatio::Square) | Some(RouterAspectRatio::SquareHd) => {
|
||||
Ok(KinoviAspectRatio::Square1x1)
|
||||
}
|
||||
Some(RouterAspectRatio::WideFourByThree) => Ok(KinoviAspectRatio::Standard4x3),
|
||||
Some(RouterAspectRatio::TallThreeByFour) => Ok(KinoviAspectRatio::Portrait3x4),
|
||||
Some(RouterAspectRatio::WideTwentyOneByNine) => Ok(KinoviAspectRatio::UltraWide21x9),
|
||||
|
||||
// Mismatches — apply strategy
|
||||
Some(unsupported) => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "aspect_ratio",
|
||||
value: format!("{:?}", unsupported),
|
||||
}))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade
|
||||
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
|
||||
Ok(nearest_aspect_ratio(unsupported))
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick the nearest supported aspect ratio by AR value (width / height).
|
||||
fn nearest_aspect_ratio(aspect_ratio: RouterAspectRatio) -> KinoviAspectRatio {
|
||||
match aspect_ratio {
|
||||
RouterAspectRatio::WideFiveByFour => KinoviAspectRatio::Standard4x3, // 1.25, nearest 1.33
|
||||
RouterAspectRatio::WideThreeByTwo => KinoviAspectRatio::Standard4x3, // 1.50, nearest 1.33
|
||||
RouterAspectRatio::TallFourByFive => KinoviAspectRatio::Portrait3x4, // 0.80, nearest 0.75
|
||||
RouterAspectRatio::TallTwoByThree => KinoviAspectRatio::Portrait3x4, // 0.67, nearest 0.75
|
||||
RouterAspectRatio::TallNineByTwentyOne => KinoviAspectRatio::Portrait9x16, // 0.43, nearest 0.56
|
||||
_ => KinoviAspectRatio::Square1x1,
|
||||
}
|
||||
}
|
||||
|
||||
// Seedance 2.5 supports output resolutions: 480p and 720p only.
|
||||
// 1080p (and higher) is NOT supported — downgrade to 720p or error based on strategy.
|
||||
fn plan_output_resolution(
|
||||
resolution: Option<RouterResolution>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Option<KinoviOutputResolution>, ArtcraftRouterError> {
|
||||
match resolution {
|
||||
None => Ok(None),
|
||||
|
||||
// Direct mappings
|
||||
Some(RouterResolution::FourEightyP) => Ok(Some(KinoviOutputResolution::FourEightyP)),
|
||||
Some(RouterResolution::SevenTwentyP) => Ok(Some(KinoviOutputResolution::SevenTwentyP)),
|
||||
|
||||
// 1080p is not supported — handle via strategy
|
||||
Some(RouterResolution::TenEightyP) => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "resolution",
|
||||
value: format!("{:?}", RouterResolution::TenEightyP),
|
||||
}))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade
|
||||
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
|
||||
// 1080p not available — downgrade to 720p (highest supported)
|
||||
Ok(Some(KinoviOutputResolution::SevenTwentyP))
|
||||
}
|
||||
},
|
||||
|
||||
// Other unsupported resolutions
|
||||
Some(unsupported) => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "resolution",
|
||||
value: format!("{:?}", unsupported),
|
||||
}))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade
|
||||
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
|
||||
Ok(Some(match unsupported {
|
||||
RouterResolution::HalfK => KinoviOutputResolution::FourEightyP,
|
||||
_ => KinoviOutputResolution::SevenTwentyP,
|
||||
}))
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 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(()),
|
||||
// Over the maximum of 1.
|
||||
_ => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "video_batch_count",
|
||||
value: format!("{}", count),
|
||||
}))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade
|
||||
| RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Seedance 2.5 supports durations of 4–30 seconds.
|
||||
fn plan_duration(
|
||||
duration_seconds: Option<u16>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<u8, ArtcraftRouterError> {
|
||||
const MIN: u16 = 4;
|
||||
const MAX: u16 = 30;
|
||||
const DEFAULT: u8 = 5;
|
||||
match duration_seconds {
|
||||
None => Ok(DEFAULT),
|
||||
Some(d) if d >= MIN && d <= MAX => Ok(d as u8),
|
||||
Some(d) => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "duration_seconds",
|
||||
value: format!("{}", d),
|
||||
}))
|
||||
}
|
||||
_ => Ok(d.clamp(MIN, MAX) as u8),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use kinovi_web_client::generate::video::generate_seedance_2p5::{
|
||||
KinoviSeedance2p5AspectRatio as KinoviAspectRatio,
|
||||
KinoviSeedance2p5OutputResolution as KinoviOutputResolution,
|
||||
};
|
||||
use tokens::tokens::characters::CharacterToken;
|
||||
|
||||
use crate::api::character_list_ref::CharacterListRef;
|
||||
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_resolution::RouterResolution;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
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::providers::kinovi::seedance_2p5::draft::KinoviSeedance2p5DraftState;
|
||||
use crate::generate::generate_video::video_generation_draft::VideoGenerationDraftRequest;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
|
||||
use super::*;
|
||||
|
||||
mod materialized_field_conversions {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn prompt_is_passed_through() {
|
||||
let draft = unwrap_draft(build_kinovi_seedance_2p5(base_builder()));
|
||||
assert_eq!(draft.prompt, "a cat dancing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_defaults_to_5() {
|
||||
let builder = GenerateVideoRequestBuilder { duration_seconds: None, ..base_builder() };
|
||||
let draft = unwrap_draft(build_kinovi_seedance_2p5(builder));
|
||||
assert_eq!(draft.duration_seconds, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_clamped_to_range() {
|
||||
let over = GenerateVideoRequestBuilder { duration_seconds: Some(99), ..base_builder() };
|
||||
assert_eq!(unwrap_draft(build_kinovi_seedance_2p5(over)).duration_seconds, 30);
|
||||
|
||||
let under = GenerateVideoRequestBuilder { duration_seconds: Some(2), ..base_builder() };
|
||||
assert_eq!(unwrap_draft(build_kinovi_seedance_2p5(under)).duration_seconds, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn total_input_seconds_is_carried_through() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
total_reference_video_input_seconds: Some(14),
|
||||
..base_builder()
|
||||
};
|
||||
let draft = unwrap_draft(build_kinovi_seedance_2p5(builder));
|
||||
assert_eq!(draft.total_input_seconds, Some(14));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn total_input_seconds_saturates_at_u8_max() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
total_reference_video_input_seconds: Some(9_999),
|
||||
..base_builder()
|
||||
};
|
||||
let draft = unwrap_draft(build_kinovi_seedance_2p5(builder));
|
||||
assert_eq!(draft.total_input_seconds, Some(u8::MAX));
|
||||
}
|
||||
}
|
||||
|
||||
mod modality_rules {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn keyframes_are_accepted() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
start_frame: Some(ImageRef::Url("https://example.com/start.jpg".to_string())),
|
||||
end_frame: Some(ImageRef::Url("https://example.com/end.jpg".to_string())),
|
||||
..base_builder()
|
||||
};
|
||||
let draft = unwrap_draft(build_kinovi_seedance_2p5(builder));
|
||||
let remaining = draft.unhandled_request_state.unwrap();
|
||||
assert!(remaining.start_frame.is_some());
|
||||
assert!(remaining.end_frame.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_frame_alone_is_accepted() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
start_frame: Some(ImageRef::Url("https://example.com/start.jpg".to_string())),
|
||||
..base_builder()
|
||||
};
|
||||
assert!(build_kinovi_seedance_2p5(builder).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_frame_without_start_frame_errors() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
end_frame: Some(ImageRef::Url("https://example.com/end.jpg".to_string())),
|
||||
..base_builder()
|
||||
};
|
||||
assert!(build_kinovi_seedance_2p5(builder).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyframes_plus_references_error() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
start_frame: Some(ImageRef::Url("https://example.com/start.jpg".to_string())),
|
||||
reference_images: Some(ImageListRef::Urls(vec!["https://example.com/ref.jpg".to_string()])),
|
||||
..base_builder()
|
||||
};
|
||||
assert!(build_kinovi_seedance_2p5(builder).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_references_error() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
reference_character_tokens: Some(CharacterListRef::CharacterTokens(vec![
|
||||
CharacterToken::new("char_abc".to_string()),
|
||||
])),
|
||||
..base_builder()
|
||||
};
|
||||
assert!(build_kinovi_seedance_2p5(builder).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_media_lands_in_unhandled() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
reference_images: Some(ImageListRef::Urls(vec!["https://example.com/ref.jpg".to_string()])),
|
||||
reference_videos: Some(VideoListRef::Urls(vec!["https://example.com/vid.mp4".to_string()])),
|
||||
..base_builder()
|
||||
};
|
||||
let draft = unwrap_draft(build_kinovi_seedance_2p5(builder));
|
||||
let remaining = draft.unhandled_request_state.unwrap();
|
||||
assert!(remaining.reference_images.is_some());
|
||||
assert!(remaining.reference_videos.is_some());
|
||||
}
|
||||
}
|
||||
|
||||
mod aspect_ratio_and_resolution {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn direct_aspect_ratio_mappings() {
|
||||
let expected = [
|
||||
(RouterAspectRatio::WideSixteenByNine, KinoviAspectRatio::Landscape16x9),
|
||||
(RouterAspectRatio::WideTwentyOneByNine, KinoviAspectRatio::UltraWide21x9),
|
||||
(RouterAspectRatio::TallNineBySixteen, KinoviAspectRatio::Portrait9x16),
|
||||
(RouterAspectRatio::Square, KinoviAspectRatio::Square1x1),
|
||||
(RouterAspectRatio::WideFourByThree, KinoviAspectRatio::Standard4x3),
|
||||
(RouterAspectRatio::TallThreeByFour, KinoviAspectRatio::Portrait3x4),
|
||||
];
|
||||
for (input, output) in expected {
|
||||
let builder = GenerateVideoRequestBuilder { aspect_ratio: Some(input), ..base_builder() };
|
||||
let draft = unwrap_draft(build_kinovi_seedance_2p5(builder));
|
||||
assert!(
|
||||
std::mem::discriminant(&draft.aspect_ratio) == std::mem::discriminant(&output),
|
||||
"aspect ratio {input:?} mapped wrong",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolution_480p_and_720p() {
|
||||
let builder = GenerateVideoRequestBuilder { resolution: Some(RouterResolution::FourEightyP), ..base_builder() };
|
||||
assert!(matches!(unwrap_draft(build_kinovi_seedance_2p5(builder)).resolution, Some(KinoviOutputResolution::FourEightyP)));
|
||||
|
||||
let builder = GenerateVideoRequestBuilder { resolution: Some(RouterResolution::SevenTwentyP), ..base_builder() };
|
||||
assert!(matches!(unwrap_draft(build_kinovi_seedance_2p5(builder)).resolution, Some(KinoviOutputResolution::SevenTwentyP)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolution_1080p_downgrades_to_720p() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
resolution: Some(RouterResolution::TenEightyP),
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayLessDowngrade,
|
||||
..base_builder()
|
||||
};
|
||||
assert!(matches!(unwrap_draft(build_kinovi_seedance_2p5(builder)).resolution, Some(KinoviOutputResolution::SevenTwentyP)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolution_1080p_error_out() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
resolution: Some(RouterResolution::TenEightyP),
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
|
||||
..base_builder()
|
||||
};
|
||||
assert!(build_kinovi_seedance_2p5(builder).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
fn base_builder() -> GenerateVideoRequestBuilder {
|
||||
GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Seedance2p5,
|
||||
provider: RouterProvider::KinoviWeb,
|
||||
prompt: Some("a cat dancing".to_string()),
|
||||
duration_seconds: Some(5),
|
||||
video_batch_count: Some(1),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn unwrap_draft(result: Result<VideoGenerationDraftOrRequest, ArtcraftRouterError>) -> KinoviSeedance2p5DraftState {
|
||||
match result.expect("build should succeed") {
|
||||
VideoGenerationDraftOrRequest::Draft(
|
||||
VideoGenerationDraftRequest::KinoviSeedance2p5(draft)
|
||||
) => draft,
|
||||
_ => panic!("expected KinoviSeedance2p5 draft"),
|
||||
}
|
||||
}
|
||||
}
|
||||
+222
@@ -0,0 +1,222 @@
|
||||
use kinovi_web_client::generate::video::generate_seedance_2p5::{
|
||||
GenerateSeedance2p5Request, KinoviSeedance2p5Modality, KinoviSeedance2p5OutputResolution,
|
||||
MAX_BILLED_INPUT_SECONDS,
|
||||
};
|
||||
|
||||
use crate::api::video_list_ref::VideoListRef;
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p5::draft::KinoviSeedance2p5DraftState;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p5::request::KinoviSeedance2p5RequestState;
|
||||
|
||||
/// 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). Input seconds are clamped to
|
||||
/// [`MAX_BILLED_INPUT_SECONDS`] — the model accepts at most 30 seconds of
|
||||
/// video.
|
||||
pub struct KinoviSeedance2p5CostState {
|
||||
pub resolution: Option<KinoviSeedance2p5OutputResolution>,
|
||||
pub duration_seconds: u8,
|
||||
pub has_video_references: bool,
|
||||
pub total_input_seconds: Option<u8>,
|
||||
}
|
||||
|
||||
impl KinoviSeedance2p5CostState {
|
||||
pub fn from_request(request: &KinoviSeedance2p5RequestState) -> Self {
|
||||
let has_video_references = matches!(
|
||||
&request.request.modality,
|
||||
KinoviSeedance2p5Modality::Reference { reference_video_urls: Some(urls), .. } if !urls.is_empty()
|
||||
);
|
||||
|
||||
Self {
|
||||
resolution: request.request.output_resolution,
|
||||
duration_seconds: request.request.duration_seconds,
|
||||
has_video_references,
|
||||
total_input_seconds: request.request.total_input_seconds,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_draft(draft: &KinoviSeedance2p5DraftState) -> Self {
|
||||
let has_video_references = draft.unhandled_request_state
|
||||
.as_ref()
|
||||
.and_then(|remaining| remaining.reference_videos.as_ref())
|
||||
.is_some_and(|videos| match videos {
|
||||
VideoListRef::MediaFileTokens(tokens) => !tokens.is_empty(),
|
||||
VideoListRef::Urls(urls) => !urls.is_empty(),
|
||||
});
|
||||
|
||||
Self {
|
||||
resolution: draft.resolution,
|
||||
duration_seconds: draft.duration_seconds,
|
||||
has_video_references,
|
||||
total_input_seconds: draft.total_input_seconds,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
|
||||
// Delegate to the kinovi_web_client calculator via a pricing-only
|
||||
// request. Only the modality's video references, the resolution, the
|
||||
// duration, and the input seconds affect the price.
|
||||
let modality = if self.has_video_references {
|
||||
KinoviSeedance2p5Modality::Reference {
|
||||
aspect_ratio: None,
|
||||
reference_image_urls: None,
|
||||
reference_video_urls: Some(vec!["https://pricing-only.invalid/ref.mp4".to_string()]),
|
||||
reference_audio_urls: None,
|
||||
}
|
||||
} else {
|
||||
KinoviSeedance2p5Modality::Reference {
|
||||
aspect_ratio: None,
|
||||
reference_image_urls: None,
|
||||
reference_video_urls: None,
|
||||
reference_audio_urls: None,
|
||||
}
|
||||
};
|
||||
|
||||
// NB: The kinovi calculator clamps too; clamping here as well keeps this
|
||||
// state's math correct even if it's read directly.
|
||||
let total_input_seconds = self.total_input_seconds
|
||||
.map(|seconds| seconds.min(MAX_BILLED_INPUT_SECONDS));
|
||||
|
||||
let pricing_request = GenerateSeedance2p5Request {
|
||||
prompt: String::new(),
|
||||
modality,
|
||||
output_resolution: self.resolution,
|
||||
duration_seconds: self.duration_seconds,
|
||||
total_input_seconds,
|
||||
use_face_blur_hack: None,
|
||||
};
|
||||
|
||||
let costs = pricing_request.calculate_costs();
|
||||
// 2.5 bills whole credits, so the rounding below is exact; the USD cents
|
||||
// (the authoritative charge) are rounded up.
|
||||
let cost_in_credits = costs.kinovi_credits.round() as u64;
|
||||
let cost_in_usd_cents = costs.usd_cents_rounded_up;
|
||||
|
||||
VideoGenerationCostEstimate {
|
||||
cost_in_credits: Some(cost_in_credits),
|
||||
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 kinovi_web_client::generate::video::generate_seedance_2p5::KinoviSeedance2p5OutputResolution as KinoviOutputResolution;
|
||||
|
||||
use super::*;
|
||||
|
||||
// ── Credits without video references (26/sec at 480p, 59/sec at 720p) ──
|
||||
|
||||
mod credits_without_video_references {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn credits_480p() {
|
||||
assert_eq!(credits(Some(KinoviOutputResolution::FourEightyP), 5, false, None), 130);
|
||||
assert_eq!(credits(Some(KinoviOutputResolution::FourEightyP), 10, false, None), 260);
|
||||
assert_eq!(credits(Some(KinoviOutputResolution::FourEightyP), 30, false, None), 780);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credits_720p() {
|
||||
assert_eq!(credits(Some(KinoviOutputResolution::SevenTwentyP), 5, false, None), 295);
|
||||
assert_eq!(credits(Some(KinoviOutputResolution::SevenTwentyP), 30, false, None), 1770);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_resolution_is_720p() {
|
||||
assert_eq!(credits(None, 10, false, None), credits(Some(KinoviOutputResolution::SevenTwentyP), 10, false, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_seconds_ignored_without_video_references() {
|
||||
assert_eq!(credits(Some(KinoviOutputResolution::FourEightyP), 10, false, Some(60)), 260);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Credits with video references (16/sec at 480p, 35/sec at 720p,
|
||||
// over output duration + input seconds) ──
|
||||
|
||||
mod credits_with_video_references {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn thirty_second_output_with_ten_input_seconds_bills_forty() {
|
||||
assert_eq!(credits(Some(KinoviOutputResolution::FourEightyP), 30, true, Some(10)), 16 * 40);
|
||||
assert_eq!(credits(Some(KinoviOutputResolution::SevenTwentyP), 30, true, Some(10)), 35 * 40);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fourteen_input_seconds_bill_forty_four() {
|
||||
assert_eq!(credits(Some(KinoviOutputResolution::FourEightyP), 30, true, Some(14)), 16 * 44);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_input_seconds_bill_the_worst_case_maximum() {
|
||||
// Unknown input duration with video references attached: the kinovi
|
||||
// estimator assumes the 30-second maximum so the provider-cost
|
||||
// estimate never undershoots the actual charge.
|
||||
assert_eq!(credits(Some(KinoviOutputResolution::FourEightyP), 10, true, None), 16 * 40);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_seconds_clamp_to_max_billed_input_seconds() {
|
||||
// 200 input seconds clamp to 30: 30s output + 30 = 60 billed seconds.
|
||||
assert_eq!(credits(Some(KinoviOutputResolution::FourEightyP), 30, true, Some(200)), 16 * 60);
|
||||
assert_eq!(
|
||||
credits(Some(KinoviOutputResolution::FourEightyP), 30, true, Some(200)),
|
||||
credits(Some(KinoviOutputResolution::FourEightyP), 30, true, Some(30)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── USD cents (rounded up) ──
|
||||
|
||||
#[test]
|
||||
fn usd_cents_are_rounded_up() {
|
||||
// 130 credits → 13000/243 = 53.4979 → 54¢.
|
||||
let estimate = cost_state(Some(KinoviOutputResolution::FourEightyP), 5, false, None).estimate_cost();
|
||||
assert_eq!(estimate.cost_in_usd_cents, Some(54));
|
||||
}
|
||||
|
||||
// ── Estimate flags ──
|
||||
|
||||
#[test]
|
||||
fn estimate_flags() {
|
||||
let estimate = cost_state(Some(KinoviOutputResolution::FourEightyP), 5, false, None).estimate_cost();
|
||||
assert!(!estimate.is_free);
|
||||
assert!(!estimate.is_unlimited);
|
||||
assert!(!estimate.is_rate_limited);
|
||||
assert!(!estimate.has_watermark);
|
||||
assert!(estimate.failures_are_refunded.is_none());
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
fn cost_state(
|
||||
resolution: Option<KinoviOutputResolution>,
|
||||
duration_seconds: u8,
|
||||
has_video_references: bool,
|
||||
total_input_seconds: Option<u8>,
|
||||
) -> KinoviSeedance2p5CostState {
|
||||
KinoviSeedance2p5CostState { resolution, duration_seconds, has_video_references, total_input_seconds }
|
||||
}
|
||||
|
||||
fn credits(
|
||||
resolution: Option<KinoviOutputResolution>,
|
||||
duration_seconds: u8,
|
||||
has_video_references: bool,
|
||||
total_input_seconds: Option<u8>,
|
||||
) -> u64 {
|
||||
cost_state(resolution, duration_seconds, has_video_references, total_input_seconds)
|
||||
.estimate_cost()
|
||||
.cost_in_credits
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
use kinovi_web_client::generate::video::generate_seedance_2p5::{
|
||||
GenerateSeedance2p5Request, KinoviSeedance2p5AspectRatio, KinoviSeedance2p5Modality,
|
||||
KinoviSeedance2p5OutputResolution,
|
||||
};
|
||||
|
||||
use crate::api::audio_list_ref::AudioListRef;
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::video_list_ref::VideoListRef;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::client_error::ClientError;
|
||||
use crate::generate::generate_video::providers::kinovi::resolve::{
|
||||
audio_list_ref_into_urls_or_tokens, image_list_ref_into_urls_or_tokens,
|
||||
resolve_and_upload_list, resolve_and_upload_single, video_list_ref_into_urls_or_tokens,
|
||||
};
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p5::request::KinoviSeedance2p5RequestState;
|
||||
use crate::generate::generate_video::video_generation_draft_context::VideoGenerationDraftContext;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct KinoviSeedance2p5DraftState {
|
||||
pub prompt: String,
|
||||
|
||||
/// Aspect ratio for the reference (text/reference-to-video) modality.
|
||||
/// Keyframe mode is always "Adaptive" and ignores this.
|
||||
pub aspect_ratio: KinoviSeedance2p5AspectRatio,
|
||||
|
||||
pub resolution: Option<KinoviSeedance2p5OutputResolution>,
|
||||
|
||||
pub duration_seconds: u8,
|
||||
|
||||
/// Calculation-only: total seconds of reference video input, summed across
|
||||
/// all reference videos (each rounded up to a whole second).
|
||||
pub total_input_seconds: Option<u8>,
|
||||
|
||||
pub unhandled_request_state: Option<KinoviSeedance2p5RemainingItems>,
|
||||
}
|
||||
|
||||
/// Media inputs not yet resolved/uploaded. The build step guarantees that
|
||||
/// keyframes and reference media are mutually exclusive.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct KinoviSeedance2p5RemainingItems {
|
||||
pub start_frame: Option<ImageRef>,
|
||||
pub end_frame: Option<ImageRef>,
|
||||
pub reference_images: Option<ImageListRef>,
|
||||
pub reference_videos: Option<VideoListRef>,
|
||||
pub reference_audio: Option<AudioListRef>,
|
||||
}
|
||||
|
||||
impl KinoviSeedance2p5DraftState {
|
||||
pub async fn to_request(
|
||||
&mut self,
|
||||
draft_context: &VideoGenerationDraftContext<'_>,
|
||||
) -> Result<KinoviSeedance2p5RequestState, ArtcraftRouterError> {
|
||||
let client = draft_context.get_kinovi_web_client_ref()?;
|
||||
let session = &client.session;
|
||||
|
||||
let mut start_frame_url = None;
|
||||
let mut end_frame_url = None;
|
||||
let mut reference_image_urls = None;
|
||||
let mut reference_video_urls = None;
|
||||
let mut reference_audio_urls = None;
|
||||
|
||||
if let Some(remaining) = self.unhandled_request_state.take() {
|
||||
let map = draft_context.media_file_to_artcraft_url_map;
|
||||
let predownloaded = draft_context.predownloaded_media_paths;
|
||||
|
||||
start_frame_url = resolve_and_upload_single(session, remaining.start_frame, map, predownloaded).await?;
|
||||
end_frame_url = resolve_and_upload_single(session, remaining.end_frame, map, predownloaded).await?;
|
||||
|
||||
reference_image_urls = resolve_and_upload_list(
|
||||
session, remaining.reference_images.map(image_list_ref_into_urls_or_tokens), map, predownloaded,
|
||||
).await?;
|
||||
|
||||
reference_video_urls = resolve_and_upload_list(
|
||||
session, remaining.reference_videos.map(video_list_ref_into_urls_or_tokens), map, predownloaded,
|
||||
).await?;
|
||||
|
||||
reference_audio_urls = resolve_and_upload_list(
|
||||
session, remaining.reference_audio.map(audio_list_ref_into_urls_or_tokens), map, predownloaded,
|
||||
).await?;
|
||||
}
|
||||
|
||||
let modality = match start_frame_url {
|
||||
Some(start_frame_url) => KinoviSeedance2p5Modality::Keyframe {
|
||||
start_frame_url,
|
||||
end_frame_url,
|
||||
},
|
||||
None => {
|
||||
// The build step rejects an end frame without a start frame, but a
|
||||
// failed resolution could recreate the situation — never send an
|
||||
// orphaned end frame as a reference request.
|
||||
if end_frame_url.is_some() {
|
||||
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(),
|
||||
}));
|
||||
}
|
||||
KinoviSeedance2p5Modality::Reference {
|
||||
aspect_ratio: Some(self.aspect_ratio),
|
||||
reference_image_urls,
|
||||
reference_video_urls,
|
||||
reference_audio_urls,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let request = GenerateSeedance2p5Request {
|
||||
prompt: self.prompt.clone(),
|
||||
modality,
|
||||
output_resolution: self.resolution,
|
||||
duration_seconds: self.duration_seconds,
|
||||
total_input_seconds: self.total_input_seconds,
|
||||
use_face_blur_hack: None,
|
||||
};
|
||||
|
||||
Ok(KinoviSeedance2p5RequestState { request })
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
pub mod build;
|
||||
pub mod draft;
|
||||
pub mod request;
|
||||
pub mod cost;
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
use kinovi_web_client::generate::video::generate_seedance_2p5::{
|
||||
generate_seedance_2p5, GenerateSeedance2p5Args, GenerateSeedance2p5Request,
|
||||
};
|
||||
|
||||
use crate::client::router_kinovi_web_client::RouterKinoviWebClient;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::provider_error::ProviderError;
|
||||
use crate::generate::generate_video::generate_video_response::{GenerateVideoResponse, KinoviWebVideoResponsePayload};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct KinoviSeedance2p5RequestState {
|
||||
/// Final materialized request; ready to fire.
|
||||
pub request: GenerateSeedance2p5Request,
|
||||
}
|
||||
|
||||
impl KinoviSeedance2p5RequestState {
|
||||
pub async fn send(&self, client: &RouterKinoviWebClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
|
||||
let session = &client.session;
|
||||
|
||||
let args = GenerateSeedance2p5Args {
|
||||
session,
|
||||
host_override: None,
|
||||
request: self.request.clone(),
|
||||
};
|
||||
|
||||
let response = generate_seedance_2p5(args)
|
||||
.await
|
||||
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::KinoviWeb(err)))?;
|
||||
|
||||
Ok(GenerateVideoResponse::KinoviWeb(KinoviWebVideoResponsePayload {
|
||||
order_id: response.order_id,
|
||||
task_id: response.task_id,
|
||||
maybe_order_ids: response.order_ids,
|
||||
maybe_task_ids: response.task_ids,
|
||||
}))
|
||||
}
|
||||
}
|
||||
+4
-3
@@ -47,17 +47,18 @@ impl KinoviSeedance2p5PreviewDraftState {
|
||||
|
||||
if let Some(remaining) = self.unhandled_request_state.take() {
|
||||
let map = draft_context.media_file_to_artcraft_url_map;
|
||||
let predownloaded = draft_context.predownloaded_media_paths;
|
||||
|
||||
reference_image_urls = resolve_and_upload_list(
|
||||
session, remaining.reference_images.map(image_list_ref_into_urls_or_tokens), map,
|
||||
session, remaining.reference_images.map(image_list_ref_into_urls_or_tokens), map, predownloaded,
|
||||
).await?;
|
||||
|
||||
reference_video_urls = resolve_and_upload_list(
|
||||
session, remaining.reference_videos.map(video_list_ref_into_urls_or_tokens), map,
|
||||
session, remaining.reference_videos.map(video_list_ref_into_urls_or_tokens), map, predownloaded,
|
||||
).await?;
|
||||
|
||||
reference_audio_urls = resolve_and_upload_list(
|
||||
session, remaining.reference_audio.map(audio_list_ref_into_urls_or_tokens), map,
|
||||
session, remaining.reference_audio.map(audio_list_ref_into_urls_or_tokens), map, predownloaded,
|
||||
).await?;
|
||||
}
|
||||
|
||||
|
||||
+24
-2
@@ -1,3 +1,7 @@
|
||||
use std::path::Path;
|
||||
|
||||
use log::{info, warn};
|
||||
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::provider_error::ProviderError;
|
||||
use crate::utils::download_file::download_file;
|
||||
@@ -6,16 +10,34 @@ use kinovi_web_client::requests::prepare_file_upload::prepare_file_upload::{prep
|
||||
use kinovi_web_client::requests::upload_file::upload_file::{upload_file, UploadFileArgs};
|
||||
use url_utils::extension::extract_extension_from_url::{extract_extension_from_url_str, ExtractExtensions};
|
||||
|
||||
/// Downloads a file from a source URL and re-uploads it to kinovi_web CDN.
|
||||
/// Re-uploads a file to the kinovi_web CDN, reading it from
|
||||
/// `maybe_local_path` when the caller already downloaded it (e.g. reference
|
||||
/// videos probed for billing), or downloading it from the source URL
|
||||
/// otherwise.
|
||||
pub(crate) async fn upload_to_kinovi_web(
|
||||
session: &KinoviWebSession,
|
||||
source_url: &str,
|
||||
maybe_local_path: Option<&Path>,
|
||||
) -> Result<String, ArtcraftRouterError> {
|
||||
let extension = extract_extension_from_url_str(source_url, &ExtractExtensions::All)
|
||||
.map(|ext| ext.without_period().to_string())
|
||||
.unwrap_or_else(|| "jpg".to_string());
|
||||
|
||||
let file_bytes = download_file(source_url).await?;
|
||||
let file_bytes = match maybe_local_path {
|
||||
Some(local_path) => match std::fs::read(local_path) {
|
||||
Ok(bytes) => {
|
||||
info!("Reusing predownloaded file for kinovi upload: {}", source_url);
|
||||
bytes
|
||||
}
|
||||
Err(err) => {
|
||||
// The file should exist; fall back to downloading rather than fail.
|
||||
warn!("Failed to read predownloaded file {:?} ({:?}); re-downloading {}",
|
||||
local_path, err, source_url);
|
||||
download_file(source_url).await?
|
||||
}
|
||||
},
|
||||
None => download_file(source_url).await?,
|
||||
};
|
||||
|
||||
let prepare_response = prepare_file_upload(PrepareFileUploadArgs {
|
||||
session,
|
||||
|
||||
+9
@@ -9,6 +9,8 @@ use crate::generate::generate_video::providers::kinovi::seedance_2p0_fast::cost:
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p0_fast::draft::KinoviSeedance2p0FastDraftState;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p0_mini::cost::KinoviSeedance2p0MiniCostState;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p0_mini::draft::KinoviSeedance2p0MiniDraftState;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p5::cost::KinoviSeedance2p5CostState;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p5::draft::KinoviSeedance2p5DraftState;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p5_preview::cost::KinoviSeedance2p5PreviewCostState;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p5_preview::draft::KinoviSeedance2p5PreviewDraftState;
|
||||
use crate::generate::generate_video::video_generation_draft_context::VideoGenerationDraftContext;
|
||||
@@ -24,6 +26,7 @@ pub enum VideoGenerationDraftRequest {
|
||||
KinoviSeedance2p0Fast(KinoviSeedance2p0FastDraftState),
|
||||
KinoviSeedance2p0Mini(KinoviSeedance2p0MiniDraftState),
|
||||
KinoviSeedance2p5Preview(KinoviSeedance2p5PreviewDraftState),
|
||||
KinoviSeedance2p5(KinoviSeedance2p5DraftState),
|
||||
}
|
||||
|
||||
impl VideoGenerationDraftRequest {
|
||||
@@ -35,6 +38,7 @@ impl VideoGenerationDraftRequest {
|
||||
Self::KinoviSeedance2p0Fast(_) => RouterProvider::KinoviWeb,
|
||||
Self::KinoviSeedance2p0Mini(_) => RouterProvider::KinoviWeb,
|
||||
Self::KinoviSeedance2p5Preview(_) => RouterProvider::KinoviWeb,
|
||||
Self::KinoviSeedance2p5(_) => RouterProvider::KinoviWeb,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +50,7 @@ impl VideoGenerationDraftRequest {
|
||||
VideoGenerationDraftRequest::KinoviSeedance2p0Fast(draft) => Ok(KinoviSeedance2p0FastCostState::from_draft(draft).estimate_cost()),
|
||||
VideoGenerationDraftRequest::KinoviSeedance2p0Mini(draft) => Ok(KinoviSeedance2p0MiniCostState::from_draft(draft).estimate_cost()),
|
||||
VideoGenerationDraftRequest::KinoviSeedance2p5Preview(draft) => Ok(KinoviSeedance2p5PreviewCostState::from_draft(draft).estimate_cost()),
|
||||
VideoGenerationDraftRequest::KinoviSeedance2p5(draft) => Ok(KinoviSeedance2p5CostState::from_draft(draft).estimate_cost()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +78,10 @@ impl VideoGenerationDraftRequest {
|
||||
let result = draft.to_request(&draft_context).await?;
|
||||
Ok(VideoGenerationRequest::KinoviSeedance2p5Preview(result))
|
||||
},
|
||||
VideoGenerationDraftRequest::KinoviSeedance2p5(mut draft) => {
|
||||
let result = draft.to_request(&draft_context).await?;
|
||||
Ok(VideoGenerationRequest::KinoviSeedance2p5(result))
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+8
@@ -4,6 +4,7 @@ use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::client_error::ClientError;
|
||||
use std::collections::HashMap;
|
||||
use std::fmt::Debug;
|
||||
use std::path::PathBuf;
|
||||
use tokens::tokens::characters::CharacterToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
@@ -19,6 +20,12 @@ pub struct VideoGenerationDraftContext<'a> {
|
||||
/// Optional context: a map of Character Tokens to their respective Kinovi IDs
|
||||
/// Only necessary if using Kinovi characters
|
||||
pub character_token_to_kinovi_id_map: Option<&'a HashMap<CharacterToken, String>>,
|
||||
|
||||
/// Optional context: source URL → local file path for media the caller has
|
||||
/// already downloaded (e.g. reference videos probed for billing). Uploads
|
||||
/// read these files instead of downloading the same bytes again. The
|
||||
/// caller must keep the files alive until the upload completes.
|
||||
pub predownloaded_media_paths: Option<&'a HashMap<String, PathBuf>>,
|
||||
}
|
||||
|
||||
impl <'a> VideoGenerationDraftContext<'a> {
|
||||
@@ -45,6 +52,7 @@ impl Debug for VideoGenerationDraftContext<'_> {
|
||||
.field("client", &self.client.is_some())
|
||||
.field("media_file_to_artcraft_url_map", &self.media_file_to_artcraft_url_map)
|
||||
.field("character_token_to_kinovi_id_map", &self.character_token_to_kinovi_id_map)
|
||||
.field("predownloaded_media_paths", &self.predownloaded_media_paths)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
+18
@@ -55,6 +55,8 @@ use crate::generate::generate_video::providers::artcraft::seedance_2p0_bpu_fast:
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bpu_fast::request::ArtcraftSeedance2p0BytePlusUltraFastRequestState;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0_mini::cost::ArtcraftSeedance2p0MiniCostState;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0_mini::request::ArtcraftSeedance2p0MiniRequestState;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p5::cost::ArtcraftSeedance2p5CostState;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p5::request::ArtcraftSeedance2p5RequestState;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p5_preview::cost::ArtcraftSeedance2p5PreviewCostState;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p5_preview::request::ArtcraftSeedance2p5PreviewRequestState;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bp_mini::cost::ArtcraftSeedance2p0BytePlusMiniCostState;
|
||||
@@ -141,6 +143,8 @@ use crate::generate::generate_video::providers::kinovi::seedance_2p0_fast::cost:
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p0_fast::request::KinoviSeedance2p0FastRequestState;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p0_mini::cost::KinoviSeedance2p0MiniCostState;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p0_mini::request::KinoviSeedance2p0MiniRequestState;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p5::cost::KinoviSeedance2p5CostState;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p5::request::KinoviSeedance2p5RequestState;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p5_preview::cost::KinoviSeedance2p5PreviewCostState;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p5_preview::request::KinoviSeedance2p5PreviewRequestState;
|
||||
|
||||
@@ -173,6 +177,7 @@ pub enum VideoGenerationRequest {
|
||||
ArtcraftSeedance2p0BytePlusUltraFast(ArtcraftSeedance2p0BytePlusUltraFastRequestState),
|
||||
ArtcraftSeedance2p0Mini(ArtcraftSeedance2p0MiniRequestState),
|
||||
ArtcraftSeedance2p5Preview(ArtcraftSeedance2p5PreviewRequestState),
|
||||
ArtcraftSeedance2p5(ArtcraftSeedance2p5RequestState),
|
||||
ArtcraftSeedance2p0BytePlusMini(ArtcraftSeedance2p0BytePlusMiniRequestState),
|
||||
ArtcraftSeedance2p0BytePlusUltraMini(ArtcraftSeedance2p0BytePlusUltraMiniRequestState),
|
||||
ArtcraftSora2(ArtcraftSora2RequestState),
|
||||
@@ -216,6 +221,7 @@ pub enum VideoGenerationRequest {
|
||||
KinoviSeedance2p0Fast(KinoviSeedance2p0FastRequestState),
|
||||
KinoviSeedance2p0Mini(KinoviSeedance2p0MiniRequestState),
|
||||
KinoviSeedance2p5Preview(KinoviSeedance2p5PreviewRequestState),
|
||||
KinoviSeedance2p5(KinoviSeedance2p5RequestState),
|
||||
}
|
||||
|
||||
impl VideoGenerationRequest {
|
||||
@@ -249,6 +255,7 @@ impl VideoGenerationRequest {
|
||||
Self::ArtcraftSeedance2p0BytePlusUltraFast(_) => RouterProvider::Artcraft,
|
||||
Self::ArtcraftSeedance2p0Mini(_) => RouterProvider::Artcraft,
|
||||
Self::ArtcraftSeedance2p5Preview(_) => RouterProvider::Artcraft,
|
||||
Self::ArtcraftSeedance2p5(_) => RouterProvider::Artcraft,
|
||||
Self::ArtcraftSeedance2p0BytePlusMini(_) => RouterProvider::Artcraft,
|
||||
Self::ArtcraftSeedance2p0BytePlusUltraMini(_) => RouterProvider::Artcraft,
|
||||
Self::ArtcraftSora2(_) => RouterProvider::Artcraft,
|
||||
@@ -292,6 +299,7 @@ impl VideoGenerationRequest {
|
||||
Self::KinoviSeedance2p0Fast(_) => RouterProvider::KinoviWeb,
|
||||
Self::KinoviSeedance2p0Mini(_) => RouterProvider::KinoviWeb,
|
||||
Self::KinoviSeedance2p5Preview(_) => RouterProvider::KinoviWeb,
|
||||
Self::KinoviSeedance2p5(_) => RouterProvider::KinoviWeb,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -325,6 +333,7 @@ impl VideoGenerationRequest {
|
||||
VideoGenerationRequest::ArtcraftSeedance2p0BytePlusUltraFast(request) => Ok(ArtcraftSeedance2p0BytePlusUltraFastCostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::ArtcraftSeedance2p0Mini(request) => Ok(ArtcraftSeedance2p0MiniCostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::ArtcraftSeedance2p5Preview(request) => Ok(ArtcraftSeedance2p5PreviewCostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::ArtcraftSeedance2p5(request) => Ok(ArtcraftSeedance2p5CostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::ArtcraftSeedance2p0BytePlusMini(request) => Ok(ArtcraftSeedance2p0BytePlusMiniCostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::ArtcraftSeedance2p0BytePlusUltraMini(request) => Ok(ArtcraftSeedance2p0BytePlusUltraMiniCostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::ArtcraftSora2(request) => Ok(ArtcraftSora2CostState::from_request(request).estimate_cost()),
|
||||
@@ -368,6 +377,7 @@ impl VideoGenerationRequest {
|
||||
VideoGenerationRequest::KinoviSeedance2p0Fast(request) => Ok(KinoviSeedance2p0FastCostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::KinoviSeedance2p0Mini(request) => Ok(KinoviSeedance2p0MiniCostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::KinoviSeedance2p5Preview(request) => Ok(KinoviSeedance2p5PreviewCostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::KinoviSeedance2p5(request) => Ok(KinoviSeedance2p5CostState::from_request(request).estimate_cost()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -483,6 +493,10 @@ impl VideoGenerationRequest {
|
||||
let client_ref = client.get_artcraft_client_ref()?;
|
||||
request.send(client_ref).await
|
||||
},
|
||||
VideoGenerationRequest::ArtcraftSeedance2p5(request) => {
|
||||
let client_ref = client.get_artcraft_client_ref()?;
|
||||
request.send(client_ref).await
|
||||
},
|
||||
VideoGenerationRequest::ArtcraftSeedance2p0BytePlusMini(request) => {
|
||||
let client_ref = client.get_artcraft_client_ref()?;
|
||||
request.send(client_ref).await
|
||||
@@ -655,6 +669,10 @@ impl VideoGenerationRequest {
|
||||
let client_ref = client.get_kinovi_web_client_ref()?;
|
||||
request.send(client_ref).await
|
||||
},
|
||||
VideoGenerationRequest::KinoviSeedance2p5(request) => {
|
||||
let client_ref = client.get_kinovi_web_client_ref()?;
|
||||
request.send(client_ref).await
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,6 +355,7 @@ pub fn base_video_request() -> GenerateVideoRequestBuilder {
|
||||
duration_seconds: None,
|
||||
video_batch_count: None,
|
||||
generate_audio: None,
|
||||
total_reference_video_input_seconds: None,
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
|
||||
idempotency_token: None,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,761 @@
|
||||
use crate::creds::kinovi_web_session::KinoviWebSession;
|
||||
use crate::error::kinovi_web_error::KinoviWebError;
|
||||
use crate::cost::kinovi_fractional_generation_cost::KinoviFractionalGenerationCost;
|
||||
use crate::requests::kinovi_host::KinoviHost;
|
||||
use crate::requests::workflow_run_task::workflow_run_task::{
|
||||
workflow_run_task, KinoviAspectRatioRaw, KinoviBatchCountRaw, KinoviModelTypeRaw,
|
||||
KinoviOutputResolutionRaw, WorkflowRunTaskArgs, WorkflowRunTaskRequest,
|
||||
};
|
||||
|
||||
// ── Constants ──
|
||||
|
||||
/// Seedance 2.5 supports at most 30 seconds of video: reference-video input
|
||||
/// seconds beyond this are clamped for billing.
|
||||
pub const MAX_BILLED_INPUT_SECONDS: u8 = 30;
|
||||
|
||||
// ── Args ──
|
||||
|
||||
pub struct GenerateSeedance2p5Args<'a> {
|
||||
pub request: GenerateSeedance2p5Request,
|
||||
pub session: &'a KinoviWebSession,
|
||||
pub host_override: Option<KinoviHost>,
|
||||
}
|
||||
|
||||
// ── Request ──
|
||||
|
||||
/// Seedance 2.5 supports keyframe (image-to-video) and reference
|
||||
/// (text-to-video / reference-to-video) modalities, and 480p/720p output.
|
||||
///
|
||||
/// On the wire it reuses the 2.5 *Preview* business type
|
||||
/// (`seedance25-preview-video-generation`) with model `seedance2-5`;
|
||||
/// text-to-video is sent as mode "reference".
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GenerateSeedance2p5Request {
|
||||
pub prompt: String,
|
||||
|
||||
pub modality: KinoviSeedance2p5Modality,
|
||||
|
||||
pub output_resolution: Option<KinoviSeedance2p5OutputResolution>,
|
||||
|
||||
pub duration_seconds: u8,
|
||||
|
||||
/// CALCULATION-ONLY (never sent on the wire): the total seconds of
|
||||
/// reference video input, summed across all reference videos. When video
|
||||
/// references are attached, Kinovi bills the input seconds on top of the
|
||||
/// output duration — see the pricing notes on [`calculate_costs`].
|
||||
///
|
||||
/// [`calculate_costs`]: GenerateSeedance2p5Request::calculate_costs
|
||||
pub total_input_seconds: Option<u8>,
|
||||
|
||||
/// Controls `faceBlurMode`: true sends "on"; false or None sends "off"
|
||||
/// (the model always sends the field, like 2.5 Preview).
|
||||
pub use_face_blur_hack: Option<bool>,
|
||||
}
|
||||
|
||||
// ── Modality ──
|
||||
|
||||
/// The generation modality. The aspect ratio choice lives here because the
|
||||
/// two modalities support disjoint aspect ratio sets.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum KinoviSeedance2p5Modality {
|
||||
/// Image-to-video (start frame + optional end frame).
|
||||
///
|
||||
/// The aspect ratio is always "Adaptive" — the only choice the API allows
|
||||
/// in this modality, expressed on the wire by omitting the `aspectRatio`
|
||||
/// field entirely — so there is nothing to configure.
|
||||
Keyframe {
|
||||
start_frame_url: String,
|
||||
end_frame_url: Option<String>,
|
||||
},
|
||||
|
||||
/// Text-to-video / reference-to-video. "Adaptive" is NOT supported here;
|
||||
/// `None` defaults to 16:9.
|
||||
Reference {
|
||||
aspect_ratio: Option<KinoviSeedance2p5AspectRatio>,
|
||||
/// Reference images, referenced in prompts as @image1, @image2, etc.
|
||||
reference_image_urls: Option<Vec<String>>,
|
||||
/// Reference videos, referenced in prompts as @video1, @video2, etc.
|
||||
reference_video_urls: Option<Vec<String>>,
|
||||
/// Reference audio, referenced in prompts as @audio1, @audio2, etc.
|
||||
reference_audio_urls: Option<Vec<String>>,
|
||||
},
|
||||
}
|
||||
|
||||
// ── Enums ──
|
||||
|
||||
/// Aspect ratios for the reference (text/reference-to-video) modality.
|
||||
/// There is deliberately no "Adaptive" variant: Adaptive is keyframe-only
|
||||
/// (and there it is the only choice).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum KinoviSeedance2p5AspectRatio {
|
||||
Landscape16x9,
|
||||
UltraWide21x9,
|
||||
Portrait9x16,
|
||||
Square1x1,
|
||||
Standard4x3,
|
||||
Portrait3x4,
|
||||
}
|
||||
|
||||
/// Output resolution. Seedance 2.5 supports only 480p and 720p for now.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum KinoviSeedance2p5OutputResolution {
|
||||
FourEightyP,
|
||||
SevenTwentyP,
|
||||
}
|
||||
|
||||
// ── Pricing ──
|
||||
//
|
||||
// Seedance 2.5 credit pricing (whole credits per billed second):
|
||||
//
|
||||
// | Resolution | No video refs | With video refs |
|
||||
// |------------|---------------|-----------------|
|
||||
// | 480p | 26 | 16 |
|
||||
// | 720p | 59 | 35 |
|
||||
//
|
||||
// Without video references (text-to-video, keyframe, and reference mode with
|
||||
// only image/audio references), billed seconds = output duration.
|
||||
//
|
||||
// With video references, the rate drops but the billed seconds are the
|
||||
// output duration PLUS the total seconds of reference video input
|
||||
// (`total_input_seconds`, summed across all reference videos and clamped to
|
||||
// [`MAX_BILLED_INPUT_SECONDS`]). E.g. a 30s output with a 10s video
|
||||
// reference bills as 40 seconds; a 30s output with two 7s references (14s
|
||||
// total input) bills as 44 seconds.
|
||||
//
|
||||
// Default resolution (None) is 720p.
|
||||
// Credit package: 525,000 credits for $2,159.0909 (~243 credits/$1).
|
||||
|
||||
impl GenerateSeedance2p5Request {
|
||||
/// Calculate the cost of this generation request, in Kinovi credits and
|
||||
/// USD cents.
|
||||
pub fn calculate_costs(&self) -> KinoviFractionalGenerationCost {
|
||||
let has_video_references = matches!(
|
||||
&self.modality,
|
||||
KinoviSeedance2p5Modality::Reference { reference_video_urls: Some(urls), .. } if !urls.is_empty()
|
||||
);
|
||||
|
||||
let (credits_per_second, billed_seconds) = if has_video_references {
|
||||
let rate = match self.output_resolution {
|
||||
Some(KinoviSeedance2p5OutputResolution::FourEightyP) => 16u64,
|
||||
Some(KinoviSeedance2p5OutputResolution::SevenTwentyP) | None => 35u64,
|
||||
};
|
||||
let input_seconds = self.total_input_seconds
|
||||
.unwrap_or(MAX_BILLED_INPUT_SECONDS)
|
||||
.min(MAX_BILLED_INPUT_SECONDS);
|
||||
let seconds = u64::from(self.duration_seconds) + u64::from(input_seconds);
|
||||
(rate, seconds)
|
||||
} else {
|
||||
let rate = match self.output_resolution {
|
||||
Some(KinoviSeedance2p5OutputResolution::FourEightyP) => 26u64,
|
||||
Some(KinoviSeedance2p5OutputResolution::SevenTwentyP) | None => 59u64,
|
||||
};
|
||||
(rate, u64::from(self.duration_seconds))
|
||||
};
|
||||
|
||||
let total_credits = credits_per_second * billed_seconds;
|
||||
KinoviFractionalGenerationCost::from_kinovi_credits(total_credits as f64)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Response ──
|
||||
|
||||
pub struct GenerateSeedance2p5Response {
|
||||
pub task_id: String,
|
||||
pub order_id: String,
|
||||
pub task_ids: Option<Vec<String>>,
|
||||
pub order_ids: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
// ── Entry point ──
|
||||
|
||||
pub async fn generate_seedance_2p5(
|
||||
args: GenerateSeedance2p5Args<'_>,
|
||||
) -> Result<GenerateSeedance2p5Response, KinoviWebError> {
|
||||
let raw_response = workflow_run_task(WorkflowRunTaskArgs {
|
||||
request: to_raw_request(args.request),
|
||||
session: args.session,
|
||||
host_override: args.host_override,
|
||||
}).await?;
|
||||
|
||||
Ok(GenerateSeedance2p5Response {
|
||||
task_id: raw_response.task_id,
|
||||
order_id: raw_response.order_id,
|
||||
task_ids: raw_response.task_ids,
|
||||
order_ids: raw_response.order_ids,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Mapping helpers ──
|
||||
|
||||
fn to_raw_request(req: GenerateSeedance2p5Request) -> WorkflowRunTaskRequest {
|
||||
let (
|
||||
aspect_ratio,
|
||||
start_frame_url,
|
||||
end_frame_url,
|
||||
reference_image_urls,
|
||||
reference_video_urls,
|
||||
reference_audio_urls,
|
||||
) = match req.modality {
|
||||
KinoviSeedance2p5Modality::Keyframe { start_frame_url, end_frame_url } => (
|
||||
// Keyframe is always "Adaptive"; the raw layer omits the aspect ratio
|
||||
// for 2.5 keyframe requests, so this placeholder never hits the wire.
|
||||
KinoviAspectRatioRaw::Landscape16x9,
|
||||
Some(start_frame_url),
|
||||
end_frame_url,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
),
|
||||
KinoviSeedance2p5Modality::Reference {
|
||||
aspect_ratio,
|
||||
reference_image_urls,
|
||||
reference_video_urls,
|
||||
reference_audio_urls,
|
||||
} => (
|
||||
map_aspect_ratio(aspect_ratio),
|
||||
None,
|
||||
None,
|
||||
reference_image_urls,
|
||||
reference_video_urls,
|
||||
reference_audio_urls,
|
||||
),
|
||||
};
|
||||
|
||||
WorkflowRunTaskRequest {
|
||||
model_type: KinoviModelTypeRaw::Seedance2p5,
|
||||
prompt: req.prompt,
|
||||
aspect_ratio,
|
||||
output_resolution: req.output_resolution.map(map_output_resolution),
|
||||
duration_seconds: req.duration_seconds,
|
||||
batch_count: KinoviBatchCountRaw::One,
|
||||
start_frame_url,
|
||||
end_frame_url,
|
||||
reference_image_urls,
|
||||
reference_video_urls,
|
||||
reference_audio_urls,
|
||||
character_ids: None,
|
||||
use_face_blur_hack: req.use_face_blur_hack,
|
||||
bitrate: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_aspect_ratio(ar: Option<KinoviSeedance2p5AspectRatio>) -> KinoviAspectRatioRaw {
|
||||
match ar {
|
||||
Some(KinoviSeedance2p5AspectRatio::Landscape16x9) => KinoviAspectRatioRaw::Landscape16x9,
|
||||
Some(KinoviSeedance2p5AspectRatio::UltraWide21x9) => KinoviAspectRatioRaw::UltraWide21x9,
|
||||
Some(KinoviSeedance2p5AspectRatio::Portrait9x16) => KinoviAspectRatioRaw::Portrait9x16,
|
||||
Some(KinoviSeedance2p5AspectRatio::Square1x1) => KinoviAspectRatioRaw::Square1x1,
|
||||
Some(KinoviSeedance2p5AspectRatio::Standard4x3) => KinoviAspectRatioRaw::Landscape4x3,
|
||||
Some(KinoviSeedance2p5AspectRatio::Portrait3x4) => KinoviAspectRatioRaw::Portrait3x4,
|
||||
None => KinoviAspectRatioRaw::Landscape16x9,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_output_resolution(res: KinoviSeedance2p5OutputResolution) -> KinoviOutputResolutionRaw {
|
||||
match res {
|
||||
KinoviSeedance2p5OutputResolution::FourEightyP => KinoviOutputResolutionRaw::FourEightyP,
|
||||
KinoviSeedance2p5OutputResolution::SevenTwentyP => KinoviOutputResolutionRaw::SevenTwentyP,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ──
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::creds::kinovi_web_session::KinoviWebSession;
|
||||
use crate::test_utils::get_test_cookies::get_test_cookies;
|
||||
use crate::test_utils::setup_test_logging::setup_test_logging;
|
||||
use errors::AnyhowResult;
|
||||
use log::LevelFilter;
|
||||
|
||||
mod pricing_tests {
|
||||
use super::*;
|
||||
|
||||
const FLOAT_TOLERANCE: f64 = 1e-9;
|
||||
|
||||
// ── No video references: output-duration billing ──
|
||||
//
|
||||
// 480p = 26 credits/sec, 720p = 59 credits/sec.
|
||||
|
||||
mod without_video_references {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn table_480p() {
|
||||
assert_eq!(t2v_480(1).calculate_costs().kinovi_credits, 26.0);
|
||||
assert_eq!(t2v_480(5).calculate_costs().kinovi_credits, 130.0);
|
||||
assert_eq!(t2v_480(10).calculate_costs().kinovi_credits, 260.0);
|
||||
assert_eq!(t2v_480(15).calculate_costs().kinovi_credits, 390.0);
|
||||
assert_eq!(t2v_480(30).calculate_costs().kinovi_credits, 780.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_720p() {
|
||||
assert_eq!(t2v_720(1).calculate_costs().kinovi_credits, 59.0);
|
||||
assert_eq!(t2v_720(5).calculate_costs().kinovi_credits, 295.0);
|
||||
assert_eq!(t2v_720(10).calculate_costs().kinovi_credits, 590.0);
|
||||
assert_eq!(t2v_720(15).calculate_costs().kinovi_credits, 885.0);
|
||||
assert_eq!(t2v_720(30).calculate_costs().kinovi_credits, 1770.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_resolution_is_720p() {
|
||||
let default = t2v_720(10).calculate_costs();
|
||||
let mut explicit = t2v_720(10);
|
||||
explicit.output_resolution = Some(KinoviSeedance2p5OutputResolution::SevenTwentyP);
|
||||
assert_eq!(default, explicit.calculate_costs());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyframe_modality_uses_no_reference_rates() {
|
||||
let mut request = keyframe_request(10);
|
||||
request.output_resolution = Some(KinoviSeedance2p5OutputResolution::FourEightyP);
|
||||
assert_eq!(request.calculate_costs().kinovi_credits, 260.0);
|
||||
|
||||
let request_720 = keyframe_request(10);
|
||||
assert_eq!(request_720.calculate_costs().kinovi_credits, 590.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_and_audio_references_do_not_change_the_rate() {
|
||||
let mut request = t2v_480(10);
|
||||
set_reference_urls(
|
||||
&mut request,
|
||||
Some(vec!["https://example.com/a.png".to_string(), "https://example.com/b.jpg".to_string()]),
|
||||
None,
|
||||
Some(vec!["https://example.com/ref.wav".to_string()]),
|
||||
);
|
||||
assert_eq!(request.calculate_costs().kinovi_credits, 260.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_video_reference_list_counts_as_no_references() {
|
||||
let mut request = t2v_480(10);
|
||||
set_reference_urls(&mut request, None, Some(Vec::new()), None);
|
||||
assert_eq!(request.calculate_costs().kinovi_credits, 260.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn total_input_seconds_is_ignored_without_video_references() {
|
||||
let mut request = t2v_480(10);
|
||||
request.total_input_seconds = Some(60);
|
||||
assert_eq!(request.calculate_costs().kinovi_credits, 260.0);
|
||||
}
|
||||
}
|
||||
|
||||
// ── With video references: input seconds are billed too ──
|
||||
//
|
||||
// 480p = 16 credits/sec, 720p = 35 credits/sec, over
|
||||
// (output duration + total_input_seconds) billed seconds.
|
||||
|
||||
mod with_video_references {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn thirty_second_output_with_ten_second_reference_bills_forty_seconds() {
|
||||
// The canonical example: 30s output + 10s of input video = 40 billed seconds.
|
||||
let request = video_ref_480(30, Some(10));
|
||||
assert_eq!(request.calculate_costs().kinovi_credits, (16 * 40) as f64);
|
||||
|
||||
let request = video_ref_720(30, Some(10));
|
||||
assert_eq!(request.calculate_costs().kinovi_credits, (35 * 40) as f64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_seven_second_references_bill_forty_four_seconds() {
|
||||
// Two 7s references (14s total input) + 30s output = 44 billed seconds.
|
||||
let mut request = video_ref_480(30, Some(14));
|
||||
set_reference_urls(
|
||||
&mut request,
|
||||
None,
|
||||
Some(vec![
|
||||
"https://example.com/ref1.mp4".to_string(),
|
||||
"https://example.com/ref2.mp4".to_string(),
|
||||
]),
|
||||
None,
|
||||
);
|
||||
assert_eq!(request.calculate_costs().kinovi_credits, (16 * 44) as f64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_480p_with_ten_input_seconds() {
|
||||
assert_eq!(video_ref_480(5, Some(10)).calculate_costs().kinovi_credits, (16 * 15) as f64);
|
||||
assert_eq!(video_ref_480(10, Some(10)).calculate_costs().kinovi_credits, (16 * 20) as f64);
|
||||
assert_eq!(video_ref_480(30, Some(10)).calculate_costs().kinovi_credits, (16 * 40) as f64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_720p_with_ten_input_seconds() {
|
||||
assert_eq!(video_ref_720(5, Some(10)).calculate_costs().kinovi_credits, (35 * 15) as f64);
|
||||
assert_eq!(video_ref_720(10, Some(10)).calculate_costs().kinovi_credits, (35 * 20) as f64);
|
||||
assert_eq!(video_ref_720(30, Some(10)).calculate_costs().kinovi_credits, (35 * 40) as f64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_total_input_seconds_bills_the_worst_case_maximum() {
|
||||
// Unknown input duration with video references attached: assume the
|
||||
// 30-second maximum so the estimate never undershoots the charge.
|
||||
assert_eq!(video_ref_480(10, None).calculate_costs().kinovi_credits, (16 * 40) as f64);
|
||||
assert_eq!(video_ref_720(10, None).calculate_costs().kinovi_credits, (35 * 40) as f64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_seconds_clamp_to_max_billed_input_seconds() {
|
||||
// 200 input seconds clamp to 30: 30s output + 30 = 60 billed seconds.
|
||||
assert_eq!(video_ref_480(30, Some(200)).calculate_costs().kinovi_credits, (16 * 60) as f64);
|
||||
assert_eq!(
|
||||
video_ref_480(30, Some(200)).calculate_costs(),
|
||||
video_ref_480(30, Some(MAX_BILLED_INPUT_SECONDS)).calculate_costs(),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_references_still_use_video_reference_rates() {
|
||||
let mut request = video_ref_480(10, Some(5));
|
||||
set_reference_urls(
|
||||
&mut request,
|
||||
Some(vec!["https://example.com/a.png".to_string()]),
|
||||
Some(vec!["https://example.com/ref.mp4".to_string()]),
|
||||
Some(vec!["https://example.com/ref.wav".to_string()]),
|
||||
);
|
||||
assert_eq!(request.calculate_costs().kinovi_credits, (16 * 15) as f64);
|
||||
}
|
||||
}
|
||||
|
||||
// ── USD cents conversion ──
|
||||
//
|
||||
// usd_cents = credits × 100 / 243, computed on integer hundredths.
|
||||
|
||||
mod usd_cents {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cents_480p_5s() {
|
||||
// 130 credits; 13000/243 = 53.4979 cents.
|
||||
let cost = t2v_480(5).calculate_costs();
|
||||
assert_eq!(cost.usd_cents_rounded_up, 54);
|
||||
assert_eq!(cost.usd_cents_rounded_down, 53);
|
||||
assert!((cost.usd_cents_fractional - (13000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cents_720p_30s() {
|
||||
// 1770 credits; 177000/243 = 728.3951 cents.
|
||||
let cost = t2v_720(30).calculate_costs();
|
||||
assert_eq!(cost.usd_cents_rounded_up, 729);
|
||||
assert_eq!(cost.usd_cents_rounded_down, 728);
|
||||
assert!((cost.usd_cents_fractional - (177000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cents_720p_video_ref_40_billed_seconds() {
|
||||
// 35 × 40 = 1400 credits; 140000/243 = 576.1317 cents.
|
||||
let cost = video_ref_720(30, Some(10)).calculate_costs();
|
||||
assert_eq!(cost.usd_cents_rounded_up, 577);
|
||||
assert_eq!(cost.usd_cents_rounded_down, 576);
|
||||
assert!((cost.usd_cents_fractional - (140000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Relative pricing ──
|
||||
|
||||
mod relative_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn video_reference_rate_is_cheaper_per_second() {
|
||||
// 16 < 26 and 35 < 59: the with-references rate is lower per billed
|
||||
// second (the input seconds are where the money goes). Compare at
|
||||
// zero input seconds — the unknown-duration default assumes the
|
||||
// 30-second maximum, which would swamp the rate comparison.
|
||||
assert!(video_ref_480(10, Some(0)).calculate_costs().kinovi_credits
|
||||
< t2v_480(10).calculate_costs().kinovi_credits);
|
||||
assert!(video_ref_720(10, Some(0)).calculate_costs().kinovi_credits
|
||||
< t2v_720(10).calculate_costs().kinovi_credits);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_scales_linearly_with_duration() {
|
||||
for duration in [1u8, 5, 10, 30] {
|
||||
assert_eq!(
|
||||
t2v_480(duration).calculate_costs().kinovi_credits,
|
||||
(26 * u64::from(duration)) as f64,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Aspect ratio doesn't affect cost ──
|
||||
|
||||
#[test]
|
||||
fn aspect_ratio_does_not_affect_credits() {
|
||||
let baseline = t2v_720(10).calculate_costs().kinovi_credits;
|
||||
|
||||
let ratios = [
|
||||
KinoviSeedance2p5AspectRatio::Landscape16x9,
|
||||
KinoviSeedance2p5AspectRatio::UltraWide21x9,
|
||||
KinoviSeedance2p5AspectRatio::Portrait9x16,
|
||||
KinoviSeedance2p5AspectRatio::Square1x1,
|
||||
KinoviSeedance2p5AspectRatio::Standard4x3,
|
||||
KinoviSeedance2p5AspectRatio::Portrait3x4,
|
||||
];
|
||||
|
||||
for ar in &ratios {
|
||||
let mut request = t2v_720(10);
|
||||
request.modality = KinoviSeedance2p5Modality::Reference {
|
||||
aspect_ratio: Some(*ar),
|
||||
reference_image_urls: None,
|
||||
reference_video_urls: None,
|
||||
reference_audio_urls: None,
|
||||
};
|
||||
assert_eq!(
|
||||
request.calculate_costs().kinovi_credits, baseline,
|
||||
"Aspect ratio {:?} should not change credits from baseline {}", ar, baseline,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
fn text_to_video_request(
|
||||
duration_seconds: u8,
|
||||
output_resolution: Option<KinoviSeedance2p5OutputResolution>,
|
||||
) -> GenerateSeedance2p5Request {
|
||||
GenerateSeedance2p5Request {
|
||||
prompt: String::new(),
|
||||
modality: KinoviSeedance2p5Modality::Reference {
|
||||
aspect_ratio: None,
|
||||
reference_image_urls: None,
|
||||
reference_video_urls: None,
|
||||
reference_audio_urls: None,
|
||||
},
|
||||
output_resolution,
|
||||
duration_seconds,
|
||||
total_input_seconds: None,
|
||||
use_face_blur_hack: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn t2v_480(duration: u8) -> GenerateSeedance2p5Request {
|
||||
text_to_video_request(duration, Some(KinoviSeedance2p5OutputResolution::FourEightyP))
|
||||
}
|
||||
|
||||
fn t2v_720(duration: u8) -> GenerateSeedance2p5Request {
|
||||
text_to_video_request(duration, None)
|
||||
}
|
||||
|
||||
fn keyframe_request(duration: u8) -> GenerateSeedance2p5Request {
|
||||
GenerateSeedance2p5Request {
|
||||
prompt: String::new(),
|
||||
modality: KinoviSeedance2p5Modality::Keyframe {
|
||||
start_frame_url: "https://example.com/start.png".to_string(),
|
||||
end_frame_url: Some("https://example.com/end.png".to_string()),
|
||||
},
|
||||
output_resolution: None,
|
||||
duration_seconds: duration,
|
||||
total_input_seconds: None,
|
||||
use_face_blur_hack: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn video_ref_480(duration: u8, total_input_seconds: Option<u8>) -> GenerateSeedance2p5Request {
|
||||
let mut request = t2v_480(duration);
|
||||
set_reference_urls(&mut request, None, Some(vec!["https://example.com/ref.mp4".to_string()]), None);
|
||||
request.total_input_seconds = total_input_seconds;
|
||||
request
|
||||
}
|
||||
|
||||
fn video_ref_720(duration: u8, total_input_seconds: Option<u8>) -> GenerateSeedance2p5Request {
|
||||
let mut request = t2v_720(duration);
|
||||
set_reference_urls(&mut request, None, Some(vec!["https://example.com/ref.mp4".to_string()]), None);
|
||||
request.total_input_seconds = total_input_seconds;
|
||||
request
|
||||
}
|
||||
|
||||
fn set_reference_urls(
|
||||
request: &mut GenerateSeedance2p5Request,
|
||||
images: Option<Vec<String>>,
|
||||
videos: Option<Vec<String>>,
|
||||
audio: Option<Vec<String>>,
|
||||
) {
|
||||
let KinoviSeedance2p5Modality::Reference {
|
||||
reference_image_urls,
|
||||
reference_video_urls,
|
||||
reference_audio_urls,
|
||||
..
|
||||
} = &mut request.modality else {
|
||||
panic!("set_reference_urls requires the Reference modality");
|
||||
};
|
||||
*reference_image_urls = images;
|
||||
*reference_video_urls = videos;
|
||||
*reference_audio_urls = audio;
|
||||
}
|
||||
}
|
||||
|
||||
mod raw_mapping_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn keyframe_maps_frames_and_no_references() {
|
||||
let raw = to_raw_request(GenerateSeedance2p5Request {
|
||||
prompt: "Car drives into the sunset".to_string(),
|
||||
modality: KinoviSeedance2p5Modality::Keyframe {
|
||||
start_frame_url: "https://example.com/start.png".to_string(),
|
||||
end_frame_url: Some("https://example.com/end.png".to_string()),
|
||||
},
|
||||
output_resolution: Some(KinoviSeedance2p5OutputResolution::FourEightyP),
|
||||
duration_seconds: 8,
|
||||
total_input_seconds: None,
|
||||
use_face_blur_hack: None,
|
||||
});
|
||||
|
||||
assert!(matches!(raw.model_type, KinoviModelTypeRaw::Seedance2p5));
|
||||
assert_eq!(raw.start_frame_url.as_deref(), Some("https://example.com/start.png"));
|
||||
assert_eq!(raw.end_frame_url.as_deref(), Some("https://example.com/end.png"));
|
||||
assert!(raw.reference_image_urls.is_none());
|
||||
assert!(raw.reference_video_urls.is_none());
|
||||
assert!(raw.reference_audio_urls.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_maps_references_and_no_frames() {
|
||||
let raw = to_raw_request(GenerateSeedance2p5Request {
|
||||
prompt: "The t-rex @image1 eats the banana".to_string(),
|
||||
modality: KinoviSeedance2p5Modality::Reference {
|
||||
aspect_ratio: Some(KinoviSeedance2p5AspectRatio::UltraWide21x9),
|
||||
reference_image_urls: Some(vec!["https://example.com/a.png".to_string()]),
|
||||
reference_video_urls: Some(vec!["https://example.com/ref.mp4".to_string()]),
|
||||
reference_audio_urls: Some(vec!["https://example.com/ref.wav".to_string()]),
|
||||
},
|
||||
output_resolution: None,
|
||||
duration_seconds: 8,
|
||||
total_input_seconds: Some(7),
|
||||
use_face_blur_hack: None,
|
||||
});
|
||||
|
||||
assert!(matches!(raw.aspect_ratio, KinoviAspectRatioRaw::UltraWide21x9));
|
||||
assert!(raw.start_frame_url.is_none());
|
||||
assert!(raw.end_frame_url.is_none());
|
||||
assert_eq!(raw.reference_image_urls.as_deref().map(|urls| urls.len()), Some(1));
|
||||
assert_eq!(raw.reference_video_urls.as_deref().map(|urls| urls.len()), Some(1));
|
||||
assert_eq!(raw.reference_audio_urls.as_deref().map(|urls| urls.len()), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_aspect_ratio_defaults_to_16x9() {
|
||||
let raw = to_raw_request(GenerateSeedance2p5Request {
|
||||
prompt: "Lightning hits a building".to_string(),
|
||||
modality: KinoviSeedance2p5Modality::Reference {
|
||||
aspect_ratio: None,
|
||||
reference_image_urls: None,
|
||||
reference_video_urls: None,
|
||||
reference_audio_urls: None,
|
||||
},
|
||||
output_resolution: None,
|
||||
duration_seconds: 5,
|
||||
total_input_seconds: None,
|
||||
use_face_blur_hack: None,
|
||||
});
|
||||
|
||||
assert!(matches!(raw.aspect_ratio, KinoviAspectRatioRaw::Landscape16x9));
|
||||
}
|
||||
}
|
||||
|
||||
mod real_requests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_text_to_video_480p() -> AnyhowResult<()> {
|
||||
setup_test_logging(LevelFilter::Trace);
|
||||
let session = test_session()?;
|
||||
let result = generate_seedance_2p5(GenerateSeedance2p5Args {
|
||||
session: &session,
|
||||
host_override: None,
|
||||
request: GenerateSeedance2p5Request {
|
||||
prompt: "Lightning hits a building".to_string(),
|
||||
modality: KinoviSeedance2p5Modality::Reference {
|
||||
aspect_ratio: Some(KinoviSeedance2p5AspectRatio::Landscape16x9),
|
||||
reference_image_urls: None,
|
||||
reference_video_urls: None,
|
||||
reference_audio_urls: None,
|
||||
},
|
||||
output_resolution: Some(KinoviSeedance2p5OutputResolution::FourEightyP),
|
||||
duration_seconds: 5,
|
||||
total_input_seconds: None,
|
||||
use_face_blur_hack: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("t2v 480p — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
assert!(!result.task_id.is_empty());
|
||||
assert!(!result.order_id.is_empty());
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_keyframe_to_video_adaptive_480p() -> AnyhowResult<()> {
|
||||
setup_test_logging(LevelFilter::Trace);
|
||||
let session = test_session()?;
|
||||
let result = generate_seedance_2p5(GenerateSeedance2p5Args {
|
||||
session: &session,
|
||||
host_override: None,
|
||||
request: GenerateSeedance2p5Request {
|
||||
prompt: "Car drives into the sunset".to_string(),
|
||||
modality: KinoviSeedance2p5Modality::Keyframe {
|
||||
start_frame_url: "https://static.seedance2-pro.com/materials/20260807/1786128486168-a2bf6132.png".to_string(),
|
||||
end_frame_url: Some("https://static.seedance2-pro.com/materials/20260807/1786128493608-d708413a.png".to_string()),
|
||||
},
|
||||
output_resolution: Some(KinoviSeedance2p5OutputResolution::FourEightyP),
|
||||
duration_seconds: 8,
|
||||
total_input_seconds: None,
|
||||
use_face_blur_hack: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("keyframe 480p — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
assert!(!result.task_id.is_empty());
|
||||
assert!(!result.order_id.is_empty());
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_reference_to_video_480p() -> AnyhowResult<()> {
|
||||
setup_test_logging(LevelFilter::Trace);
|
||||
let session = test_session()?;
|
||||
let result = generate_seedance_2p5(GenerateSeedance2p5Args {
|
||||
session: &session,
|
||||
host_override: None,
|
||||
request: GenerateSeedance2p5Request {
|
||||
prompt: "The t-rex @image1 eats the banana @image2".to_string(),
|
||||
modality: KinoviSeedance2p5Modality::Reference {
|
||||
aspect_ratio: Some(KinoviSeedance2p5AspectRatio::UltraWide21x9),
|
||||
reference_image_urls: Some(vec![
|
||||
"https://static.seedance2-pro.com/materials/20260807/1786128628611-d6ca7afd.png".to_string(),
|
||||
"https://static.seedance2-pro.com/materials/20260807/1786128638331-5395ff1c.jpg".to_string(),
|
||||
]),
|
||||
reference_video_urls: None,
|
||||
reference_audio_urls: None,
|
||||
},
|
||||
output_resolution: Some(KinoviSeedance2p5OutputResolution::FourEightyP),
|
||||
duration_seconds: 8,
|
||||
total_input_seconds: None,
|
||||
use_face_blur_hack: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("reference 480p — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
assert!(!result.task_id.is_empty());
|
||||
assert!(!result.order_id.is_empty());
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn test_session() -> AnyhowResult<KinoviWebSession> {
|
||||
let cookies = get_test_cookies()?;
|
||||
Ok(KinoviWebSession::from_cookies_string(cookies))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,4 +2,5 @@ pub mod generate_happy_horse_1p0;
|
||||
pub mod generate_seedance_2p0;
|
||||
pub mod generate_seedance_2p0_fast;
|
||||
pub mod generate_seedance_2p0_mini;
|
||||
pub mod generate_seedance_2p5;
|
||||
pub mod generate_seedance_2p5_preview;
|
||||
|
||||
+198
-20
@@ -216,6 +216,8 @@ pub enum KinoviModelTypeRaw {
|
||||
Seedance2Mini,
|
||||
/// Seedance 2.5 Preview (480p/720p only).
|
||||
Seedance2p5Preview,
|
||||
/// Seedance 2.5 (480p/720p only).
|
||||
Seedance2p5,
|
||||
/// Happy Horse 1.0.
|
||||
HappyHorse1p0,
|
||||
}
|
||||
@@ -227,27 +229,29 @@ impl KinoviModelTypeRaw {
|
||||
Self::Seedance2Fast => "seedance2-fast",
|
||||
Self::Seedance2Mini => "seedance2.0-mini",
|
||||
Self::Seedance2p5Preview => "seedance2-5-preview",
|
||||
Self::Seedance2p5 => "seedance2-5",
|
||||
Self::HappyHorse1p0 => "happyhorse1.0",
|
||||
}
|
||||
}
|
||||
|
||||
/// The tRPC `businessType` for this model. Seedance 2.0 Mini and 2.5
|
||||
/// Preview use their own business types; every other model uses the
|
||||
/// shared one.
|
||||
/// The tRPC `businessType` for this model. Seedance 2.0 Mini and the 2.5
|
||||
/// family use their own business types; every other model uses the shared
|
||||
/// one. (Seedance 2.5 reuses the *preview* business type on the wire —
|
||||
/// only the `model` string differs.)
|
||||
fn business_type(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Seedance2Mini => "seedance20-mini-video-generation",
|
||||
Self::Seedance2p5Preview => "seedance25-preview-video-generation",
|
||||
Self::Seedance2p5Preview | Self::Seedance2p5 => "seedance25-preview-video-generation",
|
||||
Self::HappyHorse1p0 => "happyhorse-video-generation",
|
||||
Self::Seedance2Pro | Self::Seedance2Fast => "wan22-video-generation",
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the aspect ratio is sent in an `aspectRatio` field (true)
|
||||
/// rather than the `resolution` field. Mini, 2.5 Preview, and Happy Horse
|
||||
/// use `aspectRatio`.
|
||||
/// rather than the `resolution` field. Mini, the 2.5 family, and Happy
|
||||
/// Horse use `aspectRatio`.
|
||||
fn uses_aspect_ratio_field(&self) -> bool {
|
||||
matches!(self, Self::Seedance2Mini | Self::Seedance2p5Preview | Self::HappyHorse1p0)
|
||||
matches!(self, Self::Seedance2Mini | Self::Seedance2p5Preview | Self::Seedance2p5 | Self::HappyHorse1p0)
|
||||
}
|
||||
|
||||
/// Whether this model uses Happy Horse's `happyhorseMode` (t2v/i2v)
|
||||
@@ -258,10 +262,11 @@ impl KinoviModelTypeRaw {
|
||||
|
||||
/// Whether reference URLs are sent in split `imageUrls` / `videoUrls`
|
||||
/// fields — with `uploadedUrls` mirroring just the images — rather than
|
||||
/// one combined `uploadedUrls` list. Seedance 2.5 Preview uses the split
|
||||
/// shape.
|
||||
/// one combined `uploadedUrls` list. The 2.5 family uses the split shape.
|
||||
/// (In 2.5 keyframe mode the start/end frames go in `imageUrls` with
|
||||
/// `uploadedUrls` mirroring them.)
|
||||
fn uses_split_reference_url_fields(&self) -> bool {
|
||||
matches!(self, Self::Seedance2p5Preview)
|
||||
matches!(self, Self::Seedance2p5Preview | Self::Seedance2p5)
|
||||
}
|
||||
|
||||
/// Whether `mode` is always "reference", even with no references attached.
|
||||
@@ -270,11 +275,25 @@ impl KinoviModelTypeRaw {
|
||||
matches!(self, Self::Seedance2p5Preview)
|
||||
}
|
||||
|
||||
/// Whether `mode` is "keyframe" only when a start/end frame is attached,
|
||||
/// and "reference" otherwise (including plain text-to-video). Seedance 2.5
|
||||
/// sends text-to-video as mode "reference".
|
||||
fn uses_reference_mode_unless_keyframes(&self) -> bool {
|
||||
matches!(self, Self::Seedance2p5)
|
||||
}
|
||||
|
||||
/// Whether keyframe mode omits the aspect ratio entirely: Seedance 2.5
|
||||
/// keyframe (image-to-video) only supports "Adaptive", which is expressed
|
||||
/// by sending no `aspectRatio` field at all.
|
||||
fn omits_aspect_ratio_in_keyframe_mode(&self) -> bool {
|
||||
matches!(self, Self::Seedance2p5)
|
||||
}
|
||||
|
||||
/// Whether the default field values (`outputResolution: "720p"`,
|
||||
/// `faceBlurMode: "off"`) are always sent explicitly rather than omitted.
|
||||
/// Seedance 2.5 Preview always sends both.
|
||||
/// The 2.5 family always sends both.
|
||||
fn always_sends_default_fields(&self) -> bool {
|
||||
matches!(self, Self::Seedance2p5Preview)
|
||||
matches!(self, Self::Seedance2p5Preview | Self::Seedance2p5)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -446,20 +465,41 @@ fn build_batch_request(req: WorkflowRunTaskRequest) -> BatchRequest {
|
||||
|
||||
let is_reference_mode = has_reference_images || has_reference_videos || has_reference_audio || has_characters;
|
||||
|
||||
let video_input_mode = if is_reference_mode || req.model_type.always_uses_reference_mode() {
|
||||
let has_keyframes = req.start_frame_url.is_some() || req.end_frame_url.is_some();
|
||||
|
||||
let video_input_mode = if req.model_type.always_uses_reference_mode() {
|
||||
"reference"
|
||||
} else if req.model_type.uses_reference_mode_unless_keyframes() {
|
||||
// Seedance 2.5: text-to-video is sent as "reference"; "keyframe" only
|
||||
// when a start/end frame is attached.
|
||||
if has_keyframes { "keyframe" } else { "reference" }
|
||||
} else if is_reference_mode {
|
||||
"reference"
|
||||
} else {
|
||||
"keyframe"
|
||||
};
|
||||
|
||||
// Reference URLs. Seedance 2.5 Preview sends split `imageUrls` /
|
||||
// `videoUrls` fields with `uploadedUrls` mirroring just the images; other
|
||||
// Reference URLs. The 2.5 family sends split `imageUrls` / `videoUrls`
|
||||
// fields with `uploadedUrls` mirroring just the images (in 2.5 keyframe
|
||||
// mode the start/end frames ride in `imageUrls` + `uploadedUrls`); other
|
||||
// models combine videos + images into `uploadedUrls` in reference mode, or
|
||||
// send the start/end frames there in keyframe mode.
|
||||
let (image_urls, video_urls, uploaded_urls): (Option<Vec<String>>, Option<Vec<String>>, Option<Vec<String>>) = if req.model_type.uses_split_reference_url_fields() {
|
||||
let image_urls = req.reference_image_urls.filter(|urls| !urls.is_empty());
|
||||
let video_urls = req.reference_video_urls.filter(|urls| !urls.is_empty());
|
||||
(image_urls.clone(), video_urls, image_urls)
|
||||
if video_input_mode == "keyframe" {
|
||||
let mut urls = Vec::new();
|
||||
if let Some(url) = req.start_frame_url {
|
||||
urls.push(url);
|
||||
}
|
||||
if let Some(url) = req.end_frame_url {
|
||||
urls.push(url);
|
||||
}
|
||||
let urls = if urls.is_empty() { None } else { Some(urls) };
|
||||
(urls.clone(), None, urls)
|
||||
} else {
|
||||
let image_urls = req.reference_image_urls.filter(|urls| !urls.is_empty());
|
||||
let video_urls = req.reference_video_urls.filter(|urls| !urls.is_empty());
|
||||
(image_urls.clone(), video_urls, image_urls)
|
||||
}
|
||||
} else if is_reference_mode {
|
||||
let mut urls = Vec::new();
|
||||
if let Some(video_urls) = req.reference_video_urls {
|
||||
@@ -506,9 +546,13 @@ fn build_batch_request(req: WorkflowRunTaskRequest) -> BatchRequest {
|
||||
let duration = format!("{}s", req.duration_seconds);
|
||||
|
||||
// The aspect ratio is sent as a ratio string (e.g. "16:9"). Seedance Pro/Fast
|
||||
// carry it in the `resolution` field; Mini and Happy Horse use `aspectRatio`.
|
||||
// carry it in the `resolution` field; Mini, the 2.5 family, and Happy Horse
|
||||
// use `aspectRatio`. Seedance 2.5 keyframe mode only supports "Adaptive",
|
||||
// which is expressed by omitting the field entirely.
|
||||
let aspect_ratio_value = req.aspect_ratio.as_aspect_ratio_str();
|
||||
let (resolution, aspect_ratio) = if req.model_type.uses_aspect_ratio_field() {
|
||||
let (resolution, aspect_ratio) = if video_input_mode == "keyframe" && req.model_type.omits_aspect_ratio_in_keyframe_mode() {
|
||||
(None, None)
|
||||
} else if req.model_type.uses_aspect_ratio_field() {
|
||||
(None, Some(aspect_ratio_value))
|
||||
} else {
|
||||
(Some(aspect_ratio_value.to_string()), None)
|
||||
@@ -893,6 +937,140 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Seedance 2.5 request shape ──
|
||||
//
|
||||
// Seedance 2.5 reuses the *preview* businessType
|
||||
// (`seedance25-preview-video-generation`) with model `seedance2-5`. Unlike
|
||||
// Preview it has a keyframe (image-to-video) mode: `mode` is "keyframe"
|
||||
// only when a start/end frame is attached (text-to-video goes out as
|
||||
// "reference"), keyframe requests omit `aspectRatio` entirely (the only
|
||||
// choice is "Adaptive"), and the frames ride in `imageUrls` with
|
||||
// `uploadedUrls` mirroring them. Reference mode matches Preview's split
|
||||
// URL shape. Mirrors
|
||||
// external/requests/sites/kinovi.ai/2026-08-07-seedance2p5/.
|
||||
|
||||
mod seedance_2p5_shape_tests {
|
||||
use super::*;
|
||||
|
||||
fn base_request(prompt: &str, duration_seconds: u8) -> WorkflowRunTaskRequest {
|
||||
WorkflowRunTaskRequest {
|
||||
model_type: KinoviModelTypeRaw::Seedance2p5,
|
||||
prompt: prompt.to_string(),
|
||||
aspect_ratio: KinoviAspectRatioRaw::Landscape16x9,
|
||||
output_resolution: Some(KinoviOutputResolutionRaw::FourEightyP),
|
||||
duration_seconds,
|
||||
batch_count: KinoviBatchCountRaw::One,
|
||||
start_frame_url: None,
|
||||
end_frame_url: None,
|
||||
reference_image_urls: None,
|
||||
reference_video_urls: None,
|
||||
reference_audio_urls: None,
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_to_video_matches_observed_request() {
|
||||
// Mirrors 1_request_text_to_video.txt: mode "reference", aspectRatio
|
||||
// "16:9", 480p, 5s, no URL fields.
|
||||
let body = build_batch_request(base_request("Lightning hits a building", 5));
|
||||
let json = serde_json::to_string(&body).unwrap();
|
||||
assert!(json.contains(r#""businessType":"seedance25-preview-video-generation""#), "{json}");
|
||||
assert!(json.contains(r#""model":"seedance2-5""#), "{json}");
|
||||
assert!(json.contains(r#""mode":"reference""#), "{json}");
|
||||
assert!(json.contains(r#""aspectRatio":"16:9""#), "{json}");
|
||||
assert!(json.contains(r#""duration":"5s""#), "{json}");
|
||||
assert!(json.contains(r#""outputResolution":"480p""#), "{json}");
|
||||
assert!(json.contains(r#""faceBlurMode":"off""#), "{json}");
|
||||
assert!(json.contains(r#""contentMode":"normal""#), "{json}");
|
||||
// No ratio-string `resolution` field, and no URL fields.
|
||||
assert!(!json.contains(r#""resolution":"#), "{json}");
|
||||
assert!(!json.contains("imageUrls"), "{json}");
|
||||
assert!(!json.contains("videoUrls"), "{json}");
|
||||
assert!(!json.contains("uploadedUrls"), "{json}");
|
||||
assert!(!json.contains("audioUrls"), "{json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyframe_with_both_frames_matches_observed_request() {
|
||||
// Mirrors 2_request_keyframe_to_video.txt: mode "keyframe", NO
|
||||
// aspectRatio (Adaptive), frames in imageUrls + uploadedUrls.
|
||||
let mut request = base_request("Car drives into the sunset", 8);
|
||||
request.start_frame_url = Some("https://static.seedance2-pro.com/materials/start.png".to_string());
|
||||
request.end_frame_url = Some("https://static.seedance2-pro.com/materials/end.png".to_string());
|
||||
|
||||
let body = build_batch_request(request);
|
||||
let json = serde_json::to_string(&body).unwrap();
|
||||
assert!(json.contains(r#""mode":"keyframe""#), "{json}");
|
||||
// Adaptive: neither the `aspectRatio` nor `resolution` field is sent.
|
||||
assert!(!json.contains("aspectRatio"), "{json}");
|
||||
assert!(!json.contains(r#""resolution":"#), "{json}");
|
||||
let expected_urls = r#"["https://static.seedance2-pro.com/materials/start.png","https://static.seedance2-pro.com/materials/end.png"]"#;
|
||||
assert!(json.contains(&format!(r#""imageUrls":{expected_urls}"#)), "{json}");
|
||||
assert!(json.contains(&format!(r#""uploadedUrls":{expected_urls}"#)), "{json}");
|
||||
assert!(!json.contains("videoUrls"), "{json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyframe_with_start_frame_only_matches_observed_request() {
|
||||
// Mirrors 4_request_keyframe_to_video_2.txt: a single start frame.
|
||||
let mut request = base_request("The cars drive by", 5);
|
||||
request.start_frame_url = Some("https://static.seedance2-pro.com/materials/start.jpg".to_string());
|
||||
|
||||
let body = build_batch_request(request);
|
||||
let json = serde_json::to_string(&body).unwrap();
|
||||
assert!(json.contains(r#""mode":"keyframe""#), "{json}");
|
||||
assert!(!json.contains("aspectRatio"), "{json}");
|
||||
let expected_urls = r#"["https://static.seedance2-pro.com/materials/start.jpg"]"#;
|
||||
assert!(json.contains(&format!(r#""imageUrls":{expected_urls}"#)), "{json}");
|
||||
assert!(json.contains(&format!(r#""uploadedUrls":{expected_urls}"#)), "{json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_with_all_reference_types_matches_observed_request() {
|
||||
// Mirrors 3_request_reference_to_video.txt: mode "reference",
|
||||
// aspectRatio "21:9", split imageUrls / videoUrls / audioUrls with
|
||||
// uploadedUrls mirroring just the images.
|
||||
let mut request = base_request("The t-rex @image1 eats the banana @image2", 8);
|
||||
request.aspect_ratio = KinoviAspectRatioRaw::UltraWide21x9;
|
||||
request.reference_image_urls = Some(vec![
|
||||
"https://static.seedance2-pro.com/materials/a.png".to_string(),
|
||||
"https://static.seedance2-pro.com/materials/b.jpg".to_string(),
|
||||
]);
|
||||
request.reference_video_urls = Some(vec!["https://static.seedance2-pro.com/materials/ref.mp4".to_string()]);
|
||||
request.reference_audio_urls = Some(vec!["https://static.seedance2-pro.com/materials/ref.wav".to_string()]);
|
||||
|
||||
let body = build_batch_request(request);
|
||||
let json = serde_json::to_string(&body).unwrap();
|
||||
assert!(json.contains(r#""mode":"reference""#), "{json}");
|
||||
assert!(json.contains(r#""aspectRatio":"21:9""#), "{json}");
|
||||
let expected_images = r#"["https://static.seedance2-pro.com/materials/a.png","https://static.seedance2-pro.com/materials/b.jpg"]"#;
|
||||
assert!(json.contains(&format!(r#""imageUrls":{expected_images}"#)), "{json}");
|
||||
assert!(json.contains(&format!(r#""uploadedUrls":{expected_images}"#)), "{json}");
|
||||
assert!(json.contains(r#""videoUrls":["https://static.seedance2-pro.com/materials/ref.mp4"]"#), "{json}");
|
||||
assert!(json.contains(r#""audioUrls":["https://static.seedance2-pro.com/materials/ref.wav"]"#), "{json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_720p_and_face_blur_off_are_sent_explicitly() {
|
||||
let mut request = base_request("a corgi", 5);
|
||||
request.output_resolution = None;
|
||||
|
||||
let body = build_batch_request(request);
|
||||
let json = serde_json::to_string(&body).unwrap();
|
||||
assert!(json.contains(r#""outputResolution":"720p""#), "{json}");
|
||||
assert!(json.contains(r#""faceBlurMode":"off""#), "{json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_string_and_business_type() {
|
||||
assert_eq!(KinoviModelTypeRaw::Seedance2p5.as_api_str(), "seedance2-5");
|
||||
assert_eq!(KinoviModelTypeRaw::Seedance2p5.business_type(), "seedance25-preview-video-generation");
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2026-06-24 request-shape change ──
|
||||
//
|
||||
// The aspect ratio is now a ratio string ("16:9"). Seedance Pro/Fast keep it
|
||||
|
||||
+3
@@ -62,6 +62,8 @@ fn video_model_to_common_model_type(model: RouterVideoModel) -> Option<CommonMod
|
||||
RouterVideoModel::Seedance2p0Fast => Some(CommonModelType::Seedance2p0Fast),
|
||||
RouterVideoModel::HappyHorse1p0 => Some(CommonModelType::HappyHorse1p0),
|
||||
RouterVideoModel::MinimaxH3 => Some(CommonModelType::MinimaxH3),
|
||||
RouterVideoModel::MinimaxH3Turbo => Some(CommonModelType::MinimaxH3Turbo),
|
||||
RouterVideoModel::MinimaxH3Ultra => Some(CommonModelType::MinimaxH3Ultra),
|
||||
RouterVideoModel::Flux3 => Some(CommonModelType::Flux3),
|
||||
RouterVideoModel::Flux3Draft => Some(CommonModelType::Flux3Draft),
|
||||
RouterVideoModel::Sora2 => Some(CommonModelType::Sora2),
|
||||
@@ -88,6 +90,7 @@ fn video_model_to_common_model_type(model: RouterVideoModel) -> Option<CommonMod
|
||||
RouterVideoModel::GrokImagineVideo => Some(CommonModelType::GrokImagineVideo),
|
||||
RouterVideoModel::GrokImagineVideo1p5 => Some(CommonModelType::GrokImagineVideo1p5),
|
||||
RouterVideoModel::Seedance2p5Preview => Some(CommonModelType::Seedance2p5Preview),
|
||||
RouterVideoModel::Seedance2p5 => Some(CommonModelType::Seedance2p5),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -58,6 +58,7 @@ pub(super) async fn handle_artcraft_video_via_router(
|
||||
duration_seconds: request.duration_seconds,
|
||||
video_batch_count: request.video_batch_count,
|
||||
generate_audio: request.generate_audio,
|
||||
total_reference_video_input_seconds: None,
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
|
||||
idempotency_token: None,
|
||||
negative_prompt: None,
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
use enums::by_table::generic_inference_jobs::inference_job_type::InferenceJobType;
|
||||
use enums::by_table::generic_inference_jobs::inference_model_type::InferenceModelType;
|
||||
|
||||
/// The first-party Minimax H3 model tier a job runs on.
|
||||
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
|
||||
pub enum FirstPartyMinimaxH3Model {
|
||||
Turbo,
|
||||
Ultra,
|
||||
}
|
||||
|
||||
impl FirstPartyMinimaxH3Model {
|
||||
pub fn inference_job_type(&self) -> InferenceJobType {
|
||||
match self {
|
||||
Self::Turbo => InferenceJobType::ArtcraftMinimaxH3Turbo,
|
||||
Self::Ultra => InferenceJobType::ArtcraftMinimaxH3Ultra,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn inference_model_type(&self) -> InferenceModelType {
|
||||
match self {
|
||||
Self::Turbo => InferenceModelType::MinimaxH3Turbo,
|
||||
Self::Ultra => InferenceModelType::MinimaxH3Ultra,
|
||||
}
|
||||
}
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
//! Look up a first-party Minimax H3 job by token.
|
||||
//!
|
||||
//! Only returns rows whose `job_type` is one of the first-party Minimax H3
|
||||
//! job types — the internal worker API must never read (or later mutate)
|
||||
//! unrelated jobs, so an unknown or non-Minimax token yields `Ok(None)`.
|
||||
|
||||
use sqlx::{Executor, MySql};
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use enums::by_table::generic_inference_jobs::inference_job_type::InferenceJobType;
|
||||
use enums::common::job_status_plus::JobStatusPlus;
|
||||
use enums::common::platform_type::PlatformType;
|
||||
use tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken;
|
||||
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken;
|
||||
use tokens::tokens::prompts::PromptToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
pub struct GetFirstPartyMinimaxH3JobByTokenArgs<'e, 'c, E>
|
||||
where E: 'e + Executor<'c, Database = MySql>
|
||||
{
|
||||
pub job_token: &'e InferenceJobToken,
|
||||
|
||||
pub mysql_executor: E,
|
||||
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct FirstPartyMinimaxH3JobDetails {
|
||||
pub job_token: InferenceJobToken,
|
||||
|
||||
pub status: JobStatusPlus,
|
||||
pub job_type: InferenceJobType,
|
||||
|
||||
pub maybe_creator_user_token: Option<UserToken>,
|
||||
pub maybe_creator_anonymous_visitor_token: Option<AnonymousVisitorTrackingToken>,
|
||||
pub creator_ip_address: String,
|
||||
|
||||
pub maybe_prompt_token: Option<PromptToken>,
|
||||
pub maybe_debug_log_event_token: Option<DebugLogEventToken>,
|
||||
|
||||
pub maybe_platform_type: Option<PlatformType>,
|
||||
|
||||
/// Set once the job has succeeded: the resulting media file.
|
||||
pub maybe_result_media_file_token: Option<MediaFileToken>,
|
||||
}
|
||||
|
||||
pub async fn get_first_party_minimax_h3_job_by_token<'e, 'c : 'e, E>(
|
||||
args: GetFirstPartyMinimaxH3JobByTokenArgs<'e, 'c, E>
|
||||
) -> Result<Option<FirstPartyMinimaxH3JobDetails>, sqlx::Error>
|
||||
where E: 'e + Executor<'c, Database = MySql>
|
||||
{
|
||||
let maybe_row = sqlx::query!(
|
||||
r#"
|
||||
SELECT
|
||||
token as `job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,
|
||||
status as `status: enums::common::job_status_plus::JobStatusPlus`,
|
||||
job_type as `job_type: enums::by_table::generic_inference_jobs::inference_job_type::InferenceJobType`,
|
||||
maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,
|
||||
maybe_creator_anonymous_visitor_token as `maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken`,
|
||||
creator_ip_address,
|
||||
maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,
|
||||
maybe_debug_log_event_token as `maybe_debug_log_event_token: tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken`,
|
||||
platform_type as `maybe_platform_type: enums::common::platform_type::PlatformType`,
|
||||
on_success_result_entity_token as `maybe_result_media_file_token: tokens::tokens::media_files::MediaFileToken`
|
||||
FROM generic_inference_jobs
|
||||
WHERE token = ?
|
||||
AND job_type IN ('artcraft_minimax_h3_turbo', 'artcraft_minimax_h3_ultra')
|
||||
"#,
|
||||
args.job_token.as_str(),
|
||||
)
|
||||
.fetch_optional(args.mysql_executor)
|
||||
.await?;
|
||||
|
||||
let Some(row) = maybe_row else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let job_type = match row.job_type {
|
||||
Some(job_type) => job_type,
|
||||
// Unreachable given the WHERE clause, but don't panic on it.
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
Ok(Some(FirstPartyMinimaxH3JobDetails {
|
||||
job_token: row.job_token,
|
||||
status: row.status,
|
||||
job_type,
|
||||
maybe_creator_user_token: row.maybe_creator_user_token,
|
||||
maybe_creator_anonymous_visitor_token: row.maybe_creator_anonymous_visitor_token,
|
||||
creator_ip_address: row.creator_ip_address,
|
||||
maybe_prompt_token: row.maybe_prompt_token,
|
||||
maybe_debug_log_event_token: row.maybe_debug_log_event_token,
|
||||
maybe_platform_type: row.maybe_platform_type,
|
||||
maybe_result_media_file_token: row.maybe_result_media_file_token,
|
||||
}))
|
||||
}
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
//! Insert a first-party Minimax H3 (Turbo or Ultra) inference job.
|
||||
//!
|
||||
//! Unlike the provider-flavored inserts (Fal, KinoviWeb, …), these jobs run
|
||||
//! on our own GPU inference, so there is no external third party and no
|
||||
//! external third party id — those columns are left NULL. A scheduler picks
|
||||
//! the pending rows up later.
|
||||
|
||||
use log::info;
|
||||
use sqlx::{Executor, MySql};
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use enums::by_table::generic_inference_jobs::inference_category::InferenceCategory;
|
||||
use enums::by_table::generic_inference_jobs::inference_job_product_category::InferenceJobProductCategory;
|
||||
use enums::common::job_status_plus::JobStatusPlus;
|
||||
use enums::common::platform_type::PlatformType;
|
||||
use enums::common::visibility::Visibility;
|
||||
use tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken;
|
||||
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
|
||||
use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken;
|
||||
use tokens::tokens::prompts::PromptToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
use tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken;
|
||||
|
||||
use crate::errors::database_query_error::DatabaseQueryError;
|
||||
use crate::payloads::generic_inference_args::generic_inference_args::GenericInferenceArgs;
|
||||
use crate::queries::generic_inference::common::job_cost_estimates::JobCostEstimates;
|
||||
use crate::queries::generic_inference::first_party::minimax_h3::first_party_minimax_h3_model::FirstPartyMinimaxH3Model;
|
||||
use crate::queries::generic_inference::common::insert_full_generic_inference_job_record::{
|
||||
insert_full_generic_inference_job_record,
|
||||
InsertFullGenericInferenceJobRecordArgs,
|
||||
};
|
||||
|
||||
pub struct InsertGenericInferenceForFirstPartyMinimaxH3WithAprioriJobTokenArgs<'e, 'c, E>
|
||||
where E: 'e + Executor<'c, Database = MySql>
|
||||
{
|
||||
/// Which first-party Minimax H3 model to enqueue.
|
||||
pub minimax_model: FirstPartyMinimaxH3Model,
|
||||
|
||||
pub uuid_idempotency_token: &'e str,
|
||||
|
||||
// NOTE: We'll generate this ahead of time so we can save it with billing info!
|
||||
pub apriori_job_token: &'e InferenceJobToken,
|
||||
|
||||
pub maybe_inference_args: Option<GenericInferenceArgs>,
|
||||
|
||||
pub maybe_prompt_token: Option<&'e PromptToken>,
|
||||
|
||||
pub maybe_wallet_ledger_entry_token: Option<&'e WalletLedgerEntryToken>,
|
||||
|
||||
/// User-facing cost in our own credits. None for the free Turbo tier.
|
||||
pub maybe_system_cost_credits: Option<u32>,
|
||||
|
||||
/// User-facing cost in USD cents. None for the free Turbo tier.
|
||||
pub maybe_system_cost_usd_cents: Option<u32>,
|
||||
|
||||
pub maybe_creator_user_token: Option<&'e UserToken>,
|
||||
pub maybe_avt_token: Option<&'e AnonymousVisitorTrackingToken>,
|
||||
pub creator_ip_address: &'e str,
|
||||
pub creator_set_visibility: Visibility,
|
||||
|
||||
/// The platform the enqueuing request came from, inferred from its User-Agent.
|
||||
pub maybe_platform_type: Option<PlatformType>,
|
||||
|
||||
pub maybe_debug_log_event_token: Option<&'e DebugLogEventToken>,
|
||||
|
||||
pub mysql_executor: E,
|
||||
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
pub async fn insert_generic_inference_job_for_first_party_minimax_h3_with_apriori_job_token<'e, 'c : 'e, E>(
|
||||
args: InsertGenericInferenceForFirstPartyMinimaxH3WithAprioriJobTokenArgs<'e, 'c, E>
|
||||
) -> Result<InferenceJobToken, DatabaseQueryError>
|
||||
where E: 'e + Executor<'c, Database = MySql>
|
||||
{
|
||||
let job_type = args.minimax_model.inference_job_type();
|
||||
let model_type = args.minimax_model.inference_model_type();
|
||||
|
||||
let cost_estimates = JobCostEstimates {
|
||||
// First-party jobs have no external provider, so no provider-side costs.
|
||||
maybe_external_third_party_cost_credits: None,
|
||||
maybe_external_third_party_cost_usd_cents: None,
|
||||
maybe_system_cost_credits: args.maybe_system_cost_credits,
|
||||
maybe_system_cost_usd_cents: args.maybe_system_cost_usd_cents,
|
||||
};
|
||||
|
||||
let record_id = insert_full_generic_inference_job_record(InsertFullGenericInferenceJobRecordArgs {
|
||||
token: args.apriori_job_token,
|
||||
uuid_idempotency_token: args.uuid_idempotency_token,
|
||||
|
||||
job_type,
|
||||
|
||||
// No external provider: a scheduler will run these on our own GPUs.
|
||||
maybe_external_third_party: None,
|
||||
maybe_external_third_party_id: None,
|
||||
|
||||
maybe_product_category: Some(InferenceJobProductCategory::ArtcraftMinimaxH3),
|
||||
inference_category: InferenceCategory::VideoGeneration,
|
||||
|
||||
maybe_model_type: Some(model_type),
|
||||
|
||||
maybe_prompt_token: args.maybe_prompt_token,
|
||||
maybe_wallet_ledger_entry_token: args.maybe_wallet_ledger_entry_token,
|
||||
|
||||
maybe_inference_args: args.maybe_inference_args,
|
||||
|
||||
maybe_creator_user_token: args.maybe_creator_user_token,
|
||||
maybe_avt_token: args.maybe_avt_token,
|
||||
creator_ip_address: args.creator_ip_address,
|
||||
creator_set_visibility: args.creator_set_visibility,
|
||||
|
||||
maybe_platform_type: args.maybe_platform_type,
|
||||
|
||||
maybe_cost_estimates: Some(cost_estimates),
|
||||
|
||||
maybe_debug_log_event_token: args.maybe_debug_log_event_token,
|
||||
|
||||
maybe_frontend_failure_category: None,
|
||||
maybe_failure_reason: None,
|
||||
|
||||
status: JobStatusPlus::Pending,
|
||||
|
||||
mysql_executor: args.mysql_executor,
|
||||
phantom: args.phantom,
|
||||
|
||||
// Web-only fields — first-party enqueues never set these.
|
||||
maybe_model_token: None,
|
||||
maybe_input_source_token: None,
|
||||
maybe_input_source_token_type: None,
|
||||
maybe_download_url: None,
|
||||
maybe_cover_image_media_file_token: None,
|
||||
maybe_raw_inference_text: None,
|
||||
maybe_routing_tag: None,
|
||||
priority_level: 0,
|
||||
requires_keepalive: false,
|
||||
max_duration_seconds: 0,
|
||||
is_debug_request: false,
|
||||
|
||||
}).await?;
|
||||
|
||||
info!("Insert generic inference job for first-party Minimax H3 ({:?}): {} with record ID {}",
|
||||
args.minimax_model, args.apriori_job_token, record_id);
|
||||
|
||||
Ok(args.apriori_job_token.clone())
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
//! Permanently mark a first-party Minimax H3 job as failed.
|
||||
//!
|
||||
//! Never flips an already-terminal job: a late or duplicate failure report
|
||||
//! against a `complete_success` / `complete_failure` row is silently ignored.
|
||||
|
||||
use sqlx::{Executor, MySql};
|
||||
use std::convert::TryFrom;
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use enums::by_table::generic_inference_jobs::frontend_failure_category::FrontendFailureCategory;
|
||||
use enums::common::job_status_plus::JobStatusPlus;
|
||||
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
|
||||
|
||||
/// `failure_reason` / `internal_debugging_failure_reason` are VARCHAR(512).
|
||||
const MAX_FAILURE_REASON_CHARS: usize = 512;
|
||||
|
||||
pub struct MarkFirstPartyMinimaxH3JobFailedArgs<'e, 'c, E>
|
||||
where E: 'e + Executor<'c, Database = MySql>
|
||||
{
|
||||
pub job_token: &'e InferenceJobToken,
|
||||
|
||||
pub maybe_frontend_failure_category: Option<FrontendFailureCategory>,
|
||||
|
||||
/// User-facing failure reason. Clamped to 512 characters.
|
||||
pub maybe_failure_reason: Option<&'e str>,
|
||||
|
||||
/// Internal-only stack trace or error. Clamped to 512 characters.
|
||||
pub maybe_internal_debugging_failure_reason: Option<&'e str>,
|
||||
|
||||
/// Total wall-clock runtime of the failed attempt, in milliseconds.
|
||||
/// NB: Written to `success_execution_millis` — the column name is
|
||||
/// unfortunate, but we want the runtime data for failures too.
|
||||
pub maybe_execution_duration_millis: Option<u64>,
|
||||
|
||||
/// Inference-only runtime of the failed attempt, in milliseconds.
|
||||
/// NB: Written to `success_inference_execution_millis` — the column name is
|
||||
/// unfortunate, but we want the runtime data for failures too.
|
||||
pub maybe_inference_duration_millis: Option<u64>,
|
||||
|
||||
pub mysql_executor: E,
|
||||
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
pub async fn mark_first_party_minimax_h3_job_failed<'e, 'c : 'e, E>(
|
||||
args: MarkFirstPartyMinimaxH3JobFailedArgs<'e, 'c, E>
|
||||
) -> Result<(), sqlx::Error>
|
||||
where E: 'e + Executor<'c, Database = MySql>
|
||||
{
|
||||
let maybe_failure_reason = args.maybe_failure_reason
|
||||
.map(|reason| clamp_chars(reason, MAX_FAILURE_REASON_CHARS));
|
||||
|
||||
let maybe_internal_debugging_failure_reason = args.maybe_internal_debugging_failure_reason
|
||||
.map(|reason| clamp_chars(reason, MAX_FAILURE_REASON_CHARS));
|
||||
|
||||
sqlx::query!(
|
||||
r#"
|
||||
UPDATE generic_inference_jobs
|
||||
SET
|
||||
status = ?,
|
||||
frontend_failure_category = ?,
|
||||
failure_reason = ?,
|
||||
internal_debugging_failure_reason = ?,
|
||||
success_execution_millis = ?,
|
||||
success_inference_execution_millis = ?
|
||||
WHERE token = ?
|
||||
AND status NOT IN ('complete_success', 'complete_failure')
|
||||
"#,
|
||||
JobStatusPlus::CompleteFailure.to_str(),
|
||||
args.maybe_frontend_failure_category.map(|category| category.to_str()),
|
||||
maybe_failure_reason,
|
||||
maybe_internal_debugging_failure_reason,
|
||||
args.maybe_execution_duration_millis.and_then(|millis| u32::try_from(millis).ok()),
|
||||
args.maybe_inference_duration_millis.and_then(|millis| u32::try_from(millis).ok()),
|
||||
args.job_token.as_str(),
|
||||
)
|
||||
.execute(args.mysql_executor)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn clamp_chars(value: &str, max_chars: usize) -> &str {
|
||||
match value.char_indices().nth(max_chars) {
|
||||
Some((byte_index, _)) => &value[..byte_index],
|
||||
None => value,
|
||||
}
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
//! Mark a first-party Minimax H3 job as started and record which worker
|
||||
//! obtained it.
|
||||
//!
|
||||
//! Takes a transaction (not a generic executor): this must run in the same
|
||||
//! transaction as [`select_pending_first_party_minimax_h3_job_for_update`]
|
||||
//! so the select-then-mark pair is atomic.
|
||||
//!
|
||||
//! [`select_pending_first_party_minimax_h3_job_for_update`]:
|
||||
//! crate::queries::generic_inference::first_party::minimax_h3::select_pending_first_party_minimax_h3_job_for_update
|
||||
|
||||
use sqlx::{MySql, Transaction};
|
||||
|
||||
use enums::common::job_status_plus::JobStatusPlus;
|
||||
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
|
||||
|
||||
/// `assigned_worker` / `assigned_cluster` are VARCHAR(128).
|
||||
const MAX_WORKER_FIELD_CHARS: usize = 128;
|
||||
|
||||
pub struct MarkFirstPartyMinimaxH3JobStartedArgs<'a, 'tx> {
|
||||
pub job_token: &'a InferenceJobToken,
|
||||
|
||||
/// The worker's hostname (linux hostname, k8s pod name), when reported.
|
||||
pub maybe_worker_hostname: Option<&'a str>,
|
||||
|
||||
/// The cluster the worker runs in (e.g. "runpod", "lambda"), when reported.
|
||||
pub maybe_cluster_name: Option<&'a str>,
|
||||
|
||||
pub transaction: &'a mut Transaction<'tx, MySql>,
|
||||
}
|
||||
|
||||
pub async fn mark_first_party_minimax_h3_job_started(
|
||||
args: MarkFirstPartyMinimaxH3JobStartedArgs<'_, '_>
|
||||
) -> Result<(), sqlx::Error> {
|
||||
let maybe_worker_hostname = args.maybe_worker_hostname
|
||||
.map(|hostname| clamp_chars(hostname, MAX_WORKER_FIELD_CHARS));
|
||||
|
||||
let maybe_cluster_name = args.maybe_cluster_name
|
||||
.map(|cluster| clamp_chars(cluster, MAX_WORKER_FIELD_CHARS));
|
||||
|
||||
sqlx::query!(
|
||||
r#"
|
||||
UPDATE generic_inference_jobs
|
||||
SET
|
||||
status = ?,
|
||||
assigned_worker = ?,
|
||||
last_assigned_worker = ?,
|
||||
assigned_cluster = ?,
|
||||
first_started_at = NOW()
|
||||
WHERE token = ?
|
||||
"#,
|
||||
JobStatusPlus::Started.to_str(),
|
||||
maybe_worker_hostname,
|
||||
maybe_worker_hostname,
|
||||
maybe_cluster_name,
|
||||
args.job_token.as_str(),
|
||||
)
|
||||
.execute(&mut **args.transaction)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn clamp_chars(value: &str, max_chars: usize) -> &str {
|
||||
match value.char_indices().nth(max_chars) {
|
||||
Some((byte_index, _)) => &value[..byte_index],
|
||||
None => value,
|
||||
}
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
//! Mark a first-party Minimax H3 job as successfully completed, pointing at
|
||||
//! the resulting media file. Mirrors the Fal webhook success write.
|
||||
//!
|
||||
//! Never flips an already-terminal job: a late or duplicate success report
|
||||
//! against a `complete_success` / `complete_failure` row is silently ignored.
|
||||
|
||||
use sqlx::{Executor, MySql};
|
||||
use std::convert::TryFrom;
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use enums::by_table::generic_inference_jobs::inference_result_type::InferenceResultType;
|
||||
use enums::common::job_status_plus::JobStatusPlus;
|
||||
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
pub struct MarkFirstPartyMinimaxH3JobSucceededArgs<'e, 'c, E>
|
||||
where E: 'e + Executor<'c, Database = MySql>
|
||||
{
|
||||
pub job_token: &'e InferenceJobToken,
|
||||
|
||||
/// The media file created from the worker's uploaded video.
|
||||
pub media_file_token: &'e MediaFileToken,
|
||||
|
||||
/// Total wall-clock runtime of the job, in milliseconds.
|
||||
pub maybe_execution_duration_millis: Option<u64>,
|
||||
|
||||
/// Inference-only runtime of the job, in milliseconds.
|
||||
pub maybe_inference_duration_millis: Option<u64>,
|
||||
|
||||
pub mysql_executor: E,
|
||||
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
pub async fn mark_first_party_minimax_h3_job_succeeded<'e, 'c : 'e, E>(
|
||||
args: MarkFirstPartyMinimaxH3JobSucceededArgs<'e, 'c, E>
|
||||
) -> Result<(), sqlx::Error>
|
||||
where E: 'e + Executor<'c, Database = MySql>
|
||||
{
|
||||
sqlx::query!(
|
||||
r#"
|
||||
UPDATE generic_inference_jobs
|
||||
SET
|
||||
status = ?,
|
||||
on_success_result_entity_type = ?,
|
||||
on_success_result_entity_token = ?,
|
||||
failure_reason = NULL,
|
||||
internal_debugging_failure_reason = NULL,
|
||||
success_execution_millis = ?,
|
||||
success_inference_execution_millis = ?,
|
||||
retry_at = NULL,
|
||||
successfully_completed_at = NOW()
|
||||
WHERE token = ?
|
||||
AND status NOT IN ('complete_success', 'complete_failure')
|
||||
"#,
|
||||
JobStatusPlus::CompleteSuccess.to_str(),
|
||||
InferenceResultType::MediaFile.to_str(),
|
||||
args.media_file_token.as_str(),
|
||||
args.maybe_execution_duration_millis.and_then(|millis| u32::try_from(millis).ok()),
|
||||
args.maybe_inference_duration_millis.and_then(|millis| u32::try_from(millis).ok()),
|
||||
args.job_token.as_str(),
|
||||
)
|
||||
.execute(args.mysql_executor)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
pub mod first_party_minimax_h3_model;
|
||||
pub mod get_first_party_minimax_h3_job_by_token;
|
||||
pub mod insert_generic_inference_job_for_first_party_minimax_h3_with_apriori_job_token;
|
||||
pub mod mark_first_party_minimax_h3_job_failed;
|
||||
pub mod mark_first_party_minimax_h3_job_started;
|
||||
pub mod mark_first_party_minimax_h3_job_succeeded;
|
||||
pub mod select_pending_first_party_minimax_h3_job_for_update;
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
//! `SELECT ... FOR UPDATE SKIP LOCKED` the oldest pending first-party
|
||||
//! Minimax H3 job of a given model tier.
|
||||
//!
|
||||
//! Takes a transaction (not a generic executor) — the row lock only means
|
||||
//! anything inside a transaction, and the caller must run
|
||||
//! [`mark_first_party_minimax_h3_job_started`] in the same transaction so the
|
||||
//! select-then-mark pair is atomic. `SKIP LOCKED` keeps concurrent workers
|
||||
//! from blocking on (or double-obtaining) the same row: each worker locks a
|
||||
//! distinct pending row or gets `None`.
|
||||
//!
|
||||
//! Performance: the scan is driven by `index_job_type` (job_type, then PK
|
||||
//! order, so "oldest first" is index order) and only pending rows survive
|
||||
//! the filter; pending first-party rows are expected to be few.
|
||||
//!
|
||||
//! [`mark_first_party_minimax_h3_job_started`]:
|
||||
//! crate::queries::generic_inference::first_party::minimax_h3::mark_first_party_minimax_h3_job_started
|
||||
|
||||
use sqlx::{MySql, Transaction};
|
||||
|
||||
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
|
||||
use tokens::tokens::prompts::PromptToken;
|
||||
|
||||
use crate::queries::generic_inference::first_party::minimax_h3::first_party_minimax_h3_model::FirstPartyMinimaxH3Model;
|
||||
|
||||
pub struct SelectPendingFirstPartyMinimaxH3JobForUpdateArgs<'a, 'tx> {
|
||||
/// Which model tier to obtain a job for.
|
||||
pub minimax_model: FirstPartyMinimaxH3Model,
|
||||
|
||||
pub transaction: &'a mut Transaction<'tx, MySql>,
|
||||
}
|
||||
|
||||
/// The locked pending job.
|
||||
#[derive(Debug)]
|
||||
pub struct PendingFirstPartyMinimaxH3Job {
|
||||
pub job_token: InferenceJobToken,
|
||||
pub maybe_prompt_token: Option<PromptToken>,
|
||||
}
|
||||
|
||||
/// Returns the locked job, or `None` when no unlocked pending job of the
|
||||
/// given tier exists.
|
||||
pub async fn select_pending_first_party_minimax_h3_job_for_update(
|
||||
args: SelectPendingFirstPartyMinimaxH3JobForUpdateArgs<'_, '_>
|
||||
) -> Result<Option<PendingFirstPartyMinimaxH3Job>, sqlx::Error> {
|
||||
let job_type = args.minimax_model.inference_job_type();
|
||||
|
||||
let maybe_row = sqlx::query!(
|
||||
r#"
|
||||
SELECT
|
||||
token as `job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,
|
||||
maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`
|
||||
FROM generic_inference_jobs
|
||||
WHERE job_type = ?
|
||||
AND status = 'pending'
|
||||
ORDER BY id ASC
|
||||
LIMIT 1
|
||||
FOR UPDATE SKIP LOCKED
|
||||
"#,
|
||||
job_type.to_str(),
|
||||
)
|
||||
.fetch_optional(&mut **args.transaction)
|
||||
.await?;
|
||||
|
||||
Ok(maybe_row.map(|row| PendingFirstPartyMinimaxH3Job {
|
||||
job_token: row.job_token,
|
||||
maybe_prompt_token: row.maybe_prompt_token,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
//! Jobs fulfilled by our own first-party GPU inference (no external provider).
|
||||
|
||||
pub mod minimax_h3;
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
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::generic_inference_jobs::InferenceJobToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use tokens::tokens::prompts::PromptToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
/// A job has a bounded number of outputs (the largest batches are single
|
||||
/// digits), so the list is capped rather than paginated.
|
||||
const MAX_RESULTS: u32 = 100;
|
||||
|
||||
pub struct JobMediaFileRecord {
|
||||
pub token: MediaFileToken,
|
||||
|
||||
pub media_class: MediaFileClass,
|
||||
pub media_type: MediaFileType,
|
||||
|
||||
pub maybe_batch_token: Option<BatchGenerationToken>,
|
||||
pub maybe_prompt_token: Option<PromptToken>,
|
||||
|
||||
pub public_bucket_directory_hash: String,
|
||||
pub maybe_public_bucket_prefix: Option<String>,
|
||||
pub maybe_public_bucket_extension: Option<String>,
|
||||
|
||||
pub maybe_file_cover_image_public_bucket_hash: Option<String>,
|
||||
pub maybe_file_cover_image_public_bucket_prefix: Option<String>,
|
||||
pub maybe_file_cover_image_public_bucket_extension: Option<String>,
|
||||
|
||||
pub maybe_origin_filename: Option<String>,
|
||||
pub maybe_duration_millis: Option<u64>,
|
||||
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
pub struct ListMediaFilesBySourceJobArgs<'a, 'c, E>
|
||||
where
|
||||
E: 'a + Executor<'c, Database = MySql>,
|
||||
{
|
||||
/// The inference job whose outputs to list.
|
||||
pub job_token: &'a InferenceJobToken,
|
||||
|
||||
/// Only return files created by this user (the session user). Enforces that
|
||||
/// callers can only list the outputs of their own jobs.
|
||||
pub creator_user_token: &'a UserToken,
|
||||
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
/// List the media files produced by an inference job (`maybe_source_job_token`),
|
||||
/// oldest first. Deliberately lean: no user/model/stats joins — just the file
|
||||
/// fields plus a self-join for the cover image.
|
||||
pub async fn list_media_files_by_source_job<'a, 'c: 'a, E>(
|
||||
args: ListMediaFilesBySourceJobArgs<'a, 'c, E>,
|
||||
) -> Result<Vec<JobMediaFileRecord>, sqlx::Error>
|
||||
where
|
||||
E: 'a + Executor<'c, Database = MySql>,
|
||||
{
|
||||
sqlx::query_as!(
|
||||
JobMediaFileRecord,
|
||||
r#"
|
||||
SELECT
|
||||
m.token as `token: tokens::tokens::media_files::MediaFileToken`,
|
||||
|
||||
m.media_class as `media_class: enums::by_table::media_files::media_file_class::MediaFileClass`,
|
||||
m.media_type as `media_type: enums::by_table::media_files::media_file_type::MediaFileType`,
|
||||
|
||||
m.maybe_batch_token as `maybe_batch_token: tokens::tokens::batch_generations::BatchGenerationToken`,
|
||||
m.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,
|
||||
|
||||
m.public_bucket_directory_hash,
|
||||
m.maybe_public_bucket_prefix,
|
||||
m.maybe_public_bucket_extension,
|
||||
|
||||
media_file_cover_image.public_bucket_directory_hash as maybe_file_cover_image_public_bucket_hash,
|
||||
media_file_cover_image.maybe_public_bucket_prefix as maybe_file_cover_image_public_bucket_prefix,
|
||||
media_file_cover_image.maybe_public_bucket_extension as maybe_file_cover_image_public_bucket_extension,
|
||||
|
||||
m.maybe_origin_filename,
|
||||
m.maybe_duration_millis as `maybe_duration_millis: u64`,
|
||||
|
||||
m.created_at,
|
||||
m.updated_at
|
||||
|
||||
FROM media_files AS m
|
||||
LEFT OUTER JOIN media_files as media_file_cover_image
|
||||
ON media_file_cover_image.token = m.maybe_cover_image_media_file_token
|
||||
WHERE
|
||||
m.maybe_source_job_token = ?
|
||||
AND m.maybe_creator_user_token = ?
|
||||
AND m.user_deleted_at IS NULL
|
||||
AND m.mod_deleted_at IS NULL
|
||||
AND NOT m.is_intermediate_system_file
|
||||
ORDER BY m.id ASC
|
||||
LIMIT ?
|
||||
"#,
|
||||
args.job_token,
|
||||
args.creator_user_token,
|
||||
MAX_RESULTS,
|
||||
)
|
||||
.fetch_all(args.mysql_executor)
|
||||
.await
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod _keys;
|
||||
pub mod count_untried_jobs_of_type;
|
||||
pub mod list_available_generic_inference_jobs;
|
||||
pub mod list_media_files_by_source_job;
|
||||
pub mod mark_generic_inference_job_completely_failed;
|
||||
pub mod mark_generic_inference_job_failure;
|
||||
pub mod mark_job_failed_by_token;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod api_providers;
|
||||
pub mod common;
|
||||
pub mod first_party;
|
||||
pub mod job;
|
||||
pub mod moderation;
|
||||
pub mod web;
|
||||
|
||||
+18
-5
@@ -1,5 +1,5 @@
|
||||
use anyhow::anyhow;
|
||||
use sqlx::MySqlPool;
|
||||
use sqlx::{Acquire, MySql};
|
||||
|
||||
use enums::by_table::generic_synthetic_ids::id_category::IdCategory;
|
||||
use enums::by_table::media_files::media_file_class::MediaFileClass;
|
||||
@@ -14,6 +14,7 @@ use enums::common::visibility::Visibility;
|
||||
use errors::AnyhowResult;
|
||||
use tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken;
|
||||
use tokens::tokens::batch_generations::BatchGenerationToken;
|
||||
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use tokens::tokens::model_weights::ModelWeightToken;
|
||||
use tokens::tokens::prompts::PromptToken;
|
||||
@@ -23,8 +24,13 @@ use crate::payloads::media_file_extra_info::media_file_extra_info::MediaFileExtr
|
||||
use crate::queries::generic_synthetic_ids::transactional_increment_generic_synthetic_id::transactional_increment_generic_synthetic_id;
|
||||
use crate::queries::media_files::create::generic_insert::insert_media_file_generic_executor::{insert_media_file_generic_executor, InsertMediaFileGenericExecutorArgs};
|
||||
|
||||
pub struct InsertArgs<'a> {
|
||||
pub pool: &'a MySqlPool,
|
||||
pub struct InsertArgs<'a, A>
|
||||
where
|
||||
A: Acquire<'a, Database = MySql>,
|
||||
{
|
||||
/// Where to get the connection: a `&MySqlPool` (acquires one) or an
|
||||
/// already-acquired `&mut PoolConnection` (reuses it).
|
||||
pub pool: A,
|
||||
|
||||
// Creator info
|
||||
pub maybe_creator_user_token: Option<&'a UserToken>,
|
||||
@@ -71,6 +77,9 @@ pub struct InsertArgs<'a> {
|
||||
// If batch generated, this is the batch token.
|
||||
pub maybe_batch_token: Option<&'a BatchGenerationToken>,
|
||||
|
||||
// If produced by an inference job, the job that generated this file.
|
||||
pub maybe_source_job_token: Option<&'a InferenceJobToken>,
|
||||
|
||||
// Storage details
|
||||
pub public_bucket_directory_hash: &'a str,
|
||||
pub maybe_public_bucket_prefix: Option<&'a str>,
|
||||
@@ -107,9 +116,12 @@ pub struct InsertArgs<'a> {
|
||||
/// Insert a media file record, allocating the creator's synthetic IDs in the
|
||||
/// same transaction. Wraps [`insert_media_file_generic_executor`], which runs
|
||||
/// the actual INSERT.
|
||||
pub async fn insert_media_file_generic(
|
||||
args: InsertArgs<'_>
|
||||
pub async fn insert_media_file_generic<'a, A>(
|
||||
args: InsertArgs<'a, A>
|
||||
) -> AnyhowResult<(MediaFileToken, u64)>
|
||||
where
|
||||
A: Acquire<'a, Database = MySql> + Send,
|
||||
<A as Acquire<'a>>::Connection: Send,
|
||||
{
|
||||
let extra_file_modification_info = args
|
||||
.maybe_extra_media_info.map(|extra| extra.to_json_string())
|
||||
@@ -165,6 +177,7 @@ pub async fn insert_media_file_generic(
|
||||
maybe_scene_source_media_file_token: args.maybe_scene_source_media_file_token,
|
||||
maybe_prompt_token: args.maybe_prompt_token,
|
||||
maybe_batch_token: args.maybe_batch_token,
|
||||
maybe_source_job_token: args.maybe_source_job_token,
|
||||
public_bucket_directory_hash: args.public_bucket_directory_hash,
|
||||
maybe_public_bucket_prefix: args.maybe_public_bucket_prefix,
|
||||
maybe_public_bucket_extension: args.maybe_public_bucket_extension,
|
||||
|
||||
+6
@@ -14,6 +14,7 @@ use enums::common::platform_type::PlatformType;
|
||||
use enums::common::visibility::Visibility;
|
||||
use tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken;
|
||||
use tokens::tokens::batch_generations::BatchGenerationToken;
|
||||
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use tokens::tokens::model_weights::ModelWeightToken;
|
||||
use tokens::tokens::prompts::PromptToken;
|
||||
@@ -74,6 +75,9 @@ where
|
||||
// If batch generated, this is the batch token.
|
||||
pub maybe_batch_token: Option<&'a BatchGenerationToken>,
|
||||
|
||||
// If produced by an inference job, the job that generated this file.
|
||||
pub maybe_source_job_token: Option<&'a InferenceJobToken>,
|
||||
|
||||
// Storage details
|
||||
pub public_bucket_directory_hash: &'a str,
|
||||
pub maybe_public_bucket_prefix: Option<&'a str>,
|
||||
@@ -159,6 +163,7 @@ where
|
||||
maybe_origin_filename = ?,
|
||||
|
||||
maybe_batch_token = ?,
|
||||
maybe_source_job_token = ?,
|
||||
|
||||
maybe_mime_type = ?,
|
||||
file_size_bytes = ?,
|
||||
@@ -218,6 +223,7 @@ where
|
||||
args.maybe_origin_filename,
|
||||
|
||||
args.maybe_batch_token.map(|t| t.as_str()),
|
||||
args.maybe_source_job_token.map(|t| t.as_str()),
|
||||
|
||||
args.maybe_mime_type,
|
||||
args.file_size_bytes,
|
||||
|
||||
+2
-1
@@ -99,6 +99,7 @@ pub async fn insert_media_file_generic_from_job(
|
||||
is_user_upload: false, // NB: Jobs typically won't be user uploads. Maybe this becomes a param in the future(?)
|
||||
maybe_audio_encoding: args.maybe_audio_encoding,
|
||||
maybe_batch_token: args.maybe_batch_token,
|
||||
maybe_source_job_token: Some(&args.job.inference_job_token),
|
||||
maybe_creator_anonymous_visitor_token: args.job.maybe_creator_anonymous_visitor_token_typed.as_ref(),
|
||||
maybe_creator_category_synthetic_id_category: args.maybe_creator_category_synthetic_id_category,
|
||||
maybe_creator_file_synthetic_id_category: args.maybe_creator_file_synthetic_id_category,
|
||||
@@ -127,7 +128,7 @@ pub async fn insert_media_file_generic_from_job(
|
||||
media_type: args.media_type,
|
||||
origin_category: args.origin_category,
|
||||
origin_product_category: args.origin_product_category,
|
||||
pool: &args.pool,
|
||||
pool: args.pool,
|
||||
public_bucket_directory_hash: args.public_bucket_directory_hash,
|
||||
}).await
|
||||
}
|
||||
|
||||
+33
-8
@@ -8,11 +8,13 @@ use enums::by_table::media_files::media_file_origin_category::MediaFileOriginCat
|
||||
use enums::by_table::media_files::media_file_origin_product_category::MediaFileOriginProductCategory;
|
||||
use enums::by_table::media_files::media_file_type::MediaFileType;
|
||||
use enums::common::visibility::Visibility;
|
||||
use sqlx::MySqlPool;
|
||||
use sqlx::pool::PoolConnection;
|
||||
use sqlx::{Acquire, MySql, MySqlPool};
|
||||
use enums::common::generation_provider::GenerationProvider;
|
||||
use enums::common::platform_type::PlatformType;
|
||||
use tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken;
|
||||
use tokens::tokens::batch_generations::BatchGenerationToken;
|
||||
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use tokens::tokens::prompts::PromptToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
@@ -75,6 +77,9 @@ pub struct MediaFileInsertBuilder {
|
||||
// // If batch generated, this is the batch token.
|
||||
maybe_batch_generation_token: Option<BatchGenerationToken>,
|
||||
|
||||
// If produced by an inference job, the job that generated this file.
|
||||
maybe_source_job_token: Option<InferenceJobToken>,
|
||||
|
||||
// Cover image (e.g. thumbnail for 3D splats)
|
||||
maybe_cover_image_media_file_token: Option<MediaFileToken>,
|
||||
|
||||
@@ -117,6 +122,7 @@ impl MediaFileInsertBuilder {
|
||||
maybe_origin_filename: None,
|
||||
maybe_engine_category: None,
|
||||
maybe_batch_generation_token: None,
|
||||
maybe_source_job_token: None,
|
||||
maybe_cover_image_media_file_token: None,
|
||||
public_bucket_directory_hash: None,
|
||||
maybe_prompt_token: None,
|
||||
@@ -263,6 +269,11 @@ impl MediaFileInsertBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn maybe_source_job_token(mut self, maybe_job_token: Option<&InferenceJobToken>) -> Self {
|
||||
self.maybe_source_job_token = maybe_job_token.cloned();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn maybe_cover_image_media_file_token(mut self, maybe_token: Option<&MediaFileToken>) -> Self {
|
||||
self.maybe_cover_image_media_file_token = maybe_token.cloned();
|
||||
self
|
||||
@@ -283,20 +294,33 @@ impl MediaFileInsertBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
// TODO(bt,2025-04-26): Other connector options.
|
||||
pub async fn insert_pool(self, mysql_pool: &MySqlPool) -> Result<MediaFileToken, MediaFileInsertBuilderError> {
|
||||
self.insert_acquire(mysql_pool).await
|
||||
}
|
||||
|
||||
/// Insert using an already-acquired connection, so callers holding one
|
||||
/// don't pay for a second pool acquisition.
|
||||
pub async fn insert_connection(self, mysql_connection: &mut PoolConnection<MySql>) -> Result<MediaFileToken, MediaFileInsertBuilderError> {
|
||||
self.insert_acquire(mysql_connection).await
|
||||
}
|
||||
|
||||
async fn insert_acquire<'a, A>(&'a self, connector: A) -> Result<MediaFileToken, MediaFileInsertBuilderError>
|
||||
where
|
||||
A: Acquire<'a, Database = MySql> + Send,
|
||||
<A as Acquire<'a>>::Connection: Send,
|
||||
{
|
||||
let media_file_type = self.media_file_type
|
||||
.ok_or_else(|| MediaFileInsertBuilderError::MissingRequiredField(
|
||||
"Media file type is required".to_string()))?;
|
||||
|
||||
let checksum_sha2 = self.checksum_sha2
|
||||
let checksum_sha2 = self.checksum_sha2.as_ref()
|
||||
.ok_or_else(|| MediaFileInsertBuilderError::MissingRequiredField(
|
||||
"Checksum SHA2 is required".to_string()))?;
|
||||
|
||||
let bucket_path = self.public_bucket_directory_hash
|
||||
let bucket_path = self.public_bucket_directory_hash.as_ref()
|
||||
.ok_or_else(|| MediaFileInsertBuilderError::MissingRequiredField(
|
||||
"Public bucket directory hash is required".to_string()))?;
|
||||
|
||||
|
||||
let mut origin_category = self.origin_category
|
||||
.ok_or_else(|| MediaFileInsertBuilderError::MissingRequiredField(
|
||||
"Origin category is required".to_string()))?;
|
||||
@@ -320,7 +344,7 @@ impl MediaFileInsertBuilder {
|
||||
}
|
||||
|
||||
let result = insert_media_file_generic(InsertArgs {
|
||||
pool: mysql_pool,
|
||||
pool: connector,
|
||||
maybe_creator_user_token: self.maybe_creator_user_token.as_ref(),
|
||||
maybe_creator_anonymous_visitor_token: self.maybe_creator_anonymous_visitor_token.as_ref(),
|
||||
creator_ip_address: self.creator_ip_address.as_deref().unwrap_or("127.0.0.1"),
|
||||
@@ -333,7 +357,7 @@ impl MediaFileInsertBuilder {
|
||||
origin_product_category: self.origin_product_category,
|
||||
maybe_origin_model_type: None, // TODO
|
||||
maybe_origin_model_token: None, // TODO
|
||||
maybe_origin_filename: self.maybe_origin_filename,
|
||||
maybe_origin_filename: self.maybe_origin_filename.clone(),
|
||||
maybe_mime_type: self.maybe_mime_type.as_deref(),
|
||||
file_size_bytes: self.file_size_bytes,
|
||||
maybe_duration_millis: self.maybe_duration_millis,
|
||||
@@ -342,12 +366,13 @@ impl MediaFileInsertBuilder {
|
||||
maybe_frame_width: self.maybe_frame_width,
|
||||
maybe_frame_height: self.maybe_frame_height,
|
||||
maybe_engine_category: self.maybe_engine_category,
|
||||
checksum_sha2: &checksum_sha2,
|
||||
checksum_sha2: checksum_sha2.as_str(),
|
||||
maybe_title: self.maybe_title.as_deref(),
|
||||
maybe_text_transcript: None, // TODO
|
||||
maybe_scene_source_media_file_token: None, // TODO
|
||||
maybe_prompt_token: self.maybe_prompt_token.as_ref(),
|
||||
maybe_batch_token: self.maybe_batch_generation_token.as_ref(),
|
||||
maybe_source_job_token: self.maybe_source_job_token.as_ref(),
|
||||
public_bucket_directory_hash: bucket_path.get_object_hash(),
|
||||
maybe_public_bucket_prefix: bucket_path.get_optional_prefix(),
|
||||
maybe_public_bucket_extension: bucket_path.get_optional_extension(),
|
||||
|
||||
+2
-1
@@ -50,7 +50,7 @@ pub async fn insert_media_file_from_studio_scene_render(
|
||||
) -> AnyhowResult<MediaFileToken>
|
||||
{
|
||||
let (new_media_token, _id) = insert_media_file_generic(InsertArgs {
|
||||
pool: &args.pool,
|
||||
pool: args.pool,
|
||||
|
||||
// Dynamic bits (user)
|
||||
maybe_creator_user_token: args.maybe_creator_user_token,
|
||||
@@ -108,6 +108,7 @@ pub async fn insert_media_file_from_studio_scene_render(
|
||||
maybe_text_transcript: None,
|
||||
maybe_origin_filename: None,
|
||||
maybe_batch_token: None,
|
||||
maybe_source_job_token: None, // NB: Studio renders are client-side, not job outputs.
|
||||
maybe_prompt_token: None,
|
||||
maybe_mod_user_token: None,
|
||||
maybe_engine_category: None,
|
||||
|
||||
+3
@@ -95,6 +95,9 @@ where
|
||||
is_user_upload: false,
|
||||
maybe_audio_encoding: None,
|
||||
maybe_batch_token: None,
|
||||
// NB: Covers/thumbnails never carry a source job token — only the primary
|
||||
// outputs of a job do.
|
||||
maybe_source_job_token: None,
|
||||
maybe_cover_image_media_file_token: None,
|
||||
maybe_creator_category_synthetic_id: None,
|
||||
maybe_creator_file_synthetic_id: None,
|
||||
|
||||
+16
-2
@@ -22,8 +22,16 @@ pub enum InferenceJobProductCategory {
|
||||
#[default]
|
||||
DownloadGptSoVits,
|
||||
|
||||
// =============== ARTCRAFT FIRST PARTY ===============
|
||||
|
||||
/// First-party (our own GPU inference) Minimax H3 video generation.
|
||||
/// Covers both the Turbo and Ultra variants; the `job_type` column
|
||||
/// distinguishes them.
|
||||
#[serde(rename = "artcraft_minimax_h3")]
|
||||
ArtcraftMinimaxH3,
|
||||
|
||||
// =============== FAL ===============
|
||||
|
||||
|
||||
FalImage,
|
||||
FalVideo,
|
||||
FalAudio,
|
||||
@@ -173,6 +181,7 @@ impl InferenceJobProductCategory {
|
||||
pub fn to_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::DownloadGptSoVits => "download_gpt_so_vits",
|
||||
Self::ArtcraftMinimaxH3 => "artcraft_minimax_h3",
|
||||
Self::FalImage => "fal_image",
|
||||
Self::FalVideo => "fal_video",
|
||||
Self::FalAudio => "fal_audio",
|
||||
@@ -217,6 +226,7 @@ impl InferenceJobProductCategory {
|
||||
pub fn from_str(value: &str) -> Result<Self, String> {
|
||||
match value {
|
||||
"download_gpt_so_vits" => Ok(Self::DownloadGptSoVits),
|
||||
"artcraft_minimax_h3" => Ok(Self::ArtcraftMinimaxH3),
|
||||
"fal_image" => Ok(Self::FalImage),
|
||||
"fal_video" => Ok(Self::FalVideo),
|
||||
"fal_audio" => Ok(Self::FalAudio),
|
||||
@@ -264,6 +274,7 @@ impl InferenceJobProductCategory {
|
||||
// NB: BTreeSet::from() isn't const, but not worth using LazyStatic, etc.
|
||||
BTreeSet::from([
|
||||
Self::DownloadGptSoVits,
|
||||
Self::ArtcraftMinimaxH3,
|
||||
Self::FalImage,
|
||||
Self::FalVideo,
|
||||
Self::FalAudio,
|
||||
@@ -317,6 +328,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_serialization() {
|
||||
assert_serialization(InferenceJobProductCategory::DownloadGptSoVits, "download_gpt_so_vits");
|
||||
assert_serialization(InferenceJobProductCategory::ArtcraftMinimaxH3, "artcraft_minimax_h3");
|
||||
assert_serialization(InferenceJobProductCategory::FalImage, "fal_image");
|
||||
assert_serialization(InferenceJobProductCategory::FalVideo, "fal_video");
|
||||
assert_serialization(InferenceJobProductCategory::FalAudio, "fal_audio");
|
||||
@@ -360,6 +372,7 @@ mod tests {
|
||||
#[test]
|
||||
fn to_str() {
|
||||
assert_eq!(InferenceJobProductCategory::DownloadGptSoVits.to_str(), "download_gpt_so_vits");
|
||||
assert_eq!(InferenceJobProductCategory::ArtcraftMinimaxH3.to_str(), "artcraft_minimax_h3");
|
||||
assert_eq!(InferenceJobProductCategory::FalImage.to_str(), "fal_image");
|
||||
assert_eq!(InferenceJobProductCategory::FalVideo.to_str(), "fal_video");
|
||||
assert_eq!(InferenceJobProductCategory::FalAudio.to_str(), "fal_audio");
|
||||
@@ -402,6 +415,7 @@ mod tests {
|
||||
#[test]
|
||||
fn from_str() {
|
||||
assert_eq!(InferenceJobProductCategory::from_str("download_gpt_so_vits").unwrap(), InferenceJobProductCategory::DownloadGptSoVits);
|
||||
assert_eq!(InferenceJobProductCategory::from_str("artcraft_minimax_h3").unwrap(), InferenceJobProductCategory::ArtcraftMinimaxH3);
|
||||
assert_eq!(InferenceJobProductCategory::from_str("fal_image").unwrap(), InferenceJobProductCategory::FalImage);
|
||||
assert_eq!(InferenceJobProductCategory::from_str("fal_video").unwrap(), InferenceJobProductCategory::FalVideo);
|
||||
assert_eq!(InferenceJobProductCategory::from_str("fal_audio").unwrap(), InferenceJobProductCategory::FalAudio);
|
||||
@@ -444,7 +458,7 @@ mod tests {
|
||||
#[test]
|
||||
fn all_variants() {
|
||||
// Static check
|
||||
const EXPECTED_COUNT : usize = 39;
|
||||
const EXPECTED_COUNT : usize = 40;
|
||||
|
||||
assert_eq!(InferenceJobProductCategory::all_variants().len(), EXPECTED_COUNT);
|
||||
assert_eq!(InferenceJobProductCategory::iter().len(), EXPECTED_COUNT);
|
||||
|
||||
+21
-1
@@ -43,6 +43,14 @@ pub enum InferenceJobType {
|
||||
#[serde(rename = "seedance2pro_bpu_queue")]
|
||||
Seedance2ProBytePlusUltraQueue,
|
||||
|
||||
/// First-party Minimax H3 Turbo jobs. These run on our own GPU inference.
|
||||
#[serde(rename = "artcraft_minimax_h3_turbo")]
|
||||
ArtcraftMinimaxH3Turbo,
|
||||
|
||||
/// First-party Minimax H3 Ultra jobs. These run on our own GPU inference.
|
||||
#[serde(rename = "artcraft_minimax_h3_ultra")]
|
||||
ArtcraftMinimaxH3Ultra,
|
||||
|
||||
/// Beeble jobs. We poll for results.
|
||||
#[serde(rename = "beeble_queue")]
|
||||
BeebleQueue,
|
||||
@@ -144,6 +152,8 @@ impl InferenceJobType {
|
||||
Self::FalQueue => "fal_queue",
|
||||
Self::GmiCloudQueue => "gmicloud_queue",
|
||||
Self::GrokApiQueue => "grok_api_queue",
|
||||
Self::ArtcraftMinimaxH3Turbo => "artcraft_minimax_h3_turbo",
|
||||
Self::ArtcraftMinimaxH3Ultra => "artcraft_minimax_h3_ultra",
|
||||
Self::BeebleQueue => "beeble_queue",
|
||||
Self::Seedance2ProQueue => "seedance2pro_queue",
|
||||
Self::Seedance2ProAltQueue => "seedance2pro_alt_queue",
|
||||
@@ -178,6 +188,8 @@ impl InferenceJobType {
|
||||
"fal_queue" => Ok(Self::FalQueue),
|
||||
"gmicloud_queue" => Ok(Self::GmiCloudQueue),
|
||||
"grok_api_queue" => Ok(Self::GrokApiQueue),
|
||||
"artcraft_minimax_h3_turbo" => Ok(Self::ArtcraftMinimaxH3Turbo),
|
||||
"artcraft_minimax_h3_ultra" => Ok(Self::ArtcraftMinimaxH3Ultra),
|
||||
"beeble_queue" => Ok(Self::BeebleQueue),
|
||||
"seedance2pro_queue" => Ok(Self::Seedance2ProQueue),
|
||||
"seedance2pro_character" => Ok(Self::Seedance2ProCharacter),
|
||||
@@ -215,6 +227,8 @@ impl InferenceJobType {
|
||||
Self::FalQueue,
|
||||
Self::GmiCloudQueue,
|
||||
Self::GrokApiQueue,
|
||||
Self::ArtcraftMinimaxH3Turbo,
|
||||
Self::ArtcraftMinimaxH3Ultra,
|
||||
Self::BeebleQueue,
|
||||
Self::Seedance2ProQueue,
|
||||
Self::Seedance2ProCharacter,
|
||||
@@ -263,6 +277,8 @@ mod tests {
|
||||
assert_serialization(InferenceJobType::FalQueue, "fal_queue");
|
||||
assert_serialization(InferenceJobType::GmiCloudQueue, "gmicloud_queue");
|
||||
assert_serialization(InferenceJobType::GrokApiQueue, "grok_api_queue");
|
||||
assert_serialization(InferenceJobType::ArtcraftMinimaxH3Turbo, "artcraft_minimax_h3_turbo");
|
||||
assert_serialization(InferenceJobType::ArtcraftMinimaxH3Ultra, "artcraft_minimax_h3_ultra");
|
||||
assert_serialization(InferenceJobType::BeebleQueue, "beeble_queue");
|
||||
assert_serialization(InferenceJobType::Seedance2ProQueue, "seedance2pro_queue");
|
||||
assert_serialization(InferenceJobType::Seedance2ProCharacter, "seedance2pro_character");
|
||||
@@ -296,6 +312,8 @@ mod tests {
|
||||
assert_eq!(InferenceJobType::FalQueue.to_str(), "fal_queue");
|
||||
assert_eq!(InferenceJobType::GmiCloudQueue.to_str(), "gmicloud_queue");
|
||||
assert_eq!(InferenceJobType::GrokApiQueue.to_str(), "grok_api_queue");
|
||||
assert_eq!(InferenceJobType::ArtcraftMinimaxH3Turbo.to_str(), "artcraft_minimax_h3_turbo");
|
||||
assert_eq!(InferenceJobType::ArtcraftMinimaxH3Ultra.to_str(), "artcraft_minimax_h3_ultra");
|
||||
assert_eq!(InferenceJobType::BeebleQueue.to_str(), "beeble_queue");
|
||||
assert_eq!(InferenceJobType::Seedance2ProQueue.to_str(), "seedance2pro_queue");
|
||||
assert_eq!(InferenceJobType::Seedance2ProCharacter.to_str(), "seedance2pro_character");
|
||||
@@ -329,6 +347,8 @@ mod tests {
|
||||
assert_eq!(InferenceJobType::from_str("fal_queue").unwrap(), InferenceJobType::FalQueue);
|
||||
assert_eq!(InferenceJobType::from_str("gmicloud_queue").unwrap(), InferenceJobType::GmiCloudQueue);
|
||||
assert_eq!(InferenceJobType::from_str("grok_api_queue").unwrap(), InferenceJobType::GrokApiQueue);
|
||||
assert_eq!(InferenceJobType::from_str("artcraft_minimax_h3_turbo").unwrap(), InferenceJobType::ArtcraftMinimaxH3Turbo);
|
||||
assert_eq!(InferenceJobType::from_str("artcraft_minimax_h3_ultra").unwrap(), InferenceJobType::ArtcraftMinimaxH3Ultra);
|
||||
assert_eq!(InferenceJobType::from_str("beeble_queue").unwrap(), InferenceJobType::BeebleQueue);
|
||||
assert_eq!(InferenceJobType::from_str("seedance2pro_queue").unwrap(), InferenceJobType::Seedance2ProQueue);
|
||||
assert_eq!(InferenceJobType::from_str("seedance2pro_character").unwrap(), InferenceJobType::Seedance2ProCharacter);
|
||||
@@ -360,7 +380,7 @@ mod tests {
|
||||
#[test]
|
||||
fn all_variants() {
|
||||
// Static check
|
||||
const EXPECTED_COUNT : usize = 29;
|
||||
const EXPECTED_COUNT : usize = 31;
|
||||
|
||||
assert_eq!(InferenceJobType::all_variants().len(), EXPECTED_COUNT);
|
||||
assert_eq!(InferenceJobType::iter().len(), EXPECTED_COUNT);
|
||||
|
||||
+27
@@ -186,6 +186,10 @@ pub enum InferenceModelType {
|
||||
HappyHorse1p0,
|
||||
#[serde(rename = "minimax_h3")]
|
||||
MinimaxH3,
|
||||
#[serde(rename = "minimax_h3_turbo")]
|
||||
MinimaxH3Turbo,
|
||||
#[serde(rename = "minimax_h3_ultra")]
|
||||
MinimaxH3Ultra,
|
||||
#[serde(rename = "seedance_1p0_lite")]
|
||||
Seedance10Lite,
|
||||
#[serde(rename = "seedance_1p0_pro")]
|
||||
@@ -216,6 +220,8 @@ pub enum InferenceModelType {
|
||||
Seedance2p0BytePlusUltraMini,
|
||||
#[serde(rename = "seedance_2p5_preview")]
|
||||
Seedance2p5Preview,
|
||||
#[serde(rename = "seedance_2p5")]
|
||||
Seedance2p5,
|
||||
#[serde(rename = "sora_2")]
|
||||
Sora2,
|
||||
#[serde(rename = "sora_2_pro")]
|
||||
@@ -375,6 +381,8 @@ impl InferenceModelType {
|
||||
Self::Kling3p0Pro => "kling_3p0_pro",
|
||||
Self::HappyHorse1p0 => "happy_horse_1p0",
|
||||
Self::MinimaxH3 => "minimax_h3",
|
||||
Self::MinimaxH3Turbo => "minimax_h3_turbo",
|
||||
Self::MinimaxH3Ultra => "minimax_h3_ultra",
|
||||
Self::Seedance10Lite => "seedance_1p0_lite",
|
||||
Self::Seedance10Pro => "seedance_1p0_pro",
|
||||
Self::Seedance1p5Pro => "seedance_1p5_pro",
|
||||
@@ -390,6 +398,7 @@ impl InferenceModelType {
|
||||
Self::Seedance2p0BytePlusMini => "seedance_2p0_bp_mini",
|
||||
Self::Seedance2p0BytePlusUltraMini => "seedance_2p0_bpu_mini",
|
||||
Self::Seedance2p5Preview => "seedance_2p5_preview",
|
||||
Self::Seedance2p5 => "seedance_2p5",
|
||||
Self::Sora2 => "sora_2",
|
||||
Self::Sora2Pro => "sora_2_pro",
|
||||
Self::Veo2 => "veo_2",
|
||||
@@ -509,6 +518,8 @@ impl InferenceModelType {
|
||||
"kling_3p0_pro" => Ok(Self::Kling3p0Pro),
|
||||
"happy_horse_1p0" => Ok(Self::HappyHorse1p0),
|
||||
"minimax_h3" => Ok(Self::MinimaxH3),
|
||||
"minimax_h3_turbo" => Ok(Self::MinimaxH3Turbo),
|
||||
"minimax_h3_ultra" => Ok(Self::MinimaxH3Ultra),
|
||||
"seedance_1p0_lite" => Ok(Self::Seedance10Lite),
|
||||
"seedance_1p0_pro" => Ok(Self::Seedance10Pro),
|
||||
"seedance_1p5_pro" => Ok(Self::Seedance1p5Pro),
|
||||
@@ -524,6 +535,7 @@ impl InferenceModelType {
|
||||
"seedance_2p0_bp_mini" => Ok(Self::Seedance2p0BytePlusMini),
|
||||
"seedance_2p0_bpu_mini" => Ok(Self::Seedance2p0BytePlusUltraMini),
|
||||
"seedance_2p5_preview" => Ok(Self::Seedance2p5Preview),
|
||||
"seedance_2p5" => Ok(Self::Seedance2p5),
|
||||
"sora_2" => Ok(Self::Sora2),
|
||||
"sora_2_pro" => Ok(Self::Sora2Pro),
|
||||
"veo_2" => Ok(Self::Veo2),
|
||||
@@ -647,6 +659,8 @@ impl InferenceModelType {
|
||||
Self::Kling3p0Pro,
|
||||
Self::HappyHorse1p0,
|
||||
Self::MinimaxH3,
|
||||
Self::MinimaxH3Turbo,
|
||||
Self::MinimaxH3Ultra,
|
||||
Self::Seedance10Lite,
|
||||
Self::Seedance10Pro,
|
||||
Self::Seedance1p5Pro,
|
||||
@@ -662,6 +676,7 @@ impl InferenceModelType {
|
||||
Self::Seedance2p0BytePlusMini,
|
||||
Self::Seedance2p0BytePlusUltraMini,
|
||||
Self::Seedance2p5Preview,
|
||||
Self::Seedance2p5,
|
||||
Self::Sora2,
|
||||
Self::Sora2Pro,
|
||||
Self::Veo2,
|
||||
@@ -768,6 +783,8 @@ impl InferenceModelType {
|
||||
CommonModelType::Kling3p0Pro => Self::Kling3p0Pro,
|
||||
CommonModelType::HappyHorse1p0 => Self::HappyHorse1p0,
|
||||
CommonModelType::MinimaxH3 => Self::MinimaxH3,
|
||||
CommonModelType::MinimaxH3Turbo => Self::MinimaxH3Turbo,
|
||||
CommonModelType::MinimaxH3Ultra => Self::MinimaxH3Ultra,
|
||||
CommonModelType::Seedance10Lite => Self::Seedance10Lite,
|
||||
CommonModelType::Seedance10Pro => Self::Seedance10Pro,
|
||||
CommonModelType::Seedance1p5Pro => Self::Seedance1p5Pro,
|
||||
@@ -783,6 +800,7 @@ impl InferenceModelType {
|
||||
CommonModelType::Seedance2p0BytePlusMini => Self::Seedance2p0BytePlusMini,
|
||||
CommonModelType::Seedance2p0BytePlusUltraMini => Self::Seedance2p0BytePlusUltraMini,
|
||||
CommonModelType::Seedance2p5Preview => Self::Seedance2p5Preview,
|
||||
CommonModelType::Seedance2p5 => Self::Seedance2p5,
|
||||
CommonModelType::Sora2 => Self::Sora2,
|
||||
CommonModelType::Sora2Pro => Self::Sora2Pro,
|
||||
CommonModelType::Veo2 => Self::Veo2,
|
||||
@@ -911,6 +929,8 @@ mod tests {
|
||||
assert_serialization(InferenceModelType::Kling3p0Pro, "kling_3p0_pro");
|
||||
assert_serialization(InferenceModelType::HappyHorse1p0, "happy_horse_1p0");
|
||||
assert_serialization(InferenceModelType::MinimaxH3, "minimax_h3");
|
||||
assert_serialization(InferenceModelType::MinimaxH3Turbo, "minimax_h3_turbo");
|
||||
assert_serialization(InferenceModelType::MinimaxH3Ultra, "minimax_h3_ultra");
|
||||
assert_serialization(InferenceModelType::Seedance10Lite, "seedance_1p0_lite");
|
||||
assert_serialization(InferenceModelType::Seedance10Pro, "seedance_1p0_pro");
|
||||
assert_serialization(InferenceModelType::Seedance1p5Pro, "seedance_1p5_pro");
|
||||
@@ -926,6 +946,7 @@ mod tests {
|
||||
assert_serialization(InferenceModelType::Seedance2p0BytePlusMini, "seedance_2p0_bp_mini");
|
||||
assert_serialization(InferenceModelType::Seedance2p0BytePlusUltraMini, "seedance_2p0_bpu_mini");
|
||||
assert_serialization(InferenceModelType::Seedance2p5Preview, "seedance_2p5_preview");
|
||||
assert_serialization(InferenceModelType::Seedance2p5, "seedance_2p5");
|
||||
assert_serialization(InferenceModelType::Sora2, "sora_2");
|
||||
assert_serialization(InferenceModelType::Sora2Pro, "sora_2_pro");
|
||||
assert_serialization(InferenceModelType::Veo2, "veo_2");
|
||||
@@ -1041,6 +1062,8 @@ mod tests {
|
||||
assert_eq!(InferenceModelType::Kling3p0Pro.to_str(), "kling_3p0_pro");
|
||||
assert_eq!(InferenceModelType::HappyHorse1p0.to_str(), "happy_horse_1p0");
|
||||
assert_eq!(InferenceModelType::MinimaxH3.to_str(), "minimax_h3");
|
||||
assert_eq!(InferenceModelType::MinimaxH3Turbo.to_str(), "minimax_h3_turbo");
|
||||
assert_eq!(InferenceModelType::MinimaxH3Ultra.to_str(), "minimax_h3_ultra");
|
||||
assert_eq!(InferenceModelType::Seedance10Lite.to_str(), "seedance_1p0_lite");
|
||||
assert_eq!(InferenceModelType::Seedance10Pro.to_str(), "seedance_1p0_pro");
|
||||
assert_eq!(InferenceModelType::Seedance1p5Pro.to_str(), "seedance_1p5_pro");
|
||||
@@ -1056,6 +1079,7 @@ mod tests {
|
||||
assert_eq!(InferenceModelType::Seedance2p0BytePlusMini.to_str(), "seedance_2p0_bp_mini");
|
||||
assert_eq!(InferenceModelType::Seedance2p0BytePlusUltraMini.to_str(), "seedance_2p0_bpu_mini");
|
||||
assert_eq!(InferenceModelType::Seedance2p5Preview.to_str(), "seedance_2p5_preview");
|
||||
assert_eq!(InferenceModelType::Seedance2p5.to_str(), "seedance_2p5");
|
||||
assert_eq!(InferenceModelType::Sora2.to_str(), "sora_2");
|
||||
assert_eq!(InferenceModelType::Sora2Pro.to_str(), "sora_2_pro");
|
||||
assert_eq!(InferenceModelType::Veo2.to_str(), "veo_2");
|
||||
@@ -1173,6 +1197,8 @@ mod tests {
|
||||
assert_eq!(InferenceModelType::from_str("kling_3p0_pro").unwrap(), InferenceModelType::Kling3p0Pro);
|
||||
assert_eq!(InferenceModelType::from_str("happy_horse_1p0").unwrap(), InferenceModelType::HappyHorse1p0);
|
||||
assert_eq!(InferenceModelType::from_str("minimax_h3").unwrap(), InferenceModelType::MinimaxH3);
|
||||
assert_eq!(InferenceModelType::from_str("minimax_h3_turbo").unwrap(), InferenceModelType::MinimaxH3Turbo);
|
||||
assert_eq!(InferenceModelType::from_str("minimax_h3_ultra").unwrap(), InferenceModelType::MinimaxH3Ultra);
|
||||
assert_eq!(InferenceModelType::from_str("seedance_1p0_lite").unwrap(), InferenceModelType::Seedance10Lite);
|
||||
assert_eq!(InferenceModelType::from_str("seedance_1p0_pro").unwrap(), InferenceModelType::Seedance10Pro);
|
||||
assert_eq!(InferenceModelType::from_str("seedance_1p5_pro").unwrap(), InferenceModelType::Seedance1p5Pro);
|
||||
@@ -1188,6 +1214,7 @@ mod tests {
|
||||
assert_eq!(InferenceModelType::from_str("seedance_2p0_bp_mini").unwrap(), InferenceModelType::Seedance2p0BytePlusMini);
|
||||
assert_eq!(InferenceModelType::from_str("seedance_2p0_bpu_mini").unwrap(), InferenceModelType::Seedance2p0BytePlusUltraMini);
|
||||
assert_eq!(InferenceModelType::from_str("seedance_2p5_preview").unwrap(), InferenceModelType::Seedance2p5Preview);
|
||||
assert_eq!(InferenceModelType::from_str("seedance_2p5").unwrap(), InferenceModelType::Seedance2p5);
|
||||
assert_eq!(InferenceModelType::from_str("sora_2").unwrap(), InferenceModelType::Sora2);
|
||||
assert_eq!(InferenceModelType::from_str("sora_2_pro").unwrap(), InferenceModelType::Sora2Pro);
|
||||
assert_eq!(InferenceModelType::from_str("veo_2").unwrap(), InferenceModelType::Veo2);
|
||||
|
||||
@@ -49,6 +49,14 @@ pub enum UserFeatureFlag {
|
||||
/// Access to API key creation
|
||||
#[serde(rename = "api_key")]
|
||||
ApiKey,
|
||||
|
||||
/// Access to Minimax
|
||||
#[serde(rename = "minimax")]
|
||||
Minimax,
|
||||
|
||||
/// Access to Minimax with debugging priority (admins)
|
||||
#[serde(rename = "minimax_p")]
|
||||
MinimaxPriority,
|
||||
}
|
||||
|
||||
// TODO(bt, 2022-12-21): This desperately needs MySQL integration tests!
|
||||
@@ -69,6 +77,8 @@ impl UserFeatureFlag {
|
||||
Self::HappyHorseRateLimit => "hh_rl",
|
||||
Self::ReferralsProgram => "referrals",
|
||||
Self::ApiKey => "api_key",
|
||||
Self::Minimax => "minimax",
|
||||
Self::MinimaxPriority => "minimax_p",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +93,8 @@ impl UserFeatureFlag {
|
||||
"hh_rl" => Ok(Self::HappyHorseRateLimit),
|
||||
"referrals" => Ok(Self::ReferralsProgram),
|
||||
"api_key" => Ok(Self::ApiKey),
|
||||
"minimax" => Ok(Self::Minimax),
|
||||
"minimax_p" => Ok(Self::MinimaxPriority),
|
||||
_ => Err(format!("invalid value: {:?}", value)),
|
||||
}
|
||||
}
|
||||
@@ -98,6 +110,8 @@ impl UserFeatureFlag {
|
||||
Self::HappyHorseRateLimit => "Happy Horse (with Rate Limit)",
|
||||
Self::ReferralsProgram => "Referrals Program",
|
||||
Self::ApiKey => "API Keys",
|
||||
Self::Minimax => "Minimax",
|
||||
Self::MinimaxPriority => "Minimax (Debugging Priority)",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +126,8 @@ impl UserFeatureFlag {
|
||||
Self::HappyHorseRateLimit => "Access to Happy Horse (with a rate limit)",
|
||||
Self::ReferralsProgram => "Access to the referrals program",
|
||||
Self::ApiKey => "Access to API key creation",
|
||||
Self::Minimax => "Access to Minimax",
|
||||
Self::MinimaxPriority => "Access to Minimax with debugging priority (admins)",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,6 +144,8 @@ impl UserFeatureFlag {
|
||||
Self::HappyHorseRateLimit,
|
||||
Self::ReferralsProgram,
|
||||
Self::ApiKey,
|
||||
Self::Minimax,
|
||||
Self::MinimaxPriority,
|
||||
])
|
||||
}
|
||||
}
|
||||
@@ -151,6 +169,8 @@ mod tests {
|
||||
assert_serialization(UserFeatureFlag::HappyHorseRateLimit, "hh_rl");
|
||||
assert_serialization(UserFeatureFlag::ReferralsProgram, "referrals");
|
||||
assert_serialization(UserFeatureFlag::ApiKey, "api_key");
|
||||
assert_serialization(UserFeatureFlag::Minimax, "minimax");
|
||||
assert_serialization(UserFeatureFlag::MinimaxPriority, "minimax_p");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -164,6 +184,8 @@ mod tests {
|
||||
assert_eq!(UserFeatureFlag::HappyHorseRateLimit.to_str(), "hh_rl");
|
||||
assert_eq!(UserFeatureFlag::ReferralsProgram.to_str(), "referrals");
|
||||
assert_eq!(UserFeatureFlag::ApiKey.to_str(), "api_key");
|
||||
assert_eq!(UserFeatureFlag::Minimax.to_str(), "minimax");
|
||||
assert_eq!(UserFeatureFlag::MinimaxPriority.to_str(), "minimax_p");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -177,13 +199,15 @@ mod tests {
|
||||
assert_eq!(UserFeatureFlag::from_str("hh_rl").unwrap(), UserFeatureFlag::HappyHorseRateLimit);
|
||||
assert_eq!(UserFeatureFlag::from_str("referrals").unwrap(), UserFeatureFlag::ReferralsProgram);
|
||||
assert_eq!(UserFeatureFlag::from_str("api_key").unwrap(), UserFeatureFlag::ApiKey);
|
||||
assert_eq!(UserFeatureFlag::from_str("minimax").unwrap(), UserFeatureFlag::Minimax);
|
||||
assert_eq!(UserFeatureFlag::from_str("minimax_p").unwrap(), UserFeatureFlag::MinimaxPriority);
|
||||
assert!(UserFeatureFlag::from_str("foo").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_variants() {
|
||||
let mut variants = UserFeatureFlag::all_variants();
|
||||
assert_eq!(variants.len(), 9);
|
||||
assert_eq!(variants.len(), 11);
|
||||
assert_eq!(variants.pop_first(), Some(UserFeatureFlag::ExploreMedia));
|
||||
assert_eq!(variants.pop_first(), Some(UserFeatureFlag::Studio));
|
||||
assert_eq!(variants.pop_first(), Some(UserFeatureFlag::Upload3d));
|
||||
@@ -193,6 +217,8 @@ mod tests {
|
||||
assert_eq!(variants.pop_first(), Some(UserFeatureFlag::HappyHorseRateLimit));
|
||||
assert_eq!(variants.pop_first(), Some(UserFeatureFlag::ReferralsProgram));
|
||||
assert_eq!(variants.pop_first(), Some(UserFeatureFlag::ApiKey));
|
||||
assert_eq!(variants.pop_first(), Some(UserFeatureFlag::Minimax));
|
||||
assert_eq!(variants.pop_first(), Some(UserFeatureFlag::MinimaxPriority));
|
||||
assert_eq!(variants.pop_first(), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,6 +157,10 @@ pub enum CommonModelType {
|
||||
HappyHorse1p0,
|
||||
#[serde(rename = "minimax_h3")]
|
||||
MinimaxH3,
|
||||
#[serde(rename = "minimax_h3_turbo")]
|
||||
MinimaxH3Turbo,
|
||||
#[serde(rename = "minimax_h3_ultra")]
|
||||
MinimaxH3Ultra,
|
||||
#[serde(rename = "seedance_1p0_lite")]
|
||||
Seedance10Lite,
|
||||
#[serde(rename = "seedance_1p0_pro")]
|
||||
@@ -187,6 +191,8 @@ pub enum CommonModelType {
|
||||
Seedance2p0BytePlusUltraMini,
|
||||
#[serde(rename = "seedance_2p5_preview")]
|
||||
Seedance2p5Preview,
|
||||
#[serde(rename = "seedance_2p5")]
|
||||
Seedance2p5,
|
||||
#[serde(rename = "sora_2")]
|
||||
Sora2,
|
||||
#[serde(rename = "sora_2_pro")]
|
||||
@@ -330,6 +336,8 @@ impl CommonModelType {
|
||||
Self::Kling3p0Pro => "kling_3p0_pro",
|
||||
Self::HappyHorse1p0 => "happy_horse_1p0",
|
||||
Self::MinimaxH3 => "minimax_h3",
|
||||
Self::MinimaxH3Turbo => "minimax_h3_turbo",
|
||||
Self::MinimaxH3Ultra => "minimax_h3_ultra",
|
||||
Self::Seedance10Lite => "seedance_1p0_lite",
|
||||
Self::Seedance10Pro => "seedance_1p0_pro",
|
||||
Self::Seedance1p5Pro => "seedance_1p5_pro",
|
||||
@@ -345,6 +353,7 @@ impl CommonModelType {
|
||||
Self::Seedance2p0BytePlusMini => "seedance_2p0_bp_mini",
|
||||
Self::Seedance2p0BytePlusUltraMini => "seedance_2p0_bpu_mini",
|
||||
Self::Seedance2p5Preview => "seedance_2p5_preview",
|
||||
Self::Seedance2p5 => "seedance_2p5",
|
||||
Self::Sora2 => "sora_2",
|
||||
Self::Sora2Pro => "sora_2_pro",
|
||||
Self::Veo2 => "veo_2",
|
||||
@@ -447,6 +456,8 @@ impl CommonModelType {
|
||||
"kling_3p0_pro" => Ok(Self::Kling3p0Pro),
|
||||
"happy_horse_1p0" => Ok(Self::HappyHorse1p0),
|
||||
"minimax_h3" => Ok(Self::MinimaxH3),
|
||||
"minimax_h3_turbo" => Ok(Self::MinimaxH3Turbo),
|
||||
"minimax_h3_ultra" => Ok(Self::MinimaxH3Ultra),
|
||||
"seedance_1p0_lite" => Ok(Self::Seedance10Lite),
|
||||
"seedance_1p0_pro" => Ok(Self::Seedance10Pro),
|
||||
"seedance_1p5_pro" => Ok(Self::Seedance1p5Pro),
|
||||
@@ -462,6 +473,7 @@ impl CommonModelType {
|
||||
"seedance_2p0_bp_mini" => Ok(Self::Seedance2p0BytePlusMini),
|
||||
"seedance_2p0_bpu_mini" => Ok(Self::Seedance2p0BytePlusUltraMini),
|
||||
"seedance_2p5_preview" => Ok(Self::Seedance2p5Preview),
|
||||
"seedance_2p5" => Ok(Self::Seedance2p5),
|
||||
"sora_2" => Ok(Self::Sora2),
|
||||
"sora_2_pro" => Ok(Self::Sora2Pro),
|
||||
"veo_2" => Ok(Self::Veo2),
|
||||
@@ -568,6 +580,8 @@ impl CommonModelType {
|
||||
Self::Kling3p0Pro,
|
||||
Self::HappyHorse1p0,
|
||||
Self::MinimaxH3,
|
||||
Self::MinimaxH3Turbo,
|
||||
Self::MinimaxH3Ultra,
|
||||
Self::Seedance10Lite,
|
||||
Self::Seedance10Pro,
|
||||
Self::Seedance1p5Pro,
|
||||
@@ -583,6 +597,7 @@ impl CommonModelType {
|
||||
Self::Seedance2p0BytePlusMini,
|
||||
Self::Seedance2p0BytePlusUltraMini,
|
||||
Self::Seedance2p5Preview,
|
||||
Self::Seedance2p5,
|
||||
Self::Sora2,
|
||||
Self::Sora2Pro,
|
||||
Self::Veo2,
|
||||
@@ -686,6 +701,8 @@ impl CommonModelType {
|
||||
Self::Kling3p0Pro => CommonModelClass::Video,
|
||||
Self::HappyHorse1p0 => CommonModelClass::Video,
|
||||
Self::MinimaxH3 => CommonModelClass::Video,
|
||||
Self::MinimaxH3Turbo => CommonModelClass::Video,
|
||||
Self::MinimaxH3Ultra => CommonModelClass::Video,
|
||||
Self::Seedance10Lite => CommonModelClass::Video,
|
||||
Self::Seedance10Pro => CommonModelClass::Video,
|
||||
Self::Seedance1p5Pro => CommonModelClass::Video,
|
||||
@@ -701,6 +718,7 @@ impl CommonModelType {
|
||||
Self::Seedance2p0BytePlusMini => CommonModelClass::Video,
|
||||
Self::Seedance2p0BytePlusUltraMini => CommonModelClass::Video,
|
||||
Self::Seedance2p5Preview => CommonModelClass::Video,
|
||||
Self::Seedance2p5 => CommonModelClass::Video,
|
||||
Self::Sora2 => CommonModelClass::Video,
|
||||
Self::Sora2Pro => CommonModelClass::Video,
|
||||
Self::Veo2 => CommonModelClass::Video,
|
||||
@@ -813,6 +831,8 @@ mod tests {
|
||||
assert_serialization(CommonModelType::Kling3p0Pro, "kling_3p0_pro");
|
||||
assert_serialization(CommonModelType::HappyHorse1p0, "happy_horse_1p0");
|
||||
assert_serialization(CommonModelType::MinimaxH3, "minimax_h3");
|
||||
assert_serialization(CommonModelType::MinimaxH3Turbo, "minimax_h3_turbo");
|
||||
assert_serialization(CommonModelType::MinimaxH3Ultra, "minimax_h3_ultra");
|
||||
assert_serialization(CommonModelType::Seedance10Lite, "seedance_1p0_lite");
|
||||
assert_serialization(CommonModelType::Seedance10Pro, "seedance_1p0_pro");
|
||||
assert_serialization(CommonModelType::Seedance1p5Pro, "seedance_1p5_pro");
|
||||
@@ -828,6 +848,7 @@ mod tests {
|
||||
assert_serialization(CommonModelType::Seedance2p0BytePlusMini, "seedance_2p0_bp_mini");
|
||||
assert_serialization(CommonModelType::Seedance2p0BytePlusUltraMini, "seedance_2p0_bpu_mini");
|
||||
assert_serialization(CommonModelType::Seedance2p5Preview, "seedance_2p5_preview");
|
||||
assert_serialization(CommonModelType::Seedance2p5, "seedance_2p5");
|
||||
assert_serialization(CommonModelType::Sora2, "sora_2");
|
||||
assert_serialization(CommonModelType::Sora2Pro, "sora_2_pro");
|
||||
assert_serialization(CommonModelType::Veo2, "veo_2");
|
||||
@@ -925,6 +946,8 @@ mod tests {
|
||||
assert_eq!(CommonModelType::Kling3p0Pro.to_str(), "kling_3p0_pro");
|
||||
assert_eq!(CommonModelType::HappyHorse1p0.to_str(), "happy_horse_1p0");
|
||||
assert_eq!(CommonModelType::MinimaxH3.to_str(), "minimax_h3");
|
||||
assert_eq!(CommonModelType::MinimaxH3Turbo.to_str(), "minimax_h3_turbo");
|
||||
assert_eq!(CommonModelType::MinimaxH3Ultra.to_str(), "minimax_h3_ultra");
|
||||
assert_eq!(CommonModelType::Seedance10Lite.to_str(), "seedance_1p0_lite");
|
||||
assert_eq!(CommonModelType::Seedance10Pro.to_str(), "seedance_1p0_pro");
|
||||
assert_eq!(CommonModelType::Seedance1p5Pro.to_str(), "seedance_1p5_pro");
|
||||
@@ -940,6 +963,7 @@ mod tests {
|
||||
assert_eq!(CommonModelType::Seedance2p0BytePlusMini.to_str(), "seedance_2p0_bp_mini");
|
||||
assert_eq!(CommonModelType::Seedance2p0BytePlusUltraMini.to_str(), "seedance_2p0_bpu_mini");
|
||||
assert_eq!(CommonModelType::Seedance2p5Preview.to_str(), "seedance_2p5_preview");
|
||||
assert_eq!(CommonModelType::Seedance2p5.to_str(), "seedance_2p5");
|
||||
assert_eq!(CommonModelType::Sora2.to_str(), "sora_2");
|
||||
assert_eq!(CommonModelType::Sora2Pro.to_str(), "sora_2_pro");
|
||||
assert_eq!(CommonModelType::Veo2.to_str(), "veo_2");
|
||||
@@ -1039,6 +1063,8 @@ mod tests {
|
||||
assert_eq!(CommonModelType::from_str("kling_3p0_pro").unwrap(), CommonModelType::Kling3p0Pro);
|
||||
assert_eq!(CommonModelType::from_str("happy_horse_1p0").unwrap(), CommonModelType::HappyHorse1p0);
|
||||
assert_eq!(CommonModelType::from_str("minimax_h3").unwrap(), CommonModelType::MinimaxH3);
|
||||
assert_eq!(CommonModelType::from_str("minimax_h3_turbo").unwrap(), CommonModelType::MinimaxH3Turbo);
|
||||
assert_eq!(CommonModelType::from_str("minimax_h3_ultra").unwrap(), CommonModelType::MinimaxH3Ultra);
|
||||
assert_eq!(CommonModelType::from_str("seedance_1p0_lite").unwrap(), CommonModelType::Seedance10Lite);
|
||||
assert_eq!(CommonModelType::from_str("seedance_1p0_pro").unwrap(), CommonModelType::Seedance10Pro);
|
||||
assert_eq!(CommonModelType::from_str("seedance_1p5_pro").unwrap(), CommonModelType::Seedance1p5Pro);
|
||||
@@ -1054,6 +1080,7 @@ mod tests {
|
||||
assert_eq!(CommonModelType::from_str("seedance_2p0_bp_mini").unwrap(), CommonModelType::Seedance2p0BytePlusMini);
|
||||
assert_eq!(CommonModelType::from_str("seedance_2p0_bpu_mini").unwrap(), CommonModelType::Seedance2p0BytePlusUltraMini);
|
||||
assert_eq!(CommonModelType::from_str("seedance_2p5_preview").unwrap(), CommonModelType::Seedance2p5Preview);
|
||||
assert_eq!(CommonModelType::from_str("seedance_2p5").unwrap(), CommonModelType::Seedance2p5);
|
||||
assert_eq!(CommonModelType::from_str("sora_2").unwrap(), CommonModelType::Sora2);
|
||||
assert_eq!(CommonModelType::from_str("sora_2_pro").unwrap(), CommonModelType::Sora2Pro);
|
||||
assert_eq!(CommonModelType::from_str("veo_2").unwrap(), CommonModelType::Veo2);
|
||||
@@ -1098,7 +1125,7 @@ mod tests {
|
||||
#[test]
|
||||
fn all_variants() {
|
||||
let mut variants = CommonModelType::all_variants();
|
||||
assert_eq!(variants.len(), 102);
|
||||
assert_eq!(variants.len(), 105);
|
||||
// Image models
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::Flux1Dev));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::Flux1Schnell));
|
||||
@@ -1153,6 +1180,8 @@ mod tests {
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::Kling3p0Pro));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::HappyHorse1p0));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::MinimaxH3));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::MinimaxH3Turbo));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::MinimaxH3Ultra));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::Seedance10Lite));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::Seedance10Pro));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::Seedance1p5Pro));
|
||||
@@ -1168,6 +1197,7 @@ mod tests {
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::Seedance2p0BytePlusMini));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::Seedance2p0BytePlusUltraMini));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::Seedance2p5Preview));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::Seedance2p5));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::Sora2));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::Sora2Pro));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::Veo2));
|
||||
|
||||
@@ -50,6 +50,12 @@ pub enum CommonVideoModel {
|
||||
#[serde(rename = "minimax_h3")]
|
||||
MinimaxH3,
|
||||
|
||||
#[serde(rename = "minimax_h3_turbo")]
|
||||
MinimaxH3Turbo,
|
||||
|
||||
#[serde(rename = "minimax_h3_ultra")]
|
||||
MinimaxH3Ultra,
|
||||
|
||||
#[serde(rename = "seedance_1p0_lite")]
|
||||
Seedance10Lite,
|
||||
|
||||
@@ -92,6 +98,9 @@ pub enum CommonVideoModel {
|
||||
#[serde(rename = "seedance_2p5_preview")]
|
||||
Seedance2p5Preview,
|
||||
|
||||
#[serde(rename = "seedance_2p5")]
|
||||
Seedance2p5,
|
||||
|
||||
#[serde(rename = "sora_2")]
|
||||
Sora2,
|
||||
|
||||
@@ -151,6 +160,8 @@ impl CommonVideoModel {
|
||||
Self::Kling3p0Pro => CommonModelType::Kling3p0Pro,
|
||||
Self::HappyHorse1p0 => CommonModelType::HappyHorse1p0,
|
||||
Self::MinimaxH3 => CommonModelType::MinimaxH3,
|
||||
Self::MinimaxH3Turbo => CommonModelType::MinimaxH3Turbo,
|
||||
Self::MinimaxH3Ultra => CommonModelType::MinimaxH3Ultra,
|
||||
Self::Seedance10Lite => CommonModelType::Seedance10Lite,
|
||||
Self::Seedance1p5Pro => CommonModelType::Seedance1p5Pro,
|
||||
Self::Seedance2p0 => CommonModelType::Seedance2p0,
|
||||
@@ -165,6 +176,7 @@ impl CommonVideoModel {
|
||||
Self::Seedance2p0BytePlusMini => CommonModelType::Seedance2p0BytePlusMini,
|
||||
Self::Seedance2p0BytePlusUltraMini => CommonModelType::Seedance2p0BytePlusUltraMini,
|
||||
Self::Seedance2p5Preview => CommonModelType::Seedance2p5Preview,
|
||||
Self::Seedance2p5 => CommonModelType::Seedance2p5,
|
||||
Self::Sora2 => CommonModelType::Sora2,
|
||||
Self::Sora2Pro => CommonModelType::Sora2Pro,
|
||||
Self::Veo2 => CommonModelType::Veo2,
|
||||
@@ -203,6 +215,8 @@ mod tests {
|
||||
assert_serialization(CommonVideoModel::Kling3p0Pro, "kling_3p0_pro");
|
||||
assert_serialization(CommonVideoModel::HappyHorse1p0, "happy_horse_1p0");
|
||||
assert_serialization(CommonVideoModel::MinimaxH3, "minimax_h3");
|
||||
assert_serialization(CommonVideoModel::MinimaxH3Turbo, "minimax_h3_turbo");
|
||||
assert_serialization(CommonVideoModel::MinimaxH3Ultra, "minimax_h3_ultra");
|
||||
assert_serialization(CommonVideoModel::Seedance10Lite, "seedance_1p0_lite");
|
||||
assert_serialization(CommonVideoModel::Seedance1p5Pro, "seedance_1p5_pro");
|
||||
assert_serialization(CommonVideoModel::Seedance2p0, "seedance_2p0");
|
||||
@@ -217,6 +231,7 @@ mod tests {
|
||||
assert_serialization(CommonVideoModel::Seedance2p0BytePlusMini, "seedance_2p0_bp_mini");
|
||||
assert_serialization(CommonVideoModel::Seedance2p0BytePlusUltraMini, "seedance_2p0_bpu_mini");
|
||||
assert_serialization(CommonVideoModel::Seedance2p5Preview, "seedance_2p5_preview");
|
||||
assert_serialization(CommonVideoModel::Seedance2p5, "seedance_2p5");
|
||||
assert_serialization(CommonVideoModel::Sora2, "sora_2");
|
||||
assert_serialization(CommonVideoModel::Sora2Pro, "sora_2_pro");
|
||||
assert_serialization(CommonVideoModel::Veo2, "veo_2");
|
||||
@@ -248,6 +263,8 @@ mod tests {
|
||||
("kling_3p0_pro", CommonVideoModel::Kling3p0Pro),
|
||||
("happy_horse_1p0", CommonVideoModel::HappyHorse1p0),
|
||||
("minimax_h3", CommonVideoModel::MinimaxH3),
|
||||
("minimax_h3_turbo", CommonVideoModel::MinimaxH3Turbo),
|
||||
("minimax_h3_ultra", CommonVideoModel::MinimaxH3Ultra),
|
||||
("seedance_1p0_lite", CommonVideoModel::Seedance10Lite),
|
||||
("seedance_1p5_pro", CommonVideoModel::Seedance1p5Pro),
|
||||
("seedance_2p0", CommonVideoModel::Seedance2p0),
|
||||
@@ -262,6 +279,7 @@ mod tests {
|
||||
("seedance_2p0_bp_mini", CommonVideoModel::Seedance2p0BytePlusMini),
|
||||
("seedance_2p0_bpu_mini", CommonVideoModel::Seedance2p0BytePlusUltraMini),
|
||||
("seedance_2p5_preview", CommonVideoModel::Seedance2p5Preview),
|
||||
("seedance_2p5", CommonVideoModel::Seedance2p5),
|
||||
("sora_2", CommonVideoModel::Sora2),
|
||||
("sora_2_pro", CommonVideoModel::Sora2Pro),
|
||||
("veo_2", CommonVideoModel::Veo2),
|
||||
@@ -300,6 +318,8 @@ mod tests {
|
||||
CommonVideoModel::Kling3p0Pro,
|
||||
CommonVideoModel::HappyHorse1p0,
|
||||
CommonVideoModel::MinimaxH3,
|
||||
CommonVideoModel::MinimaxH3Turbo,
|
||||
CommonVideoModel::MinimaxH3Ultra,
|
||||
CommonVideoModel::Seedance10Lite,
|
||||
CommonVideoModel::Seedance1p5Pro,
|
||||
CommonVideoModel::Seedance2p0,
|
||||
@@ -314,6 +334,7 @@ mod tests {
|
||||
CommonVideoModel::Seedance2p0BytePlusMini,
|
||||
CommonVideoModel::Seedance2p0BytePlusUltraMini,
|
||||
CommonVideoModel::Seedance2p5Preview,
|
||||
CommonVideoModel::Seedance2p5,
|
||||
CommonVideoModel::Sora2,
|
||||
CommonVideoModel::Sora2Pro,
|
||||
CommonVideoModel::Veo2,
|
||||
@@ -351,6 +372,8 @@ mod tests {
|
||||
(CommonVideoModel::Kling3p0Pro, CommonModelType::Kling3p0Pro),
|
||||
(CommonVideoModel::HappyHorse1p0, CommonModelType::HappyHorse1p0),
|
||||
(CommonVideoModel::MinimaxH3, CommonModelType::MinimaxH3),
|
||||
(CommonVideoModel::MinimaxH3Turbo, CommonModelType::MinimaxH3Turbo),
|
||||
(CommonVideoModel::MinimaxH3Ultra, CommonModelType::MinimaxH3Ultra),
|
||||
(CommonVideoModel::Seedance10Lite, CommonModelType::Seedance10Lite),
|
||||
(CommonVideoModel::Seedance1p5Pro, CommonModelType::Seedance1p5Pro),
|
||||
(CommonVideoModel::Seedance2p0, CommonModelType::Seedance2p0),
|
||||
@@ -365,6 +388,7 @@ mod tests {
|
||||
(CommonVideoModel::Seedance2p0BytePlusMini, CommonModelType::Seedance2p0BytePlusMini),
|
||||
(CommonVideoModel::Seedance2p0BytePlusUltraMini, CommonModelType::Seedance2p0BytePlusUltraMini),
|
||||
(CommonVideoModel::Seedance2p5Preview, CommonModelType::Seedance2p5Preview),
|
||||
(CommonVideoModel::Seedance2p5, CommonModelType::Seedance2p5),
|
||||
(CommonVideoModel::Sora2, CommonModelType::Sora2),
|
||||
(CommonVideoModel::Sora2Pro, CommonModelType::Sora2Pro),
|
||||
(CommonVideoModel::Veo2, CommonModelType::Veo2),
|
||||
|
||||
@@ -121,6 +121,7 @@ pub async fn process_successful_job(
|
||||
.file_size_bytes(video_bytes.len() as u64)
|
||||
.checksum_sha2(&checksum)
|
||||
.maybe_prompt_token(job.maybe_prompt_token.as_ref())
|
||||
.maybe_source_job_token(Some(&job.job_token))
|
||||
.maybe_platform_type(job.maybe_platform_type)
|
||||
.maybe_cover_image_media_file_token(maybe_cover_token.as_ref())
|
||||
.public_bucket_directory_hash(&bucket_path)
|
||||
|
||||
+1
@@ -137,6 +137,7 @@ async fn download_and_finalize_video(
|
||||
.maybe_creator_user(job.maybe_creator_user_token.as_ref())
|
||||
.maybe_generation_provider(Some(GenerationProvider::Artcraft))
|
||||
.maybe_prompt_token(job.maybe_prompt_token.as_ref())
|
||||
.maybe_source_job_token(Some(&job.job_token))
|
||||
.maybe_platform_type(job.maybe_platform_type)
|
||||
.media_file_class(MediaFileClass::Video)
|
||||
.media_file_origin_category(MediaFileOriginCategory::Inference)
|
||||
|
||||
+1
@@ -228,6 +228,7 @@ async fn download_and_store_one_image(
|
||||
.maybe_frame_width(result.maybe_width)
|
||||
.maybe_generation_provider(Some(GenerationProvider::Artcraft))
|
||||
.maybe_prompt_token(job.maybe_prompt_token.as_ref())
|
||||
.maybe_source_job_token(Some(&job.job_token))
|
||||
.maybe_platform_type(job.maybe_platform_type)
|
||||
.media_file_class(MediaFileClass::Image)
|
||||
.media_file_origin_category(MediaFileOriginCategory::Inference)
|
||||
|
||||
+1
@@ -134,6 +134,7 @@ pub async fn process_successful_video_job(
|
||||
.maybe_frame_width(maybe_frame_width)
|
||||
.maybe_generation_provider(Some(GenerationProvider::Artcraft))
|
||||
.maybe_prompt_token(job.maybe_prompt_token.as_ref())
|
||||
.maybe_source_job_token(Some(&job.job_token))
|
||||
.maybe_platform_type(job.maybe_platform_type)
|
||||
.media_file_class(MediaFileClass::Video)
|
||||
.media_file_origin_category(MediaFileOriginCategory::Inference)
|
||||
|
||||
@@ -121,6 +121,7 @@ pub async fn process_successful_job(
|
||||
.maybe_creator_user(job.maybe_creator_user_token.as_ref())
|
||||
.maybe_generation_provider(Some(GenerationProvider::Artcraft))
|
||||
.maybe_prompt_token(job.maybe_prompt_token.as_ref())
|
||||
.maybe_source_job_token(Some(&job.job_token))
|
||||
.maybe_platform_type(job.maybe_platform_type)
|
||||
.media_file_class(MediaFileClass::Splat)
|
||||
.media_file_origin_category(MediaFileOriginCategory::Inference)
|
||||
|
||||
+76
@@ -46,5 +46,81 @@ pub fn minimax_video_models() -> Vec<OmniGenVideoModelDetails> {
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
models.push(OmniGenVideoModelDetails {
|
||||
model: CommonVideoModel::MinimaxH3Turbo,
|
||||
model_creator: Some(ModelCreator::Hailuo),
|
||||
full_name: Some("MiniMax H3 Turbo".to_string()),
|
||||
text_prompt_supported: Some(true),
|
||||
text_prompt_max_length: Some(7000),
|
||||
starting_keyframe_supported: Some(true),
|
||||
ending_keyframe_supported: Some(true),
|
||||
image_references_supported: Some(true),
|
||||
image_references_max: Some(9),
|
||||
video_references_supported: Some(true),
|
||||
video_references_max: Some(3),
|
||||
video_references_max_total_duration_seconds: Some(15),
|
||||
audio_references_supported: Some(true),
|
||||
audio_references_max: Some(3),
|
||||
audio_references_max_total_duration_seconds: Some(15),
|
||||
aspect_ratio_options: Some(vec![
|
||||
CommonAspectRatio::WideTwentyOneByNine,
|
||||
CommonAspectRatio::WideSixteenByNine,
|
||||
CommonAspectRatio::WideFourByThree,
|
||||
CommonAspectRatio::Square,
|
||||
CommonAspectRatio::TallThreeByFour,
|
||||
CommonAspectRatio::TallNineBySixteen,
|
||||
]),
|
||||
aspect_ratio_default: Some(CommonAspectRatio::WideSixteenByNine),
|
||||
// The model renders 768P or 2K; 720p and below land on 768P, 1080p and
|
||||
// above land on 2K.
|
||||
resolution_options: Some(vec![
|
||||
CommonResolution::SevenTwentyP,
|
||||
CommonResolution::TwoK,
|
||||
]),
|
||||
resolution_default: Some(CommonResolution::TwoK),
|
||||
duration_seconds_min: Some(5),
|
||||
duration_seconds_max: Some(15),
|
||||
duration_seconds_default: Some(5),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
models.push(OmniGenVideoModelDetails {
|
||||
model: CommonVideoModel::MinimaxH3Ultra,
|
||||
model_creator: Some(ModelCreator::Hailuo),
|
||||
full_name: Some("MiniMax H3 Ultra".to_string()),
|
||||
text_prompt_supported: Some(true),
|
||||
text_prompt_max_length: Some(7000),
|
||||
starting_keyframe_supported: Some(true),
|
||||
ending_keyframe_supported: Some(true),
|
||||
image_references_supported: Some(true),
|
||||
image_references_max: Some(9),
|
||||
video_references_supported: Some(true),
|
||||
video_references_max: Some(3),
|
||||
video_references_max_total_duration_seconds: Some(15),
|
||||
audio_references_supported: Some(true),
|
||||
audio_references_max: Some(3),
|
||||
audio_references_max_total_duration_seconds: Some(15),
|
||||
aspect_ratio_options: Some(vec![
|
||||
CommonAspectRatio::WideTwentyOneByNine,
|
||||
CommonAspectRatio::WideSixteenByNine,
|
||||
CommonAspectRatio::WideFourByThree,
|
||||
CommonAspectRatio::Square,
|
||||
CommonAspectRatio::TallThreeByFour,
|
||||
CommonAspectRatio::TallNineBySixteen,
|
||||
]),
|
||||
aspect_ratio_default: Some(CommonAspectRatio::WideSixteenByNine),
|
||||
// The model renders 768P or 2K; 720p and below land on 768P, 1080p and
|
||||
// above land on 2K.
|
||||
resolution_options: Some(vec![
|
||||
CommonResolution::SevenTwentyP,
|
||||
CommonResolution::TwoK,
|
||||
]),
|
||||
resolution_default: Some(CommonResolution::TwoK),
|
||||
duration_seconds_min: Some(5),
|
||||
duration_seconds_max: Some(15),
|
||||
duration_seconds_default: Some(5),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
models
|
||||
}
|
||||
|
||||
+41
@@ -8,6 +8,47 @@ use enums::common::generation::model_creator::ModelCreator;
|
||||
pub fn seedance_2p5_video_models() -> Vec<OmniGenVideoModelDetails> {
|
||||
let mut models = Vec::new();
|
||||
|
||||
models.push(OmniGenVideoModelDetails {
|
||||
model: CommonVideoModel::Seedance2p5,
|
||||
model_creator: Some(ModelCreator::Bytedance),
|
||||
full_name: Some("Seedance 2.5".to_string()),
|
||||
extra_info: Some("Seedance 2.5. Supports videos up to 30 seconds, start/end keyframes (adaptive aspect ratio), and image/video/audio references, with 480p/720p output. Video references bill their input seconds on top of the output duration.".to_string()),
|
||||
extra_info_short: Some("Seedance 2.5; up to 30s videos".to_string()),
|
||||
text_prompt_supported: Some(true),
|
||||
text_prompt_max_length: Some(10_000),
|
||||
starting_keyframe_supported: Some(true),
|
||||
ending_keyframe_supported: Some(true),
|
||||
image_references_supported: Some(true),
|
||||
image_references_max: Some(30),
|
||||
audio_references_supported: Some(true),
|
||||
audio_references_max: Some(10),
|
||||
audio_references_max_total_duration_seconds: Some(30),
|
||||
video_references_supported: Some(true),
|
||||
video_references_max: Some(10),
|
||||
video_references_max_total_duration_seconds: Some(30),
|
||||
character_references_supported: Some(false),
|
||||
aspect_ratio_options: Some(vec![
|
||||
CommonAspectRatio::WideTwentyOneByNine,
|
||||
CommonAspectRatio::WideSixteenByNine,
|
||||
CommonAspectRatio::WideFourByThree,
|
||||
CommonAspectRatio::Square,
|
||||
CommonAspectRatio::TallThreeByFour,
|
||||
CommonAspectRatio::TallNineBySixteen,
|
||||
]),
|
||||
aspect_ratio_default: Some(CommonAspectRatio::WideSixteenByNine),
|
||||
resolution_options: Some(vec![
|
||||
CommonResolution::FourEightyP,
|
||||
CommonResolution::SevenTwentyP,
|
||||
]),
|
||||
resolution_default: Some(CommonResolution::SevenTwentyP),
|
||||
batch_size_options: Some(vec![1]),
|
||||
batch_size_default: Some(1),
|
||||
duration_seconds_min: Some(4),
|
||||
duration_seconds_max: Some(30),
|
||||
duration_seconds_default: Some(5),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
models.push(OmniGenVideoModelDetails {
|
||||
model: CommonVideoModel::Seedance2p5Preview,
|
||||
model_creator: Some(ModelCreator::Bytedance),
|
||||
|
||||
@@ -107,6 +107,14 @@ fn build_omni_gen_video_model_providers() -> Vec<OmniGenVideoModelProviderDetail
|
||||
model: CommonVideoModel::MinimaxH3,
|
||||
overrides: None,
|
||||
},
|
||||
OmniGenVideoProviderModelDetails {
|
||||
model: CommonVideoModel::MinimaxH3Turbo,
|
||||
overrides: None,
|
||||
},
|
||||
OmniGenVideoProviderModelDetails {
|
||||
model: CommonVideoModel::MinimaxH3Ultra,
|
||||
overrides: None,
|
||||
},
|
||||
OmniGenVideoProviderModelDetails {
|
||||
model: CommonVideoModel::Flux3,
|
||||
overrides: None,
|
||||
|
||||
@@ -111,6 +111,10 @@ use crate::http_server::endpoints::api_keys::list_api_keys_handler::*;
|
||||
use crate::http_server::endpoints::api_keys::get_api_key_handler::*;
|
||||
use crate::http_server::endpoints::api_keys::delete_api_key_handler::*;
|
||||
use crate::http_server::endpoints::api_keys::update_api_key_handler::*;
|
||||
use artcraft_api_defs::internal::minimax_jobs::mark_minimax_job_failure::*;
|
||||
use artcraft_api_defs::internal::minimax_jobs::mark_minimax_job_success::*;
|
||||
use artcraft_api_defs::internal::minimax_jobs::minimax_worker_model::*;
|
||||
use artcraft_api_defs::internal::minimax_jobs::obtain_minimax_job::*;
|
||||
use artcraft_api_defs::api_keys::common::*;
|
||||
use artcraft_api_defs::api_keys::create_api_key::*;
|
||||
use artcraft_api_defs::api_keys::list_api_keys::*;
|
||||
@@ -356,6 +360,7 @@ use artcraft_api_defs::credits::get_session_credits::*;
|
||||
use artcraft_api_defs::subscriptions::get_session_subscription::*;
|
||||
use artcraft_api_defs::media_file::list_batch_generated_media_files::*;
|
||||
use artcraft_api_defs::media_file::list::by_type::list_session_common::*;
|
||||
use artcraft_api_defs::media_file::job::list_media_files_by_job::*;
|
||||
use artcraft_api_defs::media_file::list::by_type::list_session_mesh_media_files::*;
|
||||
use artcraft_api_defs::media_file::list::by_type::list_session_splat_media_files::*;
|
||||
use artcraft_api_defs::media_file::list_session_project_media_files::*;
|
||||
@@ -495,6 +500,7 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
crate::http_server::endpoints::media_files::edit::set_media_file_cover_image_handler::set_media_file_cover_image_handler,
|
||||
crate::http_server::endpoints::media_files::get::batch_get_media_files_handler::batch_get_media_files_handler,
|
||||
crate::http_server::endpoints::media_files::get::get_media_file_handler::get_media_file_handler,
|
||||
crate::http_server::endpoints::media_files::job::list_media_files_by_job_handler::list_media_files_by_job_handler,
|
||||
crate::http_server::endpoints::media_files::list::list_featured_media_files_handler::list_featured_media_files_handler,
|
||||
crate::http_server::endpoints::media_files::list::list_media_files_by_batch_token_handler::list_media_files_by_batch_token_handler,
|
||||
crate::http_server::endpoints::media_files::list::list_media_files_for_user_handler::list_media_files_for_user_handler,
|
||||
@@ -639,6 +645,10 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
crate::http_server::endpoints::omni_api::upload::omni_upload_video_media_file_handler::omni_upload_video_media_file_handler,
|
||||
crate::http_server::endpoints::omni_api::job_status::get_job_status_handler::omni_api_get_job_status_handler,
|
||||
crate::http_server::endpoints::omni_api::job_status::batch_get_job_status_handler::omni_api_batch_get_job_status_handler,
|
||||
// Internal (worker fleets, internal API key authenticated)
|
||||
crate::http_server::endpoints::internal::minimax_jobs::obtain_minimax_job_handler::obtain_minimax_job_handler,
|
||||
crate::http_server::endpoints::internal::minimax_jobs::mark_minimax_job_failure_handler::mark_minimax_job_failure_handler,
|
||||
crate::http_server::endpoints::internal::minimax_jobs::mark_minimax_job_success_handler::mark_minimax_job_success_handler,
|
||||
// Moderation
|
||||
crate::http_server::endpoints::moderation::alerts::moderation_send_alert_handler::moderation_send_alert_handler,
|
||||
crate::http_server::endpoints::moderation::dashboards::moderator_list_databox_dashboards_handler::moderator_list_databox_dashboards_handler,
|
||||
@@ -755,6 +765,17 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
UserToken,
|
||||
ZsVoiceDatasetToken,
|
||||
|
||||
// Internal (worker fleets)
|
||||
MarkMinimaxJobFailureRequest,
|
||||
MarkMinimaxJobFailureResponse,
|
||||
MarkMinimaxJobSuccessResponse,
|
||||
MinimaxJobMediaReference,
|
||||
MinimaxJobPromptDetails,
|
||||
MinimaxWorkerModel,
|
||||
ObtainMinimaxJobRequest,
|
||||
ObtainMinimaxJobResponse,
|
||||
ObtainedMinimaxJob,
|
||||
|
||||
// Enums
|
||||
BetaKeyProduct,
|
||||
CommentEntityType,
|
||||
@@ -791,6 +812,7 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
|
||||
// Common response structs
|
||||
JobDetailsLivePortraitRequest,
|
||||
JobMediaFileInfo,
|
||||
JobDetailsLipsyncRequest,
|
||||
MediaFileLivePortraitDetails,
|
||||
MediaFileModelDetails,
|
||||
@@ -962,6 +984,7 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
OmniGenSplatModelsResponse,
|
||||
OmniGenSplatProviderModelDetails,
|
||||
OmniGenVideoCostAndGenerateRequest,
|
||||
EstimateFields,
|
||||
OmniGenVideoCostResponse,
|
||||
OmniGenVideoGenerateResponse,
|
||||
OmniGenVideoModelDetails,
|
||||
@@ -1125,6 +1148,7 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
ListUserImpersonationRequestsSuccessResponse,
|
||||
ListMediaFilesByBatchPathInfo,
|
||||
ListMediaFilesByBatchSuccessResponse,
|
||||
ListMediaFilesByJobSuccessResponse,
|
||||
ListMediaFilesForUserPathInfo,
|
||||
ListMediaFilesForUserQueryParams,
|
||||
ListMediaFilesForUserSuccessResponse,
|
||||
|
||||
@@ -3,3 +3,4 @@ pub mod generation_debug_logs;
|
||||
pub mod job_failure_test;
|
||||
pub mod map_kinovi_web_web_errors;
|
||||
pub mod payments_error_test;
|
||||
pub mod probed_reference_videos;
|
||||
|
||||
+298
@@ -0,0 +1,298 @@
|
||||
//! Download + ffprobe reference videos for input-seconds billing.
|
||||
//!
|
||||
//! Models that bill reference-video input seconds (e.g. Seedance 2.5) need
|
||||
//! ACCURATE durations. The `media_files` row's stored duration isn't always
|
||||
//! set and isn't always correct, so every reference video is downloaded and
|
||||
//! ffprobed — the database is never trusted for billing.
|
||||
//!
|
||||
//! The downloaded files are kept on disk inside [`ProbedReferenceVideos`]
|
||||
//! and handed to the router (via `predownloaded_media_paths` on the draft
|
||||
//! context) so the subsequent provider upload reuses the bytes instead of
|
||||
//! downloading them a second time. Dropping the struct deletes the files, so
|
||||
//! callers must keep it alive until the provider upload completes.
|
||||
//!
|
||||
//! Two phases so callers never hold a pooled DB connection across the slow
|
||||
//! download/probe work:
|
||||
//!
|
||||
//! 1. [`fetch_reference_video_sources`] — DB fetch (CDN URLs) on the
|
||||
//! caller's connection.
|
||||
//! 2. [`download_and_probe_reference_videos`] — downloads each unique file
|
||||
//! once and ffprobes it. Callers should DROP their pooled connection
|
||||
//! before this phase and re-acquire after.
|
||||
//!
|
||||
//! Billing math: each file's duration is rounded UP to a whole second, and
|
||||
//! counted once per reference (the same file referenced twice bills twice).
|
||||
//!
|
||||
//! Probing NEVER fails a generation: a file whose download or ffprobe fails
|
||||
//! is billed at the worst-case [`MAX_BILLED_INPUT_SECONDS`] instead (and is
|
||||
//! not kept on disk — the provider upload re-downloads it itself).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::convert::TryFrom;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use actix_web::HttpRequest;
|
||||
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
|
||||
use ffmpeg_utils::ffprobe::ffprobe_get_info::ffprobe_get_info;
|
||||
use kinovi_web_client::generate::video::generate_seedance_2p5::MAX_BILLED_INPUT_SECONDS;
|
||||
use log::{error, info, warn};
|
||||
use mysql_queries::queries::media_files::get::batch_get_media_files_by_tokens::batch_get_media_files_by_tokens_with_connection;
|
||||
use server_environment::ServerEnvironment;
|
||||
use sqlx::pool::PoolConnection;
|
||||
use sqlx::MySql;
|
||||
use tempfile::NamedTempFile;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::common_responses::media::media_links_builder::MediaLinksBuilder;
|
||||
use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain;
|
||||
use crate::util::http_download_url_to_tempfile::http_download_url_to_tempfile;
|
||||
|
||||
/// Downloaded + ffprobed reference videos.
|
||||
///
|
||||
/// Keep this alive until the provider upload completes — dropping it deletes
|
||||
/// the downloaded temp files.
|
||||
pub struct ProbedReferenceVideos {
|
||||
/// Sum of per-file ffprobed durations, each rounded UP to a whole second,
|
||||
/// counted once per reference (duplicates bill per reference).
|
||||
pub total_input_seconds: u16,
|
||||
|
||||
/// Source CDN URL → local temp file path, one entry per unique file.
|
||||
/// Pass to the router as `predownloaded_media_paths` so uploads reuse the
|
||||
/// already-downloaded bytes.
|
||||
local_paths_by_url: HashMap<String, PathBuf>,
|
||||
|
||||
/// Owns the downloaded files; they are deleted when this struct drops.
|
||||
_temp_files: Vec<NamedTempFile>,
|
||||
}
|
||||
|
||||
impl ProbedReferenceVideos {
|
||||
pub fn local_paths_by_url(&self) -> &HashMap<String, PathBuf> {
|
||||
&self.local_paths_by_url
|
||||
}
|
||||
}
|
||||
|
||||
/// One requested reference video (duplicates preserved, in request order).
|
||||
pub struct ReferenceVideoSource {
|
||||
pub media_token: MediaFileToken,
|
||||
pub cdn_url: String,
|
||||
}
|
||||
|
||||
/// Phase 1 (DB): resolve each reference video token to its CDN URL.
|
||||
///
|
||||
/// Returns one entry per requested token, in order, with duplicates
|
||||
/// preserved (the same file referenced twice bills twice). Unknown tokens
|
||||
/// are a 400.
|
||||
pub async fn fetch_reference_video_sources(
|
||||
video_tokens: &[MediaFileToken],
|
||||
http_request: &HttpRequest,
|
||||
server_environment: ServerEnvironment,
|
||||
mysql_connection: &mut PoolConnection<MySql>,
|
||||
) -> Result<Vec<ReferenceVideoSource>, CommonWebError> {
|
||||
const CAN_SEE_DELETED: bool = false;
|
||||
|
||||
if video_tokens.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut unique_tokens: Vec<MediaFileToken> = Vec::new();
|
||||
for token in video_tokens {
|
||||
if !unique_tokens.contains(token) {
|
||||
unique_tokens.push(token.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let media_files = batch_get_media_files_by_tokens_with_connection(
|
||||
mysql_connection,
|
||||
&unique_tokens,
|
||||
CAN_SEE_DELETED,
|
||||
).await.map_err(|err| {
|
||||
error!("Error getting reference video media files by tokens: {:?}", err);
|
||||
CommonWebError::from_anyhow_error(err)
|
||||
})?;
|
||||
|
||||
if media_files.len() != unique_tokens.len() {
|
||||
warn!("Only {} of {} unique reference video media files could be found",
|
||||
media_files.len(), unique_tokens.len());
|
||||
return Err(CommonWebError::BadInputWithSimpleMessage(
|
||||
"not all reference video media files could be found".to_string()));
|
||||
}
|
||||
|
||||
let media_domain = get_media_domain(http_request);
|
||||
|
||||
let cdn_urls_by_token: HashMap<MediaFileToken, String> = media_files
|
||||
.into_iter()
|
||||
.map(|file| {
|
||||
let bucket_path = MediaFileBucketPath::from_object_hash(
|
||||
&file.public_bucket_directory_hash,
|
||||
file.maybe_public_bucket_prefix.as_deref(),
|
||||
file.maybe_public_bucket_extension.as_deref());
|
||||
|
||||
let media_links = MediaLinksBuilder::from_media_path_and_env(
|
||||
media_domain,
|
||||
server_environment,
|
||||
&bucket_path);
|
||||
|
||||
(file.token, media_links.cdn_url.to_string())
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(video_tokens
|
||||
.iter()
|
||||
.filter_map(|token| {
|
||||
cdn_urls_by_token.get(token).map(|cdn_url| ReferenceVideoSource {
|
||||
media_token: token.clone(),
|
||||
cdn_url: cdn_url.clone(),
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Phase 2 (no DB): download each unique file once, ffprobe it, and sum the
|
||||
/// billed input seconds. Callers must not hold a pooled DB connection across
|
||||
/// this call.
|
||||
///
|
||||
/// Infallible by design: a file that can't be downloaded or probed is billed
|
||||
/// at the worst-case [`MAX_BILLED_INPUT_SECONDS`] rather than failing the
|
||||
/// generation, and its temp file isn't kept (the provider upload re-downloads
|
||||
/// files that aren't in the predownloaded map).
|
||||
pub async fn download_and_probe_reference_videos(
|
||||
sources: &[ReferenceVideoSource],
|
||||
) -> ProbedReferenceVideos {
|
||||
let mut temp_files: Vec<NamedTempFile> = Vec::new();
|
||||
let mut local_paths_by_url: HashMap<String, PathBuf> = HashMap::new();
|
||||
let mut seconds_by_url: HashMap<String, u64> = HashMap::new();
|
||||
|
||||
for source in sources {
|
||||
if seconds_by_url.contains_key(&source.cdn_url) {
|
||||
continue; // Already downloaded and probed this file.
|
||||
}
|
||||
|
||||
let billed_seconds = match download_and_probe_one(source).await {
|
||||
Ok((temp_file, duration_millis)) => {
|
||||
info!("Probed reference video {} at {}ms", source.media_token, duration_millis);
|
||||
local_paths_by_url.insert(source.cdn_url.clone(), temp_file.path().to_path_buf());
|
||||
temp_files.push(temp_file);
|
||||
duration_millis.div_ceil(1_000)
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"Failed to download/probe reference video {}; billing the {}s worst case: {:?}",
|
||||
source.media_token, MAX_BILLED_INPUT_SECONDS, err,
|
||||
);
|
||||
u64::from(MAX_BILLED_INPUT_SECONDS)
|
||||
}
|
||||
};
|
||||
|
||||
seconds_by_url.insert(source.cdn_url.clone(), billed_seconds);
|
||||
}
|
||||
|
||||
let total_input_seconds = total_billed_seconds(sources, &seconds_by_url);
|
||||
|
||||
ProbedReferenceVideos {
|
||||
total_input_seconds,
|
||||
local_paths_by_url,
|
||||
_temp_files: temp_files,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sum per-reference (so duplicates bill per reference), saturating at
|
||||
/// `u16::MAX`.
|
||||
fn total_billed_seconds(
|
||||
sources: &[ReferenceVideoSource],
|
||||
seconds_by_url: &HashMap<String, u64>,
|
||||
) -> u16 {
|
||||
let total: u64 = sources
|
||||
.iter()
|
||||
.filter_map(|source| seconds_by_url.get(&source.cdn_url))
|
||||
.sum();
|
||||
u16::try_from(total).unwrap_or(u16::MAX)
|
||||
}
|
||||
|
||||
async fn download_and_probe_one(
|
||||
source: &ReferenceVideoSource,
|
||||
) -> Result<(NamedTempFile, u64), CommonWebError> {
|
||||
let temp_file = download_reference_video(source).await?;
|
||||
let duration_millis = probe_video_duration_millis(source, &temp_file)?;
|
||||
Ok((temp_file, duration_millis))
|
||||
}
|
||||
|
||||
async fn download_reference_video(source: &ReferenceVideoSource) -> Result<NamedTempFile, CommonWebError> {
|
||||
let mut temp_file = NamedTempFile::new().map_err(|err| {
|
||||
error!("Failed to create temp file for reference video {}: {:?}", source.media_token, err);
|
||||
CommonWebError::server_error_with_message("failed to probe reference video duration")
|
||||
})?;
|
||||
|
||||
http_download_url_to_tempfile(&source.cdn_url, &mut temp_file)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
error!("Failed to download reference video {} for probing: {:?}", source.media_token, err);
|
||||
CommonWebError::server_error_with_message("failed to probe reference video duration")
|
||||
})?;
|
||||
|
||||
Ok(temp_file)
|
||||
}
|
||||
|
||||
fn probe_video_duration_millis(
|
||||
source: &ReferenceVideoSource,
|
||||
temp_file: &NamedTempFile,
|
||||
) -> Result<u64, CommonWebError> {
|
||||
let video_info = ffprobe_get_info(&temp_file.path()).map_err(|err| {
|
||||
error!("ffprobe failed for reference video {}: {:?}", source.media_token, err);
|
||||
CommonWebError::server_error_with_message("failed to probe reference video duration")
|
||||
})?;
|
||||
|
||||
video_info.duration
|
||||
.map(|duration| duration.millis as u64)
|
||||
.ok_or_else(|| {
|
||||
error!("ffprobe returned no duration for reference video {}", source.media_token);
|
||||
CommonWebError::server_error_with_message("failed to probe reference video duration")
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn durations_round_up_per_file_and_sum() {
|
||||
// 6.2s → 7, 7.0s → 7: total 14.
|
||||
let sources = [source("m_a", "https://cdn/a.mp4"), source("m_b", "https://cdn/b.mp4")];
|
||||
let seconds = seconds_map(&[("https://cdn/a.mp4", 6_200), ("https://cdn/b.mp4", 7_000)]);
|
||||
assert_eq!(total_billed_seconds(&sources, &seconds), 14);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_references_bill_per_reference() {
|
||||
// The same 9.001s file referenced twice bills 10 + 10 = 20.
|
||||
let sources = [source("m_a", "https://cdn/a.mp4"), source("m_a", "https://cdn/a.mp4")];
|
||||
let seconds = seconds_map(&[("https://cdn/a.mp4", 9_001)]);
|
||||
assert_eq!(total_billed_seconds(&sources, &seconds), 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_sources_sum_to_zero() {
|
||||
assert_eq!(total_billed_seconds(&[], &HashMap::new()), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn total_saturates_at_u16_max() {
|
||||
let sources = [source("m_a", "https://cdn/a.mp4")];
|
||||
let seconds = seconds_map(&[("https://cdn/a.mp4", u64::from(u32::MAX) * 1_000)]);
|
||||
assert_eq!(total_billed_seconds(&sources, &seconds), u16::MAX);
|
||||
}
|
||||
|
||||
fn source(token: &str, url: &str) -> ReferenceVideoSource {
|
||||
ReferenceVideoSource {
|
||||
media_token: MediaFileToken::new(token.to_string()),
|
||||
cdn_url: url.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn seconds_map(entries: &[(&str, u64)]) -> HashMap<String, u64> {
|
||||
entries
|
||||
.iter()
|
||||
.map(|(url, millis)| (url.to_string(), millis.div_ceil(1_000)))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
+4
@@ -55,6 +55,7 @@ pub async fn estimate_video_cost_handler(
|
||||
duration_seconds: request.duration_seconds,
|
||||
video_batch_count: request.video_batch_count,
|
||||
generate_audio: request.generate_audio,
|
||||
total_reference_video_input_seconds: None,
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayLessDowngrade,
|
||||
idempotency_token: None,
|
||||
};
|
||||
@@ -156,8 +157,11 @@ fn map_video_model(model: CommonVideoModel) -> Result<RouterVideoModel, HandlerE
|
||||
CommonVideoModel::Seedance2p0BytePlusMini => RouterVideoModel::Seedance2p0BytePlusMini,
|
||||
CommonVideoModel::Seedance2p0BytePlusUltraMini => RouterVideoModel::Seedance2p0BytePlusUltraMini,
|
||||
CommonVideoModel::Seedance2p5Preview => RouterVideoModel::Seedance2p5Preview,
|
||||
CommonVideoModel::Seedance2p5 => RouterVideoModel::Seedance2p5,
|
||||
CommonVideoModel::HappyHorse1p0 => RouterVideoModel::HappyHorse1p0,
|
||||
CommonVideoModel::MinimaxH3 => RouterVideoModel::MinimaxH3,
|
||||
CommonVideoModel::MinimaxH3Turbo => RouterVideoModel::MinimaxH3Turbo,
|
||||
CommonVideoModel::MinimaxH3Ultra => RouterVideoModel::MinimaxH3Ultra,
|
||||
CommonVideoModel::Sora2 => RouterVideoModel::Sora2,
|
||||
CommonVideoModel::Sora2Pro => RouterVideoModel::Sora2Pro,
|
||||
CommonVideoModel::Veo2 => RouterVideoModel::Veo2,
|
||||
|
||||
+1
@@ -202,6 +202,7 @@ async fn upload_and_save_image(
|
||||
.maybe_creator_user(inference_job.user_details.maybe_creator_user_token.as_ref())
|
||||
.maybe_creator_anonymous_visitor(inference_job.user_details.maybe_creator_anonymous_visitor_token.as_ref())
|
||||
.creator_ip_address(&inference_job.user_details.creator_ip_address)
|
||||
.maybe_source_job_token(Some(&inference_job.job_token))
|
||||
.public_bucket_directory_hash(&bucket_upload_path)
|
||||
.media_file_class(MediaFileClass::Image)
|
||||
.media_file_type(media_file_type)
|
||||
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::web::Json;
|
||||
use actix_web::{web, HttpRequest};
|
||||
use log::{error, info, warn};
|
||||
use serde_derive::Deserialize;
|
||||
|
||||
use artcraft_api_defs::internal::minimax_jobs::mark_minimax_job_failure::{
|
||||
MarkMinimaxJobFailureRequest, MarkMinimaxJobFailureResponse,
|
||||
};
|
||||
use mysql_queries::queries::generic_inference::first_party::minimax_h3::get_first_party_minimax_h3_job_by_token::{
|
||||
get_first_party_minimax_h3_job_by_token, GetFirstPartyMinimaxH3JobByTokenArgs,
|
||||
};
|
||||
use mysql_queries::queries::generic_inference::first_party::minimax_h3::mark_first_party_minimax_h3_job_failed::{
|
||||
mark_first_party_minimax_h3_job_failed, MarkFirstPartyMinimaxH3JobFailedArgs,
|
||||
};
|
||||
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::web_utils::require_internal_api_key::require_internal_api_key;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct MarkMinimaxJobFailurePathInfo {
|
||||
job_token: InferenceJobToken,
|
||||
}
|
||||
|
||||
/// Internal (worker-facing): mark a first-party Minimax job as failed.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
tag = "Internal (Minimax Jobs)",
|
||||
path = "/v1/internal/minimax_jobs/job/{job_token}/failure",
|
||||
request_body = MarkMinimaxJobFailureRequest,
|
||||
params(
|
||||
("job_token" = String, Path, description = "The inference job token"),
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Success", body = MarkMinimaxJobFailureResponse),
|
||||
(status = 401, description = "Missing or invalid internal API key"),
|
||||
(status = 404, description = "No such minimax job"),
|
||||
(status = 500, description = "Server error"),
|
||||
),
|
||||
)]
|
||||
pub async fn mark_minimax_job_failure_handler(
|
||||
http_request: HttpRequest,
|
||||
path: web::Path<MarkMinimaxJobFailurePathInfo>,
|
||||
request: Json<MarkMinimaxJobFailureRequest>,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<MarkMinimaxJobFailureResponse>, CommonWebError> {
|
||||
|
||||
require_internal_api_key(&http_request, &server_state)?;
|
||||
|
||||
let job_token = &path.job_token;
|
||||
|
||||
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
|
||||
|
||||
let maybe_job = get_first_party_minimax_h3_job_by_token(GetFirstPartyMinimaxH3JobByTokenArgs {
|
||||
job_token,
|
||||
mysql_executor: &mut *mysql_connection,
|
||||
phantom: Default::default(),
|
||||
}).await.map_err(|err| {
|
||||
error!("Error looking up minimax job {}: {:?}", job_token, err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let Some(job) = maybe_job else {
|
||||
warn!("No such minimax job: {}", job_token);
|
||||
return Err(CommonWebError::NotFound);
|
||||
};
|
||||
|
||||
// Worker-supplied text is shown to users; strip control characters.
|
||||
// (The query clamps both reasons to the columns' 512 characters.)
|
||||
let maybe_user_failure_reason = request.maybe_user_failure_reason
|
||||
.as_deref()
|
||||
.map(sanitize_user_failure_reason)
|
||||
.filter(|reason| !reason.is_empty());
|
||||
|
||||
mark_first_party_minimax_h3_job_failed(MarkFirstPartyMinimaxH3JobFailedArgs {
|
||||
job_token: &job.job_token,
|
||||
maybe_frontend_failure_category: request.maybe_frontend_failure_category,
|
||||
maybe_failure_reason: maybe_user_failure_reason.as_deref(),
|
||||
maybe_internal_debugging_failure_reason: request.maybe_internal_debugging_failure_reason.as_deref(),
|
||||
maybe_execution_duration_millis: request.execution_duration_millis,
|
||||
maybe_inference_duration_millis: request.inference_duration_millis,
|
||||
mysql_executor: &mut *mysql_connection,
|
||||
phantom: Default::default(),
|
||||
}).await.map_err(|err| {
|
||||
error!("Error marking minimax job {} failed: {:?}", job_token, err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
info!("Minimax job {} marked as failed (category: {:?})",
|
||||
job.job_token, request.maybe_frontend_failure_category);
|
||||
|
||||
Ok(Json(MarkMinimaxJobFailureResponse {
|
||||
success: true,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Replace control characters (including newlines) with spaces and trim.
|
||||
fn sanitize_user_failure_reason(reason: &str) -> String {
|
||||
reason
|
||||
.chars()
|
||||
.map(|c| if c.is_control() { ' ' } else { c })
|
||||
.collect::<String>()
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sanitize_strips_control_characters() {
|
||||
assert_eq!(sanitize_user_failure_reason("line one\nline two\r\n"), "line one line two");
|
||||
assert_eq!(sanitize_user_failure_reason("\t spaced \t"), "spaced");
|
||||
assert_eq!(sanitize_user_failure_reason("\u{0000}\u{0007}"), "");
|
||||
assert_eq!(sanitize_user_failure_reason("plain reason"), "plain reason");
|
||||
}
|
||||
}
|
||||
+361
@@ -0,0 +1,361 @@
|
||||
use std::io::Read;
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_multipart::form::tempfile::TempFile;
|
||||
use actix_multipart::form::text::Text;
|
||||
use actix_multipart::form::MultipartForm;
|
||||
use actix_web::web::Json;
|
||||
use actix_web::{web, HttpRequest};
|
||||
use log::{error, info, warn};
|
||||
use serde_derive::Deserialize;
|
||||
use sqlx::pool::PoolConnection;
|
||||
use sqlx::MySql;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use artcraft_api_defs::internal::minimax_jobs::mark_minimax_job_success::MarkMinimaxJobSuccessResponse;
|
||||
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
|
||||
use enums::by_table::media_files::media_file_class::MediaFileClass;
|
||||
use enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory;
|
||||
use enums::by_table::media_files::media_file_type::MediaFileType;
|
||||
use enums::common::generation_provider::GenerationProvider;
|
||||
use enums::common::job_status_plus::JobStatusPlus;
|
||||
use ffmpeg_utils::ffprobe::ffprobe_get_info::ffprobe_get_info;
|
||||
use filesys::path_to_string::path_to_string;
|
||||
use hashing::sha256::sha256_hash_bytes::sha256_hash_bytes;
|
||||
use mimetypes::mimetype_for_bytes::get_mimetype_for_bytes;
|
||||
use mimetypes::mimetype_to_extension::mimetype_to_extension;
|
||||
use mysql_queries::queries::generic_inference::first_party::minimax_h3::get_first_party_minimax_h3_job_by_token::{
|
||||
get_first_party_minimax_h3_job_by_token, FirstPartyMinimaxH3JobDetails, GetFirstPartyMinimaxH3JobByTokenArgs,
|
||||
};
|
||||
use mysql_queries::queries::generic_inference::first_party::minimax_h3::mark_first_party_minimax_h3_job_succeeded::{
|
||||
mark_first_party_minimax_h3_job_succeeded, MarkFirstPartyMinimaxH3JobSucceededArgs,
|
||||
};
|
||||
use mysql_queries::queries::media_files::create::insert_builder::media_file_insert_builder::MediaFileInsertBuilder;
|
||||
use thumbnail_generator::task_client::thumbnail_task::{ThumbnailTaskBuilder, ThumbnailTaskInputMimeType};
|
||||
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::web_utils::require_internal_api_key::require_internal_api_key;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
const BUCKET_PATH_PREFIX: Option<&str> = Some("artcraft_");
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct MarkMinimaxJobSuccessPathInfo {
|
||||
job_token: InferenceJobToken,
|
||||
}
|
||||
|
||||
/// Form-multipart request fields for the minimax job success endpoint.
|
||||
#[derive(MultipartForm, ToSchema)]
|
||||
#[multipart(duplicate_field = "deny")]
|
||||
pub struct MarkMinimaxJobSuccessForm {
|
||||
/// The generated video file bytes.
|
||||
#[multipart(limit = "1 GiB")]
|
||||
#[schema(value_type = Vec<u8>, format = Binary)]
|
||||
file: TempFile,
|
||||
|
||||
/// Optional: video duration in milliseconds. Probed from the file if absent.
|
||||
#[multipart(limit = "2 KiB")]
|
||||
#[schema(value_type = Option<u64>, format = Binary)]
|
||||
duration_millis: Option<Text<u64>>,
|
||||
|
||||
/// Optional: video frame width in pixels. Probed from the file if absent.
|
||||
#[multipart(limit = "2 KiB")]
|
||||
#[schema(value_type = Option<u32>, format = Binary)]
|
||||
width: Option<Text<u32>>,
|
||||
|
||||
/// Optional: video frame height in pixels. Probed from the file if absent.
|
||||
#[multipart(limit = "2 KiB")]
|
||||
#[schema(value_type = Option<u32>, format = Binary)]
|
||||
height: Option<Text<u32>>,
|
||||
|
||||
/// Optional: total wall-clock runtime of the job, in milliseconds.
|
||||
#[multipart(limit = "2 KiB")]
|
||||
#[schema(value_type = Option<u64>, format = Binary)]
|
||||
execution_duration_millis: Option<Text<u64>>,
|
||||
|
||||
/// Optional: inference-only runtime of the job, in milliseconds.
|
||||
#[multipart(limit = "2 KiB")]
|
||||
#[schema(value_type = Option<u64>, format = Binary)]
|
||||
inference_duration_millis: Option<Text<u64>>,
|
||||
}
|
||||
|
||||
/// Internal (worker-facing): mark a first-party Minimax job as successful and
|
||||
/// upload the generated video. Mirrors what the Fal webhook does when videos
|
||||
/// complete: upload to the public bucket, create the media file record, fire
|
||||
/// thumbnail tasks, then mark the job `complete_success`.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
tag = "Internal (Minimax Jobs)",
|
||||
path = "/v1/internal/minimax_jobs/job/{job_token}/success",
|
||||
params(
|
||||
("job_token" = String, Path, description = "The inference job token"),
|
||||
(
|
||||
"request" = MarkMinimaxJobSuccessForm,
|
||||
description = "Multipart form: `file` (video bytes) plus optional `duration_millis` / `width` / `height` metadata."
|
||||
),
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Success", body = MarkMinimaxJobSuccessResponse),
|
||||
(status = 400, description = "Bad input (unreadable or non-video file)"),
|
||||
(status = 401, description = "Missing or invalid internal API key"),
|
||||
(status = 404, description = "No such minimax job"),
|
||||
(status = 500, description = "Server error"),
|
||||
),
|
||||
)]
|
||||
pub async fn mark_minimax_job_success_handler(
|
||||
http_request: HttpRequest,
|
||||
path: web::Path<MarkMinimaxJobSuccessPathInfo>,
|
||||
MultipartForm(mut form): MultipartForm<MarkMinimaxJobSuccessForm>,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<MarkMinimaxJobSuccessResponse>, CommonWebError> {
|
||||
|
||||
require_internal_api_key(&http_request, &server_state)?;
|
||||
|
||||
let job_token = &path.job_token;
|
||||
|
||||
// ==================== LOOK UP JOB ==================== //
|
||||
|
||||
// NB: The connection is scoped so we don't hold a pool slot across the
|
||||
// (slow) bucket upload below.
|
||||
let maybe_job = {
|
||||
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
|
||||
|
||||
get_first_party_minimax_h3_job_by_token(GetFirstPartyMinimaxH3JobByTokenArgs {
|
||||
job_token,
|
||||
mysql_executor: &mut *mysql_connection,
|
||||
phantom: Default::default(),
|
||||
}).await.map_err(|err| {
|
||||
error!("Error looking up minimax job {}: {:?}", job_token, err);
|
||||
CommonWebError::from_error(err)
|
||||
})?
|
||||
};
|
||||
|
||||
let Some(job) = maybe_job else {
|
||||
warn!("No such minimax job: {}", job_token);
|
||||
return Err(CommonWebError::NotFound);
|
||||
};
|
||||
|
||||
// ==================== IDEMPOTENCY: TERMINAL STATUS CHECK ==================== //
|
||||
|
||||
match job.status {
|
||||
JobStatusPlus::CompleteSuccess => {
|
||||
// A duplicate / retried success report. Don't re-upload or create a
|
||||
// second media file — return the already-recorded result.
|
||||
let Some(existing_media_token) = job.maybe_result_media_file_token else {
|
||||
error!("Minimax job {} is complete_success but has no result media file token", job.job_token);
|
||||
return Err(CommonWebError::server_error_with_message(
|
||||
"job already complete but result media file is missing"));
|
||||
};
|
||||
|
||||
info!("Minimax job {} is already complete_success; ignoring duplicate success report", job.job_token);
|
||||
|
||||
return Ok(Json(MarkMinimaxJobSuccessResponse {
|
||||
success: true,
|
||||
media_file_token: existing_media_token,
|
||||
maybe_duration_millis: None,
|
||||
maybe_width: None,
|
||||
maybe_height: None,
|
||||
}));
|
||||
}
|
||||
JobStatusPlus::CompleteFailure => {
|
||||
warn!("Minimax job {} is already complete_failure; rejecting success report", job.job_token);
|
||||
return Err(CommonWebError::BadInputWithSimpleMessage(
|
||||
"job is already in a terminal state (complete_failure)".to_string()));
|
||||
}
|
||||
_ => {} // Not terminal; proceed.
|
||||
}
|
||||
|
||||
// ==================== READ + VALIDATE FILE ==================== //
|
||||
|
||||
// NB: No database connection is held anywhere in this section — the byte
|
||||
// read, hashing, ffprobe, and bucket upload below are all slow, and
|
||||
// holding a pool slot across them is what causes PoolTimedOut incidents.
|
||||
|
||||
let mut file_bytes = Vec::new();
|
||||
form.file.file.read_to_end(&mut file_bytes)
|
||||
.map_err(|err| {
|
||||
error!("Problem reading uploaded video file: {:?}", err);
|
||||
CommonWebError::server_error_with_message("problem reading uploaded video file")
|
||||
})?;
|
||||
|
||||
if file_bytes.is_empty() {
|
||||
return Err(CommonWebError::BadInputWithSimpleMessage("empty video file".to_string()));
|
||||
}
|
||||
|
||||
let mime_type = get_mimetype_for_bytes(&file_bytes)
|
||||
.map(|mime_type| mime_type.to_string())
|
||||
.ok_or_else(|| {
|
||||
warn!("Could not determine mimetype for uploaded video ({} bytes)", file_bytes.len());
|
||||
CommonWebError::BadInputWithSimpleMessage("could not determine video mimetype".to_string())
|
||||
})?;
|
||||
|
||||
let media_file_type = MediaFileType::try_from_mime_type(&mime_type)
|
||||
.ok_or_else(|| {
|
||||
warn!("Unsupported media file type: {}", mime_type);
|
||||
CommonWebError::BadInputWithSimpleMessage("unsupported media file type".to_string())
|
||||
})?;
|
||||
|
||||
let extension_with_period = mimetype_to_extension(&mime_type)
|
||||
.map(|extension| format!(".{extension}"))
|
||||
.ok_or_else(|| {
|
||||
warn!("Could not determine file extension for mimetype: {}", mime_type);
|
||||
CommonWebError::server_error_with_message("could not determine file extension")
|
||||
})?;
|
||||
|
||||
let file_size_bytes = file_bytes.len();
|
||||
let file_hash = sha256_hash_bytes(&file_bytes)
|
||||
.map_err(|err| {
|
||||
error!("Failed to hash video bytes: {:?}", err);
|
||||
CommonWebError::from_anyhow_error(err)
|
||||
})?;
|
||||
|
||||
// ==================== VIDEO METADATA ==================== //
|
||||
|
||||
// Prefer worker-supplied metadata; probe the file for anything missing.
|
||||
let mut maybe_duration_millis = form.duration_millis.map(|text| text.0);
|
||||
let mut maybe_frame_width = form.width.map(|text| text.0);
|
||||
let mut maybe_frame_height = form.height.map(|text| text.0);
|
||||
|
||||
let needs_probe = maybe_duration_millis.is_none()
|
||||
|| maybe_frame_width.is_none()
|
||||
|| maybe_frame_height.is_none();
|
||||
|
||||
if needs_probe {
|
||||
if let Ok(video_info) = ffprobe_get_info(&form.file.file.path()) {
|
||||
if maybe_duration_millis.is_none() {
|
||||
maybe_duration_millis = video_info.duration
|
||||
.as_ref()
|
||||
.map(|duration| duration.millis as u64);
|
||||
}
|
||||
if maybe_frame_width.is_none() {
|
||||
maybe_frame_width = video_info.dimensions
|
||||
.as_ref()
|
||||
.map(|dims| dims.width as u32);
|
||||
}
|
||||
if maybe_frame_height.is_none() {
|
||||
maybe_frame_height = video_info.dimensions
|
||||
.as_ref()
|
||||
.map(|dims| dims.height as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== UPLOAD + MEDIA FILE RECORD ==================== //
|
||||
|
||||
let public_upload_path = MediaFileBucketPath::generate_new(BUCKET_PATH_PREFIX, Some(&extension_with_period));
|
||||
|
||||
info!("Uploading media to bucket path: {}", public_upload_path.get_full_object_path_str());
|
||||
|
||||
server_state.public_bucket_client.upload_file_with_content_type_process(
|
||||
public_upload_path.get_full_object_path_str(),
|
||||
file_bytes.as_ref(),
|
||||
&mime_type)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
warn!("Failed to upload video to bucket: {:?}", err);
|
||||
CommonWebError::from_anyhow_error(err)
|
||||
})?;
|
||||
|
||||
// ==================== MEDIA FILE RECORD + MARK JOB DONE ==================== //
|
||||
|
||||
// Tight critical section: one connection for both post-upload writes, no
|
||||
// network calls in between.
|
||||
let media_token = {
|
||||
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
|
||||
|
||||
let media_token = insert_media_file_record(
|
||||
&mut mysql_connection,
|
||||
&job,
|
||||
&file_hash,
|
||||
file_size_bytes,
|
||||
maybe_duration_millis,
|
||||
maybe_frame_width,
|
||||
maybe_frame_height,
|
||||
media_file_type,
|
||||
&mime_type,
|
||||
&public_upload_path,
|
||||
).await?;
|
||||
|
||||
info!("Minimax video media file uploaded with token: {}", media_token);
|
||||
|
||||
mark_first_party_minimax_h3_job_succeeded(MarkFirstPartyMinimaxH3JobSucceededArgs {
|
||||
job_token: &job.job_token,
|
||||
media_file_token: &media_token,
|
||||
maybe_execution_duration_millis: form.execution_duration_millis.map(|text| text.0),
|
||||
maybe_inference_duration_millis: form.inference_duration_millis.map(|text| text.0),
|
||||
mysql_executor: &mut *mysql_connection,
|
||||
phantom: Default::default(),
|
||||
}).await.map_err(|err| {
|
||||
error!("Error marking minimax job {} succeeded: {:?}", job_token, err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
media_token
|
||||
};
|
||||
|
||||
// ==================== THUMBNAILS ==================== //
|
||||
|
||||
let thumbnail_task_result =
|
||||
ThumbnailTaskBuilder::new_for_source_mimetype(ThumbnailTaskInputMimeType::MP4)
|
||||
.with_bucket(server_state.public_bucket_client.bucket_name().as_str())
|
||||
.with_path(&*path_to_string(public_upload_path.to_full_object_pathbuf()))
|
||||
.with_output_suffix("thumb")
|
||||
.with_event_id(&media_token.to_string())
|
||||
.send_all()
|
||||
.await;
|
||||
|
||||
if let Err(err) = thumbnail_task_result {
|
||||
// Fail open
|
||||
error!("Failed to create some/all thumbnail tasks: {:?}", err);
|
||||
}
|
||||
|
||||
info!("Minimax job {} marked as complete_success with media file {}", job.job_token, media_token);
|
||||
|
||||
Ok(Json(MarkMinimaxJobSuccessResponse {
|
||||
success: true,
|
||||
media_file_token: media_token,
|
||||
maybe_duration_millis,
|
||||
maybe_width: maybe_frame_width,
|
||||
maybe_height: maybe_frame_height,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn insert_media_file_record(
|
||||
mysql_connection: &mut PoolConnection<MySql>,
|
||||
job: &FirstPartyMinimaxH3JobDetails,
|
||||
file_hash: &str,
|
||||
file_size_bytes: usize,
|
||||
maybe_duration_millis: Option<u64>,
|
||||
maybe_frame_width: Option<u32>,
|
||||
maybe_frame_height: Option<u32>,
|
||||
media_file_type: MediaFileType,
|
||||
mime_type: &str,
|
||||
public_upload_path: &MediaFileBucketPath,
|
||||
) -> Result<MediaFileToken, CommonWebError> {
|
||||
MediaFileInsertBuilder::new()
|
||||
.checksum_sha2(file_hash)
|
||||
.creator_ip_address(&job.creator_ip_address)
|
||||
.file_size_bytes(file_size_bytes as u64)
|
||||
.maybe_creator_anonymous_visitor(job.maybe_creator_anonymous_visitor_token.as_ref())
|
||||
.maybe_creator_user(job.maybe_creator_user_token.as_ref())
|
||||
.maybe_duration_millis(maybe_duration_millis)
|
||||
.maybe_frame_height(maybe_frame_height)
|
||||
.maybe_frame_width(maybe_frame_width)
|
||||
.maybe_generation_provider(Some(GenerationProvider::Artcraft))
|
||||
.maybe_prompt_token(job.maybe_prompt_token.as_ref())
|
||||
.maybe_source_job_token(Some(&job.job_token))
|
||||
.maybe_platform_type(job.maybe_platform_type)
|
||||
.media_file_class(MediaFileClass::Video)
|
||||
.media_file_origin_category(MediaFileOriginCategory::Inference)
|
||||
.media_file_type(media_file_type)
|
||||
.mime_type(mime_type)
|
||||
.public_bucket_directory_hash(public_upload_path)
|
||||
.insert_connection(mysql_connection)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
warn!("Failed to insert video media file record: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod mark_minimax_job_failure_handler;
|
||||
pub mod mark_minimax_job_success_handler;
|
||||
pub mod obtain_minimax_job_handler;
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::web::Json;
|
||||
use actix_web::{web, HttpRequest};
|
||||
use log::{error, info, warn};
|
||||
use sqlx::pool::PoolConnection;
|
||||
use sqlx::{Acquire, MySql};
|
||||
|
||||
use artcraft_api_defs::internal::minimax_jobs::minimax_worker_model::MinimaxWorkerModel;
|
||||
use artcraft_api_defs::internal::minimax_jobs::obtain_minimax_job::{
|
||||
MinimaxJobMediaReference, MinimaxJobPromptDetails, ObtainMinimaxJobRequest, ObtainMinimaxJobResponse,
|
||||
ObtainedMinimaxJob,
|
||||
};
|
||||
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
|
||||
use mysql_queries::queries::generic_inference::first_party::minimax_h3::first_party_minimax_h3_model::FirstPartyMinimaxH3Model;
|
||||
use mysql_queries::queries::generic_inference::first_party::minimax_h3::mark_first_party_minimax_h3_job_started::{
|
||||
mark_first_party_minimax_h3_job_started, MarkFirstPartyMinimaxH3JobStartedArgs,
|
||||
};
|
||||
use mysql_queries::queries::generic_inference::first_party::minimax_h3::select_pending_first_party_minimax_h3_job_for_update::{
|
||||
select_pending_first_party_minimax_h3_job_for_update, PendingFirstPartyMinimaxH3Job,
|
||||
SelectPendingFirstPartyMinimaxH3JobForUpdateArgs,
|
||||
};
|
||||
use mysql_queries::queries::prompt_context_items::list_prompt_context_items::list_prompt_context_items;
|
||||
use mysql_queries::queries::prompts::get_prompt::get_prompt_from_connection;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::common_responses::media::media_links_builder::MediaLinksBuilder;
|
||||
use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain;
|
||||
use crate::http_server::web_utils::require_internal_api_key::require_internal_api_key;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
/// Internal (worker-facing): obtain a lock on a pending first-party Minimax
|
||||
/// job by atomically moving it from `pending` to `started`. The response
|
||||
/// carries everything the worker needs to run inference: the text prompt,
|
||||
/// generation settings, and full CDN links to every media reference.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
tag = "Internal (Minimax Jobs)",
|
||||
path = "/v1/internal/minimax_jobs/obtain_job",
|
||||
request_body = ObtainMinimaxJobRequest,
|
||||
responses(
|
||||
(status = 200, description = "Success (maybe_job is null when no pending job exists)", body = ObtainMinimaxJobResponse),
|
||||
(status = 401, description = "Missing or invalid internal API key"),
|
||||
(status = 500, description = "Server error"),
|
||||
),
|
||||
)]
|
||||
pub async fn obtain_minimax_job_handler(
|
||||
http_request: HttpRequest,
|
||||
request: Json<ObtainMinimaxJobRequest>,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<ObtainMinimaxJobResponse>, CommonWebError> {
|
||||
|
||||
require_internal_api_key(&http_request, &server_state)?;
|
||||
|
||||
let minimax_model = match request.model {
|
||||
MinimaxWorkerModel::MinimaxH3Turbo => FirstPartyMinimaxH3Model::Turbo,
|
||||
MinimaxWorkerModel::MinimaxH3Ultra => FirstPartyMinimaxH3Model::Ultra,
|
||||
};
|
||||
|
||||
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
|
||||
|
||||
// The select-then-mark pair runs in one transaction: `FOR UPDATE SKIP
|
||||
// LOCKED` guarantees two workers can never obtain the same job.
|
||||
let mut transaction = mysql_connection.begin().await.map_err(|err| {
|
||||
error!("Error starting MySQL transaction: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let maybe_pending_job = select_pending_first_party_minimax_h3_job_for_update(
|
||||
SelectPendingFirstPartyMinimaxH3JobForUpdateArgs {
|
||||
minimax_model,
|
||||
transaction: &mut transaction,
|
||||
}
|
||||
).await.map_err(|err| {
|
||||
error!("Error selecting pending minimax job: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let Some(pending_job) = maybe_pending_job else {
|
||||
return Ok(Json(ObtainMinimaxJobResponse {
|
||||
success: true,
|
||||
maybe_job: None,
|
||||
}));
|
||||
};
|
||||
|
||||
mark_first_party_minimax_h3_job_started(MarkFirstPartyMinimaxH3JobStartedArgs {
|
||||
job_token: &pending_job.job_token,
|
||||
maybe_worker_hostname: request.worker_hostname.as_deref(),
|
||||
maybe_cluster_name: request.cluster_name.as_deref(),
|
||||
transaction: &mut transaction,
|
||||
}).await.map_err(|err| {
|
||||
error!("Error marking minimax job {} started: {:?}", pending_job.job_token, err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
transaction.commit().await.map_err(|err| {
|
||||
error!("Error committing transaction: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
info!("Worker {:?} (cluster {:?}) obtained minimax job {}",
|
||||
request.worker_hostname, request.cluster_name, pending_job.job_token);
|
||||
|
||||
// Hydrate the prompt + media references the worker needs. Best-effort: the
|
||||
// job is already locked, so a hydration failure yields `maybe_prompt: None`
|
||||
// and the worker should report the job failed rather than us wedging it.
|
||||
let maybe_prompt = build_prompt_details(
|
||||
&http_request,
|
||||
&server_state,
|
||||
&pending_job,
|
||||
&mut mysql_connection,
|
||||
).await;
|
||||
|
||||
Ok(Json(ObtainMinimaxJobResponse {
|
||||
success: true,
|
||||
maybe_job: Some(ObtainedMinimaxJob {
|
||||
job_token: pending_job.job_token,
|
||||
model: request.model,
|
||||
maybe_prompt,
|
||||
}),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Fetch the prompt record and its media references, resolving each media
|
||||
/// file to full CDN links.
|
||||
async fn build_prompt_details(
|
||||
http_request: &HttpRequest,
|
||||
server_state: &ServerState,
|
||||
pending_job: &PendingFirstPartyMinimaxH3Job,
|
||||
mysql_connection: &mut PoolConnection<MySql>,
|
||||
) -> Option<MinimaxJobPromptDetails> {
|
||||
let Some(prompt_token) = pending_job.maybe_prompt_token.as_ref() else {
|
||||
warn!("Minimax job {} has no prompt token", pending_job.job_token);
|
||||
return None;
|
||||
};
|
||||
|
||||
let prompt = match get_prompt_from_connection(prompt_token, mysql_connection).await {
|
||||
Ok(Some(prompt)) => prompt,
|
||||
Ok(None) => {
|
||||
warn!("Prompt {} for minimax job {} not found", prompt_token, pending_job.job_token);
|
||||
return None;
|
||||
}
|
||||
Err(err) => {
|
||||
error!("Error fetching prompt {} for minimax job {}: {:?}",
|
||||
prompt_token, pending_job.job_token, err);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let context_items = match list_prompt_context_items(prompt_token, mysql_connection).await {
|
||||
Ok(items) => items,
|
||||
Err(err) => {
|
||||
// Don't hand the worker a prompt with silently-missing references.
|
||||
error!("Error listing prompt context items for {} (minimax job {}): {:?}",
|
||||
prompt_token, pending_job.job_token, err);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let media_domain = get_media_domain(http_request);
|
||||
|
||||
let media_references = context_items
|
||||
.into_iter()
|
||||
.map(|item| {
|
||||
let bucket_path = MediaFileBucketPath::from_object_hash(
|
||||
&item.public_bucket_directory_hash,
|
||||
item.maybe_public_bucket_prefix.as_deref(),
|
||||
item.maybe_public_bucket_extension.as_deref());
|
||||
|
||||
let media_links = MediaLinksBuilder::from_media_path_and_env(
|
||||
media_domain,
|
||||
server_state.server_environment,
|
||||
&bucket_path);
|
||||
|
||||
MinimaxJobMediaReference {
|
||||
media_file_token: item.media_token,
|
||||
semantic_type: item.context_semantic_type,
|
||||
media_links,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Some(MinimaxJobPromptDetails {
|
||||
prompt_token: prompt.token,
|
||||
maybe_positive_prompt: prompt.maybe_positive_prompt,
|
||||
maybe_negative_prompt: prompt.maybe_negative_prompt,
|
||||
maybe_generation_mode: prompt.maybe_generation_mode,
|
||||
maybe_aspect_ratio: prompt.maybe_aspect_ratio,
|
||||
maybe_resolution: prompt.maybe_resolution,
|
||||
maybe_bitrate: prompt.maybe_bitrate,
|
||||
maybe_generate_audio: prompt.maybe_generate_audio,
|
||||
maybe_duration_seconds: prompt.maybe_duration_seconds,
|
||||
media_references,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
//! Internal-facing endpoints for our own worker fleets (GPU inference).
|
||||
//! Authenticated with internal API keys (`INTERNAL_API_KEYS`),
|
||||
//! never consumer credentials.
|
||||
|
||||
pub mod minimax_jobs;
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
use std::marker::PhantomData;
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::web::{Json, Path};
|
||||
use actix_web::{web, HttpRequest};
|
||||
use log::{error, warn};
|
||||
|
||||
use artcraft_api_defs::media_file::job::list_media_files_by_job::{
|
||||
JobMediaFileInfo, ListMediaFilesByJobSuccessResponse,
|
||||
};
|
||||
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
|
||||
use mysql_queries::queries::generic_inference::job::list_media_files_by_source_job::{
|
||||
list_media_files_by_source_job, ListMediaFilesBySourceJobArgs,
|
||||
};
|
||||
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::common_responses::media::media_file_cover_image_details_builder::MediaFileCoverImageDetailsBuilder;
|
||||
use crate::http_server::common_responses::media::media_links_builder::MediaLinksBuilder;
|
||||
use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain;
|
||||
use crate::http_server::user_lookup::api_or_web_session::require_api_or_web_session::require_api_or_web_session;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct ListMediaFilesByJobPathInfo {
|
||||
job_token: InferenceJobToken,
|
||||
}
|
||||
|
||||
/// List the media files produced by an inference job.
|
||||
/// Authenticates as either a web-session (cookie) user or an API-key (`Authorization` header)
|
||||
/// user, and only returns files from the caller's own jobs.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
tag = "Media Files",
|
||||
path = "/v1/media_files/by_job/{job_token}",
|
||||
responses(
|
||||
(status = 200, description = "Success", body = ListMediaFilesByJobSuccessResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 500, description = "Server error"),
|
||||
),
|
||||
params(
|
||||
("path" = ListMediaFilesByJobPathInfo, description = "Path for Request")
|
||||
)
|
||||
)]
|
||||
pub async fn list_media_files_by_job_handler(
|
||||
http_request: HttpRequest,
|
||||
path: Path<ListMediaFilesByJobPathInfo>,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<ListMediaFilesByJobSuccessResponse>, CommonWebError> {
|
||||
let job_token = &path.job_token;
|
||||
|
||||
let mut mysql_connection = server_state.mysql_pool
|
||||
.acquire()
|
||||
.await
|
||||
.map_err(|err| {
|
||||
error!("MySql pool error: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let session = require_api_or_web_session(
|
||||
&http_request,
|
||||
&server_state.session_checker,
|
||||
&server_state.avt_cookie_manager,
|
||||
&mut *mysql_connection,
|
||||
).await?;
|
||||
|
||||
// NB: Jobs another user owns (or unknown job tokens) simply return an empty
|
||||
// list — the query filters on the session user's ownership.
|
||||
let records = list_media_files_by_source_job(ListMediaFilesBySourceJobArgs {
|
||||
job_token,
|
||||
creator_user_token: &session.user_token,
|
||||
mysql_executor: &mut *mysql_connection,
|
||||
phantom: PhantomData,
|
||||
})
|
||||
.await
|
||||
.map_err(|err| {
|
||||
warn!("List media files by job {:?} query error: {:?}", job_token, err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let media_domain = get_media_domain(&http_request);
|
||||
|
||||
let media_files = records.into_iter()
|
||||
.map(|record| {
|
||||
let public_bucket_path = MediaFileBucketPath::from_object_hash(
|
||||
&record.public_bucket_directory_hash,
|
||||
record.maybe_public_bucket_prefix.as_deref(),
|
||||
record.maybe_public_bucket_extension.as_deref(),
|
||||
);
|
||||
JobMediaFileInfo {
|
||||
media_links: MediaLinksBuilder::from_media_path_and_env(
|
||||
media_domain,
|
||||
server_state.server_environment,
|
||||
&public_bucket_path,
|
||||
),
|
||||
cover_image: MediaFileCoverImageDetailsBuilder::from_optional_db_fields(
|
||||
&record.token,
|
||||
media_domain,
|
||||
server_state.server_environment,
|
||||
record.maybe_file_cover_image_public_bucket_hash.as_deref(),
|
||||
record.maybe_file_cover_image_public_bucket_prefix.as_deref(),
|
||||
record.maybe_file_cover_image_public_bucket_extension.as_deref(),
|
||||
),
|
||||
token: record.token,
|
||||
media_class: record.media_class,
|
||||
media_type: record.media_type,
|
||||
maybe_batch_token: record.maybe_batch_token,
|
||||
maybe_prompt_token: record.maybe_prompt_token,
|
||||
maybe_original_filename: record.maybe_origin_filename,
|
||||
maybe_duration_millis: record.maybe_duration_millis,
|
||||
created_at: record.created_at,
|
||||
updated_at: record.updated_at,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Ok(Json(ListMediaFilesByJobSuccessResponse {
|
||||
success: true,
|
||||
media_files,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod list_media_files_by_job_handler;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user