mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-29 21:12:52 -04:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0312e3abea | ||
|
|
ab93790332 | ||
|
|
c414185450 | ||
|
|
dffe07bfb1 | ||
|
|
3fe04d0bf3 | ||
|
|
cebe1b5f4b | ||
|
|
2c3ff4a507 | ||
|
|
c91b6be429 | ||
|
|
762498daa1 |
@@ -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 {
|
||||
|
||||
+61
-1
@@ -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.
|
||||
|
||||
+87
@@ -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());
|
||||
}
|
||||
}
|
||||
+27
@@ -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#"{
|
||||
|
||||
+327
@@ -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());
|
||||
}
|
||||
}
|
||||
+84
@@ -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());
|
||||
}
|
||||
}
|
||||
+76
@@ -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());
|
||||
}
|
||||
}
|
||||
+19
-3
@@ -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]
|
||||
|
||||
+18
@@ -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"
|
||||
}
|
||||
+19
@@ -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"
|
||||
}
|
||||
+27
@@ -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"
|
||||
}
|
||||
+37
@@ -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"
|
||||
}
|
||||
+49
@@ -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"
|
||||
}
|
||||
+55
@@ -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"
|
||||
}
|
||||
+88
@@ -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"
|
||||
}
|
||||
+36
@@ -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"
|
||||
}
|
||||
+28
@@ -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"
|
||||
}
|
||||
+6
@@ -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),
|
||||
|
||||
+2
@@ -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
-1
@@ -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);
|
||||
}
|
||||
|
||||
+123
-3
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+163
@@ -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(())
|
||||
}
|
||||
+29
-3
@@ -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
@@ -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;
|
||||
|
||||
+140
-206
@@ -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(())
|
||||
}
|
||||
|
||||
+57
-7
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user