extractors for Hunyuan 3d 3.0

This commit is contained in:
Brandon Thomas committed 2026-07-10 15:42:57 -04:00
1 parent 2c3ff4a507
commit cebe1b5f4b
6 files changed
+226 -36

No files matched your search

@@ -50,6 +50,10 @@ pub struct ExtractedContents {
/// 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!
@@ -121,6 +125,18 @@ pub struct ModelMeshData {
pub url: Option<String>,
}
/// 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<ModelGlbData>,
pub glb: Option<ModelGlbData>,
pub obj: 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.
@@ -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<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.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<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());
}
}
@@ -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<ExtractedContent
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);
@@ -37,7 +39,7 @@ 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_glb_pbr.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() && preprocessed_image.is_none()
{
@@ -50,6 +52,7 @@ pub fn extract_contents_from_payload(payload: &Value) -> Option<ExtractedContent
video,
model_glb,
model_glb_pbr,
model_urls,
model_mesh,
model_obj,
result_files,
@@ -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"
}
@@ -96,14 +96,21 @@ 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 {
// 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, batch_token) = process_model_glb_payload(
model_glb_data,
extracted.model_glb_pbr.as_ref(),
extracted.model_urls.as_ref(),
extracted.thumbnail.as_ref(),
&job,
server_state,
@@ -2,7 +2,7 @@ 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::process_model_mesh_payload::{upload_mesh_file, UploadMeshFileArgs};
use crate::state::server_state::ServerState;
use fal_client::webhook_payload::hydrated::hydrated_webhook_contents::{ModelGlbData, ThumbnailData};
use fal_client::webhook_payload::hydrated::hydrated_webhook_contents::{ModelGlbData, ModelUrlsData, ThumbnailData};
use log::{info, warn};
use mysql_queries::queries::generic_inference::api_providers::fal::get_inference_job_by_fal_id::FalJobDetails;
use pager::client::pager::Pager;
@@ -12,7 +12,10 @@ use tokens::tokens::batch_generations::BatchGenerationToken;
use tokens::tokens::media_files::MediaFileToken;
/*
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": {
@@ -59,13 +62,16 @@ use tokens::tokens::media_files::MediaFileToken;
*/
/// 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.
/// 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.glb` entry (Hunyuan 3D 3.0; often the same URL as
/// `model_glb`, in which case it is skipped). All GLBs are uploaded as mesh
/// media files; when more than one is 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_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<MediaFileToken, CommonWebError> {
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);
}
}