Compare commits

..
Author SHA1 Message Date
Brandon Thomas 5c3138d70f refactor job 2026-03-10 17:05:39 -04:00
Brandon Thomas bf188682ba start building cli tool 2026-03-10 16:55:08 -04:00
331 changed files with 1795 additions and 17904 deletions
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO generic_inference_jobs\nSET\n token = ?,\n uuid_idempotency_token = ?,\n\n job_type = ?,\n\n maybe_external_third_party = ?,\n maybe_external_third_party_id = ?,\n\n product_category = ?,\n inference_category = ?,\n\n maybe_model_type = NULL,\n maybe_model_token = NULL,\n\n maybe_input_source_token = NULL,\n maybe_input_source_token_type = NULL,\n\n maybe_download_url = NULL,\n maybe_cover_image_media_file_token = NULL,\n \n maybe_prompt_token = ?,\n\n maybe_raw_inference_text = NULL,\n\n maybe_inference_args = ?,\n\n maybe_creator_user_token = ?,\n maybe_creator_anonymous_visitor_token = ?,\n creator_ip_address = ?,\n creator_set_visibility = ?,\n\n priority_level = 0,\n is_keepalive_required = FALSE,\n max_duration_seconds = 0,\n\n is_debug_request = FALSE,\n maybe_routing_tag = NULL,\n\n status = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 14
},
"nullable": []
},
"hash": "002cdb3d086f3b4181e2997befa54bd8516cb1d3d3bb6e389862695318d5c299"
}
@@ -1,6 +1,6 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n jobs.token as `job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,\n\n jobs.status as `status: enums::common::job_status_plus::JobStatusPlus`,\n jobs.attempt_count,\n\n jobs.maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n jobs.maybe_creator_anonymous_visitor_token as `maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken`,\n jobs.creator_ip_address,\n\n jobs.product_category as `product_category: enums::by_table::generic_inference_jobs::inference_job_product_category::InferenceJobProductCategory`,\n jobs.inference_category as `inference_category: enums::by_table::generic_inference_jobs::inference_category::InferenceCategory`,\n jobs.maybe_model_type,\n jobs.maybe_model_token,\n jobs.maybe_raw_inference_text,\n jobs.maybe_inference_args,\n\n jobs.frontend_failure_category as `maybe_frontend_failure_category: enums::by_table::generic_inference_jobs::frontend_failure_category::FrontendFailureCategory`,\n jobs.failure_reason,\n\n jobs.on_success_result_entity_type as maybe_result_entity_type,\n jobs.on_success_result_entity_token as maybe_result_entity_token,\n jobs.on_success_result_batch_token as maybe_result_batch_token,\n\n model_weights.title as maybe_model_weights_title,\n tts_models.title as maybe_tts_model_title,\n voice_conversion_models.title as maybe_voice_conversion_model_title,\n\n tts_results.public_bucket_wav_audio_path as maybe_tts_public_bucket_path,\n voice_conversion_results.public_bucket_hash as maybe_voice_conversion_public_bucket_hash,\n\n media_files.public_bucket_directory_hash as maybe_media_file_public_bucket_directory_hash,\n media_files.maybe_public_bucket_prefix as maybe_media_file_public_bucket_prefix,\n media_files.maybe_public_bucket_extension as maybe_media_file_public_bucket_extension,\n\n jobs.assigned_worker as maybe_assigned_worker,\n jobs.assigned_cluster as maybe_assigned_cluster,\n\n jobs.is_keepalive_required,\n\n jobs.created_at,\n jobs.updated_at,\n\n jobs.first_started_at as maybe_first_started_at,\n jobs.successfully_completed_at as maybe_successfully_completed_at,\n\n NOW() as database_clock\n\nFROM generic_inference_jobs as jobs\n\nLEFT OUTER JOIN model_weights ON jobs.maybe_model_token = model_weights.token\nLEFT OUTER JOIN tts_models ON jobs.maybe_model_token = tts_models.token\nLEFT OUTER JOIN voice_conversion_models ON jobs.maybe_model_token = voice_conversion_models.token\n\nLEFT OUTER JOIN tts_results ON jobs.on_success_result_entity_token = tts_results.token\nLEFT OUTER JOIN voice_conversion_results ON jobs.on_success_result_entity_token = voice_conversion_results.token\nLEFT OUTER JOIN media_files ON jobs.on_success_result_entity_token = media_files.token\n\nWHERE jobs.token = ?\n ",
"query": "\nSELECT\n jobs.token as `job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,\n\n jobs.status as `status: enums::common::job_status_plus::JobStatusPlus`,\n jobs.attempt_count,\n\n jobs.maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n jobs.maybe_creator_anonymous_visitor_token as `maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken`,\n jobs.creator_ip_address,\n\n jobs.product_category as `product_category: enums::by_table::generic_inference_jobs::inference_job_product_category::InferenceJobProductCategory`,\n jobs.inference_category as `inference_category: enums::by_table::generic_inference_jobs::inference_category::InferenceCategory`,\n jobs.maybe_model_type,\n jobs.maybe_model_token,\n jobs.maybe_raw_inference_text,\n jobs.maybe_inference_args,\n\n jobs.frontend_failure_category as `maybe_frontend_failure_category: enums::by_table::generic_inference_jobs::frontend_failure_category::FrontendFailureCategory`,\n\n jobs.on_success_result_entity_type as maybe_result_entity_type,\n jobs.on_success_result_entity_token as maybe_result_entity_token,\n jobs.on_success_result_batch_token as maybe_result_batch_token,\n\n model_weights.title as maybe_model_weights_title,\n tts_models.title as maybe_tts_model_title,\n voice_conversion_models.title as maybe_voice_conversion_model_title,\n\n tts_results.public_bucket_wav_audio_path as maybe_tts_public_bucket_path,\n voice_conversion_results.public_bucket_hash as maybe_voice_conversion_public_bucket_hash,\n\n media_files.public_bucket_directory_hash as maybe_media_file_public_bucket_directory_hash,\n media_files.maybe_public_bucket_prefix as maybe_media_file_public_bucket_prefix,\n media_files.maybe_public_bucket_extension as maybe_media_file_public_bucket_extension,\n\n jobs.assigned_worker as maybe_assigned_worker,\n jobs.assigned_cluster as maybe_assigned_cluster,\n\n jobs.is_keepalive_required,\n\n jobs.created_at,\n jobs.updated_at,\n\n jobs.first_started_at as maybe_first_started_at,\n jobs.successfully_completed_at as maybe_successfully_completed_at,\n\n NOW() as database_clock\n\nFROM generic_inference_jobs as jobs\n\nLEFT OUTER JOIN model_weights ON jobs.maybe_model_token = model_weights.token\nLEFT OUTER JOIN tts_models ON jobs.maybe_model_token = tts_models.token\nLEFT OUTER JOIN voice_conversion_models ON jobs.maybe_model_token = voice_conversion_models.token\n\nLEFT OUTER JOIN tts_results ON jobs.on_success_result_entity_token = tts_results.token\nLEFT OUTER JOIN voice_conversion_results ON jobs.on_success_result_entity_token = voice_conversion_results.token\nLEFT OUTER JOIN media_files ON jobs.on_success_result_entity_token = media_files.token\n\nWHERE jobs.token = ?\n ",
"describe": {
"columns": [
{
@@ -135,16 +135,6 @@
},
{
"ordinal": 13,
"name": "failure_reason",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 2048
}
},
{
"ordinal": 14,
"name": "maybe_result_entity_type",
"type_info": {
"type": "VarString",
@@ -154,7 +144,7 @@
}
},
{
"ordinal": 15,
"ordinal": 14,
"name": "maybe_result_entity_token",
"type_info": {
"type": "VarString",
@@ -164,7 +154,7 @@
}
},
{
"ordinal": 16,
"ordinal": 15,
"name": "maybe_result_batch_token",
"type_info": {
"type": "VarString",
@@ -174,7 +164,7 @@
}
},
{
"ordinal": 17,
"ordinal": 16,
"name": "maybe_model_weights_title",
"type_info": {
"type": "VarString",
@@ -184,7 +174,7 @@
}
},
{
"ordinal": 18,
"ordinal": 17,
"name": "maybe_tts_model_title",
"type_info": {
"type": "VarString",
@@ -194,7 +184,7 @@
}
},
{
"ordinal": 19,
"ordinal": 18,
"name": "maybe_voice_conversion_model_title",
"type_info": {
"type": "VarString",
@@ -204,7 +194,7 @@
}
},
{
"ordinal": 20,
"ordinal": 19,
"name": "maybe_tts_public_bucket_path",
"type_info": {
"type": "VarString",
@@ -214,7 +204,7 @@
}
},
{
"ordinal": 21,
"ordinal": 20,
"name": "maybe_voice_conversion_public_bucket_hash",
"type_info": {
"type": "VarString",
@@ -224,7 +214,7 @@
}
},
{
"ordinal": 22,
"ordinal": 21,
"name": "maybe_media_file_public_bucket_directory_hash",
"type_info": {
"type": "VarString",
@@ -234,7 +224,7 @@
}
},
{
"ordinal": 23,
"ordinal": 22,
"name": "maybe_media_file_public_bucket_prefix",
"type_info": {
"type": "VarString",
@@ -244,7 +234,7 @@
}
},
{
"ordinal": 24,
"ordinal": 23,
"name": "maybe_media_file_public_bucket_extension",
"type_info": {
"type": "VarString",
@@ -254,7 +244,7 @@
}
},
{
"ordinal": 25,
"ordinal": 24,
"name": "maybe_assigned_worker",
"type_info": {
"type": "VarString",
@@ -264,7 +254,7 @@
}
},
{
"ordinal": 26,
"ordinal": 25,
"name": "maybe_assigned_cluster",
"type_info": {
"type": "VarString",
@@ -274,7 +264,7 @@
}
},
{
"ordinal": 27,
"ordinal": 26,
"name": "is_keepalive_required",
"type_info": {
"type": "Tiny",
@@ -284,7 +274,7 @@
}
},
{
"ordinal": 28,
"ordinal": 27,
"name": "created_at",
"type_info": {
"type": "Timestamp",
@@ -294,7 +284,7 @@
}
},
{
"ordinal": 29,
"ordinal": 28,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
@@ -304,7 +294,7 @@
}
},
{
"ordinal": 30,
"ordinal": 29,
"name": "maybe_first_started_at",
"type_info": {
"type": "Timestamp",
@@ -314,7 +304,7 @@
}
},
{
"ordinal": 31,
"ordinal": 30,
"name": "maybe_successfully_completed_at",
"type_info": {
"type": "Timestamp",
@@ -324,7 +314,7 @@
}
},
{
"ordinal": 32,
"ordinal": 31,
"name": "database_clock",
"type_info": {
"type": "Datetime",
@@ -364,7 +354,6 @@
true,
true,
true,
true,
false,
false,
false,
@@ -373,5 +362,5 @@
false
]
},
"hash": "a2c9e2f61eba666254f8432b3c50a0a7738ee3b3faa77c5281bc8e4af96159b9"
"hash": "243afe0bc574f9f233c7797add9faf96440e2bafd52032e07951272fb1ec78b1"
}
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO generic_inference_jobs\nSET\n token = ?,\n uuid_idempotency_token = ?,\n\n job_type = ?,\n\n maybe_external_third_party = ?,\n maybe_external_third_party_id = ?,\n\n product_category = ?,\n inference_category = ?,\n\n maybe_model_type = NULL,\n maybe_model_token = NULL,\n\n maybe_input_source_token = NULL,\n maybe_input_source_token_type = NULL,\n\n maybe_download_url = NULL,\n maybe_cover_image_media_file_token = NULL,\n\n maybe_prompt_token = ?,\n\n maybe_raw_inference_text = NULL,\n\n maybe_inference_args = ?,\n\n maybe_creator_user_token = ?,\n maybe_creator_anonymous_visitor_token = ?,\n creator_ip_address = ?,\n creator_set_visibility = ?,\n\n priority_level = 0,\n is_keepalive_required = FALSE,\n max_duration_seconds = 0,\n\n is_debug_request = FALSE,\n maybe_routing_tag = NULL,\n\n frontend_failure_category = ?,\n failure_reason = ?,\n\n status = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 16
},
"nullable": []
},
"hash": "29916e73a1784ea0b70ef4f637520b05efccc31e01840f681e9b042f82eb8f7e"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n SELECT\n id,\n task_status,\n task_type,\n model_type,\n provider,\n provider_job_id,\n frontend_caller,\n frontend_subscriber_id,\n frontend_subscriber_payload,\n on_complete_primary_media_file_token,\n on_complete_primary_media_file_class,\n on_complete_batch_token,\n on_complete_primary_media_file_cdn_url,\n on_complete_primary_media_file_thumbnail_url_template,\n on_failure_type,\n on_failure_message,\n created_at as \"created_at: DateTime<Utc>\",\n updated_at as \"updated_at: DateTime<Utc>\",\n completed_at as \"completed_at: DateTime<Utc>\"\n FROM tasks\n WHERE is_dismissed_by_user == 0\n ",
"query": "\n SELECT\n id,\n task_status,\n task_type,\n model_type,\n provider,\n provider_job_id,\n frontend_caller,\n frontend_subscriber_id,\n frontend_subscriber_payload,\n on_complete_primary_media_file_token,\n on_complete_primary_media_file_class,\n on_complete_batch_token,\n on_complete_primary_media_file_cdn_url,\n on_complete_primary_media_file_thumbnail_url_template,\n created_at as \"created_at: DateTime<Utc>\",\n updated_at as \"updated_at: DateTime<Utc>\",\n completed_at as \"completed_at: DateTime<Utc>\"\n FROM tasks\n WHERE is_dismissed_by_user == 0\n ",
"describe": {
"columns": [
{
@@ -73,29 +73,19 @@
"ordinal": 13,
"type_info": "Text"
},
{
"name": "on_failure_type",
"ordinal": 14,
"type_info": "Text"
},
{
"name": "on_failure_message",
"ordinal": 15,
"type_info": "Text"
},
{
"name": "created_at: DateTime<Utc>",
"ordinal": 16,
"ordinal": 14,
"type_info": "Int64"
},
{
"name": "updated_at: DateTime<Utc>",
"ordinal": 17,
"ordinal": 15,
"type_info": "Int64"
},
{
"name": "completed_at: DateTime<Utc>",
"ordinal": 18,
"ordinal": 16,
"type_info": "Int64"
}
],
@@ -117,12 +107,10 @@
true,
true,
true,
true,
true,
false,
false,
true
]
},
"hash": "2436eb7bf4b0e23a899311a67a77f71d89eb35461e2113e43cd9d221bfa91c9a"
"hash": "47f94c127abc20683b25c257aed3c2190b9bd29eb829d61392f08e10fd986767"
}
@@ -1,6 +1,6 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO wallets\nSET\n token = ?,\n wallet_namespace = ?,\n\n owner_user_token = ?,\n\n banked_credits = 0,\n monthly_credits = 0\n ",
"query": "\nINSERT INTO wallets\nSET\n token = ?,\n wallet_namespace = ?,\n\n owner_user_token = ?,\n \n banked_credits = 0,\n monthly_credits = 0\n ",
"describe": {
"columns": [],
"parameters": {
@@ -8,5 +8,5 @@
},
"nullable": []
},
"hash": "f4fb945b4311bd4458a68d82e417b40f8c36c08d572c07881b89cd157a8a8699"
"hash": "4f5100a2cfb465964e77eaacb1ae394daae94de20c56a0966a8222dc9bea63c6"
}
@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "\n UPDATE tasks\n SET task_status = ?,\n on_failure_type = ?,\n on_failure_message = ?\n WHERE id = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 4
},
"nullable": []
},
"hash": "9b99ccd0baf9a4b74d8903f63e3a3dcf4c09b75cad978f0532388cbd743b7516"
}
@@ -1,58 +0,0 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n username,\n display_name,\n username_is_generated as `username_is_generated: bool`,\n username_is_not_customized as `username_is_not_customized: bool`\nFROM users\nWHERE\n token = ?\n AND user_deleted_at IS NULL\n AND mod_deleted_at IS NULL\nLIMIT 1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "username",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 1,
"name": "display_name",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 2,
"name": "username_is_generated: bool",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 3,
"name": "username_is_not_customized: bool",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 1
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false
]
},
"hash": "be9867966f24f44e14f96966cf5113055f00c339dda75334e7e88d355b9bfe72"
}
Generated
-25
View File
@@ -3934,7 +3934,6 @@ dependencies = [
name = "enums"
version = "0.0.1"
dependencies = [
"log",
"serde",
"serde_derive",
"serde_json",
@@ -7229,29 +7228,6 @@ dependencies = [
"thiserror 1.0.63",
]
[[package]]
name = "muapi_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",
"testing",
"tokio",
"url",
"workspace-build-acceleration",
"wreq",
"wreq-util",
]
[[package]]
name = "muda"
version = "0.17.1"
@@ -11621,7 +11597,6 @@ dependencies = [
"tts_common",
"url",
"url_config",
"url_utils",
"user_input_common",
"user_traits_component",
"users",
-2
View File
@@ -16,7 +16,6 @@ members = [
"crates/api_clients/midjourney_client",
"crates/api_clients/openai_sora_client",
"crates/api_clients/openart_client",
"crates/api_clients/muapi_client",
"crates/api_clients/seedance2pro",
"crates/api_clients/runwayml_client",
"crates/api_clients/thumbnail_generator",
@@ -241,7 +240,6 @@ memory_store = { path = "crates/lib/caching/memory_store" }
midjourney_client = { path = "crates/api_clients/midjourney_client" }
mimetypes = { path = "crates/lib/files/mimetypes" }
openai_sora_client = { path = "crates/api_clients/openai_sora_client" }
muapi_client = { path = "crates/api_clients/muapi_client" }
seedance2pro = { path = "crates/api_clients/seedance2pro" }
primitives = { path = "crates/lib/data/primitives" }
shared_service_components = { path = "crates/lib/shared_service_components" }
@@ -16,9 +16,7 @@
-- on_complete_primary_media_file_class,
-- on_complete_primary_media_file_cdn_url,
-- on_complete_primary_media_file_thumbnail_url_template,
-- tasks_v6.sqlite - added two fields:
-- on_failure_type
-- on_failure_message
--
CREATE TABLE tasks (
-- Task auto-incrementing primary key.
@@ -86,15 +84,6 @@ CREATE TABLE tasks (
-- The URL template for the thumbnail of the primary media file.
on_complete_primary_media_file_thumbnail_url_template TEXT,
-- OPTIONAL.
-- An enum-like string describing the failure reason (even for non-terminal failures).
-- The enum is `TaskFailureType`, which is a close analogue of the backend API's `FrontendFailureCategory`.
on_failure_type TEXT,
-- OPTIONAL.
-- A human-readable message describing the failure reason (even for non-terminal failures).
on_failure_message TEXT,
created_at INTEGER NOT NULL DEFAULT (unixepoch('now')),
updated_at INTEGER NOT NULL DEFAULT (unixepoch('now')),
completed_at INTEGER DEFAULT NULL
@@ -23,12 +23,6 @@ pub struct Seedance2p0MultiFunctionVideoGenRequest {
/// Optional reference images (reference mode). When present, takes priority over start/end frames.
pub reference_image_media_tokens: Option<Vec<MediaFileToken>>,
/// Optional reference videos.
pub reference_video_media_tokens: Option<Vec<MediaFileToken>>,
/// Optional reference audio files.
pub reference_audio_media_tokens: Option<Vec<MediaFileToken>>,
/// Video aspect ratio / resolution.
pub aspect_ratio: Option<Seedance2p0AspectRatio>,
@@ -9,8 +9,6 @@ use serde::Deserialize;
use serde::Serialize;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use utoipa::{IntoParams, ToSchema};
use enums::api_safe::by_table::generic_inference_jobs::frontend_failure_category_for_api_clients::FrontendFailureCategoryForApiClients;
use enums::api_safe::by_table::generic_inference_jobs::frontend_failure_category_for_old_clients::FrontendFailureCategoryForOldClients;
use tokens::tokens::batch_generations::BatchGenerationToken;
pub const LIST_SESSION_JOBS_URL_PATH: &str = "/v1/jobs/session";
@@ -100,19 +98,9 @@ pub struct ListSessionStatusDetailsResponse {
/// This is typically only for non-premium users.
pub requires_keepalive: bool,
/// NB: DO NOT CHANGE: Old clients can't read the new enum payload values as they are introduced.
///
/// An enum the frontend can use to display localized/I18N error
/// messages. These pertain to both transient and permanent failures.
#[deprecated(note="use maybe_failure_category_updated")]
pub maybe_failure_category: Option<FrontendFailureCategoryForOldClients>,
/// An enum the frontend can use to display localized/I18N error
/// messages. These pertain to both transient and permanent failures.
pub maybe_failure_category_updated: Option<FrontendFailureCategoryForApiClients>,
/// User-facing failure messages
pub maybe_failure_message: Option<String>,
pub maybe_failure_category: Option<FrontendFailureCategory>,
/// This is an integer number between 0 and 100 (both inclusive) that
/// reports the completeness.
@@ -137,3 +125,4 @@ pub struct ListSessionResultDetailsResponse {
pub maybe_successfully_completed_at: Option<DateTime<Utc>>,
}
@@ -1,4 +1,3 @@
pub mod list_user_wallets;
pub mod moderator_add_banked_balance_to_wallet;
pub mod moderator_create_wallet_for_user;
pub mod moderator_get_wallet;
@@ -1,19 +0,0 @@
use enums::common::payments_namespace::PaymentsNamespace;
use serde_derive::{Deserialize, Serialize};
use tokens::tokens::users::UserToken;
use tokens::tokens::wallets::WalletToken;
use utoipa::ToSchema;
pub const MODERATOR_CREATE_WALLET_FOR_USER_PATH: &str = "/v1/moderation/wallet/create_for_user";
#[derive(Deserialize, ToSchema)]
pub struct ModeratorCreateWalletForUserRequest {
pub user_token: Option<UserToken>,
pub payments_namespace: Option<PaymentsNamespace>,
}
#[derive(Serialize, ToSchema)]
pub struct ModeratorCreateWalletForUserResponse {
pub success: bool,
pub wallet_token: WalletToken,
}
@@ -1,7 +0,0 @@
use tokens::tokens::media_files::MediaFileToken;
#[derive(Copy, Clone)]
pub enum AudioListRef<'a> {
MediaFileTokens(&'a Vec<MediaFileToken>),
Urls(&'a Vec<String>),
}
@@ -1,4 +1,3 @@
pub mod audio_list_ref;
pub mod common_aspect_ratio;
pub mod common_image_model;
pub mod common_resolution;
@@ -7,4 +6,3 @@ pub mod common_video_model;
pub mod image_list_ref;
pub mod image_ref;
pub mod provider;
pub mod video_list_ref;
@@ -1,7 +0,0 @@
use tokens::tokens::media_files::MediaFileToken;
#[derive(Copy, Clone)]
pub enum VideoListRef<'a> {
MediaFileTokens(&'a Vec<MediaFileToken>),
Urls(&'a Vec<String>),
}
@@ -69,8 +69,6 @@ mod tests {
start_frame: None,
end_frame: None,
reference_images: None,
reference_videos: None,
reference_audio: None,
resolution: None,
aspect_ratio: None,
duration_seconds: Some(duration_seconds),
@@ -69,8 +69,6 @@ mod tests {
start_frame: None,
end_frame: None,
reference_images: None,
reference_videos: None,
reference_audio: None,
resolution: None,
aspect_ratio: None,
duration_seconds: Some(duration_seconds),
@@ -72,8 +72,6 @@ mod tests {
start_frame: None,
end_frame: None,
reference_images: None,
reference_videos: None,
reference_audio: None,
resolution: None,
aspect_ratio: None,
duration_seconds: Some(duration_seconds),
@@ -31,9 +31,6 @@ pub (crate) fn estimate_video_cost_artcraft_seedance2p0(
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
reference_video_urls: None,
reference_audio_urls: None,
use_face_blur_hack: None,
};
let cost_in_usd_cents = args.estimate_cost_in_usd_cents();
@@ -64,8 +61,6 @@ mod tests {
start_frame: None,
end_frame: None,
reference_images: None,
reference_videos: None,
reference_audio: None,
resolution: None,
aspect_ratio: None,
duration_seconds: Some(duration_seconds),
@@ -18,8 +18,6 @@ pub async fn execute_artcraft_seedance2p0(
start_frame_media_token: plan.start_frame.map(|t| t.to_owned()),
end_frame_media_token: plan.end_frame.map(|t| t.to_owned()),
reference_image_media_tokens: plan.reference_images.map(|tokens| tokens.to_owned()),
reference_video_media_tokens: plan.reference_videos.map(|tokens| tokens.to_owned()),
reference_audio_media_tokens: plan.reference_audio.map(|tokens| tokens.to_owned()),
aspect_ratio: plan.aspect_ratio,
duration_seconds: plan.duration_seconds,
batch_count: Some(plan.batch_count),
@@ -1,11 +1,9 @@
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_resolution::CommonResolution;
use crate::api::common_video_model::CommonVideoModel;
use crate::api::audio_list_ref::AudioListRef;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::provider::Provider;
use crate::api::video_list_ref::VideoListRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_kling3p0_pro::plan_generate_video_artcraft_kling3p0_pro;
@@ -36,12 +34,6 @@ pub struct GenerateVideoRequest<'a> {
/// Reference images (optional).
pub reference_images: Option<ImageListRef<'a>>,
/// Reference videos (optional).
pub reference_videos: Option<VideoListRef<'a>>,
/// Reference audio (optional).
pub reference_audio: Option<AudioListRef<'a>>,
/// The resolution to use
pub resolution: Option<CommonResolution>,
@@ -177,8 +177,6 @@ mod tests {
start_frame: None,
end_frame: None,
reference_images: None,
reference_videos: None,
reference_audio: None,
resolution: None,
aspect_ratio: None,
duration_seconds: None,
@@ -177,8 +177,6 @@ mod tests {
start_frame: None,
end_frame: None,
reference_images: None,
reference_videos: None,
reference_audio: None,
resolution: None,
aspect_ratio: None,
duration_seconds: None,
@@ -190,8 +190,6 @@ mod tests {
start_frame: None,
end_frame: None,
reference_images: None,
reference_videos: None,
reference_audio: None,
resolution: None,
aspect_ratio: None,
duration_seconds: None,
@@ -1,8 +1,6 @@
use crate::api::audio_list_ref::AudioListRef;
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::video_list_ref::VideoListRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
@@ -18,8 +16,6 @@ pub struct PlanArtcraftSeedance2p0<'a> {
pub start_frame: Option<&'a MediaFileToken>,
pub end_frame: Option<&'a MediaFileToken>,
pub reference_images: Option<&'a Vec<MediaFileToken>>,
pub reference_videos: Option<&'a Vec<MediaFileToken>>,
pub reference_audio: Option<&'a Vec<MediaFileToken>>,
pub aspect_ratio: Option<Seedance2p0AspectRatio>,
pub duration_seconds: Option<u8>,
pub batch_count: Seedance2p0BatchCount,
@@ -34,8 +30,6 @@ pub fn plan_generate_video_artcraft_seedance2p0<'a>(
let start_frame = resolve_image_ref(request.start_frame)?;
let end_frame = resolve_image_ref(request.end_frame)?;
let reference_images = resolve_image_list_ref(request.reference_images)?;
let reference_videos = resolve_video_list_ref(request.reference_videos)?;
let reference_audio = resolve_audio_list_ref(request.reference_audio)?;
let aspect_ratio = plan_aspect_ratio(request.aspect_ratio, strategy)?;
let batch_count = plan_batch_count(request.video_batch_count, strategy)?;
@@ -46,8 +40,6 @@ pub fn plan_generate_video_artcraft_seedance2p0<'a>(
start_frame,
end_frame,
reference_images,
reference_videos,
reference_audio,
aspect_ratio,
duration_seconds,
batch_count,
@@ -79,30 +71,6 @@ fn resolve_image_list_ref<'a>(
}
}
fn resolve_video_list_ref<'a>(
video_list_ref: Option<VideoListRef<'a>>,
) -> Result<Option<&'a Vec<MediaFileToken>>, ArtcraftRouterError> {
match video_list_ref {
None => Ok(None),
Some(VideoListRef::MediaFileTokens(tokens)) => Ok(Some(tokens)),
Some(VideoListRef::Urls(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
}
}
}
fn resolve_audio_list_ref<'a>(
audio_list_ref: Option<AudioListRef<'a>>,
) -> Result<Option<&'a Vec<MediaFileToken>>, ArtcraftRouterError> {
match audio_list_ref {
None => Ok(None),
Some(AudioListRef::MediaFileTokens(tokens)) => Ok(Some(tokens)),
Some(AudioListRef::Urls(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
}
}
}
// Supported aspect ratios and their AR values (width / height):
// Portrait9x16 = 0.5625, Portrait3x4 = 0.75, Square1x1 = 1.0, Standard4x3 = 1.33, Landscape16x9 = 1.78
//
@@ -290,8 +290,6 @@ pub fn base_video_request() -> GenerateVideoRequest<'static> {
start_frame: None,
end_frame: None,
reference_images: None,
reference_videos: None,
reference_audio: None,
resolution: None,
aspect_ratio: None,
duration_seconds: None,
@@ -1,33 +0,0 @@
[package]
name = "muapi_client"
edition = "2024"
version = "0.0.1"
publish = false
[package.metadata.cargo-udeps.ignore]
normal = ["workspace-build-acceleration"]
[lib]
name = "muapi_client"
path = "src/lib.rs"
[dependencies]
workspace-build-acceleration.workspace = true
errors.workspace = true
chrono.workspace = true
cloudflare_errors.workspace = true
log.workspace = true
rand.workspace = true
serde.workspace = true
serde_derive.workspace = true
serde_json.workspace = true
wreq-util.workspace = true
wreq.workspace = true
url.workspace = true
[dev-dependencies]
env.workspace = true
env_logger.workspace = true
test_data = { path = "../../lib/test_data" }
testing = { path = "../../lib/testing" }
tokio = { version = "1", features = ["full"] }
@@ -1 +0,0 @@
pub mod request_id;
@@ -1,24 +0,0 @@
/// ID for a prediction request in the Muapi API.
#[derive(Clone, Debug)]
pub struct RequestId(String);
impl RequestId {
pub fn new(value: String) -> Self {
RequestId(value)
}
pub fn from_str(value: &str) -> Self {
RequestId(value.to_string())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for RequestId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
@@ -1,2 +0,0 @@
pub mod muapi_api_key;
pub mod muapi_session;
@@ -1,20 +0,0 @@
/// An API key is the credential required to interact with the Muapi API.
#[derive(Clone)]
pub struct MuapiApiKey {
api_key: String,
}
impl MuapiApiKey {
pub fn new(api_key: String) -> Self {
MuapiApiKey { api_key }
}
pub fn as_str(&self) -> &str {
&self.api_key
}
pub fn to_string(&self) -> String {
self.api_key.clone()
}
}
@@ -1,19 +0,0 @@
use crate::creds::muapi_api_key::MuapiApiKey;
/// Holds the full session data needed to make authenticated requests to Muapi.
#[derive(Clone)]
pub struct MuapiSession {
pub api_key: MuapiApiKey,
}
impl MuapiSession {
pub fn new(api_key: MuapiApiKey) -> Self {
MuapiSession { api_key }
}
pub fn from_api_key_string(api_key: String) -> Self {
MuapiSession {
api_key: MuapiApiKey::new(api_key),
}
}
}
@@ -1,4 +0,0 @@
pub mod muapi_client_error;
pub mod muapi_error;
pub mod muapi_generic_api_error;
pub mod muapi_specific_api_error;
@@ -1,26 +0,0 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
#[derive(Debug)]
pub enum MuapiClientError {
/// No API key is present in the session.
NoApiKeyPresent,
/// Error parsing a URL.
UrlParseError(url::ParseError),
/// An error was encountered in building the Wreq client.
WreqClientError(wreq::Error),
}
impl Error for MuapiClientError {}
impl Display for MuapiClientError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::NoApiKeyPresent => write!(f, "No API key present in the session."),
Self::UrlParseError(err) => write!(f, "URL parse error: {}", err),
Self::WreqClientError(err) => write!(f, "Wreq client error (during client creation): {}", err),
}
}
}
@@ -1,61 +0,0 @@
use crate::error::muapi_client_error::MuapiClientError;
use crate::error::muapi_generic_api_error::MuapiGenericApiError;
use crate::error::muapi_specific_api_error::MuapiSpecificApiError;
use cloudflare_errors::cloudflare_error::CloudflareError;
use std::error::Error;
#[derive(Debug)]
pub enum MuapiError {
Client(MuapiClientError),
ApiSpecific(MuapiSpecificApiError),
ApiGeneric(MuapiGenericApiError),
}
impl MuapiError {
pub fn is_having_downtime_issues(&self) -> bool {
match self {
Self::ApiGeneric(MuapiGenericApiError::CloudflareError(CloudflareError::BadGateway502)) => true,
Self::ApiGeneric(MuapiGenericApiError::CloudflareError(CloudflareError::GatewayTimeout504)) => true,
Self::ApiGeneric(MuapiGenericApiError::CloudflareError(CloudflareError::TimeoutOccurred524)) => true,
Self::ApiGeneric(MuapiGenericApiError::UncategorizedBadResponseWithStatusAndBody { status_code, body: _ }) => {
match status_code.as_u16() {
502 => true,
504 => true,
524 => true,
_ => false,
}
},
_ => false,
}
}
}
impl Error for MuapiError {}
impl std::fmt::Display for MuapiError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Client(e) => write!(f, "MuapiClientError: {:?}", e),
Self::ApiSpecific(e) => write!(f, "MuapiSpecificApiError: {:?}", e),
Self::ApiGeneric(e) => write!(f, "MuapiGenericApiError: {:?}", e),
}
}
}
impl From<MuapiClientError> for MuapiError {
fn from(error: MuapiClientError) -> Self {
Self::Client(error)
}
}
impl From<MuapiSpecificApiError> for MuapiError {
fn from(error: MuapiSpecificApiError) -> Self {
Self::ApiSpecific(error)
}
}
impl From<MuapiGenericApiError> for MuapiError {
fn from(error: MuapiGenericApiError) -> Self {
Self::ApiGeneric(error)
}
}
@@ -1,41 +0,0 @@
use cloudflare_errors::cloudflare_error::CloudflareError;
use std::error::Error;
use std::fmt::{Display, Formatter};
use wreq::StatusCode;
#[derive(Debug)]
pub enum MuapiGenericApiError {
/// Specific Cloudflare errors.
CloudflareError(CloudflareError),
/// serde_json::Error, likely from JSON deserialization schema mismatch.
/// Includes the original body.
SerdeResponseParseErrorWithBody(serde_json::Error, String),
/// serde_json::Error on a non-200 (error) response.
/// Includes the original body.
SerdeParseErrorWithBodyOnNon200(serde_json::Error, String),
/// An uncategorized bad HTTP response with status code and body.
UncategorizedBadResponseWithStatusAndBody {
status_code: StatusCode,
body: String,
},
/// An uncaught error from the HTTP client.
WreqError(wreq::Error),
}
impl Error for MuapiGenericApiError {}
impl Display for MuapiGenericApiError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::CloudflareError(err) => write!(f, "Cloudflare error: {}", err),
Self::SerdeResponseParseErrorWithBody(err, body) => write!(f, "Failed to parse response body: {:?}. Body: {}", err, body),
Self::SerdeParseErrorWithBodyOnNon200(err, body) => write!(f, "Failed to parse non-200 response body: {:?}. Body: {}", err, body),
Self::UncategorizedBadResponseWithStatusAndBody { status_code, body } => write!(f, "Uncategorized bad response: status code {}, body: {}", status_code, body),
Self::WreqError(err) => write!(f, "Wreq client error: {}", err),
}
}
}
@@ -1,18 +0,0 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
#[derive(Debug)]
pub enum MuapiSpecificApiError {
/// Invalid API key.
InvalidApiKey,
}
impl Error for MuapiSpecificApiError {}
impl Display for MuapiSpecificApiError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidApiKey => write!(f, "Unauthorized: invalid API key."),
}
}
}
@@ -1,8 +0,0 @@
#[cfg(test)]
pub (crate) mod test_utils;
pub mod api_types;
pub mod creds;
pub mod error;
pub mod requests;
pub mod utils;
@@ -1,3 +0,0 @@
pub mod poll_prediction_result;
pub mod seedance_2p0_image_to_video;
pub mod seedance_2p0_text_to_video;
@@ -1,2 +0,0 @@
pub mod poll_prediction_result;
mod request_types;
@@ -1,142 +0,0 @@
use crate::api_types::request_id::RequestId;
use crate::creds::muapi_session::MuapiSession;
use crate::error::muapi_client_error::MuapiClientError;
use crate::error::muapi_error::MuapiError;
use crate::error::muapi_generic_api_error::MuapiGenericApiError;
use crate::requests::poll_prediction_result::request_types::*;
use log::info;
use wreq::Client;
const PREDICTIONS_BASE_URL: &str = "https://api.muapi.ai/api/v1/predictions";
// --- Public types ---
/// The lifecycle status of a prediction.
#[derive(Debug, Clone, PartialEq)]
pub enum PredictionStatus {
/// The task is queued and has not started yet.
Pending,
/// The task is actively being processed.
Processing,
/// The task finished successfully. `outputs` will be populated.
Completed,
/// The task failed. `error` will contain the reason.
Failed,
/// An unrecognised status string was returned by the server.
Unknown(String),
}
impl PredictionStatus {
fn from_str(s: &str) -> Self {
match s {
"pending" => Self::Pending,
"processing" => Self::Processing,
"completed" => Self::Completed,
"failed" => Self::Failed,
other => Self::Unknown(other.to_string()),
}
}
pub fn is_terminal(&self) -> bool {
matches!(self, Self::Completed | Self::Failed)
}
}
// --- Args & response ---
pub struct PollPredictionResultArgs<'a> {
pub session: &'a MuapiSession,
/// The request_id returned from a submission endpoint.
pub request_id: &'a RequestId,
}
pub struct PollPredictionResultApiResponse {
/// The current status of the prediction.
pub status: PredictionStatus,
/// Output URLs. Populated when `status` is `Completed`.
pub output_urls: Option<Vec<String>>,
/// Error message. Populated when `status` is `Failed`.
pub error: Option<String>,
}
// --- Implementation ---
pub async fn poll_prediction_result(
args: PollPredictionResultArgs<'_>,
) -> Result<PollPredictionResultApiResponse, MuapiError> {
let url = format!("{}/{}/result", PREDICTIONS_BASE_URL, args.request_id.as_str());
info!("Polling Muapi prediction result: {}", url);
let api_key = args.session.api_key.as_str();
let client = Client::builder()
.build()
.map_err(|err| MuapiClientError::WreqClientError(err))?;
let response = client.get(&url)
.header("Content-Type", "application/json")
.header("x-api-key", api_key)
.send()
.await
.map_err(|err| MuapiGenericApiError::WreqError(err))?;
let status = response.status();
let response_body = response.text()
.await
.map_err(|err| MuapiGenericApiError::WreqError(err))?;
info!("Muapi poll response status: {}, body: {}", status, response_body);
if !status.is_success() {
return Err(MuapiGenericApiError::UncategorizedBadResponseWithStatusAndBody {
status_code: status,
body: response_body,
}.into());
}
let parsed: PollPredictionResultResponse = serde_json::from_str(&response_body)
.map_err(|err| MuapiGenericApiError::SerdeResponseParseErrorWithBody(err, response_body))?;
let output_urls = parsed.outputs.and_then(|urls| if urls.is_empty() { None } else { Some(urls) });
Ok(PollPredictionResultApiResponse {
status: PredictionStatus::from_str(&parsed.status),
output_urls,
error: parsed.error,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_utils::get_test_api_key::get_test_api_key;
use crate::test_utils::setup_test_logging::setup_test_logging;
use errors::AnyhowResult;
use log::LevelFilter;
#[tokio::test]
#[ignore] // manually test — requires real API key and a valid request_id
async fn test_poll_prediction_result() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = get_test_api_key()?;
let request_id = RequestId::from_str("5fd7f575-4b9e-4c0a-b7f7-0eb036027b81"); // Corgi shiba I2V
let request_id = RequestId::from_str("8a4b578b-4514-443f-b444-4fb3bc5c2cbb"); // Cat TTV
let request_id = RequestId::from_str("82d622b1-8911-4cac-8362-e18efaa743ed"); // Corgi I2V
let request_id = RequestId::from_str("671c2dee-2ca9-47d7-a12e-d16610b1b365"); // Corgi TTV #1
let request_id = RequestId::from_str("8c5303f6-b602-433c-bae8-2d3ef915b7bb"); // Corgi TTV #2
let result = poll_prediction_result(PollPredictionResultArgs {
session: &session,
request_id: &request_id,
}).await?;
println!("Status: {:?}", result.status);
println!("Output URLs: {:?}", result.output_urls);
println!("Error: {:?}", result.error);
assert_eq!(1, 2); // NB: Intentional failure to inspect output.
Ok(())
}
}
@@ -1,8 +0,0 @@
use serde_derive::Deserialize;
#[derive(Deserialize, Debug)]
pub(super) struct PollPredictionResultResponse {
pub status: String,
pub outputs: Option<Vec<String>>,
pub error: Option<String>,
}
@@ -1,2 +0,0 @@
pub mod seedance_2p0_image_to_video;
mod request_types;
@@ -1,19 +0,0 @@
use serde_derive::{Deserialize, Serialize};
// --- Request ---
#[derive(Serialize, Debug)]
pub(super) struct Seedance2p0I2vRequest {
pub prompt: String,
pub images_list: Vec<String>,
pub aspect_ratio: &'static str,
pub duration: u8,
pub quality: &'static str,
}
// --- Response ---
#[derive(Deserialize, Debug)]
pub(super) struct Seedance2p0I2vResponse {
pub request_id: String,
}
@@ -1,221 +0,0 @@
use crate::api_types::request_id::RequestId;
use crate::creds::muapi_session::MuapiSession;
use crate::error::muapi_client_error::MuapiClientError;
use crate::error::muapi_error::MuapiError;
use crate::error::muapi_generic_api_error::MuapiGenericApiError;
use crate::requests::seedance_2p0_image_to_video::request_types::*;
use log::info;
use wreq::Client;
const SEEDANCE_2P0_I2V_URL: &str = "https://api.muapi.ai/api/v1/seedance-v2.0-i2v";
// --- Public enums ---
/// Video aspect ratio.
#[derive(Debug, Clone, Copy)]
pub enum AspectRatio {
/// 16:9 landscape
Landscape16x9,
/// 9:16 portrait
Portrait9x16,
/// 4:3 standard
Standard4x3,
/// 3:4 portrait
Portrait3x4,
}
impl AspectRatio {
fn as_str(&self) -> &'static str {
match self {
Self::Landscape16x9 => "16:9",
Self::Portrait9x16 => "9:16",
Self::Standard4x3 => "4:3",
Self::Portrait3x4 => "3:4",
}
}
}
/// Video duration.
#[derive(Debug, Clone, Copy)]
pub enum Duration {
/// 5 seconds
FiveSeconds,
/// 10 seconds
TenSeconds,
/// 15 seconds
FifteenSeconds,
}
impl Duration {
fn as_u8(&self) -> u8 {
match self {
Self::FiveSeconds => 5,
Self::TenSeconds => 10,
Self::FifteenSeconds => 15,
}
}
}
/// Video quality tier.
#[derive(Debug, Clone, Copy)]
pub enum Quality {
Basic,
High,
}
impl Quality {
fn as_str(&self) -> &'static str {
match self {
Self::Basic => "basic",
Self::High => "high",
}
}
}
// --- Args & response ---
pub struct Seedance2p0ImageToVideoArgs<'a> {
pub session: &'a MuapiSession,
/// The prompt describing the desired video.
pub prompt: String,
/// One or more image URLs to use as input frames.
pub image_urls: Vec<String>,
/// The aspect ratio for the output video.
pub aspect_ratio: AspectRatio,
/// Duration of the generated video.
pub duration: Duration,
/// Quality tier.
pub quality: Quality,
}
impl std::fmt::Debug for Seedance2p0ImageToVideoArgs<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Seedance2p0ImageToVideoArgs")
.field("prompt", &self.prompt)
.field("image_urls", &self.image_urls)
.field("aspect_ratio", &self.aspect_ratio)
.field("duration", &self.duration)
.field("quality", &self.quality)
.finish()
}
}
#[derive(Debug)]
pub struct Seedance2p0ImageToVideoResponse {
/// The request ID used to poll for results.
pub request_id: RequestId,
}
// --- Implementation ---
pub async fn seedance_2p0_image_to_video(
args: Seedance2p0ImageToVideoArgs<'_>,
) -> Result<Seedance2p0ImageToVideoResponse, MuapiError> {
info!("Submitting Seedance 2.0 i2v task to Muapi: {:?}", args);
let request_body = Seedance2p0I2vRequest {
prompt: args.prompt,
images_list: args.image_urls,
aspect_ratio: args.aspect_ratio.as_str(),
duration: args.duration.as_u8(),
quality: args.quality.as_str(),
};
info!("Muapi request body: {:?}", request_body);
let api_key = args.session.api_key.as_str();
let client = Client::builder()
.build()
.map_err(|err| MuapiClientError::WreqClientError(err))?;
let response = client.post(SEEDANCE_2P0_I2V_URL)
.header("Content-Type", "application/json")
.header("x-api-key", api_key)
.json(&request_body)
.send()
.await
.map_err(|err| MuapiGenericApiError::WreqError(err))?;
let status = response.status();
let response_body = response.text()
.await
.map_err(|err| MuapiGenericApiError::WreqError(err))?;
info!("Muapi response status: {}, body: {}", status, response_body);
if !status.is_success() {
return Err(MuapiGenericApiError::UncategorizedBadResponseWithStatusAndBody {
status_code: status,
body: response_body,
}.into());
}
let parsed: Seedance2p0I2vResponse = serde_json::from_str(&response_body)
.map_err(|err| MuapiGenericApiError::SerdeResponseParseErrorWithBody(err, response_body))?;
Ok(Seedance2p0ImageToVideoResponse {
request_id: RequestId::new(parsed.request_id),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_utils::get_test_api_key::get_test_api_key;
use crate::test_utils::setup_test_logging::setup_test_logging;
use errors::AnyhowResult;
use log::LevelFilter;
use test_data::web::image_urls::*;
#[tokio::test]
#[ignore] // manually test — requires real API key
async fn test_seedance_2p0_image_to_video() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = get_test_api_key()?;
let args = Seedance2p0ImageToVideoArgs {
session: &session,
prompt: "The dog barks and runs across the lake's pier, tail wagging.".to_string(),
image_urls: vec![
JUNO_AT_LAKE_IMAGE_URL.to_string(),
],
aspect_ratio: AspectRatio::Landscape16x9,
duration: Duration::FiveSeconds,
quality: Quality::High,
};
let result = seedance_2p0_image_to_video(args).await?;
println!("Result: {:?}", result);
println!("Request ID: {}", result.request_id);
assert!(!result.request_id.as_str().is_empty());
assert_eq!(1, 2); // NB: Intentional failure to inspect output.
Ok(())
}
#[tokio::test]
#[ignore] // manually test — requires real API key
async fn test_seedance_2p0_image_to_video_portrait() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = get_test_api_key()?;
let args = Seedance2p0ImageToVideoArgs {
session: &session,
prompt: "The corgi and shiba swim through the treasure-filled ocean, bubbles rising around them.".to_string(),
image_urls: vec![
TALL_CORGI_SHIBA_TREASURE_OCEAN_URL.to_string(),
],
aspect_ratio: AspectRatio::Portrait9x16,
duration: Duration::FiveSeconds,
quality: Quality::High,
};
let result = seedance_2p0_image_to_video(args).await?;
println!("Result: {:?}", result);
println!("Request ID: {}", result.request_id);
assert!(!result.request_id.as_str().is_empty());
assert_eq!(1, 2); // NB: Intentional failure to inspect output.
Ok(())
}
}
@@ -1,2 +0,0 @@
pub mod seedance_2p0_text_to_video;
mod request_types;
@@ -1,18 +0,0 @@
use serde_derive::{Deserialize, Serialize};
// --- Request ---
#[derive(Serialize, Debug)]
pub(super) struct Seedance2p0T2vRequest {
pub prompt: String,
pub aspect_ratio: &'static str,
pub duration: u8,
pub quality: &'static str,
}
// --- Response ---
#[derive(Deserialize, Debug)]
pub(super) struct Seedance2p0T2vResponse {
pub request_id: String,
}
@@ -1,209 +0,0 @@
use crate::api_types::request_id::RequestId;
use crate::creds::muapi_session::MuapiSession;
use crate::error::muapi_client_error::MuapiClientError;
use crate::error::muapi_error::MuapiError;
use crate::error::muapi_generic_api_error::MuapiGenericApiError;
use crate::requests::seedance_2p0_text_to_video::request_types::*;
use log::info;
use wreq::Client;
const SEEDANCE_2P0_T2V_URL: &str = "https://api.muapi.ai/api/v1/seedance-v2.0-t2v";
// --- Public enums ---
/// Video aspect ratio.
#[derive(Debug, Clone, Copy)]
pub enum AspectRatio {
/// 16:9 landscape
Landscape16x9,
/// 9:16 portrait
Portrait9x16,
/// 4:3 standard
Standard4x3,
/// 3:4 portrait
Portrait3x4,
}
impl AspectRatio {
fn as_str(&self) -> &'static str {
match self {
Self::Landscape16x9 => "16:9",
Self::Portrait9x16 => "9:16",
Self::Standard4x3 => "4:3",
Self::Portrait3x4 => "3:4",
}
}
}
/// Video duration.
#[derive(Debug, Clone, Copy)]
pub enum Duration {
/// 5 seconds
FiveSeconds,
/// 10 seconds
TenSeconds,
/// 15 seconds
FifteenSeconds,
}
impl Duration {
fn as_u8(&self) -> u8 {
match self {
Self::FiveSeconds => 5,
Self::TenSeconds => 10,
Self::FifteenSeconds => 15,
}
}
}
/// Video quality tier.
#[derive(Debug, Clone, Copy)]
pub enum Quality {
Basic,
High,
}
impl Quality {
fn as_str(&self) -> &'static str {
match self {
Self::Basic => "basic",
Self::High => "high",
}
}
}
// --- Args & response ---
pub struct Seedance2p0TextToVideoArgs<'a> {
pub session: &'a MuapiSession,
/// The prompt describing the desired video.
pub prompt: String,
/// The aspect ratio for the output video.
pub aspect_ratio: AspectRatio,
/// Duration of the generated video.
pub duration: Duration,
/// Quality tier.
pub quality: Quality,
}
impl std::fmt::Debug for Seedance2p0TextToVideoArgs<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Seedance2p0TextToVideoArgs")
.field("prompt", &self.prompt)
.field("aspect_ratio", &self.aspect_ratio)
.field("duration", &self.duration)
.field("quality", &self.quality)
.finish()
}
}
#[derive(Debug)]
pub struct Seedance2p0TextToVideoResponse {
/// The request ID used to poll for results.
pub request_id: RequestId,
}
// --- Implementation ---
pub async fn seedance_2p0_text_to_video(
args: Seedance2p0TextToVideoArgs<'_>,
) -> Result<Seedance2p0TextToVideoResponse, MuapiError> {
info!("Submitting Seedance 2.0 t2v task to Muapi: {:?}", args);
let request_body = Seedance2p0T2vRequest {
prompt: args.prompt,
aspect_ratio: args.aspect_ratio.as_str(),
duration: args.duration.as_u8(),
quality: args.quality.as_str(),
};
info!("Muapi request body: {:?}", request_body);
let api_key = args.session.api_key.as_str();
let client = Client::builder()
.build()
.map_err(|err| MuapiClientError::WreqClientError(err))?;
let response = client.post(SEEDANCE_2P0_T2V_URL)
.header("Content-Type", "application/json")
.header("x-api-key", api_key)
.json(&request_body)
.send()
.await
.map_err(|err| MuapiGenericApiError::WreqError(err))?;
let status = response.status();
let response_body = response.text()
.await
.map_err(|err| MuapiGenericApiError::WreqError(err))?;
info!("Muapi response status: {}, body: {}", status, response_body);
if !status.is_success() {
return Err(MuapiGenericApiError::UncategorizedBadResponseWithStatusAndBody {
status_code: status,
body: response_body,
}.into());
}
let parsed: Seedance2p0T2vResponse = serde_json::from_str(&response_body)
.map_err(|err| MuapiGenericApiError::SerdeResponseParseErrorWithBody(err, response_body))?;
Ok(Seedance2p0TextToVideoResponse {
request_id: RequestId::new(parsed.request_id),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_utils::get_test_api_key::get_test_api_key;
use crate::test_utils::setup_test_logging::setup_test_logging;
use errors::AnyhowResult;
use log::LevelFilter;
#[tokio::test]
#[ignore] // manually test — requires real API key
async fn test_seedance_2p0_text_to_video_landscape() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = get_test_api_key()?;
let args = Seedance2p0TextToVideoArgs {
session: &session,
prompt: "A corgi walks past a lighthouse on the shore. In the background, mossy rocks and rolling green hills dot the landscape. The sand is black, and the air is full of fog and mist".to_string(),
aspect_ratio: AspectRatio::Landscape16x9,
duration: Duration::FiveSeconds,
quality: Quality::High,
};
let result = seedance_2p0_text_to_video(args).await?;
println!("Result: {:?}", result);
println!("Request ID: {}", result.request_id);
assert!(!result.request_id.as_str().is_empty());
assert_eq!(1, 2); // NB: Intentional failure to inspect output.
Ok(())
}
#[tokio::test]
#[ignore] // manually test — requires real API key
async fn test_seedance_2p0_text_to_video_portrait() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = get_test_api_key()?;
let args = Seedance2p0TextToVideoArgs {
session: &session,
prompt: "A cat sits on a windowsill watching rain fall outside. Lightning flashes and the cat's eyes widen.".to_string(),
aspect_ratio: AspectRatio::Portrait9x16,
duration: Duration::TenSeconds,
quality: Quality::Basic,
};
let result = seedance_2p0_text_to_video(args).await?;
println!("Result: {:?}", result);
println!("Request ID: {}", result.request_id);
assert!(!result.request_id.as_str().is_empty());
assert_eq!(1, 2); // NB: Intentional failure to inspect output.
Ok(())
}
}
@@ -1,10 +0,0 @@
use crate::creds::muapi_session::MuapiSession;
use errors::AnyhowResult;
use std::fs::read_to_string;
#[cfg(test)]
pub fn get_test_api_key() -> AnyhowResult<MuapiSession> {
let api_key = read_to_string("/Users/bt/Artcraft/credentials/muapi_api_key.txt")?;
let api_key = api_key.trim().to_string();
Ok(MuapiSession::from_api_key_string(api_key))
}
@@ -1,5 +0,0 @@
#[cfg(test)]
pub (crate) mod get_test_api_key;
#[cfg(test)]
pub (crate) mod setup_test_logging;
@@ -1,52 +0,0 @@
use chrono::Local;
use env_logger::Builder;
use log::{debug, error, info, trace, warn, LevelFilter};
use std::io::Write;
const RUST_LOG: &str = "RUST_LOG";
pub fn setup_test_logging(level: LevelFilter) {
let level_str = get_level_str(level);
println!("Previous log level: {:?}", env::var(RUST_LOG));
let is_unsafe = env::set_var(RUST_LOG, level_str);
if is_unsafe.is_none() {
println!("Unsafely setting Rust Log Level...");
unsafe { std::env::set_var(RUST_LOG, level_str) }
}
println!("Log level: {:?}", env::var(RUST_LOG));
Builder::new()
.is_test(true)
.format(|buf, record| {
writeln!(buf,
"{} [{}] - {}",
Local::now().format("%H:%M:%S%.6f"),
record.level(),
record.args()
)
})
.filter(None, level)
.filter_level(level)
.init();
trace!("Test trace log");
debug!("Test debug log");
info!("Test info log");
warn!("Test warn log");
error!("Test error log");
}
fn get_level_str(level: LevelFilter) -> &'static str {
match level {
LevelFilter::Error => "error",
LevelFilter::Warn => "warn",
LevelFilter::Info => "info",
LevelFilter::Debug => "debug",
LevelFilter::Trace => "trace",
LevelFilter::Off => "off",
}
}
@@ -1,2 +0,0 @@
pub const FIREFOX_USER_AGENT: &str =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:147.0) Gecko/20100101 Firefox/147.0";
@@ -1 +0,0 @@
pub mod common_headers;
@@ -31,6 +31,7 @@ wreq.workspace = true
url.workspace = true
[dev-dependencies]
chrono.workspace = true
env.workspace = true
env_logger.workspace = true
testing = { path = "../../lib/testing" }
@@ -14,17 +14,10 @@ pub enum Seedance2ProError {
impl Seedance2ProError {
pub fn is_having_downtime_issues(&self) -> bool {
match self {
Self::ApiGeneric(Seedance2ProGenericApiError::Http502ErrorBadGateway(_)) => true,
Self::ApiGeneric(Seedance2ProGenericApiError::CloudflareError(CloudflareError::BadGateway502)) => true,
Self::ApiGeneric(Seedance2ProGenericApiError::CloudflareError(CloudflareError::GatewayTimeout504)) => true,
Self::ApiGeneric(Seedance2ProGenericApiError::CloudflareError(CloudflareError::TimeoutOccurred524)) => true,
Self::ApiGeneric(Seedance2ProGenericApiError::UncategorizedBadResponseWithStatusAndBody { status_code, body: _ }) => {
match status_code.as_u16() {
502 => true,
504 => true,
524 => true,
_ => false,
}
},
_ => false,
}
}
@@ -8,6 +8,10 @@ pub enum Seedance2ProGenericApiError {
/// Specific Cloudflare errors.
CloudflareError(CloudflareError),
/// HTTP 502 Bad Gateway.
/// We preserve the message for debugging.
Http502ErrorBadGateway(String),
/// serde_json::Error, likely from JSON deserialization schema mismatch.
/// Includes the original body.
SerdeResponseParseErrorWithBody(serde_json::Error, String),
@@ -35,6 +39,7 @@ impl Display for Seedance2ProGenericApiError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::CloudflareError(err) => write!(f, "Cloudflare error: {}", err),
Self::Http502ErrorBadGateway(msg) => write!(f, "HTTP 502 Bad Gateway: {}", msg),
Self::SerdeResponseParseErrorWithBody(err, body) => write!(f, "Failed to parse response body: {:?}. Body: {}", err, body),
Self::SerdeParseErrorWithBodyOnNon200(err, body) => write!(f, "Failed to parse non-200 response body: {:?}. Body: {}", err, body),
Self::UncategorizedBadResponse(msg) => write!(f, "Uncategorized bad response: {}", msg),
@@ -31,40 +31,8 @@ pub struct GenerateVideoArgs<'a> {
/// Optional end frame image URL (keyframe mode).
pub end_frame_url: Option<String>,
/// Optional reference image URLs (reference mode).
/// When present, takes priority over start/end frames.
/// Optional reference image URLs (reference mode). When present, takes priority over start/end frames.
pub reference_image_urls: Option<Vec<String>>,
/// Optional reference video URLs (reference mode).
/// Can be combined with reference_image_urls.
/// Videos are referenced in prompts as @video1, @video2, etc.
/// When present, takes priority over start/end frames.
pub reference_video_urls: Option<Vec<String>>,
/// Optional reference audio URLs (reference mode).
/// Audio is referenced in prompts as @audio1, @audio2, etc.
/// Sent in a separate `audioUrls` field (not in `uploadedUrls`).
pub reference_audio_urls: Option<Vec<String>>,
/// Controls the `faceBlurMode` field: true sends "on", false sends "off", None omits it.
pub use_face_blur_hack: Option<bool>,
}
impl std::fmt::Debug for GenerateVideoArgs<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GenerateVideoArgs")
.field("prompt", &self.prompt)
.field("resolution", &self.resolution)
.field("duration_seconds", &self.duration_seconds)
.field("batch_count", &self.batch_count)
.field("start_frame_url", &self.start_frame_url)
.field("end_frame_url", &self.end_frame_url)
.field("reference_image_urls", &self.reference_image_urls)
.field("reference_video_urls", &self.reference_video_urls)
.field("reference_audio_urls", &self.reference_audio_urls)
.field("use_face_blur_hack", &self.use_face_blur_hack)
.finish()
}
}
impl GenerateVideoArgs<'_> {
@@ -158,26 +126,12 @@ pub struct GenerateVideoResponse {
// --- Implementation ---
pub async fn generate_video(args: GenerateVideoArgs<'_>) -> Result<GenerateVideoResponse, Seedance2ProError> {
info!("Requesting video from Seedance2Pro: {:?}", args);
let has_reference_images = args.reference_image_urls.as_ref().is_some_and(|urls| !urls.is_empty());
let has_reference_videos = args.reference_video_urls.as_ref().is_some_and(|urls| !urls.is_empty());
let has_reference_audio = args.reference_audio_urls.as_ref().is_some_and(|urls| !urls.is_empty());
let is_reference_mode = has_reference_images || has_reference_videos || has_reference_audio;
let is_reference_mode = args.reference_image_urls.as_ref().is_some_and(|urls| !urls.is_empty());
let video_input_mode = if is_reference_mode { "reference" } else { "keyframe" };
let uploaded_urls: Option<Vec<String>> = if is_reference_mode {
let mut urls = Vec::new();
if let Some(video_urls) = args.reference_video_urls {
urls.extend(video_urls);
}
if let Some(image_urls) = args.reference_image_urls {
urls.extend(image_urls);
}
if urls.is_empty() { None } else { Some(urls) }
args.reference_image_urls
} else {
let mut urls = Vec::new();
if let Some(url) = args.start_frame_url {
@@ -189,18 +143,6 @@ pub async fn generate_video(args: GenerateVideoArgs<'_>) -> Result<GenerateVideo
if urls.is_empty() { None } else { Some(urls) }
};
let audio_urls: Option<Vec<String>> = if has_reference_audio {
args.reference_audio_urls
} else {
None
};
let face_blur_mode = match args.use_face_blur_hack {
Some(true) => Some("on"),
Some(false) => Some("off"),
None => None,
};
let batch_count_value = args.batch_count.as_u8();
let batch_count = if batch_count_value > 1 { Some(batch_count_value) } else { None };
@@ -222,17 +164,13 @@ pub async fn generate_video(args: GenerateVideoArgs<'_>) -> Result<GenerateVideo
model: "seedance-20",
duration,
mode: video_input_mode,
face_blur_mode,
uploaded_urls,
audio_urls,
batch_count,
},
},
},
};
info!("Seedance2pro request : {:?}", request_body);
let cookie = args.session.cookies.as_str();
let client = Client::builder()
@@ -302,15 +240,12 @@ pub async fn generate_video(args: GenerateVideoArgs<'_>) -> Result<GenerateVideo
#[cfg(test)]
mod tests {
use std::fs;
use super::*;
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::test_utils::get_test_cookies::get_test_cookies;
use crate::test_utils::setup_test_logging::setup_test_logging;
use errors::AnyhowResult;
use log::LevelFilter;
use crate::requests::prepare_file_upload::prepare_file_upload::{prepare_file_upload, PrepareFileUploadArgs};
use crate::requests::upload_file::upload_file::{upload_file, UploadFileArgs};
fn dummy_session() -> Seedance2ProSession {
Seedance2ProSession::from_cookies_string(String::new())
@@ -328,9 +263,6 @@ mod tests {
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
reference_video_urls: None,
reference_audio_urls: None,
use_face_blur_hack: None,
}
}
@@ -393,9 +325,6 @@ mod tests {
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
reference_video_urls: None,
reference_audio_urls: None,
use_face_blur_hack: None,
};
let result = generate_video(args).await?;
println!("Task ID: {}", result.task_id);
@@ -420,9 +349,6 @@ mod tests {
start_frame_url: Some("https://static.seedance2-pro.com/materials/20260219/1771496300184-fb32e08c.jpg".to_string()),
end_frame_url: None,
reference_image_urls: None,
reference_video_urls: None,
reference_audio_urls: None,
use_face_blur_hack: None,
};
let result = generate_video(args).await?;
println!("Task ID: {}", result.task_id);
@@ -434,7 +360,7 @@ mod tests {
#[tokio::test]
#[ignore] // manually test — requires real cookies
async fn test_generate_reference_image_video() -> AnyhowResult<()> {
async fn test_generate_reference_video() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let args = GenerateVideoArgs {
@@ -449,9 +375,6 @@ mod tests {
"https://static.seedance2-pro.com/materials/20260219/1771463564512-b14bfe90.png".to_string(),
"https://static.seedance2-pro.com/materials/20260219/1771496300184-fb32e08c.jpg".to_string(),
]),
reference_video_urls: None,
reference_audio_urls: None,
use_face_blur_hack: None,
};
let result = generate_video(args).await?;
println!("Task ID: {}", result.task_id);
@@ -460,113 +383,4 @@ mod tests {
assert_eq!(1, 2); // NB: Intentional failure to inspect output.
Ok(())
}
#[tokio::test]
#[ignore] // manually test — requires real cookies
async fn test_generate_reference_video_only() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let args = GenerateVideoArgs {
session: &session,
prompt: "Change the Video @video1 to night time.".to_string(),
resolution: Resolution::Landscape16x9,
duration_seconds: 5,
batch_count: BatchCount::One,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
reference_video_urls: Some(vec![
"https://static.seedance2-pro.com/materials/20260315/1773594284659-3a46d231.mp4".to_string(),
]),
reference_audio_urls: None,
use_face_blur_hack: None,
};
let result = generate_video(args).await?;
println!("Task ID: {}", result.task_id);
println!("Order ID: {}", result.order_id);
assert!(!result.task_id.is_empty());
assert_eq!(1, 2); // NB: Intentional failure to inspect output.
Ok(())
}
#[tokio::test]
#[ignore] // manually test — requires real cookies
async fn test_generate_reference_video_and_image() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let args = GenerateVideoArgs {
session: &session,
prompt: "Put the robot in @video1 next to the house in @image1".to_string(),
resolution: Resolution::Landscape16x9,
duration_seconds: 5,
batch_count: BatchCount::One,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: Some(vec![
"https://static.seedance2-pro.com/materials/20260315/1773595053724-07a1d500.png".to_string(),
]),
reference_video_urls: Some(vec![
"https://static.seedance2-pro.com/materials/20260315/1773594284659-3a46d231.mp4".to_string(),
]),
reference_audio_urls: None,
use_face_blur_hack: None,
};
let result = generate_video(args).await?;
println!("Task ID: {}", result.task_id);
println!("Order ID: {}", result.order_id);
assert!(!result.task_id.is_empty());
assert_eq!(1, 2); // NB: Intentional failure to inspect output.
Ok(())
}
#[tokio::test]
#[ignore] // manually test — requires real cookies and a test image
async fn test_video_ref_file_that_is_too_long() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
// Step 1: Get a signed upload URL
let cookies = get_test_cookies()?;
let session = Seedance2ProSession::from_cookies_string(cookies);
let prepare_args = PrepareFileUploadArgs {
session: &session,
extension: "mp4".to_string(),
};
let prepare_result = prepare_file_upload(prepare_args).await?;
println!("Upload URL: {}", prepare_result.upload_url);
// Step 2: Read a test image
let file_bytes = fs::read("/Users/bt/Videos/Artcraft/Artcraft Best/ArtCraft Seedance Knight.mp4")?;
println!("File size: {} bytes", file_bytes.len());
// Step 3: Upload
let upload_args = UploadFileArgs {
upload_url: prepare_result.upload_url,
file_bytes,
};
let result = upload_file(upload_args).await?;
println!("Public URL: {}", result.public_url);
let args = GenerateVideoArgs {
session: &session,
prompt: "Change @video1 to night time".to_string(),
resolution: Resolution::Landscape16x9,
duration_seconds: 5,
batch_count: BatchCount::One,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
reference_video_urls: Some(vec![
result.public_url,
]),
reference_audio_urls: None,
use_face_blur_hack: None,
};
let result = generate_video(args).await?;
println!("Task ID: {}", result.task_id);
println!("Order ID: {}", result.order_id);
assert!(!result.task_id.is_empty());
assert_eq!(1, 2); // NB: Intentional failure to inspect output.
Ok(())
}
}
@@ -1,17 +1,17 @@
use serde_derive::{Deserialize, Serialize};
#[derive(Serialize, Debug)]
#[derive(Serialize)]
pub (super) struct BatchRequest {
#[serde(rename = "0")]
pub zero: BatchRequestInner,
}
#[derive(Serialize, Debug)]
#[derive(Serialize)]
pub (super) struct BatchRequestInner {
pub json: BatchRequestJson,
}
#[derive(Serialize, Debug)]
#[derive(Serialize)]
pub (super) struct BatchRequestJson {
#[serde(rename = "businessType")]
pub business_type: &'static str,
@@ -19,7 +19,7 @@ pub (super) struct BatchRequestJson {
pub api_params: ApiParams,
}
#[derive(Serialize, Debug)]
#[derive(Serialize)]
pub (super) struct ApiParams {
pub prompt: String,
pub resolution: String,
@@ -28,12 +28,8 @@ pub (super) struct ApiParams {
pub model: &'static str,
pub duration: String,
pub mode: &'static str,
#[serde(rename = "faceBlurMode", skip_serializing_if = "Option::is_none")]
pub face_blur_mode: Option<&'static str>,
#[serde(rename = "uploadedUrls", skip_serializing_if = "Option::is_none")]
pub uploaded_urls: Option<Vec<String>>,
#[serde(rename = "audioUrls", skip_serializing_if = "Option::is_none")]
pub audio_urls: Option<Vec<String>>,
#[serde(rename = "batchCount", skip_serializing_if = "Option::is_none")]
pub batch_count: Option<u8>,
}
@@ -1,4 +1,4 @@
pub mod generate_video;
pub mod poll_orders;
pub mod prepare_file_upload;
pub mod upload_file;
pub mod prepare_image_upload;
pub mod upload_image;
@@ -1,26 +0,0 @@
use super::failure_type::FailureType;
/// A structured failure reason parsed from the raw Seedance2 Pro API response.
#[derive(Debug, Clone)]
pub struct FailureReason {
/// The original reason string from the API.
pub reason: String,
/// The categorized failure type.
pub failure_type: FailureType,
}
impl FailureReason {
/// Parse a raw failure reason string into a structured `FailureReason`.
///
/// First checks for exact string matches against known reasons,
/// then falls back to case-insensitive substring matching.
pub fn from_reason(reason: &str) -> Self {
let failure_type = FailureType::classify_text(reason);
FailureReason {
reason: reason.to_string(),
failure_type,
}
}
}
@@ -1,84 +0,0 @@
/// Categorized failure type for a Seedance2 Pro order.
#[derive(Debug, Clone, PartialEq)]
pub enum FailureType {
/// User's uploaded image was rejected by platform content rules.
RuleBansUserImage,
/// User's uploaded image was rejected because it contains faces.
RuleBansUserImageWithFaces,
/// User's text prompt was rejected by platform content rules.
RuleBansUserTextPrompt,
/// User's content (image or text) was rejected by platform content rules (generic).
RuleBansUserContent,
/// The generated video was rejected by platform content review.
RuleBansGeneratedVideo,
/// The generated audio was rejected by platform content rules.
RuleBansGeneratedAudio,
/// The generated content (video/audio/other) was rejected by platform content rules (generic).
RuleBansGeneratedContent,
/// Video generation failed (timeout, server error, processing error, etc.)
GenerationFailed,
/// An unrecognized or absent failure reason.
OtherUnknownReason,
}
impl FailureType {
pub fn classify_text(reason: &str) -> Self {
// --- Exact matches first ---
match reason {
"Your uploaded image violates platform rules. Please modify and try again." =>
return Self::RuleBansUserImage,
"Face detected in uploaded media. Please adjust your media and try again." =>
return Self::RuleBansUserImageWithFaces,
"Your input text violates platform rules. Please modify and try again." =>
return Self::RuleBansUserTextPrompt,
"Content violates platform rules. Please modify and try again." =>
return Self::RuleBansUserContent,
"The generated video did not pass review. Credits will not be deducted." =>
return Self::RuleBansGeneratedVideo,
"The generated audio violates platform rules. Please adjust your prompt or images and try again." =>
return Self::RuleBansGeneratedAudio,
"The generated content violates platform rules. Please adjust your prompt or images and try again." =>
return Self::RuleBansGeneratedContent,
"Video generation failed. Please try again." =>
return Self::GenerationFailed,
"Generation timed out. Please try again." =>
return Self::GenerationFailed,
"Server error. Please try again later." =>
return Self::GenerationFailed,
"Your content could not be processed. Please try different images or adjust your prompt." =>
return Self::GenerationFailed,
_ => {}
}
// --- Substring matches (case-insensitive) ---
let lower = reason.to_lowercase();
if lower.contains("face detected") || lower.contains("ensure no faces") {
return Self::RuleBansUserImageWithFaces;
}
if lower.contains("uploaded image violates") {
return Self::RuleBansUserImage;
}
if lower.contains("input text violates") {
return Self::RuleBansUserTextPrompt;
}
if lower.contains("generated video") && lower.contains("not pass review") {
return Self::RuleBansGeneratedVideo;
}
if lower.contains("generated audio") && lower.contains("violates") {
return Self::RuleBansGeneratedAudio;
}
if lower.contains("generated content") && lower.contains("violates") {
return Self::RuleBansGeneratedContent;
}
if lower.contains("content violates") || lower.contains("platform rules") {
return Self::RuleBansUserContent;
}
if lower.contains("video generation failed") || lower.contains("timed out") || lower.contains("server error") {
return Self::GenerationFailed;
}
Self::OtherUnknownReason
}
}
@@ -1,4 +1,2 @@
pub mod failure_reason;
pub mod failure_type;
pub mod poll_orders;
mod request_types;
@@ -2,7 +2,6 @@ use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::error::seedance2pro_client_error::Seedance2ProClientError;
use crate::error::seedance2pro_error::Seedance2ProError;
use crate::error::seedance2pro_generic_api_error::Seedance2ProGenericApiError;
use crate::requests::poll_orders::failure_reason::FailureReason;
use crate::requests::poll_orders::request_types::*;
use crate::utils::common_headers::FIREFOX_USER_AGENT;
use log::info;
@@ -11,6 +10,17 @@ use wreq_util::Emulation;
const GET_ORDERS_BASE_URL: &str = "https://seedance2-pro.com/api/trpc/userOrder.getOrders";
/// Builds the tRPC `input` JSON for the getOrders endpoint.
/// When `cursor` is `Some`, it is included in the JSON payload.
fn build_input_json(cursor: Option<u64>) -> String {
match cursor {
None => r#"{"0":{"json":{"limit":30,"format":null,"direction":"forward"},"meta":{"values":{"format":["undefined"]},"v":1}}}"#.to_string(),
Some(c) => format!(
r#"{{"0":{{"json":{{"limit":30,"format":null,"cursor":{cursor},"direction":"forward"}},"meta":{{"values":{{"format":["undefined"]}},"v":1}}}}}}"#,
cursor = c
),
}
}
// --- Args & response ---
@@ -87,8 +97,8 @@ pub struct OrderStatus {
/// Detailed result entries. Typically one entry per video.
pub results: Vec<VideoResult>,
/// Structured failure reason. Populated when `task_status` is `Failed` or `fail_reason` is present.
pub fail_reason: Option<FailureReason>,
/// Failure reason. Populated when `task_status` is `Failed`.
pub fail_reason: Option<String>,
/// ISO 8601 creation timestamp (e.g. `"2026-02-19T01:20:50.398Z"`).
pub created_at: String,
@@ -162,25 +172,18 @@ pub async fn poll_orders(args: PollOrdersArgs<'_>) -> Result<PollOrdersResponse,
let orders: Vec<OrderStatus> = json.orders
.into_iter()
.map(|o| {
let task_status = TaskStatus::from_str(&o.task_status);
let fail_reason = match (&o.fail_reason, &task_status) {
(Some(reason), _) => Some(FailureReason::from_reason(reason)),
(None, TaskStatus::Failed) => Some(FailureReason::from_reason("(no reason)")),
_ => None,
};
OrderStatus {
order_id: o.order_id,
task_status,
result_url: o.result_url,
results: o.results.into_iter().map(|r| VideoResult {
url: r.url,
width: r.width,
height: r.height,
}).collect(),
fail_reason,
created_at: o.created_at,
}
.map(|o| OrderStatus {
order_id: o.order_id,
task_status: TaskStatus::from_str(&o.task_status),
result_url: o.result_url,
results: o.results.into_iter().map(|r| VideoResult {
url: r.url,
width: r.width,
height: r.height,
// ratio: r.ratio,
}).collect(),
fail_reason: o.fail_reason,
created_at: o.created_at,
})
.collect();
@@ -189,18 +192,6 @@ pub async fn poll_orders(args: PollOrdersArgs<'_>) -> Result<PollOrdersResponse,
Ok(PollOrdersResponse { orders, next_cursor })
}
/// Builds the tRPC `input` JSON for the getOrders endpoint.
/// When `cursor` is `Some`, it is included in the JSON payload.
fn build_input_json(cursor: Option<u64>) -> String {
match cursor {
None => r#"{"0":{"json":{"limit":30,"format":null,"direction":"forward"},"meta":{"values":{"format":["undefined"]},"v":1}}}"#.to_string(),
Some(c) => format!(
r#"{{"0":{{"json":{{"limit":30,"format":null,"cursor":{cursor},"direction":"forward"}},"meta":{{"values":{{"format":["undefined"]}},"v":1}}}}}}"#,
cursor = c
),
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1,2 +0,0 @@
pub mod prepare_file_upload;
mod request_types;
@@ -0,0 +1,2 @@
pub mod prepare_image_upload;
mod request_types;
@@ -2,7 +2,7 @@ use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::error::seedance2pro_client_error::Seedance2ProClientError;
use crate::error::seedance2pro_error::Seedance2ProError;
use crate::error::seedance2pro_generic_api_error::Seedance2ProGenericApiError;
use crate::requests::prepare_file_upload::request_types::*;
use crate::requests::prepare_image_upload::request_types::*;
use crate::utils::common_headers::FIREFOX_USER_AGENT;
use chrono::Utc;
use log::info;
@@ -13,35 +13,32 @@ use wreq_util::Emulation;
const SIGNED_UPLOAD_URL: &str = "https://seedance2-pro.com/api/trpc/uploads.signedUploadUrl?batch=1";
/// Generates a material path based on the current time.
/// Format: `materials/YYYYMMDD/<unix_millis>-<random_hex>.<extension>`
fn generate_material_path(extension: &str) -> String {
/// Format: `materials/YYYYMMDD/<unix_millis>-<random_hex>.png`
fn generate_material_path() -> String {
let now = Utc::now();
let date_part = now.format("%Y%m%d").to_string();
let timestamp_millis = now.timestamp_millis();
let random_hex: u32 = rand::rng().random();
let hex_part = format!("{:08x}", random_hex);
format!("materials/{}/{}-{}.{}", date_part, timestamp_millis, hex_part, extension)
format!("materials/{}/{}-{}.png", date_part, timestamp_millis, hex_part)
}
pub struct PrepareFileUploadArgs<'a> {
pub struct PrepareImageUploadArgs<'a> {
pub session: &'a Seedance2ProSession,
/// File extension without the dot, e.g. "png", "mp4", "mp3".
pub extension: String,
}
pub struct PrepareFileUploadResponse {
/// The signed URL to upload the file to (Cloudflare R2 / S3-compatible).
pub struct PrepareImageUploadResponse {
/// The signed URL to upload the image to (Cloudflare R2 / S3-compatible).
pub upload_url: String,
/// The material path that was generated for this upload.
pub material_path: String,
}
pub async fn prepare_file_upload(args: PrepareFileUploadArgs<'_>) -> Result<PrepareFileUploadResponse, Seedance2ProError> {
let material_path = generate_material_path(&args.extension);
pub async fn prepare_image_upload(args: PrepareImageUploadArgs<'_>) -> Result<PrepareImageUploadResponse, Seedance2ProError> {
let material_path = generate_material_path();
info!("Preparing file upload with path: {}", material_path);
info!("Preparing image upload with path: {}", material_path);
let client = Client::builder()
.emulation(Emulation::Firefox143)
@@ -106,7 +103,7 @@ pub async fn prepare_file_upload(args: PrepareFileUploadArgs<'_>) -> Result<Prep
.data
.json;
Ok(PrepareFileUploadResponse {
Ok(PrepareImageUploadResponse {
upload_url,
material_path,
})
@@ -123,41 +120,19 @@ mod tests {
#[tokio::test]
#[ignore] // manually test — requires real cookies
async fn test_prepare_file_upload_png() -> AnyhowResult<()> {
async fn test_prepare_image_upload() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let cookies = get_test_cookies()?;
let session = Seedance2ProSession::from_cookies_string(cookies);
let args = PrepareFileUploadArgs {
let args = PrepareImageUploadArgs {
session: &session,
extension: "png".to_string(),
};
let result = prepare_file_upload(args).await?;
let result = prepare_image_upload(args).await?;
println!("Upload URL: {}", result.upload_url);
println!("Material path: {}", result.material_path);
assert!(!result.upload_url.is_empty());
assert!(result.upload_url.contains("cloudflarestorage.com"));
assert!(result.material_path.starts_with("materials/"));
assert!(result.material_path.ends_with(".png"));
assert_eq!(1, 2); // NB: Intentional failure to check the response.
Ok(())
}
#[tokio::test]
#[ignore] // manually test — requires real cookies
async fn test_prepare_file_upload_mp4() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let cookies = get_test_cookies()?;
let session = Seedance2ProSession::from_cookies_string(cookies);
let args = PrepareFileUploadArgs {
session: &session,
extension: "mp4".to_string(),
};
let result = prepare_file_upload(args).await?;
println!("Upload URL: {}", result.upload_url);
println!("Material path: {}", result.material_path);
assert!(!result.upload_url.is_empty());
assert!(result.material_path.ends_with(".mp4"));
assert_eq!(1, 2); // NB: Intentional failure to check the response.
Ok(())
@@ -1 +0,0 @@
pub mod upload_file;
@@ -0,0 +1 @@
pub mod upload_image;
@@ -9,16 +9,16 @@ use wreq_util::Emulation;
const STATIC_BASE_URL: &str = "https://static.seedance2-pro.com";
pub struct UploadFileArgs {
/// The signed upload URL returned by `prepare_file_upload`.
pub struct UploadImageArgs {
/// The signed upload URL returned by `prepare_image_upload`.
pub upload_url: String,
/// The raw file bytes to upload.
pub file_bytes: Vec<u8>,
/// The raw image bytes to upload.
pub image_bytes: Vec<u8>,
}
pub struct UploadFileResponse {
/// The public-facing URL for the uploaded file.
pub struct UploadImageResponse {
/// The public-facing URL for the uploaded image.
/// e.g. `https://static.seedance2-pro.com/materials/20260219/1771463564512-b14bfe90.png`
pub public_url: String,
}
@@ -33,8 +33,8 @@ fn build_public_url(upload_url: &str) -> Result<String, Seedance2ProError> {
Ok(format!("{}{}", STATIC_BASE_URL, path))
}
pub async fn upload_file(args: UploadFileArgs) -> Result<UploadFileResponse, Seedance2ProError> {
info!("Uploading file to: {}", args.upload_url);
pub async fn upload_image(args: UploadImageArgs) -> Result<UploadImageResponse, Seedance2ProError> {
info!("Uploading image to: {}", args.upload_url);
let client = Client::builder()
.emulation(Emulation::Firefox143)
@@ -53,7 +53,7 @@ pub async fn upload_file(args: UploadFileArgs) -> Result<UploadFileResponse, See
.header("Sec-Fetch-Mode", "cors")
.header("Sec-Fetch-Site", "cross-site")
.header("Priority", "u=4")
.body(args.file_bytes)
.body(args.image_bytes)
.send()
.await
.map_err(|err| Seedance2ProGenericApiError::WreqError(err))?;
@@ -77,7 +77,7 @@ pub async fn upload_file(args: UploadFileArgs) -> Result<UploadFileResponse, See
info!("Public URL: {}", public_url);
Ok(UploadFileResponse { public_url })
Ok(UploadImageResponse { public_url })
}
#[cfg(test)]
@@ -86,8 +86,8 @@ mod tests {
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::test_utils::get_test_cookies::get_test_cookies;
use crate::test_utils::setup_test_logging::setup_test_logging;
use crate::requests::prepare_file_upload::prepare_file_upload::{
prepare_file_upload, PrepareFileUploadArgs,
use crate::requests::prepare_image_upload::prepare_image_upload::{
prepare_image_upload, PrepareImageUploadArgs,
};
use errors::AnyhowResult;
use log::LevelFilter;
@@ -95,62 +95,28 @@ mod tests {
#[tokio::test]
#[ignore] // manually test — requires real cookies and a test image
async fn test_upload_image_file() -> AnyhowResult<()> {
async fn test_upload_image() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
// Step 1: Get a signed upload URL
let cookies = get_test_cookies()?;
let session = Seedance2ProSession::from_cookies_string(cookies);
let prepare_args = PrepareFileUploadArgs {
let prepare_args = PrepareImageUploadArgs {
session: &session,
extension: "jpg".to_string(),
};
let prepare_result = prepare_file_upload(prepare_args).await?;
let prepare_result = prepare_image_upload(prepare_args).await?;
println!("Upload URL: {}", prepare_result.upload_url);
// Step 2: Read a test image
let file_bytes = fs::read("/Users/bt/dev/storyteller/artcraft/test_data/image/juno.jpg")?;
println!("File size: {} bytes", file_bytes.len());
let image_bytes = fs::read("/Users/bt/dev/storyteller/artcraft/test_data/image/juno.jpg")?;
println!("Image size: {} bytes", image_bytes.len());
// Step 3: Upload
let upload_args = UploadFileArgs {
let upload_args = UploadImageArgs {
upload_url: prepare_result.upload_url,
file_bytes,
image_bytes,
};
let result = upload_file(upload_args).await?;
println!("Public URL: {}", result.public_url);
assert!(result.public_url.starts_with("https://static.seedance2-pro.com/materials/"));
assert_eq!(1, 2); // NB: Intentional failure to check the response.
Ok(())
}
#[tokio::test]
#[ignore] // manually test — requires real cookies and a test image
async fn test_upload_video_file_that_is_too_long() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
// Step 1: Get a signed upload URL
let cookies = get_test_cookies()?;
let session = Seedance2ProSession::from_cookies_string(cookies);
let prepare_args = PrepareFileUploadArgs {
session: &session,
extension: "mp4".to_string(),
};
let prepare_result = prepare_file_upload(prepare_args).await?;
println!("Upload URL: {}", prepare_result.upload_url);
// Step 2: Read a test image
let file_bytes = fs::read("/Users/bt/Videos/Artcraft/Artcraft Best/ArtCraft Seedance Knight.mp4")?;
println!("File size: {} bytes", file_bytes.len());
// Step 3: Upload
let upload_args = UploadFileArgs {
upload_url: prepare_result.upload_url,
file_bytes,
};
let result = upload_file(upload_args).await?;
let result = upload_image(upload_args).await?;
println!("Public URL: {}", result.public_url);
assert!(result.public_url.starts_with("https://static.seedance2-pro.com/materials/"));
@@ -1,2 +1,27 @@
pub mod run;
pub mod seedance2pro;
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(name = "support-tool", about = "Production support CLI")]
pub struct Cli {
#[command(subcommand)]
pub command: TopLevelCommand,
}
#[derive(Subcommand)]
pub enum TopLevelCommand {
/// Seedance2 Pro support commands
Seedance2pro {
#[command(subcommand)]
command: seedance2pro::Seedance2proCommand,
},
}
pub async fn run(cli: Cli) -> anyhow::Result<()> {
match cli.command {
TopLevelCommand::Seedance2pro { command } => {
seedance2pro::run(command).await
}
}
}
@@ -1,36 +0,0 @@
use clap::{Parser, Subcommand};
use super::seedance2pro;
/// All canonical subcommand names across all modules.
/// Used by the underscore-insensitive arg normalizer.
pub fn all_canonical_names() -> Vec<&'static str> {
let mut names: Vec<&str> = vec!["seedance2pro"];
names.extend_from_slice(seedance2pro::dispatch::SUBCOMMAND_NAMES);
names
}
#[derive(Parser)]
#[command(name = "support-tool", about = "Production support CLI")]
pub struct Cli {
#[command(subcommand)]
pub command: TopLevelCommand,
}
#[derive(Subcommand)]
#[command(rename_all = "snake_case")]
pub enum TopLevelCommand {
/// Seedance2 Pro support commands
Seedance2pro {
#[command(subcommand)]
command: seedance2pro::Seedance2proCommand,
},
}
pub async fn run(cli: Cli) -> anyhow::Result<()> {
match cli.command {
TopLevelCommand::Seedance2pro { command } => {
seedance2pro::run(command).await
}
}
}
@@ -4,18 +4,10 @@ use clap::Subcommand;
use super::state::Seedance2ProState;
use super::subcommands;
/// All canonical subcommand names for this module.
/// Used by the underscore-insensitive arg normalizer.
pub const SUBCOMMAND_NAMES: &[&str] = &["find_job", "failed_job_histogram"];
#[derive(Subcommand)]
#[command(rename_all = "snake_case")]
pub enum Seedance2proCommand {
/// Find a job by its order ID across all pages
FindJob(subcommands::find_job::FindJobArgs),
/// Scan all jobs and print a histogram of failure reasons
FailedJobHistogram,
Findjob(subcommands::findjob::FindjobArgs),
}
pub async fn run(command: Seedance2proCommand) -> anyhow::Result<()> {
@@ -25,7 +17,6 @@ pub async fn run(command: Seedance2proCommand) -> anyhow::Result<()> {
let state = Seedance2ProState { cookies };
match command {
Seedance2proCommand::FindJob(args) => subcommands::find_job::run(&state, args).await,
Seedance2proCommand::FailedJobHistogram => subcommands::failed_job_histogram::run(&state).await,
Seedance2proCommand::Findjob(args) => subcommands::findjob::run(&state, args).await,
}
}
@@ -1,4 +1,4 @@
pub mod dispatch;
mod dispatch;
mod state;
mod subcommands;
@@ -1,94 +0,0 @@
use std::collections::BTreeMap;
use anyhow::anyhow;
use log::info;
use seedance2pro::creds::seedance2pro_session::Seedance2ProSession;
use seedance2pro::requests::poll_orders::failure_type::FailureType;
use seedance2pro::requests::poll_orders::poll_orders::{poll_orders, PollOrdersArgs, TaskStatus};
use super::super::state::Seedance2ProState;
pub async fn run(state: &Seedance2ProState) -> anyhow::Result<()> {
let session = Seedance2ProSession::from_cookies_string(state.cookies.clone());
let mut cursor: Option<u64> = None;
let mut page = 0usize;
let mut total_orders = 0usize;
let mut failed_count = 0usize;
let mut histogram: BTreeMap<String, usize> = BTreeMap::new();
loop {
page += 1;
info!("Fetching page {} (cursor: {:?})...", page, cursor);
let result = poll_orders(PollOrdersArgs {
session: &session,
cursor,
}).await
.map_err(|err| anyhow!("Error polling orders on page {}: {:?}", page, err))?;
let page_count = result.orders.len();
total_orders += page_count;
for order in &result.orders {
if order.task_status == TaskStatus::Failed {
failed_count += 1;
let reason = order.fail_reason.as_ref()
.map(|fr| fr.reason.as_str())
.unwrap_or("(no reason)");
*histogram.entry(reason.to_string()).or_insert(0) += 1;
}
}
info!("Page {}: {} orders ({} failed so far).", page, page_count, failed_count);
cursor = result.next_cursor;
if cursor.is_none() {
break;
}
}
println!("\nScanned {} orders across {} pages.", total_orders, page);
println!("Failed: {}\n", failed_count);
if histogram.is_empty() {
println!("No failed jobs found.");
return Ok(());
}
// Sort by count descending.
let mut sorted: Vec<(&String, &usize)> = histogram.iter().collect();
sorted.sort_by(|a, b| b.1.cmp(a.1));
println!("{:<6} {}", "Count", "Failure Reason");
println!("{:<6} {}", "-----", "--------------");
for (reason, count) in &sorted {
println!("{:<6} {}", count, reason);
}
// Build a second histogram by FailureType.
let mut type_histogram: BTreeMap<String, usize> = BTreeMap::new();
for (reason, count) in &histogram {
let failure_type = FailureType::classify_text(reason);
*type_histogram.entry(format!("{:?}", failure_type)).or_insert(0) += count;
}
let mut type_sorted: Vec<(&String, &usize)> = type_histogram.iter().collect();
type_sorted.sort_by(|a, b| b.1.cmp(a.1));
println!("\n{:<6} {}", "Count", "Failure Type");
println!("{:<6} {}", "-----", "------------");
for (failure_type, count) in &type_sorted {
println!("{:<6} {}", count, failure_type);
}
// Print reason → type mapping table.
println!("\n{:<30} {}", "Failure Type", "Failure Reason");
println!("{:<30} {}", "------------", "--------------");
for (reason, _) in &sorted {
let failure_type = FailureType::classify_text(reason);
println!("{:<30} {}", format!("{:?}", failure_type), reason);
}
Ok(())
}
@@ -7,13 +7,13 @@ use seedance2pro::requests::poll_orders::poll_orders::{poll_orders, PollOrdersAr
use super::super::state::Seedance2ProState;
#[derive(Args)]
pub struct FindJobArgs {
pub struct FindjobArgs {
/// The order ID (job token) to search for
#[arg(long)]
pub token: String,
}
pub async fn run(state: &Seedance2ProState, args: FindJobArgs) -> anyhow::Result<()> {
pub async fn run(state: &Seedance2ProState, args: FindjobArgs) -> anyhow::Result<()> {
let session = Seedance2ProSession::from_cookies_string(state.cookies.clone());
let mut cursor: Option<u64> = None;
@@ -53,8 +53,7 @@ fn order_to_json(order: &seedance2pro::requests::poll_orders::poll_orders::Order
"order_id": order.order_id,
"task_status": format!("{:?}", order.task_status),
"result_url": order.result_url,
"fail_reason": order.fail_reason.as_ref().map(|fr| fr.reason.as_str()),
"failure_type": order.fail_reason.as_ref().map(|fr| format!("{:?}", fr.failure_type)),
"fail_reason": order.fail_reason,
"created_at": order.created_at,
"results": order.results.iter().map(|r| {
serde_json::json!({
@@ -1,2 +1 @@
pub mod failed_job_histogram;
pub mod find_job;
pub mod findjob;
@@ -2,17 +2,12 @@ mod commands;
mod utils;
use clap::Parser;
use commands::run::{Cli, all_canonical_names};
use utils::normalize_subcommands::normalize_subcommand_args;
use commands::Cli;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// Normalize args so underscores in subcommand names are optional.
let canonical_names = all_canonical_names();
let args = normalize_subcommand_args(std::env::args(), &canonical_names);
// Parse CLI args first so --help works without the env file.
let cli = Cli::parse_from(args);
let cli = Cli::parse();
// Load secrets from .env file.
easyenv::from_filename(".env-support-tool-secrets")
@@ -20,5 +15,5 @@ async fn main() -> anyhow::Result<()> {
easyenv::init_env_logger(Some("info"));
commands::run::run(cli).await
commands::run(cli).await
}
@@ -1 +1 @@
pub mod normalize_subcommands;
// Shared utilities for support-tool commands.
@@ -1,27 +0,0 @@
/// Normalize CLI args so that underscores in subcommand names are optional.
///
/// For each arg that isn't a flag (doesn't start with `-`), if removing all
/// underscores from the arg matches removing all underscores from a canonical
/// name, the arg is replaced with the canonical name.
///
/// This lets users type `findjob`, `find_job`, or even `f_ind_job` and all
/// resolve to the canonical `find_job`.
pub fn normalize_subcommand_args(
args: impl IntoIterator<Item = String>,
canonical_names: &[&str],
) -> Vec<String> {
args.into_iter()
.map(|arg| {
if arg.starts_with('-') {
return arg;
}
let stripped = arg.replace('_', "").to_lowercase();
for &name in canonical_names {
if name.replace('_', "").to_lowercase() == stripped {
return name.to_string();
}
}
arg
})
.collect()
}
@@ -4,10 +4,8 @@ use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
use crate::core::events::generation_events::common::GenerationModel;
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use artcraft_router::api::common_video_model::CommonVideoModel;
use artcraft_router::api::audio_list_ref::AudioListRef;
use artcraft_router::api::image_list_ref::ImageListRef;
use artcraft_router::api::image_ref::ImageRef;
use artcraft_router::api::video_list_ref::VideoListRef;
use artcraft_router::api::provider::Provider;
use artcraft_router::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use artcraft_router::client::router_artcraft_client::RouterArtcraftClient;
@@ -40,8 +38,6 @@ pub(super) async fn handle_artcraft_seedance_2p0(
start_frame,
end_frame,
reference_images,
reference_videos: request.reference_video_media_tokens.as_ref().map(VideoListRef::MediaFileTokens),
reference_audio: request.reference_audio_media_tokens.as_ref().map(AudioListRef::MediaFileTokens),
resolution: None,
aspect_ratio: request.aspect_ratio,
duration_seconds: request.duration_seconds,
@@ -38,8 +38,6 @@ pub(super) async fn handle_artcraft_video_via_router(
start_frame,
end_frame,
reference_images: None,
reference_videos: None,
reference_audio: None,
resolution: None,
aspect_ratio: request.aspect_ratio,
duration_seconds: request.duration_seconds,
@@ -115,14 +115,6 @@ pub struct EnqueueImageToVideoRequest {
/// Reference images for the video generation (e.g. character consistency, props, etc.)
pub reference_image_media_tokens: Option<Vec<MediaFileToken>>,
/// OPTIONAL.
/// Reference videos for the video generation (e.g. video edits, etc.)
pub reference_video_media_tokens: Option<Vec<MediaFileToken>>,
/// OPTIONAL.
/// Reference audio for the video generation (e.g. voices, lip sync, music, etc.)
pub reference_audio_media_tokens: Option<Vec<MediaFileToken>>,
/// Optional.
/// Text prompt used to direct the video.
pub prompt: Option<String>,
@@ -18,7 +18,6 @@ use serde_derive::{Deserialize, Serialize};
use sqlite_tasks::queries::list_tasks_for_frontend::list_tasks_for_frontend;
use artcraft_client::endpoints::media_files::delete_media_file::delete_media_file;
use tauri::{AppHandle, State};
use enums::tauri::tasks::task_failure_type::TaskFailureType;
use enums::tauri::tasks::task_media_file_class::TaskMediaFileClass;
use tokens::tokens::batch_generations::BatchGenerationToken;
use tokens::tokens::media_files::MediaFileToken;
@@ -32,20 +31,12 @@ pub struct GetTaskQueueCommandResponse {
#[derive(Serialize)]
pub struct TaskQueueItem {
pub id: TaskId,
pub task_status: TaskStatus,
pub task_type: TaskType,
pub model_type: Option<TaskModelType>,
pub provider: Option<GenerationProvider>,
pub provider_job_id: Option<String>,
/// If the item is done, these will be filled out.
pub completed_item: Option<CompletedItemData>,
/// If the item failed, this will be filled out.
pub failure_reason: Option<FailedItemData>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub completed_at: Option<DateTime<Utc>>,
@@ -72,12 +63,6 @@ pub struct MediaFileData {
pub created_at: DateTime<Utc>,
}
#[derive(Serialize)]
pub struct FailedItemData {
pub failure_type: TaskFailureType,
pub failure_message: Option<String>,
}
impl SerializeMarker for GetTaskQueueCommandResponse {}
#[tauri::command]
@@ -118,7 +103,6 @@ pub async fn handle_request(
for task in tasks.tasks.into_iter() {
let mut completed_item = None;
let mut failure_reason = None;
if task.status == TaskStatus::CompleteSuccess {
let token_and_url = task.on_complete_primary_media_file_token
@@ -139,19 +123,6 @@ pub async fn handle_request(
} else {
warn!("Task {} is marked complete but has no primary media file token or URL.", task.id);
}
} else {
// If either failure field is present, fill out the failure report.
if let Some(failure_message) = task.on_failure_message.as_deref() {
failure_reason = Some(FailedItemData {
failure_type: task.on_failure_type.unwrap_or(TaskFailureType::Unknown),
failure_message: Some(failure_message.to_string()),
});
} else if let Some(failure_type) = task.on_failure_type {
failure_reason = Some(FailedItemData {
failure_type,
failure_message: None,
});
}
}
transformed_tasks.push(TaskQueueItem {
@@ -165,7 +136,6 @@ pub async fn handle_request(
updated_at: task.updated_at,
completed_at: task.completed_at,
completed_item,
failure_reason,
})
}
@@ -4,7 +4,7 @@ use crate::core::state::artcraft_usage_tracker::artcraft_usage_tracker::Artcraft
use crate::core::state::task_database::TaskDatabase;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use crate::services::storyteller::threads::storyteller_activity_thread::storyteller_activity_thread;
use crate::services::storyteller::threads::storyteller_task_polling_thread::storyteller_task_polling_thread::storyteller_task_polling_thread;
use crate::services::storyteller::threads::storyteller_task_polling_thread::storyteller_task_polling_thread;
use errors::AnyhowResult;
use tauri::{AppHandle, Manager};
@@ -7,4 +7,4 @@
/// We increment this whenever we make a change to the database schema.
/// This prevents deadlock on startup if the schema has changed from the
/// previous version.
pub const TASK_DATABASE_VERSION: u32 = 6;
pub const TASK_DATABASE_VERSION: u32 = 5;
@@ -1,2 +1,3 @@
pub mod events;
pub mod storyteller_activity_thread;
pub mod storyteller_task_polling_thread;
@@ -1,16 +1,34 @@
use crate::core::events::basic_sendable_event_trait::BasicSendableEvent;
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::core::state::artcraft_platform_info::ArtcraftPlatformInfo;
use crate::core::state::artcraft_usage_tracker::artcraft_usage_tracker::ArtcraftUsageTracker;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::core::state::data_dir::trait_data_subdir::DataSubdir;
use crate::core::state::os_platform::OsPlatform;
use crate::core::state::task_database::TaskDatabase;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use anyhow::anyhow;
use artcraft_api_defs::analytics::log_active_user::LogAppActiveUserRequest;
use artcraft_client::endpoints::analytics::log_active_user_v2::log_active_user_v2;
use artcraft_client::error::api_error::ApiError;
use artcraft_client::error::storyteller_error::StorytellerError;
use artcraft_api_defs::jobs::list_session_jobs::ListSessionJobsItem;
use enums::common::generation_provider::GenerationProvider;
use enums::common::job_status_plus::JobStatusPlus;
use enums::tauri::tasks::task_status::TaskStatus;
use enums::tauri::tasks::task_type::TaskType;
use errors::AnyhowResult;
use log::{debug, error, info};
use os_info::Info;
use reqwest::Url;
use sqlite_tasks::queries::list_tasks_by_provider_and_tokens::{list_tasks_by_provider_and_tokens, ListTasksArgs};
use sqlite_tasks::queries::update_task_status::{update_task_status, UpdateTaskArgs};
use std::time::Instant;
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use artcraft_client::endpoints::analytics::log_active_user::log_active_user;
use artcraft_client::endpoints::analytics::log_active_user_v2::log_active_user_v2;
use artcraft_client::endpoints::jobs::list_session_jobs::{list_session_jobs, States};
use artcraft_client::endpoints::media_files::upload_image_media_file_from_file::upload_image_media_file_from_file;
use artcraft_client::error::api_error::ApiError;
use artcraft_client::error::storyteller_error::StorytellerError;
use tauri::AppHandle;
use tokens::tokens::app_session::AppSessionToken;
const CLIENT_NAME : &str = "artcraft";
@@ -0,0 +1,339 @@
use crate::core::events::basic_sendable_event_trait::BasicSendableEvent;
use crate::core::events::generation_events::common::{GenerationAction, GenerationServiceProvider};
use crate::core::events::generation_events::generation_complete_event::GenerationCompleteEvent;
use crate::core::events::generation_events::generation_failed_event::GenerationFailedEvent;
use crate::core::events::sendable_event_trait::SendableEvent;
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::core::state::data_dir::trait_data_subdir::DataSubdir;
use crate::core::state::task_database::TaskDatabase;
use crate::core::utils::enum_conversion::generation_provider::to_generation_service_provider;
use crate::core::utils::enum_conversion::task_type::to_generation_action;
use crate::services::sora::state::sora_credential_manager::SoraCredentialManager;
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use crate::services::storyteller::threads::events::maybe_handle_frontend_caller_notification::maybe_handle_frontend_caller_notification;
use crate::services::storyteller::threads::events::maybe_handle_inpainting_complete_event::maybe_handle_inpainting_complete_event;
use crate::services::storyteller::threads::events::maybe_handle_text_to_image_complete_event::maybe_handle_text_to_image_complete_event;
use crate::services::storyteller::threads::events::maybe_send_background_removal_complete_event::maybe_send_background_removal_complete_event;
use anyhow::anyhow;
use artcraft_api_defs::jobs::list_session_jobs::ListSessionJobsItem;
use artcraft_api_defs::utils::media_links_to_thumbnail_template::media_links_to_thumbnail_template;
use enums::by_table::generic_inference_jobs::inference_category::InferenceCategory;
use enums::common::generation_provider::GenerationProvider;
use enums::common::job_status_plus::JobStatusPlus;
use enums::tauri::tasks::task_media_file_class::TaskMediaFileClass;
use enums::tauri::tasks::task_status::TaskStatus;
use enums::tauri::tasks::task_type::TaskType;
use errors::AnyhowResult;
use log::{error, info};
use reqwest::Url;
use sqlite_tasks::queries::list_tasks_by_provider_and_tokens::{list_tasks_by_provider_and_tokens, ListTasksArgs};
use sqlite_tasks::queries::task::Task;
use sqlite_tasks::queries::update_successful_task_status_with_metadata::{update_successful_task_status_with_metadata, UpdateSuccessfulTaskArgs};
use sqlite_tasks::queries::update_task_status::{update_task_status, UpdateTaskArgs};
use std::collections::HashMap;
use std::fs::File;
use std::io::Write;
use std::path::PathBuf;
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use artcraft_client::endpoints::jobs::list_session_jobs::{list_session_jobs, States};
use artcraft_client::endpoints::media_files::upload_image_media_file_from_file::upload_image_media_file_from_file;
use artcraft_client::error::api_error::ApiError;
use artcraft_client::error::storyteller_error::StorytellerError;
use tauri::AppHandle;
use tempdir::TempDir;
use tokens::tokens::media_files::MediaFileToken;
pub async fn storyteller_task_polling_thread(
app_handle: AppHandle,
app_env_configs: AppEnvConfigs,
task_database: TaskDatabase,
storyteller_creds_manager: StorytellerCredentialManager,
) -> ! {
loop {
let res = polling_loop(
&app_handle,
&app_env_configs,
&task_database,
&storyteller_creds_manager,
).await;
if let Err(err) = res {
error!("An error occurred: {:?}", err);
}
// NB: Only sleep if an error occurs.
tokio::time::sleep(std::time::Duration::from_millis(30_000)).await;
}
}
async fn polling_loop(
app_handle: &AppHandle,
app_env_configs: &AppEnvConfigs,
task_database: &TaskDatabase,
storyteller_creds_manager: &StorytellerCredentialManager,
) -> AnyhowResult<()> {
loop {
// Wait before next request for jobs.
tokio::time::sleep(std::time::Duration::from_millis(5_000)).await;
let creds = storyteller_creds_manager.get_credentials()?;
let result = list_session_jobs(
&app_env_configs.storyteller_host,
creds.as_ref(),
States::All,
).await;
let jobs = match result {
Ok(result) => result.jobs,
Err(err) => {
match &err {
StorytellerError::Api(ApiError::TooManyRequests(message)) => {
error!("Too many requests (sleeping): {:?}", message);
tokio::time::sleep(std::time::Duration::from_millis(60_000)).await;
}
_ => {}
}
return Err(anyhow!(err));
}
};
let job_ids = jobs.iter()
.map(|job| job.job_token.to_string())
.collect::<Vec<_>>();
let tasks = list_tasks_by_provider_and_tokens(ListTasksArgs {
db: task_database.get_connection(),
provider: GenerationProvider::Artcraft,
provider_job_ids: Some(job_ids),
}).await?;
let tasks = tasks.tasks;
let jobs_by_id = jobs.iter()
.map(|job| (job.job_token.to_string(), job))
.collect::<HashMap<String, _>>();
let tasks_by_provider_job_id = tasks.iter()
.filter_map(|task| {
if let Some(provider_job_id) = &task.provider_job_id {
Some((provider_job_id.clone(), task.clone()))
} else {
None
}
})
//.map(|task| (task.provider_job_id.clone(), task.clone()))
.collect::<HashMap<String, Task>>();
for job in jobs.iter() {
let task = match tasks_by_provider_job_id.get(job.job_token.as_str()) {
Some(task) => task,
None => continue,
};
match job.status.status {
JobStatusPlus::CompleteSuccess => {
match task.status {
TaskStatus::CompleteSuccess => continue,
_ => {}
}
handle_successful_job(app_handle, app_env_configs, creds.as_ref(), job, task, task_database).await?;
}
JobStatusPlus::CompleteFailure => {
match task.status {
TaskStatus::CompleteFailure => continue,
_ => {}
}
handle_failed_job(app_handle, job, task, task_database).await?;
}
_ => continue,
}
}
}
}
async fn handle_successful_job(
app_handle: &AppHandle,
app_env_configs: &AppEnvConfigs,
creds: Option<&StorytellerCredentialSet>,
job: &ListSessionJobsItem,
task: &Task,
task_database: &TaskDatabase,
) -> AnyhowResult<()> {
let maybe_primary_media_file_token = job.maybe_result
.as_ref()
.map(|result| MediaFileToken::new_from_str(&result.entity_token));
let updated = update_successful_task_status_with_metadata(UpdateSuccessfulTaskArgs {
db: task_database.get_connection(),
task_id: &task.id,
maybe_batch_token: job.maybe_result
.as_ref()
.map(|result| result.maybe_batch_token.as_ref())
.flatten(),
maybe_primary_media_file_token: maybe_primary_media_file_token.as_ref(),
maybe_primary_media_file_class: get_media_file_class(job),
maybe_primary_media_file_thumbnail_url_template: get_thumbnail_template(job),
maybe_primary_media_file_cdn_url: job.maybe_result
.as_ref()
.map(|result| result.media_links.cdn_url.as_str()),
}).await?;
if !updated {
return Ok(()); // If anything breaks with queries, don't spam events.
}
send_additional_success_events(app_handle, app_env_configs, creds, job, task).await;
let service = to_generation_service_provider(task.provider);
let action = to_generation_action(task.task_type);
let event = GenerationCompleteEvent {
action: Some(action),
service,
model: None, // TODO
};
if let Err(err) = event.send(app_handle) {
error!("Failed to send GenerationCompleteEvent: {:?}", err); // Fail open
}
Ok(())
}
async fn handle_failed_job(
app_handle: &AppHandle,
job: &ListSessionJobsItem,
task: &Task,
task_database: &TaskDatabase,
) -> AnyhowResult<()> {
info!("Marking storyteller job as failed: {:?}", task.id);
update_task_status(UpdateTaskArgs {
db: task_database.get_connection(),
task_id: &task.id,
status: TaskStatus::CompleteFailure,
}).await?;
let service = to_generation_service_provider(task.provider);
let action = to_generation_action(task.task_type);
let event = GenerationFailedEvent {
action,
service,
model: None,
reason: None,
};
event.send_infallible(app_handle);
Ok(())
}
async fn send_additional_success_events(
app_handle: &AppHandle,
app_env_configs: &AppEnvConfigs,
creds: Option<&StorytellerCredentialSet>,
job: &ListSessionJobsItem,
task: &Task
) {
info!("Attempting to dispatch events for completed Storyteller job: {:?}", task);
let result = maybe_handle_frontend_caller_notification(
app_handle,
app_env_configs,
creds,
task,
job,
).await;
if let Err(err) = result {
error!("Failed to send generation complete event: {:?}", err);
}
//let result = maybe_handle_text_to_image_complete_event(
// app_handle,
// app_env_configs,
// creds,
// task,
// job,
//).await;
//if let Err(err) = result {
// error!("Failed to send text-to-image complete event: {:?}", err);
//}
//let result = maybe_handle_inpainting_complete_event(
// app_handle,
// app_env_configs,
// creds,
// task,
// job,
//).await;
//if let Err(err) = result {
// error!("Failed to send image inpainting complete event: {:?}", err);
//}
//let result = maybe_send_background_removal_complete_event(
// app_handle,
// task,
// job,
//).await;
//if let Err(err) = result {
// error!("Failed to send background removal complete event: {:?}", err);
//}
}
fn get_thumbnail_template<'a>(job: &'a ListSessionJobsItem) -> Option<&'a str> {
let links = match job.maybe_result.as_ref() {
None => return None,
Some(result) => &result.media_links,
};
media_links_to_thumbnail_template(links)
}
fn get_media_file_class<'a>(job: &'a ListSessionJobsItem) -> Option<TaskMediaFileClass> {
match job.request.inference_category {
InferenceCategory::BackgroundRemoval => return Some(TaskMediaFileClass::Image),
InferenceCategory::ImageGeneration => return Some(TaskMediaFileClass::Image),
InferenceCategory::VideoGeneration => return Some(TaskMediaFileClass::Video),
InferenceCategory::ObjectGeneration => return Some(TaskMediaFileClass::Dimensional),
_ => {}, // Fall-through
}
let result = match job.maybe_result.as_ref() {
None => return None,
Some(result) => result,
};
let url = result.media_links.cdn_url.as_str();
if url.ends_with("jpg")
|| url.ends_with("jpeg")
|| url.ends_with("png")
{
return Some(TaskMediaFileClass::Image);
}
if url.ends_with("mp4")
|| url.ends_with("webm")
{
return Some(TaskMediaFileClass::Video);
}
if url.ends_with("glb") {
return Some(TaskMediaFileClass::Dimensional);
}
if url.ends_with("wav")
|| url.ends_with("mp3")
{
return Some(TaskMediaFileClass::Audio);
}
None
}
@@ -1,52 +0,0 @@
use crate::core::events::basic_sendable_event_trait::BasicSendableEvent;
use crate::core::events::generation_events::generation_failed_event::GenerationFailedEvent;
use crate::core::state::task_database::TaskDatabase;
use crate::core::utils::enum_conversion::generation_provider::to_generation_service_provider;
use crate::core::utils::enum_conversion::task_type::to_generation_action;
use artcraft_api_defs::jobs::list_session_jobs::ListSessionJobsItem;
use enums::tauri::tasks::task_status::TaskStatus;
use errors::AnyhowResult;
use log::info;
use sqlite_tasks::queries::task::Task;
use sqlite_tasks::queries::update_task_status::{update_task_status, UpdateTaskArgs};
use tauri::AppHandle;
use enums::tauri::tasks::task_failure_type::TaskFailureType;
use sqlite_tasks::queries::update_task_status_with_rich_failure::{update_task_status_with_rich_failure, UpdateTaskWithRichFailureArgs};
pub async fn handle_failed_job(
app_handle: &AppHandle,
job: &ListSessionJobsItem,
task: &Task,
task_database: &TaskDatabase,
) -> AnyhowResult<()> {
info!("Marking storyteller job as failed: {:?}", task.id);
let maybe_failure_type = job.status
.maybe_failure_category_updated
.as_ref()
.map(|val| TaskFailureType::from_frontend_failure_category_for_api(val));
let maybe_failure_message = job.status.maybe_failure_message.as_deref();
update_task_status_with_rich_failure(UpdateTaskWithRichFailureArgs {
db: task_database.get_connection(),
task_id: &task.id,
status: TaskStatus::CompleteFailure,
maybe_failure_type,
maybe_failure_message,
}).await?;
let service = to_generation_service_provider(task.provider);
let action = to_generation_action(task.task_type);
let event = GenerationFailedEvent {
action,
service,
model: None,
reason: None,
};
event.send_infallible(app_handle);
Ok(())
}
@@ -1,177 +0,0 @@
use crate::core::events::basic_sendable_event_trait::BasicSendableEvent;
use crate::core::events::generation_events::common::GenerationAction;
use crate::core::events::generation_events::generation_complete_event::GenerationCompleteEvent;
use crate::core::events::sendable_event_trait::SendableEvent;
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::core::state::task_database::TaskDatabase;
use crate::core::utils::enum_conversion::generation_provider::to_generation_service_provider;
use crate::core::utils::enum_conversion::task_type::to_generation_action;
use super::events::maybe_handle_frontend_caller_notification::maybe_handle_frontend_caller_notification;
use artcraft_api_defs::jobs::list_session_jobs::ListSessionJobsItem;
use artcraft_api_defs::utils::media_links_to_thumbnail_template::media_links_to_thumbnail_template;
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use enums::by_table::generic_inference_jobs::inference_category::InferenceCategory;
use enums::tauri::tasks::task_media_file_class::TaskMediaFileClass;
use errors::AnyhowResult;
use log::error;
use log::info;
use sqlite_tasks::queries::task::Task;
use sqlite_tasks::queries::update_successful_task_status_with_metadata::{update_successful_task_status_with_metadata, UpdateSuccessfulTaskArgs};
use tauri::AppHandle;
use tokens::tokens::media_files::MediaFileToken;
pub async fn handle_successful_job(
app_handle: &AppHandle,
app_env_configs: &AppEnvConfigs,
creds: Option<&StorytellerCredentialSet>,
job: &ListSessionJobsItem,
task: &Task,
task_database: &TaskDatabase,
) -> AnyhowResult<()> {
let maybe_primary_media_file_token = job.maybe_result
.as_ref()
.map(|result| MediaFileToken::new_from_str(&result.entity_token));
let updated = update_successful_task_status_with_metadata(UpdateSuccessfulTaskArgs {
db: task_database.get_connection(),
task_id: &task.id,
maybe_batch_token: job.maybe_result
.as_ref()
.map(|result| result.maybe_batch_token.as_ref())
.flatten(),
maybe_primary_media_file_token: maybe_primary_media_file_token.as_ref(),
maybe_primary_media_file_class: get_media_file_class(job),
maybe_primary_media_file_thumbnail_url_template: get_thumbnail_template(job),
maybe_primary_media_file_cdn_url: job.maybe_result
.as_ref()
.map(|result| result.media_links.cdn_url.as_str()),
}).await?;
if !updated {
return Ok(()); // If anything breaks with queries, don't spam events.
}
send_additional_success_events(app_handle, app_env_configs, creds, job, task).await;
let service = to_generation_service_provider(task.provider);
let action = to_generation_action(task.task_type);
let event = GenerationCompleteEvent {
action: Some(action),
service,
model: None, // TODO
};
if let Err(err) = event.send(app_handle) {
error!("Failed to send GenerationCompleteEvent: {:?}", err); // Fail open
}
Ok(())
}
async fn send_additional_success_events(
app_handle: &AppHandle,
app_env_configs: &AppEnvConfigs,
creds: Option<&StorytellerCredentialSet>,
job: &ListSessionJobsItem,
task: &Task
) {
info!("Attempting to dispatch events for completed Storyteller job: {:?}", task);
let result = maybe_handle_frontend_caller_notification(
app_handle,
app_env_configs,
creds,
task,
job,
).await;
if let Err(err) = result {
error!("Failed to send generation complete event: {:?}", err);
}
//let result = maybe_handle_text_to_image_complete_event(
// app_handle,
// app_env_configs,
// creds,
// task,
// job,
//).await;
//if let Err(err) = result {
// error!("Failed to send text-to-image complete event: {:?}", err);
//}
//let result = maybe_handle_inpainting_complete_event(
// app_handle,
// app_env_configs,
// creds,
// task,
// job,
//).await;
//if let Err(err) = result {
// error!("Failed to send image inpainting complete event: {:?}", err);
//}
//let result = maybe_send_background_removal_complete_event(
// app_handle,
// task,
// job,
//).await;
//if let Err(err) = result {
// error!("Failed to send background removal complete event: {:?}", err);
//}
}
fn get_thumbnail_template<'a>(job: &'a ListSessionJobsItem) -> Option<&'a str> {
let links = match job.maybe_result.as_ref() {
None => return None,
Some(result) => &result.media_links,
};
media_links_to_thumbnail_template(links)
}
fn get_media_file_class(job: &ListSessionJobsItem) -> Option<TaskMediaFileClass> {
match job.request.inference_category {
InferenceCategory::BackgroundRemoval => return Some(TaskMediaFileClass::Image),
InferenceCategory::ImageGeneration => return Some(TaskMediaFileClass::Image),
InferenceCategory::VideoGeneration => return Some(TaskMediaFileClass::Video),
InferenceCategory::ObjectGeneration => return Some(TaskMediaFileClass::Dimensional),
_ => {}, // Fall-through
}
let result = match job.maybe_result.as_ref() {
None => return None,
Some(result) => result,
};
let url = result.media_links.cdn_url.as_str();
if url.ends_with("jpg")
|| url.ends_with("jpeg")
|| url.ends_with("png")
{
return Some(TaskMediaFileClass::Image);
}
if url.ends_with("mp4")
|| url.ends_with("webm")
{
return Some(TaskMediaFileClass::Video);
}
if url.ends_with("glb") {
return Some(TaskMediaFileClass::Dimensional);
}
if url.ends_with("wav")
|| url.ends_with("mp3")
{
return Some(TaskMediaFileClass::Audio);
}
None
}
@@ -1,4 +0,0 @@
mod events;
mod handle_storyteller_failed_job;
mod handle_storyteller_successful_job;
pub mod storyteller_task_polling_thread;
@@ -1,126 +0,0 @@
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::core::state::task_database::TaskDatabase;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use crate::services::storyteller::threads::storyteller_task_polling_thread::handle_storyteller_failed_job::handle_failed_job;
use crate::services::storyteller::threads::storyteller_task_polling_thread::handle_storyteller_successful_job::handle_successful_job;
use anyhow::anyhow;
use artcraft_client::endpoints::jobs::list_session_jobs::{list_session_jobs, States};
use artcraft_client::error::api_error::ApiError;
use artcraft_client::error::storyteller_error::StorytellerError;
use enums::common::generation_provider::GenerationProvider;
use enums::common::job_status_plus::JobStatusPlus;
use enums::tauri::tasks::task_status::TaskStatus;
use errors::AnyhowResult;
use log::error;
use sqlite_tasks::queries::list_tasks_by_provider_and_tokens::{list_tasks_by_provider_and_tokens, ListTasksArgs};
use sqlite_tasks::queries::task::Task;
use std::collections::HashMap;
use tauri::AppHandle;
pub async fn storyteller_task_polling_thread(
app_handle: AppHandle,
app_env_configs: AppEnvConfigs,
task_database: TaskDatabase,
storyteller_creds_manager: StorytellerCredentialManager,
) -> ! {
loop {
let res = polling_loop(
&app_handle,
&app_env_configs,
&task_database,
&storyteller_creds_manager,
).await;
if let Err(err) = res {
error!("An error occurred: {:?}", err);
}
// NB: Only sleep if an error occurs.
tokio::time::sleep(std::time::Duration::from_millis(30_000)).await;
}
}
async fn polling_loop(
app_handle: &AppHandle,
app_env_configs: &AppEnvConfigs,
task_database: &TaskDatabase,
storyteller_creds_manager: &StorytellerCredentialManager,
) -> AnyhowResult<()> {
loop {
// Wait before next request for jobs.
tokio::time::sleep(std::time::Duration::from_millis(5_000)).await;
let creds = storyteller_creds_manager.get_credentials()?;
let result = list_session_jobs(
&app_env_configs.storyteller_host,
creds.as_ref(),
States::All,
).await;
let jobs = match result {
Ok(result) => result.jobs,
Err(err) => {
match &err {
StorytellerError::Api(ApiError::TooManyRequests(message)) => {
error!("Too many requests (sleeping): {:?}", message);
tokio::time::sleep(std::time::Duration::from_millis(60_000)).await;
}
_ => {}
}
return Err(anyhow!(err));
}
};
let job_ids = jobs.iter()
.map(|job| job.job_token.to_string())
.collect::<Vec<_>>();
let tasks = list_tasks_by_provider_and_tokens(ListTasksArgs {
db: task_database.get_connection(),
provider: GenerationProvider::Artcraft,
provider_job_ids: Some(job_ids),
}).await?;
let tasks = tasks.tasks;
let jobs_by_id = jobs.iter()
.map(|job| (job.job_token.to_string(), job))
.collect::<HashMap<String, _>>();
let tasks_by_provider_job_id = tasks.iter()
.filter_map(|task| {
if let Some(provider_job_id) = &task.provider_job_id {
Some((provider_job_id.clone(), task.clone()))
} else {
None
}
})
//.map(|task| (task.provider_job_id.clone(), task.clone()))
.collect::<HashMap<String, Task>>();
for job in jobs.iter() {
let task = match tasks_by_provider_job_id.get(job.job_token.as_str()) {
Some(task) => task,
None => continue,
};
match job.status.status {
JobStatusPlus::CompleteSuccess => {
match task.status {
TaskStatus::CompleteSuccess => continue, // NB: We're done with this task.
_ => {}
}
handle_successful_job(app_handle, app_env_configs, creds.as_ref(), job, task, task_database).await?;
}
JobStatusPlus::CompleteFailure => {
match task.status {
TaskStatus::CompleteFailure => continue, // NB: We're done with this task.
_ => {}
}
handle_failed_job(app_handle, job, task, task_database).await?;
}
_ => continue,
}
}
}
}

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