Compare commits

...
Author SHA1 Message Date
Brandon Thomas 0ff0cb6b4d codegen db 2026-04-03 02:48:57 -04:00
Brandon Thomas ced81da40e run both threads together 2026-04-03 02:45:30 -04:00
Brandon Thomas df551082b9 polling for characters (not wired up) 2026-04-03 02:15:11 -04:00
Brandon Thomas 58a7845c03 change types 2026-04-03 00:49:40 -04:00
Brandon Thomas e49812ee14 rename and fork 2026-04-03 00:47:04 -04:00
Brandon Thomas 711a0613cb refactor 2026-04-03 00:31:53 -04:00
27 changed files with 587 additions and 32 deletions
@@ -0,0 +1,47 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n jobs.token as `job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,\n jobs.maybe_external_third_party_id as `kinovi_character_id`,\n jobs.maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`\n\nFROM generic_inference_jobs as jobs\n\nWHERE jobs.maybe_external_third_party = ?\n AND jobs.job_type = ?\n AND jobs.status NOT IN ('complete_success', 'complete_failure')\n AND jobs.maybe_external_third_party_id IS NOT NULL\n\nLIMIT 25000\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "kinovi_character_id",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 256
}
},
{
"ordinal": 2,
"name": "maybe_creator_user_token: tokens::tokens::users::UserToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
}
],
"parameters": {
"Right": 2
},
"nullable": [
false,
true,
true
]
},
"hash": "9bd472515a1ec55a4b3eed9b37baea3a9740484172086b1797889a5f88b5750f"
}
@@ -1,6 +1,6 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n jobs.token as `job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,\n jobs.maybe_external_third_party_id as `order_id`,\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 jobs.creator_set_visibility as `creator_set_visibility: enums::common::visibility::Visibility`,\n jobs.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,\n jobs.maybe_wallet_ledger_entry_token as `maybe_wallet_ledger_entry_token: tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken`\n\nFROM generic_inference_jobs as jobs\n\nWHERE jobs.maybe_external_third_party = ?\n AND jobs.status NOT IN ('complete_success', 'complete_failure')\n AND jobs.maybe_external_third_party_id IS NOT NULL\n\nLIMIT 25000\n ",
"query": "\nSELECT\n jobs.token as `job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,\n jobs.maybe_external_third_party_id as `order_id`,\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 jobs.creator_set_visibility as `creator_set_visibility: enums::common::visibility::Visibility`,\n jobs.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,\n jobs.maybe_wallet_ledger_entry_token as `maybe_wallet_ledger_entry_token: tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken`\n\nFROM generic_inference_jobs as jobs\n\nWHERE jobs.maybe_external_third_party = ?\n AND jobs.job_type = ?\n AND jobs.status NOT IN ('complete_success', 'complete_failure')\n AND jobs.maybe_external_third_party_id IS NOT NULL\n\nLIMIT 25000\n ",
"describe": {
"columns": [
{
@@ -85,7 +85,7 @@
}
],
"parameters": {
"Right": 1
"Right": 2
},
"nullable": [
false,
@@ -98,5 +98,5 @@
true
]
},
"hash": "cb33fd483f0fef7ec2a1f5950218f312a19e38c7971b88db949ccaa05bf7b7c4"
"hash": "a0002183d4d077b07bb0cd062ca95bf113d1564ce3195f0e62e531ce4297dd76"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nUPDATE characters\nSET\n is_active = true,\n maybe_avatar_media_token = ?,\n maybe_full_image_media_token = ?,\n maybe_kinovi_asset_id = ?\nWHERE token = ?\n AND deleted_at IS NULL\nLIMIT 1\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 4
},
"nullable": []
},
"hash": "abbd24f51fae54ba3cd54ec7e3caf1ed7121b244796331d56b2d7f20eb7d4b9b"
}
@@ -1,6 +1,6 @@
{
"db_name": "MySQL",
"query": "\nUPDATE characters\nSET\n character_name = ?,\n kinovi_character_name = ?,\n maybe_description = ?\nWHERE token = ?\n AND deleted_at IS NULL\n ",
"query": "\nUPDATE characters\nSET\n character_name = ?,\n kinovi_character_name = ?,\n maybe_description = ?\nWHERE token = ?\n AND deleted_at IS NULL\nLIMIT 1\n ",
"describe": {
"columns": [],
"parameters": {
@@ -8,5 +8,5 @@
},
"nullable": []
},
"hash": "eae0b979383989f7a9af8ddd678ab0c0403952b8b98d9fcb078ae401ae7fa4e3"
"hash": "f54e22efa17f3b8d36387dfd724e1fd0c88634fc8defa420ddd2de7e9103518c"
}
@@ -0,0 +1,37 @@
use sqlx::MySqlPool;
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::media_files::MediaFileToken;
/// Activate a character after its creation job completes successfully.
///
/// Sets `is_active = true`, attaches media tokens, and records the kinovi asset ID.
pub async fn activate_character_with_media(
character_token: &CharacterToken,
maybe_avatar_media_token: Option<&MediaFileToken>,
maybe_full_image_media_token: Option<&MediaFileToken>,
maybe_kinovi_asset_id: Option<&str>,
pool: &MySqlPool,
) -> Result<(), sqlx::Error> {
sqlx::query!(
r#"
UPDATE characters
SET
is_active = true,
maybe_avatar_media_token = ?,
maybe_full_image_media_token = ?,
maybe_kinovi_asset_id = ?
WHERE token = ?
AND deleted_at IS NULL
LIMIT 1
"#,
maybe_avatar_media_token.map(|t| t.as_str()),
maybe_full_image_media_token.map(|t| t.as_str()),
maybe_kinovi_asset_id,
character_token.as_str(),
)
.execute(pool)
.await?;
Ok(())
}
@@ -16,6 +16,7 @@ UPDATE characters
SET deleted_at = NOW()
WHERE token = ?
AND deleted_at IS NULL
LIMIT 1
"#,
)
.bind(character_token.as_str())
@@ -0,0 +1,24 @@
use sqlx::MySqlPool;
use tokens::tokens::characters::CharacterToken;
/// Look up a character token by its Kinovi character ID.
pub async fn get_character_token_by_kinovi_id(
kinovi_character_id: &str,
pool: &MySqlPool,
) -> Result<Option<CharacterToken>, sqlx::Error> {
let row = sqlx::query_as::<_, (CharacterToken,)>(
r#"
SELECT token
FROM characters
WHERE kinovi_character_id = ?
AND deleted_at IS NULL
LIMIT 1
"#,
)
.bind(kinovi_character_id)
.fetch_optional(pool)
.await?;
Ok(row.map(|(token,)| token))
}
@@ -1,7 +1,9 @@
pub mod activate_character_with_media;
pub mod batch_lookup_characters_by_token_for_prompting;
pub mod create_pending_character;
pub mod delete_character;
pub mod get_character_by_token;
pub mod get_character_by_token_including_deleted;
pub mod get_character_token_by_kinovi_id;
pub mod list_active_characters_for_user;
pub mod update_character_name_and_description;
@@ -23,6 +23,7 @@ SET
maybe_description = ?
WHERE token = ?
AND deleted_at IS NULL
LIMIT 1
"#,
character_name,
kinovi_character_name,
@@ -0,0 +1,72 @@
use log::warn;
use sqlx::MySqlPool;
use enums::by_table::generic_inference_jobs::inference_job_external_third_party::InferenceJobExternalThirdParty;
use enums::by_table::generic_inference_jobs::inference_job_type::InferenceJobType;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::users::UserToken;
/// A pending Seedance2Pro character job waiting for a result.
#[derive(Debug)]
pub struct PendingSeedance2ProCharacterJob {
pub job_token: InferenceJobToken,
/// The Kinovi character_id (stored as the external third party ID).
pub kinovi_character_id: String,
pub maybe_creator_user_token: Option<UserToken>,
}
#[derive(Debug, Default)]
struct RawRecord {
job_token: InferenceJobToken,
kinovi_character_id: Option<String>,
maybe_creator_user_token: Option<UserToken>,
}
/// Returns all non-terminal Seedance2Pro character jobs that have an associated character ID.
pub async fn list_pending_seedance2pro_character_jobs(pool: &MySqlPool) -> Result<Vec<PendingSeedance2ProCharacterJob>, sqlx::Error> {
let records = sqlx::query_as!(
RawRecord,
r#"
SELECT
jobs.token as `job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,
jobs.maybe_external_third_party_id as `kinovi_character_id`,
jobs.maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`
FROM generic_inference_jobs as jobs
WHERE jobs.maybe_external_third_party = ?
AND jobs.job_type = ?
AND jobs.status NOT IN ('complete_success', 'complete_failure')
AND jobs.maybe_external_third_party_id IS NOT NULL
LIMIT 25000
"#,
InferenceJobExternalThirdParty::Seedance2Pro.to_str(),
InferenceJobType::Seedance2ProCharacter.to_str(),
)
.fetch_all(pool)
.await?;
let jobs = records
.into_iter()
.filter_map(|record| {
let kinovi_character_id = match record.kinovi_character_id {
Some(id) => id,
None => {
warn!("PendingSeedance2ProCharacterJob has no kinovi_character_id, skipping");
return None;
}
};
Some(PendingSeedance2ProCharacterJob {
job_token: record.job_token,
kinovi_character_id,
maybe_creator_user_token: record.maybe_creator_user_token,
})
})
.collect();
Ok(jobs)
}
@@ -1,10 +1,9 @@
use anyhow::anyhow;
use log::warn;
use sqlx::MySqlPool;
use enums::by_table::generic_inference_jobs::inference_job_external_third_party::InferenceJobExternalThirdParty;
use enums::by_table::generic_inference_jobs::inference_job_type::InferenceJobType;
use enums::common::visibility::Visibility;
use errors::AnyhowResult;
use tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::prompts::PromptToken;
@@ -41,8 +40,8 @@ struct RawRecord {
maybe_wallet_ledger_entry_token: Option<WalletLedgerEntryToken>,
}
/// Returns all non-terminal Seedance2Pro jobs that have an associated order_id.
pub async fn list_pending_seedance2pro_jobs(pool: &MySqlPool) -> AnyhowResult<Vec<PendingSeedance2ProJob>> {
/// Returns all non-terminal Seedance2Pro video jobs that have an associated order_id.
pub async fn list_pending_seedance2pro_video_jobs(pool: &MySqlPool) -> Result<Vec<PendingSeedance2ProJob>, sqlx::Error> {
let records = sqlx::query_as!(
RawRecord,
r#"
@@ -59,16 +58,17 @@ SELECT
FROM generic_inference_jobs as jobs
WHERE jobs.maybe_external_third_party = ?
AND jobs.job_type = ?
AND jobs.status NOT IN ('complete_success', 'complete_failure')
AND jobs.maybe_external_third_party_id IS NOT NULL
LIMIT 25000
"#,
InferenceJobExternalThirdParty::Seedance2Pro.to_str(),
InferenceJobType::Seedance2ProQueue.to_str(),
)
.fetch_all(pool)
.await
.map_err(|err| anyhow!("error querying pending seedance2pro jobs: {:?}", err))?;
.await?;
let jobs = records
.into_iter()
@@ -1,3 +1,4 @@
pub mod insert_generic_inference_job_for_seedance2pro_character_with_apriori_job_token;
pub mod insert_generic_inference_job_for_seedance2pro_queue_with_apriori_job_token;
pub mod list_pending_seedance2pro_jobs;
pub mod list_pending_seedance2pro_character_jobs;
pub mod list_pending_seedance2pro_video_jobs;
@@ -31,7 +31,11 @@ pub enum InferenceResultType {
ZeroShotVoiceEmbedding,
#[serde(rename = "upload_model")]
UploadModel
UploadModel,
/// Result is a character record.
#[serde(rename = "character")]
Character,
}
// TODO(bt, 2022-12-21): This desperately needs MySQL integration tests!
@@ -47,6 +51,7 @@ impl InferenceResultType {
Self::VoiceConversion => "voice_conversion",
Self::ZeroShotVoiceEmbedding => "zs_voice_embedding",
Self::UploadModel => "upload_model",
Self::Character => "character",
}
}
@@ -57,6 +62,7 @@ impl InferenceResultType {
"voice_conversion" => Ok(Self::VoiceConversion),
"zs_voice_embedding" => Ok(Self::ZeroShotVoiceEmbedding),
"upload_model" => Ok(Self::UploadModel),
"character" => Ok(Self::Character),
_ => Err(format!("invalid value: {:?}", value)),
}
}
@@ -70,6 +76,7 @@ impl InferenceResultType {
InferenceResultType::VoiceConversion,
InferenceResultType::ZeroShotVoiceEmbedding,
InferenceResultType::UploadModel,
InferenceResultType::Character,
])
}
}
@@ -86,6 +93,7 @@ mod tests {
assert_serialization(InferenceResultType::VoiceConversion, "voice_conversion");
assert_serialization(InferenceResultType::ZeroShotVoiceEmbedding, "zs_voice_embedding");
assert_serialization(InferenceResultType::UploadModel, "upload_model");
assert_serialization(InferenceResultType::Character, "character");
}
#[test]
@@ -95,6 +103,7 @@ mod tests {
assert_eq!(InferenceResultType::VoiceConversion.to_str(), "voice_conversion");
assert_eq!(InferenceResultType::ZeroShotVoiceEmbedding.to_str(), "zs_voice_embedding");
assert_eq!(InferenceResultType::UploadModel.to_str(), "upload_model");
assert_eq!(InferenceResultType::Character.to_str(), "character");
}
#[test]
@@ -104,18 +113,21 @@ mod tests {
assert_eq!(InferenceResultType::from_str("voice_conversion").unwrap(), InferenceResultType::VoiceConversion);
assert_eq!(InferenceResultType::from_str("zs_voice_embedding").unwrap(), InferenceResultType::ZeroShotVoiceEmbedding);
assert_eq!(InferenceResultType::from_str("upload_model").unwrap(), InferenceResultType::UploadModel);
assert_eq!(InferenceResultType::from_str("character").unwrap(), InferenceResultType::Character);
assert_eq!(InferenceResultType::from_str("invalid").is_err(), true);
}
#[test]
fn all_variants() {
// Static check
let mut variants = InferenceResultType::all_variants();
assert_eq!(variants.len(), 5);
assert_eq!(variants.len(), 6);
assert_eq!(variants.pop_first(), Some(InferenceResultType::MediaFile));
assert_eq!(variants.pop_first(), Some(InferenceResultType::TextToSpeech));
assert_eq!(variants.pop_first(), Some(InferenceResultType::VoiceConversion));
assert_eq!(variants.pop_first(), Some(InferenceResultType::ZeroShotVoiceEmbedding));
assert_eq!(variants.pop_first(), Some(InferenceResultType::UploadModel));
assert_eq!(variants.pop_first(), Some(InferenceResultType::Character));
assert_eq!(variants.pop_first(), None);
// Generated check
@@ -6,6 +6,7 @@ use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use server_environment::ServerEnvironment;
use sqlx::MySqlPool;
#[derive(Clone)]
pub struct JobDependencies {
pub mysql_pool: MySqlPool,
@@ -0,0 +1,110 @@
use std::collections::HashMap;
use std::time::{Duration, Instant};
use log::{error, info, warn};
use mysql_queries::queries::generic_inference::seedance2pro::list_pending_seedance2pro_character_jobs::list_pending_seedance2pro_character_jobs;
use seedance2pro_client::requests::poll_characters::poll_characters::{
poll_characters, CharacterCreationStatus, CharacterStatus, PollCharactersArgs,
};
use crate::job_dependencies::JobDependencies;
use super::process_failed_character::process_failed_character;
use super::process_successful_character::process_successful_character;
const POLL_ALERT_THRESHOLD: Duration = Duration::from_secs(600);
pub async fn character_polling_main_loop(deps: JobDependencies) {
while !deps.application_shutdown.get() {
let start = Instant::now();
let result = run_poll_iteration(&deps).await;
let elapsed = start.elapsed();
if let Err(err) = result {
error!("Error in character poll iteration: {:?}", err);
let _ = deps.job_stats.increment_failure_count();
}
if elapsed > POLL_ALERT_THRESHOLD {
warn!("Character poll iteration took {:.1}s (threshold: {}s)", elapsed.as_secs_f64(), POLL_ALERT_THRESHOLD.as_secs());
}
tokio::time::sleep(Duration::from_millis(deps.poll_interval_millis)).await;
}
warn!("Character polling main loop is shut down.");
}
async fn run_poll_iteration(deps: &JobDependencies) -> anyhow::Result<()> {
// 1. Query all pending character creation jobs from DB.
let pending_jobs = list_pending_seedance2pro_character_jobs(&deps.mysql_pool).await?;
if pending_jobs.is_empty() {
return Ok(());
}
info!("Found {} pending character job(s).", pending_jobs.len());
// Build a lookup: kinovi_character_id -> job
let job_by_character_id: HashMap<String, _> = pending_jobs
.into_iter()
.map(|job| (job.kinovi_character_id.clone(), job))
.collect();
// 2. Poll characters from Kinovi API.
let response = poll_characters(PollCharactersArgs {
session: &deps.seedance2pro_session,
cursor: None,
limit: None,
host_override: None,
})
.await
.map_err(|err| anyhow::anyhow!("Error polling characters from Kinovi: {:?}", err))?;
info!("Polled {} character(s) from Kinovi.", response.characters.len());
// 3. Match polled characters against pending jobs.
for character in &response.characters {
if deps.application_shutdown.get() {
break;
}
let job = match job_by_character_id.get(&character.character_id) {
Some(j) => j,
None => continue, // Not one of our pending jobs.
};
match &character.status {
CharacterCreationStatus::Success => {
info!(
"Character {} completed successfully, processing job {}",
character.character_id, job.job_token,
);
match process_successful_character(deps, job, character).await {
Ok(()) => {
let _ = deps.job_stats.increment_success_count();
}
Err(err) => {
warn!(
"Error processing completed character {}: {:?}",
character.character_id, err,
);
let _ = deps.job_stats.increment_failure_count();
}
}
}
CharacterCreationStatus::Failed => {
info!(
"Character {} failed, processing job {}",
character.character_id, job.job_token,
);
process_failed_character(deps, job, character).await;
}
CharacterCreationStatus::Pending => {
// Still in progress — check again next poll.
}
}
}
Ok(())
}
@@ -0,0 +1,3 @@
pub mod character_polling_main_loop;
mod process_successful_character;
mod process_failed_character;
@@ -0,0 +1,45 @@
use log::{error, info, warn};
use mysql_queries::queries::generic_inference::job::mark_job_failed_by_token::{mark_job_failed_by_token, MarkJobFailedByTokenArgs};
use mysql_queries::queries::generic_inference::seedance2pro::list_pending_seedance2pro_character_jobs::PendingSeedance2ProCharacterJob;
use seedance2pro_client::requests::poll_characters::poll_characters::CharacterStatus;
use crate::job_dependencies::JobDependencies;
pub async fn process_failed_character(
deps: &JobDependencies,
job: &PendingSeedance2ProCharacterJob,
character: &CharacterStatus,
) {
let reason = character.fail_reason
.as_deref()
.unwrap_or("unknown failure reason");
warn!(
"Character {} (job {}) failed: {}",
character.character_id, job.job_token.as_str(), reason,
);
let internal_reason = format!(
"Kinovi character creation failed. character_id={}, raw_task_status={}, raw_asset_status={:?}, fail_reason={:?}",
character.character_id,
character.raw_task_status,
character.raw_asset_status,
character.fail_reason,
);
if let Err(err) = mark_job_failed_by_token(MarkJobFailedByTokenArgs {
pool: &deps.mysql_pool,
job_token: &job.job_token,
maybe_public_failure_reason: Some(reason),
internal_debugging_failure_reason: &internal_reason,
maybe_frontend_failure_category: None,
}).await {
error!(
"Error marking character job {} as failed: {:?}",
job.job_token.as_str(), err,
);
} else {
info!("Marked character job {} as failed.", job.job_token.as_str());
}
}
@@ -0,0 +1,171 @@
use anyhow::anyhow;
use log::{error, info, warn};
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
use enums::by_table::generic_inference_jobs::inference_result_type::InferenceResultType;
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory;
use enums::by_table::media_files::media_file_origin_product_category::MediaFileOriginProductCategory;
use enums::by_table::media_files::media_file_type::MediaFileType;
use errors::AnyhowResult;
use hashing::sha256::sha256_hash_bytes::sha256_hash_bytes;
use mysql_queries::queries::characters::activate_character_with_media::activate_character_with_media;
use mysql_queries::queries::characters::get_character_token_by_kinovi_id::get_character_token_by_kinovi_id;
use mysql_queries::queries::generic_inference::seedance2pro::list_pending_seedance2pro_character_jobs::PendingSeedance2ProCharacterJob;
use mysql_queries::queries::generic_inference::web::mark_generic_inference_job_successfully_done_by_token::mark_generic_inference_job_successfully_done_by_token;
use mysql_queries::queries::media_files::create::insert_builder::media_file_insert_builder::MediaFileInsertBuilder;
use seedance2pro_client::requests::poll_characters::poll_characters::CharacterStatus;
use crate::job_dependencies::JobDependencies;
const PREFIX: &str = "artcraft_";
const SUFFIX_PNG: &str = ".png";
const SUFFIX_JPG: &str = ".jpg";
/// Process a successfully completed character creation.
///
/// 1. Download result images and upload them as media files.
/// 2. Activate the character with the new media tokens.
/// 3. Mark the inference job as completed.
pub async fn process_successful_character(
deps: &JobDependencies,
job: &PendingSeedance2ProCharacterJob,
character: &CharacterStatus,
) -> AnyhowResult<()> {
info!(
"Processing successful character {} for job {}",
character.character_id, job.job_token.as_str(),
);
// --- Look up our character record ---
let character_token = get_character_token_by_kinovi_id(&character.character_id, &deps.mysql_pool)
.await
.map_err(|err| anyhow!("Error looking up character by kinovi_id: {:?}", err))?
.ok_or_else(|| anyhow!("Character not found for kinovi_id: {}", character.character_id))?;
// --- Download and upload result images ---
// The first result image with type "avatar" is the avatar; otherwise use the first image.
// The first result image with type "full" or the second image is the full image.
let mut maybe_avatar_media_token = None;
let mut maybe_full_image_media_token = None;
for (i, result_image) in character.result_images.iter().enumerate() {
let is_avatar = result_image.image_type.as_deref() == Some("avatar") || (i == 0 && maybe_avatar_media_token.is_none());
let is_full = result_image.image_type.as_deref() == Some("full") || (i == 1 && maybe_full_image_media_token.is_none());
if !is_avatar && !is_full {
continue;
}
let media_token = match download_and_create_media_file(deps, job, &result_image.url).await {
Ok(token) => token,
Err(err) => {
warn!("Error processing result image {}: {:?}", result_image.url, err);
continue;
}
};
if is_avatar && maybe_avatar_media_token.is_none() {
maybe_avatar_media_token = Some(media_token);
} else if is_full && maybe_full_image_media_token.is_none() {
maybe_full_image_media_token = Some(media_token);
}
}
// --- Activate character ---
activate_character_with_media(
&character_token,
maybe_avatar_media_token.as_ref(),
maybe_full_image_media_token.as_ref(),
character.asset_id.as_deref(),
&deps.mysql_pool,
)
.await
.map_err(|err| anyhow!("Error activating character: {:?}", err))?;
info!("Activated character {} (token={})", character.character_id, character_token);
// --- Mark job as completed ---
mark_generic_inference_job_successfully_done_by_token(
&deps.mysql_pool,
&job.job_token,
Some(InferenceResultType::Character),
Some(character_token.as_str()),
None,
None,
)
.await
.map_err(|err| anyhow!("Error marking job as done: {:?}", err))?;
info!("Marked job {} as completed.", job.job_token.as_str());
Ok(())
}
// =============== Private helpers ===============
/// Download an image from a URL, upload to bucket, and create a media file record.
async fn download_and_create_media_file(
deps: &JobDependencies,
job: &PendingSeedance2ProCharacterJob,
image_url: &str,
) -> AnyhowResult<tokens::tokens::media_files::MediaFileToken> {
info!("Downloading character image: {}", image_url);
let image_bytes: Vec<u8> = reqwest::get(image_url)
.await
.map_err(|err| anyhow!("reqwest error downloading image: {:?}", err))?
.bytes()
.await
.map_err(|err| anyhow!("error reading image bytes: {:?}", err))?
.to_vec();
info!("Downloaded {} bytes from {}", image_bytes.len(), image_url);
let checksum = sha256_hash_bytes(&image_bytes)
.map_err(|err| anyhow!("error hashing image: {:?}", err))?;
// Determine extension from URL or default to png.
let suffix = if image_url.contains(".jpg") || image_url.contains(".jpeg") {
SUFFIX_JPG
} else {
SUFFIX_PNG
};
let media_type = if suffix == SUFFIX_JPG { MediaFileType::Jpg } else { MediaFileType::Png };
let mime_type = if suffix == SUFFIX_JPG { "image/jpeg" } else { "image/png" };
let bucket_path = MediaFileBucketPath::generate_new(Some(PREFIX), Some(suffix));
let object_path = bucket_path.get_full_object_path_str();
info!("Uploading character image to bucket: {}", object_path);
deps.public_bucket_client
.upload_file_with_content_type_process(object_path, &image_bytes, mime_type)
.await
.map_err(|err| anyhow!("error uploading image to bucket: {:?}", err))?;
let media_file_token = MediaFileInsertBuilder::new()
.maybe_creator_user(job.maybe_creator_user_token.as_ref())
.media_file_class(MediaFileClass::Image)
.media_file_type(media_type)
.is_intermediate_system_file(true)
.media_file_origin_category(MediaFileOriginCategory::Inference)
.media_file_origin_product_category(MediaFileOriginProductCategory::VideoGeneration)
.mime_type(mime_type)
.file_size_bytes(image_bytes.len() as u64)
.checksum_sha2(&checksum)
.public_bucket_directory_hash(&bucket_path)
.insert_pool(&deps.mysql_pool)
.await
.map_err(|err| anyhow!("error inserting media file record: {:?}", err))?;
info!("Created media file {} for character image", media_file_token.as_str());
Ok(media_file_token)
}
@@ -1,4 +1,2 @@
pub mod alert_on_error;
pub mod main_loop;
pub mod process_job;
pub mod process_page_batch;
pub mod character_polling_job;
pub mod video_polling_job;
@@ -1,5 +1,5 @@
use log::error;
use mysql_queries::queries::generic_inference::seedance2pro::list_pending_seedance2pro_jobs::PendingSeedance2ProJob;
use mysql_queries::queries::generic_inference::seedance2pro::list_pending_seedance2pro_video_jobs::PendingSeedance2ProJob;
use pager::client::pager::Pager;
use pager::notification::notification_details_builder::NotificationDetailsBuilder;
use pager::notification::notification_urgency::NotificationUrgency;
@@ -0,0 +1,4 @@
pub mod alert_on_error;
pub mod video_polling_main_loop;
pub mod process_job;
pub mod process_page_batch;
@@ -2,7 +2,7 @@ use log::{error, info, warn};
use enums::by_table::generic_inference_jobs::frontend_failure_category::FrontendFailureCategory;
use mysql_queries::queries::generic_inference::job::mark_job_failed_by_token::{mark_job_failed_by_token, MarkJobFailedByTokenArgs};
use mysql_queries::queries::generic_inference::seedance2pro::list_pending_seedance2pro_jobs::PendingSeedance2ProJob;
use mysql_queries::queries::generic_inference::seedance2pro::list_pending_seedance2pro_video_jobs::PendingSeedance2ProJob;
use mysql_queries::queries::wallets::refund::try_to_refund_ledger_entry::{try_to_refund_ledger_entry, WalletRefundOutcome};
use pager::notification::notification_details_builder::NotificationDetailsBuilder;
use pager::notification::notification_urgency::NotificationUrgency;
@@ -9,12 +9,12 @@ use enums::by_table::media_files::media_file_origin_product_category::MediaFileO
use enums::by_table::media_files::media_file_type::MediaFileType;
use errors::AnyhowResult;
use hashing::sha256::sha256_hash_bytes::sha256_hash_bytes;
use mysql_queries::queries::generic_inference::seedance2pro::list_pending_seedance2pro_jobs::PendingSeedance2ProJob;
use mysql_queries::queries::generic_inference::seedance2pro::list_pending_seedance2pro_video_jobs::PendingSeedance2ProJob;
use mysql_queries::queries::media_files::create::insert_builder::media_file_insert_builder::MediaFileInsertBuilder;
use mysql_queries::queries::generic_inference::web::mark_generic_inference_job_successfully_done_by_token::mark_generic_inference_job_successfully_done_by_token;
use seedance2pro_client::requests::poll_orders::poll_orders::OrderStatus;
use crate::jobs::alert_on_error::alert_pager_and_return_err;
use crate::jobs::video_polling_job::alert_on_error::alert_pager_and_return_err;
use crate::job_dependencies::JobDependencies;
const PREFIX: &str = "artcraft_";
@@ -1,12 +1,12 @@
use std::collections::HashMap;
use log::{info, warn};
use mysql_queries::queries::generic_inference::seedance2pro::list_pending_seedance2pro_jobs::PendingSeedance2ProJob;
use mysql_queries::queries::generic_inference::seedance2pro::list_pending_seedance2pro_video_jobs::PendingSeedance2ProJob;
use seedance2pro_client::requests::poll_orders::poll_orders::{OrderStatus, TaskStatus};
use crate::job_dependencies::JobDependencies;
use crate::jobs::process_job::process_failed_job::process_failed_job;
use crate::jobs::process_job::process_successful_job::process_successful_job;
use crate::jobs::video_polling_job::process_job::process_failed_job::process_failed_job;
use crate::jobs::video_polling_job::process_job::process_successful_job::process_successful_job;
/// Process a batch of polled orders against the pending jobs map.
///
@@ -4,16 +4,16 @@ use std::time::{Duration, Instant};
use log::{error, info, warn};
use pager::notification::notification_details_builder::NotificationDetailsBuilder;
use pager::notification::notification_urgency::NotificationUrgency;
use mysql_queries::queries::generic_inference::seedance2pro::list_pending_seedance2pro_jobs::list_pending_seedance2pro_jobs;
use mysql_queries::queries::generic_inference::seedance2pro::list_pending_seedance2pro_video_jobs::list_pending_seedance2pro_video_jobs;
use seedance2pro_client::requests::poll_orders::poll_orders::{poll_orders, OrderStatus, PollOrdersArgs, TaskStatus};
use crate::jobs::alert_on_error::alert_pager_and_return_err;
use crate::jobs::process_page_batch::process_page_batch;
use crate::jobs::video_polling_job::alert_on_error::alert_pager_and_return_err;
use crate::jobs::video_polling_job::process_page_batch::process_page_batch;
use crate::job_dependencies::JobDependencies;
const POLL_ALERT_THRESHOLD: Duration = Duration::from_secs(600);
pub async fn main_loop(job_dependencies: JobDependencies) {
pub async fn video_polling_main_loop(job_dependencies: JobDependencies) {
while !job_dependencies.application_shutdown.get() {
let start = Instant::now();
let result = run_poll_iteration(&job_dependencies).await;
@@ -50,7 +50,7 @@ pub async fn main_loop(job_dependencies: JobDependencies) {
async fn run_poll_iteration(deps: &JobDependencies) -> anyhow::Result<()> {
// 1. Query all non-terminal Seedance2Pro jobs from DB.
let pending_jobs = match list_pending_seedance2pro_jobs(&deps.mysql_pool).await {
let pending_jobs = match list_pending_seedance2pro_video_jobs(&deps.mysql_pool).await {
Ok(jobs) => jobs,
Err(err) => {
error!("Failed to list pending seedance2pro jobs: {:?}", err);
@@ -26,7 +26,8 @@ use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use server_environment::ServerEnvironment;
use crate::http_server::run_http_server::{launch_http_server, CreateServerArgs};
use crate::jobs::main_loop::main_loop;
use crate::jobs::character_polling_job::character_polling_main_loop::character_polling_main_loop;
use crate::jobs::video_polling_job::video_polling_main_loop::video_polling_main_loop;
use crate::job_dependencies::JobDependencies;
use crate::startup::build_pager::build_pager;
@@ -176,7 +177,20 @@ async fn main() -> AnyhowResult<()> {
}
});
main_loop(job_dependencies).await;
// Spawn both polling loops as concurrent tasks.
let video_deps = job_dependencies.clone();
let character_deps = job_dependencies;
let video_handle = tokio::spawn(async move {
video_polling_main_loop(video_deps).await;
});
let character_handle = tokio::spawn(async move {
character_polling_main_loop(character_deps).await;
});
// Wait for both to finish (they exit when application_shutdown is set).
let _ = tokio::join!(video_handle, character_handle);
info!("Shutting down pager worker...");
pager_for_shutdown.shutdown_worker();