From 762498daa1fb2f9cd9bdd06c6bf6335f9f666bfe Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 10 Jul 2026 14:20:56 -0400 Subject: [PATCH] upload triposplat ply and attach cover image --- .../hydrated/hydrated_webhook_contents.rs | 21 +++ .../extract_preprocessed_image_payload.rs | 84 +++++++++ .../hydrated/success_case_extractors/mod.rs | 8 +- .../success/triposplat_splat_payload_1.json | 28 +++ .../fal/process_success/attach_cover_image.rs | 163 ++++++++++++++++++ .../handle_successful_fal_webhook.rs | 4 +- .../webhooks/fal/process_success/mod.rs | 1 + .../process_model_glb_payload.rs | 115 +----------- .../process_model_mesh_payload.rs | 64 ++++++- 9 files changed, 372 insertions(+), 116 deletions(-) create mode 100644 crates/api_clients/fal_client/src/webhook_payload/hydrated/success_case_extractors/extract_preprocessed_image_payload.rs create mode 100644 crates/api_clients/fal_client/test_data/webhooks/success/triposplat_splat_payload_1.json create mode 100644 crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/process_success/attach_cover_image.rs 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 239201bb46..eece98effc 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 @@ -47,6 +47,8 @@ pub struct ExtractedContents { pub model_glb: Option, /// Parsed from `payload.model_mesh`. + /// NB: `triposplat` ply gaussian splat files also arrive via this payload handler. + /// These are decidedly *not* "mesh" files! pub model_mesh: Option, /// Parsed from `payload.model_obj` (e.g. Hunyuan 3D v3.1 Rapid's OBJ output). @@ -57,6 +59,10 @@ pub struct ExtractedContents { /// Parsed from `payload.thumbnail`. pub thumbnail: Option, + + /// Parsed from `payload.preprocessed_image` (e.g. TripoSplat's segmented + /// input image, usable as a cover image for the splat result). + pub preprocessed_image: Option, } /// Data under `payload.image`: @@ -100,6 +106,8 @@ pub struct ModelGlbData { } /// Data under `payload.model_mesh` (there may be other sibling keys too) +/// NB: `triposplat` ply gaussian splat files also arrive via this payload. +/// These are decidedly *not* "mesh" files! #[derive(Debug, Deserialize)] pub struct ModelMeshData { pub content_type: Option, @@ -140,6 +148,19 @@ pub struct ThumbnailData { pub url: Option, } +/// Data under `payload.preprocessed_image` (there may be other sibling keys too). +/// Seen together with `model_mesh` in TripoSplat results: the segmented input +/// image, which we use as a cover image for the splat. +#[derive(Debug, Deserialize)] +pub struct PreprocessedImageData { + pub content_type: Option, + pub file_name: Option, + pub file_size: Option, + pub height: Option, + pub url: Option, + pub width: Option, +} + #[derive(Debug)] pub struct ErrorData { /// The first human-readable message from `payload.detail[].msg`, if any. diff --git a/crates/api_clients/fal_client/src/webhook_payload/hydrated/success_case_extractors/extract_preprocessed_image_payload.rs b/crates/api_clients/fal_client/src/webhook_payload/hydrated/success_case_extractors/extract_preprocessed_image_payload.rs new file mode 100644 index 0000000000..bffba9854d --- /dev/null +++ b/crates/api_clients/fal_client/src/webhook_payload/hydrated/success_case_extractors/extract_preprocessed_image_payload.rs @@ -0,0 +1,84 @@ +use serde_json::{Map, Value}; + +use crate::webhook_payload::hydrated::hydrated_webhook_contents::PreprocessedImageData; + +/// Extract and deserialize the `preprocessed_image` key from a webhook success payload. +pub(crate) fn extract_preprocessed_image(obj: &Map) -> Option { + let value = obj.get("preprocessed_image")?; + 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 preprocessed_image_from_triposplat_test_file() { + 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 image = contents.preprocessed_image.expect("preprocessed_image should be Some"); + assert_eq!(image.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1b77c/YyJ19g7lJsRNXmvZvahTE_61fc73dbe84446b08b1fcce2167b71d5.png")); + assert_eq!(image.content_type.as_deref(), Some("image/png")); + assert_eq!(image.file_name.as_deref(), Some("61fc73dbe84446b08b1fcce2167b71d5.png")); + assert_eq!(image.file_size, Some(952754)); + assert_eq!(image.width, Some(1024)); + assert_eq!(image.height, Some(1024)); + + // The splat itself arrives under `model_mesh`. + 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)); + } + + #[test] + fn synthetic_preprocessed_image_payload() { + let obj: serde_json::Map = serde_json::from_str(r#"{ + "preprocessed_image": { + "url": "https://cdn.example.com/segmented.png", + "content_type": "image/png", + "file_name": "segmented.png", + "file_size": 12345, + "width": 1024, + "height": 1024 + } + }"#).unwrap(); + + let image = extract_preprocessed_image(&obj).expect("should extract preprocessed_image"); + assert_eq!(image.url.as_deref(), Some("https://cdn.example.com/segmented.png")); + assert_eq!(image.content_type.as_deref(), Some("image/png")); + assert_eq!(image.file_name.as_deref(), Some("segmented.png")); + assert_eq!(image.file_size, Some(12345)); + assert_eq!(image.width, Some(1024)); + assert_eq!(image.height, Some(1024)); + } + + #[test] + fn missing_preprocessed_image_key_returns_none() { + let obj: serde_json::Map = serde_json::from_str(r#"{ + "model_mesh": {"url": "https://example.com/output.ply"} + }"#).unwrap(); + + assert!(extract_preprocessed_image(&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 592320dfd7..a68c7f1d35 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 @@ -4,6 +4,7 @@ pub mod extract_video_payload; pub mod extract_model_glb_payload; pub mod extract_model_mesh_payload; pub mod extract_model_obj_payload; +pub mod extract_preprocessed_image_payload; pub mod extract_result_files_payload; pub mod extract_thumbnail_payload; @@ -14,7 +15,8 @@ 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`. If at least one is found, returns +/// `video`, `model_glb`, `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. @@ -29,10 +31,11 @@ pub fn extract_contents_from_payload(payload: &Value) -> Option Option = Some("artcraft_"); + +pub(crate) struct AttachCoverImageArgs<'a> { + pub image_url: &'a str, + pub maybe_content_type: Option<&'a str>, + /// Product category to tag the cover image record with, when the parent + /// media file carries one (e.g. `WorldGeneration` for gaussian splats). + pub maybe_origin_product_category: Option, + /// The media file the cover image is attached to. + pub target_media_token: &'a MediaFileToken, + pub job: &'a FalJobDetails, + pub server_state: &'a ServerState, +} + +/// Download a cover image from fal, upload it to the public bucket, insert an +/// intermediate media file record, and set it as the cover image of +/// `target_media_token`. Callers should fail open: a missing cover image is +/// not worth failing the whole webhook over. +pub(crate) async fn attach_cover_image( + args: AttachCoverImageArgs<'_>, +) -> Result<(), CommonWebError> { + let AttachCoverImageArgs { + image_url, + maybe_content_type, + maybe_origin_product_category, + target_media_token, + job, + server_state, + } = args; + + // Download with a retry if the first attempt returns suspiciously few bytes. + let mut file_bytes = http_download_url_to_bytes(image_url) + .await + .map_err(|err| { + warn!("Failed to download cover image from {}: {:?}", image_url, err); + CommonWebError::server_error_with_message( + &format!("Failed to download cover image: {:?}", err)) + })?; + + if file_bytes.len() <= 10 { + warn!( + "Downloaded only {} bytes from {} — retrying once", + file_bytes.len(), + image_url, + ); + file_bytes = http_download_url_to_bytes(image_url) + .await + .map_err(|err| { + warn!("Failed to download cover image on retry from {}: {:?}", image_url, err); + CommonWebError::server_error_with_message( + &format!("Failed to download cover image on retry: {:?}", err)) + })?; + } + + // Resolve mime type: magic bytes first, fal content_type as fallback. + let metadata = resolve_file_metadata(&file_bytes, maybe_content_type) + .ok_or_else(|| { + warn!( + "Could not determine file type for cover image (bytes: {}, fal content_type: {:?})", + file_bytes.len(), + maybe_content_type, + ); + CommonWebError::server_error_with_message( + &format!("Could not determine file type for cover image (bytes: {}, fal content_type: {:?})", + file_bytes.len(), maybe_content_type)) + })?; + + let mime_type = metadata.mime_type.as_str(); + + info!("Mime type of cover image: {}, source: {:?}", mime_type, metadata.source); + + let media_file_type = MediaFileType::try_from_mime_type(mime_type) + .ok_or_else(|| { + warn!("Unsupported cover image 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(|err| { + warn!("Failed to hash cover image bytes: {:?}", err); + CommonWebError::from_anyhow_error(err) + })?; + + let public_upload_path = MediaFileBucketPath::generate_new(PREFIX, Some(&extension_with_period)); + + info!("Uploading cover image 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(|err| { + warn!("Failed to upload cover image to bucket: {:?}", err); + CommonWebError::from_anyhow_error(err) + })?; + + let mut insert_builder = MediaFileInsertBuilder::new() + .checksum_sha2(&file_hash) + .creator_ip_address(&job.creator_ip_address) + .file_size_bytes(file_size_bytes as u64) + .is_intermediate_system_file(true) + .maybe_creator_anonymous_visitor(job.maybe_creator_anonymous_visitor_token.as_ref()) + .maybe_creator_user(job.maybe_creator_user_token.as_ref()) + .maybe_generation_provider(Some(GenerationProvider::Artcraft)) + .maybe_prompt_token(job.maybe_prompt_token.as_ref()) + .maybe_platform_type(job.maybe_platform_type) + .media_file_class(MediaFileClass::Image) + .media_file_origin_category(MediaFileOriginCategory::Inference) + .media_file_type(media_file_type) + .mime_type(mime_type) + .public_bucket_directory_hash(&public_upload_path); + + if let Some(origin_product_category) = maybe_origin_product_category { + insert_builder = insert_builder + .media_file_origin_product_category(origin_product_category); + } + + let cover_media_token = insert_builder + .insert_pool(&server_state.mysql_pool) + .await + .map_err(|err| { + warn!("Failed to insert cover image media file record: {:?}", err); + CommonWebError::from_error(err) + })?; + + let query_result = set_media_file_cover_image(UpdateArgs { + media_file_token: target_media_token, + maybe_cover_image_media_file_token: Some(&cover_media_token), + mysql_pool: &server_state.mysql_pool, + }).await; + + if let Err(err) = query_result { + warn!("Failed to set cover image on media file {}: {:?}", target_media_token, err); + } + + Ok(()) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/process_success/handle_successful_fal_webhook.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/process_success/handle_successful_fal_webhook.rs index cbeaf16634..92a8080df4 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/process_success/handle_successful_fal_webhook.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/process_success/handle_successful_fal_webhook.rs @@ -103,8 +103,10 @@ pub async fn handle_successful_fal_webhook( maybe_media_token = Some(token); } } else if let Some(ref model_mesh_data) = extracted.model_mesh { + // NB: triposplat ply gaussian splat files also arrive via this payload handler. + // These are decidedly *not* "mesh" files! info!("Handling model_mesh payload for request_id {} / job {:?}", request_id, job.job_token); - let token = process_model_mesh_payload(model_mesh_data, &job, server_state).await?; + let token = process_model_mesh_payload(model_mesh_data, extracted.preprocessed_image.as_ref(), &job, server_state).await?; if maybe_media_token.is_none() { maybe_media_token = Some(token); } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/process_success/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/process_success/mod.rs index 954b664878..5c7c363257 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/process_success/mod.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/process_success/mod.rs @@ -1,4 +1,5 @@ pub mod handle_successful_fal_webhook; +mod attach_cover_image; mod process_image_payload; mod process_images_payload; mod process_model_glb_payload; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/process_success/process_model_glb_payload.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/process_success/process_model_glb_payload.rs index ee7c2cac68..8bd6238fcd 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/process_success/process_model_glb_payload.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/process_success/process_model_glb_payload.rs @@ -1,4 +1,5 @@ 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::resolve_file_metadata::resolve_file_metadata; use crate::state::server_state::ServerState; use crate::util::http_download_url_to_bytes::http_download_url_to_bytes; @@ -10,10 +11,8 @@ use enums::by_table::media_files::media_file_type::MediaFileType; use fal_client::webhook_payload::hydrated::hydrated_webhook_contents::{ModelGlbData, ThumbnailData}; use hashing::sha256::sha256_hash_bytes::sha256_hash_bytes; use log::{info, warn}; -use enums::common::generation_provider::GenerationProvider; use mysql_queries::queries::generic_inference::api_providers::fal::get_inference_job_by_fal_id::FalJobDetails; use mysql_queries::queries::media_files::create::insert_builder::media_file_insert_builder::MediaFileInsertBuilder; -use mysql_queries::queries::media_files::edit::set_media_file_cover_image::{set_media_file_cover_image, UpdateArgs}; use tokens::tokens::media_files::MediaFileToken; const PREFIX : Option<&str> = Some("artcraft_"); @@ -199,108 +198,12 @@ async fn try_to_attach_thumbnail( CommonWebError::server_error_with_message("no `url` in thumbnail payload") })?; - // Download with a retry if the first attempt returns suspiciously few bytes. - let mut file_bytes = http_download_url_to_bytes(thumbnail_url) - .await - .map_err(|e| { - warn!("Failed to download thumbnail image from {}: {:?}", thumbnail_url, e); - CommonWebError::server_error_with_message( - &format!("Failed to download thumbnail image: {:?}", e)) - })?; - - if file_bytes.len() <= 10 { - warn!( - "Downloaded only {} bytes from {} — retrying once", - file_bytes.len(), - thumbnail_url, - ); - file_bytes = http_download_url_to_bytes(thumbnail_url) - .await - .map_err(|e| { - warn!("Failed to download thumbnail on retry from {}: {:?}", thumbnail_url, e); - CommonWebError::server_error_with_message( - &format!("Failed to download thumbnail on retry: {:?}", e)) - })?; - } - - // Resolve mime type: magic bytes first, fal content_type as fallback. - let metadata = resolve_file_metadata(&file_bytes, thumbnail_data.content_type.as_deref()) - .ok_or_else(|| { - warn!( - "Could not determine file type for thumbnail (bytes: {}, fal content_type: {:?})", - file_bytes.len(), - thumbnail_data.content_type, - ); - CommonWebError::server_error_with_message( - &format!("Could not determine file type for thumbnail (bytes: {}, fal content_type: {:?})", - file_bytes.len(), thumbnail_data.content_type)) - })?; - - let mime_type = metadata.mime_type.as_str(); - - info!("Mime type of thumbnail image: {}, source: {:?}", mime_type, metadata.source); - - let media_file_type = MediaFileType::try_from_mime_type(mime_type) - .ok_or_else(|| { - warn!("Unsupported thumbnail 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 thumbnail bytes: {:?}", e); - CommonWebError::from_anyhow_error(e) - })?; - - let public_upload_path = MediaFileBucketPath::generate_new(PREFIX, Some(&extension_with_period)); - - info!("Uploading cover image 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 thumbnail to bucket: {:?}", e); - CommonWebError::from_anyhow_error(e) - })?; - - let thumbnail_media_token = MediaFileInsertBuilder::new() - .checksum_sha2(&file_hash) - .creator_ip_address(&job.creator_ip_address) - .file_size_bytes(file_size_bytes as u64) - .is_intermediate_system_file(true) - .maybe_creator_anonymous_visitor(job.maybe_creator_anonymous_visitor_token.as_ref()) - .maybe_creator_user(job.maybe_creator_user_token.as_ref()) - .maybe_generation_provider(Some(GenerationProvider::Artcraft)) - .maybe_prompt_token(job.maybe_prompt_token.as_ref()) - .maybe_platform_type(job.maybe_platform_type) - .media_file_class(MediaFileClass::Image) - .media_file_origin_category(MediaFileOriginCategory::Inference) - .media_file_type(media_file_type) - .mime_type(mime_type) - .public_bucket_directory_hash(&public_upload_path) - .insert_pool(&server_state.mysql_pool) - .await - .map_err(|e| { - warn!("Failed to insert thumbnail media file record: {:?}", e); - CommonWebError::from_error(e) - })?; - - let query_result = set_media_file_cover_image(UpdateArgs { - media_file_token: glb_media_token, - maybe_cover_image_media_file_token: Some(&thumbnail_media_token), - mysql_pool: &server_state.mysql_pool, - }).await; - - if let Err(err) = query_result { - warn!("Failed to set cover image on media file {}: {:?}", glb_media_token, err); - } - - Ok(()) + attach_cover_image(AttachCoverImageArgs { + image_url: thumbnail_url, + maybe_content_type: thumbnail_data.content_type.as_deref(), + maybe_origin_product_category: None, + target_media_token: glb_media_token, + job, + server_state, + }).await } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/process_success/process_model_mesh_payload.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/process_success/process_model_mesh_payload.rs index 270f82893f..236dc9c694 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/process_success/process_model_mesh_payload.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/process_success/process_model_mesh_payload.rs @@ -1,4 +1,5 @@ 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::resolve_file_metadata::resolve_file_metadata_with_file_name; use crate::state::server_state::ServerState; use crate::util::http_download_url_to_bytes::http_download_url_to_bytes; @@ -8,7 +9,7 @@ use enums::by_table::media_files::media_file_engine_category::MediaFileEngineCat use enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory; use enums::by_table::media_files::media_file_origin_product_category::MediaFileOriginProductCategory; use enums::by_table::media_files::media_file_type::MediaFileType; -use fal_client::webhook_payload::hydrated::hydrated_webhook_contents::{ModelMeshData, ModelObjData}; +use fal_client::webhook_payload::hydrated::hydrated_webhook_contents::{ModelMeshData, ModelObjData, PreprocessedImageData}; use hashing::sha256::sha256_hash_bytes::sha256_hash_bytes; use log::{info, warn}; use enums::common::generation_provider::GenerationProvider; @@ -19,8 +20,11 @@ use tokens::tokens::media_files::MediaFileToken; const PREFIX : Option<&str> = Some("artcraft_"); +// NB: triposplat ply gaussian splat files also arrive via this payload handler. +// These are decidedly *not* "mesh" files! pub async fn process_model_mesh_payload( model_mesh_data: &ModelMeshData, + maybe_preprocessed_image: Option<&PreprocessedImageData>, job: &FalJobDetails, server_state: &ServerState, ) -> Result { @@ -31,14 +35,31 @@ pub async fn process_model_mesh_payload( CommonWebError::server_error_with_message("no `url` in model mesh payload") })?; - upload_mesh_file(UploadMeshFileArgs { + let media_token = upload_mesh_file(UploadMeshFileArgs { mesh_url, maybe_content_type: model_mesh_data.content_type.as_deref(), maybe_file_name: model_mesh_data.file_name.as_deref(), maybe_batch_token: None, job, server_state, - }).await + }).await?; + + // TripoSplat sends the segmented input image alongside the splat; attach it + // as the cover image. NB: Fail open — the result is usable without a cover. + if let Some(preprocessed_image) = maybe_preprocessed_image { + let result = try_to_attach_preprocessed_image( + preprocessed_image, + job, + server_state, + &media_token, + ).await; + + if let Err(err) = result { + warn!("Could not attach preprocessed image as cover image to media file: {:?}", err); + } + } + + Ok(media_token) } /// `model_obj` payloads (e.g. Hunyuan 3D v3.1 Rapid's OBJ output) have the @@ -151,9 +172,9 @@ pub(crate) async fn upload_mesh_file( })?; // PLY results are Gaussian splats (e.g. TripoSplat), not meshes: store - // them the way the World Labs splat pipeline does — tagged as world - // generation, with no `Object` engine category. No fal mesh endpoint - // returns PLY, so the file type is a reliable discriminator. + // them the way the World Labs splat pipeline does — `Splat` media class, + // tagged as world generation, with no `Object` engine category. No fal + // mesh endpoint returns PLY, so the file type is a reliable discriminator. let is_gaussian_splat = matches!(media_file_type, MediaFileType::Ply); let public_upload_path = MediaFileBucketPath::generate_new(PREFIX, Some(&extension_with_period)); @@ -180,7 +201,6 @@ pub(crate) async fn upload_mesh_file( .maybe_generation_provider(Some(GenerationProvider::Artcraft)) .maybe_prompt_token(job.maybe_prompt_token.as_ref()) .maybe_platform_type(job.maybe_platform_type) - .media_file_class(MediaFileClass::Mesh) .media_file_origin_category(MediaFileOriginCategory::Inference) .media_file_type(media_file_type) .mime_type(mime_type) @@ -188,9 +208,11 @@ pub(crate) async fn upload_mesh_file( if is_gaussian_splat { insert_builder = insert_builder + .media_file_class(MediaFileClass::Splat) .media_file_origin_product_category(MediaFileOriginProductCategory::WorldGeneration); } else { insert_builder = insert_builder + .media_file_class(MediaFileClass::Mesh) .maybe_engine_category(Some(MediaFileEngineCategory::Object)); } @@ -206,3 +228,31 @@ pub(crate) async fn upload_mesh_file( Ok(media_token) } + +/// Attach a TripoSplat `preprocessed_image` (the segmented input image) as +/// the cover image of the uploaded splat, mirroring how `model_glb` results +/// attach their `thumbnail`. +async fn try_to_attach_preprocessed_image( + preprocessed_image: &PreprocessedImageData, + job: &FalJobDetails, + server_state: &ServerState, + splat_media_token: &MediaFileToken, +) -> Result<(), CommonWebError> { + info!("Fal Preprocessed Image Data: {:?}", preprocessed_image); + + let image_url = preprocessed_image.url + .as_deref() + .ok_or_else(|| { + warn!("No `url` in preprocessed image payload"); + CommonWebError::server_error_with_message("no `url` in preprocessed image payload") + })?; + + attach_cover_image(AttachCoverImageArgs { + image_url, + maybe_content_type: preprocessed_image.content_type.as_deref(), + maybe_origin_product_category: Some(MediaFileOriginProductCategory::WorldGeneration), + target_media_token: splat_media_token, + job, + server_state, + }).await +}