Compare commits

...
Author SHA1 Message Date
Brandon Thomas 0312e3abea meshy results 2026-07-10 16:41:19 -04:00
Brandon Thomas ab93790332 different location for thumbnail 2026-07-10 16:36:24 -04:00
Brandon Thomas c414185450 failure category for not supported format 2026-07-10 16:28:05 -04:00
Brandon Thomas dffe07bfb1 test 2026-07-10 16:16:17 -04:00
Brandon Thomas 3fe04d0bf3 extra tests 2026-07-10 15:48:28 -04:00
Brandon Thomas cebe1b5f4b extractors for Hunyuan 3d 3.0 2026-07-10 15:42:57 -04:00
Brandon Thomas 2c3ff4a507 hunyuan 3d 2.1 2026-07-10 14:48:58 -04:00
Brandon Thomas c91b6be429 add failure reason 2026-07-10 14:40:44 -04:00
Brandon Thomas 762498daa1 upload triposplat ply and attach cover image 2026-07-10 14:20:56 -04:00
28 changed files with 1664 additions and 225 deletions
@@ -182,6 +182,46 @@ mod tests {
}
}
#[test]
fn hunyuan_3d_part_format_not_supported() {
let webhook = load_test_webhook("failure/hunyuan_3d_3p1_part_fail_format_not_supported.json");
let result = hydrate_webhook_contents(&webhook);
match result {
HydratedWebhookContents::Error(data) => {
assert_eq!(
data.error_type,
Some(WebhookErrorType::ValueError),
);
assert_eq!(
data.message.as_deref(),
Some("Part generation only supports FBX format. Please provide an FBX file or use /convert-format to convert your model to FBX first."),
);
}
other => panic!("Expected HydratedWebhookContents::Error, got {:?}", other),
}
}
#[test]
fn triposplat_no_foreground_subject() {
let webhook = load_test_webhook("failure/triposplat_fail_no_foreground_subject.json");
let result = hydrate_webhook_contents(&webhook);
match result {
HydratedWebhookContents::Error(data) => {
assert_eq!(
data.error_type,
Some(WebhookErrorType::InputValueError),
);
assert_eq!(
data.message.as_deref(),
Some("No foreground subject could be detected in the input image after background removal. Provide an image with a clear, visible subject against a plain or distinct background."),
);
}
other => panic!("Expected HydratedWebhookContents::Error, got {:?}", other),
}
}
#[test]
fn payload_error_case() {
let webhook = RawWebhookPayload {
@@ -46,7 +46,17 @@ pub struct ExtractedContents {
/// Parsed from `payload.model_glb`.
pub model_glb: Option<ModelGlbData>,
/// Parsed from `payload.model_glb_pbr` (e.g. Hunyuan 3D 2.1's PBR-textured
/// GLB variant, sent alongside `model_glb`).
pub model_glb_pbr: Option<ModelGlbData>,
/// Parsed from `payload.model_urls` (e.g. Hunyuan 3D 3.0's per-format file
/// map, sent alongside `model_glb`).
pub model_urls: Option<ModelUrlsData>,
/// Parsed from `payload.model_mesh`.
/// NB: `triposplat` ply gaussian splat files also arrive via this payload handler.
/// These are decidedly *not* "mesh" files!
pub model_mesh: Option<ModelMeshData>,
/// Parsed from `payload.model_obj` (e.g. Hunyuan 3D v3.1 Rapid's OBJ output).
@@ -57,6 +67,14 @@ pub struct ExtractedContents {
/// Parsed from `payload.thumbnail`.
pub thumbnail: Option<ThumbnailData>,
/// Parsed from `payload.rendered_image` (e.g. Tripo 3D's preview image,
/// used as a cover image like `thumbnail`).
pub rendered_image: Option<ThumbnailData>,
/// Parsed from `payload.preprocessed_image` (e.g. TripoSplat's segmented
/// input image, usable as a cover image for the splat result).
pub preprocessed_image: Option<PreprocessedImageData>,
}
/// Data under `payload.image`:
@@ -90,7 +108,8 @@ pub struct VideoData {
pub file_size: Option<u64>,
}
/// Data under `payload.model_glb` (there may be other sibling keys too)
/// Data under `payload.model_glb` (there may be other sibling keys too).
/// Also used for `payload.model_glb_pbr`, which has the same shape.
#[derive(Debug, Deserialize)]
pub struct ModelGlbData {
pub content_type: Option<String>,
@@ -100,6 +119,8 @@ pub struct ModelGlbData {
}
/// Data under `payload.model_mesh` (there may be other sibling keys too)
/// NB: `triposplat` ply gaussian splat files also arrive via this payload.
/// These are decidedly *not* "mesh" files!
#[derive(Debug, Deserialize)]
pub struct ModelMeshData {
pub content_type: Option<String>,
@@ -108,6 +129,31 @@ pub struct ModelMeshData {
pub url: Option<String>,
}
/// Data under `payload.model_urls` (e.g. Hunyuan 3D 3.0 / 3.1, Tripo 3D): a
/// map of the generation's output files by format. Any slot may be null or
/// absent. The `glb` entry frequently duplicates the top-level model key
/// (same URL), but may point to a different file. Slots may also duplicate
/// each other's URLs (e.g. Tripo 3D's `glb` and `pbr_model`).
#[derive(Debug, Deserialize)]
pub struct ModelUrlsData {
/// Untextured base model (Tripo 3D).
pub base_model: Option<ModelGlbData>,
/// Blender project file (Meshy).
pub blend: Option<ModelGlbData>,
pub fbx: Option<ModelGlbData>,
pub glb: Option<ModelGlbData>,
/// OBJ material file (Hunyuan 3D 3.1).
pub mtl: Option<ModelGlbData>,
pub obj: Option<ModelGlbData>,
/// PBR-textured model (Tripo 3D).
pub pbr_model: Option<ModelGlbData>,
/// STL model (Meshy).
pub stl: Option<ModelGlbData>,
/// PBR texture image for the OBJ (Hunyuan 3D 3.1).
pub texture: Option<ModelGlbData>,
pub usdz: Option<ModelGlbData>,
}
/// Data under `payload.model_obj` (there may be other sibling keys too).
/// Some endpoints (e.g. Hunyuan 3D v3.1 Rapid text-to-3d) return an OBJ
/// model instead of (or alongside) a GLB.
@@ -132,6 +178,7 @@ pub struct ResultFileData {
/// Data under `payload.thumbnail` (there may be other sibling keys too)
/// Frequently seen together with `model_glb`.
/// Also used for `payload.rendered_image` (Tripo 3D), which has the same shape.
#[derive(Debug, Deserialize)]
pub struct ThumbnailData {
pub content_type: Option<String>,
@@ -140,6 +187,19 @@ pub struct ThumbnailData {
pub url: Option<String>,
}
/// Data under `payload.preprocessed_image` (there may be other sibling keys too).
/// Seen together with `model_mesh` in TripoSplat results: the segmented input
/// image, which we use as a cover image for the splat.
#[derive(Debug, Deserialize)]
pub struct PreprocessedImageData {
pub content_type: Option<String>,
pub file_name: Option<String>,
pub file_size: Option<usize>,
pub height: Option<u64>,
pub url: Option<String>,
pub width: Option<u64>,
}
#[derive(Debug)]
pub struct ErrorData {
/// The first human-readable message from `payload.detail[].msg`, if any.
@@ -0,0 +1,87 @@
use serde_json::{Map, Value};
use crate::webhook_payload::hydrated::hydrated_webhook_contents::ModelGlbData;
/// Extract and deserialize the `model_glb_pbr` key from a webhook success
/// payload. Sent alongside `model_glb` by e.g. Hunyuan 3D 2.1; same shape.
pub(crate) fn extract_model_glb_pbr(obj: &Map<String, Value>) -> Option<ModelGlbData> {
let value = obj.get("model_glb_pbr")?;
serde_json::from_value(value.clone()).ok()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::webhook_payload::hydrate_webhook_contents::hydrate_webhook_contents;
use crate::webhook_payload::hydrated::hydrated_webhook_contents::HydratedWebhookContents;
use crate::webhook_payload::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_glbs_from_hunyuan_3d_2p1_test_file() {
let webhook = load_test_webhook("success/hunyuan_3d_2p1_model_glbs_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 glb = contents.model_glb.expect("model_glb should be Some");
assert_eq!(glb.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1b916/QKdBTd8WJW_rtPB3IwlEN_demo_textured.glb"));
assert_eq!(glb.content_type.as_deref(), Some("application/octet-stream"));
assert_eq!(glb.file_name.as_deref(), Some("demo_textured.glb"));
assert_eq!(glb.file_size, Some(1574216));
let glb_pbr = contents.model_glb_pbr.expect("model_glb_pbr should be Some");
assert_eq!(glb_pbr.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1b921/SJw7AARzkc4l9QG5Lrvmu_demo_textured_pbr.glb"));
assert_eq!(glb_pbr.content_type.as_deref(), Some("application/octet-stream"));
assert_eq!(glb_pbr.file_name.as_deref(), Some("demo_textured_pbr.glb"));
assert_eq!(glb_pbr.file_size, Some(7512760));
// The payload also carries a `model_mesh` zip archive of the whole
// generation. It parses out, but the webhook handler prefers the GLBs.
let mesh = contents.model_mesh.expect("model_mesh should be Some");
assert_eq!(mesh.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1b915/B25DrTdiZJNV-60RaUh4u_3d_model.zip"));
assert_eq!(mesh.file_name.as_deref(), Some("3d_model.zip"));
assert!(contents.thumbnail.is_none());
assert!(contents.preprocessed_image.is_none());
}
#[test]
fn synthetic_model_glb_pbr_payload() {
let obj: serde_json::Map<String, serde_json::Value> = serde_json::from_str(r#"{
"model_glb_pbr": {
"url": "https://cdn.example.com/model_pbr.glb",
"content_type": "model/gltf-binary",
"file_name": "model_pbr.glb",
"file_size": 7654321
}
}"#).unwrap();
let glb_pbr = extract_model_glb_pbr(&obj).expect("should extract model_glb_pbr");
assert_eq!(glb_pbr.url.as_deref(), Some("https://cdn.example.com/model_pbr.glb"));
assert_eq!(glb_pbr.content_type.as_deref(), Some("model/gltf-binary"));
assert_eq!(glb_pbr.file_name.as_deref(), Some("model_pbr.glb"));
assert_eq!(glb_pbr.file_size, Some(7654321));
}
#[test]
fn missing_model_glb_pbr_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_model_glb_pbr(&obj).is_none());
}
}
@@ -49,6 +49,33 @@ mod tests {
assert!(contents.thumbnail.is_none());
}
#[test]
fn triposplat_ply_splat_from_test_file() {
// TripoSplat sends its PLY gaussian splat under `model_mesh`.
let webhook = load_test_webhook("success/triposplat_splat_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/0aa1b77c/hEzg5xn9A-1qrLsnywjN4_output.ply"));
assert_eq!(mesh.content_type.as_deref(), Some("application/octet-stream"));
assert_eq!(mesh.file_name.as_deref(), Some("output.ply"));
assert_eq!(mesh.file_size, Some(17826208));
// The segmented input image rides along as `preprocessed_image`.
assert!(contents.preprocessed_image.is_some());
assert!(contents.model_glb.is_none());
assert!(contents.model_glb_pbr.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#"{
@@ -0,0 +1,327 @@
use serde_json::{Map, Value};
use crate::webhook_payload::hydrated::hydrated_webhook_contents::ModelUrlsData;
/// Extract and deserialize the `model_urls` key from a webhook success
/// payload (e.g. Hunyuan 3D 3.0's per-format file map).
pub(crate) fn extract_model_urls(obj: &Map<String, Value>) -> Option<ModelUrlsData> {
let value = obj.get("model_urls")?;
serde_json::from_value(value.clone()).ok()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::webhook_payload::hydrate_webhook_contents::hydrate_webhook_contents;
use crate::webhook_payload::hydrated::hydrated_webhook_contents::HydratedWebhookContents;
use crate::webhook_payload::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_urls_from_hunyuan_3d_3p0_test_file() {
let webhook = load_test_webhook("success/hunyuan_3d_3p0_model_urls_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 glb = contents.model_glb.expect("model_glb should be Some");
assert_eq!(glb.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1b969/k5FZWmTsKxHH71404bBR__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(51001160));
let model_urls = contents.model_urls.expect("model_urls should be Some");
// The `glb` slot duplicates `model_glb` in this payload (same URL).
let urls_glb = model_urls.glb.expect("model_urls.glb should be Some");
assert_eq!(urls_glb.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1b969/k5FZWmTsKxHH71404bBR__model.glb"));
let urls_obj = model_urls.obj.expect("model_urls.obj should be Some");
assert_eq!(urls_obj.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1b968/xngvKxLugXUlNh89Fp0K4_model.obj"));
assert_eq!(urls_obj.content_type.as_deref(), Some("text/plain"));
assert_eq!(urls_obj.file_name.as_deref(), Some("model.obj"));
assert_eq!(urls_obj.file_size, Some(41107268));
assert!(model_urls.fbx.is_none());
assert!(model_urls.mtl.is_none());
assert!(model_urls.texture.is_none());
assert!(model_urls.usdz.is_none());
let thumbnail = contents.thumbnail.expect("thumbnail should be Some");
assert_eq!(thumbnail.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1b969/EPgha8Kee7YZCAccjFUU7_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(136476));
assert!(contents.model_glb_pbr.is_none());
assert!(contents.model_mesh.is_none());
assert!(contents.preprocessed_image.is_none());
}
#[test]
fn model_urls_and_thumbnail_from_hunyuan_3d_3p1_test_file() {
let webhook = load_test_webhook("success/hunyuan_3d_3p1_model_urls_thumbnail_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 glb = contents.model_glb.expect("model_glb should be Some");
assert_eq!(glb.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1ba44/OdgIv8M9EkaBP_Mh4TVAB_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(64261364));
let model_urls = contents.model_urls.expect("model_urls should be Some");
// The `glb` slot duplicates `model_glb` in this payload (same URL).
let urls_glb = model_urls.glb.expect("model_urls.glb should be Some");
assert_eq!(urls_glb.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1ba44/OdgIv8M9EkaBP_Mh4TVAB_model.glb"));
let urls_obj = model_urls.obj.expect("model_urls.obj should be Some");
assert_eq!(urls_obj.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1ba43/WxPMNbzsDxaGRCmGNgNAL_c3a871e997ecd5f889bf671630de23b4.obj"));
assert_eq!(urls_obj.content_type.as_deref(), Some("model/obj"));
assert_eq!(urls_obj.file_size, Some(34563830));
// 3.1 additionally ships the OBJ's material and PBR texture files.
let urls_mtl = model_urls.mtl.expect("model_urls.mtl should be Some");
assert_eq!(urls_mtl.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1ba42/1T5VCp_xD0zCJlTLE96iK_material.mtl"));
assert_eq!(urls_mtl.content_type.as_deref(), Some("text/plain"));
assert_eq!(urls_mtl.file_name.as_deref(), Some("material.mtl"));
assert_eq!(urls_mtl.file_size, Some(245));
let urls_texture = model_urls.texture.expect("model_urls.texture should be Some");
assert_eq!(urls_texture.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1ba43/aKp3mMB9nrhlKfV2KVf0S_texture_pbr_20250901.png"));
assert_eq!(urls_texture.content_type.as_deref(), Some("image/png"));
assert_eq!(urls_texture.file_name.as_deref(), Some("texture_pbr_20250901.png"));
assert_eq!(urls_texture.file_size, Some(22794575));
assert!(model_urls.fbx.is_none());
assert!(model_urls.usdz.is_none());
// The thumbnail is attached to the GLB as its cover image downstream.
let thumbnail = contents.thumbnail.expect("thumbnail should be Some");
assert_eq!(thumbnail.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1ba44/njq8zYhYahJNNQkGGiEZW_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(141254));
assert!(contents.model_glb_pbr.is_none());
assert!(contents.model_mesh.is_none());
assert!(contents.preprocessed_image.is_none());
}
#[test]
fn obj_and_thumbnail_from_hunyuan_3d_3p1_rapid_test_file() {
let webhook = load_test_webhook("success/hunyuan_3d_3p1_rapid_obj_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");
// NB: Hunyuan 3D 3.1 Rapid puts an OBJ under the `model_glb` key. The
// key name doesn't matter downstream: the webhook handler resolves the
// real file type from content_type / file name, so it uploads as an OBJ
// mesh.
let glb = contents.model_glb.expect("model_glb should be Some");
assert_eq!(glb.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bae3/22zStkYp8-q6npG9dsMs5_23dce35fc95b34722f15a7329061d5df.obj"));
assert_eq!(glb.content_type.as_deref(), Some("model/obj"));
assert_eq!(glb.file_name.as_deref(), Some("23dce35fc95b34722f15a7329061d5df.obj"));
assert_eq!(glb.file_size, Some(3258750));
let model_urls = contents.model_urls.expect("model_urls should be Some");
// The `glb` slot is null here; `obj` duplicates `model_glb` (same URL).
assert!(model_urls.glb.is_none());
assert!(model_urls.fbx.is_none());
assert!(model_urls.usdz.is_none());
let urls_obj = model_urls.obj.expect("model_urls.obj should be Some");
assert_eq!(urls_obj.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bae3/22zStkYp8-q6npG9dsMs5_23dce35fc95b34722f15a7329061d5df.obj"));
assert_eq!(urls_obj.content_type.as_deref(), Some("model/obj"));
assert_eq!(urls_obj.file_size, Some(3258750));
let urls_mtl = model_urls.mtl.expect("model_urls.mtl should be Some");
assert_eq!(urls_mtl.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bae3/5cbE-q28i44GUOO6YeZPH_material.mtl"));
assert_eq!(urls_mtl.file_size, Some(229));
let urls_texture = model_urls.texture.expect("model_urls.texture should be Some");
assert_eq!(urls_texture.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bae3/7eBRddTHf6U8SUg2QOIHw_texture_pbr_v128.png"));
assert_eq!(urls_texture.file_name.as_deref(), Some("texture_pbr_v128.png"));
assert_eq!(urls_texture.file_size, Some(5503289));
// The thumbnail is attached to the mesh as its cover image downstream.
let thumbnail = contents.thumbnail.expect("thumbnail should be Some");
assert_eq!(thumbnail.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bae3/IO3aspD4ijEadoFxSF504_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(120530));
assert!(contents.model_glb_pbr.is_none());
assert!(contents.model_mesh.is_none());
assert!(contents.model_obj.is_none());
assert!(contents.preprocessed_image.is_none());
}
#[test]
fn model_urls_and_rendered_image_from_tripo3d_test_file() {
let webhook = load_test_webhook("success/tripo3d_model_urls_rendered_image_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");
// Tripo 3D sends its GLB under `model_mesh` (not `model_glb`); the
// webhook handler picks `model_urls.glb` as the primary GLB instead.
assert!(contents.model_glb.is_none());
let mesh = contents.model_mesh.expect("model_mesh should be Some");
assert_eq!(mesh.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bbdb/d8ATWvvXYHY-8zj-lQecf_model.glb"));
assert_eq!(mesh.content_type.as_deref(), Some("model/gltf-binary"));
let model_urls = contents.model_urls.expect("model_urls should be Some");
// `glb` and `pbr_model` duplicate each other (same URL) in this payload;
// the webhook handler dedupes by URL and uploads the file only once.
let urls_glb = model_urls.glb.expect("model_urls.glb should be Some");
assert_eq!(urls_glb.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bbdb/d8ATWvvXYHY-8zj-lQecf_model.glb"));
assert_eq!(urls_glb.file_size, Some(43981280));
let urls_pbr_model = model_urls.pbr_model.expect("model_urls.pbr_model should be Some");
assert_eq!(urls_pbr_model.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bbdb/d8ATWvvXYHY-8zj-lQecf_model.glb"));
assert!(model_urls.base_model.is_none());
assert!(model_urls.fbx.is_none());
assert!(model_urls.mtl.is_none());
assert!(model_urls.obj.is_none());
assert!(model_urls.texture.is_none());
assert!(model_urls.usdz.is_none());
// The preview arrives as `rendered_image` (no `thumbnail` key) and is
// attached to the mesh as its cover image downstream.
let rendered_image = contents.rendered_image.expect("rendered_image should be Some");
assert_eq!(rendered_image.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bbd0/MblV2S5R6CeskRABMS7-V_preview.png"));
assert!(contents.thumbnail.is_none());
assert!(contents.model_glb_pbr.is_none());
assert!(contents.preprocessed_image.is_none());
}
#[test]
fn model_urls_and_thumbnail_from_meshy_test_file() {
let webhook = load_test_webhook("success/meshy_model_urls_thumbnail_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 glb = contents.model_glb.expect("model_glb should be Some");
assert_eq!(glb.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bbe1/0vrf9NUhVxKZO4GScA0EI_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(15943768));
let model_urls = contents.model_urls.expect("model_urls should be Some");
// The `glb` slot duplicates `model_glb` (same URL); the webhook handler
// dedupes by URL so the file uploads only once.
let urls_glb = model_urls.glb.expect("model_urls.glb should be Some");
assert_eq!(urls_glb.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bbe1/0vrf9NUhVxKZO4GScA0EI_model.glb"));
// Alternate formats of the same model; parsed but not uploaded.
let urls_fbx = model_urls.fbx.expect("model_urls.fbx should be Some");
assert_eq!(urls_fbx.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bbed/yKO-HOjBnWA-LZpRLHHSB_model.fbx"));
assert_eq!(urls_fbx.file_size, Some(13665964));
let urls_obj = model_urls.obj.expect("model_urls.obj should be Some");
assert_eq!(urls_obj.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bbe2/NBmqIWBS8LuY_BPYWkOOf_model.obj"));
let urls_stl = model_urls.stl.expect("model_urls.stl should be Some");
assert_eq!(urls_stl.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bbe2/3wtOE6iEeVHZFvwAGN6Rl_model.stl"));
assert_eq!(urls_stl.content_type.as_deref(), Some("application/octet-stream"));
assert_eq!(urls_stl.file_size, Some(1508384));
let urls_usdz = model_urls.usdz.expect("model_urls.usdz should be Some");
assert_eq!(urls_usdz.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bbed/BsniPBo6Uev85V4hLFACR_model.usdz"));
assert_eq!(urls_usdz.content_type.as_deref(), Some("model/vnd.usdz+zip"));
assert!(model_urls.base_model.is_none());
assert!(model_urls.blend.is_none());
assert!(model_urls.mtl.is_none());
assert!(model_urls.pbr_model.is_none());
assert!(model_urls.texture.is_none());
// The thumbnail is attached to the GLB as its cover image downstream.
let thumbnail = contents.thumbnail.expect("thumbnail should be Some");
assert_eq!(thumbnail.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bbe3/tn1qfOr12oGXecA4mRtlI_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(192910));
assert!(contents.model_glb_pbr.is_none());
assert!(contents.model_mesh.is_none());
assert!(contents.rendered_image.is_none());
assert!(contents.preprocessed_image.is_none());
}
#[test]
fn synthetic_model_urls_payload_with_distinct_glb() {
let obj: serde_json::Map<String, serde_json::Value> = serde_json::from_str(r#"{
"model_urls": {
"fbx": null,
"glb": {
"url": "https://cdn.example.com/model_alt.glb",
"content_type": "model/gltf-binary",
"file_name": "model_alt.glb",
"file_size": 1234567
},
"obj": null,
"usdz": null
}
}"#).unwrap();
let model_urls = extract_model_urls(&obj).expect("should extract model_urls");
let glb = model_urls.glb.expect("glb slot should be Some");
assert_eq!(glb.url.as_deref(), Some("https://cdn.example.com/model_alt.glb"));
assert_eq!(glb.file_size, Some(1234567));
assert!(model_urls.fbx.is_none());
assert!(model_urls.obj.is_none());
assert!(model_urls.usdz.is_none());
}
#[test]
fn missing_model_urls_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_model_urls(&obj).is_none());
}
}
@@ -0,0 +1,84 @@
use serde_json::{Map, Value};
use crate::webhook_payload::hydrated::hydrated_webhook_contents::PreprocessedImageData;
/// Extract and deserialize the `preprocessed_image` key from a webhook success payload.
pub(crate) fn extract_preprocessed_image(obj: &Map<String, Value>) -> Option<PreprocessedImageData> {
let value = obj.get("preprocessed_image")?;
serde_json::from_value(value.clone()).ok()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::webhook_payload::hydrate_webhook_contents::hydrate_webhook_contents;
use crate::webhook_payload::hydrated::hydrated_webhook_contents::HydratedWebhookContents;
use crate::webhook_payload::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 preprocessed_image_from_triposplat_test_file() {
let webhook = load_test_webhook("success/triposplat_splat_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 image = contents.preprocessed_image.expect("preprocessed_image should be Some");
assert_eq!(image.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1b77c/YyJ19g7lJsRNXmvZvahTE_61fc73dbe84446b08b1fcce2167b71d5.png"));
assert_eq!(image.content_type.as_deref(), Some("image/png"));
assert_eq!(image.file_name.as_deref(), Some("61fc73dbe84446b08b1fcce2167b71d5.png"));
assert_eq!(image.file_size, Some(952754));
assert_eq!(image.width, Some(1024));
assert_eq!(image.height, Some(1024));
// The splat itself arrives under `model_mesh`.
let mesh = contents.model_mesh.expect("model_mesh should be Some");
assert_eq!(mesh.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1b77c/hEzg5xn9A-1qrLsnywjN4_output.ply"));
assert_eq!(mesh.content_type.as_deref(), Some("application/octet-stream"));
assert_eq!(mesh.file_name.as_deref(), Some("output.ply"));
assert_eq!(mesh.file_size, Some(17826208));
}
#[test]
fn synthetic_preprocessed_image_payload() {
let obj: serde_json::Map<String, serde_json::Value> = serde_json::from_str(r#"{
"preprocessed_image": {
"url": "https://cdn.example.com/segmented.png",
"content_type": "image/png",
"file_name": "segmented.png",
"file_size": 12345,
"width": 1024,
"height": 1024
}
}"#).unwrap();
let image = extract_preprocessed_image(&obj).expect("should extract preprocessed_image");
assert_eq!(image.url.as_deref(), Some("https://cdn.example.com/segmented.png"));
assert_eq!(image.content_type.as_deref(), Some("image/png"));
assert_eq!(image.file_name.as_deref(), Some("segmented.png"));
assert_eq!(image.file_size, Some(12345));
assert_eq!(image.width, Some(1024));
assert_eq!(image.height, Some(1024));
}
#[test]
fn missing_preprocessed_image_key_returns_none() {
let obj: serde_json::Map<String, serde_json::Value> = serde_json::from_str(r#"{
"model_mesh": {"url": "https://example.com/output.ply"}
}"#).unwrap();
assert!(extract_preprocessed_image(&obj).is_none());
}
}
@@ -0,0 +1,76 @@
use serde_json::{Map, Value};
use crate::webhook_payload::hydrated::hydrated_webhook_contents::ThumbnailData;
/// Extract and deserialize the `rendered_image` key from a webhook success
/// payload (e.g. Tripo 3D's preview image). Same shape as `thumbnail`, and
/// used the same way: as a cover image for the model.
pub(crate) fn extract_rendered_image(obj: &Map<String, Value>) -> Option<ThumbnailData> {
let value = obj.get("rendered_image")?;
serde_json::from_value(value.clone()).ok()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::webhook_payload::hydrate_webhook_contents::hydrate_webhook_contents;
use crate::webhook_payload::hydrated::hydrated_webhook_contents::HydratedWebhookContents;
use crate::webhook_payload::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 rendered_image_from_tripo3d_test_file() {
let webhook = load_test_webhook("success/tripo3d_model_urls_rendered_image_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 rendered_image = contents.rendered_image.expect("rendered_image should be Some");
assert_eq!(rendered_image.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bbd0/MblV2S5R6CeskRABMS7-V_preview.png"));
assert_eq!(rendered_image.content_type.as_deref(), Some("image/png"));
assert_eq!(rendered_image.file_name.as_deref(), Some("preview.png"));
assert_eq!(rendered_image.file_size, Some(30970));
// Tripo has no `thumbnail` key; the preview only arrives here.
assert!(contents.thumbnail.is_none());
}
#[test]
fn synthetic_rendered_image_payload() {
let obj: serde_json::Map<String, serde_json::Value> = serde_json::from_str(r#"{
"rendered_image": {
"url": "https://cdn.example.com/preview.png",
"content_type": "image/png",
"file_name": "preview.png",
"file_size": 12345
}
}"#).unwrap();
let image = extract_rendered_image(&obj).expect("should extract rendered_image");
assert_eq!(image.url.as_deref(), Some("https://cdn.example.com/preview.png"));
assert_eq!(image.content_type.as_deref(), Some("image/png"));
assert_eq!(image.file_name.as_deref(), Some("preview.png"));
assert_eq!(image.file_size, Some(12345));
}
#[test]
fn missing_rendered_image_key_returns_none() {
let obj: serde_json::Map<String, serde_json::Value> = serde_json::from_str(r#"{
"thumbnail": {"url": "https://example.com/preview.png"}
}"#).unwrap();
assert!(extract_rendered_image(&obj).is_none());
}
}
@@ -2,8 +2,12 @@ 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_glb_pbr_payload;
pub mod extract_model_mesh_payload;
pub mod extract_model_obj_payload;
pub mod extract_model_urls_payload;
pub mod extract_preprocessed_image_payload;
pub mod extract_rendered_image_payload;
pub mod extract_result_files_payload;
pub mod extract_thumbnail_payload;
@@ -14,7 +18,9 @@ use crate::webhook_payload::hydrated::hydrated_webhook_contents::ExtractedConten
/// 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
/// `video`, `model_glb`, `model_glb_pbr`, `model_urls`, `model_mesh`,
/// `model_obj`, `result_files`, `thumbnail`, `rendered_image`,
/// `preprocessed_image`. 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.
@@ -25,14 +31,20 @@ pub fn extract_contents_from_payload(payload: &Value) -> Option<ExtractedContent
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_glb_pbr = extract_model_glb_pbr_payload::extract_model_glb_pbr(obj);
let model_urls = extract_model_urls_payload::extract_model_urls(obj);
let model_mesh = extract_model_mesh_payload::extract_model_mesh(obj);
let model_obj = extract_model_obj_payload::extract_model_obj(obj);
let result_files = extract_result_files_payload::extract_result_files(obj);
let thumbnail = extract_thumbnail_payload::extract_thumbnail(obj);
let rendered_image = extract_rendered_image_payload::extract_rendered_image(obj);
let preprocessed_image = extract_preprocessed_image_payload::extract_preprocessed_image(obj);
if image.is_none() && images.is_none() && video.is_none()
&& model_glb.is_none() && model_mesh.is_none() && model_obj.is_none()
&& result_files.is_none() && thumbnail.is_none()
&& model_glb.is_none() && model_glb_pbr.is_none() && model_urls.is_none()
&& model_mesh.is_none() && model_obj.is_none()
&& result_files.is_none() && thumbnail.is_none() && rendered_image.is_none()
&& preprocessed_image.is_none()
{
return None;
}
@@ -42,9 +54,13 @@ pub fn extract_contents_from_payload(payload: &Value) -> Option<ExtractedContent
images,
video,
model_glb,
model_glb_pbr,
model_urls,
model_mesh,
model_obj,
result_files,
thumbnail,
rendered_image,
preprocessed_image,
})
}
@@ -19,6 +19,12 @@ pub enum WebhookErrorType {
ImageLoadError,
FileDownloadError,
FaceDetectionError,
/// Generic input validation error. The `msg` field carries the specifics
/// (e.g. TripoSplat's "No foreground subject could be detected...").
InputValueError,
/// Generic validation error. The `msg` field carries the specifics (e.g.
/// Hunyuan 3D Part's "Part generation only supports FBX format...").
ValueError,
GreaterThan,
GreaterThanEqual,
LessThan,
@@ -64,6 +70,8 @@ mod tests {
("\"image_load_error\"", WebhookErrorType::ImageLoadError),
("\"file_download_error\"", WebhookErrorType::FileDownloadError),
("\"face_detection_error\"", WebhookErrorType::FaceDetectionError),
("\"input_value_error\"", WebhookErrorType::InputValueError),
("\"value_error\"", WebhookErrorType::ValueError),
("\"greater_than\"", WebhookErrorType::GreaterThan),
("\"greater_than_equal\"", WebhookErrorType::GreaterThanEqual),
("\"less_than\"", WebhookErrorType::LessThan),
@@ -95,6 +103,8 @@ mod tests {
fn from_str_known() {
assert_eq!(WebhookErrorType::from_str("content_policy_violation"), WebhookErrorType::ContentPolicyViolation);
assert_eq!(WebhookErrorType::from_str("face_detection_error"), WebhookErrorType::FaceDetectionError);
assert_eq!(WebhookErrorType::from_str("input_value_error"), WebhookErrorType::InputValueError);
assert_eq!(WebhookErrorType::from_str("value_error"), WebhookErrorType::ValueError);
}
#[test]
@@ -0,0 +1,18 @@
{
"error": "Unexpected status code: 422",
"gateway_request_id": "019f4dad-7ff7-7902-b206-cb11bf1ef843",
"payload": {
"detail": [
{
"loc": [
"body",
"input_file_url"
],
"msg": "Part generation only supports FBX format. Please provide an FBX file or use /convert-format to convert your model to FBX first.",
"type": "value_error"
}
]
},
"request_id": "019f4dad-7ff7-7902-b206-cb11bf1ef843",
"status": "ERROR"
}
@@ -0,0 +1,19 @@
{
"error": "Unexpected status code: 422",
"gateway_request_id": "019f4d0e-3f44-7ed1-bb12-f2f834bdde7b",
"payload": {
"detail": [
{
"input": "https://cdn-2.fakeyou.com/media/f/d/6/f/6/fd6f6czv21gkrdqhptz6nj072wh7w06y/artcraft_fd6f6czv21gkrdqhptz6nj072wh7w06y.png",
"loc": [
"body"
],
"msg": "No foreground subject could be detected in the input image after background removal. Provide an image with a clear, visible subject against a plain or distinct background.",
"type": "input_value_error",
"url": "https://docs.fal.ai/errors#input_value_error"
}
]
},
"request_id": "019f4d0e-3f44-7ed1-bb12-f2f834bdde7b",
"status": "ERROR"
}
@@ -0,0 +1,27 @@
{
"error": null,
"gateway_request_id": "019f4d49-6859-77b3-aaac-3b91b248481a",
"payload": {
"model_glb": {
"content_type": "application/octet-stream",
"file_name": "demo_textured.glb",
"file_size": 1574216,
"url": "https://v3b.fal.media/files/b/0aa1b916/QKdBTd8WJW_rtPB3IwlEN_demo_textured.glb"
},
"model_glb_pbr": {
"content_type": "application/octet-stream",
"file_name": "demo_textured_pbr.glb",
"file_size": 7512760,
"url": "https://v3b.fal.media/files/b/0aa1b921/SJw7AARzkc4l9QG5Lrvmu_demo_textured_pbr.glb"
},
"model_mesh": {
"content_type": "application/octet-stream",
"file_name": "3d_model.zip",
"file_size": 16332900,
"url": "https://v3b.fal.media/files/b/0aa1b915/B25DrTdiZJNV-60RaUh4u_3d_model.zip"
},
"seed": 283904
},
"request_id": "019f4d49-6859-77b3-aaac-3b91b248481a",
"status": "OK"
}
@@ -0,0 +1,37 @@
{
"error": null,
"gateway_request_id": "019f4d58-0e11-7aa0-aa79-e0294bf9ff16",
"payload": {
"model_glb": {
"content_type": "model/gltf-binary",
"file_name": "model.glb",
"file_size": 51001160,
"url": "https://v3b.fal.media/files/b/0aa1b969/k5FZWmTsKxHH71404bBR__model.glb"
},
"model_urls": {
"fbx": null,
"glb": {
"content_type": "model/gltf-binary",
"file_name": "model.glb",
"file_size": 51001160,
"url": "https://v3b.fal.media/files/b/0aa1b969/k5FZWmTsKxHH71404bBR__model.glb"
},
"obj": {
"content_type": "text/plain",
"file_name": "model.obj",
"file_size": 41107268,
"url": "https://v3b.fal.media/files/b/0aa1b968/xngvKxLugXUlNh89Fp0K4_model.obj"
},
"usdz": null
},
"seed": null,
"thumbnail": {
"content_type": "image/png",
"file_name": "preview.png",
"file_size": 136476,
"url": "https://v3b.fal.media/files/b/0aa1b969/EPgha8Kee7YZCAccjFUU7_preview.png"
}
},
"request_id": "019f4d58-0e11-7aa0-aa79-e0294bf9ff16",
"status": "OK"
}
@@ -0,0 +1,49 @@
{
"error": null,
"gateway_request_id": "019f4d79-35fd-7462-85be-359c8232f56e",
"payload": {
"model_glb": {
"content_type": "model/gltf-binary",
"file_name": "model.glb",
"file_size": 64261364,
"url": "https://v3b.fal.media/files/b/0aa1ba44/OdgIv8M9EkaBP_Mh4TVAB_model.glb"
},
"model_urls": {
"fbx": null,
"glb": {
"content_type": "model/gltf-binary",
"file_name": "model.glb",
"file_size": 64261364,
"url": "https://v3b.fal.media/files/b/0aa1ba44/OdgIv8M9EkaBP_Mh4TVAB_model.glb"
},
"mtl": {
"content_type": "text/plain",
"file_name": "material.mtl",
"file_size": 245,
"url": "https://v3b.fal.media/files/b/0aa1ba42/1T5VCp_xD0zCJlTLE96iK_material.mtl"
},
"obj": {
"content_type": "model/obj",
"file_name": "c3a871e997ecd5f889bf671630de23b4.obj",
"file_size": 34563830,
"url": "https://v3b.fal.media/files/b/0aa1ba43/WxPMNbzsDxaGRCmGNgNAL_c3a871e997ecd5f889bf671630de23b4.obj"
},
"texture": {
"content_type": "image/png",
"file_name": "texture_pbr_20250901.png",
"file_size": 22794575,
"url": "https://v3b.fal.media/files/b/0aa1ba43/aKp3mMB9nrhlKfV2KVf0S_texture_pbr_20250901.png"
},
"usdz": null
},
"seed": null,
"thumbnail": {
"content_type": "image/png",
"file_name": "preview.png",
"file_size": 141254,
"url": "https://v3b.fal.media/files/b/0aa1ba44/njq8zYhYahJNNQkGGiEZW_preview.png"
}
},
"request_id": "019f4d79-35fd-7462-85be-359c8232f56e",
"status": "OK"
}
@@ -0,0 +1,55 @@
{
"error": null,
"gateway_request_id": "019f4d93-5a68-7a23-b2e7-8a1bc55e50f8",
"payload": {
"material_mtl": {
"content_type": "text/plain",
"file_name": "material.mtl",
"file_size": 229,
"url": "https://v3b.fal.media/files/b/0aa1bae3/5cbE-q28i44GUOO6YeZPH_material.mtl"
},
"model_glb": {
"content_type": "model/obj",
"file_name": "23dce35fc95b34722f15a7329061d5df.obj",
"file_size": 3258750,
"url": "https://v3b.fal.media/files/b/0aa1bae3/22zStkYp8-q6npG9dsMs5_23dce35fc95b34722f15a7329061d5df.obj"
},
"model_urls": {
"fbx": null,
"glb": null,
"mtl": {
"content_type": "text/plain",
"file_name": "material.mtl",
"file_size": 229,
"url": "https://v3b.fal.media/files/b/0aa1bae3/5cbE-q28i44GUOO6YeZPH_material.mtl"
},
"obj": {
"content_type": "model/obj",
"file_name": "23dce35fc95b34722f15a7329061d5df.obj",
"file_size": 3258750,
"url": "https://v3b.fal.media/files/b/0aa1bae3/22zStkYp8-q6npG9dsMs5_23dce35fc95b34722f15a7329061d5df.obj"
},
"texture": {
"content_type": "image/png",
"file_name": "texture_pbr_v128.png",
"file_size": 5503289,
"url": "https://v3b.fal.media/files/b/0aa1bae3/7eBRddTHf6U8SUg2QOIHw_texture_pbr_v128.png"
},
"usdz": null
},
"texture": {
"content_type": "image/png",
"file_name": "texture_pbr_v128.png",
"file_size": 5503289,
"url": "https://v3b.fal.media/files/b/0aa1bae3/7eBRddTHf6U8SUg2QOIHw_texture_pbr_v128.png"
},
"thumbnail": {
"content_type": "image/png",
"file_name": "preview.png",
"file_size": 120530,
"url": "https://v3b.fal.media/files/b/0aa1bae3/IO3aspD4ijEadoFxSF504_preview.png"
}
},
"request_id": "019f4d93-5a68-7a23-b2e7-8a1bc55e50f8",
"status": "OK"
}
@@ -0,0 +1,88 @@
{
"error": null,
"gateway_request_id": "019f4db7-e55c-78d1-9719-0303ca50a961",
"payload": {
"animation_fbx": null,
"animation_glb": null,
"basic_animations": null,
"model_glb": {
"content_type": "model/gltf-binary",
"file_name": "model.glb",
"file_size": 15943768,
"url": "https://v3b.fal.media/files/b/0aa1bbe1/0vrf9NUhVxKZO4GScA0EI_model.glb"
},
"model_urls": {
"blend": null,
"fbx": {
"content_type": "application/octet-stream",
"file_name": "model.fbx",
"file_size": 13665964,
"url": "https://v3b.fal.media/files/b/0aa1bbed/yKO-HOjBnWA-LZpRLHHSB_model.fbx"
},
"glb": {
"content_type": "model/gltf-binary",
"file_name": "model.glb",
"file_size": 15943768,
"url": "https://v3b.fal.media/files/b/0aa1bbe1/0vrf9NUhVxKZO4GScA0EI_model.glb"
},
"obj": {
"content_type": "text/plain",
"file_name": "model.obj",
"file_size": 3083671,
"url": "https://v3b.fal.media/files/b/0aa1bbe2/NBmqIWBS8LuY_BPYWkOOf_model.obj"
},
"stl": {
"content_type": "application/octet-stream",
"file_name": "model.stl",
"file_size": 1508384,
"url": "https://v3b.fal.media/files/b/0aa1bbe2/3wtOE6iEeVHZFvwAGN6Rl_model.stl"
},
"usdz": {
"content_type": "model/vnd.usdz+zip",
"file_name": "model.usdz",
"file_size": 16861118,
"url": "https://v3b.fal.media/files/b/0aa1bbed/BsniPBo6Uev85V4hLFACR_model.usdz"
}
},
"rig_task_id": null,
"rigged_character_fbx": null,
"rigged_character_glb": null,
"seed": 274321742,
"texture_urls": [
{
"base_color": {
"content_type": "image/png",
"file_name": "texture_0.png",
"file_size": 7539838,
"url": "https://v3b.fal.media/files/b/0aa1bbe3/EXWiAuAYPobYiJPnW66mQ_texture_0.png"
},
"metallic": {
"content_type": "image/png",
"file_name": "texture_0_metallic.png",
"file_size": 858941,
"url": "https://v3b.fal.media/files/b/0aa1bbee/RTZIZRCDQTvN7jdl4EL_C_texture_0_metallic.png"
},
"normal": {
"content_type": "image/png",
"file_name": "texture_0_normal.png",
"file_size": 8236038,
"url": "https://v3b.fal.media/files/b/0aa1bbe3/yNk-S5Dhw2xc9fb3fIjwD_texture_0_normal.png"
},
"roughness": {
"content_type": "image/png",
"file_name": "texture_0_roughness.png",
"file_size": 1184908,
"url": "https://v3b.fal.media/files/b/0aa1bbe3/nnT25g7wgul2oDTRlI_jX_texture_0_roughness.png"
}
}
],
"thumbnail": {
"content_type": "image/png",
"file_name": "preview.png",
"file_size": 192910,
"url": "https://v3b.fal.media/files/b/0aa1bbe3/tn1qfOr12oGXecA4mRtlI_preview.png"
}
},
"request_id": "019f4db7-e55c-78d1-9719-0303ca50a961",
"status": "OK"
}
@@ -0,0 +1,36 @@
{
"error": null,
"gateway_request_id": "019f4db5-aadd-76c3-8122-ff1ba53fe48c",
"payload": {
"model_mesh": {
"content_type": "model/gltf-binary",
"file_name": "model.glb",
"file_size": 43981280,
"url": "https://v3b.fal.media/files/b/0aa1bbdb/d8ATWvvXYHY-8zj-lQecf_model.glb"
},
"model_urls": {
"base_model": null,
"fbx": null,
"glb": {
"content_type": "model/gltf-binary",
"file_name": "model.glb",
"file_size": 43981280,
"url": "https://v3b.fal.media/files/b/0aa1bbdb/d8ATWvvXYHY-8zj-lQecf_model.glb"
},
"pbr_model": {
"content_type": "model/gltf-binary",
"file_name": "model.glb",
"file_size": 43981280,
"url": "https://v3b.fal.media/files/b/0aa1bbdb/d8ATWvvXYHY-8zj-lQecf_model.glb"
}
},
"rendered_image": {
"content_type": "image/png",
"file_name": "preview.png",
"file_size": 30970,
"url": "https://v3b.fal.media/files/b/0aa1bbd0/MblV2S5R6CeskRABMS7-V_preview.png"
}
},
"request_id": "019f4db5-aadd-76c3-8122-ff1ba53fe48c",
"status": "OK"
}
@@ -0,0 +1,28 @@
{
"error": null,
"gateway_request_id": "019f4d0f-4cf2-76d3-b99b-c400641b3844",
"payload": {
"model_mesh": {
"content_type": "application/octet-stream",
"file_name": "output.ply",
"file_size": 17826208,
"url": "https://v3b.fal.media/files/b/0aa1b77c/hEzg5xn9A-1qrLsnywjN4_output.ply"
},
"num_gaussians": 262144,
"preprocessed_image": {
"content_type": "image/png",
"file_name": "61fc73dbe84446b08b1fcce2167b71d5.png",
"file_size": 952754,
"height": 1024,
"url": "https://v3b.fal.media/files/b/0aa1b77c/YyJ19g7lJsRNXmvZvahTE_61fc73dbe84446b08b1fcce2167b71d5.png",
"width": 1024
},
"seed": 1637204116,
"timings": {
"export": 0.794984320000367,
"inference": 4.911052849000043
}
},
"request_id": "019f4d0f-4cf2-76d3-b99b-c400641b3844",
"status": "OK"
}
@@ -9,6 +9,8 @@ use utoipa::ToSchema;
#[serde(rename_all = "snake_case")]
pub enum FrontendFailureCategoryForApiClients {
FaceNotDetected,
NoForegroundSubjectDetected,
FormatNotSupported,
KeepAliveElapsed,
NotYetImplemented,
RetryableWorkerError,
@@ -35,6 +37,8 @@ impl FrontendFailureCategoryForApiClients {
pub fn from_db_enum(value: FrontendFailureCategory) -> Self {
match value {
FrontendFailureCategory::FaceNotDetected => Self::FaceNotDetected,
FrontendFailureCategory::NoForegroundSubjectDetected => Self::NoForegroundSubjectDetected,
FrontendFailureCategory::FormatNotSupported => Self::FormatNotSupported,
FrontendFailureCategory::KeepAliveElapsed => Self::KeepAliveElapsed,
FrontendFailureCategory::NotYetImplemented => Self::NotYetImplemented,
FrontendFailureCategory::RetryableWorkerError => Self::RetryableWorkerError,
@@ -62,6 +66,8 @@ mod tests {
fn test_deserialize_known_variants() {
let cases = vec![
("\"face_not_detected\"", FrontendFailureCategoryForApiClients::FaceNotDetected),
("\"no_foreground_subject_detected\"", FrontendFailureCategoryForApiClients::NoForegroundSubjectDetected),
("\"format_not_supported\"", FrontendFailureCategoryForApiClients::FormatNotSupported),
("\"keep_alive_elapsed\"", FrontendFailureCategoryForApiClients::KeepAliveElapsed),
("\"not_yet_implemented\"", FrontendFailureCategoryForApiClients::NotYetImplemented),
("\"retryable_worker_error\"", FrontendFailureCategoryForApiClients::RetryableWorkerError),
@@ -60,6 +60,8 @@ mod tests {
#[test]
fn test_new_variants_return_none() {
assert_eq!(FrontendFailureCategoryForOldClients::try_from_db_enum(FrontendFailureCategory::NoForegroundSubjectDetected), None);
assert_eq!(FrontendFailureCategoryForOldClients::try_from_db_enum(FrontendFailureCategory::FormatNotSupported), None);
assert_eq!(FrontendFailureCategoryForOldClients::try_from_db_enum(FrontendFailureCategory::ModelRulesViolation), None);
assert_eq!(FrontendFailureCategoryForOldClients::try_from_db_enum(FrontendFailureCategory::RuleBansUserImage), None);
assert_eq!(FrontendFailureCategoryForOldClients::try_from_db_enum(FrontendFailureCategory::RuleBansUserImageWithFaces), None);
@@ -28,6 +28,19 @@ pub enum FrontendFailureCategory {
#[serde(rename = "face_not_detected")]
FaceNotDetected,
/// No foreground subject could be detected in the input image after
/// background removal (fal TripoSplat `input_value_error`). The user should
/// provide an image with a clear subject against a plain or distinct
/// background.
#[serde(rename = "no_foreground_subject_detected")]
NoForegroundSubjectDetected,
/// The input file's format isn't supported by the model (e.g. Hunyuan 3D
/// Part only accepts FBX input). The user should convert or pick a
/// supported file.
#[serde(rename = "format_not_supported")]
FormatNotSupported,
/// The user stepped away from their device and expected the workload to finish.
/// Some workloads require that the user keep their browser open.
#[serde(rename = "keep_alive_elapsed")]
@@ -124,6 +137,8 @@ impl FrontendFailureCategory {
pub fn to_str(&self) -> &'static str {
match self {
Self::FaceNotDetected => "face_not_detected",
Self::NoForegroundSubjectDetected => "no_foreground_subject_detected",
Self::FormatNotSupported => "format_not_supported",
Self::KeepAliveElapsed => "keep_alive_elapsed",
Self::NotYetImplemented => "not_yet_implemented",
Self::RetryableWorkerError => "retryable_worker_error",
@@ -145,6 +160,8 @@ impl FrontendFailureCategory {
pub fn from_str(value: &str) -> Result<Self, String> {
match value {
"face_not_detected" => Ok(Self::FaceNotDetected),
"no_foreground_subject_detected" => Ok(Self::NoForegroundSubjectDetected),
"format_not_supported" => Ok(Self::FormatNotSupported),
"keep_alive_elapsed" => Ok(Self::KeepAliveElapsed),
"not_yet_implemented" => Ok(Self::NotYetImplemented),
"retryable_worker_error" => Ok(Self::RetryableWorkerError),
@@ -169,6 +186,8 @@ impl FrontendFailureCategory {
// NB: BTreeSet::from() isn't const, but not worth using LazyStatic, etc.
BTreeSet::from([
Self::FaceNotDetected,
Self::NoForegroundSubjectDetected,
Self::FormatNotSupported,
Self::KeepAliveElapsed,
Self::NotYetImplemented,
Self::RetryableWorkerError,
@@ -199,6 +218,8 @@ mod tests {
#[test]
fn test_serialization() {
assert_serialization(FrontendFailureCategory::FaceNotDetected, "face_not_detected");
assert_serialization(FrontendFailureCategory::NoForegroundSubjectDetected, "no_foreground_subject_detected");
assert_serialization(FrontendFailureCategory::FormatNotSupported, "format_not_supported");
assert_serialization(FrontendFailureCategory::KeepAliveElapsed, "keep_alive_elapsed");
assert_serialization(FrontendFailureCategory::NotYetImplemented, "not_yet_implemented");
assert_serialization(FrontendFailureCategory::RetryableWorkerError, "retryable_worker_error");
@@ -219,6 +240,8 @@ mod tests {
#[test]
fn to_str() {
assert_eq!(FrontendFailureCategory::FaceNotDetected.to_str(), "face_not_detected");
assert_eq!(FrontendFailureCategory::NoForegroundSubjectDetected.to_str(), "no_foreground_subject_detected");
assert_eq!(FrontendFailureCategory::FormatNotSupported.to_str(), "format_not_supported");
assert_eq!(FrontendFailureCategory::KeepAliveElapsed.to_str(), "keep_alive_elapsed");
assert_eq!(FrontendFailureCategory::NotYetImplemented.to_str(), "not_yet_implemented");
assert_eq!(FrontendFailureCategory::RetryableWorkerError.to_str(), "retryable_worker_error");
@@ -239,6 +262,8 @@ mod tests {
#[test]
fn from_str() {
assert_eq!(FrontendFailureCategory::from_str("face_not_detected").unwrap(), FrontendFailureCategory::FaceNotDetected);
assert_eq!(FrontendFailureCategory::from_str("no_foreground_subject_detected").unwrap(), FrontendFailureCategory::NoForegroundSubjectDetected);
assert_eq!(FrontendFailureCategory::from_str("format_not_supported").unwrap(), FrontendFailureCategory::FormatNotSupported);
assert_eq!(FrontendFailureCategory::from_str("keep_alive_elapsed").unwrap(), FrontendFailureCategory::KeepAliveElapsed);
assert_eq!(FrontendFailureCategory::from_str("not_yet_implemented").unwrap(), FrontendFailureCategory::NotYetImplemented);
assert_eq!(FrontendFailureCategory::from_str("retryable_worker_error").unwrap(), FrontendFailureCategory::RetryableWorkerError);
@@ -260,8 +285,10 @@ mod tests {
#[test]
fn all_variants() {
let mut variants = FrontendFailureCategory::all_variants();
assert_eq!(variants.len(), 16);
assert_eq!(variants.len(), 18);
assert_eq!(variants.pop_first(), Some(FrontendFailureCategory::FaceNotDetected));
assert_eq!(variants.pop_first(), Some(FrontendFailureCategory::NoForegroundSubjectDetected));
assert_eq!(variants.pop_first(), Some(FrontendFailureCategory::FormatNotSupported));
assert_eq!(variants.pop_first(), Some(FrontendFailureCategory::KeepAliveElapsed));
assert_eq!(variants.pop_first(), Some(FrontendFailureCategory::NotYetImplemented));
assert_eq!(variants.pop_first(), Some(FrontendFailureCategory::RetryableWorkerError));
@@ -30,6 +30,14 @@ pub enum TaskFailureType {
RuleBansGeneratedAudio,
RuleBansGeneratedContent,
/// No foreground subject could be detected in the input image after
/// background removal (e.g. TripoSplat image-to-splat).
NoForegroundSubjectDetected,
/// The input file's format isn't supported by the model (e.g. Hunyuan 3D
/// Part only accepts FBX input).
FormatNotSupported,
/// No reason given for generation failure, but this matches what we were told.
GenerationFailed,
}
@@ -50,6 +58,8 @@ impl TaskFailureType {
FrontendFailureCategory::RuleBansGeneratedVideo => Self::RuleBansGeneratedVideo,
FrontendFailureCategory::RuleBansGeneratedAudio => Self::RuleBansGeneratedAudio,
FrontendFailureCategory::RuleBansGeneratedContent => Self::RuleBansGeneratedContent,
FrontendFailureCategory::NoForegroundSubjectDetected => Self::NoForegroundSubjectDetected,
FrontendFailureCategory::FormatNotSupported => Self::FormatNotSupported,
FrontendFailureCategory::GenerationFailed => Self::GenerationFailed,
_ => Self::Unknown,
}
@@ -67,6 +77,8 @@ impl TaskFailureType {
FrontendFailureCategoryForApiClients::RuleBansGeneratedVideo => Self::RuleBansGeneratedVideo,
FrontendFailureCategoryForApiClients::RuleBansGeneratedAudio => Self::RuleBansGeneratedAudio,
FrontendFailureCategoryForApiClients::RuleBansGeneratedContent => Self::RuleBansGeneratedContent,
FrontendFailureCategoryForApiClients::NoForegroundSubjectDetected => Self::NoForegroundSubjectDetected,
FrontendFailureCategoryForApiClients::FormatNotSupported => Self::FormatNotSupported,
FrontendFailureCategoryForApiClients::GenerationFailed => Self::GenerationFailed,
// Types ArtCraft doesn't care about
@@ -96,6 +108,8 @@ impl TaskFailureType {
Self::RuleBansGeneratedVideo => "rule_bans_generated_video",
Self::RuleBansGeneratedAudio => "rule_bans_generated_audio",
Self::RuleBansGeneratedContent => "rule_bans_generated_content",
Self::NoForegroundSubjectDetected => "no_foreground_subject_detected",
Self::FormatNotSupported => "format_not_supported",
Self::GenerationFailed => "generation_failed",
}
}
@@ -110,6 +124,8 @@ impl TaskFailureType {
"rule_bans_generated_video" => Ok(Self::RuleBansGeneratedVideo),
"rule_bans_generated_audio" => Ok(Self::RuleBansGeneratedAudio),
"rule_bans_generated_content" => Ok(Self::RuleBansGeneratedContent),
"no_foreground_subject_detected" => Ok(Self::NoForegroundSubjectDetected),
"format_not_supported" => Ok(Self::FormatNotSupported),
"generation_failed" => Ok(Self::GenerationFailed),
_ => Err(EnumError::CouldNotConvertFromString(value.to_string())),
}
@@ -125,6 +141,8 @@ impl TaskFailureType {
Self::RuleBansGeneratedVideo,
Self::RuleBansGeneratedAudio,
Self::RuleBansGeneratedContent,
Self::NoForegroundSubjectDetected,
Self::FormatNotSupported,
Self::GenerationFailed,
])
}
@@ -149,6 +167,8 @@ mod tests {
assert_serialization(TaskFailureType::RuleBansGeneratedVideo, "rule_bans_generated_video");
assert_serialization(TaskFailureType::RuleBansGeneratedAudio, "rule_bans_generated_audio");
assert_serialization(TaskFailureType::RuleBansGeneratedContent, "rule_bans_generated_content");
assert_serialization(TaskFailureType::NoForegroundSubjectDetected, "no_foreground_subject_detected");
assert_serialization(TaskFailureType::FormatNotSupported, "format_not_supported");
assert_serialization(TaskFailureType::GenerationFailed, "generation_failed");
}
@@ -162,6 +182,8 @@ mod tests {
assert_eq!(TaskFailureType::RuleBansGeneratedVideo.to_str(), "rule_bans_generated_video");
assert_eq!(TaskFailureType::RuleBansGeneratedAudio.to_str(), "rule_bans_generated_audio");
assert_eq!(TaskFailureType::RuleBansGeneratedContent.to_str(), "rule_bans_generated_content");
assert_eq!(TaskFailureType::NoForegroundSubjectDetected.to_str(), "no_foreground_subject_detected");
assert_eq!(TaskFailureType::FormatNotSupported.to_str(), "format_not_supported");
assert_eq!(TaskFailureType::GenerationFailed.to_str(), "generation_failed");
}
@@ -175,6 +197,8 @@ mod tests {
assert_eq!(TaskFailureType::from_str("rule_bans_generated_video").unwrap(), TaskFailureType::RuleBansGeneratedVideo);
assert_eq!(TaskFailureType::from_str("rule_bans_generated_audio").unwrap(), TaskFailureType::RuleBansGeneratedAudio);
assert_eq!(TaskFailureType::from_str("rule_bans_generated_content").unwrap(), TaskFailureType::RuleBansGeneratedContent);
assert_eq!(TaskFailureType::from_str("no_foreground_subject_detected").unwrap(), TaskFailureType::NoForegroundSubjectDetected);
assert_eq!(TaskFailureType::from_str("format_not_supported").unwrap(), TaskFailureType::FormatNotSupported);
assert_eq!(TaskFailureType::from_str("generation_failed").unwrap(), TaskFailureType::GenerationFailed);
}
@@ -192,7 +216,7 @@ mod tests {
#[test]
fn all_variants() {
let mut variants = TaskFailureType::all_variants();
assert_eq!(variants.len(), 9);
assert_eq!(variants.len(), 11);
assert_eq!(variants.pop_first(), Some(TaskFailureType::Unknown));
assert_eq!(variants.pop_first(), Some(TaskFailureType::RuleBansUserImage));
assert_eq!(variants.pop_first(), Some(TaskFailureType::RuleBansUserImageWithFaces));
@@ -201,6 +225,8 @@ mod tests {
assert_eq!(variants.pop_first(), Some(TaskFailureType::RuleBansGeneratedVideo));
assert_eq!(variants.pop_first(), Some(TaskFailureType::RuleBansGeneratedAudio));
assert_eq!(variants.pop_first(), Some(TaskFailureType::RuleBansGeneratedContent));
assert_eq!(variants.pop_first(), Some(TaskFailureType::NoForegroundSubjectDetected));
assert_eq!(variants.pop_first(), Some(TaskFailureType::FormatNotSupported));
assert_eq!(variants.pop_first(), Some(TaskFailureType::GenerationFailed));
assert_eq!(variants.pop_first(), None);
}
@@ -81,7 +81,10 @@ pub async fn handle_failed_fal_webhook(
error_data.message,
);
let failure_category = guess_failure_category(error_data.error_type.as_ref());
let failure_category = guess_failure_category(
error_data.error_type.as_ref(),
error_data.message.as_deref(),
);
info!(
"Marking job {} as failed for request_id {}. Category: {:?}, Reason: {}",
@@ -118,19 +121,136 @@ pub async fn handle_failed_fal_webhook(
// =============== Private helpers ===============
/// Map a FAL WebhookErrorType to a frontend failure category.
/// Map a FAL WebhookErrorType (and, for generic validation errors, the
/// human-readable message) to a frontend failure category.
///
/// Returns `GenerationFailed` as the default if the error type is None or unrecognized.
fn guess_failure_category(error_type: Option<&WebhookErrorType>) -> FrontendFailureCategory {
fn guess_failure_category(
error_type: Option<&WebhookErrorType>,
maybe_message: Option<&str>,
) -> FrontendFailureCategory {
match error_type {
Some(WebhookErrorType::ContentPolicyViolation) => FrontendFailureCategory::RuleBansUserContent,
Some(WebhookErrorType::FaceDetectionError) => FrontendFailureCategory::FaceNotDetected,
Some(WebhookErrorType::FileTooLarge) => FrontendFailureCategory::FilesizeTooLarge,
Some(WebhookErrorType::ImageTooLarge) => FrontendFailureCategory::ImageDimensionsTooLarge,
Some(WebhookErrorType::ImageTooSmall) => FrontendFailureCategory::ImageDimensionsTooSmall,
// `input_value_error` / `value_error` are generic validation errors; the
// message carries the specifics.
Some(WebhookErrorType::InputValueError)
| Some(WebhookErrorType::ValueError) => guess_validation_failure_category(maybe_message),
Some(WebhookErrorType::NoMediaGenerated)
| Some(WebhookErrorType::ImageLoadError)
| Some(WebhookErrorType::FileDownloadError) => FrontendFailureCategory::GenerationFailed,
_ => FrontendFailureCategory::GenerationFailed,
}
}
/// Classify a generic fal validation error (`input_value_error` /
/// `value_error`) by its human-readable message.
fn guess_validation_failure_category(maybe_message: Option<&str>) -> FrontendFailureCategory {
let Some(message) = maybe_message else {
return FrontendFailureCategory::GenerationFailed;
};
let message = message.to_ascii_lowercase();
// TripoSplat: "No foreground subject could be detected in the input image
// after background removal. ..."
if message.contains("no foreground subject") {
return FrontendFailureCategory::NoForegroundSubjectDetected;
}
// Hunyuan 3D Part: "Part generation only supports FBX format. Please
// provide an FBX file or use /convert-format to convert your model to FBX
// first."
if message.contains("only supports") && message.contains("format") {
return FrontendFailureCategory::FormatNotSupported;
}
FrontendFailureCategory::GenerationFailed
}
#[cfg(test)]
mod tests {
use super::*;
const NO_FOREGROUND_SUBJECT_MESSAGE: &str =
"No foreground subject could be detected in the input image after background removal. \
Provide an image with a clear, visible subject against a plain or distinct background.";
const FBX_ONLY_MESSAGE: &str =
"Part generation only supports FBX format. Please provide an FBX file or use \
/convert-format to convert your model to FBX first.";
mod guess_failure_category_tests {
use super::*;
#[test]
fn input_value_error_with_no_foreground_subject_message() {
let category = guess_failure_category(
Some(&WebhookErrorType::InputValueError),
Some(NO_FOREGROUND_SUBJECT_MESSAGE),
);
assert_eq!(category, FrontendFailureCategory::NoForegroundSubjectDetected);
}
#[test]
fn value_error_with_fbx_only_message() {
let category = guess_failure_category(
Some(&WebhookErrorType::ValueError),
Some(FBX_ONLY_MESSAGE),
);
assert_eq!(category, FrontendFailureCategory::FormatNotSupported);
}
#[test]
fn input_value_error_with_fbx_only_message() {
// Both generic validation error types share the message classifier.
let category = guess_failure_category(
Some(&WebhookErrorType::InputValueError),
Some(FBX_ONLY_MESSAGE),
);
assert_eq!(category, FrontendFailureCategory::FormatNotSupported);
}
#[test]
fn input_value_error_with_other_message() {
let category = guess_failure_category(
Some(&WebhookErrorType::InputValueError),
Some("Field `num_gaussians` must be a positive integer."),
);
assert_eq!(category, FrontendFailureCategory::GenerationFailed);
}
#[test]
fn value_error_with_other_message() {
let category = guess_failure_category(
Some(&WebhookErrorType::ValueError),
Some("Input file could not be parsed."),
);
assert_eq!(category, FrontendFailureCategory::GenerationFailed);
}
#[test]
fn input_value_error_without_message() {
let category = guess_failure_category(Some(&WebhookErrorType::InputValueError), None);
assert_eq!(category, FrontendFailureCategory::GenerationFailed);
}
#[test]
fn content_policy_violation_ignores_message() {
let category = guess_failure_category(
Some(&WebhookErrorType::ContentPolicyViolation),
Some(NO_FOREGROUND_SUBJECT_MESSAGE),
);
assert_eq!(category, FrontendFailureCategory::RuleBansUserContent);
}
#[test]
fn no_error_type_defaults_to_generation_failed() {
let category = guess_failure_category(None, Some(FBX_ONLY_MESSAGE));
assert_eq!(category, FrontendFailureCategory::GenerationFailed);
}
}
}
@@ -0,0 +1,163 @@
//! Downloads an image from a fal result payload, uploads it to the public
//! bucket, inserts an intermediate media file record, and sets it as the
//! cover image of another media file (e.g. a mesh or gaussian splat).
use crate::http_server::common_responses::common_web_error::CommonWebError;
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;
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory;
use enums::by_table::media_files::media_file_origin_product_category::MediaFileOriginProductCategory;
use enums::by_table::media_files::media_file_type::MediaFileType;
use enums::common::generation_provider::GenerationProvider;
use hashing::sha256::sha256_hash_bytes::sha256_hash_bytes;
use log::{info, warn};
use mysql_queries::queries::generic_inference::api_providers::fal::get_inference_job_by_fal_id::FalJobDetails;
use mysql_queries::queries::media_files::create::insert_builder::media_file_insert_builder::MediaFileInsertBuilder;
use mysql_queries::queries::media_files::edit::set_media_file_cover_image::{set_media_file_cover_image, UpdateArgs};
use tokens::tokens::media_files::MediaFileToken;
const PREFIX : Option<&str> = Some("artcraft_");
pub(crate) struct AttachCoverImageArgs<'a> {
pub image_url: &'a str,
pub maybe_content_type: Option<&'a str>,
/// Product category to tag the cover image record with, when the parent
/// media file carries one (e.g. `WorldGeneration` for gaussian splats).
pub maybe_origin_product_category: Option<MediaFileOriginProductCategory>,
/// The media file the cover image is attached to.
pub target_media_token: &'a MediaFileToken,
pub job: &'a FalJobDetails,
pub server_state: &'a ServerState,
}
/// Download a cover image from fal, upload it to the public bucket, insert an
/// intermediate media file record, and set it as the cover image of
/// `target_media_token`. Callers should fail open: a missing cover image is
/// not worth failing the whole webhook over.
pub(crate) async fn attach_cover_image(
args: AttachCoverImageArgs<'_>,
) -> Result<(), CommonWebError> {
let AttachCoverImageArgs {
image_url,
maybe_content_type,
maybe_origin_product_category,
target_media_token,
job,
server_state,
} = args;
// Download with a retry if the first attempt returns suspiciously few bytes.
let mut file_bytes = http_download_url_to_bytes(image_url)
.await
.map_err(|err| {
warn!("Failed to download cover image from {}: {:?}", image_url, err);
CommonWebError::server_error_with_message(
&format!("Failed to download cover image: {:?}", err))
})?;
if file_bytes.len() <= 10 {
warn!(
"Downloaded only {} bytes from {} — retrying once",
file_bytes.len(),
image_url,
);
file_bytes = http_download_url_to_bytes(image_url)
.await
.map_err(|err| {
warn!("Failed to download cover image on retry from {}: {:?}", image_url, err);
CommonWebError::server_error_with_message(
&format!("Failed to download cover image on retry: {:?}", err))
})?;
}
// Resolve mime type: magic bytes first, fal content_type as fallback.
let metadata = resolve_file_metadata(&file_bytes, maybe_content_type)
.ok_or_else(|| {
warn!(
"Could not determine file type for cover image (bytes: {}, fal content_type: {:?})",
file_bytes.len(),
maybe_content_type,
);
CommonWebError::server_error_with_message(
&format!("Could not determine file type for cover image (bytes: {}, fal content_type: {:?})",
file_bytes.len(), maybe_content_type))
})?;
let mime_type = metadata.mime_type.as_str();
info!("Mime type of cover image: {}, source: {:?}", mime_type, metadata.source);
let media_file_type = MediaFileType::try_from_mime_type(mime_type)
.ok_or_else(|| {
warn!("Unsupported cover image media file type: {}", mime_type);
CommonWebError::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(|err| {
warn!("Failed to hash cover image bytes: {:?}", err);
CommonWebError::from_anyhow_error(err)
})?;
let public_upload_path = MediaFileBucketPath::generate_new(PREFIX, Some(&extension_with_period));
info!("Uploading cover image 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(|err| {
warn!("Failed to upload cover image to bucket: {:?}", err);
CommonWebError::from_anyhow_error(err)
})?;
let mut insert_builder = MediaFileInsertBuilder::new()
.checksum_sha2(&file_hash)
.creator_ip_address(&job.creator_ip_address)
.file_size_bytes(file_size_bytes as u64)
.is_intermediate_system_file(true)
.maybe_creator_anonymous_visitor(job.maybe_creator_anonymous_visitor_token.as_ref())
.maybe_creator_user(job.maybe_creator_user_token.as_ref())
.maybe_generation_provider(Some(GenerationProvider::Artcraft))
.maybe_prompt_token(job.maybe_prompt_token.as_ref())
.maybe_platform_type(job.maybe_platform_type)
.media_file_class(MediaFileClass::Image)
.media_file_origin_category(MediaFileOriginCategory::Inference)
.media_file_type(media_file_type)
.mime_type(mime_type)
.public_bucket_directory_hash(&public_upload_path);
if let Some(origin_product_category) = maybe_origin_product_category {
insert_builder = insert_builder
.media_file_origin_product_category(origin_product_category);
}
let cover_media_token = insert_builder
.insert_pool(&server_state.mysql_pool)
.await
.map_err(|err| {
warn!("Failed to insert cover image media file record: {:?}", err);
CommonWebError::from_error(err)
})?;
let query_result = set_media_file_cover_image(UpdateArgs {
media_file_token: target_media_token,
maybe_cover_image_media_file_token: Some(&cover_media_token),
mysql_pool: &server_state.mysql_pool,
}).await;
if let Err(err) = query_result {
warn!("Failed to set cover image on media file {}: {:?}", target_media_token, err);
}
Ok(())
}
@@ -96,15 +96,41 @@ pub async fn handle_successful_fal_webhook(
}
}
if let Some(ref model_glb_data) = extracted.model_glb {
// NB: GLBs take priority over `model_mesh`. Hunyuan 3D 2.1 sends both,
// where `model_mesh` is a zip archive of the whole generation that we
// skip in favor of the GLBs (standard + optional PBR variant). The
// primary GLB is `model_glb`, falling back to `model_urls.glb` when the
// top-level key is absent (Hunyuan 3D 3.0 sends both, usually the same
// file).
let maybe_primary_glb = extracted.model_glb.as_ref()
.or_else(|| extracted.model_urls.as_ref().and_then(|urls| urls.glb.as_ref()));
if let Some(model_glb_data) = maybe_primary_glb {
info!("Handling model_glb payload for request_id {} / job {:?}", request_id, job.job_token);
let token = process_model_glb_payload(model_glb_data, extracted.thumbnail.as_ref(), &job, server_state).await?;
// The cover image arrives as `thumbnail` (Hunyuan) or `rendered_image`
// (Tripo 3D); both have the same shape.
let maybe_cover_image = extracted.thumbnail.as_ref()
.or(extracted.rendered_image.as_ref());
let (token, batch_token) = process_model_glb_payload(
model_glb_data,
extracted.model_glb_pbr.as_ref(),
extracted.model_urls.as_ref(),
maybe_cover_image,
&job,
server_state,
pager,
).await?;
if maybe_media_token.is_none() {
maybe_media_token = Some(token);
}
if maybe_batch_token.is_none() {
maybe_batch_token = batch_token;
}
} else if let Some(ref model_mesh_data) = extracted.model_mesh {
// NB: triposplat ply gaussian splat files also arrive via this payload handler.
// These are decidedly *not* "mesh" files!
info!("Handling model_mesh payload for request_id {} / job {:?}", request_id, job.job_token);
let token = process_model_mesh_payload(model_mesh_data, &job, server_state).await?;
let token = process_model_mesh_payload(model_mesh_data, extracted.preprocessed_image.as_ref(), &job, server_state).await?;
if maybe_media_token.is_none() {
maybe_media_token = Some(token);
}
@@ -1,4 +1,5 @@
pub mod handle_successful_fal_webhook;
mod attach_cover_image;
mod process_image_payload;
mod process_images_payload;
mod process_model_glb_payload;
@@ -1,25 +1,21 @@
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::endpoints::webhooks::fal::process_success::resolve_file_metadata::resolve_file_metadata;
use crate::http_server::endpoints::webhooks::fal::process_success::attach_cover_image::{attach_cover_image, AttachCoverImageArgs};
use crate::http_server::endpoints::webhooks::fal::process_success::process_model_mesh_payload::{upload_mesh_file, UploadMeshFileArgs};
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;
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_engine_category::MediaFileEngineCategory;
use enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory;
use enums::by_table::media_files::media_file_type::MediaFileType;
use fal_client::webhook_payload::hydrated::hydrated_webhook_contents::{ModelGlbData, ThumbnailData};
use hashing::sha256::sha256_hash_bytes::sha256_hash_bytes;
use fal_client::webhook_payload::hydrated::hydrated_webhook_contents::{ModelGlbData, ModelUrlsData, ThumbnailData};
use log::{info, warn};
use enums::common::generation_provider::GenerationProvider;
use mysql_queries::queries::generic_inference::api_providers::fal::get_inference_job_by_fal_id::FalJobDetails;
use mysql_queries::queries::media_files::create::insert_builder::media_file_insert_builder::MediaFileInsertBuilder;
use mysql_queries::queries::media_files::edit::set_media_file_cover_image::{set_media_file_cover_image, UpdateArgs};
use pager::client::pager::Pager;
use pager::notification::notification_details_builder::NotificationDetailsBuilder;
use pager::notification::notification_urgency::NotificationUrgency;
use tokens::tokens::batch_generations::BatchGenerationToken;
use tokens::tokens::media_files::MediaFileToken;
const PREFIX : Option<&str> = Some("artcraft_");
/*
Example payload, from Hunyuan 3d 3.0,
Example payload, from Hunyuan 3d 3.0. The `model_urls.glb` entry usually
duplicates `model_glb` (same URL), but is uploaded as a second mesh when it
points at a different file. The other `model_urls` slots (fbx/obj/usdz) are
alternate formats of the same model and are not uploaded.
{
"model_glb": {
@@ -53,14 +49,35 @@ const PREFIX : Option<&str> = Some("artcraft_");
"seed": null
}
Hunyuan 3D 2.1 additionally sends a PBR-textured GLB variant (plus a
`model_mesh` zip archive of the whole generation, which we skip in favor
of the GLBs):
{
"model_glb": { "url": "...demo_textured.glb", ... },
"model_glb_pbr": { "url": "...demo_textured_pbr.glb", ... },
"model_mesh": { "url": "...3d_model.zip", ... },
"seed": 283904
}
*/
/// Process a `model_glb` payload, plus the optional secondary GLBs some
/// models send: the `model_glb_pbr` variant (Hunyuan 3D 2.1) and the
/// `model_urls` entries `glb` (Hunyuan 3D 3.0), `base_model` and `pbr_model`
/// (Tripo 3D). Secondary entries frequently repeat the primary's URL, so
/// only distinct files are uploaded. All GLBs become mesh media files; when
/// more than one is uploaded they share a batch generation token, with the
/// standard GLB as the primary media file.
pub async fn process_model_glb_payload(
model_glb_data: &ModelGlbData,
maybe_model_glb_pbr_data: Option<&ModelGlbData>,
maybe_model_urls_data: Option<&ModelUrlsData>,
maybe_thumbnail_data: Option<&ThumbnailData>,
job: &FalJobDetails,
server_state: &ServerState,
) -> Result<MediaFileToken, CommonWebError> {
pager: &Pager,
) -> Result<(MediaFileToken, Option<BatchGenerationToken>), CommonWebError> {
let mesh_url = model_glb_data.url
.as_deref()
.ok_or_else(|| {
@@ -68,100 +85,67 @@ pub async fn process_model_glb_payload(
CommonWebError::server_error_with_message("no `url` in model glb payload")
})?;
// Download with a retry if the first attempt returns suspiciously few bytes.
let mut file_bytes = http_download_url_to_bytes(mesh_url)
.await
.map_err(|err| {
warn!("Failed to download mesh from {}: {:?}", mesh_url, err);
CommonWebError::from_error(err)
})?;
// Secondary GLB candidates. Slots frequently duplicate URLs — Hunyuan 3D
// 3.0's `model_urls.glb` usually matches `model_glb`, and Tripo 3D's `glb`
// and `pbr_model` match each other — so each distinct URL uploads once.
let candidates = [
("model_glb_pbr", maybe_model_glb_pbr_data),
("model_urls.glb", maybe_model_urls_data.and_then(|urls| urls.glb.as_ref())),
("model_urls.base_model", maybe_model_urls_data.and_then(|urls| urls.base_model.as_ref())),
("model_urls.pbr_model", maybe_model_urls_data.and_then(|urls| urls.pbr_model.as_ref())),
];
if file_bytes.len() <= 10 {
warn!(
"Downloaded only {} bytes from {} — retrying once",
file_bytes.len(),
mesh_url,
);
file_bytes = http_download_url_to_bytes(mesh_url)
.await
.map_err(|e| {
warn!("Failed to download mesh on retry from {}: {:?}", mesh_url, e);
CommonWebError::server_error_with_message(
&format!("Failed to download mesh on retry: {:?}", e))
})?;
let mut seen_urls = vec![mesh_url];
let mut secondary_glbs: Vec<(&str, &ModelGlbData)> = Vec::new();
for (label, maybe_glb_data) in candidates {
let Some(glb_data) = maybe_glb_data else {
continue;
};
let Some(url) = glb_data.url.as_deref() else {
warn!("No `url` in {} payload; skipping", label);
continue;
};
if seen_urls.contains(&url) {
continue;
}
seen_urls.push(url);
secondary_glbs.push((label, glb_data));
}
// Resolve mime type: magic bytes first, fal content_type as fallback.
let metadata = resolve_file_metadata(&file_bytes, model_glb_data.content_type.as_deref())
.ok_or_else(|| {
warn!(
"Could not determine file type for mesh (bytes: {}, fal content_type: {:?})",
file_bytes.len(),
model_glb_data.content_type,
);
CommonWebError::server_error_with_message(
&format!("Could not determine file type for mesh (bytes: {}, fal content_type: {:?})",
file_bytes.len(), model_glb_data.content_type))
})?;
// NB: We don't create `batch_generations` table records. The foreign key
// in `media_files` is enough to look up the rest of the batch.
let maybe_batch_token = (!secondary_glbs.is_empty())
.then(BatchGenerationToken::generate);
let mime_type = metadata.mime_type.as_str();
info!("Mime type of mesh: {}, source: {:?}", mime_type, metadata.source);
let media_file_type = MediaFileType::try_from_mime_type(mime_type)
.ok_or_else(|| {
warn!("Unsupported media file type: {}", mime_type);
CommonWebError::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 mesh bytes: {:?}", e);
CommonWebError::from_anyhow_error(e)
})?;
let public_upload_path = MediaFileBucketPath::generate_new(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 mesh to bucket: {:?}", e);
CommonWebError::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::Mesh)
.media_file_type(media_file_type)
.media_file_origin_category(MediaFileOriginCategory::Inference)
.maybe_engine_category(Some(MediaFileEngineCategory::Object))
//.media_file_origin_product_category(MediaFileOriginProductCategory::Unknown)
.mime_type(mime_type)
.file_size_bytes(file_size_bytes as u64)
.maybe_prompt_token(job.maybe_prompt_token.as_ref())
.maybe_platform_type(job.maybe_platform_type)
.checksum_sha2(&file_hash)
.insert_pool(&server_state.mysql_pool)
.await
.map_err(|e| {
warn!("Failed to insert glb media file record: {:?}", e);
CommonWebError::from_error(e)
})?;
let media_token = upload_mesh_file(UploadMeshFileArgs {
mesh_url,
maybe_content_type: model_glb_data.content_type.as_deref(),
maybe_file_name: model_glb_data.file_name.as_deref(),
maybe_batch_token: maybe_batch_token.as_ref(),
job,
server_state,
}).await?;
info!("Glb media file uploaded with token: {}", media_token);
// Upload the secondary GLBs into the same batch. NB: Fail open — the
// primary GLB is already uploaded, so page ourselves instead of failing
// the job.
for (label, glb_data) in secondary_glbs {
let result = upload_secondary_glb(
glb_data,
maybe_batch_token.as_ref(),
job,
server_state,
).await;
if let Err(err) = result {
warn!("Failed to upload {} GLB variant: {:?}", label, err);
page_about_secondary_glb_failure(label, err, job, pager);
}
}
let result = try_to_attach_thumbnail(
maybe_thumbnail_data,
job,
@@ -174,6 +158,33 @@ pub async fn process_model_glb_payload(
warn!("Could not attach thumbnail as cover image to media file: {:?}", err);
}
Ok((media_token, maybe_batch_token))
}
async fn upload_secondary_glb(
glb_data: &ModelGlbData,
maybe_batch_token: Option<&BatchGenerationToken>,
job: &FalJobDetails,
server_state: &ServerState,
) -> Result<MediaFileToken, CommonWebError> {
let glb_url = glb_data.url
.as_deref()
.ok_or_else(|| {
warn!("No `url` in secondary glb payload");
CommonWebError::server_error_with_message("no `url` in secondary glb payload")
})?;
let media_token = upload_mesh_file(UploadMeshFileArgs {
mesh_url: glb_url,
maybe_content_type: glb_data.content_type.as_deref(),
maybe_file_name: glb_data.file_name.as_deref(),
maybe_batch_token,
job,
server_state,
}).await?;
info!("Secondary glb media file uploaded with token: {}", media_token);
Ok(media_token)
}
@@ -199,108 +210,31 @@ async fn try_to_attach_thumbnail(
CommonWebError::server_error_with_message("no `url` in thumbnail payload")
})?;
// Download with a retry if the first attempt returns suspiciously few bytes.
let mut file_bytes = http_download_url_to_bytes(thumbnail_url)
.await
.map_err(|e| {
warn!("Failed to download thumbnail image from {}: {:?}", thumbnail_url, e);
CommonWebError::server_error_with_message(
&format!("Failed to download thumbnail image: {:?}", e))
})?;
if file_bytes.len() <= 10 {
warn!(
"Downloaded only {} bytes from {} — retrying once",
file_bytes.len(),
thumbnail_url,
);
file_bytes = http_download_url_to_bytes(thumbnail_url)
.await
.map_err(|e| {
warn!("Failed to download thumbnail on retry from {}: {:?}", thumbnail_url, e);
CommonWebError::server_error_with_message(
&format!("Failed to download thumbnail on retry: {:?}", e))
})?;
attach_cover_image(AttachCoverImageArgs {
image_url: thumbnail_url,
maybe_content_type: thumbnail_data.content_type.as_deref(),
maybe_origin_product_category: None,
target_media_token: glb_media_token,
job,
server_state,
}).await
}
fn page_about_secondary_glb_failure(label: &str, err: CommonWebError, job: &FalJobDetails, pager: &Pager) {
let notification = NotificationDetailsBuilder::from_boxed_error(err.into())
.set_title(format!("Failure to download {} GLB variant from FAL webhook", label))
.set_description(Some(format!(
"We uploaded the primary GLB and marked the job as a success, \
but the {} variant failed and the user may need assistance.\n\
**Internal Job Token**: {}\n\
**Fal ID**: {}\n",
label,
job.job_token.as_str(),
job.external_third_party_id)))
.set_urgency(Some(NotificationUrgency::Medium))
.build();
if let Err(pager_err) = pager.enqueue_page(notification) {
warn!("Failed to enqueue {} GLB failure alert: {:?}", label, pager_err);
}
// Resolve mime type: magic bytes first, fal content_type as fallback.
let metadata = resolve_file_metadata(&file_bytes, thumbnail_data.content_type.as_deref())
.ok_or_else(|| {
warn!(
"Could not determine file type for thumbnail (bytes: {}, fal content_type: {:?})",
file_bytes.len(),
thumbnail_data.content_type,
);
CommonWebError::server_error_with_message(
&format!("Could not determine file type for thumbnail (bytes: {}, fal content_type: {:?})",
file_bytes.len(), thumbnail_data.content_type))
})?;
let mime_type = metadata.mime_type.as_str();
info!("Mime type of thumbnail image: {}, source: {:?}", mime_type, metadata.source);
let media_file_type = MediaFileType::try_from_mime_type(mime_type)
.ok_or_else(|| {
warn!("Unsupported thumbnail media file type: {}", mime_type);
CommonWebError::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 thumbnail bytes: {:?}", e);
CommonWebError::from_anyhow_error(e)
})?;
let public_upload_path = MediaFileBucketPath::generate_new(PREFIX, Some(&extension_with_period));
info!("Uploading cover image 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 thumbnail to bucket: {:?}", e);
CommonWebError::from_anyhow_error(e)
})?;
let thumbnail_media_token = MediaFileInsertBuilder::new()
.checksum_sha2(&file_hash)
.creator_ip_address(&job.creator_ip_address)
.file_size_bytes(file_size_bytes as u64)
.is_intermediate_system_file(true)
.maybe_creator_anonymous_visitor(job.maybe_creator_anonymous_visitor_token.as_ref())
.maybe_creator_user(job.maybe_creator_user_token.as_ref())
.maybe_generation_provider(Some(GenerationProvider::Artcraft))
.maybe_prompt_token(job.maybe_prompt_token.as_ref())
.maybe_platform_type(job.maybe_platform_type)
.media_file_class(MediaFileClass::Image)
.media_file_origin_category(MediaFileOriginCategory::Inference)
.media_file_type(media_file_type)
.mime_type(mime_type)
.public_bucket_directory_hash(&public_upload_path)
.insert_pool(&server_state.mysql_pool)
.await
.map_err(|e| {
warn!("Failed to insert thumbnail media file record: {:?}", e);
CommonWebError::from_error(e)
})?;
let query_result = set_media_file_cover_image(UpdateArgs {
media_file_token: glb_media_token,
maybe_cover_image_media_file_token: Some(&thumbnail_media_token),
mysql_pool: &server_state.mysql_pool,
}).await;
if let Err(err) = query_result {
warn!("Failed to set cover image on media file {}: {:?}", glb_media_token, err);
}
Ok(())
}
@@ -1,4 +1,5 @@
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::endpoints::webhooks::fal::process_success::attach_cover_image::{attach_cover_image, AttachCoverImageArgs};
use crate::http_server::endpoints::webhooks::fal::process_success::resolve_file_metadata::resolve_file_metadata_with_file_name;
use crate::state::server_state::ServerState;
use crate::util::http_download_url_to_bytes::http_download_url_to_bytes;
@@ -8,7 +9,7 @@ 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_origin_product_category::MediaFileOriginProductCategory;
use enums::by_table::media_files::media_file_type::MediaFileType;
use fal_client::webhook_payload::hydrated::hydrated_webhook_contents::{ModelMeshData, ModelObjData};
use fal_client::webhook_payload::hydrated::hydrated_webhook_contents::{ModelMeshData, ModelObjData, PreprocessedImageData};
use hashing::sha256::sha256_hash_bytes::sha256_hash_bytes;
use log::{info, warn};
use enums::common::generation_provider::GenerationProvider;
@@ -19,8 +20,11 @@ use tokens::tokens::media_files::MediaFileToken;
const PREFIX : Option<&str> = Some("artcraft_");
// NB: triposplat ply gaussian splat files also arrive via this payload handler.
// These are decidedly *not* "mesh" files!
pub async fn process_model_mesh_payload(
model_mesh_data: &ModelMeshData,
maybe_preprocessed_image: Option<&PreprocessedImageData>,
job: &FalJobDetails,
server_state: &ServerState,
) -> Result<MediaFileToken, CommonWebError> {
@@ -31,14 +35,31 @@ pub async fn process_model_mesh_payload(
CommonWebError::server_error_with_message("no `url` in model mesh payload")
})?;
upload_mesh_file(UploadMeshFileArgs {
let media_token = upload_mesh_file(UploadMeshFileArgs {
mesh_url,
maybe_content_type: model_mesh_data.content_type.as_deref(),
maybe_file_name: model_mesh_data.file_name.as_deref(),
maybe_batch_token: None,
job,
server_state,
}).await
}).await?;
// TripoSplat sends the segmented input image alongside the splat; attach it
// as the cover image. NB: Fail open — the result is usable without a cover.
if let Some(preprocessed_image) = maybe_preprocessed_image {
let result = try_to_attach_preprocessed_image(
preprocessed_image,
job,
server_state,
&media_token,
).await;
if let Err(err) = result {
warn!("Could not attach preprocessed image as cover image to media file: {:?}", err);
}
}
Ok(media_token)
}
/// `model_obj` payloads (e.g. Hunyuan 3D v3.1 Rapid's OBJ output) have the
@@ -151,9 +172,9 @@ pub(crate) async fn upload_mesh_file(
})?;
// PLY results are Gaussian splats (e.g. TripoSplat), not meshes: store
// them the way the World Labs splat pipeline does — tagged as world
// generation, with no `Object` engine category. No fal mesh endpoint
// returns PLY, so the file type is a reliable discriminator.
// them the way the World Labs splat pipeline does — `Splat` media class,
// tagged as world generation, with no `Object` engine category. No fal
// mesh endpoint returns PLY, so the file type is a reliable discriminator.
let is_gaussian_splat = matches!(media_file_type, MediaFileType::Ply);
let public_upload_path = MediaFileBucketPath::generate_new(PREFIX, Some(&extension_with_period));
@@ -180,7 +201,6 @@ pub(crate) async fn upload_mesh_file(
.maybe_generation_provider(Some(GenerationProvider::Artcraft))
.maybe_prompt_token(job.maybe_prompt_token.as_ref())
.maybe_platform_type(job.maybe_platform_type)
.media_file_class(MediaFileClass::Mesh)
.media_file_origin_category(MediaFileOriginCategory::Inference)
.media_file_type(media_file_type)
.mime_type(mime_type)
@@ -188,9 +208,11 @@ pub(crate) async fn upload_mesh_file(
if is_gaussian_splat {
insert_builder = insert_builder
.media_file_class(MediaFileClass::Splat)
.media_file_origin_product_category(MediaFileOriginProductCategory::WorldGeneration);
} else {
insert_builder = insert_builder
.media_file_class(MediaFileClass::Mesh)
.maybe_engine_category(Some(MediaFileEngineCategory::Object));
}
@@ -206,3 +228,31 @@ pub(crate) async fn upload_mesh_file(
Ok(media_token)
}
/// Attach a TripoSplat `preprocessed_image` (the segmented input image) as
/// the cover image of the uploaded splat, mirroring how `model_glb` results
/// attach their `thumbnail`.
async fn try_to_attach_preprocessed_image(
preprocessed_image: &PreprocessedImageData,
job: &FalJobDetails,
server_state: &ServerState,
splat_media_token: &MediaFileToken,
) -> Result<(), CommonWebError> {
info!("Fal Preprocessed Image Data: {:?}", preprocessed_image);
let image_url = preprocessed_image.url
.as_deref()
.ok_or_else(|| {
warn!("No `url` in preprocessed image payload");
CommonWebError::server_error_with_message("no `url` in preprocessed image payload")
})?;
attach_cover_image(AttachCoverImageArgs {
image_url,
maybe_content_type: preprocessed_image.content_type.as_deref(),
maybe_origin_product_category: Some(MediaFileOriginProductCategory::WorldGeneration),
target_media_token: splat_media_token,
job,
server_state,
}).await
}