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 4b93ec29ac..c0186db958 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 @@ -50,6 +50,10 @@ pub struct ExtractedContents { /// GLB variant, sent alongside `model_glb`). pub model_glb_pbr: Option, + /// Parsed from `payload.model_urls` (e.g. Hunyuan 3D 3.0's per-format file + /// map, sent alongside `model_glb`). + pub model_urls: Option, + /// Parsed from `payload.model_mesh`. /// NB: `triposplat` ply gaussian splat files also arrive via this payload handler. /// These are decidedly *not* "mesh" files! @@ -121,6 +125,18 @@ pub struct ModelMeshData { pub url: Option, } +/// Data under `payload.model_urls` (e.g. Hunyuan 3D 3.0): a map of the +/// generation's output files by format. Any slot may be null. The `glb` +/// entry frequently duplicates `payload.model_glb` (same URL), but may +/// point to a different file. +#[derive(Debug, Deserialize)] +pub struct ModelUrlsData { + pub fbx: Option, + pub glb: Option, + pub obj: Option, + pub usdz: Option, +} + /// 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. diff --git a/crates/api_clients/fal_client/src/webhook_payload/hydrated/success_case_extractors/extract_model_urls_payload.rs b/crates/api_clients/fal_client/src/webhook_payload/hydrated/success_case_extractors/extract_model_urls_payload.rs new file mode 100644 index 0000000000..430156efbc --- /dev/null +++ b/crates/api_clients/fal_client/src/webhook_payload/hydrated/success_case_extractors/extract_model_urls_payload.rs @@ -0,0 +1,104 @@ +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) -> Option { + 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.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 synthetic_model_urls_payload_with_distinct_glb() { + let obj: serde_json::Map = 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 = serde_json::from_str(r#"{ + "model_glb": {"url": "https://example.com/model.glb"} + }"#).unwrap(); + + assert!(extract_model_urls(&obj).is_none()); + } +} 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 fa0ccfa675..8a670148f3 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 @@ -5,6 +5,7 @@ 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_result_files_payload; pub mod extract_thumbnail_payload; @@ -16,9 +17,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_glb_pbr`, `model_mesh`, `model_obj`, -/// `result_files`, `thumbnail`, `preprocessed_image`. If at least one is -/// found, returns +/// `video`, `model_glb`, `model_glb_pbr`, `model_urls`, `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. @@ -30,6 +31,7 @@ pub fn extract_contents_from_payload(payload: &Value) -> Option Option Option, + maybe_model_urls_data: Option<&ModelUrlsData>, maybe_thumbnail_data: Option<&ThumbnailData>, job: &FalJobDetails, server_state: &ServerState, @@ -78,10 +84,16 @@ pub async fn process_model_glb_payload( CommonWebError::server_error_with_message("no `url` in model glb payload") })?; + // Hunyuan 3D 3.0's `model_urls.glb` usually duplicates `model_glb`; only + // treat it as a second GLB when it points at a different file. + let maybe_model_urls_glb = maybe_model_urls_data + .and_then(|urls| urls.glb.as_ref()) + .filter(|glb| glb.url.as_deref().is_some_and(|url| url != mesh_url)); + // 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()); + let maybe_batch_token = (maybe_model_glb_pbr_data.is_some() || maybe_model_urls_glb.is_some()) + .then(BatchGenerationToken::generate); let media_token = upload_mesh_file(UploadMeshFileArgs { mesh_url, @@ -94,19 +106,29 @@ pub async fn process_model_glb_payload( 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, + // 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. + let secondary_glbs = [ + ("PBR", maybe_model_glb_pbr_data), + ("model_urls", maybe_model_urls_glb), + ]; + + for (label, maybe_glb_data) in secondary_glbs { + let Some(glb_data) = maybe_glb_data else { + continue; + }; + + 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 PBR GLB variant: {:?}", err); - page_about_pbr_failure(err, job, pager); + warn!("Failed to upload {} GLB variant: {:?}", label, err); + page_about_secondary_glb_failure(label, err, job, pager); } } @@ -125,29 +147,29 @@ pub async fn process_model_glb_payload( Ok((media_token, maybe_batch_token)) } -async fn upload_model_glb_pbr( - pbr_data: &ModelGlbData, +async fn upload_secondary_glb( + glb_data: &ModelGlbData, maybe_batch_token: Option<&BatchGenerationToken>, job: &FalJobDetails, server_state: &ServerState, ) -> Result { - let pbr_url = pbr_data.url + let glb_url = glb_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") + 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: pbr_url, - maybe_content_type: pbr_data.content_type.as_deref(), - maybe_file_name: pbr_data.file_name.as_deref(), + 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!("PBR glb media file uploaded with token: {}", media_token); + info!("Secondary glb media file uploaded with token: {}", media_token); Ok(media_token) } @@ -184,20 +206,21 @@ async fn try_to_attach_thumbnail( }).await } -fn page_about_pbr_failure(err: CommonWebError, job: &FalJobDetails, pager: &Pager) { +fn page_about_secondary_glb_failure(label: &str, 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_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 PBR variant failed and the user may need assistance.\n\ + 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 PBR GLB failure alert: {:?}", pager_err); + warn!("Failed to enqueue {} GLB failure alert: {:?}", label, pager_err); } }