mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-28 12:32:51 -04:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ff0cb6b4d | ||
|
|
ced81da40e | ||
|
|
df551082b9 | ||
|
|
58a7845c03 | ||
|
|
e49812ee14 | ||
|
|
711a0613cb |
+47
@@ -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"
|
||||
}
|
||||
+3
-3
@@ -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"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
+2
-2
@@ -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"
|
||||
}
|
||||
+37
@@ -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())
|
||||
|
||||
+24
@@ -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;
|
||||
|
||||
+1
@@ -23,6 +23,7 @@ SET
|
||||
maybe_description = ?
|
||||
WHERE token = ?
|
||||
AND deleted_at IS NULL
|
||||
LIMIT 1
|
||||
"#,
|
||||
character_name,
|
||||
kinovi_character_name,
|
||||
|
||||
+72
@@ -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)
|
||||
}
|
||||
+6
-6
@@ -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()
|
||||
+2
-1
@@ -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;
|
||||
|
||||
+14
-2
@@ -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,
|
||||
|
||||
|
||||
+110
@@ -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;
|
||||
+45
@@ -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());
|
||||
}
|
||||
}
|
||||
+171
@@ -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
-1
@@ -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;
|
||||
+1
-1
@@ -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;
|
||||
+2
-2
@@ -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_";
|
||||
+3
-3
@@ -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.
|
||||
///
|
||||
+5
-5
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user