diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/cost_and_generate_requests/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/cost_and_generate_requests/mod.rs index f825ddab07..35501ad1e2 100644 --- a/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/cost_and_generate_requests/mod.rs +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/cost_and_generate_requests/mod.rs @@ -1,3 +1,5 @@ pub mod omni_gen_audio_cost_and_generate_request; pub mod omni_gen_image_cost_and_generate_request; +pub mod omni_gen_mesh_cost_and_generate_request; +pub mod omni_gen_splat_cost_and_generate_request; pub mod omni_gen_video_cost_and_generate_request; diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/cost_and_generate_requests/omni_gen_mesh_cost_and_generate_request.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/cost_and_generate_requests/omni_gen_mesh_cost_and_generate_request.rs new file mode 100644 index 0000000000..b1b00ef517 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/cost_and_generate_requests/omni_gen_mesh_cost_and_generate_request.rs @@ -0,0 +1,52 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use enums::common::generation::common_mesh_model::CommonMeshModel; +use enums::common::generation::common_mesh_output_type::CommonMeshOutputType; +use enums::common::generation::common_polygon_type::CommonPolygonType; +use tokens::tokens::media_files::MediaFileToken; + +/// Shared request body for both the mesh cost estimate and mesh generation endpoints. +#[derive(Clone, Serialize, Deserialize, ToSchema, Debug)] +pub struct OmniGenMeshCostAndGenerateRequest { + /// REQUIRED (even if marked optional) + /// Idempotency token to prevent duplicate requests. + pub idempotency_token: Option, + + /// REQUIRED (even if marked optional) + /// Which model to use. + pub model: Option, + + /// The text prompt for the mesh generation (text-to-3D). + pub prompt: Option, + + /// Reference images (optional). + /// The primary/front input image, or the sketch image for sketch-to-3D. + pub reference_image_media_tokens: Option>, + + /// Front view image (optional; multi-view input). + /// Can be used instead of `reference_image_media_tokens[0]` when the other + /// side views are supplied. + pub front_image_media_token: Option, + + /// Back view image (optional; multi-view input). + pub back_image_media_token: Option, + + /// Left view image (optional; multi-view input). + pub left_image_media_token: Option, + + /// Right view image (optional; multi-view input). + pub right_image_media_token: Option, + + /// The mesh output type to generate (normal, low-poly, or geometry-only). + pub mesh_output_type: Option, + + /// The polygon type to use for the generated mesh (triangle or quad). + pub polygon_type: Option, + + /// Target face count for the generated mesh. + pub face_count: Option, + + /// Whether to generate PBR (physically based rendering) materials. + pub enable_pbr: Option, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/cost_and_generate_requests/omni_gen_splat_cost_and_generate_request.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/cost_and_generate_requests/omni_gen_splat_cost_and_generate_request.rs new file mode 100644 index 0000000000..5434903698 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/cost_and_generate_requests/omni_gen_splat_cost_and_generate_request.rs @@ -0,0 +1,34 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use enums::common::generation::common_splat_model::CommonSplatModel; +use tokens::tokens::media_files::MediaFileToken; + +/// Shared request body for both the splat cost estimate and splat generation endpoints. +#[derive(Clone, Serialize, Deserialize, ToSchema, Debug)] +pub struct OmniGenSplatCostAndGenerateRequest { + /// REQUIRED (even if marked optional) + /// Idempotency token to prevent duplicate requests. + pub idempotency_token: Option, + + /// REQUIRED (even if marked optional) + /// Which model to use. + pub model: Option, + + /// The text prompt for the splat generation. + pub prompt: Option, + + /// Reference images (optional). + /// NOTE: Spherical/panorama coordinates are not yet supported per-image; + /// multiple images are treated as multi-view input of the same world. + pub reference_image_media_tokens: Option>, + + /// Reference video (optional). + pub reference_video_media_token: Option, + + /// Whether the single reference image is a 360-degree panorama. + pub is_panoramic: Option, + + /// Whether to disable prompt recaptioning. + pub disable_recaption: Option, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/cost_response/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/cost_response/mod.rs index 3a42d41b55..f360c72ce4 100644 --- a/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/cost_response/mod.rs +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/cost_response/mod.rs @@ -1,3 +1,5 @@ pub mod omni_gen_audio_cost_response; pub mod omni_gen_image_cost_response; +pub mod omni_gen_mesh_cost_response; +pub mod omni_gen_splat_cost_response; pub mod omni_gen_video_cost_response; diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/cost_response/omni_gen_mesh_cost_response.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/cost_response/omni_gen_mesh_cost_response.rs new file mode 100644 index 0000000000..a3308aa497 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/cost_response/omni_gen_mesh_cost_response.rs @@ -0,0 +1,32 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +/// Response body for the omni-gen mesh cost estimate endpoint. +#[derive(Serialize, Deserialize, ToSchema)] +pub struct OmniGenMeshCostResponse { + pub success: bool, + + /// Estimated cost in credits. + pub cost_in_credits: Option, + + /// Estimated cost in USD cents. + pub cost_in_usd_cents: Option, + + /// Whether the generation is free for this user/plan. + pub is_free: bool, + + /// Whether the user has unlimited generations. + pub is_unlimited: bool, + + /// Whether the user is rate-limited. + pub is_rate_limited: bool, + + /// Whether the output will have a watermark. + pub has_watermark: bool, + + /// Whether failures are refunded. + /// True: 100% yes + /// False: 100% no + /// None: Unknown or variable + pub failures_are_refunded: Option, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/cost_response/omni_gen_splat_cost_response.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/cost_response/omni_gen_splat_cost_response.rs new file mode 100644 index 0000000000..823a169c3c --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/cost_response/omni_gen_splat_cost_response.rs @@ -0,0 +1,32 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +/// Response body for the omni-gen splat cost estimate endpoint. +#[derive(Serialize, Deserialize, ToSchema)] +pub struct OmniGenSplatCostResponse { + pub success: bool, + + /// Estimated cost in credits. + pub cost_in_credits: Option, + + /// Estimated cost in USD cents. + pub cost_in_usd_cents: Option, + + /// Whether the generation is free for this user/plan. + pub is_free: bool, + + /// Whether the user has unlimited generations. + pub is_unlimited: bool, + + /// Whether the user is rate-limited. + pub is_rate_limited: bool, + + /// Whether the output will have a watermark. + pub has_watermark: bool, + + /// Whether failures are refunded. + /// True: 100% yes + /// False: 100% no + /// None: Unknown or variable + pub failures_are_refunded: Option, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/generate_response/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/generate_response/mod.rs index e59c3f4ae1..97c385973c 100644 --- a/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/generate_response/mod.rs +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/generate_response/mod.rs @@ -1,3 +1,5 @@ pub mod omni_gen_audio_generate_response; pub mod omni_gen_image_generate_response; +pub mod omni_gen_mesh_generate_response; +pub mod omni_gen_splat_generate_response; pub mod omni_gen_video_generate_response; diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/generate_response/omni_gen_mesh_generate_response.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/generate_response/omni_gen_mesh_generate_response.rs new file mode 100644 index 0000000000..5a6d1e2b3b --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/generate_response/omni_gen_mesh_generate_response.rs @@ -0,0 +1,16 @@ +use serde_derive::{Deserialize, Serialize}; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use utoipa::ToSchema; + +/// Response body for the omni-gen mesh generation endpoint. +#[derive(Serialize, Deserialize, ToSchema)] +pub struct OmniGenMeshGenerateResponse { + pub success: bool, + + pub inference_job_token: InferenceJobToken, + + /// All job tokens created by this request (including the primary). + /// For single-job requests this will contain just one element matching + /// `inference_job_token`. For batch requests this will contain all of them. + pub all_job_tokens: Vec, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/generate_response/omni_gen_splat_generate_response.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/generate_response/omni_gen_splat_generate_response.rs new file mode 100644 index 0000000000..9e1f51b138 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/generate_response/omni_gen_splat_generate_response.rs @@ -0,0 +1,16 @@ +use serde_derive::{Deserialize, Serialize}; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use utoipa::ToSchema; + +/// Response body for the omni-gen splat generation endpoint. +#[derive(Serialize, Deserialize, ToSchema)] +pub struct OmniGenSplatGenerateResponse { + pub success: bool, + + pub inference_job_token: InferenceJobToken, + + /// All job tokens created by this request (including the primary). + /// For single-job requests this will contain just one element matching + /// `inference_job_token`. For batch requests this will contain all of them. + pub all_job_tokens: Vec, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/models/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/models/mod.rs index 10420201b8..1aa2d95888 100644 --- a/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/models/mod.rs +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/models/mod.rs @@ -1,3 +1,5 @@ pub mod omni_gen_audio_models; pub mod omni_gen_image_models; +pub mod omni_gen_mesh_models; +pub mod omni_gen_splat_models; pub mod omni_gen_video_models; diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/models/omni_gen_mesh_models.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/models/omni_gen_mesh_models.rs new file mode 100644 index 0000000000..3b63c2364c --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/models/omni_gen_mesh_models.rs @@ -0,0 +1,113 @@ +use enums::common::generation::common_mesh_model::CommonMeshModel; +use enums::common::generation::common_mesh_output_type::CommonMeshOutputType; +use enums::common::generation::common_polygon_type::CommonPolygonType; +use enums::common::generation::model_creator::ModelCreator; +use enums::common::generation_provider::GenerationProvider; +use serde_derive::Serialize; +use utoipa::ToSchema; + +/// Mesh model to default to if none is specified +const DEFAULT_MESH_MODEL : CommonMeshModel = CommonMeshModel::Hunyuan3d3; + +/// Response body for the mesh models endpoint. +#[derive(Serialize, ToSchema, Clone)] +pub struct OmniGenMeshModelsResponse { + pub success: bool, + + /// A list of all models: details, features, and capabilities + pub models: Vec, + + /// Provider-by-provider model offering and capability list, + /// with possible capability overrides (future) + pub providers: Vec, +} + +#[derive(Serialize, ToSchema, Clone)] +pub struct OmniGenMeshModelProviderDetails { + pub provider: GenerationProvider, + pub models: Vec, +} + +#[derive(Serialize, ToSchema, Clone)] +pub struct OmniGenMeshProviderModelDetails { + pub model: CommonMeshModel, + + #[serde(skip_serializing_if = "Option::is_none")] + pub overrides: Option, +} + +#[derive(Serialize, ToSchema, Clone)] +pub struct OmniGenMeshModelDetails { + + pub model: CommonMeshModel, + + #[serde(skip_serializing_if = "Option::is_none")] + pub model_creator: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub full_name: Option, + + /// Additional details about the model. + #[serde(skip_serializing_if = "Option::is_none")] + pub extra_info: Option, + + /// Additional details about the model. (Brief; only a few words.) + #[serde(skip_serializing_if = "Option::is_none")] + pub extra_info_short: Option, + + /// Whether text-to-3D prompting is supported. + #[serde(skip_serializing_if = "Option::is_none")] + pub text_prompt_supported: Option, + + /// Whether image-to-3D input is supported. + #[serde(skip_serializing_if = "Option::is_none")] + pub image_input_supported: Option, + + /// Whether sketch-to-3D input is supported. + #[serde(skip_serializing_if = "Option::is_none")] + pub sketch_input_supported: Option, + + /// Whether multi-view (front/back/left/right) image input is supported. + #[serde(skip_serializing_if = "Option::is_none")] + pub multi_view_supported: Option, + + /// The mesh output types the model supports. + #[serde(skip_serializing_if = "Option::is_none")] + pub mesh_output_types: Option>, + + /// The polygon types the model supports. + #[serde(skip_serializing_if = "Option::is_none")] + pub polygon_types: Option>, + + /// Whether a target face count can be specified. + #[serde(skip_serializing_if = "Option::is_none")] + pub face_count_supported: Option, + + /// Whether PBR (physically based rendering) material generation is supported. + #[serde(skip_serializing_if = "Option::is_none")] + pub pbr_supported: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub is_disabled: Option, +} + +impl Default for OmniGenMeshModelDetails { + fn default() -> Self { + Self { + model: DEFAULT_MESH_MODEL, + model_creator: None, + full_name: None, + extra_info: None, + extra_info_short: None, + text_prompt_supported: None, + image_input_supported: None, + sketch_input_supported: None, + multi_view_supported: None, + mesh_output_types: None, + polygon_types: None, + face_count_supported: None, + pbr_supported: None, + is_disabled: None, + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/models/omni_gen_splat_models.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/models/omni_gen_splat_models.rs new file mode 100644 index 0000000000..ed9212103f --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/models/omni_gen_splat_models.rs @@ -0,0 +1,98 @@ +use enums::common::generation::common_splat_model::CommonSplatModel; +use enums::common::generation::model_creator::ModelCreator; +use enums::common::generation_provider::GenerationProvider; +use serde_derive::Serialize; +use utoipa::ToSchema; + +/// Splat model to default to if none is specified +const DEFAULT_SPLAT_MODEL : CommonSplatModel = CommonSplatModel::Marble1p1; + +/// Response body for the splat models endpoint. +#[derive(Serialize, ToSchema, Clone)] +pub struct OmniGenSplatModelsResponse { + pub success: bool, + + /// A list of all models: details, features, and capabilities + pub models: Vec, + + /// Provider-by-provider model offering and capability list, + /// with possible capability overrides (future) + pub providers: Vec, +} + +#[derive(Serialize, ToSchema, Clone)] +pub struct OmniGenSplatModelProviderDetails { + pub provider: GenerationProvider, + pub models: Vec, +} + +#[derive(Serialize, ToSchema, Clone)] +pub struct OmniGenSplatProviderModelDetails { + pub model: CommonSplatModel, + + #[serde(skip_serializing_if = "Option::is_none")] + pub overrides: Option, +} + +#[derive(Serialize, ToSchema, Clone)] +pub struct OmniGenSplatModelDetails { + + pub model: CommonSplatModel, + + #[serde(skip_serializing_if = "Option::is_none")] + pub model_creator: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub full_name: Option, + + /// Additional details about the model. + #[serde(skip_serializing_if = "Option::is_none")] + pub extra_info: Option, + + /// Additional details about the model. (Brief; only a few words.) + #[serde(skip_serializing_if = "Option::is_none")] + pub extra_info_short: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub text_prompt_supported: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub image_references_supported: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub image_references_max: Option, + + /// Whether a reference video input is supported. + #[serde(skip_serializing_if = "Option::is_none")] + pub video_reference_supported: Option, + + /// Whether a 360-degree panorama reference image is supported. + #[serde(skip_serializing_if = "Option::is_none")] + pub panorama_supported: Option, + + /// Whether the "disable recaption" toggle is supported. + #[serde(skip_serializing_if = "Option::is_none")] + pub disable_recaption_supported: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub is_disabled: Option, +} + +impl Default for OmniGenSplatModelDetails { + fn default() -> Self { + Self { + model: DEFAULT_SPLAT_MODEL, + model_creator: None, + full_name: None, + extra_info: None, + extra_info_short: None, + text_prompt_supported: None, + image_references_supported: None, + image_references_max: None, + video_reference_supported: None, + panorama_supported: None, + disable_recaption_supported: None, + is_disabled: None, + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/generate/mesh/mod.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/generate/mesh/mod.rs new file mode 100644 index 0000000000..40ebdf21bb --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/generate/mesh/mod.rs @@ -0,0 +1 @@ +pub mod omni_gen_mesh; diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/generate/mesh/omni_gen_mesh.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/generate/mesh/omni_gen_mesh.rs new file mode 100644 index 0000000000..8444d3fc23 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/generate/mesh/omni_gen_mesh.rs @@ -0,0 +1,21 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::basic_json_post_request::basic_json_post_request; +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_mesh_cost_and_generate_request::OmniGenMeshCostAndGenerateRequest; +use artcraft_api_defs::omni_gen::generate_response::omni_gen_mesh_generate_response::OmniGenMeshGenerateResponse; + +pub const OMNI_GEN_MESH_GENERATE_PATH: &str = "/v1/omni_gen/generate/mesh"; + +pub async fn omni_gen_mesh_generate( + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + request: OmniGenMeshCostAndGenerateRequest, +) -> Result { + Ok(basic_json_post_request( + api_host, + OMNI_GEN_MESH_GENERATE_PATH, + maybe_creds, + request, + ).await?) +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/generate/mod.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/generate/mod.rs index 53475895eb..cd3fbb9d3f 100644 --- a/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/generate/mod.rs +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/generate/mod.rs @@ -1,3 +1,5 @@ pub mod audio; pub mod image; +pub mod mesh; +pub mod splat; pub mod video; diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/generate/splat/mod.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/generate/splat/mod.rs new file mode 100644 index 0000000000..014c2d2661 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/generate/splat/mod.rs @@ -0,0 +1 @@ +pub mod omni_gen_splat; diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/generate/splat/omni_gen_splat.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/generate/splat/omni_gen_splat.rs new file mode 100644 index 0000000000..86ff3c5a7e --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/generate/splat/omni_gen_splat.rs @@ -0,0 +1,21 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::basic_json_post_request::basic_json_post_request; +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_splat_cost_and_generate_request::OmniGenSplatCostAndGenerateRequest; +use artcraft_api_defs::omni_gen::generate_response::omni_gen_splat_generate_response::OmniGenSplatGenerateResponse; + +pub const OMNI_GEN_SPLAT_GENERATE_PATH: &str = "/v1/omni_gen/generate/splat"; + +pub async fn omni_gen_splat_generate( + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + request: OmniGenSplatCostAndGenerateRequest, +) -> Result { + Ok(basic_json_post_request( + api_host, + OMNI_GEN_SPLAT_GENERATE_PATH, + maybe_creds, + request, + ).await?) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/api/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/api/mod.rs index ea78847c50..c380058d14 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/api/mod.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/api/mod.rs @@ -6,9 +6,11 @@ pub mod router_aspect_ratio; pub mod router_audio_model; pub mod router_bitrate; pub mod router_image_model; +pub mod router_mesh_model; pub mod router_provider; pub mod router_quality; pub mod router_resolution; pub mod router_splat_model; pub mod router_video_model; pub mod video_list_ref; +pub mod video_ref; diff --git a/crates/api_clients/artcraft/artcraft_router/src/api/router_mesh_model.rs b/crates/api_clients/artcraft/artcraft_router/src/api/router_mesh_model.rs new file mode 100644 index 0000000000..faf1fce572 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/api/router_mesh_model.rs @@ -0,0 +1,62 @@ +use serde_derive::{Deserialize, Serialize}; + +/// Common mesh (3D object) models supported by the router. +/// Not all models are available through all providers. +#[derive(Copy, Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RouterMeshModel { + #[serde(rename = "hunyuan_3d_2p0")] + Hunyuan3d2p0, + + #[serde(rename = "hunyuan_3d_2p1")] + Hunyuan3d2p1, + + #[serde(rename = "hunyuan_3d_3")] + Hunyuan3d3, + + /// Hunyuan 3D v3 in sketch-to-3D mode. Same underlying model as + /// [`Self::Hunyuan3d3`], but takes a sketch image plus a prompt as input. + #[serde(rename = "hunyuan_3d_3_sketch")] + Hunyuan3d3Sketch, +} + +#[cfg(test)] +mod tests { + use super::*; + + // NB: These strings must match `CommonMeshModel` — the two enums convert + // via serde string round-trip. + #[test] + fn all_variants_serialize_to_common_mesh_model_strings() { + assert_serde_round_trip(RouterMeshModel::Hunyuan3d2p0, "hunyuan_3d_2p0"); + assert_serde_round_trip(RouterMeshModel::Hunyuan3d2p1, "hunyuan_3d_2p1"); + assert_serde_round_trip(RouterMeshModel::Hunyuan3d3, "hunyuan_3d_3"); + assert_serde_round_trip(RouterMeshModel::Hunyuan3d3Sketch, "hunyuan_3d_3_sketch"); + } + + #[test] + fn round_trips_through_common_mesh_model() { + use enums::common::generation::common_mesh_model::CommonMeshModel; + + let cases = [ + (RouterMeshModel::Hunyuan3d2p0, CommonMeshModel::Hunyuan3d2p0), + (RouterMeshModel::Hunyuan3d2p1, CommonMeshModel::Hunyuan3d2p1), + (RouterMeshModel::Hunyuan3d3, CommonMeshModel::Hunyuan3d3), + (RouterMeshModel::Hunyuan3d3Sketch, CommonMeshModel::Hunyuan3d3Sketch), + ]; + for (router_model, expected_common) in cases { + let json = serde_json::to_string(&router_model).unwrap(); + let common: CommonMeshModel = serde_json::from_str(&json) + .unwrap_or_else(|e| panic!("CommonMeshModel failed to parse {json}: {e}")); + assert_eq!(common, expected_common, "for {router_model:?}"); + } + } + + fn assert_serde_round_trip(model: RouterMeshModel, expected: &str) { + let json = serde_json::to_string(&model).unwrap(); + assert_eq!(json, format!("\"{expected}\"")); + let parsed: RouterMeshModel = serde_json::from_str(&json).unwrap(); + // RouterMeshModel isn't PartialEq, so round-trip back to the wire form. + assert_eq!(serde_json::to_string(&parsed).unwrap(), json); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/api/router_provider.rs b/crates/api_clients/artcraft/artcraft_router/src/api/router_provider.rs index 83b04c8e72..8a772efc94 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/api/router_provider.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/api/router_provider.rs @@ -9,4 +9,5 @@ pub enum RouterProvider { GmiCloud, GrokApi, Seedance2Pro, + WorldLabs, } diff --git a/crates/api_clients/artcraft/artcraft_router/src/api/router_splat_model.rs b/crates/api_clients/artcraft/artcraft_router/src/api/router_splat_model.rs index 025352c986..d357da1027 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/api/router_splat_model.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/api/router_splat_model.rs @@ -5,9 +5,68 @@ use serde_derive::{Deserialize, Serialize}; #[derive(Copy, Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum RouterSplatModel { + /// Deprecated upstream; treated as [`Self::Marble1p0Draft`]. #[serde(rename = "marble_0p1_mini")] Marble0p1Mini, + /// Deprecated upstream; treated as [`Self::Marble1p0`]. #[serde(rename = "marble_0p1_plus")] Marble0p1Plus, + + #[serde(rename = "marble_1p0")] + Marble1p0, + + #[serde(rename = "marble_1p0_draft")] + Marble1p0Draft, + + #[serde(rename = "marble_1p1")] + Marble1p1, + + #[serde(rename = "marble_1p1_plus")] + Marble1p1Plus, +} + +#[cfg(test)] +mod tests { + use super::*; + + // NB: These strings must match `CommonSplatModel` — the two enums convert + // via serde string round-trip. + #[test] + fn all_variants_serialize_to_common_splat_model_strings() { + assert_serde_round_trip(RouterSplatModel::Marble0p1Mini, "marble_0p1_mini"); + assert_serde_round_trip(RouterSplatModel::Marble0p1Plus, "marble_0p1_plus"); + assert_serde_round_trip(RouterSplatModel::Marble1p0, "marble_1p0"); + assert_serde_round_trip(RouterSplatModel::Marble1p0Draft, "marble_1p0_draft"); + assert_serde_round_trip(RouterSplatModel::Marble1p1, "marble_1p1"); + assert_serde_round_trip(RouterSplatModel::Marble1p1Plus, "marble_1p1_plus"); + } + + #[test] + fn round_trips_through_common_splat_model() { + use enums::common::generation::common_splat_model::CommonSplatModel; + + let cases = [ + (RouterSplatModel::Marble0p1Mini, CommonSplatModel::Marble0p1Mini), + (RouterSplatModel::Marble0p1Plus, CommonSplatModel::Marble0p1Plus), + (RouterSplatModel::Marble1p0, CommonSplatModel::Marble1p0), + (RouterSplatModel::Marble1p0Draft, CommonSplatModel::Marble1p0Draft), + (RouterSplatModel::Marble1p1, CommonSplatModel::Marble1p1), + (RouterSplatModel::Marble1p1Plus, CommonSplatModel::Marble1p1Plus), + ]; + for (router_model, expected_common) in cases { + let json = serde_json::to_string(&router_model).unwrap(); + let common: CommonSplatModel = serde_json::from_str(&json) + .unwrap_or_else(|e| panic!("CommonSplatModel failed to parse {json}: {e}")); + assert_eq!(common, expected_common, "for {router_model:?}"); + } + } + + fn assert_serde_round_trip(model: RouterSplatModel, expected: &str) { + let json = serde_json::to_string(&model).unwrap(); + assert_eq!(json, format!("\"{expected}\"")); + let parsed: RouterSplatModel = serde_json::from_str(&json).unwrap(); + // RouterSplatModel isn't PartialEq, so round-trip back to the wire form. + assert_eq!(serde_json::to_string(&parsed).unwrap(), json); + } } diff --git a/crates/api_clients/artcraft/artcraft_router/src/api/video_ref.rs b/crates/api_clients/artcraft/artcraft_router/src/api/video_ref.rs new file mode 100644 index 0000000000..e3f4a9e8f9 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/api/video_ref.rs @@ -0,0 +1,7 @@ +use tokens::tokens::media_files::MediaFileToken; + +#[derive(Clone, Debug)] +pub enum VideoRef { + MediaFileToken(MediaFileToken), + Url(String), +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/client/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/client/mod.rs index 0b1997e22d..14d6e41cdd 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/client/mod.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/client/mod.rs @@ -8,3 +8,4 @@ pub mod router_fal_client; pub mod router_gmicloud_client; pub mod router_grok_api_client; pub mod router_seedance2pro_client; +pub mod router_worldlabs_client; diff --git a/crates/api_clients/artcraft/artcraft_router/src/client/multi_router_client.rs b/crates/api_clients/artcraft/artcraft_router/src/client/multi_router_client.rs index dc1fe7a630..5ccccaa64f 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/client/multi_router_client.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/client/multi_router_client.rs @@ -3,6 +3,7 @@ use crate::client::router_fal_client::RouterFalClient; use crate::client::router_gmicloud_client::RouterGmiCloudClient; use crate::client::router_grok_api_client::RouterGrokApiClient; use crate::client::router_seedance2pro_client::RouterSeedance2ProClient; +use crate::client::router_worldlabs_client::RouterWorldLabsClient; use crate::errors::client_error::{ClientError, ClientType}; pub struct MultiRouterClient { @@ -11,6 +12,7 @@ pub struct MultiRouterClient { pub(crate) gmicloud_client: Option, pub(crate) grok_api_client: Option, pub(crate) seedance2pro_client: Option, + pub(crate) worldlabs_client: Option, } impl MultiRouterClient { @@ -38,4 +40,9 @@ impl MultiRouterClient { self.seedance2pro_client.as_ref() .ok_or(ClientError::ClientNotConfigured(ClientType::Seedance2Pro)) } + + pub fn get_worldlabs_client_ref(&self) -> Result<&RouterWorldLabsClient, ClientError> { + self.worldlabs_client.as_ref() + .ok_or(ClientError::ClientNotConfigured(ClientType::WorldLabs)) + } } diff --git a/crates/api_clients/artcraft/artcraft_router/src/client/multi_router_client_builder.rs b/crates/api_clients/artcraft/artcraft_router/src/client/multi_router_client_builder.rs index 82ee0864c3..17fd9560f1 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/client/multi_router_client_builder.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/client/multi_router_client_builder.rs @@ -4,6 +4,7 @@ use crate::client::router_fal_client::RouterFalClient; use crate::client::router_gmicloud_client::RouterGmiCloudClient; use crate::client::router_grok_api_client::RouterGrokApiClient; use crate::client::router_seedance2pro_client::RouterSeedance2ProClient; +use crate::client::router_worldlabs_client::RouterWorldLabsClient; pub struct MultiRouterClientBuilder { artcraft_client: Option, @@ -11,6 +12,7 @@ pub struct MultiRouterClientBuilder { gmicloud_client: Option, grok_api_client: Option, seedance2pro_client: Option, + worldlabs_client: Option, } impl MultiRouterClientBuilder { @@ -21,6 +23,7 @@ impl MultiRouterClientBuilder { gmicloud_client: None, grok_api_client: None, seedance2pro_client: None, + worldlabs_client: None, } } @@ -49,6 +52,11 @@ impl MultiRouterClientBuilder { self } + pub fn set_worldlabs_client(mut self, client: RouterWorldLabsClient) -> Self { + self.worldlabs_client = Some(client); + self + } + pub fn build(self) -> MultiRouterClient { MultiRouterClient { artcraft_client: self.artcraft_client, @@ -56,6 +64,7 @@ impl MultiRouterClientBuilder { gmicloud_client: self.gmicloud_client, grok_api_client: self.grok_api_client, seedance2pro_client: self.seedance2pro_client, + worldlabs_client: self.worldlabs_client, } } } diff --git a/crates/api_clients/artcraft/artcraft_router/src/client/router_client.rs b/crates/api_clients/artcraft/artcraft_router/src/client/router_client.rs index f825c06477..af615f87e9 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/client/router_client.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/client/router_client.rs @@ -4,6 +4,7 @@ use crate::client::router_fal_client::RouterFalClient; use crate::client::router_gmicloud_client::RouterGmiCloudClient; use crate::client::router_grok_api_client::RouterGrokApiClient; use crate::client::router_seedance2pro_client::RouterSeedance2ProClient; +use crate::client::router_worldlabs_client::RouterWorldLabsClient; use crate::errors::client_error::{ClientError, ClientType}; pub enum RouterClient { @@ -13,6 +14,7 @@ pub enum RouterClient { GmiCloud(RouterGmiCloudClient), GrokApi(RouterGrokApiClient), Seedance2Pro(RouterSeedance2ProClient), + WorldLabs(RouterWorldLabsClient), } impl RouterClient { @@ -55,4 +57,12 @@ impl RouterClient { _ => Err(ClientError::ClientNotConfigured(ClientType::Seedance2Pro)), } } + + pub fn get_worldlabs_client_ref(&self) -> Result<&RouterWorldLabsClient, ClientError> { + match self { + RouterClient::WorldLabs(client) => Ok(client), + RouterClient::Multi(multi) => multi.get_worldlabs_client_ref(), + _ => Err(ClientError::ClientNotConfigured(ClientType::WorldLabs)), + } + } } diff --git a/crates/api_clients/artcraft/artcraft_router/src/client/router_worldlabs_client.rs b/crates/api_clients/artcraft/artcraft_router/src/client/router_worldlabs_client.rs new file mode 100644 index 0000000000..1967085fe9 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/client/router_worldlabs_client.rs @@ -0,0 +1,16 @@ +use worldlabs_api_client::credentials::world_labs_api_creds::WorldLabsApiCreds; + +/// A client for the official World Labs (Marble) API. +pub struct RouterWorldLabsClient { + pub(crate) creds: WorldLabsApiCreds, +} + +impl RouterWorldLabsClient { + pub fn new(creds: WorldLabsApiCreds) -> Self { + Self { creds } + } + + pub fn new_from_raw_key(api_key: &str) -> Self { + Self::new(WorldLabsApiCreds::new(api_key.to_string())) + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/errors/client_error.rs b/crates/api_clients/artcraft/artcraft_router/src/errors/client_error.rs index 88926eedcd..b7b9af8760 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/errors/client_error.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/errors/client_error.rs @@ -10,6 +10,7 @@ pub enum ClientType { GmiCloud, GrokApi, Seedance2Pro, + WorldLabs, } impl Display for ClientType { @@ -20,6 +21,7 @@ impl Display for ClientType { Self::GmiCloud => write!(f, "GmiCloud"), Self::GrokApi => write!(f, "GrokApi"), Self::Seedance2Pro => write!(f, "Seedance2Pro"), + Self::WorldLabs => write!(f, "WorldLabs"), } } } diff --git a/crates/api_clients/artcraft/artcraft_router/src/errors/provider_error.rs b/crates/api_clients/artcraft/artcraft_router/src/errors/provider_error.rs index 40ba0e3035..92b8d8f098 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/errors/provider_error.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/errors/provider_error.rs @@ -5,6 +5,7 @@ use fal_client::error::fal_error_plus::FalErrorPlus; use gmicloud_client::error::gmicloud_error::GmiCloudError; use grok_api_client::error::grok_error::GrokError; use seedance2pro_client::error::seedance2pro_error::Seedance2ProError; +use worldlabs_api_client::error::world_labs_error::WorldLabsError; #[derive(Debug)] pub enum ProviderError { @@ -13,6 +14,7 @@ pub enum ProviderError { GmiCloud(GmiCloudError), Grok(GrokError), Seedance2Pro(Seedance2ProError), + WorldLabs(WorldLabsError), } impl Error for ProviderError {} @@ -25,6 +27,7 @@ impl Display for ProviderError { Self::GmiCloud(e) => write!(f, "GmiCloud provider error: {}", e), Self::Grok(e) => write!(f, "Grok provider error: {}", e), Self::Seedance2Pro(e) => write!(f, "Seedance2Pro provider error: {}", e), + Self::WorldLabs(e) => write!(f, "WorldLabs provider error: {}", e), } } } @@ -58,3 +61,9 @@ impl From for ProviderError { Self::Seedance2Pro(error) } } + +impl From for ProviderError { + fn from(error: WorldLabsError) -> Self { + Self::WorldLabs(error) + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/generate_mesh_request_builder.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/generate_mesh_request_builder.rs new file mode 100644 index 0000000000..c63ffaf63a --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/generate_mesh_request_builder.rs @@ -0,0 +1,266 @@ +use enums::common::generation::common_mesh_output_type::CommonMeshOutputType; +use enums::common::generation::common_polygon_type::CommonPolygonType; + +use crate::api::image_list_ref::ImageListRef; +use crate::api::image_ref::ImageRef; +use crate::api::router_mesh_model::RouterMeshModel; +use crate::api::router_provider::RouterProvider; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_mesh::mesh_generation_draft_or_request::MeshGenerationDraftOrRequest; +use crate::generate::generate_mesh::providers::artcraft::hunyuan3d_3::build::build_artcraft_hunyuan3d_3; +use crate::generate::generate_mesh::providers::artcraft::hunyuan3d_3_sketch::build::build_artcraft_hunyuan3d_3_sketch; +use crate::generate::generate_mesh::providers::artcraft::hunyuan_3d_2p0::build::build_artcraft_hunyuan_3d_2p0; +use crate::generate::generate_mesh::providers::artcraft::hunyuan_3d_2p1::build::build_artcraft_hunyuan_3d_2p1; +use crate::generate::generate_mesh::providers::fal::hunyuan3d_3::build::build_fal_hunyuan3d_3; +use crate::generate::generate_mesh::providers::fal::hunyuan3d_3_sketch::build::build_fal_hunyuan3d_3_sketch; +use crate::generate::generate_mesh::providers::fal::hunyuan_3d_2p0::build::build_fal_hunyuan_3d_2p0; +use crate::generate::generate_mesh::providers::fal::hunyuan_3d_2p1::build::build_fal_hunyuan_3d_2p1; + +/// RouterProvider-agnostic mesh (3D object) generation request. Distilled by +/// `build2()` into a `MeshGenerationDraftOrRequest` for the selected +/// (provider, model) pair. +#[derive(Clone, Debug)] +pub struct GenerateMeshRequestBuilder { + /// Which model to use. + pub model: RouterMeshModel, + + /// Which provider to use. + pub provider: RouterProvider, + + /// The text prompt (text-to-3D, or the sketch description for sketch-to-3D). + pub prompt: Option, + + /// Reference images (optional). + /// The primary/front input image, or the sketch image for sketch-to-3D. + pub reference_images: Option, + + /// Front view image (optional; multi-view input). + /// Can be used instead of `reference_images[0]` when the other side views + /// are supplied. + pub front_image: Option, + + /// Back view image (optional; multi-view input). + pub back_image: Option, + + /// Left view image (optional; multi-view input). + pub left_image: Option, + + /// Right view image (optional; multi-view input). + pub right_image: Option, + + /// The mesh output type to generate (normal, low-poly, or geometry-only). + pub mesh_output_type: Option, + + /// The polygon type to use for the generated mesh (triangle or quad). + pub polygon_type: Option, + + /// Target face count for the generated mesh. + pub face_count: Option, + + /// Whether to generate PBR (physically based rendering) materials. + pub enable_pbr: Option, + + /// If the request is a mismatch with the (model/provider), how to mitigate it. + pub request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy, + + /// Some providers support idempotency. + /// If not supplied, we'll generate one for the required providers. + pub idempotency_token: Option, +} + +impl Default for GenerateMeshRequestBuilder { + fn default() -> Self { + Self { + model: RouterMeshModel::Hunyuan3d3, + provider: RouterProvider::Artcraft, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade, + prompt: None, + reference_images: None, + front_image: None, + back_image: None, + left_image: None, + right_image: None, + mesh_output_type: None, + polygon_type: None, + face_count: None, + enable_pbr: None, + idempotency_token: None, + } + } +} + +impl GenerateMeshRequestBuilder { + + pub fn build2(self) -> Result { + match (self.provider, self.model) { + // Artcraft + (RouterProvider::Artcraft, RouterMeshModel::Hunyuan3d2p0) => build_artcraft_hunyuan_3d_2p0(self), + (RouterProvider::Artcraft, RouterMeshModel::Hunyuan3d2p1) => build_artcraft_hunyuan_3d_2p1(self), + (RouterProvider::Artcraft, RouterMeshModel::Hunyuan3d3) => build_artcraft_hunyuan3d_3(self), + (RouterProvider::Artcraft, RouterMeshModel::Hunyuan3d3Sketch) => build_artcraft_hunyuan3d_3_sketch(self), + // Fal + (RouterProvider::Fal, RouterMeshModel::Hunyuan3d2p0) => build_fal_hunyuan_3d_2p0(self), + (RouterProvider::Fal, RouterMeshModel::Hunyuan3d2p1) => build_fal_hunyuan_3d_2p1(self), + (RouterProvider::Fal, RouterMeshModel::Hunyuan3d3) => build_fal_hunyuan3d_3(self), + (RouterProvider::Fal, RouterMeshModel::Hunyuan3d3Sketch) => build_fal_hunyuan3d_3_sketch(self), + _ => self.unsupported_provider_and_model(), + } + } + + pub fn get_or_generate_idempotency_token(&self) -> String { + self.idempotency_token.clone() + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()) + } + + fn unsupported_provider_and_model(&self) -> Result { + Err(ArtcraftRouterError::UnsupportedProviderAndModelForNewApi( + format!("Mesh generation for model `{:?}` is not supported for provider {:?}", self.model, self.provider) + )) + } +} + +#[cfg(test)] +mod tests { + use crate::generate::generate_mesh::mesh_generation_request::MeshGenerationRequest; + + use super::*; + + const IMAGE_URL: &str = "https://example.com/front.png"; + + mod dispatch_tests { + use super::*; + + #[test] + fn artcraft_models_dispatch() { + let cases = [ + RouterMeshModel::Hunyuan3d2p0, + RouterMeshModel::Hunyuan3d2p1, + RouterMeshModel::Hunyuan3d3, + RouterMeshModel::Hunyuan3d3Sketch, + ]; + for model in cases { + let result = builder_with_token_image(RouterProvider::Artcraft, model).build2() + .unwrap_or_else(|e| panic!("build should succeed for {model:?}: {e}")); + let request = expect_request(result); + let matches = matches!( + (model, &request), + (RouterMeshModel::Hunyuan3d2p0, MeshGenerationRequest::ArtcraftHunyuan3d2p0(_)) + | (RouterMeshModel::Hunyuan3d2p1, MeshGenerationRequest::ArtcraftHunyuan3d2p1(_)) + | (RouterMeshModel::Hunyuan3d3, MeshGenerationRequest::ArtcraftHunyuan3d3(_)) + | (RouterMeshModel::Hunyuan3d3Sketch, MeshGenerationRequest::ArtcraftHunyuan3d3Sketch(_)) + ); + assert!(matches, "unexpected dispatch for {model:?}: {request:?}"); + } + } + + #[test] + fn fal_hunyuan3d_3_with_image_dispatches_to_image_endpoint() { + let result = builder_with_url_image(RouterProvider::Fal, RouterMeshModel::Hunyuan3d3) + .build2().expect("build"); + assert!(matches!( + expect_request(result), + MeshGenerationRequest::FalHunyuan3d3Image(_) + )); + } + + #[test] + fn fal_hunyuan3d_3_with_prompt_only_dispatches_to_text_endpoint() { + let builder = GenerateMeshRequestBuilder { + provider: RouterProvider::Fal, + model: RouterMeshModel::Hunyuan3d3, + prompt: Some("a red ceramic teapot".to_string()), + ..Default::default() + }; + let result = builder.build2().expect("build"); + assert!(matches!( + expect_request(result), + MeshGenerationRequest::FalHunyuan3d3Text(_) + )); + } + + #[test] + fn fal_hunyuan3d_3_sketch_dispatches() { + let builder = GenerateMeshRequestBuilder { + prompt: Some("a red ceramic teapot".to_string()), + ..builder_with_url_image(RouterProvider::Fal, RouterMeshModel::Hunyuan3d3Sketch) + }; + let result = builder.build2().expect("build"); + assert!(matches!( + expect_request(result), + MeshGenerationRequest::FalHunyuan3d3Sketch(_) + )); + } + + #[test] + fn fal_hunyuan_3d_2p0_dispatches() { + let result = builder_with_url_image(RouterProvider::Fal, RouterMeshModel::Hunyuan3d2p0) + .build2().expect("build"); + assert!(matches!( + expect_request(result), + MeshGenerationRequest::FalHunyuan3d2p0(_) + )); + } + + #[test] + fn fal_hunyuan_3d_2p1_dispatches() { + let result = builder_with_url_image(RouterProvider::Fal, RouterMeshModel::Hunyuan3d2p1) + .build2().expect("build"); + assert!(matches!( + expect_request(result), + MeshGenerationRequest::FalHunyuan3d2p1(_) + )); + } + } + + mod unsupported_combo_tests { + use super::*; + + #[test] + fn non_mesh_providers_are_unsupported() { + for provider in [ + RouterProvider::GmiCloud, + RouterProvider::GrokApi, + RouterProvider::Seedance2Pro, + RouterProvider::WorldLabs, + ] { + let result = builder_with_url_image(provider, RouterMeshModel::Hunyuan3d3).build2(); + assert!( + matches!(result, Err(ArtcraftRouterError::UnsupportedProviderAndModelForNewApi(_))), + "expected unsupported error for {provider:?}", + ); + } + } + } + + // ── Helpers ── + + fn builder_with_url_image(provider: RouterProvider, model: RouterMeshModel) -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + provider, + model, + reference_images: Some(ImageListRef::Urls(vec![IMAGE_URL.to_string()])), + ..Default::default() + } + } + + fn builder_with_token_image(provider: RouterProvider, model: RouterMeshModel) -> GenerateMeshRequestBuilder { + use tokens::tokens::media_files::MediaFileToken; + GenerateMeshRequestBuilder { + provider, + model, + prompt: Some("a red ceramic teapot".to_string()), + reference_images: Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_test123".to_string()), + ])), + ..Default::default() + } + } + + fn expect_request(result: MeshGenerationDraftOrRequest) -> MeshGenerationRequest { + match result { + MeshGenerationDraftOrRequest::Request(request) => request, + MeshGenerationDraftOrRequest::Draft(draft) => panic!("expected Request, got Draft: {draft:?}"), + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/generate_mesh_response.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/generate_mesh_response.rs new file mode 100644 index 0000000000..5d45cbb555 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/generate_mesh_response.rs @@ -0,0 +1,51 @@ +use std::fmt::Debug; +use std::sync::Arc; + +use tokens::tokens::generic_inference_jobs::InferenceJobToken; + +#[derive(Clone, Debug)] +pub struct ArtcraftMeshResponsePayload { + pub inference_job_token: InferenceJobToken, + pub all_inference_job_tokens: Vec, +} + +#[derive(Clone, Debug)] +pub struct FalMeshResponsePayload { + pub request_id: Option, + pub gateway_request_id: Option, + + /// Queue-mode status URL (fal's queue/polling flow). `None` for webhook + /// dispatch — the webhook callback drives status updates instead. + pub maybe_status_url: Option, + + /// Queue-mode response URL (fal's queue/polling flow). `None` for webhook + /// dispatch. + pub maybe_response_url: Option, + + /// The outbound request that was sent to Fal. + /// Stored as a trait object so any Request type can be captured. + /// Use `format!("{:?}", ...)` or `format!("{:#?}", ...)` to print. + pub maybe_outbound_request: Option>, +} + +#[derive(Clone, Debug)] +pub enum GenerateMeshResponse { + Artcraft(ArtcraftMeshResponsePayload), + Fal(FalMeshResponsePayload), +} + +impl GenerateMeshResponse { + pub fn get_artcraft_payload(&self) -> Option { + match self { + Self::Artcraft(p) => Some(p.clone()), + _ => None, + } + } + + pub fn get_fal_payload(&self) -> Option { + match self { + Self::Fal(p) => Some(p.clone()), + _ => None, + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/mesh_generation_cost_estimate.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/mesh_generation_cost_estimate.rs new file mode 100644 index 0000000000..0187cf09a5 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/mesh_generation_cost_estimate.rs @@ -0,0 +1,16 @@ +/// The result of estimating the cost of a mesh generation plan. +#[derive(Clone, Debug)] +pub struct MeshGenerationCostEstimate { + pub cost_in_credits: Option, + pub cost_in_usd_cents: Option, + pub is_free: bool, + pub is_unlimited: bool, + pub is_rate_limited: bool, + pub has_watermark: bool, + + /// Whether failures are refunded. + /// True: 100% yes + /// False: 100% no + /// None: Unknown or variable + pub failures_are_refunded: Option, +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/mesh_generation_draft.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/mesh_generation_draft.rs new file mode 100644 index 0000000000..0eef23b75c --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/mesh_generation_draft.rs @@ -0,0 +1,35 @@ +use crate::api::router_provider::RouterProvider; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_mesh::mesh_generation_cost_estimate::MeshGenerationCostEstimate; +use crate::generate::generate_mesh::mesh_generation_draft_context::MeshGenerationDraftContext; +use crate::generate::generate_mesh::mesh_generation_request::MeshGenerationRequest; + +/** + * Wrapper for all mesh generation draft requests. + * + * No mesh provider currently needs a draft phase: fal takes media URLs + * directly and Artcraft takes media tokens, so `build2()` always returns a + * `Request`. The enum exists so the mesh pipeline matches the audio/image + * draft-or-request shape and providers that need pre-upload can slot in + * later. + */ +#[derive(Clone, Debug)] +pub enum MeshGenerationDraftRequest {} + +impl MeshGenerationDraftRequest { + + pub fn get_provider(&self) -> RouterProvider { + match *self {} + } + + /// Return a cost estimate to fulfill the request. + pub fn estimate_cost(&self) -> Result { + match *self {} + } + + /// Finalize the draft request before generation + /// This may involve uploading media to the provider. + pub async fn finalize(self, _draft_context: MeshGenerationDraftContext<'_>) -> Result { + match self {} + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/mesh_generation_draft_context.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/mesh_generation_draft_context.rs new file mode 100644 index 0000000000..1760d54afe --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/mesh_generation_draft_context.rs @@ -0,0 +1,25 @@ +use std::collections::HashMap; +use std::fmt::Debug; + +use tokens::tokens::media_files::MediaFileToken; + +use crate::client::router_client::RouterClient; + +#[derive(Clone, Default)] +pub struct MeshGenerationDraftContext<'a> { + /// Optional: the router client, needed for providers that require authentication. + pub client: Option<&'a RouterClient>, + + /// Optional context: a map of Media File Tokens to their ArtCraft URLs + /// Only needed if we have to fetch these assets and upload them to another provider. + pub media_file_to_artcraft_url_map: Option<&'a HashMap>, +} + +impl Debug for MeshGenerationDraftContext<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("MeshGenerationDraftContext") + .field("client", &self.client.is_some()) + .field("media_file_to_artcraft_url_map", &self.media_file_to_artcraft_url_map) + .finish() + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/mesh_generation_draft_or_request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/mesh_generation_draft_or_request.rs new file mode 100644 index 0000000000..e82d7e09f2 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/mesh_generation_draft_or_request.rs @@ -0,0 +1,28 @@ +use crate::api::router_provider::RouterProvider; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_mesh::mesh_generation_cost_estimate::MeshGenerationCostEstimate; +use crate::generate::generate_mesh::mesh_generation_draft::MeshGenerationDraftRequest; +use crate::generate::generate_mesh::mesh_generation_request::MeshGenerationRequest; + +#[derive(Debug, Clone)] +pub enum MeshGenerationDraftOrRequest { + Draft(MeshGenerationDraftRequest), + Request(MeshGenerationRequest), +} + +impl MeshGenerationDraftOrRequest { + + pub fn get_provider(&self) -> RouterProvider { + match self { + Self::Draft(draft) => draft.get_provider(), + Self::Request(request) => request.get_provider(), + } + } + + pub fn estimate_cost(&self) -> Result { + match self { + MeshGenerationDraftOrRequest::Draft(draft) => draft.estimate_cost(), + MeshGenerationDraftOrRequest::Request(request) => request.estimate_cost(), + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/mesh_generation_request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/mesh_generation_request.rs new file mode 100644 index 0000000000..3c9acab8a8 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/mesh_generation_request.rs @@ -0,0 +1,113 @@ +use crate::api::router_provider::RouterProvider; +use crate::client::router_client::RouterClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_mesh::generate_mesh_response::GenerateMeshResponse; +use crate::generate::generate_mesh::mesh_generation_cost_estimate::MeshGenerationCostEstimate; +use crate::generate::generate_mesh::providers::artcraft::hunyuan3d_3::cost::ArtcraftHunyuan3d3CostState; +use crate::generate::generate_mesh::providers::artcraft::hunyuan3d_3::request::ArtcraftHunyuan3d3RequestState; +use crate::generate::generate_mesh::providers::artcraft::hunyuan3d_3_sketch::cost::ArtcraftHunyuan3d3SketchCostState; +use crate::generate::generate_mesh::providers::artcraft::hunyuan3d_3_sketch::request::ArtcraftHunyuan3d3SketchRequestState; +use crate::generate::generate_mesh::providers::artcraft::hunyuan_3d_2p0::cost::ArtcraftHunyuan3d2p0CostState; +use crate::generate::generate_mesh::providers::artcraft::hunyuan_3d_2p0::request::ArtcraftHunyuan3d2p0RequestState; +use crate::generate::generate_mesh::providers::artcraft::hunyuan_3d_2p1::cost::ArtcraftHunyuan3d2p1CostState; +use crate::generate::generate_mesh::providers::artcraft::hunyuan_3d_2p1::request::ArtcraftHunyuan3d2p1RequestState; +use crate::generate::generate_mesh::providers::fal::hunyuan3d_3::cost::{ + FalHunyuan3d3ImageCostState, FalHunyuan3d3TextCostState, +}; +use crate::generate::generate_mesh::providers::fal::hunyuan3d_3::request::{ + FalHunyuan3d3ImageRequestState, FalHunyuan3d3TextRequestState, +}; +use crate::generate::generate_mesh::providers::fal::hunyuan3d_3_sketch::cost::FalHunyuan3d3SketchCostState; +use crate::generate::generate_mesh::providers::fal::hunyuan3d_3_sketch::request::FalHunyuan3d3SketchRequestState; +use crate::generate::generate_mesh::providers::fal::hunyuan_3d_2p0::cost::FalHunyuan3d2p0CostState; +use crate::generate::generate_mesh::providers::fal::hunyuan_3d_2p0::request::FalHunyuan3d2p0RequestState; +use crate::generate::generate_mesh::providers::fal::hunyuan_3d_2p1::cost::FalHunyuan3d2p1CostState; +use crate::generate::generate_mesh::providers::fal::hunyuan_3d_2p1::request::FalHunyuan3d2p1RequestState; + +#[derive(Clone, Debug)] +pub enum MeshGenerationRequest { + ArtcraftHunyuan3d2p0(ArtcraftHunyuan3d2p0RequestState), + ArtcraftHunyuan3d2p1(ArtcraftHunyuan3d2p1RequestState), + ArtcraftHunyuan3d3(ArtcraftHunyuan3d3RequestState), + ArtcraftHunyuan3d3Sketch(ArtcraftHunyuan3d3SketchRequestState), + FalHunyuan3d2p0(FalHunyuan3d2p0RequestState), + FalHunyuan3d2p1(FalHunyuan3d2p1RequestState), + FalHunyuan3d3Image(FalHunyuan3d3ImageRequestState), + FalHunyuan3d3Text(FalHunyuan3d3TextRequestState), + FalHunyuan3d3Sketch(FalHunyuan3d3SketchRequestState), +} + +impl MeshGenerationRequest { + + pub fn get_provider(&self) -> RouterProvider { + match self { + Self::ArtcraftHunyuan3d2p0(_) => RouterProvider::Artcraft, + Self::ArtcraftHunyuan3d2p1(_) => RouterProvider::Artcraft, + Self::ArtcraftHunyuan3d3(_) => RouterProvider::Artcraft, + Self::ArtcraftHunyuan3d3Sketch(_) => RouterProvider::Artcraft, + Self::FalHunyuan3d2p0(_) => RouterProvider::Fal, + Self::FalHunyuan3d2p1(_) => RouterProvider::Fal, + Self::FalHunyuan3d3Image(_) => RouterProvider::Fal, + Self::FalHunyuan3d3Text(_) => RouterProvider::Fal, + Self::FalHunyuan3d3Sketch(_) => RouterProvider::Fal, + } + } + + /// Return a cost estimate to fulfill the request. + pub fn estimate_cost(&self) -> Result { + match self { + MeshGenerationRequest::ArtcraftHunyuan3d2p0(request) => Ok(ArtcraftHunyuan3d2p0CostState::from_request(request).estimate_cost()), + MeshGenerationRequest::ArtcraftHunyuan3d2p1(request) => Ok(ArtcraftHunyuan3d2p1CostState::from_request(request).estimate_cost()), + MeshGenerationRequest::ArtcraftHunyuan3d3(request) => Ok(ArtcraftHunyuan3d3CostState::from_request(request).estimate_cost()), + MeshGenerationRequest::ArtcraftHunyuan3d3Sketch(request) => Ok(ArtcraftHunyuan3d3SketchCostState::from_request(request).estimate_cost()), + MeshGenerationRequest::FalHunyuan3d2p0(request) => Ok(FalHunyuan3d2p0CostState::from_request(request).estimate_cost()), + MeshGenerationRequest::FalHunyuan3d2p1(request) => Ok(FalHunyuan3d2p1CostState::from_request(request).estimate_cost()), + MeshGenerationRequest::FalHunyuan3d3Image(request) => Ok(FalHunyuan3d3ImageCostState::from_request(request).estimate_cost()), + MeshGenerationRequest::FalHunyuan3d3Text(request) => Ok(FalHunyuan3d3TextCostState::from_request(request).estimate_cost()), + MeshGenerationRequest::FalHunyuan3d3Sketch(request) => Ok(FalHunyuan3d3SketchCostState::from_request(request).estimate_cost()), + } + } + + /// Send the mesh generation request + /// If successful, returns the job IDs. + pub async fn send_request(&self, client: &RouterClient) -> Result { + match self { + MeshGenerationRequest::ArtcraftHunyuan3d2p0(request) => { + let client_ref = client.get_artcraft_client_ref()?; + request.send(client_ref).await + }, + MeshGenerationRequest::ArtcraftHunyuan3d2p1(request) => { + let client_ref = client.get_artcraft_client_ref()?; + request.send(client_ref).await + }, + MeshGenerationRequest::ArtcraftHunyuan3d3(request) => { + let client_ref = client.get_artcraft_client_ref()?; + request.send(client_ref).await + }, + MeshGenerationRequest::ArtcraftHunyuan3d3Sketch(request) => { + let client_ref = client.get_artcraft_client_ref()?; + request.send(client_ref).await + }, + MeshGenerationRequest::FalHunyuan3d2p0(request) => { + let client_ref = client.get_fal_client_ref()?; + request.send(client_ref).await + }, + MeshGenerationRequest::FalHunyuan3d2p1(request) => { + let client_ref = client.get_fal_client_ref()?; + request.send(client_ref).await + }, + MeshGenerationRequest::FalHunyuan3d3Image(request) => { + let client_ref = client.get_fal_client_ref()?; + request.send(client_ref).await + }, + MeshGenerationRequest::FalHunyuan3d3Text(request) => { + let client_ref = client.get_fal_client_ref()?; + request.send(client_ref).await + }, + MeshGenerationRequest::FalHunyuan3d3Sketch(request) => { + let client_ref = client.get_fal_client_ref()?; + request.send(client_ref).await + }, + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/mod.rs new file mode 100644 index 0000000000..83ad68d45e --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/mod.rs @@ -0,0 +1,8 @@ +pub mod generate_mesh_request_builder; +pub mod generate_mesh_response; +pub mod mesh_generation_cost_estimate; +pub mod mesh_generation_draft; +pub mod mesh_generation_draft_context; +pub mod mesh_generation_draft_or_request; +pub mod mesh_generation_request; +pub mod providers; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/build_common.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/build_common.rs new file mode 100644 index 0000000000..f6f40a9b51 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/build_common.rs @@ -0,0 +1,135 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_mesh_cost_and_generate_request::OmniGenMeshCostAndGenerateRequest; +use enums::common::generation::common_mesh_model::CommonMeshModel as CommonMeshModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; +use crate::generate::generate_mesh::providers::artcraft::resolve::{ + resolve_image_list_ref, resolve_image_ref, +}; + +/// Build an `OmniGenMeshCostAndGenerateRequest` from the builder. The +/// Artcraft provider is a thin forwarding shim: the storyteller-web omni +/// mesh endpoint owns per-model validation and option mapping, so the +/// builder's options are passed through as-is. +pub fn build_artcraft_omni_mesh_request( + mut builder: GenerateMeshRequestBuilder, + model: CommonMeshModelEnum, +) -> Result { + let reference_image_media_tokens = resolve_image_list_ref(builder.reference_images.take())?; + let front_image_media_token = resolve_image_ref(builder.front_image.take())?; + let back_image_media_token = resolve_image_ref(builder.back_image.take())?; + let left_image_media_token = resolve_image_ref(builder.left_image.take())?; + let right_image_media_token = resolve_image_ref(builder.right_image.take())?; + let idempotency_token = builder.get_or_generate_idempotency_token(); + + Ok(OmniGenMeshCostAndGenerateRequest { + idempotency_token: Some(idempotency_token), + model: Some(model), + prompt: builder.prompt.take(), + reference_image_media_tokens, + front_image_media_token, + back_image_media_token, + left_image_media_token, + right_image_media_token, + mesh_output_type: builder.mesh_output_type, + polygon_type: builder.polygon_type, + face_count: builder.face_count, + enable_pbr: builder.enable_pbr, + }) +} + +#[cfg(test)] +mod tests { + use enums::common::generation::common_mesh_output_type::CommonMeshOutputType; + use enums::common::generation::common_polygon_type::CommonPolygonType; + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::image_ref::ImageRef; + use crate::api::router_mesh_model::RouterMeshModel; + use crate::api::router_provider::RouterProvider; + use crate::errors::client_error::ClientError; + + use super::*; + + #[test] + fn all_fields_are_forwarded() { + let builder = GenerateMeshRequestBuilder { + prompt: Some("a red ceramic teapot".to_string()), + reference_images: Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_ref".to_string()), + ])), + front_image: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_front".to_string()))), + back_image: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_back".to_string()))), + left_image: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_left".to_string()))), + right_image: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_right".to_string()))), + mesh_output_type: Some(CommonMeshOutputType::LowPoly), + polygon_type: Some(CommonPolygonType::Quad), + face_count: Some(100_000), + enable_pbr: Some(true), + idempotency_token: Some("test-token".to_string()), + ..base_builder() + }; + + let request = build_artcraft_omni_mesh_request(builder, CommonMeshModelEnum::Hunyuan3d3) + .expect("build"); + + assert_eq!(request.idempotency_token.as_deref(), Some("test-token")); + assert_eq!(request.model, Some(CommonMeshModelEnum::Hunyuan3d3)); + assert_eq!(request.prompt.as_deref(), Some("a red ceramic teapot")); + assert_eq!(request.reference_image_media_tokens.as_ref().map(|t| t.len()), Some(1)); + assert_eq!(request.front_image_media_token.as_ref().map(|t| t.as_str()), Some("mf_front")); + assert_eq!(request.back_image_media_token.as_ref().map(|t| t.as_str()), Some("mf_back")); + assert_eq!(request.left_image_media_token.as_ref().map(|t| t.as_str()), Some("mf_left")); + assert_eq!(request.right_image_media_token.as_ref().map(|t| t.as_str()), Some("mf_right")); + assert_eq!(request.mesh_output_type, Some(CommonMeshOutputType::LowPoly)); + assert_eq!(request.polygon_type, Some(CommonPolygonType::Quad)); + assert_eq!(request.face_count, Some(100_000)); + assert_eq!(request.enable_pbr, Some(true)); + } + + #[test] + fn idempotency_token_is_generated_when_missing() { + let request = build_artcraft_omni_mesh_request(base_builder(), CommonMeshModelEnum::Hunyuan3d3) + .expect("build"); + assert!(request.idempotency_token.is_some()); + assert!(!request.idempotency_token.unwrap().is_empty()); + } + + #[test] + fn reference_image_url_references_are_rejected() { + let builder = GenerateMeshRequestBuilder { + reference_images: Some(ImageListRef::Urls(vec!["https://example.com/a.png".to_string()])), + ..base_builder() + }; + let result = build_artcraft_omni_mesh_request(builder, CommonMeshModelEnum::Hunyuan3d3); + assert!(matches!( + result, + Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens)) + )); + } + + #[test] + fn side_image_url_references_are_rejected() { + let builder = GenerateMeshRequestBuilder { + back_image: Some(ImageRef::Url("https://example.com/back.png".to_string())), + ..base_builder() + }; + let result = build_artcraft_omni_mesh_request(builder, CommonMeshModelEnum::Hunyuan3d3); + assert!(matches!( + result, + Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens)) + )); + } + + // ── Helpers ── + + fn base_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + model: RouterMeshModel::Hunyuan3d3, + provider: RouterProvider::Artcraft, + prompt: Some("a red ceramic teapot".to_string()), + ..Default::default() + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan3d_3/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan3d_3/build.rs new file mode 100644 index 0000000000..cd208ca6eb --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan3d_3/build.rs @@ -0,0 +1,14 @@ +use enums::common::generation::common_mesh_model::CommonMeshModel as CommonMeshModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; +use crate::generate::generate_mesh::mesh_generation_draft_or_request::MeshGenerationDraftOrRequest; +use crate::generate::generate_mesh::mesh_generation_request::MeshGenerationRequest; +use crate::generate::generate_mesh::providers::artcraft::build_common::build_artcraft_omni_mesh_request; +use crate::generate::generate_mesh::providers::artcraft::hunyuan3d_3::request::ArtcraftHunyuan3d3RequestState; + +pub fn build_artcraft_hunyuan3d_3(builder: GenerateMeshRequestBuilder) -> Result { + let request = build_artcraft_omni_mesh_request(builder, CommonMeshModelEnum::Hunyuan3d3)?; + let state = ArtcraftHunyuan3d3RequestState { request }; + Ok(MeshGenerationDraftOrRequest::Request(MeshGenerationRequest::ArtcraftHunyuan3d3(state))) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan3d_3/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan3d_3/cost.rs new file mode 100644 index 0000000000..41e0e9f1b7 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan3d_3/cost.rs @@ -0,0 +1,225 @@ +use enums::common::generation::common_mesh_output_type::CommonMeshOutputType; + +use crate::generate::generate_mesh::mesh_generation_cost_estimate::MeshGenerationCostEstimate; +use crate::generate::generate_mesh::providers::artcraft::hunyuan3d_3::request::ArtcraftHunyuan3d3RequestState; + +const NORMAL_COST_IN_USD_CENTS: u64 = 49; +const LOW_POLY_COST_IN_USD_CENTS: u64 = 59; +const GEOMETRY_COST_IN_USD_CENTS: u64 = 30; +const ADD_ON_COST_IN_USD_CENTS: u64 = 20; + +/// Hunyuan 3D v3 via Artcraft: flat base price by output type, plus a flat +/// add-on for each extra option (PBR materials, custom face count, and +/// multi-view side images). +#[derive(Clone, Debug)] +pub struct ArtcraftHunyuan3d3CostState { + pub cost_in_usd_cents: u64, +} + +impl ArtcraftHunyuan3d3CostState { + pub fn from_request(request: &ArtcraftHunyuan3d3RequestState) -> Self { + let request = &request.request; + + let mut cost = match request.mesh_output_type { + None | Some(CommonMeshOutputType::Normal) => NORMAL_COST_IN_USD_CENTS, + Some(CommonMeshOutputType::LowPoly) => LOW_POLY_COST_IN_USD_CENTS, + Some(CommonMeshOutputType::Geometry) => GEOMETRY_COST_IN_USD_CENTS, + }; + if request.enable_pbr.unwrap_or(false) { + cost += ADD_ON_COST_IN_USD_CENTS; + } + if request.face_count.is_some() { + cost += ADD_ON_COST_IN_USD_CENTS; + } + let uses_multi_view = request.back_image_media_token.is_some() + || request.left_image_media_token.is_some() + || request.right_image_media_token.is_some(); + if uses_multi_view { + cost += ADD_ON_COST_IN_USD_CENTS; + } + + Self { cost_in_usd_cents: cost } + } + + pub fn estimate_cost(&self) -> MeshGenerationCostEstimate { + MeshGenerationCostEstimate { + cost_in_credits: Some(self.cost_in_usd_cents), + cost_in_usd_cents: Some(self.cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use enums::common::generation::common_polygon_type::CommonPolygonType; + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::image_ref::ImageRef; + use crate::api::router_mesh_model::RouterMeshModel; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; + + use super::*; + + mod base_costs { + use super::*; + + #[test] + fn default_output_type_is_forty_nine_cents() { + assert_eq!(estimate_usd_cents(base_builder()), 49); + } + + #[test] + fn normal_is_forty_nine_cents() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::Normal), + ..base_builder() + }; + assert_eq!(estimate_usd_cents(builder), 49); + } + + #[test] + fn low_poly_is_fifty_nine_cents() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::LowPoly), + ..base_builder() + }; + assert_eq!(estimate_usd_cents(builder), 59); + } + + #[test] + fn geometry_is_thirty_cents() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::Geometry), + ..base_builder() + }; + assert_eq!(estimate_usd_cents(builder), 30); + } + + #[test] + fn text_mode_prices_the_same_as_image_mode() { + // Text-only request (no image references). + let text_builder = GenerateMeshRequestBuilder { + model: RouterMeshModel::Hunyuan3d3, + provider: RouterProvider::Artcraft, + prompt: Some("a red ceramic teapot".to_string()), + ..Default::default() + }; + assert_eq!(estimate_usd_cents(text_builder), 49); + } + + #[test] + fn polygon_type_does_not_change_the_price() { + let builder = GenerateMeshRequestBuilder { + polygon_type: Some(CommonPolygonType::Quad), + ..base_builder() + }; + assert_eq!(estimate_usd_cents(builder), 49); + } + } + + mod add_ons { + use super::*; + + #[test] + fn pbr_adds_twenty_cents() { + let builder = GenerateMeshRequestBuilder { + enable_pbr: Some(true), + ..base_builder() + }; + assert_eq!(estimate_usd_cents(builder), 49 + 20); + } + + #[test] + fn pbr_false_adds_nothing() { + let builder = GenerateMeshRequestBuilder { + enable_pbr: Some(false), + ..base_builder() + }; + assert_eq!(estimate_usd_cents(builder), 49); + } + + #[test] + fn face_count_adds_twenty_cents() { + let builder = GenerateMeshRequestBuilder { + face_count: Some(100_000), + ..base_builder() + }; + assert_eq!(estimate_usd_cents(builder), 49 + 20); + } + + #[test] + fn each_side_image_adds_twenty_cents_at_most_once() { + for side in ["back", "left", "right"] { + let mut builder = base_builder(); + let image = Some(ImageRef::MediaFileToken(MediaFileToken::new(format!("mf_{side}")))); + match side { + "back" => builder.back_image = image, + "left" => builder.left_image = image, + _ => builder.right_image = image, + } + assert_eq!(estimate_usd_cents(builder), 49 + 20, "for {side} image"); + } + + let builder = GenerateMeshRequestBuilder { + back_image: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_back".to_string()))), + left_image: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_left".to_string()))), + right_image: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_right".to_string()))), + ..base_builder() + }; + assert_eq!(estimate_usd_cents(builder), 49 + 20); + } + + #[test] + fn all_add_ons_stack() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::LowPoly), + enable_pbr: Some(true), + face_count: Some(100_000), + back_image: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_back".to_string()))), + ..base_builder() + }; + assert_eq!(estimate_usd_cents(builder), 59 + 20 + 20 + 20); + } + + #[test] + fn geometry_with_all_add_ons() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::Geometry), + enable_pbr: Some(true), + face_count: Some(10_000), + left_image: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_left".to_string()))), + ..base_builder() + }; + assert_eq!(estimate_usd_cents(builder), 30 + 20 + 20 + 20); + } + } + + // ── Helpers ── + + fn base_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + model: RouterMeshModel::Hunyuan3d3, + provider: RouterProvider::Artcraft, + reference_images: Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_front".to_string()), + ])), + ..Default::default() + } + } + + fn estimate_usd_cents(builder: GenerateMeshRequestBuilder) -> u64 { + builder.build2() + .expect("build should succeed") + .estimate_cost() + .expect("estimate should succeed") + .cost_in_usd_cents + .expect("cost should be present") + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan3d_3/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan3d_3/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan3d_3/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan3d_3/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan3d_3/request.rs new file mode 100644 index 0000000000..6fe7df8f08 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan3d_3/request.rs @@ -0,0 +1,64 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_mesh_cost_and_generate_request::OmniGenMeshCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_mesh::generate_mesh_response::GenerateMeshResponse; +use crate::generate::generate_mesh::providers::artcraft::request_common::send_artcraft_omni_mesh_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftHunyuan3d3RequestState { + pub request: OmniGenMeshCostAndGenerateRequest, +} + +impl ArtcraftHunyuan3d3RequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_mesh_request(&self.request, client).await + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_mesh_model::RouterMeshModel; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; + use crate::generate::generate_mesh::generate_mesh_response::GenerateMeshResponse; + use crate::generate::generate_mesh::mesh_generation_draft_or_request::MeshGenerationDraftOrRequest; + use crate::test_helpers::get_artcraft_client; + + #[tokio::test] + #[ignore] // sends a real generation to the Artcraft backend, incurs cost + async fn text_to_mesh_teapot() { + let response = run_pipeline(GenerateMeshRequestBuilder { + model: RouterMeshModel::Hunyuan3d3, + provider: RouterProvider::Artcraft, + prompt: Some("A red ceramic teapot with a curved spout.".to_string()), + ..Default::default() + }).await; + assert!(matches!(response, GenerateMeshResponse::Artcraft(_))); + assert_eq!(1, 2, "Inspect output above"); + } + + // ── Helpers ── + + async fn run_pipeline(builder: GenerateMeshRequestBuilder) -> GenerateMeshResponse { + let client = get_artcraft_client(); + + let draft_or_request = builder.build2().expect("build2 should succeed"); + let request = match draft_or_request { + MeshGenerationDraftOrRequest::Request(r) => r, + _ => panic!("expected Request variant (Artcraft skips draft)"), + }; + + let response = request.send_request(&client).await.expect("send_request should succeed"); + + match &response { + GenerateMeshResponse::Artcraft(p) => { + println!("inference_job_token={:?}", p.inference_job_token); + println!("all_inference_job_tokens={:?}", p.all_inference_job_tokens); + } + other => println!("response: {:?}", other), + } + + response + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan3d_3_sketch/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan3d_3_sketch/build.rs new file mode 100644 index 0000000000..894ded2b5f --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan3d_3_sketch/build.rs @@ -0,0 +1,14 @@ +use enums::common::generation::common_mesh_model::CommonMeshModel as CommonMeshModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; +use crate::generate::generate_mesh::mesh_generation_draft_or_request::MeshGenerationDraftOrRequest; +use crate::generate::generate_mesh::mesh_generation_request::MeshGenerationRequest; +use crate::generate::generate_mesh::providers::artcraft::build_common::build_artcraft_omni_mesh_request; +use crate::generate::generate_mesh::providers::artcraft::hunyuan3d_3_sketch::request::ArtcraftHunyuan3d3SketchRequestState; + +pub fn build_artcraft_hunyuan3d_3_sketch(builder: GenerateMeshRequestBuilder) -> Result { + let request = build_artcraft_omni_mesh_request(builder, CommonMeshModelEnum::Hunyuan3d3Sketch)?; + let state = ArtcraftHunyuan3d3SketchRequestState { request }; + Ok(MeshGenerationDraftOrRequest::Request(MeshGenerationRequest::ArtcraftHunyuan3d3Sketch(state))) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan3d_3_sketch/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan3d_3_sketch/cost.rs new file mode 100644 index 0000000000..0a7a4c6a05 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan3d_3_sketch/cost.rs @@ -0,0 +1,115 @@ +use crate::generate::generate_mesh::mesh_generation_cost_estimate::MeshGenerationCostEstimate; +use crate::generate::generate_mesh::providers::artcraft::hunyuan3d_3_sketch::request::ArtcraftHunyuan3d3SketchRequestState; + +const BASE_COST_IN_USD_CENTS: u64 = 49; +const ADD_ON_COST_IN_USD_CENTS: u64 = 20; + +/// Hunyuan 3D v3 sketch-to-3D via Artcraft: flat base price plus a flat +/// add-on for each extra option (PBR materials, custom face count). +#[derive(Clone, Debug)] +pub struct ArtcraftHunyuan3d3SketchCostState { + pub cost_in_usd_cents: u64, +} + +impl ArtcraftHunyuan3d3SketchCostState { + pub fn from_request(request: &ArtcraftHunyuan3d3SketchRequestState) -> Self { + let request = &request.request; + + let mut cost = BASE_COST_IN_USD_CENTS; + if request.enable_pbr.unwrap_or(false) { + cost += ADD_ON_COST_IN_USD_CENTS; + } + if request.face_count.is_some() { + cost += ADD_ON_COST_IN_USD_CENTS; + } + + Self { cost_in_usd_cents: cost } + } + + pub fn estimate_cost(&self) -> MeshGenerationCostEstimate { + MeshGenerationCostEstimate { + cost_in_credits: Some(self.cost_in_usd_cents), + cost_in_usd_cents: Some(self.cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_mesh_model::RouterMeshModel; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; + + #[test] + fn base_cost_is_forty_nine_cents() { + assert_eq!(estimate_usd_cents(base_builder()), 49); + } + + #[test] + fn pbr_adds_twenty_cents() { + let builder = GenerateMeshRequestBuilder { + enable_pbr: Some(true), + ..base_builder() + }; + assert_eq!(estimate_usd_cents(builder), 49 + 20); + } + + #[test] + fn pbr_false_adds_nothing() { + let builder = GenerateMeshRequestBuilder { + enable_pbr: Some(false), + ..base_builder() + }; + assert_eq!(estimate_usd_cents(builder), 49); + } + + #[test] + fn face_count_adds_twenty_cents() { + let builder = GenerateMeshRequestBuilder { + face_count: Some(100_000), + ..base_builder() + }; + assert_eq!(estimate_usd_cents(builder), 49 + 20); + } + + #[test] + fn all_add_ons_stack() { + let builder = GenerateMeshRequestBuilder { + enable_pbr: Some(true), + face_count: Some(100_000), + ..base_builder() + }; + assert_eq!(estimate_usd_cents(builder), 49 + 20 + 20); + } + + // ── Helpers ── + + fn base_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + model: RouterMeshModel::Hunyuan3d3Sketch, + provider: RouterProvider::Artcraft, + prompt: Some("a red ceramic teapot".to_string()), + reference_images: Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_sketch".to_string()), + ])), + ..Default::default() + } + } + + fn estimate_usd_cents(builder: GenerateMeshRequestBuilder) -> u64 { + builder.build2() + .expect("build should succeed") + .estimate_cost() + .expect("estimate should succeed") + .cost_in_usd_cents + .expect("cost should be present") + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan3d_3_sketch/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan3d_3_sketch/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan3d_3_sketch/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan3d_3_sketch/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan3d_3_sketch/request.rs new file mode 100644 index 0000000000..5d0bc59379 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan3d_3_sketch/request.rs @@ -0,0 +1,17 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_mesh_cost_and_generate_request::OmniGenMeshCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_mesh::generate_mesh_response::GenerateMeshResponse; +use crate::generate::generate_mesh::providers::artcraft::request_common::send_artcraft_omni_mesh_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftHunyuan3d3SketchRequestState { + pub request: OmniGenMeshCostAndGenerateRequest, +} + +impl ArtcraftHunyuan3d3SketchRequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_mesh_request(&self.request, client).await + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_2p0/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_2p0/build.rs new file mode 100644 index 0000000000..47cdb4b080 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_2p0/build.rs @@ -0,0 +1,14 @@ +use enums::common::generation::common_mesh_model::CommonMeshModel as CommonMeshModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; +use crate::generate::generate_mesh::mesh_generation_draft_or_request::MeshGenerationDraftOrRequest; +use crate::generate::generate_mesh::mesh_generation_request::MeshGenerationRequest; +use crate::generate::generate_mesh::providers::artcraft::build_common::build_artcraft_omni_mesh_request; +use crate::generate::generate_mesh::providers::artcraft::hunyuan_3d_2p0::request::ArtcraftHunyuan3d2p0RequestState; + +pub fn build_artcraft_hunyuan_3d_2p0(builder: GenerateMeshRequestBuilder) -> Result { + let request = build_artcraft_omni_mesh_request(builder, CommonMeshModelEnum::Hunyuan3d2p0)?; + let state = ArtcraftHunyuan3d2p0RequestState { request }; + Ok(MeshGenerationDraftOrRequest::Request(MeshGenerationRequest::ArtcraftHunyuan3d2p0(state))) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_2p0/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_2p0/cost.rs new file mode 100644 index 0000000000..2772e1e6d2 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_2p0/cost.rs @@ -0,0 +1,93 @@ +use enums::common::generation::common_mesh_output_type::CommonMeshOutputType; + +use crate::generate::generate_mesh::mesh_generation_cost_estimate::MeshGenerationCostEstimate; +use crate::generate::generate_mesh::providers::artcraft::hunyuan_3d_2p0::request::ArtcraftHunyuan3d2p0RequestState; + +const TEXTURED_COST_IN_USD_CENTS: u64 = 63; +const GEOMETRY_COST_IN_USD_CENTS: u64 = 21; + +/// Hunyuan 3D 2.0 via Artcraft: flat price by output type. Geometry-only +/// (white mesh) is cheaper; every other output type prices as textured. +#[derive(Clone, Debug)] +pub struct ArtcraftHunyuan3d2p0CostState { + pub cost_in_usd_cents: u64, +} + +impl ArtcraftHunyuan3d2p0CostState { + pub fn from_request(request: &ArtcraftHunyuan3d2p0RequestState) -> Self { + let cost = match request.request.mesh_output_type { + Some(CommonMeshOutputType::Geometry) => GEOMETRY_COST_IN_USD_CENTS, + None | Some(_) => TEXTURED_COST_IN_USD_CENTS, + }; + Self { cost_in_usd_cents: cost } + } + + pub fn estimate_cost(&self) -> MeshGenerationCostEstimate { + MeshGenerationCostEstimate { + cost_in_credits: Some(self.cost_in_usd_cents), + cost_in_usd_cents: Some(self.cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_mesh_model::RouterMeshModel; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; + + use super::*; + + #[test] + fn default_output_type_is_sixty_three_cents() { + assert_eq!(estimate_usd_cents(base_builder()), 63); + } + + #[test] + fn normal_is_sixty_three_cents() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::Normal), + ..base_builder() + }; + assert_eq!(estimate_usd_cents(builder), 63); + } + + #[test] + fn geometry_is_twenty_one_cents() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::Geometry), + ..base_builder() + }; + assert_eq!(estimate_usd_cents(builder), 21); + } + + // ── Helpers ── + + fn base_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + model: RouterMeshModel::Hunyuan3d2p0, + provider: RouterProvider::Artcraft, + reference_images: Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_front".to_string()), + ])), + ..Default::default() + } + } + + fn estimate_usd_cents(builder: GenerateMeshRequestBuilder) -> u64 { + builder.build2() + .expect("build should succeed") + .estimate_cost() + .expect("estimate should succeed") + .cost_in_usd_cents + .expect("cost should be present") + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_2p0/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_2p0/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_2p0/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_2p0/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_2p0/request.rs new file mode 100644 index 0000000000..942f02828a --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_2p0/request.rs @@ -0,0 +1,17 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_mesh_cost_and_generate_request::OmniGenMeshCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_mesh::generate_mesh_response::GenerateMeshResponse; +use crate::generate::generate_mesh::providers::artcraft::request_common::send_artcraft_omni_mesh_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftHunyuan3d2p0RequestState { + pub request: OmniGenMeshCostAndGenerateRequest, +} + +impl ArtcraftHunyuan3d2p0RequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_mesh_request(&self.request, client).await + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_2p1/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_2p1/build.rs new file mode 100644 index 0000000000..af2f555278 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_2p1/build.rs @@ -0,0 +1,14 @@ +use enums::common::generation::common_mesh_model::CommonMeshModel as CommonMeshModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; +use crate::generate::generate_mesh::mesh_generation_draft_or_request::MeshGenerationDraftOrRequest; +use crate::generate::generate_mesh::mesh_generation_request::MeshGenerationRequest; +use crate::generate::generate_mesh::providers::artcraft::build_common::build_artcraft_omni_mesh_request; +use crate::generate::generate_mesh::providers::artcraft::hunyuan_3d_2p1::request::ArtcraftHunyuan3d2p1RequestState; + +pub fn build_artcraft_hunyuan_3d_2p1(builder: GenerateMeshRequestBuilder) -> Result { + let request = build_artcraft_omni_mesh_request(builder, CommonMeshModelEnum::Hunyuan3d2p1)?; + let state = ArtcraftHunyuan3d2p1RequestState { request }; + Ok(MeshGenerationDraftOrRequest::Request(MeshGenerationRequest::ArtcraftHunyuan3d2p1(state))) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_2p1/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_2p1/cost.rs new file mode 100644 index 0000000000..e045ff4806 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_2p1/cost.rs @@ -0,0 +1,93 @@ +use enums::common::generation::common_mesh_output_type::CommonMeshOutputType; + +use crate::generate::generate_mesh::mesh_generation_cost_estimate::MeshGenerationCostEstimate; +use crate::generate::generate_mesh::providers::artcraft::hunyuan_3d_2p1::request::ArtcraftHunyuan3d2p1RequestState; + +const TEXTURED_COST_IN_USD_CENTS: u64 = 117; +const GEOMETRY_COST_IN_USD_CENTS: u64 = 39; + +/// Hunyuan 3D 2.1 via Artcraft: flat price by output type. Geometry-only +/// (white mesh) is cheaper; every other output type prices as textured. +#[derive(Clone, Debug)] +pub struct ArtcraftHunyuan3d2p1CostState { + pub cost_in_usd_cents: u64, +} + +impl ArtcraftHunyuan3d2p1CostState { + pub fn from_request(request: &ArtcraftHunyuan3d2p1RequestState) -> Self { + let cost = match request.request.mesh_output_type { + Some(CommonMeshOutputType::Geometry) => GEOMETRY_COST_IN_USD_CENTS, + None | Some(_) => TEXTURED_COST_IN_USD_CENTS, + }; + Self { cost_in_usd_cents: cost } + } + + pub fn estimate_cost(&self) -> MeshGenerationCostEstimate { + MeshGenerationCostEstimate { + cost_in_credits: Some(self.cost_in_usd_cents), + cost_in_usd_cents: Some(self.cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_mesh_model::RouterMeshModel; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; + + use super::*; + + #[test] + fn default_output_type_is_one_seventeen_cents() { + assert_eq!(estimate_usd_cents(base_builder()), 117); + } + + #[test] + fn normal_is_one_seventeen_cents() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::Normal), + ..base_builder() + }; + assert_eq!(estimate_usd_cents(builder), 117); + } + + #[test] + fn geometry_is_thirty_nine_cents() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::Geometry), + ..base_builder() + }; + assert_eq!(estimate_usd_cents(builder), 39); + } + + // ── Helpers ── + + fn base_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + model: RouterMeshModel::Hunyuan3d2p1, + provider: RouterProvider::Artcraft, + reference_images: Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_front".to_string()), + ])), + ..Default::default() + } + } + + fn estimate_usd_cents(builder: GenerateMeshRequestBuilder) -> u64 { + builder.build2() + .expect("build should succeed") + .estimate_cost() + .expect("estimate should succeed") + .cost_in_usd_cents + .expect("cost should be present") + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_2p1/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_2p1/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_2p1/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_2p1/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_2p1/request.rs new file mode 100644 index 0000000000..ccd980dcdb --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/hunyuan_3d_2p1/request.rs @@ -0,0 +1,17 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_mesh_cost_and_generate_request::OmniGenMeshCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_mesh::generate_mesh_response::GenerateMeshResponse; +use crate::generate::generate_mesh::providers::artcraft::request_common::send_artcraft_omni_mesh_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftHunyuan3d2p1RequestState { + pub request: OmniGenMeshCostAndGenerateRequest, +} + +impl ArtcraftHunyuan3d2p1RequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_mesh_request(&self.request, client).await + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/mod.rs new file mode 100644 index 0000000000..a5105a0bee --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/mod.rs @@ -0,0 +1,7 @@ +pub(crate) mod build_common; +pub(crate) mod request_common; +pub(crate) mod resolve; +pub mod hunyuan3d_3; +pub mod hunyuan3d_3_sketch; +pub mod hunyuan_3d_2p0; +pub mod hunyuan_3d_2p1; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/request_common.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/request_common.rs new file mode 100644 index 0000000000..fc49ab6dec --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/request_common.rs @@ -0,0 +1,29 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_mesh_cost_and_generate_request::OmniGenMeshCostAndGenerateRequest; +use artcraft_client::endpoints::omni_gen::generate::mesh::omni_gen_mesh::omni_gen_mesh_generate; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::provider_error::ProviderError; +use crate::generate::generate_mesh::generate_mesh_response::{ + ArtcraftMeshResponsePayload, GenerateMeshResponse, +}; + +/// Send a mesh generation request to the Artcraft omni-gen endpoint. +/// All Artcraft model request states delegate to this function. +pub async fn send_artcraft_omni_mesh_request( + request: &OmniGenMeshCostAndGenerateRequest, + client: &RouterArtcraftClient, +) -> Result { + let response = omni_gen_mesh_generate( + &client.api_host, + Some(&client.credentials), + request.clone(), + ) + .await + .map_err(|err| ArtcraftRouterError::Provider(ProviderError::Storyteller(err)))?; + + Ok(GenerateMeshResponse::Artcraft(ArtcraftMeshResponsePayload { + inference_job_token: response.inference_job_token, + all_inference_job_tokens: response.all_job_tokens, + })) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/resolve.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/resolve.rs new file mode 100644 index 0000000000..9222a39549 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/artcraft/resolve.rs @@ -0,0 +1,34 @@ +//! Shared resolve helpers for the Artcraft mesh provider. +//! +//! Artcraft only accepts media file tokens — raw URLs are rejected. + +use tokens::tokens::media_files::MediaFileToken; + +use crate::api::image_list_ref::ImageListRef; +use crate::api::image_ref::ImageRef; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; + +pub(super) fn resolve_image_list_ref( + image_list_ref: Option, +) -> Result>, ArtcraftRouterError> { + match image_list_ref { + None => Ok(None), + Some(ImageListRef::MediaFileTokens(tokens)) => Ok(Some(tokens)), + Some(ImageListRef::Urls(_)) => { + Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens)) + } + } +} + +pub(super) fn resolve_image_ref( + image_ref: Option, +) -> Result, ArtcraftRouterError> { + match image_ref { + None => Ok(None), + Some(ImageRef::MediaFileToken(token)) => Ok(Some(token)), + Some(ImageRef::Url(_)) => { + Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens)) + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan3d_3/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan3d_3/build.rs new file mode 100644 index 0000000000..6f46320993 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan3d_3/build.rs @@ -0,0 +1,330 @@ +use enums::common::generation::common_mesh_output_type::CommonMeshOutputType; +use enums::common::generation::common_polygon_type::CommonPolygonType; +use fal_client::requests::api::mesh::image::hunyuan3d_3_image_to_mesh::api::{ + Hunyuan3d3ImageToMeshGenerateType, Hunyuan3d3ImageToMeshPolygonType, Hunyuan3d3ImageToMeshRequest, +}; +use fal_client::requests::api::mesh::text::hunyuan3d_3_text_to_mesh::api::{ + Hunyuan3d3TextToMeshGenerateType, Hunyuan3d3TextToMeshPolygonType, Hunyuan3d3TextToMeshRequest, +}; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; +use crate::generate::generate_mesh::mesh_generation_draft_or_request::MeshGenerationDraftOrRequest; +use crate::generate::generate_mesh::mesh_generation_request::MeshGenerationRequest; +use crate::generate::generate_mesh::providers::fal::hunyuan3d_3::request::{ + FalHunyuan3d3ImageRequestState, FalHunyuan3d3TextRequestState, +}; +use crate::generate::generate_mesh::providers::fal::resolve::{ + plan_face_count, plan_primary_image_url, plan_side_image_url, +}; +use crate::generate::generate_mesh::providers::reject_unsupported::{ + reject_unsupported_image_ref, reject_unsupported_option, +}; + +/// Hunyuan 3D v3 combines fal's image-to-3d and text-to-3d endpoints under a +/// single router model. The request shape picks the endpoint: any image input +/// (reference images or `front_image`) dispatches to image-to-3d; otherwise a +/// prompt dispatches to text-to-3d. +pub fn build_fal_hunyuan3d_3(builder: GenerateMeshRequestBuilder) -> Result { + let state = build_fal_hunyuan3d_3_state(builder)?; + let request = match state { + FalHunyuan3d3State::Image(state) => MeshGenerationRequest::FalHunyuan3d3Image(state), + FalHunyuan3d3State::Text(state) => MeshGenerationRequest::FalHunyuan3d3Text(state), + }; + Ok(MeshGenerationDraftOrRequest::Request(request)) +} + +/// The endpoint selected by the request shape. +#[derive(Clone, Debug)] +pub(crate) enum FalHunyuan3d3State { + Image(FalHunyuan3d3ImageRequestState), + Text(FalHunyuan3d3TextRequestState), +} + +pub(crate) fn build_fal_hunyuan3d_3_state( + mut builder: GenerateMeshRequestBuilder, +) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + let maybe_front_url = plan_primary_image_url( + builder.reference_images.take(), + builder.front_image.take(), + strategy, + )?; + let face_count = plan_face_count(builder.face_count, strategy)?; + + match maybe_front_url { + Some(image_url) => { + // Image mode. The image endpoint has no prompt parameter. + reject_unsupported_option("prompt", builder.prompt.as_ref(), strategy)?; + + let request = Hunyuan3d3ImageToMeshRequest { + image_url, + back_image_url: plan_side_image_url(builder.back_image.take())?, + left_image_url: plan_side_image_url(builder.left_image.take())?, + right_image_url: plan_side_image_url(builder.right_image.take())?, + face_count, + generate_type: builder.mesh_output_type.map(to_image_generate_type), + polygon_type: builder.polygon_type.map(to_image_polygon_type), + enable_pbr: builder.enable_pbr, + }; + Ok(FalHunyuan3d3State::Image(FalHunyuan3d3ImageRequestState { request })) + } + None => { + // Text mode. Multi-view side images only make sense alongside a front + // image, so they're unsupported here. + reject_unsupported_image_ref("back_image", builder.back_image.as_ref(), strategy)?; + reject_unsupported_image_ref("left_image", builder.left_image.as_ref(), strategy)?; + reject_unsupported_image_ref("right_image", builder.right_image.as_ref(), strategy)?; + + let prompt = builder.prompt.take().ok_or_else(|| { + ArtcraftRouterError::InvalidInput( + "Hunyuan 3D v3 requires an input image or a prompt".to_string(), + ) + })?; + + let request = Hunyuan3d3TextToMeshRequest { + prompt, + face_count, + generate_type: builder.mesh_output_type.map(to_text_generate_type), + polygon_type: builder.polygon_type.map(to_text_polygon_type), + enable_pbr: builder.enable_pbr, + }; + Ok(FalHunyuan3d3State::Text(FalHunyuan3d3TextRequestState { request })) + } + } +} + +// ── Enum mapping helpers ── + +fn to_image_generate_type(output_type: CommonMeshOutputType) -> Hunyuan3d3ImageToMeshGenerateType { + match output_type { + CommonMeshOutputType::Normal => Hunyuan3d3ImageToMeshGenerateType::Normal, + CommonMeshOutputType::LowPoly => Hunyuan3d3ImageToMeshGenerateType::LowPoly, + CommonMeshOutputType::Geometry => Hunyuan3d3ImageToMeshGenerateType::Geometry, + } +} + +fn to_image_polygon_type(polygon_type: CommonPolygonType) -> Hunyuan3d3ImageToMeshPolygonType { + match polygon_type { + CommonPolygonType::Triangle => Hunyuan3d3ImageToMeshPolygonType::Triangle, + CommonPolygonType::Quad => Hunyuan3d3ImageToMeshPolygonType::Quadrilateral, + } +} + +fn to_text_generate_type(output_type: CommonMeshOutputType) -> Hunyuan3d3TextToMeshGenerateType { + match output_type { + CommonMeshOutputType::Normal => Hunyuan3d3TextToMeshGenerateType::Normal, + CommonMeshOutputType::LowPoly => Hunyuan3d3TextToMeshGenerateType::LowPoly, + CommonMeshOutputType::Geometry => Hunyuan3d3TextToMeshGenerateType::Geometry, + } +} + +fn to_text_polygon_type(polygon_type: CommonPolygonType) -> Hunyuan3d3TextToMeshPolygonType { + match polygon_type { + CommonPolygonType::Triangle => Hunyuan3d3TextToMeshPolygonType::Triangle, + CommonPolygonType::Quad => Hunyuan3d3TextToMeshPolygonType::Quadrilateral, + } +} + +#[cfg(test)] +mod tests { + use crate::api::image_list_ref::ImageListRef; + use crate::api::image_ref::ImageRef; + use crate::api::router_mesh_model::RouterMeshModel; + use crate::api::router_provider::RouterProvider; + use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + use crate::errors::client_error::ClientError; + + use super::*; + + const FRONT_URL: &str = "https://example.com/front.png"; + const BACK_URL: &str = "https://example.com/back.png"; + + mod shape_dispatch { + use super::*; + + #[test] + fn reference_image_dispatches_to_image_mode() { + let state = build_fal_hunyuan3d_3_state(image_builder()).expect("build"); + let image = expect_image(state); + assert_eq!(image.request.image_url, FRONT_URL); + } + + #[test] + fn front_image_dispatches_to_image_mode() { + let builder = GenerateMeshRequestBuilder { + reference_images: None, + front_image: Some(ImageRef::Url(FRONT_URL.to_string())), + ..base_builder() + }; + let state = build_fal_hunyuan3d_3_state(builder).expect("build"); + let image = expect_image(state); + assert_eq!(image.request.image_url, FRONT_URL); + } + + #[test] + fn prompt_only_dispatches_to_text_mode() { + let state = build_fal_hunyuan3d_3_state(text_builder()).expect("build"); + let text = expect_text(state); + assert_eq!(text.request.prompt, "a red ceramic teapot"); + } + + #[test] + fn neither_image_nor_prompt_is_rejected() { + let result = build_fal_hunyuan3d_3_state(base_builder()); + assert!(matches!(result, Err(ArtcraftRouterError::InvalidInput(_)))); + } + } + + mod image_mode { + use super::*; + + #[test] + fn side_images_map_to_multi_view_params() { + let builder = GenerateMeshRequestBuilder { + back_image: Some(ImageRef::Url(BACK_URL.to_string())), + left_image: Some(ImageRef::Url("https://example.com/left.png".to_string())), + right_image: Some(ImageRef::Url("https://example.com/right.png".to_string())), + ..image_builder() + }; + let image = expect_image(build_fal_hunyuan3d_3_state(builder).expect("build")); + assert_eq!(image.request.back_image_url.as_deref(), Some(BACK_URL)); + assert_eq!(image.request.left_image_url.as_deref(), Some("https://example.com/left.png")); + assert_eq!(image.request.right_image_url.as_deref(), Some("https://example.com/right.png")); + } + + #[test] + fn options_map_through() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::LowPoly), + polygon_type: Some(CommonPolygonType::Quad), + face_count: Some(100_000), + enable_pbr: Some(true), + ..image_builder() + }; + let image = expect_image(build_fal_hunyuan3d_3_state(builder).expect("build")); + assert!(matches!(image.request.generate_type, Some(Hunyuan3d3ImageToMeshGenerateType::LowPoly))); + assert!(matches!(image.request.polygon_type, Some(Hunyuan3d3ImageToMeshPolygonType::Quadrilateral))); + assert_eq!(image.request.face_count, Some(100_000)); + assert_eq!(image.request.enable_pbr, Some(true)); + } + + #[test] + fn prompt_with_image_errors_out_under_error_out() { + let builder = GenerateMeshRequestBuilder { + prompt: Some("a red ceramic teapot".to_string()), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + ..image_builder() + }; + assert!(build_fal_hunyuan3d_3_state(builder).is_err()); + } + + #[test] + fn prompt_with_image_is_dropped_under_lenient_strategies() { + let builder = GenerateMeshRequestBuilder { + prompt: Some("a red ceramic teapot".to_string()), + ..image_builder() + }; + assert!(matches!( + build_fal_hunyuan3d_3_state(builder).expect("build"), + FalHunyuan3d3State::Image(_) + )); + } + + #[test] + fn media_tokens_are_rejected() { + use tokens::tokens::media_files::MediaFileToken; + let builder = GenerateMeshRequestBuilder { + reference_images: Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_test123".to_string()), + ])), + ..base_builder() + }; + assert!(matches!( + build_fal_hunyuan3d_3_state(builder), + Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)) + )); + } + } + + mod text_mode { + use super::*; + + #[test] + fn options_map_through() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::Geometry), + polygon_type: Some(CommonPolygonType::Triangle), + face_count: Some(40_000), + enable_pbr: Some(false), + ..text_builder() + }; + let text = expect_text(build_fal_hunyuan3d_3_state(builder).expect("build")); + assert!(matches!(text.request.generate_type, Some(Hunyuan3d3TextToMeshGenerateType::Geometry))); + assert!(matches!(text.request.polygon_type, Some(Hunyuan3d3TextToMeshPolygonType::Triangle))); + assert_eq!(text.request.face_count, Some(40_000)); + assert_eq!(text.request.enable_pbr, Some(false)); + } + + #[test] + fn side_images_without_front_error_out_under_error_out() { + let builder = GenerateMeshRequestBuilder { + back_image: Some(ImageRef::Url(BACK_URL.to_string())), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + ..text_builder() + }; + assert!(build_fal_hunyuan3d_3_state(builder).is_err()); + } + + #[test] + fn side_images_without_front_are_dropped_under_lenient_strategies() { + let builder = GenerateMeshRequestBuilder { + back_image: Some(ImageRef::Url(BACK_URL.to_string())), + ..text_builder() + }; + assert!(matches!( + build_fal_hunyuan3d_3_state(builder).expect("build"), + FalHunyuan3d3State::Text(_) + )); + } + } + + // ── Helpers ── + + fn base_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + model: RouterMeshModel::Hunyuan3d3, + provider: RouterProvider::Fal, + ..Default::default() + } + } + + fn image_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + reference_images: Some(ImageListRef::Urls(vec![FRONT_URL.to_string()])), + ..base_builder() + } + } + + fn text_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + prompt: Some("a red ceramic teapot".to_string()), + ..base_builder() + } + } + + fn expect_image(state: FalHunyuan3d3State) -> FalHunyuan3d3ImageRequestState { + match state { + FalHunyuan3d3State::Image(image) => image, + FalHunyuan3d3State::Text(text) => panic!("expected image mode, got text: {text:?}"), + } + } + + fn expect_text(state: FalHunyuan3d3State) -> FalHunyuan3d3TextRequestState { + match state { + FalHunyuan3d3State::Text(text) => text, + FalHunyuan3d3State::Image(image) => panic!("expected text mode, got image: {image:?}"), + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan3d_3/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan3d_3/cost.rs new file mode 100644 index 0000000000..98568333ff --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan3d_3/cost.rs @@ -0,0 +1,164 @@ +use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; + +use crate::generate::generate_mesh::mesh_generation_cost_estimate::MeshGenerationCostEstimate; +use crate::generate::generate_mesh::providers::fal::hunyuan3d_3::request::{ + FalHunyuan3d3ImageRequestState, FalHunyuan3d3TextRequestState, +}; + +#[derive(Clone, Debug)] +pub struct FalHunyuan3d3ImageCostState { + pub cost_in_usd_cents: u64, +} + +impl FalHunyuan3d3ImageCostState { + pub fn from_request(request: &FalHunyuan3d3ImageRequestState) -> Self { + // Cost math is owned by fal_client's per-endpoint + // `FalRequestCostCalculator` implementations. The router state just + // forwards the result so router cost ≡ fal_client cost by construction. + Self { + cost_in_usd_cents: request.request.calculate_cost_in_cents(), + } + } + + pub fn estimate_cost(&self) -> MeshGenerationCostEstimate { + fal_mesh_cost_estimate(self.cost_in_usd_cents) + } +} + +#[derive(Clone, Debug)] +pub struct FalHunyuan3d3TextCostState { + pub cost_in_usd_cents: u64, +} + +impl FalHunyuan3d3TextCostState { + pub fn from_request(request: &FalHunyuan3d3TextRequestState) -> Self { + Self { + cost_in_usd_cents: request.request.calculate_cost_in_cents(), + } + } + + pub fn estimate_cost(&self) -> MeshGenerationCostEstimate { + fal_mesh_cost_estimate(self.cost_in_usd_cents) + } +} + +pub(crate) fn fal_mesh_cost_estimate(cost_in_usd_cents: u64) -> MeshGenerationCostEstimate { + MeshGenerationCostEstimate { + cost_in_credits: Some(cost_in_usd_cents), + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } +} + +#[cfg(test)] +mod tests { + use enums::common::generation::common_mesh_output_type::CommonMeshOutputType; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::image_ref::ImageRef; + use crate::api::router_mesh_model::RouterMeshModel; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; + + const FRONT_URL: &str = "https://example.com/front.png"; + + mod image_mode_costs { + use super::*; + + #[test] + fn base_cost_table() { + let cases = [ + (None, 38), + (Some(CommonMeshOutputType::Normal), 38), + (Some(CommonMeshOutputType::LowPoly), 45), + (Some(CommonMeshOutputType::Geometry), 23), + ]; + for (output_type, expected) in cases { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: output_type, + ..image_builder() + }; + assert_eq!(estimate_usd_cents(builder), expected, "for {output_type:?}"); + } + } + + #[test] + fn add_ons_stack() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::LowPoly), + enable_pbr: Some(true), + face_count: Some(100_000), + back_image: Some(ImageRef::Url("https://example.com/back.png".to_string())), + ..image_builder() + }; + // LowPoly(45) + PBR(15) + face_count(15) + multi_view(15) + assert_eq!(estimate_usd_cents(builder), 90); + } + } + + mod text_mode_costs { + use super::*; + + #[test] + fn base_cost_table() { + let cases = [ + (None, 38), + (Some(CommonMeshOutputType::Normal), 38), + (Some(CommonMeshOutputType::LowPoly), 45), + (Some(CommonMeshOutputType::Geometry), 23), + ]; + for (output_type, expected) in cases { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: output_type, + ..text_builder() + }; + assert_eq!(estimate_usd_cents(builder), expected, "for {output_type:?}"); + } + } + + #[test] + fn add_ons_stack() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::Geometry), + enable_pbr: Some(true), + face_count: Some(40_000), + ..text_builder() + }; + // Geometry(23) + PBR(15) + face_count(15) + assert_eq!(estimate_usd_cents(builder), 53); + } + } + + // ── Helpers ── + + fn image_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + model: RouterMeshModel::Hunyuan3d3, + provider: RouterProvider::Fal, + reference_images: Some(ImageListRef::Urls(vec![FRONT_URL.to_string()])), + ..Default::default() + } + } + + fn text_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + model: RouterMeshModel::Hunyuan3d3, + provider: RouterProvider::Fal, + prompt: Some("a red ceramic teapot".to_string()), + ..Default::default() + } + } + + fn estimate_usd_cents(builder: GenerateMeshRequestBuilder) -> u64 { + builder.build2() + .expect("build should succeed") + .estimate_cost() + .expect("estimate should succeed") + .cost_in_usd_cents + .expect("cost should be present") + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan3d_3/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan3d_3/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan3d_3/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan3d_3/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan3d_3/request.rs new file mode 100644 index 0000000000..ed2fcc77aa --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan3d_3/request.rs @@ -0,0 +1,141 @@ +use std::fmt::Debug; +use std::sync::Arc; + +use fal_client::requests::api::mesh::image::hunyuan3d_3_image_to_mesh::api::Hunyuan3d3ImageToMeshRequest; +use fal_client::requests::api::mesh::text::hunyuan3d_3_text_to_mesh::api::Hunyuan3d3TextToMeshRequest; +use fal_client::requests::traits::fal_endpoint_trait::FalEndpoint; + +use crate::client::router_fal_client::RouterFalClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_mesh::generate_mesh_response::{ + FalMeshResponsePayload, GenerateMeshResponse, +}; + +#[derive(Clone, Debug)] +pub struct FalHunyuan3d3ImageRequestState { + /// Final materialized request; ready to fire. + pub request: Hunyuan3d3ImageToMeshRequest, +} + +impl FalHunyuan3d3ImageRequestState { + pub async fn send(&self, client: &RouterFalClient) -> Result { + send_fal_mesh_request(&self.request, client).await + } +} + +#[derive(Clone, Debug)] +pub struct FalHunyuan3d3TextRequestState { + /// Final materialized request; ready to fire. + pub request: Hunyuan3d3TextToMeshRequest, +} + +impl FalHunyuan3d3TextRequestState { + pub async fn send(&self, client: &RouterFalClient) -> Result { + send_fal_mesh_request(&self.request, client).await + } +} + +/// Send a mesh request to fal using the client's dispatch mode (webhook or +/// queue). Shared by all Fal mesh request states. +pub(crate) async fn send_fal_mesh_request( + request: &R, + client: &RouterFalClient, +) -> Result +where + R: FalEndpoint + Clone + Debug + Send + Sync + 'static, +{ + let outbound: Arc = Arc::new(request.clone()); + + let payload = if let Some(webhook_url) = &client.webhook_url { + let response = request.send_webhook_request(&client.api_key, webhook_url).await?; + FalMeshResponsePayload { + request_id: response.request_id, + gateway_request_id: response.gateway_request_id, + maybe_status_url: None, + maybe_response_url: None, + maybe_outbound_request: Some(outbound), + } + } else { + let response = request.send_queue_request(&client.api_key).await?; + FalMeshResponsePayload { + request_id: Some(response.request_id), + gateway_request_id: None, + maybe_status_url: Some(response.status_url), + maybe_response_url: Some(response.response_url), + maybe_outbound_request: Some(outbound), + } + }; + + Ok(GenerateMeshResponse::Fal(payload)) +} + +#[cfg(test)] +mod tests { + use fal_client::creds::fal_api_key::FalApiKey; + use test_data::web::image_urls::ERNEST_SCARED_STUPID_IMAGE_URL; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_mesh_model::RouterMeshModel; + use crate::api::router_provider::RouterProvider; + use crate::client::router_client::RouterClient; + use crate::client::router_fal_client::RouterFalClient; + use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; + use crate::generate::generate_mesh::generate_mesh_response::GenerateMeshResponse; + use crate::generate::generate_mesh::mesh_generation_draft_or_request::MeshGenerationDraftOrRequest; + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_hunyuan3d_3_image_to_mesh() { + let response = run_pipeline(GenerateMeshRequestBuilder { + reference_images: Some(ImageListRef::Urls(vec![ERNEST_SCARED_STUPID_IMAGE_URL.to_string()])), + ..hunyuan3d_3_builder() + }).await; + let payload = response.get_fal_payload().expect("expected Fal payload"); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + assert_eq!(1, 2, "Inspect output above"); + } + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_hunyuan3d_3_text_to_mesh() { + let response = run_pipeline(GenerateMeshRequestBuilder { + prompt: Some("A velociraptor with an open mouth full of sharp teeth.".to_string()), + ..hunyuan3d_3_builder() + }).await; + let payload = response.get_fal_payload().expect("expected Fal payload"); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + assert_eq!(1, 2, "Inspect output above"); + } + + // ── Helpers ── + + fn hunyuan3d_3_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + model: RouterMeshModel::Hunyuan3d3, + provider: RouterProvider::Fal, + ..Default::default() + } + } + + pub(crate) fn get_fal_client() -> RouterClient { + let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt") + .expect("Failed to read fal_api_key.txt"); + let api_key = FalApiKey::from_str(secret.trim()); + let webhook_url = "https://example.com/fal-webhook-test".to_string(); + RouterClient::Fal(RouterFalClient::new_with_webhook(api_key, webhook_url)) + } + + pub(crate) async fn run_pipeline(builder: GenerateMeshRequestBuilder) -> GenerateMeshResponse { + let client = get_fal_client(); + + let draft_or_request = builder.build2().expect("build2 should succeed"); + let request = match draft_or_request { + MeshGenerationDraftOrRequest::Request(r) => r, + _ => panic!("expected Request variant (Fal mesh skips draft)"), + }; + + let response = request.send_request(&client).await.expect("send_request should succeed"); + println!("response: {:?}", response); + response + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan3d_3_sketch/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan3d_3_sketch/build.rs new file mode 100644 index 0000000000..61814cb4ee --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan3d_3_sketch/build.rs @@ -0,0 +1,154 @@ +use fal_client::requests::api::mesh::sketch::hunyuan3d_3_sketch_to_mesh::api::Hunyuan3d3SketchToMeshRequest; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; +use crate::generate::generate_mesh::mesh_generation_draft_or_request::MeshGenerationDraftOrRequest; +use crate::generate::generate_mesh::mesh_generation_request::MeshGenerationRequest; +use crate::generate::generate_mesh::providers::fal::hunyuan3d_3_sketch::request::FalHunyuan3d3SketchRequestState; +use crate::generate::generate_mesh::providers::fal::resolve::{ + plan_face_count, plan_primary_image_url, +}; +use crate::generate::generate_mesh::providers::reject_unsupported::{ + reject_unsupported_image_ref, reject_unsupported_option, +}; + +pub fn build_fal_hunyuan3d_3_sketch(builder: GenerateMeshRequestBuilder) -> Result { + let state = build_fal_hunyuan3d_3_sketch_state(builder)?; + Ok(MeshGenerationDraftOrRequest::Request(MeshGenerationRequest::FalHunyuan3d3Sketch(state))) +} + +pub(crate) fn build_fal_hunyuan3d_3_sketch_state( + mut builder: GenerateMeshRequestBuilder, +) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + // fal's sketch-to-3d schema has no generate_type/polygon_type parameters, + // and no multi-view side images. + reject_unsupported_option("mesh_output_type", builder.mesh_output_type.as_ref(), strategy)?; + reject_unsupported_option("polygon_type", builder.polygon_type.as_ref(), strategy)?; + reject_unsupported_image_ref("back_image", builder.back_image.as_ref(), strategy)?; + reject_unsupported_image_ref("left_image", builder.left_image.as_ref(), strategy)?; + reject_unsupported_image_ref("right_image", builder.right_image.as_ref(), strategy)?; + + let image_url = plan_primary_image_url( + builder.reference_images.take(), + builder.front_image.take(), + strategy, + )? + .ok_or_else(|| ArtcraftRouterError::InvalidInput( + "A sketch image is required for Hunyuan 3D v3 sketch-to-3D".to_string(), + ))?; + + let prompt = builder.prompt.take().ok_or_else(|| { + ArtcraftRouterError::InvalidInput( + "A prompt is required for Hunyuan 3D v3 sketch-to-3D".to_string(), + ) + })?; + + let request = Hunyuan3d3SketchToMeshRequest { + image_url, + prompt, + face_count: plan_face_count(builder.face_count, strategy)?, + enable_pbr: builder.enable_pbr, + }; + + Ok(FalHunyuan3d3SketchRequestState { request }) +} + +#[cfg(test)] +mod tests { + use enums::common::generation::common_mesh_output_type::CommonMeshOutputType; + use enums::common::generation::common_polygon_type::CommonPolygonType; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::image_ref::ImageRef; + use crate::api::router_mesh_model::RouterMeshModel; + use crate::api::router_provider::RouterProvider; + use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + + use super::*; + + const SKETCH_URL: &str = "https://example.com/sketch.png"; + + #[test] + fn sketch_and_prompt_pass_through() { + let state = build_fal_hunyuan3d_3_sketch_state(base_builder()).expect("build"); + assert_eq!(state.request.image_url, SKETCH_URL); + assert_eq!(state.request.prompt, "a red ceramic teapot"); + } + + #[test] + fn missing_sketch_is_rejected() { + let builder = GenerateMeshRequestBuilder { reference_images: None, ..base_builder() }; + assert!(matches!( + build_fal_hunyuan3d_3_sketch_state(builder), + Err(ArtcraftRouterError::InvalidInput(_)) + )); + } + + #[test] + fn missing_prompt_is_rejected() { + let builder = GenerateMeshRequestBuilder { prompt: None, ..base_builder() }; + assert!(matches!( + build_fal_hunyuan3d_3_sketch_state(builder), + Err(ArtcraftRouterError::InvalidInput(_)) + )); + } + + #[test] + fn face_count_and_pbr_pass_through() { + let builder = GenerateMeshRequestBuilder { + face_count: Some(100_000), + enable_pbr: Some(true), + ..base_builder() + }; + let state = build_fal_hunyuan3d_3_sketch_state(builder).expect("build"); + assert_eq!(state.request.face_count, Some(100_000)); + assert_eq!(state.request.enable_pbr, Some(true)); + } + + #[test] + fn unsupported_options_error_out() { + let cases: Vec = vec![ + GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::LowPoly), + ..base_builder() + }, + GenerateMeshRequestBuilder { + polygon_type: Some(CommonPolygonType::Quad), + ..base_builder() + }, + GenerateMeshRequestBuilder { + back_image: Some(ImageRef::Url("https://example.com/back.png".to_string())), + ..base_builder() + }, + ]; + for mut builder in cases { + builder.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut; + assert!(build_fal_hunyuan3d_3_sketch_state(builder).is_err()); + } + } + + #[test] + fn unsupported_options_are_dropped_under_lenient_strategies() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::LowPoly), + polygon_type: Some(CommonPolygonType::Quad), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade, + ..base_builder() + }; + assert!(build_fal_hunyuan3d_3_sketch_state(builder).is_ok()); + } + + // ── Helpers ── + + fn base_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + model: RouterMeshModel::Hunyuan3d3Sketch, + provider: RouterProvider::Fal, + prompt: Some("a red ceramic teapot".to_string()), + reference_images: Some(ImageListRef::Urls(vec![SKETCH_URL.to_string()])), + ..Default::default() + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan3d_3_sketch/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan3d_3_sketch/cost.rs new file mode 100644 index 0000000000..cb6ab4052d --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan3d_3_sketch/cost.rs @@ -0,0 +1,87 @@ +use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; + +use crate::generate::generate_mesh::mesh_generation_cost_estimate::MeshGenerationCostEstimate; +use crate::generate::generate_mesh::providers::fal::hunyuan3d_3::cost::fal_mesh_cost_estimate; +use crate::generate::generate_mesh::providers::fal::hunyuan3d_3_sketch::request::FalHunyuan3d3SketchRequestState; + +#[derive(Clone, Debug)] +pub struct FalHunyuan3d3SketchCostState { + pub cost_in_usd_cents: u64, +} + +impl FalHunyuan3d3SketchCostState { + pub fn from_request(request: &FalHunyuan3d3SketchRequestState) -> Self { + // Cost math is owned by fal_client's per-endpoint + // `FalRequestCostCalculator` implementations. The router state just + // forwards the result so router cost ≡ fal_client cost by construction. + Self { + cost_in_usd_cents: request.request.calculate_cost_in_cents(), + } + } + + pub fn estimate_cost(&self) -> MeshGenerationCostEstimate { + fal_mesh_cost_estimate(self.cost_in_usd_cents) + } +} + +#[cfg(test)] +mod tests { + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_mesh_model::RouterMeshModel; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; + + #[test] + fn base_cost_is_thirty_eight_cents() { + assert_eq!(estimate_usd_cents(base_builder()), 38); + } + + #[test] + fn pbr_adds_fifteen_cents() { + let builder = GenerateMeshRequestBuilder { + enable_pbr: Some(true), + ..base_builder() + }; + assert_eq!(estimate_usd_cents(builder), 38 + 15); + } + + #[test] + fn face_count_adds_fifteen_cents() { + let builder = GenerateMeshRequestBuilder { + face_count: Some(100_000), + ..base_builder() + }; + assert_eq!(estimate_usd_cents(builder), 38 + 15); + } + + #[test] + fn all_add_ons_stack() { + let builder = GenerateMeshRequestBuilder { + enable_pbr: Some(true), + face_count: Some(100_000), + ..base_builder() + }; + assert_eq!(estimate_usd_cents(builder), 38 + 15 + 15); + } + + // ── Helpers ── + + fn base_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + model: RouterMeshModel::Hunyuan3d3Sketch, + provider: RouterProvider::Fal, + prompt: Some("a red ceramic teapot".to_string()), + reference_images: Some(ImageListRef::Urls(vec!["https://example.com/sketch.png".to_string()])), + ..Default::default() + } + } + + fn estimate_usd_cents(builder: GenerateMeshRequestBuilder) -> u64 { + builder.build2() + .expect("build should succeed") + .estimate_cost() + .expect("estimate should succeed") + .cost_in_usd_cents + .expect("cost should be present") + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan3d_3_sketch/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan3d_3_sketch/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan3d_3_sketch/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan3d_3_sketch/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan3d_3_sketch/request.rs new file mode 100644 index 0000000000..df83ba0c79 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan3d_3_sketch/request.rs @@ -0,0 +1,68 @@ +use fal_client::requests::api::mesh::sketch::hunyuan3d_3_sketch_to_mesh::api::Hunyuan3d3SketchToMeshRequest; + +use crate::client::router_fal_client::RouterFalClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_mesh::generate_mesh_response::GenerateMeshResponse; +use crate::generate::generate_mesh::providers::fal::hunyuan3d_3::request::send_fal_mesh_request; + +#[derive(Clone, Debug)] +pub struct FalHunyuan3d3SketchRequestState { + /// Final materialized request; ready to fire. + pub request: Hunyuan3d3SketchToMeshRequest, +} + +impl FalHunyuan3d3SketchRequestState { + pub async fn send(&self, client: &RouterFalClient) -> Result { + send_fal_mesh_request(&self.request, client).await + } +} + +#[cfg(test)] +mod tests { + use fal_client::creds::fal_api_key::FalApiKey; + use test_data::web::image_urls::ERNEST_SCARED_STUPID_IMAGE_URL; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_mesh_model::RouterMeshModel; + use crate::api::router_provider::RouterProvider; + use crate::client::router_client::RouterClient; + use crate::client::router_fal_client::RouterFalClient; + use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; + use crate::generate::generate_mesh::mesh_generation_draft_or_request::MeshGenerationDraftOrRequest; + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_sketch_to_mesh() { + let builder = GenerateMeshRequestBuilder { + model: RouterMeshModel::Hunyuan3d3Sketch, + provider: RouterProvider::Fal, + prompt: Some("A cartoon character, colorful, plastic material".to_string()), + reference_images: Some(ImageListRef::Urls(vec![ERNEST_SCARED_STUPID_IMAGE_URL.to_string()])), + ..Default::default() + }; + + let client = get_fal_client(); + let draft_or_request = builder.build2().expect("build2 should succeed"); + let request = match draft_or_request { + MeshGenerationDraftOrRequest::Request(r) => r, + _ => panic!("expected Request variant (Fal mesh skips draft)"), + }; + + let response = request.send_request(&client).await.expect("send_request should succeed"); + println!("response: {:?}", response); + + let payload = response.get_fal_payload().expect("expected Fal payload"); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + assert_eq!(1, 2, "Inspect output above"); + } + + // ── Helpers ── + + fn get_fal_client() -> RouterClient { + let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt") + .expect("Failed to read fal_api_key.txt"); + let api_key = FalApiKey::from_str(secret.trim()); + let webhook_url = "https://example.com/fal-webhook-test".to_string(); + RouterClient::Fal(RouterFalClient::new_with_webhook(api_key, webhook_url)) + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_2p0/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_2p0/build.rs new file mode 100644 index 0000000000..2f586db1ff --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_2p0/build.rs @@ -0,0 +1,219 @@ +use fal_client::requests::api::mesh::image::hunyuan_3d_2p0_image_to_mesh::api::Hunyuan3d2p0ImageToMeshRequest; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; +use crate::generate::generate_mesh::mesh_generation_draft_or_request::MeshGenerationDraftOrRequest; +use crate::generate::generate_mesh::mesh_generation_request::MeshGenerationRequest; +use crate::generate::generate_mesh::providers::fal::hunyuan_3d_2p0::request::FalHunyuan3d2p0RequestState; +use crate::generate::generate_mesh::providers::fal::resolve::{ + plan_primary_image_url, plan_textured_mesh, +}; +use crate::generate::generate_mesh::providers::reject_unsupported::{ + reject_unsupported_image_ref, reject_unsupported_option, +}; + +pub fn build_fal_hunyuan_3d_2p0(builder: GenerateMeshRequestBuilder) -> Result { + let state = build_fal_hunyuan_3d_2p0_state(builder)?; + Ok(MeshGenerationDraftOrRequest::Request(MeshGenerationRequest::FalHunyuan3d2p0(state))) +} + +pub(crate) fn build_fal_hunyuan_3d_2p0_state( + mut builder: GenerateMeshRequestBuilder, +) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + // Hunyuan 3D 2.0 is image-to-3D only, with a single input image and no + // extra mesh options. + reject_unsupported_option("prompt", builder.prompt.as_ref(), strategy)?; + reject_unsupported_option("polygon_type", builder.polygon_type.as_ref(), strategy)?; + reject_unsupported_option("face_count", builder.face_count.as_ref(), strategy)?; + reject_unsupported_option("enable_pbr", builder.enable_pbr.as_ref(), strategy)?; + reject_unsupported_image_ref("back_image", builder.back_image.as_ref(), strategy)?; + reject_unsupported_image_ref("left_image", builder.left_image.as_ref(), strategy)?; + reject_unsupported_image_ref("right_image", builder.right_image.as_ref(), strategy)?; + + let image_url = plan_primary_image_url( + builder.reference_images.take(), + builder.front_image.take(), + strategy, + )? + .ok_or_else(|| ArtcraftRouterError::InvalidInput( + "An input image is required for Hunyuan 3D 2.0".to_string(), + ))?; + + let request = Hunyuan3d2p0ImageToMeshRequest { + image_url, + textured_mesh: plan_textured_mesh(builder.mesh_output_type, strategy)?, + // Tuning knobs stay at the endpoint's defaults. + guidance_scale: None, + num_inference_steps: None, + octree_resolution: None, + seed: None, + }; + + Ok(FalHunyuan3d2p0RequestState { request }) +} + +#[cfg(test)] +mod tests { + use enums::common::generation::common_mesh_output_type::CommonMeshOutputType; + use enums::common::generation::common_polygon_type::CommonPolygonType; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::image_ref::ImageRef; + use crate::api::router_mesh_model::RouterMeshModel; + use crate::api::router_provider::RouterProvider; + use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + + use super::*; + + const IMAGE_URL: &str = "https://example.com/front.png"; + + mod image_requirements { + use super::*; + + #[test] + fn single_image_passes_through() { + let state = build_fal_hunyuan_3d_2p0_state(base_builder()).expect("build"); + assert_eq!(state.request.image_url, IMAGE_URL); + } + + #[test] + fn missing_image_is_rejected() { + let builder = GenerateMeshRequestBuilder { reference_images: None, ..base_builder() }; + assert!(matches!( + build_fal_hunyuan_3d_2p0_state(builder), + Err(ArtcraftRouterError::InvalidInput(_)) + )); + } + + #[test] + fn two_images_error_out() { + let builder = GenerateMeshRequestBuilder { + reference_images: Some(ImageListRef::Urls(vec![ + IMAGE_URL.to_string(), + "https://example.com/other.png".to_string(), + ])), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + ..base_builder() + }; + assert!(build_fal_hunyuan_3d_2p0_state(builder).is_err()); + } + + #[test] + fn two_images_keep_the_first_under_lenient_strategies() { + let builder = GenerateMeshRequestBuilder { + reference_images: Some(ImageListRef::Urls(vec![ + IMAGE_URL.to_string(), + "https://example.com/other.png".to_string(), + ])), + ..base_builder() + }; + let state = build_fal_hunyuan_3d_2p0_state(builder).expect("build"); + assert_eq!(state.request.image_url, IMAGE_URL); + } + } + + mod output_type_mapping { + use super::*; + + #[test] + fn default_maps_to_textured() { + let state = build_fal_hunyuan_3d_2p0_state(base_builder()).expect("build"); + assert_eq!(state.request.textured_mesh, Some(true)); + } + + #[test] + fn normal_maps_to_textured() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::Normal), + ..base_builder() + }; + let state = build_fal_hunyuan_3d_2p0_state(builder).expect("build"); + assert_eq!(state.request.textured_mesh, Some(true)); + } + + #[test] + fn geometry_maps_to_white_mesh() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::Geometry), + ..base_builder() + }; + let state = build_fal_hunyuan_3d_2p0_state(builder).expect("build"); + assert_eq!(state.request.textured_mesh, Some(false)); + } + + #[test] + fn low_poly_errors_out_under_error_out() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::LowPoly), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + ..base_builder() + }; + assert!(build_fal_hunyuan_3d_2p0_state(builder).is_err()); + } + + #[test] + fn low_poly_upgrades_to_textured_under_lenient_strategies() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::LowPoly), + ..base_builder() + }; + let state = build_fal_hunyuan_3d_2p0_state(builder).expect("build"); + assert_eq!(state.request.textured_mesh, Some(true)); + } + } + + mod unsupported_options { + use super::*; + + #[test] + fn unsupported_options_error_out() { + let cases: Vec = vec![ + GenerateMeshRequestBuilder { prompt: Some("a teapot".to_string()), ..base_builder() }, + GenerateMeshRequestBuilder { polygon_type: Some(CommonPolygonType::Quad), ..base_builder() }, + GenerateMeshRequestBuilder { face_count: Some(100_000), ..base_builder() }, + GenerateMeshRequestBuilder { enable_pbr: Some(true), ..base_builder() }, + GenerateMeshRequestBuilder { + back_image: Some(ImageRef::Url("https://example.com/back.png".to_string())), + ..base_builder() + }, + ]; + for mut builder in cases { + builder.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut; + assert!(build_fal_hunyuan_3d_2p0_state(builder).is_err()); + } + } + + #[test] + fn unsupported_options_are_dropped_under_lenient_strategies() { + let builder = GenerateMeshRequestBuilder { + prompt: Some("a teapot".to_string()), + face_count: Some(100_000), + enable_pbr: Some(true), + ..base_builder() + }; + assert!(build_fal_hunyuan_3d_2p0_state(builder).is_ok()); + } + + #[test] + fn tuning_knobs_stay_at_endpoint_defaults() { + let state = build_fal_hunyuan_3d_2p0_state(base_builder()).expect("build"); + assert!(state.request.guidance_scale.is_none()); + assert!(state.request.num_inference_steps.is_none()); + assert!(state.request.octree_resolution.is_none()); + assert!(state.request.seed.is_none()); + } + } + + // ── Helpers ── + + fn base_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + model: RouterMeshModel::Hunyuan3d2p0, + provider: RouterProvider::Fal, + reference_images: Some(ImageListRef::Urls(vec![IMAGE_URL.to_string()])), + ..Default::default() + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_2p0/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_2p0/cost.rs new file mode 100644 index 0000000000..61a0e05972 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_2p0/cost.rs @@ -0,0 +1,75 @@ +use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; + +use crate::generate::generate_mesh::mesh_generation_cost_estimate::MeshGenerationCostEstimate; +use crate::generate::generate_mesh::providers::fal::hunyuan3d_3::cost::fal_mesh_cost_estimate; +use crate::generate::generate_mesh::providers::fal::hunyuan_3d_2p0::request::FalHunyuan3d2p0RequestState; + +#[derive(Clone, Debug)] +pub struct FalHunyuan3d2p0CostState { + pub cost_in_usd_cents: u64, +} + +impl FalHunyuan3d2p0CostState { + pub fn from_request(request: &FalHunyuan3d2p0RequestState) -> Self { + // Cost math is owned by fal_client's per-endpoint + // `FalRequestCostCalculator` implementations. The router state just + // forwards the result so router cost ≡ fal_client cost by construction. + Self { + cost_in_usd_cents: request.request.calculate_cost_in_cents(), + } + } + + pub fn estimate_cost(&self) -> MeshGenerationCostEstimate { + fal_mesh_cost_estimate(self.cost_in_usd_cents) + } +} + +#[cfg(test)] +mod tests { + use enums::common::generation::common_mesh_output_type::CommonMeshOutputType; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_mesh_model::RouterMeshModel; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; + + #[test] + fn textured_mesh_is_forty_eight_cents() { + assert_eq!(estimate_usd_cents(base_builder()), 48); + + let normal = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::Normal), + ..base_builder() + }; + assert_eq!(estimate_usd_cents(normal), 48); + } + + #[test] + fn white_mesh_is_sixteen_cents() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::Geometry), + ..base_builder() + }; + assert_eq!(estimate_usd_cents(builder), 16); + } + + // ── Helpers ── + + fn base_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + model: RouterMeshModel::Hunyuan3d2p0, + provider: RouterProvider::Fal, + reference_images: Some(ImageListRef::Urls(vec!["https://example.com/front.png".to_string()])), + ..Default::default() + } + } + + fn estimate_usd_cents(builder: GenerateMeshRequestBuilder) -> u64 { + builder.build2() + .expect("build should succeed") + .estimate_cost() + .expect("estimate should succeed") + .cost_in_usd_cents + .expect("cost should be present") + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_2p0/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_2p0/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_2p0/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_2p0/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_2p0/request.rs new file mode 100644 index 0000000000..276f7afe74 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_2p0/request.rs @@ -0,0 +1,67 @@ +use fal_client::requests::api::mesh::image::hunyuan_3d_2p0_image_to_mesh::api::Hunyuan3d2p0ImageToMeshRequest; + +use crate::client::router_fal_client::RouterFalClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_mesh::generate_mesh_response::GenerateMeshResponse; +use crate::generate::generate_mesh::providers::fal::hunyuan3d_3::request::send_fal_mesh_request; + +#[derive(Clone, Debug)] +pub struct FalHunyuan3d2p0RequestState { + /// Final materialized request; ready to fire. + pub request: Hunyuan3d2p0ImageToMeshRequest, +} + +impl FalHunyuan3d2p0RequestState { + pub async fn send(&self, client: &RouterFalClient) -> Result { + send_fal_mesh_request(&self.request, client).await + } +} + +#[cfg(test)] +mod tests { + use fal_client::creds::fal_api_key::FalApiKey; + use test_data::web::image_urls::ERNEST_SCARED_STUPID_IMAGE_URL; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_mesh_model::RouterMeshModel; + use crate::api::router_provider::RouterProvider; + use crate::client::router_client::RouterClient; + use crate::client::router_fal_client::RouterFalClient; + use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; + use crate::generate::generate_mesh::mesh_generation_draft_or_request::MeshGenerationDraftOrRequest; + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_hunyuan_3d_2p0_image_to_mesh() { + let builder = GenerateMeshRequestBuilder { + model: RouterMeshModel::Hunyuan3d2p0, + provider: RouterProvider::Fal, + reference_images: Some(ImageListRef::Urls(vec![ERNEST_SCARED_STUPID_IMAGE_URL.to_string()])), + ..Default::default() + }; + + let client = get_fal_client(); + let draft_or_request = builder.build2().expect("build2 should succeed"); + let request = match draft_or_request { + MeshGenerationDraftOrRequest::Request(r) => r, + _ => panic!("expected Request variant (Fal mesh skips draft)"), + }; + + let response = request.send_request(&client).await.expect("send_request should succeed"); + println!("response: {:?}", response); + + let payload = response.get_fal_payload().expect("expected Fal payload"); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + assert_eq!(1, 2, "Inspect output above"); + } + + // ── Helpers ── + + fn get_fal_client() -> RouterClient { + let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt") + .expect("Failed to read fal_api_key.txt"); + let api_key = FalApiKey::from_str(secret.trim()); + let webhook_url = "https://example.com/fal-webhook-test".to_string(); + RouterClient::Fal(RouterFalClient::new_with_webhook(api_key, webhook_url)) + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_2p1/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_2p1/build.rs new file mode 100644 index 0000000000..3000381021 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_2p1/build.rs @@ -0,0 +1,146 @@ +use fal_client::requests::api::mesh::image::hunyuan_3d_2p1_image_to_mesh::api::Hunyuan3d2p1ImageToMeshRequest; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; +use crate::generate::generate_mesh::mesh_generation_draft_or_request::MeshGenerationDraftOrRequest; +use crate::generate::generate_mesh::mesh_generation_request::MeshGenerationRequest; +use crate::generate::generate_mesh::providers::fal::hunyuan_3d_2p1::request::FalHunyuan3d2p1RequestState; +use crate::generate::generate_mesh::providers::fal::resolve::{ + plan_primary_image_url, plan_textured_mesh, +}; +use crate::generate::generate_mesh::providers::reject_unsupported::{ + reject_unsupported_image_ref, reject_unsupported_option, +}; + +pub fn build_fal_hunyuan_3d_2p1(builder: GenerateMeshRequestBuilder) -> Result { + let state = build_fal_hunyuan_3d_2p1_state(builder)?; + Ok(MeshGenerationDraftOrRequest::Request(MeshGenerationRequest::FalHunyuan3d2p1(state))) +} + +pub(crate) fn build_fal_hunyuan_3d_2p1_state( + mut builder: GenerateMeshRequestBuilder, +) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + // Hunyuan 3D 2.1 is image-to-3D only, with a single input image and no + // extra mesh options. + reject_unsupported_option("prompt", builder.prompt.as_ref(), strategy)?; + reject_unsupported_option("polygon_type", builder.polygon_type.as_ref(), strategy)?; + reject_unsupported_option("face_count", builder.face_count.as_ref(), strategy)?; + reject_unsupported_option("enable_pbr", builder.enable_pbr.as_ref(), strategy)?; + reject_unsupported_image_ref("back_image", builder.back_image.as_ref(), strategy)?; + reject_unsupported_image_ref("left_image", builder.left_image.as_ref(), strategy)?; + reject_unsupported_image_ref("right_image", builder.right_image.as_ref(), strategy)?; + + let image_url = plan_primary_image_url( + builder.reference_images.take(), + builder.front_image.take(), + strategy, + )? + .ok_or_else(|| ArtcraftRouterError::InvalidInput( + "An input image is required for Hunyuan 3D 2.1".to_string(), + ))?; + + let request = Hunyuan3d2p1ImageToMeshRequest { + image_url, + textured_mesh: plan_textured_mesh(builder.mesh_output_type, strategy)?, + // Tuning knobs stay at the endpoint's defaults. + guidance_scale: None, + num_inference_steps: None, + octree_resolution: None, + seed: None, + }; + + Ok(FalHunyuan3d2p1RequestState { request }) +} + +#[cfg(test)] +mod tests { + use enums::common::generation::common_mesh_output_type::CommonMeshOutputType; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_mesh_model::RouterMeshModel; + use crate::api::router_provider::RouterProvider; + use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + + use super::*; + + const IMAGE_URL: &str = "https://example.com/front.png"; + + #[test] + fn single_image_passes_through() { + let state = build_fal_hunyuan_3d_2p1_state(base_builder()).expect("build"); + assert_eq!(state.request.image_url, IMAGE_URL); + assert_eq!(state.request.textured_mesh, Some(true)); + } + + #[test] + fn missing_image_is_rejected() { + let builder = GenerateMeshRequestBuilder { reference_images: None, ..base_builder() }; + assert!(matches!( + build_fal_hunyuan_3d_2p1_state(builder), + Err(ArtcraftRouterError::InvalidInput(_)) + )); + } + + #[test] + fn geometry_maps_to_white_mesh() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::Geometry), + ..base_builder() + }; + let state = build_fal_hunyuan_3d_2p1_state(builder).expect("build"); + assert_eq!(state.request.textured_mesh, Some(false)); + } + + #[test] + fn low_poly_errors_out_under_error_out() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::LowPoly), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + ..base_builder() + }; + assert!(build_fal_hunyuan_3d_2p1_state(builder).is_err()); + } + + #[test] + fn low_poly_upgrades_to_textured_under_lenient_strategies() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::LowPoly), + ..base_builder() + }; + let state = build_fal_hunyuan_3d_2p1_state(builder).expect("build"); + assert_eq!(state.request.textured_mesh, Some(true)); + } + + #[test] + fn unsupported_options_error_out() { + let builder = GenerateMeshRequestBuilder { + prompt: Some("a teapot".to_string()), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + ..base_builder() + }; + assert!(build_fal_hunyuan_3d_2p1_state(builder).is_err()); + } + + #[test] + fn unsupported_options_are_dropped_under_lenient_strategies() { + let builder = GenerateMeshRequestBuilder { + prompt: Some("a teapot".to_string()), + face_count: Some(100_000), + ..base_builder() + }; + assert!(build_fal_hunyuan_3d_2p1_state(builder).is_ok()); + } + + // ── Helpers ── + + fn base_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + model: RouterMeshModel::Hunyuan3d2p1, + provider: RouterProvider::Fal, + reference_images: Some(ImageListRef::Urls(vec![IMAGE_URL.to_string()])), + ..Default::default() + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_2p1/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_2p1/cost.rs new file mode 100644 index 0000000000..1de566656a --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_2p1/cost.rs @@ -0,0 +1,75 @@ +use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; + +use crate::generate::generate_mesh::mesh_generation_cost_estimate::MeshGenerationCostEstimate; +use crate::generate::generate_mesh::providers::fal::hunyuan3d_3::cost::fal_mesh_cost_estimate; +use crate::generate::generate_mesh::providers::fal::hunyuan_3d_2p1::request::FalHunyuan3d2p1RequestState; + +#[derive(Clone, Debug)] +pub struct FalHunyuan3d2p1CostState { + pub cost_in_usd_cents: u64, +} + +impl FalHunyuan3d2p1CostState { + pub fn from_request(request: &FalHunyuan3d2p1RequestState) -> Self { + // Cost math is owned by fal_client's per-endpoint + // `FalRequestCostCalculator` implementations. The router state just + // forwards the result so router cost ≡ fal_client cost by construction. + Self { + cost_in_usd_cents: request.request.calculate_cost_in_cents(), + } + } + + pub fn estimate_cost(&self) -> MeshGenerationCostEstimate { + fal_mesh_cost_estimate(self.cost_in_usd_cents) + } +} + +#[cfg(test)] +mod tests { + use enums::common::generation::common_mesh_output_type::CommonMeshOutputType; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_mesh_model::RouterMeshModel; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; + + #[test] + fn textured_mesh_is_ninety_cents() { + assert_eq!(estimate_usd_cents(base_builder()), 90); + + let normal = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::Normal), + ..base_builder() + }; + assert_eq!(estimate_usd_cents(normal), 90); + } + + #[test] + fn white_mesh_is_thirty_cents() { + let builder = GenerateMeshRequestBuilder { + mesh_output_type: Some(CommonMeshOutputType::Geometry), + ..base_builder() + }; + assert_eq!(estimate_usd_cents(builder), 30); + } + + // ── Helpers ── + + fn base_builder() -> GenerateMeshRequestBuilder { + GenerateMeshRequestBuilder { + model: RouterMeshModel::Hunyuan3d2p1, + provider: RouterProvider::Fal, + reference_images: Some(ImageListRef::Urls(vec!["https://example.com/front.png".to_string()])), + ..Default::default() + } + } + + fn estimate_usd_cents(builder: GenerateMeshRequestBuilder) -> u64 { + builder.build2() + .expect("build should succeed") + .estimate_cost() + .expect("estimate should succeed") + .cost_in_usd_cents + .expect("cost should be present") + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_2p1/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_2p1/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_2p1/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_2p1/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_2p1/request.rs new file mode 100644 index 0000000000..9e20a829af --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/hunyuan_3d_2p1/request.rs @@ -0,0 +1,67 @@ +use fal_client::requests::api::mesh::image::hunyuan_3d_2p1_image_to_mesh::api::Hunyuan3d2p1ImageToMeshRequest; + +use crate::client::router_fal_client::RouterFalClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_mesh::generate_mesh_response::GenerateMeshResponse; +use crate::generate::generate_mesh::providers::fal::hunyuan3d_3::request::send_fal_mesh_request; + +#[derive(Clone, Debug)] +pub struct FalHunyuan3d2p1RequestState { + /// Final materialized request; ready to fire. + pub request: Hunyuan3d2p1ImageToMeshRequest, +} + +impl FalHunyuan3d2p1RequestState { + pub async fn send(&self, client: &RouterFalClient) -> Result { + send_fal_mesh_request(&self.request, client).await + } +} + +#[cfg(test)] +mod tests { + use fal_client::creds::fal_api_key::FalApiKey; + use test_data::web::image_urls::ERNEST_SCARED_STUPID_IMAGE_URL; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_mesh_model::RouterMeshModel; + use crate::api::router_provider::RouterProvider; + use crate::client::router_client::RouterClient; + use crate::client::router_fal_client::RouterFalClient; + use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; + use crate::generate::generate_mesh::mesh_generation_draft_or_request::MeshGenerationDraftOrRequest; + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_hunyuan_3d_2p1_image_to_mesh() { + let builder = GenerateMeshRequestBuilder { + model: RouterMeshModel::Hunyuan3d2p1, + provider: RouterProvider::Fal, + reference_images: Some(ImageListRef::Urls(vec![ERNEST_SCARED_STUPID_IMAGE_URL.to_string()])), + ..Default::default() + }; + + let client = get_fal_client(); + let draft_or_request = builder.build2().expect("build2 should succeed"); + let request = match draft_or_request { + MeshGenerationDraftOrRequest::Request(r) => r, + _ => panic!("expected Request variant (Fal mesh skips draft)"), + }; + + let response = request.send_request(&client).await.expect("send_request should succeed"); + println!("response: {:?}", response); + + let payload = response.get_fal_payload().expect("expected Fal payload"); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + assert_eq!(1, 2, "Inspect output above"); + } + + // ── Helpers ── + + fn get_fal_client() -> RouterClient { + let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt") + .expect("Failed to read fal_api_key.txt"); + let api_key = FalApiKey::from_str(secret.trim()); + let webhook_url = "https://example.com/fal-webhook-test".to_string(); + RouterClient::Fal(RouterFalClient::new_with_webhook(api_key, webhook_url)) + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/mod.rs new file mode 100644 index 0000000000..7f426ad084 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/mod.rs @@ -0,0 +1,5 @@ +pub(crate) mod resolve; +pub mod hunyuan3d_3; +pub mod hunyuan3d_3_sketch; +pub mod hunyuan_3d_2p0; +pub mod hunyuan_3d_2p1; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/resolve.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/resolve.rs new file mode 100644 index 0000000000..e70b412eb2 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/fal/resolve.rs @@ -0,0 +1,208 @@ +//! Shared plan helpers for the Fal mesh provider. +//! +//! Fal only accepts media URLs — media file tokens are rejected. Callers +//! must resolve tokens to URLs before building for the Fal provider. + +use enums::common::generation::common_mesh_output_type::CommonMeshOutputType; + +use crate::api::image_list_ref::ImageListRef; +use crate::api::image_ref::ImageRef; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; + +/// Resolve the primary (front) image from `reference_images` and/or +/// `front_image`. More than one candidate rejects under `ErrorOut`; the other +/// strategies keep the first (reference images take precedence over +/// `front_image`). +pub(crate) fn plan_primary_image_url( + reference_images: Option, + front_image: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + let mut candidates: Vec = Vec::new(); + + match reference_images { + None => {} + Some(ImageListRef::Urls(urls)) => candidates.extend(urls), + Some(ImageListRef::MediaFileTokens(tokens)) if tokens.is_empty() => {} + Some(ImageListRef::MediaFileTokens(_)) => { + return Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)); + } + } + + if let Some(front) = front_image { + candidates.push(image_ref_to_url(front)?); + } + + if candidates.len() > 1 { + if let RequestMismatchMitigationStrategy::ErrorOut = strategy { + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "reference_images", + value: format!("Expected at most 1 primary image, got {}", candidates.len()), + })); + } + } + Ok(candidates.into_iter().next()) +} + +/// Resolve an optional side-view image (back/left/right) to a URL. +pub(crate) fn plan_side_image_url( + image_ref: Option, +) -> Result, ArtcraftRouterError> { + image_ref.map(image_ref_to_url).transpose() +} + +/// Narrow a `u64` face count to the `u32` the fal bindings take. +/// Out-of-range values reject under `ErrorOut` and clamp otherwise. +pub(crate) fn plan_face_count( + face_count: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + let Some(face_count) = face_count else { + return Ok(None); + }; + match u32::try_from(face_count) { + Ok(value) => Ok(Some(value)), + Err(_) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "face_count", + value: format!("{}", face_count), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade + | RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(u32::MAX)), + }, + } +} + +/// Map a mesh output type onto the Hunyuan 2.x endpoints' `textured_mesh` +/// flag. Those endpoints only produce textured or white (geometry-only) +/// meshes: `Normal` (or unset) maps to textured, `Geometry` to white, and +/// `LowPoly` rejects under `ErrorOut` or upgrades to textured otherwise. +pub(crate) fn plan_textured_mesh( + mesh_output_type: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + match mesh_output_type { + None | Some(CommonMeshOutputType::Normal) => Ok(Some(true)), + Some(CommonMeshOutputType::Geometry) => Ok(Some(false)), + Some(CommonMeshOutputType::LowPoly) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "mesh_output_type", + value: format!("{:?}", CommonMeshOutputType::LowPoly), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade + | RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(true)), + }, + } +} + +fn image_ref_to_url(image_ref: ImageRef) -> Result { + match image_ref { + ImageRef::Url(url) => Ok(url), + ImageRef::MediaFileToken(_) => { + Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)) + } + } +} + +#[cfg(test)] +mod tests { + use tokens::tokens::media_files::MediaFileToken; + + use super::*; + + mod primary_image_tests { + use super::*; + + #[test] + fn none_resolves_to_none() { + let result = plan_primary_image_url(None, None, RequestMismatchMitigationStrategy::ErrorOut) + .expect("plan"); + assert!(result.is_none()); + } + + #[test] + fn single_reference_url_is_used() { + let refs = Some(ImageListRef::Urls(vec!["https://example.com/a.png".to_string()])); + let result = plan_primary_image_url(refs, None, RequestMismatchMitigationStrategy::ErrorOut) + .expect("plan"); + assert_eq!(result.as_deref(), Some("https://example.com/a.png")); + } + + #[test] + fn front_image_is_used_when_no_references() { + let front = Some(ImageRef::Url("https://example.com/front.png".to_string())); + let result = plan_primary_image_url(None, front, RequestMismatchMitigationStrategy::ErrorOut) + .expect("plan"); + assert_eq!(result.as_deref(), Some("https://example.com/front.png")); + } + + #[test] + fn multiple_candidates_error_out() { + let refs = Some(ImageListRef::Urls(vec!["https://example.com/a.png".to_string()])); + let front = Some(ImageRef::Url("https://example.com/front.png".to_string())); + let result = plan_primary_image_url(refs, front, RequestMismatchMitigationStrategy::ErrorOut); + assert!(result.is_err()); + } + + #[test] + fn multiple_candidates_keep_the_first_under_lenient_strategies() { + let refs = Some(ImageListRef::Urls(vec![ + "https://example.com/a.png".to_string(), + "https://example.com/b.png".to_string(), + ])); + let result = plan_primary_image_url(refs, None, RequestMismatchMitigationStrategy::PayMoreUpgrade) + .expect("plan"); + assert_eq!(result.as_deref(), Some("https://example.com/a.png")); + } + + #[test] + fn media_tokens_are_rejected() { + let refs = Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_test123".to_string()), + ])); + let result = plan_primary_image_url(refs, None, RequestMismatchMitigationStrategy::PayMoreUpgrade); + assert!(matches!( + result, + Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)) + )); + } + + #[test] + fn empty_token_list_resolves_to_none() { + let refs = Some(ImageListRef::MediaFileTokens(vec![])); + let result = plan_primary_image_url(refs, None, RequestMismatchMitigationStrategy::ErrorOut) + .expect("plan"); + assert!(result.is_none()); + } + } + + mod face_count_tests { + use super::*; + + #[test] + fn in_range_passes_through() { + let result = plan_face_count(Some(500_000), RequestMismatchMitigationStrategy::ErrorOut) + .expect("plan"); + assert_eq!(result, Some(500_000)); + } + + #[test] + fn overflow_errors_out() { + let result = plan_face_count(Some(u64::MAX), RequestMismatchMitigationStrategy::ErrorOut); + assert!(result.is_err()); + } + + #[test] + fn overflow_clamps_under_lenient_strategies() { + let result = plan_face_count(Some(u64::MAX), RequestMismatchMitigationStrategy::PayLessDowngrade) + .expect("plan"); + assert_eq!(result, Some(u32::MAX)); + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/mod.rs new file mode 100644 index 0000000000..4418b658ee --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/mod.rs @@ -0,0 +1,3 @@ +pub mod artcraft; +pub mod fal; +pub(crate) mod reject_unsupported; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/reject_unsupported.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/reject_unsupported.rs new file mode 100644 index 0000000000..77dd3e347e --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_mesh/providers/reject_unsupported.rs @@ -0,0 +1,44 @@ +//! Shared helpers for rejecting builder options that a model does not support. +//! +//! Per the request-mismatch mitigation strategy: `ErrorOut` rejects the +//! request when an unsupported option is set; the other strategies silently +//! drop the option. + +use std::fmt::Debug; + +use crate::api::image_ref::ImageRef; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; + +/// Reject (under `ErrorOut`) or silently drop a set option the model doesn't +/// support. +pub(crate) fn reject_unsupported_option( + field: &'static str, + value: Option<&T>, + strategy: RequestMismatchMitigationStrategy, +) -> Result<(), ArtcraftRouterError> { + let Some(value) = value else { + return Ok(()); + }; + match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field, + value: format!("{:?}", value), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade + | RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(()), + } +} + +/// Reject (under `ErrorOut`) or silently drop a single side-view image for +/// models/endpoints that take none. +pub(crate) fn reject_unsupported_image_ref( + field: &'static str, + image_ref: Option<&ImageRef>, + strategy: RequestMismatchMitigationStrategy, +) -> Result<(), ArtcraftRouterError> { + reject_unsupported_option(field, image_ref, strategy) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/cost/artcraft/estimate_splat_cost_artcraft_marble_0p1_mini.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/cost/artcraft/estimate_splat_cost_artcraft_marble_0p1_mini.rs index 34da23c64c..2e56175b44 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/cost/artcraft/estimate_splat_cost_artcraft_marble_0p1_mini.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/cost/artcraft/estimate_splat_cost_artcraft_marble_0p1_mini.rs @@ -3,6 +3,7 @@ use crate::generate::generate_splat::splat_generation_cost_estimate::SplatGenera use worldlabs_api_client::api::api_types::world_labs_model::WorldLabsModel; use worldlabs_api_client::pricing::check_pricing::{calculate_cost, InputType}; +#[allow(deprecated)] // legacy marble 0.1 pricing intentionally uses the deprecated variants pub(crate) fn estimate_splat_cost_artcraft_marble_0p1_mini( plan: &PlanArtcraftMarble0p1Mini, ) -> SplatGenerationCostEstimate { diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/cost/artcraft/estimate_splat_cost_artcraft_marble_0p1_plus.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/cost/artcraft/estimate_splat_cost_artcraft_marble_0p1_plus.rs index 5b5be1f770..4c6090f624 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/cost/artcraft/estimate_splat_cost_artcraft_marble_0p1_plus.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/cost/artcraft/estimate_splat_cost_artcraft_marble_0p1_plus.rs @@ -3,6 +3,7 @@ use crate::generate::generate_splat::splat_generation_cost_estimate::SplatGenera use worldlabs_api_client::api::api_types::world_labs_model::WorldLabsModel; use worldlabs_api_client::pricing::check_pricing::{calculate_cost, InputType}; +#[allow(deprecated)] // legacy marble 0.1 pricing intentionally uses the deprecated variants pub(crate) fn estimate_splat_cost_artcraft_marble_0p1_plus( plan: &PlanArtcraftMarble0p1Plus, ) -> SplatGenerationCostEstimate { diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/execute/artcraft/generate_splat_artcraft_marble_0p1_mini.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/execute/artcraft/generate_splat_artcraft_marble_0p1_mini.rs index 20b0660f24..3f870f02fd 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/execute/artcraft/generate_splat_artcraft_marble_0p1_mini.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/execute/artcraft/generate_splat_artcraft_marble_0p1_mini.rs @@ -27,6 +27,7 @@ pub async fn execute_artcraft_marble_0p1_mini( .map_err(|err| ArtcraftRouterError::Provider(ProviderError::Storyteller(err)))?; Ok(GenerateSplatResponse::Artcraft(ArtcraftSplatResponsePayload { - inference_job_token: response.inference_job_token, + inference_job_token: response.inference_job_token.clone(), + all_inference_job_tokens: vec![response.inference_job_token], })) } diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/execute/artcraft/generate_splat_artcraft_marble_0p1_plus.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/execute/artcraft/generate_splat_artcraft_marble_0p1_plus.rs index 136a180b37..732c4e597c 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/execute/artcraft/generate_splat_artcraft_marble_0p1_plus.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/execute/artcraft/generate_splat_artcraft_marble_0p1_plus.rs @@ -27,6 +27,7 @@ pub async fn execute_artcraft_marble_0p1_plus( .map_err(|err| ArtcraftRouterError::Provider(ProviderError::Storyteller(err)))?; Ok(GenerateSplatResponse::Artcraft(ArtcraftSplatResponsePayload { - inference_job_token: response.inference_job_token, + inference_job_token: response.inference_job_token.clone(), + all_inference_job_tokens: vec![response.inference_job_token], })) } diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/generate_splat_request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/generate_splat_request.rs index c1f5f24882..58f96e9829 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/generate_splat_request.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/generate_splat_request.rs @@ -37,6 +37,11 @@ impl GenerateSplatRequest { match self.model { RouterSplatModel::Marble0p1Mini => plan_generate_splat_artcraft_marble_0p1_mini(self), RouterSplatModel::Marble0p1Plus => plan_generate_splat_artcraft_marble_0p1_plus(self), + // The marble 1.x models are only supported by the new + // `GenerateSplatRequestBuilder::build2()` pipeline. + _ => Err(ArtcraftRouterError::UnsupportedModel( + format!("Splat generation for model `{:?}` is not supported by the legacy plan API; use GenerateSplatRequestBuilder::build2()", self.model) + )), } } diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/generate_splat_request_builder.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/generate_splat_request_builder.rs new file mode 100644 index 0000000000..32778cfadd --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/generate_splat_request_builder.rs @@ -0,0 +1,299 @@ +use crate::api::image_list_ref::ImageListRef; +use crate::api::router_provider::RouterProvider; +use crate::api::router_splat_model::RouterSplatModel; +use crate::api::video_ref::VideoRef; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_splat::providers::artcraft::marble_1p0::build::build_artcraft_marble_1p0; +use crate::generate::generate_splat::providers::artcraft::marble_1p0_draft::build::build_artcraft_marble_1p0_draft; +use crate::generate::generate_splat::providers::artcraft::marble_1p1::build::build_artcraft_marble_1p1; +use crate::generate::generate_splat::providers::artcraft::marble_1p1_plus::build::build_artcraft_marble_1p1_plus; +use crate::generate::generate_splat::providers::worldlabs::marble_1p0::build::build_worldlabs_marble_1p0; +use crate::generate::generate_splat::providers::worldlabs::marble_1p0_draft::build::build_worldlabs_marble_1p0_draft; +use crate::generate::generate_splat::providers::worldlabs::marble_1p1::build::build_worldlabs_marble_1p1; +use crate::generate::generate_splat::providers::worldlabs::marble_1p1_plus::build::build_worldlabs_marble_1p1_plus; +use crate::generate::generate_splat::splat_generation_draft_or_request::SplatGenerationDraftOrRequest; + +/// RouterProvider-agnostic splat (gaussian world) generation request. +/// Distilled by `build2()` into a `SplatGenerationDraftOrRequest` for the +/// selected (provider, model) pair. +/// +/// NB: The deprecated marble 0.1 models are treated as their marble 1.x +/// successors (`Marble0p1Mini` → `Marble1p0Draft`, `Marble0p1Plus` → +/// `Marble1p0`). +#[derive(Clone, Debug)] +pub struct GenerateSplatRequestBuilder { + /// Which model to use. + pub model: RouterSplatModel, + + /// Which provider to use. + pub provider: RouterProvider, + + /// The text prompt for the splat generation. + pub prompt: Option, + + /// Reference images (optional). + /// One image generates from a single view; multiple images are treated as + /// multi-view input of the same world. + pub reference_images: Option, + + /// Reference video (optional). Cannot be combined with reference images. + pub reference_video: Option, + + /// Whether the single reference image is a 360-degree panorama. + pub is_panoramic: Option, + + /// Whether to disable prompt recaptioning. + pub disable_recaption: Option, + + /// If the request is a mismatch with the (model/provider), how to mitigate it. + pub request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy, + + /// Some providers support idempotency. + /// If not supplied, we'll generate one for the required providers. + pub idempotency_token: Option, +} + +impl Default for GenerateSplatRequestBuilder { + fn default() -> Self { + Self { + model: RouterSplatModel::Marble1p0Draft, + provider: RouterProvider::Artcraft, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade, + prompt: None, + reference_images: None, + reference_video: None, + is_panoramic: None, + disable_recaption: None, + idempotency_token: None, + } + } +} + +impl GenerateSplatRequestBuilder { + + pub fn build2(self) -> Result { + match (self.provider, self.model) { + // Artcraft + (RouterProvider::Artcraft, RouterSplatModel::Marble1p0 | RouterSplatModel::Marble0p1Plus) => build_artcraft_marble_1p0(self), + (RouterProvider::Artcraft, RouterSplatModel::Marble1p0Draft | RouterSplatModel::Marble0p1Mini) => build_artcraft_marble_1p0_draft(self), + (RouterProvider::Artcraft, RouterSplatModel::Marble1p1) => build_artcraft_marble_1p1(self), + (RouterProvider::Artcraft, RouterSplatModel::Marble1p1Plus) => build_artcraft_marble_1p1_plus(self), + // World Labs + (RouterProvider::WorldLabs, RouterSplatModel::Marble1p0 | RouterSplatModel::Marble0p1Plus) => build_worldlabs_marble_1p0(self), + (RouterProvider::WorldLabs, RouterSplatModel::Marble1p0Draft | RouterSplatModel::Marble0p1Mini) => build_worldlabs_marble_1p0_draft(self), + (RouterProvider::WorldLabs, RouterSplatModel::Marble1p1) => build_worldlabs_marble_1p1(self), + (RouterProvider::WorldLabs, RouterSplatModel::Marble1p1Plus) => build_worldlabs_marble_1p1_plus(self), + _ => self.unsupported_provider_and_model(), + } + } + + pub fn get_or_generate_idempotency_token(&self) -> String { + self.idempotency_token.clone() + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()) + } + + fn unsupported_provider_and_model(&self) -> Result { + Err(ArtcraftRouterError::UnsupportedProviderAndModelForNewApi( + format!("Splat generation for model `{:?}` is not supported for provider {:?}", self.model, self.provider) + )) + } +} + +#[cfg(test)] +mod tests { + use tokens::tokens::media_files::MediaFileToken; + + use crate::generate::generate_splat::splat_generation_draft::SplatGenerationDraftRequest; + use crate::generate::generate_splat::splat_generation_request::SplatGenerationRequest; + + use super::*; + + const IMAGE_URL: &str = "https://example.com/room.png"; + + mod artcraft_dispatch_tests { + use super::*; + + #[test] + fn all_models_dispatch_to_requests() { + let cases = [ + RouterSplatModel::Marble1p0, + RouterSplatModel::Marble1p0Draft, + RouterSplatModel::Marble1p1, + RouterSplatModel::Marble1p1Plus, + ]; + for model in cases { + let result = artcraft_builder(model).build2() + .unwrap_or_else(|e| panic!("build should succeed for {model:?}: {e}")); + let request = expect_request(result); + let matches = matches!( + (model, &request), + (RouterSplatModel::Marble1p0, SplatGenerationRequest::ArtcraftMarble1p0(_)) + | (RouterSplatModel::Marble1p0Draft, SplatGenerationRequest::ArtcraftMarble1p0Draft(_)) + | (RouterSplatModel::Marble1p1, SplatGenerationRequest::ArtcraftMarble1p1(_)) + | (RouterSplatModel::Marble1p1Plus, SplatGenerationRequest::ArtcraftMarble1p1Plus(_)) + ); + assert!(matches, "unexpected dispatch for {model:?}: {request:?}"); + } + } + + #[test] + fn legacy_marble_0p1_mini_is_treated_as_marble_1p0_draft() { + let result = artcraft_builder(RouterSplatModel::Marble0p1Mini).build2().expect("build"); + assert!(matches!( + expect_request(result), + SplatGenerationRequest::ArtcraftMarble1p0Draft(_) + )); + } + + #[test] + fn legacy_marble_0p1_plus_is_treated_as_marble_1p0() { + let result = artcraft_builder(RouterSplatModel::Marble0p1Plus).build2().expect("build"); + assert!(matches!( + expect_request(result), + SplatGenerationRequest::ArtcraftMarble1p0(_) + )); + } + } + + mod worldlabs_dispatch_tests { + use super::*; + + #[test] + fn text_prompts_dispatch_to_direct_requests() { + let cases = [ + RouterSplatModel::Marble1p0, + RouterSplatModel::Marble1p0Draft, + RouterSplatModel::Marble1p1, + RouterSplatModel::Marble1p1Plus, + ]; + for model in cases { + let result = worldlabs_text_builder(model).build2() + .unwrap_or_else(|e| panic!("build should succeed for {model:?}: {e}")); + let request = expect_request(result); + let matches = matches!( + (model, &request), + (RouterSplatModel::Marble1p0, SplatGenerationRequest::WorldLabsMarble1p0(_)) + | (RouterSplatModel::Marble1p0Draft, SplatGenerationRequest::WorldLabsMarble1p0Draft(_)) + | (RouterSplatModel::Marble1p1, SplatGenerationRequest::WorldLabsMarble1p1(_)) + | (RouterSplatModel::Marble1p1Plus, SplatGenerationRequest::WorldLabsMarble1p1Plus(_)) + ); + assert!(matches, "unexpected dispatch for {model:?}: {request:?}"); + } + } + + #[test] + fn media_inputs_dispatch_to_drafts() { + let cases = [ + RouterSplatModel::Marble1p0, + RouterSplatModel::Marble1p0Draft, + RouterSplatModel::Marble1p1, + RouterSplatModel::Marble1p1Plus, + ]; + for model in cases { + let builder = GenerateSplatRequestBuilder { + reference_images: Some(ImageListRef::Urls(vec![IMAGE_URL.to_string()])), + ..worldlabs_text_builder(model) + }; + let result = builder.build2() + .unwrap_or_else(|e| panic!("build should succeed for {model:?}: {e}")); + let draft = expect_draft(result); + let matches = matches!( + (model, &draft), + (RouterSplatModel::Marble1p0, SplatGenerationDraftRequest::WorldLabsMarble1p0(_)) + | (RouterSplatModel::Marble1p0Draft, SplatGenerationDraftRequest::WorldLabsMarble1p0Draft(_)) + | (RouterSplatModel::Marble1p1, SplatGenerationDraftRequest::WorldLabsMarble1p1(_)) + | (RouterSplatModel::Marble1p1Plus, SplatGenerationDraftRequest::WorldLabsMarble1p1Plus(_)) + ); + assert!(matches, "unexpected dispatch for {model:?}: {draft:?}"); + } + } + + #[test] + fn legacy_models_are_treated_as_their_successors() { + let mini = worldlabs_text_builder(RouterSplatModel::Marble0p1Mini).build2().expect("build"); + assert!(matches!( + expect_request(mini), + SplatGenerationRequest::WorldLabsMarble1p0Draft(_) + )); + + let plus = worldlabs_text_builder(RouterSplatModel::Marble0p1Plus).build2().expect("build"); + assert!(matches!( + expect_request(plus), + SplatGenerationRequest::WorldLabsMarble1p0(_) + )); + } + + #[test] + fn media_token_inputs_dispatch_to_drafts() { + let builder = GenerateSplatRequestBuilder { + prompt: None, + reference_images: Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_test123".to_string()), + ])), + ..worldlabs_text_builder(RouterSplatModel::Marble1p0) + }; + let result = builder.build2().expect("build"); + assert!(matches!(result, SplatGenerationDraftOrRequest::Draft(_))); + } + } + + mod unsupported_combo_tests { + use super::*; + + #[test] + fn non_splat_providers_are_unsupported() { + for provider in [ + RouterProvider::Fal, + RouterProvider::GmiCloud, + RouterProvider::GrokApi, + RouterProvider::Seedance2Pro, + ] { + let result = GenerateSplatRequestBuilder { + provider, + model: RouterSplatModel::Marble1p0, + prompt: Some("a cozy cabin".to_string()), + ..Default::default() + }.build2(); + assert!( + matches!(result, Err(ArtcraftRouterError::UnsupportedProviderAndModelForNewApi(_))), + "expected unsupported error for {provider:?}", + ); + } + } + } + + // ── Helpers ── + + fn artcraft_builder(model: RouterSplatModel) -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + provider: RouterProvider::Artcraft, + model, + prompt: Some("a cozy cabin in the snowy mountains".to_string()), + ..Default::default() + } + } + + fn worldlabs_text_builder(model: RouterSplatModel) -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + provider: RouterProvider::WorldLabs, + model, + prompt: Some("a cozy cabin in the snowy mountains".to_string()), + ..Default::default() + } + } + + fn expect_request(result: SplatGenerationDraftOrRequest) -> SplatGenerationRequest { + match result { + SplatGenerationDraftOrRequest::Request(request) => request, + SplatGenerationDraftOrRequest::Draft(draft) => panic!("expected Request, got Draft: {draft:?}"), + } + } + + fn expect_draft(result: SplatGenerationDraftOrRequest) -> SplatGenerationDraftRequest { + match result { + SplatGenerationDraftOrRequest::Draft(draft) => draft, + SplatGenerationDraftOrRequest::Request(request) => panic!("expected Draft, got Request: {request:?}"), + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/generate_splat_response.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/generate_splat_response.rs index bc770a3dc7..16895a8b21 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/generate_splat_response.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/generate_splat_response.rs @@ -3,17 +3,34 @@ use tokens::tokens::generic_inference_jobs::InferenceJobToken; #[derive(Clone, Debug)] pub struct ArtcraftSplatResponsePayload { pub inference_job_token: InferenceJobToken, + pub all_inference_job_tokens: Vec, +} + +#[derive(Clone, Debug)] +pub struct WorldLabsSplatResponsePayload { + /// The World Labs operation ID; poll it for generation status. + pub operation_id: String, + pub done: bool, } #[derive(Clone, Debug)] pub enum GenerateSplatResponse { Artcraft(ArtcraftSplatResponsePayload), + WorldLabs(WorldLabsSplatResponsePayload), } impl GenerateSplatResponse { pub fn get_artcraft_payload(&self) -> Option { match self { Self::Artcraft(p) => Some(p.clone()), + _ => None, + } + } + + pub fn get_worldlabs_payload(&self) -> Option { + match self { + Self::WorldLabs(p) => Some(p.clone()), + _ => None, } } } diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/mod.rs index 9419c6951b..b0acb8e9ca 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/mod.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/mod.rs @@ -1,7 +1,18 @@ +// New audio-style pipeline (marble 1.x; use `GenerateSplatRequestBuilder::build2()`). +pub mod generate_splat_request_builder; +pub mod providers; +pub mod splat_generation_draft; +pub mod splat_generation_draft_context; +pub mod splat_generation_draft_or_request; +pub mod splat_generation_request; + +// Shared between the new and legacy pipelines. +pub mod generate_splat_response; +pub mod splat_generation_cost_estimate; + +// Legacy plan-based pipeline (marble 0.1 only; still used by the desktop app). pub mod cost; pub mod execute; pub mod generate_splat_request; -pub mod generate_splat_response; pub mod plan; -pub mod splat_generation_cost_estimate; pub mod splat_generation_plan; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/build_common.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/build_common.rs new file mode 100644 index 0000000000..29661b4d1f --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/build_common.rs @@ -0,0 +1,115 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_splat_cost_and_generate_request::OmniGenSplatCostAndGenerateRequest; +use enums::common::generation::common_splat_model::CommonSplatModel as CommonSplatModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_splat::generate_splat_request_builder::GenerateSplatRequestBuilder; +use crate::generate::generate_splat::providers::artcraft::resolve::{ + resolve_image_list_ref, resolve_video_ref, +}; + +/// Build an `OmniGenSplatCostAndGenerateRequest` from the builder. The +/// Artcraft provider is a thin forwarding shim: the storyteller-web omni +/// splat endpoint owns per-model validation and option mapping, so the +/// builder's options are passed through as-is. +pub fn build_artcraft_omni_splat_request( + mut builder: GenerateSplatRequestBuilder, + model: CommonSplatModelEnum, +) -> Result { + let reference_image_media_tokens = resolve_image_list_ref(builder.reference_images.take())?; + let reference_video_media_token = resolve_video_ref(builder.reference_video.take())?; + let idempotency_token = builder.get_or_generate_idempotency_token(); + + Ok(OmniGenSplatCostAndGenerateRequest { + idempotency_token: Some(idempotency_token), + model: Some(model), + prompt: builder.prompt.take(), + reference_image_media_tokens, + reference_video_media_token, + is_panoramic: builder.is_panoramic, + disable_recaption: builder.disable_recaption, + }) +} + +#[cfg(test)] +mod tests { + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_provider::RouterProvider; + use crate::api::router_splat_model::RouterSplatModel; + use crate::api::video_ref::VideoRef; + use crate::errors::client_error::ClientError; + + use super::*; + + #[test] + fn all_fields_are_forwarded() { + let builder = GenerateSplatRequestBuilder { + prompt: Some("a cozy cabin".to_string()), + reference_images: Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_image".to_string()), + ])), + reference_video: Some(VideoRef::MediaFileToken(MediaFileToken::new("mf_video".to_string()))), + is_panoramic: Some(true), + disable_recaption: Some(true), + idempotency_token: Some("test-token".to_string()), + ..base_builder() + }; + + let request = build_artcraft_omni_splat_request(builder, CommonSplatModelEnum::Marble1p0) + .expect("build"); + + assert_eq!(request.idempotency_token.as_deref(), Some("test-token")); + assert_eq!(request.model, Some(CommonSplatModelEnum::Marble1p0)); + assert_eq!(request.prompt.as_deref(), Some("a cozy cabin")); + assert_eq!(request.reference_image_media_tokens.as_ref().map(|t| t.len()), Some(1)); + assert_eq!(request.reference_video_media_token.as_ref().map(|t| t.as_str()), Some("mf_video")); + assert_eq!(request.is_panoramic, Some(true)); + assert_eq!(request.disable_recaption, Some(true)); + } + + #[test] + fn idempotency_token_is_generated_when_missing() { + let request = build_artcraft_omni_splat_request(base_builder(), CommonSplatModelEnum::Marble1p0Draft) + .expect("build"); + assert!(request.idempotency_token.is_some()); + assert!(!request.idempotency_token.unwrap().is_empty()); + } + + #[test] + fn image_url_references_are_rejected() { + let builder = GenerateSplatRequestBuilder { + reference_images: Some(ImageListRef::Urls(vec!["https://example.com/a.png".to_string()])), + ..base_builder() + }; + let result = build_artcraft_omni_splat_request(builder, CommonSplatModelEnum::Marble1p0); + assert!(matches!( + result, + Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens)) + )); + } + + #[test] + fn video_url_references_are_rejected() { + let builder = GenerateSplatRequestBuilder { + reference_video: Some(VideoRef::Url("https://example.com/a.mp4".to_string())), + ..base_builder() + }; + let result = build_artcraft_omni_splat_request(builder, CommonSplatModelEnum::Marble1p0); + assert!(matches!( + result, + Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens)) + )); + } + + // ── Helpers ── + + fn base_builder() -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + model: RouterSplatModel::Marble1p0, + provider: RouterProvider::Artcraft, + prompt: Some("a cozy cabin".to_string()), + ..Default::default() + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/cost_common.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/cost_common.rs new file mode 100644 index 0000000000..26e631e483 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/cost_common.rs @@ -0,0 +1,64 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_splat_cost_and_generate_request::OmniGenSplatCostAndGenerateRequest; +use worldlabs_api_client::pricing::check_pricing::InputType; + +use crate::generate::generate_splat::splat_generation_cost_estimate::SplatGenerationCostEstimate; + +/// Flat prices for a splat model by input type, in USD cents. +#[derive(Copy, Clone, Debug)] +pub(crate) struct ArtcraftSplatPriceTable { + pub image_panorama: u64, + pub text: u64, + pub image_non_panorama: u64, + pub multi_image: u64, + pub video: u64, +} + +impl ArtcraftSplatPriceTable { + pub(crate) fn cost_in_usd_cents(&self, input_type: InputType) -> u64 { + match input_type { + InputType::ImagePanorama => self.image_panorama, + InputType::Text => self.text, + InputType::ImageNonPanorama => self.image_non_panorama, + InputType::MultiImage => self.multi_image, + InputType::Video => self.video, + } + } +} + +/// Derive the pricing input type from a forwarded omni splat request. +/// Mirrors the request-assembly derivation: video wins over images, two or +/// more images are multi-image, one image is panorama/non-panorama, and +/// anything else prices as text. +pub(crate) fn derive_input_type_for_pricing( + request: &OmniGenSplatCostAndGenerateRequest, +) -> InputType { + let image_count = request.reference_image_media_tokens.as_ref() + .map(|tokens| tokens.len()) + .unwrap_or(0); + + if request.reference_video_media_token.is_some() { + InputType::Video + } else if image_count >= 2 { + InputType::MultiImage + } else if image_count == 1 { + if request.is_panoramic == Some(true) { + InputType::ImagePanorama + } else { + InputType::ImageNonPanorama + } + } else { + InputType::Text + } +} + +pub(crate) fn artcraft_splat_cost_estimate(cost_in_usd_cents: u64) -> SplatGenerationCostEstimate { + SplatGenerationCostEstimate { + cost_in_credits: Some(cost_in_usd_cents), + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p0/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p0/build.rs new file mode 100644 index 0000000000..52868a60dc --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p0/build.rs @@ -0,0 +1,14 @@ +use enums::common::generation::common_splat_model::CommonSplatModel as CommonSplatModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_splat::generate_splat_request_builder::GenerateSplatRequestBuilder; +use crate::generate::generate_splat::providers::artcraft::build_common::build_artcraft_omni_splat_request; +use crate::generate::generate_splat::providers::artcraft::marble_1p0::request::ArtcraftMarble1p0RequestState; +use crate::generate::generate_splat::splat_generation_draft_or_request::SplatGenerationDraftOrRequest; +use crate::generate::generate_splat::splat_generation_request::SplatGenerationRequest; + +pub fn build_artcraft_marble_1p0(builder: GenerateSplatRequestBuilder) -> Result { + let request = build_artcraft_omni_splat_request(builder, CommonSplatModelEnum::Marble1p0)?; + let state = ArtcraftMarble1p0RequestState { request }; + Ok(SplatGenerationDraftOrRequest::Request(SplatGenerationRequest::ArtcraftMarble1p0(state))) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p0/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p0/cost.rs new file mode 100644 index 0000000000..1d498e5fc3 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p0/cost.rs @@ -0,0 +1,120 @@ +use worldlabs_api_client::pricing::check_pricing::InputType; + +use crate::generate::generate_splat::providers::artcraft::cost_common::{ + artcraft_splat_cost_estimate, derive_input_type_for_pricing, ArtcraftSplatPriceTable, +}; +use crate::generate::generate_splat::providers::artcraft::marble_1p0::request::ArtcraftMarble1p0RequestState; +use crate::generate::generate_splat::splat_generation_cost_estimate::SplatGenerationCostEstimate; + +const PRICES: ArtcraftSplatPriceTable = ArtcraftSplatPriceTable { + image_panorama: 156, + text: 165, + image_non_panorama: 165, + multi_image: 167, + video: 167, +}; + +/// Marble 1.0 via Artcraft is flat priced by input type. +#[derive(Clone, Debug)] +pub struct ArtcraftMarble1p0CostState { + pub input_type: InputType, +} + +impl ArtcraftMarble1p0CostState { + pub fn from_request(request: &ArtcraftMarble1p0RequestState) -> Self { + Self { input_type: derive_input_type_for_pricing(&request.request) } + } + + pub fn estimate_cost(&self) -> SplatGenerationCostEstimate { + artcraft_splat_cost_estimate(PRICES.cost_in_usd_cents(self.input_type)) + } +} + +#[cfg(test)] +mod tests { + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_provider::RouterProvider; + use crate::api::router_splat_model::RouterSplatModel; + use crate::api::video_ref::VideoRef; + use crate::generate::generate_splat::generate_splat_request_builder::GenerateSplatRequestBuilder; + + #[test] + fn text_is_165_cents() { + assert_eq!(estimate_usd_cents(text_builder()), 165); + } + + #[test] + fn single_image_is_165_cents() { + assert_eq!(estimate_usd_cents(single_image_builder(false)), 165); + } + + #[test] + fn panoramic_image_is_156_cents() { + assert_eq!(estimate_usd_cents(single_image_builder(true)), 156); + } + + #[test] + fn multi_image_is_167_cents() { + assert_eq!(estimate_usd_cents(multi_image_builder()), 167); + } + + #[test] + fn video_is_167_cents() { + assert_eq!(estimate_usd_cents(video_builder()), 167); + } + + // ── Helpers ── + + fn base_builder() -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + model: RouterSplatModel::Marble1p0, + provider: RouterProvider::Artcraft, + ..Default::default() + } + } + + fn text_builder() -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + prompt: Some("a cozy cabin in the snowy mountains".to_string()), + ..base_builder() + } + } + + fn single_image_builder(is_panoramic: bool) -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + reference_images: Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_image_1".to_string()), + ])), + is_panoramic: Some(is_panoramic), + ..base_builder() + } + } + + fn multi_image_builder() -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + reference_images: Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_image_1".to_string()), + MediaFileToken::new("mf_image_2".to_string()), + ])), + ..base_builder() + } + } + + fn video_builder() -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + reference_video: Some(VideoRef::MediaFileToken(MediaFileToken::new("mf_video".to_string()))), + ..base_builder() + } + } + + fn estimate_usd_cents(builder: GenerateSplatRequestBuilder) -> u64 { + builder.build2() + .expect("build should succeed") + .estimate_cost() + .expect("estimate should succeed") + .cost_in_usd_cents + .expect("cost should be present") + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p0/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p0/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p0/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p0/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p0/request.rs new file mode 100644 index 0000000000..eab8286a2b --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p0/request.rs @@ -0,0 +1,17 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_splat_cost_and_generate_request::OmniGenSplatCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_splat::generate_splat_response::GenerateSplatResponse; +use crate::generate::generate_splat::providers::artcraft::request_common::send_artcraft_omni_splat_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftMarble1p0RequestState { + pub request: OmniGenSplatCostAndGenerateRequest, +} + +impl ArtcraftMarble1p0RequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_splat_request(&self.request, client).await + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p0_draft/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p0_draft/build.rs new file mode 100644 index 0000000000..c1b457d77c --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p0_draft/build.rs @@ -0,0 +1,14 @@ +use enums::common::generation::common_splat_model::CommonSplatModel as CommonSplatModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_splat::generate_splat_request_builder::GenerateSplatRequestBuilder; +use crate::generate::generate_splat::providers::artcraft::build_common::build_artcraft_omni_splat_request; +use crate::generate::generate_splat::providers::artcraft::marble_1p0_draft::request::ArtcraftMarble1p0DraftRequestState; +use crate::generate::generate_splat::splat_generation_draft_or_request::SplatGenerationDraftOrRequest; +use crate::generate::generate_splat::splat_generation_request::SplatGenerationRequest; + +pub fn build_artcraft_marble_1p0_draft(builder: GenerateSplatRequestBuilder) -> Result { + let request = build_artcraft_omni_splat_request(builder, CommonSplatModelEnum::Marble1p0Draft)?; + let state = ArtcraftMarble1p0DraftRequestState { request }; + Ok(SplatGenerationDraftOrRequest::Request(SplatGenerationRequest::ArtcraftMarble1p0Draft(state))) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p0_draft/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p0_draft/cost.rs new file mode 100644 index 0000000000..f416506e13 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p0_draft/cost.rs @@ -0,0 +1,120 @@ +use worldlabs_api_client::pricing::check_pricing::InputType; + +use crate::generate::generate_splat::providers::artcraft::cost_common::{ + artcraft_splat_cost_estimate, derive_input_type_for_pricing, ArtcraftSplatPriceTable, +}; +use crate::generate::generate_splat::providers::artcraft::marble_1p0_draft::request::ArtcraftMarble1p0DraftRequestState; +use crate::generate::generate_splat::splat_generation_cost_estimate::SplatGenerationCostEstimate; + +const PRICES: ArtcraftSplatPriceTable = ArtcraftSplatPriceTable { + image_panorama: 16, + text: 24, + image_non_panorama: 24, + multi_image: 26, + video: 26, +}; + +/// Marble 1.0 Draft via Artcraft is flat priced by input type. +#[derive(Clone, Debug)] +pub struct ArtcraftMarble1p0DraftCostState { + pub input_type: InputType, +} + +impl ArtcraftMarble1p0DraftCostState { + pub fn from_request(request: &ArtcraftMarble1p0DraftRequestState) -> Self { + Self { input_type: derive_input_type_for_pricing(&request.request) } + } + + pub fn estimate_cost(&self) -> SplatGenerationCostEstimate { + artcraft_splat_cost_estimate(PRICES.cost_in_usd_cents(self.input_type)) + } +} + +#[cfg(test)] +mod tests { + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_provider::RouterProvider; + use crate::api::router_splat_model::RouterSplatModel; + use crate::api::video_ref::VideoRef; + use crate::generate::generate_splat::generate_splat_request_builder::GenerateSplatRequestBuilder; + + #[test] + fn text_is_24_cents() { + assert_eq!(estimate_usd_cents(text_builder()), 24); + } + + #[test] + fn single_image_is_24_cents() { + assert_eq!(estimate_usd_cents(single_image_builder(false)), 24); + } + + #[test] + fn panoramic_image_is_16_cents() { + assert_eq!(estimate_usd_cents(single_image_builder(true)), 16); + } + + #[test] + fn multi_image_is_26_cents() { + assert_eq!(estimate_usd_cents(multi_image_builder()), 26); + } + + #[test] + fn video_is_26_cents() { + assert_eq!(estimate_usd_cents(video_builder()), 26); + } + + // ── Helpers ── + + fn base_builder() -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + model: RouterSplatModel::Marble1p0Draft, + provider: RouterProvider::Artcraft, + ..Default::default() + } + } + + fn text_builder() -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + prompt: Some("a cozy cabin in the snowy mountains".to_string()), + ..base_builder() + } + } + + fn single_image_builder(is_panoramic: bool) -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + reference_images: Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_image_1".to_string()), + ])), + is_panoramic: Some(is_panoramic), + ..base_builder() + } + } + + fn multi_image_builder() -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + reference_images: Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_image_1".to_string()), + MediaFileToken::new("mf_image_2".to_string()), + ])), + ..base_builder() + } + } + + fn video_builder() -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + reference_video: Some(VideoRef::MediaFileToken(MediaFileToken::new("mf_video".to_string()))), + ..base_builder() + } + } + + fn estimate_usd_cents(builder: GenerateSplatRequestBuilder) -> u64 { + builder.build2() + .expect("build should succeed") + .estimate_cost() + .expect("estimate should succeed") + .cost_in_usd_cents + .expect("cost should be present") + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p0_draft/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p0_draft/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p0_draft/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p0_draft/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p0_draft/request.rs new file mode 100644 index 0000000000..424e49ebaa --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p0_draft/request.rs @@ -0,0 +1,58 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_splat_cost_and_generate_request::OmniGenSplatCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_splat::generate_splat_response::GenerateSplatResponse; +use crate::generate::generate_splat::providers::artcraft::request_common::send_artcraft_omni_splat_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftMarble1p0DraftRequestState { + pub request: OmniGenSplatCostAndGenerateRequest, +} + +impl ArtcraftMarble1p0DraftRequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_splat_request(&self.request, client).await + } +} + +#[cfg(test)] +mod tests { + use crate::api::router_provider::RouterProvider; + use crate::api::router_splat_model::RouterSplatModel; + use crate::generate::generate_splat::generate_splat_request_builder::GenerateSplatRequestBuilder; + use crate::generate::generate_splat::generate_splat_response::GenerateSplatResponse; + use crate::generate::generate_splat::splat_generation_draft_or_request::SplatGenerationDraftOrRequest; + use crate::test_helpers::get_artcraft_client; + + #[tokio::test] + #[ignore] // sends a real generation to the Artcraft backend, incurs cost + async fn text_to_world() { + let builder = GenerateSplatRequestBuilder { + model: RouterSplatModel::Marble1p0Draft, + provider: RouterProvider::Artcraft, + prompt: Some("A cozy cabin in the snowy mountains".to_string()), + ..Default::default() + }; + + let client = get_artcraft_client(); + let draft_or_request = builder.build2().expect("build2 should succeed"); + let request = match draft_or_request { + SplatGenerationDraftOrRequest::Request(r) => r, + _ => panic!("expected Request variant (Artcraft skips draft)"), + }; + + let response = request.send_request(&client).await.expect("send_request should succeed"); + + match &response { + GenerateSplatResponse::Artcraft(p) => { + println!("inference_job_token={:?}", p.inference_job_token); + println!("all_inference_job_tokens={:?}", p.all_inference_job_tokens); + } + other => println!("response: {:?}", other), + } + + assert!(matches!(response, GenerateSplatResponse::Artcraft(_))); + assert_eq!(1, 2, "Inspect output above"); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p1/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p1/build.rs new file mode 100644 index 0000000000..391aaa143c --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p1/build.rs @@ -0,0 +1,14 @@ +use enums::common::generation::common_splat_model::CommonSplatModel as CommonSplatModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_splat::generate_splat_request_builder::GenerateSplatRequestBuilder; +use crate::generate::generate_splat::providers::artcraft::build_common::build_artcraft_omni_splat_request; +use crate::generate::generate_splat::providers::artcraft::marble_1p1::request::ArtcraftMarble1p1RequestState; +use crate::generate::generate_splat::splat_generation_draft_or_request::SplatGenerationDraftOrRequest; +use crate::generate::generate_splat::splat_generation_request::SplatGenerationRequest; + +pub fn build_artcraft_marble_1p1(builder: GenerateSplatRequestBuilder) -> Result { + let request = build_artcraft_omni_splat_request(builder, CommonSplatModelEnum::Marble1p1)?; + let state = ArtcraftMarble1p1RequestState { request }; + Ok(SplatGenerationDraftOrRequest::Request(SplatGenerationRequest::ArtcraftMarble1p1(state))) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p1/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p1/cost.rs new file mode 100644 index 0000000000..8c22dc1d78 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p1/cost.rs @@ -0,0 +1,120 @@ +use worldlabs_api_client::pricing::check_pricing::InputType; + +use crate::generate::generate_splat::providers::artcraft::cost_common::{ + artcraft_splat_cost_estimate, derive_input_type_for_pricing, ArtcraftSplatPriceTable, +}; +use crate::generate::generate_splat::providers::artcraft::marble_1p1::request::ArtcraftMarble1p1RequestState; +use crate::generate::generate_splat::splat_generation_cost_estimate::SplatGenerationCostEstimate; + +const PRICES: ArtcraftSplatPriceTable = ArtcraftSplatPriceTable { + image_panorama: 156, + text: 165, + image_non_panorama: 165, + multi_image: 167, + video: 167, +}; + +/// Marble 1.1 via Artcraft is flat priced by input type. +#[derive(Clone, Debug)] +pub struct ArtcraftMarble1p1CostState { + pub input_type: InputType, +} + +impl ArtcraftMarble1p1CostState { + pub fn from_request(request: &ArtcraftMarble1p1RequestState) -> Self { + Self { input_type: derive_input_type_for_pricing(&request.request) } + } + + pub fn estimate_cost(&self) -> SplatGenerationCostEstimate { + artcraft_splat_cost_estimate(PRICES.cost_in_usd_cents(self.input_type)) + } +} + +#[cfg(test)] +mod tests { + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_provider::RouterProvider; + use crate::api::router_splat_model::RouterSplatModel; + use crate::api::video_ref::VideoRef; + use crate::generate::generate_splat::generate_splat_request_builder::GenerateSplatRequestBuilder; + + #[test] + fn text_is_165_cents() { + assert_eq!(estimate_usd_cents(text_builder()), 165); + } + + #[test] + fn single_image_is_165_cents() { + assert_eq!(estimate_usd_cents(single_image_builder(false)), 165); + } + + #[test] + fn panoramic_image_is_156_cents() { + assert_eq!(estimate_usd_cents(single_image_builder(true)), 156); + } + + #[test] + fn multi_image_is_167_cents() { + assert_eq!(estimate_usd_cents(multi_image_builder()), 167); + } + + #[test] + fn video_is_167_cents() { + assert_eq!(estimate_usd_cents(video_builder()), 167); + } + + // ── Helpers ── + + fn base_builder() -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + model: RouterSplatModel::Marble1p1, + provider: RouterProvider::Artcraft, + ..Default::default() + } + } + + fn text_builder() -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + prompt: Some("a cozy cabin in the snowy mountains".to_string()), + ..base_builder() + } + } + + fn single_image_builder(is_panoramic: bool) -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + reference_images: Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_image_1".to_string()), + ])), + is_panoramic: Some(is_panoramic), + ..base_builder() + } + } + + fn multi_image_builder() -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + reference_images: Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_image_1".to_string()), + MediaFileToken::new("mf_image_2".to_string()), + ])), + ..base_builder() + } + } + + fn video_builder() -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + reference_video: Some(VideoRef::MediaFileToken(MediaFileToken::new("mf_video".to_string()))), + ..base_builder() + } + } + + fn estimate_usd_cents(builder: GenerateSplatRequestBuilder) -> u64 { + builder.build2() + .expect("build should succeed") + .estimate_cost() + .expect("estimate should succeed") + .cost_in_usd_cents + .expect("cost should be present") + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p1/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p1/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p1/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p1/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p1/request.rs new file mode 100644 index 0000000000..3a311d0c71 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p1/request.rs @@ -0,0 +1,17 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_splat_cost_and_generate_request::OmniGenSplatCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_splat::generate_splat_response::GenerateSplatResponse; +use crate::generate::generate_splat::providers::artcraft::request_common::send_artcraft_omni_splat_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftMarble1p1RequestState { + pub request: OmniGenSplatCostAndGenerateRequest, +} + +impl ArtcraftMarble1p1RequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_splat_request(&self.request, client).await + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p1_plus/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p1_plus/build.rs new file mode 100644 index 0000000000..4b19546b41 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p1_plus/build.rs @@ -0,0 +1,14 @@ +use enums::common::generation::common_splat_model::CommonSplatModel as CommonSplatModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_splat::generate_splat_request_builder::GenerateSplatRequestBuilder; +use crate::generate::generate_splat::providers::artcraft::build_common::build_artcraft_omni_splat_request; +use crate::generate::generate_splat::providers::artcraft::marble_1p1_plus::request::ArtcraftMarble1p1PlusRequestState; +use crate::generate::generate_splat::splat_generation_draft_or_request::SplatGenerationDraftOrRequest; +use crate::generate::generate_splat::splat_generation_request::SplatGenerationRequest; + +pub fn build_artcraft_marble_1p1_plus(builder: GenerateSplatRequestBuilder) -> Result { + let request = build_artcraft_omni_splat_request(builder, CommonSplatModelEnum::Marble1p1Plus)?; + let state = ArtcraftMarble1p1PlusRequestState { request }; + Ok(SplatGenerationDraftOrRequest::Request(SplatGenerationRequest::ArtcraftMarble1p1Plus(state))) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p1_plus/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p1_plus/cost.rs new file mode 100644 index 0000000000..dd2ac330f0 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p1_plus/cost.rs @@ -0,0 +1,120 @@ +use worldlabs_api_client::pricing::check_pricing::InputType; + +use crate::generate::generate_splat::providers::artcraft::cost_common::{ + artcraft_splat_cost_estimate, derive_input_type_for_pricing, ArtcraftSplatPriceTable, +}; +use crate::generate::generate_splat::providers::artcraft::marble_1p1_plus::request::ArtcraftMarble1p1PlusRequestState; +use crate::generate::generate_splat::splat_generation_cost_estimate::SplatGenerationCostEstimate; + +const PRICES: ArtcraftSplatPriceTable = ArtcraftSplatPriceTable { + image_panorama: 312, + text: 321, + image_non_panorama: 321, + multi_image: 323, + video: 323, +}; + +/// Marble 1.1 Plus via Artcraft is flat priced by input type. +#[derive(Clone, Debug)] +pub struct ArtcraftMarble1p1PlusCostState { + pub input_type: InputType, +} + +impl ArtcraftMarble1p1PlusCostState { + pub fn from_request(request: &ArtcraftMarble1p1PlusRequestState) -> Self { + Self { input_type: derive_input_type_for_pricing(&request.request) } + } + + pub fn estimate_cost(&self) -> SplatGenerationCostEstimate { + artcraft_splat_cost_estimate(PRICES.cost_in_usd_cents(self.input_type)) + } +} + +#[cfg(test)] +mod tests { + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_provider::RouterProvider; + use crate::api::router_splat_model::RouterSplatModel; + use crate::api::video_ref::VideoRef; + use crate::generate::generate_splat::generate_splat_request_builder::GenerateSplatRequestBuilder; + + #[test] + fn text_is_321_cents() { + assert_eq!(estimate_usd_cents(text_builder()), 321); + } + + #[test] + fn single_image_is_321_cents() { + assert_eq!(estimate_usd_cents(single_image_builder(false)), 321); + } + + #[test] + fn panoramic_image_is_312_cents() { + assert_eq!(estimate_usd_cents(single_image_builder(true)), 312); + } + + #[test] + fn multi_image_is_323_cents() { + assert_eq!(estimate_usd_cents(multi_image_builder()), 323); + } + + #[test] + fn video_is_323_cents() { + assert_eq!(estimate_usd_cents(video_builder()), 323); + } + + // ── Helpers ── + + fn base_builder() -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + model: RouterSplatModel::Marble1p1Plus, + provider: RouterProvider::Artcraft, + ..Default::default() + } + } + + fn text_builder() -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + prompt: Some("a cozy cabin in the snowy mountains".to_string()), + ..base_builder() + } + } + + fn single_image_builder(is_panoramic: bool) -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + reference_images: Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_image_1".to_string()), + ])), + is_panoramic: Some(is_panoramic), + ..base_builder() + } + } + + fn multi_image_builder() -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + reference_images: Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_image_1".to_string()), + MediaFileToken::new("mf_image_2".to_string()), + ])), + ..base_builder() + } + } + + fn video_builder() -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + reference_video: Some(VideoRef::MediaFileToken(MediaFileToken::new("mf_video".to_string()))), + ..base_builder() + } + } + + fn estimate_usd_cents(builder: GenerateSplatRequestBuilder) -> u64 { + builder.build2() + .expect("build should succeed") + .estimate_cost() + .expect("estimate should succeed") + .cost_in_usd_cents + .expect("cost should be present") + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p1_plus/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p1_plus/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p1_plus/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p1_plus/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p1_plus/request.rs new file mode 100644 index 0000000000..aff347a04d --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/marble_1p1_plus/request.rs @@ -0,0 +1,17 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_splat_cost_and_generate_request::OmniGenSplatCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_splat::generate_splat_response::GenerateSplatResponse; +use crate::generate::generate_splat::providers::artcraft::request_common::send_artcraft_omni_splat_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftMarble1p1PlusRequestState { + pub request: OmniGenSplatCostAndGenerateRequest, +} + +impl ArtcraftMarble1p1PlusRequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_splat_request(&self.request, client).await + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/mod.rs new file mode 100644 index 0000000000..fe0b5ede6a --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/mod.rs @@ -0,0 +1,8 @@ +pub(crate) mod build_common; +pub(crate) mod cost_common; +pub(crate) mod request_common; +pub(crate) mod resolve; +pub mod marble_1p0; +pub mod marble_1p0_draft; +pub mod marble_1p1; +pub mod marble_1p1_plus; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/request_common.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/request_common.rs new file mode 100644 index 0000000000..f44c5bdf35 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/request_common.rs @@ -0,0 +1,29 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_splat_cost_and_generate_request::OmniGenSplatCostAndGenerateRequest; +use artcraft_client::endpoints::omni_gen::generate::splat::omni_gen_splat::omni_gen_splat_generate; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::provider_error::ProviderError; +use crate::generate::generate_splat::generate_splat_response::{ + ArtcraftSplatResponsePayload, GenerateSplatResponse, +}; + +/// Send a splat generation request to the Artcraft omni-gen endpoint. +/// All Artcraft model request states delegate to this function. +pub async fn send_artcraft_omni_splat_request( + request: &OmniGenSplatCostAndGenerateRequest, + client: &RouterArtcraftClient, +) -> Result { + let response = omni_gen_splat_generate( + &client.api_host, + Some(&client.credentials), + request.clone(), + ) + .await + .map_err(|err| ArtcraftRouterError::Provider(ProviderError::Storyteller(err)))?; + + Ok(GenerateSplatResponse::Artcraft(ArtcraftSplatResponsePayload { + inference_job_token: response.inference_job_token, + all_inference_job_tokens: response.all_job_tokens, + })) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/resolve.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/resolve.rs new file mode 100644 index 0000000000..e5d6df9329 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/artcraft/resolve.rs @@ -0,0 +1,34 @@ +//! Shared resolve helpers for the Artcraft splat provider. +//! +//! Artcraft only accepts media file tokens — raw URLs are rejected. + +use tokens::tokens::media_files::MediaFileToken; + +use crate::api::image_list_ref::ImageListRef; +use crate::api::video_ref::VideoRef; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; + +pub(super) fn resolve_image_list_ref( + image_list_ref: Option, +) -> Result>, ArtcraftRouterError> { + match image_list_ref { + None => Ok(None), + Some(ImageListRef::MediaFileTokens(tokens)) => Ok(Some(tokens)), + Some(ImageListRef::Urls(_)) => { + Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens)) + } + } +} + +pub(super) fn resolve_video_ref( + video_ref: Option, +) -> Result, ArtcraftRouterError> { + match video_ref { + None => Ok(None), + Some(VideoRef::MediaFileToken(token)) => Ok(Some(token)), + Some(VideoRef::Url(_)) => { + Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens)) + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/mod.rs new file mode 100644 index 0000000000..980cdc0632 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/mod.rs @@ -0,0 +1,2 @@ +pub mod artcraft; +pub mod worldlabs; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/build_common.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/build_common.rs new file mode 100644 index 0000000000..59fcbd0a15 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/build_common.rs @@ -0,0 +1,54 @@ +use worldlabs_api_client::api::api_types::world_labs_model::WorldLabsModel; +use worldlabs_api_client::api::requests::generate_world::http_request::WorldPrompt; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_splat::generate_splat_request_builder::GenerateSplatRequestBuilder; +use crate::generate::generate_splat::providers::worldlabs::draft_common::WorldLabsSplatDraft; +use crate::generate::generate_splat::providers::worldlabs::request_common::WorldLabsSplatRequest; +use crate::generate::generate_splat::providers::worldlabs::resolve::{plan_splat_input, SplatInput}; + +/// Either a ready-to-send request (text input) or a draft that must upload +/// media assets first. Wrapped into the per-model states by each model's +/// `build.rs` shim. +#[derive(Clone, Debug)] +pub(crate) enum WorldLabsSplatDraftOrRequest { + Draft(WorldLabsSplatDraft), + Request(WorldLabsSplatRequest), +} + +/// Build a World Labs splat draft-or-request from the builder. Text-only +/// prompts go DIRECT (no upload); any media input needs the draft phase to +/// re-upload the media as World Labs media assets. +pub(crate) fn build_worldlabs_splat( + mut builder: GenerateSplatRequestBuilder, + model: WorldLabsModel, +) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + let prompt = builder.prompt.take(); + + let input = plan_splat_input( + builder.reference_images.take(), + builder.reference_video.take(), + builder.is_panoramic, + prompt.is_some(), + strategy, + )?; + + match input { + SplatInput::Text => { + let world_prompt = WorldPrompt::Text { + text_prompt: prompt, + disable_recaption: builder.disable_recaption, + }; + Ok(WorldLabsSplatDraftOrRequest::Request(WorldLabsSplatRequest { model, world_prompt })) + } + media_input => { + Ok(WorldLabsSplatDraftOrRequest::Draft(WorldLabsSplatDraft { + model, + text_prompt: prompt, + disable_recaption: builder.disable_recaption, + input: media_input, + })) + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/draft_common.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/draft_common.rs new file mode 100644 index 0000000000..bca7b4dc88 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/draft_common.rs @@ -0,0 +1,263 @@ +use std::time::Duration; + +use tokens::tokens::media_files::MediaFileToken; +use worldlabs_api_client::api::api_types::world_labs_model::WorldLabsModel; +use worldlabs_api_client::api::requests::generate_world::http_request::{ + ContentReference, SphericallyLocatedContent, WorldPrompt, +}; +use worldlabs_api_client::api::requests::prepare_upload::prepare_upload::{ + prepare_upload, MediaAssetKind, PrepareUploadArgs, +}; +use worldlabs_api_client::api::requests::upload_to_signed_url::upload_to_signed_url::{ + upload_to_signed_url, UploadToSignedUrlArgs, +}; +use worldlabs_api_client::pricing::check_pricing::InputType; + +use crate::api::image_ref::ImageRef; +use crate::api::video_ref::VideoRef; +use crate::client::router_worldlabs_client::RouterWorldLabsClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::errors::provider_error::ProviderError; +use crate::generate::generate_splat::providers::worldlabs::request_common::WorldLabsSplatRequest; +use crate::generate::generate_splat::providers::worldlabs::resolve::SplatInput; +use crate::generate::generate_splat::splat_generation_draft_context::SplatGenerationDraftContext; +use crate::utils::download_file::download_file; + +/// How long to wait for each prepare/upload call during the draft phase. +const UPLOAD_REQUEST_TIMEOUT: Duration = Duration::from_secs(120); + +/// A World Labs splat draft, shared by all marble model draft states. Holds +/// unresolved media references that `to_request()` downloads from their +/// source URLs and re-uploads as World Labs media assets. +#[derive(Clone, Debug)] +pub struct WorldLabsSplatDraft { + pub model: WorldLabsModel, + pub text_prompt: Option, + pub disable_recaption: Option, + + /// Pending media input to resolve and upload. Never `SplatInput::Text` — + /// text-only prompts skip the draft phase. + pub(crate) input: SplatInput, +} + +impl WorldLabsSplatDraft { + + pub async fn to_request( + &self, + draft_context: &SplatGenerationDraftContext<'_>, + ) -> Result { + let client = draft_context.get_worldlabs_client_ref()?; + + let world_prompt = match &self.input { + // Text-only prompts never enter the draft phase, but assemble the + // prompt anyway so the state machine has no dead ends. + SplatInput::Text => WorldPrompt::Text { + text_prompt: self.text_prompt.clone(), + disable_recaption: self.disable_recaption, + }, + SplatInput::Image { image, is_panoramic } => { + let source_url = resolve_image_source_url(image, draft_context)?; + let content = upload_media_asset(client, &source_url, MediaAssetKind::Image).await?; + WorldPrompt::Image { + image_prompt: content, + text_prompt: self.text_prompt.clone(), + is_pano: Some(*is_panoramic), + disable_recaption: self.disable_recaption, + } + } + SplatInput::MultiImage { images } => { + let mut multi_image_prompt = Vec::with_capacity(images.len()); + for image in images { + let source_url = resolve_image_source_url(image, draft_context)?; + let content = upload_media_asset(client, &source_url, MediaAssetKind::Image).await?; + // Spherical coordinates aren't exposed through the router yet, so + // every image is submitted without an azimuth. + multi_image_prompt.push(SphericallyLocatedContent { content, azimuth: None }); + } + WorldPrompt::MultiImage { + multi_image_prompt, + text_prompt: self.text_prompt.clone(), + reconstruct_images: None, + disable_recaption: self.disable_recaption, + } + } + SplatInput::Video { video } => { + let source_url = resolve_video_source_url(video, draft_context)?; + let content = upload_media_asset(client, &source_url, MediaAssetKind::Video).await?; + WorldPrompt::Video { + video_prompt: content, + text_prompt: self.text_prompt.clone(), + disable_recaption: self.disable_recaption, + } + } + }; + + Ok(WorldLabsSplatRequest { + model: self.model, + world_prompt, + }) + } + + /// The pricing input type of the pending input. + pub(crate) fn input_type(&self) -> InputType { + self.input.to_input_type() + } +} + +// ── Resolve/upload helpers ── + +/// Download the media from its source URL and re-upload it as a World Labs +/// media asset, returning the content reference for the generation prompt. +async fn upload_media_asset( + client: &RouterWorldLabsClient, + source_url: &str, + kind: MediaAssetKind, +) -> Result { + let file_bytes = download_file(source_url).await?; + + let file_name = file_name_for_upload(source_url, kind); + let content_type = content_type_for_file_name(&file_name, kind); + + let prepared = prepare_upload(PrepareUploadArgs { + creds: &client.creds, + file_name: &file_name, + kind, + request_timeout: Some(UPLOAD_REQUEST_TIMEOUT), + }) + .await + .map_err(|err| ArtcraftRouterError::Provider(ProviderError::WorldLabs(err)))?; + + upload_to_signed_url(UploadToSignedUrlArgs { + upload_url: &prepared.upload_url, + file_bytes, + required_headers: &prepared.required_headers, + content_type, + request_timeout: Some(UPLOAD_REQUEST_TIMEOUT), + }) + .await + .map_err(|err| ArtcraftRouterError::Provider(ProviderError::WorldLabs(err)))?; + + Ok(ContentReference::MediaAsset { + media_asset_id: prepared.media_asset_id.as_str().to_string(), + }) +} + +fn resolve_image_source_url( + image: &ImageRef, + draft_context: &SplatGenerationDraftContext<'_>, +) -> Result { + match image { + ImageRef::Url(url) => Ok(url.clone()), + ImageRef::MediaFileToken(token) => resolve_token_source_url(token, draft_context), + } +} + +fn resolve_video_source_url( + video: &VideoRef, + draft_context: &SplatGenerationDraftContext<'_>, +) -> Result { + match video { + VideoRef::Url(url) => Ok(url.clone()), + VideoRef::MediaFileToken(token) => resolve_token_source_url(token, draft_context), + } +} + +fn resolve_token_source_url( + token: &MediaFileToken, + draft_context: &SplatGenerationDraftContext<'_>, +) -> Result { + let map = draft_context.media_file_to_artcraft_url_map + .ok_or(ArtcraftRouterError::Client(ClientError::MediaFileToUrlMapNotProvided))?; + map.get(token).cloned().ok_or_else(|| { + ArtcraftRouterError::Client(ClientError::MediaFileTokenNotFoundInMap { + token: token.clone(), + }) + }) +} + +// ── File name / content type helpers ── + +fn file_name_for_upload(source_url: &str, kind: MediaAssetKind) -> String { + let default_extension = match kind { + MediaAssetKind::Image => "png", + MediaAssetKind::Video => "mp4", + }; + let extension = extension_from_url(source_url) + .unwrap_or_else(|| default_extension.to_string()); + format!("upload.{extension}") +} + +fn extension_from_url(url: &str) -> Option { + let path = url.split(['?', '#']).next().unwrap_or(url); + let file_name = path.rsplit('/').next()?; + let (_, extension) = file_name.rsplit_once('.')?; + if extension.is_empty() || extension.len() > 5 { + return None; + } + Some(extension.to_lowercase()) +} + +fn content_type_for_file_name(file_name: &str, kind: MediaAssetKind) -> &'static str { + let extension = file_name.rsplit_once('.').map(|(_, ext)| ext).unwrap_or(""); + match extension { + "jpg" | "jpeg" => "image/jpeg", + "png" => "image/png", + "webp" => "image/webp", + "gif" => "image/gif", + "mp4" => "video/mp4", + "mov" => "video/quicktime", + "webm" => "video/webm", + _ => match kind { + MediaAssetKind::Image => "image/png", + MediaAssetKind::Video => "video/mp4", + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod extension_tests { + use super::*; + + #[test] + fn extension_is_parsed_from_url_path() { + assert_eq!(extension_from_url("https://example.com/a/b/photo.JPG").as_deref(), Some("jpg")); + assert_eq!(extension_from_url("https://example.com/clip.mp4?query=1").as_deref(), Some("mp4")); + assert_eq!(extension_from_url("https://example.com/pic.png#frag").as_deref(), Some("png")); + } + + #[test] + fn missing_extension_returns_none() { + assert_eq!(extension_from_url("https://example.com/no-extension"), None); + assert_eq!(extension_from_url("https://example.com/dir/"), None); + } + + #[test] + fn file_name_falls_back_to_kind_default() { + assert_eq!(file_name_for_upload("https://example.com/x", MediaAssetKind::Image), "upload.png"); + assert_eq!(file_name_for_upload("https://example.com/x", MediaAssetKind::Video), "upload.mp4"); + assert_eq!(file_name_for_upload("https://example.com/a.webp", MediaAssetKind::Image), "upload.webp"); + } + } + + mod content_type_tests { + use super::*; + + #[test] + fn known_extensions_map_to_content_types() { + assert_eq!(content_type_for_file_name("upload.jpg", MediaAssetKind::Image), "image/jpeg"); + assert_eq!(content_type_for_file_name("upload.png", MediaAssetKind::Image), "image/png"); + assert_eq!(content_type_for_file_name("upload.mp4", MediaAssetKind::Video), "video/mp4"); + assert_eq!(content_type_for_file_name("upload.mov", MediaAssetKind::Video), "video/quicktime"); + } + + #[test] + fn unknown_extensions_fall_back_to_kind_default() { + assert_eq!(content_type_for_file_name("upload.xyz", MediaAssetKind::Image), "image/png"); + assert_eq!(content_type_for_file_name("upload.xyz", MediaAssetKind::Video), "video/mp4"); + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p0/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p0/build.rs new file mode 100644 index 0000000000..925b7c2308 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p0/build.rs @@ -0,0 +1,25 @@ +use worldlabs_api_client::api::api_types::world_labs_model::WorldLabsModel; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_splat::generate_splat_request_builder::GenerateSplatRequestBuilder; +use crate::generate::generate_splat::providers::worldlabs::build_common::{ + build_worldlabs_splat, WorldLabsSplatDraftOrRequest, +}; +use crate::generate::generate_splat::providers::worldlabs::marble_1p0::draft::WorldLabsMarble1p0DraftState; +use crate::generate::generate_splat::providers::worldlabs::marble_1p0::request::WorldLabsMarble1p0RequestState; +use crate::generate::generate_splat::splat_generation_draft::SplatGenerationDraftRequest; +use crate::generate::generate_splat::splat_generation_draft_or_request::SplatGenerationDraftOrRequest; +use crate::generate::generate_splat::splat_generation_request::SplatGenerationRequest; + +pub fn build_worldlabs_marble_1p0(builder: GenerateSplatRequestBuilder) -> Result { + match build_worldlabs_splat(builder, WorldLabsModel::Marble1p0)? { + WorldLabsSplatDraftOrRequest::Request(request) => { + let state = WorldLabsMarble1p0RequestState { request }; + Ok(SplatGenerationDraftOrRequest::Request(SplatGenerationRequest::WorldLabsMarble1p0(state))) + } + WorldLabsSplatDraftOrRequest::Draft(draft) => { + let state = WorldLabsMarble1p0DraftState { draft }; + Ok(SplatGenerationDraftOrRequest::Draft(SplatGenerationDraftRequest::WorldLabsMarble1p0(state))) + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p0/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p0/cost.rs new file mode 100644 index 0000000000..6c0890eedf --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p0/cost.rs @@ -0,0 +1,136 @@ +use worldlabs_api_client::api::api_types::world_labs_model::WorldLabsModel; +use worldlabs_api_client::pricing::check_pricing::{calculate_cost, InputType}; + +use crate::generate::generate_splat::providers::worldlabs::marble_1p0::draft::WorldLabsMarble1p0DraftState; +use crate::generate::generate_splat::providers::worldlabs::marble_1p0::request::WorldLabsMarble1p0RequestState; +use crate::generate::generate_splat::splat_generation_cost_estimate::SplatGenerationCostEstimate; + +const MODEL: WorldLabsModel = WorldLabsModel::Marble1p0; + +/// Cost math is owned by worldlabs_api_client's pricing table — the router +/// just forwards the result so router cost ≡ provider cost by construction. +#[derive(Clone, Debug)] +pub struct WorldLabsMarble1p0CostState { + pub input_type: InputType, +} + +impl WorldLabsMarble1p0CostState { + pub fn from_request(request: &WorldLabsMarble1p0RequestState) -> Self { + Self { input_type: request.request.input_type() } + } + + pub fn from_draft(draft: &WorldLabsMarble1p0DraftState) -> Self { + Self { input_type: draft.draft.input_type() } + } + + pub fn estimate_cost(&self) -> SplatGenerationCostEstimate { + let cost = calculate_cost(MODEL, self.input_type); + SplatGenerationCostEstimate { + cost_in_credits: Some(cost.worldlabs_credits as u64), + cost_in_usd_cents: Some(cost.us_dollar_cents as u64), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_provider::RouterProvider; + use crate::api::router_splat_model::RouterSplatModel; + use crate::api::video_ref::VideoRef; + use crate::generate::generate_splat::generate_splat_request_builder::GenerateSplatRequestBuilder; + + #[test] + fn text_is_126_cents() { + assert_eq!(estimate_usd_cents(text_builder()), 126); + } + + #[test] + fn single_image_is_126_cents() { + assert_eq!(estimate_usd_cents(single_image_builder(false)), 126); + } + + #[test] + fn panoramic_image_is_120_cents() { + assert_eq!(estimate_usd_cents(single_image_builder(true)), 120); + } + + #[test] + fn multi_image_is_128_cents() { + assert_eq!(estimate_usd_cents(multi_image_builder()), 128); + } + + #[test] + fn video_is_128_cents() { + assert_eq!(estimate_usd_cents(video_builder()), 128); + } + + #[test] + fn credits_come_from_the_provider_pricing_table() { + let estimate = text_builder().build2() + .expect("build should succeed") + .estimate_cost() + .expect("estimate should succeed"); + assert_eq!(estimate.cost_in_credits, Some(1580)); + } + + // ── Helpers ── + + fn base_builder() -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + model: RouterSplatModel::Marble1p0, + provider: RouterProvider::WorldLabs, + ..Default::default() + } + } + + fn text_builder() -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + prompt: Some("a cozy cabin in the snowy mountains".to_string()), + ..base_builder() + } + } + + fn single_image_builder(is_panoramic: bool) -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + reference_images: Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_image_1".to_string()), + ])), + is_panoramic: Some(is_panoramic), + ..base_builder() + } + } + + fn multi_image_builder() -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + reference_images: Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_image_1".to_string()), + MediaFileToken::new("mf_image_2".to_string()), + ])), + ..base_builder() + } + } + + fn video_builder() -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + reference_video: Some(VideoRef::MediaFileToken(MediaFileToken::new("mf_video".to_string()))), + ..base_builder() + } + } + + fn estimate_usd_cents(builder: GenerateSplatRequestBuilder) -> u64 { + builder.build2() + .expect("build should succeed") + .estimate_cost() + .expect("estimate should succeed") + .cost_in_usd_cents + .expect("cost should be present") + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p0/draft.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p0/draft.rs new file mode 100644 index 0000000000..e4dae6904f --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p0/draft.rs @@ -0,0 +1,17 @@ +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_splat::providers::worldlabs::draft_common::WorldLabsSplatDraft; +use crate::generate::generate_splat::providers::worldlabs::marble_1p0::request::WorldLabsMarble1p0RequestState; +use crate::generate::generate_splat::splat_generation_draft_context::SplatGenerationDraftContext; + +#[derive(Clone, Debug)] +pub struct WorldLabsMarble1p0DraftState { + /// Shared World Labs draft; the model is baked in at build time. + pub draft: WorldLabsSplatDraft, +} + +impl WorldLabsMarble1p0DraftState { + pub async fn to_request(&self, draft_context: &SplatGenerationDraftContext<'_>) -> Result { + let request = self.draft.to_request(draft_context).await?; + Ok(WorldLabsMarble1p0RequestState { request }) + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p0/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p0/mod.rs new file mode 100644 index 0000000000..89225a3692 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p0/mod.rs @@ -0,0 +1,4 @@ +pub mod build; +pub mod cost; +pub mod draft; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p0/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p0/request.rs new file mode 100644 index 0000000000..ea0d151c60 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p0/request.rs @@ -0,0 +1,17 @@ +use crate::client::router_worldlabs_client::RouterWorldLabsClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_splat::generate_splat_response::GenerateSplatResponse; +use crate::generate::generate_splat::providers::worldlabs::request_common::WorldLabsSplatRequest; + +#[derive(Clone, Debug)] +pub struct WorldLabsMarble1p0RequestState { + /// Final materialized request; ready to fire. Media references (if any) + /// have been uploaded as World Labs media assets by the draft phase. + pub request: WorldLabsSplatRequest, +} + +impl WorldLabsMarble1p0RequestState { + pub async fn send(&self, client: &RouterWorldLabsClient) -> Result { + self.request.send(client).await + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p0_draft/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p0_draft/build.rs new file mode 100644 index 0000000000..1d11a6545e --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p0_draft/build.rs @@ -0,0 +1,25 @@ +use worldlabs_api_client::api::api_types::world_labs_model::WorldLabsModel; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_splat::generate_splat_request_builder::GenerateSplatRequestBuilder; +use crate::generate::generate_splat::providers::worldlabs::build_common::{ + build_worldlabs_splat, WorldLabsSplatDraftOrRequest, +}; +use crate::generate::generate_splat::providers::worldlabs::marble_1p0_draft::draft::WorldLabsMarble1p0DraftModelDraftState; +use crate::generate::generate_splat::providers::worldlabs::marble_1p0_draft::request::WorldLabsMarble1p0DraftModelRequestState; +use crate::generate::generate_splat::splat_generation_draft::SplatGenerationDraftRequest; +use crate::generate::generate_splat::splat_generation_draft_or_request::SplatGenerationDraftOrRequest; +use crate::generate::generate_splat::splat_generation_request::SplatGenerationRequest; + +pub fn build_worldlabs_marble_1p0_draft(builder: GenerateSplatRequestBuilder) -> Result { + match build_worldlabs_splat(builder, WorldLabsModel::Marble1p0Draft)? { + WorldLabsSplatDraftOrRequest::Request(request) => { + let state = WorldLabsMarble1p0DraftModelRequestState { request }; + Ok(SplatGenerationDraftOrRequest::Request(SplatGenerationRequest::WorldLabsMarble1p0Draft(state))) + } + WorldLabsSplatDraftOrRequest::Draft(draft) => { + let state = WorldLabsMarble1p0DraftModelDraftState { draft }; + Ok(SplatGenerationDraftOrRequest::Draft(SplatGenerationDraftRequest::WorldLabsMarble1p0Draft(state))) + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p0_draft/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p0_draft/cost.rs new file mode 100644 index 0000000000..0ef5810c21 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p0_draft/cost.rs @@ -0,0 +1,136 @@ +use worldlabs_api_client::api::api_types::world_labs_model::WorldLabsModel; +use worldlabs_api_client::pricing::check_pricing::{calculate_cost, InputType}; + +use crate::generate::generate_splat::providers::worldlabs::marble_1p0_draft::draft::WorldLabsMarble1p0DraftModelDraftState; +use crate::generate::generate_splat::providers::worldlabs::marble_1p0_draft::request::WorldLabsMarble1p0DraftModelRequestState; +use crate::generate::generate_splat::splat_generation_cost_estimate::SplatGenerationCostEstimate; + +const MODEL: WorldLabsModel = WorldLabsModel::Marble1p0Draft; + +/// Cost math is owned by worldlabs_api_client's pricing table — the router +/// just forwards the result so router cost ≡ provider cost by construction. +#[derive(Clone, Debug)] +pub struct WorldLabsMarble1p0DraftModelCostState { + pub input_type: InputType, +} + +impl WorldLabsMarble1p0DraftModelCostState { + pub fn from_request(request: &WorldLabsMarble1p0DraftModelRequestState) -> Self { + Self { input_type: request.request.input_type() } + } + + pub fn from_draft(draft: &WorldLabsMarble1p0DraftModelDraftState) -> Self { + Self { input_type: draft.draft.input_type() } + } + + pub fn estimate_cost(&self) -> SplatGenerationCostEstimate { + let cost = calculate_cost(MODEL, self.input_type); + SplatGenerationCostEstimate { + cost_in_credits: Some(cost.worldlabs_credits as u64), + cost_in_usd_cents: Some(cost.us_dollar_cents as u64), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_provider::RouterProvider; + use crate::api::router_splat_model::RouterSplatModel; + use crate::api::video_ref::VideoRef; + use crate::generate::generate_splat::generate_splat_request_builder::GenerateSplatRequestBuilder; + + #[test] + fn text_is_18_cents() { + assert_eq!(estimate_usd_cents(text_builder()), 18); + } + + #[test] + fn single_image_is_18_cents() { + assert_eq!(estimate_usd_cents(single_image_builder(false)), 18); + } + + #[test] + fn panoramic_image_is_12_cents() { + assert_eq!(estimate_usd_cents(single_image_builder(true)), 12); + } + + #[test] + fn multi_image_is_20_cents() { + assert_eq!(estimate_usd_cents(multi_image_builder()), 20); + } + + #[test] + fn video_is_20_cents() { + assert_eq!(estimate_usd_cents(video_builder()), 20); + } + + #[test] + fn credits_come_from_the_provider_pricing_table() { + let estimate = text_builder().build2() + .expect("build should succeed") + .estimate_cost() + .expect("estimate should succeed"); + assert_eq!(estimate.cost_in_credits, Some(230)); + } + + // ── Helpers ── + + fn base_builder() -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + model: RouterSplatModel::Marble1p0Draft, + provider: RouterProvider::WorldLabs, + ..Default::default() + } + } + + fn text_builder() -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + prompt: Some("a cozy cabin in the snowy mountains".to_string()), + ..base_builder() + } + } + + fn single_image_builder(is_panoramic: bool) -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + reference_images: Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_image_1".to_string()), + ])), + is_panoramic: Some(is_panoramic), + ..base_builder() + } + } + + fn multi_image_builder() -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + reference_images: Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_image_1".to_string()), + MediaFileToken::new("mf_image_2".to_string()), + ])), + ..base_builder() + } + } + + fn video_builder() -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + reference_video: Some(VideoRef::MediaFileToken(MediaFileToken::new("mf_video".to_string()))), + ..base_builder() + } + } + + fn estimate_usd_cents(builder: GenerateSplatRequestBuilder) -> u64 { + builder.build2() + .expect("build should succeed") + .estimate_cost() + .expect("estimate should succeed") + .cost_in_usd_cents + .expect("cost should be present") + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p0_draft/draft.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p0_draft/draft.rs new file mode 100644 index 0000000000..67fa389cd2 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p0_draft/draft.rs @@ -0,0 +1,17 @@ +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_splat::providers::worldlabs::draft_common::WorldLabsSplatDraft; +use crate::generate::generate_splat::providers::worldlabs::marble_1p0_draft::request::WorldLabsMarble1p0DraftModelRequestState; +use crate::generate::generate_splat::splat_generation_draft_context::SplatGenerationDraftContext; + +#[derive(Clone, Debug)] +pub struct WorldLabsMarble1p0DraftModelDraftState { + /// Shared World Labs draft; the model is baked in at build time. + pub draft: WorldLabsSplatDraft, +} + +impl WorldLabsMarble1p0DraftModelDraftState { + pub async fn to_request(&self, draft_context: &SplatGenerationDraftContext<'_>) -> Result { + let request = self.draft.to_request(draft_context).await?; + Ok(WorldLabsMarble1p0DraftModelRequestState { request }) + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p0_draft/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p0_draft/mod.rs new file mode 100644 index 0000000000..89225a3692 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p0_draft/mod.rs @@ -0,0 +1,4 @@ +pub mod build; +pub mod cost; +pub mod draft; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p0_draft/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p0_draft/request.rs new file mode 100644 index 0000000000..6fbfec0b7a --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p0_draft/request.rs @@ -0,0 +1,102 @@ +use crate::client::router_worldlabs_client::RouterWorldLabsClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_splat::generate_splat_response::GenerateSplatResponse; +use crate::generate::generate_splat::providers::worldlabs::request_common::WorldLabsSplatRequest; + +#[derive(Clone, Debug)] +pub struct WorldLabsMarble1p0DraftModelRequestState { + /// Final materialized request; ready to fire. Media references (if any) + /// have been uploaded as World Labs media assets by the draft phase. + pub request: WorldLabsSplatRequest, +} + +impl WorldLabsMarble1p0DraftModelRequestState { + pub async fn send(&self, client: &RouterWorldLabsClient) -> Result { + self.request.send(client).await + } +} + +#[cfg(test)] +mod tests { + use test_data::web::image_urls::MOUNTAIN_TREE_IMAGE_URL; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_provider::RouterProvider; + use crate::api::router_splat_model::RouterSplatModel; + use crate::client::router_client::RouterClient; + use crate::client::router_worldlabs_client::RouterWorldLabsClient; + use crate::generate::generate_splat::generate_splat_request_builder::GenerateSplatRequestBuilder; + use crate::generate::generate_splat::generate_splat_response::GenerateSplatResponse; + use crate::generate::generate_splat::splat_generation_draft_context::SplatGenerationDraftContext; + use crate::generate::generate_splat::splat_generation_draft_or_request::SplatGenerationDraftOrRequest; + + const WORLDLABS_API_KEY_PATH: &str = "/Users/bt/Artcraft/credentials/world_labs_api_key.txt"; + + #[tokio::test] + #[ignore] // sends a real generation to World Labs, incurs cost + async fn text_to_world() { + let response = run_pipeline(GenerateSplatRequestBuilder { + prompt: Some("A cozy cabin in the snowy mountains".to_string()), + ..base_builder() + }).await; + let payload = response.get_worldlabs_payload().expect("expected WorldLabs payload"); + assert!(!payload.operation_id.is_empty()); + assert_eq!(1, 2, "Inspect output above"); + } + + #[tokio::test] + #[ignore] // sends a real generation to World Labs, incurs cost; uploads media + async fn image_to_world() { + let response = run_pipeline(GenerateSplatRequestBuilder { + prompt: Some("Mountain landscape with a tree".to_string()), + reference_images: Some(ImageListRef::Urls(vec![MOUNTAIN_TREE_IMAGE_URL.to_string()])), + ..base_builder() + }).await; + let payload = response.get_worldlabs_payload().expect("expected WorldLabs payload"); + assert!(!payload.operation_id.is_empty()); + assert_eq!(1, 2, "Inspect output above"); + } + + // ── Helpers ── + + fn base_builder() -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + model: RouterSplatModel::Marble1p0Draft, + provider: RouterProvider::WorldLabs, + ..Default::default() + } + } + + fn get_worldlabs_client() -> RouterClient { + let api_key = std::fs::read_to_string(WORLDLABS_API_KEY_PATH) + .expect("Failed to read world_labs_api_key.txt"); + RouterClient::WorldLabs(RouterWorldLabsClient::new_from_raw_key(api_key.trim())) + } + + async fn run_pipeline(builder: GenerateSplatRequestBuilder) -> GenerateSplatResponse { + let client = get_worldlabs_client(); + + let draft_or_request = builder.build2().expect("build2 should succeed"); + let request = match draft_or_request { + SplatGenerationDraftOrRequest::Request(request) => request, + SplatGenerationDraftOrRequest::Draft(draft) => { + let draft_context = SplatGenerationDraftContext { + client: Some(&client), + ..Default::default() + }; + draft.finalize(draft_context).await.expect("finalize should succeed") + } + }; + + let response = request.send_request(&client).await.expect("send_request should succeed"); + + match &response { + GenerateSplatResponse::WorldLabs(p) => { + println!("operation_id={}, done={}", p.operation_id, p.done); + } + other => println!("response: {:?}", other), + } + + response + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p1/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p1/build.rs new file mode 100644 index 0000000000..2972c3485b --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p1/build.rs @@ -0,0 +1,25 @@ +use worldlabs_api_client::api::api_types::world_labs_model::WorldLabsModel; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_splat::generate_splat_request_builder::GenerateSplatRequestBuilder; +use crate::generate::generate_splat::providers::worldlabs::build_common::{ + build_worldlabs_splat, WorldLabsSplatDraftOrRequest, +}; +use crate::generate::generate_splat::providers::worldlabs::marble_1p1::draft::WorldLabsMarble1p1DraftState; +use crate::generate::generate_splat::providers::worldlabs::marble_1p1::request::WorldLabsMarble1p1RequestState; +use crate::generate::generate_splat::splat_generation_draft::SplatGenerationDraftRequest; +use crate::generate::generate_splat::splat_generation_draft_or_request::SplatGenerationDraftOrRequest; +use crate::generate::generate_splat::splat_generation_request::SplatGenerationRequest; + +pub fn build_worldlabs_marble_1p1(builder: GenerateSplatRequestBuilder) -> Result { + match build_worldlabs_splat(builder, WorldLabsModel::Marble1p1)? { + WorldLabsSplatDraftOrRequest::Request(request) => { + let state = WorldLabsMarble1p1RequestState { request }; + Ok(SplatGenerationDraftOrRequest::Request(SplatGenerationRequest::WorldLabsMarble1p1(state))) + } + WorldLabsSplatDraftOrRequest::Draft(draft) => { + let state = WorldLabsMarble1p1DraftState { draft }; + Ok(SplatGenerationDraftOrRequest::Draft(SplatGenerationDraftRequest::WorldLabsMarble1p1(state))) + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p1/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p1/cost.rs new file mode 100644 index 0000000000..2d7bc205ad --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p1/cost.rs @@ -0,0 +1,136 @@ +use worldlabs_api_client::api::api_types::world_labs_model::WorldLabsModel; +use worldlabs_api_client::pricing::check_pricing::{calculate_cost, InputType}; + +use crate::generate::generate_splat::providers::worldlabs::marble_1p1::draft::WorldLabsMarble1p1DraftState; +use crate::generate::generate_splat::providers::worldlabs::marble_1p1::request::WorldLabsMarble1p1RequestState; +use crate::generate::generate_splat::splat_generation_cost_estimate::SplatGenerationCostEstimate; + +const MODEL: WorldLabsModel = WorldLabsModel::Marble1p1; + +/// Cost math is owned by worldlabs_api_client's pricing table — the router +/// just forwards the result so router cost ≡ provider cost by construction. +#[derive(Clone, Debug)] +pub struct WorldLabsMarble1p1CostState { + pub input_type: InputType, +} + +impl WorldLabsMarble1p1CostState { + pub fn from_request(request: &WorldLabsMarble1p1RequestState) -> Self { + Self { input_type: request.request.input_type() } + } + + pub fn from_draft(draft: &WorldLabsMarble1p1DraftState) -> Self { + Self { input_type: draft.draft.input_type() } + } + + pub fn estimate_cost(&self) -> SplatGenerationCostEstimate { + let cost = calculate_cost(MODEL, self.input_type); + SplatGenerationCostEstimate { + cost_in_credits: Some(cost.worldlabs_credits as u64), + cost_in_usd_cents: Some(cost.us_dollar_cents as u64), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_provider::RouterProvider; + use crate::api::router_splat_model::RouterSplatModel; + use crate::api::video_ref::VideoRef; + use crate::generate::generate_splat::generate_splat_request_builder::GenerateSplatRequestBuilder; + + #[test] + fn text_is_126_cents() { + assert_eq!(estimate_usd_cents(text_builder()), 126); + } + + #[test] + fn single_image_is_126_cents() { + assert_eq!(estimate_usd_cents(single_image_builder(false)), 126); + } + + #[test] + fn panoramic_image_is_120_cents() { + assert_eq!(estimate_usd_cents(single_image_builder(true)), 120); + } + + #[test] + fn multi_image_is_128_cents() { + assert_eq!(estimate_usd_cents(multi_image_builder()), 128); + } + + #[test] + fn video_is_128_cents() { + assert_eq!(estimate_usd_cents(video_builder()), 128); + } + + #[test] + fn credits_come_from_the_provider_pricing_table() { + let estimate = text_builder().build2() + .expect("build should succeed") + .estimate_cost() + .expect("estimate should succeed"); + assert_eq!(estimate.cost_in_credits, Some(1580)); + } + + // ── Helpers ── + + fn base_builder() -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + model: RouterSplatModel::Marble1p1, + provider: RouterProvider::WorldLabs, + ..Default::default() + } + } + + fn text_builder() -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + prompt: Some("a cozy cabin in the snowy mountains".to_string()), + ..base_builder() + } + } + + fn single_image_builder(is_panoramic: bool) -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + reference_images: Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_image_1".to_string()), + ])), + is_panoramic: Some(is_panoramic), + ..base_builder() + } + } + + fn multi_image_builder() -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + reference_images: Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_image_1".to_string()), + MediaFileToken::new("mf_image_2".to_string()), + ])), + ..base_builder() + } + } + + fn video_builder() -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + reference_video: Some(VideoRef::MediaFileToken(MediaFileToken::new("mf_video".to_string()))), + ..base_builder() + } + } + + fn estimate_usd_cents(builder: GenerateSplatRequestBuilder) -> u64 { + builder.build2() + .expect("build should succeed") + .estimate_cost() + .expect("estimate should succeed") + .cost_in_usd_cents + .expect("cost should be present") + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p1/draft.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p1/draft.rs new file mode 100644 index 0000000000..457e6e41a2 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p1/draft.rs @@ -0,0 +1,17 @@ +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_splat::providers::worldlabs::draft_common::WorldLabsSplatDraft; +use crate::generate::generate_splat::providers::worldlabs::marble_1p1::request::WorldLabsMarble1p1RequestState; +use crate::generate::generate_splat::splat_generation_draft_context::SplatGenerationDraftContext; + +#[derive(Clone, Debug)] +pub struct WorldLabsMarble1p1DraftState { + /// Shared World Labs draft; the model is baked in at build time. + pub draft: WorldLabsSplatDraft, +} + +impl WorldLabsMarble1p1DraftState { + pub async fn to_request(&self, draft_context: &SplatGenerationDraftContext<'_>) -> Result { + let request = self.draft.to_request(draft_context).await?; + Ok(WorldLabsMarble1p1RequestState { request }) + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p1/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p1/mod.rs new file mode 100644 index 0000000000..89225a3692 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p1/mod.rs @@ -0,0 +1,4 @@ +pub mod build; +pub mod cost; +pub mod draft; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p1/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p1/request.rs new file mode 100644 index 0000000000..8556d4aae6 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p1/request.rs @@ -0,0 +1,17 @@ +use crate::client::router_worldlabs_client::RouterWorldLabsClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_splat::generate_splat_response::GenerateSplatResponse; +use crate::generate::generate_splat::providers::worldlabs::request_common::WorldLabsSplatRequest; + +#[derive(Clone, Debug)] +pub struct WorldLabsMarble1p1RequestState { + /// Final materialized request; ready to fire. Media references (if any) + /// have been uploaded as World Labs media assets by the draft phase. + pub request: WorldLabsSplatRequest, +} + +impl WorldLabsMarble1p1RequestState { + pub async fn send(&self, client: &RouterWorldLabsClient) -> Result { + self.request.send(client).await + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p1_plus/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p1_plus/build.rs new file mode 100644 index 0000000000..899930f9e4 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p1_plus/build.rs @@ -0,0 +1,25 @@ +use worldlabs_api_client::api::api_types::world_labs_model::WorldLabsModel; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_splat::generate_splat_request_builder::GenerateSplatRequestBuilder; +use crate::generate::generate_splat::providers::worldlabs::build_common::{ + build_worldlabs_splat, WorldLabsSplatDraftOrRequest, +}; +use crate::generate::generate_splat::providers::worldlabs::marble_1p1_plus::draft::WorldLabsMarble1p1PlusDraftState; +use crate::generate::generate_splat::providers::worldlabs::marble_1p1_plus::request::WorldLabsMarble1p1PlusRequestState; +use crate::generate::generate_splat::splat_generation_draft::SplatGenerationDraftRequest; +use crate::generate::generate_splat::splat_generation_draft_or_request::SplatGenerationDraftOrRequest; +use crate::generate::generate_splat::splat_generation_request::SplatGenerationRequest; + +pub fn build_worldlabs_marble_1p1_plus(builder: GenerateSplatRequestBuilder) -> Result { + match build_worldlabs_splat(builder, WorldLabsModel::Marble1p1Plus)? { + WorldLabsSplatDraftOrRequest::Request(request) => { + let state = WorldLabsMarble1p1PlusRequestState { request }; + Ok(SplatGenerationDraftOrRequest::Request(SplatGenerationRequest::WorldLabsMarble1p1Plus(state))) + } + WorldLabsSplatDraftOrRequest::Draft(draft) => { + let state = WorldLabsMarble1p1PlusDraftState { draft }; + Ok(SplatGenerationDraftOrRequest::Draft(SplatGenerationDraftRequest::WorldLabsMarble1p1Plus(state))) + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p1_plus/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p1_plus/cost.rs new file mode 100644 index 0000000000..9afa2dd53a --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p1_plus/cost.rs @@ -0,0 +1,136 @@ +use worldlabs_api_client::api::api_types::world_labs_model::WorldLabsModel; +use worldlabs_api_client::pricing::check_pricing::{calculate_cost, InputType}; + +use crate::generate::generate_splat::providers::worldlabs::marble_1p1_plus::draft::WorldLabsMarble1p1PlusDraftState; +use crate::generate::generate_splat::providers::worldlabs::marble_1p1_plus::request::WorldLabsMarble1p1PlusRequestState; +use crate::generate::generate_splat::splat_generation_cost_estimate::SplatGenerationCostEstimate; + +const MODEL: WorldLabsModel = WorldLabsModel::Marble1p1Plus; + +/// Cost math is owned by worldlabs_api_client's pricing table — the router +/// just forwards the result so router cost ≡ provider cost by construction. +#[derive(Clone, Debug)] +pub struct WorldLabsMarble1p1PlusCostState { + pub input_type: InputType, +} + +impl WorldLabsMarble1p1PlusCostState { + pub fn from_request(request: &WorldLabsMarble1p1PlusRequestState) -> Self { + Self { input_type: request.request.input_type() } + } + + pub fn from_draft(draft: &WorldLabsMarble1p1PlusDraftState) -> Self { + Self { input_type: draft.draft.input_type() } + } + + pub fn estimate_cost(&self) -> SplatGenerationCostEstimate { + let cost = calculate_cost(MODEL, self.input_type); + SplatGenerationCostEstimate { + cost_in_credits: Some(cost.worldlabs_credits as u64), + cost_in_usd_cents: Some(cost.us_dollar_cents as u64), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_provider::RouterProvider; + use crate::api::router_splat_model::RouterSplatModel; + use crate::api::video_ref::VideoRef; + use crate::generate::generate_splat::generate_splat_request_builder::GenerateSplatRequestBuilder; + + #[test] + fn text_is_246_cents() { + assert_eq!(estimate_usd_cents(text_builder()), 246); + } + + #[test] + fn single_image_is_246_cents() { + assert_eq!(estimate_usd_cents(single_image_builder(false)), 246); + } + + #[test] + fn panoramic_image_is_240_cents() { + assert_eq!(estimate_usd_cents(single_image_builder(true)), 240); + } + + #[test] + fn multi_image_is_248_cents() { + assert_eq!(estimate_usd_cents(multi_image_builder()), 248); + } + + #[test] + fn video_is_248_cents() { + assert_eq!(estimate_usd_cents(video_builder()), 248); + } + + #[test] + fn credits_come_from_the_provider_pricing_table() { + let estimate = text_builder().build2() + .expect("build should succeed") + .estimate_cost() + .expect("estimate should succeed"); + assert_eq!(estimate.cost_in_credits, Some(3080)); + } + + // ── Helpers ── + + fn base_builder() -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + model: RouterSplatModel::Marble1p1Plus, + provider: RouterProvider::WorldLabs, + ..Default::default() + } + } + + fn text_builder() -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + prompt: Some("a cozy cabin in the snowy mountains".to_string()), + ..base_builder() + } + } + + fn single_image_builder(is_panoramic: bool) -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + reference_images: Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_image_1".to_string()), + ])), + is_panoramic: Some(is_panoramic), + ..base_builder() + } + } + + fn multi_image_builder() -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + reference_images: Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_image_1".to_string()), + MediaFileToken::new("mf_image_2".to_string()), + ])), + ..base_builder() + } + } + + fn video_builder() -> GenerateSplatRequestBuilder { + GenerateSplatRequestBuilder { + reference_video: Some(VideoRef::MediaFileToken(MediaFileToken::new("mf_video".to_string()))), + ..base_builder() + } + } + + fn estimate_usd_cents(builder: GenerateSplatRequestBuilder) -> u64 { + builder.build2() + .expect("build should succeed") + .estimate_cost() + .expect("estimate should succeed") + .cost_in_usd_cents + .expect("cost should be present") + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p1_plus/draft.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p1_plus/draft.rs new file mode 100644 index 0000000000..986dddf115 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p1_plus/draft.rs @@ -0,0 +1,17 @@ +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_splat::providers::worldlabs::draft_common::WorldLabsSplatDraft; +use crate::generate::generate_splat::providers::worldlabs::marble_1p1_plus::request::WorldLabsMarble1p1PlusRequestState; +use crate::generate::generate_splat::splat_generation_draft_context::SplatGenerationDraftContext; + +#[derive(Clone, Debug)] +pub struct WorldLabsMarble1p1PlusDraftState { + /// Shared World Labs draft; the model is baked in at build time. + pub draft: WorldLabsSplatDraft, +} + +impl WorldLabsMarble1p1PlusDraftState { + pub async fn to_request(&self, draft_context: &SplatGenerationDraftContext<'_>) -> Result { + let request = self.draft.to_request(draft_context).await?; + Ok(WorldLabsMarble1p1PlusRequestState { request }) + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p1_plus/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p1_plus/mod.rs new file mode 100644 index 0000000000..89225a3692 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p1_plus/mod.rs @@ -0,0 +1,4 @@ +pub mod build; +pub mod cost; +pub mod draft; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p1_plus/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p1_plus/request.rs new file mode 100644 index 0000000000..d1bb0a2aa1 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/marble_1p1_plus/request.rs @@ -0,0 +1,17 @@ +use crate::client::router_worldlabs_client::RouterWorldLabsClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_splat::generate_splat_response::GenerateSplatResponse; +use crate::generate::generate_splat::providers::worldlabs::request_common::WorldLabsSplatRequest; + +#[derive(Clone, Debug)] +pub struct WorldLabsMarble1p1PlusRequestState { + /// Final materialized request; ready to fire. Media references (if any) + /// have been uploaded as World Labs media assets by the draft phase. + pub request: WorldLabsSplatRequest, +} + +impl WorldLabsMarble1p1PlusRequestState { + pub async fn send(&self, client: &RouterWorldLabsClient) -> Result { + self.request.send(client).await + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/mod.rs new file mode 100644 index 0000000000..acf8c39812 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/mod.rs @@ -0,0 +1,8 @@ +pub(crate) mod build_common; +pub(crate) mod draft_common; +pub(crate) mod request_common; +pub(crate) mod resolve; +pub mod marble_1p0; +pub mod marble_1p0_draft; +pub mod marble_1p1; +pub mod marble_1p1_plus; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/request_common.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/request_common.rs new file mode 100644 index 0000000000..c4bc907b4b --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/request_common.rs @@ -0,0 +1,67 @@ +use std::time::Duration; + +use worldlabs_api_client::api::api_types::world_labs_model::WorldLabsModel; +use worldlabs_api_client::api::requests::generate_world::generate_world::{ + generate_world, GenerateWorldArgs, +}; +use worldlabs_api_client::api::requests::generate_world::http_request::WorldPrompt; +use worldlabs_api_client::pricing::check_pricing::InputType; + +use crate::client::router_worldlabs_client::RouterWorldLabsClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::provider_error::ProviderError; +use crate::generate::generate_splat::generate_splat_response::{ + GenerateSplatResponse, WorldLabsSplatResponsePayload, +}; + +/// How long to wait for the World Labs generate call to accept the request. +/// The call only starts the generation; status is polled separately via the +/// returned operation ID. +const GENERATE_REQUEST_TIMEOUT: Duration = Duration::from_secs(60); + +/// A fully materialized World Labs generation request, shared by all marble +/// model request states. Media references (if any) have already been +/// uploaded as World Labs media assets by the draft phase. +#[derive(Clone, Debug)] +pub struct WorldLabsSplatRequest { + pub model: WorldLabsModel, + pub world_prompt: WorldPrompt, +} + +impl WorldLabsSplatRequest { + pub async fn send(&self, client: &RouterWorldLabsClient) -> Result { + let response = generate_world(GenerateWorldArgs { + creds: &client.creds, + world_prompt: self.world_prompt.clone(), + display_name: None, + model: self.model, + seed: None, + tags: None, + permission: None, + request_timeout: Some(GENERATE_REQUEST_TIMEOUT), + }) + .await + .map_err(|err| ArtcraftRouterError::Provider(ProviderError::WorldLabs(err)))?; + + Ok(GenerateSplatResponse::WorldLabs(WorldLabsSplatResponsePayload { + operation_id: response.operation_id.as_str().to_string(), + done: response.done, + })) + } + + /// The pricing input type of the assembled prompt. + pub(crate) fn input_type(&self) -> InputType { + match &self.world_prompt { + WorldPrompt::Text { .. } => InputType::Text, + WorldPrompt::Image { is_pano, .. } => { + if is_pano.unwrap_or(false) { + InputType::ImagePanorama + } else { + InputType::ImageNonPanorama + } + } + WorldPrompt::MultiImage { .. } => InputType::MultiImage, + WorldPrompt::Video { .. } => InputType::Video, + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/resolve.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/resolve.rs new file mode 100644 index 0000000000..528ece4a05 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/providers/worldlabs/resolve.rs @@ -0,0 +1,211 @@ +//! Input planning for the World Labs splat provider. +//! +//! The derivation is shared by request assembly and pricing so the two +//! always agree on the input type. + +use worldlabs_api_client::pricing::check_pricing::InputType; + +use crate::api::image_list_ref::ImageListRef; +use crate::api::image_ref::ImageRef; +use crate::api::video_ref::VideoRef; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; + +/// The planned splat input, derived from the builder's media references. +#[derive(Clone, Debug)] +pub(crate) enum SplatInput { + Text, + Image { image: ImageRef, is_panoramic: bool }, + MultiImage { images: Vec }, + Video { video: VideoRef }, +} + +impl SplatInput { + pub(crate) fn to_input_type(&self) -> InputType { + match self { + SplatInput::Text => InputType::Text, + SplatInput::Image { is_panoramic: true, .. } => InputType::ImagePanorama, + SplatInput::Image { is_panoramic: false, .. } => InputType::ImageNonPanorama, + SplatInput::MultiImage { .. } => InputType::MultiImage, + SplatInput::Video { .. } => InputType::Video, + } + } +} + +/// Derive the splat input from the builder's references: +/// - a reference video wins (images alongside it reject under `ErrorOut` and +/// are dropped otherwise), +/// - two or more reference images are multi-image input, +/// - one reference image is a single panorama or non-panorama image, +/// - otherwise a prompt is required for text input. +pub(crate) fn plan_splat_input( + reference_images: Option, + reference_video: Option, + is_panoramic: Option, + has_prompt: bool, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + let mut images = image_list_to_refs(reference_images); + + if let Some(video) = reference_video { + if !images.is_empty() { + if let RequestMismatchMitigationStrategy::ErrorOut = strategy { + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "reference_images", + value: "Reference images cannot be combined with a reference video".to_string(), + })); + } + } + return Ok(SplatInput::Video { video }); + } + + match images.len() { + 0 => { + if has_prompt { + Ok(SplatInput::Text) + } else { + Err(ArtcraftRouterError::InvalidInput( + "A prompt, reference image, or reference video is required for splat generation".to_string(), + )) + } + } + 1 => Ok(SplatInput::Image { + image: images.remove(0), + is_panoramic: is_panoramic.unwrap_or(false), + }), + _ => Ok(SplatInput::MultiImage { images }), + } +} + +fn image_list_to_refs(reference_images: Option) -> Vec { + match reference_images { + None => Vec::new(), + Some(ImageListRef::Urls(urls)) => urls.into_iter().map(ImageRef::Url).collect(), + Some(ImageListRef::MediaFileTokens(tokens)) => { + tokens.into_iter().map(ImageRef::MediaFileToken).collect() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const IMAGE_URL: &str = "https://example.com/room.png"; + const VIDEO_URL: &str = "https://example.com/room.mp4"; + + #[test] + fn video_wins() { + let input = plan_splat_input( + None, + Some(VideoRef::Url(VIDEO_URL.to_string())), + None, + true, + RequestMismatchMitigationStrategy::ErrorOut, + ).expect("plan"); + assert!(matches!(input, SplatInput::Video { .. })); + assert_eq!(input.to_input_type(), InputType::Video); + } + + #[test] + fn video_with_images_errors_out_under_error_out() { + let result = plan_splat_input( + Some(ImageListRef::Urls(vec![IMAGE_URL.to_string()])), + Some(VideoRef::Url(VIDEO_URL.to_string())), + None, + false, + RequestMismatchMitigationStrategy::ErrorOut, + ); + assert!(result.is_err()); + } + + #[test] + fn video_with_images_drops_the_images_under_lenient_strategies() { + let input = plan_splat_input( + Some(ImageListRef::Urls(vec![IMAGE_URL.to_string()])), + Some(VideoRef::Url(VIDEO_URL.to_string())), + None, + false, + RequestMismatchMitigationStrategy::PayMoreUpgrade, + ).expect("plan"); + assert!(matches!(input, SplatInput::Video { .. })); + } + + #[test] + fn two_images_are_multi_image() { + let input = plan_splat_input( + Some(ImageListRef::Urls(vec![ + IMAGE_URL.to_string(), + "https://example.com/other.png".to_string(), + ])), + None, + None, + false, + RequestMismatchMitigationStrategy::ErrorOut, + ).expect("plan"); + assert_eq!(input.to_input_type(), InputType::MultiImage); + } + + #[test] + fn one_panoramic_image_is_image_panorama() { + let input = plan_splat_input( + Some(ImageListRef::Urls(vec![IMAGE_URL.to_string()])), + None, + Some(true), + false, + RequestMismatchMitigationStrategy::ErrorOut, + ).expect("plan"); + assert_eq!(input.to_input_type(), InputType::ImagePanorama); + } + + #[test] + fn one_image_is_image_non_panorama() { + for is_panoramic in [None, Some(false)] { + let input = plan_splat_input( + Some(ImageListRef::Urls(vec![IMAGE_URL.to_string()])), + None, + is_panoramic, + false, + RequestMismatchMitigationStrategy::ErrorOut, + ).expect("plan"); + assert_eq!(input.to_input_type(), InputType::ImageNonPanorama); + } + } + + #[test] + fn prompt_only_is_text() { + let input = plan_splat_input( + None, + None, + None, + true, + RequestMismatchMitigationStrategy::ErrorOut, + ).expect("plan"); + assert_eq!(input.to_input_type(), InputType::Text); + } + + #[test] + fn empty_input_is_rejected() { + let result = plan_splat_input( + None, + None, + None, + false, + RequestMismatchMitigationStrategy::PayMoreUpgrade, + ); + assert!(matches!(result, Err(ArtcraftRouterError::InvalidInput(_)))); + } + + #[test] + fn empty_image_list_counts_as_no_images() { + let input = plan_splat_input( + Some(ImageListRef::Urls(vec![])), + None, + None, + true, + RequestMismatchMitigationStrategy::ErrorOut, + ).expect("plan"); + assert_eq!(input.to_input_type(), InputType::Text); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/splat_generation_cost_estimate.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/splat_generation_cost_estimate.rs index 73a189c3e0..a9b93682cd 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/splat_generation_cost_estimate.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/splat_generation_cost_estimate.rs @@ -1,4 +1,5 @@ /// The result of estimating the cost of a splat generation plan. +#[derive(Clone, Debug)] pub struct SplatGenerationCostEstimate { pub cost_in_credits: Option, pub cost_in_usd_cents: Option, @@ -6,5 +7,10 @@ pub struct SplatGenerationCostEstimate { pub is_unlimited: bool, pub is_rate_limited: bool, pub has_watermark: bool, + + /// Whether failures are refunded. + /// True: 100% yes + /// False: 100% no + /// None: Unknown or variable pub failures_are_refunded: Option, } diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/splat_generation_draft.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/splat_generation_draft.rs new file mode 100644 index 0000000000..381b686779 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/splat_generation_draft.rs @@ -0,0 +1,75 @@ +use crate::api::router_provider::RouterProvider; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_splat::providers::worldlabs::marble_1p0::cost::WorldLabsMarble1p0CostState; +use crate::generate::generate_splat::providers::worldlabs::marble_1p0::draft::WorldLabsMarble1p0DraftState; +use crate::generate::generate_splat::providers::worldlabs::marble_1p0_draft::cost::WorldLabsMarble1p0DraftModelCostState; +use crate::generate::generate_splat::providers::worldlabs::marble_1p0_draft::draft::WorldLabsMarble1p0DraftModelDraftState; +use crate::generate::generate_splat::providers::worldlabs::marble_1p1::cost::WorldLabsMarble1p1CostState; +use crate::generate::generate_splat::providers::worldlabs::marble_1p1::draft::WorldLabsMarble1p1DraftState; +use crate::generate::generate_splat::providers::worldlabs::marble_1p1_plus::cost::WorldLabsMarble1p1PlusCostState; +use crate::generate::generate_splat::providers::worldlabs::marble_1p1_plus::draft::WorldLabsMarble1p1PlusDraftState; +use crate::generate::generate_splat::splat_generation_cost_estimate::SplatGenerationCostEstimate; +use crate::generate::generate_splat::splat_generation_draft_context::SplatGenerationDraftContext; +use crate::generate::generate_splat::splat_generation_request::SplatGenerationRequest; + +/** + * Wrapper for all splat generation draft requests. + * + * Only the World Labs media inputs need a draft phase: reference images and + * videos must be downloaded from our CDN and re-uploaded to World Labs media + * assets before the request can be sent. Text-only prompts (and the Artcraft + * provider, which takes media tokens directly) return a `Request` from + * `build2()`. + */ +#[derive(Clone, Debug)] +pub enum SplatGenerationDraftRequest { + WorldLabsMarble1p0(WorldLabsMarble1p0DraftState), + WorldLabsMarble1p0Draft(WorldLabsMarble1p0DraftModelDraftState), + WorldLabsMarble1p1(WorldLabsMarble1p1DraftState), + WorldLabsMarble1p1Plus(WorldLabsMarble1p1PlusDraftState), +} + +impl SplatGenerationDraftRequest { + + pub fn get_provider(&self) -> RouterProvider { + match self { + Self::WorldLabsMarble1p0(_) => RouterProvider::WorldLabs, + Self::WorldLabsMarble1p0Draft(_) => RouterProvider::WorldLabs, + Self::WorldLabsMarble1p1(_) => RouterProvider::WorldLabs, + Self::WorldLabsMarble1p1Plus(_) => RouterProvider::WorldLabs, + } + } + + /// Return a cost estimate to fulfill the request. + pub fn estimate_cost(&self) -> Result { + match self { + SplatGenerationDraftRequest::WorldLabsMarble1p0(draft) => Ok(WorldLabsMarble1p0CostState::from_draft(draft).estimate_cost()), + SplatGenerationDraftRequest::WorldLabsMarble1p0Draft(draft) => Ok(WorldLabsMarble1p0DraftModelCostState::from_draft(draft).estimate_cost()), + SplatGenerationDraftRequest::WorldLabsMarble1p1(draft) => Ok(WorldLabsMarble1p1CostState::from_draft(draft).estimate_cost()), + SplatGenerationDraftRequest::WorldLabsMarble1p1Plus(draft) => Ok(WorldLabsMarble1p1PlusCostState::from_draft(draft).estimate_cost()), + } + } + + /// Finalize the draft request before generation + /// This may involve uploading media to the provider. + pub async fn finalize(self, draft_context: SplatGenerationDraftContext<'_>) -> Result { + match self { + SplatGenerationDraftRequest::WorldLabsMarble1p0(draft) => { + let result = draft.to_request(&draft_context).await?; + Ok(SplatGenerationRequest::WorldLabsMarble1p0(result)) + }, + SplatGenerationDraftRequest::WorldLabsMarble1p0Draft(draft) => { + let result = draft.to_request(&draft_context).await?; + Ok(SplatGenerationRequest::WorldLabsMarble1p0Draft(result)) + }, + SplatGenerationDraftRequest::WorldLabsMarble1p1(draft) => { + let result = draft.to_request(&draft_context).await?; + Ok(SplatGenerationRequest::WorldLabsMarble1p1(result)) + }, + SplatGenerationDraftRequest::WorldLabsMarble1p1Plus(draft) => { + let result = draft.to_request(&draft_context).await?; + Ok(SplatGenerationRequest::WorldLabsMarble1p1Plus(result)) + }, + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/splat_generation_draft_context.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/splat_generation_draft_context.rs new file mode 100644 index 0000000000..b76894036c --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/splat_generation_draft_context.rs @@ -0,0 +1,36 @@ +use std::collections::HashMap; +use std::fmt::Debug; + +use tokens::tokens::media_files::MediaFileToken; + +use crate::client::router_client::RouterClient; +use crate::client::router_worldlabs_client::RouterWorldLabsClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; + +#[derive(Clone, Default)] +pub struct SplatGenerationDraftContext<'a> { + /// Optional: the router client, needed for providers that require authentication. + pub client: Option<&'a RouterClient>, + + /// Optional context: a map of Media File Tokens to their ArtCraft URLs + /// Only needed if we have to fetch these assets and upload them to another provider. + pub media_file_to_artcraft_url_map: Option<&'a HashMap>, +} + +impl <'a> SplatGenerationDraftContext<'a> { + pub fn get_worldlabs_client_ref(&self) -> Result<&RouterWorldLabsClient, ArtcraftRouterError> { + let client = self.client.ok_or(ArtcraftRouterError::Client(ClientError::RouterClientNotProvided))?; + client.get_worldlabs_client_ref() + .map_err(|err| ArtcraftRouterError::Client(err)) + } +} + +impl Debug for SplatGenerationDraftContext<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SplatGenerationDraftContext") + .field("client", &self.client.is_some()) + .field("media_file_to_artcraft_url_map", &self.media_file_to_artcraft_url_map) + .finish() + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/splat_generation_draft_or_request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/splat_generation_draft_or_request.rs new file mode 100644 index 0000000000..5046e6c255 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/splat_generation_draft_or_request.rs @@ -0,0 +1,28 @@ +use crate::api::router_provider::RouterProvider; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_splat::splat_generation_cost_estimate::SplatGenerationCostEstimate; +use crate::generate::generate_splat::splat_generation_draft::SplatGenerationDraftRequest; +use crate::generate::generate_splat::splat_generation_request::SplatGenerationRequest; + +#[derive(Debug, Clone)] +pub enum SplatGenerationDraftOrRequest { + Draft(SplatGenerationDraftRequest), + Request(SplatGenerationRequest), +} + +impl SplatGenerationDraftOrRequest { + + pub fn get_provider(&self) -> RouterProvider { + match self { + Self::Draft(draft) => draft.get_provider(), + Self::Request(request) => request.get_provider(), + } + } + + pub fn estimate_cost(&self) -> Result { + match self { + SplatGenerationDraftOrRequest::Draft(draft) => draft.estimate_cost(), + SplatGenerationDraftOrRequest::Request(request) => request.estimate_cost(), + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/splat_generation_request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/splat_generation_request.rs new file mode 100644 index 0000000000..c62f463c99 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_splat/splat_generation_request.rs @@ -0,0 +1,102 @@ +use crate::api::router_provider::RouterProvider; +use crate::client::router_client::RouterClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_splat::generate_splat_response::GenerateSplatResponse; +use crate::generate::generate_splat::providers::artcraft::marble_1p0::cost::ArtcraftMarble1p0CostState; +use crate::generate::generate_splat::providers::artcraft::marble_1p0::request::ArtcraftMarble1p0RequestState; +use crate::generate::generate_splat::providers::artcraft::marble_1p0_draft::cost::ArtcraftMarble1p0DraftCostState; +use crate::generate::generate_splat::providers::artcraft::marble_1p0_draft::request::ArtcraftMarble1p0DraftRequestState; +use crate::generate::generate_splat::providers::artcraft::marble_1p1::cost::ArtcraftMarble1p1CostState; +use crate::generate::generate_splat::providers::artcraft::marble_1p1::request::ArtcraftMarble1p1RequestState; +use crate::generate::generate_splat::providers::artcraft::marble_1p1_plus::cost::ArtcraftMarble1p1PlusCostState; +use crate::generate::generate_splat::providers::artcraft::marble_1p1_plus::request::ArtcraftMarble1p1PlusRequestState; +use crate::generate::generate_splat::providers::worldlabs::marble_1p0::cost::WorldLabsMarble1p0CostState; +use crate::generate::generate_splat::providers::worldlabs::marble_1p0::request::WorldLabsMarble1p0RequestState; +use crate::generate::generate_splat::providers::worldlabs::marble_1p0_draft::cost::WorldLabsMarble1p0DraftModelCostState; +use crate::generate::generate_splat::providers::worldlabs::marble_1p0_draft::request::WorldLabsMarble1p0DraftModelRequestState; +use crate::generate::generate_splat::providers::worldlabs::marble_1p1::cost::WorldLabsMarble1p1CostState; +use crate::generate::generate_splat::providers::worldlabs::marble_1p1::request::WorldLabsMarble1p1RequestState; +use crate::generate::generate_splat::providers::worldlabs::marble_1p1_plus::cost::WorldLabsMarble1p1PlusCostState; +use crate::generate::generate_splat::providers::worldlabs::marble_1p1_plus::request::WorldLabsMarble1p1PlusRequestState; +use crate::generate::generate_splat::splat_generation_cost_estimate::SplatGenerationCostEstimate; + +#[derive(Clone, Debug)] +pub enum SplatGenerationRequest { + ArtcraftMarble1p0(ArtcraftMarble1p0RequestState), + ArtcraftMarble1p0Draft(ArtcraftMarble1p0DraftRequestState), + ArtcraftMarble1p1(ArtcraftMarble1p1RequestState), + ArtcraftMarble1p1Plus(ArtcraftMarble1p1PlusRequestState), + WorldLabsMarble1p0(WorldLabsMarble1p0RequestState), + WorldLabsMarble1p0Draft(WorldLabsMarble1p0DraftModelRequestState), + WorldLabsMarble1p1(WorldLabsMarble1p1RequestState), + WorldLabsMarble1p1Plus(WorldLabsMarble1p1PlusRequestState), +} + +impl SplatGenerationRequest { + + pub fn get_provider(&self) -> RouterProvider { + match self { + Self::ArtcraftMarble1p0(_) => RouterProvider::Artcraft, + Self::ArtcraftMarble1p0Draft(_) => RouterProvider::Artcraft, + Self::ArtcraftMarble1p1(_) => RouterProvider::Artcraft, + Self::ArtcraftMarble1p1Plus(_) => RouterProvider::Artcraft, + Self::WorldLabsMarble1p0(_) => RouterProvider::WorldLabs, + Self::WorldLabsMarble1p0Draft(_) => RouterProvider::WorldLabs, + Self::WorldLabsMarble1p1(_) => RouterProvider::WorldLabs, + Self::WorldLabsMarble1p1Plus(_) => RouterProvider::WorldLabs, + } + } + + /// Return a cost estimate to fulfill the request. + pub fn estimate_cost(&self) -> Result { + match self { + SplatGenerationRequest::ArtcraftMarble1p0(request) => Ok(ArtcraftMarble1p0CostState::from_request(request).estimate_cost()), + SplatGenerationRequest::ArtcraftMarble1p0Draft(request) => Ok(ArtcraftMarble1p0DraftCostState::from_request(request).estimate_cost()), + SplatGenerationRequest::ArtcraftMarble1p1(request) => Ok(ArtcraftMarble1p1CostState::from_request(request).estimate_cost()), + SplatGenerationRequest::ArtcraftMarble1p1Plus(request) => Ok(ArtcraftMarble1p1PlusCostState::from_request(request).estimate_cost()), + SplatGenerationRequest::WorldLabsMarble1p0(request) => Ok(WorldLabsMarble1p0CostState::from_request(request).estimate_cost()), + SplatGenerationRequest::WorldLabsMarble1p0Draft(request) => Ok(WorldLabsMarble1p0DraftModelCostState::from_request(request).estimate_cost()), + SplatGenerationRequest::WorldLabsMarble1p1(request) => Ok(WorldLabsMarble1p1CostState::from_request(request).estimate_cost()), + SplatGenerationRequest::WorldLabsMarble1p1Plus(request) => Ok(WorldLabsMarble1p1PlusCostState::from_request(request).estimate_cost()), + } + } + + /// Send the splat generation request + /// If successful, returns the job IDs. + pub async fn send_request(&self, client: &RouterClient) -> Result { + match self { + SplatGenerationRequest::ArtcraftMarble1p0(request) => { + let client_ref = client.get_artcraft_client_ref()?; + request.send(client_ref).await + }, + SplatGenerationRequest::ArtcraftMarble1p0Draft(request) => { + let client_ref = client.get_artcraft_client_ref()?; + request.send(client_ref).await + }, + SplatGenerationRequest::ArtcraftMarble1p1(request) => { + let client_ref = client.get_artcraft_client_ref()?; + request.send(client_ref).await + }, + SplatGenerationRequest::ArtcraftMarble1p1Plus(request) => { + let client_ref = client.get_artcraft_client_ref()?; + request.send(client_ref).await + }, + SplatGenerationRequest::WorldLabsMarble1p0(request) => { + let client_ref = client.get_worldlabs_client_ref()?; + request.send(client_ref).await + }, + SplatGenerationRequest::WorldLabsMarble1p0Draft(request) => { + let client_ref = client.get_worldlabs_client_ref()?; + request.send(client_ref).await + }, + SplatGenerationRequest::WorldLabsMarble1p1(request) => { + let client_ref = client.get_worldlabs_client_ref()?; + request.send(client_ref).await + }, + SplatGenerationRequest::WorldLabsMarble1p1Plus(request) => { + let client_ref = client.get_worldlabs_client_ref()?; + request.send(client_ref).await + }, + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/mod.rs index 10b472ab05..e6b63cc290 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/mod.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/mod.rs @@ -1,4 +1,5 @@ pub mod generate_audio; pub mod generate_image; +pub mod generate_mesh; pub mod generate_splat; pub mod generate_video; diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan3d_3_image_to_mesh/api.rs b/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan3d_3_image_to_mesh/api.rs index 96fd734832..3e68bcf0bf 100644 --- a/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan3d_3_image_to_mesh/api.rs +++ b/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan3d_3_image_to_mesh/api.rs @@ -110,7 +110,7 @@ mod tests { #[tokio::test] #[ignore] // manually run — fires a real API request and incurs cost async fn test_image_to_mesh_queue() -> AnyhowResult<()> { - let secret = read_to_string("/Users/bt/Artcraft/credentials/fal.api_key.txt")?; + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; let api_key = FalApiKey::from_str(&secret); let request = Hunyuan3d3ImageToMeshRequest { @@ -130,5 +130,99 @@ mod tests { Ok(()) } + // ── Wire-shape sanity (no API calls) ── + + #[test] + fn raw_request_maps_all_fields() { + let request = Hunyuan3d3ImageToMeshRequest { + image_url: "https://example.com/front.jpg".to_string(), + back_image_url: Some("https://example.com/back.jpg".to_string()), + left_image_url: Some("https://example.com/left.jpg".to_string()), + right_image_url: Some("https://example.com/right.jpg".to_string()), + face_count: Some(100_000), + generate_type: Some(Hunyuan3d3ImageToMeshGenerateType::LowPoly), + polygon_type: Some(Hunyuan3d3ImageToMeshPolygonType::Quadrilateral), + enable_pbr: Some(true), + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!( + json, + serde_json::json!({ + "input_image_url": "https://example.com/front.jpg", + "back_image_url": "https://example.com/back.jpg", + "left_image_url": "https://example.com/left.jpg", + "right_image_url": "https://example.com/right.jpg", + "face_count": 100_000, + "generate_type": "LowPoly", + "polygon_type": "quadrilateral", + "enable_pbr": true, + }), + ); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let request = Hunyuan3d3ImageToMeshRequest { + image_url: "https://example.com/front.jpg".to_string(), + back_image_url: None, + left_image_url: None, + right_image_url: None, + face_count: None, + generate_type: None, + polygon_type: None, + enable_pbr: None, + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!( + json, + serde_json::json!({ "input_image_url": "https://example.com/front.jpg" }), + ); + } + + #[test] + fn every_generate_type_maps_to_wire_string() { + for (variant, expected) in [ + (Hunyuan3d3ImageToMeshGenerateType::Normal, "Normal"), + (Hunyuan3d3ImageToMeshGenerateType::LowPoly, "LowPoly"), + (Hunyuan3d3ImageToMeshGenerateType::Geometry, "Geometry"), + ] { + let mut request = base_wire_request(); + request.generate_type = Some(variant); + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.generate_type.as_deref(), Some(expected)); + } + } + + #[test] + fn every_polygon_type_maps_to_wire_string() { + for (variant, expected) in [ + (Hunyuan3d3ImageToMeshPolygonType::Triangle, "triangle"), + (Hunyuan3d3ImageToMeshPolygonType::Quadrilateral, "quadrilateral"), + ] { + let mut request = base_wire_request(); + request.polygon_type = Some(variant); + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.polygon_type.as_deref(), Some(expected)); + } + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!(Hunyuan3d3ImageToMeshRequest::ENDPOINT, "fal-ai/hunyuan3d-v3/image-to-3d"); + } + // NB: Pricing tests are in cost.rs + + fn base_wire_request() -> Hunyuan3d3ImageToMeshRequest { + Hunyuan3d3ImageToMeshRequest { + image_url: "https://example.com/front.jpg".to_string(), + back_image_url: None, + left_image_url: None, + right_image_url: None, + face_count: None, + generate_type: None, + polygon_type: None, + enable_pbr: None, + } + } } diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan_3d_2p0_image_to_mesh/api.rs b/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan_3d_2p0_image_to_mesh/api.rs new file mode 100644 index 0000000000..9f29ef907b --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan_3d_2p0_image_to_mesh/api.rs @@ -0,0 +1,151 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::mesh::image::hunyuan_3d_2p0_image_to_mesh::raw_request::{ + Hunyuan3d2p0ImageToMeshInput, Hunyuan3d2p0ImageToMeshOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +/// Hunyuan3D 2.0 image-to-3D: turns a single image into a 3D mesh, +/// optionally textured. +#[derive(Clone, Debug)] +pub struct Hunyuan3d2p0ImageToMeshRequest { + /// URL of the input image. + pub image_url: String, + + /// Whether to generate a textured mesh instead of a white (untextured) + /// mesh. fal's default is false when `None`. Textured costs 3x. + pub textured_mesh: Option, + + /// Guidance scale for the model. fal's default is 7.5 when `None`. + pub guidance_scale: Option, + + /// Number of inference steps. fal's default is 50 when `None`. + pub num_inference_steps: Option, + + /// Octree resolution for the mesh. fal's default is 256 when `None`. + pub octree_resolution: Option, + + /// Seed for reproducible generation. Random when `None`. + pub seed: Option, +} + +impl FalEndpoint for Hunyuan3d2p0ImageToMeshRequest { + const ENDPOINT: &str = "fal-ai/hunyuan3d/v2"; + + type RawRequest = Hunyuan3d2p0ImageToMeshInput; + type RawResponse = Hunyuan3d2p0ImageToMeshOutput; + + fn to_raw_request(&self) -> Result { + Ok(Self::RawRequest { + input_image_url: self.image_url.clone(), + textured_mesh: self.textured_mesh, + guidance_scale: self.guidance_scale, + num_inference_steps: self.num_inference_steps, + octree_resolution: self.octree_resolution, + seed: self.seed, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::ERNEST_SCARED_STUPID_IMAGE_URL; + + // ── Real requests (manually run; require a live key and cost money) ── + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_image_to_mesh_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Hunyuan3d2p0ImageToMeshRequest { + image_url: ERNEST_SCARED_STUPID_IMAGE_URL.to_string(), + textured_mesh: Some(true), + guidance_scale: None, + num_inference_steps: None, + octree_resolution: None, + seed: None, + }; + + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("Webhook result: {:?}", result); + assert!(result.request_id.is_some() || result.gateway_request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_image_to_mesh_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Hunyuan3d2p0ImageToMeshRequest { + image_url: ERNEST_SCARED_STUPID_IMAGE_URL.to_string(), + textured_mesh: Some(true), + guidance_scale: None, + num_inference_steps: None, + octree_resolution: None, + seed: None, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + // ── Wire-shape sanity (no API calls) ── + + #[test] + fn raw_request_maps_all_fields() { + let request = Hunyuan3d2p0ImageToMeshRequest { + image_url: "https://example.com/image.jpg".to_string(), + textured_mesh: Some(true), + guidance_scale: Some(5.0), + num_inference_steps: Some(30), + octree_resolution: Some(512), + seed: Some(42), + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!( + json, + serde_json::json!({ + "input_image_url": "https://example.com/image.jpg", + "textured_mesh": true, + "guidance_scale": 5.0, + "num_inference_steps": 30, + "octree_resolution": 512, + "seed": 42, + }), + ); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let request = Hunyuan3d2p0ImageToMeshRequest { + image_url: "https://example.com/image.jpg".to_string(), + textured_mesh: None, + guidance_scale: None, + num_inference_steps: None, + octree_resolution: None, + seed: None, + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!( + json, + serde_json::json!({ "input_image_url": "https://example.com/image.jpg" }), + ); + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!(Hunyuan3d2p0ImageToMeshRequest::ENDPOINT, "fal-ai/hunyuan3d/v2"); + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan_3d_2p0_image_to_mesh/cost.rs b/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan_3d_2p0_image_to_mesh/cost.rs new file mode 100644 index 0000000000..c751f61206 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan_3d_2p0_image_to_mesh/cost.rs @@ -0,0 +1,67 @@ +use crate::requests::api::mesh::image::hunyuan_3d_2p0_image_to_mesh::api::Hunyuan3d2p0ImageToMeshRequest; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +impl FalRequestCostCalculator for Hunyuan3d2p0ImageToMeshRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Pricing (see https://fal.ai/models/fal-ai/hunyuan3d/v2): + // "Your request will cost $0.16 per generation." (white mesh) + // Textured mesh is charged at 3x the white mesh price: $0.48. + // + // White mesh: $0.16 → 16¢ + // Textured mesh: $0.48 → 48¢ + if self.textured_mesh.unwrap_or(false) { + 48 + } else { + 16 + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod cost_table { + use super::*; + + #[test] + fn white_mesh_by_default() { + assert_eq!(base_request().calculate_cost_in_cents(), 16); + } + + #[test] + fn white_mesh_explicit() { + let mut req = base_request(); + req.textured_mesh = Some(false); + assert_eq!(req.calculate_cost_in_cents(), 16); + } + + #[test] + fn textured_mesh_costs_three_times_white() { + let mut req = base_request(); + req.textured_mesh = Some(true); + assert_eq!(req.calculate_cost_in_cents(), 48); + } + + #[test] + fn tuning_parameters_do_not_affect_cost() { + let mut req = base_request(); + req.guidance_scale = Some(10.0); + req.num_inference_steps = Some(25); + req.octree_resolution = Some(512); + req.seed = Some(7); + assert_eq!(req.calculate_cost_in_cents(), 16); + } + } + + fn base_request() -> Hunyuan3d2p0ImageToMeshRequest { + Hunyuan3d2p0ImageToMeshRequest { + image_url: "https://example.com/image.jpg".to_string(), + textured_mesh: None, + guidance_scale: None, + num_inference_steps: None, + octree_resolution: None, + seed: None, + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan_3d_2p0_image_to_mesh/mod.rs b/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan_3d_2p0_image_to_mesh/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan_3d_2p0_image_to_mesh/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan_3d_2p0_image_to_mesh/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan_3d_2p0_image_to_mesh/raw_request.rs new file mode 100644 index 0000000000..61a3cc069d --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan_3d_2p0_image_to_mesh/raw_request.rs @@ -0,0 +1,34 @@ +use serde::{Deserialize, Serialize}; + +/// Over-the-wire input shape for `fal-ai/hunyuan3d/v2`. +/// fal's schema: +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Hunyuan3d2p0ImageToMeshInput { + /// URL of the image to use while generating the 3D model. + pub input_image_url: String, + + /// If true, a textured mesh is generated and the price charged is 3x + /// that of a white (untextured) mesh. fal default: false. + #[serde(skip_serializing_if = "Option::is_none")] + pub textured_mesh: Option, + + /// Guidance scale for the model. fal default: 7.5. + #[serde(skip_serializing_if = "Option::is_none")] + pub guidance_scale: Option, + + /// Number of inference steps to perform. fal default: 50. + #[serde(skip_serializing_if = "Option::is_none")] + pub num_inference_steps: Option, + + /// Octree resolution for the model. fal default: 256. + #[serde(skip_serializing_if = "Option::is_none")] + pub octree_resolution: Option, + + /// The same seed and prompt given to the same model version produce the + /// same output. + #[serde(skip_serializing_if = "Option::is_none")] + pub seed: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Hunyuan3d2p0ImageToMeshOutput {} diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan_3d_2p1_image_to_mesh/api.rs b/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan_3d_2p1_image_to_mesh/api.rs new file mode 100644 index 0000000000..009b140718 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan_3d_2p1_image_to_mesh/api.rs @@ -0,0 +1,153 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::mesh::image::hunyuan_3d_2p1_image_to_mesh::raw_request::{ + Hunyuan3d2p1ImageToMeshInput, Hunyuan3d2p1ImageToMeshOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +/// Hunyuan3D 2.1 image-to-3D: turns a single image into a 3D mesh, +/// optionally with PBR textures. +/// +/// NB: fal lists this endpoint as deprecated, but it is still callable. +#[derive(Clone, Debug)] +pub struct Hunyuan3d2p1ImageToMeshRequest { + /// URL of the input image. + pub image_url: String, + + /// Whether to generate a textured (PBR) mesh instead of a white + /// (untextured) mesh. fal's default is false when `None`. Textured costs 3x. + pub textured_mesh: Option, + + /// Guidance scale for the model. fal's default is 7.5 when `None`. + pub guidance_scale: Option, + + /// Number of inference steps. fal's default is 50 when `None`. + pub num_inference_steps: Option, + + /// Octree resolution for the mesh. fal's default is 256 when `None`. + pub octree_resolution: Option, + + /// Seed for reproducible generation. Random when `None`. + pub seed: Option, +} + +impl FalEndpoint for Hunyuan3d2p1ImageToMeshRequest { + const ENDPOINT: &str = "fal-ai/hunyuan3d-v21"; + + type RawRequest = Hunyuan3d2p1ImageToMeshInput; + type RawResponse = Hunyuan3d2p1ImageToMeshOutput; + + fn to_raw_request(&self) -> Result { + Ok(Self::RawRequest { + input_image_url: self.image_url.clone(), + textured_mesh: self.textured_mesh, + guidance_scale: self.guidance_scale, + num_inference_steps: self.num_inference_steps, + octree_resolution: self.octree_resolution, + seed: self.seed, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::ERNEST_SCARED_STUPID_IMAGE_URL; + + // ── Real requests (manually run; require a live key and cost money) ── + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_image_to_mesh_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Hunyuan3d2p1ImageToMeshRequest { + image_url: ERNEST_SCARED_STUPID_IMAGE_URL.to_string(), + textured_mesh: Some(true), + guidance_scale: None, + num_inference_steps: None, + octree_resolution: None, + seed: None, + }; + + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("Webhook result: {:?}", result); + assert!(result.request_id.is_some() || result.gateway_request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_image_to_mesh_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Hunyuan3d2p1ImageToMeshRequest { + image_url: ERNEST_SCARED_STUPID_IMAGE_URL.to_string(), + textured_mesh: Some(true), + guidance_scale: None, + num_inference_steps: None, + octree_resolution: None, + seed: None, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + // ── Wire-shape sanity (no API calls) ── + + #[test] + fn raw_request_maps_all_fields() { + let request = Hunyuan3d2p1ImageToMeshRequest { + image_url: "https://example.com/image.jpg".to_string(), + textured_mesh: Some(true), + guidance_scale: Some(5.0), + num_inference_steps: Some(30), + octree_resolution: Some(512), + seed: Some(42), + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!( + json, + serde_json::json!({ + "input_image_url": "https://example.com/image.jpg", + "textured_mesh": true, + "guidance_scale": 5.0, + "num_inference_steps": 30, + "octree_resolution": 512, + "seed": 42, + }), + ); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let request = Hunyuan3d2p1ImageToMeshRequest { + image_url: "https://example.com/image.jpg".to_string(), + textured_mesh: None, + guidance_scale: None, + num_inference_steps: None, + octree_resolution: None, + seed: None, + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!( + json, + serde_json::json!({ "input_image_url": "https://example.com/image.jpg" }), + ); + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!(Hunyuan3d2p1ImageToMeshRequest::ENDPOINT, "fal-ai/hunyuan3d-v21"); + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan_3d_2p1_image_to_mesh/cost.rs b/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan_3d_2p1_image_to_mesh/cost.rs new file mode 100644 index 0000000000..7435417b0e --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan_3d_2p1_image_to_mesh/cost.rs @@ -0,0 +1,68 @@ +use crate::requests::api::mesh::image::hunyuan_3d_2p1_image_to_mesh::api::Hunyuan3d2p1ImageToMeshRequest; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +impl FalRequestCostCalculator for Hunyuan3d2p1ImageToMeshRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Pricing (see https://fal.ai/models/fal-ai/hunyuan3d-v21): + // "Your request will cost $0.3 per generation." (white mesh) + // Per fal's input schema, textured mesh is charged at 3x the white + // mesh price: $0.90. + // + // White mesh: $0.30 → 30¢ + // Textured mesh: $0.90 → 90¢ + if self.textured_mesh.unwrap_or(false) { + 90 + } else { + 30 + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod cost_table { + use super::*; + + #[test] + fn white_mesh_by_default() { + assert_eq!(base_request().calculate_cost_in_cents(), 30); + } + + #[test] + fn white_mesh_explicit() { + let mut req = base_request(); + req.textured_mesh = Some(false); + assert_eq!(req.calculate_cost_in_cents(), 30); + } + + #[test] + fn textured_mesh_costs_three_times_white() { + let mut req = base_request(); + req.textured_mesh = Some(true); + assert_eq!(req.calculate_cost_in_cents(), 90); + } + + #[test] + fn tuning_parameters_do_not_affect_cost() { + let mut req = base_request(); + req.guidance_scale = Some(10.0); + req.num_inference_steps = Some(25); + req.octree_resolution = Some(512); + req.seed = Some(7); + assert_eq!(req.calculate_cost_in_cents(), 30); + } + } + + fn base_request() -> Hunyuan3d2p1ImageToMeshRequest { + Hunyuan3d2p1ImageToMeshRequest { + image_url: "https://example.com/image.jpg".to_string(), + textured_mesh: None, + guidance_scale: None, + num_inference_steps: None, + octree_resolution: None, + seed: None, + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan_3d_2p1_image_to_mesh/mod.rs b/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan_3d_2p1_image_to_mesh/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan_3d_2p1_image_to_mesh/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan_3d_2p1_image_to_mesh/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan_3d_2p1_image_to_mesh/raw_request.rs new file mode 100644 index 0000000000..f6c2b9e18c --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/image/hunyuan_3d_2p1_image_to_mesh/raw_request.rs @@ -0,0 +1,36 @@ +use serde::{Deserialize, Serialize}; + +/// Over-the-wire input shape for `fal-ai/hunyuan3d-v21`. +/// fal's schema: +/// +/// NB: fal lists this endpoint as deprecated, but it is still callable. +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Hunyuan3d2p1ImageToMeshInput { + /// URL of the image to use while generating the 3D model. + pub input_image_url: String, + + /// If true, a textured (PBR) mesh is generated and the price charged is + /// 3x that of a white (untextured) mesh. fal default: false. + #[serde(skip_serializing_if = "Option::is_none")] + pub textured_mesh: Option, + + /// Guidance scale for the model. fal default: 7.5. + #[serde(skip_serializing_if = "Option::is_none")] + pub guidance_scale: Option, + + /// Number of inference steps to perform. fal default: 50. + #[serde(skip_serializing_if = "Option::is_none")] + pub num_inference_steps: Option, + + /// Octree resolution for the model. fal default: 256. + #[serde(skip_serializing_if = "Option::is_none")] + pub octree_resolution: Option, + + /// The same seed and prompt given to the same model version produce the + /// same output. + #[serde(skip_serializing_if = "Option::is_none")] + pub seed: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Hunyuan3d2p1ImageToMeshOutput {} diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/image/mod.rs b/crates/api_clients/fal_client/src/requests/api/mesh/image/mod.rs index d135f1d3fe..c43fa61b92 100644 --- a/crates/api_clients/fal_client/src/requests/api/mesh/image/mod.rs +++ b/crates/api_clients/fal_client/src/requests/api/mesh/image/mod.rs @@ -1 +1,3 @@ pub mod hunyuan3d_3_image_to_mesh; +pub mod hunyuan_3d_2p0_image_to_mesh; +pub mod hunyuan_3d_2p1_image_to_mesh; diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/mod.rs b/crates/api_clients/fal_client/src/requests/api/mesh/mod.rs index 70e7921855..05a90ab303 100644 --- a/crates/api_clients/fal_client/src/requests/api/mesh/mod.rs +++ b/crates/api_clients/fal_client/src/requests/api/mesh/mod.rs @@ -1,2 +1,3 @@ pub mod image; -pub mod text; \ No newline at end of file +pub mod sketch; +pub mod text; diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/sketch/hunyuan3d_3_sketch_to_mesh/api.rs b/crates/api_clients/fal_client/src/requests/api/mesh/sketch/hunyuan3d_3_sketch_to_mesh/api.rs new file mode 100644 index 0000000000..3cd24e27d4 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/sketch/hunyuan3d_3_sketch_to_mesh/api.rs @@ -0,0 +1,142 @@ +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::mesh::sketch::hunyuan3d_3_sketch_to_mesh::raw_request::{ + Hunyuan3d3SketchToMeshInput, Hunyuan3d3SketchToMeshOutput, +}; +use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + +/// Hunyuan3D V3 sketch-to-3D: turns a sketch or line-art image plus a text +/// prompt into a textured 3D mesh. +/// +/// NB: unlike the v3 image-to-3d and text-to-3d endpoints, fal's published +/// sketch-to-3d schema has no `generate_type` or `polygon_type` parameters. +#[derive(Clone, Debug)] +pub struct Hunyuan3d3SketchToMeshRequest { + /// URL of the sketch or line-art image. Resolution must be between + /// 128x128 and 5000x5000 pixels. + pub image_url: String, + + /// Text prompt describing the 3D content attributes such as color, + /// category, and material. + pub prompt: String, + + /// Target face count for the output mesh. Range 40000-1500000; + /// fal's default is 500000 when `None`. Setting a custom value adds cost. + pub face_count: Option, + + /// Whether to enable PBR (physically-based rendering) materials. + /// fal's default is false when `None`. Enabling adds cost. + pub enable_pbr: Option, +} + +impl FalEndpoint for Hunyuan3d3SketchToMeshRequest { + const ENDPOINT: &str = "fal-ai/hunyuan3d-v3/sketch-to-3d"; + + type RawRequest = Hunyuan3d3SketchToMeshInput; + type RawResponse = Hunyuan3d3SketchToMeshOutput; + + fn to_raw_request(&self) -> Result { + Ok(Self::RawRequest { + input_image_url: self.image_url.clone(), + prompt: self.prompt.clone(), + face_count: self.face_count, + enable_pbr: self.enable_pbr, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::traits::fal_endpoint_trait::FalEndpoint; + use errors::AnyhowResult; + use std::fs::read_to_string; + use test_data::web::image_urls::ERNEST_SCARED_STUPID_IMAGE_URL; + + // ── Real requests (manually run; require a live key and cost money) ── + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_sketch_to_mesh_webhook() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Hunyuan3d3SketchToMeshRequest { + image_url: ERNEST_SCARED_STUPID_IMAGE_URL.to_string(), + prompt: "A cute robot".to_string(), + face_count: None, + enable_pbr: None, + }; + + let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?; + println!("Webhook result: {:?}", result); + assert!(result.request_id.is_some() || result.gateway_request_id.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn test_sketch_to_mesh_queue() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let request = Hunyuan3d3SketchToMeshRequest { + image_url: ERNEST_SCARED_STUPID_IMAGE_URL.to_string(), + prompt: "A cute robot".to_string(), + face_count: None, + enable_pbr: None, + }; + + let result = request.send_queue_request(&api_key).await?; + println!("Queue result — request_id: {}", result.request_id); + assert!(!result.request_id.is_empty()); + Ok(()) + } + + // ── Wire-shape sanity (no API calls) ── + + #[test] + fn raw_request_maps_all_fields() { + let request = Hunyuan3d3SketchToMeshRequest { + image_url: "https://example.com/sketch.png".to_string(), + prompt: "A red ceramic teapot".to_string(), + face_count: Some(100_000), + enable_pbr: Some(true), + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!( + json, + serde_json::json!({ + "input_image_url": "https://example.com/sketch.png", + "prompt": "A red ceramic teapot", + "face_count": 100_000, + "enable_pbr": true, + }), + ); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let request = Hunyuan3d3SketchToMeshRequest { + image_url: "https://example.com/sketch.png".to_string(), + prompt: "minimal".to_string(), + face_count: None, + enable_pbr: None, + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!( + json, + serde_json::json!({ + "input_image_url": "https://example.com/sketch.png", + "prompt": "minimal", + }), + ); + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!(Hunyuan3d3SketchToMeshRequest::ENDPOINT, "fal-ai/hunyuan3d-v3/sketch-to-3d"); + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/sketch/hunyuan3d_3_sketch_to_mesh/cost.rs b/crates/api_clients/fal_client/src/requests/api/mesh/sketch/hunyuan3d_3_sketch_to_mesh/cost.rs new file mode 100644 index 0000000000..7ab955a2a5 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/sketch/hunyuan3d_3_sketch_to_mesh/cost.rs @@ -0,0 +1,78 @@ +use crate::requests::api::mesh::sketch::hunyuan3d_3_sketch_to_mesh::api::Hunyuan3d3SketchToMeshRequest; +use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents}; + +impl FalRequestCostCalculator for Hunyuan3d3SketchToMeshRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Pricing (see https://fal.ai/models/fal-ai/hunyuan3d-v3/sketch-to-3d): + // "Your request will cost $0.375 per generation." + // + // Base: $0.375 → 38¢ (rounded up). Sketch-to-3d has no generate_type + // parameter, so the LowPoly/Geometry prices of the other v3 endpoints + // do not apply here. + // + // Add-ons (each +$0.15 = 15¢): + // - PBR materials enabled + // - Custom face count specified + let mut cost: u64 = 38; + if self.enable_pbr.unwrap_or(false) { + cost += 15; + } + if self.face_count.is_some() { + cost += 15; + } + cost + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod cost_table { + use super::*; + + #[test] + fn base_cost() { + assert_eq!(base_request().calculate_cost_in_cents(), 38); + } + + #[test] + fn pbr_adds_fifteen() { + let mut req = base_request(); + req.enable_pbr = Some(true); + assert_eq!(req.calculate_cost_in_cents(), 38 + 15); + } + + #[test] + fn pbr_false_no_extra() { + let mut req = base_request(); + req.enable_pbr = Some(false); + assert_eq!(req.calculate_cost_in_cents(), 38); + } + + #[test] + fn face_count_adds_fifteen() { + let mut req = base_request(); + req.face_count = Some(50_000); + assert_eq!(req.calculate_cost_in_cents(), 38 + 15); + } + + #[test] + fn all_add_ons_stack() { + let mut req = base_request(); + req.enable_pbr = Some(true); + req.face_count = Some(100_000); + // Base(38) + PBR(15) + face_count(15) = 68 + assert_eq!(req.calculate_cost_in_cents(), 68); + } + } + + fn base_request() -> Hunyuan3d3SketchToMeshRequest { + Hunyuan3d3SketchToMeshRequest { + image_url: "https://example.com/sketch.png".to_string(), + prompt: "test".to_string(), + face_count: None, + enable_pbr: None, + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/sketch/hunyuan3d_3_sketch_to_mesh/mod.rs b/crates/api_clients/fal_client/src/requests/api/mesh/sketch/hunyuan3d_3_sketch_to_mesh/mod.rs new file mode 100644 index 0000000000..3bfb6c86db --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/sketch/hunyuan3d_3_sketch_to_mesh/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod cost; +pub mod raw_request; diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/sketch/hunyuan3d_3_sketch_to_mesh/raw_request.rs b/crates/api_clients/fal_client/src/requests/api/mesh/sketch/hunyuan3d_3_sketch_to_mesh/raw_request.rs new file mode 100644 index 0000000000..cb3d700ac7 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/sketch/hunyuan3d_3_sketch_to_mesh/raw_request.rs @@ -0,0 +1,28 @@ +use serde::{Deserialize, Serialize}; + +/// Over-the-wire input shape for `fal-ai/hunyuan3d-v3/sketch-to-3d`. +/// fal's schema: +/// +/// NB: unlike the v3 image-to-3d and text-to-3d endpoints, fal's published +/// sketch-to-3d schema has no `generate_type` or `polygon_type` parameters. +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Hunyuan3d3SketchToMeshInput { + /// URL of the sketch or line-art image to transform into a 3D model. + /// Image resolution must be between 128x128 and 5000x5000 pixels. + pub input_image_url: String, + + /// Text prompt describing the 3D content attributes such as color, + /// category, and material. + pub prompt: String, + + /// Target face count. Range: 40000-1500000. fal default: 500000. + #[serde(skip_serializing_if = "Option::is_none")] + pub face_count: Option, + + /// Whether to enable PBR material generation. fal default: false. + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_pbr: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Hunyuan3d3SketchToMeshOutput {} diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/sketch/mod.rs b/crates/api_clients/fal_client/src/requests/api/mesh/sketch/mod.rs new file mode 100644 index 0000000000..b3d854f8a1 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/mesh/sketch/mod.rs @@ -0,0 +1 @@ +pub mod hunyuan3d_3_sketch_to_mesh; diff --git a/crates/api_clients/fal_client/src/requests/api/mesh/text/hunyuan3d_3_text_to_mesh/api.rs b/crates/api_clients/fal_client/src/requests/api/mesh/text/hunyuan3d_3_text_to_mesh/api.rs index b704fac78e..b9f49c05b9 100644 --- a/crates/api_clients/fal_client/src/requests/api/mesh/text/hunyuan3d_3_text_to_mesh/api.rs +++ b/crates/api_clients/fal_client/src/requests/api/mesh/text/hunyuan3d_3_text_to_mesh/api.rs @@ -111,5 +111,84 @@ mod tests { Ok(()) } + // ── Wire-shape sanity (no API calls) ── + + #[test] + fn raw_request_maps_all_fields() { + let request = Hunyuan3d3TextToMeshRequest { + prompt: "A red ceramic teapot".to_string(), + face_count: Some(100_000), + generate_type: Some(Hunyuan3d3TextToMeshGenerateType::LowPoly), + polygon_type: Some(Hunyuan3d3TextToMeshPolygonType::Quadrilateral), + enable_pbr: Some(true), + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!( + json, + serde_json::json!({ + "prompt": "A red ceramic teapot", + "face_count": 100_000, + "generate_type": "LowPoly", + "polygon_type": "quadrilateral", + "enable_pbr": true, + }), + ); + } + + #[test] + fn raw_request_omits_unset_optionals() { + let request = Hunyuan3d3TextToMeshRequest { + prompt: "minimal".to_string(), + face_count: None, + generate_type: None, + polygon_type: None, + enable_pbr: None, + }; + let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap(); + assert_eq!(json, serde_json::json!({ "prompt": "minimal" })); + } + + #[test] + fn every_generate_type_maps_to_wire_string() { + for (variant, expected) in [ + (Hunyuan3d3TextToMeshGenerateType::Normal, "Normal"), + (Hunyuan3d3TextToMeshGenerateType::LowPoly, "LowPoly"), + (Hunyuan3d3TextToMeshGenerateType::Geometry, "Geometry"), + ] { + let mut request = base_wire_request(); + request.generate_type = Some(variant); + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.generate_type.as_deref(), Some(expected)); + } + } + + #[test] + fn every_polygon_type_maps_to_wire_string() { + for (variant, expected) in [ + (Hunyuan3d3TextToMeshPolygonType::Triangle, "triangle"), + (Hunyuan3d3TextToMeshPolygonType::Quadrilateral, "quadrilateral"), + ] { + let mut request = base_wire_request(); + request.polygon_type = Some(variant); + let raw = request.to_raw_request().unwrap(); + assert_eq!(raw.polygon_type.as_deref(), Some(expected)); + } + } + + #[test] + fn endpoint_path_is_canonical() { + assert_eq!(Hunyuan3d3TextToMeshRequest::ENDPOINT, "fal-ai/hunyuan3d-v3/text-to-3d"); + } + // NB: Pricing tests are in cost.rs + + fn base_wire_request() -> Hunyuan3d3TextToMeshRequest { + Hunyuan3d3TextToMeshRequest { + prompt: "test".to_string(), + face_count: None, + generate_type: None, + polygon_type: None, + enable_pbr: None, + } + } } diff --git a/crates/desktop/artcraft/src/core/commands/enqueue/generate_error.rs b/crates/desktop/artcraft/src/core/commands/enqueue/generate_error.rs index 8d65ef1c97..52bb545f62 100644 --- a/crates/desktop/artcraft/src/core/commands/enqueue/generate_error.rs +++ b/crates/desktop/artcraft/src/core/commands/enqueue/generate_error.rs @@ -238,6 +238,7 @@ impl From for GenerateError { ProviderError::Storyteller(_) => BillingProvider::Artcraft, ProviderError::GmiCloud(_) => BillingProvider::Artcraft, ProviderError::Grok(_) => BillingProvider::Artcraft, + ProviderError::WorldLabs(_) => BillingProvider::Artcraft, }; Self::BillingIssue(BillingIssueReason { provider }) }, @@ -246,6 +247,9 @@ impl From for GenerateError { ArtcraftRouterError::Provider(ProviderError::GmiCloud(_)) => Self::ArtcraftRouterNotYetSupportedProvider("gmicloud"), ArtcraftRouterError::Provider(ProviderError::Grok(_)) => Self::ArtcraftRouterNotYetSupportedProvider("grok_api"), ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(_)) => Self::ArtcraftRouterNotYetSupportedProvider("seedance2pro"), + // NB: the desktop app reaches World Labs through the Artcraft backend; the router's + // direct World Labs provider isn't used here. + ArtcraftRouterError::Provider(ProviderError::WorldLabs(_)) => Self::ArtcraftRouterNotYetSupportedProvider("world_labs"), ArtcraftRouterError::UnsupportedModel(model) => Self::NotYetImplemented(format!("Unsupported model: {}", model)), ArtcraftRouterError::UnsupportedProviderAndModelForNewApi(message) => Self::ArtcraftRouterNotYetSupportedProvider("unsupported model for new router API"), } diff --git a/crates/desktop/artcraft/src/core/commands/generate/common/router_image_request_to_artcraft_prompt.rs b/crates/desktop/artcraft/src/core/commands/generate/common/router_image_request_to_artcraft_prompt.rs index fa39e16fdf..42527c9904 100644 --- a/crates/desktop/artcraft/src/core/commands/generate/common/router_image_request_to_artcraft_prompt.rs +++ b/crates/desktop/artcraft/src/core/commands/generate/common/router_image_request_to_artcraft_prompt.rs @@ -72,6 +72,7 @@ fn provider_to_generation_provider(provider: RouterProvider) -> GenerationProvid RouterProvider::Seedance2Pro => GenerationProvider::Artcraft , RouterProvider::GmiCloud => GenerationProvider::Artcraft, RouterProvider::GrokApi => GenerationProvider::Artcraft, + RouterProvider::WorldLabs => GenerationProvider::Artcraft, } } diff --git a/crates/desktop/artcraft/src/core/commands/generate/common/router_video_request_to_artcraft_prompt.rs b/crates/desktop/artcraft/src/core/commands/generate/common/router_video_request_to_artcraft_prompt.rs index 549355856b..d86e48c118 100644 --- a/crates/desktop/artcraft/src/core/commands/generate/common/router_video_request_to_artcraft_prompt.rs +++ b/crates/desktop/artcraft/src/core/commands/generate/common/router_video_request_to_artcraft_prompt.rs @@ -95,6 +95,7 @@ fn provider_to_generation_provider(provider: RouterProvider) -> GenerationProvid RouterProvider::Seedance2Pro => GenerationProvider::Artcraft , RouterProvider::GmiCloud => GenerationProvider::Artcraft, RouterProvider::GrokApi => GenerationProvider::Artcraft, + RouterProvider::WorldLabs => GenerationProvider::Artcraft, } } diff --git a/crates/schema/database/mysql_queries/src/queries/generic_inference/api_providers/worldlabs/insert_generic_inference_job_for_worldlabs_queue_with_apriori_job_token.rs b/crates/schema/database/mysql_queries/src/queries/generic_inference/api_providers/worldlabs/insert_generic_inference_job_for_worldlabs_queue_with_apriori_job_token.rs index e6a9d13047..f91288f041 100644 --- a/crates/schema/database/mysql_queries/src/queries/generic_inference/api_providers/worldlabs/insert_generic_inference_job_for_worldlabs_queue_with_apriori_job_token.rs +++ b/crates/schema/database/mysql_queries/src/queries/generic_inference/api_providers/worldlabs/insert_generic_inference_job_for_worldlabs_queue_with_apriori_job_token.rs @@ -12,12 +12,14 @@ use enums::common::platform_type::PlatformType; use enums::common::visibility::Visibility; use tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken; use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken; use tokens::tokens::prompts::PromptToken; use tokens::tokens::users::UserToken; use tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken; use crate::errors::database_query_error::DatabaseQueryError; use crate::payloads::generic_inference_args::generic_inference_args::GenericInferenceArgs; +use crate::queries::generic_inference::common::job_cost_estimates::JobCostEstimates; use crate::queries::generic_inference::api_providers::common::insert_generic_inference_job_for_provider::{ insert_generic_inference_job_for_provider, InsertGenericInferenceJobForProviderArgs, @@ -49,6 +51,11 @@ pub struct InsertGenericInferenceForWorldlabsWithAprioriJobTokenArgs<'e, 'c, E> /// The platform the enqueuing request came from, inferred from its User-Agent. pub maybe_platform_type: Option, + /// Estimated system (user-facing) and provider-side costs. + pub maybe_cost_estimates: Option, + + pub maybe_debug_log_event_token: Option<&'e DebugLogEventToken>, + pub mysql_executor: E, pub phantom: PhantomData<&'c E>, @@ -76,8 +83,8 @@ pub async fn insert_generic_inference_job_for_worldlabs_queue_with_apriori_job_t creator_ip_address: args.creator_ip_address, creator_set_visibility: args.creator_set_visibility, maybe_platform_type: args.maybe_platform_type, - maybe_cost_estimates: None, - maybe_debug_log_event_token: None, + maybe_cost_estimates: args.maybe_cost_estimates, + maybe_debug_log_event_token: args.maybe_debug_log_event_token, maybe_frontend_failure_category: None, maybe_failure_reason: None, status: JobStatusPlus::Pending, diff --git a/crates/schema/public/enums/src/by_table/generic_inference_jobs/inference_model_type.rs b/crates/schema/public/enums/src/by_table/generic_inference_jobs/inference_model_type.rs index 084dc5f8ed..5030b979d8 100644 --- a/crates/schema/public/enums/src/by_table/generic_inference_jobs/inference_model_type.rs +++ b/crates/schema/public/enums/src/by_table/generic_inference_jobs/inference_model_type.rs @@ -257,6 +257,14 @@ pub enum InferenceModelType { Marble0p1Mini, #[serde(rename = "marble_0p1_plus")] Marble0p1Plus, + #[serde(rename = "marble_1p0")] + Marble1p0, + #[serde(rename = "marble_1p0_draft")] + Marble1p0Draft, + #[serde(rename = "marble_1p1")] + Marble1p1, + #[serde(rename = "marble_1p1_plus")] + Marble1p1Plus, } // TODO(bt, 2022-12-21): This desperately needs MySQL integration tests! @@ -378,6 +386,10 @@ impl InferenceModelType { // Splat generation models (World Labs) Self::Marble0p1Mini => "marble_0p1_mini", Self::Marble0p1Plus => "marble_0p1_plus", + Self::Marble1p0 => "marble_1p0", + Self::Marble1p0Draft => "marble_1p0_draft", + Self::Marble1p1 => "marble_1p1", + Self::Marble1p1Plus => "marble_1p1_plus", } } @@ -494,6 +506,10 @@ impl InferenceModelType { // Splat generation models (World Labs) "marble_0p1_mini" => Ok(Self::Marble0p1Mini), "marble_0p1_plus" => Ok(Self::Marble0p1Plus), + "marble_1p0" => Ok(Self::Marble1p0), + "marble_1p0_draft" => Ok(Self::Marble1p0Draft), + "marble_1p1" => Ok(Self::Marble1p1), + "marble_1p1_plus" => Ok(Self::Marble1p1Plus), _ => Err(format!("invalid value: {:?}", value)), } @@ -614,6 +630,10 @@ impl InferenceModelType { // Splat generation models (World Labs) Self::Marble0p1Mini, Self::Marble0p1Plus, + Self::Marble1p0, + Self::Marble1p0Draft, + Self::Marble1p1, + Self::Marble1p1Plus, ]) } @@ -717,6 +737,10 @@ impl InferenceModelType { // Splat generation models (World Labs) CommonModelType::Marble0p1Mini => Self::Marble0p1Mini, CommonModelType::Marble0p1Plus => Self::Marble0p1Plus, + CommonModelType::Marble1p0 => Self::Marble1p0, + CommonModelType::Marble1p0Draft => Self::Marble1p0Draft, + CommonModelType::Marble1p1 => Self::Marble1p1, + CommonModelType::Marble1p1Plus => Self::Marble1p1Plus, } } } @@ -841,6 +865,10 @@ mod tests { // Splat generation models (World Labs) assert_serialization(InferenceModelType::Marble0p1Mini, "marble_0p1_mini"); assert_serialization(InferenceModelType::Marble0p1Plus, "marble_0p1_plus"); + assert_serialization(InferenceModelType::Marble1p0, "marble_1p0"); + assert_serialization(InferenceModelType::Marble1p0Draft, "marble_1p0_draft"); + assert_serialization(InferenceModelType::Marble1p1, "marble_1p1"); + assert_serialization(InferenceModelType::Marble1p1Plus, "marble_1p1_plus"); } #[test] @@ -956,6 +984,10 @@ mod tests { // Splat generation models (World Labs) assert_eq!(InferenceModelType::Marble0p1Mini.to_str(), "marble_0p1_mini"); assert_eq!(InferenceModelType::Marble0p1Plus.to_str(), "marble_0p1_plus"); + assert_eq!(InferenceModelType::Marble1p0.to_str(), "marble_1p0"); + assert_eq!(InferenceModelType::Marble1p0Draft.to_str(), "marble_1p0_draft"); + assert_eq!(InferenceModelType::Marble1p1.to_str(), "marble_1p1"); + assert_eq!(InferenceModelType::Marble1p1Plus.to_str(), "marble_1p1_plus"); } #[test] @@ -1069,6 +1101,10 @@ mod tests { // Splat generation models (World Labs) assert_eq!(InferenceModelType::from_str("marble_0p1_mini").unwrap(), InferenceModelType::Marble0p1Mini); assert_eq!(InferenceModelType::from_str("marble_0p1_plus").unwrap(), InferenceModelType::Marble0p1Plus); + assert_eq!(InferenceModelType::from_str("marble_1p0").unwrap(), InferenceModelType::Marble1p0); + assert_eq!(InferenceModelType::from_str("marble_1p0_draft").unwrap(), InferenceModelType::Marble1p0Draft); + assert_eq!(InferenceModelType::from_str("marble_1p1").unwrap(), InferenceModelType::Marble1p1); + assert_eq!(InferenceModelType::from_str("marble_1p1_plus").unwrap(), InferenceModelType::Marble1p1Plus); } #[test] diff --git a/crates/schema/public/enums/src/common/generation/common_mesh_model.rs b/crates/schema/public/enums/src/common/generation/common_mesh_model.rs new file mode 100644 index 0000000000..a014bede27 --- /dev/null +++ b/crates/schema/public/enums/src/common/generation/common_mesh_model.rs @@ -0,0 +1,93 @@ +use utoipa::ToSchema; + +/// Mesh (3D object) models available for generation. +#[derive(Copy, Clone, Debug, Eq, PartialEq, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum CommonMeshModel { + #[serde(rename = "hunyuan_3d_2p0")] + Hunyuan3d2p0, + + #[serde(rename = "hunyuan_3d_2p1")] + Hunyuan3d2p1, + + #[serde(rename = "hunyuan_3d_3")] + Hunyuan3d3, + + /// Hunyuan 3D v3 in sketch-to-3D mode. Same underlying model as + /// [`Self::Hunyuan3d3`], but takes a sketch image as input. + #[serde(rename = "hunyuan_3d_3_sketch")] + Hunyuan3d3Sketch, +} + +impl CommonMeshModel { + pub fn to_common_model_type(&self) -> crate::common::generation::common_model_type::CommonModelType { + use crate::common::generation::common_model_type::CommonModelType; + match self { + Self::Hunyuan3d2p0 => CommonModelType::Hunyuan3d2_0, + Self::Hunyuan3d2p1 => CommonModelType::Hunyuan3d2_1, + Self::Hunyuan3d3 => CommonModelType::Hunyuan3d3, + // Sketch mode is the same model, just a different input mode. + Self::Hunyuan3d3Sketch => CommonModelType::Hunyuan3d3, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::common::generation::common_model_type::CommonModelType; + use crate::test_helpers::assert_serialization; + + #[test] + fn test_serialization() { + assert_serialization(CommonMeshModel::Hunyuan3d2p0, "hunyuan_3d_2p0"); + assert_serialization(CommonMeshModel::Hunyuan3d2p1, "hunyuan_3d_2p1"); + assert_serialization(CommonMeshModel::Hunyuan3d3, "hunyuan_3d_3"); + assert_serialization(CommonMeshModel::Hunyuan3d3Sketch, "hunyuan_3d_3_sketch"); + } + + #[test] + fn test_deserialization() { + let cases = [ + ("hunyuan_3d_2p0", CommonMeshModel::Hunyuan3d2p0), + ("hunyuan_3d_2p1", CommonMeshModel::Hunyuan3d2p1), + ("hunyuan_3d_3", CommonMeshModel::Hunyuan3d3), + ("hunyuan_3d_3_sketch", CommonMeshModel::Hunyuan3d3Sketch), + ]; + for (json_str, expected) in cases { + let json = format!("\"{}\"", json_str); + let deserialized: CommonMeshModel = serde_json::from_str(&json) + .unwrap_or_else(|e| panic!("Failed to deserialize {:?}: {}", json_str, e)); + assert_eq!(deserialized, expected, "Failed for {:?}", json_str); + } + } + + #[test] + fn test_round_trip() { + let all = [ + CommonMeshModel::Hunyuan3d2p0, + CommonMeshModel::Hunyuan3d2p1, + CommonMeshModel::Hunyuan3d3, + CommonMeshModel::Hunyuan3d3Sketch, + ]; + for variant in all { + let json = serde_json::to_string(&variant).unwrap(); + let deserialized: CommonMeshModel = serde_json::from_str(&json).unwrap(); + assert_eq!(variant, deserialized, "Round-trip failed for {:?}", variant); + } + } + + #[test] + fn all_mesh_models_convert_to_common_model_type() { + let models = [ + (CommonMeshModel::Hunyuan3d2p0, CommonModelType::Hunyuan3d2_0), + (CommonMeshModel::Hunyuan3d2p1, CommonModelType::Hunyuan3d2_1), + (CommonMeshModel::Hunyuan3d3, CommonModelType::Hunyuan3d3), + // Sketch mode maps to the same underlying model. + (CommonMeshModel::Hunyuan3d3Sketch, CommonModelType::Hunyuan3d3), + ]; + for (mesh_model, expected) in models { + assert_eq!(mesh_model.to_common_model_type(), expected); + } + } +} diff --git a/crates/schema/public/enums/src/common/generation/common_mesh_output_type.rs b/crates/schema/public/enums/src/common/generation/common_mesh_output_type.rs new file mode 100644 index 0000000000..0e0a97b3d7 --- /dev/null +++ b/crates/schema/public/enums/src/common/generation/common_mesh_output_type.rs @@ -0,0 +1,60 @@ +use utoipa::ToSchema; + +/// Mesh output types for 3D object generation. +#[derive(Copy, Clone, Debug, Eq, PartialEq, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum CommonMeshOutputType { + /// A standard, fully-detailed mesh. + #[serde(rename = "normal")] + Normal, + + /// A reduced-polygon mesh suitable for real-time rendering. + #[serde(rename = "low_poly")] + LowPoly, + + /// Geometry only, without textures/materials. + #[serde(rename = "geometry")] + Geometry, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_helpers::assert_serialization; + + #[test] + fn test_serialization() { + assert_serialization(CommonMeshOutputType::Normal, "normal"); + assert_serialization(CommonMeshOutputType::LowPoly, "low_poly"); + assert_serialization(CommonMeshOutputType::Geometry, "geometry"); + } + + #[test] + fn test_deserialization() { + let cases = [ + ("normal", CommonMeshOutputType::Normal), + ("low_poly", CommonMeshOutputType::LowPoly), + ("geometry", CommonMeshOutputType::Geometry), + ]; + for (json_str, expected) in cases { + let json = format!("\"{}\"", json_str); + let deserialized: CommonMeshOutputType = serde_json::from_str(&json) + .unwrap_or_else(|e| panic!("Failed to deserialize {:?}: {}", json_str, e)); + assert_eq!(deserialized, expected, "Failed for {:?}", json_str); + } + } + + #[test] + fn test_round_trip() { + let all = [ + CommonMeshOutputType::Normal, + CommonMeshOutputType::LowPoly, + CommonMeshOutputType::Geometry, + ]; + for variant in all { + let json = serde_json::to_string(&variant).unwrap(); + let deserialized: CommonMeshOutputType = serde_json::from_str(&json).unwrap(); + assert_eq!(variant, deserialized, "Round-trip failed for {:?}", variant); + } + } +} diff --git a/crates/schema/public/enums/src/common/generation/common_model_type.rs b/crates/schema/public/enums/src/common/generation/common_model_type.rs index bc2d4dd21b..4d18fc81ba 100644 --- a/crates/schema/public/enums/src/common/generation/common_model_type.rs +++ b/crates/schema/public/enums/src/common/generation/common_model_type.rs @@ -226,6 +226,14 @@ pub enum CommonModelType { Marble0p1Mini, #[serde(rename = "marble_0p1_plus")] Marble0p1Plus, + #[serde(rename = "marble_1p0")] + Marble1p0, + #[serde(rename = "marble_1p0_draft")] + Marble1p0Draft, + #[serde(rename = "marble_1p1")] + Marble1p1, + #[serde(rename = "marble_1p1_plus")] + Marble1p1Plus, } impl_enum_display_and_debug_using_to_str!(CommonModelType); @@ -331,6 +339,10 @@ impl CommonModelType { // Splat generation models (World Labs) Self::Marble0p1Mini => "marble_0p1_mini", Self::Marble0p1Plus => "marble_0p1_plus", + Self::Marble1p0 => "marble_1p0", + Self::Marble1p0Draft => "marble_1p0_draft", + Self::Marble1p1 => "marble_1p1", + Self::Marble1p1Plus => "marble_1p1_plus", } } @@ -430,6 +442,10 @@ impl CommonModelType { // Splat generation models (World Labs) "marble_0p1_mini" => Ok(Self::Marble0p1Mini), "marble_0p1_plus" => Ok(Self::Marble0p1Plus), + "marble_1p0" => Ok(Self::Marble1p0), + "marble_1p0_draft" => Ok(Self::Marble1p0Draft), + "marble_1p1" => Ok(Self::Marble1p1), + "marble_1p1_plus" => Ok(Self::Marble1p1Plus), _ => Err(format!("invalid model_type: {:?}", job_status)), } @@ -533,6 +549,10 @@ impl CommonModelType { // Splat generation models (World Labs) Self::Marble0p1Mini, Self::Marble0p1Plus, + Self::Marble1p0, + Self::Marble1p0Draft, + Self::Marble1p1, + Self::Marble1p1Plus, ]) } @@ -633,6 +653,10 @@ impl CommonModelType { // Splat generation models (World Labs) Self::Marble0p1Mini => CommonModelClass::DimensionalSplat, Self::Marble0p1Plus => CommonModelClass::DimensionalSplat, + Self::Marble1p0 => CommonModelClass::DimensionalSplat, + Self::Marble1p0Draft => CommonModelClass::DimensionalSplat, + Self::Marble1p1 => CommonModelClass::DimensionalSplat, + Self::Marble1p1Plus => CommonModelClass::DimensionalSplat, } } } @@ -738,6 +762,10 @@ mod tests { // Splat generation models (World Labs) assert_serialization(CommonModelType::Marble0p1Mini, "marble_0p1_mini"); assert_serialization(CommonModelType::Marble0p1Plus, "marble_0p1_plus"); + assert_serialization(CommonModelType::Marble1p0, "marble_1p0"); + assert_serialization(CommonModelType::Marble1p0Draft, "marble_1p0_draft"); + assert_serialization(CommonModelType::Marble1p1, "marble_1p1"); + assert_serialization(CommonModelType::Marble1p1Plus, "marble_1p1_plus"); } #[test] @@ -835,6 +863,10 @@ mod tests { // Splat generation models (World Labs) assert_eq!(CommonModelType::Marble0p1Mini.to_str(), "marble_0p1_mini"); assert_eq!(CommonModelType::Marble0p1Plus.to_str(), "marble_0p1_plus"); + assert_eq!(CommonModelType::Marble1p0.to_str(), "marble_1p0"); + assert_eq!(CommonModelType::Marble1p0Draft.to_str(), "marble_1p0_draft"); + assert_eq!(CommonModelType::Marble1p1.to_str(), "marble_1p1"); + assert_eq!(CommonModelType::Marble1p1Plus.to_str(), "marble_1p1_plus"); } #[test] @@ -929,12 +961,16 @@ mod tests { // Splat generation models (World Labs) assert_eq!(CommonModelType::from_str("marble_0p1_mini").unwrap(), CommonModelType::Marble0p1Mini); assert_eq!(CommonModelType::from_str("marble_0p1_plus").unwrap(), CommonModelType::Marble0p1Plus); + assert_eq!(CommonModelType::from_str("marble_1p0").unwrap(), CommonModelType::Marble1p0); + assert_eq!(CommonModelType::from_str("marble_1p0_draft").unwrap(), CommonModelType::Marble1p0Draft); + assert_eq!(CommonModelType::from_str("marble_1p1").unwrap(), CommonModelType::Marble1p1); + assert_eq!(CommonModelType::from_str("marble_1p1_plus").unwrap(), CommonModelType::Marble1p1Plus); } #[test] fn all_variants() { let mut variants = CommonModelType::all_variants(); - assert_eq!(variants.len(), 84); + assert_eq!(variants.len(), 88); // Image models assert_eq!(variants.pop_first(), Some(CommonModelType::Flux1Dev)); assert_eq!(variants.pop_first(), Some(CommonModelType::Flux1Schnell)); @@ -1024,6 +1060,10 @@ mod tests { // Splat generation models (World Labs) assert_eq!(variants.pop_first(), Some(CommonModelType::Marble0p1Mini)); assert_eq!(variants.pop_first(), Some(CommonModelType::Marble0p1Plus)); + assert_eq!(variants.pop_first(), Some(CommonModelType::Marble1p0)); + assert_eq!(variants.pop_first(), Some(CommonModelType::Marble1p0Draft)); + assert_eq!(variants.pop_first(), Some(CommonModelType::Marble1p1)); + assert_eq!(variants.pop_first(), Some(CommonModelType::Marble1p1Plus)); assert_eq!(variants.pop_first(), None); } @@ -1116,6 +1156,10 @@ mod tests { assert_eq!(CommonModelType::Hunyuan3d3.get_model_class(), CommonModelClass::DimensionalMesh); assert_eq!(CommonModelType::Marble0p1Mini.get_model_class(), CommonModelClass::DimensionalSplat); assert_eq!(CommonModelType::Marble0p1Plus.get_model_class(), CommonModelClass::DimensionalSplat); + assert_eq!(CommonModelType::Marble1p0.get_model_class(), CommonModelClass::DimensionalSplat); + assert_eq!(CommonModelType::Marble1p0Draft.get_model_class(), CommonModelClass::DimensionalSplat); + assert_eq!(CommonModelType::Marble1p1.get_model_class(), CommonModelClass::DimensionalSplat); + assert_eq!(CommonModelType::Marble1p1Plus.get_model_class(), CommonModelClass::DimensionalSplat); } } } diff --git a/crates/schema/public/enums/src/common/generation/common_polygon_type.rs b/crates/schema/public/enums/src/common/generation/common_polygon_type.rs new file mode 100644 index 0000000000..bf6a96d274 --- /dev/null +++ b/crates/schema/public/enums/src/common/generation/common_polygon_type.rs @@ -0,0 +1,51 @@ +use utoipa::ToSchema; + +/// Polygon types for generated meshes. +#[derive(Copy, Clone, Debug, Eq, PartialEq, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum CommonPolygonType { + #[serde(rename = "triangle")] + Triangle, + + #[serde(rename = "quad")] + Quad, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_helpers::assert_serialization; + + #[test] + fn test_serialization() { + assert_serialization(CommonPolygonType::Triangle, "triangle"); + assert_serialization(CommonPolygonType::Quad, "quad"); + } + + #[test] + fn test_deserialization() { + let cases = [ + ("triangle", CommonPolygonType::Triangle), + ("quad", CommonPolygonType::Quad), + ]; + for (json_str, expected) in cases { + let json = format!("\"{}\"", json_str); + let deserialized: CommonPolygonType = serde_json::from_str(&json) + .unwrap_or_else(|e| panic!("Failed to deserialize {:?}: {}", json_str, e)); + assert_eq!(deserialized, expected, "Failed for {:?}", json_str); + } + } + + #[test] + fn test_round_trip() { + let all = [ + CommonPolygonType::Triangle, + CommonPolygonType::Quad, + ]; + for variant in all { + let json = serde_json::to_string(&variant).unwrap(); + let deserialized: CommonPolygonType = serde_json::from_str(&json).unwrap(); + assert_eq!(variant, deserialized, "Round-trip failed for {:?}", variant); + } + } +} diff --git a/crates/schema/public/enums/src/common/generation/common_splat_model.rs b/crates/schema/public/enums/src/common/generation/common_splat_model.rs index 1db4fd2953..989e38d90a 100644 --- a/crates/schema/public/enums/src/common/generation/common_splat_model.rs +++ b/crates/schema/public/enums/src/common/generation/common_splat_model.rs @@ -1,8 +1,7 @@ use utoipa::ToSchema; /// Splat models available for generation. -/// Mirrors artcraft_router::api::router_splat_model::RouterSplatModel. -#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)] +#[derive(Copy, Clone, Debug, Eq, PartialEq, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "snake_case")] pub enum CommonSplatModel { #[serde(rename = "marble_0p1_mini")] @@ -10,4 +9,97 @@ pub enum CommonSplatModel { #[serde(rename = "marble_0p1_plus")] Marble0p1Plus, + + #[serde(rename = "marble_1p0")] + Marble1p0, + + #[serde(rename = "marble_1p0_draft")] + Marble1p0Draft, + + #[serde(rename = "marble_1p1")] + Marble1p1, + + #[serde(rename = "marble_1p1_plus")] + Marble1p1Plus, +} + +impl CommonSplatModel { + pub fn to_common_model_type(&self) -> crate::common::generation::common_model_type::CommonModelType { + use crate::common::generation::common_model_type::CommonModelType; + match self { + Self::Marble0p1Mini => CommonModelType::Marble0p1Mini, + Self::Marble0p1Plus => CommonModelType::Marble0p1Plus, + Self::Marble1p0 => CommonModelType::Marble1p0, + Self::Marble1p0Draft => CommonModelType::Marble1p0Draft, + Self::Marble1p1 => CommonModelType::Marble1p1, + Self::Marble1p1Plus => CommonModelType::Marble1p1Plus, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::common::generation::common_model_type::CommonModelType; + use crate::test_helpers::assert_serialization; + + #[test] + fn test_serialization() { + assert_serialization(CommonSplatModel::Marble0p1Mini, "marble_0p1_mini"); + assert_serialization(CommonSplatModel::Marble0p1Plus, "marble_0p1_plus"); + assert_serialization(CommonSplatModel::Marble1p0, "marble_1p0"); + assert_serialization(CommonSplatModel::Marble1p0Draft, "marble_1p0_draft"); + assert_serialization(CommonSplatModel::Marble1p1, "marble_1p1"); + assert_serialization(CommonSplatModel::Marble1p1Plus, "marble_1p1_plus"); + } + + #[test] + fn test_deserialization() { + let cases = [ + ("marble_0p1_mini", CommonSplatModel::Marble0p1Mini), + ("marble_0p1_plus", CommonSplatModel::Marble0p1Plus), + ("marble_1p0", CommonSplatModel::Marble1p0), + ("marble_1p0_draft", CommonSplatModel::Marble1p0Draft), + ("marble_1p1", CommonSplatModel::Marble1p1), + ("marble_1p1_plus", CommonSplatModel::Marble1p1Plus), + ]; + for (json_str, expected) in cases { + let json = format!("\"{}\"", json_str); + let deserialized: CommonSplatModel = serde_json::from_str(&json) + .unwrap_or_else(|e| panic!("Failed to deserialize {:?}: {}", json_str, e)); + assert_eq!(deserialized, expected, "Failed for {:?}", json_str); + } + } + + #[test] + fn test_round_trip() { + let all = [ + CommonSplatModel::Marble0p1Mini, + CommonSplatModel::Marble0p1Plus, + CommonSplatModel::Marble1p0, + CommonSplatModel::Marble1p0Draft, + CommonSplatModel::Marble1p1, + CommonSplatModel::Marble1p1Plus, + ]; + for variant in all { + let json = serde_json::to_string(&variant).unwrap(); + let deserialized: CommonSplatModel = serde_json::from_str(&json).unwrap(); + assert_eq!(variant, deserialized, "Round-trip failed for {:?}", variant); + } + } + + #[test] + fn all_splat_models_convert_to_common_model_type() { + let models = [ + (CommonSplatModel::Marble0p1Mini, CommonModelType::Marble0p1Mini), + (CommonSplatModel::Marble0p1Plus, CommonModelType::Marble0p1Plus), + (CommonSplatModel::Marble1p0, CommonModelType::Marble1p0), + (CommonSplatModel::Marble1p0Draft, CommonModelType::Marble1p0Draft), + (CommonSplatModel::Marble1p1, CommonModelType::Marble1p1), + (CommonSplatModel::Marble1p1Plus, CommonModelType::Marble1p1Plus), + ]; + for (splat_model, expected) in models { + assert_eq!(splat_model.to_common_model_type(), expected); + } + } } diff --git a/crates/schema/public/enums/src/common/generation/mod.rs b/crates/schema/public/enums/src/common/generation/mod.rs index 2b8cea2dda..1e8f99d124 100644 --- a/crates/schema/public/enums/src/common/generation/mod.rs +++ b/crates/schema/public/enums/src/common/generation/mod.rs @@ -3,7 +3,10 @@ pub mod common_audio_model; pub mod common_bitrate; pub mod common_generation_mode; pub mod common_image_model; +pub mod common_mesh_model; +pub mod common_mesh_output_type; pub mod common_musical_key; +pub mod common_polygon_type; pub mod common_quality; pub mod common_model_class; pub mod common_model_type; diff --git a/crates/service/web/storyteller_web/src/configs/omni_gen/mesh_models.rs b/crates/service/web/storyteller_web/src/configs/omni_gen/mesh_models.rs new file mode 100644 index 0000000000..afd908c730 --- /dev/null +++ b/crates/service/web/storyteller_web/src/configs/omni_gen/mesh_models.rs @@ -0,0 +1,118 @@ +use artcraft_api_defs::omni_gen::models::omni_gen_mesh_models::{ + OmniGenMeshModelDetails, + OmniGenMeshModelProviderDetails, + OmniGenMeshModelsResponse, + OmniGenMeshProviderModelDetails, +}; +use enums::common::generation::common_mesh_model::CommonMeshModel; +use enums::common::generation::common_mesh_output_type::CommonMeshOutputType; +use enums::common::generation::common_polygon_type::CommonPolygonType; +use enums::common::generation::model_creator::ModelCreator; +use enums::common::generation_provider::GenerationProvider; +use once_cell::sync::Lazy; + +pub static OMNI_GEN_MESH_MODELS_AND_PROVIDERS: Lazy = Lazy::new(|| { + let models = build_omni_gen_mesh_models(); + let providers = build_omni_gen_mesh_model_providers(); + OmniGenMeshModelsResponse { + success: true, + models, + providers, + } +}); + +fn build_omni_gen_mesh_models() -> Vec { + let mut models = Vec::new(); + + // Hunyuan 3D 2.0: image-to-3D only (exactly one input image). + models.push(OmniGenMeshModelDetails { + model: CommonMeshModel::Hunyuan3d2p0, + model_creator: Some(ModelCreator::Tencent), + full_name: Some("Hunyuan 3D 2.0".to_string()), + image_input_supported: Some(true), + mesh_output_types: Some(vec![ + CommonMeshOutputType::Normal, + CommonMeshOutputType::Geometry, + ]), + ..Default::default() + }); + + // Hunyuan 3D 2.1: image-to-3D only (exactly one input image). + models.push(OmniGenMeshModelDetails { + model: CommonMeshModel::Hunyuan3d2p1, + model_creator: Some(ModelCreator::Tencent), + full_name: Some("Hunyuan 3D 2.1".to_string()), + image_input_supported: Some(true), + mesh_output_types: Some(vec![ + CommonMeshOutputType::Normal, + CommonMeshOutputType::Geometry, + ]), + ..Default::default() + }); + + // Hunyuan 3D 3: text and/or image input with multi-view support and full + // output shaping controls (output type, polygon type, face count, PBR). + models.push(OmniGenMeshModelDetails { + model: CommonMeshModel::Hunyuan3d3, + model_creator: Some(ModelCreator::Tencent), + full_name: Some("Hunyuan 3D 3".to_string()), + text_prompt_supported: Some(true), + image_input_supported: Some(true), + multi_view_supported: Some(true), + mesh_output_types: Some(vec![ + CommonMeshOutputType::Normal, + CommonMeshOutputType::LowPoly, + CommonMeshOutputType::Geometry, + ]), + polygon_types: Some(vec![ + CommonPolygonType::Triangle, + CommonPolygonType::Quad, + ]), + face_count_supported: Some(true), + pbr_supported: Some(true), + ..Default::default() + }); + + // Hunyuan 3D 3 Sketch: sketch-to-3D. Requires both a sketch image and a + // text prompt. + models.push(OmniGenMeshModelDetails { + model: CommonMeshModel::Hunyuan3d3Sketch, + model_creator: Some(ModelCreator::Tencent), + full_name: Some("Hunyuan 3D 3 Sketch".to_string()), + text_prompt_supported: Some(true), + sketch_input_supported: Some(true), + face_count_supported: Some(true), + pbr_supported: Some(true), + ..Default::default() + }); + + models +} + +fn build_omni_gen_mesh_model_providers() -> Vec { + let mut providers = Vec::new(); + + providers.push(OmniGenMeshModelProviderDetails { + provider: GenerationProvider::Artcraft, + models: vec![ + OmniGenMeshProviderModelDetails { + model: CommonMeshModel::Hunyuan3d2p0, + overrides: None, + }, + OmniGenMeshProviderModelDetails { + model: CommonMeshModel::Hunyuan3d2p1, + overrides: None, + }, + OmniGenMeshProviderModelDetails { + model: CommonMeshModel::Hunyuan3d3, + overrides: None, + }, + OmniGenMeshProviderModelDetails { + model: CommonMeshModel::Hunyuan3d3Sketch, + overrides: None, + }, + ], + }); + + providers +} diff --git a/crates/service/web/storyteller_web/src/configs/omni_gen/mod.rs b/crates/service/web/storyteller_web/src/configs/omni_gen/mod.rs index a718ad7b8e..095d15194d 100644 --- a/crates/service/web/storyteller_web/src/configs/omni_gen/mod.rs +++ b/crates/service/web/storyteller_web/src/configs/omni_gen/mod.rs @@ -1,3 +1,5 @@ pub mod audio_models; pub mod image_models; +pub mod mesh_models; +pub mod splat_models; pub mod video_models; diff --git a/crates/service/web/storyteller_web/src/configs/omni_gen/splat_models.rs b/crates/service/web/storyteller_web/src/configs/omni_gen/splat_models.rs new file mode 100644 index 0000000000..b783af876c --- /dev/null +++ b/crates/service/web/storyteller_web/src/configs/omni_gen/splat_models.rs @@ -0,0 +1,78 @@ +use artcraft_api_defs::omni_gen::models::omni_gen_splat_models::{ + OmniGenSplatModelDetails, + OmniGenSplatModelProviderDetails, + OmniGenSplatModelsResponse, + OmniGenSplatProviderModelDetails, +}; +use enums::common::generation::common_splat_model::CommonSplatModel; +use enums::common::generation::model_creator::ModelCreator; +use enums::common::generation_provider::GenerationProvider; +use once_cell::sync::Lazy; + +/// World Labs MultiImage input accepts up to this many reference images. +const MARBLE_MAX_IMAGE_REFERENCES: u16 = 4; + +pub static OMNI_GEN_SPLAT_MODELS_AND_PROVIDERS: Lazy = Lazy::new(|| { + let models = build_omni_gen_splat_models(); + let providers = build_omni_gen_splat_model_providers(); + OmniGenSplatModelsResponse { + success: true, + models, + providers, + } +}); + +fn build_omni_gen_splat_models() -> Vec { + vec![ + marble_model(CommonSplatModel::Marble1p0, "Marble 1.0"), + marble_model(CommonSplatModel::Marble1p0Draft, "Marble 1.0 Draft"), + marble_model(CommonSplatModel::Marble1p1, "Marble 1.1"), + marble_model(CommonSplatModel::Marble1p1Plus, "Marble 1.1 Plus"), + ] +} + +fn build_omni_gen_splat_model_providers() -> Vec { + let mut providers = Vec::new(); + + providers.push(OmniGenSplatModelProviderDetails { + provider: GenerationProvider::Artcraft, + models: vec![ + OmniGenSplatProviderModelDetails { + model: CommonSplatModel::Marble1p0, + overrides: None, + }, + OmniGenSplatProviderModelDetails { + model: CommonSplatModel::Marble1p0Draft, + overrides: None, + }, + OmniGenSplatProviderModelDetails { + model: CommonSplatModel::Marble1p1, + overrides: None, + }, + OmniGenSplatProviderModelDetails { + model: CommonSplatModel::Marble1p1Plus, + overrides: None, + }, + ], + }); + + providers +} + +/// All Marble models share the same capability surface: text prompt, image +/// references (up to 4, multi-view), a reference video, 360-degree panorama +/// input, and the "disable recaption" toggle. +fn marble_model(model: CommonSplatModel, full_name: &str) -> OmniGenSplatModelDetails { + OmniGenSplatModelDetails { + model, + model_creator: Some(ModelCreator::WorldLabs), + full_name: Some(full_name.to_string()), + text_prompt_supported: Some(true), + image_references_supported: Some(true), + image_references_max: Some(MARBLE_MAX_IMAGE_REFERENCES), + video_reference_supported: Some(true), + panorama_supported: Some(true), + disable_recaption_supported: Some(true), + ..Default::default() + } +} diff --git a/crates/service/web/storyteller_web/src/docs/api_doc.rs b/crates/service/web/storyteller_web/src/docs/api_doc.rs index 1d44e0e79f..93e371fa3b 100644 --- a/crates/service/web/storyteller_web/src/docs/api_doc.rs +++ b/crates/service/web/storyteller_web/src/docs/api_doc.rs @@ -346,15 +346,23 @@ use artcraft_api_defs::omni_api::generate_requests::omni_api_image_generate_requ use artcraft_api_defs::omni_api::generate_requests::omni_api_video_generate_request::*; use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_audio_cost_and_generate_request::*; use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::*; +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_mesh_cost_and_generate_request::*; +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_splat_cost_and_generate_request::*; use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::*; use artcraft_api_defs::omni_gen::cost_response::omni_gen_audio_cost_response::*; use artcraft_api_defs::omni_gen::cost_response::omni_gen_image_cost_response::*; +use artcraft_api_defs::omni_gen::cost_response::omni_gen_mesh_cost_response::*; +use artcraft_api_defs::omni_gen::cost_response::omni_gen_splat_cost_response::*; use artcraft_api_defs::omni_gen::cost_response::omni_gen_video_cost_response::*; use artcraft_api_defs::omni_gen::generate_response::omni_gen_audio_generate_response::*; use artcraft_api_defs::omni_gen::generate_response::omni_gen_image_generate_response::*; +use artcraft_api_defs::omni_gen::generate_response::omni_gen_mesh_generate_response::*; +use artcraft_api_defs::omni_gen::generate_response::omni_gen_splat_generate_response::*; use artcraft_api_defs::omni_gen::generate_response::omni_gen_video_generate_response::*; use artcraft_api_defs::omni_gen::models::omni_gen_audio_models::*; use artcraft_api_defs::omni_gen::models::omni_gen_image_models::*; +use artcraft_api_defs::omni_gen::models::omni_gen_mesh_models::*; +use artcraft_api_defs::omni_gen::models::omni_gen_splat_models::*; use artcraft_api_defs::omni_gen::models::omni_gen_video_models::*; use artcraft_api_defs::omni_api::job_status::omni_api_batch_get_job_status::*; use artcraft_api_defs::omni_api::job_status::omni_api_get_job_status::*; @@ -572,12 +580,18 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux // Omni Gen crate::http_server::endpoints::omni_gen::cost::audio::omni_gen_audio_cost_handler::omni_gen_audio_cost_handler, crate::http_server::endpoints::omni_gen::cost::image::omni_gen_image_cost_handler::omni_gen_image_cost_handler, + crate::http_server::endpoints::omni_gen::cost::mesh::omni_gen_mesh_cost_handler::omni_gen_mesh_cost_handler, + crate::http_server::endpoints::omni_gen::cost::splat::omni_gen_splat_cost_handler::omni_gen_splat_cost_handler, crate::http_server::endpoints::omni_gen::cost::video::omni_gen_video_cost_handler::omni_gen_video_cost_handler, crate::http_server::endpoints::omni_gen::generate::audio::omni_gen_audio_generate_handler::omni_gen_audio_generate_handler, crate::http_server::endpoints::omni_gen::generate::image::omni_gen_image_generate_handler::omni_gen_image_generate_handler, + crate::http_server::endpoints::omni_gen::generate::mesh::omni_gen_mesh_generate_handler::omni_gen_mesh_generate_handler, + crate::http_server::endpoints::omni_gen::generate::splat::omni_gen_splat_generate_handler::omni_gen_splat_generate_handler, crate::http_server::endpoints::omni_gen::generate::video::omni_gen_video_generate_handler::omni_gen_video_generate_handler, crate::http_server::endpoints::omni_gen::models::audio::omni_gen_audio_models_handler::omni_gen_audio_models_handler, crate::http_server::endpoints::omni_gen::models::image::omni_gen_image_models_handler::omni_gen_image_models_handler, + crate::http_server::endpoints::omni_gen::models::mesh::omni_gen_mesh_models_handler::omni_gen_mesh_models_handler, + crate::http_server::endpoints::omni_gen::models::splat::omni_gen_splat_models_handler::omni_gen_splat_models_handler, crate::http_server::endpoints::omni_gen::models::video::omni_gen_video_models_handler::omni_gen_video_models_handler, // Omni API (API-key authenticated generate) crate::http_server::endpoints::omni_api::generate::image::omni_api_image_generate_handler::omni_api_image_generate_handler, @@ -880,6 +894,20 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux OmniGenImageModelsQuery, OmniGenImageModelsResponse, OmniGenImageProviderModelDetails, + OmniGenMeshCostAndGenerateRequest, + OmniGenMeshCostResponse, + OmniGenMeshGenerateResponse, + OmniGenMeshModelDetails, + OmniGenMeshModelProviderDetails, + OmniGenMeshModelsResponse, + OmniGenMeshProviderModelDetails, + OmniGenSplatCostAndGenerateRequest, + OmniGenSplatCostResponse, + OmniGenSplatGenerateResponse, + OmniGenSplatModelDetails, + OmniGenSplatModelProviderDetails, + OmniGenSplatModelsResponse, + OmniGenSplatProviderModelDetails, OmniGenVideoCostAndGenerateRequest, OmniGenVideoCostResponse, OmniGenVideoGenerateResponse, diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/common/generation_debug_logs.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/common/generation_debug_logs.rs index 9a177337f9..879d030352 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/common/generation_debug_logs.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/common/generation_debug_logs.rs @@ -82,6 +82,8 @@ pub fn provider_request_debug_log_type(provider: RouterProvider) -> Option None, // No DebugLogType variant yet; add one if GmiCloud routing is revived. RouterProvider::GmiCloud => None, + // No DebugLogType variant yet for World Labs outbound requests. + RouterProvider::WorldLabs => None, } } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/cost_estimate/splat/estimate_splat_cost_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/cost_estimate/splat/estimate_splat_cost_handler.rs index 9dfa0e67a0..f70f9eb780 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/cost_estimate/splat/estimate_splat_cost_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/cost_estimate/splat/estimate_splat_cost_handler.rs @@ -31,7 +31,7 @@ pub async fn estimate_splat_cost_handler( request: Json, ) -> Result, HandlerError> { let router_provider = map_provider(request.provider, request.model)?; - let router_model = map_splat_model(request.model); + let router_model = map_splat_model(request.model)?; // For cost estimation we only care whether a reference image is present, // not the actual image data. We populate a dummy token/url so the plan @@ -132,9 +132,11 @@ fn map_provider( } } -fn map_splat_model(model: CommonSplatModel) -> RouterSplatModel { +fn map_splat_model(model: CommonSplatModel) -> Result { match model { - CommonSplatModel::Marble0p1Mini => RouterSplatModel::Marble0p1Mini, - CommonSplatModel::Marble0p1Plus => RouterSplatModel::Marble0p1Plus, + CommonSplatModel::Marble0p1Mini => Ok(RouterSplatModel::Marble0p1Mini), + CommonSplatModel::Marble0p1Plus => Ok(RouterSplatModel::Marble0p1Plus), + other => Err(HandlerError::InvalidInput( + format!("model {:?} is not supported by this endpoint", other))), } } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/splat/generate_worldlabs_marble_0p1_mini_splat_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/splat/generate_worldlabs_marble_0p1_mini_splat_handler.rs index f530ecfdef..e4c7e7bfc9 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/splat/generate_worldlabs_marble_0p1_mini_splat_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/splat/generate_worldlabs_marble_0p1_mini_splat_handler.rs @@ -235,6 +235,8 @@ pub async fn generate_worldlabs_marble_0p1_mini_splat_handler( creator_ip_address: &ip_address, creator_set_visibility: Visibility::Public, maybe_platform_type: get_request_platform_type(&http_request), + maybe_cost_estimates: None, + maybe_debug_log_event_token: None, mysql_executor: &mut *transaction, phantom: Default::default(), }).await; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/splat/generate_worldlabs_marble_0p1_plus_splat_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/splat/generate_worldlabs_marble_0p1_plus_splat_handler.rs index 8c0ff010e0..2a85312568 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/splat/generate_worldlabs_marble_0p1_plus_splat_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/splat/generate_worldlabs_marble_0p1_plus_splat_handler.rs @@ -235,6 +235,8 @@ pub async fn generate_worldlabs_marble_0p1_plus_splat_handler( creator_ip_address: &ip_address, creator_set_visibility: Visibility::Public, maybe_platform_type: get_request_platform_type(&http_request), + maybe_cost_estimates: None, + maybe_debug_log_event_token: None, mysql_executor: &mut *transaction, phantom: Default::default(), }).await; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/cost/mesh/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/cost/mesh/mod.rs new file mode 100644 index 0000000000..fd18284928 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/cost/mesh/mod.rs @@ -0,0 +1 @@ +pub mod omni_gen_mesh_cost_handler; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/cost/mesh/omni_gen_mesh_cost_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/cost/mesh/omni_gen_mesh_cost_handler.rs new file mode 100644 index 0000000000..8d49d83124 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/cost/mesh/omni_gen_mesh_cost_handler.rs @@ -0,0 +1,59 @@ +use std::sync::Arc; + +use actix_web::web::Json; +use actix_web::{web, HttpRequest}; +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_mesh_cost_and_generate_request::OmniGenMeshCostAndGenerateRequest; +use artcraft_api_defs::omni_gen::cost_response::omni_gen_mesh_cost_response::OmniGenMeshCostResponse; +use artcraft_router::api::router_provider::RouterProvider; +use log::warn; + +use crate::http_server::common_responses::common_web_error::CommonWebError; +use crate::http_server::endpoints::omni_gen::generate::mesh::helpers::hydrate_router_request::hydrate_to_router_request; +use crate::http_server::endpoints::omni_gen::shared_utils::mesh::validate_mesh_request::validate_mesh_request; +use crate::state::server_state::ServerState; + +/// Estimate the cost of a mesh (3D object) generation. +#[utoipa::path( + post, + tag = "Omni Gen", + path = "/v1/omni_gen/cost/mesh", + request_body = OmniGenMeshCostAndGenerateRequest, + responses( + (status = 200, description = "Success", body = OmniGenMeshCostResponse), + (status = 400, description = "Bad input"), + (status = 500, description = "Server error"), + ), +)] +pub async fn omni_gen_mesh_cost_handler( + _http_request: HttpRequest, + request: Json, + _server_state: web::Data>, +) -> Result, CommonWebError> { + validate_mesh_request(&request)?; + + let mut builder = hydrate_to_router_request(&request)?; + + builder.provider = RouterProvider::Artcraft; + + let estimate = builder.build2() + .map_err(|e| { + warn!("Failed to build mesh cost estimate: {}", e); + CommonWebError::from_error(e) + })? + .estimate_cost() + .map_err(|e| { + warn!("Failed to estimate mesh cost: {}", e); + CommonWebError::from_error(e) + })?; + + Ok(Json(OmniGenMeshCostResponse { + success: true, + cost_in_credits: estimate.cost_in_credits, + cost_in_usd_cents: estimate.cost_in_usd_cents, + is_free: estimate.is_free, + is_unlimited: estimate.is_unlimited, + is_rate_limited: estimate.is_rate_limited, + has_watermark: estimate.has_watermark, + failures_are_refunded: estimate.failures_are_refunded, + })) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/cost/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/cost/mod.rs index 53475895eb..cd3fbb9d3f 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/cost/mod.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/cost/mod.rs @@ -1,3 +1,5 @@ pub mod audio; pub mod image; +pub mod mesh; +pub mod splat; pub mod video; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/cost/splat/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/cost/splat/mod.rs new file mode 100644 index 0000000000..7177f54417 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/cost/splat/mod.rs @@ -0,0 +1 @@ +pub mod omni_gen_splat_cost_handler; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/cost/splat/omni_gen_splat_cost_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/cost/splat/omni_gen_splat_cost_handler.rs new file mode 100644 index 0000000000..2fef812a01 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/cost/splat/omni_gen_splat_cost_handler.rs @@ -0,0 +1,59 @@ +use std::sync::Arc; + +use actix_web::web::Json; +use actix_web::{web, HttpRequest}; +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_splat_cost_and_generate_request::OmniGenSplatCostAndGenerateRequest; +use artcraft_api_defs::omni_gen::cost_response::omni_gen_splat_cost_response::OmniGenSplatCostResponse; +use artcraft_router::api::router_provider::RouterProvider; +use log::warn; + +use crate::http_server::common_responses::common_web_error::CommonWebError; +use crate::http_server::endpoints::omni_gen::generate::splat::helpers::hydrate_router_request::hydrate_to_router_request; +use crate::http_server::endpoints::omni_gen::shared_utils::splat::validate_splat_request::validate_splat_request; +use crate::state::server_state::ServerState; + +/// Estimate the cost of a splat (3D world) generation. +#[utoipa::path( + post, + tag = "Omni Gen", + path = "/v1/omni_gen/cost/splat", + request_body = OmniGenSplatCostAndGenerateRequest, + responses( + (status = 200, description = "Success", body = OmniGenSplatCostResponse), + (status = 400, description = "Bad input"), + (status = 500, description = "Server error"), + ), +)] +pub async fn omni_gen_splat_cost_handler( + _http_request: HttpRequest, + request: Json, + _server_state: web::Data>, +) -> Result, CommonWebError> { + validate_splat_request(&request)?; + + let mut builder = hydrate_to_router_request(&request)?; + + builder.provider = RouterProvider::Artcraft; + + let estimate = builder.build2() + .map_err(|e| { + warn!("Failed to build splat cost estimate: {}", e); + CommonWebError::from_error(e) + })? + .estimate_cost() + .map_err(|e| { + warn!("Failed to estimate splat cost: {}", e); + CommonWebError::from_error(e) + })?; + + Ok(Json(OmniGenSplatCostResponse { + success: true, + cost_in_credits: estimate.cost_in_credits, + cost_in_usd_cents: estimate.cost_in_usd_cents, + is_free: estimate.is_free, + is_unlimited: estimate.is_unlimited, + is_rate_limited: estimate.is_rate_limited, + has_watermark: estimate.has_watermark, + failures_are_refunded: estimate.failures_are_refunded, + })) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/mesh/helpers/hydrate_router_request.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/mesh/helpers/hydrate_router_request.rs new file mode 100644 index 0000000000..b5f19b9756 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/mesh/helpers/hydrate_router_request.rs @@ -0,0 +1,55 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_mesh_cost_and_generate_request::OmniGenMeshCostAndGenerateRequest; +use artcraft_router::api::image_list_ref::ImageListRef; +use artcraft_router::api::image_ref::ImageRef; +use artcraft_router::api::router_mesh_model::RouterMeshModel; +use artcraft_router::api::router_provider::RouterProvider; +use artcraft_router::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use artcraft_router::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; +use enums::common::generation::common_mesh_model::CommonMeshModel as CommonMeshModelEnum; + +use crate::http_server::common_responses::common_web_error::CommonWebError; + +pub fn hydrate_to_router_request( + request: &OmniGenMeshCostAndGenerateRequest, +) -> Result { + let api_model = request.model + .as_ref() + .ok_or_else(|| CommonWebError::BadInputWithSimpleMessage( + "model is required".to_string(), + ))?; + + let model = convert_model(api_model)?; + + Ok(GenerateMeshRequestBuilder { + model, + provider: RouterProvider::Artcraft, + prompt: request.prompt.clone(), + reference_images: request.reference_image_media_tokens.clone() + .map(ImageListRef::MediaFileTokens), + front_image: request.front_image_media_token.clone() + .map(ImageRef::MediaFileToken), + back_image: request.back_image_media_token.clone() + .map(ImageRef::MediaFileToken), + left_image: request.left_image_media_token.clone() + .map(ImageRef::MediaFileToken), + right_image: request.right_image_media_token.clone() + .map(ImageRef::MediaFileToken), + mesh_output_type: request.mesh_output_type, + polygon_type: request.polygon_type, + face_count: request.face_count, + enable_pbr: request.enable_pbr, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade, + idempotency_token: request.idempotency_token.clone(), + }) +} + +fn convert_model( + model: &CommonMeshModelEnum, +) -> Result { + let json = serde_json::to_string(model)?; + serde_json::from_str(&json).map_err(|e| { + CommonWebError::BadInputWithSimpleMessage( + format!("Unsupported mesh model: {}", e), + ) + }) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/mesh/helpers/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/mesh/helpers/mod.rs new file mode 100644 index 0000000000..7c3ebd85ff --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/mesh/helpers/mod.rs @@ -0,0 +1,3 @@ +pub(crate) mod hydrate_router_request; +pub mod pipeline_result; +pub mod resolve_media_tokens_to_urls; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/mesh/helpers/pipeline_result.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/mesh/helpers/pipeline_result.rs new file mode 100644 index 0000000000..14ed9e7fa6 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/mesh/helpers/pipeline_result.rs @@ -0,0 +1,14 @@ +use artcraft_router::generate::generate_mesh::generate_mesh_response::GenerateMeshResponse; +use mysql_queries::queries::generic_inference::common::job_cost_estimates::JobCostEstimates; + +use crate::http_server::endpoints::omni_gen::generate::video::helpers::bill_wallet::BillWalletResult; + +/// The pipeline produces this, then the handler suffix handles DB writes. +pub struct PipelineResult { + pub billing: BillWalletResult, + pub response: GenerateMeshResponse, + + /// System (user-facing) and provider-side cost estimates, written onto the + /// generic_inference_jobs rows. + pub cost_estimates: JobCostEstimates, +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/mesh/helpers/resolve_media_tokens_to_urls.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/mesh/helpers/resolve_media_tokens_to_urls.rs new file mode 100644 index 0000000000..bf6036af41 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/mesh/helpers/resolve_media_tokens_to_urls.rs @@ -0,0 +1,45 @@ +use std::collections::HashMap; + +use artcraft_router::api::image_list_ref::ImageListRef; +use artcraft_router::api::image_ref::ImageRef; +use artcraft_router::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; +use tokens::tokens::media_files::MediaFileToken; + +/// For providers that take URLs directly (Fal), swap +/// `MediaFileToken(s)` → `Url(s)` using the resolved map. +pub fn resolve_media_tokens_to_urls( + builder: &mut GenerateMeshRequestBuilder, + url_map: Option<&HashMap>, +) { + let map = match url_map { + Some(m) => m, + None => return, + }; + + // reference_images + if let Some(ImageListRef::MediaFileTokens(ref tokens)) = builder.reference_images { + let urls: Vec = tokens.iter() + .filter_map(|t| map.get(t).cloned()) + .collect(); + if !urls.is_empty() { + builder.reference_images = Some(ImageListRef::Urls(urls)); + } + } + + // multi-view side images + resolve_image_ref(&mut builder.front_image, map); + resolve_image_ref(&mut builder.back_image, map); + resolve_image_ref(&mut builder.left_image, map); + resolve_image_ref(&mut builder.right_image, map); +} + +fn resolve_image_ref( + image_ref: &mut Option, + map: &HashMap, +) { + if let Some(ImageRef::MediaFileToken(ref token)) = image_ref { + if let Some(url) = map.get(token) { + *image_ref = Some(ImageRef::Url(url.clone())); + } + } +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/mesh/insert_db_job/insert_fal_job.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/mesh/insert_db_job/insert_fal_job.rs new file mode 100644 index 0000000000..7631c6bce0 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/mesh/insert_db_job/insert_fal_job.rs @@ -0,0 +1,56 @@ +use log::warn; + +use enums::common::visibility::Visibility; +use mysql_queries::queries::generic_inference::api_providers::fal::insert_generic_inference_job_for_fal_queue::FalCategory; +use mysql_queries::queries::generic_inference::api_providers::fal::insert_generic_inference_job_for_fal_queue_with_apriori_job_token::{ + insert_generic_inference_job_for_fal_queue_with_apriori_job_token, + InsertGenericInferenceForFalWithAprioriJobTokenArgs, +}; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; + +use crate::http_server::common_responses::common_web_error::CommonWebError; +use crate::http_server::endpoints::omni_gen::generate::video::insert_db_job::shared_job_args::SharedJobArgs; + +pub struct InsertFalJobArgs<'a, 'tx> { + pub external_job_id: &'a str, + pub shared: SharedJobArgs<'a, 'tx>, +} + +pub async fn insert_fal_job(args: InsertFalJobArgs<'_, '_>) -> Result { + let InsertFalJobArgs { + external_job_id, + shared, + } = args; + + let db_result = insert_generic_inference_job_for_fal_queue_with_apriori_job_token( + InsertGenericInferenceForFalWithAprioriJobTokenArgs { + apriori_job_token: shared.apriori_job_token, + uuid_idempotency_token: shared.idempotency_token, + maybe_external_third_party_id: external_job_id, + fal_category: FalCategory::ObjectGeneration, + maybe_model_type: shared.maybe_model_type, + maybe_prompt_token: shared.maybe_prompt_token, + maybe_creator_user_token: Some(shared.user_token), + maybe_avt_token: shared.maybe_avt_token, + creator_ip_address: shared.ip_address, + creator_set_visibility: Visibility::Public, + maybe_platform_type: shared.maybe_platform_type, + maybe_cost_estimates: shared.maybe_cost_estimates, + maybe_debug_log_event_token: shared.maybe_debug_log_event_token, + mysql_executor: &mut **shared.transaction, + maybe_inference_args: None, + starting_job_status_override: None, + maybe_frontend_failure_category: None, + maybe_failure_reason: None, + phantom: Default::default(), + } + ).await; + + match db_result { + Ok(token) => Ok(token), + Err(err) => { + warn!("Error inserting fal mesh inference job: {:?}", err); + Err(CommonWebError::from_error(err)) + } + } +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/mesh/insert_db_job/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/mesh/insert_db_job/mod.rs new file mode 100644 index 0000000000..ecfec47b4c --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/mesh/insert_db_job/mod.rs @@ -0,0 +1 @@ +pub mod insert_fal_job; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/mesh/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/mesh/mod.rs new file mode 100644 index 0000000000..119f368aa3 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/mesh/mod.rs @@ -0,0 +1,5 @@ +pub mod omni_gen_mesh_generate_handler; + +pub mod helpers; +pub mod pipeline_v2; +pub mod insert_db_job; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/mesh/omni_gen_mesh_generate_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/mesh/omni_gen_mesh_generate_handler.rs new file mode 100644 index 0000000000..05885ea235 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/mesh/omni_gen_mesh_generate_handler.rs @@ -0,0 +1,350 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use actix_web::web::Json; +use actix_web::{web, HttpRequest}; +use log::{error, info, warn}; +use sqlx::Acquire; + +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_mesh_cost_and_generate_request::OmniGenMeshCostAndGenerateRequest; +use artcraft_api_defs::omni_gen::generate_response::omni_gen_mesh_generate_response::OmniGenMeshGenerateResponse; +use artcraft_router::generate::generate_mesh::generate_mesh_response::GenerateMeshResponse; +use enums::by_table::debug_logs::debug_log_level::DebugLogLevel; +use enums::by_table::debug_logs::debug_log_type::DebugLogType; +use enums::by_table::prompt_context_items::prompt_context_semantic_type::PromptContextSemanticType; +use enums::by_table::prompts::prompt_type::PromptType; +use enums::common::generation::common_generation_mode::CommonGenerationMode; +use enums::common::generation::common_model_type::CommonModelType; +use enums::common::generation_provider::GenerationProvider; +use http_server_common::request::get_request_ip::get_request_ip; +use mysql_queries::queries::debug_logs::insert_debug_log::{insert_debug_log, InsertDebugLogArgs}; +use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_idempotency_token; +use mysql_queries::queries::prompt_context_items::insert_batch_prompt_context_items::{ + insert_batch_prompt_context_items, InsertBatchArgs, PromptContextItem, +}; +use mysql_queries::queries::prompts::insert_prompt::{insert_prompt, InsertPromptArgs}; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use tokens::tokens::media_files::MediaFileToken; +use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken; + +use crate::http_server::common_responses::common_web_error::CommonWebError; +use crate::http_server::endpoints::generate::common::generation_debug_logs::GenerationDebugLogContext; +use crate::http_server::endpoints::generate::common::payments_error_test::payments_error_test; +use crate::http_server::endpoints::omni_gen::generate::mesh::helpers::hydrate_router_request::hydrate_to_router_request; +use crate::http_server::endpoints::omni_gen::generate::mesh::insert_db_job::insert_fal_job::{insert_fal_job, InsertFalJobArgs}; +use crate::http_server::endpoints::omni_gen::generate::mesh::pipeline_v2::run_pipeline_v2::{run_pipeline_v2, RunPipelineV2Args}; +use crate::http_server::endpoints::omni_gen::generate::video::insert_db_job::shared_job_args::SharedJobArgs; +use crate::http_server::endpoints::omni_gen::shared_utils::mesh::validate_mesh_request::validate_mesh_request; +use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format; +use crate::http_server::web_utils::get_request_platform_type::get_request_platform_type; +use crate::state::server_state::ServerState; +use crate::util::lookup::lookup_media_files_as_cdn_url_list_and_map::lookup_media_files_as_cdn_url_list_and_map; + +/// Generate a mesh (3D object) using the omni-gen unified endpoint. +#[utoipa::path( + post, + tag = "Omni Gen", + path = "/v1/omni_gen/generate/mesh", + request_body = OmniGenMeshCostAndGenerateRequest, + responses( + (status = 200, description = "Success", body = OmniGenMeshGenerateResponse), + (status = 400, description = "Bad input"), + (status = 401, description = "Unauthorized"), + (status = 402, description = "Payment required"), + (status = 500, description = "Server error"), + ), +)] +pub async fn omni_gen_mesh_generate_handler( + http_request: HttpRequest, + request: Json, + server_state: web::Data>, +) -> Result, CommonWebError> { + + info!("request: {:?}", request); + + // Reject doomed combos (e.g. prompt-only hunyuan 2.x, sketch without a + // prompt) before any billable or DB-mutating work — see helper for the rules. + validate_mesh_request(&request)?; + + payments_error_test(&request.prompt.as_deref().unwrap_or(""))?; + + let debug_log_event_token = DebugLogEventToken::generate(); + + let maybe_prompt_model_type: Option = request.model + .as_ref() + .map(|m| m.to_common_model_type()); + + // ==================== SESSION ==================== // + + let mut mysql_connection = server_state.mysql_pool.acquire().await?; + + let maybe_user_session = server_state + .session_checker + .maybe_get_user_session_from_connection(&http_request, &mut mysql_connection) + .await + .map_err(|e| { + warn!("Session checker error: {:?}", e); + CommonWebError::from(e) + })?; + + let session = match maybe_user_session.as_ref() { + Some(session) => session, + None => return Err(CommonWebError::NotAuthorized), + }; + + let user_token = &session.user_token; + + let maybe_avt_token = server_state + .avt_cookie_manager + .get_avt_token_from_request(&http_request); + + // ==================== IDEMPOTENCY ==================== // + + let idempotency_token = request.idempotency_token.as_deref() + .unwrap_or("") + .to_string(); + + if let Err(reason) = validate_idempotency_token_format(&idempotency_token) { + return Err(CommonWebError::BadInputWithSimpleMessage(reason)); + } + + insert_idempotency_token(&idempotency_token, &mut *mysql_connection) + .await + .map_err(|err| { + error!("Error inserting idempotency token: {:?}", err); + CommonWebError::BadInputWithSimpleMessage("repeated idempotency token".to_string()) + })?; + + // ==================== RESOLVE MEDIA TOKENS ==================== // + + let all_tokens: Vec = collect_image_tokens(&request); + + let media_file_to_url_map: Option> = if all_tokens.is_empty() { + None + } else { + info!("Resolving {} media file tokens to CDN URLs", all_tokens.len()); + let resolved = lookup_media_files_as_cdn_url_list_and_map( + &http_request, + &mut mysql_connection, + server_state.server_environment, + &all_tokens, + ).await?; + Some(resolved.token_to_url_map) + }; + + // ==================== HYDRATE ROUTER REQUEST ==================== // + + let router_builder = hydrate_to_router_request(&request)?; + + // ==================== DEBUG LOG: HTTP REQUEST ==================== // + + let ip_address = get_request_ip(&http_request); + let request_url = http_request.uri().to_string(); + + if let Err(err) = insert_debug_log(InsertDebugLogArgs { + apriori_debug_log_event_token: Some(&debug_log_event_token), + maybe_creator_user_token: Some(user_token), + debug_log_type: DebugLogType::HttpRequest, + maybe_log_level: Some(DebugLogLevel::Info), + maybe_ip_address: Some(&ip_address), + maybe_url: Some(&request_url), + message: &serde_json::to_string(&*request).unwrap_or_default(), + mysql_executor: &mut *mysql_connection, + phantom: Default::default(), + }).await { + warn!("Failed to insert HTTP request debug log: {:?}", err); + } + + // NB: The pipeline takes over the connection for its remaining pre-request DB writes (billing, + // outbound provider request debug log) and releases it before the (slow, external) generation + // call — holding a pool slot across that call is what starves the pool and causes PoolTimedOut + // on unrelated endpoints. We re-acquire below to write the result. + + let debug_log_context = GenerationDebugLogContext { + event_token: &debug_log_event_token, + user_token, + ip_address: &ip_address, + request_url: &request_url, + }; + + let pipeline_result = run_pipeline_v2(RunPipelineV2Args { + router_builder: &router_builder, + server_state: &server_state, + user_token, + media_file_to_url_map: &media_file_to_url_map, + debug_log_context: &debug_log_context, + mysql_connection, + }).await; + + // ==================== DEBUG LOG: PIPELINE ERROR ==================== // + + let pipeline_result = match pipeline_result { + Ok(result) => result, + Err(err) => { + // Best-effort error log; never mask the original error. + if let Ok(mut error_log_connection) = server_state.mysql_pool.acquire().await { + if let Err(log_err) = insert_debug_log(InsertDebugLogArgs { + apriori_debug_log_event_token: Some(&debug_log_event_token), + maybe_creator_user_token: Some(user_token), + debug_log_type: DebugLogType::BackendFailure, + maybe_log_level: Some(DebugLogLevel::Error), + maybe_ip_address: Some(&ip_address), + maybe_url: Some(&request_url), + message: &format!("Mesh generation pipeline failed: {:?}", err), + mysql_executor: &mut *error_log_connection, + phantom: Default::default(), + }).await { + warn!("Failed to insert pipeline error debug log: {:?}", log_err); + } + } + return Err(err); + } + }; + + let mut mysql_connection = server_state.mysql_pool.acquire().await?; + + // NB: Outbound provider requests (Fal) are debug-logged inside + // the pipeline BEFORE the send, so the payload is captured even on failure. + + // ==================== WRITE RESULT ==================== // + + let maybe_platform_type = get_request_platform_type(&http_request); + + let mut transaction = mysql_connection.begin().await.map_err(|err| { + error!("Error starting MySQL transaction: {:?}", err); + CommonWebError::from_error(err) + })?; + + // -- Prompt -- + + let prompt_token = match insert_prompt(InsertPromptArgs { + maybe_apriori_prompt_token: None, + prompt_type: PromptType::ArtcraftApp, + maybe_creator_user_token: Some(user_token), + maybe_model_type: maybe_prompt_model_type, + maybe_generation_provider: Some(GenerationProvider::Artcraft), + maybe_positive_prompt: request.prompt.as_deref(), + maybe_negative_prompt: None, + maybe_other_args: None, + maybe_generation_mode: Some(determine_generation_mode(&request)), + maybe_aspect_ratio: None, + maybe_resolution: None, + maybe_bitrate: None, + maybe_batch_count: None, + maybe_generate_audio: None, + maybe_duration_seconds: None, + creator_ip_address: &ip_address, + mysql_executor: &mut *transaction, + phantom: Default::default(), + }).await { + Ok(token) => Some(token), + Err(err) => { + warn!("Error inserting prompt: {:?}", err); + None + } + }; + + // -- Prompt context items -- + + if let Some(token) = prompt_token.as_ref() { + let context_items: Vec = collect_image_tokens(&request) + .into_iter() + .map(|media_token| PromptContextItem { + media_token, + context_semantic_type: PromptContextSemanticType::Imgref, + }) + .collect(); + + if !context_items.is_empty() { + if let Err(err) = insert_batch_prompt_context_items(InsertBatchArgs { + prompt_token: token.clone(), + items: context_items, + transaction: &mut transaction, + }).await { + warn!("Error inserting batch prompt context items: {:?}", err); + } + } + } + + // -- Inference job -- + + let (primary_job_token, all_job_tokens): (InferenceJobToken, Vec) = + match &pipeline_result.response { + GenerateMeshResponse::Fal(payload) => { + let external_id = payload.request_id.as_deref().ok_or_else(|| { + error!("Fal mesh generation response missing request_id"); + CommonWebError::server_error_with_message("Fal generation response missing request_id") + })?; + info!("Inserting fal mesh job with token: {:?}", pipeline_result.billing.apriori_job_token); + + let token = insert_fal_job(InsertFalJobArgs { + external_job_id: external_id, + shared: SharedJobArgs { + apriori_job_token: &pipeline_result.billing.apriori_job_token, + idempotency_token: &idempotency_token, + user_token, + maybe_avt_token: maybe_avt_token.as_ref(), + maybe_model_type: maybe_prompt_model_type, + maybe_prompt_token: prompt_token.as_ref(), + maybe_debug_log_event_token: Some(&debug_log_event_token), + maybe_platform_type, + maybe_cost_estimates: Some(pipeline_result.cost_estimates), + ip_address: &ip_address, + transaction: &mut transaction, + }, + }).await?; + + ( + token.clone(), + vec![token], + ) + } + GenerateMeshResponse::Artcraft(payload) => { + ( + payload.inference_job_token.clone(), + payload.all_inference_job_tokens.clone(), + ) + } + }; + + transaction.commit().await.map_err(|err| { + error!("Error committing transaction: {:?}", err); + CommonWebError::from_error(err) + })?; + + Ok(Json(OmniGenMeshGenerateResponse { + success: true, + inference_job_token: primary_job_token, + all_job_tokens, + })) +} + +/// All image media tokens on the request: the primary/sketch reference image +/// plus the four multi-view side images. +fn collect_image_tokens(request: &OmniGenMeshCostAndGenerateRequest) -> Vec { + let mut tokens: Vec = Vec::new(); + + if let Some(ref_tokens) = &request.reference_image_media_tokens { + tokens.extend(ref_tokens.iter().cloned()); + } + for maybe_token in [ + &request.front_image_media_token, + &request.back_image_media_token, + &request.left_image_media_token, + &request.right_image_media_token, + ] { + if let Some(token) = maybe_token { + tokens.push(token.clone()); + } + } + + tokens +} + +fn determine_generation_mode(request: &OmniGenMeshCostAndGenerateRequest) -> CommonGenerationMode { + if !collect_image_tokens(request).is_empty() { + return CommonGenerationMode::Reference; + } + + CommonGenerationMode::Text +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/mesh/pipeline_v2/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/mesh/pipeline_v2/mod.rs new file mode 100644 index 0000000000..f207b21293 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/mesh/pipeline_v2/mod.rs @@ -0,0 +1 @@ +pub mod run_pipeline_v2; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/mesh/pipeline_v2/run_pipeline_v2.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/mesh/pipeline_v2/run_pipeline_v2.rs new file mode 100644 index 0000000000..6b84bd3229 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/mesh/pipeline_v2/run_pipeline_v2.rs @@ -0,0 +1,208 @@ +use std::collections::HashMap; +use std::convert::TryFrom; + +use log::{error, info, warn}; + +use artcraft_router::api::router_provider::RouterProvider; +use artcraft_router::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder; +use artcraft_router::generate::generate_mesh::generate_mesh_response::GenerateMeshResponse; +use artcraft_router::generate::generate_mesh::mesh_generation_draft_context::MeshGenerationDraftContext; +use artcraft_router::generate::generate_mesh::mesh_generation_draft_or_request::MeshGenerationDraftOrRequest; +use mysql_queries::queries::generic_inference::common::job_cost_estimates::JobCostEstimates; +use tokens::tokens::media_files::MediaFileToken; +use tokens::tokens::users::UserToken; + +use crate::http_server::common_responses::common_web_error::CommonWebError; +use crate::http_server::endpoint_helpers::refund_wallet_after_api_failure::refund_wallet_after_api_failure; +use crate::http_server::endpoints::generate::common::generation_debug_logs::{ + insert_provider_request_debug_log, provider_request_debug_log_type, GenerationDebugLogContext, +}; +use crate::http_server::endpoints::omni_gen::generate::mesh::helpers::pipeline_result::PipelineResult; +use crate::http_server::endpoints::omni_gen::generate::mesh::helpers::resolve_media_tokens_to_urls::resolve_media_tokens_to_urls; +use crate::http_server::endpoints::omni_gen::generate::video::helpers::bill_wallet::bill_wallet; +use crate::http_server::endpoints::omni_gen::generate::video::helpers::build_router_client::build_router_client; +use crate::http_server::endpoints::omni_gen::generate::video::kinovi_account::KinoviAccount; +use crate::http_server::endpoints::omni_gen::shared_utils::map_seedance2pro_router_error::map_router_error_to_web_error; +use crate::state::server_state::ServerState; + +pub struct RunPipelineV2Args<'a> { + pub router_builder: &'a GenerateMeshRequestBuilder, + pub server_state: &'a ServerState, + pub user_token: &'a UserToken, + pub media_file_to_url_map: &'a Option>, + pub debug_log_context: &'a GenerationDebugLogContext<'a>, + /// The handler's open connection. The pipeline uses it for its remaining + /// pre-request DB writes (billing, outbound-request debug log) and releases + /// it BEFORE the external provider call. + pub mysql_connection: sqlx::pool::PoolConnection, +} + +// NB: This pipeline does an external generation call (`upload_and_generate`) that can take many +// seconds. It deliberately does NOT hold a pooled DB connection across that call — it acquires +// short-lived connections only for the billing and (on failure) refund writes. Holding a pooled +// connection across the external call is what starves the pool and causes `PoolTimedOut`. +pub async fn run_pipeline_v2(args: RunPipelineV2Args<'_>) -> Result { + let RunPipelineV2Args { + router_builder, + server_state, + user_token, + media_file_to_url_map, + debug_log_context, + mut mysql_connection, + } = args; + + let router_builder = router_builder.clone(); + + // All mesh models are fulfilled through Fal. + let provider = RouterProvider::Fal; + + // 1. Build execution request + let mut exec_builder = router_builder.clone(); + exec_builder.provider = provider; + + // Fal takes media URLs directly, not media file tokens. + // Resolve tokens to URLs before building. + resolve_media_tokens_to_urls(&mut exec_builder, media_file_to_url_map.as_ref()); + + let draft_or_request = exec_builder.build2() + .map_err(|e| { + warn!("Failed to build2 for mesh v2 pipeline: {}", e); + CommonWebError::from_error(e) + })?; + + // 2. Calculate cost. + // Swap provider to Artcraft so credits = cents. + let system_cost_estimate = { + let mut cost_builder = router_builder.clone(); + cost_builder.provider = RouterProvider::Artcraft; + + cost_builder.build2() + .map_err(|e| { + warn!("Failed to build2 mesh cost estimate for v2: {}", e); + CommonWebError::from_error(e) + })? + .estimate_cost() + .map_err(|e| { + warn!("Failed to estimate mesh cost for v2: {}", e); + CommonWebError::from_error(e) + })? + }; + + let cost = system_cost_estimate.cost_in_credits.unwrap_or(0); + + // Provider-side estimate (what the fulfilling provider charges us). + // Bookkeeping only — failures must not block generation. + let maybe_provider_cost_estimate = match draft_or_request.estimate_cost() { + Ok(estimate) => Some(estimate), + Err(err) => { + warn!("Failed to estimate provider cost for v2 mesh: {}", err); + None + } + }; + + let cost_estimates = JobCostEstimates { + maybe_external_third_party_cost_credits: maybe_provider_cost_estimate.as_ref() + .and_then(|e| e.cost_in_credits) + .and_then(|v| u32::try_from(v).ok()), + maybe_external_third_party_cost_usd_cents: maybe_provider_cost_estimate.as_ref() + .and_then(|e| e.cost_in_usd_cents) + .and_then(|v| u32::try_from(v).ok()), + maybe_system_cost_credits: system_cost_estimate.cost_in_credits + .and_then(|v| u32::try_from(v).ok()), + maybe_system_cost_usd_cents: system_cost_estimate.cost_in_usd_cents + .and_then(|v| u32::try_from(v).ok()), + }; + + info!("v2 mesh estimated cost: {} credits (estimates: {:?})", cost, cost_estimates); + + // 3. Bill wallet on the handler's connection (same pre-request DB phase). + let billing = bill_wallet(user_token, cost, &mut mysql_connection).await?; + + // Debug-log the outbound provider request BEFORE the send — still on the + // handler's connection — so the payload is captured even when the + // upload/enqueue fails. + if let Some(debug_log_type) = provider_request_debug_log_type(provider) { + insert_provider_request_debug_log( + debug_log_context, + debug_log_type, + &format!("{:#?}", draft_or_request), + &mut *mysql_connection, + ).await; + } + + // NB: Done with pre-request DB writes. Release the pooled connection before + // the (slow, external) provider call — holding it across that call is what + // starves the pool and causes PoolTimedOut. Post-send writes re-acquire. + drop(mysql_connection); + + // 4. Upload media (if draft) and generate the mesh. + // NB: No pooled DB connection is held across this call. + let result = upload_and_generate( + draft_or_request, + server_state, + media_file_to_url_map.as_ref(), + ).await; + + // 5. On failure, refund the wallet. The job row is never inserted when the + // send fails, so nothing downstream would ever refund it. + if let Err(ref err) = result { + if let Some(ledger_entry_token) = billing.maybe_wallet_ledger_entry_token.as_ref() { + warn!("v2 mesh generation failed, issuing refund for {}: {:?}", ledger_entry_token.as_str(), err); + + match server_state.mysql_pool.acquire().await { + Ok(mut refund_connection) => { + if let Err(refund_err) = refund_wallet_after_api_failure(ledger_entry_token, &mut refund_connection).await { + error!("Failed to refund wallet after v2 mesh failure: {:?}", refund_err); + } + } + Err(acquire_err) => { + error!("Failed to acquire MySQL connection to refund wallet after v2 mesh failure: {:?}", acquire_err); + } + } + } + } + + let response = result?; + + info!("v2 mesh generation response: {:?}", response); + + Ok(PipelineResult { billing, response, cost_estimates }) +} + +/// Finalize the draft (uploading media if needed), then send the generation request. +/// +/// This is the block that gets refunded on failure. +async fn upload_and_generate( + draft_or_request: MeshGenerationDraftOrRequest, + server_state: &ServerState, + media_file_urls_by_token: Option<&HashMap>, +) -> Result { + + let provider = draft_or_request.get_provider(); + // Kinovi is not a mesh provider; the account argument is inert for Fal. + let client = build_router_client(provider, server_state, KinoviAccount::Volcengine)?; + + let mesh_request = match draft_or_request { + MeshGenerationDraftOrRequest::Request(request) => request, + MeshGenerationDraftOrRequest::Draft(draft) => { + let draft_context = MeshGenerationDraftContext { + client: Some(&client), + media_file_to_artcraft_url_map: media_file_urls_by_token, + }; + + draft.finalize(draft_context) + .await + .map_err(|err| { + warn!("Failed to finalize v2 mesh draft: {:?}", err); + map_router_error_to_web_error(err) + })? + } + }; + + mesh_request.send_request(&client) + .await + .map_err(|err| { + warn!("v2 mesh generation failed: {:?}", err); + map_router_error_to_web_error(err) + }) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/mod.rs index 53475895eb..cd3fbb9d3f 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/mod.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/mod.rs @@ -1,3 +1,5 @@ pub mod audio; pub mod image; +pub mod mesh; +pub mod splat; pub mod video; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/splat/helpers/hydrate_router_request.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/splat/helpers/hydrate_router_request.rs new file mode 100644 index 0000000000..85ca8f8673 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/splat/helpers/hydrate_router_request.rs @@ -0,0 +1,47 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_splat_cost_and_generate_request::OmniGenSplatCostAndGenerateRequest; +use artcraft_router::api::image_list_ref::ImageListRef; +use artcraft_router::api::router_provider::RouterProvider; +use artcraft_router::api::router_splat_model::RouterSplatModel; +use artcraft_router::api::video_ref::VideoRef; +use artcraft_router::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use artcraft_router::generate::generate_splat::generate_splat_request_builder::GenerateSplatRequestBuilder; +use enums::common::generation::common_splat_model::CommonSplatModel as CommonSplatModelEnum; + +use crate::http_server::common_responses::common_web_error::CommonWebError; + +pub fn hydrate_to_router_request( + request: &OmniGenSplatCostAndGenerateRequest, +) -> Result { + let api_model = request.model + .as_ref() + .ok_or_else(|| CommonWebError::BadInputWithSimpleMessage( + "model is required".to_string(), + ))?; + + let model = convert_model(api_model)?; + + Ok(GenerateSplatRequestBuilder { + model, + provider: RouterProvider::Artcraft, + prompt: request.prompt.clone(), + reference_images: request.reference_image_media_tokens.clone() + .map(ImageListRef::MediaFileTokens), + reference_video: request.reference_video_media_token.clone() + .map(VideoRef::MediaFileToken), + is_panoramic: request.is_panoramic, + disable_recaption: request.disable_recaption, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade, + idempotency_token: request.idempotency_token.clone(), + }) +} + +fn convert_model( + model: &CommonSplatModelEnum, +) -> Result { + let json = serde_json::to_string(model)?; + serde_json::from_str(&json).map_err(|e| { + CommonWebError::BadInputWithSimpleMessage( + format!("Unsupported splat model: {}", e), + ) + }) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/splat/helpers/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/splat/helpers/mod.rs new file mode 100644 index 0000000000..b3a95eb972 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/splat/helpers/mod.rs @@ -0,0 +1,2 @@ +pub(crate) mod hydrate_router_request; +pub mod pipeline_result; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/splat/helpers/pipeline_result.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/splat/helpers/pipeline_result.rs new file mode 100644 index 0000000000..c4acd5daa8 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/splat/helpers/pipeline_result.rs @@ -0,0 +1,14 @@ +use artcraft_router::generate::generate_splat::generate_splat_response::GenerateSplatResponse; +use mysql_queries::queries::generic_inference::common::job_cost_estimates::JobCostEstimates; + +use crate::http_server::endpoints::omni_gen::generate::video::helpers::bill_wallet::BillWalletResult; + +/// The pipeline produces this, then the handler suffix handles DB writes. +pub struct PipelineResult { + pub billing: BillWalletResult, + pub response: GenerateSplatResponse, + + /// System (user-facing) and provider-side cost estimates, written onto the + /// generic_inference_jobs rows. + pub cost_estimates: JobCostEstimates, +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/splat/insert_db_job/insert_worldlabs_job.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/splat/insert_db_job/insert_worldlabs_job.rs new file mode 100644 index 0000000000..3a8bd5ef90 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/splat/insert_db_job/insert_worldlabs_job.rs @@ -0,0 +1,56 @@ +use log::warn; + +use enums::common::visibility::Visibility; +use mysql_queries::queries::generic_inference::api_providers::worldlabs::insert_generic_inference_job_for_worldlabs_queue_with_apriori_job_token::{ + insert_generic_inference_job_for_worldlabs_queue_with_apriori_job_token, + InsertGenericInferenceForWorldlabsWithAprioriJobTokenArgs, +}; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken; + +use crate::http_server::common_responses::common_web_error::CommonWebError; +use crate::http_server::endpoints::omni_gen::generate::video::insert_db_job::shared_job_args::SharedJobArgs; + +pub struct InsertWorldlabsJobArgs<'a, 'tx> { + /// The external operation_id returned by the World Labs generate_world call. + pub operation_id: &'a str, + pub maybe_wallet_ledger_entry_token: Option<&'a WalletLedgerEntryToken>, + pub shared: SharedJobArgs<'a, 'tx>, +} + +pub async fn insert_worldlabs_job(args: InsertWorldlabsJobArgs<'_, '_>) -> Result { + let InsertWorldlabsJobArgs { + operation_id, + maybe_wallet_ledger_entry_token, + shared, + } = args; + + let db_result = insert_generic_inference_job_for_worldlabs_queue_with_apriori_job_token( + InsertGenericInferenceForWorldlabsWithAprioriJobTokenArgs { + apriori_job_token: shared.apriori_job_token, + uuid_idempotency_token: shared.idempotency_token, + maybe_external_third_party_id: operation_id, + maybe_model_type: shared.maybe_model_type, + maybe_prompt_token: shared.maybe_prompt_token, + maybe_wallet_ledger_entry_token, + maybe_creator_user_token: Some(shared.user_token), + maybe_avt_token: shared.maybe_avt_token, + creator_ip_address: shared.ip_address, + creator_set_visibility: Visibility::Public, + maybe_platform_type: shared.maybe_platform_type, + maybe_cost_estimates: shared.maybe_cost_estimates, + maybe_debug_log_event_token: shared.maybe_debug_log_event_token, + mysql_executor: &mut **shared.transaction, + maybe_inference_args: None, + phantom: Default::default(), + } + ).await; + + match db_result { + Ok(token) => Ok(token), + Err(err) => { + warn!("Error inserting worldlabs splat inference job (operation_id={}): {:?}", operation_id, err); + Err(CommonWebError::from_error(err)) + } + } +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/splat/insert_db_job/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/splat/insert_db_job/mod.rs new file mode 100644 index 0000000000..bc6a24943e --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/splat/insert_db_job/mod.rs @@ -0,0 +1 @@ +pub mod insert_worldlabs_job; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/splat/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/splat/mod.rs new file mode 100644 index 0000000000..42a9051e8e --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/splat/mod.rs @@ -0,0 +1,5 @@ +pub mod omni_gen_splat_generate_handler; + +pub mod helpers; +pub mod pipeline_v2; +pub mod insert_db_job; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/splat/omni_gen_splat_generate_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/splat/omni_gen_splat_generate_handler.rs new file mode 100644 index 0000000000..3c69c9bd89 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/splat/omni_gen_splat_generate_handler.rs @@ -0,0 +1,344 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use actix_web::web::Json; +use actix_web::{web, HttpRequest}; +use log::{error, info, warn}; +use sqlx::Acquire; + +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_splat_cost_and_generate_request::OmniGenSplatCostAndGenerateRequest; +use artcraft_api_defs::omni_gen::generate_response::omni_gen_splat_generate_response::OmniGenSplatGenerateResponse; +use artcraft_router::generate::generate_splat::generate_splat_response::GenerateSplatResponse; +use enums::by_table::debug_logs::debug_log_level::DebugLogLevel; +use enums::by_table::debug_logs::debug_log_type::DebugLogType; +use enums::by_table::prompt_context_items::prompt_context_semantic_type::PromptContextSemanticType; +use enums::by_table::prompts::prompt_type::PromptType; +use enums::common::generation::common_generation_mode::CommonGenerationMode; +use enums::common::generation::common_model_type::CommonModelType; +use enums::common::generation_provider::GenerationProvider; +use http_server_common::request::get_request_ip::get_request_ip; +use mysql_queries::queries::debug_logs::insert_debug_log::{insert_debug_log, InsertDebugLogArgs}; +use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_idempotency_token; +use mysql_queries::queries::prompt_context_items::insert_batch_prompt_context_items::{ + insert_batch_prompt_context_items, InsertBatchArgs, PromptContextItem, +}; +use mysql_queries::queries::prompts::insert_prompt::{insert_prompt, InsertPromptArgs}; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use tokens::tokens::media_files::MediaFileToken; +use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken; + +use crate::http_server::common_responses::common_web_error::CommonWebError; +use crate::http_server::endpoints::generate::common::generation_debug_logs::GenerationDebugLogContext; +use crate::http_server::endpoints::generate::common::payments_error_test::payments_error_test; +use crate::http_server::endpoints::omni_gen::generate::splat::helpers::hydrate_router_request::hydrate_to_router_request; +use crate::http_server::endpoints::omni_gen::generate::splat::insert_db_job::insert_worldlabs_job::{insert_worldlabs_job, InsertWorldlabsJobArgs}; +use crate::http_server::endpoints::omni_gen::generate::splat::pipeline_v2::run_pipeline_v2::{run_pipeline_v2, RunPipelineV2Args}; +use crate::http_server::endpoints::omni_gen::generate::video::insert_db_job::shared_job_args::SharedJobArgs; +use crate::http_server::endpoints::omni_gen::shared_utils::splat::validate_splat_request::validate_splat_request; +use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format; +use crate::http_server::web_utils::get_request_platform_type::get_request_platform_type; +use crate::state::server_state::ServerState; +use crate::util::lookup::lookup_media_files_as_cdn_url_list_and_map::lookup_media_files_as_cdn_url_list_and_map; + +/// Generate a splat (3D world) using the omni-gen unified endpoint. +#[utoipa::path( + post, + tag = "Omni Gen", + path = "/v1/omni_gen/generate/splat", + request_body = OmniGenSplatCostAndGenerateRequest, + responses( + (status = 200, description = "Success", body = OmniGenSplatGenerateResponse), + (status = 400, description = "Bad input"), + (status = 401, description = "Unauthorized"), + (status = 402, description = "Payment required"), + (status = 500, description = "Server error"), + ), +)] +pub async fn omni_gen_splat_generate_handler( + http_request: HttpRequest, + request: Json, + server_state: web::Data>, +) -> Result, CommonWebError> { + + info!("request: {:?}", request); + + // Reject doomed combos (e.g. video + images, panorama without exactly one + // image) before any billable or DB-mutating work — see helper for the rules. + validate_splat_request(&request)?; + + payments_error_test(&request.prompt.as_deref().unwrap_or(""))?; + + let debug_log_event_token = DebugLogEventToken::generate(); + + let maybe_prompt_model_type: Option = request.model + .as_ref() + .map(|m| m.to_common_model_type()); + + // ==================== SESSION ==================== // + + let mut mysql_connection = server_state.mysql_pool.acquire().await?; + + let maybe_user_session = server_state + .session_checker + .maybe_get_user_session_from_connection(&http_request, &mut mysql_connection) + .await + .map_err(|e| { + warn!("Session checker error: {:?}", e); + CommonWebError::from(e) + })?; + + let session = match maybe_user_session.as_ref() { + Some(session) => session, + None => return Err(CommonWebError::NotAuthorized), + }; + + let user_token = &session.user_token; + + let maybe_avt_token = server_state + .avt_cookie_manager + .get_avt_token_from_request(&http_request); + + // ==================== IDEMPOTENCY ==================== // + + let idempotency_token = request.idempotency_token.as_deref() + .unwrap_or("") + .to_string(); + + if let Err(reason) = validate_idempotency_token_format(&idempotency_token) { + return Err(CommonWebError::BadInputWithSimpleMessage(reason)); + } + + insert_idempotency_token(&idempotency_token, &mut *mysql_connection) + .await + .map_err(|err| { + error!("Error inserting idempotency token: {:?}", err); + CommonWebError::BadInputWithSimpleMessage("repeated idempotency token".to_string()) + })?; + + // ==================== RESOLVE MEDIA TOKENS ==================== // + + let mut all_tokens: Vec = Vec::new(); + + if let Some(tokens) = &request.reference_image_media_tokens { + all_tokens.extend(tokens.iter().cloned()); + } + if let Some(token) = &request.reference_video_media_token { + all_tokens.push(token.clone()); + } + + let media_file_to_url_map: Option> = if all_tokens.is_empty() { + None + } else { + info!("Resolving {} media file tokens to CDN URLs", all_tokens.len()); + let resolved = lookup_media_files_as_cdn_url_list_and_map( + &http_request, + &mut mysql_connection, + server_state.server_environment, + &all_tokens, + ).await?; + Some(resolved.token_to_url_map) + }; + + // ==================== HYDRATE ROUTER REQUEST ==================== // + + let router_builder = hydrate_to_router_request(&request)?; + + // ==================== DEBUG LOG: HTTP REQUEST ==================== // + + let ip_address = get_request_ip(&http_request); + let request_url = http_request.uri().to_string(); + + if let Err(err) = insert_debug_log(InsertDebugLogArgs { + apriori_debug_log_event_token: Some(&debug_log_event_token), + maybe_creator_user_token: Some(user_token), + debug_log_type: DebugLogType::HttpRequest, + maybe_log_level: Some(DebugLogLevel::Info), + maybe_ip_address: Some(&ip_address), + maybe_url: Some(&request_url), + message: &serde_json::to_string(&*request).unwrap_or_default(), + mysql_executor: &mut *mysql_connection, + phantom: Default::default(), + }).await { + warn!("Failed to insert HTTP request debug log: {:?}", err); + } + + // NB: The pipeline takes over the connection for its remaining pre-request DB writes (billing, + // outbound provider request debug log) and releases it before the (slow, external) generation + // call — holding a pool slot across that call is what starves the pool and causes PoolTimedOut + // on unrelated endpoints. We re-acquire below to write the result. + + let debug_log_context = GenerationDebugLogContext { + event_token: &debug_log_event_token, + user_token, + ip_address: &ip_address, + request_url: &request_url, + }; + + let pipeline_result = run_pipeline_v2(RunPipelineV2Args { + router_builder: &router_builder, + server_state: &server_state, + user_token, + media_file_to_url_map: &media_file_to_url_map, + debug_log_context: &debug_log_context, + mysql_connection, + }).await; + + // ==================== DEBUG LOG: PIPELINE ERROR ==================== // + + let pipeline_result = match pipeline_result { + Ok(result) => result, + Err(err) => { + // Best-effort error log; never mask the original error. + if let Ok(mut error_log_connection) = server_state.mysql_pool.acquire().await { + if let Err(log_err) = insert_debug_log(InsertDebugLogArgs { + apriori_debug_log_event_token: Some(&debug_log_event_token), + maybe_creator_user_token: Some(user_token), + debug_log_type: DebugLogType::BackendFailure, + maybe_log_level: Some(DebugLogLevel::Error), + maybe_ip_address: Some(&ip_address), + maybe_url: Some(&request_url), + message: &format!("Splat generation pipeline failed: {:?}", err), + mysql_executor: &mut *error_log_connection, + phantom: Default::default(), + }).await { + warn!("Failed to insert pipeline error debug log: {:?}", log_err); + } + } + return Err(err); + } + }; + + let mut mysql_connection = server_state.mysql_pool.acquire().await?; + + // NB: Outbound provider requests are debug-logged inside the pipeline + // BEFORE the send, so the payload is captured even on failure. + + // ==================== WRITE RESULT ==================== // + + let maybe_platform_type = get_request_platform_type(&http_request); + + let mut transaction = mysql_connection.begin().await.map_err(|err| { + error!("Error starting MySQL transaction: {:?}", err); + CommonWebError::from_error(err) + })?; + + // -- Prompt -- + + let prompt_token = match insert_prompt(InsertPromptArgs { + maybe_apriori_prompt_token: None, + prompt_type: PromptType::ArtcraftApp, + maybe_creator_user_token: Some(user_token), + maybe_model_type: maybe_prompt_model_type, + maybe_generation_provider: Some(GenerationProvider::Artcraft), + maybe_positive_prompt: request.prompt.as_deref(), + maybe_negative_prompt: None, + maybe_other_args: None, + maybe_generation_mode: Some(determine_generation_mode(&request)), + maybe_aspect_ratio: None, + maybe_resolution: None, + maybe_bitrate: None, + maybe_batch_count: None, + maybe_generate_audio: None, + maybe_duration_seconds: None, + creator_ip_address: &ip_address, + mysql_executor: &mut *transaction, + phantom: Default::default(), + }).await { + Ok(token) => Some(token), + Err(err) => { + warn!("Error inserting prompt: {:?}", err); + None + } + }; + + // -- Prompt context items -- + + if let Some(token) = prompt_token.as_ref() { + let mut context_items = Vec::new(); + + if let Some(ref_tokens) = &request.reference_image_media_tokens { + for media_token in ref_tokens { + context_items.push(PromptContextItem { + media_token: media_token.clone(), + context_semantic_type: PromptContextSemanticType::Imgref, + }); + } + } + if let Some(video_token) = &request.reference_video_media_token { + context_items.push(PromptContextItem { + media_token: video_token.clone(), + context_semantic_type: PromptContextSemanticType::VidRef, + }); + } + + if !context_items.is_empty() { + if let Err(err) = insert_batch_prompt_context_items(InsertBatchArgs { + prompt_token: token.clone(), + items: context_items, + transaction: &mut transaction, + }).await { + warn!("Error inserting batch prompt context items: {:?}", err); + } + } + } + + // -- Inference job -- + + let (primary_job_token, all_job_tokens): (InferenceJobToken, Vec) = + match &pipeline_result.response { + GenerateSplatResponse::WorldLabs(payload) => { + info!("Inserting worldlabs splat job with token: {:?}", pipeline_result.billing.apriori_job_token); + + let token = insert_worldlabs_job(InsertWorldlabsJobArgs { + operation_id: &payload.operation_id, + maybe_wallet_ledger_entry_token: pipeline_result.billing.maybe_wallet_ledger_entry_token.as_ref(), + shared: SharedJobArgs { + apriori_job_token: &pipeline_result.billing.apriori_job_token, + idempotency_token: &idempotency_token, + user_token, + maybe_avt_token: maybe_avt_token.as_ref(), + maybe_model_type: maybe_prompt_model_type, + maybe_prompt_token: prompt_token.as_ref(), + maybe_debug_log_event_token: Some(&debug_log_event_token), + maybe_platform_type, + maybe_cost_estimates: Some(pipeline_result.cost_estimates), + ip_address: &ip_address, + transaction: &mut transaction, + }, + }).await?; + + ( + token.clone(), + vec![token], + ) + } + GenerateSplatResponse::Artcraft(payload) => { + ( + payload.inference_job_token.clone(), + payload.all_inference_job_tokens.clone(), + ) + } + }; + + transaction.commit().await.map_err(|err| { + error!("Error committing transaction: {:?}", err); + CommonWebError::from_error(err) + })?; + + Ok(Json(OmniGenSplatGenerateResponse { + success: true, + inference_job_token: primary_job_token, + all_job_tokens, + })) +} + +fn determine_generation_mode(request: &OmniGenSplatCostAndGenerateRequest) -> CommonGenerationMode { + let has_reference = request.reference_image_media_tokens.as_ref().is_some_and(|t| !t.is_empty()) + || request.reference_video_media_token.is_some(); + + if has_reference { + return CommonGenerationMode::Reference; + } + + CommonGenerationMode::Text +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/splat/pipeline_v2/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/splat/pipeline_v2/mod.rs new file mode 100644 index 0000000000..f207b21293 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/splat/pipeline_v2/mod.rs @@ -0,0 +1 @@ +pub mod run_pipeline_v2; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/splat/pipeline_v2/run_pipeline_v2.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/splat/pipeline_v2/run_pipeline_v2.rs new file mode 100644 index 0000000000..008fb931a0 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/splat/pipeline_v2/run_pipeline_v2.rs @@ -0,0 +1,208 @@ +use std::collections::HashMap; +use std::convert::TryFrom; + +use log::{error, info, warn}; + +use artcraft_router::api::router_provider::RouterProvider; +use artcraft_router::generate::generate_splat::generate_splat_request_builder::GenerateSplatRequestBuilder; +use artcraft_router::generate::generate_splat::generate_splat_response::GenerateSplatResponse; +use artcraft_router::generate::generate_splat::splat_generation_draft_context::SplatGenerationDraftContext; +use artcraft_router::generate::generate_splat::splat_generation_draft_or_request::SplatGenerationDraftOrRequest; +use mysql_queries::queries::generic_inference::common::job_cost_estimates::JobCostEstimates; +use tokens::tokens::media_files::MediaFileToken; +use tokens::tokens::users::UserToken; + +use crate::http_server::common_responses::common_web_error::CommonWebError; +use crate::http_server::endpoint_helpers::refund_wallet_after_api_failure::refund_wallet_after_api_failure; +use crate::http_server::endpoints::generate::common::generation_debug_logs::{ + insert_provider_request_debug_log, provider_request_debug_log_type, GenerationDebugLogContext, +}; +use crate::http_server::endpoints::omni_gen::generate::splat::helpers::pipeline_result::PipelineResult; +use crate::http_server::endpoints::omni_gen::generate::video::helpers::bill_wallet::bill_wallet; +use crate::http_server::endpoints::omni_gen::generate::video::helpers::build_router_client::build_router_client; +use crate::http_server::endpoints::omni_gen::generate::video::kinovi_account::KinoviAccount; +use crate::http_server::endpoints::omni_gen::shared_utils::splat::map_worldlabs_router_error::map_worldlabs_router_error; +use crate::state::server_state::ServerState; + +pub struct RunPipelineV2Args<'a> { + pub router_builder: &'a GenerateSplatRequestBuilder, + pub server_state: &'a ServerState, + pub user_token: &'a UserToken, + pub media_file_to_url_map: &'a Option>, + pub debug_log_context: &'a GenerationDebugLogContext<'a>, + /// The handler's open connection. The pipeline uses it for its remaining + /// pre-request DB writes (billing, outbound-request debug log) and releases + /// it BEFORE the external provider call. + pub mysql_connection: sqlx::pool::PoolConnection, +} + +// NB: This pipeline does an external generation call (`upload_and_generate`) that can take many +// seconds. It deliberately does NOT hold a pooled DB connection across that call — it acquires +// short-lived connections only for the billing and (on failure) refund writes. Holding a pooled +// connection across the external call is what starves the pool and causes `PoolTimedOut`. +pub async fn run_pipeline_v2(args: RunPipelineV2Args<'_>) -> Result { + let RunPipelineV2Args { + router_builder, + server_state, + user_token, + media_file_to_url_map, + debug_log_context, + mut mysql_connection, + } = args; + + let router_builder = router_builder.clone(); + + // All splat models are fulfilled through World Labs. + let provider = RouterProvider::WorldLabs; + + // 1. Build execution request. + // World Labs media inputs stay token-typed here — requests with media + // return a Draft whose finalize step maps tokens to Artcraft URLs via + // the draft context (mirroring the audio pipeline's Kinovi drafts). + let mut exec_builder = router_builder.clone(); + exec_builder.provider = provider; + + let draft_or_request = exec_builder.build2() + .map_err(|e| { + warn!("Failed to build2 for splat v2 pipeline: {}", e); + CommonWebError::from_error(e) + })?; + + // 2. Calculate cost. + // Swap provider to Artcraft so credits = cents. + let system_cost_estimate = { + let mut cost_builder = router_builder.clone(); + cost_builder.provider = RouterProvider::Artcraft; + + cost_builder.build2() + .map_err(|e| { + warn!("Failed to build2 splat cost estimate for v2: {}", e); + CommonWebError::from_error(e) + })? + .estimate_cost() + .map_err(|e| { + warn!("Failed to estimate splat cost for v2: {}", e); + CommonWebError::from_error(e) + })? + }; + + let cost = system_cost_estimate.cost_in_credits.unwrap_or(0); + + // Provider-side estimate (what the fulfilling provider charges us). + // Bookkeeping only — failures must not block generation. + let maybe_provider_cost_estimate = match draft_or_request.estimate_cost() { + Ok(estimate) => Some(estimate), + Err(err) => { + warn!("Failed to estimate provider cost for v2 splat: {}", err); + None + } + }; + + let cost_estimates = JobCostEstimates { + maybe_external_third_party_cost_credits: maybe_provider_cost_estimate.as_ref() + .and_then(|e| e.cost_in_credits) + .and_then(|v| u32::try_from(v).ok()), + maybe_external_third_party_cost_usd_cents: maybe_provider_cost_estimate.as_ref() + .and_then(|e| e.cost_in_usd_cents) + .and_then(|v| u32::try_from(v).ok()), + maybe_system_cost_credits: system_cost_estimate.cost_in_credits + .and_then(|v| u32::try_from(v).ok()), + maybe_system_cost_usd_cents: system_cost_estimate.cost_in_usd_cents + .and_then(|v| u32::try_from(v).ok()), + }; + + info!("v2 splat estimated cost: {} credits (estimates: {:?})", cost, cost_estimates); + + // 3. Bill wallet on the handler's connection (same pre-request DB phase). + let billing = bill_wallet(user_token, cost, &mut mysql_connection).await?; + + // Debug-log the outbound provider request BEFORE the send — still on the + // handler's connection — so the payload is captured even when the + // upload/enqueue fails. + if let Some(debug_log_type) = provider_request_debug_log_type(provider) { + insert_provider_request_debug_log( + debug_log_context, + debug_log_type, + &format!("{:#?}", draft_or_request), + &mut *mysql_connection, + ).await; + } + + // NB: Done with pre-request DB writes. Release the pooled connection before + // the (slow, external) provider call — holding it across that call is what + // starves the pool and causes PoolTimedOut. Post-send writes re-acquire. + drop(mysql_connection); + + // 4. Finalize the draft (if any) and generate the splat. + // NB: No pooled DB connection is held across this call. + let result = upload_and_generate( + draft_or_request, + server_state, + media_file_to_url_map.as_ref(), + ).await; + + // 5. On failure, refund the wallet (mirroring the legacy marble splat + // handlers). The job row is never inserted when the send fails, so + // nothing downstream would ever refund it. + if let Err(ref err) = result { + if let Some(ledger_entry_token) = billing.maybe_wallet_ledger_entry_token.as_ref() { + warn!("v2 splat generation failed, issuing refund for {}: {:?}", ledger_entry_token.as_str(), err); + + match server_state.mysql_pool.acquire().await { + Ok(mut refund_connection) => { + if let Err(refund_err) = refund_wallet_after_api_failure(ledger_entry_token, &mut refund_connection).await { + error!("Failed to refund wallet after v2 splat failure: {:?}", refund_err); + } + } + Err(acquire_err) => { + error!("Failed to acquire MySQL connection to refund wallet after v2 splat failure: {:?}", acquire_err); + } + } + } + } + + let response = result?; + + info!("v2 splat generation response: {:?}", response); + + Ok(PipelineResult { billing, response, cost_estimates }) +} + +/// Finalize the draft (resolving media tokens to URLs), then send the +/// generation request. +/// +/// This is the block that gets refunded on failure. +async fn upload_and_generate( + draft_or_request: SplatGenerationDraftOrRequest, + server_state: &ServerState, + media_file_urls_by_token: Option<&HashMap>, +) -> Result { + + let provider = draft_or_request.get_provider(); + // Kinovi is not a splat provider; the account argument is inert for World Labs. + let client = build_router_client(provider, server_state, KinoviAccount::Volcengine)?; + + let splat_request = match draft_or_request { + SplatGenerationDraftOrRequest::Request(request) => request, + SplatGenerationDraftOrRequest::Draft(draft) => { + let draft_context = SplatGenerationDraftContext { + client: Some(&client), + media_file_to_artcraft_url_map: media_file_urls_by_token, + }; + + draft.finalize(draft_context) + .await + .map_err(|err| { + warn!("Failed to finalize v2 splat draft: {:?}", err); + map_worldlabs_router_error(err) + })? + } + }; + + splat_request.send_request(&client) + .await + .map_err(|err| { + warn!("v2 splat generation failed: {:?}", err); + map_worldlabs_router_error(err) + }) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/helpers/build_router_client.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/helpers/build_router_client.rs index 780254c5ac..ad6bfce8fe 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/helpers/build_router_client.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/helpers/build_router_client.rs @@ -6,7 +6,9 @@ use artcraft_router::client::router_fal_client::RouterFalClient; use artcraft_router::client::router_gmicloud_client::RouterGmiCloudClient; use artcraft_router::client::router_grok_api_client::RouterGrokApiClient; use artcraft_router::client::router_seedance2pro_client::RouterSeedance2ProClient; +use artcraft_router::client::router_worldlabs_client::RouterWorldLabsClient; use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession; +use worldlabs_api_client::credentials::world_labs_api_creds::WorldLabsApiCreds; use crate::http_server::common_responses::common_web_error::CommonWebError; use crate::http_server::endpoints::omni_gen::generate::video::kinovi_account::KinoviAccount; @@ -38,6 +40,12 @@ pub fn build_router_client( server_state.inference_providers.grok_api.api_key.clone(), ))) } + RouterProvider::WorldLabs => { + let creds = WorldLabsApiCreds::new( + server_state.inference_providers.worldlabs.api_key.clone(), + ); + Ok(RouterClient::WorldLabs(RouterWorldLabsClient::new(creds))) + } other => { Err(CommonWebError::server_error_with_message( &format!("Unsupported provider for video generation: {:?}", other), diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/models/mesh/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/models/mesh/mod.rs new file mode 100644 index 0000000000..17657d500a --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/models/mesh/mod.rs @@ -0,0 +1 @@ +pub mod omni_gen_mesh_models_handler; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/models/mesh/omni_gen_mesh_models_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/models/mesh/omni_gen_mesh_models_handler.rs new file mode 100644 index 0000000000..8117ded4d5 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/models/mesh/omni_gen_mesh_models_handler.rs @@ -0,0 +1,27 @@ +use std::sync::Arc; + +use actix_web::web::Json; +use actix_web::{web, HttpRequest}; +use artcraft_api_defs::omni_gen::models::omni_gen_mesh_models::OmniGenMeshModelsResponse; + +use crate::configs::omni_gen::mesh_models::OMNI_GEN_MESH_MODELS_AND_PROVIDERS; +use crate::http_server::common_responses::common_web_error::CommonWebError; +use crate::state::server_state::ServerState; + +/// List available mesh (3D object) models. +#[utoipa::path( + get, + tag = "Omni Gen", + path = "/v1/omni_gen/models/mesh", + responses( + (status = 200, description = "Success", body = OmniGenMeshModelsResponse), + (status = 500, description = "Server error"), + ), +)] +pub async fn omni_gen_mesh_models_handler( + _http_request: HttpRequest, + _server_state: web::Data>, +) -> Result, CommonWebError> { + let response = (*OMNI_GEN_MESH_MODELS_AND_PROVIDERS).clone(); + Ok(Json(response)) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/models/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/models/mod.rs index 53475895eb..cd3fbb9d3f 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/models/mod.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/models/mod.rs @@ -1,3 +1,5 @@ pub mod audio; pub mod image; +pub mod mesh; +pub mod splat; pub mod video; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/models/splat/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/models/splat/mod.rs new file mode 100644 index 0000000000..ae318ec80b --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/models/splat/mod.rs @@ -0,0 +1 @@ +pub mod omni_gen_splat_models_handler; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/models/splat/omni_gen_splat_models_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/models/splat/omni_gen_splat_models_handler.rs new file mode 100644 index 0000000000..4e55c68200 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/models/splat/omni_gen_splat_models_handler.rs @@ -0,0 +1,27 @@ +use std::sync::Arc; + +use actix_web::web::Json; +use actix_web::{web, HttpRequest}; +use artcraft_api_defs::omni_gen::models::omni_gen_splat_models::OmniGenSplatModelsResponse; + +use crate::configs::omni_gen::splat_models::OMNI_GEN_SPLAT_MODELS_AND_PROVIDERS; +use crate::http_server::common_responses::common_web_error::CommonWebError; +use crate::state::server_state::ServerState; + +/// List available splat (3D world) models. +#[utoipa::path( + get, + tag = "Omni Gen", + path = "/v1/omni_gen/models/splat", + responses( + (status = 200, description = "Success", body = OmniGenSplatModelsResponse), + (status = 500, description = "Server error"), + ), +)] +pub async fn omni_gen_splat_models_handler( + _http_request: HttpRequest, + _server_state: web::Data>, +) -> Result, CommonWebError> { + let response = (*OMNI_GEN_SPLAT_MODELS_AND_PROVIDERS).clone(); + Ok(Json(response)) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/shared_utils/mesh/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/shared_utils/mesh/mod.rs new file mode 100644 index 0000000000..9bcea5df59 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/shared_utils/mesh/mod.rs @@ -0,0 +1 @@ +pub mod validate_mesh_request; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/shared_utils/mesh/validate_mesh_request.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/shared_utils/mesh/validate_mesh_request.rs new file mode 100644 index 0000000000..869e50f828 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/shared_utils/mesh/validate_mesh_request.rs @@ -0,0 +1,296 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_mesh_cost_and_generate_request::OmniGenMeshCostAndGenerateRequest; +use enums::common::generation::common_mesh_model::CommonMeshModel; + +use crate::http_server::common_responses::common_web_error::CommonWebError; + +const MIN_FACE_COUNT: u64 = 40_000; +const MAX_FACE_COUNT: u64 = 1_500_000; + +/// Validate requests before they incur user costs or send API requests. +pub fn validate_mesh_request( + request: &OmniGenMeshCostAndGenerateRequest, +) -> Result<(), CommonWebError> { + // Model presence is enforced later during hydration; nothing to validate without it. + let model = match request.model { + Some(model) => model, + None => return Ok(()), + }; + + let reference_image_count = request.reference_image_media_tokens.as_ref().map_or(0, |t| t.len()); + + // `reference_image_media_tokens` is the primary/front (or sketch) image; + // additional views go through the dedicated side-image fields. + if reference_image_count > 1 { + return Err(bad_input( + "at most one reference image is supported; use the front/back/left/right \ + fields for multi-view input".to_string(), + )); + } + + if reference_image_count > 0 && request.front_image_media_token.is_some() { + return Err(bad_input( + "provide either reference_image_media_tokens or front_image_media_token, not both".to_string(), + )); + } + + let has_primary_image = reference_image_count > 0 || request.front_image_media_token.is_some(); + let has_side_images = request.back_image_media_token.is_some() + || request.left_image_media_token.is_some() + || request.right_image_media_token.is_some(); + let has_prompt = request.prompt.as_deref().is_some_and(|p| !p.trim().is_empty()); + + match model { + CommonMeshModel::Hunyuan3d2p0 | CommonMeshModel::Hunyuan3d2p1 => { + if !has_primary_image { + return Err(bad_input(format!( + "model {:?} requires exactly one input image", model, + ))); + } + if has_side_images { + return Err(bad_input(format!( + "model {:?} does not support multi-view input", model, + ))); + } + } + CommonMeshModel::Hunyuan3d3 => { + if !has_primary_image && !has_prompt { + return Err(bad_input( + "model hunyuan_3d_3 requires an input image or a prompt".to_string(), + )); + } + if has_side_images && !has_primary_image { + return Err(bad_input( + "multi-view side images require a front/reference image".to_string(), + )); + } + } + CommonMeshModel::Hunyuan3d3Sketch => { + if !has_primary_image || !has_prompt { + return Err(bad_input( + "model hunyuan_3d_3_sketch requires both a sketch image and a prompt".to_string(), + )); + } + if has_side_images { + return Err(bad_input( + "model hunyuan_3d_3_sketch does not support multi-view input".to_string(), + )); + } + } + } + + if let Some(face_count) = request.face_count { + if !(MIN_FACE_COUNT..=MAX_FACE_COUNT).contains(&face_count) { + return Err(bad_input(format!( + "face_count must be between {} and {}", MIN_FACE_COUNT, MAX_FACE_COUNT, + ))); + } + } + + Ok(()) +} + +fn bad_input(message: String) -> CommonWebError { + CommonWebError::BadInputWithSimpleMessage(message) +} + +#[cfg(test)] +mod tests { + use tokens::tokens::media_files::MediaFileToken; + + use super::*; + + mod image_requirement_tests { + use super::*; + + #[test] + fn hunyuan_2p0_rejects_prompt_only() { + let request = base_request(CommonMeshModel::Hunyuan3d2p0); + assert!(validate_mesh_request(&request).is_err()); + } + + #[test] + fn hunyuan_2p1_rejects_prompt_only() { + let request = base_request(CommonMeshModel::Hunyuan3d2p1); + assert!(validate_mesh_request(&request).is_err()); + } + + #[test] + fn hunyuan_2p0_accepts_exactly_one_image() { + let request = OmniGenMeshCostAndGenerateRequest { + reference_image_media_tokens: Some(vec![media_token()]), + ..base_request(CommonMeshModel::Hunyuan3d2p0) + }; + assert!(validate_mesh_request(&request).is_ok()); + } + + #[test] + fn hunyuan_2p1_rejects_multiple_reference_images() { + let request = OmniGenMeshCostAndGenerateRequest { + reference_image_media_tokens: Some(vec![media_token(), media_token()]), + ..base_request(CommonMeshModel::Hunyuan3d2p1) + }; + assert!(validate_mesh_request(&request).is_err()); + } + + #[test] + fn hunyuan_3d3_accepts_prompt_only() { + let request = base_request(CommonMeshModel::Hunyuan3d3); + assert!(validate_mesh_request(&request).is_ok()); + } + + #[test] + fn hunyuan_3d3_accepts_image_only() { + let request = OmniGenMeshCostAndGenerateRequest { + prompt: None, + reference_image_media_tokens: Some(vec![media_token()]), + ..base_request(CommonMeshModel::Hunyuan3d3) + }; + assert!(validate_mesh_request(&request).is_ok()); + } + + #[test] + fn hunyuan_3d3_rejects_empty_request() { + let request = OmniGenMeshCostAndGenerateRequest { + prompt: None, + ..base_request(CommonMeshModel::Hunyuan3d3) + }; + assert!(validate_mesh_request(&request).is_err()); + } + + #[test] + fn rejects_both_reference_and_front_image() { + let request = OmniGenMeshCostAndGenerateRequest { + reference_image_media_tokens: Some(vec![media_token()]), + front_image_media_token: Some(media_token()), + ..base_request(CommonMeshModel::Hunyuan3d3) + }; + assert!(validate_mesh_request(&request).is_err()); + } + } + + mod sketch_tests { + use super::*; + + #[test] + fn sketch_requires_image_and_prompt() { + let request = OmniGenMeshCostAndGenerateRequest { + reference_image_media_tokens: Some(vec![media_token()]), + ..base_request(CommonMeshModel::Hunyuan3d3Sketch) + }; + assert!(validate_mesh_request(&request).is_ok()); + } + + #[test] + fn sketch_rejects_missing_image() { + let request = base_request(CommonMeshModel::Hunyuan3d3Sketch); + assert!(validate_mesh_request(&request).is_err()); + } + + #[test] + fn sketch_rejects_missing_prompt() { + let request = OmniGenMeshCostAndGenerateRequest { + prompt: None, + reference_image_media_tokens: Some(vec![media_token()]), + ..base_request(CommonMeshModel::Hunyuan3d3Sketch) + }; + assert!(validate_mesh_request(&request).is_err()); + } + } + + mod multi_view_tests { + use super::*; + + #[test] + fn hunyuan_3d3_accepts_side_images_with_front_image() { + let request = OmniGenMeshCostAndGenerateRequest { + front_image_media_token: Some(media_token()), + back_image_media_token: Some(media_token()), + left_image_media_token: Some(media_token()), + right_image_media_token: Some(media_token()), + ..base_request(CommonMeshModel::Hunyuan3d3) + }; + assert!(validate_mesh_request(&request).is_ok()); + } + + #[test] + fn hunyuan_3d3_rejects_side_images_without_front_image() { + let request = OmniGenMeshCostAndGenerateRequest { + back_image_media_token: Some(media_token()), + ..base_request(CommonMeshModel::Hunyuan3d3) + }; + assert!(validate_mesh_request(&request).is_err()); + } + + #[test] + fn hunyuan_2p0_rejects_side_images() { + let request = OmniGenMeshCostAndGenerateRequest { + reference_image_media_tokens: Some(vec![media_token()]), + back_image_media_token: Some(media_token()), + ..base_request(CommonMeshModel::Hunyuan3d2p0) + }; + assert!(validate_mesh_request(&request).is_err()); + } + + #[test] + fn sketch_rejects_side_images() { + let request = OmniGenMeshCostAndGenerateRequest { + reference_image_media_tokens: Some(vec![media_token()]), + left_image_media_token: Some(media_token()), + ..base_request(CommonMeshModel::Hunyuan3d3Sketch) + }; + assert!(validate_mesh_request(&request).is_err()); + } + } + + mod face_count_tests { + use super::*; + + #[test] + fn accepts_face_count_in_range() { + let request = OmniGenMeshCostAndGenerateRequest { + face_count: Some(500_000), + ..base_request(CommonMeshModel::Hunyuan3d3) + }; + assert!(validate_mesh_request(&request).is_ok()); + } + + #[test] + fn rejects_face_count_too_low() { + let request = OmniGenMeshCostAndGenerateRequest { + face_count: Some(MIN_FACE_COUNT - 1), + ..base_request(CommonMeshModel::Hunyuan3d3) + }; + assert!(validate_mesh_request(&request).is_err()); + } + + #[test] + fn rejects_face_count_too_high() { + let request = OmniGenMeshCostAndGenerateRequest { + face_count: Some(MAX_FACE_COUNT + 1), + ..base_request(CommonMeshModel::Hunyuan3d3) + }; + assert!(validate_mesh_request(&request).is_err()); + } + } + + fn base_request(model: CommonMeshModel) -> OmniGenMeshCostAndGenerateRequest { + OmniGenMeshCostAndGenerateRequest { + idempotency_token: None, + model: Some(model), + prompt: Some("a small ceramic corgi figurine".to_string()), + reference_image_media_tokens: None, + front_image_media_token: None, + back_image_media_token: None, + left_image_media_token: None, + right_image_media_token: None, + mesh_output_type: None, + polygon_type: None, + face_count: None, + enable_pbr: None, + } + } + + fn media_token() -> MediaFileToken { + MediaFileToken::new("mf_test123".to_string()) + } +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/shared_utils/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/shared_utils/mod.rs index b90181697a..d9e885cd7b 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/shared_utils/mod.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/shared_utils/mod.rs @@ -1,3 +1,5 @@ pub mod audio; pub mod map_seedance2pro_router_error; +pub mod mesh; +pub mod splat; pub mod video; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/shared_utils/splat/map_worldlabs_router_error.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/shared_utils/splat/map_worldlabs_router_error.rs new file mode 100644 index 0000000000..cdf4f74183 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/shared_utils/splat/map_worldlabs_router_error.rs @@ -0,0 +1,36 @@ +//! Maps artcraft router errors to web errors for the omni_gen splat pipeline. +//! +//! World Labs NSFW rejections become content-policy 4xx responses and +//! auth failures become 403s (mirroring the legacy marble splat handlers). +//! Everything else is unanticipated and stays a 500. + +use artcraft_router::errors::artcraft_router_error::ArtcraftRouterError; +use artcraft_router::errors::provider_error::ProviderError; +use worldlabs_api_client::error::world_labs_error::WorldLabsError; +use worldlabs_api_client::error::world_labs_specific_api_error::WorldLabsSpecificApiError; + +use crate::http_server::common_responses::common_web_error::CommonWebError; + +/// Map an artcraft router error to a web error, unwrapping World Labs errors. +pub fn map_worldlabs_router_error(error: ArtcraftRouterError) -> CommonWebError { + match error { + ArtcraftRouterError::Provider(ProviderError::WorldLabs(worldlabs_error)) => { + classify_worldlabs_error(worldlabs_error) + } + other => CommonWebError::from_error(other), + } +} + +fn classify_worldlabs_error(err: WorldLabsError) -> CommonWebError { + if let WorldLabsError::ApiSpecific(WorldLabsSpecificApiError::NsfwContentPolicyRejected { message }) = &err { + return CommonWebError::ContentPolicyRejectedWithMessage( + message.clone().unwrap_or_else(|| "Content rejected by policy".to_string()) + ); + } + + if err.is_403_forbidden() { + return CommonWebError::Forbidden; + } + + CommonWebError::from_error(err) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/shared_utils/splat/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/shared_utils/splat/mod.rs new file mode 100644 index 0000000000..b3889085da --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/shared_utils/splat/mod.rs @@ -0,0 +1,2 @@ +pub mod map_worldlabs_router_error; +pub mod validate_splat_request; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/shared_utils/splat/validate_splat_request.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/shared_utils/splat/validate_splat_request.rs new file mode 100644 index 0000000000..2a33803e7b --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/shared_utils/splat/validate_splat_request.rs @@ -0,0 +1,189 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_splat_cost_and_generate_request::OmniGenSplatCostAndGenerateRequest; + +use crate::http_server::common_responses::common_web_error::CommonWebError; + +/// World Labs MultiImage input accepts up to this many reference images. +const MAX_IMAGE_REFERENCES: usize = 4; + +/// Validate requests before they incur user costs or send API requests. +/// +/// Model presence is enforced later during hydration; the input-shape rules +/// below apply uniformly to all Marble models. +pub fn validate_splat_request( + request: &OmniGenSplatCostAndGenerateRequest, +) -> Result<(), CommonWebError> { + let image_reference_count = request.reference_image_media_tokens.as_ref().map_or(0, |t| t.len()); + let has_video = request.reference_video_media_token.is_some(); + let has_prompt = request.prompt.as_deref().is_some_and(|p| !p.trim().is_empty()); + + if !has_prompt && image_reference_count == 0 && !has_video { + return Err(bad_input( + "at least one of prompt, reference images, or a reference video must be provided".to_string(), + )); + } + + if has_video && image_reference_count > 0 { + return Err(bad_input( + "a reference video cannot be combined with reference images".to_string(), + )); + } + + if image_reference_count > MAX_IMAGE_REFERENCES { + return Err(bad_input(format!( + "at most {} reference images are supported", MAX_IMAGE_REFERENCES, + ))); + } + + if request.is_panoramic == Some(true) && image_reference_count != 1 { + return Err(bad_input( + "is_panoramic requires exactly one reference image".to_string(), + )); + } + + Ok(()) +} + +fn bad_input(message: String) -> CommonWebError { + CommonWebError::BadInputWithSimpleMessage(message) +} + +#[cfg(test)] +mod tests { + use enums::common::generation::common_splat_model::CommonSplatModel; + use tokens::tokens::media_files::MediaFileToken; + + use super::*; + + mod input_presence_tests { + use super::*; + + #[test] + fn accepts_prompt_only() { + let request = base_request(); + assert!(validate_splat_request(&request).is_ok()); + } + + #[test] + fn accepts_images_only() { + let request = OmniGenSplatCostAndGenerateRequest { + prompt: None, + reference_image_media_tokens: Some(vec![media_token()]), + ..base_request() + }; + assert!(validate_splat_request(&request).is_ok()); + } + + #[test] + fn accepts_video_only() { + let request = OmniGenSplatCostAndGenerateRequest { + prompt: None, + reference_video_media_token: Some(media_token()), + ..base_request() + }; + assert!(validate_splat_request(&request).is_ok()); + } + + #[test] + fn rejects_empty_request() { + let request = OmniGenSplatCostAndGenerateRequest { + prompt: None, + ..base_request() + }; + assert!(validate_splat_request(&request).is_err()); + } + + #[test] + fn rejects_whitespace_only_prompt() { + let request = OmniGenSplatCostAndGenerateRequest { + prompt: Some(" ".to_string()), + ..base_request() + }; + assert!(validate_splat_request(&request).is_err()); + } + } + + mod reference_combination_tests { + use super::*; + + #[test] + fn rejects_video_combined_with_images() { + let request = OmniGenSplatCostAndGenerateRequest { + reference_image_media_tokens: Some(vec![media_token()]), + reference_video_media_token: Some(media_token()), + ..base_request() + }; + assert!(validate_splat_request(&request).is_err()); + } + + #[test] + fn rejects_too_many_reference_images() { + let request = OmniGenSplatCostAndGenerateRequest { + reference_image_media_tokens: Some(vec![ + media_token(), media_token(), media_token(), media_token(), media_token(), + ]), + ..base_request() + }; + assert!(validate_splat_request(&request).is_err()); + } + + #[test] + fn accepts_max_reference_images() { + let request = OmniGenSplatCostAndGenerateRequest { + reference_image_media_tokens: Some(vec![ + media_token(), media_token(), media_token(), media_token(), + ]), + ..base_request() + }; + assert!(validate_splat_request(&request).is_ok()); + } + } + + mod panorama_tests { + use super::*; + + #[test] + fn panorama_accepts_exactly_one_image() { + let request = OmniGenSplatCostAndGenerateRequest { + reference_image_media_tokens: Some(vec![media_token()]), + is_panoramic: Some(true), + ..base_request() + }; + assert!(validate_splat_request(&request).is_ok()); + } + + #[test] + fn panorama_rejects_zero_images() { + let request = OmniGenSplatCostAndGenerateRequest { + is_panoramic: Some(true), + ..base_request() + }; + assert!(validate_splat_request(&request).is_err()); + } + + #[test] + fn panorama_rejects_multiple_images() { + let request = OmniGenSplatCostAndGenerateRequest { + reference_image_media_tokens: Some(vec![media_token(), media_token()]), + is_panoramic: Some(true), + ..base_request() + }; + assert!(validate_splat_request(&request).is_err()); + } + } + + fn base_request() -> OmniGenSplatCostAndGenerateRequest { + OmniGenSplatCostAndGenerateRequest { + idempotency_token: None, + model: Some(CommonSplatModel::Marble1p1), + prompt: Some("a cozy cabin in a snowy forest".to_string()), + reference_image_media_tokens: None, + reference_video_media_token: None, + is_panoramic: None, + disable_recaption: None, + } + } + + fn media_token() -> MediaFileToken { + MediaFileToken::new("mf_test123".to_string()) + } +} diff --git a/crates/service/web/storyteller_web/src/http_server/routes/application_routes/omni_gen_routes.rs b/crates/service/web/storyteller_web/src/http_server/routes/application_routes/omni_gen_routes.rs index 388659fd96..abd686e1a5 100644 --- a/crates/service/web/storyteller_web/src/http_server/routes/application_routes/omni_gen_routes.rs +++ b/crates/service/web/storyteller_web/src/http_server/routes/application_routes/omni_gen_routes.rs @@ -5,12 +5,18 @@ use actix_web::{web, App, Error, HttpResponse}; use crate::http_server::endpoints::omni_gen::cost::audio::omni_gen_audio_cost_handler::omni_gen_audio_cost_handler; use crate::http_server::endpoints::omni_gen::cost::image::omni_gen_image_cost_handler::omni_gen_image_cost_handler; +use crate::http_server::endpoints::omni_gen::cost::mesh::omni_gen_mesh_cost_handler::omni_gen_mesh_cost_handler; +use crate::http_server::endpoints::omni_gen::cost::splat::omni_gen_splat_cost_handler::omni_gen_splat_cost_handler; use crate::http_server::endpoints::omni_gen::cost::video::omni_gen_video_cost_handler::omni_gen_video_cost_handler; use crate::http_server::endpoints::omni_gen::generate::audio::omni_gen_audio_generate_handler::omni_gen_audio_generate_handler; use crate::http_server::endpoints::omni_gen::generate::image::omni_gen_image_generate_handler::omni_gen_image_generate_handler; +use crate::http_server::endpoints::omni_gen::generate::mesh::omni_gen_mesh_generate_handler::omni_gen_mesh_generate_handler; +use crate::http_server::endpoints::omni_gen::generate::splat::omni_gen_splat_generate_handler::omni_gen_splat_generate_handler; use crate::http_server::endpoints::omni_gen::generate::video::omni_gen_video_generate_handler::omni_gen_video_generate_handler; use crate::http_server::endpoints::omni_gen::models::audio::omni_gen_audio_models_handler::omni_gen_audio_models_handler; use crate::http_server::endpoints::omni_gen::models::image::omni_gen_image_models_handler::omni_gen_image_models_handler; +use crate::http_server::endpoints::omni_gen::models::mesh::omni_gen_mesh_models_handler::omni_gen_mesh_models_handler; +use crate::http_server::endpoints::omni_gen::models::splat::omni_gen_splat_models_handler::omni_gen_splat_models_handler; use crate::http_server::endpoints::omni_gen::models::video::omni_gen_video_models_handler::omni_gen_video_models_handler; pub fn add_omni_gen_routes(app: App) -> App @@ -38,6 +44,14 @@ where .route(web::post().to(omni_gen_audio_cost_handler)) .route(web::head().to(|| HttpResponse::Ok())) ) + .service(web::resource("/mesh") + .route(web::post().to(omni_gen_mesh_cost_handler)) + .route(web::head().to(|| HttpResponse::Ok())) + ) + .service(web::resource("/splat") + .route(web::post().to(omni_gen_splat_cost_handler)) + .route(web::head().to(|| HttpResponse::Ok())) + ) ) .service(web::scope("/generate") .service(web::resource("/video") @@ -52,6 +66,14 @@ where .route(web::post().to(omni_gen_audio_generate_handler)) .route(web::head().to(|| HttpResponse::Ok())) ) + .service(web::resource("/mesh") + .route(web::post().to(omni_gen_mesh_generate_handler)) + .route(web::head().to(|| HttpResponse::Ok())) + ) + .service(web::resource("/splat") + .route(web::post().to(omni_gen_splat_generate_handler)) + .route(web::head().to(|| HttpResponse::Ok())) + ) ) .service(web::scope("/models") .service(web::resource("/video") @@ -66,6 +88,14 @@ where .route(web::get().to(omni_gen_audio_models_handler)) .route(web::head().to(|| HttpResponse::Ok())) ) + .service(web::resource("/mesh") + .route(web::get().to(omni_gen_mesh_models_handler)) + .route(web::head().to(|| HttpResponse::Ok())) + ) + .service(web::resource("/splat") + .route(web::get().to(omni_gen_splat_models_handler)) + .route(web::head().to(|| HttpResponse::Ok())) + ) ) ) }