Compare commits

..
Author SHA1 Message Date
Tanish Manku b9ee643bf6 Stop the bootstrap scripts claiming the dev webapp targets the local backend
It never has: setDevelopment() is commented out in main.tsx, so the dev server
falls through to its /v1 proxy at api.storyteller.ai. Both bootstrap epilogues
and the seed script now say so and print the VITE_DEV_API_HOST needed to
actually reach the local backend.
2026-08-03 17:24:39 -07:00
Tanish Manku 07e9a35044 Add portable local MySQL/Redis bootstrap scripts for backend dev
Brings up a fully local storyteller-web: portable MySQL 8.4 and Redis under a
gitignored .devstack/ (plain user processes -- no admin, no services, no WSL,
no Docker), all 281 migrations, role/badge seeds, and a demo login with
credits. Windows and Linux variants, plus a doctor that reports what is and
isn't healthy.

Scripts and docs only. Nothing here is compiled into any binary, and no
application behaviour changes: the backend it starts is the one that ships.

Integrations that cannot be provided locally -- object storage, generation
providers, email -- get placeholder credentials, so those endpoints fail when
exercised. Swap in a real dev credential for the one you are working on. The
doc records exactly which areas that leaves working.

An earlier revision closed those gaps with development-only behaviour inside
storyteller-web (DEV_FAKE_GENERATION, a filesystem bucket mode, a CDN_BASE_URL
override). That is deliberately not here: a switch that exists in the shipped
binary can be flipped. Frontend work that wants a populated, working stack
should use frontend/apps/fake-storyteller-web instead.
2026-08-03 14:41:14 -07:00
1102 changed files with 16092 additions and 40617 deletions
-20
View File
@@ -1,25 +1,5 @@
# Testing
## Database Fixture Tests
- Live in-crate (e.g. `storyteller-web`'s omni_gen video pricing tests) and
use the `mysql_testing` crate (`crates/schema/database/mysql_testing`) for
guarded pools, schema setup, and account/session/wallet fixtures
- They RUN BY DEFAULT with `cargo test -p storyteller-web` and from the IDE
(requires a local MySQL with an `artcraft_test` database). Machines/CI
without MySQL skip them via the off switch:
`SQLX_OFFLINE=true cargo test -p storyteller-web --features skip_database_tests`
- They connect ONLY via `ARTCRAFT_TEST_DATABASE_URL` (default
`mysql://root:@localhost:3306/artcraft_test`); the guard panics on any
database whose name lacks "test", on `storyteller`/`artcraft`, and on
cloud hosts — see `mysql_testing/README.md`
- Database tests run in PARALLEL: each test creates its own users, wallets,
and rows; schema setup single-flights via a MySQL named lock. Only tests
mutating shared/global state take
`mysql_testing::serial::acquire_serial_test_lock().await`
- External providers must never be called: the harness stubs Kinovi with an
in-process HTTP server via `KINOVI_CUSTOM_API_HOST`/`KINOVI_CUSTOM_CDN_HOST`
## Running Tests
- Most crates: `cargo test -p {crate_name}`
+5
View File
@@ -1,3 +1,8 @@
# Shell scripts and dotenv config files are executed/parsed on Linux (WSL2, CI)
# even when the repo is cloned on Windows — they must stay LF in the worktree.
*.sh text eol=lf
*.env text eol=lf
# Ignore certain code for Github language statistics
# https://github.com/github/linguist/blob/master/docs/overrides.md
old_code/trumpet-trumped.com/dictionary/cmudict-0.7b linguist-vendored
+3
View File
@@ -104,3 +104,6 @@ windows/sqlite/sqlite3.lib
crates/desktop/src-tauri/*.dll
windows_dlls/
# Portable local dev stack (script/bootstrap) - MySQL/Redis binaries, data, logs
.devstack/
@@ -1,12 +0,0 @@
{
"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"
}
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\nUPDATE generic_inference_jobs\nSET\n status = ?,\n assigned_worker = ?,\n last_assigned_worker = ?,\n assigned_cluster = ?,\n first_started_at = NOW()\nWHERE token = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 5
},
"nullable": []
},
"hash": "258ac40d3fab39f110e1e82d50e73542423bfe9a21f8127e6cd83214c1726e25"
}
@@ -1,36 +0,0 @@
{
"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"
}
@@ -1,6 +1,6 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n users.token as `user_token: UserToken`,\n users.username,\n users.display_name,\n users.email_address,\n users.user_role_slug,\n users.is_banned,\n user_roles.can_ban_users,\n users.maybe_feature_flags\nFROM user_sessions\nINNER JOIN users\n ON users.token = user_sessions.user_token\nLEFT OUTER JOIN user_roles\n ON users.user_role_slug = user_roles.slug\nWHERE user_sessions.token = ?\n AND user_sessions.deleted_at IS NULL\n AND users.user_deleted_at IS NULL\n AND users.mod_deleted_at IS NULL\nLIMIT 1\n ",
"query": "\nSELECT\n users.token as `user_token: UserToken`,\n users.username,\n users.display_name,\n users.email_address,\n users.user_role_slug,\n users.is_banned,\n user_roles.can_ban_users,\n api_keys.token as `api_key_token: ApiKeyToken`\nFROM api_keys\nINNER JOIN users\n ON users.token = api_keys.owner_user_token\nLEFT OUTER JOIN user_roles\n ON users.user_role_slug = user_roles.slug\nWHERE api_keys.api_key = ?\n AND api_keys.maybe_deleted_at IS NULL\n AND users.user_deleted_at IS NULL\n AND users.mod_deleted_at IS NULL\nLIMIT 1\n ",
"describe": {
"columns": [
{
@@ -75,12 +75,12 @@
},
{
"ordinal": 7,
"name": "maybe_feature_flags",
"name": "api_key_token: ApiKeyToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 1020
"max_size": 128
}
}
],
@@ -95,8 +95,8 @@
false,
false,
true,
true
false
]
},
"hash": "cf2794c87868e77dfaa45bdbe630ae276dbb8c2d041442ba465af0b7a6d61af6"
"hash": "9188e235caa439d50628333c2f4722b9f1a8f575c192d4d36a2039709c412ac0"
}
@@ -1,12 +0,0 @@
{
"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"
}
@@ -1,6 +1,6 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n users.token as `user_token: UserToken`,\n users.username,\n users.display_name,\n users.email_address,\n users.user_role_slug,\n users.is_banned,\n user_roles.can_ban_users,\n users.maybe_feature_flags,\n api_keys.token as `api_key_token: ApiKeyToken`\nFROM api_keys\nINNER JOIN users\n ON users.token = api_keys.owner_user_token\nLEFT OUTER JOIN user_roles\n ON users.user_role_slug = user_roles.slug\nWHERE api_keys.api_key = ?\n AND api_keys.maybe_deleted_at IS NULL\n AND users.user_deleted_at IS NULL\n AND users.mod_deleted_at IS NULL\nLIMIT 1\n ",
"query": "\nSELECT\n users.token as `user_token: UserToken`,\n users.username,\n users.display_name,\n users.email_address,\n users.user_role_slug,\n users.is_banned,\n user_roles.can_ban_users\nFROM user_sessions\nINNER JOIN users\n ON users.token = user_sessions.user_token\nLEFT OUTER JOIN user_roles\n ON users.user_role_slug = user_roles.slug\nWHERE user_sessions.token = ?\n AND user_sessions.deleted_at IS NULL\n AND users.user_deleted_at IS NULL\n AND users.mod_deleted_at IS NULL\nLIMIT 1\n ",
"describe": {
"columns": [
{
@@ -72,26 +72,6 @@
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 7,
"name": "maybe_feature_flags",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 8,
"name": "api_key_token: ApiKeyToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
}
],
"parameters": {
@@ -104,10 +84,8 @@
false,
false,
false,
true,
true,
false
true
]
},
"hash": "62b3e2e058331a6819acd98c634b3bf2d10aa38d43b49ab69fb8637497beec2f"
"hash": "c77126adb36f9e2f0248720eb4d8c1cd9d14719f64a02678d8021e26addd2c18"
}
@@ -1,179 +0,0 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n m.token as `token: tokens::tokens::media_files::MediaFileToken`,\n\n m.media_class as `media_class: enums::by_table::media_files::media_file_class::MediaFileClass`,\n m.media_type as `media_type: enums::by_table::media_files::media_file_type::MediaFileType`,\n\n m.maybe_batch_token as `maybe_batch_token: tokens::tokens::batch_generations::BatchGenerationToken`,\n m.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,\n\n m.public_bucket_directory_hash,\n m.maybe_public_bucket_prefix,\n m.maybe_public_bucket_extension,\n\n media_file_cover_image.public_bucket_directory_hash as maybe_file_cover_image_public_bucket_hash,\n media_file_cover_image.maybe_public_bucket_prefix as maybe_file_cover_image_public_bucket_prefix,\n media_file_cover_image.maybe_public_bucket_extension as maybe_file_cover_image_public_bucket_extension,\n\n m.maybe_origin_filename,\n m.maybe_duration_millis as `maybe_duration_millis: u64`,\n\n m.created_at,\n m.updated_at\n\nFROM media_files AS m\nLEFT OUTER JOIN media_files as media_file_cover_image\n ON media_file_cover_image.token = m.maybe_cover_image_media_file_token\nWHERE\n m.maybe_source_job_token = ?\n AND m.maybe_creator_user_token = ?\n AND m.user_deleted_at IS NULL\n AND m.mod_deleted_at IS NULL\n AND NOT m.is_intermediate_system_file\nORDER BY m.id ASC\nLIMIT ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token: tokens::tokens::media_files::MediaFileToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "media_class: enums::by_table::media_files::media_file_class::MediaFileClass",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 2,
"name": "media_type: enums::by_table::media_files::media_file_type::MediaFileType",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 3,
"name": "maybe_batch_token: tokens::tokens::batch_generations::BatchGenerationToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 4,
"name": "maybe_prompt_token: tokens::tokens::prompts::PromptToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 5,
"name": "public_bucket_directory_hash",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 6,
"name": "maybe_public_bucket_prefix",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 7,
"name": "maybe_public_bucket_extension",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 8,
"name": "maybe_file_cover_image_public_bucket_hash",
"type_info": {
"type": "VarString",
"flags": "BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 9,
"name": "maybe_file_cover_image_public_bucket_prefix",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 10,
"name": "maybe_file_cover_image_public_bucket_extension",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 11,
"name": "maybe_origin_filename",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 12,
"name": "maybe_duration_millis: u64",
"type_info": {
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 10
}
},
{
"ordinal": 13,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 14,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP | ON_UPDATE_NOW",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 3
},
"nullable": [
false,
false,
false,
true,
true,
false,
true,
true,
true,
true,
true,
true,
true,
false,
false
]
},
"hash": "cd04f4f7b48ae9da53b6a210111f8e3edff94dee30263e22b45d5e29dbe9245a"
}
@@ -1,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 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"
}
@@ -1,124 +0,0 @@
{
"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"
}
@@ -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\n maybe_mime_type = ?,\n file_size_bytes = ?,\n maybe_duration_millis = ?,\n maybe_audio_encoding = ?,\n maybe_video_encoding = ?,\n maybe_frame_width = ?,\n maybe_frame_height = ?,\n maybe_prompt_token = ?,\n checksum_sha2 = ?,\n\n maybe_engine_category = ?,\n\n maybe_title = ?,\n maybe_text_transcript = ?,\n\n maybe_scene_source_media_file_token = ?,\n\n public_bucket_directory_hash = ?,\n maybe_public_bucket_prefix = ?,\n maybe_public_bucket_extension = ?,\n\n maybe_creator_user_token = ?,\n maybe_creator_anonymous_visitor_token = ?,\n\n creator_ip_address = ?,\n creator_set_visibility = ?,\n\n maybe_creator_file_synthetic_id = ?,\n maybe_creator_category_synthetic_id = ?,\n\n extra_file_modification_info = ?,\n\n maybe_generation_provider = ?,\n\n platform_type = ?,\n\n maybe_cover_image_media_file_token = ?,\n\n maybe_mod_user_token = ?,\n is_generated_on_prem = ?,\n generated_by_worker = ?,\n generated_by_cluster = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 41
},
"nullable": []
},
"hash": "f5bc4a956a20f6c0998585092a0922301d53c95ae8ea9cb808ae2dbcf8d0bb32"
}
Generated
+27 -44
View File
@@ -757,9 +757,9 @@ dependencies = [
"fal_client",
"gmicloud_client",
"grok_api_client",
"kinovi_web_client",
"log",
"reqwest 0.12.23",
"seedance2pro_client",
"serde",
"serde_derive",
"serde_json",
@@ -6359,29 +6359,6 @@ dependencies = [
"unicode-segmentation",
]
[[package]]
name = "kinovi_web_client"
version = "0.0.1"
dependencies = [
"chrono",
"cloudflare_errors",
"env",
"env_logger 0.11.9",
"errors",
"log",
"rand 0.9.2",
"serde",
"serde_derive",
"serde_json",
"test_data",
"test_utils",
"tokio",
"url",
"workspace-build-acceleration",
"wreq",
"wreq-util",
]
[[package]]
name = "kubectl-pod-cleanup"
version = "0.0.1"
@@ -7363,22 +7340,6 @@ dependencies = [
"workspace-build-acceleration",
]
[[package]]
name = "mysql_testing"
version = "0.1.0"
dependencies = [
"anyhow",
"bcrypt",
"bucket_paths",
"chrono",
"enums",
"log",
"mysql_queries",
"sqlx",
"tokens",
"tokio",
]
[[package]]
name = "mysqlclient-sys"
version = "0.2.5"
@@ -10558,7 +10519,6 @@ dependencies = [
"errors",
"hashing",
"jobs_common",
"kinovi_web_client",
"log",
"mysql_queries",
"pager",
@@ -10566,6 +10526,7 @@ dependencies = [
"reqwest 0.12.23",
"rootly_client",
"rootly_config",
"seedance2pro_client",
"serde",
"serde_derive",
"serde_json",
@@ -10577,6 +10538,29 @@ dependencies = [
"workspace-build-acceleration",
]
[[package]]
name = "seedance2pro_client"
version = "0.0.1"
dependencies = [
"chrono",
"cloudflare_errors",
"env",
"env_logger 0.11.9",
"errors",
"log",
"rand 0.9.2",
"serde",
"serde_derive",
"serde_json",
"test_data",
"test_utils",
"tokio",
"url",
"workspace-build-acceleration",
"wreq",
"wreq-util",
]
[[package]]
name = "selectors"
version = "0.24.0"
@@ -11648,7 +11632,6 @@ dependencies = [
"images",
"infer 0.16.0",
"jwt_signer",
"kinovi_web_client",
"language-tags",
"lexical-sort",
"limitation",
@@ -11662,7 +11645,6 @@ dependencies = [
"migration",
"mimetypes",
"mysql_queries",
"mysql_testing",
"once_cell",
"opaque_cursors",
"pager",
@@ -11681,6 +11663,7 @@ dependencies = [
"rootly_config",
"rust-s3",
"rustls 0.23.36",
"seedance2pro_client",
"serde",
"serde_derive",
"serde_json",
@@ -11884,8 +11867,8 @@ dependencies = [
"easyenv",
"enums",
"errors",
"kinovi_web_client",
"log",
"seedance2pro_client",
"serde_json",
"tokens",
"tokio",
+2 -4
View File
@@ -27,7 +27,7 @@ members = [
"crates/api_clients/openart_client",
"crates/api_clients/rootly_client",
"crates/api_clients/runwayml_client",
"crates/api_clients/kinovi_web_client",
"crates/api_clients/seedance2pro_client",
"crates/api_clients/thumbnail_generator",
"crates/api_clients/worldlabs_api_client",
"crates/api_clients/worldlabs_consumer_client",
@@ -120,7 +120,6 @@ members = [
"crates/schema/database/elasticsearch_schema",
"crates/schema/database/migration",
"crates/schema/database/mysql_queries",
"crates/schema/database/mysql_testing",
"crates/schema/database/redis_common",
"crates/schema/database/redis_schema",
"crates/schema/database/sqlite_tasks",
@@ -283,11 +282,10 @@ mimetypes = { path = "crates/lib/files/mimetypes" }
muapi_client = { path = "crates/api_clients/muapi_client" }
metrics = { path = "crates/lib/metrics" }
mysql_queries = { path = "crates/schema/database/mysql_queries" }
mysql_testing = { path = "crates/schema/database/mysql_testing" }
pager = { path = "crates/lib/pager" }
openai_sora_client = { path = "crates/api_clients/openai_sora_client" }
primitives = { path = "crates/lib/data/primitives" }
kinovi_web_client = { path = "crates/api_clients/kinovi_web_client" }
seedance2pro_client = { path = "crates/api_clients/seedance2pro_client" }
server_environment = { path = "crates/lib/server_environment" }
sha1_hash = { path = "crates/lib/sha1_hash" }
shared_service_components = { path = "crates/lib/shared_service_components" }
@@ -1,7 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE media_files
DROP INDEX index_maybe_source_job_token,
DROP COLUMN maybe_source_job_token;
@@ -1,13 +0,0 @@
-- 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,10 +102,6 @@ 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).
@@ -331,7 +327,6 @@ 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),
+293
View File
@@ -0,0 +1,293 @@
# Local Dev Stack — Requirements & Bootstrap
How to run `storyteller-web` on your own machine against a local MySQL and
Redis — no shared dev database, no Docker, no admin rights. This is for **work
on the backend itself**.
> **Working on the frontend?** You probably want
> `frontend/apps/fake-storyteller-web` instead — an in-memory stand-in for this
> API that needs no database, no build, and no setup. Everything below is
> unnecessary for webapp work. Come back here when you need the real server.
These scripts install and supervise infrastructure. They change no application
behaviour, and nothing in them is compiled into any binary: the backend they
start is the same one that ships. Integrations they cannot provide locally
(object storage, generation providers, email) get placeholder credentials, so
the endpoints that use them fail when exercised. Swap in a real dev credential
for whichever one you're actually working on.
The automated path on **Windows** (primary platform, native — no WSL, no
Docker, no admin rights for the stack itself):
```powershell
.\script\bootstrap\windows\bootstrap_dev_stack.ps1 # one-time setup, idempotent, re-run anytime
.\script\bootstrap\windows\run_backend_dev.ps1 # terminal 1: API on http://localhost:12345
.\script\bootstrap\windows\seed_demo_user.ps1 # terminal 2: create demo login (localdev1 / localdev1pass)
$env:VITE_DEV_API_HOST = "http://localhost:12345" # terminal 2: aim the webapp at your backend
cd frontend; npx nx dev artcraft-webapp # webapp on http://localhost:4201
.\script\bootstrap\windows\dev_stack_doctor.ps1 # health check, any time
.\script\bootstrap\windows\stop_dev_services.ps1 # stop MySQL/Redis when done
```
The equivalent on **Linux** (Ubuntu 22.04+ — native, WSL2, or CI):
```bash
./script/bootstrap/bootstrap_dev_stack.sh # one-time setup, idempotent, re-run anytime
./script/bootstrap/run_backend_dev.sh # terminal 1: API on http://localhost:12345
./script/bootstrap/seed_demo_user.sh # terminal 2: create demo login (localdev1 / localdev1pass)
cd frontend && VITE_DEV_API_HOST=http://localhost:12345 npx nx dev artcraft-webapp # terminal 2: webapp on :4201
./script/bootstrap/dev_stack_doctor.sh # health check, any time
```
This document records the underlying requirements so the scripts aren't a
black box, and corrects some stale claims in older docs.
## Supported platforms
| Platform | Status |
|---------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| Windows 10/11 | Primary. `script/bootstrap/windows/*.ps1` run a **portable stack**: MySQL 8.4 (official zip) and Redis live under the gitignored `.devstack/` and run as plain user processes — no admin, no services, no WSL, no Docker. Build tools (cmake, perl, nasm, llvm) are offered via winget |
| Ubuntu 22.04+ | Supported by `script/bootstrap/*.sh` (native, WSL2, or CI runner/container); services via apt |
| macOS | Not covered by the scripts; follow `_docs/dev_setup_server.md` manually |
### Windows specifics
- Everything stateful lives in `.devstack/` (gitignored): `mysql/` (extracted
zip), `mysql-data/`, `redis/`, `logs/`, `pids/`, `downloads/` (cached zips),
`my.ini`. Delete the directory to fully reset; re-run bootstrap to rebuild.
- Pinned services: MySQL **8.4.11** from `cdn.mysql.com` (the 8.x series is
required; only some patch versions exist on the CDN) and Redis **5.0.14.1**
(tporadowski build — old but sufficient for the backend's keepalive/TTL/
rate-limit usage; override `REDIS_ZIP_URL` to use Memurai/Garnet instead).
- The portable MySQL root user has **no password** (`--initialize-insecure`) —
local dev only, bound to 127.0.0.1.
- diesel-cli is compiled against the portable MySQL's own client library
(`MYSQLCLIENT_LIB_DIR`/`MYSQLCLIENT_VERSION`); `libmysql.dll` must be on
`PATH` to *run* it — the scripts handle both.
- Compiling the backend needs cmake, Strawberry Perl, NASM, and LLVM/libclang
(the `wreq` HTTP client builds BoringSSL, and `aws-lc-sys`/`ring` need
NASM/clang on MSVC). The bootstrap checks and offers winget installs.
- The generated secrets file additionally sets `TEMP_DIR` (the upload temp-dir
helper defaults to `/tmp`, which doesn't exist on Windows).
## Requirements matrix
| Requirement | Version / detail | Needed for |
|-------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------|
| MySQL server | 8.x series (9.x unsupported by tooling) | Backend boot (eager connect) + migrations |
| Redis server | any recent | Backend boot (r2d2 pool connects eagerly) |
| Rust (stable) | no pinned MSRV; Docker builds use 1.93.0 | Building `storyteller-web`, diesel_cli |
| diesel_cli | `cargo install diesel_cli --no-default-features --features mysql,sqlite` | Running the 281 migrations |
| Node.js | 20+ (enforced by `script/common/frontend_preflight.sh`) | Frontend (Nx 21 / Vite 6) |
| npm | ships with Node; **pnpm must not be used** | Frontend install (`frontend/package-lock.json`) |
| System libs (apt) | build-essential, cmake, libssl-dev, libclang-dev, libfontconfig1-dev, fontconfig, perl, pkg-config, ffmpeg, libmysqlclient-dev, libsqlite3-dev, curl, jq | Compiling the workspace + diesel_cli; seeding scripts |
| Elasticsearch | optional (7.x) | Only 4 search endpoints; server boots without it |
| Docker | **not required** — no docker-compose exists | — |
Database identity (used consistently by docs, `.env`, and defaults):
database `storyteller`, user `storyteller`, password `password`, host
`localhost`.
## Backend: what `storyteller-web` needs to boot
Binary: `storyteller-web` in `crates/service/web/storyteller_web`
(binaries: `storyteller-web`, `docs` — Swagger UI on :8989, `docs-cli` — emits
`api.json`). Binds `0.0.0.0:12345` (`BIND_ADDRESS`), plain HTTP.
**Run it from the repo root** — the config search path and `includes/` paths
are cwd-relative.
### Config loading (corrected facts)
- Mode is selected by the `SERVER_ENVIRONMENT` **process env var only**
(default: Development). Setting it inside an env file does nothing — it is
read before any file loads.
- In Development mode, files load from
`crates/service/web/storyteller_web/config/` in this order:
`storyteller-web.common.env` → `storyteller-web.development.env` →
`storyteller-web.development-secrets.env` (gitignored; the bootstrap
generates it).
- **Precedence is first-value-wins** (dotenv never overrides an existing key):
real process env > common.env > development.env > development-secrets.env.
The secrets file therefore cannot override anything already set in
`development.env`.
- The repo-root `.env` is **NOT loaded by the server** (it passes
`ignore_legacy_dot_env_file: true`). Root `.env`'s `DATABASE_URL` feeds
diesel-cli/sqlx-cli only; the server reads **`MYSQL_URL`** (default
`mysql://storyteller:password@localhost/storyteller`).
### Hard requirements at boot
| Requirement | Behavior if missing |
|-------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------|
| MySQL reachable via `MYSQL_URL` | Boot fails (sqlx pool connects eagerly; the DB must exist and be migrated — health-check and ban-poller threads query immediately) |
| Redis reachable via `REDIS_0_URL` (default `redis://localhost/0`) | Boot fails (r2d2 pool connects eagerly) |
| ~30 `get_env_string_required` vars present | Boot fails on the first absent var |
Of the required-present vars, 19 are supplied by **no** checked-in file and
live in the generated secrets file: `ACCESS_KEY`, `SECRET_KEY`, `REGION_NAME`,
`W2L_PRIVATE_DOWNLOAD_BUCKET_NAME`, `W2L_PUBLIC_DOWNLOAD_BUCKET_NAME`,
`RESEND_API_KEY`, `FAL_API_KEY`, `GMICLOUD_API_KEY`, `GROK_API_KEY`,
`BEEBLE_API_KEY`, `OPENAI_API_KEY`, `WORLDLABS_API_KEY`,
`SEEDANCE2PRO_COOKIES`, `SEEDANCE2PRO_WHITELIST_COOKIES`,
`SEEDANCE2PRO_BYTEPLUS_ULTRA_COOKIES`, `STRIPE_FAKEYOU_ACCOUNT_ID`,
`STRIPE_ARTCRAFT_ACCOUNT_ID`, `STRIPE_ARTCRAFT_SECRET_KEY`,
`STRIPE_ARTCRAFT_SECRET_WEBHOOK_KEY`.
**Placeholder values are safe at boot**: none of these services is contacted
at boot (clients are constructed offline; Elasticsearch, S3/R2, Stripe,
Resend, and Google-cert fetches are all lazy). But two earlier claims here
were wrong and are corrected:
- **The omni_gen endpoints call providers synchronously in the HTTP handler**
(`/v1/omni_gen/generate/{image,video}` — the path the webapp uses). With
placeholder keys the provider call fails and the submit returns HTTP 500
*after* billing the wallet (the image pipeline has no refund; video refunds
only Kinovi). Only the legacy polling providers (GmiCloud, Grok, Kinovi
orders, WorldLabs) are worker-driven.
- **Uploads fail hard with placeholder S3 creds** — every media upload endpoint
uploads to the bucket *before* inserting the DB row, and `rust-s3` is built
with `fail-on-err`.
### What does and doesn't work locally
| Area | Works with placeholder credentials? |
|----------------------------------------------|-------------------------------------|
| Login, sessions, account, profile | Yes |
| Wallets, credits, billing reads | Yes |
| Folders, tags, prompts, characters, projects | Yes |
| Media listing (rows) | Yes — but the images 404, see below |
| Media uploads | No — needs real R2/S3 credentials |
| Image/video/mesh/splat generation | No — needs a real provider key |
| Transactional email | No — needs a real Resend key |
| Elasticsearch-backed search | No — needs a local ES + reindex |
The rows-but-no-images case is the one that surprises people: `media_files`
rows carry a bucket path, and the URL the API builds from it points at the
shared R2 bucket, which does not have your local objects. Put real dev R2
credentials in the secrets file if you need media to render, or use
`fake-storyteller-web`, which serves its own bytes.
### Auth on localhost
- `POST /v1/login` takes `{"username_or_email": "...", "password": "..."}`;
success returns `{"success": true, "signed_session": "<jwt>"}` **and** a
`session` cookie.
- With the checked-in dev config (`COOKIE_DOMAIN=.jungle.horse`), the session
cookie is issued non-Secure and host-only (the Domain attribute is never
set), so plain-HTTP localhost login works as-is. The frontend additionally
stores `signed_session` in localStorage and sends it as a `session` header.
- Dev-mode CORS allows any `localhost`/`127.0.0.1` origin on any port, with
credentials.
- Health endpoint for scripts/CI: `GET /_status`.
### Build
- Compile with `SQLX_OFFLINE=true` (uses the checked-in `.sqlx/` cache; 483
queries). Without it, the sqlx macros open a **live DB connection at compile
time** using `DATABASE_URL` from the root `.env`. `SQLX_OFFLINE` has no
runtime effect — ignore older notes saying not to set it when running.
- First build takes several minutes; the crate has ~370 pre-existing warnings.
## Database: migrations and seed data
- 281 Diesel migrations in `_database/sql/migrations/` (`diesel.toml` at repo
root points there). Run from the repo root: `diesel migration run`.
`"Encountered unknown type for Mysql: enum"` warnings are harmless.
`migrations_squashed/` is documentation only — not executable.
- Seed data: `_database/sql/seed/sql/system_roles.sql` (roles `user`, `mod`,
`admin`) and `user_badges.sql`. **The `user` role is mandatory** — account
creation hardcodes `user_role_slug='user'`. The legacy wrapper
`bootstrap_inserts_roles_etc.sh` must run with cwd `_database/sql/` and is
not idempotent; the bootstrap script applies the same SQL files with
idempotency guards instead.
- Demo user: created through **`POST /v1/create_account`** (no captcha, no
invite code, no email-verification gate; password just needs 6+ chars). This
exercises the real signup path instead of hand-rolled SQL. Gotcha: usernames
like `demo`, `admin`, `test`, `dev`, `artcraft` are reserved
(`includes/binary_includes/usernames/reserved_usernames.txt`) — the default
demo user is `localdev1`. The Rust seeder
(`cargo run --bin dev-database-seed`) exists but hard-requires a
`.env-secrets` file and also seeds model weights, so the HTTP path is
preferred.
- Demo credits: signup creates **no wallet**, and the webapp gates the
generate button on wallet balance — so `seed_demo_user` also seeds an
`artcraft`-namespace wallet with `DEMO_CREDITS` (default 100,000) banked
credits, plus matching ledger rows. Idempotent: re-runs top the balance
back up to `DEMO_CREDITS` but never lower it. (Raw SQL, because the only
real credit-granting paths are Stripe webhooks and moderator tooling.)
- Demo gallery: **not seeded**. Media rows without reachable bytes render as
broken tiles, so seeding them locally would be worse than an empty library.
Use `fake-storyteller-web` for a populated one.
## Frontend: `artcraft-webapp`
- Nx workspace at repo-root `frontend/` (npm workspaces; `npm install` from
`frontend/`). The FontAwesome Pro registry token is committed in
`frontend/.npmrc` — no manual auth step.
- Serve: `npx nx dev artcraft-webapp` → `http://localhost:4201`. No lib
pre-build is needed in dev (libs resolve to TS source via the
`"development"` export condition); production `nx build` does need lib
builds.
- **By default the dev webapp targets production.** `src/main.tsx` points the
API host at the Vite dev origin, and Vite proxies `/v1` to
`https://api.storyteller.ai`. The adjacent `setDevelopment()` call — which
would point it at `localhost:12345` — is commented out. To aim the webapp at
your local backend, set `VITE_DEV_API_HOST=http://localhost:12345` before
`nx dev` (that override ships with the `fake-storyteller-web` change; until
it lands, uncomment `setDevelopment()` locally instead).
- Optional: `VITE_GOOGLE_CLIENT_ID` in `frontend/apps/artcraft-webapp/.env`
enables the Google SSO button; email/password login needs nothing.
- Other dev ports if you run more apps: website 4200, dashboard 4202 (separate
pnpm app, excluded from Nx), video-info-website 4250, genhub 4300, Tauri
frontend 5173.
## What still touches production (known, deliberate seams)
The bootstrap changes no runtime behaviour, so these remain open. Each is
closed by supplying a real credential, not by a code path:
| Seam | Today |
|---------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------|
| Media/CDN URLs in API responses | The compiled-in `cdn_link.rs` constants point at the public `pub-….r2.dev` bucket, so a locally-created row's media URL resolves to an object that isn't there |
| Media uploads | 500 with placeholder credentials; supply real dev R2/S3 keys to exercise them |
| Generation | 500 with placeholder keys, *after* the wallet is billed; supply a real key for the provider under test |
| Vite `/v1` proxy in webapp | Targets `https://api.storyteller.ai`, so a dev webapp with no host override reads and writes production |
| Elasticsearch-backed search | `search_featured` / `search_session` media files, `weights/search`, legacy `tts/search` fail without a local ES + `elasticsearch-cli` reindex (optional) |
## CI usage
The same bootstrap works on a fresh Ubuntu runner/container:
```bash
export MYSQL_ROOT_PASSWORD=root # GitHub Actions' preinstalled MySQL uses password auth
./script/bootstrap/bootstrap_dev_stack.sh --yes
./script/bootstrap/run_backend_dev.sh &
./script/bootstrap/seed_demo_user.sh # waits for /_status
./script/bootstrap/dev_stack_doctor.sh
```
`--yes` answers all prompts; `--skip-frontend` / `--skip-rust-build` /
`--skip-packages` slice the work for focused jobs.
## Verification checklist
- `./script/bootstrap/dev_stack_doctor.sh` exits 0 with no FAILs.
- `POST /v1/login` with the demo user returns `success: true` plus a
`signed_session` (done automatically by `seed_demo_user.sh`).
- `GET /_status` returns 200, and MySQL has the full migration set applied.
- With `VITE_DEV_API_HOST` pointed at `localhost:12345`, the webapp logs in as
`localdev1` and shows its credits in the top bar. The library is empty and
generation 500s — both expected, see the table above.
## Corrections to older docs
- `crates/service/web/storyteller_web/CLAUDE.md`: the repo-root `.env` is
*not* auto-loaded by the server, and `SQLX_OFFLINE=true` is fine (and
recommended) when running — it only affects compile-time query checking.
- `_docs/dev_setup_server.md`: `./script/dev_mysql_connect.sh` doesn't exist —
it's `./script/dev_database_connect.sh`.
- `diesel.toml`'s comment about needing an unreleased diesel CLI is stale —
any current `diesel_cli` works.
- `TESTING_INFRA_HANDOFF.md` (untracked): the server env var is `MYSQL_URL`,
not `DATABASE_URL`; migration count is 281; crate path is
`crates/service/web/storyteller_web`.
-227
View File
@@ -1,227 +0,0 @@
# 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.
+2 -2
View File
@@ -51,9 +51,9 @@ helpers go in the parent `mod tests` so sub-modules can `use super::*`.
- `storyteller_web` — main HTTP API monolith (actix-web)
- `artcraft` (desktop) — Tauri desktop app
- `artcraft_router` — provider routing for image/video generation (Artcraft, Fal, KinoviWeb, Muapi)
- `artcraft_router` — provider routing for image/video generation (Artcraft, Fal, Seedance2Pro, Muapi)
- `artcraft_api_defs` — shared HTTP API type definitions
- `kinovi_web_client` — HTTP client for the Kinovi/KinoviWeb video generation service
- `seedance2pro_client` — HTTP client for the Kinovi/Seedance2Pro video generation service
- `enums` — database-backed enums stored as VARCHAR fields
- `tokens` — primary key identifiers with Stripe-like prefixes (e.g. `user_`, `mf_`)
- `mysql_queries` — SQLx MySQL queries for the backend
@@ -1,29 +0,0 @@
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,
}
@@ -1,29 +0,0 @@
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>,
}
@@ -1,33 +0,0 @@
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);
}
}
}
@@ -1,4 +0,0 @@
pub mod mark_minimax_job_failure;
pub mod mark_minimax_job_success;
pub mod minimax_worker_model;
pub mod obtain_minimax_job;
@@ -1,85 +0,0 @@
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,
}
@@ -1,4 +0,0 @@
//! Internal-facing API surfaces (worker fleets, etc.). These are
//! authenticated with internal API keys, never consumer credentials.
pub mod minimax_jobs;
@@ -5,7 +5,6 @@ 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;
@@ -1,61 +0,0 @@
//! 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>,
}
@@ -1 +0,0 @@
pub mod list_media_files_by_job;
@@ -1,5 +1,4 @@
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,23 +69,4 @@ 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>,
}
@@ -11,7 +11,7 @@ pub struct OmniGenVideoGenerateResponse {
/// All job tokens created by this request (including the primary).
/// For single-job requests this will contain just one element matching
/// `inference_job_token`. For batch requests (eg. KinoviWeb with
/// `inference_job_token`. For batch requests (eg. Seedance2Pro with
/// multiple order IDs) this will contain all of them.
pub all_job_tokens: Vec<InferenceJobToken>,
}
@@ -25,6 +25,8 @@ pub enum ApiClientVideoModel {
Seedance2p0Fast,
Seedance2p0BytePlus,
Seedance2p0BytePlusFast,
Seedance2p0Ultra,
Seedance2p0UltraFast,
Seedance2p0BytePlusUltra,
Seedance2p0BytePlusUltraFast,
Seedance2p0Mini,
@@ -63,6 +65,8 @@ impl From<String> for ApiClientVideoModel {
"seedance_2p0_fast" => Self::Seedance2p0Fast,
"seedance_2p0_bp" => Self::Seedance2p0BytePlus,
"seedance_2p0_bp_fast" => Self::Seedance2p0BytePlusFast,
"seedance_2p0_u" => Self::Seedance2p0Ultra,
"seedance_2p0_u_fast" => Self::Seedance2p0UltraFast,
"seedance_2p0_bpu" => Self::Seedance2p0BytePlusUltra,
"seedance_2p0_bpu_fast" => Self::Seedance2p0BytePlusUltraFast,
"seedance_2p0_mini" => Self::Seedance2p0Mini,
@@ -102,6 +106,8 @@ impl From<ApiClientVideoModel> for String {
ApiClientVideoModel::Seedance2p0Fast => "seedance_2p0_fast".to_string(),
ApiClientVideoModel::Seedance2p0BytePlus => "seedance_2p0_bp".to_string(),
ApiClientVideoModel::Seedance2p0BytePlusFast => "seedance_2p0_bp_fast".to_string(),
ApiClientVideoModel::Seedance2p0Ultra => "seedance_2p0_u".to_string(),
ApiClientVideoModel::Seedance2p0UltraFast => "seedance_2p0_u_fast".to_string(),
ApiClientVideoModel::Seedance2p0BytePlusUltra => "seedance_2p0_bpu".to_string(),
ApiClientVideoModel::Seedance2p0BytePlusUltraFast => "seedance_2p0_bpu_fast".to_string(),
ApiClientVideoModel::Seedance2p0Mini => "seedance_2p0_mini".to_string(),
@@ -159,6 +165,8 @@ mod tests {
(ApiClientVideoModel::Seedance2p0Fast, "seedance_2p0_fast"),
(ApiClientVideoModel::Seedance2p0BytePlus, "seedance_2p0_bp"),
(ApiClientVideoModel::Seedance2p0BytePlusFast, "seedance_2p0_bp_fast"),
(ApiClientVideoModel::Seedance2p0Ultra, "seedance_2p0_u"),
(ApiClientVideoModel::Seedance2p0UltraFast, "seedance_2p0_u_fast"),
(ApiClientVideoModel::Seedance2p0BytePlusUltra, "seedance_2p0_bpu"),
(ApiClientVideoModel::Seedance2p0BytePlusUltraFast, "seedance_2p0_bpu_fast"),
(ApiClientVideoModel::Seedance2p0Mini, "seedance_2p0_mini"),
@@ -22,7 +22,7 @@ artcraft_client.workspace = true
fal_client.workspace = true
gmicloud_client.workspace = true
grok_api_client.workspace = true
kinovi_web_client.workspace = true
seedance2pro_client.workspace = true
tokens.workspace = true
url_utils.workspace = true
worldlabs_api_client.workspace = true
@@ -8,6 +8,6 @@ pub enum RouterProvider {
Fal,
GmiCloud,
GrokApi,
KinoviWeb,
Seedance2Pro,
WorldLabs,
}
@@ -14,12 +14,6 @@ pub enum RouterVideoModel {
#[serde(rename = "grok_imagine_video_1p5")]
GrokImagineVideo1p5,
#[serde(rename = "flux_3")]
Flux3,
#[serde(rename = "flux_3_draft")]
Flux3Draft,
#[serde(rename = "kling_1p6_pro")]
Kling16Pro,
@@ -41,15 +35,6 @@ pub enum RouterVideoModel {
#[serde(rename = "kling_3p0_pro")]
Kling3p0Pro,
#[serde(rename = "minimax_h3")]
MinimaxH3,
#[serde(rename = "minimax_h3_turbo")]
MinimaxH3Turbo,
#[serde(rename = "minimax_h3_ultra")]
MinimaxH3Ultra,
#[serde(rename = "seedance_1p0_lite")]
Seedance10Lite,
@@ -68,7 +53,11 @@ pub enum RouterVideoModel {
#[serde(rename = "seedance_2p0_bp_fast")]
Seedance2p0BytePlusFast,
#[serde(rename = "seedance_2p0_u")]
Seedance2p0Ultra,
#[serde(rename = "seedance_2p0_u_fast")]
Seedance2p0UltraFast,
#[serde(rename = "seedance_2p0_bpu")]
Seedance2p0BytePlusUltra,
@@ -85,15 +74,6 @@ pub enum RouterVideoModel {
#[serde(rename = "seedance_2p0_bpu_mini")]
Seedance2p0BytePlusUltraMini,
#[serde(rename = "seedance_2p5_preview")]
Seedance2p5Preview,
#[serde(rename = "seedance_2p5")]
Seedance2p5,
#[serde(rename = "seedance_2p5_u")]
Seedance2p5Ultra,
#[serde(rename = "happy_horse_1p0")]
HappyHorse1p0,
@@ -153,25 +133,6 @@ mod tests {
assert_serde_round_trip(RouterVideoModel::Seedance2p0BytePlusUltraMini, "seedance_2p0_bpu_mini");
}
#[test]
fn seedance_2p5_preview_serializes() {
assert_serde_round_trip(RouterVideoModel::Seedance2p5Preview, "seedance_2p5_preview");
}
#[test]
fn seedance_2p5_serializes() {
// NB: This string must match `CommonVideoModel` — the two enums convert
// via serde string round-trip in the omni handlers.
assert_serde_round_trip(RouterVideoModel::Seedance2p5, "seedance_2p5");
}
#[test]
fn seedance_2p5_ultra_serializes() {
// NB: This string must match `CommonVideoModel` — the two enums convert
// via serde string round-trip in the omni handlers.
assert_serde_round_trip(RouterVideoModel::Seedance2p5Ultra, "seedance_2p5_u");
}
#[test]
fn veo_3p1_lite_and_vidu_variants_serialize() {
// NB: These strings must match `CommonVideoModel` — the two enums convert
@@ -180,27 +141,4 @@ mod tests {
assert_serde_round_trip(RouterVideoModel::ViduQ3, "vidu_q3");
assert_serde_round_trip(RouterVideoModel::ViduQ3Turbo, "vidu_q3_turbo");
}
#[test]
fn flux_3_variants_serialize() {
// NB: These strings must match `CommonVideoModel` — the two enums convert
// via serde string round-trip in the omni handlers.
assert_serde_round_trip(RouterVideoModel::Flux3, "flux_3");
assert_serde_round_trip(RouterVideoModel::Flux3Draft, "flux_3_draft");
}
#[test]
fn minimax_h3_serializes() {
// NB: This string must match `CommonVideoModel` — the two enums convert
// via serde string round-trip in the omni handlers.
assert_serde_round_trip(RouterVideoModel::MinimaxH3, "minimax_h3");
}
#[test]
fn minimax_h3_turbo_and_ultra_serialize() {
// NB: These strings must match `CommonVideoModel` — the two enums convert
// via serde string round-trip in the omni handlers.
assert_serde_round_trip(RouterVideoModel::MinimaxH3Turbo, "minimax_h3_turbo");
assert_serde_round_trip(RouterVideoModel::MinimaxH3Ultra, "minimax_h3_ultra");
}
}
@@ -7,5 +7,5 @@ pub mod router_client;
pub mod router_fal_client;
pub mod router_gmicloud_client;
pub mod router_grok_api_client;
pub mod router_kinovi_web_client;
pub mod router_seedance2pro_client;
pub mod router_worldlabs_client;
@@ -2,7 +2,7 @@ use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::client::router_fal_client::RouterFalClient;
use crate::client::router_gmicloud_client::RouterGmiCloudClient;
use crate::client::router_grok_api_client::RouterGrokApiClient;
use crate::client::router_kinovi_web_client::RouterKinoviWebClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::client::router_worldlabs_client::RouterWorldLabsClient;
use crate::errors::client_error::{ClientError, ClientType};
@@ -11,7 +11,7 @@ pub struct MultiRouterClient {
pub(crate) fal_client: Option<RouterFalClient>,
pub(crate) gmicloud_client: Option<RouterGmiCloudClient>,
pub(crate) grok_api_client: Option<RouterGrokApiClient>,
pub(crate) kinovi_web_client: Option<RouterKinoviWebClient>,
pub(crate) seedance2pro_client: Option<RouterSeedance2ProClient>,
pub(crate) worldlabs_client: Option<RouterWorldLabsClient>,
}
@@ -36,9 +36,9 @@ impl MultiRouterClient {
.ok_or(ClientError::ClientNotConfigured(ClientType::GrokApi))
}
pub fn get_kinovi_web_client_ref(&self) -> Result<&RouterKinoviWebClient, ClientError> {
self.kinovi_web_client.as_ref()
.ok_or(ClientError::ClientNotConfigured(ClientType::KinoviWeb))
pub fn get_seedance2pro_client_ref(&self) -> Result<&RouterSeedance2ProClient, ClientError> {
self.seedance2pro_client.as_ref()
.ok_or(ClientError::ClientNotConfigured(ClientType::Seedance2Pro))
}
pub fn get_worldlabs_client_ref(&self) -> Result<&RouterWorldLabsClient, ClientError> {
@@ -3,7 +3,7 @@ use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::client::router_fal_client::RouterFalClient;
use crate::client::router_gmicloud_client::RouterGmiCloudClient;
use crate::client::router_grok_api_client::RouterGrokApiClient;
use crate::client::router_kinovi_web_client::RouterKinoviWebClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::client::router_worldlabs_client::RouterWorldLabsClient;
pub struct MultiRouterClientBuilder {
@@ -11,7 +11,7 @@ pub struct MultiRouterClientBuilder {
fal_client: Option<RouterFalClient>,
gmicloud_client: Option<RouterGmiCloudClient>,
grok_api_client: Option<RouterGrokApiClient>,
kinovi_web_client: Option<RouterKinoviWebClient>,
seedance2pro_client: Option<RouterSeedance2ProClient>,
worldlabs_client: Option<RouterWorldLabsClient>,
}
@@ -22,7 +22,7 @@ impl MultiRouterClientBuilder {
fal_client: None,
gmicloud_client: None,
grok_api_client: None,
kinovi_web_client: None,
seedance2pro_client: None,
worldlabs_client: None,
}
}
@@ -47,8 +47,8 @@ impl MultiRouterClientBuilder {
self
}
pub fn set_kinovi_web_client(mut self, client: RouterKinoviWebClient) -> Self {
self.kinovi_web_client = Some(client);
pub fn set_seedance2pro_client(mut self, client: RouterSeedance2ProClient) -> Self {
self.seedance2pro_client = Some(client);
self
}
@@ -63,7 +63,7 @@ impl MultiRouterClientBuilder {
fal_client: self.fal_client,
gmicloud_client: self.gmicloud_client,
grok_api_client: self.grok_api_client,
kinovi_web_client: self.kinovi_web_client,
seedance2pro_client: self.seedance2pro_client,
worldlabs_client: self.worldlabs_client,
}
}
@@ -3,7 +3,7 @@ use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::client::router_fal_client::RouterFalClient;
use crate::client::router_gmicloud_client::RouterGmiCloudClient;
use crate::client::router_grok_api_client::RouterGrokApiClient;
use crate::client::router_kinovi_web_client::RouterKinoviWebClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::client::router_worldlabs_client::RouterWorldLabsClient;
use crate::errors::client_error::{ClientError, ClientType};
@@ -13,7 +13,7 @@ pub enum RouterClient {
Fal(RouterFalClient),
GmiCloud(RouterGmiCloudClient),
GrokApi(RouterGrokApiClient),
KinoviWeb(RouterKinoviWebClient),
Seedance2Pro(RouterSeedance2ProClient),
WorldLabs(RouterWorldLabsClient),
}
@@ -50,11 +50,11 @@ impl RouterClient {
}
}
pub fn get_kinovi_web_client_ref(&self) -> Result<&RouterKinoviWebClient, ClientError> {
pub fn get_seedance2pro_client_ref(&self) -> Result<&RouterSeedance2ProClient, ClientError> {
match self {
RouterClient::KinoviWeb(client) => Ok(client),
RouterClient::Multi(multi) => multi.get_kinovi_web_client_ref(),
_ => Err(ClientError::ClientNotConfigured(ClientType::KinoviWeb)),
RouterClient::Seedance2Pro(client) => Ok(client),
RouterClient::Multi(multi) => multi.get_seedance2pro_client_ref(),
_ => Err(ClientError::ClientNotConfigured(ClientType::Seedance2Pro)),
}
}
@@ -1,11 +0,0 @@
use kinovi_web_client::creds::kinovi_web_session::KinoviWebSession;
pub struct RouterKinoviWebClient {
pub(crate) session: KinoviWebSession,
}
impl RouterKinoviWebClient {
pub fn new(session: KinoviWebSession) -> Self {
RouterKinoviWebClient { session }
}
}
@@ -0,0 +1,11 @@
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
pub struct RouterSeedance2ProClient {
pub(crate) session: Seedance2ProSession,
}
impl RouterSeedance2ProClient {
pub fn new(session: Seedance2ProSession) -> Self {
RouterSeedance2ProClient { session }
}
}
@@ -2,8 +2,8 @@ use crate::errors::client_error::ClientError;
use crate::errors::download_error::DownloadError;
use crate::errors::provider_error::ProviderError;
use fal_client::error::fal_error_plus::FalErrorPlus;
use kinovi_web_client::error::kinovi_web_error::KinoviWebError;
use kinovi_web_client::error::kinovi_web_specific_api_error::KinoviWebSpecificApiError;
use seedance2pro_client::error::seedance2pro_error::Seedance2ProError;
use seedance2pro_client::error::seedance2pro_specific_api_error::Seedance2ProSpecificApiError;
use std::error::Error;
use std::fmt::{Display, Formatter};
use artcraft_client::error::api_error::ApiError;
@@ -65,7 +65,7 @@ impl From<ProviderError> for ArtcraftRouterError {
fn from(error: ProviderError) -> Self {
let is_billing_error = match &error {
ProviderError::Fal(FalErrorPlus::FalBillingError(_)) => true,
ProviderError::KinoviWeb(KinoviWebError::ApiSpecific(KinoviWebSpecificApiError::BillingError { .. })) => true,
ProviderError::Seedance2Pro(Seedance2ProError::ApiSpecific(Seedance2ProSpecificApiError::BillingError { .. })) => true,
ProviderError::Storyteller(StorytellerError::Api(ApiError::PaymentRequired(_))) => true,
_ => false,
};
@@ -9,7 +9,7 @@ pub enum ClientType {
Fal,
GmiCloud,
GrokApi,
KinoviWeb,
Seedance2Pro,
WorldLabs,
}
@@ -20,7 +20,7 @@ impl Display for ClientType {
Self::Fal => write!(f, "Fal"),
Self::GmiCloud => write!(f, "GmiCloud"),
Self::GrokApi => write!(f, "GrokApi"),
Self::KinoviWeb => write!(f, "KinoviWeb"),
Self::Seedance2Pro => write!(f, "Seedance2Pro"),
Self::WorldLabs => write!(f, "WorldLabs"),
}
}
@@ -47,8 +47,8 @@ pub enum ClientError {
/// Fal only accepts image URLs for image inputs, not media tokens.
FalOnlySupportsUrls,
/// KinoviWeb only accepts URLs for media inputs, not media tokens.
KinoviWebOnlySupportsUrls,
/// Seedance2Pro only accepts URLs for media inputs, not media tokens.
Seedance2ProOnlySupportsUrls,
/// The Fal endpoint requires a webhook URL but the caller built the client
/// in polling/queue mode (no webhook URL). Returned by webhook-only
@@ -91,8 +91,8 @@ impl Display for ClientError {
Self::FalOnlySupportsUrls => {
write!(f, "Fal only supports image URLs for image inputs, not media tokens")
}
Self::KinoviWebOnlySupportsUrls => {
write!(f, "KinoviWeb only supports URLs for media inputs; resolve media tokens to URLs before calling this provider")
Self::Seedance2ProOnlySupportsUrls => {
write!(f, "Seedance2Pro only supports URLs for media inputs; resolve media tokens to URLs before calling this provider")
}
Self::WebhookUrlRequired => {
write!(f, "This Fal endpoint only supports webhook dispatch; the caller built RouterFalClient in polling-only mode (no webhook URL)")
@@ -4,7 +4,7 @@ use artcraft_client::error::storyteller_error::StorytellerError;
use fal_client::error::fal_error_plus::FalErrorPlus;
use gmicloud_client::error::gmicloud_error::GmiCloudError;
use grok_api_client::error::grok_error::GrokError;
use kinovi_web_client::error::kinovi_web_error::KinoviWebError;
use seedance2pro_client::error::seedance2pro_error::Seedance2ProError;
use worldlabs_api_client::error::world_labs_error::WorldLabsError;
#[derive(Debug)]
@@ -13,7 +13,7 @@ pub enum ProviderError {
Fal(FalErrorPlus),
GmiCloud(GmiCloudError),
Grok(GrokError),
KinoviWeb(KinoviWebError),
Seedance2Pro(Seedance2ProError),
WorldLabs(WorldLabsError),
}
@@ -26,7 +26,7 @@ impl Display for ProviderError {
Self::Fal(e) => write!(f, "Fal provider error: {}", e),
Self::GmiCloud(e) => write!(f, "GmiCloud provider error: {}", e),
Self::Grok(e) => write!(f, "Grok provider error: {}", e),
Self::KinoviWeb(e) => write!(f, "KinoviWeb provider error: {}", e),
Self::Seedance2Pro(e) => write!(f, "Seedance2Pro provider error: {}", e),
Self::WorldLabs(e) => write!(f, "WorldLabs provider error: {}", e),
}
}
@@ -56,9 +56,9 @@ impl From<GrokError> for ProviderError {
}
}
impl From<KinoviWebError> for ProviderError {
fn from(error: KinoviWebError) -> Self {
Self::KinoviWeb(error)
impl From<Seedance2ProError> for ProviderError {
fn from(error: Seedance2ProError) -> Self {
Self::Seedance2Pro(error)
}
}
@@ -26,8 +26,8 @@ impl AudioGenerationDraftRequest {
pub fn get_provider(&self) -> RouterProvider {
match self {
Self::KinoviSunoRemix(_) => RouterProvider::KinoviWeb,
Self::KinoviSunoSample(_) => RouterProvider::KinoviWeb,
Self::KinoviSunoRemix(_) => RouterProvider::Seedance2Pro,
Self::KinoviSunoSample(_) => RouterProvider::Seedance2Pro,
}
}
@@ -4,7 +4,7 @@ use std::fmt::Debug;
use tokens::tokens::media_files::MediaFileToken;
use crate::client::router_client::RouterClient;
use crate::client::router_kinovi_web_client::RouterKinoviWebClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
@@ -19,9 +19,9 @@ pub struct AudioGenerationDraftContext<'a> {
}
impl <'a> AudioGenerationDraftContext<'a> {
pub fn get_kinovi_web_client_ref(&self) -> Result<&RouterKinoviWebClient, ArtcraftRouterError> {
pub fn get_seedance2pro_client_ref(&self) -> Result<&RouterSeedance2ProClient, ArtcraftRouterError> {
let client = self.client.ok_or(ArtcraftRouterError::Client(ClientError::RouterClientNotProvided))?;
client.get_kinovi_web_client_ref()
client.get_seedance2pro_client_ref()
.map_err(|err| ArtcraftRouterError::Client(err))
}
}
@@ -48,10 +48,10 @@ impl AudioGenerationRequest {
Self::ArtcraftSunoSample(_) => RouterProvider::Artcraft,
Self::ArtcraftSeedAudio1p0(_) => RouterProvider::Artcraft,
Self::FalSeedAudio1p0(_) => RouterProvider::Fal,
Self::KinoviSunoMusic(_) => RouterProvider::KinoviWeb,
Self::KinoviSunoRemix(_) => RouterProvider::KinoviWeb,
Self::KinoviSunoSounds(_) => RouterProvider::KinoviWeb,
Self::KinoviSunoSample(_) => RouterProvider::KinoviWeb,
Self::KinoviSunoMusic(_) => RouterProvider::Seedance2Pro,
Self::KinoviSunoRemix(_) => RouterProvider::Seedance2Pro,
Self::KinoviSunoSounds(_) => RouterProvider::Seedance2Pro,
Self::KinoviSunoSample(_) => RouterProvider::Seedance2Pro,
}
}
@@ -100,19 +100,19 @@ impl AudioGenerationRequest {
request.send(client_ref).await
},
AudioGenerationRequest::KinoviSunoMusic(request) => {
let client_ref = client.get_kinovi_web_client_ref()?;
let client_ref = client.get_seedance2pro_client_ref()?;
request.send(client_ref).await
},
AudioGenerationRequest::KinoviSunoRemix(request) => {
let client_ref = client.get_kinovi_web_client_ref()?;
let client_ref = client.get_seedance2pro_client_ref()?;
request.send(client_ref).await
},
AudioGenerationRequest::KinoviSunoSounds(request) => {
let client_ref = client.get_kinovi_web_client_ref()?;
let client_ref = client.get_seedance2pro_client_ref()?;
request.send(client_ref).await
},
AudioGenerationRequest::KinoviSunoSample(request) => {
let client_ref = client.get_kinovi_web_client_ref()?;
let client_ref = client.get_seedance2pro_client_ref()?;
request.send(client_ref).await
},
}
@@ -116,10 +116,10 @@ impl GenerateAudioRequestBuilder {
// Fal
(RouterProvider::Fal, RouterAudioModel::SeedAudio1p0) => build_fal_seed_audio_1p0(self),
// Kinovi
(RouterProvider::KinoviWeb, RouterAudioModel::SunoMusic) => build_kinovi_suno_music(self),
(RouterProvider::KinoviWeb, RouterAudioModel::SunoRemix) => build_kinovi_suno_remix(self),
(RouterProvider::KinoviWeb, RouterAudioModel::SunoSounds) => build_kinovi_suno_sounds(self),
(RouterProvider::KinoviWeb, RouterAudioModel::SunoSample) => build_kinovi_suno_sample(self),
(RouterProvider::Seedance2Pro, RouterAudioModel::SunoMusic) => build_kinovi_suno_music(self),
(RouterProvider::Seedance2Pro, RouterAudioModel::SunoRemix) => build_kinovi_suno_remix(self),
(RouterProvider::Seedance2Pro, RouterAudioModel::SunoSounds) => build_kinovi_suno_sounds(self),
(RouterProvider::Seedance2Pro, RouterAudioModel::SunoSample) => build_kinovi_suno_sample(self),
_ => self.unsupported_provider_and_model(),
}
}
@@ -202,7 +202,7 @@ mod tests {
#[test]
fn kinovi_suno_music_dispatches_to_request() {
let result = builder(RouterProvider::KinoviWeb, RouterAudioModel::SunoMusic).build2().expect("build");
let result = builder(RouterProvider::Seedance2Pro, RouterAudioModel::SunoMusic).build2().expect("build");
assert!(matches!(
result,
AudioGenerationDraftOrRequest::Request(AudioGenerationRequest::KinoviSunoMusic(_))
@@ -211,7 +211,7 @@ mod tests {
#[test]
fn kinovi_suno_sounds_dispatches_to_request() {
let result = builder(RouterProvider::KinoviWeb, RouterAudioModel::SunoSounds).build2().expect("build");
let result = builder(RouterProvider::Seedance2Pro, RouterAudioModel::SunoSounds).build2().expect("build");
assert!(matches!(
result,
AudioGenerationDraftOrRequest::Request(AudioGenerationRequest::KinoviSunoSounds(_))
@@ -220,7 +220,7 @@ mod tests {
#[test]
fn kinovi_suno_remix_dispatches_to_draft() {
let result = builder_with_audio_ref(RouterProvider::KinoviWeb, RouterAudioModel::SunoRemix).build2().expect("build");
let result = builder_with_audio_ref(RouterProvider::Seedance2Pro, RouterAudioModel::SunoRemix).build2().expect("build");
assert!(matches!(
result,
AudioGenerationDraftOrRequest::Draft(AudioGenerationDraftRequest::KinoviSunoRemix(_))
@@ -229,7 +229,7 @@ mod tests {
#[test]
fn kinovi_suno_sample_dispatches_to_draft() {
let result = builder_with_audio_ref(RouterProvider::KinoviWeb, RouterAudioModel::SunoSample).build2().expect("build");
let result = builder_with_audio_ref(RouterProvider::Seedance2Pro, RouterAudioModel::SunoSample).build2().expect("build");
assert!(matches!(
result,
AudioGenerationDraftOrRequest::Draft(AudioGenerationDraftRequest::KinoviSunoSample(_))
@@ -242,7 +242,7 @@ mod tests {
#[test]
fn kinovi_seed_audio_1p0_is_unsupported() {
let result = builder(RouterProvider::KinoviWeb, RouterAudioModel::SeedAudio1p0).build2();
let result = builder(RouterProvider::Seedance2Pro, RouterAudioModel::SeedAudio1p0).build2();
assert!(matches!(result, Err(ArtcraftRouterError::UnsupportedProviderAndModelForNewApi(_))));
}
@@ -10,7 +10,7 @@ pub struct ArtcraftAudioResponsePayload {
}
#[derive(Clone, Debug)]
pub struct KinoviWebAudioResponsePayload {
pub struct Seedance2proAudioResponsePayload {
pub order_id: String,
pub task_id: String,
}
@@ -37,7 +37,7 @@ pub struct FalAudioResponsePayload {
#[derive(Clone, Debug)]
pub enum GenerateAudioResponse {
Artcraft(ArtcraftAudioResponsePayload),
KinoviWeb(KinoviWebAudioResponsePayload),
Seedance2Pro(Seedance2proAudioResponsePayload),
Fal(FalAudioResponsePayload),
}
@@ -49,9 +49,9 @@ impl GenerateAudioResponse {
}
}
pub fn get_kinovi_web_payload(&self) -> Option<KinoviWebAudioResponsePayload> {
pub fn get_seedance2pro_payload(&self) -> Option<Seedance2proAudioResponsePayload> {
match self {
Self::KinoviWeb(p) => Some(p.clone()),
Self::Seedance2Pro(p) => Some(p.clone()),
_ => None,
}
}
@@ -5,14 +5,14 @@
//! draft `finalize()` phase. The upload plumbing is shared with the Kinovi
//! video provider (`generate_video::providers::kinovi::upload`).
use kinovi_web_client::creds::kinovi_web_session::KinoviWebSession;
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::audio_list_ref::AudioListRef;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_audio::audio_generation_draft_context::AudioGenerationDraftContext;
use crate::generate::generate_video::providers::kinovi::upload::upload_to_kinovi_web;
use crate::generate::generate_video::providers::kinovi::upload::upload_to_seedance2pro;
/// A single unresolved audio reference: either an ArtCraft/public URL or a
/// media file token that resolves to one via the draft context's map.
@@ -55,7 +55,7 @@ pub(crate) fn require_single_audio_ref(
/// Resolve a single audio reference to a source URL and re-upload it to the
/// Kinovi CDN, returning the Kinovi CDN URL.
pub(crate) async fn resolve_and_upload_audio_ref(
session: &KinoviWebSession,
session: &Seedance2ProSession,
audio_ref: &SingleAudioRef,
draft_context: &AudioGenerationDraftContext<'_>,
) -> Result<String, ArtcraftRouterError> {
@@ -71,7 +71,7 @@ pub(crate) async fn resolve_and_upload_audio_ref(
})?
}
};
upload_to_kinovi_web(session, &source_url, None).await
upload_to_seedance2pro(session, &source_url).await
}
#[cfg(test)]
@@ -1,4 +1,4 @@
use kinovi_web_client::generate::audio::generate_suno_music::GenerateSunoMusicRequest;
use seedance2pro_client::generate::audio::generate_suno_music::GenerateSunoMusicRequest;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_audio::audio_generation_draft_or_request::AudioGenerationDraftOrRequest;
@@ -119,7 +119,7 @@ mod tests {
fn base_builder() -> GenerateAudioRequestBuilder {
GenerateAudioRequestBuilder {
model: RouterAudioModel::SunoMusic,
provider: RouterProvider::KinoviWeb,
provider: RouterProvider::Seedance2Pro,
prompt: Some("a song about corgis".to_string()),
style_prompt: Some("sea shanty".to_string()),
..Default::default()
@@ -1,4 +1,4 @@
use kinovi_web_client::generate::audio::generate_suno_music::GenerateSunoMusicRequest;
use seedance2pro_client::generate::audio::generate_suno_music::GenerateSunoMusicRequest;
use crate::generate::generate_audio::audio_generation_cost_estimate::AudioGenerationCostEstimate;
use crate::generate::generate_audio::providers::kinovi::suno_music::request::KinoviSunoMusicRequestState;
@@ -13,7 +13,7 @@ impl KinoviSunoMusicCostState {
}
pub fn estimate_cost(&self) -> AudioGenerationCostEstimate {
// Cost math is owned by kinovi_web_client's binding — the router just
// Cost math is owned by seedance2pro_client's binding — the router just
// forwards the result so router cost ≡ binding cost by construction.
let pricing_request = GenerateSunoMusicRequest {
prompt: String::new(),
@@ -54,7 +54,7 @@ mod tests {
fn cost_from_built_request_is_seven_cents() {
let builder = GenerateAudioRequestBuilder {
model: RouterAudioModel::SunoMusic,
provider: RouterProvider::KinoviWeb,
provider: RouterProvider::Seedance2Pro,
prompt: Some("a song".to_string()),
..Default::default()
};
@@ -1,12 +1,12 @@
use kinovi_web_client::generate::audio::generate_suno_music::{
use seedance2pro_client::generate::audio::generate_suno_music::{
generate_suno_music, GenerateSunoMusicArgs, GenerateSunoMusicRequest,
};
use crate::client::router_kinovi_web_client::RouterKinoviWebClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_audio::generate_audio_response::{
GenerateAudioResponse, KinoviWebAudioResponsePayload,
GenerateAudioResponse, Seedance2proAudioResponsePayload,
};
#[derive(Debug, Clone)]
@@ -16,7 +16,7 @@ pub struct KinoviSunoMusicRequestState {
}
impl KinoviSunoMusicRequestState {
pub async fn send(&self, client: &RouterKinoviWebClient) -> Result<GenerateAudioResponse, ArtcraftRouterError> {
pub async fn send(&self, client: &RouterSeedance2ProClient) -> Result<GenerateAudioResponse, ArtcraftRouterError> {
let session = &client.session;
let args = GenerateSunoMusicArgs {
@@ -27,9 +27,9 @@ impl KinoviSunoMusicRequestState {
let response = generate_suno_music(args)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::KinoviWeb(err)))?;
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?;
Ok(GenerateAudioResponse::KinoviWeb(KinoviWebAudioResponsePayload {
Ok(GenerateAudioResponse::Seedance2Pro(Seedance2proAudioResponsePayload {
order_id: response.order_id,
task_id: response.task_id,
}))
@@ -38,12 +38,12 @@ impl KinoviSunoMusicRequestState {
#[cfg(test)]
mod tests {
use kinovi_web_client::creds::kinovi_web_session::KinoviWebSession;
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use crate::api::router_audio_model::RouterAudioModel;
use crate::api::router_provider::RouterProvider;
use crate::client::router_client::RouterClient;
use crate::client::router_kinovi_web_client::RouterKinoviWebClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::generate::generate_audio::audio_generation_draft_or_request::AudioGenerationDraftOrRequest;
use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder;
use crate::generate::generate_audio::generate_audio_response::GenerateAudioResponse;
@@ -56,7 +56,7 @@ mod tests {
style_prompt: Some("Sea shanty, folk".to_string()),
..suno_music_builder()
}).await;
assert!(matches!(response, GenerateAudioResponse::KinoviWeb(_)));
assert!(matches!(response, GenerateAudioResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
@@ -69,7 +69,7 @@ mod tests {
is_instrumental: Some(true),
..suno_music_builder()
}).await;
assert!(matches!(response, GenerateAudioResponse::KinoviWeb(_)));
assert!(matches!(response, GenerateAudioResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
@@ -78,20 +78,20 @@ mod tests {
fn suno_music_builder() -> GenerateAudioRequestBuilder {
GenerateAudioRequestBuilder {
model: RouterAudioModel::SunoMusic,
provider: RouterProvider::KinoviWeb,
provider: RouterProvider::Seedance2Pro,
..Default::default()
}
}
fn get_kinovi_web_client() -> RouterClient {
fn get_seedance2pro_client() -> RouterClient {
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/seedance2pro_cookies.txt")
.expect("Failed to read kinovi_web cookies");
let session = KinoviWebSession::from_cookies_string(cookies.trim().to_string());
RouterClient::KinoviWeb(RouterKinoviWebClient::new(session))
.expect("Failed to read seedance2pro cookies");
let session = Seedance2ProSession::from_cookies_string(cookies.trim().to_string());
RouterClient::Seedance2Pro(RouterSeedance2ProClient::new(session))
}
async fn run_pipeline(builder: GenerateAudioRequestBuilder) -> GenerateAudioResponse {
let client = get_kinovi_web_client();
let client = get_seedance2pro_client();
let draft_or_request = builder.build2().expect("build2 should succeed");
let request = match draft_or_request {
@@ -102,7 +102,7 @@ mod tests {
let response = request.send_request(&client).await.expect("send_request should succeed");
match &response {
GenerateAudioResponse::KinoviWeb(p) => {
GenerateAudioResponse::Seedance2Pro(p) => {
println!("task_id={}, order_id={}", p.task_id, p.order_id);
}
other => println!("response: {:?}", other),
@@ -168,7 +168,7 @@ mod tests {
fn base_builder() -> GenerateAudioRequestBuilder {
GenerateAudioRequestBuilder {
model: RouterAudioModel::SunoRemix,
provider: RouterProvider::KinoviWeb,
provider: RouterProvider::Seedance2Pro,
prompt: Some("make this electronic".to_string()),
style_prompt: Some("EDM style".to_string()),
audio_references: Some(AudioListRef::Urls(vec!["https://example.com/a.mp3".to_string()])),
@@ -1,4 +1,4 @@
use kinovi_web_client::generate::audio::generate_suno_remix::{
use seedance2pro_client::generate::audio::generate_suno_remix::{
GenerateSunoRemixRequest, KinoviSunoRemixSource,
};
@@ -20,7 +20,7 @@ impl KinoviSunoRemixCostState {
}
pub fn estimate_cost(&self) -> AudioGenerationCostEstimate {
// Cost math is owned by kinovi_web_client's binding — the router just
// Cost math is owned by seedance2pro_client's binding — the router just
// forwards the result so router cost ≡ binding cost by construction.
let pricing_request = GenerateSunoRemixRequest {
prompt: String::new(),
@@ -63,7 +63,7 @@ mod tests {
fn cost_from_draft_is_seven_cents() {
let builder = GenerateAudioRequestBuilder {
model: RouterAudioModel::SunoRemix,
provider: RouterProvider::KinoviWeb,
provider: RouterProvider::Seedance2Pro,
prompt: Some("make this electronic".to_string()),
audio_references: Some(AudioListRef::Urls(vec!["https://example.com/a.mp3".to_string()])),
..Default::default()
@@ -1,4 +1,4 @@
use kinovi_web_client::generate::audio::generate_suno_remix::{
use seedance2pro_client::generate::audio::generate_suno_remix::{
GenerateSunoRemixRequest, KinoviSunoRemixSource,
};
@@ -26,7 +26,7 @@ impl KinoviSunoRemixDraftState {
&mut self,
draft_context: &AudioGenerationDraftContext<'_>,
) -> Result<KinoviSunoRemixRequestState, ArtcraftRouterError> {
let client = draft_context.get_kinovi_web_client_ref()?;
let client = draft_context.get_seedance2pro_client_ref()?;
let session = &client.session;
let audio_source = self.audio_source.take().ok_or_else(|| {
@@ -1,12 +1,12 @@
use kinovi_web_client::generate::audio::generate_suno_remix::{
use seedance2pro_client::generate::audio::generate_suno_remix::{
generate_suno_remix, GenerateSunoRemixArgs, GenerateSunoRemixRequest,
};
use crate::client::router_kinovi_web_client::RouterKinoviWebClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_audio::generate_audio_response::{
GenerateAudioResponse, KinoviWebAudioResponsePayload,
GenerateAudioResponse, Seedance2proAudioResponsePayload,
};
#[derive(Debug, Clone)]
@@ -17,7 +17,7 @@ pub struct KinoviSunoRemixRequestState {
}
impl KinoviSunoRemixRequestState {
pub async fn send(&self, client: &RouterKinoviWebClient) -> Result<GenerateAudioResponse, ArtcraftRouterError> {
pub async fn send(&self, client: &RouterSeedance2ProClient) -> Result<GenerateAudioResponse, ArtcraftRouterError> {
let session = &client.session;
let args = GenerateSunoRemixArgs {
@@ -28,9 +28,9 @@ impl KinoviSunoRemixRequestState {
let response = generate_suno_remix(args)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::KinoviWeb(err)))?;
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?;
Ok(GenerateAudioResponse::KinoviWeb(KinoviWebAudioResponsePayload {
Ok(GenerateAudioResponse::Seedance2Pro(Seedance2proAudioResponsePayload {
order_id: response.order_id,
task_id: response.task_id,
}))
@@ -39,16 +39,16 @@ impl KinoviSunoRemixRequestState {
#[cfg(test)]
mod tests {
use kinovi_web_client::creds::kinovi_web_session::KinoviWebSession;
use kinovi_web_client::requests::prepare_file_upload::prepare_file_upload::{prepare_file_upload, PrepareFileUploadArgs};
use kinovi_web_client::requests::upload_file::upload_file::{upload_file, UploadFileArgs};
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use seedance2pro_client::requests::prepare_file_upload::prepare_file_upload::{prepare_file_upload, PrepareFileUploadArgs};
use seedance2pro_client::requests::upload_file::upload_file::{upload_file, UploadFileArgs};
use test_utils::test_file_path::test_file_path;
use crate::api::audio_list_ref::AudioListRef;
use crate::api::router_audio_model::RouterAudioModel;
use crate::api::router_provider::RouterProvider;
use crate::client::router_client::RouterClient;
use crate::client::router_kinovi_web_client::RouterKinoviWebClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::generate::generate_audio::audio_generation_draft_context::AudioGenerationDraftContext;
use crate::generate::generate_audio::audio_generation_draft_or_request::AudioGenerationDraftOrRequest;
use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder;
@@ -59,13 +59,13 @@ mod tests {
#[tokio::test]
#[ignore] // Sends a real generation to Kinovi; costs credits. Requires a local audio file.
async fn remix_uploaded_audio() {
let client = get_kinovi_web_client();
let client = get_seedance2pro_client();
let audio_url = upload_test_audio(&client).await;
println!("Uploaded audio: {}", audio_url);
let builder = GenerateAudioRequestBuilder {
model: RouterAudioModel::SunoRemix,
provider: RouterProvider::KinoviWeb,
provider: RouterProvider::Seedance2Pro,
prompt: Some("Make this electronic".to_string()),
style_prompt: Some("EDM style".to_string()),
audio_references: Some(AudioListRef::Urls(vec![audio_url])),
@@ -73,17 +73,17 @@ mod tests {
};
let response = run_pipeline(&client, builder).await;
assert!(matches!(response, GenerateAudioResponse::KinoviWeb(_)));
assert!(matches!(response, GenerateAudioResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
// ── Helpers ──
fn get_kinovi_web_client() -> RouterClient {
fn get_seedance2pro_client() -> RouterClient {
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/seedance2pro_cookies.txt")
.expect("Failed to read kinovi_web cookies");
let session = KinoviWebSession::from_cookies_string(cookies.trim().to_string());
RouterClient::KinoviWeb(RouterKinoviWebClient::new(session))
.expect("Failed to read seedance2pro cookies");
let session = Seedance2ProSession::from_cookies_string(cookies.trim().to_string());
RouterClient::Seedance2Pro(RouterSeedance2ProClient::new(session))
}
async fn run_pipeline(client: &RouterClient, builder: GenerateAudioRequestBuilder) -> GenerateAudioResponse {
@@ -102,7 +102,7 @@ mod tests {
let response = request.send_request(client).await.expect("send_request should succeed");
match &response {
GenerateAudioResponse::KinoviWeb(p) => {
GenerateAudioResponse::Seedance2Pro(p) => {
println!("task_id={}, order_id={}", p.task_id, p.order_id);
}
other => println!("response: {:?}", other),
@@ -112,7 +112,7 @@ mod tests {
}
async fn upload_test_audio(client: &RouterClient) -> String {
let session = &client.get_kinovi_web_client_ref().expect("kinovi_web client").session;
let session = &client.get_seedance2pro_client_ref().expect("seedance2pro client").session;
let audio_path = test_file_path(TEST_AUDIO_PATH).expect("test audio should exist");
let audio_bytes = std::fs::read(&audio_path).expect("read test audio");
@@ -162,7 +162,7 @@ mod tests {
fn base_builder() -> GenerateAudioRequestBuilder {
GenerateAudioRequestBuilder {
model: RouterAudioModel::SunoSample,
provider: RouterProvider::KinoviWeb,
provider: RouterProvider::Seedance2Pro,
prompt: Some("mystical RPG adventure".to_string()),
style_prompt: Some("fantasy video game".to_string()),
audio_references: Some(AudioListRef::Urls(vec!["https://example.com/a.aac".to_string()])),
@@ -1,4 +1,4 @@
use kinovi_web_client::generate::audio::generate_suno_sample::GenerateSunoSampleRequest;
use seedance2pro_client::generate::audio::generate_suno_sample::GenerateSunoSampleRequest;
use crate::generate::generate_audio::audio_generation_cost_estimate::AudioGenerationCostEstimate;
use crate::generate::generate_audio::providers::kinovi::suno_sample::draft::KinoviSunoSampleDraftState;
@@ -18,7 +18,7 @@ impl KinoviSunoSampleCostState {
}
pub fn estimate_cost(&self) -> AudioGenerationCostEstimate {
// Cost math is owned by kinovi_web_client's binding — the router just
// Cost math is owned by seedance2pro_client's binding — the router just
// forwards the result so router cost ≡ binding cost by construction.
let pricing_request = GenerateSunoSampleRequest {
prompt: String::new(),
@@ -63,7 +63,7 @@ mod tests {
fn cost_from_draft_is_seven_cents() {
let builder = GenerateAudioRequestBuilder {
model: RouterAudioModel::SunoSample,
provider: RouterProvider::KinoviWeb,
provider: RouterProvider::Seedance2Pro,
prompt: Some("mystical RPG adventure".to_string()),
audio_references: Some(AudioListRef::Urls(vec!["https://example.com/a.aac".to_string()])),
..Default::default()
@@ -1,4 +1,4 @@
use kinovi_web_client::generate::audio::generate_suno_sample::GenerateSunoSampleRequest;
use seedance2pro_client::generate::audio::generate_suno_sample::GenerateSunoSampleRequest;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_audio::audio_generation_draft_context::AudioGenerationDraftContext;
@@ -34,7 +34,7 @@ impl KinoviSunoSampleDraftState {
&mut self,
draft_context: &AudioGenerationDraftContext<'_>,
) -> Result<KinoviSunoSampleRequestState, ArtcraftRouterError> {
let client = draft_context.get_kinovi_web_client_ref()?;
let client = draft_context.get_seedance2pro_client_ref()?;
let session = &client.session;
let audio_source = self.audio_source.take().ok_or_else(|| {
@@ -1,12 +1,12 @@
use kinovi_web_client::generate::audio::generate_suno_sample::{
use seedance2pro_client::generate::audio::generate_suno_sample::{
generate_suno_sample, GenerateSunoSampleArgs, GenerateSunoSampleRequest,
};
use crate::client::router_kinovi_web_client::RouterKinoviWebClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_audio::generate_audio_response::{
GenerateAudioResponse, KinoviWebAudioResponsePayload,
GenerateAudioResponse, Seedance2proAudioResponsePayload,
};
#[derive(Debug, Clone)]
@@ -17,7 +17,7 @@ pub struct KinoviSunoSampleRequestState {
}
impl KinoviSunoSampleRequestState {
pub async fn send(&self, client: &RouterKinoviWebClient) -> Result<GenerateAudioResponse, ArtcraftRouterError> {
pub async fn send(&self, client: &RouterSeedance2ProClient) -> Result<GenerateAudioResponse, ArtcraftRouterError> {
let session = &client.session;
let args = GenerateSunoSampleArgs {
@@ -28,9 +28,9 @@ impl KinoviSunoSampleRequestState {
let response = generate_suno_sample(args)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::KinoviWeb(err)))?;
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?;
Ok(GenerateAudioResponse::KinoviWeb(KinoviWebAudioResponsePayload {
Ok(GenerateAudioResponse::Seedance2Pro(Seedance2proAudioResponsePayload {
order_id: response.order_id,
task_id: response.task_id,
}))
@@ -39,16 +39,16 @@ impl KinoviSunoSampleRequestState {
#[cfg(test)]
mod tests {
use kinovi_web_client::creds::kinovi_web_session::KinoviWebSession;
use kinovi_web_client::requests::prepare_file_upload::prepare_file_upload::{prepare_file_upload, PrepareFileUploadArgs};
use kinovi_web_client::requests::upload_file::upload_file::{upload_file, UploadFileArgs};
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use seedance2pro_client::requests::prepare_file_upload::prepare_file_upload::{prepare_file_upload, PrepareFileUploadArgs};
use seedance2pro_client::requests::upload_file::upload_file::{upload_file, UploadFileArgs};
use test_utils::test_file_path::test_file_path;
use crate::api::audio_list_ref::AudioListRef;
use crate::api::router_audio_model::RouterAudioModel;
use crate::api::router_provider::RouterProvider;
use crate::client::router_client::RouterClient;
use crate::client::router_kinovi_web_client::RouterKinoviWebClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::generate::generate_audio::audio_generation_draft_context::AudioGenerationDraftContext;
use crate::generate::generate_audio::audio_generation_draft_or_request::AudioGenerationDraftOrRequest;
use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder;
@@ -59,13 +59,13 @@ mod tests {
#[tokio::test]
#[ignore] // Sends a real generation to Kinovi; costs credits. Requires a local audio file.
async fn sample_instrumental() {
let client = get_kinovi_web_client();
let client = get_seedance2pro_client();
let audio_url = upload_test_audio(&client).await;
println!("Uploaded audio: {}", audio_url);
let builder = GenerateAudioRequestBuilder {
model: RouterAudioModel::SunoSample,
provider: RouterProvider::KinoviWeb,
provider: RouterProvider::Seedance2Pro,
prompt: Some("Mystical RPG adventure, make it have a grand climax".to_string()),
style_prompt: Some("Fantasy video game score".to_string()),
is_instrumental: Some(true),
@@ -74,17 +74,17 @@ mod tests {
};
let response = run_pipeline(&client, builder).await;
assert!(matches!(response, GenerateAudioResponse::KinoviWeb(_)));
assert!(matches!(response, GenerateAudioResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
// ── Helpers ──
fn get_kinovi_web_client() -> RouterClient {
fn get_seedance2pro_client() -> RouterClient {
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/seedance2pro_cookies.txt")
.expect("Failed to read kinovi_web cookies");
let session = KinoviWebSession::from_cookies_string(cookies.trim().to_string());
RouterClient::KinoviWeb(RouterKinoviWebClient::new(session))
.expect("Failed to read seedance2pro cookies");
let session = Seedance2ProSession::from_cookies_string(cookies.trim().to_string());
RouterClient::Seedance2Pro(RouterSeedance2ProClient::new(session))
}
async fn run_pipeline(client: &RouterClient, builder: GenerateAudioRequestBuilder) -> GenerateAudioResponse {
@@ -103,7 +103,7 @@ mod tests {
let response = request.send_request(client).await.expect("send_request should succeed");
match &response {
GenerateAudioResponse::KinoviWeb(p) => {
GenerateAudioResponse::Seedance2Pro(p) => {
println!("task_id={}, order_id={}", p.task_id, p.order_id);
}
other => println!("response: {:?}", other),
@@ -113,7 +113,7 @@ mod tests {
}
async fn upload_test_audio(client: &RouterClient) -> String {
let session = &client.get_kinovi_web_client_ref().expect("kinovi_web client").session;
let session = &client.get_seedance2pro_client_ref().expect("seedance2pro client").session;
let audio_path = test_file_path(TEST_AUDIO_PATH).expect("test audio should exist");
let audio_bytes = std::fs::read(&audio_path).expect("read test audio");
@@ -1,5 +1,5 @@
use enums::common::generation::common_musical_key::CommonMusicalKey;
use kinovi_web_client::generate::audio::generate_suno_sound::{
use seedance2pro_client::generate::audio::generate_suno_sound::{
GenerateSunoSoundRequest, KinoviSunoSoundKey, KinoviSunoSoundType,
};
@@ -201,7 +201,7 @@ mod tests {
fn base_builder() -> GenerateAudioRequestBuilder {
GenerateAudioRequestBuilder {
model: RouterAudioModel::SunoSounds,
provider: RouterProvider::KinoviWeb,
provider: RouterProvider::Seedance2Pro,
prompt: Some("rain sound effects".to_string()),
..Default::default()
}
@@ -1,4 +1,4 @@
use kinovi_web_client::generate::audio::generate_suno_sound::{
use seedance2pro_client::generate::audio::generate_suno_sound::{
GenerateSunoSoundRequest, KinoviSunoSoundKey, KinoviSunoSoundType,
};
@@ -15,7 +15,7 @@ impl KinoviSunoSoundsCostState {
}
pub fn estimate_cost(&self) -> AudioGenerationCostEstimate {
// Cost math is owned by kinovi_web_client's binding — the router just
// Cost math is owned by seedance2pro_client's binding — the router just
// forwards the result so router cost ≡ binding cost by construction.
let pricing_request = GenerateSunoSoundRequest {
prompt: String::new(),
@@ -57,7 +57,7 @@ mod tests {
fn cost_from_built_request_is_two_cents() {
let builder = GenerateAudioRequestBuilder {
model: RouterAudioModel::SunoSounds,
provider: RouterProvider::KinoviWeb,
provider: RouterProvider::Seedance2Pro,
prompt: Some("thunder".to_string()),
is_loopable: Some(true),
bpm: Some(60),
@@ -1,12 +1,12 @@
use kinovi_web_client::generate::audio::generate_suno_sound::{
use seedance2pro_client::generate::audio::generate_suno_sound::{
generate_suno_sound, GenerateSunoSoundArgs, GenerateSunoSoundRequest,
};
use crate::client::router_kinovi_web_client::RouterKinoviWebClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_audio::generate_audio_response::{
GenerateAudioResponse, KinoviWebAudioResponsePayload,
GenerateAudioResponse, Seedance2proAudioResponsePayload,
};
#[derive(Debug, Clone)]
@@ -16,7 +16,7 @@ pub struct KinoviSunoSoundsRequestState {
}
impl KinoviSunoSoundsRequestState {
pub async fn send(&self, client: &RouterKinoviWebClient) -> Result<GenerateAudioResponse, ArtcraftRouterError> {
pub async fn send(&self, client: &RouterSeedance2ProClient) -> Result<GenerateAudioResponse, ArtcraftRouterError> {
let session = &client.session;
let args = GenerateSunoSoundArgs {
@@ -27,9 +27,9 @@ impl KinoviSunoSoundsRequestState {
let response = generate_suno_sound(args)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::KinoviWeb(err)))?;
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?;
Ok(GenerateAudioResponse::KinoviWeb(KinoviWebAudioResponsePayload {
Ok(GenerateAudioResponse::Seedance2Pro(Seedance2proAudioResponsePayload {
order_id: response.order_id,
task_id: response.task_id,
}))
@@ -39,12 +39,12 @@ impl KinoviSunoSoundsRequestState {
#[cfg(test)]
mod tests {
use enums::common::generation::common_musical_key::CommonMusicalKey;
use kinovi_web_client::creds::kinovi_web_session::KinoviWebSession;
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use crate::api::router_audio_model::RouterAudioModel;
use crate::api::router_provider::RouterProvider;
use crate::client::router_client::RouterClient;
use crate::client::router_kinovi_web_client::RouterKinoviWebClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::generate::generate_audio::audio_generation_draft_or_request::AudioGenerationDraftOrRequest;
use crate::generate::generate_audio::generate_audio_request_builder::GenerateAudioRequestBuilder;
use crate::generate::generate_audio::generate_audio_response::GenerateAudioResponse;
@@ -56,7 +56,7 @@ mod tests {
prompt: Some("A heavy castle door creaking open".to_string()),
..suno_sounds_builder()
}).await;
assert!(matches!(response, GenerateAudioResponse::KinoviWeb(_)));
assert!(matches!(response, GenerateAudioResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
@@ -70,7 +70,7 @@ mod tests {
musical_key: Some(CommonMusicalKey::AMinor),
..suno_sounds_builder()
}).await;
assert!(matches!(response, GenerateAudioResponse::KinoviWeb(_)));
assert!(matches!(response, GenerateAudioResponse::Seedance2Pro(_)));
assert_eq!(1, 2, "Inspect output above");
}
@@ -79,20 +79,20 @@ mod tests {
fn suno_sounds_builder() -> GenerateAudioRequestBuilder {
GenerateAudioRequestBuilder {
model: RouterAudioModel::SunoSounds,
provider: RouterProvider::KinoviWeb,
provider: RouterProvider::Seedance2Pro,
..Default::default()
}
}
fn get_kinovi_web_client() -> RouterClient {
fn get_seedance2pro_client() -> RouterClient {
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/seedance2pro_cookies.txt")
.expect("Failed to read kinovi_web cookies");
let session = KinoviWebSession::from_cookies_string(cookies.trim().to_string());
RouterClient::KinoviWeb(RouterKinoviWebClient::new(session))
.expect("Failed to read seedance2pro cookies");
let session = Seedance2ProSession::from_cookies_string(cookies.trim().to_string());
RouterClient::Seedance2Pro(RouterSeedance2ProClient::new(session))
}
async fn run_pipeline(builder: GenerateAudioRequestBuilder) -> GenerateAudioResponse {
let client = get_kinovi_web_client();
let client = get_seedance2pro_client();
let draft_or_request = builder.build2().expect("build2 should succeed");
let request = match draft_or_request {
@@ -103,7 +103,7 @@ mod tests {
let response = request.send_request(&client).await.expect("send_request should succeed");
match &response {
GenerateAudioResponse::KinoviWeb(p) => {
GenerateAudioResponse::Seedance2Pro(p) => {
println!("task_id={}, order_id={}", p.task_id, p.order_id);
}
other => println!("response: {:?}", other),
@@ -146,12 +146,12 @@ impl GenerateImageRequestBuilder {
(RouterProvider::Fal, RouterImageModel::QwenEdit2511Angles) => build_fal_qwen_edit_2511_angles(self),
(RouterProvider::Fal, RouterImageModel::Flux2LoraAngles) => build_fal_flux_2_lora_angles(self),
// Kinovi / KinoviWeb (Midjourney + Seedream)
(RouterProvider::KinoviWeb, RouterImageModel::Midjourney7) => build_kinovi_midjourney_7(self),
(RouterProvider::KinoviWeb, RouterImageModel::Midjourney7Niji) => build_kinovi_midjourney_7_niji(self),
(RouterProvider::KinoviWeb, RouterImageModel::Midjourney8) => build_kinovi_midjourney_8(self),
(RouterProvider::KinoviWeb, RouterImageModel::Seedream5p0Pro) => build_kinovi_seedream_5p0_pro(self),
(RouterProvider::KinoviWeb, RouterImageModel::Seedream5p0ProUltra) => build_kinovi_seedream_5p0_pro(self),
// Kinovi / Seedance2Pro (Midjourney + Seedream)
(RouterProvider::Seedance2Pro, RouterImageModel::Midjourney7) => build_kinovi_midjourney_7(self),
(RouterProvider::Seedance2Pro, RouterImageModel::Midjourney7Niji) => build_kinovi_midjourney_7_niji(self),
(RouterProvider::Seedance2Pro, RouterImageModel::Midjourney8) => build_kinovi_midjourney_8(self),
(RouterProvider::Seedance2Pro, RouterImageModel::Seedream5p0Pro) => build_kinovi_seedream_5p0_pro(self),
(RouterProvider::Seedance2Pro, RouterImageModel::Seedream5p0ProUltra) => build_kinovi_seedream_5p0_pro(self),
_ => self.unsupported_provider_and_model(),
}
@@ -25,11 +25,11 @@ pub struct FalImageResponsePayload {
pub maybe_outbound_request: Option<Arc<dyn Debug + Send + Sync>>,
}
/// Response from the KinoviWeb/Kinovi provider (used for Midjourney
/// image generation). Mirrors `KinoviWebVideoResponsePayload` on the
/// Response from the Seedance2Pro/Kinovi provider (used for Midjourney
/// image generation). Mirrors `Seedance2proVideoResponsePayload` on the
/// video side.
#[derive(Clone, Debug)]
pub struct KinoviWebImageResponsePayload {
pub struct Seedance2proImageResponsePayload {
pub order_id: String,
pub task_id: String,
pub maybe_order_ids: Option<Vec<String>>,
@@ -40,7 +40,7 @@ pub struct KinoviWebImageResponsePayload {
pub enum GenerateImageResponse {
Artcraft(ArtcraftImageResponsePayload),
Fal(FalImageResponsePayload),
KinoviWeb(KinoviWebImageResponsePayload),
Seedance2Pro(Seedance2proImageResponsePayload),
}
impl GenerateImageResponse {
@@ -58,9 +58,9 @@ impl GenerateImageResponse {
}
}
pub fn get_kinovi_web_payload(&self) -> Option<KinoviWebImageResponsePayload> {
pub fn get_seedance2pro_payload(&self) -> Option<Seedance2proImageResponsePayload> {
match self {
Self::KinoviWeb(p) => Some(p.clone()),
Self::Seedance2Pro(p) => Some(p.clone()),
_ => None,
}
}
@@ -17,7 +17,7 @@ use crate::generate::generate_image::providers::kinovi::seedream_5p0_pro::draft:
/// Drafts hold validated/planned parameters but may still need media
/// resolution (e.g. uploading local files to a provider) before they can
/// be sent. Today only the Kinovi models need this — when the caller
/// supplies reference images, we must upload them to the KinoviWeb
/// supplies reference images, we must upload them to the Seedance2Pro
/// CDN before sending the request.
#[derive(Clone, Debug)]
pub enum ImageGenerationDraftRequest {
@@ -30,10 +30,10 @@ pub enum ImageGenerationDraftRequest {
impl ImageGenerationDraftRequest {
pub fn get_provider(&self) -> RouterProvider {
match self {
Self::KinoviMidjourney7(_) => RouterProvider::KinoviWeb,
Self::KinoviMidjourney7Niji(_) => RouterProvider::KinoviWeb,
Self::KinoviMidjourney8(_) => RouterProvider::KinoviWeb,
Self::KinoviSeedream5p0Pro(_) => RouterProvider::KinoviWeb,
Self::KinoviMidjourney7(_) => RouterProvider::Seedance2Pro,
Self::KinoviMidjourney7Niji(_) => RouterProvider::Seedance2Pro,
Self::KinoviMidjourney8(_) => RouterProvider::Seedance2Pro,
Self::KinoviSeedream5p0Pro(_) => RouterProvider::Seedance2Pro,
}
}
@@ -4,21 +4,21 @@ use std::fmt::Debug;
use tokens::tokens::media_files::MediaFileToken;
use crate::client::router_client::RouterClient;
use crate::client::router_kinovi_web_client::RouterKinoviWebClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
/// Context passed when finalizing an image draft into a concrete request.
///
/// Drafts only exist for providers that need to upload assets before
/// sending the request. Today that's the KinoviWeb/Kinovi pipeline
/// sending the request. Today that's the Seedance2Pro/Kinovi pipeline
/// (used for Midjourney image generation with reference images); other
/// providers — Artcraft, Fal — accept image URLs directly and skip the
/// draft phase entirely.
#[derive(Clone, Default)]
pub struct ImageGenerationDraftContext<'a> {
/// Optional: the router client, needed for providers that require
/// authenticated calls (e.g. KinoviWeb/Kinovi uploads).
/// authenticated calls (e.g. Seedance2Pro/Kinovi uploads).
pub client: Option<&'a RouterClient>,
/// Optional: a map of Media File Tokens to their ArtCraft URLs.
@@ -29,9 +29,9 @@ pub struct ImageGenerationDraftContext<'a> {
}
impl<'a> ImageGenerationDraftContext<'a> {
pub fn get_kinovi_web_client_ref(&self) -> Result<&RouterKinoviWebClient, ArtcraftRouterError> {
pub fn get_seedance2pro_client_ref(&self) -> Result<&RouterSeedance2ProClient, ArtcraftRouterError> {
let client = self.client.ok_or(ArtcraftRouterError::Client(ClientError::RouterClientNotProvided))?;
client.get_kinovi_web_client_ref()
client.get_seedance2pro_client_ref()
.map_err(ArtcraftRouterError::Client)
}
}
@@ -123,7 +123,7 @@ pub enum ImageGenerationRequest {
FalQwenEdit2511Angles(FalQwenEdit2511AnglesRequestState),
FalFlux2LoraAngles(FalFlux2LoraAnglesRequestState),
// ── Kinovi / KinoviWeb provider (Midjourney + Seedream image generation) ──
// ── Kinovi / Seedance2Pro provider (Midjourney + Seedream image generation) ──
KinoviMidjourney7(KinoviMidjourney7RequestState),
KinoviMidjourney7Niji(KinoviMidjourney7NijiRequestState),
KinoviMidjourney8(KinoviMidjourney8RequestState),
@@ -170,10 +170,10 @@ impl ImageGenerationRequest {
Self::FalQwenEdit2511Angles(_) => RouterProvider::Fal,
Self::FalFlux2LoraAngles(_) => RouterProvider::Fal,
Self::KinoviMidjourney7(_) => RouterProvider::KinoviWeb,
Self::KinoviMidjourney7Niji(_) => RouterProvider::KinoviWeb,
Self::KinoviMidjourney8(_) => RouterProvider::KinoviWeb,
Self::KinoviSeedream5p0Pro(_) => RouterProvider::KinoviWeb,
Self::KinoviMidjourney7(_) => RouterProvider::Seedance2Pro,
Self::KinoviMidjourney7Niji(_) => RouterProvider::Seedance2Pro,
Self::KinoviMidjourney8(_) => RouterProvider::Seedance2Pro,
Self::KinoviSeedream5p0Pro(_) => RouterProvider::Seedance2Pro,
}
}
@@ -416,24 +416,24 @@ impl ImageGenerationRequest {
request.send(fal_client).await
}
// ── Kinovi / KinoviWeb (Midjourney + Seedream) ──
// ── Kinovi / Seedance2Pro (Midjourney + Seedream) ──
Self::KinoviMidjourney7(request) => {
let seedance_client = client.get_kinovi_web_client_ref()
let seedance_client = client.get_seedance2pro_client_ref()
.map_err(ArtcraftRouterError::Client)?;
request.send(seedance_client).await
}
Self::KinoviMidjourney7Niji(request) => {
let seedance_client = client.get_kinovi_web_client_ref()
let seedance_client = client.get_seedance2pro_client_ref()
.map_err(ArtcraftRouterError::Client)?;
request.send(seedance_client).await
}
Self::KinoviMidjourney8(request) => {
let seedance_client = client.get_kinovi_web_client_ref()
let seedance_client = client.get_seedance2pro_client_ref()
.map_err(ArtcraftRouterError::Client)?;
request.send(seedance_client).await
}
Self::KinoviSeedream5p0Pro(request) => {
let seedance_client = client.get_kinovi_web_client_ref()
let seedance_client = client.get_seedance2pro_client_ref()
.map_err(ArtcraftRouterError::Client)?;
request.send(seedance_client).await
}
@@ -22,15 +22,14 @@ use crate::generate::generate_image::generate_image_request_builder::GenerateIma
/// Per-model build functions are expected to validate model-specific
/// constraints (e.g. unsupported aspect ratios) BEFORE calling this helper.
/// Validation that's identical across every Artcraft image model — batch count
/// (1..=[`max_batch_count`]) and image_inputs shape — lives here.
/// (1..=4) and image_inputs shape — lives here.
pub fn build_artcraft_omni_image_request(
builder: GenerateImageRequestBuilder,
model: CommonImageModelEnum,
) -> Result<OmniGenImageCostAndGenerateRequest, ArtcraftRouterError> {
let strategy = builder.request_mismatch_mitigation_strategy;
let image_batch_count =
plan_batch_count_with_max(builder.image_batch_count, strategy, max_batch_count(model))?;
let image_batch_count = plan_batch_count(builder.image_batch_count, strategy)?;
let image_media_tokens = resolve_image_list_ref(builder.image_inputs.clone())?;
let aspect_ratio = builder.aspect_ratio.map(to_aspect_ratio_enum);
@@ -53,47 +52,26 @@ pub fn build_artcraft_omni_image_request(
})
}
/// The largest batch each Artcraft image model accepts. Seedream 5.0 Pro and
/// Ultra accept batches of up to 8; every other model caps at 4.
pub fn max_batch_count(model: CommonImageModelEnum) -> u16 {
match model {
CommonImageModelEnum::Seedream5p0Pro | CommonImageModelEnum::Seedream5p0ProUltra => 8,
_ => 4,
}
}
/// Validate batch count (1..=4). Mirrors the v1 plan-level validation that
/// every Artcraft image model performs.
pub fn plan_batch_count(
image_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<u16, ArtcraftRouterError> {
plan_batch_count_with_max(image_batch_count, strategy, 4)
}
/// Validate batch count (1..=`max`), clamping or erroring per the mitigation
/// strategy when the request exceeds the model's cap.
pub fn plan_batch_count_with_max(
image_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
max: u16,
) -> Result<u16, ArtcraftRouterError> {
let count = image_batch_count.unwrap_or(1);
if count == 0 {
return Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations));
}
if count <= max {
return Ok(count);
}
match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "image_batch_count",
value: format!("{}", count),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(max),
match count {
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1..=4 => Ok(count),
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "image_batch_count",
value: format!("{}", count),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(4),
},
}
}
@@ -2,7 +2,8 @@ use crate::generate::generate_image::image_generation_cost_estimate::ImageGenera
use crate::generate::generate_image::providers::artcraft::flux_2_lora_angles::request::ArtcraftFlux2LoraAnglesRequestState;
/// Cost state for Artcraft Flux 2 LoRA Angles. Pricing mirrors v1
/// (`estimate_image_cost_artcraft_flux_2_lora_angles`): 4¢ per output image.
/// (`estimate_image_cost_artcraft_flux_2_lora_angles`): 4¢ per output image
/// (Artcraft markup over Fal's 2¢/image base rate).
#[derive(Clone, Debug)]
pub struct ArtcraftFlux2LoraAnglesCostState {
pub num_images: u16,
@@ -16,9 +16,7 @@ impl ArtcraftQwenEdit2511AnglesCostState {
}
pub fn estimate_cost(&self) -> ImageGenerationCostEstimate {
// 4.6¢ per output image (held in hundredths of a cent, rounded up to
// whole cents across the batch).
let cost_in_usd_cents = (460u64 * self.num_images as u64).div_ceil(100);
let cost_in_usd_cents = 4u64 * self.num_images as u64;
ImageGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
cost_in_usd_cents: Some(cost_in_usd_cents),
@@ -61,14 +59,14 @@ mod tests {
}
#[test]
fn one_image_costs_5_cents() { assert_eq!(cost_cents(1), 5); }
fn one_image_costs_4_cents() { assert_eq!(cost_cents(1), 4); }
#[test]
fn two_images_costs_10_cents() { assert_eq!(cost_cents(2), 10); }
fn two_images_costs_8_cents() { assert_eq!(cost_cents(2), 8); }
#[test]
fn three_images_costs_14_cents() { assert_eq!(cost_cents(3), 14); }
fn three_images_costs_12_cents() { assert_eq!(cost_cents(3), 12); }
#[test]
fn four_images_costs_19_cents() { assert_eq!(cost_cents(4), 19); }
fn four_images_costs_16_cents() { assert_eq!(cost_cents(4), 16); }
}
@@ -148,7 +148,7 @@ mod tests {
#[test]
fn price_covers_kinovi_cost() {
use kinovi_web_client::generate::image::generate_seedream_5p0_pro::{
use seedance2pro_client::generate::image::generate_seedream_5p0_pro::{
KinoviSeedream5p0ProBatchCount, KinoviSeedream5p0ProResolution,
};
use crate::generate::generate_image::providers::kinovi::seedream_5p0_pro::cost::KinoviSeedream5p0ProCostState;
@@ -169,7 +169,7 @@ mod tests {
#[test]
fn price_covers_kinovi_cost() {
use kinovi_web_client::generate::image::generate_seedream_5p0_pro::{
use seedance2pro_client::generate::image::generate_seedream_5p0_pro::{
KinoviSeedream5p0ProBatchCount, KinoviSeedream5p0ProResolution,
};
use crate::generate::generate_image::providers::kinovi::seedream_5p0_pro::cost::KinoviSeedream5p0ProCostState;
@@ -1,4 +1,4 @@
use kinovi_web_client::generate::image::generate_midjourney_v7::{
use seedance2pro_client::generate::image::generate_midjourney_v7::{
GenerateMidjourneyV7AspectRatio, GenerateMidjourneyV7Quality,
GenerateMidjourneyV7Request, KinoviMidjourneyBatchCount,
};
@@ -50,7 +50,7 @@ pub fn build_kinovi_midjourney_7(
}
// Image inputs present → must go through the draft phase so we can
// upload each image to the KinoviWeb CDN before sending.
// upload each image to the Seedance2Pro CDN before sending.
let draft = KinoviMidjourney7DraftState {
prompt,
aspect_ratio,
@@ -159,7 +159,7 @@ pub(crate) fn plan_batch_count(
#[cfg(test)]
mod tests {
use kinovi_web_client::generate::image::generate_midjourney_v7::KinoviMidjourneyBatchCount;
use seedance2pro_client::generate::image::generate_midjourney_v7::KinoviMidjourneyBatchCount;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::image_list_ref::ImageListRef;
@@ -178,7 +178,7 @@ mod tests {
fn base_builder() -> GenerateImageRequestBuilder {
GenerateImageRequestBuilder {
model: RouterImageModel::Midjourney7,
provider: RouterProvider::KinoviWeb,
provider: RouterProvider::Seedance2Pro,
prompt: Some("a corgi astronaut".to_string()),
image_inputs: None,
resolution: None,
@@ -291,7 +291,7 @@ mod tests {
mod aspect_ratio_tests {
use super::*;
use kinovi_web_client::generate::image::generate_midjourney_v7::GenerateMidjourneyV7AspectRatio as Ar;
use seedance2pro_client::generate::image::generate_midjourney_v7::GenerateMidjourneyV7AspectRatio as Ar;
fn aspect_ratio_for(input: Option<RouterAspectRatio>) -> Ar {
let builder = GenerateImageRequestBuilder { aspect_ratio: input, ..base_builder() };
@@ -334,7 +334,7 @@ mod tests {
mod quality_tests {
use super::*;
use kinovi_web_client::generate::image::generate_midjourney_v7::GenerateMidjourneyV7Quality as Q;
use seedance2pro_client::generate::image::generate_midjourney_v7::GenerateMidjourneyV7Quality as Q;
fn quality_for(input: Option<RouterQuality>) -> Option<Q> {
let builder = GenerateImageRequestBuilder { quality: input, ..base_builder() };
@@ -1,4 +1,4 @@
use kinovi_web_client::generate::image::generate_midjourney_v7::{
use seedance2pro_client::generate::image::generate_midjourney_v7::{
GenerateMidjourneyV7AspectRatio, GenerateMidjourneyV7Request, KinoviMidjourneyBatchCount,
};
@@ -171,7 +171,7 @@ mod tests {
fn base_builder() -> GenerateImageRequestBuilder {
GenerateImageRequestBuilder {
model: RouterImageModel::Midjourney7,
provider: RouterProvider::KinoviWeb,
provider: RouterProvider::Seedance2Pro,
prompt: Some("test".to_string()),
image_inputs: None,
resolution: None,
@@ -1,4 +1,4 @@
use kinovi_web_client::generate::image::generate_midjourney_v7::{
use seedance2pro_client::generate::image::generate_midjourney_v7::{
GenerateMidjourneyV7AspectRatio, GenerateMidjourneyV7Quality,
GenerateMidjourneyV7Request, KinoviMidjourneyBatchCount,
};
@@ -17,7 +17,7 @@ pub struct KinoviMidjourney7DraftState {
pub quality: Option<GenerateMidjourneyV7Quality>,
pub batch_count: KinoviMidjourneyBatchCount,
/// Image references that still need to be uploaded to KinoviWeb CDN
/// Image references that still need to be uploaded to Seedance2Pro CDN
/// before the request is sendable. `None` once `to_request()` has run.
pub unhandled_request_state: Option<KinoviMidjourney7RemainingItems>,
}
@@ -32,7 +32,7 @@ impl KinoviMidjourney7DraftState {
&mut self,
draft_context: &ImageGenerationDraftContext<'_>,
) -> Result<KinoviMidjourney7RequestState, ArtcraftRouterError> {
let client = draft_context.get_kinovi_web_client_ref()?;
let client = draft_context.get_seedance2pro_client_ref()?;
let session = &client.session;
let mut reference_image_urls = None;
@@ -63,10 +63,10 @@ impl KinoviMidjourney7DraftState {
#[cfg(test)]
mod tests {
use super::*;
use kinovi_web_client::generate::image::generate_midjourney_v7::GenerateMidjourneyV7AspectRatio;
use seedance2pro_client::generate::image::generate_midjourney_v7::GenerateMidjourneyV7AspectRatio;
// Note: most behavioral tests live in `build.rs` and `cost.rs`. The
// `to_request()` method requires a live KinoviWeb client to upload
// `to_request()` method requires a live Seedance2Pro client to upload
// images, so its end-to-end behavior is exercised by the `request.rs`
// #[ignore]'d integration tests rather than unit-tested here.
@@ -1,12 +1,12 @@
use kinovi_web_client::generate::image::generate_midjourney_v7::{
use seedance2pro_client::generate::image::generate_midjourney_v7::{
generate_midjourney_v7, GenerateMidjourneyV7Args, GenerateMidjourneyV7Request,
};
use crate::client::router_kinovi_web_client::RouterKinoviWebClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_image::generate_image_response::{
GenerateImageResponse, KinoviWebImageResponsePayload,
GenerateImageResponse, Seedance2proImageResponsePayload,
};
#[derive(Debug, Clone)]
@@ -18,7 +18,7 @@ pub struct KinoviMidjourney7RequestState {
impl KinoviMidjourney7RequestState {
pub async fn send(
&self,
client: &RouterKinoviWebClient,
client: &RouterSeedance2ProClient,
) -> Result<GenerateImageResponse, ArtcraftRouterError> {
let args = GenerateMidjourneyV7Args {
session: &client.session,
@@ -28,9 +28,9 @@ impl KinoviMidjourney7RequestState {
let response = generate_midjourney_v7(args)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::KinoviWeb(err)))?;
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?;
Ok(GenerateImageResponse::KinoviWeb(KinoviWebImageResponsePayload {
Ok(GenerateImageResponse::Seedance2Pro(Seedance2proImageResponsePayload {
order_id: response.order_id,
task_id: response.task_id,
maybe_order_ids: response.order_ids,
@@ -43,7 +43,7 @@ impl KinoviMidjourney7RequestState {
mod tests {
use std::collections::HashMap;
use kinovi_web_client::creds::kinovi_web_session::KinoviWebSession;
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::image_list_ref::ImageListRef;
@@ -52,7 +52,7 @@ mod tests {
use crate::api::router_provider::RouterProvider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::client::router_client::RouterClient;
use crate::client::router_kinovi_web_client::RouterKinoviWebClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder;
use crate::generate::generate_image::generate_image_response::GenerateImageResponse;
use crate::generate::generate_image::image_generation_draft::ImageGenerationDraftRequest;
@@ -64,7 +64,7 @@ mod tests {
fn base_builder() -> GenerateImageRequestBuilder {
GenerateImageRequestBuilder {
model: RouterImageModel::Midjourney7,
provider: RouterProvider::KinoviWeb,
provider: RouterProvider::Seedance2Pro,
prompt: Some("a serene mountain lake at sunrise, cinematic".to_string()),
image_inputs: None,
resolution: None,
@@ -82,9 +82,9 @@ mod tests {
fn get_kinovi_client() -> RouterClient {
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/seedance2pro_cookies.txt")
.expect("Failed to read kinovi_web cookies");
let session = KinoviWebSession::from_cookies_string(cookies.trim().to_string());
RouterClient::KinoviWeb(RouterKinoviWebClient::new(session))
.expect("Failed to read seedance2pro cookies");
let session = Seedance2ProSession::from_cookies_string(cookies.trim().to_string());
RouterClient::Seedance2Pro(RouterSeedance2ProClient::new(session))
}
#[tokio::test]
@@ -98,9 +98,9 @@ mod tests {
_ => panic!("expected direct Request for text-to-image"),
};
let response = request.send(client.get_kinovi_web_client_ref().unwrap())
let response = request.send(client.get_seedance2pro_client_ref().unwrap())
.await.expect("send should succeed");
let payload = response.get_kinovi_web_payload().expect("expected kinovi_web payload");
let payload = response.get_seedance2pro_payload().expect("expected seedance2pro payload");
println!("v7 t2i — task_id={}, order_id={}", payload.task_id, payload.order_id);
assert!(!payload.task_id.is_empty());
}
@@ -135,7 +135,7 @@ mod tests {
.finalize(ctx).await.expect("finalize");
let response = request.send_request(&client).await.expect("send_request");
let payload = response.get_kinovi_web_payload().expect("expected kinovi_web payload");
let payload = response.get_seedance2pro_payload().expect("expected seedance2pro payload");
println!("v7 with ref — task_id={}, order_id={}", payload.task_id, payload.order_id);
assert!(!payload.task_id.is_empty());
}
@@ -1,4 +1,4 @@
use kinovi_web_client::generate::image::generate_midjourney_v7_niji::{
use seedance2pro_client::generate::image::generate_midjourney_v7_niji::{
GenerateMidjourneyV7NijiAspectRatio, GenerateMidjourneyV7NijiQuality,
GenerateMidjourneyV7NijiRequest, KinoviMidjourneyBatchCount,
};
@@ -147,7 +147,7 @@ pub(crate) fn plan_batch_count(
#[cfg(test)]
mod tests {
use kinovi_web_client::generate::image::generate_midjourney_v7_niji::KinoviMidjourneyBatchCount;
use seedance2pro_client::generate::image::generate_midjourney_v7_niji::KinoviMidjourneyBatchCount;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::image_list_ref::ImageListRef;
@@ -166,7 +166,7 @@ mod tests {
fn base_builder() -> GenerateImageRequestBuilder {
GenerateImageRequestBuilder {
model: RouterImageModel::Midjourney7Niji,
provider: RouterProvider::KinoviWeb,
provider: RouterProvider::Seedance2Pro,
prompt: Some("a magical anime forest scene".to_string()),
image_inputs: None,
resolution: None,
@@ -235,7 +235,7 @@ mod tests {
#[test]
fn aspect_ratio_passes_through_to_niji_enum() {
use kinovi_web_client::generate::image::generate_midjourney_v7_niji::GenerateMidjourneyV7NijiAspectRatio as Ar;
use seedance2pro_client::generate::image::generate_midjourney_v7_niji::GenerateMidjourneyV7NijiAspectRatio as Ar;
let builder = GenerateImageRequestBuilder {
aspect_ratio: Some(RouterAspectRatio::WideTwentyOneByNine),
..base_builder()
@@ -249,7 +249,7 @@ mod tests {
#[test]
fn quality_high_maps_to_full() {
use kinovi_web_client::generate::image::generate_midjourney_v7_niji::GenerateMidjourneyV7NijiQuality as Q;
use seedance2pro_client::generate::image::generate_midjourney_v7_niji::GenerateMidjourneyV7NijiQuality as Q;
let builder = GenerateImageRequestBuilder {
quality: Some(RouterQuality::High),
..base_builder()
@@ -1,4 +1,4 @@
use kinovi_web_client::generate::image::generate_midjourney_v7_niji::{
use seedance2pro_client::generate::image::generate_midjourney_v7_niji::{
GenerateMidjourneyV7NijiAspectRatio, GenerateMidjourneyV7NijiRequest, KinoviMidjourneyBatchCount,
};
@@ -1,4 +1,4 @@
use kinovi_web_client::generate::image::generate_midjourney_v7_niji::{
use seedance2pro_client::generate::image::generate_midjourney_v7_niji::{
GenerateMidjourneyV7NijiAspectRatio, GenerateMidjourneyV7NijiQuality,
GenerateMidjourneyV7NijiRequest, KinoviMidjourneyBatchCount,
};
@@ -29,7 +29,7 @@ impl KinoviMidjourney7NijiDraftState {
&mut self,
draft_context: &ImageGenerationDraftContext<'_>,
) -> Result<KinoviMidjourney7NijiRequestState, ArtcraftRouterError> {
let client = draft_context.get_kinovi_web_client_ref()?;
let client = draft_context.get_seedance2pro_client_ref()?;
let session = &client.session;
let mut reference_image_urls = None;
@@ -1,12 +1,12 @@
use kinovi_web_client::generate::image::generate_midjourney_v7_niji::{
use seedance2pro_client::generate::image::generate_midjourney_v7_niji::{
generate_midjourney_v7_niji, GenerateMidjourneyV7NijiArgs, GenerateMidjourneyV7NijiRequest,
};
use crate::client::router_kinovi_web_client::RouterKinoviWebClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_image::generate_image_response::{
GenerateImageResponse, KinoviWebImageResponsePayload,
GenerateImageResponse, Seedance2proImageResponsePayload,
};
#[derive(Debug, Clone)]
@@ -17,7 +17,7 @@ pub struct KinoviMidjourney7NijiRequestState {
impl KinoviMidjourney7NijiRequestState {
pub async fn send(
&self,
client: &RouterKinoviWebClient,
client: &RouterSeedance2ProClient,
) -> Result<GenerateImageResponse, ArtcraftRouterError> {
let args = GenerateMidjourneyV7NijiArgs {
session: &client.session,
@@ -27,9 +27,9 @@ impl KinoviMidjourney7NijiRequestState {
let response = generate_midjourney_v7_niji(args)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::KinoviWeb(err)))?;
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?;
Ok(GenerateImageResponse::KinoviWeb(KinoviWebImageResponsePayload {
Ok(GenerateImageResponse::Seedance2Pro(Seedance2proImageResponsePayload {
order_id: response.order_id,
task_id: response.task_id,
maybe_order_ids: response.order_ids,
@@ -42,7 +42,7 @@ impl KinoviMidjourney7NijiRequestState {
mod tests {
use std::collections::HashMap;
use kinovi_web_client::creds::kinovi_web_session::KinoviWebSession;
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::image_list_ref::ImageListRef;
@@ -51,7 +51,7 @@ mod tests {
use crate::api::router_provider::RouterProvider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::client::router_client::RouterClient;
use crate::client::router_kinovi_web_client::RouterKinoviWebClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder;
use crate::generate::generate_image::image_generation_draft::ImageGenerationDraftRequest;
use crate::generate::generate_image::image_generation_draft_context::ImageGenerationDraftContext;
@@ -62,7 +62,7 @@ mod tests {
fn base_builder() -> GenerateImageRequestBuilder {
GenerateImageRequestBuilder {
model: RouterImageModel::Midjourney7Niji,
provider: RouterProvider::KinoviWeb,
provider: RouterProvider::Seedance2Pro,
prompt: Some("a magical anime fox spirit in a moonlit bamboo forest".to_string()),
image_inputs: None,
resolution: None,
@@ -80,9 +80,9 @@ mod tests {
fn get_kinovi_client() -> RouterClient {
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/seedance2pro_cookies.txt")
.expect("Failed to read kinovi_web cookies");
let session = KinoviWebSession::from_cookies_string(cookies.trim().to_string());
RouterClient::KinoviWeb(RouterKinoviWebClient::new(session))
.expect("Failed to read seedance2pro cookies");
let session = Seedance2ProSession::from_cookies_string(cookies.trim().to_string());
RouterClient::Seedance2Pro(RouterSeedance2ProClient::new(session))
}
#[tokio::test]
@@ -96,9 +96,9 @@ mod tests {
_ => panic!("expected direct Request for text-to-image"),
};
let response = request.send(client.get_kinovi_web_client_ref().unwrap())
let response = request.send(client.get_seedance2pro_client_ref().unwrap())
.await.expect("send should succeed");
let payload = response.get_kinovi_web_payload().expect("expected kinovi_web payload");
let payload = response.get_seedance2pro_payload().expect("expected seedance2pro payload");
println!("v7-niji t2i — task_id={}, order_id={}", payload.task_id, payload.order_id);
assert!(!payload.task_id.is_empty());
}
@@ -133,7 +133,7 @@ mod tests {
.finalize(ctx).await.expect("finalize");
let response = request.send_request(&client).await.expect("send_request");
let payload = response.get_kinovi_web_payload().expect("expected kinovi_web payload");
let payload = response.get_seedance2pro_payload().expect("expected seedance2pro payload");
println!("v7-niji with ref — task_id={}, order_id={}", payload.task_id, payload.order_id);
assert!(!payload.task_id.is_empty());
}
@@ -1,4 +1,4 @@
use kinovi_web_client::generate::image::generate_midjourney_v8::{
use seedance2pro_client::generate::image::generate_midjourney_v8::{
GenerateMidjourneyV8AspectRatio, GenerateMidjourneyV8Quality,
GenerateMidjourneyV8Request, KinoviMidjourneyBatchCount,
};
@@ -147,7 +147,7 @@ pub(crate) fn plan_batch_count(
#[cfg(test)]
mod tests {
use kinovi_web_client::generate::image::generate_midjourney_v8::KinoviMidjourneyBatchCount;
use seedance2pro_client::generate::image::generate_midjourney_v8::KinoviMidjourneyBatchCount;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::image_list_ref::ImageListRef;
@@ -164,7 +164,7 @@ mod tests {
fn base_builder() -> GenerateImageRequestBuilder {
GenerateImageRequestBuilder {
model: RouterImageModel::Midjourney8,
provider: RouterProvider::KinoviWeb,
provider: RouterProvider::Seedance2Pro,
prompt: Some("a spooky horror scene".to_string()),
image_inputs: None,
resolution: None,
@@ -1,4 +1,4 @@
use kinovi_web_client::generate::image::generate_midjourney_v8::{
use seedance2pro_client::generate::image::generate_midjourney_v8::{
GenerateMidjourneyV8AspectRatio, GenerateMidjourneyV8Request, KinoviMidjourneyBatchCount,
};
@@ -1,4 +1,4 @@
use kinovi_web_client::generate::image::generate_midjourney_v8::{
use seedance2pro_client::generate::image::generate_midjourney_v8::{
GenerateMidjourneyV8AspectRatio, GenerateMidjourneyV8Quality,
GenerateMidjourneyV8Request, KinoviMidjourneyBatchCount,
};
@@ -29,7 +29,7 @@ impl KinoviMidjourney8DraftState {
&mut self,
draft_context: &ImageGenerationDraftContext<'_>,
) -> Result<KinoviMidjourney8RequestState, ArtcraftRouterError> {
let client = draft_context.get_kinovi_web_client_ref()?;
let client = draft_context.get_seedance2pro_client_ref()?;
let session = &client.session;
let mut reference_image_urls = None;
@@ -1,12 +1,12 @@
use kinovi_web_client::generate::image::generate_midjourney_v8::{
use seedance2pro_client::generate::image::generate_midjourney_v8::{
generate_midjourney_v8, GenerateMidjourneyV8Args, GenerateMidjourneyV8Request,
};
use crate::client::router_kinovi_web_client::RouterKinoviWebClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_image::generate_image_response::{
GenerateImageResponse, KinoviWebImageResponsePayload,
GenerateImageResponse, Seedance2proImageResponsePayload,
};
#[derive(Debug, Clone)]
@@ -17,7 +17,7 @@ pub struct KinoviMidjourney8RequestState {
impl KinoviMidjourney8RequestState {
pub async fn send(
&self,
client: &RouterKinoviWebClient,
client: &RouterSeedance2ProClient,
) -> Result<GenerateImageResponse, ArtcraftRouterError> {
let args = GenerateMidjourneyV8Args {
session: &client.session,
@@ -27,9 +27,9 @@ impl KinoviMidjourney8RequestState {
let response = generate_midjourney_v8(args)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::KinoviWeb(err)))?;
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?;
Ok(GenerateImageResponse::KinoviWeb(KinoviWebImageResponsePayload {
Ok(GenerateImageResponse::Seedance2Pro(Seedance2proImageResponsePayload {
order_id: response.order_id,
task_id: response.task_id,
maybe_order_ids: response.order_ids,
@@ -42,7 +42,7 @@ impl KinoviMidjourney8RequestState {
mod tests {
use std::collections::HashMap;
use kinovi_web_client::creds::kinovi_web_session::KinoviWebSession;
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::image_list_ref::ImageListRef;
@@ -51,7 +51,7 @@ mod tests {
use crate::api::router_provider::RouterProvider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::client::router_client::RouterClient;
use crate::client::router_kinovi_web_client::RouterKinoviWebClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder;
use crate::generate::generate_image::image_generation_draft::ImageGenerationDraftRequest;
use crate::generate::generate_image::image_generation_draft_context::ImageGenerationDraftContext;
@@ -62,7 +62,7 @@ mod tests {
fn base_builder() -> GenerateImageRequestBuilder {
GenerateImageRequestBuilder {
model: RouterImageModel::Midjourney8,
provider: RouterProvider::KinoviWeb,
provider: RouterProvider::Seedance2Pro,
prompt: Some("a spooky horror scene with ghosts in an abandoned mansion".to_string()),
image_inputs: None,
resolution: None,
@@ -80,9 +80,9 @@ mod tests {
fn get_kinovi_client() -> RouterClient {
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/seedance2pro_cookies.txt")
.expect("Failed to read kinovi_web cookies");
let session = KinoviWebSession::from_cookies_string(cookies.trim().to_string());
RouterClient::KinoviWeb(RouterKinoviWebClient::new(session))
.expect("Failed to read seedance2pro cookies");
let session = Seedance2ProSession::from_cookies_string(cookies.trim().to_string());
RouterClient::Seedance2Pro(RouterSeedance2ProClient::new(session))
}
#[tokio::test]
@@ -96,9 +96,9 @@ mod tests {
_ => panic!("expected direct Request for text-to-image"),
};
let response = request.send(client.get_kinovi_web_client_ref().unwrap())
let response = request.send(client.get_seedance2pro_client_ref().unwrap())
.await.expect("send should succeed");
let payload = response.get_kinovi_web_payload().expect("expected kinovi_web payload");
let payload = response.get_seedance2pro_payload().expect("expected seedance2pro payload");
println!("v8 t2i — task_id={}, order_id={}", payload.task_id, payload.order_id);
assert!(!payload.task_id.is_empty());
}
@@ -133,7 +133,7 @@ mod tests {
.finalize(ctx).await.expect("finalize");
let response = request.send_request(&client).await.expect("send_request");
let payload = response.get_kinovi_web_payload().expect("expected kinovi_web payload");
let payload = response.get_seedance2pro_payload().expect("expected seedance2pro payload");
println!("v8 with ref — task_id={}, order_id={}", payload.task_id, payload.order_id);
assert!(!payload.task_id.is_empty());
}
@@ -1,21 +1,21 @@
use std::collections::HashMap;
use kinovi_web_client::creds::kinovi_web_session::KinoviWebSession;
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::image_list_ref::ImageListRef;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_image::providers::kinovi::upload::upload_to_kinovi_web;
use crate::generate::generate_image::providers::kinovi::upload::upload_to_seedance2pro;
/// Resolve an [`ImageListRef`] to a list of public KinoviWeb CDN URLs,
/// Resolve an [`ImageListRef`] to a list of public Seedance2Pro CDN URLs,
/// uploading each image one at a time. Preserves order.
///
/// Returns `Ok(None)` when the input is `None` or an empty list — the
/// caller can then omit the field from the wire request entirely. (The
/// downstream Midjourney API rejects empty arrays.)
pub(crate) async fn resolve_and_upload_image_list(
session: &KinoviWebSession,
session: &Seedance2ProSession,
list: Option<ImageListRef>,
maybe_map: Option<&HashMap<MediaFileToken, String>>,
) -> Result<Option<Vec<String>>, ArtcraftRouterError> {
@@ -29,7 +29,7 @@ pub(crate) async fn resolve_and_upload_image_list(
let mut uploaded = Vec::with_capacity(source_urls.len());
for url in &source_urls {
uploaded.push(upload_to_kinovi_web(session, url).await?);
uploaded.push(upload_to_seedance2pro(session, url).await?);
}
Ok(Some(uploaded))
}
@@ -1,4 +1,4 @@
use kinovi_web_client::generate::image::generate_seedream_5p0_pro::{
use seedance2pro_client::generate::image::generate_seedream_5p0_pro::{
GenerateSeedream5p0ProRequest, KinoviSeedream5p0ProAspectRatio, KinoviSeedream5p0ProBatchCount,
KinoviSeedream5p0ProResolution, MAX_REFERENCE_IMAGES,
};
@@ -67,7 +67,7 @@ fn has_image_inputs(image_inputs: Option<&ImageListRef>) -> bool {
}
/// Reject over-limit reference lists before any uploads happen — the
/// KinoviWeb client would reject them anyway, but only after the draft
/// Seedance2Pro client would reject them anyway, but only after the draft
/// stage has already uploaded every image to the Kinovi CDN.
fn validate_reference_image_count(
image_inputs: Option<&ImageListRef>,
@@ -191,7 +191,7 @@ mod tests {
fn base_builder() -> GenerateImageRequestBuilder {
GenerateImageRequestBuilder {
model: RouterImageModel::Seedream5p0Pro,
provider: RouterProvider::KinoviWeb,
provider: RouterProvider::Seedance2Pro,
prompt: Some("an anime girl riding a dinosaur".to_string()),
image_inputs: None,
resolution: None,
@@ -1,4 +1,4 @@
use kinovi_web_client::generate::image::generate_seedream_5p0_pro::{
use seedance2pro_client::generate::image::generate_seedream_5p0_pro::{
GenerateSeedream5p0ProRequest, KinoviSeedream5p0ProAspectRatio, KinoviSeedream5p0ProBatchCount,
KinoviSeedream5p0ProResolution,
};
@@ -1,4 +1,4 @@
use kinovi_web_client::generate::image::generate_seedream_5p0_pro::{
use seedance2pro_client::generate::image::generate_seedream_5p0_pro::{
GenerateSeedream5p0ProRequest, KinoviSeedream5p0ProAspectRatio, KinoviSeedream5p0ProBatchCount,
KinoviSeedream5p0ProResolution,
};
@@ -29,7 +29,7 @@ impl KinoviSeedream5p0ProDraftState {
&mut self,
draft_context: &ImageGenerationDraftContext<'_>,
) -> Result<KinoviSeedream5p0ProRequestState, ArtcraftRouterError> {
let client = draft_context.get_kinovi_web_client_ref()?;
let client = draft_context.get_seedance2pro_client_ref()?;
let session = &client.session;
let mut reference_image_urls = None;
@@ -1,12 +1,12 @@
use kinovi_web_client::generate::image::generate_seedream_5p0_pro::{
use seedance2pro_client::generate::image::generate_seedream_5p0_pro::{
generate_seedream_5p0_pro, GenerateSeedream5p0ProArgs, GenerateSeedream5p0ProRequest,
};
use crate::client::router_kinovi_web_client::RouterKinoviWebClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_image::generate_image_response::{
GenerateImageResponse, KinoviWebImageResponsePayload,
GenerateImageResponse, Seedance2proImageResponsePayload,
};
#[derive(Debug, Clone)]
@@ -17,7 +17,7 @@ pub struct KinoviSeedream5p0ProRequestState {
impl KinoviSeedream5p0ProRequestState {
pub async fn send(
&self,
client: &RouterKinoviWebClient,
client: &RouterSeedance2ProClient,
) -> Result<GenerateImageResponse, ArtcraftRouterError> {
let args = GenerateSeedream5p0ProArgs {
session: &client.session,
@@ -27,9 +27,9 @@ impl KinoviSeedream5p0ProRequestState {
let response = generate_seedream_5p0_pro(args)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::KinoviWeb(err)))?;
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?;
Ok(GenerateImageResponse::KinoviWeb(KinoviWebImageResponsePayload {
Ok(GenerateImageResponse::Seedance2Pro(Seedance2proImageResponsePayload {
order_id: response.order_id,
task_id: response.task_id,
maybe_order_ids: response.order_ids,
@@ -42,7 +42,7 @@ impl KinoviSeedream5p0ProRequestState {
mod tests {
use std::collections::HashMap;
use kinovi_web_client::creds::kinovi_web_session::KinoviWebSession;
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::image_list_ref::ImageListRef;
@@ -52,7 +52,7 @@ mod tests {
use crate::api::router_resolution::RouterResolution;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::client::router_client::RouterClient;
use crate::client::router_kinovi_web_client::RouterKinoviWebClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder;
use crate::generate::generate_image::image_generation_draft::ImageGenerationDraftRequest;
use crate::generate::generate_image::image_generation_draft_context::ImageGenerationDraftContext;
@@ -63,7 +63,7 @@ mod tests {
fn base_builder() -> GenerateImageRequestBuilder {
GenerateImageRequestBuilder {
model: RouterImageModel::Seedream5p0Pro,
provider: RouterProvider::KinoviWeb,
provider: RouterProvider::Seedance2Pro,
prompt: Some("an anime girl riding a dinosaur through a neon city".to_string()),
image_inputs: None,
resolution: Some(RouterResolution::OneK),
@@ -81,9 +81,9 @@ mod tests {
fn get_kinovi_client() -> RouterClient {
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/seedance2pro_cookies.txt")
.expect("Failed to read kinovi_web cookies");
let session = KinoviWebSession::from_cookies_string(cookies.trim().to_string());
RouterClient::KinoviWeb(RouterKinoviWebClient::new(session))
.expect("Failed to read seedance2pro cookies");
let session = Seedance2ProSession::from_cookies_string(cookies.trim().to_string());
RouterClient::Seedance2Pro(RouterSeedance2ProClient::new(session))
}
#[tokio::test]
@@ -97,9 +97,9 @@ mod tests {
_ => panic!("expected direct Request for text-to-image"),
};
let response = request.send(client.get_kinovi_web_client_ref().unwrap())
let response = request.send(client.get_seedance2pro_client_ref().unwrap())
.await.expect("send should succeed");
let payload = response.get_kinovi_web_payload().expect("expected kinovi_web payload");
let payload = response.get_seedance2pro_payload().expect("expected seedance2pro payload");
println!("seedream 5.0 pro t2i — task_id={}, order_id={}", payload.task_id, payload.order_id);
assert!(!payload.task_id.is_empty());
}
@@ -134,7 +134,7 @@ mod tests {
.finalize(ctx).await.expect("finalize");
let response = request.send_request(&client).await.expect("send_request");
let payload = response.get_kinovi_web_payload().expect("expected kinovi_web payload");
let payload = response.get_seedance2pro_payload().expect("expected seedance2pro payload");
println!("seedream 5.0 pro with ref — task_id={}, order_id={}", payload.task_id, payload.order_id);
assert!(!payload.task_id.is_empty());
}
@@ -1,6 +1,6 @@
use kinovi_web_client::creds::kinovi_web_session::KinoviWebSession;
use kinovi_web_client::requests::prepare_file_upload::prepare_file_upload::{prepare_file_upload, PrepareFileUploadArgs};
use kinovi_web_client::requests::upload_file::upload_file::{upload_file, UploadFileArgs};
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use seedance2pro_client::requests::prepare_file_upload::prepare_file_upload::{prepare_file_upload, PrepareFileUploadArgs};
use seedance2pro_client::requests::upload_file::upload_file::{upload_file, UploadFileArgs};
use url_utils::extension::extract_extension_from_url::{extract_extension_from_url_str, ExtractExtensions};
use crate::errors::artcraft_router_error::ArtcraftRouterError;
@@ -8,14 +8,14 @@ use crate::errors::provider_error::ProviderError;
use crate::utils::download_file::download_file;
/// Downloads an image from a source URL and re-uploads it to the
/// KinoviWeb/Kinovi CDN. Returns the public URL.
/// Seedance2Pro/Kinovi CDN. Returns the public URL.
///
/// This is the image-side twin of `generate_video::providers::kinovi::upload`.
/// The actual upload path doesn't care whether the file is a video or an
/// image, but keeping the two helpers separate lets each side evolve
/// independently and makes intent clear at call sites.
pub(crate) async fn upload_to_kinovi_web(
session: &KinoviWebSession,
pub(crate) async fn upload_to_seedance2pro(
session: &Seedance2ProSession,
source_url: &str,
) -> Result<String, ArtcraftRouterError> {
let extension = extract_extension_from_url_str(source_url, &ExtractExtensions::All)
@@ -30,7 +30,7 @@ pub(crate) async fn upload_to_kinovi_web(
host_override: None,
})
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::KinoviWeb(err)))?;
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?;
let upload_response = upload_file(UploadFileArgs {
upload_url: prepare_response.upload_url,
@@ -38,7 +38,7 @@ pub(crate) async fn upload_to_kinovi_web(
host_override: None,
})
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::KinoviWeb(err)))?;
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?;
Ok(upload_response.public_url)
}
@@ -268,7 +268,7 @@ mod tests {
for provider in [
RouterProvider::GmiCloud,
RouterProvider::GrokApi,
RouterProvider::KinoviWeb,
RouterProvider::Seedance2Pro,
RouterProvider::WorldLabs,
] {
let result = builder_with_url_image(provider, RouterMeshModel::Hunyuan3d3).build2();
@@ -252,7 +252,7 @@ mod tests {
RouterProvider::Fal,
RouterProvider::GmiCloud,
RouterProvider::GrokApi,
RouterProvider::KinoviWeb,
RouterProvider::Seedance2Pro,
] {
let result = GenerateSplatRequestBuilder {
provider,
@@ -74,17 +74,17 @@ generate_video/
│ ├── seedance_2p0/ ← Seedance 2.0 Pro model (skips draft, returns Request directly)
│ │ ├── mod.rs
│ │ ├── build.rs ← builder → Request conversion + plan helpers
│ │ ├── cost.rs ← standalone cost estimation (independent of kinovi_web_client)
│ │ ├── cost.rs ← standalone cost estimation (independent of seedance2pro_client)
│ │ └── request.rs ← request state + send() via Artcraft multi-function API
│ └── seedance_2p0_fast/ ← Seedance 2.0 Fast model (skips draft, 480p/720p only)
│ ├── mod.rs
│ ├── build.rs ← builder → Request conversion + plan helpers (no 1080p)
│ ├── cost.rs ← standalone cost estimation with Fast-specific pricing
│ └── request.rs ← request state + send() via Artcraft omni-gen API
└── kinovi/ ← Kinovi/KinoviWeb provider
└── kinovi/ ← Kinovi/Seedance2Pro provider
├── mod.rs
├── resolve.rs ← shared: media token resolution, upload helpers
├── upload.rs ← shared: download + re-upload to KinoviWeb CDN
├── upload.rs ← shared: download + re-upload to Seedance2Pro CDN
├── seedance_2p0/ ← Seedance 2.0 Pro model
│ ├── mod.rs
│ ├── build.rs ← builder → draft conversion + plan helpers
@@ -26,6 +26,8 @@ use crate::generate::generate_video::providers::artcraft::seedance_1p0_lite::bui
use crate::generate::generate_video::providers::artcraft::seedance_1p5_pro::build::build_artcraft_seedance_1p5_pro;
use crate::generate::generate_video::providers::artcraft::seedance_2p0::build::build_artcraft_seedance_2p0;
use crate::generate::generate_video::providers::artcraft::seedance_2p0_fast::build::build_artcraft_seedance_2p0_fast;
use crate::generate::generate_video::providers::artcraft::seedance_2p0_u::build::build_artcraft_seedance_2p0_u;
use crate::generate::generate_video::providers::artcraft::seedance_2p0_u_fast::build::build_artcraft_seedance_2p0_u_fast;
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bp::build::build_artcraft_seedance_2p0_bp;
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bp_fast::build::build_artcraft_seedance_2p0_bp_fast;
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bpu::build::build_artcraft_seedance_2p0_bpu;
@@ -41,13 +43,12 @@ use crate::generate::generate_video::providers::artcraft::veo_3_fast::build::bui
use crate::generate::generate_video::providers::artcraft::veo_3p1::build::build_artcraft_veo_3p1;
use crate::generate::generate_video::providers::artcraft::veo_3p1_fast::build::build_artcraft_veo_3p1_fast;
use crate::generate::generate_video::providers::artcraft::veo_3p1_lite::build::build_artcraft_veo_3p1_lite;
use crate::generate::generate_video::providers::artcraft::flux_3::build::build_artcraft_flux_3;
use crate::generate::generate_video::providers::artcraft::flux_3_draft::build::build_artcraft_flux_3_draft;
use crate::generate::generate_video::providers::artcraft::minimax_h3::build::build_artcraft_minimax_h3;
use crate::generate::generate_video::providers::artcraft::vidu_q3::build::build_artcraft_vidu_q3;
use crate::generate::generate_video::providers::artcraft::vidu_q3_turbo::build::build_artcraft_vidu_q3_turbo;
use crate::generate::generate_video::providers::kinovi::happy_horse_1p0::build::build_kinovi_happy_horse_1p0;
use crate::generate::generate_video::providers::kinovi::seedance_2p0::build::build_kinovi_seedance_2p0;
use crate::generate::generate_video::providers::gmicloud::seedance_2p0_g::build::build_gmicloud_seedance_2p0_u;
use crate::generate::generate_video::providers::gmicloud::seedance_2p0_fast_g::build::build_gmicloud_seedance_2p0_u_fast;
use crate::generate::generate_video::providers::grok_api::grok_imagine_video::build::build_grok_api_grok_imagine_video;
use crate::generate::generate_video::providers::grok_api::grok_imagine_video_1p5::build::build_grok_api_grok_imagine_video_1p5;
use crate::generate::generate_video::providers::fal::kling_1_6_pro::build::build_fal_kling_1_6_pro;
@@ -67,18 +68,10 @@ use crate::generate::generate_video::providers::fal::veo_3_fast::build::build_fa
use crate::generate::generate_video::providers::fal::veo_3p1::build::build_fal_veo_3p1;
use crate::generate::generate_video::providers::fal::veo_3p1_fast::build::build_fal_veo_3p1_fast;
use crate::generate::generate_video::providers::fal::veo_3p1_lite::build::build_fal_veo_3p1_lite;
use crate::generate::generate_video::providers::fal::flux_3::build::build_fal_flux_3;
use crate::generate::generate_video::providers::fal::flux_3_draft::build::build_fal_flux_3_draft;
use crate::generate::generate_video::providers::fal::minimax_h3::build::build_fal_minimax_h3;
use crate::generate::generate_video::providers::fal::vidu_q3::build::build_fal_vidu_q3;
use crate::generate::generate_video::providers::fal::vidu_q3_turbo::build::build_fal_vidu_q3_turbo;
use crate::generate::generate_video::providers::kinovi::seedance_2p0_fast::build::build_kinovi_seedance_2p0_fast;
use crate::generate::generate_video::providers::kinovi::seedance_2p0_mini::build::build_kinovi_seedance_2p0_mini;
use crate::generate::generate_video::providers::artcraft::seedance_2p5::build::build_artcraft_seedance_2p5;
use crate::generate::generate_video::providers::artcraft::seedance_2p5_u::build::build_artcraft_seedance_2p5_u;
use crate::generate::generate_video::providers::artcraft::seedance_2p5_preview::build::build_artcraft_seedance_2p5_preview;
use crate::generate::generate_video::providers::kinovi::seedance_2p5::build::build_kinovi_seedance_2p5;
use crate::generate::generate_video::providers::kinovi::seedance_2p5_preview::build::build_kinovi_seedance_2p5_preview;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
/// RouterProvider-agnostic video generation request. Distilled by `build2()` into a
@@ -137,12 +130,6 @@ 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,
@@ -171,7 +158,6 @@ impl Default for GenerateVideoRequestBuilder {
duration_seconds: None,
video_batch_count: None,
generate_audio: None,
total_reference_video_input_seconds: None,
idempotency_token: None,
}
}
@@ -184,8 +170,6 @@ impl GenerateVideoRequestBuilder {
// Artcraft
(RouterProvider::Artcraft, RouterVideoModel::GrokImagineVideo) => build_artcraft_grok_imagine_video(self),
(RouterProvider::Artcraft, RouterVideoModel::GrokImagineVideo1p5) => build_artcraft_grok_imagine_video_1p5(self),
(RouterProvider::Artcraft, RouterVideoModel::Flux3) => build_artcraft_flux_3(self),
(RouterProvider::Artcraft, RouterVideoModel::Flux3Draft) => build_artcraft_flux_3_draft(self),
(RouterProvider::Artcraft, RouterVideoModel::HappyHorse1p0) => build_artcraft_happy_horse_1p0(self),
(RouterProvider::Artcraft, RouterVideoModel::Kling16Pro) => build_artcraft_kling_1_6_pro(self),
(RouterProvider::Artcraft, RouterVideoModel::Kling21Master) => build_artcraft_kling_2_1_master(self),
@@ -194,9 +178,10 @@ impl GenerateVideoRequestBuilder {
(RouterProvider::Artcraft, RouterVideoModel::Kling2p6Pro) => build_artcraft_kling_2_6_pro(self),
(RouterProvider::Artcraft, RouterVideoModel::Kling3p0Pro) => build_artcraft_kling_3p0_pro(self),
(RouterProvider::Artcraft, RouterVideoModel::Kling3p0Standard) => build_artcraft_kling_3p0_standard(self),
(RouterProvider::Artcraft, RouterVideoModel::MinimaxH3) => build_artcraft_minimax_h3(self),
(RouterProvider::Artcraft, RouterVideoModel::Seedance2p0) => build_artcraft_seedance_2p0(self),
(RouterProvider::Artcraft, RouterVideoModel::Seedance2p0Fast) => build_artcraft_seedance_2p0_fast(self),
(RouterProvider::Artcraft, RouterVideoModel::Seedance2p0Ultra) => build_artcraft_seedance_2p0_u(self),
(RouterProvider::Artcraft, RouterVideoModel::Seedance2p0UltraFast) => build_artcraft_seedance_2p0_u_fast(self),
(RouterProvider::Artcraft, RouterVideoModel::Seedance2p0BytePlus) => build_artcraft_seedance_2p0_bp(self),
(RouterProvider::Artcraft, RouterVideoModel::Seedance2p0BytePlusFast) => build_artcraft_seedance_2p0_bp_fast(self),
(RouterProvider::Artcraft, RouterVideoModel::Seedance2p0BytePlusUltra) => build_artcraft_seedance_2p0_bpu(self),
@@ -204,9 +189,6 @@ impl GenerateVideoRequestBuilder {
(RouterProvider::Artcraft, RouterVideoModel::Seedance2p0Mini) => build_artcraft_seedance_2p0_mini(self),
(RouterProvider::Artcraft, RouterVideoModel::Seedance2p0BytePlusMini) => build_artcraft_seedance_2p0_bp_mini(self),
(RouterProvider::Artcraft, RouterVideoModel::Seedance2p0BytePlusUltraMini) => build_artcraft_seedance_2p0_bpu_mini(self),
(RouterProvider::Artcraft, RouterVideoModel::Seedance2p5Preview) => build_artcraft_seedance_2p5_preview(self),
(RouterProvider::Artcraft, RouterVideoModel::Seedance2p5) => build_artcraft_seedance_2p5(self),
(RouterProvider::Artcraft, RouterVideoModel::Seedance2p5Ultra) => build_artcraft_seedance_2p5_u(self),
(RouterProvider::Artcraft, RouterVideoModel::PreviewModel) => build_artcraft_preview_model(self),
(RouterProvider::Artcraft, RouterVideoModel::PreviewModelFast) => build_artcraft_preview_model_fast(self),
(RouterProvider::Artcraft, RouterVideoModel::Seedance10Lite) => build_artcraft_seedance_1p0_lite(self),
@@ -222,8 +204,6 @@ impl GenerateVideoRequestBuilder {
(RouterProvider::Artcraft, RouterVideoModel::ViduQ3) => build_artcraft_vidu_q3(self),
(RouterProvider::Artcraft, RouterVideoModel::ViduQ3Turbo) => build_artcraft_vidu_q3_turbo(self),
// Fal
(RouterProvider::Fal, RouterVideoModel::Flux3) => build_fal_flux_3(self),
(RouterProvider::Fal, RouterVideoModel::Flux3Draft) => build_fal_flux_3_draft(self),
(RouterProvider::Fal, RouterVideoModel::Kling16Pro) => build_fal_kling_1_6_pro(self),
(RouterProvider::Fal, RouterVideoModel::Kling21Master) => build_fal_kling_2_1_master(self),
(RouterProvider::Fal, RouterVideoModel::Kling21Pro) => build_fal_kling_2_1_pro(self),
@@ -231,7 +211,6 @@ impl GenerateVideoRequestBuilder {
(RouterProvider::Fal, RouterVideoModel::Kling2p6Pro) => build_fal_kling_2_6_pro(self),
(RouterProvider::Fal, RouterVideoModel::Kling3p0Pro) => build_fal_kling_3p0_pro(self),
(RouterProvider::Fal, RouterVideoModel::Kling3p0Standard) => build_fal_kling_3p0_standard(self),
(RouterProvider::Fal, RouterVideoModel::MinimaxH3) => build_fal_minimax_h3(self),
(RouterProvider::Fal, RouterVideoModel::Seedance10Lite) => build_fal_seedance_1p0_lite(self),
(RouterProvider::Fal, RouterVideoModel::Seedance1p5Pro) => build_fal_seedance_1p5_pro(self),
(RouterProvider::Fal, RouterVideoModel::Sora2) => build_fal_sora_2(self),
@@ -244,18 +223,19 @@ impl GenerateVideoRequestBuilder {
(RouterProvider::Fal, RouterVideoModel::Veo3p1Lite) => build_fal_veo_3p1_lite(self),
(RouterProvider::Fal, RouterVideoModel::ViduQ3) => build_fal_vidu_q3(self),
(RouterProvider::Fal, RouterVideoModel::ViduQ3Turbo) => build_fal_vidu_q3_turbo(self),
// GmiCloud
(RouterProvider::GmiCloud, RouterVideoModel::Seedance2p0Ultra) => build_gmicloud_seedance_2p0_u(self),
(RouterProvider::GmiCloud, RouterVideoModel::Seedance2p0UltraFast) => build_gmicloud_seedance_2p0_u_fast(self),
// Grok
(RouterProvider::GrokApi, RouterVideoModel::GrokImagineVideo) => build_grok_api_grok_imagine_video(self),
(RouterProvider::GrokApi, RouterVideoModel::GrokImagineVideo1p5) => build_grok_api_grok_imagine_video_1p5(self),
// Kinovi
(RouterProvider::KinoviWeb, RouterVideoModel::HappyHorse1p0) => build_kinovi_happy_horse_1p0(self),
(RouterProvider::KinoviWeb, RouterVideoModel::Seedance2p0) => build_kinovi_seedance_2p0(self),
(RouterProvider::KinoviWeb, RouterVideoModel::Seedance2p0Fast) => build_kinovi_seedance_2p0_fast(self),
(RouterProvider::KinoviWeb, RouterVideoModel::Seedance2p0Mini) => build_kinovi_seedance_2p0_mini(self),
(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),
(RouterProvider::Seedance2Pro, RouterVideoModel::HappyHorse1p0) => build_kinovi_happy_horse_1p0(self),
(RouterProvider::Seedance2Pro, RouterVideoModel::Seedance2p0) => build_kinovi_seedance_2p0(self),
(RouterProvider::Seedance2Pro, RouterVideoModel::Seedance2p0Fast) => build_kinovi_seedance_2p0_fast(self),
(RouterProvider::Seedance2Pro, RouterVideoModel::Seedance2p0Mini) => build_kinovi_seedance_2p0_mini(self),
(RouterProvider::Seedance2Pro, RouterVideoModel::Seedance2p0BytePlusMini) => build_kinovi_seedance_2p0_mini(self),
(RouterProvider::Seedance2Pro, RouterVideoModel::Seedance2p0BytePlusUltraMini) => build_kinovi_seedance_2p0_mini(self),
_ => self.unsupported_provider_and_model(),
}
}
@@ -10,7 +10,7 @@ pub struct ArtcraftVideoResponsePayload {
}
#[derive(Clone, Debug)]
pub struct KinoviWebVideoResponsePayload {
pub struct Seedance2proVideoResponsePayload {
pub order_id: String,
pub task_id: String,
pub maybe_order_ids: Option<Vec<String>>,
@@ -58,7 +58,7 @@ pub enum GenerateVideoResponse {
Artcraft(ArtcraftVideoResponsePayload),
GmiCloud(GmiCloudVideoResponsePayload),
Grok(GrokVideoResponsePayload),
KinoviWeb(KinoviWebVideoResponsePayload),
Seedance2Pro(Seedance2proVideoResponsePayload),
Fal(FalVideoResponsePayload),
}
@@ -70,9 +70,9 @@ impl GenerateVideoResponse {
}
}
pub fn get_kinovi_web_payload(&self) -> Option<KinoviWebVideoResponsePayload> {
pub fn get_seedance2pro_payload(&self) -> Option<Seedance2proVideoResponsePayload> {
match self {
Self::KinoviWeb(p) => Some(p.clone()),
Self::Seedance2Pro(p) => Some(p.clone()),
_ => None,
}
}
@@ -82,7 +82,6 @@ pub fn build_artcraft_omni_video_request(
negative_prompt: None,
generate_audio: None,
quality: None,
estimate_only: None,
})
}
@@ -231,7 +230,7 @@ fn plan_output_resolution(
}
}
/// Batch counts: 1 through 4.
/// Batch counts: 1, 2, 4.
pub fn plan_batch_count(
video_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
@@ -239,8 +238,7 @@ pub fn plan_batch_count(
let count = video_batch_count.unwrap_or(1);
match count {
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1..=4 => Ok(count),
// 5 and above:
1 | 2 | 4 => Ok(count),
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
@@ -249,31 +247,9 @@ pub fn plan_batch_count(
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(4),
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(4),
},
}
}
/// Batch counts for the Seedance 2.0 Mini family: 1-8 are all valid
/// (wider than the shared 1/2/4 planning); higher counts clamp to 8 per the
/// mitigation strategy.
pub fn plan_mini_batch_count(
video_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<u16, ArtcraftRouterError> {
let count = video_batch_count.unwrap_or(1);
match count {
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1..=8 => Ok(count),
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "video_batch_count",
value: format!("{}", count),
}))
RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(if count < 4 { 2 } else { 4 })
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(8),
},
}
}

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