mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-08 19:11:29 +00:00
upload triposplat ply and attach cover image
This commit is contained in:
1 parent
cb6d0fe27c
commit
762498daa1
9 files changed
+372
-116
No files matched your search
+21
@@ -47,6 +47,8 @@ pub struct ExtractedContents {
|
||||
pub model_glb: Option<ModelGlbData>,
|
||||
|
||||
/// 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<ModelMeshData>,
|
||||
|
||||
/// 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<ThumbnailData>,
|
||||
|
||||
/// 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<PreprocessedImageData>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
@@ -140,6 +148,19 @@ pub struct ThumbnailData {
|
||||
pub url: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
pub file_name: Option<String>,
|
||||
pub file_size: Option<usize>,
|
||||
pub height: Option<u64>,
|
||||
pub url: Option<String>,
|
||||
pub width: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ErrorData {
|
||||
/// The first human-readable message from `payload.detail[].msg`, if any.
|
||||
|
||||
+84
@@ -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<String, Value>) -> Option<PreprocessedImageData> {
|
||||
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<String, serde_json::Value> = 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<String, serde_json::Value> = serde_json::from_str(r#"{
|
||||
"model_mesh": {"url": "https://example.com/output.ply"}
|
||||
}"#).unwrap();
|
||||
|
||||
assert!(extract_preprocessed_image(&obj).is_none());
|
||||
}
|
||||
}
|
||||
+6
-2
@@ -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<ExtractedContent
|
||||
let model_obj = extract_model_obj_payload::extract_model_obj(obj);
|
||||
let result_files = extract_result_files_payload::extract_result_files(obj);
|
||||
let thumbnail = extract_thumbnail_payload::extract_thumbnail(obj);
|
||||
let preprocessed_image = extract_preprocessed_image_payload::extract_preprocessed_image(obj);
|
||||
|
||||
if image.is_none() && images.is_none() && video.is_none()
|
||||
&& model_glb.is_none() && model_mesh.is_none() && model_obj.is_none()
|
||||
&& result_files.is_none() && thumbnail.is_none()
|
||||
&& result_files.is_none() && thumbnail.is_none() && preprocessed_image.is_none()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
@@ -46,5 +49,6 @@ pub fn extract_contents_from_payload(payload: &Value) -> Option<ExtractedContent
|
||||
model_obj,
|
||||
result_files,
|
||||
thumbnail,
|
||||
preprocessed_image,
|
||||
})
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"error": null,
|
||||
"gateway_request_id": "019f4d0f-4cf2-76d3-b99b-c400641b3844",
|
||||
"payload": {
|
||||
"model_mesh": {
|
||||
"content_type": "application/octet-stream",
|
||||
"file_name": "output.ply",
|
||||
"file_size": 17826208,
|
||||
"url": "https://v3b.fal.media/files/b/0aa1b77c/hEzg5xn9A-1qrLsnywjN4_output.ply"
|
||||
},
|
||||
"num_gaussians": 262144,
|
||||
"preprocessed_image": {
|
||||
"content_type": "image/png",
|
||||
"file_name": "61fc73dbe84446b08b1fcce2167b71d5.png",
|
||||
"file_size": 952754,
|
||||
"height": 1024,
|
||||
"url": "https://v3b.fal.media/files/b/0aa1b77c/YyJ19g7lJsRNXmvZvahTE_61fc73dbe84446b08b1fcce2167b71d5.png",
|
||||
"width": 1024
|
||||
},
|
||||
"seed": 1637204116,
|
||||
"timings": {
|
||||
"export": 0.794984320000367,
|
||||
"inference": 4.911052849000043
|
||||
}
|
||||
},
|
||||
"request_id": "019f4d0f-4cf2-76d3-b99b-c400641b3844",
|
||||
"status": "OK"
|
||||
}
|
||||
+163
@@ -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<MediaFileOriginProductCategory>,
|
||||
/// 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(())
|
||||
}
|
||||
+3
-1
@@ -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);
|
||||
}
|
||||
|
||||
+1
@@ -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;
|
||||
|
||||
+9
-106
@@ -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
|
||||
}
|
||||
+57
-7
@@ -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<MediaFileToken, CommonWebError> {
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user