mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-06 02:00:46 +00:00
extractors for Hunyuan 3d 3.0
This commit is contained in:
1 parent
2c3ff4a507
commit
cebe1b5f4b
6 files changed
+226
-36
No files matched your search
+16
@@ -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.
|
||||
|
||||
+104
@@ -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());
|
||||
}
|
||||
}
|
||||
+7
-4
@@ -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,
|
||||
|
||||
+37
@@ -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"
|
||||
}
|
||||
+11
-4
@@ -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,
|
||||
|
||||
+51
-28
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user