hunyuan 3d 2.1

This commit is contained in:
Brandon Thomas
2026-07-10 14:48:58 -04:00
parent c91b6be429
commit 2c3ff4a507
8 changed files with 266 additions and 107 deletions
@@ -46,6 +46,10 @@ 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_mesh`.
/// NB: `triposplat` ply gaussian splat files also arrive via this payload handler.
/// These are decidedly *not* "mesh" files!
@@ -96,7 +100,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>,
@@ -0,0 +1,87 @@
use serde_json::{Map, Value};
use crate::webhook_payload::hydrated::hydrated_webhook_contents::ModelGlbData;
/// Extract and deserialize the `model_glb_pbr` key from a webhook success
/// payload. Sent alongside `model_glb` by e.g. Hunyuan 3D 2.1; same shape.
pub(crate) fn extract_model_glb_pbr(obj: &Map<String, Value>) -> Option<ModelGlbData> {
let value = obj.get("model_glb_pbr")?;
serde_json::from_value(value.clone()).ok()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::webhook_payload::hydrate_webhook_contents::hydrate_webhook_contents;
use crate::webhook_payload::hydrated::hydrated_webhook_contents::HydratedWebhookContents;
use crate::webhook_payload::raw::raw_webhook_payload::RawWebhookPayload;
fn load_test_webhook(filename: &str) -> RawWebhookPayload {
let path = format!("test_data/webhooks/{}", filename);
let json = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("Failed to read {}: {}", path, e));
serde_json::from_str(&json)
.unwrap_or_else(|e| panic!("Failed to parse {}: {}", path, e))
}
#[test]
fn model_glbs_from_hunyuan_3d_2p1_test_file() {
let webhook = load_test_webhook("success/hunyuan_3d_2p1_model_glbs_payload_1.json");
let result = hydrate_webhook_contents(&webhook);
let HydratedWebhookContents::Success(data) = result else {
panic!("Expected Success, got {:?}", result);
};
let contents = data.extracted_contents
.expect("extracted_contents should be Some");
let glb = contents.model_glb.expect("model_glb should be Some");
assert_eq!(glb.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1b916/QKdBTd8WJW_rtPB3IwlEN_demo_textured.glb"));
assert_eq!(glb.content_type.as_deref(), Some("application/octet-stream"));
assert_eq!(glb.file_name.as_deref(), Some("demo_textured.glb"));
assert_eq!(glb.file_size, Some(1574216));
let glb_pbr = contents.model_glb_pbr.expect("model_glb_pbr should be Some");
assert_eq!(glb_pbr.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1b921/SJw7AARzkc4l9QG5Lrvmu_demo_textured_pbr.glb"));
assert_eq!(glb_pbr.content_type.as_deref(), Some("application/octet-stream"));
assert_eq!(glb_pbr.file_name.as_deref(), Some("demo_textured_pbr.glb"));
assert_eq!(glb_pbr.file_size, Some(7512760));
// The payload also carries a `model_mesh` zip archive of the whole
// generation. It parses out, but the webhook handler prefers the GLBs.
let mesh = contents.model_mesh.expect("model_mesh should be Some");
assert_eq!(mesh.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1b915/B25DrTdiZJNV-60RaUh4u_3d_model.zip"));
assert_eq!(mesh.file_name.as_deref(), Some("3d_model.zip"));
assert!(contents.thumbnail.is_none());
assert!(contents.preprocessed_image.is_none());
}
#[test]
fn synthetic_model_glb_pbr_payload() {
let obj: serde_json::Map<String, serde_json::Value> = serde_json::from_str(r#"{
"model_glb_pbr": {
"url": "https://cdn.example.com/model_pbr.glb",
"content_type": "model/gltf-binary",
"file_name": "model_pbr.glb",
"file_size": 7654321
}
}"#).unwrap();
let glb_pbr = extract_model_glb_pbr(&obj).expect("should extract model_glb_pbr");
assert_eq!(glb_pbr.url.as_deref(), Some("https://cdn.example.com/model_pbr.glb"));
assert_eq!(glb_pbr.content_type.as_deref(), Some("model/gltf-binary"));
assert_eq!(glb_pbr.file_name.as_deref(), Some("model_pbr.glb"));
assert_eq!(glb_pbr.file_size, Some(7654321));
}
#[test]
fn missing_model_glb_pbr_key_returns_none() {
let obj: serde_json::Map<String, serde_json::Value> = serde_json::from_str(r#"{
"model_glb": {"url": "https://example.com/model.glb"}
}"#).unwrap();
assert!(extract_model_glb_pbr(&obj).is_none());
}
}
@@ -49,6 +49,33 @@ mod tests {
assert!(contents.thumbnail.is_none());
}
#[test]
fn triposplat_ply_splat_from_test_file() {
// TripoSplat sends its PLY gaussian splat under `model_mesh`.
let webhook = load_test_webhook("success/triposplat_splat_payload_1.json");
let result = hydrate_webhook_contents(&webhook);
let HydratedWebhookContents::Success(data) = result else {
panic!("Expected Success, got {:?}", result);
};
let contents = data.extracted_contents
.expect("extracted_contents should be Some");
let mesh = contents.model_mesh.expect("model_mesh should be Some");
assert_eq!(mesh.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1b77c/hEzg5xn9A-1qrLsnywjN4_output.ply"));
assert_eq!(mesh.content_type.as_deref(), Some("application/octet-stream"));
assert_eq!(mesh.file_name.as_deref(), Some("output.ply"));
assert_eq!(mesh.file_size, Some(17826208));
// The segmented input image rides along as `preprocessed_image`.
assert!(contents.preprocessed_image.is_some());
assert!(contents.model_glb.is_none());
assert!(contents.model_glb_pbr.is_none());
assert!(contents.thumbnail.is_none());
}
#[test]
fn synthetic_model_mesh_payload() {
let obj: serde_json::Map<String, serde_json::Value> = serde_json::from_str(r#"{
@@ -2,6 +2,7 @@ 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_preprocessed_image_payload;
@@ -15,8 +16,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`, `model_obj`, `result_files`,
/// `thumbnail`, `preprocessed_image`. If at least one is found, returns
/// `video`, `model_glb`, `model_glb_pbr`, `model_mesh`, `model_obj`,
/// `result_files`, `thumbnail`, `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.
@@ -27,6 +29,7 @@ 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_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);
@@ -34,7 +37,8 @@ pub fn extract_contents_from_payload(payload: &Value) -> Option<ExtractedContent
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()
&& model_glb.is_none() && model_glb_pbr.is_none()
&& model_mesh.is_none() && model_obj.is_none()
&& result_files.is_none() && thumbnail.is_none() && preprocessed_image.is_none()
{
return None;
@@ -45,6 +49,7 @@ pub fn extract_contents_from_payload(payload: &Value) -> Option<ExtractedContent
images,
video,
model_glb,
model_glb_pbr,
model_mesh,
model_obj,
result_files,
@@ -99,6 +99,7 @@ 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);
}
#[test]
@@ -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"
}
@@ -96,12 +96,25 @@ pub async fn handle_successful_fal_webhook(
}
}
// NB: `model_glb` takes 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).
if let Some(ref model_glb_data) = extracted.model_glb {
info!("Handling model_glb payload for request_id {} / job {:?}", request_id, job.job_token);
let token = process_model_glb_payload(model_glb_data, extracted.thumbnail.as_ref(), &job, server_state).await?;
let (token, batch_token) = process_model_glb_payload(
model_glb_data,
extracted.model_glb_pbr.as_ref(),
extracted.thumbnail.as_ref(),
&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!
@@ -1,22 +1,16 @@
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;
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 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 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,
@@ -52,14 +46,31 @@ 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 `model_glb_pbr` variant
/// (e.g. Hunyuan 3D 2.1). Both GLBs are uploaded as mesh media files; when
/// both are present 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_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(|| {
@@ -67,100 +78,38 @@ 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)
})?;
// 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 = maybe_model_glb_pbr_data
.map(|_| BatchGenerationToken::generate());
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))
})?;
}
// 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))
})?;
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 PBR variant into the same batch. NB: Fail open — the primary
// GLB is already uploaded, so page ourselves instead of failing the job.
if let Some(pbr_data) = maybe_model_glb_pbr_data {
let result = upload_model_glb_pbr(
pbr_data,
maybe_batch_token.as_ref(),
job,
server_state,
).await;
if let Err(err) = result {
warn!("Failed to upload PBR GLB variant: {:?}", err);
page_about_pbr_failure(err, job, pager);
}
}
let result = try_to_attach_thumbnail(
maybe_thumbnail_data,
job,
@@ -173,6 +122,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_model_glb_pbr(
pbr_data: &ModelGlbData,
maybe_batch_token: Option<&BatchGenerationToken>,
job: &FalJobDetails,
server_state: &ServerState,
) -> Result<MediaFileToken, CommonWebError> {
let pbr_url = pbr_data.url
.as_deref()
.ok_or_else(|| {
warn!("No `url` in model glb pbr payload");
CommonWebError::server_error_with_message("no `url` in model glb pbr payload")
})?;
let media_token = upload_mesh_file(UploadMeshFileArgs {
mesh_url: pbr_url,
maybe_content_type: pbr_data.content_type.as_deref(),
maybe_file_name: pbr_data.file_name.as_deref(),
maybe_batch_token,
job,
server_state,
}).await?;
info!("PBR glb media file uploaded with token: {}", media_token);
Ok(media_token)
}
@@ -207,3 +183,21 @@ async fn try_to_attach_thumbnail(
server_state,
}).await
}
fn page_about_pbr_failure(err: CommonWebError, job: &FalJobDetails, pager: &Pager) {
let notification = NotificationDetailsBuilder::from_boxed_error(err.into())
.set_title("Failure to download PBR GLB variant from FAL webhook".to_string())
.set_description(Some(format!(
"We uploaded the primary GLB and marked the job as a success, \
but the PBR variant failed and the user may need assistance.\n\
**Internal Job Token**: {}\n\
**Fal ID**: {}\n",
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 PBR GLB failure alert: {:?}", pager_err);
}
}