mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-20 17:02:53 -04:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9da8764803 | ||
|
|
a0edec29e7 | ||
|
|
da7082f405 | ||
|
|
33f1829c07 | ||
|
|
5f9941a26a | ||
|
|
24d49a2263 | ||
|
|
961862cdff | ||
|
|
9c7b35671b |
+135
@@ -0,0 +1,135 @@
|
||||
{
|
||||
"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\n jobs.maybe_external_third_party as `external_third_party: enums::by_table::generic_inference_jobs::inference_job_external_third_party::InferenceJobExternalThirdParty`,\n jobs.maybe_external_third_party_id as `external_third_party_id`,\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.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,\n jobs.maybe_debug_log_event_token as `maybe_debug_log_event_token: tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken`,\n\n jobs.created_at,\n jobs.updated_at\n\nFROM generic_inference_jobs as jobs\n\nWHERE jobs.maybe_external_third_party = ?\nAND jobs.maybe_external_third_party_id = ?\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "status: enums::common::job_status_plus::JobStatusPlus",
|
||||
"type_info": {
|
||||
"type": "String",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | ENUM",
|
||||
"char_set": 224,
|
||||
"max_size": 76
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "external_third_party: enums::by_table::generic_inference_jobs::inference_job_external_third_party::InferenceJobExternalThirdParty",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "MULTIPLE_KEY | BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 64
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "external_third_party_id",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "MULTIPLE_KEY | BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 256
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "maybe_creator_user_token: tokens::tokens::users::UserToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "MULTIPLE_KEY | BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "MULTIPLE_KEY | BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "creator_ip_address",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 160
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "maybe_prompt_token: tokens::tokens::prompts::PromptToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "maybe_debug_log_event_token: tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "created_at",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "updated_at",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | BINARY | TIMESTAMP | ON_UPDATE_NOW",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "31d85cc0013f29e6db2d272ee6e709cca79b46496a2c222960f1a1419175f00b"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
$kind: collection
|
||||
name: edit
|
||||
order: 312
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
$kind: http-request
|
||||
name: Beeble SwitchX Edit Video
|
||||
url: "{{api_host}}/v1/generate/video/edit/beeble_switchx"
|
||||
method: POST
|
||||
headers:
|
||||
- key: Accept
|
||||
value: application/json
|
||||
- key: Session
|
||||
value: ""
|
||||
disabled: true
|
||||
body:
|
||||
type: json
|
||||
content: >-
|
||||
{
|
||||
"uuid_idempotency_token": "{{$randomUUID}}",
|
||||
//"source_video_media_token": "m_6vve5gffn2r4rajyvy8z2380x15kqc", // two corgis (dev)
|
||||
"source_video_media_token": "m_rkgdk7qw0a7gz4mrzfv0q78ax57kbx", // corgi cowboy (dev)
|
||||
//"source_video_media_token": "m_1k19k6dgdny253zag8g3jqf3bgemss", // corgi watermellon (prod)
|
||||
"reference_image_media_token": "m_pf310vdhc4v98n7r8mz70bz9geest3" // tornado (dev)
|
||||
//"reference_image_media_token": "m_z4y097t9pdmbyatf70z0qb8zvfphvm" // cactus (prod)
|
||||
//"prompt": "Stormy skies, there's a rotating tornado in the background"
|
||||
}
|
||||
order: 1778262498846
|
||||
@@ -9,7 +9,7 @@ body:
|
||||
// hanashi : password (dev)
|
||||
//"username_or_email": "hanashi",
|
||||
//"password": "hanashi"
|
||||
"username_or_email": "newuser2",
|
||||
"password": "newuser2"
|
||||
"username_or_email": "newuser4",
|
||||
"password": "newuser4"
|
||||
}
|
||||
order: 1000
|
||||
|
||||
@@ -18,6 +18,12 @@ pub enum BeebleSpecificApiError {
|
||||
|
||||
/// The generation job failed with a reported error message.
|
||||
GenerationFailed(String),
|
||||
|
||||
/// 400 Bad Request — the callback_url is not a valid publicly-reachable HTTPS URL.
|
||||
BadWebhookUrl {
|
||||
message: String,
|
||||
webhook_url: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl Error for BeebleSpecificApiError {}
|
||||
@@ -30,6 +36,9 @@ impl Display for BeebleSpecificApiError {
|
||||
Self::IdempotencyConflict => write!(f, "Beeble API: Idempotency conflict"),
|
||||
Self::RateLimited => write!(f, "Beeble API: Rate limited"),
|
||||
Self::GenerationFailed(msg) => write!(f, "Beeble API: Generation failed: {}", msg),
|
||||
Self::BadWebhookUrl { message, webhook_url } => {
|
||||
write!(f, "Beeble API: Bad webhook URL '{}': {}", webhook_url, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,3 +5,4 @@ pub(crate) mod test_utils;
|
||||
pub mod creds;
|
||||
pub mod error;
|
||||
pub mod requests;
|
||||
pub mod webhook_api;
|
||||
|
||||
@@ -69,7 +69,12 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::test_utils::get_test_api_key::get_test_api_key;
|
||||
|
||||
const TEST_JOB_ID: &str = "swx_uZAcwMjRZpDesqfp3pKkF";
|
||||
//const TEST_JOB_ID: &str = "swx_uZAcwMjRZpDesqfp3pKkF"; // Production
|
||||
//const TEST_JOB_ID: &str = "swx_Ontb6KfbfdY24Cz8DZ9c8"; // Dev
|
||||
//const TEST_JOB_ID: &str = "swx_k5SmgdFcesuE05BE8VqFb"; // Dev
|
||||
//const TEST_JOB_ID: &str = "swx_snkg7Et8EOvWaQ0QgoZlZ"; // Dev
|
||||
const TEST_JOB_ID: &str = "swx_a1GBhEHwIfhzXfWEkEbcb"; // Dev
|
||||
//const TEST_JOB_ID: &str = "swx_9Cbr7coiW9ZOayIrKGe0v"; // Prod
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually test — requires real API key
|
||||
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
use reqwest::StatusCode;
|
||||
|
||||
use crate::error::beeble_error::BeebleError;
|
||||
use crate::error::beeble_generic_api_error::BeebleGenericApiError;
|
||||
use crate::error::beeble_specific_api_error::BeebleSpecificApiError;
|
||||
|
||||
/// Additional context for error handling that depends on the original request.
|
||||
pub struct ErrorContext <'a> {
|
||||
pub maybe_callback_url: Option<&'a str>,
|
||||
}
|
||||
|
||||
/// Classify a non-success Beeble API response into the appropriate error.
|
||||
///
|
||||
/// Only call this when `!status.is_success()`. Always returns `Err`.
|
||||
pub fn handle_error_response<T>(
|
||||
status: StatusCode,
|
||||
response_body: &str,
|
||||
context: &ErrorContext,
|
||||
) -> Result<T, BeebleError> {
|
||||
match status.as_u16() {
|
||||
401 => Err(BeebleSpecificApiError::Unauthorized.into()),
|
||||
402 => Err(BeebleSpecificApiError::InsufficientCredits.into()),
|
||||
400 if response_body.contains("INVALID_CALLBACK_URL") => {
|
||||
let message = extract_error_message(response_body)
|
||||
.unwrap_or_else(|| "callback_url must be a valid, publicly-reachable HTTPS URL".to_string());
|
||||
Err(BeebleSpecificApiError::BadWebhookUrl {
|
||||
message,
|
||||
webhook_url: context.maybe_callback_url.unwrap_or_default().to_string(),
|
||||
}.into())
|
||||
}
|
||||
409 => Err(BeebleSpecificApiError::IdempotencyConflict.into()),
|
||||
429 => Err(BeebleSpecificApiError::RateLimited.into()),
|
||||
_ => {
|
||||
Err(BeebleGenericApiError::UncategorizedBadResponseWithStatusAndBody {
|
||||
status_code: status,
|
||||
body: response_body.to_string(),
|
||||
}.into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to extract the "message" field from a Beeble error response body.
|
||||
/// Beeble errors look like: `{"error":{"message":"...","code":"..."}}`
|
||||
fn extract_error_message(body: &str) -> Option<String> {
|
||||
let parsed: serde_json::Value = serde_json::from_str(body).ok()?;
|
||||
parsed.get("error")?.get("message")?.as_str().map(|s| s.to_string())
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
pub mod start_generation;
|
||||
pub(crate) mod handle_error_response;
|
||||
pub(crate) mod request_types;
|
||||
pub mod start_generation;
|
||||
|
||||
@@ -4,7 +4,7 @@ use crate::creds::beeble_api_key::BeebleApiKey;
|
||||
use crate::error::beeble_client_error::BeebleClientError;
|
||||
use crate::error::beeble_error::BeebleError;
|
||||
use crate::error::beeble_generic_api_error::BeebleGenericApiError;
|
||||
use crate::error::beeble_specific_api_error::BeebleSpecificApiError;
|
||||
use crate::requests::start_generation::handle_error_response::{handle_error_response, ErrorContext};
|
||||
use crate::requests::start_generation::request_types::*;
|
||||
|
||||
const BEEBLE_API_BASE_URL: &str = "https://api.beeble.ai/v1";
|
||||
@@ -110,7 +110,7 @@ pub async fn start_generation(args: StartGenerationArgs) -> Result<StartGenerati
|
||||
reference_image_uri: req.reference_image_uri,
|
||||
alpha_uri: req.alpha_uri,
|
||||
max_resolution: req.max_resolution,
|
||||
callback_url: req.callback_url,
|
||||
callback_url: req.callback_url.clone(),
|
||||
idempotency_key: req.idempotency_key,
|
||||
};
|
||||
|
||||
@@ -127,24 +127,17 @@ pub async fn start_generation(args: StartGenerationArgs) -> Result<StartGenerati
|
||||
.map_err(|err| BeebleGenericApiError::ReqwestError(err))?;
|
||||
|
||||
let status = response.status();
|
||||
|
||||
let response_body = response.text()
|
||||
.await
|
||||
.map_err(|err| BeebleGenericApiError::ReqwestError(err))?;
|
||||
|
||||
info!("Beeble start generation response: status={}", status);
|
||||
|
||||
match status.as_u16() {
|
||||
401 => return Err(BeebleSpecificApiError::Unauthorized.into()),
|
||||
402 => return Err(BeebleSpecificApiError::InsufficientCredits.into()),
|
||||
409 => return Err(BeebleSpecificApiError::IdempotencyConflict.into()),
|
||||
429 => return Err(BeebleSpecificApiError::RateLimited.into()),
|
||||
_ if !status.is_success() => {
|
||||
return Err(BeebleGenericApiError::UncategorizedBadResponseWithStatusAndBody {
|
||||
status_code: status,
|
||||
body: response_body,
|
||||
}.into());
|
||||
}
|
||||
_ => {}
|
||||
if !status.is_success() {
|
||||
return handle_error_response(status, &response_body, &ErrorContext {
|
||||
maybe_callback_url: req.callback_url.as_deref(),
|
||||
});
|
||||
}
|
||||
|
||||
let parsed: GenerationJobResponseBody = serde_json::from_str(&response_body)
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
use serde_derive::Deserialize;
|
||||
|
||||
/// Raw deserialized webhook payload from Beeble.
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
pub struct BeebleWebhookPayload {
|
||||
pub id: String,
|
||||
pub status: BeebleWebhookStatus,
|
||||
pub output: Option<BeebleWebhookOutput>,
|
||||
pub error: Option<String>,
|
||||
pub completed_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum BeebleWebhookStatus {
|
||||
Completed,
|
||||
Failed,
|
||||
/// Catch unknown variants gracefully.
|
||||
#[serde(other)]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
pub struct BeebleWebhookOutput {
|
||||
pub render: Option<String>,
|
||||
pub source: Option<String>,
|
||||
pub alpha: Option<String>,
|
||||
}
|
||||
|
||||
pub fn parse_beeble_webhook(raw_body: &str) -> Result<BeebleWebhookPayload, serde_json::Error> {
|
||||
serde_json::from_str(raw_body)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const SUCCESS_PAYLOAD: &str = r#"{
|
||||
"id": "swx_abc123",
|
||||
"status": "completed",
|
||||
"output": {
|
||||
"render": "https://cdn.beeble.ai/render.mp4",
|
||||
"source": "https://cdn.beeble.ai/source.mp4",
|
||||
"alpha": "https://cdn.beeble.ai/alpha.mp4"
|
||||
},
|
||||
"completed_at": "2026-02-23T10:05:00Z"
|
||||
}"#;
|
||||
|
||||
const FAILURE_PAYLOAD: &str = r#"{
|
||||
"id": "swx_abc123",
|
||||
"status": "failed",
|
||||
"completed_at": "2026-02-23T10:05:00Z",
|
||||
"error": "Processing failed"
|
||||
}"#;
|
||||
|
||||
const UNKNOWN_STATUS_PAYLOAD: &str = r#"{
|
||||
"id": "swx_xyz789",
|
||||
"status": "processing",
|
||||
"completed_at": null
|
||||
}"#;
|
||||
|
||||
const NULL_OUTPUT_PAYLOAD: &str = r#"{
|
||||
"id": "swx_abc123",
|
||||
"status": "completed",
|
||||
"output": null,
|
||||
"completed_at": "2026-02-23T10:05:00Z"
|
||||
}"#;
|
||||
|
||||
mod parse_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_success_payload() {
|
||||
let payload = parse_beeble_webhook(SUCCESS_PAYLOAD).unwrap();
|
||||
assert_eq!(payload.id, "swx_abc123");
|
||||
assert_eq!(payload.status, BeebleWebhookStatus::Completed);
|
||||
assert!(payload.error.is_none());
|
||||
|
||||
let output = payload.output.unwrap();
|
||||
assert_eq!(output.render.as_deref(), Some("https://cdn.beeble.ai/render.mp4"));
|
||||
assert_eq!(output.source.as_deref(), Some("https://cdn.beeble.ai/source.mp4"));
|
||||
assert_eq!(output.alpha.as_deref(), Some("https://cdn.beeble.ai/alpha.mp4"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_failure_payload() {
|
||||
let payload = parse_beeble_webhook(FAILURE_PAYLOAD).unwrap();
|
||||
assert_eq!(payload.id, "swx_abc123");
|
||||
assert_eq!(payload.status, BeebleWebhookStatus::Failed);
|
||||
assert_eq!(payload.error.as_deref(), Some("Processing failed"));
|
||||
assert!(payload.output.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_unknown_status() {
|
||||
let payload = parse_beeble_webhook(UNKNOWN_STATUS_PAYLOAD).unwrap();
|
||||
assert_eq!(payload.id, "swx_xyz789");
|
||||
assert_eq!(payload.status, BeebleWebhookStatus::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_null_output() {
|
||||
let payload = parse_beeble_webhook(NULL_OUTPUT_PAYLOAD).unwrap();
|
||||
assert_eq!(payload.id, "swx_abc123");
|
||||
assert_eq!(payload.status, BeebleWebhookStatus::Completed);
|
||||
assert!(payload.output.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_invalid_json() {
|
||||
let result = parse_beeble_webhook("not valid json");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod beeble_webhook_payload;
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
use anyhow::anyhow;
|
||||
use chrono::{DateTime, Utc};
|
||||
use log::warn;
|
||||
use sqlx::pool::PoolConnection;
|
||||
use sqlx::{MySql, MySqlPool};
|
||||
|
||||
use enums::by_table::generic_inference_jobs::inference_job_external_third_party::InferenceJobExternalThirdParty;
|
||||
use enums::common::job_status_plus::JobStatusPlus;
|
||||
use errors::AnyhowResult;
|
||||
use tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken;
|
||||
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
|
||||
use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken;
|
||||
use tokens::tokens::prompts::PromptToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct BeebleJobDetails {
|
||||
pub job_token: InferenceJobToken,
|
||||
|
||||
pub status: JobStatusPlus,
|
||||
|
||||
pub external_third_party: InferenceJobExternalThirdParty,
|
||||
pub external_third_party_id: String,
|
||||
|
||||
pub maybe_creator_user_token: Option<UserToken>,
|
||||
pub maybe_creator_anonymous_visitor_token: Option<AnonymousVisitorTrackingToken>,
|
||||
pub creator_ip_address: String,
|
||||
|
||||
pub maybe_prompt_token: Option<PromptToken>,
|
||||
pub maybe_debug_log_event_token: Option<DebugLogEventToken>,
|
||||
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct RawJobRecord {
|
||||
job_token: InferenceJobToken,
|
||||
|
||||
status: JobStatusPlus,
|
||||
|
||||
// NB: Nullable, but required to be filled for this query
|
||||
external_third_party: Option<InferenceJobExternalThirdParty>,
|
||||
|
||||
// NB: Nullable, but required to be filled for this query
|
||||
external_third_party_id: Option<String>,
|
||||
|
||||
maybe_creator_user_token: Option<UserToken>,
|
||||
maybe_creator_anonymous_visitor_token: Option<AnonymousVisitorTrackingToken>,
|
||||
creator_ip_address: String,
|
||||
|
||||
maybe_prompt_token: Option<PromptToken>,
|
||||
maybe_debug_log_event_token: Option<DebugLogEventToken>,
|
||||
|
||||
created_at: DateTime<Utc>,
|
||||
updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Returns Ok(None) when the record cannot be found.
|
||||
pub async fn get_inference_job_by_beeble_id(beeble_id: &str, mysql_pool: &MySqlPool)
|
||||
-> AnyhowResult<Option<BeebleJobDetails>>
|
||||
{
|
||||
let mut connection = mysql_pool.acquire().await?;
|
||||
get_inference_job_by_beeble_id_from_connection(beeble_id, &mut connection).await
|
||||
}
|
||||
|
||||
/// Returns Ok(None) when the record cannot be found.
|
||||
pub async fn get_inference_job_by_beeble_id_from_connection(beeble_id: &str, mysql_connection: &mut PoolConnection<MySql>)
|
||||
-> AnyhowResult<Option<BeebleJobDetails>>
|
||||
{
|
||||
let maybe_status = sqlx::query_as!(
|
||||
RawJobRecord,
|
||||
r#"
|
||||
SELECT
|
||||
jobs.token as `job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,
|
||||
|
||||
jobs.status as `status: enums::common::job_status_plus::JobStatusPlus`,
|
||||
|
||||
jobs.maybe_external_third_party as `external_third_party: enums::by_table::generic_inference_jobs::inference_job_external_third_party::InferenceJobExternalThirdParty`,
|
||||
jobs.maybe_external_third_party_id as `external_third_party_id`,
|
||||
|
||||
jobs.maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,
|
||||
jobs.maybe_creator_anonymous_visitor_token as `maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken`,
|
||||
jobs.creator_ip_address,
|
||||
|
||||
jobs.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,
|
||||
jobs.maybe_debug_log_event_token as `maybe_debug_log_event_token: tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken`,
|
||||
|
||||
jobs.created_at,
|
||||
jobs.updated_at
|
||||
|
||||
FROM generic_inference_jobs as jobs
|
||||
|
||||
WHERE jobs.maybe_external_third_party = ?
|
||||
AND jobs.maybe_external_third_party_id = ?
|
||||
"#,
|
||||
InferenceJobExternalThirdParty::Beeble.to_str(),
|
||||
beeble_id
|
||||
)
|
||||
.fetch_one(&mut **mysql_connection)
|
||||
.await;
|
||||
|
||||
let record = match maybe_status {
|
||||
Ok(record) => record,
|
||||
Err(err) => match err {
|
||||
sqlx::Error::RowNotFound => return Ok(None),
|
||||
_ => {
|
||||
warn!("error querying job record: {:?}", err);
|
||||
return Err(anyhow!("error querying job record: {:?}", err));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let record = raw_record_to_public_result(record)?;
|
||||
|
||||
Ok(Some(record))
|
||||
}
|
||||
|
||||
fn raw_record_to_public_result(record: RawJobRecord) -> AnyhowResult<BeebleJobDetails> {
|
||||
Ok(BeebleJobDetails {
|
||||
job_token: record.job_token,
|
||||
status: record.status,
|
||||
external_third_party: record.external_third_party.ok_or_else(|| anyhow!("missing external_third_party"))?,
|
||||
external_third_party_id: record.external_third_party_id.ok_or_else(|| anyhow!("missing external_third_party_id"))?,
|
||||
maybe_creator_user_token: record.maybe_creator_user_token,
|
||||
maybe_creator_anonymous_visitor_token: record.maybe_creator_anonymous_visitor_token,
|
||||
creator_ip_address: record.creator_ip_address,
|
||||
maybe_prompt_token: record.maybe_prompt_token,
|
||||
maybe_debug_log_event_token: record.maybe_debug_log_event_token,
|
||||
created_at: record.created_at,
|
||||
updated_at: record.updated_at,
|
||||
})
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
pub mod get_inference_job_by_beeble_id;
|
||||
pub mod insert_generic_inference_job_for_beeble_queue_with_apriori_job_token;
|
||||
|
||||
@@ -22,6 +22,7 @@ pub enum DebugLogType {
|
||||
FalRequest,
|
||||
KinoviRequest,
|
||||
FalWebhook,
|
||||
BeebleWebhook,
|
||||
}
|
||||
|
||||
impl_enum_display_and_debug_using_to_str!(DebugLogType);
|
||||
@@ -35,6 +36,7 @@ impl DebugLogType {
|
||||
Self::FalRequest => "fal_request",
|
||||
Self::KinoviRequest => "kinovi_request",
|
||||
Self::FalWebhook => "fal_webhook",
|
||||
Self::BeebleWebhook => "beeble_webhook",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +46,7 @@ impl DebugLogType {
|
||||
"fal_request" => Ok(Self::FalRequest),
|
||||
"kinovi_request" => Ok(Self::KinoviRequest),
|
||||
"fal_webhook" => Ok(Self::FalWebhook),
|
||||
"beeble_webhook" => Ok(Self::BeebleWebhook),
|
||||
_ => Err(EnumError::CouldNotConvertFromString(value.to_string())),
|
||||
}
|
||||
}
|
||||
@@ -54,6 +57,7 @@ impl DebugLogType {
|
||||
Self::FalRequest,
|
||||
Self::KinoviRequest,
|
||||
Self::FalWebhook,
|
||||
Self::BeebleWebhook,
|
||||
])
|
||||
}
|
||||
}
|
||||
@@ -74,6 +78,7 @@ mod tests {
|
||||
assert_serialization(DebugLogType::FalRequest, "fal_request");
|
||||
assert_serialization(DebugLogType::KinoviRequest, "kinovi_request");
|
||||
assert_serialization(DebugLogType::FalWebhook, "fal_webhook");
|
||||
assert_serialization(DebugLogType::BeebleWebhook, "beeble_webhook");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -82,6 +87,7 @@ mod tests {
|
||||
assert_eq!(DebugLogType::FalRequest.to_str(), "fal_request");
|
||||
assert_eq!(DebugLogType::KinoviRequest.to_str(), "kinovi_request");
|
||||
assert_eq!(DebugLogType::FalWebhook.to_str(), "fal_webhook");
|
||||
assert_eq!(DebugLogType::BeebleWebhook.to_str(), "beeble_webhook");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -90,6 +96,7 @@ mod tests {
|
||||
assert_eq!(DebugLogType::from_str("fal_request").unwrap(), DebugLogType::FalRequest);
|
||||
assert_eq!(DebugLogType::from_str("kinovi_request").unwrap(), DebugLogType::KinoviRequest);
|
||||
assert_eq!(DebugLogType::from_str("fal_webhook").unwrap(), DebugLogType::FalWebhook);
|
||||
assert_eq!(DebugLogType::from_str("beeble_webhook").unwrap(), DebugLogType::BeebleWebhook);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -106,11 +113,12 @@ mod tests {
|
||||
#[test]
|
||||
fn all_variants() {
|
||||
let mut variants = DebugLogType::all_variants();
|
||||
assert_eq!(variants.len(), 4);
|
||||
assert_eq!(variants.len(), 5);
|
||||
assert_eq!(variants.pop_first(), Some(DebugLogType::HttpRequest));
|
||||
assert_eq!(variants.pop_first(), Some(DebugLogType::FalRequest));
|
||||
assert_eq!(variants.pop_first(), Some(DebugLogType::KinoviRequest));
|
||||
assert_eq!(variants.pop_first(), Some(DebugLogType::FalWebhook));
|
||||
assert_eq!(variants.pop_first(), Some(DebugLogType::BeebleWebhook));
|
||||
assert_eq!(variants.pop_first(), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,8 @@ FAL_WEBHOOK_URL="https://brandons-macbook-pro.taild62114.ts.net/v1/webhooks/fal"
|
||||
#FAL_WEBHOOK_URL="https://halide.tailce84f.ts.net/v1/webhooks/fal"
|
||||
#FAL_WEBHOOK_URL="https://s1.pancake-chimaera.ts.net/api/fal/webhook"
|
||||
|
||||
BEEBLE_WEBHOOK_URL="https://brandons-macbook-pro.taild62114.ts.net/v1/webhooks/beeble"
|
||||
|
||||
STRIPE_ARTCRAFT_CHECKOUT_SUCCESS_URL="https://getartcraft.com/checkout_success"
|
||||
STRIPE_ARTCRAFT_CHECKOUT_CANCEL_URL="https://getartcraft.com/checkout_cancel"
|
||||
STRIPE_ARTCRAFT_PORTAL_RETURN_URL="https://getartcraft.com/portal_closed"
|
||||
|
||||
+1
-1
@@ -214,7 +214,7 @@ pub async fn beeble_switchx_edit_video_gen_handler(
|
||||
reference_image_uri: maybe_reference_beeble_uri,
|
||||
alpha_uri: None,
|
||||
max_resolution: Some(1080),
|
||||
callback_url: None,
|
||||
callback_url: Some(server_state.beeble.webhook_url.clone()),
|
||||
idempotency_key: Some(request.uuid_idempotency_token.clone()),
|
||||
},
|
||||
}).await.map_err(|err| {
|
||||
|
||||
+425
@@ -0,0 +1,425 @@
|
||||
use std::io::Write;
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::web::Bytes;
|
||||
use actix_web::web::Json;
|
||||
use actix_web::{web, HttpRequest};
|
||||
use log::{error, info, warn};
|
||||
use sqlx::pool::PoolConnection;
|
||||
use sqlx::MySql;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
use beeble_client::webhook_api::beeble_webhook_payload::{
|
||||
parse_beeble_webhook, BeebleWebhookPayload, BeebleWebhookStatus,
|
||||
};
|
||||
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
|
||||
use enums::by_table::debug_logs::debug_log_type::DebugLogType;
|
||||
use enums::by_table::generic_inference_jobs::frontend_failure_category::FrontendFailureCategory;
|
||||
use enums::by_table::media_files::media_file_class::MediaFileClass;
|
||||
use enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory;
|
||||
use enums::by_table::media_files::media_file_type::MediaFileType;
|
||||
use ffmpeg_utils::ffprobe::ffprobe_get_info::ffprobe_get_info;
|
||||
use filesys::path_to_string::path_to_string;
|
||||
use hashing::sha256::sha256_hash_bytes::sha256_hash_bytes;
|
||||
use http_server_common::response::response_success_helpers::SimpleGenericJsonSuccess;
|
||||
use mysql_queries::queries::debug_logs::insert_debug_log::{insert_debug_log, InsertDebugLogArgs};
|
||||
use mysql_queries::queries::generic_inference::beeble::get_inference_job_by_beeble_id::{
|
||||
get_inference_job_by_beeble_id_from_connection, BeebleJobDetails,
|
||||
};
|
||||
use mysql_queries::queries::generic_inference::fal::mark_fal_generic_inference_job_successfully_done::{
|
||||
mark_fal_generic_inference_job_successfully_done, MarkJobArgs,
|
||||
};
|
||||
use mysql_queries::queries::generic_inference::job::mark_job_failed_by_token::{
|
||||
mark_job_failed_by_token_from_connection, MarkJobFailedByTokenFromConnectionArgs,
|
||||
};
|
||||
use mysql_queries::queries::media_files::create::insert_builder::media_file_insert_builder::MediaFileInsertBuilder;
|
||||
use pager::notification::notification_details_builder::NotificationDetailsBuilder;
|
||||
use pager::notification::notification_urgency::NotificationUrgency;
|
||||
use thumbnail_generator::task_client::thumbnail_task::{ThumbnailTaskBuilder, ThumbnailTaskInputMimeType};
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
|
||||
use crate::http_server::endpoints::webhooks::fal::process_success::resolve_file_metadata::resolve_file_metadata;
|
||||
use crate::state::server_state::ServerState;
|
||||
use crate::util::http_download_url_to_bytes::http_download_url_to_bytes;
|
||||
|
||||
const VIDEO_BUCKET_PREFIX: Option<&str> = Some("artcraft_");
|
||||
|
||||
// =============== Handler ===============
|
||||
|
||||
pub async fn beeble_webhook_handler(
|
||||
http_request: HttpRequest,
|
||||
request_body_bytes: Bytes,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<SimpleGenericJsonSuccess>, AdvancedCommonWebError> {
|
||||
|
||||
// Step 1: Parse bytes into a UTF-8 string and log it.
|
||||
let raw_body = String::from_utf8(request_body_bytes.to_vec())
|
||||
.map_err(|err| {
|
||||
error!("Beeble webhook: could not decode request body to UTF-8: {:?}", err);
|
||||
enqueue_parse_error_alert(&server_state, &http_request, "UTF-8 decode failed", &err, None);
|
||||
AdvancedCommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
info!("Received Beeble webhook body: {}", raw_body);
|
||||
|
||||
// Step 2: Parse into BeebleWebhookPayload.
|
||||
let payload = parse_beeble_webhook(&raw_body)
|
||||
.map_err(|err| {
|
||||
error!("Beeble webhook: could not parse webhook payload: {:?}", err);
|
||||
enqueue_parse_error_alert(&server_state, &http_request, "JSON parse failed", &err, Some(&raw_body));
|
||||
AdvancedCommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let job_id = payload.id.as_str();
|
||||
|
||||
info!("Beeble webhook job_id: {} (status: {:?})", job_id, payload.status);
|
||||
|
||||
// Step 3: Acquire a MySQL connection.
|
||||
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
|
||||
|
||||
// Step 4: Look up the job by external third-party ID.
|
||||
let job = match get_inference_job_by_beeble_id_from_connection(job_id, &mut mysql_connection).await {
|
||||
Ok(Some(record)) => record,
|
||||
Ok(None) => {
|
||||
warn!("Could not find job record by Beeble job_id: {}", job_id);
|
||||
return Err(AdvancedCommonWebError::NotFound);
|
||||
}
|
||||
Err(err) => {
|
||||
error!("Error querying job record for Beeble job_id {}: {:?}", job_id, err);
|
||||
return Err(AdvancedCommonWebError::from_anyhow_error(err));
|
||||
}
|
||||
};
|
||||
|
||||
info!("Beeble webhook job record for job_id {}: {:?}", job_id, job);
|
||||
|
||||
// Insert debug log for the webhook payload.
|
||||
if let Some(debug_log_event_token) = &job.maybe_debug_log_event_token {
|
||||
if let Err(err) = insert_debug_log(InsertDebugLogArgs {
|
||||
apriori_debug_log_event_token: Some(debug_log_event_token),
|
||||
maybe_creator_user_token: job.maybe_creator_user_token.as_ref(),
|
||||
debug_log_type: DebugLogType::BeebleWebhook,
|
||||
message: &raw_body,
|
||||
mysql_executor: &mut *mysql_connection,
|
||||
phantom: Default::default(),
|
||||
}).await {
|
||||
warn!("Failed to insert Beeble webhook debug log: {:?}", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5: Branch on status.
|
||||
let result = match payload.status {
|
||||
BeebleWebhookStatus::Completed => {
|
||||
handle_completed(&server_state, &mut mysql_connection, job_id, &payload, &job).await
|
||||
}
|
||||
BeebleWebhookStatus::Failed => {
|
||||
handle_failed(&mut mysql_connection, job_id, &payload, &job).await
|
||||
}
|
||||
BeebleWebhookStatus::Unknown => {
|
||||
warn!("Beeble webhook received unknown status for job_id {}", job_id);
|
||||
Err(AdvancedCommonWebError::server_error_with_message(
|
||||
&format!("Beeble webhook received unknown status for job_id {}", job_id),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(ref err) = result {
|
||||
if err.is_server_error() {
|
||||
error!("Beeble webhook error for job_id {}: {:?}", job_id, err);
|
||||
|
||||
let description = format!(
|
||||
"Beeble webhook failed for job_id: {}\n\nError: {:?}\n\nWebhook JSON Payload: {}",
|
||||
job_id, err, raw_body,
|
||||
);
|
||||
|
||||
let mut builder = if let Some(cause_arc) = err.clone_cause_arc() {
|
||||
NotificationDetailsBuilder::from_error(cause_arc)
|
||||
.set_title(format!("Beeble webhook processing failed (job_id: {})", job_id))
|
||||
} else {
|
||||
NotificationDetailsBuilder::from_title(
|
||||
format!("Beeble webhook processing failed (job_id: {})", job_id)
|
||||
)
|
||||
};
|
||||
|
||||
builder = builder
|
||||
.set_description(Some(description))
|
||||
.set_third_party_id(Some(job_id.to_string()))
|
||||
.set_urgency(Some(NotificationUrgency::High))
|
||||
.set_http_method(Some(http_request.method().to_string()))
|
||||
.set_http_path(Some(http_request.path().to_string()));
|
||||
|
||||
if let Err(pager_err) = server_state.pager.enqueue_page(builder.build()) {
|
||||
error!("Failed to enqueue Beeble webhook pager alert: {:?}", pager_err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
// =============== Private helpers ===============
|
||||
|
||||
async fn handle_completed(
|
||||
server_state: &ServerState,
|
||||
mysql_connection: &mut PoolConnection<MySql>,
|
||||
job_id: &str,
|
||||
payload: &BeebleWebhookPayload,
|
||||
job: &BeebleJobDetails,
|
||||
) -> Result<Json<SimpleGenericJsonSuccess>, AdvancedCommonWebError> {
|
||||
let output = payload.output.as_ref().ok_or_else(|| {
|
||||
warn!("Beeble webhook completed but no output for job_id {}", job_id);
|
||||
AdvancedCommonWebError::server_error_with_message("Beeble webhook completed but output is missing")
|
||||
})?;
|
||||
|
||||
let render_url = output.render.as_deref().ok_or_else(|| {
|
||||
warn!("Beeble webhook completed but no render URL for job_id {}", job_id);
|
||||
AdvancedCommonWebError::server_error_with_message("Beeble webhook completed but render URL is missing")
|
||||
})?;
|
||||
|
||||
info!("Downloading Beeble render video from {} for job_id {}", render_url, job_id);
|
||||
|
||||
// Download and process the primary render video.
|
||||
let render_media_token = download_and_upload_video(render_url, job, server_state).await?;
|
||||
|
||||
info!("Render video uploaded with token {} for job_id {}", render_media_token, job_id);
|
||||
|
||||
// Download and process the alpha video if present.
|
||||
if let Some(ref alpha_url) = output.alpha {
|
||||
info!("Downloading Beeble alpha video from {} for job_id {}", alpha_url, job_id);
|
||||
match download_and_upload_video(alpha_url, job, server_state).await {
|
||||
Ok(alpha_token) => {
|
||||
info!("Alpha video uploaded with token {} for job_id {}", alpha_token, job_id);
|
||||
}
|
||||
Err(err) => {
|
||||
// Fail open for alpha — the render is the primary artifact.
|
||||
warn!("Failed to process alpha video for job_id {}: {:?}", job_id, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mark the job as successfully done with the render video's media token.
|
||||
mark_fal_generic_inference_job_successfully_done(MarkJobArgs {
|
||||
job_token: &job.job_token,
|
||||
media_file_token: &render_media_token,
|
||||
maybe_batch_token: None,
|
||||
mysql_executor: &mut **mysql_connection,
|
||||
phantom: Default::default(),
|
||||
}).await.map_err(|err| {
|
||||
warn!("Error marking job as successfully done for job_id {}: {:?}", job_id, err);
|
||||
AdvancedCommonWebError::from_anyhow_error(err)
|
||||
})?;
|
||||
|
||||
info!("Job {} marked as successfully done for Beeble job_id {}", job.job_token, job_id);
|
||||
|
||||
Ok(SimpleGenericJsonSuccess::wrapped(true))
|
||||
}
|
||||
|
||||
async fn handle_failed(
|
||||
mysql_connection: &mut PoolConnection<MySql>,
|
||||
job_id: &str,
|
||||
payload: &BeebleWebhookPayload,
|
||||
job: &BeebleJobDetails,
|
||||
) -> Result<Json<SimpleGenericJsonSuccess>, AdvancedCommonWebError> {
|
||||
let public_failure_reason = payload.error
|
||||
.as_deref()
|
||||
.unwrap_or("Unknown Beeble error")
|
||||
.to_string();
|
||||
|
||||
let internal_failure_reason = format!(
|
||||
"Beeble job_id={}, error={:?}",
|
||||
job_id,
|
||||
payload.error,
|
||||
);
|
||||
|
||||
info!(
|
||||
"Marking job {} as failed for Beeble job_id {}. Reason: {}",
|
||||
job.job_token.as_str(),
|
||||
job_id,
|
||||
public_failure_reason,
|
||||
);
|
||||
|
||||
if let Err(err) = mark_job_failed_by_token_from_connection(MarkJobFailedByTokenFromConnectionArgs {
|
||||
mysql_connection,
|
||||
job_token: &job.job_token,
|
||||
maybe_public_failure_reason: Some(&public_failure_reason),
|
||||
internal_debugging_failure_reason: &internal_failure_reason,
|
||||
maybe_frontend_failure_category: Some(FrontendFailureCategory::GenerationFailed),
|
||||
}).await {
|
||||
error!(
|
||||
"Error marking job {} as failed for Beeble job_id {}: {:?}",
|
||||
job.job_token.as_str(),
|
||||
job_id,
|
||||
err,
|
||||
);
|
||||
return Err(AdvancedCommonWebError::from_anyhow_error(err));
|
||||
}
|
||||
|
||||
info!(
|
||||
"Job {} marked as failed for Beeble job_id {}.",
|
||||
job.job_token.as_str(),
|
||||
job_id,
|
||||
);
|
||||
|
||||
Ok(SimpleGenericJsonSuccess::wrapped(true))
|
||||
}
|
||||
|
||||
async fn download_and_upload_video(
|
||||
video_url: &str,
|
||||
job: &BeebleJobDetails,
|
||||
server_state: &ServerState,
|
||||
) -> Result<MediaFileToken, AdvancedCommonWebError> {
|
||||
// Download with a retry if the first attempt returns suspiciously few bytes.
|
||||
let mut file_bytes = http_download_url_to_bytes(video_url)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
warn!("Failed to download video from {}: {:?}", video_url, err);
|
||||
AdvancedCommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
if file_bytes.len() <= 10 {
|
||||
warn!(
|
||||
"Downloaded only {} bytes from {} — retrying once",
|
||||
file_bytes.len(),
|
||||
video_url,
|
||||
);
|
||||
file_bytes = http_download_url_to_bytes(video_url)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
warn!("Failed to download video on retry from {}: {:?}", video_url, err);
|
||||
AdvancedCommonWebError::from_error(err)
|
||||
})?;
|
||||
}
|
||||
|
||||
// Resolve mime type from magic bytes, with no fallback content_type from Beeble.
|
||||
let metadata = resolve_file_metadata(&file_bytes, None)
|
||||
.ok_or_else(|| {
|
||||
warn!(
|
||||
"Could not determine file type for Beeble video (bytes: {})",
|
||||
file_bytes.len(),
|
||||
);
|
||||
AdvancedCommonWebError::server_error_with_message(
|
||||
&format!("Could not determine file type for Beeble video (bytes: {})", file_bytes.len()))
|
||||
})?;
|
||||
|
||||
info!("File type: {}, extension: {:?}, source: {:?}",
|
||||
metadata.mime_type, metadata.file_extension, metadata.source);
|
||||
|
||||
let mime_type = metadata.mime_type.as_str();
|
||||
|
||||
let media_file_type = MediaFileType::try_from_mime_type(mime_type)
|
||||
.ok_or_else(|| {
|
||||
warn!("Unsupported media file type: {}", mime_type);
|
||||
AdvancedCommonWebError::server_error_with_message(
|
||||
&format!("Unsupported media file type: {}", mime_type))
|
||||
})?;
|
||||
|
||||
let extension_with_period = metadata.file_extension.extension_with_period();
|
||||
|
||||
let file_size_bytes = file_bytes.len();
|
||||
let file_hash = sha256_hash_bytes(&file_bytes)
|
||||
.map_err(|e| {
|
||||
warn!("Failed to hash video bytes: {:?}", e);
|
||||
AdvancedCommonWebError::from_anyhow_error(e)
|
||||
})?;
|
||||
|
||||
let mut maybe_duration_millis = None;
|
||||
let mut maybe_frame_width = None;
|
||||
let mut maybe_frame_height = None;
|
||||
|
||||
if let Ok(mut file) = NamedTempFile::new() {
|
||||
let _r = file.write_all(&file_bytes);
|
||||
|
||||
if let Ok(video_info) = ffprobe_get_info(&file.path()) {
|
||||
maybe_duration_millis = video_info.duration
|
||||
.as_ref()
|
||||
.map(|duration| duration.millis as u64);
|
||||
|
||||
maybe_frame_width = video_info.dimensions
|
||||
.as_ref()
|
||||
.map(|dims| dims.width as u32);
|
||||
|
||||
maybe_frame_height = video_info.dimensions
|
||||
.as_ref()
|
||||
.map(|dims| dims.height as u32);
|
||||
}
|
||||
}
|
||||
|
||||
let public_upload_path = MediaFileBucketPath::generate_new(VIDEO_BUCKET_PREFIX, Some(&extension_with_period));
|
||||
|
||||
info!("Uploading media to bucket path: {}", public_upload_path.get_full_object_path_str());
|
||||
|
||||
server_state.public_bucket_client.upload_file_with_content_type_process(
|
||||
public_upload_path.get_full_object_path_str(),
|
||||
file_bytes.as_ref(),
|
||||
mime_type)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!("Failed to upload video to bucket: {:?}", e);
|
||||
AdvancedCommonWebError::from_anyhow_error(e)
|
||||
})?;
|
||||
|
||||
let media_token = MediaFileInsertBuilder::new()
|
||||
.maybe_creator_user(job.maybe_creator_user_token.as_ref())
|
||||
.maybe_creator_anonymous_visitor(job.maybe_creator_anonymous_visitor_token.as_ref())
|
||||
.creator_ip_address(&job.creator_ip_address)
|
||||
.public_bucket_directory_hash(&public_upload_path)
|
||||
.media_file_class(MediaFileClass::Video)
|
||||
.media_file_type(media_file_type)
|
||||
.media_file_origin_category(MediaFileOriginCategory::Inference)
|
||||
.mime_type(mime_type)
|
||||
.file_size_bytes(file_size_bytes as u64)
|
||||
.maybe_frame_width(maybe_frame_width)
|
||||
.maybe_frame_height(maybe_frame_height)
|
||||
.maybe_duration_millis(maybe_duration_millis)
|
||||
.checksum_sha2(&file_hash)
|
||||
.maybe_prompt_token(job.maybe_prompt_token.as_ref())
|
||||
.insert_pool(&server_state.mysql_pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!("Failed to insert video media file record: {:?}", e);
|
||||
AdvancedCommonWebError::from_error(e)
|
||||
})?;
|
||||
|
||||
info!("Video media file uploaded with token: {}", media_token);
|
||||
|
||||
// let thumbnail_task_result =
|
||||
// ThumbnailTaskBuilder::new_for_source_mimetype(ThumbnailTaskInputMimeType::MP4)
|
||||
// .with_bucket(server_state.public_bucket_client.bucket_name().as_str())
|
||||
// .with_path(&*path_to_string(public_upload_path.to_full_object_pathbuf()))
|
||||
// .with_output_suffix("thumb")
|
||||
// .with_event_id(&media_token.to_string())
|
||||
// .send_all()
|
||||
// .await;
|
||||
// if let Err(err) = thumbnail_task_result {
|
||||
// // Fail open
|
||||
// error!("Failed to create some/all thumbnail tasks: {:?}", err);
|
||||
// }
|
||||
|
||||
Ok(media_token)
|
||||
}
|
||||
|
||||
/// Send a pager alert for early parse failures (before we have a job_id).
|
||||
fn enqueue_parse_error_alert<E: std::fmt::Debug>(
|
||||
server_state: &ServerState,
|
||||
http_request: &HttpRequest,
|
||||
context: &str,
|
||||
err: &E,
|
||||
maybe_raw_body: Option<&str>,
|
||||
) {
|
||||
let description = match maybe_raw_body {
|
||||
Some(body) => format!("Error: {:?}\n\nBeeble Webhook JSON Payload: {}", err, body),
|
||||
None => format!("Beeble Error: {:?}", err),
|
||||
};
|
||||
|
||||
let notification = NotificationDetailsBuilder::from_title(
|
||||
format!("Beeble webhook parse failure: {}", context))
|
||||
.set_description(Some(description))
|
||||
.set_urgency(Some(NotificationUrgency::High))
|
||||
.set_http_method(Some(http_request.method().to_string()))
|
||||
.set_http_path(Some(http_request.path().to_string()))
|
||||
.build();
|
||||
|
||||
if let Err(pager_err) = server_state.pager.enqueue_page(notification) {
|
||||
error!("Failed to enqueue Beeble webhook parse error alert: {:?}", pager_err);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod beeble_webhook_handler;
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
|
||||
use crate::http_server::endpoints::webhooks::process_failure::handle_failed_fal_webhook::handle_failed_fal_webhook;
|
||||
use crate::http_server::endpoints::webhooks::process_success::handle_successful_fal_webhook::handle_successful_fal_webhook;
|
||||
use crate::http_server::endpoints::webhooks::fal::process_failure::handle_failed_fal_webhook::handle_failed_fal_webhook;
|
||||
use crate::http_server::endpoints::webhooks::fal::process_success::handle_successful_fal_webhook::handle_successful_fal_webhook;
|
||||
use crate::state::server_state::ServerState;
|
||||
use actix_web::web::Bytes;
|
||||
use actix_web::web::Json;
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod fal_webhook_handler;
|
||||
pub(super) mod process_failure;
|
||||
pub(super) mod process_success;
|
||||
+1
-1
@@ -4,4 +4,4 @@ mod process_images_payload;
|
||||
mod process_model_glb_payload;
|
||||
mod process_model_mesh_payload;
|
||||
mod process_video_payload;
|
||||
mod resolve_file_metadata;
|
||||
pub(in crate::http_server::endpoints::webhooks) mod resolve_file_metadata;
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
|
||||
use crate::http_server::endpoints::webhooks::process_success::resolve_file_metadata::resolve_file_metadata;
|
||||
use crate::http_server::endpoints::webhooks::fal::process_success::resolve_file_metadata::resolve_file_metadata;
|
||||
use crate::state::server_state::ServerState;
|
||||
use crate::util::http_download_url_to_bytes::http_download_url_to_bytes;
|
||||
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
|
||||
use crate::http_server::endpoints::webhooks::process_success::resolve_file_metadata::resolve_file_metadata;
|
||||
use crate::http_server::endpoints::webhooks::fal::process_success::resolve_file_metadata::resolve_file_metadata;
|
||||
use crate::state::server_state::ServerState;
|
||||
use crate::util::http_download_url_to_bytes::http_download_url_to_bytes;
|
||||
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
|
||||
use crate::http_server::endpoints::webhooks::process_success::resolve_file_metadata::resolve_file_metadata;
|
||||
use crate::http_server::endpoints::webhooks::fal::process_success::resolve_file_metadata::resolve_file_metadata;
|
||||
use crate::state::server_state::ServerState;
|
||||
use crate::util::http_download_url_to_bytes::http_download_url_to_bytes;
|
||||
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
|
||||
use crate::http_server::endpoints::webhooks::process_success::resolve_file_metadata::resolve_file_metadata;
|
||||
use crate::http_server::endpoints::webhooks::fal::process_success::resolve_file_metadata::resolve_file_metadata;
|
||||
use crate::state::server_state::ServerState;
|
||||
use crate::util::http_download_url_to_bytes::http_download_url_to_bytes;
|
||||
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
|
||||
use crate::http_server::endpoints::webhooks::process_success::resolve_file_metadata::resolve_file_metadata;
|
||||
use crate::http_server::endpoints::webhooks::fal::process_success::resolve_file_metadata::resolve_file_metadata;
|
||||
use crate::state::server_state::ServerState;
|
||||
use crate::util::http_download_url_to_bytes::http_download_url_to_bytes;
|
||||
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
|
||||
@@ -1,3 +1,2 @@
|
||||
pub mod fal_webhook_handler;
|
||||
pub (super) mod process_failure;
|
||||
pub (super) mod process_success;
|
||||
pub mod beeble;
|
||||
pub mod fal;
|
||||
|
||||
+6
-1
@@ -1,4 +1,5 @@
|
||||
use crate::http_server::endpoints::webhooks::fal_webhook_handler::fal_webhook_handler;
|
||||
use crate::http_server::endpoints::webhooks::beeble::beeble_webhook_handler::beeble_webhook_handler;
|
||||
use crate::http_server::endpoints::webhooks::fal::fal_webhook_handler::fal_webhook_handler;
|
||||
use actix_http::body::MessageBody;
|
||||
use actix_service::ServiceFactory;
|
||||
use actix_web::dev::{ServiceRequest, ServiceResponse};
|
||||
@@ -20,5 +21,9 @@ where
|
||||
.route(web::post().to(fal_webhook_handler))
|
||||
.route(web::head().to(|| HttpResponse::Ok()))
|
||||
)
|
||||
.service(web::resource("/beeble")
|
||||
.route(web::post().to(beeble_webhook_handler))
|
||||
.route(web::head().to(|| HttpResponse::Ok()))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -479,6 +479,7 @@ async fn main() -> AnyhowResult<()> {
|
||||
api_key: beeble_client::creds::beeble_api_key::BeebleApiKey::new(
|
||||
easyenv::get_env_string_required("BEEBLE_API_KEY")?,
|
||||
),
|
||||
webhook_url: easyenv::get_env_string_required("BEEBLE_WEBHOOK_URL")?,
|
||||
},
|
||||
fal: FalData {
|
||||
api_key: fal_api_key,
|
||||
|
||||
@@ -328,6 +328,7 @@ pub struct FalData {
|
||||
#[derive(Clone)]
|
||||
pub struct BeebleData {
|
||||
pub api_key: BeebleApiKey,
|
||||
pub webhook_url: String,
|
||||
}
|
||||
|
||||
/// Seedance 2 Pro integration
|
||||
|
||||
Reference in New Issue
Block a user