Compare commits

...
Author SHA1 Message Date
Brandon Thomas 091579e9bb reuse thumbnail data 2026-04-12 02:00:21 -04:00
Brandon Thomas fa7a0c3200 thumbnail extractor 2026-04-12 01:52:28 -04:00
Brandon Thomas 422eabb616 wip 2026-04-12 01:47:51 -04:00
Brandon Thomas 0c810cfadb refactor to use new extractors 2026-04-12 01:30:24 -04:00
Brandon Thomas 7bf757fdf1 use new fields (almost) 2026-04-11 23:52:48 -04:00
Brandon Thomas b2947f69ce typed extractors 2026-04-11 22:30:56 -04:00
Brandon Thomas c5e9cf6bcf reoganize 2026-04-11 21:43:11 -04:00
Brandon Thomas d02ce726c6 refactor 2026-04-11 21:19:51 -04:00
Brandon Thomas eb8301ab43 extract payloads 2026-04-11 20:37:11 -04:00
Brandon Thomas b8895fbae9 begin to extract contents 2026-04-11 20:19:54 -04:00
Brandon Thomas ebbc9af5ed comment 2026-04-11 20:03:30 -04:00
Brandon Thomas 7879d682ab cleanup 2026-04-11 19:59:30 -04:00
31 changed files with 971 additions and 259 deletions
@@ -1,36 +1,44 @@
use serde_json::Value;
use crate::webhook_api::payload::webhook_error_type::WebhookErrorType;
use crate::webhook_api::payload::webhook_inner_payload::{ErrorData, PayloadErrorData, SuccessData, WebhookInnerPayload};
use crate::webhook_api::payload::webhook_payload::{WebhookPayload, WebhookStatus};
use crate::webhook_api::raw::webhook_error_type::WebhookErrorType;
use crate::webhook_api::hydrated::hydrated_webhook_contents::{ErrorData, PayloadErrorData, WebhookSuccessData, HydratedWebhookContents};
use crate::webhook_api::raw::raw_webhook_payload::{RawWebhookPayload, RawWebhookStatus};
use crate::webhook_api::hydrated::success_case_extractors::extract_contents_from_payload;
/// Parse the inner payload of a FAL webhook into one of three cases.
///
/// 1. If status is ERROR, parse out the first `detail` entry's `msg` and `type`.
/// 2. If status is OK but there's no payload and there is a `payload_error`, return PayloadError.
/// 3. Otherwise, return Success with the payload.
pub fn parse_webhook_inner_payload(webhook: &WebhookPayload) -> WebhookInnerPayload {
pub fn hydrate_webhook_contents(webhook: &RawWebhookPayload) -> HydratedWebhookContents {
match webhook.status {
WebhookStatus::Error => {
RawWebhookStatus::Error => {
let (message, error_type) = extract_error_first_detail(&webhook.payload);
WebhookInnerPayload::Error(ErrorData {
HydratedWebhookContents::Error(ErrorData {
message,
error_type,
})
}
WebhookStatus::Ok => {
// Check for payload_error case: status=OK but no payload, has payload_error.
RawWebhookStatus::Ok => {
// Check for `payload_error` case: status=OK but no payload, has payload_error.
if webhook.payload.is_none() {
if let Some(ref payload_error) = webhook.payload_error {
return WebhookInnerPayload::PayloadError(PayloadErrorData {
return HydratedWebhookContents::PayloadError(PayloadErrorData {
payload_error: payload_error.clone(),
});
}
}
WebhookInnerPayload::Success(SuccessData {
payload: webhook.payload.clone().unwrap_or(Value::Null),
let value = webhook.payload
.clone()
.unwrap_or(Value::Null);
let extracted_contents = extract_contents_from_payload(&value);
HydratedWebhookContents::Success(WebhookSuccessData {
payload: value,
extracted_contents,
})
}
}
@@ -67,7 +75,7 @@ fn extract_error_first_detail(payload: &Option<Value>) -> (Option<String>, Optio
mod tests {
use super::*;
fn load_test_webhook(filename: &str) -> WebhookPayload {
fn load_test_webhook(filename: &str) -> RawWebhookPayload {
let path = format!("test_data/webhooks/{}", filename);
let json = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("Failed to read {}: {}", path, e));
@@ -78,10 +86,10 @@ mod tests {
#[test]
fn gpt_image_1p5_content_policy_violation() {
let webhook = load_test_webhook("gpt_image_1p5_fail_content_policy.json");
let result = parse_webhook_inner_payload(&webhook);
let result = hydrate_webhook_contents(&webhook);
match result {
WebhookInnerPayload::Error(data) => {
HydratedWebhookContents::Error(data) => {
assert_eq!(
data.error_type,
Some(WebhookErrorType::ContentPolicyViolation),
@@ -91,17 +99,17 @@ mod tests {
Some("The content could not be processed because it contained material flagged by a content checker."),
);
}
other => panic!("Expected WebhookInnerPayload::Error, got {:?}", other),
other => panic!("Expected HydratedWebhookContents::Error, got {:?}", other),
}
}
#[test]
fn gpt_image_invalid_api_key() {
let webhook = load_test_webhook("gpt_image_fail_invalid_api_key.json");
let result = parse_webhook_inner_payload(&webhook);
let result = hydrate_webhook_contents(&webhook);
match result {
WebhookInnerPayload::Error(data) => {
HydratedWebhookContents::Error(data) => {
assert_eq!(
data.error_type,
Some(WebhookErrorType::InvalidApiKey),
@@ -111,17 +119,17 @@ mod tests {
Some("Invalid API key"),
);
}
other => panic!("Expected WebhookInnerPayload::Error, got {:?}", other),
other => panic!("Expected HydratedWebhookContents::Error, got {:?}", other),
}
}
#[test]
fn kling_1p6_pro_file_too_large() {
let webhook = load_test_webhook("kling_1p6_pro_file_too_large_error.json");
let result = parse_webhook_inner_payload(&webhook);
let result = hydrate_webhook_contents(&webhook);
match result {
WebhookInnerPayload::Error(data) => {
HydratedWebhookContents::Error(data) => {
assert_eq!(
data.error_type,
Some(WebhookErrorType::FileTooLarge),
@@ -131,17 +139,17 @@ mod tests {
Some("File size exceeds the maximum allowed size of 10485760 bytes. Please upload a smaller file."),
);
}
other => panic!("Expected WebhookInnerPayload::Error, got {:?}", other),
other => panic!("Expected HydratedWebhookContents::Error, got {:?}", other),
}
}
#[test]
fn kling_3p0_pro_content_policy_violation() {
let webhook = load_test_webhook("kling_3p0_pro_fail_content_policy.json");
let result = parse_webhook_inner_payload(&webhook);
let result = hydrate_webhook_contents(&webhook);
match result {
WebhookInnerPayload::Error(data) => {
HydratedWebhookContents::Error(data) => {
assert_eq!(
data.error_type,
Some(WebhookErrorType::ContentPolicyViolation),
@@ -151,17 +159,17 @@ mod tests {
Some("The content could not be processed because it contained material flagged by a content checker."),
);
}
other => panic!("Expected WebhookInnerPayload::Error, got {:?}", other),
other => panic!("Expected HydratedWebhookContents::Error, got {:?}", other),
}
}
#[test]
fn nano_banana_pro_no_media_generated() {
let webhook = load_test_webhook("nano_banana_pro_fail_no_media_generated.json");
let result = parse_webhook_inner_payload(&webhook);
let result = hydrate_webhook_contents(&webhook);
match result {
WebhookInnerPayload::Error(data) => {
HydratedWebhookContents::Error(data) => {
assert_eq!(
data.error_type,
Some(WebhookErrorType::NoMediaGenerated),
@@ -170,71 +178,71 @@ mod tests {
data.message.as_deref().unwrap().starts_with("The model did not generate"),
);
}
other => panic!("Expected WebhookInnerPayload::Error, got {:?}", other),
other => panic!("Expected HydratedWebhookContents::Error, got {:?}", other),
}
}
#[test]
fn payload_error_case() {
let webhook = WebhookPayload {
let webhook = RawWebhookPayload {
request_id: "test-123".to_string(),
gateway_request_id: "test-123".to_string(),
status: WebhookStatus::Ok,
status: RawWebhookStatus::Ok,
error: None,
payload: None,
payload_error: Some("encoding error occurred".to_string()),
};
let result = parse_webhook_inner_payload(&webhook);
let result = hydrate_webhook_contents(&webhook);
match result {
WebhookInnerPayload::PayloadError(data) => {
HydratedWebhookContents::PayloadError(data) => {
assert_eq!(data.payload_error, "encoding error occurred");
}
other => panic!("Expected WebhookInnerPayload::PayloadError, got {:?}", other),
other => panic!("Expected HydratedWebhookContents::PayloadError, got {:?}", other),
}
}
#[test]
fn success_case() {
let webhook = WebhookPayload {
let webhook = RawWebhookPayload {
request_id: "test-456".to_string(),
gateway_request_id: "test-456".to_string(),
status: WebhookStatus::Ok,
status: RawWebhookStatus::Ok,
error: None,
payload: Some(serde_json::json!({"images": [{"url": "https://example.com/img.png"}]})),
payload_error: None,
};
let result = parse_webhook_inner_payload(&webhook);
let result = hydrate_webhook_contents(&webhook);
match result {
WebhookInnerPayload::Success(data) => {
HydratedWebhookContents::Success(data) => {
assert!(data.payload.get("images").is_some());
}
other => panic!("Expected WebhookInnerPayload::Success, got {:?}", other),
other => panic!("Expected HydratedWebhookContents::Success, got {:?}", other),
}
}
#[test]
fn error_with_no_payload() {
let webhook = WebhookPayload {
let webhook = RawWebhookPayload {
request_id: "test-789".to_string(),
gateway_request_id: "test-789".to_string(),
status: WebhookStatus::Error,
status: RawWebhookStatus::Error,
error: Some("Internal server error".to_string()),
payload: None,
payload_error: None,
};
let result = parse_webhook_inner_payload(&webhook);
let result = hydrate_webhook_contents(&webhook);
match result {
WebhookInnerPayload::Error(data) => {
HydratedWebhookContents::Error(data) => {
assert_eq!(data.message, None);
assert_eq!(data.error_type, None);
}
other => panic!("Expected WebhookInnerPayload::Error, got {:?}", other),
other => panic!("Expected HydratedWebhookContents::Error, got {:?}", other),
}
}
}
@@ -0,0 +1,127 @@
use serde::Deserialize;
use serde_json::Value;
use crate::webhook_api::raw::webhook_error_type::WebhookErrorType;
/// The parsed inner payload of a FAL webhook.
#[derive(Debug)]
pub enum HydratedWebhookContents {
/// The webhook reported success and has a payload.
Success(WebhookSuccessData),
/// The webhook reported an error (status=ERROR) with optional detail info.
Error(ErrorData),
/// The webhook reported as "success" but (1) had no payload and (2) had a payload_error.
/// In rare instances of an "OK" response, there may be an error on Fal's end with encoding
/// the payload. If that happens, the "payload_error" field may be set, and this enum variant
/// represents that failure case.
PayloadError(PayloadErrorData),
}
#[derive(Debug)]
pub struct WebhookSuccessData {
/// The success data is polymorphic, so we're returning a JSON `Value` for now.
/// This will allow for downstream handlers to parse the payload as needed.
/// This is the entire raw success payload.
pub payload: Value,
/// If there are any extracted sub-payload contents, such as "images" or "video",
/// then they are included here. This may not be fully inclusive of future
/// payload types.
pub extracted_contents: Option<ExtractedContents>,
}
#[derive(Debug)]
pub struct ExtractedContents {
/// Parsed from `payload.image` (single image result).
pub image: Option<ImageData>,
/// Parsed from `payload.images` (batch image results).
pub images: Option<Vec<ImagesData>>,
/// Parsed from `payload.video`.
pub video: Option<VideoData>,
/// Parsed from `payload.model_glb`.
pub model_glb: Option<ModelGlbData>,
/// Parsed from `payload.model_mesh`.
pub model_mesh: Option<ModelMeshData>,
/// Parsed from `payload.thumbnail`.
pub thumbnail: Option<ThumbnailData>,
}
/// Data under `payload.image`:
#[derive(Debug, Deserialize)]
pub struct ImageData {
pub url: Option<String>,
pub content_type: Option<String>,
pub file_name: Option<String>,
pub file_size: Option<u64>,
pub height: Option<u64>,
pub width: Option<u64>,
}
/// Data under `payload.images` (a list of these):
#[derive(Debug, Deserialize)]
pub struct ImagesData {
pub url: Option<String>,
pub content_type: Option<String>,
pub file_name: Option<String>,
pub file_size: Option<u64>,
pub height: Option<u64>,
pub width: Option<u64>,
}
/// Data under `payload.video`:
#[derive(Debug, Deserialize)]
pub struct VideoData {
pub url: Option<String>,
pub content_type: Option<String>,
pub file_name: Option<String>,
pub file_size: Option<u64>,
}
/// Data under `payload.model_glb` (there may be other sibling keys too)
#[derive(Debug, Deserialize)]
pub struct ModelGlbData {
pub content_type: Option<String>,
pub file_name: Option<String>,
pub file_size: Option<usize>,
pub url: Option<String>,
}
/// Data under `payload.model_mesh` (there may be other sibling keys too)
#[derive(Debug, Deserialize)]
pub struct ModelMeshData {
pub content_type: Option<String>,
pub file_name: Option<String>,
pub file_size: Option<usize>,
pub url: Option<String>,
}
/// Data under `payload.thumbnail` (there may be other sibling keys too)
/// Frequently seen together with `model_glb`.
#[derive(Debug, Deserialize)]
pub struct ThumbnailData {
pub content_type: Option<String>,
pub file_name: Option<String>,
pub file_size: Option<usize>,
pub url: Option<String>,
}
#[derive(Debug)]
pub struct ErrorData {
/// The first human-readable message from `payload.detail[].msg`, if any.
pub message: Option<String>,
/// The first machine-readable error type from `payload.detail[].type`, if any.
pub error_type: Option<WebhookErrorType>,
}
#[derive(Debug)]
pub struct PayloadErrorData {
pub payload_error: String,
}
@@ -0,0 +1,2 @@
pub mod hydrated_webhook_contents;
pub mod success_case_extractors;
@@ -0,0 +1,100 @@
use serde_json::{Map, Value};
use crate::webhook_api::hydrated::hydrated_webhook_contents::ImageData;
/// Extract and deserialize the `image` key from a webhook success payload.
pub (crate) fn extract_image(obj: &Map<String, Value>) -> Option<ImageData> {
let value = obj.get("image")?;
serde_json::from_value(value.clone()).ok()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::webhook_api::hydrate_webhook_contents::hydrate_webhook_contents;
use crate::webhook_api::hydrated::hydrated_webhook_contents::HydratedWebhookContents;
use crate::webhook_api::raw::raw_webhook_payload::{RawWebhookPayload, RawWebhookStatus};
#[test]
fn payload_without_known_keys_has_none_extracted_contents() {
let webhook = RawWebhookPayload {
request_id: "test-no-keys".to_string(),
gateway_request_id: "test-no-keys".to_string(),
status: RawWebhookStatus::Ok,
error: None,
payload: Some(serde_json::json!({"some_other_key": "value"})),
payload_error: None,
};
let result = hydrate_webhook_contents(&webhook);
let HydratedWebhookContents::Success(data) = result else {
panic!("Expected Success, got {:?}", result);
};
assert!(data.extracted_contents.is_none());
}
#[test]
fn null_payload_has_none_extracted_contents() {
let webhook = RawWebhookPayload {
request_id: "test-null".to_string(),
gateway_request_id: "test-null".to_string(),
status: RawWebhookStatus::Ok,
error: None,
payload: None,
payload_error: None,
};
let result = hydrate_webhook_contents(&webhook);
let HydratedWebhookContents::Success(data) = result else {
panic!("Expected Success, got {:?}", result);
};
assert!(data.extracted_contents.is_none());
}
#[test]
fn synthetic_image_payload() {
let obj: serde_json::Map<String, serde_json::Value> = serde_json::from_str(r#"{
"image": {
"url": "https://cdn.example.com/single.png",
"content_type": "image/png",
"file_name": "single.png",
"file_size": 98765,
"width": 512,
"height": 512
}
}"#).unwrap();
let image = extract_image(&obj).expect("should extract image");
assert_eq!(image.url.as_deref(), Some("https://cdn.example.com/single.png"));
assert_eq!(image.content_type.as_deref(), Some("image/png"));
assert_eq!(image.file_name.as_deref(), Some("single.png"));
assert_eq!(image.file_size, Some(98765));
assert_eq!(image.width, Some(512));
assert_eq!(image.height, Some(512));
}
#[test]
fn synthetic_image_url_only() {
let obj: serde_json::Map<String, serde_json::Value> = serde_json::from_str(r#"{
"image": {"url": "https://cdn.example.com/img.jpg"}
}"#).unwrap();
let image = extract_image(&obj).expect("should extract image");
assert_eq!(image.url.as_deref(), Some("https://cdn.example.com/img.jpg"));
assert!(image.content_type.is_none());
assert!(image.file_size.is_none());
}
#[test]
fn missing_image_key_returns_none() {
let obj: serde_json::Map<String, serde_json::Value> = serde_json::from_str(r#"{
"images": [{"url": "https://example.com/img.png"}]
}"#).unwrap();
assert!(extract_image(&obj).is_none());
}
}
@@ -0,0 +1,96 @@
use serde_json::{Map, Value};
use crate::webhook_api::hydrated::hydrated_webhook_contents::ImagesData;
/// Extract and deserialize the `images` key from a webhook success payload.
pub (crate) fn extract_images(obj: &Map<String, Value>) -> Option<Vec<ImagesData>> {
let value = obj.get("images")?;
serde_json::from_value(value.clone()).ok()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::webhook_api::hydrate_webhook_contents::hydrate_webhook_contents;
use crate::webhook_api::hydrated::hydrated_webhook_contents::HydratedWebhookContents;
use crate::webhook_api::raw::raw_webhook_payload::RawWebhookPayload;
fn load_test_webhook(filename: &str) -> RawWebhookPayload {
let path = format!("test_data/webhooks/{}", filename);
let json = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("Failed to read {}: {}", path, e));
serde_json::from_str(&json)
.unwrap_or_else(|e| panic!("Failed to parse {}: {}", path, e))
}
#[test]
fn images_payload_from_test_file() {
let webhook = load_test_webhook("success/images_payload_1.json");
let result = hydrate_webhook_contents(&webhook);
let HydratedWebhookContents::Success(data) = result else {
panic!("Expected Success, got {:?}", result);
};
let contents = data.extracted_contents
.expect("extracted_contents should be Some");
let images = contents.images.expect("images should be Some");
assert_eq!(images.len(), 2);
assert_eq!(images[0].url.as_deref(), Some("https://v3b.fal.media/files/b/01234567/name1.png"));
assert_eq!(images[0].content_type.as_deref(), Some("image/png"));
assert_eq!(images[0].file_name.as_deref(), Some("name1.png"));
assert_eq!(images[0].file_size, Some(11500002));
assert_eq!(images[1].url.as_deref(), Some("https://v3b.fal.media/files/b/01234567/name2.png"));
assert_eq!(images[1].file_size, Some(13499999));
assert!(contents.image.is_none());
assert!(contents.video.is_none());
assert!(contents.model_glb.is_none());
assert!(contents.model_mesh.is_none());
}
#[test]
fn synthetic_images_payload() {
let obj: serde_json::Map<String, serde_json::Value> = serde_json::from_str(r#"{
"images": [
{"url": "https://example.com/a.png", "content_type": "image/png", "width": 1024, "height": 768},
{"url": "https://example.com/b.jpg", "content_type": "image/jpeg", "file_size": 54321}
]
}"#).unwrap();
let images = extract_images(&obj).expect("should extract images");
assert_eq!(images.len(), 2);
assert_eq!(images[0].url.as_deref(), Some("https://example.com/a.png"));
assert_eq!(images[0].width, Some(1024));
assert_eq!(images[0].height, Some(768));
assert!(images[0].file_size.is_none());
assert_eq!(images[1].url.as_deref(), Some("https://example.com/b.jpg"));
assert_eq!(images[1].content_type.as_deref(), Some("image/jpeg"));
assert_eq!(images[1].file_size, Some(54321));
assert!(images[1].width.is_none());
}
#[test]
fn empty_images_array_returns_empty_vec() {
let obj: serde_json::Map<String, serde_json::Value> = serde_json::from_str(r#"{
"images": []
}"#).unwrap();
let images = extract_images(&obj).expect("should extract empty images");
assert!(images.is_empty());
}
#[test]
fn missing_images_key_returns_none() {
let obj: serde_json::Map<String, serde_json::Value> = serde_json::from_str(r#"{
"video": {"url": "https://example.com/v.mp4"}
}"#).unwrap();
assert!(extract_images(&obj).is_none());
}
}
@@ -0,0 +1,91 @@
use serde_json::{Map, Value};
use crate::webhook_api::hydrated::hydrated_webhook_contents::ModelGlbData;
/// Extract and deserialize the `model_glb` key from a webhook success payload.
pub (crate) fn extract_model_glb(obj: &Map<String, Value>) -> Option<ModelGlbData> {
let value = obj.get("model_glb")?;
serde_json::from_value(value.clone()).ok()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::webhook_api::hydrate_webhook_contents::hydrate_webhook_contents;
use crate::webhook_api::hydrated::hydrated_webhook_contents::HydratedWebhookContents;
use crate::webhook_api::raw::raw_webhook_payload::RawWebhookPayload;
fn load_test_webhook(filename: &str) -> RawWebhookPayload {
let path = format!("test_data/webhooks/{}", filename);
let json = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("Failed to read {}: {}", path, e));
serde_json::from_str(&json)
.unwrap_or_else(|e| panic!("Failed to parse {}: {}", path, e))
}
#[test]
fn model_glb_from_test_file() {
let webhook = load_test_webhook("success/model_glb_urls_and_thumbnail_1.json");
let result = hydrate_webhook_contents(&webhook);
let HydratedWebhookContents::Success(data) = result else {
panic!("Expected Success, got {:?}", result);
};
let contents = data.extracted_contents
.expect("extracted_contents should be Some");
let glb = contents.model_glb.expect("model_glb should be Some");
assert_eq!(glb.url.as_deref(), Some("https://v3b.fal.media/files/b/0a95eb37/f7UaAhyfcx0BAnYy0GpFM_model.glb"));
assert_eq!(glb.content_type.as_deref(), Some("model/gltf-binary"));
assert_eq!(glb.file_name.as_deref(), Some("model.glb"));
assert_eq!(glb.file_size, Some(66284788));
// This payload also has a thumbnail.
assert!(contents.thumbnail.is_some());
// No image/images/video/mesh in this payload.
assert!(contents.image.is_none());
assert!(contents.images.is_none());
assert!(contents.video.is_none());
assert!(contents.model_mesh.is_none());
}
#[test]
fn synthetic_model_glb_payload() {
let obj: serde_json::Map<String, serde_json::Value> = serde_json::from_str(r#"{
"model_glb": {
"url": "https://cdn.example.com/model.glb",
"content_type": "model/gltf-binary",
"file_name": "output.glb",
"file_size": 5432100
}
}"#).unwrap();
let glb = extract_model_glb(&obj).expect("should extract model_glb");
assert_eq!(glb.url.as_deref(), Some("https://cdn.example.com/model.glb"));
assert_eq!(glb.content_type.as_deref(), Some("model/gltf-binary"));
assert_eq!(glb.file_name.as_deref(), Some("output.glb"));
assert_eq!(glb.file_size, Some(5432100));
}
#[test]
fn model_glb_url_only() {
let obj: serde_json::Map<String, serde_json::Value> = serde_json::from_str(r#"{
"model_glb": {"url": "https://cdn.example.com/m.glb"}
}"#).unwrap();
let glb = extract_model_glb(&obj).expect("should extract model_glb");
assert_eq!(glb.url.as_deref(), Some("https://cdn.example.com/m.glb"));
assert!(glb.content_type.is_none());
}
#[test]
fn missing_model_glb_key_returns_none() {
let obj: serde_json::Map<String, serde_json::Value> = serde_json::from_str(r#"{
"video": {"url": "https://example.com/v.mp4"}
}"#).unwrap();
assert!(extract_model_glb(&obj).is_none());
}
}
@@ -0,0 +1,89 @@
use serde_json::{Map, Value};
use crate::webhook_api::hydrated::hydrated_webhook_contents::ModelMeshData;
/// Extract and deserialize the `model_mesh` key from a webhook success payload.
pub (crate) fn extract_model_mesh(obj: &Map<String, Value>) -> Option<ModelMeshData> {
let value = obj.get("model_mesh")?;
serde_json::from_value(value.clone()).ok()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::webhook_api::hydrate_webhook_contents::hydrate_webhook_contents;
use crate::webhook_api::hydrated::hydrated_webhook_contents::HydratedWebhookContents;
use crate::webhook_api::raw::raw_webhook_payload::RawWebhookPayload;
fn load_test_webhook(filename: &str) -> RawWebhookPayload {
let path = format!("test_data/webhooks/{}", filename);
let json = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("Failed to read {}: {}", path, e));
serde_json::from_str(&json)
.unwrap_or_else(|e| panic!("Failed to parse {}: {}", path, e))
}
#[test]
fn model_mesh_from_test_file() {
let webhook = load_test_webhook("success/model_mesh_payload_1.json");
let result = hydrate_webhook_contents(&webhook);
let HydratedWebhookContents::Success(data) = result else {
panic!("Expected Success, got {:?}", result);
};
let contents = data.extracted_contents
.expect("extracted_contents should be Some");
let mesh = contents.model_mesh.expect("model_mesh should be Some");
assert_eq!(mesh.url.as_deref(), Some("https://v3b.fal.media/files/b/0a95eb2d/XCpH7fk-tZVXr1XMUr7DY_mesh-1775972273-765284.glb"));
assert_eq!(mesh.content_type.as_deref(), Some("application/octet-stream"));
assert_eq!(mesh.file_name.as_deref(), Some("mesh-1775972273-765284.glb"));
assert_eq!(mesh.file_size, Some(3886928));
// No other content types in this payload.
assert!(contents.image.is_none());
assert!(contents.images.is_none());
assert!(contents.video.is_none());
assert!(contents.model_glb.is_none());
assert!(contents.thumbnail.is_none());
}
#[test]
fn synthetic_model_mesh_payload() {
let obj: serde_json::Map<String, serde_json::Value> = serde_json::from_str(r#"{
"model_mesh": {
"url": "https://cdn.example.com/mesh.obj",
"content_type": "model/obj",
"file_name": "output.obj",
"file_size": 1234567
}
}"#).unwrap();
let mesh = extract_model_mesh(&obj).expect("should extract model_mesh");
assert_eq!(mesh.url.as_deref(), Some("https://cdn.example.com/mesh.obj"));
assert_eq!(mesh.content_type.as_deref(), Some("model/obj"));
assert_eq!(mesh.file_name.as_deref(), Some("output.obj"));
assert_eq!(mesh.file_size, Some(1234567));
}
#[test]
fn model_mesh_url_only() {
let obj: serde_json::Map<String, serde_json::Value> = serde_json::from_str(r#"{
"model_mesh": {"url": "https://cdn.example.com/m.obj"}
}"#).unwrap();
let mesh = extract_model_mesh(&obj).expect("should extract model_mesh");
assert_eq!(mesh.url.as_deref(), Some("https://cdn.example.com/m.obj"));
assert!(mesh.content_type.is_none());
}
#[test]
fn missing_model_mesh_key_returns_none() {
let obj: serde_json::Map<String, serde_json::Value> = serde_json::from_str(r#"{
"image": {"url": "https://example.com/img.png"}
}"#).unwrap();
assert!(extract_model_mesh(&obj).is_none());
}
}
@@ -0,0 +1,86 @@
use serde_json::{Map, Value};
use crate::webhook_api::hydrated::hydrated_webhook_contents::ThumbnailData;
/// Extract and deserialize the `thumbnail` key from a webhook success payload.
pub(crate) fn extract_thumbnail(obj: &Map<String, Value>) -> Option<ThumbnailData> {
let value = obj.get("thumbnail")?;
serde_json::from_value(value.clone()).ok()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::webhook_api::hydrate_webhook_contents::hydrate_webhook_contents;
use crate::webhook_api::hydrated::hydrated_webhook_contents::HydratedWebhookContents;
use crate::webhook_api::raw::raw_webhook_payload::RawWebhookPayload;
fn load_test_webhook(filename: &str) -> RawWebhookPayload {
let path = format!("test_data/webhooks/{}", filename);
let json = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("Failed to read {}: {}", path, e));
serde_json::from_str(&json)
.unwrap_or_else(|e| panic!("Failed to parse {}: {}", path, e))
}
#[test]
fn thumbnail_from_model_glb_test_file() {
let webhook = load_test_webhook("success/model_glb_urls_and_thumbnail_1.json");
let result = hydrate_webhook_contents(&webhook);
let HydratedWebhookContents::Success(data) = result else {
panic!("Expected Success, got {:?}", result);
};
let contents = data.extracted_contents
.expect("extracted_contents should be Some");
let thumbnail = contents.thumbnail.expect("thumbnail should be Some");
assert_eq!(thumbnail.url.as_deref(), Some("https://v3b.fal.media/files/b/0a95eb38/yvwGJWZV9TkWZuZvjxZNj_preview.png"));
assert_eq!(thumbnail.content_type.as_deref(), Some("image/png"));
assert_eq!(thumbnail.file_name.as_deref(), Some("preview.png"));
assert_eq!(thumbnail.file_size, Some(93436));
}
#[test]
fn synthetic_thumbnail_payload() {
let obj: serde_json::Map<String, serde_json::Value> = serde_json::from_str(r#"{
"thumbnail": {
"url": "https://cdn.example.com/thumb.png",
"content_type": "image/png",
"file_name": "thumb.png",
"file_size": 12345
}
}"#).unwrap();
let thumb = extract_thumbnail(&obj).expect("should extract thumbnail");
assert_eq!(thumb.url.as_deref(), Some("https://cdn.example.com/thumb.png"));
assert_eq!(thumb.content_type.as_deref(), Some("image/png"));
assert_eq!(thumb.file_name.as_deref(), Some("thumb.png"));
assert_eq!(thumb.file_size, Some(12345));
}
#[test]
fn missing_thumbnail_key_returns_none() {
let obj: serde_json::Map<String, serde_json::Value> = serde_json::from_str(r#"{
"model_glb": {"url": "https://example.com/model.glb"}
}"#).unwrap();
assert!(extract_thumbnail(&obj).is_none());
}
#[test]
fn model_mesh_payload_has_no_thumbnail() {
let webhook = load_test_webhook("success/model_mesh_payload_1.json");
let result = hydrate_webhook_contents(&webhook);
let HydratedWebhookContents::Success(data) = result else {
panic!("Expected Success, got {:?}", result);
};
let contents = data.extracted_contents
.expect("extracted_contents should be Some");
assert!(contents.thumbnail.is_none());
}
}
@@ -0,0 +1,90 @@
use serde_json::{Map, Value};
use crate::webhook_api::hydrated::hydrated_webhook_contents::VideoData;
/// Extract and deserialize the `video` key from a webhook success payload.
pub (crate) fn extract_video(obj: &Map<String, Value>) -> Option<VideoData> {
let value = obj.get("video")?;
serde_json::from_value(value.clone()).ok()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::webhook_api::hydrate_webhook_contents::hydrate_webhook_contents;
use crate::webhook_api::hydrated::hydrated_webhook_contents::HydratedWebhookContents;
use crate::webhook_api::raw::raw_webhook_payload::RawWebhookPayload;
fn load_test_webhook(filename: &str) -> RawWebhookPayload {
let path = format!("test_data/webhooks/{}", filename);
let json = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("Failed to read {}: {}", path, e));
serde_json::from_str(&json)
.unwrap_or_else(|e| panic!("Failed to parse {}: {}", path, e))
}
#[test]
fn video_payload_from_test_file() {
let webhook = load_test_webhook("success/video_payload_1.json");
let result = hydrate_webhook_contents(&webhook);
let HydratedWebhookContents::Success(data) = result else {
panic!("Expected Success, got {:?}", result);
};
let contents = data.extracted_contents
.expect("extracted_contents should be Some");
let video = contents.video.expect("video should be Some");
assert_eq!(video.url.as_deref(), Some("https://v3b.fal.media/files/b/0abcdef0/AB-CDE_123456789abcde_output.mp4"));
assert_eq!(video.content_type.as_deref(), Some("video/mp4"));
assert_eq!(video.file_name.as_deref(), Some("output.mp4"));
assert_eq!(video.file_size, Some(6226845));
assert!(contents.image.is_none());
assert!(contents.images.is_none());
assert!(contents.model_glb.is_none());
assert!(contents.model_mesh.is_none());
}
#[test]
fn synthetic_video_payload() {
let obj: serde_json::Map<String, serde_json::Value> = serde_json::from_str(r#"{
"video": {
"url": "https://cdn.example.com/output.mp4",
"content_type": "video/mp4",
"file_name": "clip.mp4",
"file_size": 12345678
}
}"#).unwrap();
let video = extract_video(&obj).expect("should extract video");
assert_eq!(video.url.as_deref(), Some("https://cdn.example.com/output.mp4"));
assert_eq!(video.content_type.as_deref(), Some("video/mp4"));
assert_eq!(video.file_name.as_deref(), Some("clip.mp4"));
assert_eq!(video.file_size, Some(12345678));
}
#[test]
fn synthetic_video_url_only() {
let obj: serde_json::Map<String, serde_json::Value> = serde_json::from_str(r#"{
"video": {"url": "https://cdn.example.com/v.mp4"}
}"#).unwrap();
let video = extract_video(&obj).expect("should extract video");
assert_eq!(video.url.as_deref(), Some("https://cdn.example.com/v.mp4"));
assert!(video.content_type.is_none());
assert!(video.file_name.is_none());
assert!(video.file_size.is_none());
}
#[test]
fn missing_video_key_returns_none() {
let obj: serde_json::Map<String, serde_json::Value> = serde_json::from_str(r#"{
"images": [{"url": "https://example.com/img.png"}]
}"#).unwrap();
assert!(extract_video(&obj).is_none());
}
}
@@ -0,0 +1,43 @@
pub mod extract_image_payload;
pub mod extract_images_payload;
pub mod extract_video_payload;
pub mod extract_model_glb_payload;
pub mod extract_model_mesh_payload;
pub mod extract_thumbnail_payload;
use serde_json::Value;
use crate::webhook_api::hydrated::hydrated_webhook_contents::ExtractedContents;
/// Try to extract known content keys from a success payload.
///
/// Checks the payload (as a JSON object) for any of: `image`, `images`,
/// `video`, `model_glb`, `model_mesh`. If at least one is found, returns
/// `Some(ExtractedContents)` with copies of the relevant values. Multiple
/// keys can be populated simultaneously. Returns `None` if the payload is
/// not an object or none of the known keys are present.
pub fn extract_contents_from_payload(payload: &Value) -> Option<ExtractedContents> {
let obj = payload.as_object()?;
let image = extract_image_payload::extract_image(obj);
let images = extract_images_payload::extract_images(obj);
let video = extract_video_payload::extract_video(obj);
let model_glb = extract_model_glb_payload::extract_model_glb(obj);
let model_mesh = extract_model_mesh_payload::extract_model_mesh(obj);
let thumbnail = extract_thumbnail_payload::extract_thumbnail(obj);
if image.is_none() && images.is_none() && video.is_none()
&& model_glb.is_none() && model_mesh.is_none() && thumbnail.is_none()
{
return None;
}
Some(ExtractedContents {
image,
images,
video,
model_glb,
model_mesh,
thumbnail,
})
}
@@ -1,3 +1,4 @@
pub mod parse_webhook_inner_payload;
pub mod parse_webhook_payload;
pub mod payload;
pub mod raw;
pub mod hydrated;
pub mod hydrate_webhook_contents;
pub mod parse_raw_webhook_payload;
@@ -0,0 +1,5 @@
use crate::webhook_api::raw::raw_webhook_payload::RawWebhookPayload;
pub fn parse_raw_webhook_payload(json: &str) -> Result<RawWebhookPayload, serde_json::Error> {
serde_json::from_str(json)
}
@@ -1,5 +0,0 @@
use crate::webhook_api::payload::webhook_payload::WebhookPayload;
pub fn parse_webhook_payload(json: &str) -> Result<WebhookPayload, serde_json::Error> {
serde_json::from_str(json)
}
@@ -1,3 +0,0 @@
pub mod webhook_inner_payload;
pub mod webhook_payload;
pub mod webhook_error_type;
@@ -1,32 +0,0 @@
use serde_json::Value;
use crate::webhook_api::payload::webhook_error_type::WebhookErrorType;
/// The parsed inner payload of a FAL webhook.
#[derive(Debug)]
pub enum WebhookInnerPayload {
/// The webhook reported success and has a payload.
Success(SuccessData),
/// The webhook reported an error (status=ERROR) with optional detail info.
Error(ErrorData),
/// The webhook reported success but had no payload and instead had a payload_error.
PayloadError(PayloadErrorData),
}
#[derive(Debug)]
pub struct SuccessData {
pub payload: Value,
}
#[derive(Debug)]
pub struct ErrorData {
/// The first human-readable message from `payload.detail[].msg`, if any.
pub message: Option<String>,
/// The first machine-readable error type from `payload.detail[].type`, if any.
pub error_type: Option<WebhookErrorType>,
}
#[derive(Debug)]
pub struct PayloadErrorData {
pub payload_error: String,
}
@@ -0,0 +1,2 @@
pub mod webhook_error_type;
pub mod raw_webhook_payload;
@@ -3,7 +3,7 @@ use serde_json::Value;
/// The value FAL sends to webhook endpoints.
#[derive(Deserialize, Debug, Clone)]
pub struct WebhookPayload {
pub struct RawWebhookPayload {
/// Value originally used in the queue API
pub request_id: String,
@@ -13,7 +13,7 @@ pub struct WebhookPayload {
pub gateway_request_id: String,
/// Status of the request: OK or ERROR.
pub status: WebhookStatus,
pub status: RawWebhookStatus,
/// When an error happens, the status will be ERROR.
/// The error property will contain a message and payload will contain the error details.
@@ -32,7 +32,7 @@ pub struct WebhookPayload {
}
#[derive(Deserialize, Debug, Clone, Copy)]
pub enum WebhookStatus {
pub enum RawWebhookStatus {
/// Success case
#[serde(alias = "OK")]
Ok,
@@ -0,0 +1,26 @@
{
"error": null,
"gateway_request_id": "11111111-2222-3333-4444-555555555555",
"payload": {
"images": [
{
"content_type": "image/png",
"file_name": "name1.png",
"file_size": 11500002,
"height": null,
"url": "https://v3b.fal.media/files/b/01234567/name1.png",
"width": null
},
{
"content_type": "image/png",
"file_name": "name2.png",
"file_size": 13499999,
"height": null,
"url": "https://v3b.fal.media/files/b/01234567/name2.png",
"width": null
}
]
},
"request_id": "11111111-2222-3333-4444-555555555555",
"status": "OK"
}
@@ -0,0 +1,37 @@
{
"error": null,
"gateway_request_id": "019d8031-ede2-7932-a18f-9b2865908c0e",
"payload": {
"model_glb": {
"content_type": "model/gltf-binary",
"file_name": "model.glb",
"file_size": 66284788,
"url": "https://v3b.fal.media/files/b/0a95eb37/f7UaAhyfcx0BAnYy0GpFM_model.glb"
},
"model_urls": {
"fbx": null,
"glb": {
"content_type": "model/gltf-binary",
"file_name": "model.glb",
"file_size": 66284788,
"url": "https://v3b.fal.media/files/b/0a95eb37/f7UaAhyfcx0BAnYy0GpFM_model.glb"
},
"obj": {
"content_type": "text/plain",
"file_name": "model.obj",
"file_size": 55806533,
"url": "https://v3b.fal.media/files/b/0a95eb38/vfrPPtdTXNEYh_ExY-adN_model.obj"
},
"usdz": null
},
"seed": null,
"thumbnail": {
"content_type": "image/png",
"file_name": "preview.png",
"file_size": 93436,
"url": "https://v3b.fal.media/files/b/0a95eb38/yvwGJWZV9TkWZuZvjxZNj_preview.png"
}
},
"request_id": "019d8031-ede2-7932-a18f-9b2865908c0e",
"status": "OK"
}
@@ -0,0 +1,15 @@
{
"error": null,
"gateway_request_id": "019d8032-38cb-7161-8dd8-0b4adf10fa72",
"payload": {
"model_mesh": {
"content_type": "application/octet-stream",
"file_name": "mesh-1775972273-765284.glb",
"file_size": 3886928,
"url": "https://v3b.fal.media/files/b/0a95eb2d/XCpH7fk-tZVXr1XMUr7DY_mesh-1775972273-765284.glb"
},
"seed": 765284
},
"request_id": "019d8032-38cb-7161-8dd8-0b4adf10fa72",
"status": "OK"
}
@@ -0,0 +1,14 @@
{
"error": null,
"gateway_request_id": "01234560-1230-1230-1230-122233344455",
"payload": {
"video": {
"content_type": "video/mp4",
"file_name": "output.mp4",
"file_size": 6226845,
"url": "https://v3b.fal.media/files/b/0abcdef0/AB-CDE_123456789abcde_output.mp4"
}
},
"request_id": "01234560-1230-1230-1230-122233344455",
"status": "OK"
}
@@ -44,7 +44,7 @@ pub async fn list_active_user_subscriptions_handler(
.await
.map_err(|err| {
warn!("Error looking up user: {:?}", err);
AdvancedCommonWebError::from_error
AdvancedCommonWebError::from_error(err)
})?;
let user_metadata = match maybe_user_metadata {
@@ -7,9 +7,9 @@ use crate::state::server_state::ServerState;
use actix_web::web::Bytes;
use actix_web::web::Json;
use actix_web::{web, HttpRequest};
use fal_client::webhook_api::parse_webhook_inner_payload::parse_webhook_inner_payload;
use fal_client::webhook_api::parse_webhook_payload::parse_webhook_payload;
use fal_client::webhook_api::payload::webhook_inner_payload::WebhookInnerPayload;
use fal_client::webhook_api::hydrate_webhook_contents::hydrate_webhook_contents;
use fal_client::webhook_api::hydrated::hydrated_webhook_contents::HydratedWebhookContents;
use fal_client::webhook_api::parse_raw_webhook_payload::parse_raw_webhook_payload;
use http_server_common::response::response_success_helpers::SimpleGenericJsonSuccess;
use log::{error, info, warn};
use pager::notification::notification_details_builder::NotificationDetailsBuilder;
@@ -36,8 +36,10 @@ pub async fn fal_webhook_handler(
info!("Received FAL webhook body: {}", raw_body);
// Step 2: Parse into WebhookPayload.
let webhook_payload = parse_webhook_payload(&raw_body)
println!("\n\nReceived FAL webhook body: {}\n\n", raw_body);
// Step 2: Parse into RawWebhookPayload.
let webhook_payload = parse_raw_webhook_payload(&raw_body)
.map_err(|err| {
error!("FAL webhook: could not parse webhook payload: {:?}", err);
enqueue_parse_error_alert(&server_state, &http_request, "JSON parse failed", &err, Some(&raw_body));
@@ -50,15 +52,15 @@ pub async fn fal_webhook_handler(
info!("FAL webhook request_id: {} (status: {:?})", request_id, webhook_payload.status);
// Step 3: Parse the inner payload.
let inner_payload = parse_webhook_inner_payload(&webhook_payload);
// Step 3: Hydrate the webhook contents.
let hydrated_contents = hydrate_webhook_contents(&webhook_payload);
// Step 4 & 5: Branch on the inner payload type.
let result = match inner_payload {
WebhookInnerPayload::Success(success_data) => {
handle_successful_fal_webhook(&server_state, request_id, &success_data.payload).await
let result = match hydrated_contents {
HydratedWebhookContents::Success(success_data) => {
handle_successful_fal_webhook(&server_state, request_id, &success_data).await
}
WebhookInnerPayload::Error(error_data) => {
HydratedWebhookContents::Error(error_data) => {
handle_failed_fal_webhook(
&server_state,
request_id,
@@ -66,7 +68,7 @@ pub async fn fal_webhook_handler(
webhook_payload.error.as_deref(),
).await
}
WebhookInnerPayload::PayloadError(payload_error_data) => {
HydratedWebhookContents::PayloadError(payload_error_data) => {
warn!(
"FAL webhook payload_error for request_id {}: {}",
request_id, payload_error_data.payload_error,
@@ -2,8 +2,8 @@ use crate::http_server::common_responses::advanced_common_web_error::AdvancedCom
use crate::state::server_state::ServerState;
use actix_web::web::Json;
use enums::by_table::generic_inference_jobs::frontend_failure_category::FrontendFailureCategory;
use fal_client::webhook_api::payload::webhook_error_type::WebhookErrorType;
use fal_client::webhook_api::payload::webhook_inner_payload::ErrorData;
use fal_client::webhook_api::raw::webhook_error_type::WebhookErrorType;
use fal_client::webhook_api::hydrated::hydrated_webhook_contents::ErrorData;
use http_server_common::response::response_success_helpers::SimpleGenericJsonSuccess;
use log::{error, info, warn};
use mysql_queries::queries::generic_inference::fal::get_inference_job_by_fal_id::get_inference_job_by_fal_id;
@@ -1,9 +1,9 @@
use actix_web::web::Json;
use fal_client::webhook_api::hydrated::hydrated_webhook_contents::WebhookSuccessData;
use http_server_common::response::response_success_helpers::SimpleGenericJsonSuccess;
use log::{info, warn};
use mysql_queries::queries::generic_inference::fal::get_inference_job_by_fal_id::get_inference_job_by_fal_id;
use mysql_queries::queries::generic_inference::fal::mark_fal_generic_inference_job_successfully_done::{mark_fal_generic_inference_job_successfully_done, MarkJobArgs};
use serde_json::Value;
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use crate::state::server_state::ServerState;
@@ -17,7 +17,7 @@ use super::process_video_payload::process_video_payload;
pub async fn handle_successful_fal_webhook(
server_state: &ServerState,
request_id: &str,
payload: &Value,
success_data: &WebhookSuccessData,
) -> Result<Json<SimpleGenericJsonSuccess>, AdvancedCommonWebError> {
let db_result = get_inference_job_by_fal_id(
@@ -42,25 +42,46 @@ pub async fn handle_successful_fal_webhook(
let mut maybe_media_token = None;
let mut maybe_batch_token = None;
if let Some(payload_obj) = payload.as_object() {
if payload_obj.contains_key("image") {
if let Some(ref extracted) = success_data.extracted_contents {
if let Some(ref image_data) = extracted.image {
info!("Handling image payload for request_id {} / job {:?}", request_id, job.job_token);
let token = process_image_payload(payload_obj, &job, server_state).await?;
maybe_media_token = Some(token);
} else if payload_obj.contains_key("images") {
(maybe_media_token, maybe_batch_token) = process_images_payload(payload_obj, &job, server_state).await?;
} else if payload_obj.contains_key("video") {
let token = process_image_payload(image_data, &job, server_state).await?;
if maybe_media_token.is_none() {
maybe_media_token = Some(token);
}
}
if let Some(ref images_data) = extracted.images {
info!("Handling images payload for request_id {} / job {:?}", request_id, job.job_token);
let (media_token, batch_token) = process_images_payload(images_data, &job, server_state).await?;
if maybe_media_token.is_none() {
maybe_media_token = media_token;
}
if maybe_batch_token.is_none() {
maybe_batch_token = batch_token;
}
}
if let Some(ref video_data) = extracted.video {
info!("Handling video payload for request_id {} / job {:?}", request_id, job.job_token);
let token = process_video_payload(payload_obj, &job, server_state).await?;
maybe_media_token = Some(token);
} else if payload_obj.contains_key("model_glb") {
let token = process_video_payload(video_data, &job, server_state).await?;
if maybe_media_token.is_none() {
maybe_media_token = Some(token);
}
}
if let Some(ref model_glb_data) = extracted.model_glb {
info!("Handling model_glb payload for request_id {} / job {:?}", request_id, job.job_token);
let token = process_model_glb_payload(payload_obj, &job, server_state).await?;
maybe_media_token = Some(token);
} else if payload_obj.contains_key("model_mesh") {
let token = process_model_glb_payload(model_glb_data, extracted.thumbnail.as_ref(), &job, server_state).await?;
if maybe_media_token.is_none() {
maybe_media_token = Some(token);
}
} else if let Some(ref model_mesh_data) = extracted.model_mesh {
info!("Handling model_mesh payload for request_id {} / job {:?}", request_id, job.job_token);
let token = process_model_mesh_payload(payload_obj, &job, server_state).await?;
maybe_media_token = Some(token);
let token = process_model_mesh_payload(model_mesh_data, &job, server_state).await?;
if maybe_media_token.is_none() {
maybe_media_token = Some(token);
}
}
}
@@ -6,6 +6,7 @@ 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 errors::AnyhowResult;
use fal_client::webhook_api::hydrated::hydrated_webhook_contents::ImageData;
use hashing::sha256::sha256_hash_bytes::sha256_hash_bytes;
use images::encoding::webp_bytes_to_png_bytes::webp_bytes_to_png_bytes;
use images::image_info::image_info::ImageInfo;
@@ -14,34 +15,16 @@ use mimetypes::mimetype_info::file_extension::FileExtension;
use mimetypes::mimetype_info::mimetype_info::MimetypeInfo;
use mysql_queries::queries::generic_inference::fal::get_inference_job_by_fal_id::FalJobDetails;
use mysql_queries::queries::media_files::create::insert_builder::media_file_insert_builder::MediaFileInsertBuilder;
use serde_json::{Map, Value};
use tokens::tokens::media_files::MediaFileToken;
const PREFIX : Option<&str> = Some("artcraft_");
#[derive(Deserialize, Debug)]
pub struct FalWebhookImage {
pub content_type: Option<String>,
pub file_name: Option<String>,
pub file_size: Option<usize>,
pub height: Option<usize>,
pub width: Option<usize>,
pub url: Option<String>,
}
pub async fn process_image_payload(
payload: &Map<String, Value>,
image_data: &ImageData,
job: &FalJobDetails,
server_state: &ServerState,
) -> AnyhowResult<MediaFileToken> {
let image_value = payload.get("image")
.ok_or_else(|| anyhow!("no `image` key in payload"))?;
info!("Fal Image Payload: {:?}", image_value);
let image: FalWebhookImage = serde_json::from_value(image_value.clone())?;
let image_url = image.url
let image_url = image_data.url
.as_deref()
.ok_or_else(|| anyhow!("no `url` in image payload"))?;
@@ -56,7 +39,6 @@ pub async fn process_image_payload(
let mimetype_info = MimetypeInfo::get_for_bytes(&file_bytes)
.ok_or_else(|| anyhow!("Failed to get mimetype info"))?;
info!("File type: {}, extension: {:?}",
mimetype_info.mime_type(),
mimetype_info.file_extension());
@@ -6,6 +6,7 @@ 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 errors::AnyhowResult;
use fal_client::webhook_api::hydrated::hydrated_webhook_contents::ImagesData;
use hashing::sha256::sha256_hash_bytes::sha256_hash_bytes;
use images::encoding::webp_bytes_to_png_bytes::webp_bytes_to_png_bytes;
use images::image_info::image_info::ImageInfo;
@@ -14,76 +15,54 @@ use mimetypes::mimetype_info::file_extension::FileExtension;
use mimetypes::mimetype_info::mimetype_info::MimetypeInfo;
use mysql_queries::queries::generic_inference::fal::get_inference_job_by_fal_id::FalJobDetails;
use mysql_queries::queries::media_files::create::insert_builder::media_file_insert_builder::MediaFileInsertBuilder;
use serde_json::{Map, Value};
use tokens::tokens::batch_generations::BatchGenerationToken;
use tokens::tokens::media_files::MediaFileToken;
const PREFIX : Option<&str> = Some("artcraft_");
#[derive(Deserialize, Debug)]
pub struct FalWebhookImage {
pub content_type: Option<String>,
pub file_name: Option<String>,
pub file_size: Option<usize>,
pub height: Option<usize>,
pub width: Option<usize>,
pub url: Option<String>,
}
pub async fn process_images_payload(
payload: &Map<String, Value>,
images_data: &[ImagesData],
job: &FalJobDetails,
server_state: &ServerState,
) -> AnyhowResult<(Option<MediaFileToken>, Option<BatchGenerationToken>)> {
let images_value = payload.get("images")
.ok_or_else(|| anyhow!("no `images` key in payload"))?;
info!("Fal Images Payload: {:?}", images_value);
let images: Vec<FalWebhookImage> = serde_json::from_value(images_value.clone())?;
let mut maybe_media_token = None;
// NB: We are not going to create `batch_generations` table records. We don't need them.
// The foreign key in `media_files` is enough to look up the rest of the batch.
let mut maybe_batch_token = None;
if images.len() > 1 {
if images_data.len() > 1 {
maybe_batch_token = Some(BatchGenerationToken::generate());
}
for image in images {
let media_token = upload_image(job, server_state, &image, maybe_batch_token.as_ref()).await?;
for image in images_data {
let media_token = upload_image(job, server_state, image, maybe_batch_token.as_ref()).await?;
if maybe_media_token.is_none() {
maybe_media_token = Some(media_token); // Set the first media token
}
}
Ok((maybe_media_token, maybe_batch_token))
}
async fn upload_image(
job: &FalJobDetails,
server_state: &ServerState,
image: &FalWebhookImage,
image: &ImagesData,
maybe_batch_token: Option<&BatchGenerationToken>,
) -> AnyhowResult<MediaFileToken> {
let image_url = image.url
.as_deref()
.ok_or_else(|| anyhow!("no `url` in image payload"))?;
//let mime_type = image.content_type
// .as_deref()
// .ok_or_else(|| anyhow!("no `content_type` in image payload"))?;
let file_bytes = http_download_url_to_bytes(image_url)
.await
.map_err(|e| anyhow!("Failed to download image: {:?}", e))?;
let mimetype_info = MimetypeInfo::get_for_bytes(&file_bytes)
.ok_or_else(|| anyhow!("Failed to get mimetype info"))?;
.ok_or_else(|| anyhow!("Failed to get mimetype info (1)"))?;
info!("File type: {}, extension: {:?}",
mimetype_info.mime_type(),
@@ -96,7 +75,7 @@ async fn upload_image(
let file_bytes = webp_bytes_to_png_bytes(&file_bytes)?;
let mimetype_info = MimetypeInfo::get_for_bytes(&file_bytes)
.ok_or_else(|| anyhow!("Failed to get mimetype info"))?;
.ok_or_else(|| anyhow!("Failed to get mimetype info (2)"))?;
info!("Updated file type: {}, extension: {:?}",
mimetype_info.mime_type(),
@@ -127,7 +106,7 @@ async fn upload_image(
async fn upload_image_bytes(
job: &FalJobDetails,
server_state: &ServerState,
image: &FalWebhookImage,
image: &ImagesData,
file_bytes: &[u8],
mimetype_info: MimetypeInfo,
maybe_batch_token: Option<&BatchGenerationToken>,
@@ -7,12 +7,12 @@ use enums::by_table::media_files::media_file_engine_category::MediaFileEngineCat
use enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory;
use enums::by_table::media_files::media_file_type::MediaFileType;
use errors::AnyhowResult;
use fal_client::webhook_api::hydrated::hydrated_webhook_contents::{ModelGlbData, ThumbnailData};
use hashing::sha256::sha256_hash_bytes::sha256_hash_bytes;
use log::{info, warn};
use mimetypes::mimetype_info::mimetype_info::MimetypeInfo;
use mysql_queries::queries::generic_inference::fal::get_inference_job_by_fal_id::FalJobDetails;
use mysql_queries::queries::media_files::create::insert_builder::media_file_insert_builder::MediaFileInsertBuilder;
use serde_json::{Map, Value};
use mysql_queries::queries::media_files::edit::set_media_file_cover_image::{set_media_file_cover_image, UpdateArgs};
use tokens::tokens::media_files::MediaFileToken;
@@ -55,34 +55,15 @@ const PREFIX : Option<&str> = Some("artcraft_");
*/
#[derive(Deserialize, Debug)]
pub struct FalMediaPayload {
pub content_type: Option<String>,
pub file_name: Option<String>,
pub file_size: Option<usize>,
pub url: Option<String>,
}
pub async fn process_model_glb_payload(
payload: &Map<String, Value>,
model_glb_data: &ModelGlbData,
maybe_thumbnail_data: Option<&ThumbnailData>,
job: &FalJobDetails,
server_state: &ServerState,
) -> AnyhowResult<MediaFileToken> {
let model_glb_value = payload.get("model_glb")
.ok_or_else(|| anyhow!("no `model_mesh` key in payload"))?;
info!("Fal Model Glb Payload: {:?}", model_glb_value);
let mesh: FalMediaPayload = serde_json::from_value(model_glb_value.clone())?;
let mesh_url = mesh.url
let mesh_url = model_glb_data.url
.as_deref()
.ok_or_else(|| anyhow!("no `url` in image payload"))?;
//let mime_type = mesh.content_type
// .as_deref()
// .ok_or_else(|| anyhow!("no `content_type` in mesh payload"))?;
.ok_or_else(|| anyhow!("no `url` in model glb payload"))?;
let file_bytes = http_download_url_to_bytes(mesh_url)
.await
@@ -135,7 +116,7 @@ pub async fn process_model_glb_payload(
info!("Glb media file uploaded with token: {}", media_token);
let result = try_to_attach_thumbnail(
payload,
maybe_thumbnail_data,
job,
server_state,
&media_token,
@@ -150,33 +131,30 @@ pub async fn process_model_glb_payload(
}
async fn try_to_attach_thumbnail(
payload: &Map<String, Value>,
maybe_thumbnail_data: Option<&ThumbnailData>,
job: &FalJobDetails,
server_state: &ServerState,
glb_media_token: &MediaFileToken,
) -> AnyhowResult<()> {
let thumbnail_data = maybe_thumbnail_data
.ok_or_else(|| anyhow!("no thumbnail data in extracted contents"))?;
let thumbnail_value = payload.get("thumbnail")
.ok_or_else(|| anyhow!("no `thumbnail` key in payload"))?;
info!("Fal Thumbnail Data: {:?}", thumbnail_data);
info!("Fal Thumbnail Payload: {:?}", thumbnail_value);
let thumbnail : FalMediaPayload = serde_json::from_value(thumbnail_value.clone())?;
let mesh_url = thumbnail.url
let thumbnail_url = thumbnail_data.url
.as_deref()
.ok_or_else(|| anyhow!("no `url` in image payload"))?;
.ok_or_else(|| anyhow!("no `url` in thumbnail payload"))?;
let file_bytes = http_download_url_to_bytes(mesh_url)
let file_bytes = http_download_url_to_bytes(thumbnail_url)
.await
.map_err(|e| anyhow!("Failed to download image: {:?}", e))?;
.map_err(|e| anyhow!("Failed to download thumbnail image: {:?}", e))?;
let mimetype_info = MimetypeInfo::get_for_bytes(&file_bytes)
.ok_or_else(|| anyhow!("Failed to get mimetype info"))?;
let mime_type = mimetype_info.mime_type();
info!("Mime type of image: {}", mime_type);
info!("Mime type of thumbnail image: {}", mime_type);
let media_file_type = MediaFileType::try_from_mime_type(mime_type)
.ok_or_else(|| anyhow!("Unsupported media file type: {}", mime_type))?;
@@ -7,44 +7,24 @@ use enums::by_table::media_files::media_file_engine_category::MediaFileEngineCat
use enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory;
use enums::by_table::media_files::media_file_type::MediaFileType;
use errors::AnyhowResult;
use fal_client::webhook_api::hydrated::hydrated_webhook_contents::ModelMeshData;
use hashing::sha256::sha256_hash_bytes::sha256_hash_bytes;
use log::info;
use mimetypes::mimetype_info::mimetype_info::MimetypeInfo;
use mysql_queries::queries::generic_inference::fal::get_inference_job_by_fal_id::FalJobDetails;
use mysql_queries::queries::media_files::create::insert_builder::media_file_insert_builder::MediaFileInsertBuilder;
use serde_json::{Map, Value};
use tokens::tokens::media_files::MediaFileToken;
const PREFIX : Option<&str> = Some("artcraft_");
#[derive(Deserialize, Debug)]
pub struct FalWebhookModelMesh {
pub content_type: Option<String>,
pub file_name: Option<String>,
pub file_size: Option<usize>,
pub url: Option<String>,
}
pub async fn process_model_mesh_payload(
payload: &Map<String, Value>,
model_mesh_data: &ModelMeshData,
job: &FalJobDetails,
server_state: &ServerState,
) -> AnyhowResult<MediaFileToken> {
let model_mesh_value = payload.get("model_mesh")
.ok_or_else(|| anyhow!("no `model_mesh` key in payload"))?;
info!("Fal Model Mesh Payload: {:?}", model_mesh_value);
let mesh: FalWebhookModelMesh = serde_json::from_value(model_mesh_value.clone())?;
let mesh_url = mesh.url
let mesh_url = model_mesh_data.url
.as_deref()
.ok_or_else(|| anyhow!("no `url` in image payload"))?;
//let mime_type = mesh.content_type
// .as_deref()
// .ok_or_else(|| anyhow!("no `content_type` in mesh payload"))?;
.ok_or_else(|| anyhow!("no `url` in model mesh payload"))?;
let file_bytes = http_download_url_to_bytes(mesh_url)
.await
@@ -6,13 +6,13 @@ 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 errors::AnyhowResult;
use fal_client::webhook_api::hydrated::hydrated_webhook_contents::VideoData;
use filesys::path_to_string::path_to_string;
use hashing::sha256::sha256_hash_bytes::sha256_hash_bytes;
use log::{error, info};
use mimetypes::mimetype_info::mimetype_info::MimetypeInfo;
use mysql_queries::queries::generic_inference::fal::get_inference_job_by_fal_id::FalJobDetails;
use mysql_queries::queries::media_files::create::insert_builder::media_file_insert_builder::MediaFileInsertBuilder;
use serde_json::{Map, Value};
use std::io::Write;
use tempfile::NamedTempFile;
use thumbnail_generator::task_client::thumbnail_task::{ThumbnailTaskBuilder, ThumbnailTaskInputMimeType};
@@ -21,36 +21,14 @@ use ffmpeg_utils::ffprobe::ffprobe_get_info::ffprobe_get_info;
const PREFIX : Option<&str> = Some("artcraft_");
#[derive(Deserialize, Debug)]
pub struct FalWebhookVideo {
pub content_type: Option<String>,
pub file_name: Option<String>,
pub file_size: Option<usize>,
pub height: Option<usize>, // NB: Might not be on videos
pub width: Option<usize>, // NB: Might not be on videos
pub url: Option<String>,
}
pub async fn process_video_payload(
payload: &Map<String, Value>,
video_data: &VideoData,
job: &FalJobDetails,
server_state: &ServerState,
) -> AnyhowResult<MediaFileToken> {
let video_value = payload.get("video")
.ok_or_else(|| anyhow!("no `image` key in payload"))?;
info!("Fal Video Payload: {:?}", video_value);
let video: FalWebhookVideo = serde_json::from_value(video_value.clone())?;
let video_url = video.url
let video_url = video_data.url
.as_deref()
.ok_or_else(|| anyhow!("no `url` in video payload"))?;
//let mime_type = image.content_type
// .as_deref()
// .ok_or_else(|| anyhow!("no `content_type` in image payload"))?;
let file_bytes = http_download_url_to_bytes(video_url)
.await