From 2c3ff4a507be54af353273fb1ca7818eed00a5cc Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 10 Jul 2026 14:48:58 -0400 Subject: [PATCH] hunyuan 3d 2.1 --- .../hydrated/hydrated_webhook_contents.rs | 7 +- .../extract_model_glb_pbr_payload.rs | 87 ++++++++ .../extract_model_mesh_payload.rs | 27 +++ .../hydrated/success_case_extractors/mod.rs | 11 +- .../webhook_payload/raw/webhook_error_type.rs | 1 + .../hunyuan_3d_2p1_model_glbs_payload_1.json | 27 +++ .../handle_successful_fal_webhook.rs | 15 +- .../process_model_glb_payload.rs | 198 +++++++++--------- 8 files changed, 266 insertions(+), 107 deletions(-) create mode 100644 crates/api_clients/fal_client/src/webhook_payload/hydrated/success_case_extractors/extract_model_glb_pbr_payload.rs create mode 100644 crates/api_clients/fal_client/test_data/webhooks/success/hunyuan_3d_2p1_model_glbs_payload_1.json diff --git a/crates/api_clients/fal_client/src/webhook_payload/hydrated/hydrated_webhook_contents.rs b/crates/api_clients/fal_client/src/webhook_payload/hydrated/hydrated_webhook_contents.rs index eece98effc..4b93ec29ac 100644 --- a/crates/api_clients/fal_client/src/webhook_payload/hydrated/hydrated_webhook_contents.rs +++ b/crates/api_clients/fal_client/src/webhook_payload/hydrated/hydrated_webhook_contents.rs @@ -46,6 +46,10 @@ pub struct ExtractedContents { /// Parsed from `payload.model_glb`. pub model_glb: Option, + /// 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, + /// 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, } -/// 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, diff --git a/crates/api_clients/fal_client/src/webhook_payload/hydrated/success_case_extractors/extract_model_glb_pbr_payload.rs b/crates/api_clients/fal_client/src/webhook_payload/hydrated/success_case_extractors/extract_model_glb_pbr_payload.rs new file mode 100644 index 0000000000..83a484703c --- /dev/null +++ b/crates/api_clients/fal_client/src/webhook_payload/hydrated/success_case_extractors/extract_model_glb_pbr_payload.rs @@ -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) -> Option { + 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 = 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 = serde_json::from_str(r#"{ + "model_glb": {"url": "https://example.com/model.glb"} + }"#).unwrap(); + + assert!(extract_model_glb_pbr(&obj).is_none()); + } +} diff --git a/crates/api_clients/fal_client/src/webhook_payload/hydrated/success_case_extractors/extract_model_mesh_payload.rs b/crates/api_clients/fal_client/src/webhook_payload/hydrated/success_case_extractors/extract_model_mesh_payload.rs index 9e7cf1757d..d8e1f5c213 100644 --- a/crates/api_clients/fal_client/src/webhook_payload/hydrated/success_case_extractors/extract_model_mesh_payload.rs +++ b/crates/api_clients/fal_client/src/webhook_payload/hydrated/success_case_extractors/extract_model_mesh_payload.rs @@ -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 = serde_json::from_str(r#"{ diff --git a/crates/api_clients/fal_client/src/webhook_payload/hydrated/success_case_extractors/mod.rs b/crates/api_clients/fal_client/src/webhook_payload/hydrated/success_case_extractors/mod.rs index a68c7f1d35..fa0ccfa675 100644 --- a/crates/api_clients/fal_client/src/webhook_payload/hydrated/success_case_extractors/mod.rs +++ b/crates/api_clients/fal_client/src/webhook_payload/hydrated/success_case_extractors/mod.rs @@ -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 Option Option = 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 { + pager: &Pager, +) -> Result<(MediaFileToken, Option), 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 { + 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); + } +}