From 73b40d321e1b936ab251b3add478d804f8bf4ce4 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 10 Jul 2026 16:41:59 -0400 Subject: [PATCH] fal 3d fixes (#1771) --- .../hydrate_webhook_contents.rs | 40 ++ .../hydrated/hydrated_webhook_contents.rs | 62 +++- .../extract_model_glb_pbr_payload.rs | 87 +++++ .../extract_model_mesh_payload.rs | 27 ++ .../extract_model_urls_payload.rs | 327 +++++++++++++++++ .../extract_preprocessed_image_payload.rs | 84 +++++ .../extract_rendered_image_payload.rs | 76 ++++ .../hydrated/success_case_extractors/mod.rs | 22 +- .../webhook_payload/raw/webhook_error_type.rs | 10 + ...3d_3p1_part_fail_format_not_supported.json | 18 + ...triposplat_fail_no_foreground_subject.json | 19 + .../hunyuan_3d_2p1_model_glbs_payload_1.json | 27 ++ .../hunyuan_3d_3p0_model_urls_payload_1.json | 37 ++ ...3d_3p1_model_urls_thumbnail_payload_1.json | 49 +++ .../hunyuan_3d_3p1_rapid_obj_payload_1.json | 55 +++ .../meshy_model_urls_thumbnail_payload_1.json | 88 +++++ ...d_model_urls_rendered_image_payload_1.json | 36 ++ .../success/triposplat_splat_payload_1.json | 28 ++ ...ontend_failure_category_for_api_clients.rs | 6 + ...ontend_failure_category_for_old_clients.rs | 2 + .../frontend_failure_category.rs | 29 +- .../src/tauri/tasks/task_failure_type.rs | 28 +- .../handle_failed_fal_webhook.rs | 126 ++++++- .../fal/process_success/attach_cover_image.rs | 163 +++++++++ .../handle_successful_fal_webhook.rs | 32 +- .../webhooks/fal/process_success/mod.rs | 1 + .../process_model_glb_payload.rs | 346 +++++++----------- .../process_model_mesh_payload.rs | 64 +++- 28 files changed, 1664 insertions(+), 225 deletions(-) create mode 100644 crates/api_clients/fal_client/src/webhook_payload/hydrated/success_case_extractors/extract_model_glb_pbr_payload.rs create mode 100644 crates/api_clients/fal_client/src/webhook_payload/hydrated/success_case_extractors/extract_model_urls_payload.rs 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/src/webhook_payload/hydrated/success_case_extractors/extract_rendered_image_payload.rs create mode 100644 crates/api_clients/fal_client/test_data/webhooks/failure/hunyuan_3d_3p1_part_fail_format_not_supported.json create mode 100644 crates/api_clients/fal_client/test_data/webhooks/failure/triposplat_fail_no_foreground_subject.json create mode 100644 crates/api_clients/fal_client/test_data/webhooks/success/hunyuan_3d_2p1_model_glbs_payload_1.json create mode 100644 crates/api_clients/fal_client/test_data/webhooks/success/hunyuan_3d_3p0_model_urls_payload_1.json create mode 100644 crates/api_clients/fal_client/test_data/webhooks/success/hunyuan_3d_3p1_model_urls_thumbnail_payload_1.json create mode 100644 crates/api_clients/fal_client/test_data/webhooks/success/hunyuan_3d_3p1_rapid_obj_payload_1.json create mode 100644 crates/api_clients/fal_client/test_data/webhooks/success/meshy_model_urls_thumbnail_payload_1.json create mode 100644 crates/api_clients/fal_client/test_data/webhooks/success/tripo3d_model_urls_rendered_image_payload_1.json 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/hydrate_webhook_contents.rs b/crates/api_clients/fal_client/src/webhook_payload/hydrate_webhook_contents.rs index fb2863fcbe..b25a58d492 100644 --- a/crates/api_clients/fal_client/src/webhook_payload/hydrate_webhook_contents.rs +++ b/crates/api_clients/fal_client/src/webhook_payload/hydrate_webhook_contents.rs @@ -182,6 +182,46 @@ mod tests { } } + #[test] + fn hunyuan_3d_part_format_not_supported() { + let webhook = load_test_webhook("failure/hunyuan_3d_3p1_part_fail_format_not_supported.json"); + let result = hydrate_webhook_contents(&webhook); + + match result { + HydratedWebhookContents::Error(data) => { + assert_eq!( + data.error_type, + Some(WebhookErrorType::ValueError), + ); + assert_eq!( + data.message.as_deref(), + Some("Part generation only supports FBX format. Please provide an FBX file or use /convert-format to convert your model to FBX first."), + ); + } + other => panic!("Expected HydratedWebhookContents::Error, got {:?}", other), + } + } + + #[test] + fn triposplat_no_foreground_subject() { + let webhook = load_test_webhook("failure/triposplat_fail_no_foreground_subject.json"); + let result = hydrate_webhook_contents(&webhook); + + match result { + HydratedWebhookContents::Error(data) => { + assert_eq!( + data.error_type, + Some(WebhookErrorType::InputValueError), + ); + assert_eq!( + data.message.as_deref(), + Some("No foreground subject could be detected in the input image after background removal. Provide an image with a clear, visible subject against a plain or distinct background."), + ); + } + other => panic!("Expected HydratedWebhookContents::Error, got {:?}", other), + } + } + #[test] fn payload_error_case() { let webhook = RawWebhookPayload { 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..5458106d7f 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 @@ -46,7 +46,17 @@ pub struct ExtractedContents { /// Parsed from `payload.model_glb`. pub model_glb: Option, + /// Parsed from `payload.model_glb_pbr` (e.g. Hunyuan 3D 2.1's PBR-textured + /// 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! pub model_mesh: Option, /// Parsed from `payload.model_obj` (e.g. Hunyuan 3D v3.1 Rapid's OBJ output). @@ -57,6 +67,14 @@ pub struct ExtractedContents { /// Parsed from `payload.thumbnail`. pub thumbnail: Option, + + /// Parsed from `payload.rendered_image` (e.g. Tripo 3D's preview image, + /// used as a cover image like `thumbnail`). + pub rendered_image: 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`: @@ -90,7 +108,8 @@ pub struct VideoData { pub file_size: Option, } -/// Data under `payload.model_glb` (there may be other sibling keys too) +/// Data under `payload.model_glb` (there may be other sibling keys too). +/// Also used for `payload.model_glb_pbr`, which has the same shape. #[derive(Debug, Deserialize)] pub struct ModelGlbData { pub content_type: Option, @@ -100,6 +119,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, @@ -108,6 +129,31 @@ pub struct ModelMeshData { pub url: Option, } +/// Data under `payload.model_urls` (e.g. Hunyuan 3D 3.0 / 3.1, Tripo 3D): a +/// map of the generation's output files by format. Any slot may be null or +/// absent. The `glb` entry frequently duplicates the top-level model key +/// (same URL), but may point to a different file. Slots may also duplicate +/// each other's URLs (e.g. Tripo 3D's `glb` and `pbr_model`). +#[derive(Debug, Deserialize)] +pub struct ModelUrlsData { + /// Untextured base model (Tripo 3D). + pub base_model: Option, + /// Blender project file (Meshy). + pub blend: Option, + pub fbx: Option, + pub glb: Option, + /// OBJ material file (Hunyuan 3D 3.1). + pub mtl: Option, + pub obj: Option, + /// PBR-textured model (Tripo 3D). + pub pbr_model: Option, + /// STL model (Meshy). + pub stl: Option, + /// PBR texture image for the OBJ (Hunyuan 3D 3.1). + pub texture: 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. @@ -132,6 +178,7 @@ pub struct ResultFileData { /// Data under `payload.thumbnail` (there may be other sibling keys too) /// Frequently seen together with `model_glb`. +/// Also used for `payload.rendered_image` (Tripo 3D), which has the same shape. #[derive(Debug, Deserialize)] pub struct ThumbnailData { pub content_type: Option, @@ -140,6 +187,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_model_glb_pbr_payload.rs b/crates/api_clients/fal_client/src/webhook_payload/hydrated/success_case_extractors/extract_model_glb_pbr_payload.rs new file mode 100644 index 0000000000..83a484703c --- /dev/null +++ b/crates/api_clients/fal_client/src/webhook_payload/hydrated/success_case_extractors/extract_model_glb_pbr_payload.rs @@ -0,0 +1,87 @@ +use serde_json::{Map, Value}; + +use crate::webhook_payload::hydrated::hydrated_webhook_contents::ModelGlbData; + +/// Extract and deserialize the `model_glb_pbr` key from a webhook success +/// payload. Sent alongside `model_glb` by e.g. Hunyuan 3D 2.1; same shape. +pub(crate) fn extract_model_glb_pbr(obj: &Map) -> Option { + let value = obj.get("model_glb_pbr")?; + 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_glbs_from_hunyuan_3d_2p1_test_file() { + let webhook = load_test_webhook("success/hunyuan_3d_2p1_model_glbs_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/0aa1b916/QKdBTd8WJW_rtPB3IwlEN_demo_textured.glb")); + assert_eq!(glb.content_type.as_deref(), Some("application/octet-stream")); + assert_eq!(glb.file_name.as_deref(), Some("demo_textured.glb")); + assert_eq!(glb.file_size, Some(1574216)); + + let glb_pbr = contents.model_glb_pbr.expect("model_glb_pbr should be Some"); + assert_eq!(glb_pbr.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1b921/SJw7AARzkc4l9QG5Lrvmu_demo_textured_pbr.glb")); + assert_eq!(glb_pbr.content_type.as_deref(), Some("application/octet-stream")); + assert_eq!(glb_pbr.file_name.as_deref(), Some("demo_textured_pbr.glb")); + assert_eq!(glb_pbr.file_size, Some(7512760)); + + // The payload also carries a `model_mesh` zip archive of the whole + // generation. It parses out, but the webhook handler prefers the GLBs. + let mesh = contents.model_mesh.expect("model_mesh should be Some"); + assert_eq!(mesh.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1b915/B25DrTdiZJNV-60RaUh4u_3d_model.zip")); + assert_eq!(mesh.file_name.as_deref(), Some("3d_model.zip")); + + assert!(contents.thumbnail.is_none()); + assert!(contents.preprocessed_image.is_none()); + } + + #[test] + fn synthetic_model_glb_pbr_payload() { + let obj: serde_json::Map = serde_json::from_str(r#"{ + "model_glb_pbr": { + "url": "https://cdn.example.com/model_pbr.glb", + "content_type": "model/gltf-binary", + "file_name": "model_pbr.glb", + "file_size": 7654321 + } + }"#).unwrap(); + + let glb_pbr = extract_model_glb_pbr(&obj).expect("should extract model_glb_pbr"); + assert_eq!(glb_pbr.url.as_deref(), Some("https://cdn.example.com/model_pbr.glb")); + assert_eq!(glb_pbr.content_type.as_deref(), Some("model/gltf-binary")); + assert_eq!(glb_pbr.file_name.as_deref(), Some("model_pbr.glb")); + assert_eq!(glb_pbr.file_size, Some(7654321)); + } + + #[test] + fn missing_model_glb_pbr_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_glb_pbr(&obj).is_none()); + } +} diff --git a/crates/api_clients/fal_client/src/webhook_payload/hydrated/success_case_extractors/extract_model_mesh_payload.rs b/crates/api_clients/fal_client/src/webhook_payload/hydrated/success_case_extractors/extract_model_mesh_payload.rs index 9e7cf1757d..d8e1f5c213 100644 --- a/crates/api_clients/fal_client/src/webhook_payload/hydrated/success_case_extractors/extract_model_mesh_payload.rs +++ b/crates/api_clients/fal_client/src/webhook_payload/hydrated/success_case_extractors/extract_model_mesh_payload.rs @@ -49,6 +49,33 @@ mod tests { assert!(contents.thumbnail.is_none()); } + #[test] + fn triposplat_ply_splat_from_test_file() { + // TripoSplat sends its PLY gaussian splat under `model_mesh`. + 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 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)); + + // The segmented input image rides along as `preprocessed_image`. + assert!(contents.preprocessed_image.is_some()); + + assert!(contents.model_glb.is_none()); + assert!(contents.model_glb_pbr.is_none()); + assert!(contents.thumbnail.is_none()); + } + #[test] fn synthetic_model_mesh_payload() { let obj: serde_json::Map = serde_json::from_str(r#"{ 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..cb5c63b41b --- /dev/null +++ b/crates/api_clients/fal_client/src/webhook_payload/hydrated/success_case_extractors/extract_model_urls_payload.rs @@ -0,0 +1,327 @@ +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.mtl.is_none()); + assert!(model_urls.texture.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 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 obj_and_thumbnail_from_hunyuan_3d_3p1_rapid_test_file() { + let webhook = load_test_webhook("success/hunyuan_3d_3p1_rapid_obj_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"); + + // NB: Hunyuan 3D 3.1 Rapid puts an OBJ under the `model_glb` key. The + // key name doesn't matter downstream: the webhook handler resolves the + // real file type from content_type / file name, so it uploads as an OBJ + // mesh. + let glb = contents.model_glb.expect("model_glb should be Some"); + assert_eq!(glb.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bae3/22zStkYp8-q6npG9dsMs5_23dce35fc95b34722f15a7329061d5df.obj")); + assert_eq!(glb.content_type.as_deref(), Some("model/obj")); + assert_eq!(glb.file_name.as_deref(), Some("23dce35fc95b34722f15a7329061d5df.obj")); + assert_eq!(glb.file_size, Some(3258750)); + + let model_urls = contents.model_urls.expect("model_urls should be Some"); + + // The `glb` slot is null here; `obj` duplicates `model_glb` (same URL). + assert!(model_urls.glb.is_none()); + assert!(model_urls.fbx.is_none()); + assert!(model_urls.usdz.is_none()); + + 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/0aa1bae3/22zStkYp8-q6npG9dsMs5_23dce35fc95b34722f15a7329061d5df.obj")); + assert_eq!(urls_obj.content_type.as_deref(), Some("model/obj")); + assert_eq!(urls_obj.file_size, Some(3258750)); + + 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/0aa1bae3/5cbE-q28i44GUOO6YeZPH_material.mtl")); + assert_eq!(urls_mtl.file_size, Some(229)); + + 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/0aa1bae3/7eBRddTHf6U8SUg2QOIHw_texture_pbr_v128.png")); + assert_eq!(urls_texture.file_name.as_deref(), Some("texture_pbr_v128.png")); + assert_eq!(urls_texture.file_size, Some(5503289)); + + // The thumbnail is attached to the mesh 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/0aa1bae3/IO3aspD4ijEadoFxSF504_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(120530)); + + assert!(contents.model_glb_pbr.is_none()); + assert!(contents.model_mesh.is_none()); + assert!(contents.model_obj.is_none()); + assert!(contents.preprocessed_image.is_none()); + } + + #[test] + fn model_urls_and_rendered_image_from_tripo3d_test_file() { + let webhook = load_test_webhook("success/tripo3d_model_urls_rendered_image_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"); + + // Tripo 3D sends its GLB under `model_mesh` (not `model_glb`); the + // webhook handler picks `model_urls.glb` as the primary GLB instead. + assert!(contents.model_glb.is_none()); + let mesh = contents.model_mesh.expect("model_mesh should be Some"); + assert_eq!(mesh.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bbdb/d8ATWvvXYHY-8zj-lQecf_model.glb")); + assert_eq!(mesh.content_type.as_deref(), Some("model/gltf-binary")); + + let model_urls = contents.model_urls.expect("model_urls should be Some"); + + // `glb` and `pbr_model` duplicate each other (same URL) in this payload; + // the webhook handler dedupes by URL and uploads the file only once. + 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/0aa1bbdb/d8ATWvvXYHY-8zj-lQecf_model.glb")); + assert_eq!(urls_glb.file_size, Some(43981280)); + + let urls_pbr_model = model_urls.pbr_model.expect("model_urls.pbr_model should be Some"); + assert_eq!(urls_pbr_model.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bbdb/d8ATWvvXYHY-8zj-lQecf_model.glb")); + + assert!(model_urls.base_model.is_none()); + assert!(model_urls.fbx.is_none()); + assert!(model_urls.mtl.is_none()); + assert!(model_urls.obj.is_none()); + assert!(model_urls.texture.is_none()); + assert!(model_urls.usdz.is_none()); + + // The preview arrives as `rendered_image` (no `thumbnail` key) and is + // attached to the mesh as its cover image downstream. + let rendered_image = contents.rendered_image.expect("rendered_image should be Some"); + assert_eq!(rendered_image.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bbd0/MblV2S5R6CeskRABMS7-V_preview.png")); + assert!(contents.thumbnail.is_none()); + + assert!(contents.model_glb_pbr.is_none()); + assert!(contents.preprocessed_image.is_none()); + } + + #[test] + fn model_urls_and_thumbnail_from_meshy_test_file() { + let webhook = load_test_webhook("success/meshy_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/0aa1bbe1/0vrf9NUhVxKZO4GScA0EI_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(15943768)); + + let model_urls = contents.model_urls.expect("model_urls should be Some"); + + // The `glb` slot duplicates `model_glb` (same URL); the webhook handler + // dedupes by URL so the file uploads only once. + 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/0aa1bbe1/0vrf9NUhVxKZO4GScA0EI_model.glb")); + + // Alternate formats of the same model; parsed but not uploaded. + let urls_fbx = model_urls.fbx.expect("model_urls.fbx should be Some"); + assert_eq!(urls_fbx.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bbed/yKO-HOjBnWA-LZpRLHHSB_model.fbx")); + assert_eq!(urls_fbx.file_size, Some(13665964)); + + 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/0aa1bbe2/NBmqIWBS8LuY_BPYWkOOf_model.obj")); + + let urls_stl = model_urls.stl.expect("model_urls.stl should be Some"); + assert_eq!(urls_stl.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bbe2/3wtOE6iEeVHZFvwAGN6Rl_model.stl")); + assert_eq!(urls_stl.content_type.as_deref(), Some("application/octet-stream")); + assert_eq!(urls_stl.file_size, Some(1508384)); + + let urls_usdz = model_urls.usdz.expect("model_urls.usdz should be Some"); + assert_eq!(urls_usdz.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bbed/BsniPBo6Uev85V4hLFACR_model.usdz")); + assert_eq!(urls_usdz.content_type.as_deref(), Some("model/vnd.usdz+zip")); + + assert!(model_urls.base_model.is_none()); + assert!(model_urls.blend.is_none()); + assert!(model_urls.mtl.is_none()); + assert!(model_urls.pbr_model.is_none()); + assert!(model_urls.texture.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/0aa1bbe3/tn1qfOr12oGXecA4mRtlI_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(192910)); + + assert!(contents.model_glb_pbr.is_none()); + assert!(contents.model_mesh.is_none()); + assert!(contents.rendered_image.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/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/extract_rendered_image_payload.rs b/crates/api_clients/fal_client/src/webhook_payload/hydrated/success_case_extractors/extract_rendered_image_payload.rs new file mode 100644 index 0000000000..a2ab444db6 --- /dev/null +++ b/crates/api_clients/fal_client/src/webhook_payload/hydrated/success_case_extractors/extract_rendered_image_payload.rs @@ -0,0 +1,76 @@ +use serde_json::{Map, Value}; + +use crate::webhook_payload::hydrated::hydrated_webhook_contents::ThumbnailData; + +/// Extract and deserialize the `rendered_image` key from a webhook success +/// payload (e.g. Tripo 3D's preview image). Same shape as `thumbnail`, and +/// used the same way: as a cover image for the model. +pub(crate) fn extract_rendered_image(obj: &Map) -> Option { + let value = obj.get("rendered_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 rendered_image_from_tripo3d_test_file() { + let webhook = load_test_webhook("success/tripo3d_model_urls_rendered_image_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 rendered_image = contents.rendered_image.expect("rendered_image should be Some"); + assert_eq!(rendered_image.url.as_deref(), Some("https://v3b.fal.media/files/b/0aa1bbd0/MblV2S5R6CeskRABMS7-V_preview.png")); + assert_eq!(rendered_image.content_type.as_deref(), Some("image/png")); + assert_eq!(rendered_image.file_name.as_deref(), Some("preview.png")); + assert_eq!(rendered_image.file_size, Some(30970)); + + // Tripo has no `thumbnail` key; the preview only arrives here. + assert!(contents.thumbnail.is_none()); + } + + #[test] + fn synthetic_rendered_image_payload() { + let obj: serde_json::Map = serde_json::from_str(r#"{ + "rendered_image": { + "url": "https://cdn.example.com/preview.png", + "content_type": "image/png", + "file_name": "preview.png", + "file_size": 12345 + } + }"#).unwrap(); + + let image = extract_rendered_image(&obj).expect("should extract rendered_image"); + assert_eq!(image.url.as_deref(), Some("https://cdn.example.com/preview.png")); + assert_eq!(image.content_type.as_deref(), Some("image/png")); + assert_eq!(image.file_name.as_deref(), Some("preview.png")); + assert_eq!(image.file_size, Some(12345)); + } + + #[test] + fn missing_rendered_image_key_returns_none() { + let obj: serde_json::Map = serde_json::from_str(r#"{ + "thumbnail": {"url": "https://example.com/preview.png"} + }"#).unwrap(); + + assert!(extract_rendered_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..a9a63cedc1 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 @@ -2,8 +2,12 @@ pub mod extract_image_payload; pub mod extract_images_payload; pub mod extract_video_payload; 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_rendered_image_payload; pub mod extract_result_files_payload; pub mod extract_thumbnail_payload; @@ -14,7 +18,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_mesh`. If at least one is found, returns +/// `video`, `model_glb`, `model_glb_pbr`, `model_urls`, `model_mesh`, +/// `model_obj`, `result_files`, `thumbnail`, `rendered_image`, +/// `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. @@ -25,14 +31,20 @@ pub fn extract_contents_from_payload(payload: &Value) -> Option Option Self { match value { FrontendFailureCategory::FaceNotDetected => Self::FaceNotDetected, + FrontendFailureCategory::NoForegroundSubjectDetected => Self::NoForegroundSubjectDetected, + FrontendFailureCategory::FormatNotSupported => Self::FormatNotSupported, FrontendFailureCategory::KeepAliveElapsed => Self::KeepAliveElapsed, FrontendFailureCategory::NotYetImplemented => Self::NotYetImplemented, FrontendFailureCategory::RetryableWorkerError => Self::RetryableWorkerError, @@ -62,6 +66,8 @@ mod tests { fn test_deserialize_known_variants() { let cases = vec![ ("\"face_not_detected\"", FrontendFailureCategoryForApiClients::FaceNotDetected), + ("\"no_foreground_subject_detected\"", FrontendFailureCategoryForApiClients::NoForegroundSubjectDetected), + ("\"format_not_supported\"", FrontendFailureCategoryForApiClients::FormatNotSupported), ("\"keep_alive_elapsed\"", FrontendFailureCategoryForApiClients::KeepAliveElapsed), ("\"not_yet_implemented\"", FrontendFailureCategoryForApiClients::NotYetImplemented), ("\"retryable_worker_error\"", FrontendFailureCategoryForApiClients::RetryableWorkerError), diff --git a/crates/schema/public/enums/src/api_safe/by_table/generic_inference_jobs/frontend_failure_category_for_old_clients.rs b/crates/schema/public/enums/src/api_safe/by_table/generic_inference_jobs/frontend_failure_category_for_old_clients.rs index 93fe941979..a00e495fb3 100644 --- a/crates/schema/public/enums/src/api_safe/by_table/generic_inference_jobs/frontend_failure_category_for_old_clients.rs +++ b/crates/schema/public/enums/src/api_safe/by_table/generic_inference_jobs/frontend_failure_category_for_old_clients.rs @@ -60,6 +60,8 @@ mod tests { #[test] fn test_new_variants_return_none() { + assert_eq!(FrontendFailureCategoryForOldClients::try_from_db_enum(FrontendFailureCategory::NoForegroundSubjectDetected), None); + assert_eq!(FrontendFailureCategoryForOldClients::try_from_db_enum(FrontendFailureCategory::FormatNotSupported), None); assert_eq!(FrontendFailureCategoryForOldClients::try_from_db_enum(FrontendFailureCategory::ModelRulesViolation), None); assert_eq!(FrontendFailureCategoryForOldClients::try_from_db_enum(FrontendFailureCategory::RuleBansUserImage), None); assert_eq!(FrontendFailureCategoryForOldClients::try_from_db_enum(FrontendFailureCategory::RuleBansUserImageWithFaces), None); diff --git a/crates/schema/public/enums/src/by_table/generic_inference_jobs/frontend_failure_category.rs b/crates/schema/public/enums/src/by_table/generic_inference_jobs/frontend_failure_category.rs index 994cca16b3..64a2ce90ce 100644 --- a/crates/schema/public/enums/src/by_table/generic_inference_jobs/frontend_failure_category.rs +++ b/crates/schema/public/enums/src/by_table/generic_inference_jobs/frontend_failure_category.rs @@ -28,6 +28,19 @@ pub enum FrontendFailureCategory { #[serde(rename = "face_not_detected")] FaceNotDetected, + /// No foreground subject could be detected in the input image after + /// background removal (fal TripoSplat `input_value_error`). The user should + /// provide an image with a clear subject against a plain or distinct + /// background. + #[serde(rename = "no_foreground_subject_detected")] + NoForegroundSubjectDetected, + + /// The input file's format isn't supported by the model (e.g. Hunyuan 3D + /// Part only accepts FBX input). The user should convert or pick a + /// supported file. + #[serde(rename = "format_not_supported")] + FormatNotSupported, + /// The user stepped away from their device and expected the workload to finish. /// Some workloads require that the user keep their browser open. #[serde(rename = "keep_alive_elapsed")] @@ -124,6 +137,8 @@ impl FrontendFailureCategory { pub fn to_str(&self) -> &'static str { match self { Self::FaceNotDetected => "face_not_detected", + Self::NoForegroundSubjectDetected => "no_foreground_subject_detected", + Self::FormatNotSupported => "format_not_supported", Self::KeepAliveElapsed => "keep_alive_elapsed", Self::NotYetImplemented => "not_yet_implemented", Self::RetryableWorkerError => "retryable_worker_error", @@ -145,6 +160,8 @@ impl FrontendFailureCategory { pub fn from_str(value: &str) -> Result { match value { "face_not_detected" => Ok(Self::FaceNotDetected), + "no_foreground_subject_detected" => Ok(Self::NoForegroundSubjectDetected), + "format_not_supported" => Ok(Self::FormatNotSupported), "keep_alive_elapsed" => Ok(Self::KeepAliveElapsed), "not_yet_implemented" => Ok(Self::NotYetImplemented), "retryable_worker_error" => Ok(Self::RetryableWorkerError), @@ -169,6 +186,8 @@ impl FrontendFailureCategory { // NB: BTreeSet::from() isn't const, but not worth using LazyStatic, etc. BTreeSet::from([ Self::FaceNotDetected, + Self::NoForegroundSubjectDetected, + Self::FormatNotSupported, Self::KeepAliveElapsed, Self::NotYetImplemented, Self::RetryableWorkerError, @@ -199,6 +218,8 @@ mod tests { #[test] fn test_serialization() { assert_serialization(FrontendFailureCategory::FaceNotDetected, "face_not_detected"); + assert_serialization(FrontendFailureCategory::NoForegroundSubjectDetected, "no_foreground_subject_detected"); + assert_serialization(FrontendFailureCategory::FormatNotSupported, "format_not_supported"); assert_serialization(FrontendFailureCategory::KeepAliveElapsed, "keep_alive_elapsed"); assert_serialization(FrontendFailureCategory::NotYetImplemented, "not_yet_implemented"); assert_serialization(FrontendFailureCategory::RetryableWorkerError, "retryable_worker_error"); @@ -219,6 +240,8 @@ mod tests { #[test] fn to_str() { assert_eq!(FrontendFailureCategory::FaceNotDetected.to_str(), "face_not_detected"); + assert_eq!(FrontendFailureCategory::NoForegroundSubjectDetected.to_str(), "no_foreground_subject_detected"); + assert_eq!(FrontendFailureCategory::FormatNotSupported.to_str(), "format_not_supported"); assert_eq!(FrontendFailureCategory::KeepAliveElapsed.to_str(), "keep_alive_elapsed"); assert_eq!(FrontendFailureCategory::NotYetImplemented.to_str(), "not_yet_implemented"); assert_eq!(FrontendFailureCategory::RetryableWorkerError.to_str(), "retryable_worker_error"); @@ -239,6 +262,8 @@ mod tests { #[test] fn from_str() { assert_eq!(FrontendFailureCategory::from_str("face_not_detected").unwrap(), FrontendFailureCategory::FaceNotDetected); + assert_eq!(FrontendFailureCategory::from_str("no_foreground_subject_detected").unwrap(), FrontendFailureCategory::NoForegroundSubjectDetected); + assert_eq!(FrontendFailureCategory::from_str("format_not_supported").unwrap(), FrontendFailureCategory::FormatNotSupported); assert_eq!(FrontendFailureCategory::from_str("keep_alive_elapsed").unwrap(), FrontendFailureCategory::KeepAliveElapsed); assert_eq!(FrontendFailureCategory::from_str("not_yet_implemented").unwrap(), FrontendFailureCategory::NotYetImplemented); assert_eq!(FrontendFailureCategory::from_str("retryable_worker_error").unwrap(), FrontendFailureCategory::RetryableWorkerError); @@ -260,8 +285,10 @@ mod tests { #[test] fn all_variants() { let mut variants = FrontendFailureCategory::all_variants(); - assert_eq!(variants.len(), 16); + assert_eq!(variants.len(), 18); assert_eq!(variants.pop_first(), Some(FrontendFailureCategory::FaceNotDetected)); + assert_eq!(variants.pop_first(), Some(FrontendFailureCategory::NoForegroundSubjectDetected)); + assert_eq!(variants.pop_first(), Some(FrontendFailureCategory::FormatNotSupported)); assert_eq!(variants.pop_first(), Some(FrontendFailureCategory::KeepAliveElapsed)); assert_eq!(variants.pop_first(), Some(FrontendFailureCategory::NotYetImplemented)); assert_eq!(variants.pop_first(), Some(FrontendFailureCategory::RetryableWorkerError)); diff --git a/crates/schema/public/enums/src/tauri/tasks/task_failure_type.rs b/crates/schema/public/enums/src/tauri/tasks/task_failure_type.rs index 6b6f73f1f0..f531dcd6d9 100644 --- a/crates/schema/public/enums/src/tauri/tasks/task_failure_type.rs +++ b/crates/schema/public/enums/src/tauri/tasks/task_failure_type.rs @@ -30,6 +30,14 @@ pub enum TaskFailureType { RuleBansGeneratedAudio, RuleBansGeneratedContent, + /// No foreground subject could be detected in the input image after + /// background removal (e.g. TripoSplat image-to-splat). + NoForegroundSubjectDetected, + + /// The input file's format isn't supported by the model (e.g. Hunyuan 3D + /// Part only accepts FBX input). + FormatNotSupported, + /// No reason given for generation failure, but this matches what we were told. GenerationFailed, } @@ -50,6 +58,8 @@ impl TaskFailureType { FrontendFailureCategory::RuleBansGeneratedVideo => Self::RuleBansGeneratedVideo, FrontendFailureCategory::RuleBansGeneratedAudio => Self::RuleBansGeneratedAudio, FrontendFailureCategory::RuleBansGeneratedContent => Self::RuleBansGeneratedContent, + FrontendFailureCategory::NoForegroundSubjectDetected => Self::NoForegroundSubjectDetected, + FrontendFailureCategory::FormatNotSupported => Self::FormatNotSupported, FrontendFailureCategory::GenerationFailed => Self::GenerationFailed, _ => Self::Unknown, } @@ -67,6 +77,8 @@ impl TaskFailureType { FrontendFailureCategoryForApiClients::RuleBansGeneratedVideo => Self::RuleBansGeneratedVideo, FrontendFailureCategoryForApiClients::RuleBansGeneratedAudio => Self::RuleBansGeneratedAudio, FrontendFailureCategoryForApiClients::RuleBansGeneratedContent => Self::RuleBansGeneratedContent, + FrontendFailureCategoryForApiClients::NoForegroundSubjectDetected => Self::NoForegroundSubjectDetected, + FrontendFailureCategoryForApiClients::FormatNotSupported => Self::FormatNotSupported, FrontendFailureCategoryForApiClients::GenerationFailed => Self::GenerationFailed, // Types ArtCraft doesn't care about @@ -96,6 +108,8 @@ impl TaskFailureType { Self::RuleBansGeneratedVideo => "rule_bans_generated_video", Self::RuleBansGeneratedAudio => "rule_bans_generated_audio", Self::RuleBansGeneratedContent => "rule_bans_generated_content", + Self::NoForegroundSubjectDetected => "no_foreground_subject_detected", + Self::FormatNotSupported => "format_not_supported", Self::GenerationFailed => "generation_failed", } } @@ -110,6 +124,8 @@ impl TaskFailureType { "rule_bans_generated_video" => Ok(Self::RuleBansGeneratedVideo), "rule_bans_generated_audio" => Ok(Self::RuleBansGeneratedAudio), "rule_bans_generated_content" => Ok(Self::RuleBansGeneratedContent), + "no_foreground_subject_detected" => Ok(Self::NoForegroundSubjectDetected), + "format_not_supported" => Ok(Self::FormatNotSupported), "generation_failed" => Ok(Self::GenerationFailed), _ => Err(EnumError::CouldNotConvertFromString(value.to_string())), } @@ -125,6 +141,8 @@ impl TaskFailureType { Self::RuleBansGeneratedVideo, Self::RuleBansGeneratedAudio, Self::RuleBansGeneratedContent, + Self::NoForegroundSubjectDetected, + Self::FormatNotSupported, Self::GenerationFailed, ]) } @@ -149,6 +167,8 @@ mod tests { assert_serialization(TaskFailureType::RuleBansGeneratedVideo, "rule_bans_generated_video"); assert_serialization(TaskFailureType::RuleBansGeneratedAudio, "rule_bans_generated_audio"); assert_serialization(TaskFailureType::RuleBansGeneratedContent, "rule_bans_generated_content"); + assert_serialization(TaskFailureType::NoForegroundSubjectDetected, "no_foreground_subject_detected"); + assert_serialization(TaskFailureType::FormatNotSupported, "format_not_supported"); assert_serialization(TaskFailureType::GenerationFailed, "generation_failed"); } @@ -162,6 +182,8 @@ mod tests { assert_eq!(TaskFailureType::RuleBansGeneratedVideo.to_str(), "rule_bans_generated_video"); assert_eq!(TaskFailureType::RuleBansGeneratedAudio.to_str(), "rule_bans_generated_audio"); assert_eq!(TaskFailureType::RuleBansGeneratedContent.to_str(), "rule_bans_generated_content"); + assert_eq!(TaskFailureType::NoForegroundSubjectDetected.to_str(), "no_foreground_subject_detected"); + assert_eq!(TaskFailureType::FormatNotSupported.to_str(), "format_not_supported"); assert_eq!(TaskFailureType::GenerationFailed.to_str(), "generation_failed"); } @@ -175,6 +197,8 @@ mod tests { assert_eq!(TaskFailureType::from_str("rule_bans_generated_video").unwrap(), TaskFailureType::RuleBansGeneratedVideo); assert_eq!(TaskFailureType::from_str("rule_bans_generated_audio").unwrap(), TaskFailureType::RuleBansGeneratedAudio); assert_eq!(TaskFailureType::from_str("rule_bans_generated_content").unwrap(), TaskFailureType::RuleBansGeneratedContent); + assert_eq!(TaskFailureType::from_str("no_foreground_subject_detected").unwrap(), TaskFailureType::NoForegroundSubjectDetected); + assert_eq!(TaskFailureType::from_str("format_not_supported").unwrap(), TaskFailureType::FormatNotSupported); assert_eq!(TaskFailureType::from_str("generation_failed").unwrap(), TaskFailureType::GenerationFailed); } @@ -192,7 +216,7 @@ mod tests { #[test] fn all_variants() { let mut variants = TaskFailureType::all_variants(); - assert_eq!(variants.len(), 9); + assert_eq!(variants.len(), 11); assert_eq!(variants.pop_first(), Some(TaskFailureType::Unknown)); assert_eq!(variants.pop_first(), Some(TaskFailureType::RuleBansUserImage)); assert_eq!(variants.pop_first(), Some(TaskFailureType::RuleBansUserImageWithFaces)); @@ -201,6 +225,8 @@ mod tests { assert_eq!(variants.pop_first(), Some(TaskFailureType::RuleBansGeneratedVideo)); assert_eq!(variants.pop_first(), Some(TaskFailureType::RuleBansGeneratedAudio)); assert_eq!(variants.pop_first(), Some(TaskFailureType::RuleBansGeneratedContent)); + assert_eq!(variants.pop_first(), Some(TaskFailureType::NoForegroundSubjectDetected)); + assert_eq!(variants.pop_first(), Some(TaskFailureType::FormatNotSupported)); assert_eq!(variants.pop_first(), Some(TaskFailureType::GenerationFailed)); assert_eq!(variants.pop_first(), None); } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/process_failure/handle_failed_fal_webhook.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/process_failure/handle_failed_fal_webhook.rs index 8fc51e91b0..f42ad51c6a 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/process_failure/handle_failed_fal_webhook.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/process_failure/handle_failed_fal_webhook.rs @@ -81,7 +81,10 @@ pub async fn handle_failed_fal_webhook( error_data.message, ); - let failure_category = guess_failure_category(error_data.error_type.as_ref()); + let failure_category = guess_failure_category( + error_data.error_type.as_ref(), + error_data.message.as_deref(), + ); info!( "Marking job {} as failed for request_id {}. Category: {:?}, Reason: {}", @@ -118,19 +121,136 @@ pub async fn handle_failed_fal_webhook( // =============== Private helpers =============== -/// Map a FAL WebhookErrorType to a frontend failure category. +/// Map a FAL WebhookErrorType (and, for generic validation errors, the +/// human-readable message) to a frontend failure category. /// /// Returns `GenerationFailed` as the default if the error type is None or unrecognized. -fn guess_failure_category(error_type: Option<&WebhookErrorType>) -> FrontendFailureCategory { +fn guess_failure_category( + error_type: Option<&WebhookErrorType>, + maybe_message: Option<&str>, +) -> FrontendFailureCategory { match error_type { Some(WebhookErrorType::ContentPolicyViolation) => FrontendFailureCategory::RuleBansUserContent, Some(WebhookErrorType::FaceDetectionError) => FrontendFailureCategory::FaceNotDetected, Some(WebhookErrorType::FileTooLarge) => FrontendFailureCategory::FilesizeTooLarge, Some(WebhookErrorType::ImageTooLarge) => FrontendFailureCategory::ImageDimensionsTooLarge, Some(WebhookErrorType::ImageTooSmall) => FrontendFailureCategory::ImageDimensionsTooSmall, + // `input_value_error` / `value_error` are generic validation errors; the + // message carries the specifics. + Some(WebhookErrorType::InputValueError) + | Some(WebhookErrorType::ValueError) => guess_validation_failure_category(maybe_message), Some(WebhookErrorType::NoMediaGenerated) | Some(WebhookErrorType::ImageLoadError) | Some(WebhookErrorType::FileDownloadError) => FrontendFailureCategory::GenerationFailed, _ => FrontendFailureCategory::GenerationFailed, } } + +/// Classify a generic fal validation error (`input_value_error` / +/// `value_error`) by its human-readable message. +fn guess_validation_failure_category(maybe_message: Option<&str>) -> FrontendFailureCategory { + let Some(message) = maybe_message else { + return FrontendFailureCategory::GenerationFailed; + }; + + let message = message.to_ascii_lowercase(); + + // TripoSplat: "No foreground subject could be detected in the input image + // after background removal. ..." + if message.contains("no foreground subject") { + return FrontendFailureCategory::NoForegroundSubjectDetected; + } + + // Hunyuan 3D Part: "Part generation only supports FBX format. Please + // provide an FBX file or use /convert-format to convert your model to FBX + // first." + if message.contains("only supports") && message.contains("format") { + return FrontendFailureCategory::FormatNotSupported; + } + + FrontendFailureCategory::GenerationFailed +} + +#[cfg(test)] +mod tests { + use super::*; + + const NO_FOREGROUND_SUBJECT_MESSAGE: &str = + "No foreground subject could be detected in the input image after background removal. \ + Provide an image with a clear, visible subject against a plain or distinct background."; + + const FBX_ONLY_MESSAGE: &str = + "Part generation only supports FBX format. Please provide an FBX file or use \ + /convert-format to convert your model to FBX first."; + + mod guess_failure_category_tests { + use super::*; + + #[test] + fn input_value_error_with_no_foreground_subject_message() { + let category = guess_failure_category( + Some(&WebhookErrorType::InputValueError), + Some(NO_FOREGROUND_SUBJECT_MESSAGE), + ); + assert_eq!(category, FrontendFailureCategory::NoForegroundSubjectDetected); + } + + #[test] + fn value_error_with_fbx_only_message() { + let category = guess_failure_category( + Some(&WebhookErrorType::ValueError), + Some(FBX_ONLY_MESSAGE), + ); + assert_eq!(category, FrontendFailureCategory::FormatNotSupported); + } + + #[test] + fn input_value_error_with_fbx_only_message() { + // Both generic validation error types share the message classifier. + let category = guess_failure_category( + Some(&WebhookErrorType::InputValueError), + Some(FBX_ONLY_MESSAGE), + ); + assert_eq!(category, FrontendFailureCategory::FormatNotSupported); + } + + #[test] + fn input_value_error_with_other_message() { + let category = guess_failure_category( + Some(&WebhookErrorType::InputValueError), + Some("Field `num_gaussians` must be a positive integer."), + ); + assert_eq!(category, FrontendFailureCategory::GenerationFailed); + } + + #[test] + fn value_error_with_other_message() { + let category = guess_failure_category( + Some(&WebhookErrorType::ValueError), + Some("Input file could not be parsed."), + ); + assert_eq!(category, FrontendFailureCategory::GenerationFailed); + } + + #[test] + fn input_value_error_without_message() { + let category = guess_failure_category(Some(&WebhookErrorType::InputValueError), None); + assert_eq!(category, FrontendFailureCategory::GenerationFailed); + } + + #[test] + fn content_policy_violation_ignores_message() { + let category = guess_failure_category( + Some(&WebhookErrorType::ContentPolicyViolation), + Some(NO_FOREGROUND_SUBJECT_MESSAGE), + ); + assert_eq!(category, FrontendFailureCategory::RuleBansUserContent); + } + + #[test] + fn no_error_type_defaults_to_generation_failed() { + let category = guess_failure_category(None, Some(FBX_ONLY_MESSAGE)); + assert_eq!(category, FrontendFailureCategory::GenerationFailed); + } + } +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/process_success/attach_cover_image.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/process_success/attach_cover_image.rs new file mode 100644 index 0000000000..7c669b0aa7 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/process_success/attach_cover_image.rs @@ -0,0 +1,163 @@ +//! Downloads an image from a fal result payload, uploads it to the public +//! bucket, inserts an intermediate media file record, and sets it as the +//! cover image of another media file (e.g. a mesh or gaussian splat). + +use crate::http_server::common_responses::common_web_error::CommonWebError; +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; +use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath; +use enums::by_table::media_files::media_file_class::MediaFileClass; +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 enums::common::generation_provider::GenerationProvider; +use hashing::sha256::sha256_hash_bytes::sha256_hash_bytes; +use log::{info, warn}; +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_"); + +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..5942ce8397 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 @@ -96,15 +96,41 @@ pub async fn handle_successful_fal_webhook( } } - 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 = process_model_glb_payload(model_glb_data, extracted.thumbnail.as_ref(), &job, server_state).await?; + // The cover image arrives as `thumbnail` (Hunyuan) or `rendered_image` + // (Tripo 3D); both have the same shape. + let maybe_cover_image = extracted.thumbnail.as_ref() + .or(extracted.rendered_image.as_ref()); + let (token, batch_token) = process_model_glb_payload( + model_glb_data, + extracted.model_glb_pbr.as_ref(), + extracted.model_urls.as_ref(), + maybe_cover_image, + &job, + server_state, + pager, + ).await?; if maybe_media_token.is_none() { maybe_media_token = Some(token); } + if maybe_batch_token.is_none() { + maybe_batch_token = batch_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..58b09f06b4 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,25 +1,21 @@ use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::endpoints::webhooks::fal::process_success::resolve_file_metadata::resolve_file_metadata; +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 crate::util::http_download_url_to_bytes::http_download_url_to_bytes; -use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath; -use enums::by_table::media_files::media_file_class::MediaFileClass; -use enums::by_table::media_files::media_file_engine_category::MediaFileEngineCategory; -use enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory; -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 fal_client::webhook_payload::hydrated::hydrated_webhook_contents::{ModelGlbData, ModelUrlsData, ThumbnailData}; 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 pager::client::pager::Pager; +use pager::notification::notification_details_builder::NotificationDetailsBuilder; +use pager::notification::notification_urgency::NotificationUrgency; +use tokens::tokens::batch_generations::BatchGenerationToken; use tokens::tokens::media_files::MediaFileToken; -const PREFIX : Option<&str> = Some("artcraft_"); - /* - 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": { @@ -53,14 +49,35 @@ const PREFIX : Option<&str> = Some("artcraft_"); "seed": null } + Hunyuan 3D 2.1 additionally sends a PBR-textured GLB variant (plus a + `model_mesh` zip archive of the whole generation, which we skip in favor + of the GLBs): + + { + "model_glb": { "url": "...demo_textured.glb", ... }, + "model_glb_pbr": { "url": "...demo_textured_pbr.glb", ... }, + "model_mesh": { "url": "...3d_model.zip", ... }, + "seed": 283904 + } + */ +/// 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` entries `glb` (Hunyuan 3D 3.0), `base_model` and `pbr_model` +/// (Tripo 3D). Secondary entries frequently repeat the primary's URL, so +/// only distinct files are uploaded. All GLBs become mesh media files; when +/// more than one is uploaded 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, -) -> Result { + pager: &Pager, +) -> Result<(MediaFileToken, Option), CommonWebError> { let mesh_url = model_glb_data.url .as_deref() .ok_or_else(|| { @@ -68,100 +85,67 @@ pub async fn process_model_glb_payload( CommonWebError::server_error_with_message("no `url` in model glb payload") })?; - // Download with a retry if the first attempt returns suspiciously few bytes. - let mut file_bytes = http_download_url_to_bytes(mesh_url) - .await - .map_err(|err| { - warn!("Failed to download mesh from {}: {:?}", mesh_url, err); - CommonWebError::from_error(err) - })?; + // Secondary GLB candidates. Slots frequently duplicate URLs — Hunyuan 3D + // 3.0's `model_urls.glb` usually matches `model_glb`, and Tripo 3D's `glb` + // and `pbr_model` match each other — so each distinct URL uploads once. + let candidates = [ + ("model_glb_pbr", maybe_model_glb_pbr_data), + ("model_urls.glb", maybe_model_urls_data.and_then(|urls| urls.glb.as_ref())), + ("model_urls.base_model", maybe_model_urls_data.and_then(|urls| urls.base_model.as_ref())), + ("model_urls.pbr_model", maybe_model_urls_data.and_then(|urls| urls.pbr_model.as_ref())), + ]; - if file_bytes.len() <= 10 { - warn!( - "Downloaded only {} bytes from {} — retrying once", - file_bytes.len(), - mesh_url, - ); - file_bytes = http_download_url_to_bytes(mesh_url) - .await - .map_err(|e| { - warn!("Failed to download mesh on retry from {}: {:?}", mesh_url, e); - CommonWebError::server_error_with_message( - &format!("Failed to download mesh on retry: {:?}", e)) - })?; + let mut seen_urls = vec![mesh_url]; + let mut secondary_glbs: Vec<(&str, &ModelGlbData)> = Vec::new(); + + for (label, maybe_glb_data) in candidates { + let Some(glb_data) = maybe_glb_data else { + continue; + }; + let Some(url) = glb_data.url.as_deref() else { + warn!("No `url` in {} payload; skipping", label); + continue; + }; + if seen_urls.contains(&url) { + continue; + } + seen_urls.push(url); + secondary_glbs.push((label, glb_data)); } - // Resolve mime type: magic bytes first, fal content_type as fallback. - let metadata = resolve_file_metadata(&file_bytes, model_glb_data.content_type.as_deref()) - .ok_or_else(|| { - warn!( - "Could not determine file type for mesh (bytes: {}, fal content_type: {:?})", - file_bytes.len(), - model_glb_data.content_type, - ); - CommonWebError::server_error_with_message( - &format!("Could not determine file type for mesh (bytes: {}, fal content_type: {:?})", - file_bytes.len(), model_glb_data.content_type)) - })?; + // 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 = (!secondary_glbs.is_empty()) + .then(BatchGenerationToken::generate); - let mime_type = metadata.mime_type.as_str(); - - info!("Mime type of mesh: {}, source: {:?}", mime_type, metadata.source); - - let media_file_type = MediaFileType::try_from_mime_type(mime_type) - .ok_or_else(|| { - warn!("Unsupported 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 mesh bytes: {:?}", e); - CommonWebError::from_anyhow_error(e) - })?; - - let public_upload_path = MediaFileBucketPath::generate_new(PREFIX, Some(&extension_with_period)); - - info!("Uploading 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 mesh to bucket: {:?}", e); - CommonWebError::from_anyhow_error(e) - })?; - - let media_token = MediaFileInsertBuilder::new() - .maybe_creator_user(job.maybe_creator_user_token.as_ref()) - .maybe_creator_anonymous_visitor(job.maybe_creator_anonymous_visitor_token.as_ref()) - .creator_ip_address(&job.creator_ip_address) - .public_bucket_directory_hash(&public_upload_path) - .media_file_class(MediaFileClass::Mesh) - .media_file_type(media_file_type) - .media_file_origin_category(MediaFileOriginCategory::Inference) - .maybe_engine_category(Some(MediaFileEngineCategory::Object)) - //.media_file_origin_product_category(MediaFileOriginProductCategory::Unknown) - .mime_type(mime_type) - .file_size_bytes(file_size_bytes as u64) - .maybe_prompt_token(job.maybe_prompt_token.as_ref()) - .maybe_platform_type(job.maybe_platform_type) - .checksum_sha2(&file_hash) - .insert_pool(&server_state.mysql_pool) - .await - .map_err(|e| { - warn!("Failed to insert glb media file record: {:?}", e); - CommonWebError::from_error(e) - })?; + let media_token = upload_mesh_file(UploadMeshFileArgs { + mesh_url, + maybe_content_type: model_glb_data.content_type.as_deref(), + maybe_file_name: model_glb_data.file_name.as_deref(), + maybe_batch_token: maybe_batch_token.as_ref(), + job, + server_state, + }).await?; info!("Glb media file uploaded with token: {}", media_token); + // 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. + for (label, glb_data) in secondary_glbs { + 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 {} GLB variant: {:?}", label, err); + page_about_secondary_glb_failure(label, err, job, pager); + } + } + let result = try_to_attach_thumbnail( maybe_thumbnail_data, job, @@ -174,6 +158,33 @@ pub async fn process_model_glb_payload( warn!("Could not attach thumbnail as cover image to media file: {:?}", err); } + Ok((media_token, maybe_batch_token)) +} + +async fn upload_secondary_glb( + glb_data: &ModelGlbData, + maybe_batch_token: Option<&BatchGenerationToken>, + job: &FalJobDetails, + server_state: &ServerState, +) -> Result { + let glb_url = glb_data.url + .as_deref() + .ok_or_else(|| { + 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: 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!("Secondary glb media file uploaded with token: {}", media_token); + Ok(media_token) } @@ -199,108 +210,31 @@ 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)) - })?; + 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 +} + +fn page_about_secondary_glb_failure(label: &str, err: CommonWebError, job: &FalJobDetails, pager: &Pager) { + let notification = NotificationDetailsBuilder::from_boxed_error(err.into()) + .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 {} 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 {} GLB failure alert: {:?}", label, pager_err); } - - // 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(()) } 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 +}