Compare commits

..
Author SHA1 Message Date
Hanashi 2cfcb6131d default launch scripts to dev server 2026-08-08 19:16:32 -04:00
Hanashi 480c073c54 devex: easier to target backend in dev 2026-08-08 16:40:02 -04:00
hanashi 9646a791aa minimax feature (#1865) 2026-08-08 16:34:49 -04:00
BFlat 4d839d3b8b Library/video promptbox: bulk "Add tags" action + default video mode to Seedance 2.5 (#1864)
- Select multiple items in the library and add tags to all of them at
once
- Tag suggestions pop up as you type, based on your existing tags
- Fixed the selection bar looking squished (buttons no longer wrap onto
two lines)
- Video generation now defaults to Seedance 2.5 (was 2.0)
- Omni Reference is the default video input mode for everyone
2026-08-08 15:41:13 +07:00
hanashi b7f354face fix seedance 2.5 estimates (#1863) 2026-08-08 00:46:06 -04:00
hanashi c888a494b9 seedance 2.5 bindings (#1862) 2026-08-07 23:41:44 -04:00
Tanish Manku a048458956 3D: Animation clip blending (#1861) 2026-08-07 18:48:54 -07:00
BFlat d598896e40 Model picker: add scroll fade + chevron indicators to submenu flyout (#1860)
- Bring the main panel's top/bottom gradient fades and bouncing scroll
hint arrows over to the family flyout list.
2026-08-07 23:48:20 +07:00
BFlat af9dfa556f Model picker: grouped submenus, color icons, capability tags (#1859)
- Model pickers on the create pages now group models by family
(Seedance, Veo, Kling, etc.) with a flyout submenu per family, so the
list is no longer one long scroll
- The flyout opens on hover or click, and automatically scrolls the
currently selected model into view
- New setting in **Settings → General** ("Group models by family") to
switch back to the classic flat list. Grouped is the default and works
in both the webapp and the desktop app
- Model rows now use full-color brand icons, while the picker button
keeps its existing look
- Video models show capability tags: **Audio Support**, **Start/End**,
and **Reference**
- Short model lists (under 8 models) stay flat, and mobile keeps the
existing bottom-sheet list
- Fix: videos in the media viewer can now go fullscreen (the expand
button was disabled)
2026-08-07 23:36:59 +07:00
Tanish Manku ea0de174c9 3D: Fix CI/CD deploy (worker vite build issue) (#1858) 2026-08-07 08:47:10 -07:00
Tanish MankuandBFlat a1435df07d 3D: Animations support + UI (#1856)
Co-authored-by: BFlat <bombayapirat@gmail.com>
2026-08-06 22:48:35 -07:00
hanashi a7a32a3862 minimax worker endpoints (#1855) 2026-08-07 01:24:20 -04:00
BFlat bb10c993a4 Hotfix: Minimax landing scroll issue 2026-08-07 10:48:53 +07:00
BFlat c6474edd5c Website: MiniMax H3 marketing page (#1854)
## Summary

New marketing page at /minimax-h3 where visitors can try MiniMax H3 for
free,
right on the page.

- Hero is a working promptbox, locked to MiniMax H3 with a "Free" badge
- Full settings available: aspect ratio, resolution, duration, sound,
start/end frames
- New users sign up in a modal when they hit Generate (Google or email),
then their prompt runs immediately without leaving the page
- Generated videos show up right below the promptbox, with a queue
notice and a CTA to keep creating in the app while they wait
- Example videos from MiniMax's announcement, self-hosted with
thumbnails, only load when played, credited to MiniMax
- Overview, highlights, prompt tips, and FAQ copy making the free offer
clear Website promptbox updated to match the current webapp promptbox
(reference deck, drag & drop, focus mode, same buttons)
- SEO, redirects, and URL aliases (/minimax, /hailuo) wired up
2026-08-07 10:41:07 +07:00
hanashi 9020f0aa5b minimax turbo (#1852) 2026-08-06 18:21:28 -04:00
BFlat c2e3c27a72 Model list: names, icons, descriptions for new omni gen models (MiniM… (#1851)
…ax H3, Flux 3, Veo 3.1 Lite, Vidu Q3, 3D/world)
2026-08-06 23:32:37 +07:00
BFlat 22847961e1 Admin dashboard: bulk ban (#1850) 2026-08-06 22:58:37 +07:00
BFlat f72981875c Website: beta sign up remove scripts (#1849) 2026-08-06 13:07:13 +07:00
BFlat 39e89c76e8 Website: beta sign up page (#1848) 2026-08-06 12:43:47 +07:00
hanashi a473be78bd new endpoint for media files by job token (#1846) 2026-08-04 23:13:30 -04:00
hanashi 36cfba0000 media files - record job token (#1845) 2026-08-04 22:09:08 -04:00
hanashi a5f59d7995 Flux3 (#1844) 2026-08-04 15:12:32 -04:00
291 changed files with 15304 additions and 2836 deletions
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nUPDATE generic_inference_jobs\nSET\n status = ?,\n on_success_result_entity_type = ?,\n on_success_result_entity_token = ?,\n failure_reason = NULL,\n internal_debugging_failure_reason = NULL,\n success_execution_millis = ?,\n success_inference_execution_millis = ?,\n retry_at = NULL,\n successfully_completed_at = NOW()\nWHERE token = ?\nAND status NOT IN ('complete_success', 'complete_failure')\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 6
},
"nullable": []
},
"hash": "03919ba8286d18980ecaa0038df76f43f5388db0d3838181fa8f15f4cbb932af"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nUPDATE generic_inference_jobs\nSET\n status = ?,\n assigned_worker = ?,\n last_assigned_worker = ?,\n assigned_cluster = ?,\n first_started_at = NOW()\nWHERE token = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 5
},
"nullable": []
},
"hash": "258ac40d3fab39f110e1e82d50e73542423bfe9a21f8127e6cd83214c1726e25"
}
@@ -0,0 +1,36 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n token as `job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,\n maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`\nFROM generic_inference_jobs\nWHERE job_type = ?\nAND status = 'pending'\nORDER BY id ASC\nLIMIT 1\nFOR UPDATE SKIP LOCKED\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "maybe_prompt_token: tokens::tokens::prompts::PromptToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
true
]
},
"hash": "6ca0fcdf495624cf347f612586868f6dea93f52d9f942a92dcd2b3e6aba7c63c"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nUPDATE generic_inference_jobs\nSET\n status = ?,\n frontend_failure_category = ?,\n failure_reason = ?,\n internal_debugging_failure_reason = ?,\n success_execution_millis = ?,\n success_inference_execution_millis = ?\nWHERE token = ?\nAND status NOT IN ('complete_success', 'complete_failure')\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 7
},
"nullable": []
},
"hash": "96599b4854248d84188b092db84edb50dc5402bb7771fd8ea96a96ad43e732c0"
}
@@ -0,0 +1,179 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n m.token as `token: tokens::tokens::media_files::MediaFileToken`,\n\n m.media_class as `media_class: enums::by_table::media_files::media_file_class::MediaFileClass`,\n m.media_type as `media_type: enums::by_table::media_files::media_file_type::MediaFileType`,\n\n m.maybe_batch_token as `maybe_batch_token: tokens::tokens::batch_generations::BatchGenerationToken`,\n m.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,\n\n m.public_bucket_directory_hash,\n m.maybe_public_bucket_prefix,\n m.maybe_public_bucket_extension,\n\n media_file_cover_image.public_bucket_directory_hash as maybe_file_cover_image_public_bucket_hash,\n media_file_cover_image.maybe_public_bucket_prefix as maybe_file_cover_image_public_bucket_prefix,\n media_file_cover_image.maybe_public_bucket_extension as maybe_file_cover_image_public_bucket_extension,\n\n m.maybe_origin_filename,\n m.maybe_duration_millis as `maybe_duration_millis: u64`,\n\n m.created_at,\n m.updated_at\n\nFROM media_files AS m\nLEFT OUTER JOIN media_files as media_file_cover_image\n ON media_file_cover_image.token = m.maybe_cover_image_media_file_token\nWHERE\n m.maybe_source_job_token = ?\n AND m.maybe_creator_user_token = ?\n AND m.user_deleted_at IS NULL\n AND m.mod_deleted_at IS NULL\n AND NOT m.is_intermediate_system_file\nORDER BY m.id ASC\nLIMIT ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token: tokens::tokens::media_files::MediaFileToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "media_class: enums::by_table::media_files::media_file_class::MediaFileClass",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 2,
"name": "media_type: enums::by_table::media_files::media_file_type::MediaFileType",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 3,
"name": "maybe_batch_token: tokens::tokens::batch_generations::BatchGenerationToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 4,
"name": "maybe_prompt_token: tokens::tokens::prompts::PromptToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 5,
"name": "public_bucket_directory_hash",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 6,
"name": "maybe_public_bucket_prefix",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 7,
"name": "maybe_public_bucket_extension",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 8,
"name": "maybe_file_cover_image_public_bucket_hash",
"type_info": {
"type": "VarString",
"flags": "BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 9,
"name": "maybe_file_cover_image_public_bucket_prefix",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 10,
"name": "maybe_file_cover_image_public_bucket_extension",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 11,
"name": "maybe_origin_filename",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 12,
"name": "maybe_duration_millis: u64",
"type_info": {
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 10
}
},
{
"ordinal": 13,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 14,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP | ON_UPDATE_NOW",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 3
},
"nullable": [
false,
false,
false,
true,
true,
false,
true,
true,
true,
true,
true,
true,
true,
false,
false
]
},
"hash": "cd04f4f7b48ae9da53b6a210111f8e3edff94dee30263e22b45d5e29dbe9245a"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\n INSERT INTO media_files\n SET\n token = ?,\n\n media_class = ?,\n media_type = ?,\n\n is_user_upload = ?,\n is_intermediate_system_file = ?,\n\n origin_category = ?,\n origin_product_category = ?,\n maybe_origin_model_type = ?,\n maybe_origin_model_token = ?,\n maybe_origin_filename = ?,\n\n maybe_batch_token = ?,\n maybe_source_job_token = ?,\n\n maybe_mime_type = ?,\n file_size_bytes = ?,\n maybe_duration_millis = ?,\n maybe_audio_encoding = ?,\n maybe_video_encoding = ?,\n maybe_frame_width = ?,\n maybe_frame_height = ?,\n maybe_prompt_token = ?,\n checksum_sha2 = ?,\n\n maybe_engine_category = ?,\n\n maybe_title = ?,\n maybe_text_transcript = ?,\n\n maybe_scene_source_media_file_token = ?,\n\n public_bucket_directory_hash = ?,\n maybe_public_bucket_prefix = ?,\n maybe_public_bucket_extension = ?,\n\n maybe_creator_user_token = ?,\n maybe_creator_anonymous_visitor_token = ?,\n\n creator_ip_address = ?,\n creator_set_visibility = ?,\n\n maybe_creator_file_synthetic_id = ?,\n maybe_creator_category_synthetic_id = ?,\n\n extra_file_modification_info = ?,\n\n maybe_generation_provider = ?,\n\n platform_type = ?,\n\n maybe_cover_image_media_file_token = ?,\n\n maybe_mod_user_token = ?,\n is_generated_on_prem = ?,\n generated_by_worker = ?,\n generated_by_cluster = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 42
},
"nullable": []
},
"hash": "d88098343686ded52a71fc45a1b96d28ea3003975d6e7d20eee2022aa31d8189"
}
@@ -0,0 +1,124 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n token as `job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,\n status as `status: enums::common::job_status_plus::JobStatusPlus`,\n job_type as `job_type: enums::by_table::generic_inference_jobs::inference_job_type::InferenceJobType`,\n maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n maybe_creator_anonymous_visitor_token as `maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken`,\n creator_ip_address,\n maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,\n maybe_debug_log_event_token as `maybe_debug_log_event_token: tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken`,\n platform_type as `maybe_platform_type: enums::common::platform_type::PlatformType`,\n on_success_result_entity_token as `maybe_result_media_file_token: tokens::tokens::media_files::MediaFileToken`\nFROM generic_inference_jobs\nWHERE token = ?\nAND job_type IN ('artcraft_minimax_h3_turbo', 'artcraft_minimax_h3_ultra')\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "status: enums::common::job_status_plus::JobStatusPlus",
"type_info": {
"type": "String",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | ENUM",
"char_set": 224,
"max_size": 76
}
},
{
"ordinal": 2,
"name": "job_type: enums::by_table::generic_inference_jobs::inference_job_type::InferenceJobType",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 3,
"name": "maybe_creator_user_token: tokens::tokens::users::UserToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 4,
"name": "maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 5,
"name": "creator_ip_address",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 160
}
},
{
"ordinal": 6,
"name": "maybe_prompt_token: tokens::tokens::prompts::PromptToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 7,
"name": "maybe_debug_log_event_token: tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 8,
"name": "maybe_platform_type: enums::common::platform_type::PlatformType",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 9,
"name": "maybe_result_media_file_token: tokens::tokens::media_files::MediaFileToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
true,
true,
true,
false,
true,
true,
true,
true
]
},
"hash": "f1d41ad17cb01f5aef0ce2eea042ccb3e5e649365a0befaf0f46492b75523442"
}
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\n INSERT INTO media_files\n SET\n token = ?,\n\n media_class = ?,\n media_type = ?,\n\n is_user_upload = ?,\n is_intermediate_system_file = ?,\n\n origin_category = ?,\n origin_product_category = ?,\n maybe_origin_model_type = ?,\n maybe_origin_model_token = ?,\n maybe_origin_filename = ?,\n\n maybe_batch_token = ?,\n\n maybe_mime_type = ?,\n file_size_bytes = ?,\n maybe_duration_millis = ?,\n maybe_audio_encoding = ?,\n maybe_video_encoding = ?,\n maybe_frame_width = ?,\n maybe_frame_height = ?,\n maybe_prompt_token = ?,\n checksum_sha2 = ?,\n\n maybe_engine_category = ?,\n\n maybe_title = ?,\n maybe_text_transcript = ?,\n\n maybe_scene_source_media_file_token = ?,\n\n public_bucket_directory_hash = ?,\n maybe_public_bucket_prefix = ?,\n maybe_public_bucket_extension = ?,\n\n maybe_creator_user_token = ?,\n maybe_creator_anonymous_visitor_token = ?,\n\n creator_ip_address = ?,\n creator_set_visibility = ?,\n\n maybe_creator_file_synthetic_id = ?,\n maybe_creator_category_synthetic_id = ?,\n\n extra_file_modification_info = ?,\n\n maybe_generation_provider = ?,\n\n platform_type = ?,\n\n maybe_cover_image_media_file_token = ?,\n\n maybe_mod_user_token = ?,\n is_generated_on_prem = ?,\n generated_by_worker = ?,\n generated_by_cluster = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 41
},
"nullable": []
},
"hash": "f5bc4a956a20f6c0998585092a0922301d53c95ae8ea9cb808ae2dbcf8d0bb32"
}
@@ -0,0 +1,7 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE media_files
DROP INDEX index_maybe_source_job_token,
DROP COLUMN maybe_source_job_token;
@@ -0,0 +1,13 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
-- The inference job ('jinf_...') whose completion produced this media file.
-- Only set for files written as job outputs; uploads and other origins stay NULL.
ALTER TABLE media_files
ADD COLUMN maybe_source_job_token VARCHAR(32) DEFAULT NULL;
-- For "outputs of this job"-style lookups.
-- The column is all-NULL at creation time, so the index build is cheap.
ALTER TABLE media_files
ADD INDEX index_maybe_source_job_token (maybe_source_job_token);
@@ -102,6 +102,10 @@ CREATE TABLE media_files (
-- If the media file is generated as part of a batch, this designates the batch.
maybe_batch_token VARCHAR(32) DEFAULT NULL,
-- The inference job ('jinf_...') whose completion produced this media file.
-- Only set for files written as job outputs; uploads and other origins stay NULL.
maybe_source_job_token VARCHAR(32) DEFAULT NULL,
-- ========== METADATA ==========
-- If the file is a user upload (not including studio renders).
@@ -327,6 +331,7 @@ CREATE TABLE media_files (
KEY fk_maybe_origin_model_token (maybe_origin_model_token),
KEY fk_maybe_origin_model_type_and_token (maybe_origin_model_type, maybe_origin_model_token),
KEY index_maybe_batch_token (maybe_batch_token),
KEY index_maybe_source_job_token (maybe_source_job_token),
KEY index_is_user_upload (is_user_upload),
KEY index_is_intermediate_system_file (is_intermediate_system_file),
KEY index_maybe_scene_source_media_file_token (maybe_scene_source_media_file_token),
+227
View File
@@ -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.
@@ -0,0 +1,29 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use enums::by_table::generic_inference_jobs::frontend_failure_category::FrontendFailureCategory;
/// Request body for `POST /v1/internal/minimax_jobs/job/{job_token}/failure`.
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct MarkMinimaxJobFailureRequest {
/// Optional failure class the frontend can localize and present.
pub maybe_frontend_failure_category: Option<FrontendFailureCategory>,
/// Optional failure reason we can show the user (512 characters max).
pub maybe_user_failure_reason: Option<String>,
/// Optional internal-only stack trace or error (512 characters max).
pub maybe_internal_debugging_failure_reason: Option<String>,
/// Optional total wall-clock runtime of the failed attempt, in milliseconds.
pub execution_duration_millis: Option<u64>,
/// Optional inference-only runtime of the failed attempt, in milliseconds.
pub inference_duration_millis: Option<u64>,
}
/// Response body for `POST /v1/internal/minimax_jobs/job/{job_token}/failure`.
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct MarkMinimaxJobFailureResponse {
pub success: bool,
}
@@ -0,0 +1,29 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use tokens::tokens::media_files::MediaFileToken;
// NB: The success request is form-multipart (video bytes + optional
// `duration_millis` / `width` / `height` metadata fields), so its form struct
// lives with the actix handler; only the response shape is declared here.
/// Response body for `POST /v1/internal/minimax_jobs/job/{job_token}/success`.
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct MarkMinimaxJobSuccessResponse {
pub success: bool,
/// The media file created from the uploaded video.
pub media_file_token: MediaFileToken,
/// Video duration stored on the media file record, in milliseconds
/// (worker-supplied, or probed from the file when not supplied).
pub maybe_duration_millis: Option<u64>,
/// Frame width stored on the media file record, in pixels
/// (worker-supplied, or probed from the file when not supplied).
pub maybe_width: Option<u32>,
/// Frame height stored on the media file record, in pixels
/// (worker-supplied, or probed from the file when not supplied).
pub maybe_height: Option<u32>,
}
@@ -0,0 +1,33 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
/// The first-party Minimax models a worker can request jobs for.
///
/// This is deliberately its own enum (not `CommonVideoModel`) so the internal
/// worker API surface stays small and stable.
#[derive(Copy, Clone, Debug, Eq, PartialEq, Serialize, Deserialize, ToSchema)]
pub enum MinimaxWorkerModel {
#[serde(rename = "minimax_h3_turbo")]
MinimaxH3Turbo,
#[serde(rename = "minimax_h3_ultra")]
MinimaxH3Ultra,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn serde_round_trip() {
let cases = [
(MinimaxWorkerModel::MinimaxH3Turbo, "\"minimax_h3_turbo\""),
(MinimaxWorkerModel::MinimaxH3Ultra, "\"minimax_h3_ultra\""),
];
for (variant, json) in cases {
assert_eq!(serde_json::to_string(&variant).unwrap(), json);
let parsed: MinimaxWorkerModel = serde_json::from_str(json).unwrap();
assert_eq!(parsed, variant);
}
}
}
@@ -0,0 +1,4 @@
pub mod mark_minimax_job_failure;
pub mod mark_minimax_job_success;
pub mod minimax_worker_model;
pub mod obtain_minimax_job;
@@ -0,0 +1,85 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use enums::by_table::prompt_context_items::prompt_context_semantic_type::PromptContextSemanticType;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_bitrate::CommonBitrate;
use enums::common::generation::common_generation_mode::CommonGenerationMode;
use enums::common::generation::common_resolution::CommonResolution;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::prompts::PromptToken;
use crate::common::responses::media_links::MediaLinks;
use crate::internal::minimax_jobs::minimax_worker_model::MinimaxWorkerModel;
/// Request body for `POST /v1/internal/minimax_jobs/obtain_job`.
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct ObtainMinimaxJobRequest {
/// Which model the worker wants a job for.
pub model: MinimaxWorkerModel,
/// The worker's hostname (linux hostname, k8s pod name), if reported.
pub worker_hostname: Option<String>,
/// The cluster the worker runs in (e.g. "runpod", "lambdalabs"), if reported.
pub cluster_name: Option<String>,
}
/// Response body for `POST /v1/internal/minimax_jobs/obtain_job`.
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct ObtainMinimaxJobResponse {
pub success: bool,
/// The obtained job, or `None` when no pending job is available.
pub maybe_job: Option<ObtainedMinimaxJob>,
}
/// A job the worker now holds a lock on (status was moved to `started`).
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct ObtainedMinimaxJob {
pub job_token: InferenceJobToken,
/// Echo of the requested model.
pub model: MinimaxWorkerModel,
/// The prompt and inputs the worker needs to run inference. Only `None`
/// when the job was somehow enqueued without a prompt record (or the
/// prompt lookup failed) — workers should fail such jobs via the failure
/// endpoint.
pub maybe_prompt: Option<MinimaxJobPromptDetails>,
}
/// The prompt (text + generation settings) and its attached media references.
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct MinimaxJobPromptDetails {
pub prompt_token: PromptToken,
pub maybe_positive_prompt: Option<String>,
pub maybe_negative_prompt: Option<String>,
pub maybe_generation_mode: Option<CommonGenerationMode>,
pub maybe_aspect_ratio: Option<CommonAspectRatio>,
pub maybe_resolution: Option<CommonResolution>,
pub maybe_bitrate: Option<CommonBitrate>,
pub maybe_generate_audio: Option<bool>,
pub maybe_duration_seconds: Option<u32>,
/// Image / video / audio references attached to the prompt, with full CDN
/// links, in their original order.
pub media_references: Vec<MinimaxJobMediaReference>,
}
/// One media input attached to the prompt (start frame, end frame, or an
/// image / video / audio reference), with full CDN links.
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct MinimaxJobMediaReference {
pub media_file_token: MediaFileToken,
/// The role the media plays (start frame, end frame, image / video / audio
/// reference, etc.).
pub semantic_type: PromptContextSemanticType,
/// Full CDN links to the media file.
pub media_links: MediaLinks,
}
@@ -0,0 +1,4 @@
//! Internal-facing API surfaces (worker fleets, etc.). These are
//! authenticated with internal API keys, never consumer credentials.
pub mod minimax_jobs;
@@ -5,6 +5,7 @@ pub mod common;
pub mod credits;
pub mod folders;
pub mod generate;
pub mod internal;
pub mod jobs;
pub mod media_file;
pub mod moderation;
@@ -0,0 +1,61 @@
//! Types for `GET /v1/media_files/by_job/{job_token}` — list the media files
//! produced by an inference job.
use chrono::{DateTime, Utc};
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_type::MediaFileType;
use tokens::tokens::batch_generations::BatchGenerationToken;
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::prompts::PromptToken;
use crate::common::responses::media_file_cover_image_details::MediaFileCoverImageDetails;
use crate::common::responses::media_links::MediaLinks;
#[derive(Serialize, Deserialize, ToSchema)]
pub struct ListMediaFilesByJobSuccessResponse {
pub success: bool,
/// The job's output files, oldest first.
pub media_files: Vec<JobMediaFileInfo>,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct JobMediaFileInfo {
pub token: MediaFileToken,
/// The coarse-grained class of media file: image, video, etc.
pub media_class: MediaFileClass,
/// Type of media will dictate which fields are populated and what
/// the frontend should display (eg. video player vs audio player).
/// This is closer in meaning to a "mime type".
pub media_type: MediaFileType,
/// If the file was generated as part of a batch, this is the token for the batch.
pub maybe_batch_token: Option<BatchGenerationToken>,
/// The foreign key to the prompt used to generate the media, if applicable.
pub maybe_prompt_token: Option<PromptToken>,
/// Rich CDN links to the media, including thumbnails, previews, and more.
pub media_links: MediaLinks,
/// Information about the cover image. Many media files do not require a cover image,
/// e.g. image files, video files with thumbnails, audio files, etc.
/// 3D files require them.
pub cover_image: MediaFileCoverImageDetails,
/// The original filename for uploaded files, if they were provided.
/// In the future we'll provide our own internal optional filenames.
pub maybe_original_filename: Option<String>,
/// Duration for audio and video files, if available.
/// Measured in milliseconds.
pub maybe_duration_millis: Option<u64>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
@@ -0,0 +1 @@
pub mod list_media_files_by_job;
@@ -1,4 +1,5 @@
pub mod delete_media_file;
pub mod job;
pub mod list;
pub mod list_batch_generated_media_files;
pub mod list_session_project_media_files;
@@ -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");
}
}
@@ -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)]
@@ -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(),
}
}
@@ -82,6 +82,7 @@ pub fn build_artcraft_omni_video_request(
negative_prompt: None,
generate_audio: None,
quality: None,
estimate_only: None,
})
}
@@ -63,6 +63,7 @@ mod tests {
duration_seconds: Some(5),
video_batch_count: Some(1),
generate_audio: None,
estimate_only: None,
},
};
@@ -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;
@@ -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"),
}
}
}
@@ -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()
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -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
}
}
@@ -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,
@@ -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 {
@@ -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;
@@ -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>,
@@ -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(
@@ -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(
@@ -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(
@@ -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"),
}
}
}
@@ -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()
}
}
@@ -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 })
}
}
@@ -0,0 +1,4 @@
pub mod build;
pub mod draft;
pub mod request;
pub mod cost;
@@ -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,
}))
}
}
@@ -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?;
}
@@ -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,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))
},
}
}
}
@@ -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()
}
}
@@ -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;
@@ -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
@@ -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),
}
}
@@ -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,
@@ -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,
}
}
}
@@ -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,
}))
}
@@ -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())
}
@@ -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,
}
}
@@ -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,
}
}
@@ -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(())
}
@@ -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;
@@ -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;
@@ -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;
@@ -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,
@@ -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,
@@ -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
}
@@ -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(),
@@ -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,
@@ -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,
@@ -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);
@@ -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);
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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
}
@@ -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;
@@ -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()
}
}
@@ -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,
@@ -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)
@@ -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");
}
}
@@ -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)
})
}
@@ -0,0 +1,3 @@
pub mod mark_minimax_job_failure_handler;
pub mod mark_minimax_job_success_handler;
pub mod obtain_minimax_job_handler;
@@ -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;
@@ -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