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 c0186db958..99cce52b48 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 @@ -125,15 +125,19 @@ 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 +/// Data under `payload.model_urls` (e.g. Hunyuan 3D 3.0 / 3.1): a map of the +/// generation's output files by format. Any slot may be null or absent. 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, + /// OBJ material file (Hunyuan 3D 3.1). + pub mtl: Option, pub obj: Option, + /// PBR texture image for the OBJ (Hunyuan 3D 3.1). + pub texture: Option, pub usdz: Option, } 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 index 430156efbc..2692548565 100644 --- 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 @@ -55,6 +55,8 @@ mod tests { 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"); @@ -68,6 +70,63 @@ mod tests { 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 synthetic_model_urls_payload_with_distinct_glb() { let obj: serde_json::Map = serde_json::from_str(r#"{ diff --git a/crates/api_clients/fal_client/test_data/webhooks/success/hunyuan_3d_3p1_model_urls_thumbnail_payload_1.json b/crates/api_clients/fal_client/test_data/webhooks/success/hunyuan_3d_3p1_model_urls_thumbnail_payload_1.json new file mode 100644 index 0000000000..ff83998550 --- /dev/null +++ b/crates/api_clients/fal_client/test_data/webhooks/success/hunyuan_3d_3p1_model_urls_thumbnail_payload_1.json @@ -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" +}