diff --git a/.sqlx/query-31d85cc0013f29e6db2d272ee6e709cca79b46496a2c222960f1a1419175f00b.json b/.sqlx/query-31d85cc0013f29e6db2d272ee6e709cca79b46496a2c222960f1a1419175f00b.json new file mode 100644 index 0000000000..6d81f07e58 --- /dev/null +++ b/.sqlx/query-31d85cc0013f29e6db2d272ee6e709cca79b46496a2c222960f1a1419175f00b.json @@ -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" +} diff --git a/_tools/postman/collections/ArtCraft API/generate/video/edit/.resources/definition.yaml b/_tools/postman/collections/ArtCraft API/generate/video/edit/.resources/definition.yaml new file mode 100644 index 0000000000..974de54bef --- /dev/null +++ b/_tools/postman/collections/ArtCraft API/generate/video/edit/.resources/definition.yaml @@ -0,0 +1,3 @@ +$kind: collection +name: edit +order: 312 diff --git a/_tools/postman/collections/ArtCraft API/generate/video/edit/Beeble SwitchX Edit Video.request.yaml b/_tools/postman/collections/ArtCraft API/generate/video/edit/Beeble SwitchX Edit Video.request.yaml new file mode 100644 index 0000000000..6556711b2a --- /dev/null +++ b/_tools/postman/collections/ArtCraft API/generate/video/edit/Beeble SwitchX Edit Video.request.yaml @@ -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 diff --git a/_tools/postman/collections/ArtCraft API/users/Login.request.yaml b/_tools/postman/collections/ArtCraft API/users/Login.request.yaml index 7a4fbcc6f9..7865c12e54 100644 --- a/_tools/postman/collections/ArtCraft API/users/Login.request.yaml +++ b/_tools/postman/collections/ArtCraft API/users/Login.request.yaml @@ -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 diff --git a/crates/api_clients/beeble_client/src/error/beeble_specific_api_error.rs b/crates/api_clients/beeble_client/src/error/beeble_specific_api_error.rs index aa422092f7..b94c513b5a 100644 --- a/crates/api_clients/beeble_client/src/error/beeble_specific_api_error.rs +++ b/crates/api_clients/beeble_client/src/error/beeble_specific_api_error.rs @@ -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) + } } } } diff --git a/crates/api_clients/beeble_client/src/lib.rs b/crates/api_clients/beeble_client/src/lib.rs index e9c19b8190..5b3f02f548 100644 --- a/crates/api_clients/beeble_client/src/lib.rs +++ b/crates/api_clients/beeble_client/src/lib.rs @@ -5,3 +5,4 @@ pub(crate) mod test_utils; pub mod creds; pub mod error; pub mod requests; +pub mod webhook_api; diff --git a/crates/api_clients/beeble_client/src/requests/get_job_status/get_job_status.rs b/crates/api_clients/beeble_client/src/requests/get_job_status/get_job_status.rs index 169cd4ba21..c72d59de79 100644 --- a/crates/api_clients/beeble_client/src/requests/get_job_status/get_job_status.rs +++ b/crates/api_clients/beeble_client/src/requests/get_job_status/get_job_status.rs @@ -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 diff --git a/crates/api_clients/beeble_client/src/requests/start_generation/handle_error_response.rs b/crates/api_clients/beeble_client/src/requests/start_generation/handle_error_response.rs new file mode 100644 index 0000000000..737bd0f634 --- /dev/null +++ b/crates/api_clients/beeble_client/src/requests/start_generation/handle_error_response.rs @@ -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( + status: StatusCode, + response_body: &str, + context: &ErrorContext, +) -> Result { + 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 { + let parsed: serde_json::Value = serde_json::from_str(body).ok()?; + parsed.get("error")?.get("message")?.as_str().map(|s| s.to_string()) +} diff --git a/crates/api_clients/beeble_client/src/requests/start_generation/mod.rs b/crates/api_clients/beeble_client/src/requests/start_generation/mod.rs index a56cdcc495..47b573c7a4 100644 --- a/crates/api_clients/beeble_client/src/requests/start_generation/mod.rs +++ b/crates/api_clients/beeble_client/src/requests/start_generation/mod.rs @@ -1,2 +1,3 @@ -pub mod start_generation; +pub(crate) mod handle_error_response; pub(crate) mod request_types; +pub mod start_generation; diff --git a/crates/api_clients/beeble_client/src/requests/start_generation/start_generation.rs b/crates/api_clients/beeble_client/src/requests/start_generation/start_generation.rs index 2573965d13..25ea435002 100644 --- a/crates/api_clients/beeble_client/src/requests/start_generation/start_generation.rs +++ b/crates/api_clients/beeble_client/src/requests/start_generation/start_generation.rs @@ -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 Result 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) diff --git a/crates/api_clients/beeble_client/src/webhook_api/beeble_webhook_payload.rs b/crates/api_clients/beeble_client/src/webhook_api/beeble_webhook_payload.rs new file mode 100644 index 0000000000..e6c155330c --- /dev/null +++ b/crates/api_clients/beeble_client/src/webhook_api/beeble_webhook_payload.rs @@ -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, + pub error: Option, + pub completed_at: Option, +} + +#[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, + pub source: Option, + pub alpha: Option, +} + +pub fn parse_beeble_webhook(raw_body: &str) -> Result { + 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()); + } + } +} diff --git a/crates/api_clients/beeble_client/src/webhook_api/mod.rs b/crates/api_clients/beeble_client/src/webhook_api/mod.rs new file mode 100644 index 0000000000..51fc117576 --- /dev/null +++ b/crates/api_clients/beeble_client/src/webhook_api/mod.rs @@ -0,0 +1 @@ +pub mod beeble_webhook_payload; diff --git a/crates/schema/database/mysql_queries/src/queries/generic_inference/beeble/get_inference_job_by_beeble_id.rs b/crates/schema/database/mysql_queries/src/queries/generic_inference/beeble/get_inference_job_by_beeble_id.rs new file mode 100644 index 0000000000..7aac61409d --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/generic_inference/beeble/get_inference_job_by_beeble_id.rs @@ -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, + pub maybe_creator_anonymous_visitor_token: Option, + pub creator_ip_address: String, + + pub maybe_prompt_token: Option, + pub maybe_debug_log_event_token: Option, + + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Default)] +struct RawJobRecord { + job_token: InferenceJobToken, + + status: JobStatusPlus, + + // NB: Nullable, but required to be filled for this query + external_third_party: Option, + + // NB: Nullable, but required to be filled for this query + external_third_party_id: Option, + + maybe_creator_user_token: Option, + maybe_creator_anonymous_visitor_token: Option, + creator_ip_address: String, + + maybe_prompt_token: Option, + maybe_debug_log_event_token: Option, + + created_at: DateTime, + updated_at: DateTime, +} + +/// 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> +{ + 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) + -> AnyhowResult> +{ + 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 { + 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, + }) +} diff --git a/crates/schema/database/mysql_queries/src/queries/generic_inference/beeble/mod.rs b/crates/schema/database/mysql_queries/src/queries/generic_inference/beeble/mod.rs index 8e22648705..88b852cf5f 100644 --- a/crates/schema/database/mysql_queries/src/queries/generic_inference/beeble/mod.rs +++ b/crates/schema/database/mysql_queries/src/queries/generic_inference/beeble/mod.rs @@ -1 +1,2 @@ +pub mod get_inference_job_by_beeble_id; pub mod insert_generic_inference_job_for_beeble_queue_with_apriori_job_token; diff --git a/crates/schema/public/enums/src/by_table/debug_logs/debug_log_type.rs b/crates/schema/public/enums/src/by_table/debug_logs/debug_log_type.rs index ff488b446f..cd8ec21f8b 100644 --- a/crates/schema/public/enums/src/by_table/debug_logs/debug_log_type.rs +++ b/crates/schema/public/enums/src/by_table/debug_logs/debug_log_type.rs @@ -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); } } diff --git a/crates/service/web/storyteller_web/config/storyteller-web.development.env b/crates/service/web/storyteller_web/config/storyteller-web.development.env index 7ac97ccd2f..e8b3318f5e 100644 --- a/crates/service/web/storyteller_web/config/storyteller-web.development.env +++ b/crates/service/web/storyteller_web/config/storyteller-web.development.env @@ -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" diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/edit/beeble_switchx_edit_video_gen_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/edit/beeble_switchx_edit_video_gen_handler.rs index b7a4b716cc..c9e32c39aa 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/edit/beeble_switchx_edit_video_gen_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/edit/beeble_switchx_edit_video_gen_handler.rs @@ -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| { diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/beeble/beeble_webhook_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/beeble/beeble_webhook_handler.rs new file mode 100644 index 0000000000..2d2d8e4399 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/beeble/beeble_webhook_handler.rs @@ -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>, +) -> Result, 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, + job_id: &str, + payload: &BeebleWebhookPayload, + job: &BeebleJobDetails, +) -> Result, 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, + job_id: &str, + payload: &BeebleWebhookPayload, + job: &BeebleJobDetails, +) -> Result, 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 { + // 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( + 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); + } +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/beeble/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/beeble/mod.rs new file mode 100644 index 0000000000..3452f881cb --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/beeble/mod.rs @@ -0,0 +1 @@ +pub mod beeble_webhook_handler; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal_webhook_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/fal_webhook_handler.rs similarity index 95% rename from crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal_webhook_handler.rs rename to crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/fal_webhook_handler.rs index dd7ee4c683..81d0a98426 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal_webhook_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/fal_webhook_handler.rs @@ -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; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/mod.rs new file mode 100644 index 0000000000..98b875c6d9 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/mod.rs @@ -0,0 +1,3 @@ +pub mod fal_webhook_handler; +pub(super) mod process_failure; +pub(super) mod process_success; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/process_failure/handle_failed_fal_webhook.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/process_failure/handle_failed_fal_webhook.rs similarity index 100% rename from crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/process_failure/handle_failed_fal_webhook.rs rename to crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/process_failure/handle_failed_fal_webhook.rs diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/process_failure/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/process_failure/mod.rs similarity index 100% rename from crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/process_failure/mod.rs rename to crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/process_failure/mod.rs diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/process_success/handle_successful_fal_webhook.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/process_success/handle_successful_fal_webhook.rs similarity index 100% rename from crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/process_success/handle_successful_fal_webhook.rs rename to crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/process_success/handle_successful_fal_webhook.rs diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/process_success/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/process_success/mod.rs similarity index 71% rename from crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/process_success/mod.rs rename to crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/process_success/mod.rs index 0ddb0f004c..5e9447188b 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/process_success/mod.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/process_success/mod.rs @@ -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; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/process_success/process_image_payload.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/process_success/process_image_payload.rs similarity index 98% rename from crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/process_success/process_image_payload.rs rename to crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/process_success/process_image_payload.rs index a7d0e3f637..db9b252347 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/process_success/process_image_payload.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/process_success/process_image_payload.rs @@ -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; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/process_success/process_images_payload.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/process_success/process_images_payload.rs similarity index 99% rename from crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/process_success/process_images_payload.rs rename to crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/process_success/process_images_payload.rs index 25362ea607..0b2e0b7f8d 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/process_success/process_images_payload.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/process_success/process_images_payload.rs @@ -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; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/process_success/process_model_glb_payload.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/process_success/process_model_glb_payload.rs similarity index 99% rename from crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/process_success/process_model_glb_payload.rs rename to crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/process_success/process_model_glb_payload.rs index aba0e58fb4..420c89859c 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/process_success/process_model_glb_payload.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/process_success/process_model_glb_payload.rs @@ -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; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/process_success/process_model_mesh_payload.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/process_success/process_model_mesh_payload.rs similarity index 97% rename from crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/process_success/process_model_mesh_payload.rs rename to crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/process_success/process_model_mesh_payload.rs index efb18b9a02..1702820ac5 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/process_success/process_model_mesh_payload.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/process_success/process_model_mesh_payload.rs @@ -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; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/process_success/process_video_payload.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/process_success/process_video_payload.rs similarity index 98% rename from crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/process_success/process_video_payload.rs rename to crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/process_success/process_video_payload.rs index 2ea2987639..498786ae3b 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/process_success/process_video_payload.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/process_success/process_video_payload.rs @@ -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; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/process_success/resolve_file_metadata.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/process_success/resolve_file_metadata.rs similarity index 100% rename from crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/process_success/resolve_file_metadata.rs rename to crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/process_success/resolve_file_metadata.rs diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/mod.rs index ddd024c0f7..70d0205381 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/mod.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/mod.rs @@ -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; diff --git a/crates/service/web/storyteller_web/src/http_server/routes/application_routes/webhook_routes.rs b/crates/service/web/storyteller_web/src/http_server/routes/application_routes/webhook_routes.rs index 1e07afc636..beaddc8d6d 100644 --- a/crates/service/web/storyteller_web/src/http_server/routes/application_routes/webhook_routes.rs +++ b/crates/service/web/storyteller_web/src/http_server/routes/application_routes/webhook_routes.rs @@ -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())) + ) ) } diff --git a/crates/service/web/storyteller_web/src/main.rs b/crates/service/web/storyteller_web/src/main.rs index ad09789439..b04675f1ce 100644 --- a/crates/service/web/storyteller_web/src/main.rs +++ b/crates/service/web/storyteller_web/src/main.rs @@ -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, diff --git a/crates/service/web/storyteller_web/src/state/server_state.rs b/crates/service/web/storyteller_web/src/state/server_state.rs index 75bc0d675e..f2babed120 100644 --- a/crates/service/web/storyteller_web/src/state/server_state.rs +++ b/crates/service/web/storyteller_web/src/state/server_state.rs @@ -328,6 +328,7 @@ pub struct FalData { #[derive(Clone)] pub struct BeebleData { pub api_key: BeebleApiKey, + pub webhook_url: String, } /// Seedance 2 Pro integration