mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-26 03:39:28 -04:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd8fe7f3db | ||
|
|
54b172bf6f | ||
|
|
d7868c7da0 | ||
|
|
d538bc2a68 | ||
|
|
bec8c7ede4 | ||
|
|
0866e63dd4 | ||
|
|
78a5c4ff87 | ||
|
|
d53fad0072 | ||
|
|
2e5e9443a3 |
@@ -77,3 +77,20 @@ Organize for top-to-bottom reading. Important things first, details later.
|
||||
- Private helpers go *below* the methods that call them
|
||||
- Among helpers: meatier logic above leaf-level formatters
|
||||
- **In test modules**: constants first, then test cases (grouped into sub-modules when 2+), then helper functions last
|
||||
|
||||
## Markdown
|
||||
|
||||
- **Tables must be space-padded so columns align in plain text.** Markdown
|
||||
tables are read raw (terminals, diffs, editors) at least as often as
|
||||
rendered, and condensed tables are unreadable there. Pad every cell to its
|
||||
column width:
|
||||
|
||||
```markdown
|
||||
| Model | Configuration | Credits | Speed | Score |
|
||||
|--------------|---------------|--------------------|---------|
|
||||
| Meshy 6 | text or image | 104 | 80.0 | +24 |
|
||||
| Rodin 2.5 | text or image | 13 | 10.0 | +3 |
|
||||
```
|
||||
|
||||
Not: `| Model | Configuration | Credits | Speed | Score |` packed tight
|
||||
with varying widths per row.
|
||||
|
||||
+21
@@ -3,6 +3,7 @@ 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_mesh_quality::CommonMeshQuality;
|
||||
use enums::common::generation::common_polygon_type::CommonPolygonType;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
@@ -38,6 +39,12 @@ pub struct OmniGenMeshCostAndGenerateRequest {
|
||||
/// Right view image (optional; multi-view input).
|
||||
pub right_image_media_token: Option<MediaFileToken>,
|
||||
|
||||
/// Input mesh file for mesh-to-mesh models (part splitting, retopology).
|
||||
/// Required by `hunyuan_3d_3p1_part` (FBX) and
|
||||
/// `hunyuan_3d_3p1_topology` (GLB/OBJ).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub input_mesh_media_token: Option<MediaFileToken>,
|
||||
|
||||
/// The mesh output type to generate (normal, low-poly, or geometry-only).
|
||||
pub mesh_output_type: Option<CommonMeshOutputType>,
|
||||
|
||||
@@ -49,4 +56,18 @@ pub struct OmniGenMeshCostAndGenerateRequest {
|
||||
|
||||
/// Whether to generate PBR (physically based rendering) materials.
|
||||
pub enable_pbr: Option<bool>,
|
||||
|
||||
/// Whether to generate textures. Some models (Tripo3D) can skip texturing
|
||||
/// for a cheaper, faster generation.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub enable_texture: Option<bool>,
|
||||
|
||||
/// Texture quality. `Detailed` costs extra on some models (Tripo3D's HD
|
||||
/// texture tier).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub texture_quality: Option<CommonMeshQuality>,
|
||||
|
||||
/// Geometry quality. `Detailed` costs extra on some models (Tripo3D).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub geometry_quality: Option<CommonMeshQuality>,
|
||||
}
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ use serde_derive::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
/// Response body for the omni-gen video cost estimate endpoint.
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct OmniGenVideoCostResponse {
|
||||
pub success: bool,
|
||||
|
||||
|
||||
+21
@@ -71,6 +71,11 @@ pub struct OmniGenMeshModelDetails {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub multi_view_supported: Option<bool>,
|
||||
|
||||
/// Whether the model takes an existing mesh file as input
|
||||
/// (mesh-to-mesh models like part splitting and retopology).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mesh_input_supported: Option<bool>,
|
||||
|
||||
/// The mesh output types the model supports.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mesh_output_types: Option<Vec<CommonMeshOutputType>>,
|
||||
@@ -87,6 +92,18 @@ pub struct OmniGenMeshModelDetails {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub pbr_supported: Option<bool>,
|
||||
|
||||
/// Whether texture generation can be toggled off (untextured output).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub texture_toggle_supported: Option<bool>,
|
||||
|
||||
/// Whether a texture quality level (standard/detailed) can be selected.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub texture_quality_supported: Option<bool>,
|
||||
|
||||
/// Whether a geometry quality level (standard/detailed) can be selected.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub geometry_quality_supported: Option<bool>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub is_disabled: Option<bool>,
|
||||
}
|
||||
@@ -103,10 +120,14 @@ impl Default for OmniGenMeshModelDetails {
|
||||
image_input_supported: None,
|
||||
sketch_input_supported: None,
|
||||
multi_view_supported: None,
|
||||
mesh_input_supported: None,
|
||||
mesh_output_types: None,
|
||||
polygon_types: None,
|
||||
face_count_supported: None,
|
||||
pbr_supported: None,
|
||||
texture_toggle_supported: None,
|
||||
texture_quality_supported: None,
|
||||
geometry_quality_supported: None,
|
||||
is_disabled: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
/// Reference to an input 3D mesh file (e.g. GLB/OBJ/FBX) for mesh-to-mesh
|
||||
/// models like part splitting and retopology.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum MeshRef {
|
||||
MediaFileToken(MediaFileToken),
|
||||
Url(String),
|
||||
}
|
||||
@@ -2,6 +2,7 @@ pub mod audio_list_ref;
|
||||
pub mod character_list_ref;
|
||||
pub mod image_list_ref;
|
||||
pub mod image_ref;
|
||||
pub mod mesh_ref;
|
||||
pub mod router_aspect_ratio;
|
||||
pub mod router_audio_model;
|
||||
pub mod router_bitrate;
|
||||
|
||||
@@ -18,6 +18,37 @@ pub enum RouterMeshModel {
|
||||
/// [`Self::Hunyuan3d3`], but takes a sketch image plus a prompt as input.
|
||||
#[serde(rename = "hunyuan_3d_3_sketch")]
|
||||
Hunyuan3d3Sketch,
|
||||
|
||||
/// Hunyuan 3D v3.1 Pro: text or (multi-view) image input.
|
||||
#[serde(rename = "hunyuan_3d_3p1_pro")]
|
||||
Hunyuan3d3p1Pro,
|
||||
|
||||
/// Hunyuan 3D v3.1 Rapid: the fast, low-cost tier. Text or single-image
|
||||
/// input.
|
||||
#[serde(rename = "hunyuan_3d_3p1_rapid")]
|
||||
Hunyuan3d3p1Rapid,
|
||||
|
||||
/// Hunyuan 3D v3.1 Part: splits an existing mesh into semantic parts.
|
||||
/// Takes a mesh file as input, not text/images.
|
||||
#[serde(rename = "hunyuan_3d_3p1_part")]
|
||||
Hunyuan3d3p1Part,
|
||||
|
||||
/// Hunyuan 3D v3.1 Smart Topology: retopologizes an existing mesh.
|
||||
/// Takes a mesh file as input, not text/images.
|
||||
#[serde(rename = "hunyuan_3d_3p1_topology")]
|
||||
Hunyuan3d3p1SmartTopology,
|
||||
|
||||
/// Tripo3D H3.1: text, single-image, or multi-view image input.
|
||||
#[serde(rename = "tripo3d_h3p1")]
|
||||
Tripo3dH3p1,
|
||||
|
||||
/// Meshy 6: text or single-image input.
|
||||
#[serde(rename = "meshy_v6")]
|
||||
MeshyV6,
|
||||
|
||||
/// Hyper3D Rodin v2.5 Fast: text or image(s) input.
|
||||
#[serde(rename = "rodin_2p5_fast")]
|
||||
Rodin2p5Fast,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -32,6 +63,13 @@ mod tests {
|
||||
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");
|
||||
assert_serde_round_trip(RouterMeshModel::Hunyuan3d3p1Pro, "hunyuan_3d_3p1_pro");
|
||||
assert_serde_round_trip(RouterMeshModel::Hunyuan3d3p1Rapid, "hunyuan_3d_3p1_rapid");
|
||||
assert_serde_round_trip(RouterMeshModel::Hunyuan3d3p1Part, "hunyuan_3d_3p1_part");
|
||||
assert_serde_round_trip(RouterMeshModel::Hunyuan3d3p1SmartTopology, "hunyuan_3d_3p1_topology");
|
||||
assert_serde_round_trip(RouterMeshModel::Tripo3dH3p1, "tripo3d_h3p1");
|
||||
assert_serde_round_trip(RouterMeshModel::MeshyV6, "meshy_v6");
|
||||
assert_serde_round_trip(RouterMeshModel::Rodin2p5Fast, "rodin_2p5_fast");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -43,6 +81,13 @@ mod tests {
|
||||
(RouterMeshModel::Hunyuan3d2p1, CommonMeshModel::Hunyuan3d2p1),
|
||||
(RouterMeshModel::Hunyuan3d3, CommonMeshModel::Hunyuan3d3),
|
||||
(RouterMeshModel::Hunyuan3d3Sketch, CommonMeshModel::Hunyuan3d3Sketch),
|
||||
(RouterMeshModel::Hunyuan3d3p1Pro, CommonMeshModel::Hunyuan3d3p1Pro),
|
||||
(RouterMeshModel::Hunyuan3d3p1Rapid, CommonMeshModel::Hunyuan3d3p1Rapid),
|
||||
(RouterMeshModel::Hunyuan3d3p1Part, CommonMeshModel::Hunyuan3d3p1Part),
|
||||
(RouterMeshModel::Hunyuan3d3p1SmartTopology, CommonMeshModel::Hunyuan3d3p1SmartTopology),
|
||||
(RouterMeshModel::Tripo3dH3p1, CommonMeshModel::Tripo3dH3p1),
|
||||
(RouterMeshModel::MeshyV6, CommonMeshModel::MeshyV6),
|
||||
(RouterMeshModel::Rodin2p5Fast, CommonMeshModel::Rodin2p5Fast),
|
||||
];
|
||||
for (router_model, expected_common) in cases {
|
||||
let json = serde_json::to_string(&router_model).unwrap();
|
||||
|
||||
+47
@@ -1,8 +1,10 @@
|
||||
use enums::common::generation::common_mesh_output_type::CommonMeshOutputType;
|
||||
use enums::common::generation::common_mesh_quality::CommonMeshQuality;
|
||||
use enums::common::generation::common_polygon_type::CommonPolygonType;
|
||||
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::mesh_ref::MeshRef;
|
||||
use crate::api::router_mesh_model::RouterMeshModel;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
|
||||
@@ -12,10 +14,24 @@ use crate::generate::generate_mesh::providers::artcraft::hunyuan3d_3::build::bui
|
||||
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::artcraft::hunyuan_3d_3p1_part::build::build_artcraft_hunyuan_3d_3p1_part;
|
||||
use crate::generate::generate_mesh::providers::artcraft::hunyuan_3d_3p1_pro::build::build_artcraft_hunyuan_3d_3p1_pro;
|
||||
use crate::generate::generate_mesh::providers::artcraft::hunyuan_3d_3p1_rapid::build::build_artcraft_hunyuan_3d_3p1_rapid;
|
||||
use crate::generate::generate_mesh::providers::artcraft::hunyuan_3d_3p1_smart_topology::build::build_artcraft_hunyuan_3d_3p1_smart_topology;
|
||||
use crate::generate::generate_mesh::providers::artcraft::meshy_v6::build::build_artcraft_meshy_v6;
|
||||
use crate::generate::generate_mesh::providers::artcraft::rodin_2p5_fast::build::build_artcraft_rodin_2p5_fast;
|
||||
use crate::generate::generate_mesh::providers::artcraft::tripo3d_h3p1::build::build_artcraft_tripo3d_h3p1;
|
||||
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;
|
||||
use crate::generate::generate_mesh::providers::fal::hunyuan_3d_3p1_part::build::build_fal_hunyuan_3d_3p1_part;
|
||||
use crate::generate::generate_mesh::providers::fal::hunyuan_3d_3p1_pro::build::build_fal_hunyuan_3d_3p1_pro;
|
||||
use crate::generate::generate_mesh::providers::fal::hunyuan_3d_3p1_rapid::build::build_fal_hunyuan_3d_3p1_rapid;
|
||||
use crate::generate::generate_mesh::providers::fal::hunyuan_3d_3p1_smart_topology::build::build_fal_hunyuan_3d_3p1_smart_topology;
|
||||
use crate::generate::generate_mesh::providers::fal::meshy_v6::build::build_fal_meshy_v6;
|
||||
use crate::generate::generate_mesh::providers::fal::rodin_2p5_fast::build::build_fal_rodin_2p5_fast;
|
||||
use crate::generate::generate_mesh::providers::fal::tripo3d_h3p1::build::build_fal_tripo3d_h3p1;
|
||||
|
||||
/// RouterProvider-agnostic mesh (3D object) generation request. Distilled by
|
||||
/// `build2()` into a `MeshGenerationDraftOrRequest` for the selected
|
||||
@@ -49,6 +65,9 @@ pub struct GenerateMeshRequestBuilder {
|
||||
/// Right view image (optional; multi-view input).
|
||||
pub right_image: Option<ImageRef>,
|
||||
|
||||
/// Input mesh file for mesh-to-mesh models (part splitting, retopology).
|
||||
pub input_mesh: Option<MeshRef>,
|
||||
|
||||
/// The mesh output type to generate (normal, low-poly, or geometry-only).
|
||||
pub mesh_output_type: Option<CommonMeshOutputType>,
|
||||
|
||||
@@ -61,6 +80,16 @@ pub struct GenerateMeshRequestBuilder {
|
||||
/// Whether to generate PBR (physically based rendering) materials.
|
||||
pub enable_pbr: Option<bool>,
|
||||
|
||||
/// Whether to generate textures. Some models (Tripo3D) can skip texturing
|
||||
/// for a cheaper, faster generation.
|
||||
pub enable_texture: Option<bool>,
|
||||
|
||||
/// Texture quality. `Detailed` may cost extra (e.g. Tripo3D's HD tier).
|
||||
pub texture_quality: Option<CommonMeshQuality>,
|
||||
|
||||
/// Geometry quality. `Detailed` may cost extra (e.g. Tripo3D).
|
||||
pub geometry_quality: Option<CommonMeshQuality>,
|
||||
|
||||
/// If the request is a mismatch with the (model/provider), how to mitigate it.
|
||||
pub request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy,
|
||||
|
||||
@@ -81,10 +110,14 @@ impl Default for GenerateMeshRequestBuilder {
|
||||
back_image: None,
|
||||
left_image: None,
|
||||
right_image: None,
|
||||
input_mesh: None,
|
||||
mesh_output_type: None,
|
||||
polygon_type: None,
|
||||
face_count: None,
|
||||
enable_pbr: None,
|
||||
enable_texture: None,
|
||||
texture_quality: None,
|
||||
geometry_quality: None,
|
||||
idempotency_token: None,
|
||||
}
|
||||
}
|
||||
@@ -99,11 +132,25 @@ impl GenerateMeshRequestBuilder {
|
||||
(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),
|
||||
(RouterProvider::Artcraft, RouterMeshModel::Hunyuan3d3p1Pro) => build_artcraft_hunyuan_3d_3p1_pro(self),
|
||||
(RouterProvider::Artcraft, RouterMeshModel::Hunyuan3d3p1Rapid) => build_artcraft_hunyuan_3d_3p1_rapid(self),
|
||||
(RouterProvider::Artcraft, RouterMeshModel::Hunyuan3d3p1Part) => build_artcraft_hunyuan_3d_3p1_part(self),
|
||||
(RouterProvider::Artcraft, RouterMeshModel::Hunyuan3d3p1SmartTopology) => build_artcraft_hunyuan_3d_3p1_smart_topology(self),
|
||||
(RouterProvider::Artcraft, RouterMeshModel::Tripo3dH3p1) => build_artcraft_tripo3d_h3p1(self),
|
||||
(RouterProvider::Artcraft, RouterMeshModel::MeshyV6) => build_artcraft_meshy_v6(self),
|
||||
(RouterProvider::Artcraft, RouterMeshModel::Rodin2p5Fast) => build_artcraft_rodin_2p5_fast(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),
|
||||
(RouterProvider::Fal, RouterMeshModel::Hunyuan3d3p1Pro) => build_fal_hunyuan_3d_3p1_pro(self),
|
||||
(RouterProvider::Fal, RouterMeshModel::Hunyuan3d3p1Rapid) => build_fal_hunyuan_3d_3p1_rapid(self),
|
||||
(RouterProvider::Fal, RouterMeshModel::Hunyuan3d3p1Part) => build_fal_hunyuan_3d_3p1_part(self),
|
||||
(RouterProvider::Fal, RouterMeshModel::Hunyuan3d3p1SmartTopology) => build_fal_hunyuan_3d_3p1_smart_topology(self),
|
||||
(RouterProvider::Fal, RouterMeshModel::Tripo3dH3p1) => build_fal_tripo3d_h3p1(self),
|
||||
(RouterProvider::Fal, RouterMeshModel::MeshyV6) => build_fal_meshy_v6(self),
|
||||
(RouterProvider::Fal, RouterMeshModel::Rodin2p5Fast) => build_fal_rodin_2p5_fast(self),
|
||||
_ => self.unsupported_provider_and_model(),
|
||||
}
|
||||
}
|
||||
|
||||
+155
-54
@@ -11,6 +11,20 @@ use crate::generate::generate_mesh::providers::artcraft::hunyuan_3d_2p0::cost::A
|
||||
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::artcraft::hunyuan_3d_3p1_part::cost::ArtcraftHunyuan3d3p1PartCostState;
|
||||
use crate::generate::generate_mesh::providers::artcraft::hunyuan_3d_3p1_part::request::ArtcraftHunyuan3d3p1PartRequestState;
|
||||
use crate::generate::generate_mesh::providers::artcraft::hunyuan_3d_3p1_pro::cost::ArtcraftHunyuan3d3p1ProCostState;
|
||||
use crate::generate::generate_mesh::providers::artcraft::hunyuan_3d_3p1_pro::request::ArtcraftHunyuan3d3p1ProRequestState;
|
||||
use crate::generate::generate_mesh::providers::artcraft::hunyuan_3d_3p1_rapid::cost::ArtcraftHunyuan3d3p1RapidCostState;
|
||||
use crate::generate::generate_mesh::providers::artcraft::hunyuan_3d_3p1_rapid::request::ArtcraftHunyuan3d3p1RapidRequestState;
|
||||
use crate::generate::generate_mesh::providers::artcraft::hunyuan_3d_3p1_smart_topology::cost::ArtcraftHunyuan3d3p1SmartTopologyCostState;
|
||||
use crate::generate::generate_mesh::providers::artcraft::hunyuan_3d_3p1_smart_topology::request::ArtcraftHunyuan3d3p1SmartTopologyRequestState;
|
||||
use crate::generate::generate_mesh::providers::artcraft::meshy_v6::cost::ArtcraftMeshyV6CostState;
|
||||
use crate::generate::generate_mesh::providers::artcraft::meshy_v6::request::ArtcraftMeshyV6RequestState;
|
||||
use crate::generate::generate_mesh::providers::artcraft::rodin_2p5_fast::cost::ArtcraftRodin2p5FastCostState;
|
||||
use crate::generate::generate_mesh::providers::artcraft::rodin_2p5_fast::request::ArtcraftRodin2p5FastRequestState;
|
||||
use crate::generate::generate_mesh::providers::artcraft::tripo3d_h3p1::cost::ArtcraftTripo3dH3p1CostState;
|
||||
use crate::generate::generate_mesh::providers::artcraft::tripo3d_h3p1::request::ArtcraftTripo3dH3p1RequestState;
|
||||
use crate::generate::generate_mesh::providers::fal::hunyuan3d_3::cost::{
|
||||
FalHunyuan3d3ImageCostState, FalHunyuan3d3TextCostState,
|
||||
};
|
||||
@@ -23,6 +37,40 @@ use crate::generate::generate_mesh::providers::fal::hunyuan_3d_2p0::cost::FalHun
|
||||
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;
|
||||
use crate::generate::generate_mesh::providers::fal::hunyuan_3d_3p1_part::cost::FalHunyuan3d3p1PartCostState;
|
||||
use crate::generate::generate_mesh::providers::fal::hunyuan_3d_3p1_part::request::FalHunyuan3d3p1PartRequestState;
|
||||
use crate::generate::generate_mesh::providers::fal::hunyuan_3d_3p1_pro::cost::{
|
||||
FalHunyuan3d3p1ProImageCostState, FalHunyuan3d3p1ProTextCostState,
|
||||
};
|
||||
use crate::generate::generate_mesh::providers::fal::hunyuan_3d_3p1_pro::request::{
|
||||
FalHunyuan3d3p1ProImageRequestState, FalHunyuan3d3p1ProTextRequestState,
|
||||
};
|
||||
use crate::generate::generate_mesh::providers::fal::hunyuan_3d_3p1_rapid::cost::{
|
||||
FalHunyuan3d3p1RapidImageCostState, FalHunyuan3d3p1RapidTextCostState,
|
||||
};
|
||||
use crate::generate::generate_mesh::providers::fal::hunyuan_3d_3p1_rapid::request::{
|
||||
FalHunyuan3d3p1RapidImageRequestState, FalHunyuan3d3p1RapidTextRequestState,
|
||||
};
|
||||
use crate::generate::generate_mesh::providers::fal::hunyuan_3d_3p1_smart_topology::cost::FalHunyuan3d3p1SmartTopologyCostState;
|
||||
use crate::generate::generate_mesh::providers::fal::hunyuan_3d_3p1_smart_topology::request::FalHunyuan3d3p1SmartTopologyRequestState;
|
||||
use crate::generate::generate_mesh::providers::fal::meshy_v6::cost::{
|
||||
FalMeshyV6ImageCostState, FalMeshyV6TextCostState,
|
||||
};
|
||||
use crate::generate::generate_mesh::providers::fal::meshy_v6::request::{
|
||||
FalMeshyV6ImageRequestState, FalMeshyV6TextRequestState,
|
||||
};
|
||||
use crate::generate::generate_mesh::providers::fal::rodin_2p5_fast::cost::{
|
||||
FalRodin2p5FastImageCostState, FalRodin2p5FastTextCostState,
|
||||
};
|
||||
use crate::generate::generate_mesh::providers::fal::rodin_2p5_fast::request::{
|
||||
FalRodin2p5FastImageRequestState, FalRodin2p5FastTextRequestState,
|
||||
};
|
||||
use crate::generate::generate_mesh::providers::fal::tripo3d_h3p1::cost::{
|
||||
FalTripo3dH3p1ImageCostState, FalTripo3dH3p1MultiviewCostState, FalTripo3dH3p1TextCostState,
|
||||
};
|
||||
use crate::generate::generate_mesh::providers::fal::tripo3d_h3p1::request::{
|
||||
FalTripo3dH3p1ImageRequestState, FalTripo3dH3p1MultiviewRequestState, FalTripo3dH3p1TextRequestState,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum MeshGenerationRequest {
|
||||
@@ -30,41 +78,101 @@ pub enum MeshGenerationRequest {
|
||||
ArtcraftHunyuan3d2p1(ArtcraftHunyuan3d2p1RequestState),
|
||||
ArtcraftHunyuan3d3(ArtcraftHunyuan3d3RequestState),
|
||||
ArtcraftHunyuan3d3Sketch(ArtcraftHunyuan3d3SketchRequestState),
|
||||
ArtcraftHunyuan3d3p1Pro(ArtcraftHunyuan3d3p1ProRequestState),
|
||||
ArtcraftHunyuan3d3p1Rapid(ArtcraftHunyuan3d3p1RapidRequestState),
|
||||
ArtcraftHunyuan3d3p1Part(ArtcraftHunyuan3d3p1PartRequestState),
|
||||
ArtcraftHunyuan3d3p1SmartTopology(ArtcraftHunyuan3d3p1SmartTopologyRequestState),
|
||||
ArtcraftTripo3dH3p1(ArtcraftTripo3dH3p1RequestState),
|
||||
ArtcraftMeshyV6(ArtcraftMeshyV6RequestState),
|
||||
ArtcraftRodin2p5Fast(ArtcraftRodin2p5FastRequestState),
|
||||
FalHunyuan3d2p0(FalHunyuan3d2p0RequestState),
|
||||
FalHunyuan3d2p1(FalHunyuan3d2p1RequestState),
|
||||
FalHunyuan3d3Image(FalHunyuan3d3ImageRequestState),
|
||||
FalHunyuan3d3Text(FalHunyuan3d3TextRequestState),
|
||||
FalHunyuan3d3Sketch(FalHunyuan3d3SketchRequestState),
|
||||
FalHunyuan3d3p1ProImage(FalHunyuan3d3p1ProImageRequestState),
|
||||
FalHunyuan3d3p1ProText(FalHunyuan3d3p1ProTextRequestState),
|
||||
FalHunyuan3d3p1RapidImage(FalHunyuan3d3p1RapidImageRequestState),
|
||||
FalHunyuan3d3p1RapidText(FalHunyuan3d3p1RapidTextRequestState),
|
||||
FalHunyuan3d3p1Part(FalHunyuan3d3p1PartRequestState),
|
||||
FalHunyuan3d3p1SmartTopology(FalHunyuan3d3p1SmartTopologyRequestState),
|
||||
FalTripo3dH3p1Text(FalTripo3dH3p1TextRequestState),
|
||||
FalTripo3dH3p1Image(FalTripo3dH3p1ImageRequestState),
|
||||
FalTripo3dH3p1Multiview(FalTripo3dH3p1MultiviewRequestState),
|
||||
FalMeshyV6Text(FalMeshyV6TextRequestState),
|
||||
FalMeshyV6Image(FalMeshyV6ImageRequestState),
|
||||
FalRodin2p5FastText(FalRodin2p5FastTextRequestState),
|
||||
FalRodin2p5FastImage(FalRodin2p5FastImageRequestState),
|
||||
}
|
||||
|
||||
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,
|
||||
Self::ArtcraftHunyuan3d2p0(_)
|
||||
| Self::ArtcraftHunyuan3d2p1(_)
|
||||
| Self::ArtcraftHunyuan3d3(_)
|
||||
| Self::ArtcraftHunyuan3d3Sketch(_)
|
||||
| Self::ArtcraftHunyuan3d3p1Pro(_)
|
||||
| Self::ArtcraftHunyuan3d3p1Rapid(_)
|
||||
| Self::ArtcraftHunyuan3d3p1Part(_)
|
||||
| Self::ArtcraftHunyuan3d3p1SmartTopology(_)
|
||||
| Self::ArtcraftTripo3dH3p1(_)
|
||||
| Self::ArtcraftMeshyV6(_)
|
||||
| Self::ArtcraftRodin2p5Fast(_) => RouterProvider::Artcraft,
|
||||
Self::FalHunyuan3d2p0(_)
|
||||
| Self::FalHunyuan3d2p1(_)
|
||||
| Self::FalHunyuan3d3Image(_)
|
||||
| Self::FalHunyuan3d3Text(_)
|
||||
| Self::FalHunyuan3d3Sketch(_)
|
||||
| Self::FalHunyuan3d3p1ProImage(_)
|
||||
| Self::FalHunyuan3d3p1ProText(_)
|
||||
| Self::FalHunyuan3d3p1RapidImage(_)
|
||||
| Self::FalHunyuan3d3p1RapidText(_)
|
||||
| Self::FalHunyuan3d3p1Part(_)
|
||||
| Self::FalHunyuan3d3p1SmartTopology(_)
|
||||
| Self::FalTripo3dH3p1Text(_)
|
||||
| Self::FalTripo3dH3p1Image(_)
|
||||
| Self::FalTripo3dH3p1Multiview(_)
|
||||
| Self::FalMeshyV6Text(_)
|
||||
| Self::FalMeshyV6Image(_)
|
||||
| Self::FalRodin2p5FastText(_)
|
||||
| Self::FalRodin2p5FastImage(_) => RouterProvider::Fal,
|
||||
}
|
||||
}
|
||||
|
||||
/// Return a cost estimate to fulfill the request.
|
||||
pub fn estimate_cost(&self) -> Result<MeshGenerationCostEstimate, ArtcraftRouterError> {
|
||||
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()),
|
||||
Self::ArtcraftHunyuan3d2p0(request) => Ok(ArtcraftHunyuan3d2p0CostState::from_request(request).estimate_cost()),
|
||||
Self::ArtcraftHunyuan3d2p1(request) => Ok(ArtcraftHunyuan3d2p1CostState::from_request(request).estimate_cost()),
|
||||
Self::ArtcraftHunyuan3d3(request) => Ok(ArtcraftHunyuan3d3CostState::from_request(request).estimate_cost()),
|
||||
Self::ArtcraftHunyuan3d3Sketch(request) => Ok(ArtcraftHunyuan3d3SketchCostState::from_request(request).estimate_cost()),
|
||||
Self::ArtcraftHunyuan3d3p1Pro(request) => Ok(ArtcraftHunyuan3d3p1ProCostState::from_request(request).estimate_cost()),
|
||||
Self::ArtcraftHunyuan3d3p1Rapid(request) => Ok(ArtcraftHunyuan3d3p1RapidCostState::from_request(request).estimate_cost()),
|
||||
Self::ArtcraftHunyuan3d3p1Part(request) => Ok(ArtcraftHunyuan3d3p1PartCostState::from_request(request).estimate_cost()),
|
||||
Self::ArtcraftHunyuan3d3p1SmartTopology(request) => Ok(ArtcraftHunyuan3d3p1SmartTopologyCostState::from_request(request).estimate_cost()),
|
||||
Self::ArtcraftTripo3dH3p1(request) => Ok(ArtcraftTripo3dH3p1CostState::from_request(request).estimate_cost()),
|
||||
Self::ArtcraftMeshyV6(request) => Ok(ArtcraftMeshyV6CostState::from_request(request).estimate_cost()),
|
||||
Self::ArtcraftRodin2p5Fast(request) => Ok(ArtcraftRodin2p5FastCostState::from_request(request).estimate_cost()),
|
||||
Self::FalHunyuan3d2p0(request) => Ok(FalHunyuan3d2p0CostState::from_request(request).estimate_cost()),
|
||||
Self::FalHunyuan3d2p1(request) => Ok(FalHunyuan3d2p1CostState::from_request(request).estimate_cost()),
|
||||
Self::FalHunyuan3d3Image(request) => Ok(FalHunyuan3d3ImageCostState::from_request(request).estimate_cost()),
|
||||
Self::FalHunyuan3d3Text(request) => Ok(FalHunyuan3d3TextCostState::from_request(request).estimate_cost()),
|
||||
Self::FalHunyuan3d3Sketch(request) => Ok(FalHunyuan3d3SketchCostState::from_request(request).estimate_cost()),
|
||||
Self::FalHunyuan3d3p1ProImage(request) => Ok(FalHunyuan3d3p1ProImageCostState::from_request(request).estimate_cost()),
|
||||
Self::FalHunyuan3d3p1ProText(request) => Ok(FalHunyuan3d3p1ProTextCostState::from_request(request).estimate_cost()),
|
||||
Self::FalHunyuan3d3p1RapidImage(request) => Ok(FalHunyuan3d3p1RapidImageCostState::from_request(request).estimate_cost()),
|
||||
Self::FalHunyuan3d3p1RapidText(request) => Ok(FalHunyuan3d3p1RapidTextCostState::from_request(request).estimate_cost()),
|
||||
Self::FalHunyuan3d3p1Part(request) => Ok(FalHunyuan3d3p1PartCostState::from_request(request).estimate_cost()),
|
||||
Self::FalHunyuan3d3p1SmartTopology(request) => Ok(FalHunyuan3d3p1SmartTopologyCostState::from_request(request).estimate_cost()),
|
||||
Self::FalTripo3dH3p1Text(request) => Ok(FalTripo3dH3p1TextCostState::from_request(request).estimate_cost()),
|
||||
Self::FalTripo3dH3p1Image(request) => Ok(FalTripo3dH3p1ImageCostState::from_request(request).estimate_cost()),
|
||||
Self::FalTripo3dH3p1Multiview(request) => Ok(FalTripo3dH3p1MultiviewCostState::from_request(request).estimate_cost()),
|
||||
Self::FalMeshyV6Text(request) => Ok(FalMeshyV6TextCostState::from_request(request).estimate_cost()),
|
||||
Self::FalMeshyV6Image(request) => Ok(FalMeshyV6ImageCostState::from_request(request).estimate_cost()),
|
||||
Self::FalRodin2p5FastText(request) => Ok(FalRodin2p5FastTextCostState::from_request(request).estimate_cost()),
|
||||
Self::FalRodin2p5FastImage(request) => Ok(FalRodin2p5FastImageCostState::from_request(request).estimate_cost()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,42 +180,35 @@ impl MeshGenerationRequest {
|
||||
/// If successful, returns the job IDs.
|
||||
pub async fn send_request(&self, client: &RouterClient) -> Result<GenerateMeshResponse, ArtcraftRouterError> {
|
||||
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
|
||||
},
|
||||
Self::ArtcraftHunyuan3d2p0(request) => request.send(client.get_artcraft_client_ref()?).await,
|
||||
Self::ArtcraftHunyuan3d2p1(request) => request.send(client.get_artcraft_client_ref()?).await,
|
||||
Self::ArtcraftHunyuan3d3(request) => request.send(client.get_artcraft_client_ref()?).await,
|
||||
Self::ArtcraftHunyuan3d3Sketch(request) => request.send(client.get_artcraft_client_ref()?).await,
|
||||
Self::ArtcraftHunyuan3d3p1Pro(request) => request.send(client.get_artcraft_client_ref()?).await,
|
||||
Self::ArtcraftHunyuan3d3p1Rapid(request) => request.send(client.get_artcraft_client_ref()?).await,
|
||||
Self::ArtcraftHunyuan3d3p1Part(request) => request.send(client.get_artcraft_client_ref()?).await,
|
||||
Self::ArtcraftHunyuan3d3p1SmartTopology(request) => request.send(client.get_artcraft_client_ref()?).await,
|
||||
Self::ArtcraftTripo3dH3p1(request) => request.send(client.get_artcraft_client_ref()?).await,
|
||||
Self::ArtcraftMeshyV6(request) => request.send(client.get_artcraft_client_ref()?).await,
|
||||
Self::ArtcraftRodin2p5Fast(request) => request.send(client.get_artcraft_client_ref()?).await,
|
||||
Self::FalHunyuan3d2p0(request) => request.send(client.get_fal_client_ref()?).await,
|
||||
Self::FalHunyuan3d2p1(request) => request.send(client.get_fal_client_ref()?).await,
|
||||
Self::FalHunyuan3d3Image(request) => request.send(client.get_fal_client_ref()?).await,
|
||||
Self::FalHunyuan3d3Text(request) => request.send(client.get_fal_client_ref()?).await,
|
||||
Self::FalHunyuan3d3Sketch(request) => request.send(client.get_fal_client_ref()?).await,
|
||||
Self::FalHunyuan3d3p1ProImage(request) => request.send(client.get_fal_client_ref()?).await,
|
||||
Self::FalHunyuan3d3p1ProText(request) => request.send(client.get_fal_client_ref()?).await,
|
||||
Self::FalHunyuan3d3p1RapidImage(request) => request.send(client.get_fal_client_ref()?).await,
|
||||
Self::FalHunyuan3d3p1RapidText(request) => request.send(client.get_fal_client_ref()?).await,
|
||||
Self::FalHunyuan3d3p1Part(request) => request.send(client.get_fal_client_ref()?).await,
|
||||
Self::FalHunyuan3d3p1SmartTopology(request) => request.send(client.get_fal_client_ref()?).await,
|
||||
Self::FalTripo3dH3p1Text(request) => request.send(client.get_fal_client_ref()?).await,
|
||||
Self::FalTripo3dH3p1Image(request) => request.send(client.get_fal_client_ref()?).await,
|
||||
Self::FalTripo3dH3p1Multiview(request) => request.send(client.get_fal_client_ref()?).await,
|
||||
Self::FalMeshyV6Text(request) => request.send(client.get_fal_client_ref()?).await,
|
||||
Self::FalMeshyV6Image(request) => request.send(client.get_fal_client_ref()?).await,
|
||||
Self::FalRodin2p5FastText(request) => request.send(client.get_fal_client_ref()?).await,
|
||||
Self::FalRodin2p5FastImage(request) => request.send(client.get_fal_client_ref()?).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
-1
@@ -4,7 +4,7 @@ use enums::common::generation::common_mesh_model::CommonMeshModel as CommonMeshM
|
||||
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,
|
||||
resolve_image_list_ref, resolve_image_ref, resolve_mesh_ref,
|
||||
};
|
||||
|
||||
/// Build an `OmniGenMeshCostAndGenerateRequest` from the builder. The
|
||||
@@ -20,6 +20,7 @@ pub fn build_artcraft_omni_mesh_request(
|
||||
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 input_mesh_media_token = resolve_mesh_ref(builder.input_mesh.take())?;
|
||||
let idempotency_token = builder.get_or_generate_idempotency_token();
|
||||
|
||||
Ok(OmniGenMeshCostAndGenerateRequest {
|
||||
@@ -31,10 +32,14 @@ pub fn build_artcraft_omni_mesh_request(
|
||||
back_image_media_token,
|
||||
left_image_media_token,
|
||||
right_image_media_token,
|
||||
input_mesh_media_token,
|
||||
mesh_output_type: builder.mesh_output_type,
|
||||
polygon_type: builder.polygon_type,
|
||||
face_count: builder.face_count,
|
||||
enable_pbr: builder.enable_pbr,
|
||||
enable_texture: builder.enable_texture,
|
||||
texture_quality: builder.texture_quality,
|
||||
geometry_quality: builder.geometry_quality,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+14
@@ -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_3p1_part::request::ArtcraftHunyuan3d3p1PartRequestState;
|
||||
|
||||
pub fn build_artcraft_hunyuan_3d_3p1_part(builder: GenerateMeshRequestBuilder) -> Result<MeshGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
let request = build_artcraft_omni_mesh_request(builder, CommonMeshModelEnum::Hunyuan3d3p1Part)?;
|
||||
let state = ArtcraftHunyuan3d3p1PartRequestState { request };
|
||||
Ok(MeshGenerationDraftOrRequest::Request(MeshGenerationRequest::ArtcraftHunyuan3d3p1Part(state)))
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
use crate::generate::generate_mesh::mesh_generation_cost_estimate::MeshGenerationCostEstimate;
|
||||
use crate::generate::generate_mesh::providers::artcraft::hunyuan_3d_3p1_part::request::ArtcraftHunyuan3d3p1PartRequestState;
|
||||
|
||||
const FLAT_COST_IN_USD_CENTS: u64 = 59;
|
||||
|
||||
/// Hunyuan 3D v3.1 Part via Artcraft: flat 59¢ per generation, with no
|
||||
/// add-ons.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ArtcraftHunyuan3d3p1PartCostState {
|
||||
pub cost_in_usd_cents: u64,
|
||||
}
|
||||
|
||||
impl ArtcraftHunyuan3d3p1PartCostState {
|
||||
pub fn from_request(_request: &ArtcraftHunyuan3d3p1PartRequestState) -> Self {
|
||||
Self { cost_in_usd_cents: FLAT_COST_IN_USD_CENTS }
|
||||
}
|
||||
|
||||
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::mesh_ref::MeshRef;
|
||||
use crate::api::router_mesh_model::RouterMeshModel;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder;
|
||||
|
||||
mod cost_table {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cost_is_a_flat_fifty_nine_cents() {
|
||||
assert_eq!(estimate_usd_cents(base_builder()), 59);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_do_not_change_the_price() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
enable_pbr: Some(true),
|
||||
face_count: Some(100_000),
|
||||
..base_builder()
|
||||
};
|
||||
assert_eq!(estimate_usd_cents(builder), 59);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
fn base_builder() -> GenerateMeshRequestBuilder {
|
||||
GenerateMeshRequestBuilder {
|
||||
model: RouterMeshModel::Hunyuan3d3p1Part,
|
||||
provider: RouterProvider::Artcraft,
|
||||
input_mesh: Some(MeshRef::MediaFileToken(MediaFileToken::new("mf_mesh".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")
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod build;
|
||||
pub mod cost;
|
||||
pub mod request;
|
||||
+17
@@ -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 ArtcraftHunyuan3d3p1PartRequestState {
|
||||
pub request: OmniGenMeshCostAndGenerateRequest,
|
||||
}
|
||||
|
||||
impl ArtcraftHunyuan3d3p1PartRequestState {
|
||||
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateMeshResponse, ArtcraftRouterError> {
|
||||
send_artcraft_omni_mesh_request(&self.request, client).await
|
||||
}
|
||||
}
|
||||
+14
@@ -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_3p1_pro::request::ArtcraftHunyuan3d3p1ProRequestState;
|
||||
|
||||
pub fn build_artcraft_hunyuan_3d_3p1_pro(builder: GenerateMeshRequestBuilder) -> Result<MeshGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
let request = build_artcraft_omni_mesh_request(builder, CommonMeshModelEnum::Hunyuan3d3p1Pro)?;
|
||||
let state = ArtcraftHunyuan3d3p1ProRequestState { request };
|
||||
Ok(MeshGenerationDraftOrRequest::Request(MeshGenerationRequest::ArtcraftHunyuan3d3p1Pro(state)))
|
||||
}
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
use crate::generate::generate_mesh::mesh_generation_cost_estimate::MeshGenerationCostEstimate;
|
||||
use crate::generate::generate_mesh::providers::artcraft::hunyuan_3d_3p1_pro::request::ArtcraftHunyuan3d3p1ProRequestState;
|
||||
|
||||
const BASE_COST_IN_USD_CENTS: u64 = 49;
|
||||
const ADD_ON_COST_IN_USD_CENTS: u64 = 20;
|
||||
|
||||
/// Hunyuan 3D v3.1 Pro via Artcraft: flat 49¢ base price (any output type),
|
||||
/// plus a flat 20¢ add-on for each extra option (PBR materials, custom face
|
||||
/// count, and multi-view side images).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ArtcraftHunyuan3d3p1ProCostState {
|
||||
pub cost_in_usd_cents: u64,
|
||||
}
|
||||
|
||||
impl ArtcraftHunyuan3d3p1ProCostState {
|
||||
pub fn from_request(request: &ArtcraftHunyuan3d3p1ProRequestState) -> 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;
|
||||
}
|
||||
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_mesh_output_type::CommonMeshOutputType;
|
||||
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 every_output_type_prices_the_same() {
|
||||
let cases = [
|
||||
Some(CommonMeshOutputType::Normal),
|
||||
Some(CommonMeshOutputType::Geometry),
|
||||
];
|
||||
for output_type in cases {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
mesh_output_type: output_type,
|
||||
..base_builder()
|
||||
};
|
||||
assert_eq!(estimate_usd_cents(builder), 49, "for {output_type:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_mode_prices_the_same_as_image_mode() {
|
||||
// Text-only request (no image references).
|
||||
let text_builder = GenerateMeshRequestBuilder {
|
||||
model: RouterMeshModel::Hunyuan3d3p1Pro,
|
||||
provider: RouterProvider::Artcraft,
|
||||
prompt: Some("a red ceramic teapot".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(estimate_usd_cents(text_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 {
|
||||
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), 49 + 20 + 20 + 20);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
fn base_builder() -> GenerateMeshRequestBuilder {
|
||||
GenerateMeshRequestBuilder {
|
||||
model: RouterMeshModel::Hunyuan3d3p1Pro,
|
||||
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")
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod build;
|
||||
pub mod cost;
|
||||
pub mod request;
|
||||
+64
@@ -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 ArtcraftHunyuan3d3p1ProRequestState {
|
||||
pub request: OmniGenMeshCostAndGenerateRequest,
|
||||
}
|
||||
|
||||
impl ArtcraftHunyuan3d3p1ProRequestState {
|
||||
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateMeshResponse, ArtcraftRouterError> {
|
||||
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::Hunyuan3d3p1Pro,
|
||||
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
|
||||
}
|
||||
}
|
||||
+14
@@ -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_3p1_rapid::request::ArtcraftHunyuan3d3p1RapidRequestState;
|
||||
|
||||
pub fn build_artcraft_hunyuan_3d_3p1_rapid(builder: GenerateMeshRequestBuilder) -> Result<MeshGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
let request = build_artcraft_omni_mesh_request(builder, CommonMeshModelEnum::Hunyuan3d3p1Rapid)?;
|
||||
let state = ArtcraftHunyuan3d3p1RapidRequestState { request };
|
||||
Ok(MeshGenerationDraftOrRequest::Request(MeshGenerationRequest::ArtcraftHunyuan3d3p1Rapid(state)))
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
use crate::generate::generate_mesh::mesh_generation_cost_estimate::MeshGenerationCostEstimate;
|
||||
use crate::generate::generate_mesh::providers::artcraft::hunyuan_3d_3p1_rapid::request::ArtcraftHunyuan3d3p1RapidRequestState;
|
||||
|
||||
const BASE_COST_IN_USD_CENTS: u64 = 30;
|
||||
const PBR_ADD_ON_COST_IN_USD_CENTS: u64 = 20;
|
||||
|
||||
/// Hunyuan 3D v3.1 Rapid via Artcraft: flat 30¢ base price, plus a flat 20¢
|
||||
/// add-on for PBR materials.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ArtcraftHunyuan3d3p1RapidCostState {
|
||||
pub cost_in_usd_cents: u64,
|
||||
}
|
||||
|
||||
impl ArtcraftHunyuan3d3p1RapidCostState {
|
||||
pub fn from_request(request: &ArtcraftHunyuan3d3p1RapidRequestState) -> Self {
|
||||
let request = &request.request;
|
||||
|
||||
let mut cost = BASE_COST_IN_USD_CENTS;
|
||||
if request.enable_pbr.unwrap_or(false) {
|
||||
cost += PBR_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_mesh_output_type::CommonMeshOutputType;
|
||||
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::*;
|
||||
|
||||
mod base_costs {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn base_cost_is_thirty_cents() {
|
||||
assert_eq!(estimate_usd_cents(base_builder()), 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_mode_prices_the_same_as_image_mode() {
|
||||
// Text-only request (no image references).
|
||||
let text_builder = GenerateMeshRequestBuilder {
|
||||
model: RouterMeshModel::Hunyuan3d3p1Rapid,
|
||||
provider: RouterProvider::Artcraft,
|
||||
prompt: Some("a red ceramic teapot".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(estimate_usd_cents(text_builder), 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_type_does_not_change_the_price() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
mesh_output_type: Some(CommonMeshOutputType::Geometry),
|
||||
..base_builder()
|
||||
};
|
||||
assert_eq!(estimate_usd_cents(builder), 30);
|
||||
}
|
||||
}
|
||||
|
||||
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), 30 + 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pbr_false_adds_nothing() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
enable_pbr: Some(false),
|
||||
..base_builder()
|
||||
};
|
||||
assert_eq!(estimate_usd_cents(builder), 30);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
fn base_builder() -> GenerateMeshRequestBuilder {
|
||||
GenerateMeshRequestBuilder {
|
||||
model: RouterMeshModel::Hunyuan3d3p1Rapid,
|
||||
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")
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod build;
|
||||
pub mod cost;
|
||||
pub mod request;
|
||||
+64
@@ -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 ArtcraftHunyuan3d3p1RapidRequestState {
|
||||
pub request: OmniGenMeshCostAndGenerateRequest,
|
||||
}
|
||||
|
||||
impl ArtcraftHunyuan3d3p1RapidRequestState {
|
||||
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateMeshResponse, ArtcraftRouterError> {
|
||||
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::Hunyuan3d3p1Rapid,
|
||||
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
|
||||
}
|
||||
}
|
||||
+14
@@ -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_3p1_smart_topology::request::ArtcraftHunyuan3d3p1SmartTopologyRequestState;
|
||||
|
||||
pub fn build_artcraft_hunyuan_3d_3p1_smart_topology(builder: GenerateMeshRequestBuilder) -> Result<MeshGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
let request = build_artcraft_omni_mesh_request(builder, CommonMeshModelEnum::Hunyuan3d3p1SmartTopology)?;
|
||||
let state = ArtcraftHunyuan3d3p1SmartTopologyRequestState { request };
|
||||
Ok(MeshGenerationDraftOrRequest::Request(MeshGenerationRequest::ArtcraftHunyuan3d3p1SmartTopology(state)))
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
use crate::generate::generate_mesh::mesh_generation_cost_estimate::MeshGenerationCostEstimate;
|
||||
use crate::generate::generate_mesh::providers::artcraft::hunyuan_3d_3p1_smart_topology::request::ArtcraftHunyuan3d3p1SmartTopologyRequestState;
|
||||
|
||||
const FLAT_COST_IN_USD_CENTS: u64 = 98;
|
||||
|
||||
/// Hunyuan 3D v3.1 Smart Topology via Artcraft: flat 98¢ per generation,
|
||||
/// with no add-ons.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ArtcraftHunyuan3d3p1SmartTopologyCostState {
|
||||
pub cost_in_usd_cents: u64,
|
||||
}
|
||||
|
||||
impl ArtcraftHunyuan3d3p1SmartTopologyCostState {
|
||||
pub fn from_request(_request: &ArtcraftHunyuan3d3p1SmartTopologyRequestState) -> Self {
|
||||
Self { cost_in_usd_cents: FLAT_COST_IN_USD_CENTS }
|
||||
}
|
||||
|
||||
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::mesh_ref::MeshRef;
|
||||
use crate::api::router_mesh_model::RouterMeshModel;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder;
|
||||
|
||||
mod cost_table {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cost_is_a_flat_ninety_eight_cents() {
|
||||
assert_eq!(estimate_usd_cents(base_builder()), 98);
|
||||
}
|
||||
|
||||
#[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), 98);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
fn base_builder() -> GenerateMeshRequestBuilder {
|
||||
GenerateMeshRequestBuilder {
|
||||
model: RouterMeshModel::Hunyuan3d3p1SmartTopology,
|
||||
provider: RouterProvider::Artcraft,
|
||||
input_mesh: Some(MeshRef::MediaFileToken(MediaFileToken::new("mf_mesh".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")
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod build;
|
||||
pub mod cost;
|
||||
pub mod request;
|
||||
+17
@@ -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 ArtcraftHunyuan3d3p1SmartTopologyRequestState {
|
||||
pub request: OmniGenMeshCostAndGenerateRequest,
|
||||
}
|
||||
|
||||
impl ArtcraftHunyuan3d3p1SmartTopologyRequestState {
|
||||
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateMeshResponse, ArtcraftRouterError> {
|
||||
send_artcraft_omni_mesh_request(&self.request, client).await
|
||||
}
|
||||
}
|
||||
+14
@@ -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::meshy_v6::request::ArtcraftMeshyV6RequestState;
|
||||
|
||||
pub fn build_artcraft_meshy_v6(builder: GenerateMeshRequestBuilder) -> Result<MeshGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
let request = build_artcraft_omni_mesh_request(builder, CommonMeshModelEnum::MeshyV6)?;
|
||||
let state = ArtcraftMeshyV6RequestState { request };
|
||||
Ok(MeshGenerationDraftOrRequest::Request(MeshGenerationRequest::ArtcraftMeshyV6(state)))
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
use crate::generate::generate_mesh::mesh_generation_cost_estimate::MeshGenerationCostEstimate;
|
||||
use crate::generate::generate_mesh::providers::artcraft::meshy_v6::request::ArtcraftMeshyV6RequestState;
|
||||
|
||||
const FLAT_COST_IN_USD_CENTS: u64 = 104;
|
||||
|
||||
/// Meshy 6 via Artcraft: flat price per generation, regardless of options
|
||||
/// or input mode (text or image).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ArtcraftMeshyV6CostState {
|
||||
pub cost_in_usd_cents: u64,
|
||||
}
|
||||
|
||||
impl ArtcraftMeshyV6CostState {
|
||||
pub fn from_request(_request: &ArtcraftMeshyV6RequestState) -> Self {
|
||||
Self { cost_in_usd_cents: FLAT_COST_IN_USD_CENTS }
|
||||
}
|
||||
|
||||
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_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::router_mesh_model::RouterMeshModel;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder;
|
||||
|
||||
use super::*;
|
||||
|
||||
mod flat_pricing {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn image_mode_is_one_hundred_four_cents() {
|
||||
assert_eq!(estimate_usd_cents(image_builder()), 104);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_mode_is_one_hundred_four_cents() {
|
||||
assert_eq!(estimate_usd_cents(text_builder()), 104);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_do_not_change_the_price() {
|
||||
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()
|
||||
};
|
||||
assert_eq!(estimate_usd_cents(builder), 104);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
fn image_builder() -> GenerateMeshRequestBuilder {
|
||||
GenerateMeshRequestBuilder {
|
||||
model: RouterMeshModel::MeshyV6,
|
||||
provider: RouterProvider::Artcraft,
|
||||
reference_images: Some(ImageListRef::MediaFileTokens(vec![
|
||||
MediaFileToken::new("mf_front".to_string()),
|
||||
])),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn text_builder() -> GenerateMeshRequestBuilder {
|
||||
GenerateMeshRequestBuilder {
|
||||
model: RouterMeshModel::MeshyV6,
|
||||
provider: RouterProvider::Artcraft,
|
||||
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")
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod build;
|
||||
pub mod cost;
|
||||
pub mod request;
|
||||
+64
@@ -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 ArtcraftMeshyV6RequestState {
|
||||
pub request: OmniGenMeshCostAndGenerateRequest,
|
||||
}
|
||||
|
||||
impl ArtcraftMeshyV6RequestState {
|
||||
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateMeshResponse, ArtcraftRouterError> {
|
||||
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::MeshyV6,
|
||||
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
|
||||
}
|
||||
}
|
||||
+7
@@ -5,3 +5,10 @@ pub mod hunyuan3d_3;
|
||||
pub mod hunyuan3d_3_sketch;
|
||||
pub mod hunyuan_3d_2p0;
|
||||
pub mod hunyuan_3d_2p1;
|
||||
pub mod hunyuan_3d_3p1_part;
|
||||
pub mod hunyuan_3d_3p1_pro;
|
||||
pub mod hunyuan_3d_3p1_rapid;
|
||||
pub mod hunyuan_3d_3p1_smart_topology;
|
||||
pub mod meshy_v6;
|
||||
pub mod rodin_2p5_fast;
|
||||
pub mod tripo3d_h3p1;
|
||||
|
||||
+13
@@ -6,6 +6,7 @@ use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::mesh_ref::MeshRef;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::client_error::ClientError;
|
||||
|
||||
@@ -32,3 +33,15 @@ pub(super) fn resolve_image_ref(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn resolve_mesh_ref(
|
||||
mesh_ref: Option<MeshRef>,
|
||||
) -> Result<Option<MediaFileToken>, ArtcraftRouterError> {
|
||||
match mesh_ref {
|
||||
None => Ok(None),
|
||||
Some(MeshRef::MediaFileToken(token)) => Ok(Some(token)),
|
||||
Some(MeshRef::Url(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+14
@@ -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::rodin_2p5_fast::request::ArtcraftRodin2p5FastRequestState;
|
||||
|
||||
pub fn build_artcraft_rodin_2p5_fast(builder: GenerateMeshRequestBuilder) -> Result<MeshGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
let request = build_artcraft_omni_mesh_request(builder, CommonMeshModelEnum::Rodin2p5Fast)?;
|
||||
let state = ArtcraftRodin2p5FastRequestState { request };
|
||||
Ok(MeshGenerationDraftOrRequest::Request(MeshGenerationRequest::ArtcraftRodin2p5Fast(state)))
|
||||
}
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
use crate::generate::generate_mesh::mesh_generation_cost_estimate::MeshGenerationCostEstimate;
|
||||
use crate::generate::generate_mesh::providers::artcraft::rodin_2p5_fast::request::ArtcraftRodin2p5FastRequestState;
|
||||
|
||||
const FLAT_COST_IN_USD_CENTS: u64 = 13;
|
||||
|
||||
/// Hyper3D Rodin v2.5 Fast via Artcraft: flat price per generation,
|
||||
/// regardless of options or input mode (text or image).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ArtcraftRodin2p5FastCostState {
|
||||
pub cost_in_usd_cents: u64,
|
||||
}
|
||||
|
||||
impl ArtcraftRodin2p5FastCostState {
|
||||
pub fn from_request(_request: &ArtcraftRodin2p5FastRequestState) -> Self {
|
||||
Self { cost_in_usd_cents: FLAT_COST_IN_USD_CENTS }
|
||||
}
|
||||
|
||||
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_mesh_output_type::CommonMeshOutputType;
|
||||
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::*;
|
||||
|
||||
mod flat_pricing {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn image_mode_is_thirteen_cents() {
|
||||
assert_eq!(estimate_usd_cents(image_builder()), 13);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_mode_is_thirteen_cents() {
|
||||
assert_eq!(estimate_usd_cents(text_builder()), 13);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_do_not_change_the_price() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
mesh_output_type: Some(CommonMeshOutputType::Geometry),
|
||||
enable_pbr: Some(true),
|
||||
..image_builder()
|
||||
};
|
||||
assert_eq!(estimate_usd_cents(builder), 13);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
fn image_builder() -> GenerateMeshRequestBuilder {
|
||||
GenerateMeshRequestBuilder {
|
||||
model: RouterMeshModel::Rodin2p5Fast,
|
||||
provider: RouterProvider::Artcraft,
|
||||
reference_images: Some(ImageListRef::MediaFileTokens(vec![
|
||||
MediaFileToken::new("mf_front".to_string()),
|
||||
])),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn text_builder() -> GenerateMeshRequestBuilder {
|
||||
GenerateMeshRequestBuilder {
|
||||
model: RouterMeshModel::Rodin2p5Fast,
|
||||
provider: RouterProvider::Artcraft,
|
||||
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")
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod build;
|
||||
pub mod cost;
|
||||
pub mod request;
|
||||
+64
@@ -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 ArtcraftRodin2p5FastRequestState {
|
||||
pub request: OmniGenMeshCostAndGenerateRequest,
|
||||
}
|
||||
|
||||
impl ArtcraftRodin2p5FastRequestState {
|
||||
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateMeshResponse, ArtcraftRouterError> {
|
||||
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::Rodin2p5Fast,
|
||||
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
|
||||
}
|
||||
}
|
||||
+14
@@ -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::tripo3d_h3p1::request::ArtcraftTripo3dH3p1RequestState;
|
||||
|
||||
pub fn build_artcraft_tripo3d_h3p1(builder: GenerateMeshRequestBuilder) -> Result<MeshGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
let request = build_artcraft_omni_mesh_request(builder, CommonMeshModelEnum::Tripo3dH3p1)?;
|
||||
let state = ArtcraftTripo3dH3p1RequestState { request };
|
||||
Ok(MeshGenerationDraftOrRequest::Request(MeshGenerationRequest::ArtcraftTripo3dH3p1(state)))
|
||||
}
|
||||
+338
@@ -0,0 +1,338 @@
|
||||
use enums::common::generation::common_mesh_output_type::CommonMeshOutputType;
|
||||
use enums::common::generation::common_mesh_quality::CommonMeshQuality;
|
||||
use enums::common::generation::common_polygon_type::CommonPolygonType;
|
||||
|
||||
use crate::generate::generate_mesh::mesh_generation_cost_estimate::MeshGenerationCostEstimate;
|
||||
use crate::generate::generate_mesh::providers::artcraft::tripo3d_h3p1::request::ArtcraftTripo3dH3p1RequestState;
|
||||
|
||||
// Text and multi-view generations share a price tier; single-image
|
||||
// generations price higher.
|
||||
const TEXT_OR_MULTIVIEW_UNTEXTURED_COST_IN_USD_CENTS: u64 = 13;
|
||||
const TEXT_OR_MULTIVIEW_STANDARD_TEXTURE_COST_IN_USD_CENTS: u64 = 26;
|
||||
const TEXT_OR_MULTIVIEW_DETAILED_TEXTURE_COST_IN_USD_CENTS: u64 = 39;
|
||||
const IMAGE_UNTEXTURED_COST_IN_USD_CENTS: u64 = 26;
|
||||
const IMAGE_STANDARD_TEXTURE_COST_IN_USD_CENTS: u64 = 39;
|
||||
const IMAGE_DETAILED_TEXTURE_COST_IN_USD_CENTS: u64 = 52;
|
||||
const DETAILED_GEOMETRY_ADD_ON_COST_IN_USD_CENTS: u64 = 26;
|
||||
const QUAD_ADD_ON_COST_IN_USD_CENTS: u64 = 7;
|
||||
|
||||
/// Tripo3D H3.1 via Artcraft: base price by input mode (text and multi-view
|
||||
/// share a tier; single-image is higher) and texture tier (untextured,
|
||||
/// standard, or detailed), plus flat add-ons for detailed geometry and quad
|
||||
/// topology.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ArtcraftTripo3dH3p1CostState {
|
||||
pub cost_in_usd_cents: u64,
|
||||
}
|
||||
|
||||
impl ArtcraftTripo3dH3p1CostState {
|
||||
pub fn from_request(request: &ArtcraftTripo3dH3p1RequestState) -> Self {
|
||||
let request = &request.request;
|
||||
|
||||
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();
|
||||
let has_primary_image = request.front_image_media_token.is_some()
|
||||
|| request.reference_image_media_tokens.as_ref().is_some_and(|tokens| !tokens.is_empty());
|
||||
let uses_single_image = !uses_multi_view && has_primary_image;
|
||||
|
||||
// The untextured tier requires that PBR isn't explicitly requested,
|
||||
// since PBR implies texturing.
|
||||
let texture_off = (matches!(request.mesh_output_type, Some(CommonMeshOutputType::Geometry))
|
||||
|| request.enable_texture == Some(false))
|
||||
&& request.enable_pbr != Some(true);
|
||||
|
||||
let (untextured, standard, detailed) = if uses_single_image {
|
||||
(
|
||||
IMAGE_UNTEXTURED_COST_IN_USD_CENTS,
|
||||
IMAGE_STANDARD_TEXTURE_COST_IN_USD_CENTS,
|
||||
IMAGE_DETAILED_TEXTURE_COST_IN_USD_CENTS,
|
||||
)
|
||||
} else {
|
||||
(
|
||||
TEXT_OR_MULTIVIEW_UNTEXTURED_COST_IN_USD_CENTS,
|
||||
TEXT_OR_MULTIVIEW_STANDARD_TEXTURE_COST_IN_USD_CENTS,
|
||||
TEXT_OR_MULTIVIEW_DETAILED_TEXTURE_COST_IN_USD_CENTS,
|
||||
)
|
||||
};
|
||||
|
||||
let mut cost = if texture_off {
|
||||
untextured
|
||||
} else if matches!(request.texture_quality, Some(CommonMeshQuality::Detailed)) {
|
||||
detailed
|
||||
} else {
|
||||
standard
|
||||
};
|
||||
|
||||
if matches!(request.geometry_quality, Some(CommonMeshQuality::Detailed)) {
|
||||
cost += DETAILED_GEOMETRY_ADD_ON_COST_IN_USD_CENTS;
|
||||
}
|
||||
if matches!(request.polygon_type, Some(CommonPolygonType::Quad)) {
|
||||
cost += QUAD_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::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 text_mode_costs {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_is_standard_textures() {
|
||||
assert_eq!(estimate_usd_cents(text_builder()), 26);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn untextured_via_enable_texture_off() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
enable_texture: Some(false),
|
||||
..text_builder()
|
||||
};
|
||||
assert_eq!(estimate_usd_cents(builder), 13);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn untextured_via_geometry_output() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
mesh_output_type: Some(CommonMeshOutputType::Geometry),
|
||||
..text_builder()
|
||||
};
|
||||
assert_eq!(estimate_usd_cents(builder), 13);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn texture_off_with_pbr_keeps_textured_tier() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
enable_texture: Some(false),
|
||||
enable_pbr: Some(true),
|
||||
..text_builder()
|
||||
};
|
||||
assert_eq!(estimate_usd_cents(builder), 26);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detailed_texture() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
texture_quality: Some(CommonMeshQuality::Detailed),
|
||||
..text_builder()
|
||||
};
|
||||
assert_eq!(estimate_usd_cents(builder), 39);
|
||||
}
|
||||
}
|
||||
|
||||
mod image_mode_costs {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_is_standard_textures() {
|
||||
assert_eq!(estimate_usd_cents(image_builder()), 39);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn untextured_via_enable_texture_off() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
enable_texture: Some(false),
|
||||
..image_builder()
|
||||
};
|
||||
assert_eq!(estimate_usd_cents(builder), 26);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn untextured_via_geometry_output() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
mesh_output_type: Some(CommonMeshOutputType::Geometry),
|
||||
..image_builder()
|
||||
};
|
||||
assert_eq!(estimate_usd_cents(builder), 26);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detailed_texture() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
texture_quality: Some(CommonMeshQuality::Detailed),
|
||||
..image_builder()
|
||||
};
|
||||
assert_eq!(estimate_usd_cents(builder), 52);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn front_image_token_prices_as_image_mode() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
front_image: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_front".to_string()))),
|
||||
..base_builder()
|
||||
};
|
||||
assert_eq!(estimate_usd_cents(builder), 39);
|
||||
}
|
||||
}
|
||||
|
||||
mod multiview_mode_costs {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_is_standard_textures() {
|
||||
assert_eq!(estimate_usd_cents(multiview_builder()), 26);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_side_view_prices_as_multiview() {
|
||||
for side in ["back", "left", "right"] {
|
||||
let mut builder = image_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), 26, "for {side} image");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn untextured_via_enable_texture_off() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
enable_texture: Some(false),
|
||||
..multiview_builder()
|
||||
};
|
||||
assert_eq!(estimate_usd_cents(builder), 13);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detailed_texture() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
texture_quality: Some(CommonMeshQuality::Detailed),
|
||||
..multiview_builder()
|
||||
};
|
||||
assert_eq!(estimate_usd_cents(builder), 39);
|
||||
}
|
||||
}
|
||||
|
||||
mod add_ons {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn detailed_geometry_adds_twenty_six_cents() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
geometry_quality: Some(CommonMeshQuality::Detailed),
|
||||
..image_builder()
|
||||
};
|
||||
assert_eq!(estimate_usd_cents(builder), 39 + 26);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quad_adds_seven_cents() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
polygon_type: Some(CommonPolygonType::Quad),
|
||||
..image_builder()
|
||||
};
|
||||
assert_eq!(estimate_usd_cents(builder), 39 + 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn triangle_polygon_adds_nothing() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
polygon_type: Some(CommonPolygonType::Triangle),
|
||||
..image_builder()
|
||||
};
|
||||
assert_eq!(estimate_usd_cents(builder), 39);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standard_qualities_add_nothing() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
texture_quality: Some(CommonMeshQuality::Standard),
|
||||
geometry_quality: Some(CommonMeshQuality::Standard),
|
||||
..image_builder()
|
||||
};
|
||||
assert_eq!(estimate_usd_cents(builder), 39);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_add_ons_stack() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
texture_quality: Some(CommonMeshQuality::Detailed),
|
||||
geometry_quality: Some(CommonMeshQuality::Detailed),
|
||||
polygon_type: Some(CommonPolygonType::Quad),
|
||||
..image_builder()
|
||||
};
|
||||
assert_eq!(estimate_usd_cents(builder), 52 + 26 + 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_ons_stack_on_the_untextured_tier() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
enable_texture: Some(false),
|
||||
geometry_quality: Some(CommonMeshQuality::Detailed),
|
||||
polygon_type: Some(CommonPolygonType::Quad),
|
||||
..text_builder()
|
||||
};
|
||||
assert_eq!(estimate_usd_cents(builder), 13 + 26 + 7);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
fn base_builder() -> GenerateMeshRequestBuilder {
|
||||
GenerateMeshRequestBuilder {
|
||||
model: RouterMeshModel::Tripo3dH3p1,
|
||||
provider: RouterProvider::Artcraft,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn text_builder() -> GenerateMeshRequestBuilder {
|
||||
GenerateMeshRequestBuilder {
|
||||
prompt: Some("a red ceramic teapot".to_string()),
|
||||
..base_builder()
|
||||
}
|
||||
}
|
||||
|
||||
fn image_builder() -> GenerateMeshRequestBuilder {
|
||||
GenerateMeshRequestBuilder {
|
||||
reference_images: Some(ImageListRef::MediaFileTokens(vec![
|
||||
MediaFileToken::new("mf_front".to_string()),
|
||||
])),
|
||||
..base_builder()
|
||||
}
|
||||
}
|
||||
|
||||
fn multiview_builder() -> GenerateMeshRequestBuilder {
|
||||
GenerateMeshRequestBuilder {
|
||||
back_image: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_back".to_string()))),
|
||||
..image_builder()
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod build;
|
||||
pub mod cost;
|
||||
pub mod request;
|
||||
+64
@@ -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 ArtcraftTripo3dH3p1RequestState {
|
||||
pub request: OmniGenMeshCostAndGenerateRequest,
|
||||
}
|
||||
|
||||
impl ArtcraftTripo3dH3p1RequestState {
|
||||
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateMeshResponse, ArtcraftRouterError> {
|
||||
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::Tripo3dH3p1,
|
||||
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
|
||||
}
|
||||
}
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
use fal_client::requests::api::mesh::part::hunyuan_3d_3p1_part::api::Hunyuan3d3p1PartRequest;
|
||||
|
||||
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_3p1_part::request::FalHunyuan3d3p1PartRequestState;
|
||||
use crate::generate::generate_mesh::providers::fal::resolve::plan_input_mesh_url;
|
||||
use crate::generate::generate_mesh::providers::reject_unsupported::{
|
||||
reject_unsupported_image_ref, reject_unsupported_option,
|
||||
};
|
||||
|
||||
/// Hunyuan 3D v3.1 Part splits an existing mesh into semantic parts. It takes
|
||||
/// only a mesh file (FBX) as input — text, images, and generation options are
|
||||
/// all unsupported.
|
||||
pub fn build_fal_hunyuan_3d_3p1_part(builder: GenerateMeshRequestBuilder) -> Result<MeshGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
let state = build_fal_hunyuan_3d_3p1_part_state(builder)?;
|
||||
Ok(MeshGenerationDraftOrRequest::Request(MeshGenerationRequest::FalHunyuan3d3p1Part(state)))
|
||||
}
|
||||
|
||||
pub(crate) fn build_fal_hunyuan_3d_3p1_part_state(
|
||||
mut builder: GenerateMeshRequestBuilder,
|
||||
) -> Result<FalHunyuan3d3p1PartRequestState, ArtcraftRouterError> {
|
||||
let strategy = builder.request_mismatch_mitigation_strategy;
|
||||
|
||||
let input_file_url = plan_input_mesh_url(builder.input_mesh.take())?.ok_or_else(|| {
|
||||
ArtcraftRouterError::InvalidInput(
|
||||
"Hunyuan 3D v3.1 Part requires an input mesh file (FBX)".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Part splitting takes only the mesh file; every other option is
|
||||
// unsupported.
|
||||
reject_unsupported_option("prompt", builder.prompt.as_ref(), strategy)?;
|
||||
reject_unsupported_option("reference_images", builder.reference_images.as_ref(), strategy)?;
|
||||
reject_unsupported_image_ref("front_image", builder.front_image.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)?;
|
||||
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_option("face_count", builder.face_count.as_ref(), strategy)?;
|
||||
reject_unsupported_option("enable_pbr", builder.enable_pbr.as_ref(), strategy)?;
|
||||
reject_unsupported_option("enable_texture", builder.enable_texture.as_ref(), strategy)?;
|
||||
reject_unsupported_option("texture_quality", builder.texture_quality.as_ref(), strategy)?;
|
||||
reject_unsupported_option("geometry_quality", builder.geometry_quality.as_ref(), strategy)?;
|
||||
|
||||
let request = Hunyuan3d3p1PartRequest { input_file_url };
|
||||
Ok(FalHunyuan3d3p1PartRequestState { request })
|
||||
}
|
||||
|
||||
#[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_ref::ImageRef;
|
||||
use crate::api::mesh_ref::MeshRef;
|
||||
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 MESH_URL: &str = "https://example.com/model.fbx";
|
||||
|
||||
mod shape_dispatch {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn input_mesh_url_builds_the_request() {
|
||||
let state = build_fal_hunyuan_3d_3p1_part_state(mesh_builder()).expect("build");
|
||||
assert_eq!(state.request.input_file_url, MESH_URL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_input_mesh_is_rejected() {
|
||||
let result = build_fal_hunyuan_3d_3p1_part_state(base_builder());
|
||||
assert!(matches!(result, Err(ArtcraftRouterError::InvalidInput(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_token_input_mesh_is_rejected() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
input_mesh: Some(MeshRef::MediaFileToken(MediaFileToken::new("mf_mesh".to_string()))),
|
||||
..base_builder()
|
||||
};
|
||||
assert!(matches!(
|
||||
build_fal_hunyuan_3d_3p1_part_state(builder),
|
||||
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
mod unsupported_options {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn each_unsupported_option_errors_out_under_error_out() {
|
||||
let cases: Vec<fn(&mut GenerateMeshRequestBuilder)> = vec![
|
||||
|b| b.prompt = Some("a red ceramic teapot".to_string()),
|
||||
|b| b.front_image = Some(ImageRef::Url("https://example.com/front.png".to_string())),
|
||||
|b| b.back_image = Some(ImageRef::Url("https://example.com/back.png".to_string())),
|
||||
|b| b.mesh_output_type = Some(CommonMeshOutputType::Normal),
|
||||
|b| b.polygon_type = Some(CommonPolygonType::Quad),
|
||||
|b| b.face_count = Some(100_000),
|
||||
|b| b.enable_pbr = Some(true),
|
||||
|b| b.enable_texture = Some(false),
|
||||
];
|
||||
for (index, set) in cases.into_iter().enumerate() {
|
||||
let mut builder = GenerateMeshRequestBuilder {
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
|
||||
..mesh_builder()
|
||||
};
|
||||
set(&mut builder);
|
||||
assert!(build_fal_hunyuan_3d_3p1_part_state(builder).is_err(), "for case {index}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_options_are_dropped_under_lenient_strategies() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
prompt: Some("a red ceramic teapot".to_string()),
|
||||
mesh_output_type: Some(CommonMeshOutputType::Normal),
|
||||
face_count: Some(100_000),
|
||||
enable_pbr: Some(true),
|
||||
..mesh_builder()
|
||||
};
|
||||
let state = build_fal_hunyuan_3d_3p1_part_state(builder).expect("build");
|
||||
assert_eq!(state.request.input_file_url, MESH_URL);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
fn base_builder() -> GenerateMeshRequestBuilder {
|
||||
GenerateMeshRequestBuilder {
|
||||
model: RouterMeshModel::Hunyuan3d3p1Part,
|
||||
provider: RouterProvider::Fal,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn mesh_builder() -> GenerateMeshRequestBuilder {
|
||||
GenerateMeshRequestBuilder {
|
||||
input_mesh: Some(MeshRef::Url(MESH_URL.to_string())),
|
||||
..base_builder()
|
||||
}
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
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_3p1_part::request::FalHunyuan3d3p1PartRequestState;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FalHunyuan3d3p1PartCostState {
|
||||
pub cost_in_usd_cents: u64,
|
||||
}
|
||||
|
||||
impl FalHunyuan3d3p1PartCostState {
|
||||
pub fn from_request(request: &FalHunyuan3d3p1PartRequestState) -> 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::mesh_ref::MeshRef;
|
||||
use crate::api::router_mesh_model::RouterMeshModel;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder;
|
||||
|
||||
const MESH_URL: &str = "https://example.com/model.fbx";
|
||||
|
||||
#[test]
|
||||
fn cost_is_a_flat_forty_five_cents() {
|
||||
assert_eq!(estimate_usd_cents(mesh_builder()), 45);
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
fn mesh_builder() -> GenerateMeshRequestBuilder {
|
||||
GenerateMeshRequestBuilder {
|
||||
model: RouterMeshModel::Hunyuan3d3p1Part,
|
||||
provider: RouterProvider::Fal,
|
||||
input_mesh: Some(MeshRef::Url(MESH_URL.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")
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod build;
|
||||
pub mod cost;
|
||||
pub mod request;
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
use fal_client::requests::api::mesh::part::hunyuan_3d_3p1_part::api::Hunyuan3d3p1PartRequest;
|
||||
|
||||
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 FalHunyuan3d3p1PartRequestState {
|
||||
/// Final materialized request; ready to fire.
|
||||
pub request: Hunyuan3d3p1PartRequest,
|
||||
}
|
||||
|
||||
impl FalHunyuan3d3p1PartRequestState {
|
||||
pub async fn send(&self, client: &RouterFalClient) -> Result<GenerateMeshResponse, ArtcraftRouterError> {
|
||||
send_fal_mesh_request(&self.request, client).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::api::mesh_ref::MeshRef;
|
||||
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::mesh_generation_draft_or_request::MeshGenerationDraftOrRequest;
|
||||
use crate::test_helpers::get_fal_client;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // requires real API key and a real FBX URL, incurs cost
|
||||
async fn send_part_split() {
|
||||
let client = get_fal_client();
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
model: RouterMeshModel::Hunyuan3d3p1Part,
|
||||
provider: RouterProvider::Fal,
|
||||
input_mesh: Some(MeshRef::Url("https://example.com/model.fbx".to_string())),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
+411
@@ -0,0 +1,411 @@
|
||||
use enums::common::generation::common_mesh_output_type::CommonMeshOutputType;
|
||||
use fal_client::requests::api::mesh::image::hunyuan_3d_3p1_pro_image_to_mesh::api::{
|
||||
Hunyuan3d3p1ProImageToMeshGenerateType, Hunyuan3d3p1ProImageToMeshRequest,
|
||||
};
|
||||
use fal_client::requests::api::mesh::text::hunyuan_3d_3p1_pro_text_to_mesh::api::{
|
||||
Hunyuan3d3p1ProTextToMeshGenerateType, Hunyuan3d3p1ProTextToMeshRequest,
|
||||
};
|
||||
|
||||
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
|
||||
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_3p1_pro::request::{
|
||||
FalHunyuan3d3p1ProImageRequestState, FalHunyuan3d3p1ProTextRequestState,
|
||||
};
|
||||
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.1 Pro 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_hunyuan_3d_3p1_pro(builder: GenerateMeshRequestBuilder) -> Result<MeshGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
let state = build_fal_hunyuan_3d_3p1_pro_state(builder)?;
|
||||
let request = match state {
|
||||
FalHunyuan3d3p1ProState::Image(state) => MeshGenerationRequest::FalHunyuan3d3p1ProImage(state),
|
||||
FalHunyuan3d3p1ProState::Text(state) => MeshGenerationRequest::FalHunyuan3d3p1ProText(state),
|
||||
};
|
||||
Ok(MeshGenerationDraftOrRequest::Request(request))
|
||||
}
|
||||
|
||||
/// The endpoint selected by the request shape.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) enum FalHunyuan3d3p1ProState {
|
||||
Image(FalHunyuan3d3p1ProImageRequestState),
|
||||
Text(FalHunyuan3d3p1ProTextRequestState),
|
||||
}
|
||||
|
||||
pub(crate) fn build_fal_hunyuan_3d_3p1_pro_state(
|
||||
mut builder: GenerateMeshRequestBuilder,
|
||||
) -> Result<FalHunyuan3d3p1ProState, ArtcraftRouterError> {
|
||||
let strategy = builder.request_mismatch_mitigation_strategy;
|
||||
|
||||
// v3.1 Pro has no polygon-type, texture, or input-mesh parameters.
|
||||
reject_unsupported_option("polygon_type", builder.polygon_type.as_ref(), strategy)?;
|
||||
reject_unsupported_option("enable_texture", builder.enable_texture.as_ref(), strategy)?;
|
||||
reject_unsupported_option("texture_quality", builder.texture_quality.as_ref(), strategy)?;
|
||||
reject_unsupported_option("geometry_quality", builder.geometry_quality.as_ref(), strategy)?;
|
||||
reject_unsupported_option("input_mesh", builder.input_mesh.as_ref(), 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 = Hunyuan3d3p1ProImageToMeshRequest {
|
||||
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())?,
|
||||
// The builder has no fields for the v3.1-exclusive extra views.
|
||||
top_image_url: None,
|
||||
bottom_image_url: None,
|
||||
left_front_image_url: None,
|
||||
right_front_image_url: None,
|
||||
generate_type: plan_image_generate_type(builder.mesh_output_type, strategy)?,
|
||||
face_count,
|
||||
enable_pbr: builder.enable_pbr,
|
||||
};
|
||||
Ok(FalHunyuan3d3p1ProState::Image(FalHunyuan3d3p1ProImageRequestState { 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.1 Pro requires an input image or a prompt".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let request = Hunyuan3d3p1ProTextToMeshRequest {
|
||||
prompt,
|
||||
generate_type: plan_text_generate_type(builder.mesh_output_type, strategy)?,
|
||||
face_count,
|
||||
enable_pbr: builder.enable_pbr,
|
||||
};
|
||||
Ok(FalHunyuan3d3p1ProState::Text(FalHunyuan3d3p1ProTextRequestState { request }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Enum mapping helpers ──
|
||||
|
||||
/// v3.1 drops the v3 `LowPoly` generation type: `LowPoly` rejects under
|
||||
/// `ErrorOut` and drops to the endpoint default otherwise.
|
||||
fn plan_image_generate_type(
|
||||
mesh_output_type: Option<CommonMeshOutputType>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Option<Hunyuan3d3p1ProImageToMeshGenerateType>, ArtcraftRouterError> {
|
||||
match mesh_output_type {
|
||||
None => Ok(None),
|
||||
Some(CommonMeshOutputType::Normal) => Ok(Some(Hunyuan3d3p1ProImageToMeshGenerateType::Normal)),
|
||||
Some(CommonMeshOutputType::Geometry) => Ok(Some(Hunyuan3d3p1ProImageToMeshGenerateType::Geometry)),
|
||||
Some(CommonMeshOutputType::LowPoly) => {
|
||||
reject_unsupported_option("mesh_output_type", Some(&CommonMeshOutputType::LowPoly), strategy)?;
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn plan_text_generate_type(
|
||||
mesh_output_type: Option<CommonMeshOutputType>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Option<Hunyuan3d3p1ProTextToMeshGenerateType>, ArtcraftRouterError> {
|
||||
match mesh_output_type {
|
||||
None => Ok(None),
|
||||
Some(CommonMeshOutputType::Normal) => Ok(Some(Hunyuan3d3p1ProTextToMeshGenerateType::Normal)),
|
||||
Some(CommonMeshOutputType::Geometry) => Ok(Some(Hunyuan3d3p1ProTextToMeshGenerateType::Geometry)),
|
||||
Some(CommonMeshOutputType::LowPoly) => {
|
||||
reject_unsupported_option("mesh_output_type", Some(&CommonMeshOutputType::LowPoly), strategy)?;
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use enums::common::generation::common_mesh_quality::CommonMeshQuality;
|
||||
use enums::common::generation::common_polygon_type::CommonPolygonType;
|
||||
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::mesh_ref::MeshRef;
|
||||
use crate::api::router_mesh_model::RouterMeshModel;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
|
||||
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_hunyuan_3d_3p1_pro_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_hunyuan_3d_3p1_pro_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_hunyuan_3d_3p1_pro_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_hunyuan_3d_3p1_pro_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_hunyuan_3d_3p1_pro_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 v3p1_exclusive_extra_views_are_left_unset() {
|
||||
let image = expect_image(build_fal_hunyuan_3d_3p1_pro_state(image_builder()).expect("build"));
|
||||
assert!(image.request.top_image_url.is_none());
|
||||
assert!(image.request.bottom_image_url.is_none());
|
||||
assert!(image.request.left_front_image_url.is_none());
|
||||
assert!(image.request.right_front_image_url.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_map_through() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
mesh_output_type: Some(CommonMeshOutputType::Geometry),
|
||||
face_count: Some(100_000),
|
||||
enable_pbr: Some(true),
|
||||
..image_builder()
|
||||
};
|
||||
let image = expect_image(build_fal_hunyuan_3d_3p1_pro_state(builder).expect("build"));
|
||||
assert!(matches!(image.request.generate_type, Some(Hunyuan3d3p1ProImageToMeshGenerateType::Geometry)));
|
||||
assert_eq!(image.request.face_count, Some(100_000));
|
||||
assert_eq!(image.request.enable_pbr, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn low_poly_errors_out_under_error_out() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
mesh_output_type: Some(CommonMeshOutputType::LowPoly),
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
|
||||
..image_builder()
|
||||
};
|
||||
assert!(build_fal_hunyuan_3d_3p1_pro_state(builder).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn low_poly_drops_to_default_under_lenient_strategies() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
mesh_output_type: Some(CommonMeshOutputType::LowPoly),
|
||||
..image_builder()
|
||||
};
|
||||
let image = expect_image(build_fal_hunyuan_3d_3p1_pro_state(builder).expect("build"));
|
||||
assert!(image.request.generate_type.is_none());
|
||||
}
|
||||
|
||||
#[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_hunyuan_3d_3p1_pro_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_hunyuan_3d_3p1_pro_state(builder).expect("build"),
|
||||
FalHunyuan3d3p1ProState::Image(_)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
mod text_mode {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn options_map_through() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
mesh_output_type: Some(CommonMeshOutputType::Normal),
|
||||
face_count: Some(40_000),
|
||||
enable_pbr: Some(false),
|
||||
..text_builder()
|
||||
};
|
||||
let text = expect_text(build_fal_hunyuan_3d_3p1_pro_state(builder).expect("build"));
|
||||
assert!(matches!(text.request.generate_type, Some(Hunyuan3d3p1ProTextToMeshGenerateType::Normal)));
|
||||
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_hunyuan_3d_3p1_pro_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_hunyuan_3d_3p1_pro_state(builder).expect("build"),
|
||||
FalHunyuan3d3p1ProState::Text(_)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
mod unsupported_options {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn polygon_type_errors_out_under_error_out() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
polygon_type: Some(CommonPolygonType::Quad),
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
|
||||
..image_builder()
|
||||
};
|
||||
assert!(build_fal_hunyuan_3d_3p1_pro_state(builder).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn texture_options_error_out_under_error_out() {
|
||||
let cases: Vec<fn(&mut GenerateMeshRequestBuilder)> = vec![
|
||||
|b| b.enable_texture = Some(false),
|
||||
|b| b.texture_quality = Some(CommonMeshQuality::Detailed),
|
||||
|b| b.geometry_quality = Some(CommonMeshQuality::Detailed),
|
||||
];
|
||||
for (index, set) in cases.into_iter().enumerate() {
|
||||
let mut builder = GenerateMeshRequestBuilder {
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
|
||||
..image_builder()
|
||||
};
|
||||
set(&mut builder);
|
||||
assert!(build_fal_hunyuan_3d_3p1_pro_state(builder).is_err(), "for case {index}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_mesh_errors_out_under_error_out() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
input_mesh: Some(MeshRef::Url("https://example.com/model.glb".to_string())),
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
|
||||
..text_builder()
|
||||
};
|
||||
assert!(build_fal_hunyuan_3d_3p1_pro_state(builder).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_options_are_dropped_under_lenient_strategies() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
polygon_type: Some(CommonPolygonType::Quad),
|
||||
enable_texture: Some(false),
|
||||
texture_quality: Some(CommonMeshQuality::Detailed),
|
||||
geometry_quality: Some(CommonMeshQuality::Detailed),
|
||||
input_mesh: Some(MeshRef::Url("https://example.com/model.glb".to_string())),
|
||||
..image_builder()
|
||||
};
|
||||
assert!(matches!(
|
||||
build_fal_hunyuan_3d_3p1_pro_state(builder).expect("build"),
|
||||
FalHunyuan3d3p1ProState::Image(_)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
fn base_builder() -> GenerateMeshRequestBuilder {
|
||||
GenerateMeshRequestBuilder {
|
||||
model: RouterMeshModel::Hunyuan3d3p1Pro,
|
||||
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: FalHunyuan3d3p1ProState) -> FalHunyuan3d3p1ProImageRequestState {
|
||||
match state {
|
||||
FalHunyuan3d3p1ProState::Image(image) => image,
|
||||
FalHunyuan3d3p1ProState::Text(text) => panic!("expected image mode, got text: {text:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn expect_text(state: FalHunyuan3d3p1ProState) -> FalHunyuan3d3p1ProTextRequestState {
|
||||
match state {
|
||||
FalHunyuan3d3p1ProState::Text(text) => text,
|
||||
FalHunyuan3d3p1ProState::Image(image) => panic!("expected text mode, got image: {image:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
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_3p1_pro::request::{
|
||||
FalHunyuan3d3p1ProImageRequestState, FalHunyuan3d3p1ProTextRequestState,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FalHunyuan3d3p1ProImageCostState {
|
||||
pub cost_in_usd_cents: u64,
|
||||
}
|
||||
|
||||
impl FalHunyuan3d3p1ProImageCostState {
|
||||
pub fn from_request(request: &FalHunyuan3d3p1ProImageRequestState) -> 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 FalHunyuan3d3p1ProTextCostState {
|
||||
pub cost_in_usd_cents: u64,
|
||||
}
|
||||
|
||||
impl FalHunyuan3d3p1ProTextCostState {
|
||||
pub fn from_request(request: &FalHunyuan3d3p1ProTextRequestState) -> 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)
|
||||
}
|
||||
}
|
||||
|
||||
#[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::Geometry), 38),
|
||||
];
|
||||
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 multi_view_adds_fifteen_cents() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
back_image: Some(ImageRef::Url("https://example.com/back.png".to_string())),
|
||||
..image_builder()
|
||||
};
|
||||
assert_eq!(estimate_usd_cents(builder), 38 + 15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_ons_stack() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
mesh_output_type: Some(CommonMeshOutputType::Geometry),
|
||||
enable_pbr: Some(true),
|
||||
face_count: Some(100_000),
|
||||
back_image: Some(ImageRef::Url("https://example.com/back.png".to_string())),
|
||||
..image_builder()
|
||||
};
|
||||
// base(38) + PBR(15) + face_count(15) + multi_view(15)
|
||||
assert_eq!(estimate_usd_cents(builder), 83);
|
||||
}
|
||||
}
|
||||
|
||||
mod text_mode_costs {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn base_cost_table() {
|
||||
let cases = [
|
||||
(None, 38),
|
||||
(Some(CommonMeshOutputType::Normal), 38),
|
||||
(Some(CommonMeshOutputType::Geometry), 38),
|
||||
];
|
||||
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 {
|
||||
enable_pbr: Some(true),
|
||||
face_count: Some(40_000),
|
||||
..text_builder()
|
||||
};
|
||||
// base(38) + PBR(15) + face_count(15)
|
||||
assert_eq!(estimate_usd_cents(builder), 68);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
fn image_builder() -> GenerateMeshRequestBuilder {
|
||||
GenerateMeshRequestBuilder {
|
||||
model: RouterMeshModel::Hunyuan3d3p1Pro,
|
||||
provider: RouterProvider::Fal,
|
||||
reference_images: Some(ImageListRef::Urls(vec![FRONT_URL.to_string()])),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn text_builder() -> GenerateMeshRequestBuilder {
|
||||
GenerateMeshRequestBuilder {
|
||||
model: RouterMeshModel::Hunyuan3d3p1Pro,
|
||||
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")
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod build;
|
||||
pub mod cost;
|
||||
pub mod request;
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
use fal_client::requests::api::mesh::image::hunyuan_3d_3p1_pro_image_to_mesh::api::Hunyuan3d3p1ProImageToMeshRequest;
|
||||
use fal_client::requests::api::mesh::text::hunyuan_3d_3p1_pro_text_to_mesh::api::Hunyuan3d3p1ProTextToMeshRequest;
|
||||
|
||||
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 FalHunyuan3d3p1ProImageRequestState {
|
||||
/// Final materialized request; ready to fire.
|
||||
pub request: Hunyuan3d3p1ProImageToMeshRequest,
|
||||
}
|
||||
|
||||
impl FalHunyuan3d3p1ProImageRequestState {
|
||||
pub async fn send(&self, client: &RouterFalClient) -> Result<GenerateMeshResponse, ArtcraftRouterError> {
|
||||
send_fal_mesh_request(&self.request, client).await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FalHunyuan3d3p1ProTextRequestState {
|
||||
/// Final materialized request; ready to fire.
|
||||
pub request: Hunyuan3d3p1ProTextToMeshRequest,
|
||||
}
|
||||
|
||||
impl FalHunyuan3d3p1ProTextRequestState {
|
||||
pub async fn send(&self, client: &RouterFalClient) -> Result<GenerateMeshResponse, ArtcraftRouterError> {
|
||||
send_fal_mesh_request(&self.request, client).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
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::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_fal_client;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // requires real API key, incurs cost
|
||||
async fn send_pro_image_to_mesh() {
|
||||
let response = run_pipeline(GenerateMeshRequestBuilder {
|
||||
reference_images: Some(ImageListRef::Urls(vec![ERNEST_SCARED_STUPID_IMAGE_URL.to_string()])),
|
||||
..pro_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_pro_text_to_mesh() {
|
||||
let response = run_pipeline(GenerateMeshRequestBuilder {
|
||||
prompt: Some("A velociraptor with an open mouth full of sharp teeth.".to_string()),
|
||||
..pro_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 pro_builder() -> GenerateMeshRequestBuilder {
|
||||
GenerateMeshRequestBuilder {
|
||||
model: RouterMeshModel::Hunyuan3d3p1Pro,
|
||||
provider: RouterProvider::Fal,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
+323
@@ -0,0 +1,323 @@
|
||||
use enums::common::generation::common_mesh_output_type::CommonMeshOutputType;
|
||||
use fal_client::requests::api::mesh::image::hunyuan_3d_3p1_rapid_image_to_mesh::api::Hunyuan3d3p1RapidImageToMeshRequest;
|
||||
use fal_client::requests::api::mesh::text::hunyuan_3d_3p1_rapid_text_to_mesh::api::Hunyuan3d3p1RapidTextToMeshRequest;
|
||||
|
||||
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
|
||||
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_3p1_rapid::request::{
|
||||
FalHunyuan3d3p1RapidImageRequestState, FalHunyuan3d3p1RapidTextRequestState,
|
||||
};
|
||||
use crate::generate::generate_mesh::providers::fal::resolve::plan_primary_image_url;
|
||||
use crate::generate::generate_mesh::providers::reject_unsupported::{
|
||||
reject_unsupported_image_ref, reject_unsupported_option,
|
||||
};
|
||||
|
||||
/// Hunyuan 3D v3.1 Rapid combines fal's rapid 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_hunyuan_3d_3p1_rapid(builder: GenerateMeshRequestBuilder) -> Result<MeshGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
let state = build_fal_hunyuan_3d_3p1_rapid_state(builder)?;
|
||||
let request = match state {
|
||||
FalHunyuan3d3p1RapidState::Image(state) => MeshGenerationRequest::FalHunyuan3d3p1RapidImage(state),
|
||||
FalHunyuan3d3p1RapidState::Text(state) => MeshGenerationRequest::FalHunyuan3d3p1RapidText(state),
|
||||
};
|
||||
Ok(MeshGenerationDraftOrRequest::Request(request))
|
||||
}
|
||||
|
||||
/// The endpoint selected by the request shape.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) enum FalHunyuan3d3p1RapidState {
|
||||
Image(FalHunyuan3d3p1RapidImageRequestState),
|
||||
Text(FalHunyuan3d3p1RapidTextRequestState),
|
||||
}
|
||||
|
||||
pub(crate) fn build_fal_hunyuan_3d_3p1_rapid_state(
|
||||
mut builder: GenerateMeshRequestBuilder,
|
||||
) -> Result<FalHunyuan3d3p1RapidState, ArtcraftRouterError> {
|
||||
let strategy = builder.request_mismatch_mitigation_strategy;
|
||||
|
||||
// The rapid schema is minimal: no polygon-type, face-count, texture, or
|
||||
// input-mesh parameters.
|
||||
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_texture", builder.enable_texture.as_ref(), strategy)?;
|
||||
reject_unsupported_option("texture_quality", builder.texture_quality.as_ref(), strategy)?;
|
||||
reject_unsupported_option("geometry_quality", builder.geometry_quality.as_ref(), strategy)?;
|
||||
reject_unsupported_option("input_mesh", builder.input_mesh.as_ref(), strategy)?;
|
||||
|
||||
// Rapid is single-image only — no multi-view side images in either mode.
|
||||
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 maybe_front_url = plan_primary_image_url(
|
||||
builder.reference_images.take(),
|
||||
builder.front_image.take(),
|
||||
strategy,
|
||||
)?;
|
||||
let enable_geometry = plan_enable_geometry(builder.mesh_output_type, 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 = Hunyuan3d3p1RapidImageToMeshRequest {
|
||||
image_url,
|
||||
enable_pbr: builder.enable_pbr,
|
||||
enable_geometry,
|
||||
};
|
||||
Ok(FalHunyuan3d3p1RapidState::Image(FalHunyuan3d3p1RapidImageRequestState { request }))
|
||||
}
|
||||
None => {
|
||||
let prompt = builder.prompt.take().ok_or_else(|| {
|
||||
ArtcraftRouterError::InvalidInput(
|
||||
"Hunyuan 3D v3.1 Rapid requires an input image or a prompt".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let request = Hunyuan3d3p1RapidTextToMeshRequest {
|
||||
prompt,
|
||||
enable_pbr: builder.enable_pbr,
|
||||
enable_geometry,
|
||||
};
|
||||
Ok(FalHunyuan3d3p1RapidState::Text(FalHunyuan3d3p1RapidTextRequestState { request }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Enum mapping helpers ──
|
||||
|
||||
/// Rapid has no generation-type enum — geometry-only output is a boolean.
|
||||
/// `Geometry` maps to `enable_geometry: true`; `Normal` (or unset) leaves it
|
||||
/// unset (fal defaults to false); `LowPoly` rejects under `ErrorOut` and
|
||||
/// drops otherwise.
|
||||
fn plan_enable_geometry(
|
||||
mesh_output_type: Option<CommonMeshOutputType>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Option<bool>, ArtcraftRouterError> {
|
||||
match mesh_output_type {
|
||||
None | Some(CommonMeshOutputType::Normal) => Ok(None),
|
||||
Some(CommonMeshOutputType::Geometry) => Ok(Some(true)),
|
||||
Some(CommonMeshOutputType::LowPoly) => {
|
||||
reject_unsupported_option("mesh_output_type", Some(&CommonMeshOutputType::LowPoly), strategy)?;
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use enums::common::generation::common_polygon_type::CommonPolygonType;
|
||||
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::mesh_ref::MeshRef;
|
||||
use crate::api::router_mesh_model::RouterMeshModel;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
|
||||
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_hunyuan_3d_3p1_rapid_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_hunyuan_3d_3p1_rapid_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_hunyuan_3d_3p1_rapid_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_hunyuan_3d_3p1_rapid_state(base_builder());
|
||||
assert!(matches!(result, Err(ArtcraftRouterError::InvalidInput(_))));
|
||||
}
|
||||
}
|
||||
|
||||
mod option_mapping {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn geometry_output_maps_to_enable_geometry() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
mesh_output_type: Some(CommonMeshOutputType::Geometry),
|
||||
..image_builder()
|
||||
};
|
||||
let image = expect_image(build_fal_hunyuan_3d_3p1_rapid_state(builder).expect("build"));
|
||||
assert_eq!(image.request.enable_geometry, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normal_output_leaves_enable_geometry_unset() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
mesh_output_type: Some(CommonMeshOutputType::Normal),
|
||||
..image_builder()
|
||||
};
|
||||
let image = expect_image(build_fal_hunyuan_3d_3p1_rapid_state(builder).expect("build"));
|
||||
assert!(image.request.enable_geometry.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enable_pbr_passes_through_in_both_modes() {
|
||||
let image_builder = GenerateMeshRequestBuilder {
|
||||
enable_pbr: Some(true),
|
||||
..image_builder()
|
||||
};
|
||||
let image = expect_image(build_fal_hunyuan_3d_3p1_rapid_state(image_builder).expect("build"));
|
||||
assert_eq!(image.request.enable_pbr, Some(true));
|
||||
|
||||
let text_builder = GenerateMeshRequestBuilder {
|
||||
enable_pbr: Some(true),
|
||||
..text_builder()
|
||||
};
|
||||
let text = expect_text(build_fal_hunyuan_3d_3p1_rapid_state(text_builder).expect("build"));
|
||||
assert_eq!(text.request.enable_pbr, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn low_poly_errors_out_under_error_out() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
mesh_output_type: Some(CommonMeshOutputType::LowPoly),
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
|
||||
..image_builder()
|
||||
};
|
||||
assert!(build_fal_hunyuan_3d_3p1_rapid_state(builder).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn low_poly_drops_to_default_under_lenient_strategies() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
mesh_output_type: Some(CommonMeshOutputType::LowPoly),
|
||||
..image_builder()
|
||||
};
|
||||
let image = expect_image(build_fal_hunyuan_3d_3p1_rapid_state(builder).expect("build"));
|
||||
assert!(image.request.enable_geometry.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
mod unsupported_options {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn each_unsupported_option_errors_out_under_error_out() {
|
||||
let cases: Vec<fn(&mut GenerateMeshRequestBuilder)> = vec![
|
||||
|b| b.polygon_type = Some(CommonPolygonType::Quad),
|
||||
|b| b.face_count = Some(100_000),
|
||||
|b| b.enable_texture = Some(false),
|
||||
|b| b.input_mesh = Some(MeshRef::Url("https://example.com/model.glb".to_string())),
|
||||
|b| b.back_image = Some(ImageRef::Url(BACK_URL.to_string())),
|
||||
|b| b.left_image = Some(ImageRef::Url(BACK_URL.to_string())),
|
||||
|b| b.right_image = Some(ImageRef::Url(BACK_URL.to_string())),
|
||||
];
|
||||
for (index, set) in cases.into_iter().enumerate() {
|
||||
let mut builder = GenerateMeshRequestBuilder {
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
|
||||
..image_builder()
|
||||
};
|
||||
set(&mut builder);
|
||||
assert!(build_fal_hunyuan_3d_3p1_rapid_state(builder).is_err(), "for case {index}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_options_are_dropped_under_lenient_strategies() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
polygon_type: Some(CommonPolygonType::Quad),
|
||||
face_count: Some(100_000),
|
||||
back_image: Some(ImageRef::Url(BACK_URL.to_string())),
|
||||
..image_builder()
|
||||
};
|
||||
assert!(matches!(
|
||||
build_fal_hunyuan_3d_3p1_rapid_state(builder).expect("build"),
|
||||
FalHunyuan3d3p1RapidState::Image(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[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_hunyuan_3d_3p1_rapid_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_hunyuan_3d_3p1_rapid_state(builder).expect("build"),
|
||||
FalHunyuan3d3p1RapidState::Image(_)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
fn base_builder() -> GenerateMeshRequestBuilder {
|
||||
GenerateMeshRequestBuilder {
|
||||
model: RouterMeshModel::Hunyuan3d3p1Rapid,
|
||||
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: FalHunyuan3d3p1RapidState) -> FalHunyuan3d3p1RapidImageRequestState {
|
||||
match state {
|
||||
FalHunyuan3d3p1RapidState::Image(image) => image,
|
||||
FalHunyuan3d3p1RapidState::Text(text) => panic!("expected image mode, got text: {text:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn expect_text(state: FalHunyuan3d3p1RapidState) -> FalHunyuan3d3p1RapidTextRequestState {
|
||||
match state {
|
||||
FalHunyuan3d3p1RapidState::Text(text) => text,
|
||||
FalHunyuan3d3p1RapidState::Image(image) => panic!("expected text mode, got image: {image:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
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_3p1_rapid::request::{
|
||||
FalHunyuan3d3p1RapidImageRequestState, FalHunyuan3d3p1RapidTextRequestState,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FalHunyuan3d3p1RapidImageCostState {
|
||||
pub cost_in_usd_cents: u64,
|
||||
}
|
||||
|
||||
impl FalHunyuan3d3p1RapidImageCostState {
|
||||
pub fn from_request(request: &FalHunyuan3d3p1RapidImageRequestState) -> 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 FalHunyuan3d3p1RapidTextCostState {
|
||||
pub cost_in_usd_cents: u64,
|
||||
}
|
||||
|
||||
impl FalHunyuan3d3p1RapidTextCostState {
|
||||
pub fn from_request(request: &FalHunyuan3d3p1RapidTextRequestState) -> 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)
|
||||
}
|
||||
}
|
||||
|
||||
#[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;
|
||||
|
||||
const FRONT_URL: &str = "https://example.com/front.png";
|
||||
|
||||
mod image_mode_costs {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn base_cost_is_twenty_three_cents() {
|
||||
assert_eq!(estimate_usd_cents(image_builder()), 23);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pbr_adds_fifteen_cents() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
enable_pbr: Some(true),
|
||||
..image_builder()
|
||||
};
|
||||
assert_eq!(estimate_usd_cents(builder), 23 + 15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn geometry_output_does_not_change_the_price() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
mesh_output_type: Some(CommonMeshOutputType::Geometry),
|
||||
..image_builder()
|
||||
};
|
||||
assert_eq!(estimate_usd_cents(builder), 23);
|
||||
}
|
||||
}
|
||||
|
||||
mod text_mode_costs {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn base_cost_is_twenty_three_cents() {
|
||||
assert_eq!(estimate_usd_cents(text_builder()), 23);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pbr_adds_fifteen_cents() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
enable_pbr: Some(true),
|
||||
..text_builder()
|
||||
};
|
||||
assert_eq!(estimate_usd_cents(builder), 23 + 15);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
fn image_builder() -> GenerateMeshRequestBuilder {
|
||||
GenerateMeshRequestBuilder {
|
||||
model: RouterMeshModel::Hunyuan3d3p1Rapid,
|
||||
provider: RouterProvider::Fal,
|
||||
reference_images: Some(ImageListRef::Urls(vec![FRONT_URL.to_string()])),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn text_builder() -> GenerateMeshRequestBuilder {
|
||||
GenerateMeshRequestBuilder {
|
||||
model: RouterMeshModel::Hunyuan3d3p1Rapid,
|
||||
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")
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod build;
|
||||
pub mod cost;
|
||||
pub mod request;
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
use fal_client::requests::api::mesh::image::hunyuan_3d_3p1_rapid_image_to_mesh::api::Hunyuan3d3p1RapidImageToMeshRequest;
|
||||
use fal_client::requests::api::mesh::text::hunyuan_3d_3p1_rapid_text_to_mesh::api::Hunyuan3d3p1RapidTextToMeshRequest;
|
||||
|
||||
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 FalHunyuan3d3p1RapidImageRequestState {
|
||||
/// Final materialized request; ready to fire.
|
||||
pub request: Hunyuan3d3p1RapidImageToMeshRequest,
|
||||
}
|
||||
|
||||
impl FalHunyuan3d3p1RapidImageRequestState {
|
||||
pub async fn send(&self, client: &RouterFalClient) -> Result<GenerateMeshResponse, ArtcraftRouterError> {
|
||||
send_fal_mesh_request(&self.request, client).await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FalHunyuan3d3p1RapidTextRequestState {
|
||||
/// Final materialized request; ready to fire.
|
||||
pub request: Hunyuan3d3p1RapidTextToMeshRequest,
|
||||
}
|
||||
|
||||
impl FalHunyuan3d3p1RapidTextRequestState {
|
||||
pub async fn send(&self, client: &RouterFalClient) -> Result<GenerateMeshResponse, ArtcraftRouterError> {
|
||||
send_fal_mesh_request(&self.request, client).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
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::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_fal_client;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // requires real API key, incurs cost
|
||||
async fn send_rapid_image_to_mesh() {
|
||||
let response = run_pipeline(GenerateMeshRequestBuilder {
|
||||
reference_images: Some(ImageListRef::Urls(vec![ERNEST_SCARED_STUPID_IMAGE_URL.to_string()])),
|
||||
..rapid_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_rapid_text_to_mesh() {
|
||||
let response = run_pipeline(GenerateMeshRequestBuilder {
|
||||
prompt: Some("A velociraptor with an open mouth full of sharp teeth.".to_string()),
|
||||
..rapid_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 rapid_builder() -> GenerateMeshRequestBuilder {
|
||||
GenerateMeshRequestBuilder {
|
||||
model: RouterMeshModel::Hunyuan3d3p1Rapid,
|
||||
provider: RouterProvider::Fal,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
+247
@@ -0,0 +1,247 @@
|
||||
use enums::common::generation::common_polygon_type::CommonPolygonType;
|
||||
use fal_client::requests::api::mesh::topology::hunyuan_3d_3p1_smart_topology::api::{
|
||||
Hunyuan3d3p1SmartTopologyInputFileType, Hunyuan3d3p1SmartTopologyPolygonType,
|
||||
Hunyuan3d3p1SmartTopologyRequest,
|
||||
};
|
||||
|
||||
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_3p1_smart_topology::request::FalHunyuan3d3p1SmartTopologyRequestState;
|
||||
use crate::generate::generate_mesh::providers::fal::resolve::plan_input_mesh_url;
|
||||
use crate::generate::generate_mesh::providers::reject_unsupported::{
|
||||
reject_unsupported_image_ref, reject_unsupported_option,
|
||||
};
|
||||
|
||||
/// Hunyuan 3D v3.1 Smart Topology retopologizes an existing mesh. It takes a
|
||||
/// mesh file (GLB or OBJ) and an optional polygon type — text, images, and
|
||||
/// the other generation options are all unsupported.
|
||||
pub fn build_fal_hunyuan_3d_3p1_smart_topology(builder: GenerateMeshRequestBuilder) -> Result<MeshGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
let state = build_fal_hunyuan_3d_3p1_smart_topology_state(builder)?;
|
||||
Ok(MeshGenerationDraftOrRequest::Request(MeshGenerationRequest::FalHunyuan3d3p1SmartTopology(state)))
|
||||
}
|
||||
|
||||
pub(crate) fn build_fal_hunyuan_3d_3p1_smart_topology_state(
|
||||
mut builder: GenerateMeshRequestBuilder,
|
||||
) -> Result<FalHunyuan3d3p1SmartTopologyRequestState, ArtcraftRouterError> {
|
||||
let strategy = builder.request_mismatch_mitigation_strategy;
|
||||
|
||||
let input_file_url = plan_input_mesh_url(builder.input_mesh.take())?.ok_or_else(|| {
|
||||
ArtcraftRouterError::InvalidInput(
|
||||
"Hunyuan 3D v3.1 Smart Topology requires an input mesh file (GLB or OBJ)".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Retopology takes only the mesh file and a polygon type; every other
|
||||
// option is unsupported.
|
||||
reject_unsupported_option("prompt", builder.prompt.as_ref(), strategy)?;
|
||||
reject_unsupported_option("reference_images", builder.reference_images.as_ref(), strategy)?;
|
||||
reject_unsupported_image_ref("front_image", builder.front_image.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)?;
|
||||
reject_unsupported_option("mesh_output_type", builder.mesh_output_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_option("enable_texture", builder.enable_texture.as_ref(), strategy)?;
|
||||
reject_unsupported_option("texture_quality", builder.texture_quality.as_ref(), strategy)?;
|
||||
reject_unsupported_option("geometry_quality", builder.geometry_quality.as_ref(), strategy)?;
|
||||
|
||||
let input_file_type = infer_input_file_type(&input_file_url);
|
||||
let request = Hunyuan3d3p1SmartTopologyRequest {
|
||||
input_file_url,
|
||||
input_file_type,
|
||||
polygon_type: builder.polygon_type.map(to_polygon_type),
|
||||
face_level: None,
|
||||
};
|
||||
Ok(FalHunyuan3d3p1SmartTopologyRequestState { request })
|
||||
}
|
||||
|
||||
// ── Mapping helpers ──
|
||||
|
||||
/// Infer the input file format from the URL path: a path ending in `.obj`
|
||||
/// (case-insensitive, ignoring any query string) maps to `Obj`; anything
|
||||
/// else is left unset (fal defaults to GLB).
|
||||
fn infer_input_file_type(url: &str) -> Option<Hunyuan3d3p1SmartTopologyInputFileType> {
|
||||
let path = url.split(['?', '#']).next().unwrap_or(url);
|
||||
if path.to_ascii_lowercase().ends_with(".obj") {
|
||||
Some(Hunyuan3d3p1SmartTopologyInputFileType::Obj)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn to_polygon_type(polygon_type: CommonPolygonType) -> Hunyuan3d3p1SmartTopologyPolygonType {
|
||||
match polygon_type {
|
||||
CommonPolygonType::Triangle => Hunyuan3d3p1SmartTopologyPolygonType::Triangle,
|
||||
CommonPolygonType::Quad => Hunyuan3d3p1SmartTopologyPolygonType::Quadrilateral,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use enums::common::generation::common_mesh_output_type::CommonMeshOutputType;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::mesh_ref::MeshRef;
|
||||
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 GLB_URL: &str = "https://example.com/model.glb";
|
||||
const OBJ_URL: &str = "https://example.com/model.obj";
|
||||
|
||||
mod shape_dispatch {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn input_mesh_url_builds_the_request() {
|
||||
let state = build_fal_hunyuan_3d_3p1_smart_topology_state(mesh_builder(GLB_URL))
|
||||
.expect("build");
|
||||
assert_eq!(state.request.input_file_url, GLB_URL);
|
||||
assert!(state.request.face_level.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_input_mesh_is_rejected() {
|
||||
let result = build_fal_hunyuan_3d_3p1_smart_topology_state(base_builder());
|
||||
assert!(matches!(result, Err(ArtcraftRouterError::InvalidInput(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_token_input_mesh_is_rejected() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
input_mesh: Some(MeshRef::MediaFileToken(MediaFileToken::new("mf_mesh".to_string()))),
|
||||
..base_builder()
|
||||
};
|
||||
assert!(matches!(
|
||||
build_fal_hunyuan_3d_3p1_smart_topology_state(builder),
|
||||
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
mod option_mapping {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn obj_url_infers_obj_input_file_type() {
|
||||
let state = build_fal_hunyuan_3d_3p1_smart_topology_state(mesh_builder(OBJ_URL))
|
||||
.expect("build");
|
||||
assert!(matches!(
|
||||
state.request.input_file_type,
|
||||
Some(Hunyuan3d3p1SmartTopologyInputFileType::Obj)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn obj_inference_is_case_insensitive_and_ignores_the_query_string() {
|
||||
let cases = [
|
||||
"https://example.com/model.OBJ",
|
||||
"https://example.com/model.obj?token=abc123",
|
||||
"https://example.com/model.Obj#fragment",
|
||||
];
|
||||
for url in cases {
|
||||
assert!(
|
||||
matches!(infer_input_file_type(url), Some(Hunyuan3d3p1SmartTopologyInputFileType::Obj)),
|
||||
"for {url}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_obj_urls_leave_input_file_type_unset() {
|
||||
let cases = [
|
||||
GLB_URL,
|
||||
"https://example.com/model.fbx",
|
||||
"https://example.com/model.glb?format=obj",
|
||||
];
|
||||
for url in cases {
|
||||
assert!(infer_input_file_type(url).is_none(), "for {url}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polygon_type_maps_through() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
polygon_type: Some(CommonPolygonType::Quad),
|
||||
..mesh_builder(GLB_URL)
|
||||
};
|
||||
let state = build_fal_hunyuan_3d_3p1_smart_topology_state(builder).expect("build");
|
||||
assert!(matches!(
|
||||
state.request.polygon_type,
|
||||
Some(Hunyuan3d3p1SmartTopologyPolygonType::Quadrilateral)
|
||||
));
|
||||
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
polygon_type: Some(CommonPolygonType::Triangle),
|
||||
..mesh_builder(GLB_URL)
|
||||
};
|
||||
let state = build_fal_hunyuan_3d_3p1_smart_topology_state(builder).expect("build");
|
||||
assert!(matches!(
|
||||
state.request.polygon_type,
|
||||
Some(Hunyuan3d3p1SmartTopologyPolygonType::Triangle)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
mod unsupported_options {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn each_unsupported_option_errors_out_under_error_out() {
|
||||
let cases: Vec<fn(&mut GenerateMeshRequestBuilder)> = vec![
|
||||
|b| b.prompt = Some("a red ceramic teapot".to_string()),
|
||||
|b| b.front_image = Some(ImageRef::Url("https://example.com/front.png".to_string())),
|
||||
|b| b.back_image = Some(ImageRef::Url("https://example.com/back.png".to_string())),
|
||||
|b| b.mesh_output_type = Some(CommonMeshOutputType::Normal),
|
||||
|b| b.face_count = Some(100_000),
|
||||
|b| b.enable_pbr = Some(true),
|
||||
|b| b.enable_texture = Some(false),
|
||||
];
|
||||
for (index, set) in cases.into_iter().enumerate() {
|
||||
let mut builder = GenerateMeshRequestBuilder {
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
|
||||
..mesh_builder(GLB_URL)
|
||||
};
|
||||
set(&mut builder);
|
||||
assert!(build_fal_hunyuan_3d_3p1_smart_topology_state(builder).is_err(), "for case {index}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_options_are_dropped_under_lenient_strategies() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
prompt: Some("a red ceramic teapot".to_string()),
|
||||
mesh_output_type: Some(CommonMeshOutputType::Normal),
|
||||
face_count: Some(100_000),
|
||||
enable_pbr: Some(true),
|
||||
..mesh_builder(GLB_URL)
|
||||
};
|
||||
let state = build_fal_hunyuan_3d_3p1_smart_topology_state(builder).expect("build");
|
||||
assert_eq!(state.request.input_file_url, GLB_URL);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
fn base_builder() -> GenerateMeshRequestBuilder {
|
||||
GenerateMeshRequestBuilder {
|
||||
model: RouterMeshModel::Hunyuan3d3p1SmartTopology,
|
||||
provider: RouterProvider::Fal,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn mesh_builder(url: &str) -> GenerateMeshRequestBuilder {
|
||||
GenerateMeshRequestBuilder {
|
||||
input_mesh: Some(MeshRef::Url(url.to_string())),
|
||||
..base_builder()
|
||||
}
|
||||
}
|
||||
}
|
||||
+75
@@ -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_3p1_smart_topology::request::FalHunyuan3d3p1SmartTopologyRequestState;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FalHunyuan3d3p1SmartTopologyCostState {
|
||||
pub cost_in_usd_cents: u64,
|
||||
}
|
||||
|
||||
impl FalHunyuan3d3p1SmartTopologyCostState {
|
||||
pub fn from_request(request: &FalHunyuan3d3p1SmartTopologyRequestState) -> 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_polygon_type::CommonPolygonType;
|
||||
|
||||
use crate::api::mesh_ref::MeshRef;
|
||||
use crate::api::router_mesh_model::RouterMeshModel;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder;
|
||||
|
||||
const MESH_URL: &str = "https://example.com/model.glb";
|
||||
|
||||
mod cost_table {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cost_is_a_flat_seventy_five_cents() {
|
||||
assert_eq!(estimate_usd_cents(mesh_builder()), 75);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polygon_type_does_not_change_the_price() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
polygon_type: Some(CommonPolygonType::Quad),
|
||||
..mesh_builder()
|
||||
};
|
||||
assert_eq!(estimate_usd_cents(builder), 75);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
fn mesh_builder() -> GenerateMeshRequestBuilder {
|
||||
GenerateMeshRequestBuilder {
|
||||
model: RouterMeshModel::Hunyuan3d3p1SmartTopology,
|
||||
provider: RouterProvider::Fal,
|
||||
input_mesh: Some(MeshRef::Url(MESH_URL.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")
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod build;
|
||||
pub mod cost;
|
||||
pub mod request;
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
use fal_client::requests::api::mesh::topology::hunyuan_3d_3p1_smart_topology::api::Hunyuan3d3p1SmartTopologyRequest;
|
||||
|
||||
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 FalHunyuan3d3p1SmartTopologyRequestState {
|
||||
/// Final materialized request; ready to fire.
|
||||
pub request: Hunyuan3d3p1SmartTopologyRequest,
|
||||
}
|
||||
|
||||
impl FalHunyuan3d3p1SmartTopologyRequestState {
|
||||
pub async fn send(&self, client: &RouterFalClient) -> Result<GenerateMeshResponse, ArtcraftRouterError> {
|
||||
send_fal_mesh_request(&self.request, client).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::api::mesh_ref::MeshRef;
|
||||
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::mesh_generation_draft_or_request::MeshGenerationDraftOrRequest;
|
||||
use crate::test_helpers::get_fal_client;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // requires real API key and a real GLB URL, incurs cost
|
||||
async fn send_smart_topology() {
|
||||
let client = get_fal_client();
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
model: RouterMeshModel::Hunyuan3d3p1SmartTopology,
|
||||
provider: RouterProvider::Fal,
|
||||
input_mesh: Some(MeshRef::Url("https://example.com/model.glb".to_string())),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
+490
@@ -0,0 +1,490 @@
|
||||
use enums::common::generation::common_mesh_output_type::CommonMeshOutputType;
|
||||
use enums::common::generation::common_polygon_type::CommonPolygonType;
|
||||
use fal_client::requests::api::mesh::image::meshy_v6_image_to_mesh::api::MeshyV6ImageToMeshRequest;
|
||||
use fal_client::requests::api::mesh::text::meshy_v6_text_to_mesh::api::{
|
||||
MeshyV6ModelType, MeshyV6TextToMeshRequest, MeshyV6Topology,
|
||||
};
|
||||
|
||||
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
|
||||
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::meshy_v6::request::{
|
||||
FalMeshyV6ImageRequestState, FalMeshyV6TextRequestState,
|
||||
};
|
||||
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,
|
||||
};
|
||||
|
||||
/// Meshy 6 combines fal's image-to-3d and text-to-3d endpoints under a single
|
||||
/// router model. The request shape picks the endpoint: any primary image
|
||||
/// input dispatches to image-to-3d; otherwise a prompt dispatches to
|
||||
/// text-to-3d. Neither endpoint takes side-view images or an input mesh.
|
||||
pub fn build_fal_meshy_v6(builder: GenerateMeshRequestBuilder) -> Result<MeshGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
let state = build_fal_meshy_v6_state(builder)?;
|
||||
let request = match state {
|
||||
FalMeshyV6State::Image(state) => MeshGenerationRequest::FalMeshyV6Image(state),
|
||||
FalMeshyV6State::Text(state) => MeshGenerationRequest::FalMeshyV6Text(state),
|
||||
};
|
||||
Ok(MeshGenerationDraftOrRequest::Request(request))
|
||||
}
|
||||
|
||||
/// The endpoint selected by the request shape.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) enum FalMeshyV6State {
|
||||
Image(FalMeshyV6ImageRequestState),
|
||||
Text(FalMeshyV6TextRequestState),
|
||||
}
|
||||
|
||||
pub(crate) fn build_fal_meshy_v6_state(
|
||||
mut builder: GenerateMeshRequestBuilder,
|
||||
) -> Result<FalMeshyV6State, ArtcraftRouterError> {
|
||||
let strategy = builder.request_mismatch_mitigation_strategy;
|
||||
|
||||
// Options neither Meshy 6 endpoint supports.
|
||||
reject_unsupported_option("texture_quality", builder.texture_quality.as_ref(), strategy)?;
|
||||
reject_unsupported_option("geometry_quality", builder.geometry_quality.as_ref(), strategy)?;
|
||||
reject_unsupported_option("input_mesh", builder.input_mesh.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 maybe_image_url = plan_primary_image_url(
|
||||
builder.reference_images.take(),
|
||||
builder.front_image.take(),
|
||||
strategy,
|
||||
)?;
|
||||
let target_polycount = plan_face_count(builder.face_count, strategy)?;
|
||||
let topology = builder.polygon_type.map(to_topology);
|
||||
|
||||
match maybe_image_url {
|
||||
Some(image_url) => {
|
||||
// Image mode. The image endpoint has no prompt parameter.
|
||||
reject_unsupported_option("prompt", builder.prompt.as_ref(), strategy)?;
|
||||
|
||||
let request = MeshyV6ImageToMeshRequest {
|
||||
image_url,
|
||||
model_type: plan_image_model_type(builder.mesh_output_type),
|
||||
topology,
|
||||
target_polycount,
|
||||
symmetry_mode: None,
|
||||
should_remesh: None,
|
||||
should_texture: plan_should_texture(builder.mesh_output_type, builder.enable_texture),
|
||||
enable_pbr: builder.enable_pbr,
|
||||
pose_mode: None,
|
||||
texture_prompt: None,
|
||||
texture_image_url: None,
|
||||
enable_rigging: None,
|
||||
rigging_height_meters: None,
|
||||
enable_animation: None,
|
||||
animation_action_id: None,
|
||||
enable_safety_checker: None,
|
||||
};
|
||||
Ok(FalMeshyV6State::Image(FalMeshyV6ImageRequestState { request }))
|
||||
}
|
||||
None => {
|
||||
// Text mode. The text endpoint always textures, so `enable_texture`
|
||||
// has no equivalent parameter.
|
||||
reject_unsupported_option("enable_texture", builder.enable_texture.as_ref(), strategy)?;
|
||||
|
||||
let prompt = builder.prompt.take().ok_or_else(|| {
|
||||
ArtcraftRouterError::InvalidInput(
|
||||
"Meshy 6 requires an input image or a prompt".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let request = MeshyV6TextToMeshRequest {
|
||||
prompt,
|
||||
mode: None,
|
||||
seed: None,
|
||||
model_type: plan_text_model_type(builder.mesh_output_type, strategy)?,
|
||||
topology,
|
||||
target_polycount,
|
||||
should_remesh: None,
|
||||
symmetry_mode: None,
|
||||
enable_pbr: builder.enable_pbr,
|
||||
pose_mode: None,
|
||||
enable_prompt_expansion: None,
|
||||
texture_prompt: None,
|
||||
texture_image_url: None,
|
||||
enable_rigging: None,
|
||||
rigging_height_meters: None,
|
||||
enable_animation: None,
|
||||
animation_action_id: None,
|
||||
enable_safety_checker: None,
|
||||
};
|
||||
Ok(FalMeshyV6State::Text(FalMeshyV6TextRequestState { request }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Option planning helpers ──
|
||||
|
||||
/// Map the output type onto the image endpoint's `model_type`. `Geometry`
|
||||
/// is expressed via `should_texture` instead, so only `LowPoly` maps here.
|
||||
fn plan_image_model_type(
|
||||
mesh_output_type: Option<CommonMeshOutputType>,
|
||||
) -> Option<MeshyV6ModelType> {
|
||||
match mesh_output_type {
|
||||
Some(CommonMeshOutputType::LowPoly) => Some(MeshyV6ModelType::LowPoly),
|
||||
None | Some(CommonMeshOutputType::Normal) | Some(CommonMeshOutputType::Geometry) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Map the output type onto the text endpoint's `model_type`. The text
|
||||
/// endpoint cannot skip texturing, so `Geometry` rejects under `ErrorOut`
|
||||
/// and is dropped otherwise.
|
||||
fn plan_text_model_type(
|
||||
mesh_output_type: Option<CommonMeshOutputType>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Option<MeshyV6ModelType>, ArtcraftRouterError> {
|
||||
match mesh_output_type {
|
||||
None | Some(CommonMeshOutputType::Normal) => Ok(None),
|
||||
Some(CommonMeshOutputType::LowPoly) => Ok(Some(MeshyV6ModelType::LowPoly)),
|
||||
Some(CommonMeshOutputType::Geometry) => {
|
||||
reject_unsupported_option(
|
||||
"mesh_output_type",
|
||||
Some(&CommonMeshOutputType::Geometry),
|
||||
strategy,
|
||||
)?;
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A geometry-only output type or an explicit `enable_texture: false` both
|
||||
/// map to the image endpoint's `should_texture: false`.
|
||||
fn plan_should_texture(
|
||||
mesh_output_type: Option<CommonMeshOutputType>,
|
||||
enable_texture: Option<bool>,
|
||||
) -> Option<bool> {
|
||||
let skip_texturing = matches!(mesh_output_type, Some(CommonMeshOutputType::Geometry))
|
||||
|| enable_texture == Some(false);
|
||||
if skip_texturing {
|
||||
Some(false)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn to_topology(polygon_type: CommonPolygonType) -> MeshyV6Topology {
|
||||
match polygon_type {
|
||||
CommonPolygonType::Quad => MeshyV6Topology::Quad,
|
||||
CommonPolygonType::Triangle => MeshyV6Topology::Triangle,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use enums::common::generation::common_mesh_quality::CommonMeshQuality;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::mesh_ref::MeshRef;
|
||||
use crate::api::router_mesh_model::RouterMeshModel;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::errors::client_error::ClientError;
|
||||
|
||||
use super::*;
|
||||
|
||||
const FRONT_URL: &str = "https://example.com/front.png";
|
||||
const PROMPT: &str = "a red ceramic teapot";
|
||||
|
||||
mod shape_dispatch {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reference_image_dispatches_to_image_mode() {
|
||||
let state = build_fal_meshy_v6_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_meshy_v6_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_meshy_v6_state(text_builder()).expect("build");
|
||||
let text = expect_text(state);
|
||||
assert_eq!(text.request.prompt, PROMPT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neither_image_nor_prompt_is_rejected() {
|
||||
let result = build_fal_meshy_v6_state(base_builder());
|
||||
assert!(matches!(result, Err(ArtcraftRouterError::InvalidInput(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_tokens_are_rejected() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
reference_images: Some(ImageListRef::MediaFileTokens(vec![
|
||||
MediaFileToken::new("mf_test123".to_string()),
|
||||
])),
|
||||
..base_builder()
|
||||
};
|
||||
assert!(matches!(
|
||||
build_fal_meshy_v6_state(builder),
|
||||
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
mod image_mode {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn geometry_output_sets_should_texture_false() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
mesh_output_type: Some(CommonMeshOutputType::Geometry),
|
||||
..image_builder()
|
||||
};
|
||||
let image = expect_image(build_fal_meshy_v6_state(builder).expect("build"));
|
||||
assert_eq!(image.request.should_texture, Some(false));
|
||||
assert_eq!(image.request.model_type, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enable_texture_false_sets_should_texture_false() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
enable_texture: Some(false),
|
||||
..image_builder()
|
||||
};
|
||||
let image = expect_image(build_fal_meshy_v6_state(builder).expect("build"));
|
||||
assert_eq!(image.request.should_texture, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_texture_is_unset_by_default() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
enable_texture: Some(true),
|
||||
..image_builder()
|
||||
};
|
||||
let image = expect_image(build_fal_meshy_v6_state(builder).expect("build"));
|
||||
assert_eq!(image.request.should_texture, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn low_poly_maps_to_model_type() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
mesh_output_type: Some(CommonMeshOutputType::LowPoly),
|
||||
..image_builder()
|
||||
};
|
||||
let image = expect_image(build_fal_meshy_v6_state(builder).expect("build"));
|
||||
assert_eq!(image.request.model_type, Some(MeshyV6ModelType::LowPoly));
|
||||
assert_eq!(image.request.should_texture, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_map_through() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
polygon_type: Some(CommonPolygonType::Quad),
|
||||
face_count: Some(100_000),
|
||||
enable_pbr: Some(true),
|
||||
..image_builder()
|
||||
};
|
||||
let image = expect_image(build_fal_meshy_v6_state(builder).expect("build"));
|
||||
assert_eq!(image.request.topology, Some(MeshyV6Topology::Quad));
|
||||
assert_eq!(image.request.target_polycount, 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(PROMPT.to_string()),
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
|
||||
..image_builder()
|
||||
};
|
||||
assert!(build_fal_meshy_v6_state(builder).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_with_image_is_dropped_under_lenient_strategies() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
prompt: Some(PROMPT.to_string()),
|
||||
..image_builder()
|
||||
};
|
||||
assert!(matches!(
|
||||
build_fal_meshy_v6_state(builder).expect("build"),
|
||||
FalMeshyV6State::Image(_)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
mod text_mode {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn options_map_through() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
mesh_output_type: Some(CommonMeshOutputType::LowPoly),
|
||||
polygon_type: Some(CommonPolygonType::Triangle),
|
||||
face_count: Some(40_000),
|
||||
enable_pbr: Some(false),
|
||||
..text_builder()
|
||||
};
|
||||
let text = expect_text(build_fal_meshy_v6_state(builder).expect("build"));
|
||||
assert_eq!(text.request.model_type, Some(MeshyV6ModelType::LowPoly));
|
||||
assert_eq!(text.request.topology, Some(MeshyV6Topology::Triangle));
|
||||
assert_eq!(text.request.target_polycount, Some(40_000));
|
||||
assert_eq!(text.request.enable_pbr, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn geometry_errors_out_under_error_out() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
mesh_output_type: Some(CommonMeshOutputType::Geometry),
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
|
||||
..text_builder()
|
||||
};
|
||||
assert!(build_fal_meshy_v6_state(builder).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn geometry_is_dropped_under_lenient_strategies() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
mesh_output_type: Some(CommonMeshOutputType::Geometry),
|
||||
..text_builder()
|
||||
};
|
||||
let text = expect_text(build_fal_meshy_v6_state(builder).expect("build"));
|
||||
assert_eq!(text.request.model_type, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enable_texture_errors_out_under_error_out() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
enable_texture: Some(false),
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
|
||||
..text_builder()
|
||||
};
|
||||
assert!(build_fal_meshy_v6_state(builder).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enable_texture_is_dropped_under_lenient_strategies() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
enable_texture: Some(false),
|
||||
..text_builder()
|
||||
};
|
||||
assert!(matches!(
|
||||
build_fal_meshy_v6_state(builder).expect("build"),
|
||||
FalMeshyV6State::Text(_)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
mod unsupported_options {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn texture_quality_errors_out_under_error_out() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
texture_quality: Some(CommonMeshQuality::Detailed),
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
|
||||
..image_builder()
|
||||
};
|
||||
assert!(build_fal_meshy_v6_state(builder).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn geometry_quality_errors_out_under_error_out() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
geometry_quality: Some(CommonMeshQuality::Detailed),
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
|
||||
..image_builder()
|
||||
};
|
||||
assert!(build_fal_meshy_v6_state(builder).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_mesh_errors_out_under_error_out() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
input_mesh: Some(MeshRef::Url("https://example.com/mesh.glb".to_string())),
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
|
||||
..image_builder()
|
||||
};
|
||||
assert!(build_fal_meshy_v6_state(builder).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn side_images_error_out_under_error_out() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
back_image: Some(ImageRef::Url("https://example.com/back.png".to_string())),
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
|
||||
..image_builder()
|
||||
};
|
||||
assert!(build_fal_meshy_v6_state(builder).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_options_are_dropped_under_lenient_strategies() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
texture_quality: Some(CommonMeshQuality::Detailed),
|
||||
geometry_quality: Some(CommonMeshQuality::Standard),
|
||||
input_mesh: Some(MeshRef::Url("https://example.com/mesh.glb".to_string())),
|
||||
back_image: Some(ImageRef::Url("https://example.com/back.png".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()
|
||||
};
|
||||
assert!(matches!(
|
||||
build_fal_meshy_v6_state(builder).expect("build"),
|
||||
FalMeshyV6State::Image(_)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
fn base_builder() -> GenerateMeshRequestBuilder {
|
||||
GenerateMeshRequestBuilder {
|
||||
model: RouterMeshModel::MeshyV6,
|
||||
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(PROMPT.to_string()),
|
||||
..base_builder()
|
||||
}
|
||||
}
|
||||
|
||||
fn expect_image(state: FalMeshyV6State) -> FalMeshyV6ImageRequestState {
|
||||
match state {
|
||||
FalMeshyV6State::Image(image) => image,
|
||||
FalMeshyV6State::Text(text) => panic!("expected image mode, got text: {text:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn expect_text(state: FalMeshyV6State) -> FalMeshyV6TextRequestState {
|
||||
match state {
|
||||
FalMeshyV6State::Text(text) => text,
|
||||
FalMeshyV6State::Image(image) => panic!("expected text mode, got image: {image:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
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::meshy_v6::request::{
|
||||
FalMeshyV6ImageRequestState, FalMeshyV6TextRequestState,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FalMeshyV6ImageCostState {
|
||||
pub cost_in_usd_cents: u64,
|
||||
}
|
||||
|
||||
impl FalMeshyV6ImageCostState {
|
||||
pub fn from_request(request: &FalMeshyV6ImageRequestState) -> 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 FalMeshyV6TextCostState {
|
||||
pub cost_in_usd_cents: u64,
|
||||
}
|
||||
|
||||
impl FalMeshyV6TextCostState {
|
||||
pub fn from_request(request: &FalMeshyV6TextRequestState) -> 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)
|
||||
}
|
||||
}
|
||||
|
||||
#[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::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 flat_pricing {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn image_mode_is_eighty_cents() {
|
||||
assert_eq!(estimate_usd_cents(image_builder()), 80);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_mode_is_eighty_cents() {
|
||||
assert_eq!(estimate_usd_cents(text_builder()), 80);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_do_not_change_the_price() {
|
||||
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()
|
||||
};
|
||||
assert_eq!(estimate_usd_cents(builder), 80);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
fn image_builder() -> GenerateMeshRequestBuilder {
|
||||
GenerateMeshRequestBuilder {
|
||||
model: RouterMeshModel::MeshyV6,
|
||||
provider: RouterProvider::Fal,
|
||||
reference_images: Some(ImageListRef::Urls(vec![FRONT_URL.to_string()])),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn text_builder() -> GenerateMeshRequestBuilder {
|
||||
GenerateMeshRequestBuilder {
|
||||
model: RouterMeshModel::MeshyV6,
|
||||
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")
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod build;
|
||||
pub mod cost;
|
||||
pub mod request;
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
use fal_client::requests::api::mesh::image::meshy_v6_image_to_mesh::api::MeshyV6ImageToMeshRequest;
|
||||
use fal_client::requests::api::mesh::text::meshy_v6_text_to_mesh::api::MeshyV6TextToMeshRequest;
|
||||
|
||||
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 FalMeshyV6ImageRequestState {
|
||||
/// Final materialized request; ready to fire.
|
||||
pub request: MeshyV6ImageToMeshRequest,
|
||||
}
|
||||
|
||||
impl FalMeshyV6ImageRequestState {
|
||||
pub async fn send(&self, client: &RouterFalClient) -> Result<GenerateMeshResponse, ArtcraftRouterError> {
|
||||
send_fal_mesh_request(&self.request, client).await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FalMeshyV6TextRequestState {
|
||||
/// Final materialized request; ready to fire.
|
||||
pub request: MeshyV6TextToMeshRequest,
|
||||
}
|
||||
|
||||
impl FalMeshyV6TextRequestState {
|
||||
pub async fn send(&self, client: &RouterFalClient) -> Result<GenerateMeshResponse, ArtcraftRouterError> {
|
||||
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::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_meshy_v6_image_to_mesh() {
|
||||
let response = run_pipeline(GenerateMeshRequestBuilder {
|
||||
reference_images: Some(ImageListRef::Urls(vec![ERNEST_SCARED_STUPID_IMAGE_URL.to_string()])),
|
||||
..meshy_v6_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_meshy_v6_text_to_mesh() {
|
||||
let response = run_pipeline(GenerateMeshRequestBuilder {
|
||||
prompt: Some("A velociraptor with an open mouth full of sharp teeth.".to_string()),
|
||||
..meshy_v6_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 meshy_v6_builder() -> GenerateMeshRequestBuilder {
|
||||
GenerateMeshRequestBuilder {
|
||||
model: RouterMeshModel::MeshyV6,
|
||||
provider: RouterProvider::Fal,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
+7
@@ -3,3 +3,10 @@ pub mod hunyuan3d_3;
|
||||
pub mod hunyuan3d_3_sketch;
|
||||
pub mod hunyuan_3d_2p0;
|
||||
pub mod hunyuan_3d_2p1;
|
||||
pub mod hunyuan_3d_3p1_part;
|
||||
pub mod hunyuan_3d_3p1_pro;
|
||||
pub mod hunyuan_3d_3p1_rapid;
|
||||
pub mod hunyuan_3d_3p1_smart_topology;
|
||||
pub mod meshy_v6;
|
||||
pub mod rodin_2p5_fast;
|
||||
pub mod tripo3d_h3p1;
|
||||
|
||||
+15
@@ -7,6 +7,7 @@ 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::mesh_ref::MeshRef;
|
||||
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::client_error::ClientError;
|
||||
@@ -53,6 +54,20 @@ pub(crate) fn plan_side_image_url(
|
||||
image_ref.map(image_ref_to_url).transpose()
|
||||
}
|
||||
|
||||
/// Resolve an optional input mesh file to a URL. Fal only takes URLs;
|
||||
/// media file tokens are rejected.
|
||||
pub(crate) fn plan_input_mesh_url(
|
||||
mesh_ref: Option<MeshRef>,
|
||||
) -> Result<Option<String>, ArtcraftRouterError> {
|
||||
match mesh_ref {
|
||||
None => Ok(None),
|
||||
Some(MeshRef::Url(url)) => Ok(Some(url)),
|
||||
Some(MeshRef::MediaFileToken(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(
|
||||
|
||||
+514
@@ -0,0 +1,514 @@
|
||||
use enums::common::generation::common_mesh_output_type::CommonMeshOutputType;
|
||||
use fal_client::requests::api::mesh::image::rodin_2p5_fast_image_to_mesh::api::Rodin2p5FastImageToMeshRequest;
|
||||
use fal_client::requests::api::mesh::text::rodin_2p5_fast_text_to_mesh::api::{
|
||||
Rodin2p5FastMaterial, Rodin2p5FastTextToMeshRequest,
|
||||
};
|
||||
|
||||
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;
|
||||
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::rodin_2p5_fast::request::{
|
||||
FalRodin2p5FastImageRequestState, FalRodin2p5FastTextRequestState,
|
||||
};
|
||||
use crate::generate::generate_mesh::providers::reject_unsupported::{
|
||||
reject_unsupported_image_ref, reject_unsupported_option,
|
||||
};
|
||||
|
||||
/// The image endpoint accepts at most this many input images.
|
||||
const MAX_INPUT_IMAGES: usize = 5;
|
||||
|
||||
/// Hyper3D Rodin v2.5 Fast combines fal's image-to-3d and text-to-3d
|
||||
/// endpoints under a single router model. Any image input dispatches to
|
||||
/// image-to-3d (which takes up to five views of the object, plus an optional
|
||||
/// guidance prompt); otherwise a prompt dispatches to text-to-3d.
|
||||
pub fn build_fal_rodin_2p5_fast(builder: GenerateMeshRequestBuilder) -> Result<MeshGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
let state = build_fal_rodin_2p5_fast_state(builder)?;
|
||||
let request = match state {
|
||||
FalRodin2p5FastState::Image(state) => MeshGenerationRequest::FalRodin2p5FastImage(state),
|
||||
FalRodin2p5FastState::Text(state) => MeshGenerationRequest::FalRodin2p5FastText(state),
|
||||
};
|
||||
Ok(MeshGenerationDraftOrRequest::Request(request))
|
||||
}
|
||||
|
||||
/// The endpoint selected by the request shape.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) enum FalRodin2p5FastState {
|
||||
Image(FalRodin2p5FastImageRequestState),
|
||||
Text(FalRodin2p5FastTextRequestState),
|
||||
}
|
||||
|
||||
pub(crate) fn build_fal_rodin_2p5_fast_state(
|
||||
mut builder: GenerateMeshRequestBuilder,
|
||||
) -> Result<FalRodin2p5FastState, ArtcraftRouterError> {
|
||||
let strategy = builder.request_mismatch_mitigation_strategy;
|
||||
|
||||
// Options neither Rodin v2.5 Fast endpoint supports.
|
||||
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("texture_quality", builder.texture_quality.as_ref(), strategy)?;
|
||||
reject_unsupported_option("geometry_quality", builder.geometry_quality.as_ref(), strategy)?;
|
||||
reject_unsupported_option("input_mesh", builder.input_mesh.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 material = plan_material(
|
||||
builder.mesh_output_type,
|
||||
builder.enable_pbr,
|
||||
builder.enable_texture,
|
||||
strategy,
|
||||
)?;
|
||||
let image_urls = plan_image_urls(
|
||||
builder.reference_images.take(),
|
||||
builder.front_image.take(),
|
||||
strategy,
|
||||
)?;
|
||||
|
||||
if image_urls.is_empty() {
|
||||
// Text mode.
|
||||
let prompt = builder.prompt.take().ok_or_else(|| {
|
||||
ArtcraftRouterError::InvalidInput(
|
||||
"Rodin v2.5 Fast requires an input image or a prompt".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let request = Rodin2p5FastTextToMeshRequest {
|
||||
prompt,
|
||||
tier: None,
|
||||
seed: None,
|
||||
geometry_file_format: None,
|
||||
material,
|
||||
quality_mesh_option: None,
|
||||
texture_mode: None,
|
||||
enable_creative_mode: None,
|
||||
hd_texture: None,
|
||||
texture_delight: None,
|
||||
is_micro: None,
|
||||
ta_pose: None,
|
||||
bbox_condition: None,
|
||||
};
|
||||
Ok(FalRodin2p5FastState::Text(FalRodin2p5FastTextRequestState { request }))
|
||||
} else {
|
||||
// Image mode. The prompt is optional guidance here.
|
||||
let request = Rodin2p5FastImageToMeshRequest {
|
||||
image_urls,
|
||||
prompt: builder.prompt.take(),
|
||||
use_original_alpha: None,
|
||||
tier: None,
|
||||
seed: None,
|
||||
geometry_file_format: None,
|
||||
material,
|
||||
quality_mesh_option: None,
|
||||
texture_mode: None,
|
||||
enable_creative_mode: None,
|
||||
hd_texture: None,
|
||||
texture_delight: None,
|
||||
is_micro: None,
|
||||
ta_pose: None,
|
||||
bbox_condition: None,
|
||||
preview_render: None,
|
||||
};
|
||||
Ok(FalRodin2p5FastState::Image(FalRodin2p5FastImageRequestState { request }))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Option planning helpers ──
|
||||
|
||||
/// Collect every input image URL: all `reference_images` URLs plus the
|
||||
/// `front_image` if set. More than [`MAX_INPUT_IMAGES`] rejects under
|
||||
/// `ErrorOut`; the lenient strategies keep the first five. Fal only takes
|
||||
/// URLs, so media file tokens are rejected.
|
||||
fn plan_image_urls(
|
||||
reference_images: Option<ImageListRef>,
|
||||
front_image: Option<ImageRef>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Vec<String>, ArtcraftRouterError> {
|
||||
let mut urls: Vec<String> = Vec::new();
|
||||
|
||||
match reference_images {
|
||||
None => {}
|
||||
Some(ImageListRef::Urls(reference_urls)) => urls.extend(reference_urls),
|
||||
Some(ImageListRef::MediaFileTokens(tokens)) if tokens.is_empty() => {}
|
||||
Some(ImageListRef::MediaFileTokens(_)) => {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls));
|
||||
}
|
||||
}
|
||||
|
||||
match front_image {
|
||||
None => {}
|
||||
Some(ImageRef::Url(url)) => urls.push(url),
|
||||
Some(ImageRef::MediaFileToken(_)) => {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls));
|
||||
}
|
||||
}
|
||||
|
||||
if urls.len() > MAX_INPUT_IMAGES {
|
||||
match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "reference_images",
|
||||
value: format!("Expected at most {MAX_INPUT_IMAGES} images, got {}", urls.len()),
|
||||
}));
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade
|
||||
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
|
||||
urls.truncate(MAX_INPUT_IMAGES);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(urls)
|
||||
}
|
||||
|
||||
/// Map the builder's texturing options onto Rodin's `material` parameter.
|
||||
/// PBR wins when requested; otherwise a geometry-only output type or an
|
||||
/// explicit `enable_texture: false` map to no material. Rodin has no
|
||||
/// low-poly mode, so `LowPoly` rejects under `ErrorOut` and is dropped
|
||||
/// otherwise.
|
||||
fn plan_material(
|
||||
mesh_output_type: Option<CommonMeshOutputType>,
|
||||
enable_pbr: Option<bool>,
|
||||
enable_texture: Option<bool>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Option<Rodin2p5FastMaterial>, ArtcraftRouterError> {
|
||||
if matches!(mesh_output_type, Some(CommonMeshOutputType::LowPoly)) {
|
||||
reject_unsupported_option(
|
||||
"mesh_output_type",
|
||||
Some(&CommonMeshOutputType::LowPoly),
|
||||
strategy,
|
||||
)?;
|
||||
}
|
||||
if enable_pbr == Some(true) {
|
||||
return Ok(Some(Rodin2p5FastMaterial::Pbr));
|
||||
}
|
||||
let skip_texturing = matches!(mesh_output_type, Some(CommonMeshOutputType::Geometry))
|
||||
|| enable_texture == Some(false);
|
||||
if skip_texturing {
|
||||
return Ok(Some(Rodin2p5FastMaterial::NoMaterial));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use enums::common::generation::common_mesh_quality::CommonMeshQuality;
|
||||
use enums::common::generation::common_polygon_type::CommonPolygonType;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::api::mesh_ref::MeshRef;
|
||||
use crate::api::router_mesh_model::RouterMeshModel;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
|
||||
use super::*;
|
||||
|
||||
const FRONT_URL: &str = "https://example.com/front.png";
|
||||
const SECOND_URL: &str = "https://example.com/second.png";
|
||||
const PROMPT: &str = "a red ceramic teapot";
|
||||
|
||||
mod shape_dispatch {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reference_image_dispatches_to_image_mode() {
|
||||
let state = build_fal_rodin_2p5_fast_state(image_builder()).expect("build");
|
||||
let image = expect_image(state);
|
||||
assert_eq!(image.request.image_urls, vec![FRONT_URL.to_string()]);
|
||||
}
|
||||
|
||||
#[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 image = expect_image(build_fal_rodin_2p5_fast_state(builder).expect("build"));
|
||||
assert_eq!(image.request.image_urls, vec![FRONT_URL.to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_only_dispatches_to_text_mode() {
|
||||
let state = build_fal_rodin_2p5_fast_state(text_builder()).expect("build");
|
||||
let text = expect_text(state);
|
||||
assert_eq!(text.request.prompt, PROMPT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neither_image_nor_prompt_is_rejected() {
|
||||
let result = build_fal_rodin_2p5_fast_state(base_builder());
|
||||
assert!(matches!(result, Err(ArtcraftRouterError::InvalidInput(_))));
|
||||
}
|
||||
}
|
||||
|
||||
mod image_mode {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn multiple_reference_urls_collect_into_image_urls() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
reference_images: Some(ImageListRef::Urls(vec![
|
||||
FRONT_URL.to_string(),
|
||||
SECOND_URL.to_string(),
|
||||
])),
|
||||
front_image: Some(ImageRef::Url("https://example.com/third.png".to_string())),
|
||||
..base_builder()
|
||||
};
|
||||
let image = expect_image(build_fal_rodin_2p5_fast_state(builder).expect("build"));
|
||||
assert_eq!(image.request.image_urls, vec![
|
||||
FRONT_URL.to_string(),
|
||||
SECOND_URL.to_string(),
|
||||
"https://example.com/third.png".to_string(),
|
||||
]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn more_than_five_images_error_out_under_error_out() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
reference_images: Some(ImageListRef::Urls(six_urls())),
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
|
||||
..base_builder()
|
||||
};
|
||||
assert!(matches!(
|
||||
build_fal_rodin_2p5_fast_state(builder),
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { .. }))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn more_than_five_images_truncate_under_lenient_strategies() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
reference_images: Some(ImageListRef::Urls(six_urls())),
|
||||
..base_builder()
|
||||
};
|
||||
let image = expect_image(build_fal_rodin_2p5_fast_state(builder).expect("build"));
|
||||
assert_eq!(image.request.image_urls.len(), 5);
|
||||
assert_eq!(image.request.image_urls, &six_urls()[..5]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_is_forwarded_as_guidance() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
prompt: Some(PROMPT.to_string()),
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
|
||||
..image_builder()
|
||||
};
|
||||
let image = expect_image(build_fal_rodin_2p5_fast_state(builder).expect("build"));
|
||||
assert_eq!(image.request.prompt.as_deref(), Some(PROMPT));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_media_tokens_are_rejected() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
reference_images: Some(ImageListRef::MediaFileTokens(vec![
|
||||
MediaFileToken::new("mf_test123".to_string()),
|
||||
])),
|
||||
..base_builder()
|
||||
};
|
||||
assert!(matches!(
|
||||
build_fal_rodin_2p5_fast_state(builder),
|
||||
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn front_image_media_token_is_rejected() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
front_image: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_front".to_string()))),
|
||||
..base_builder()
|
||||
};
|
||||
assert!(matches!(
|
||||
build_fal_rodin_2p5_fast_state(builder),
|
||||
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
mod material_mapping {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn pbr_maps_to_pbr_material() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
enable_pbr: Some(true),
|
||||
..image_builder()
|
||||
};
|
||||
let image = expect_image(build_fal_rodin_2p5_fast_state(builder).expect("build"));
|
||||
assert_eq!(image.request.material, Some(Rodin2p5FastMaterial::Pbr));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn geometry_maps_to_no_material() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
mesh_output_type: Some(CommonMeshOutputType::Geometry),
|
||||
..image_builder()
|
||||
};
|
||||
let image = expect_image(build_fal_rodin_2p5_fast_state(builder).expect("build"));
|
||||
assert_eq!(image.request.material, Some(Rodin2p5FastMaterial::NoMaterial));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enable_texture_false_maps_to_no_material() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
enable_texture: Some(false),
|
||||
..text_builder()
|
||||
};
|
||||
let text = expect_text(build_fal_rodin_2p5_fast_state(builder).expect("build"));
|
||||
assert_eq!(text.request.material, Some(Rodin2p5FastMaterial::NoMaterial));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pbr_takes_precedence_over_geometry() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
mesh_output_type: Some(CommonMeshOutputType::Geometry),
|
||||
enable_pbr: Some(true),
|
||||
..image_builder()
|
||||
};
|
||||
let image = expect_image(build_fal_rodin_2p5_fast_state(builder).expect("build"));
|
||||
assert_eq!(image.request.material, Some(Rodin2p5FastMaterial::Pbr));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn material_is_unset_by_default() {
|
||||
let image = expect_image(build_fal_rodin_2p5_fast_state(image_builder()).expect("build"));
|
||||
assert_eq!(image.request.material, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn low_poly_errors_out_under_error_out() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
mesh_output_type: Some(CommonMeshOutputType::LowPoly),
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
|
||||
..image_builder()
|
||||
};
|
||||
assert!(build_fal_rodin_2p5_fast_state(builder).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn low_poly_is_dropped_under_lenient_strategies() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
mesh_output_type: Some(CommonMeshOutputType::LowPoly),
|
||||
..image_builder()
|
||||
};
|
||||
let image = expect_image(build_fal_rodin_2p5_fast_state(builder).expect("build"));
|
||||
assert_eq!(image.request.material, None);
|
||||
}
|
||||
}
|
||||
|
||||
mod unsupported_options {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn unsupported_options_error_out_under_error_out() {
|
||||
let cases: Vec<(&str, GenerateMeshRequestBuilder)> = vec![
|
||||
("polygon_type", GenerateMeshRequestBuilder {
|
||||
polygon_type: Some(CommonPolygonType::Quad),
|
||||
..error_out_image_builder()
|
||||
}),
|
||||
("face_count", GenerateMeshRequestBuilder {
|
||||
face_count: Some(100_000),
|
||||
..error_out_image_builder()
|
||||
}),
|
||||
("texture_quality", GenerateMeshRequestBuilder {
|
||||
texture_quality: Some(CommonMeshQuality::Detailed),
|
||||
..error_out_image_builder()
|
||||
}),
|
||||
("geometry_quality", GenerateMeshRequestBuilder {
|
||||
geometry_quality: Some(CommonMeshQuality::Detailed),
|
||||
..error_out_image_builder()
|
||||
}),
|
||||
("input_mesh", GenerateMeshRequestBuilder {
|
||||
input_mesh: Some(MeshRef::Url("https://example.com/mesh.glb".to_string())),
|
||||
..error_out_image_builder()
|
||||
}),
|
||||
("back_image", GenerateMeshRequestBuilder {
|
||||
back_image: Some(ImageRef::Url("https://example.com/back.png".to_string())),
|
||||
..error_out_image_builder()
|
||||
}),
|
||||
("left_image", GenerateMeshRequestBuilder {
|
||||
left_image: Some(ImageRef::Url("https://example.com/left.png".to_string())),
|
||||
..error_out_image_builder()
|
||||
}),
|
||||
("right_image", GenerateMeshRequestBuilder {
|
||||
right_image: Some(ImageRef::Url("https://example.com/right.png".to_string())),
|
||||
..error_out_image_builder()
|
||||
}),
|
||||
];
|
||||
for (field, builder) in cases {
|
||||
assert!(
|
||||
build_fal_rodin_2p5_fast_state(builder).is_err(),
|
||||
"expected error for {field}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_options_are_dropped_under_lenient_strategies() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
polygon_type: Some(CommonPolygonType::Quad),
|
||||
face_count: Some(100_000),
|
||||
texture_quality: Some(CommonMeshQuality::Detailed),
|
||||
geometry_quality: Some(CommonMeshQuality::Standard),
|
||||
input_mesh: Some(MeshRef::Url("https://example.com/mesh.glb".to_string())),
|
||||
back_image: Some(ImageRef::Url("https://example.com/back.png".to_string())),
|
||||
..image_builder()
|
||||
};
|
||||
assert!(matches!(
|
||||
build_fal_rodin_2p5_fast_state(builder).expect("build"),
|
||||
FalRodin2p5FastState::Image(_)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
fn base_builder() -> GenerateMeshRequestBuilder {
|
||||
GenerateMeshRequestBuilder {
|
||||
model: RouterMeshModel::Rodin2p5Fast,
|
||||
provider: RouterProvider::Fal,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn image_builder() -> GenerateMeshRequestBuilder {
|
||||
GenerateMeshRequestBuilder {
|
||||
reference_images: Some(ImageListRef::Urls(vec![FRONT_URL.to_string()])),
|
||||
..base_builder()
|
||||
}
|
||||
}
|
||||
|
||||
fn error_out_image_builder() -> GenerateMeshRequestBuilder {
|
||||
GenerateMeshRequestBuilder {
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
|
||||
..image_builder()
|
||||
}
|
||||
}
|
||||
|
||||
fn text_builder() -> GenerateMeshRequestBuilder {
|
||||
GenerateMeshRequestBuilder {
|
||||
prompt: Some(PROMPT.to_string()),
|
||||
..base_builder()
|
||||
}
|
||||
}
|
||||
|
||||
fn six_urls() -> Vec<String> {
|
||||
(1..=6).map(|i| format!("https://example.com/view-{i}.png")).collect()
|
||||
}
|
||||
|
||||
fn expect_image(state: FalRodin2p5FastState) -> FalRodin2p5FastImageRequestState {
|
||||
match state {
|
||||
FalRodin2p5FastState::Image(image) => image,
|
||||
FalRodin2p5FastState::Text(text) => panic!("expected image mode, got text: {text:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn expect_text(state: FalRodin2p5FastState) -> FalRodin2p5FastTextRequestState {
|
||||
match state {
|
||||
FalRodin2p5FastState::Text(text) => text,
|
||||
FalRodin2p5FastState::Image(image) => panic!("expected text mode, got image: {image:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
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::rodin_2p5_fast::request::{
|
||||
FalRodin2p5FastImageRequestState, FalRodin2p5FastTextRequestState,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FalRodin2p5FastImageCostState {
|
||||
pub cost_in_usd_cents: u64,
|
||||
}
|
||||
|
||||
impl FalRodin2p5FastImageCostState {
|
||||
pub fn from_request(request: &FalRodin2p5FastImageRequestState) -> 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 FalRodin2p5FastTextCostState {
|
||||
pub cost_in_usd_cents: u64,
|
||||
}
|
||||
|
||||
impl FalRodin2p5FastTextCostState {
|
||||
pub fn from_request(request: &FalRodin2p5FastTextRequestState) -> 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)
|
||||
}
|
||||
}
|
||||
|
||||
#[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;
|
||||
|
||||
const FRONT_URL: &str = "https://example.com/front.png";
|
||||
|
||||
mod flat_pricing {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn image_mode_is_ten_cents() {
|
||||
assert_eq!(estimate_usd_cents(image_builder()), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_mode_is_ten_cents() {
|
||||
assert_eq!(estimate_usd_cents(text_builder()), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_do_not_change_the_price() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
mesh_output_type: Some(CommonMeshOutputType::Geometry),
|
||||
enable_pbr: Some(true),
|
||||
..image_builder()
|
||||
};
|
||||
assert_eq!(estimate_usd_cents(builder), 10);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
fn image_builder() -> GenerateMeshRequestBuilder {
|
||||
GenerateMeshRequestBuilder {
|
||||
model: RouterMeshModel::Rodin2p5Fast,
|
||||
provider: RouterProvider::Fal,
|
||||
reference_images: Some(ImageListRef::Urls(vec![FRONT_URL.to_string()])),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn text_builder() -> GenerateMeshRequestBuilder {
|
||||
GenerateMeshRequestBuilder {
|
||||
model: RouterMeshModel::Rodin2p5Fast,
|
||||
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")
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod build;
|
||||
pub mod cost;
|
||||
pub mod request;
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
use fal_client::requests::api::mesh::image::rodin_2p5_fast_image_to_mesh::api::Rodin2p5FastImageToMeshRequest;
|
||||
use fal_client::requests::api::mesh::text::rodin_2p5_fast_text_to_mesh::api::Rodin2p5FastTextToMeshRequest;
|
||||
|
||||
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 FalRodin2p5FastImageRequestState {
|
||||
/// Final materialized request; ready to fire.
|
||||
pub request: Rodin2p5FastImageToMeshRequest,
|
||||
}
|
||||
|
||||
impl FalRodin2p5FastImageRequestState {
|
||||
pub async fn send(&self, client: &RouterFalClient) -> Result<GenerateMeshResponse, ArtcraftRouterError> {
|
||||
send_fal_mesh_request(&self.request, client).await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FalRodin2p5FastTextRequestState {
|
||||
/// Final materialized request; ready to fire.
|
||||
pub request: Rodin2p5FastTextToMeshRequest,
|
||||
}
|
||||
|
||||
impl FalRodin2p5FastTextRequestState {
|
||||
pub async fn send(&self, client: &RouterFalClient) -> Result<GenerateMeshResponse, ArtcraftRouterError> {
|
||||
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::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_rodin_2p5_fast_image_to_mesh() {
|
||||
let response = run_pipeline(GenerateMeshRequestBuilder {
|
||||
reference_images: Some(ImageListRef::Urls(vec![ERNEST_SCARED_STUPID_IMAGE_URL.to_string()])),
|
||||
..rodin_2p5_fast_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_rodin_2p5_fast_text_to_mesh() {
|
||||
let response = run_pipeline(GenerateMeshRequestBuilder {
|
||||
prompt: Some("A velociraptor with an open mouth full of sharp teeth.".to_string()),
|
||||
..rodin_2p5_fast_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 rodin_2p5_fast_builder() -> GenerateMeshRequestBuilder {
|
||||
GenerateMeshRequestBuilder {
|
||||
model: RouterMeshModel::Rodin2p5Fast,
|
||||
provider: RouterProvider::Fal,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
+629
@@ -0,0 +1,629 @@
|
||||
use enums::common::generation::common_mesh_output_type::CommonMeshOutputType;
|
||||
use enums::common::generation::common_mesh_quality::CommonMeshQuality;
|
||||
use enums::common::generation::common_polygon_type::CommonPolygonType;
|
||||
use fal_client::requests::api::mesh::image::tripo3d_h3p1_image_to_mesh::api::{
|
||||
Tripo3dH3p1ImageGeometryQuality, Tripo3dH3p1ImageTextureQuality, Tripo3dH3p1ImageToMeshRequest,
|
||||
};
|
||||
use fal_client::requests::api::mesh::multiview::tripo3d_h3p1_multiview_to_mesh::api::Tripo3dH3p1MultiviewToMeshRequest;
|
||||
use fal_client::requests::api::mesh::text::tripo3d_h3p1_text_to_mesh::api::{
|
||||
Tripo3dH3p1GeometryQuality, Tripo3dH3p1TextToMeshRequest, Tripo3dH3p1TextureQuality,
|
||||
};
|
||||
|
||||
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::resolve::{
|
||||
plan_face_count, plan_primary_image_url, plan_side_image_url,
|
||||
};
|
||||
use crate::generate::generate_mesh::providers::fal::tripo3d_h3p1::request::{
|
||||
FalTripo3dH3p1ImageRequestState, FalTripo3dH3p1MultiviewRequestState,
|
||||
FalTripo3dH3p1TextRequestState,
|
||||
};
|
||||
use crate::generate::generate_mesh::providers::reject_unsupported::reject_unsupported_option;
|
||||
|
||||
/// Tripo3D H3.1 combines fal's text-to-3d, image-to-3d, and multiview-to-3d
|
||||
/// endpoints under a single router model. The request shape picks the
|
||||
/// endpoint: any side-view image (back/left/right) dispatches to
|
||||
/// multiview-to-3d (which also requires a front image); otherwise a primary
|
||||
/// image dispatches to image-to-3d; otherwise a prompt dispatches to
|
||||
/// text-to-3d.
|
||||
pub fn build_fal_tripo3d_h3p1(builder: GenerateMeshRequestBuilder) -> Result<MeshGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
let state = build_fal_tripo3d_h3p1_state(builder)?;
|
||||
let request = match state {
|
||||
FalTripo3dH3p1State::Multiview(state) => MeshGenerationRequest::FalTripo3dH3p1Multiview(state),
|
||||
FalTripo3dH3p1State::Image(state) => MeshGenerationRequest::FalTripo3dH3p1Image(state),
|
||||
FalTripo3dH3p1State::Text(state) => MeshGenerationRequest::FalTripo3dH3p1Text(state),
|
||||
};
|
||||
Ok(MeshGenerationDraftOrRequest::Request(request))
|
||||
}
|
||||
|
||||
/// The endpoint selected by the request shape.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) enum FalTripo3dH3p1State {
|
||||
Multiview(FalTripo3dH3p1MultiviewRequestState),
|
||||
Image(FalTripo3dH3p1ImageRequestState),
|
||||
Text(FalTripo3dH3p1TextRequestState),
|
||||
}
|
||||
|
||||
pub(crate) fn build_fal_tripo3d_h3p1_state(
|
||||
mut builder: GenerateMeshRequestBuilder,
|
||||
) -> Result<FalTripo3dH3p1State, ArtcraftRouterError> {
|
||||
let strategy = builder.request_mismatch_mitigation_strategy;
|
||||
|
||||
// No Tripo3D endpoint takes a mesh input.
|
||||
reject_unsupported_option("input_mesh", builder.input_mesh.as_ref(), strategy)?;
|
||||
|
||||
let maybe_front_url = plan_primary_image_url(
|
||||
builder.reference_images.take(),
|
||||
builder.front_image.take(),
|
||||
strategy,
|
||||
)?;
|
||||
let maybe_left_url = plan_side_image_url(builder.left_image.take())?;
|
||||
let maybe_back_url = plan_side_image_url(builder.back_image.take())?;
|
||||
let maybe_right_url = plan_side_image_url(builder.right_image.take())?;
|
||||
|
||||
let options = plan_tripo3d_options(&mut builder)?;
|
||||
|
||||
let has_side_view = maybe_left_url.is_some()
|
||||
|| maybe_back_url.is_some()
|
||||
|| maybe_right_url.is_some();
|
||||
|
||||
if has_side_view {
|
||||
// Multiview mode. The endpoint reconstructs from 2-4 views, and the
|
||||
// front view anchors the set, so it's required.
|
||||
let front_url = maybe_front_url.ok_or_else(|| {
|
||||
ArtcraftRouterError::InvalidInput(
|
||||
"Tripo3D multi-view input requires a front/reference image".to_string(),
|
||||
)
|
||||
})?;
|
||||
// The multiview endpoint has no prompt parameter.
|
||||
reject_unsupported_option("prompt", builder.prompt.as_ref(), strategy)?;
|
||||
|
||||
// fal's canonical view order is [front, left, back, right]; absent views
|
||||
// are simply omitted.
|
||||
let mut image_urls = vec![front_url];
|
||||
image_urls.extend(maybe_left_url);
|
||||
image_urls.extend(maybe_back_url);
|
||||
image_urls.extend(maybe_right_url);
|
||||
|
||||
let request = Tripo3dH3p1MultiviewToMeshRequest {
|
||||
image_urls,
|
||||
face_limit: options.face_limit,
|
||||
texture: options.texture,
|
||||
pbr: options.pbr,
|
||||
model_seed: None,
|
||||
texture_seed: None,
|
||||
texture_quality: options.texture_quality.map(to_image_texture_quality),
|
||||
geometry_quality: options.geometry_quality.map(to_image_geometry_quality),
|
||||
texture_alignment: None,
|
||||
auto_size: None,
|
||||
orientation: None,
|
||||
quad: options.quad,
|
||||
};
|
||||
Ok(FalTripo3dH3p1State::Multiview(FalTripo3dH3p1MultiviewRequestState { request }))
|
||||
} else if let Some(image_url) = maybe_front_url {
|
||||
// Image mode. The image endpoint has no prompt parameter.
|
||||
reject_unsupported_option("prompt", builder.prompt.as_ref(), strategy)?;
|
||||
|
||||
let request = Tripo3dH3p1ImageToMeshRequest {
|
||||
image_url,
|
||||
face_limit: options.face_limit,
|
||||
texture: options.texture,
|
||||
pbr: options.pbr,
|
||||
model_seed: None,
|
||||
texture_seed: None,
|
||||
texture_quality: options.texture_quality.map(to_image_texture_quality),
|
||||
geometry_quality: options.geometry_quality.map(to_image_geometry_quality),
|
||||
texture_alignment: None,
|
||||
auto_size: None,
|
||||
orientation: None,
|
||||
quad: options.quad,
|
||||
};
|
||||
Ok(FalTripo3dH3p1State::Image(FalTripo3dH3p1ImageRequestState { request }))
|
||||
} else {
|
||||
// Text mode.
|
||||
let prompt = builder.prompt.take().ok_or_else(|| {
|
||||
ArtcraftRouterError::InvalidInput(
|
||||
"Tripo3D H3.1 requires an input image or a prompt".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let request = Tripo3dH3p1TextToMeshRequest {
|
||||
prompt,
|
||||
negative_prompt: None,
|
||||
face_limit: options.face_limit,
|
||||
texture: options.texture,
|
||||
pbr: options.pbr,
|
||||
model_seed: None,
|
||||
image_seed: None,
|
||||
texture_seed: None,
|
||||
texture_quality: options.texture_quality.map(to_text_texture_quality),
|
||||
geometry_quality: options.geometry_quality.map(to_text_geometry_quality),
|
||||
auto_size: None,
|
||||
quad: options.quad,
|
||||
};
|
||||
Ok(FalTripo3dH3p1State::Text(FalTripo3dH3p1TextRequestState { request }))
|
||||
}
|
||||
}
|
||||
|
||||
/// Options shared by all three Tripo3D H3.1 endpoints, resolved from the
|
||||
/// builder once and mapped onto each endpoint's field types per-mode.
|
||||
struct Tripo3dH3p1Options {
|
||||
face_limit: Option<u32>,
|
||||
texture: Option<bool>,
|
||||
pbr: Option<bool>,
|
||||
texture_quality: Option<CommonMeshQuality>,
|
||||
geometry_quality: Option<CommonMeshQuality>,
|
||||
quad: Option<bool>,
|
||||
}
|
||||
|
||||
fn plan_tripo3d_options(
|
||||
builder: &mut GenerateMeshRequestBuilder,
|
||||
) -> Result<Tripo3dH3p1Options, ArtcraftRouterError> {
|
||||
let strategy = builder.request_mismatch_mitigation_strategy;
|
||||
|
||||
// Tripo3D has no low-poly output mode; `Normal` and `Geometry` map onto the
|
||||
// texture flags below.
|
||||
if matches!(builder.mesh_output_type, Some(CommonMeshOutputType::LowPoly)) {
|
||||
reject_unsupported_option("mesh_output_type", builder.mesh_output_type.as_ref(), strategy)?;
|
||||
}
|
||||
|
||||
// A geometry-only output (or explicitly disabled texturing) maps to the
|
||||
// untextured tier — unless PBR is explicitly requested, since PBR implies
|
||||
// texturing. Both flags default to true on fal's side and either one alone
|
||||
// re-enables texturing, so both must be explicitly disabled together to
|
||||
// reach the untextured billing tier.
|
||||
let wants_untextured = matches!(builder.mesh_output_type, Some(CommonMeshOutputType::Geometry))
|
||||
|| builder.enable_texture == Some(false);
|
||||
let texture_off = wants_untextured && builder.enable_pbr != Some(true);
|
||||
let (texture, pbr) = if texture_off {
|
||||
(Some(false), Some(false))
|
||||
} else {
|
||||
(builder.enable_texture, builder.enable_pbr)
|
||||
};
|
||||
|
||||
let quad = builder.polygon_type.take().map(|polygon_type| match polygon_type {
|
||||
CommonPolygonType::Quad => true,
|
||||
CommonPolygonType::Triangle => false,
|
||||
});
|
||||
|
||||
Ok(Tripo3dH3p1Options {
|
||||
face_limit: plan_face_count(builder.face_count, strategy)?,
|
||||
texture,
|
||||
pbr,
|
||||
texture_quality: builder.texture_quality.take(),
|
||||
geometry_quality: builder.geometry_quality.take(),
|
||||
quad,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Enum mapping helpers ──
|
||||
|
||||
fn to_text_texture_quality(quality: CommonMeshQuality) -> Tripo3dH3p1TextureQuality {
|
||||
match quality {
|
||||
CommonMeshQuality::Standard => Tripo3dH3p1TextureQuality::Standard,
|
||||
CommonMeshQuality::Detailed => Tripo3dH3p1TextureQuality::Detailed,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_text_geometry_quality(quality: CommonMeshQuality) -> Tripo3dH3p1GeometryQuality {
|
||||
match quality {
|
||||
CommonMeshQuality::Standard => Tripo3dH3p1GeometryQuality::Standard,
|
||||
CommonMeshQuality::Detailed => Tripo3dH3p1GeometryQuality::Detailed,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_image_texture_quality(quality: CommonMeshQuality) -> Tripo3dH3p1ImageTextureQuality {
|
||||
match quality {
|
||||
CommonMeshQuality::Standard => Tripo3dH3p1ImageTextureQuality::Standard,
|
||||
CommonMeshQuality::Detailed => Tripo3dH3p1ImageTextureQuality::Detailed,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_image_geometry_quality(quality: CommonMeshQuality) -> Tripo3dH3p1ImageGeometryQuality {
|
||||
match quality {
|
||||
CommonMeshQuality::Standard => Tripo3dH3p1ImageGeometryQuality::Standard,
|
||||
CommonMeshQuality::Detailed => Tripo3dH3p1ImageGeometryQuality::Detailed,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::mesh_ref::MeshRef;
|
||||
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 LEFT_URL: &str = "https://example.com/left.png";
|
||||
const BACK_URL: &str = "https://example.com/back.png";
|
||||
const RIGHT_URL: &str = "https://example.com/right.png";
|
||||
|
||||
mod shape_dispatch {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reference_image_dispatches_to_image_mode() {
|
||||
let state = build_fal_tripo3d_h3p1_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 image = expect_image(build_fal_tripo3d_h3p1_state(builder).expect("build"));
|
||||
assert_eq!(image.request.image_url, FRONT_URL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_only_dispatches_to_text_mode() {
|
||||
let state = build_fal_tripo3d_h3p1_state(text_builder()).expect("build");
|
||||
let text = expect_text(state);
|
||||
assert_eq!(text.request.prompt, "a red ceramic teapot");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn side_image_dispatches_to_multiview_mode() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
back_image: Some(ImageRef::Url(BACK_URL.to_string())),
|
||||
..image_builder()
|
||||
};
|
||||
let multiview = expect_multiview(build_fal_tripo3d_h3p1_state(builder).expect("build"));
|
||||
assert_eq!(multiview.request.image_urls, vec![FRONT_URL, BACK_URL]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn side_images_without_front_are_rejected() {
|
||||
// A hard error even under the (default) lenient strategy: the endpoint
|
||||
// cannot anchor the view set without a front image.
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
back_image: Some(ImageRef::Url(BACK_URL.to_string())),
|
||||
..base_builder()
|
||||
};
|
||||
assert!(matches!(
|
||||
build_fal_tripo3d_h3p1_state(builder),
|
||||
Err(ArtcraftRouterError::InvalidInput(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neither_image_nor_prompt_is_rejected() {
|
||||
let result = build_fal_tripo3d_h3p1_state(base_builder());
|
||||
assert!(matches!(result, Err(ArtcraftRouterError::InvalidInput(_))));
|
||||
}
|
||||
}
|
||||
|
||||
mod multiview_mode {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn urls_follow_canonical_view_order() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
left_image: Some(ImageRef::Url(LEFT_URL.to_string())),
|
||||
back_image: Some(ImageRef::Url(BACK_URL.to_string())),
|
||||
right_image: Some(ImageRef::Url(RIGHT_URL.to_string())),
|
||||
..image_builder()
|
||||
};
|
||||
let multiview = expect_multiview(build_fal_tripo3d_h3p1_state(builder).expect("build"));
|
||||
assert_eq!(
|
||||
multiview.request.image_urls,
|
||||
vec![FRONT_URL, LEFT_URL, BACK_URL, RIGHT_URL],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_views_are_omitted() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
back_image: Some(ImageRef::Url(BACK_URL.to_string())),
|
||||
right_image: Some(ImageRef::Url(RIGHT_URL.to_string())),
|
||||
..image_builder()
|
||||
};
|
||||
let multiview = expect_multiview(build_fal_tripo3d_h3p1_state(builder).expect("build"));
|
||||
assert_eq!(multiview.request.image_urls, vec![FRONT_URL, BACK_URL, RIGHT_URL]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_map_through() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
face_count: Some(100_000),
|
||||
polygon_type: Some(CommonPolygonType::Quad),
|
||||
texture_quality: Some(CommonMeshQuality::Detailed),
|
||||
geometry_quality: Some(CommonMeshQuality::Detailed),
|
||||
..multiview_builder()
|
||||
};
|
||||
let multiview = expect_multiview(build_fal_tripo3d_h3p1_state(builder).expect("build"));
|
||||
assert_eq!(multiview.request.face_limit, Some(100_000));
|
||||
assert_eq!(multiview.request.quad, Some(true));
|
||||
assert_eq!(multiview.request.texture_quality, Some(Tripo3dH3p1ImageTextureQuality::Detailed));
|
||||
assert_eq!(multiview.request.geometry_quality, Some(Tripo3dH3p1ImageGeometryQuality::Detailed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_with_multiview_errors_out_under_error_out() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
prompt: Some("a red ceramic teapot".to_string()),
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
|
||||
..multiview_builder()
|
||||
};
|
||||
assert!(build_fal_tripo3d_h3p1_state(builder).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_with_multiview_is_dropped_under_lenient_strategies() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
prompt: Some("a red ceramic teapot".to_string()),
|
||||
..multiview_builder()
|
||||
};
|
||||
assert!(matches!(
|
||||
build_fal_tripo3d_h3p1_state(builder).expect("build"),
|
||||
FalTripo3dH3p1State::Multiview(_)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
mod image_mode {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn options_map_through() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
face_count: Some(50_000),
|
||||
polygon_type: Some(CommonPolygonType::Quad),
|
||||
texture_quality: Some(CommonMeshQuality::Detailed),
|
||||
geometry_quality: Some(CommonMeshQuality::Standard),
|
||||
..image_builder()
|
||||
};
|
||||
let image = expect_image(build_fal_tripo3d_h3p1_state(builder).expect("build"));
|
||||
assert_eq!(image.request.face_limit, Some(50_000));
|
||||
assert_eq!(image.request.quad, Some(true));
|
||||
assert_eq!(image.request.texture_quality, Some(Tripo3dH3p1ImageTextureQuality::Detailed));
|
||||
assert_eq!(image.request.geometry_quality, Some(Tripo3dH3p1ImageGeometryQuality::Standard));
|
||||
}
|
||||
|
||||
#[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_tripo3d_h3p1_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_tripo3d_h3p1_state(builder).expect("build"),
|
||||
FalTripo3dH3p1State::Image(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_tokens_are_rejected() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
reference_images: Some(ImageListRef::MediaFileTokens(vec![
|
||||
MediaFileToken::new("mf_test123".to_string()),
|
||||
])),
|
||||
..base_builder()
|
||||
};
|
||||
assert!(matches!(
|
||||
build_fal_tripo3d_h3p1_state(builder),
|
||||
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
mod text_mode {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn options_map_through() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
face_count: Some(40_000),
|
||||
polygon_type: Some(CommonPolygonType::Triangle),
|
||||
texture_quality: Some(CommonMeshQuality::Detailed),
|
||||
geometry_quality: Some(CommonMeshQuality::Detailed),
|
||||
..text_builder()
|
||||
};
|
||||
let text = expect_text(build_fal_tripo3d_h3p1_state(builder).expect("build"));
|
||||
assert_eq!(text.request.face_limit, Some(40_000));
|
||||
assert_eq!(text.request.quad, Some(false));
|
||||
assert_eq!(text.request.texture_quality, Some(Tripo3dH3p1TextureQuality::Detailed));
|
||||
assert_eq!(text.request.geometry_quality, Some(Tripo3dH3p1GeometryQuality::Detailed));
|
||||
}
|
||||
}
|
||||
|
||||
mod option_mapping {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn geometry_output_disables_texture_and_pbr() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
mesh_output_type: Some(CommonMeshOutputType::Geometry),
|
||||
..image_builder()
|
||||
};
|
||||
let image = expect_image(build_fal_tripo3d_h3p1_state(builder).expect("build"));
|
||||
assert_eq!(image.request.texture, Some(false));
|
||||
assert_eq!(image.request.pbr, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enable_texture_false_disables_texture_and_pbr() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
enable_texture: Some(false),
|
||||
..text_builder()
|
||||
};
|
||||
let text = expect_text(build_fal_tripo3d_h3p1_state(builder).expect("build"));
|
||||
assert_eq!(text.request.texture, Some(false));
|
||||
assert_eq!(text.request.pbr, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enable_texture_false_with_pbr_true_keeps_textures() {
|
||||
// PBR implies texturing, so an explicit PBR request wins.
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
enable_texture: Some(false),
|
||||
enable_pbr: Some(true),
|
||||
..image_builder()
|
||||
};
|
||||
let image = expect_image(build_fal_tripo3d_h3p1_state(builder).expect("build"));
|
||||
assert_eq!(image.request.texture, Some(false));
|
||||
assert_eq!(image.request.pbr, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn texture_flags_pass_through_by_default() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
enable_texture: Some(true),
|
||||
enable_pbr: Some(true),
|
||||
..image_builder()
|
||||
};
|
||||
let image = expect_image(build_fal_tripo3d_h3p1_state(builder).expect("build"));
|
||||
assert_eq!(image.request.texture, Some(true));
|
||||
assert_eq!(image.request.pbr, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unset_texture_flags_stay_unset() {
|
||||
let image = expect_image(build_fal_tripo3d_h3p1_state(image_builder()).expect("build"));
|
||||
assert_eq!(image.request.texture, None);
|
||||
assert_eq!(image.request.pbr, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polygon_type_maps_to_quad_flag() {
|
||||
let cases = [
|
||||
(Some(CommonPolygonType::Quad), Some(true)),
|
||||
(Some(CommonPolygonType::Triangle), Some(false)),
|
||||
(None, None),
|
||||
];
|
||||
for (polygon_type, expected) in cases {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
polygon_type,
|
||||
..image_builder()
|
||||
};
|
||||
let image = expect_image(build_fal_tripo3d_h3p1_state(builder).expect("build"));
|
||||
assert_eq!(image.request.quad, expected, "for {polygon_type:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn low_poly_errors_out_under_error_out() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
mesh_output_type: Some(CommonMeshOutputType::LowPoly),
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
|
||||
..image_builder()
|
||||
};
|
||||
assert!(matches!(
|
||||
build_fal_tripo3d_h3p1_state(builder),
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { .. }))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn low_poly_is_dropped_under_lenient_strategies() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
mesh_output_type: Some(CommonMeshOutputType::LowPoly),
|
||||
..image_builder()
|
||||
};
|
||||
let image = expect_image(build_fal_tripo3d_h3p1_state(builder).expect("build"));
|
||||
// Dropped: LowPoly doesn't force the untextured flags either.
|
||||
assert_eq!(image.request.texture, None);
|
||||
assert_eq!(image.request.pbr, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_mesh_errors_out_under_error_out() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
input_mesh: Some(MeshRef::Url("https://example.com/mesh.glb".to_string())),
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
|
||||
..image_builder()
|
||||
};
|
||||
assert!(matches!(
|
||||
build_fal_tripo3d_h3p1_state(builder),
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { .. }))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_mesh_is_dropped_under_lenient_strategies() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
input_mesh: Some(MeshRef::Url("https://example.com/mesh.glb".to_string())),
|
||||
..image_builder()
|
||||
};
|
||||
assert!(matches!(
|
||||
build_fal_tripo3d_h3p1_state(builder).expect("build"),
|
||||
FalTripo3dH3p1State::Image(_)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
fn base_builder() -> GenerateMeshRequestBuilder {
|
||||
GenerateMeshRequestBuilder {
|
||||
model: RouterMeshModel::Tripo3dH3p1,
|
||||
provider: RouterProvider::Fal,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn image_builder() -> GenerateMeshRequestBuilder {
|
||||
GenerateMeshRequestBuilder {
|
||||
reference_images: Some(ImageListRef::Urls(vec![FRONT_URL.to_string()])),
|
||||
..base_builder()
|
||||
}
|
||||
}
|
||||
|
||||
fn multiview_builder() -> GenerateMeshRequestBuilder {
|
||||
GenerateMeshRequestBuilder {
|
||||
left_image: Some(ImageRef::Url(LEFT_URL.to_string())),
|
||||
..image_builder()
|
||||
}
|
||||
}
|
||||
|
||||
fn text_builder() -> GenerateMeshRequestBuilder {
|
||||
GenerateMeshRequestBuilder {
|
||||
prompt: Some("a red ceramic teapot".to_string()),
|
||||
..base_builder()
|
||||
}
|
||||
}
|
||||
|
||||
fn expect_multiview(state: FalTripo3dH3p1State) -> FalTripo3dH3p1MultiviewRequestState {
|
||||
match state {
|
||||
FalTripo3dH3p1State::Multiview(multiview) => multiview,
|
||||
other => panic!("expected multiview mode, got: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn expect_image(state: FalTripo3dH3p1State) -> FalTripo3dH3p1ImageRequestState {
|
||||
match state {
|
||||
FalTripo3dH3p1State::Image(image) => image,
|
||||
other => panic!("expected image mode, got: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn expect_text(state: FalTripo3dH3p1State) -> FalTripo3dH3p1TextRequestState {
|
||||
match state {
|
||||
FalTripo3dH3p1State::Text(text) => text,
|
||||
other => panic!("expected text mode, got: {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
+263
@@ -0,0 +1,263 @@
|
||||
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::tripo3d_h3p1::request::{
|
||||
FalTripo3dH3p1ImageRequestState, FalTripo3dH3p1MultiviewRequestState,
|
||||
FalTripo3dH3p1TextRequestState,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FalTripo3dH3p1TextCostState {
|
||||
pub cost_in_usd_cents: u64,
|
||||
}
|
||||
|
||||
impl FalTripo3dH3p1TextCostState {
|
||||
pub fn from_request(request: &FalTripo3dH3p1TextRequestState) -> 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 FalTripo3dH3p1ImageCostState {
|
||||
pub cost_in_usd_cents: u64,
|
||||
}
|
||||
|
||||
impl FalTripo3dH3p1ImageCostState {
|
||||
pub fn from_request(request: &FalTripo3dH3p1ImageRequestState) -> 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)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FalTripo3dH3p1MultiviewCostState {
|
||||
pub cost_in_usd_cents: u64,
|
||||
}
|
||||
|
||||
impl FalTripo3dH3p1MultiviewCostState {
|
||||
pub fn from_request(request: &FalTripo3dH3p1MultiviewRequestState) -> 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)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use enums::common::generation::common_mesh_output_type::CommonMeshOutputType;
|
||||
use enums::common::generation::common_mesh_quality::CommonMeshQuality;
|
||||
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::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder;
|
||||
|
||||
const FRONT_URL: &str = "https://example.com/front.png";
|
||||
const BACK_URL: &str = "https://example.com/back.png";
|
||||
|
||||
mod text_mode_costs {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_is_standard_textures() {
|
||||
assert_eq!(estimate_usd_cents(text_builder()), 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn untextured_via_enable_texture_off() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
enable_texture: Some(false),
|
||||
..text_builder()
|
||||
};
|
||||
assert_eq!(estimate_usd_cents(builder), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn untextured_via_geometry_output() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
mesh_output_type: Some(CommonMeshOutputType::Geometry),
|
||||
..text_builder()
|
||||
};
|
||||
assert_eq!(estimate_usd_cents(builder), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn texture_off_with_pbr_keeps_textured_tier() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
enable_texture: Some(false),
|
||||
enable_pbr: Some(true),
|
||||
..text_builder()
|
||||
};
|
||||
assert_eq!(estimate_usd_cents(builder), 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detailed_texture() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
texture_quality: Some(CommonMeshQuality::Detailed),
|
||||
..text_builder()
|
||||
};
|
||||
assert_eq!(estimate_usd_cents(builder), 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_ons_stack() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
texture_quality: Some(CommonMeshQuality::Detailed),
|
||||
geometry_quality: Some(CommonMeshQuality::Detailed),
|
||||
polygon_type: Some(CommonPolygonType::Quad),
|
||||
..text_builder()
|
||||
};
|
||||
// Detailed texture(30) + detailed geometry(20) + quad(5)
|
||||
assert_eq!(estimate_usd_cents(builder), 55);
|
||||
}
|
||||
}
|
||||
|
||||
mod image_mode_costs {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_is_standard_textures() {
|
||||
assert_eq!(estimate_usd_cents(image_builder()), 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn untextured_via_enable_texture_off() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
enable_texture: Some(false),
|
||||
..image_builder()
|
||||
};
|
||||
assert_eq!(estimate_usd_cents(builder), 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detailed_texture() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
texture_quality: Some(CommonMeshQuality::Detailed),
|
||||
..image_builder()
|
||||
};
|
||||
assert_eq!(estimate_usd_cents(builder), 40);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_ons_stack() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
texture_quality: Some(CommonMeshQuality::Detailed),
|
||||
geometry_quality: Some(CommonMeshQuality::Detailed),
|
||||
polygon_type: Some(CommonPolygonType::Quad),
|
||||
..image_builder()
|
||||
};
|
||||
// Detailed texture(40) + detailed geometry(20) + quad(5)
|
||||
assert_eq!(estimate_usd_cents(builder), 65);
|
||||
}
|
||||
}
|
||||
|
||||
mod multiview_mode_costs {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_is_standard_textures() {
|
||||
assert_eq!(estimate_usd_cents(multiview_builder()), 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn untextured_via_geometry_output() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
mesh_output_type: Some(CommonMeshOutputType::Geometry),
|
||||
..multiview_builder()
|
||||
};
|
||||
assert_eq!(estimate_usd_cents(builder), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detailed_texture() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
texture_quality: Some(CommonMeshQuality::Detailed),
|
||||
..multiview_builder()
|
||||
};
|
||||
assert_eq!(estimate_usd_cents(builder), 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn triangle_polygon_adds_nothing() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
polygon_type: Some(CommonPolygonType::Triangle),
|
||||
..multiview_builder()
|
||||
};
|
||||
assert_eq!(estimate_usd_cents(builder), 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_ons_stack() {
|
||||
let builder = GenerateMeshRequestBuilder {
|
||||
texture_quality: Some(CommonMeshQuality::Detailed),
|
||||
geometry_quality: Some(CommonMeshQuality::Detailed),
|
||||
polygon_type: Some(CommonPolygonType::Quad),
|
||||
..multiview_builder()
|
||||
};
|
||||
// Detailed texture(30) + detailed geometry(20) + quad(5)
|
||||
assert_eq!(estimate_usd_cents(builder), 55);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
fn base_builder() -> GenerateMeshRequestBuilder {
|
||||
GenerateMeshRequestBuilder {
|
||||
model: RouterMeshModel::Tripo3dH3p1,
|
||||
provider: RouterProvider::Fal,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn text_builder() -> GenerateMeshRequestBuilder {
|
||||
GenerateMeshRequestBuilder {
|
||||
prompt: Some("a red ceramic teapot".to_string()),
|
||||
..base_builder()
|
||||
}
|
||||
}
|
||||
|
||||
fn image_builder() -> GenerateMeshRequestBuilder {
|
||||
GenerateMeshRequestBuilder {
|
||||
reference_images: Some(ImageListRef::Urls(vec![FRONT_URL.to_string()])),
|
||||
..base_builder()
|
||||
}
|
||||
}
|
||||
|
||||
fn multiview_builder() -> GenerateMeshRequestBuilder {
|
||||
GenerateMeshRequestBuilder {
|
||||
back_image: Some(ImageRef::Url(BACK_URL.to_string())),
|
||||
..image_builder()
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod build;
|
||||
pub mod cost;
|
||||
pub mod request;
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
use fal_client::requests::api::mesh::image::tripo3d_h3p1_image_to_mesh::api::Tripo3dH3p1ImageToMeshRequest;
|
||||
use fal_client::requests::api::mesh::multiview::tripo3d_h3p1_multiview_to_mesh::api::Tripo3dH3p1MultiviewToMeshRequest;
|
||||
use fal_client::requests::api::mesh::text::tripo3d_h3p1_text_to_mesh::api::Tripo3dH3p1TextToMeshRequest;
|
||||
|
||||
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 FalTripo3dH3p1TextRequestState {
|
||||
/// Final materialized request; ready to fire.
|
||||
pub request: Tripo3dH3p1TextToMeshRequest,
|
||||
}
|
||||
|
||||
impl FalTripo3dH3p1TextRequestState {
|
||||
pub async fn send(&self, client: &RouterFalClient) -> Result<GenerateMeshResponse, ArtcraftRouterError> {
|
||||
send_fal_mesh_request(&self.request, client).await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FalTripo3dH3p1ImageRequestState {
|
||||
/// Final materialized request; ready to fire.
|
||||
pub request: Tripo3dH3p1ImageToMeshRequest,
|
||||
}
|
||||
|
||||
impl FalTripo3dH3p1ImageRequestState {
|
||||
pub async fn send(&self, client: &RouterFalClient) -> Result<GenerateMeshResponse, ArtcraftRouterError> {
|
||||
send_fal_mesh_request(&self.request, client).await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FalTripo3dH3p1MultiviewRequestState {
|
||||
/// Final materialized request; ready to fire.
|
||||
pub request: Tripo3dH3p1MultiviewToMeshRequest,
|
||||
}
|
||||
|
||||
impl FalTripo3dH3p1MultiviewRequestState {
|
||||
pub async fn send(&self, client: &RouterFalClient) -> Result<GenerateMeshResponse, ArtcraftRouterError> {
|
||||
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, JUNO_AT_LAKE_IMAGE_URL};
|
||||
|
||||
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::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_tripo3d_h3p1_text_to_mesh() {
|
||||
let response = run_pipeline(GenerateMeshRequestBuilder {
|
||||
prompt: Some("A velociraptor with an open mouth full of sharp teeth.".to_string()),
|
||||
..tripo3d_h3p1_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_tripo3d_h3p1_image_to_mesh() {
|
||||
let response = run_pipeline(GenerateMeshRequestBuilder {
|
||||
reference_images: Some(ImageListRef::Urls(vec![ERNEST_SCARED_STUPID_IMAGE_URL.to_string()])),
|
||||
..tripo3d_h3p1_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_tripo3d_h3p1_multiview_to_mesh() {
|
||||
let response = run_pipeline(GenerateMeshRequestBuilder {
|
||||
reference_images: Some(ImageListRef::Urls(vec![ERNEST_SCARED_STUPID_IMAGE_URL.to_string()])),
|
||||
back_image: Some(ImageRef::Url(JUNO_AT_LAKE_IMAGE_URL.to_string())),
|
||||
..tripo3d_h3p1_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 tripo3d_h3p1_builder() -> GenerateMeshRequestBuilder {
|
||||
GenerateMeshRequestBuilder {
|
||||
model: RouterMeshModel::Tripo3dH3p1,
|
||||
provider: RouterProvider::Fal,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
+13
-18
@@ -1,7 +1,6 @@
|
||||
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
|
||||
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::client_error::ClientError;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::providers::artcraft::build_common::{
|
||||
build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport,
|
||||
@@ -27,17 +26,10 @@ pub fn build_artcraft_grok_imagine_video_1p5(
|
||||
UltraWideSupport::Unsupported,
|
||||
)?;
|
||||
|
||||
// xAI's v1.5 model rejects text-to-video at the server. Bounce here rather
|
||||
// than spending an upstream call to learn the same thing.
|
||||
if request.start_frame_image_media_token.is_none()
|
||||
&& request.reference_image_media_tokens.as_ref().map_or(true, |v| v.is_empty())
|
||||
{
|
||||
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "image_inputs",
|
||||
value: "text-to-video isn't supported by grok-imagine-video-1.5; supply a start_frame or at least one reference image".to_string(),
|
||||
}));
|
||||
}
|
||||
|
||||
// NB: xAI's v1.5 model rejects text-to-video at the server, but that is
|
||||
// NOT enforced here — the cost path builds image-less requests to price
|
||||
// them while the user is still composing. The generate endpoints reject
|
||||
// image-less v1.5 requests at the handler layer (validate_when_image_required).
|
||||
let state = ArtcraftGrokImagineVideo1p5RequestState { request };
|
||||
|
||||
Ok(VideoGenerationDraftOrRequest::Request(
|
||||
@@ -129,6 +121,15 @@ mod tests {
|
||||
mod media_token_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn builds_without_any_image_inputs() {
|
||||
// The cost path prices image-less requests while the user is still
|
||||
// composing; the generate endpoints enforce the image requirement.
|
||||
let req = unwrap_request(make_builder(|_| {}));
|
||||
assert!(req.request.start_frame_image_media_token.is_none());
|
||||
assert!(req.request.reference_image_media_tokens.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_frame_token_passed_through() {
|
||||
let token = MediaFileToken::new("mf_start".to_string());
|
||||
@@ -180,12 +181,6 @@ mod tests {
|
||||
fn make_builder(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder {
|
||||
let mut builder = base_builder();
|
||||
f(&mut builder);
|
||||
// v1.5 requires an input image; tests that don't explicitly exercise the
|
||||
// image-handling paths get a default MediaFileToken start_frame so they
|
||||
// sail past the no-image guard in `build()`.
|
||||
if builder.start_frame.is_none() && builder.reference_images.is_none() {
|
||||
builder.start_frame = Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_default".to_string())));
|
||||
}
|
||||
builder
|
||||
}
|
||||
|
||||
|
||||
+37
-24
@@ -9,7 +9,6 @@ use grok_api_client::api::types::video_types::video_model::VideoModel as GrokVid
|
||||
use grok_api_client::api::types::video_types::video_resolution::VideoResolution as GrokResolution;
|
||||
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::client_error::ClientError;
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
use crate::generate::generate_video::providers::artcraft::grok_imagine_video_1p5::request::ArtcraftGrokImagineVideo1p5RequestState;
|
||||
|
||||
@@ -65,16 +64,10 @@ impl ArtcraftGrokImagineVideo1p5CostState {
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> Result<VideoGenerationCostEstimate, ArtcraftRouterError> {
|
||||
// Defense in depth: `build()` already enforces this, but a state
|
||||
// constructed by hand must not get a quote for an operation xAI will
|
||||
// reject.
|
||||
if self.input_image_count == 0 {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "image_inputs",
|
||||
value: "text-to-video isn't supported by grok-imagine-video-1.5; supply a start_frame or at least one reference image".to_string(),
|
||||
}));
|
||||
}
|
||||
|
||||
// NB: Image-less requests DO get a quote even though xAI's v1.5 rejects
|
||||
// text-to-video — the cost path prices requests the user is still
|
||||
// composing. The image requirement is enforced at send time (request.rs)
|
||||
// and by the generate endpoints.
|
||||
let base = self.base_cost_in_cents_for_batch();
|
||||
// 5% markup, ceil so the user is always charged enough to cover the
|
||||
// upstream grok cost regardless of rounding.
|
||||
@@ -204,24 +197,23 @@ mod tests {
|
||||
assert_eq!(state.estimate_cost().expect("estimate_cost").cost_in_usd_cents.unwrap(), 150);
|
||||
}
|
||||
|
||||
/// v1.5 doesn't support text-to-video. The cost calculator must refuse
|
||||
/// to quote an image-less request even if a state is constructed
|
||||
/// directly.
|
||||
/// v1.5 doesn't support text-to-video at generation time, but the cost
|
||||
/// calculator still quotes an image-less request — the cost UI prices
|
||||
/// requests before the user has attached an image. The requirement is
|
||||
/// enforced at send time and by the generate endpoints.
|
||||
#[test]
|
||||
fn estimate_cost_rejects_text_only_request() {
|
||||
fn estimate_cost_quotes_text_only_request() {
|
||||
let state = ArtcraftGrokImagineVideo1p5CostState {
|
||||
resolution: CommonResolution::SevenTwentyP,
|
||||
duration_seconds: 5,
|
||||
batch_count: 1,
|
||||
input_image_count: 0,
|
||||
};
|
||||
let err = state.estimate_cost().expect_err("text-only should be rejected");
|
||||
match err {
|
||||
ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { field, .. }) => {
|
||||
assert_eq!(field, "image_inputs");
|
||||
}
|
||||
other => panic!("expected Client(ModelDoesNotSupportOption), got {:?}", other),
|
||||
}
|
||||
let base = state.base_cost_in_cents_for_batch();
|
||||
let want = base.saturating_mul(MARKUP_NUMERATOR).div_ceil(MARKUP_DENOMINATOR);
|
||||
let got = state.estimate_cost().expect("estimate_cost").cost_in_usd_cents.unwrap();
|
||||
assert_eq!(got, want);
|
||||
assert!(got > 0);
|
||||
}
|
||||
|
||||
// (resolution, duration, image_count, batch) — every case has at least
|
||||
@@ -326,6 +318,28 @@ mod tests {
|
||||
.cost_in_usd_cents.unwrap();
|
||||
assert!(p1080 > p720, "1080p ({p1080}) should cost more than 720p ({p720})");
|
||||
}
|
||||
|
||||
/// Regression: an image-less v1.5 request must get a quote through the
|
||||
/// full public builder path (build2 → estimate_cost). The cost UI polls
|
||||
/// for a price before the user has attached an image; the image
|
||||
/// requirement is enforced at send time and by the generate endpoints,
|
||||
/// NOT here.
|
||||
#[test]
|
||||
fn image_less_request_gets_a_quote_through_the_builder() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::GrokImagineVideo1p5,
|
||||
provider: RouterProvider::Artcraft,
|
||||
duration_seconds: Some(5),
|
||||
video_batch_count: Some(1),
|
||||
..Default::default()
|
||||
};
|
||||
let cost = builder.build2()
|
||||
.expect("build2 should succeed without an image")
|
||||
.estimate_cost()
|
||||
.expect("estimate_cost should succeed without an image");
|
||||
assert!(cost.cost_in_usd_cents.unwrap() > 0);
|
||||
assert_eq!(cost.cost_in_credits, cost.cost_in_usd_cents);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────
|
||||
@@ -390,8 +404,7 @@ mod tests {
|
||||
resolution,
|
||||
duration_seconds: Some(duration_seconds),
|
||||
video_batch_count: Some(video_batch_count),
|
||||
// v1.5 requires an input image — the no-image guard in build()
|
||||
// rejects T2V.
|
||||
// These cases price the image-to-video tier (one input image).
|
||||
start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_default".to_string()))),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
+57
-2
@@ -13,8 +13,11 @@ pub struct ArtcraftGrokImagineVideo1p5RequestState {
|
||||
|
||||
impl ArtcraftGrokImagineVideo1p5RequestState {
|
||||
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
|
||||
// Defense in depth: `build()` already enforces this. Bouncing here costs
|
||||
// nothing and avoids an upstream call we know will fail.
|
||||
// xAI's v1.5 model rejects text-to-video at the server. This is the
|
||||
// GENERATION-time guard — `build()` and `estimate_cost()` deliberately
|
||||
// allow image-less states so the cost path can quote a request the user
|
||||
// is still composing. Bouncing here costs nothing and avoids an upstream
|
||||
// call we know will fail.
|
||||
if self.request.start_frame_image_media_token.is_none()
|
||||
&& self.request.reference_image_media_tokens.as_ref().map_or(true, |v| v.is_empty())
|
||||
{
|
||||
@@ -27,3 +30,55 @@ impl ArtcraftGrokImagineVideo1p5RequestState {
|
||||
send_artcraft_omni_video_request(&self.request, client).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
|
||||
use artcraft_client::utils::api_host::ApiHost;
|
||||
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Generation must never reach xAI with an image-less v1.5 request. The
|
||||
/// build/cost layers allow that state (the cost path quotes it), so the
|
||||
/// send-time guard is the router's last line of defense.
|
||||
#[tokio::test]
|
||||
async fn send_rejects_image_less_request_before_any_network_call() {
|
||||
let state = ArtcraftGrokImagineVideo1p5RequestState {
|
||||
request: OmniGenVideoCostAndGenerateRequest {
|
||||
idempotency_token: Some("a1b2c3d4-e5f6-7890-abcd-ef1234567890".to_string()),
|
||||
model: Some(CommonVideoModelEnum::GrokImagineVideo1p5),
|
||||
prompt: Some("a corgi running through a field".to_string()),
|
||||
negative_prompt: None,
|
||||
start_frame_image_media_token: None,
|
||||
end_frame_image_media_token: None,
|
||||
reference_image_media_tokens: None,
|
||||
reference_video_media_tokens: None,
|
||||
reference_audio_media_tokens: None,
|
||||
reference_character_tokens: None,
|
||||
resolution: None,
|
||||
aspect_ratio: None,
|
||||
bitrate: None,
|
||||
quality: None,
|
||||
duration_seconds: Some(5),
|
||||
video_batch_count: Some(1),
|
||||
generate_audio: None,
|
||||
},
|
||||
};
|
||||
|
||||
// The guard fires before the client is used, so an unroutable localhost
|
||||
// client with empty credentials proves no network call happens.
|
||||
let client = RouterArtcraftClient::new(
|
||||
ApiHost::Localhost { port: 1 },
|
||||
StorytellerCredentialSet::empty(),
|
||||
);
|
||||
|
||||
let err = state.send(&client).await.expect_err("image-less send should be rejected");
|
||||
match err {
|
||||
ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { field, .. }) => {
|
||||
assert_eq!(field, "image_inputs");
|
||||
}
|
||||
other => panic!("expected Client(ModelDoesNotSupportOption), got {:?}", other),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+226
@@ -0,0 +1,226 @@
|
||||
use crate::error::fal_error_plus::FalErrorPlus;
|
||||
use crate::requests::api::mesh::image::hunyuan_3d_3p1_pro_image_to_mesh::raw_request::{
|
||||
Hunyuan3d3p1ProImageToMeshInput, Hunyuan3d3p1ProImageToMeshOutput,
|
||||
};
|
||||
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
|
||||
|
||||
/// Hunyuan 3D v3.1 Pro image-to-3D.
|
||||
///
|
||||
/// v3.1 expands multi-view input to seven optional extra views (back, left,
|
||||
/// right, top, bottom, left-front 45°, right-front 45°) and drops the v3
|
||||
/// `LowPoly` generation type and `polygon_type` parameter.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Hunyuan3d3p1ProImageToMeshRequest {
|
||||
/// URL of the front-view input image (required).
|
||||
pub image_url: String,
|
||||
|
||||
/// Optional back-view image URL for multi-view generation.
|
||||
pub back_image_url: Option<String>,
|
||||
|
||||
/// Optional left-view image URL for multi-view generation.
|
||||
pub left_image_url: Option<String>,
|
||||
|
||||
/// Optional right-view image URL for multi-view generation.
|
||||
pub right_image_url: Option<String>,
|
||||
|
||||
/// Optional top-view image URL (v3.1 exclusive).
|
||||
pub top_image_url: Option<String>,
|
||||
|
||||
/// Optional bottom-view image URL (v3.1 exclusive).
|
||||
pub bottom_image_url: Option<String>,
|
||||
|
||||
/// Optional left-front 45° image URL (v3.1 exclusive).
|
||||
pub left_front_image_url: Option<String>,
|
||||
|
||||
/// Optional right-front 45° image URL (v3.1 exclusive).
|
||||
pub right_front_image_url: Option<String>,
|
||||
|
||||
/// Generation type. fal's default is `Normal` when `None`.
|
||||
pub generate_type: Option<Hunyuan3d3p1ProImageToMeshGenerateType>,
|
||||
|
||||
/// 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<u32>,
|
||||
|
||||
/// Whether to enable PBR (physically-based rendering) materials.
|
||||
/// fal's default is false when `None`. Enabling adds cost.
|
||||
pub enable_pbr: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub enum Hunyuan3d3p1ProImageToMeshGenerateType {
|
||||
Normal,
|
||||
Geometry,
|
||||
}
|
||||
|
||||
impl Hunyuan3d3p1ProImageToMeshRequest {
|
||||
/// True when any of the optional extra views is provided (billed as the
|
||||
/// multi-view add-on).
|
||||
pub fn uses_multi_view(&self) -> bool {
|
||||
self.back_image_url.is_some()
|
||||
|| self.left_image_url.is_some()
|
||||
|| self.right_image_url.is_some()
|
||||
|| self.top_image_url.is_some()
|
||||
|| self.bottom_image_url.is_some()
|
||||
|| self.left_front_image_url.is_some()
|
||||
|| self.right_front_image_url.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
impl FalEndpoint for Hunyuan3d3p1ProImageToMeshRequest {
|
||||
const ENDPOINT: &str = "fal-ai/hunyuan-3d/v3.1/pro/image-to-3d";
|
||||
|
||||
type RawRequest = Hunyuan3d3p1ProImageToMeshInput;
|
||||
type RawResponse = Hunyuan3d3p1ProImageToMeshOutput;
|
||||
|
||||
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
|
||||
let generate_type = self.generate_type.map(|t| match t {
|
||||
Hunyuan3d3p1ProImageToMeshGenerateType::Normal => "Normal",
|
||||
Hunyuan3d3p1ProImageToMeshGenerateType::Geometry => "Geometry",
|
||||
}.to_string());
|
||||
|
||||
Ok(Self::RawRequest {
|
||||
input_image_url: self.image_url.clone(),
|
||||
back_image_url: self.back_image_url.clone(),
|
||||
left_image_url: self.left_image_url.clone(),
|
||||
right_image_url: self.right_image_url.clone(),
|
||||
top_image_url: self.top_image_url.clone(),
|
||||
bottom_image_url: self.bottom_image_url.clone(),
|
||||
left_front_image_url: self.left_front_image_url.clone(),
|
||||
right_front_image_url: self.right_front_image_url.clone(),
|
||||
generate_type,
|
||||
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;
|
||||
|
||||
fn base_request() -> Hunyuan3d3p1ProImageToMeshRequest {
|
||||
Hunyuan3d3p1ProImageToMeshRequest {
|
||||
image_url: "https://example.com/front.jpg".to_string(),
|
||||
back_image_url: None,
|
||||
left_image_url: None,
|
||||
right_image_url: None,
|
||||
top_image_url: None,
|
||||
bottom_image_url: None,
|
||||
left_front_image_url: None,
|
||||
right_front_image_url: None,
|
||||
generate_type: None,
|
||||
face_count: None,
|
||||
enable_pbr: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[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 = Hunyuan3d3p1ProImageToMeshRequest {
|
||||
image_url: ERNEST_SCARED_STUPID_IMAGE_URL.to_string(),
|
||||
..base_request()
|
||||
};
|
||||
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 = Hunyuan3d3p1ProImageToMeshRequest {
|
||||
image_url: ERNEST_SCARED_STUPID_IMAGE_URL.to_string(),
|
||||
..base_request()
|
||||
};
|
||||
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 = Hunyuan3d3p1ProImageToMeshRequest {
|
||||
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()),
|
||||
top_image_url: Some("https://example.com/top.jpg".to_string()),
|
||||
bottom_image_url: Some("https://example.com/bottom.jpg".to_string()),
|
||||
left_front_image_url: Some("https://example.com/lf.jpg".to_string()),
|
||||
right_front_image_url: Some("https://example.com/rf.jpg".to_string()),
|
||||
generate_type: Some(Hunyuan3d3p1ProImageToMeshGenerateType::Geometry),
|
||||
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/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",
|
||||
"top_image_url": "https://example.com/top.jpg",
|
||||
"bottom_image_url": "https://example.com/bottom.jpg",
|
||||
"left_front_image_url": "https://example.com/lf.jpg",
|
||||
"right_front_image_url": "https://example.com/rf.jpg",
|
||||
"generate_type": "Geometry",
|
||||
"face_count": 100_000,
|
||||
"enable_pbr": true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_request_omits_unset_optionals() {
|
||||
let json = serde_json::to_value(base_request().to_raw_request().unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
json,
|
||||
serde_json::json!({ "input_image_url": "https://example.com/front.jpg" }),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uses_multi_view_detects_every_extra_view() {
|
||||
assert!(!base_request().uses_multi_view());
|
||||
let setters: Vec<fn(&mut Hunyuan3d3p1ProImageToMeshRequest)> = vec![
|
||||
|r| r.back_image_url = Some("u".to_string()),
|
||||
|r| r.left_image_url = Some("u".to_string()),
|
||||
|r| r.right_image_url = Some("u".to_string()),
|
||||
|r| r.top_image_url = Some("u".to_string()),
|
||||
|r| r.bottom_image_url = Some("u".to_string()),
|
||||
|r| r.left_front_image_url = Some("u".to_string()),
|
||||
|r| r.right_front_image_url = Some("u".to_string()),
|
||||
];
|
||||
for set in setters {
|
||||
let mut req = base_request();
|
||||
set(&mut req);
|
||||
assert!(req.uses_multi_view());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_path_is_canonical() {
|
||||
assert_eq!(
|
||||
Hunyuan3d3p1ProImageToMeshRequest::ENDPOINT,
|
||||
"fal-ai/hunyuan-3d/v3.1/pro/image-to-3d",
|
||||
);
|
||||
}
|
||||
|
||||
// NB: Pricing tests are in cost.rs
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
use crate::requests::api::mesh::image::hunyuan_3d_3p1_pro_image_to_mesh::api::Hunyuan3d3p1ProImageToMeshRequest;
|
||||
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
|
||||
|
||||
impl FalRequestCostCalculator for Hunyuan3d3p1ProImageToMeshRequest {
|
||||
fn calculate_cost_in_cents(&self) -> UsdCents {
|
||||
// Pricing (see https://fal.ai/models/fal-ai/hunyuan-3d/v3.1/pro/image-to-3d):
|
||||
// "Your request will cost $0.375 per generation. Enabling PBR materials
|
||||
// adds $0.15. Using multi-view images adds $0.15. Custom face count
|
||||
// adds $0.15."
|
||||
//
|
||||
// Base: $0.375 → 38¢ (rounded up). Unlike v3, the v3.1 pro pricing has no
|
||||
// per-generation-type price deltas — Geometry bills the same base.
|
||||
let mut cost: u64 = 38;
|
||||
if self.enable_pbr.unwrap_or(false) {
|
||||
cost += 15;
|
||||
}
|
||||
if self.face_count.is_some() {
|
||||
cost += 15;
|
||||
}
|
||||
if self.uses_multi_view() {
|
||||
cost += 15;
|
||||
}
|
||||
cost
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::requests::api::mesh::image::hunyuan_3d_3p1_pro_image_to_mesh::api::Hunyuan3d3p1ProImageToMeshGenerateType;
|
||||
|
||||
fn base_request() -> Hunyuan3d3p1ProImageToMeshRequest {
|
||||
Hunyuan3d3p1ProImageToMeshRequest {
|
||||
image_url: "https://example.com/front.jpg".to_string(),
|
||||
back_image_url: None,
|
||||
left_image_url: None,
|
||||
right_image_url: None,
|
||||
top_image_url: None,
|
||||
bottom_image_url: None,
|
||||
left_front_image_url: None,
|
||||
right_front_image_url: None,
|
||||
generate_type: None,
|
||||
face_count: None,
|
||||
enable_pbr: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base_cost() {
|
||||
assert_eq!(base_request().calculate_cost_in_cents(), 38);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_type_does_not_change_cost() {
|
||||
for t in [
|
||||
Hunyuan3d3p1ProImageToMeshGenerateType::Normal,
|
||||
Hunyuan3d3p1ProImageToMeshGenerateType::Geometry,
|
||||
] {
|
||||
let mut req = base_request();
|
||||
req.generate_type = Some(t);
|
||||
assert_eq!(req.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 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 any_extra_view_adds_fifteen_once() {
|
||||
// v3.1-exclusive views must also trigger the multi-view add-on.
|
||||
let mut req = base_request();
|
||||
req.top_image_url = Some("https://example.com/top.jpg".to_string());
|
||||
assert_eq!(req.calculate_cost_in_cents(), 38 + 15);
|
||||
|
||||
// Stacking more views doesn't add more.
|
||||
req.back_image_url = Some("https://example.com/back.jpg".to_string());
|
||||
req.left_front_image_url = Some("https://example.com/lf.jpg".to_string());
|
||||
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);
|
||||
req.right_front_image_url = Some("https://example.com/rf.jpg".to_string());
|
||||
// 38 + 15 + 15 + 15 = 83
|
||||
assert_eq!(req.calculate_cost_in_cents(), 83);
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod api;
|
||||
pub mod cost;
|
||||
pub mod raw_request;
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Over-the-wire input shape for `fal-ai/hunyuan-3d/v3.1/pro/image-to-3d`.
|
||||
/// fal's schema: <https://fal.ai/models/fal-ai/hunyuan-3d/v3.1/pro/image-to-3d/api>
|
||||
#[derive(Debug, Serialize, Deserialize, Default)]
|
||||
pub struct Hunyuan3d3p1ProImageToMeshInput {
|
||||
/// Front-view image (required). 128-5000px, max 8MB, JPG/PNG/WEBP.
|
||||
pub input_image_url: String,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub back_image_url: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub left_image_url: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub right_image_url: Option<String>,
|
||||
|
||||
/// Top view (v3.1 exclusive).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub top_image_url: Option<String>,
|
||||
|
||||
/// Bottom view (v3.1 exclusive).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bottom_image_url: Option<String>,
|
||||
|
||||
/// Left-front 45° view (v3.1 exclusive).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub left_front_image_url: Option<String>,
|
||||
|
||||
/// Right-front 45° view (v3.1 exclusive).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub right_front_image_url: Option<String>,
|
||||
|
||||
/// Options: "Normal", "Geometry" (no LowPoly on v3.1).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub generate_type: Option<String>,
|
||||
|
||||
/// Range 40000-1500000. fal default: 500000.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub face_count: Option<u32>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enable_pbr: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Hunyuan3d3p1ProImageToMeshOutput {}
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
use crate::error::fal_error_plus::FalErrorPlus;
|
||||
use crate::requests::api::mesh::image::hunyuan_3d_3p1_rapid_image_to_mesh::raw_request::{
|
||||
Hunyuan3d3p1RapidImageToMeshInput, Hunyuan3d3p1RapidImageToMeshOutput,
|
||||
};
|
||||
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
|
||||
|
||||
/// Hunyuan 3D v3.1 Rapid image-to-3D — the fast, low-cost tier.
|
||||
///
|
||||
/// NB: the rapid schema is minimal — single input image (no multi-view), no
|
||||
/// `generate_type`, `face_count`, or `polygon_type`. Geometry-only output is
|
||||
/// a boolean (`enable_geometry`).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Hunyuan3d3p1RapidImageToMeshRequest {
|
||||
/// URL of the input image (required).
|
||||
pub image_url: String,
|
||||
|
||||
/// Whether to enable PBR (physically-based rendering) materials.
|
||||
/// fal's default is false when `None`. Enabling adds cost.
|
||||
pub enable_pbr: Option<bool>,
|
||||
|
||||
/// Generate a geometry-only white model without textures.
|
||||
/// fal's default is false when `None`. Disables PBR output.
|
||||
pub enable_geometry: Option<bool>,
|
||||
}
|
||||
|
||||
impl FalEndpoint for Hunyuan3d3p1RapidImageToMeshRequest {
|
||||
const ENDPOINT: &str = "fal-ai/hunyuan-3d/v3.1/rapid/image-to-3d";
|
||||
|
||||
type RawRequest = Hunyuan3d3p1RapidImageToMeshInput;
|
||||
type RawResponse = Hunyuan3d3p1RapidImageToMeshOutput;
|
||||
|
||||
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
|
||||
Ok(Self::RawRequest {
|
||||
input_image_url: self.image_url.clone(),
|
||||
enable_pbr: self.enable_pbr,
|
||||
enable_geometry: self.enable_geometry,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[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;
|
||||
|
||||
#[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 = Hunyuan3d3p1RapidImageToMeshRequest {
|
||||
image_url: ERNEST_SCARED_STUPID_IMAGE_URL.to_string(),
|
||||
enable_pbr: None,
|
||||
enable_geometry: 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 = Hunyuan3d3p1RapidImageToMeshRequest {
|
||||
image_url: ERNEST_SCARED_STUPID_IMAGE_URL.to_string(),
|
||||
enable_pbr: None,
|
||||
enable_geometry: 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 = Hunyuan3d3p1RapidImageToMeshRequest {
|
||||
image_url: "https://example.com/front.jpg".to_string(),
|
||||
enable_pbr: Some(true),
|
||||
enable_geometry: 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",
|
||||
"enable_pbr": true,
|
||||
"enable_geometry": true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_request_omits_unset_optionals() {
|
||||
let request = Hunyuan3d3p1RapidImageToMeshRequest {
|
||||
image_url: "https://example.com/front.jpg".to_string(),
|
||||
enable_pbr: None,
|
||||
enable_geometry: 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 endpoint_path_is_canonical() {
|
||||
assert_eq!(
|
||||
Hunyuan3d3p1RapidImageToMeshRequest::ENDPOINT,
|
||||
"fal-ai/hunyuan-3d/v3.1/rapid/image-to-3d",
|
||||
);
|
||||
}
|
||||
|
||||
// NB: Pricing tests are in cost.rs
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
use crate::requests::api::mesh::image::hunyuan_3d_3p1_rapid_image_to_mesh::api::Hunyuan3d3p1RapidImageToMeshRequest;
|
||||
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
|
||||
|
||||
impl FalRequestCostCalculator for Hunyuan3d3p1RapidImageToMeshRequest {
|
||||
fn calculate_cost_in_cents(&self) -> UsdCents {
|
||||
// Pricing (see https://fal.ai/models/fal-ai/hunyuan-3d/v3.1/rapid/image-to-3d):
|
||||
// "Base generation (text or image to 3D) costs $0.225. Enabling PBR
|
||||
// materials adds $0.15."
|
||||
//
|
||||
// Base: $0.225 → 23¢ (rounded up).
|
||||
let mut cost: u64 = 23;
|
||||
if self.enable_pbr.unwrap_or(false) {
|
||||
cost += 15;
|
||||
}
|
||||
cost
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn base_request() -> Hunyuan3d3p1RapidImageToMeshRequest {
|
||||
Hunyuan3d3p1RapidImageToMeshRequest {
|
||||
image_url: "https://example.com/front.jpg".to_string(),
|
||||
enable_pbr: None,
|
||||
enable_geometry: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base_cost() {
|
||||
assert_eq!(base_request().calculate_cost_in_cents(), 23);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pbr_adds_fifteen() {
|
||||
let mut req = base_request();
|
||||
req.enable_pbr = Some(true);
|
||||
assert_eq!(req.calculate_cost_in_cents(), 23 + 15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn geometry_does_not_change_cost() {
|
||||
let mut req = base_request();
|
||||
req.enable_geometry = Some(true);
|
||||
assert_eq!(req.calculate_cost_in_cents(), 23);
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod api;
|
||||
pub mod cost;
|
||||
pub mod raw_request;
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Over-the-wire input shape for `fal-ai/hunyuan-3d/v3.1/rapid/image-to-3d`.
|
||||
/// fal's schema: <https://fal.ai/models/fal-ai/hunyuan-3d/v3.1/rapid/image-to-3d/api>
|
||||
#[derive(Debug, Serialize, Deserialize, Default)]
|
||||
pub struct Hunyuan3d3p1RapidImageToMeshInput {
|
||||
/// Front-view image (required). 128-5000px, max 8MB, JPG/PNG/WEBP.
|
||||
pub input_image_url: String,
|
||||
|
||||
/// Generate PBR textures (metallic, roughness, normal). fal default: false.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enable_pbr: Option<bool>,
|
||||
|
||||
/// Generate a geometry-only white model without textures. fal default: false.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enable_geometry: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Hunyuan3d3p1RapidImageToMeshOutput {}
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
use crate::error::fal_error_plus::FalErrorPlus;
|
||||
use crate::requests::api::mesh::image::meshy_v6_image_to_mesh::raw_request::{
|
||||
MeshyV6ImageToMeshInput, MeshyV6ImageToMeshOutput,
|
||||
};
|
||||
use crate::requests::api::mesh::text::meshy_v6_text_to_mesh::api::{
|
||||
MeshyV6ModelType, MeshyV6PoseMode, MeshyV6SymmetryMode, MeshyV6Topology,
|
||||
};
|
||||
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
|
||||
|
||||
/// Meshy 6 image-to-3D. Shares its option enums with the text binding.
|
||||
///
|
||||
/// NB: unlike text-to-3d, the image schema has no `mode`, `seed`, or
|
||||
/// `enable_prompt_expansion`, and adds `should_texture`.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct MeshyV6ImageToMeshRequest {
|
||||
/// URL of the input image (required).
|
||||
pub image_url: String,
|
||||
|
||||
/// Model type. fal's default is `Standard` when `None`.
|
||||
pub model_type: Option<MeshyV6ModelType>,
|
||||
|
||||
/// Mesh topology. fal's default is `Triangle` when `None`.
|
||||
pub topology: Option<MeshyV6Topology>,
|
||||
|
||||
/// Target polygon count. Range 100-300000; fal's default is 30000.
|
||||
pub target_polycount: Option<u32>,
|
||||
|
||||
/// Symmetry mode. fal's default is `Auto` when `None`.
|
||||
pub symmetry_mode: Option<MeshyV6SymmetryMode>,
|
||||
|
||||
/// Whether to remesh the output. fal's default is true when `None`.
|
||||
pub should_remesh: Option<bool>,
|
||||
|
||||
/// Whether to texture the output. fal's default is true when `None`.
|
||||
pub should_texture: Option<bool>,
|
||||
|
||||
/// Whether to generate PBR materials. fal's default is false when `None`.
|
||||
pub enable_pbr: Option<bool>,
|
||||
|
||||
/// Character pose mode. Unspecified when `None` (fal's default).
|
||||
pub pose_mode: Option<MeshyV6PoseMode>,
|
||||
|
||||
/// Optional texture-specific prompt.
|
||||
pub texture_prompt: Option<String>,
|
||||
|
||||
/// Optional texture reference image URL.
|
||||
pub texture_image_url: Option<String>,
|
||||
|
||||
/// Rig the generated character. fal's default is false when `None`.
|
||||
pub enable_rigging: Option<bool>,
|
||||
|
||||
/// Character height for rigging, in meters. fal's default is 1.7.
|
||||
pub rigging_height_meters: Option<f32>,
|
||||
|
||||
/// Animate the rigged character. fal's default is false when `None`.
|
||||
pub enable_animation: Option<bool>,
|
||||
|
||||
/// Animation action ID. Range 0-696; fal's default is 92.
|
||||
pub animation_action_id: Option<u32>,
|
||||
|
||||
/// fal's default is true when `None`.
|
||||
pub enable_safety_checker: Option<bool>,
|
||||
}
|
||||
|
||||
impl FalEndpoint for MeshyV6ImageToMeshRequest {
|
||||
const ENDPOINT: &str = "fal-ai/meshy/v6/image-to-3d";
|
||||
|
||||
type RawRequest = MeshyV6ImageToMeshInput;
|
||||
type RawResponse = MeshyV6ImageToMeshOutput;
|
||||
|
||||
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
|
||||
Ok(Self::RawRequest {
|
||||
image_url: self.image_url.clone(),
|
||||
model_type: self.model_type.map(|m| m.to_str().to_string()),
|
||||
topology: self.topology.map(|t| t.to_str().to_string()),
|
||||
target_polycount: self.target_polycount,
|
||||
symmetry_mode: self.symmetry_mode.map(|s| s.to_str().to_string()),
|
||||
should_remesh: self.should_remesh,
|
||||
should_texture: self.should_texture,
|
||||
enable_pbr: self.enable_pbr,
|
||||
pose_mode: self.pose_mode.map(|p| p.to_str().to_string()),
|
||||
texture_prompt: self.texture_prompt.clone(),
|
||||
texture_image_url: self.texture_image_url.clone(),
|
||||
enable_rigging: self.enable_rigging,
|
||||
rigging_height_meters: self.rigging_height_meters,
|
||||
enable_animation: self.enable_animation,
|
||||
animation_action_id: self.animation_action_id,
|
||||
enable_safety_checker: self.enable_safety_checker,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[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;
|
||||
|
||||
fn base_request() -> MeshyV6ImageToMeshRequest {
|
||||
MeshyV6ImageToMeshRequest {
|
||||
image_url: "https://example.com/input.jpg".to_string(),
|
||||
model_type: None,
|
||||
topology: None,
|
||||
target_polycount: None,
|
||||
symmetry_mode: None,
|
||||
should_remesh: None,
|
||||
should_texture: None,
|
||||
enable_pbr: None,
|
||||
pose_mode: None,
|
||||
texture_prompt: None,
|
||||
texture_image_url: None,
|
||||
enable_rigging: None,
|
||||
rigging_height_meters: None,
|
||||
enable_animation: None,
|
||||
animation_action_id: None,
|
||||
enable_safety_checker: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[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 = MeshyV6ImageToMeshRequest {
|
||||
image_url: ERNEST_SCARED_STUPID_IMAGE_URL.to_string(),
|
||||
..base_request()
|
||||
};
|
||||
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 = MeshyV6ImageToMeshRequest {
|
||||
image_url: ERNEST_SCARED_STUPID_IMAGE_URL.to_string(),
|
||||
..base_request()
|
||||
};
|
||||
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_core_fields() {
|
||||
let request = MeshyV6ImageToMeshRequest {
|
||||
model_type: Some(MeshyV6ModelType::LowPoly),
|
||||
topology: Some(MeshyV6Topology::Quad),
|
||||
should_texture: Some(false),
|
||||
enable_pbr: Some(true),
|
||||
..base_request()
|
||||
};
|
||||
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
|
||||
assert_eq!(json["image_url"], "https://example.com/input.jpg");
|
||||
assert_eq!(json["model_type"], "lowpoly");
|
||||
assert_eq!(json["topology"], "quad");
|
||||
assert_eq!(json["should_texture"], false);
|
||||
assert_eq!(json["enable_pbr"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_request_omits_unset_optionals() {
|
||||
let json = serde_json::to_value(base_request().to_raw_request().unwrap()).unwrap();
|
||||
assert_eq!(json, serde_json::json!({ "image_url": "https://example.com/input.jpg" }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_path_is_canonical() {
|
||||
assert_eq!(MeshyV6ImageToMeshRequest::ENDPOINT, "fal-ai/meshy/v6/image-to-3d");
|
||||
}
|
||||
|
||||
// NB: Pricing tests are in cost.rs
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
use crate::requests::api::mesh::image::meshy_v6_image_to_mesh::api::MeshyV6ImageToMeshRequest;
|
||||
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
|
||||
|
||||
impl FalRequestCostCalculator for MeshyV6ImageToMeshRequest {
|
||||
fn calculate_cost_in_cents(&self) -> UsdCents {
|
||||
// Pricing (see https://fal.ai/models/fal-ai/meshy/v6/image-to-3d):
|
||||
// "Your request will cost $0.8 per generation."
|
||||
80
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn flat_cost_regardless_of_options() {
|
||||
let mut request = MeshyV6ImageToMeshRequest {
|
||||
image_url: "https://example.com/input.jpg".to_string(),
|
||||
model_type: None,
|
||||
topology: None,
|
||||
target_polycount: None,
|
||||
symmetry_mode: None,
|
||||
should_remesh: None,
|
||||
should_texture: None,
|
||||
enable_pbr: None,
|
||||
pose_mode: None,
|
||||
texture_prompt: None,
|
||||
texture_image_url: None,
|
||||
enable_rigging: None,
|
||||
rigging_height_meters: None,
|
||||
enable_animation: None,
|
||||
animation_action_id: None,
|
||||
enable_safety_checker: None,
|
||||
};
|
||||
assert_eq!(request.calculate_cost_in_cents(), 80);
|
||||
|
||||
request.should_texture = Some(false);
|
||||
request.enable_pbr = Some(true);
|
||||
assert_eq!(request.calculate_cost_in_cents(), 80);
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod api;
|
||||
pub mod cost;
|
||||
pub mod raw_request;
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Over-the-wire input shape for `fal-ai/meshy/v6/image-to-3d`.
|
||||
/// fal's schema: <https://fal.ai/models/fal-ai/meshy/v6/image-to-3d/api>
|
||||
#[derive(Debug, Serialize, Deserialize, Default)]
|
||||
pub struct MeshyV6ImageToMeshInput {
|
||||
/// URL of the input image (.jpg/.jpeg/.png/AVIF/HEIF).
|
||||
pub image_url: String,
|
||||
|
||||
/// Options: "standard", "lowpoly". fal default: "standard".
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model_type: Option<String>,
|
||||
|
||||
/// Options: "quad", "triangle". fal default: "triangle".
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub topology: Option<String>,
|
||||
|
||||
/// Range 100-300000. fal default: 30000.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub target_polycount: Option<u32>,
|
||||
|
||||
/// Options: "off", "auto", "on". fal default: "auto".
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub symmetry_mode: Option<String>,
|
||||
|
||||
/// fal default: true.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub should_remesh: Option<bool>,
|
||||
|
||||
/// fal default: true.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub should_texture: Option<bool>,
|
||||
|
||||
/// fal default: false.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enable_pbr: Option<bool>,
|
||||
|
||||
/// Options: "a-pose", "t-pose". fal default: "" (unspecified).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub pose_mode: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub texture_prompt: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub texture_image_url: Option<String>,
|
||||
|
||||
/// fal default: false.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enable_rigging: Option<bool>,
|
||||
|
||||
/// fal default: 1.7.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rigging_height_meters: Option<f32>,
|
||||
|
||||
/// fal default: false.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enable_animation: Option<bool>,
|
||||
|
||||
/// Range 0-696. fal default: 92.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub animation_action_id: Option<u32>,
|
||||
|
||||
/// fal default: true.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enable_safety_checker: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct MeshyV6ImageToMeshOutput {}
|
||||
@@ -1,3 +1,8 @@
|
||||
pub mod hunyuan3d_3_image_to_mesh;
|
||||
pub mod hunyuan_3d_2p0_image_to_mesh;
|
||||
pub mod hunyuan_3d_2p1_image_to_mesh;
|
||||
pub mod hunyuan3d_3_image_to_mesh;
|
||||
pub mod hunyuan_3d_3p1_pro_image_to_mesh;
|
||||
pub mod hunyuan_3d_3p1_rapid_image_to_mesh;
|
||||
pub mod meshy_v6_image_to_mesh;
|
||||
pub mod rodin_2p5_fast_image_to_mesh;
|
||||
pub mod tripo3d_h3p1_image_to_mesh;
|
||||
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
use crate::error::fal_error_plus::FalErrorPlus;
|
||||
use crate::requests::api::mesh::image::rodin_2p5_fast_image_to_mesh::raw_request::{
|
||||
Rodin2p5FastImageToMeshInput, Rodin2p5FastImageToMeshOutput,
|
||||
};
|
||||
use crate::requests::api::mesh::text::rodin_2p5_fast_text_to_mesh::api::{
|
||||
Rodin2p5FastGeometryFileFormat, Rodin2p5FastMaterial, Rodin2p5FastQualityMeshOption,
|
||||
Rodin2p5FastTextureMode, Rodin2p5FastTier,
|
||||
};
|
||||
use crate::requests::api::mesh::text::rodin_2p5_fast_text_to_mesh::raw_request::Rodin2p5FastBboxCondition;
|
||||
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
|
||||
|
||||
/// Hyper3D Rodin v2.5 Fast image-to-3D. Shares its option enums with the
|
||||
/// text binding.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Rodin2p5FastImageToMeshRequest {
|
||||
/// Up to 5 input images of the object.
|
||||
pub image_urls: Vec<String>,
|
||||
|
||||
/// Optional guidance prompt. Auto-generated from the images when `None`.
|
||||
pub prompt: Option<String>,
|
||||
|
||||
/// Preserve the transparency channel of the input. fal's default is false.
|
||||
pub use_original_alpha: Option<bool>,
|
||||
|
||||
/// Generation tier. fal's default is `ExtremeLow` when `None`.
|
||||
pub tier: Option<Rodin2p5FastTier>,
|
||||
|
||||
/// Seed for reproducibility. Range 0-65535.
|
||||
pub seed: Option<u32>,
|
||||
|
||||
/// Output geometry format. fal's default is `Glb` when `None`.
|
||||
pub geometry_file_format: Option<Rodin2p5FastGeometryFileFormat>,
|
||||
|
||||
/// Material type. fal's default is `Shaded` when `None`.
|
||||
pub material: Option<Rodin2p5FastMaterial>,
|
||||
|
||||
/// Mesh quality/topology preset. fal's default is `Auto` when `None`.
|
||||
pub quality_mesh_option: Option<Rodin2p5FastQualityMeshOption>,
|
||||
|
||||
/// Texture resolution mode. fal's default is tier-dependent when `None`.
|
||||
pub texture_mode: Option<Rodin2p5FastTextureMode>,
|
||||
|
||||
/// Enhanced generative robustness. fal's default is false when `None`.
|
||||
pub enable_creative_mode: Option<bool>,
|
||||
|
||||
/// Enhanced texture post-processing. fal's default is false when `None`.
|
||||
pub hd_texture: Option<bool>,
|
||||
|
||||
/// Removes baked lighting from textures. fal's default is false when `None`.
|
||||
pub texture_delight: Option<bool>,
|
||||
|
||||
/// Finer geometric detail. fal's default is false when `None`.
|
||||
pub is_micro: Option<bool>,
|
||||
|
||||
/// Generate in T/A-pose for rigging/animation. fal's default is false.
|
||||
pub ta_pose: Option<bool>,
|
||||
|
||||
/// Bounding-box controlnet limiting the maximum model size.
|
||||
pub bbox_condition: Option<Rodin2p5FastBboxCondition>,
|
||||
|
||||
/// Generate a preview render image. fal's default is false when `None`.
|
||||
pub preview_render: Option<bool>,
|
||||
}
|
||||
|
||||
impl FalEndpoint for Rodin2p5FastImageToMeshRequest {
|
||||
const ENDPOINT: &str = "fal-ai/hyper3d/rodin/v2.5/fast";
|
||||
|
||||
type RawRequest = Rodin2p5FastImageToMeshInput;
|
||||
type RawResponse = Rodin2p5FastImageToMeshOutput;
|
||||
|
||||
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
|
||||
Ok(Self::RawRequest {
|
||||
image_urls: self.image_urls.clone(),
|
||||
prompt: self.prompt.clone(),
|
||||
use_original_alpha: self.use_original_alpha,
|
||||
tier: self.tier.map(|t| t.to_str().to_string()),
|
||||
seed: self.seed,
|
||||
geometry_file_format: self.geometry_file_format.map(|f| f.to_str().to_string()),
|
||||
material: self.material.map(|m| m.to_str().to_string()),
|
||||
quality_mesh_option: self.quality_mesh_option.map(|q| q.to_str().to_string()),
|
||||
texture_mode: self.texture_mode.map(|t| t.to_str().to_string()),
|
||||
enable_creative_mode: self.enable_creative_mode,
|
||||
hd_texture: self.hd_texture,
|
||||
texture_delight: self.texture_delight,
|
||||
is_micro: self.is_micro,
|
||||
ta_pose: self.ta_pose,
|
||||
bbox_condition: self.bbox_condition.clone(),
|
||||
preview_render: self.preview_render,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[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;
|
||||
|
||||
fn base_request() -> Rodin2p5FastImageToMeshRequest {
|
||||
Rodin2p5FastImageToMeshRequest {
|
||||
image_urls: vec!["https://example.com/input.jpg".to_string()],
|
||||
prompt: None,
|
||||
use_original_alpha: None,
|
||||
tier: None,
|
||||
seed: None,
|
||||
geometry_file_format: None,
|
||||
material: None,
|
||||
quality_mesh_option: None,
|
||||
texture_mode: None,
|
||||
enable_creative_mode: None,
|
||||
hd_texture: None,
|
||||
texture_delight: None,
|
||||
is_micro: None,
|
||||
ta_pose: None,
|
||||
bbox_condition: None,
|
||||
preview_render: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[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 = Rodin2p5FastImageToMeshRequest {
|
||||
image_urls: vec![ERNEST_SCARED_STUPID_IMAGE_URL.to_string()],
|
||||
..base_request()
|
||||
};
|
||||
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 = Rodin2p5FastImageToMeshRequest {
|
||||
image_urls: vec![ERNEST_SCARED_STUPID_IMAGE_URL.to_string()],
|
||||
..base_request()
|
||||
};
|
||||
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_core_fields() {
|
||||
let request = Rodin2p5FastImageToMeshRequest {
|
||||
prompt: Some("a robot".to_string()),
|
||||
use_original_alpha: Some(true),
|
||||
tier: Some(Rodin2p5FastTier::Minimum),
|
||||
quality_mesh_option: Some(Rodin2p5FastQualityMeshOption::Triangle20k),
|
||||
ta_pose: Some(true),
|
||||
preview_render: Some(true),
|
||||
..base_request()
|
||||
};
|
||||
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
|
||||
assert_eq!(json["image_urls"], serde_json::json!(["https://example.com/input.jpg"]));
|
||||
assert_eq!(json["prompt"], "a robot");
|
||||
assert_eq!(json["use_original_alpha"], true);
|
||||
assert_eq!(json["tier"], "Gen-2.5-Minimum");
|
||||
assert_eq!(json["quality_mesh_option"], "20K Triangle");
|
||||
assert_eq!(json["TAPose"], true);
|
||||
assert_eq!(json["preview_render"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_request_omits_unset_optionals() {
|
||||
let json = serde_json::to_value(base_request().to_raw_request().unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
json,
|
||||
serde_json::json!({ "image_urls": ["https://example.com/input.jpg"] }),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_path_is_canonical() {
|
||||
assert_eq!(Rodin2p5FastImageToMeshRequest::ENDPOINT, "fal-ai/hyper3d/rodin/v2.5/fast");
|
||||
}
|
||||
|
||||
// NB: Pricing tests are in cost.rs
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
use crate::requests::api::mesh::image::rodin_2p5_fast_image_to_mesh::api::Rodin2p5FastImageToMeshRequest;
|
||||
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
|
||||
|
||||
impl FalRequestCostCalculator for Rodin2p5FastImageToMeshRequest {
|
||||
fn calculate_cost_in_cents(&self) -> UsdCents {
|
||||
// Pricing (see https://fal.ai/models/fal-ai/hyper3d/rodin/v2.5/fast):
|
||||
// "Your request will cost $0.1 per generation."
|
||||
10
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::requests::api::mesh::text::rodin_2p5_fast_text_to_mesh::api::Rodin2p5FastMaterial;
|
||||
|
||||
#[test]
|
||||
fn flat_cost_regardless_of_options() {
|
||||
let mut request = Rodin2p5FastImageToMeshRequest {
|
||||
image_urls: vec!["https://example.com/input.jpg".to_string()],
|
||||
prompt: None,
|
||||
use_original_alpha: None,
|
||||
tier: None,
|
||||
seed: None,
|
||||
geometry_file_format: None,
|
||||
material: None,
|
||||
quality_mesh_option: None,
|
||||
texture_mode: None,
|
||||
enable_creative_mode: None,
|
||||
hd_texture: None,
|
||||
texture_delight: None,
|
||||
is_micro: None,
|
||||
ta_pose: None,
|
||||
bbox_condition: None,
|
||||
preview_render: None,
|
||||
};
|
||||
assert_eq!(request.calculate_cost_in_cents(), 10);
|
||||
|
||||
request.material = Some(Rodin2p5FastMaterial::Pbr);
|
||||
request.hd_texture = Some(true);
|
||||
request.image_urls.push("https://example.com/second.jpg".to_string());
|
||||
assert_eq!(request.calculate_cost_in_cents(), 10);
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod api;
|
||||
pub mod cost;
|
||||
pub mod raw_request;
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::requests::api::mesh::text::rodin_2p5_fast_text_to_mesh::raw_request::Rodin2p5FastBboxCondition;
|
||||
|
||||
/// Over-the-wire input shape for `fal-ai/hyper3d/rodin/v2.5/fast`
|
||||
/// (image-to-3D mode).
|
||||
/// fal's schema: <https://fal.ai/models/fal-ai/hyper3d/rodin/v2.5/fast/api>
|
||||
#[derive(Debug, Serialize, Deserialize, Default)]
|
||||
pub struct Rodin2p5FastImageToMeshInput {
|
||||
/// Up to 5 input images for image-to-3D.
|
||||
pub image_urls: Vec<String>,
|
||||
|
||||
/// Optional guidance prompt. Auto-generated from images when omitted.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub prompt: Option<String>,
|
||||
|
||||
/// Preserve the transparency channel of the input. fal default: false.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub use_original_alpha: Option<bool>,
|
||||
|
||||
/// Options: "Gen-2.5-Minimum", "Gen-2.5-Extreme-Low", "Gen-2.5-Low".
|
||||
/// fal default: "Gen-2.5-Extreme-Low".
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tier: Option<String>,
|
||||
|
||||
/// Range 0-65535.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub seed: Option<u32>,
|
||||
|
||||
/// Options: "glb", "usdz", "fbx", "obj", "stl". fal default: "glb".
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub geometry_file_format: Option<String>,
|
||||
|
||||
/// Options: "PBR", "Shaded", "All", "None". fal default: "Shaded".
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub material: Option<String>,
|
||||
|
||||
/// See the text binding for the full option list. fal default: "Auto".
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub quality_mesh_option: Option<String>,
|
||||
|
||||
/// Options: "legacy", "extreme-low", "low", "medium", "high".
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub texture_mode: Option<String>,
|
||||
|
||||
/// fal default: false.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enable_creative_mode: Option<bool>,
|
||||
|
||||
/// Enhanced texture post-processing. fal default: false.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub hd_texture: Option<bool>,
|
||||
|
||||
/// Removes baked lighting from textures. fal default: false.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub texture_delight: Option<bool>,
|
||||
|
||||
/// Finer geometric detail. fal default: false.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub is_micro: Option<bool>,
|
||||
|
||||
/// Generate in T/A-pose for rigging/animation. fal default: false.
|
||||
/// NB: fal's field name is literally "TAPose".
|
||||
#[serde(rename = "TAPose", skip_serializing_if = "Option::is_none")]
|
||||
pub ta_pose: Option<bool>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bbox_condition: Option<Rodin2p5FastBboxCondition>,
|
||||
|
||||
/// Generate a preview render image. fal default: false.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub preview_render: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Rodin2p5FastImageToMeshOutput {}
|
||||
+240
@@ -0,0 +1,240 @@
|
||||
use crate::error::fal_error_plus::FalErrorPlus;
|
||||
use crate::requests::api::mesh::image::tripo3d_h3p1_image_to_mesh::raw_request::{
|
||||
Tripo3dH3p1ImageToMeshInput, Tripo3dH3p1ImageToMeshOutput,
|
||||
};
|
||||
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
|
||||
|
||||
/// Tripo3D H3.1 image-to-3D.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Tripo3dH3p1ImageToMeshRequest {
|
||||
/// URL of the input image (required).
|
||||
pub image_url: String,
|
||||
|
||||
/// Maximum face count. Range 1000-2000000; adaptive when `None`.
|
||||
pub face_limit: Option<u32>,
|
||||
|
||||
/// Enable texture generation. fal's default is true when `None`.
|
||||
/// Disabling drops to the cheaper no-texture price tier.
|
||||
pub texture: Option<bool>,
|
||||
|
||||
/// Enable PBR materials (implies texture). fal's default is true when `None`.
|
||||
pub pbr: Option<bool>,
|
||||
|
||||
/// Seed for geometry reproducibility.
|
||||
pub model_seed: Option<i64>,
|
||||
|
||||
/// Seed for texture reproducibility.
|
||||
pub texture_seed: Option<i64>,
|
||||
|
||||
/// Texture quality. `Detailed` bills at the HD-texture price tier.
|
||||
/// fal's default is `Standard` when `None`.
|
||||
pub texture_quality: Option<Tripo3dH3p1ImageTextureQuality>,
|
||||
|
||||
/// Geometry quality. `Detailed` adds cost. fal's default is `Standard`.
|
||||
pub geometry_quality: Option<Tripo3dH3p1ImageGeometryQuality>,
|
||||
|
||||
/// How textures align to the source. fal's default is `OriginalImage`.
|
||||
pub texture_alignment: Option<Tripo3dH3p1TextureAlignment>,
|
||||
|
||||
/// Auto-scale the model to real-world dimensions (meters). fal default: false.
|
||||
pub auto_size: Option<bool>,
|
||||
|
||||
/// Model orientation. fal's default is `Default` when `None`.
|
||||
pub orientation: Option<Tripo3dH3p1Orientation>,
|
||||
|
||||
/// Generate quad mesh topology instead of triangles (returns FBX).
|
||||
/// Adds cost. fal's default is false when `None`.
|
||||
pub quad: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
|
||||
pub enum Tripo3dH3p1ImageTextureQuality {
|
||||
Standard,
|
||||
Detailed,
|
||||
}
|
||||
|
||||
impl Tripo3dH3p1ImageTextureQuality {
|
||||
pub(crate) fn to_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Standard => "standard",
|
||||
Self::Detailed => "detailed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
|
||||
pub enum Tripo3dH3p1ImageGeometryQuality {
|
||||
Standard,
|
||||
Detailed,
|
||||
}
|
||||
|
||||
impl Tripo3dH3p1ImageGeometryQuality {
|
||||
pub(crate) fn to_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Standard => "standard",
|
||||
Self::Detailed => "detailed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
|
||||
pub enum Tripo3dH3p1TextureAlignment {
|
||||
OriginalImage,
|
||||
Geometry,
|
||||
}
|
||||
|
||||
impl Tripo3dH3p1TextureAlignment {
|
||||
pub(crate) fn to_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::OriginalImage => "original_image",
|
||||
Self::Geometry => "geometry",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
|
||||
pub enum Tripo3dH3p1Orientation {
|
||||
Default,
|
||||
AlignImage,
|
||||
}
|
||||
|
||||
impl Tripo3dH3p1Orientation {
|
||||
pub(crate) fn to_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Default => "default",
|
||||
Self::AlignImage => "align_image",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FalEndpoint for Tripo3dH3p1ImageToMeshRequest {
|
||||
const ENDPOINT: &str = "tripo3d/h3.1/image-to-3d";
|
||||
|
||||
type RawRequest = Tripo3dH3p1ImageToMeshInput;
|
||||
type RawResponse = Tripo3dH3p1ImageToMeshOutput;
|
||||
|
||||
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
|
||||
Ok(Self::RawRequest {
|
||||
image_url: self.image_url.clone(),
|
||||
face_limit: self.face_limit,
|
||||
texture: self.texture,
|
||||
pbr: self.pbr,
|
||||
model_seed: self.model_seed,
|
||||
texture_seed: self.texture_seed,
|
||||
texture_quality: self.texture_quality.map(|q| q.to_str().to_string()),
|
||||
geometry_quality: self.geometry_quality.map(|q| q.to_str().to_string()),
|
||||
texture_alignment: self.texture_alignment.map(|a| a.to_str().to_string()),
|
||||
auto_size: self.auto_size,
|
||||
orientation: self.orientation.map(|o| o.to_str().to_string()),
|
||||
quad: self.quad,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[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;
|
||||
|
||||
fn base_request() -> Tripo3dH3p1ImageToMeshRequest {
|
||||
Tripo3dH3p1ImageToMeshRequest {
|
||||
image_url: "https://example.com/input.jpg".to_string(),
|
||||
face_limit: None,
|
||||
texture: None,
|
||||
pbr: None,
|
||||
model_seed: None,
|
||||
texture_seed: None,
|
||||
texture_quality: None,
|
||||
geometry_quality: None,
|
||||
texture_alignment: None,
|
||||
auto_size: None,
|
||||
orientation: None,
|
||||
quad: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[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 = Tripo3dH3p1ImageToMeshRequest {
|
||||
image_url: ERNEST_SCARED_STUPID_IMAGE_URL.to_string(),
|
||||
..base_request()
|
||||
};
|
||||
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 = Tripo3dH3p1ImageToMeshRequest {
|
||||
image_url: ERNEST_SCARED_STUPID_IMAGE_URL.to_string(),
|
||||
..base_request()
|
||||
};
|
||||
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 = Tripo3dH3p1ImageToMeshRequest {
|
||||
image_url: "https://example.com/input.jpg".to_string(),
|
||||
face_limit: Some(50_000),
|
||||
texture: Some(true),
|
||||
pbr: Some(false),
|
||||
model_seed: Some(1),
|
||||
texture_seed: Some(3),
|
||||
texture_quality: Some(Tripo3dH3p1ImageTextureQuality::Detailed),
|
||||
geometry_quality: Some(Tripo3dH3p1ImageGeometryQuality::Detailed),
|
||||
texture_alignment: Some(Tripo3dH3p1TextureAlignment::Geometry),
|
||||
auto_size: Some(true),
|
||||
orientation: Some(Tripo3dH3p1Orientation::AlignImage),
|
||||
quad: Some(true),
|
||||
};
|
||||
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
json,
|
||||
serde_json::json!({
|
||||
"image_url": "https://example.com/input.jpg",
|
||||
"face_limit": 50_000,
|
||||
"texture": true,
|
||||
"pbr": false,
|
||||
"model_seed": 1,
|
||||
"texture_seed": 3,
|
||||
"texture_quality": "detailed",
|
||||
"geometry_quality": "detailed",
|
||||
"texture_alignment": "geometry",
|
||||
"auto_size": true,
|
||||
"orientation": "align_image",
|
||||
"quad": true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_request_omits_unset_optionals() {
|
||||
let json = serde_json::to_value(base_request().to_raw_request().unwrap()).unwrap();
|
||||
assert_eq!(json, serde_json::json!({ "image_url": "https://example.com/input.jpg" }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_path_is_canonical() {
|
||||
assert_eq!(Tripo3dH3p1ImageToMeshRequest::ENDPOINT, "tripo3d/h3.1/image-to-3d");
|
||||
}
|
||||
|
||||
// NB: Pricing tests are in cost.rs
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
use crate::requests::api::mesh::image::tripo3d_h3p1_image_to_mesh::api::{
|
||||
Tripo3dH3p1ImageGeometryQuality, Tripo3dH3p1ImageTextureQuality, Tripo3dH3p1ImageToMeshRequest,
|
||||
};
|
||||
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
|
||||
|
||||
impl FalRequestCostCalculator for Tripo3dH3p1ImageToMeshRequest {
|
||||
fn calculate_cost_in_cents(&self) -> UsdCents {
|
||||
// Pricing (see https://fal.ai/models/tripo3d/h3.1/image-to-3d):
|
||||
// "Your request will cost $0.20 (without textures), $0.30 (with standard
|
||||
// textures), or $0.40 (with HD textures), plus an additional $0.20 for
|
||||
// detailed geometry and $0.05 for quad mesh if selected."
|
||||
//
|
||||
// NB: the image endpoint's texture tiers are 10¢ above the text/multiview
|
||||
// endpoints'. `texture` defaults to true → standard tier is the default.
|
||||
// `pbr` implies texturing and ALSO defaults to true, so the cheap
|
||||
// untextured tier only applies when both `texture` and `pbr` are
|
||||
// explicitly disabled. Anything else may texture server-side; bill the
|
||||
// textured tier so we never undercharge.
|
||||
let texture_enabled = self.texture.unwrap_or(true) || self.pbr.unwrap_or(true);
|
||||
let mut cost: u64 = if !texture_enabled {
|
||||
20
|
||||
} else {
|
||||
match self.texture_quality {
|
||||
Some(Tripo3dH3p1ImageTextureQuality::Detailed) => 40,
|
||||
None | Some(Tripo3dH3p1ImageTextureQuality::Standard) => 30,
|
||||
}
|
||||
};
|
||||
if matches!(self.geometry_quality, Some(Tripo3dH3p1ImageGeometryQuality::Detailed)) {
|
||||
cost += 20;
|
||||
}
|
||||
if self.quad.unwrap_or(false) {
|
||||
cost += 5;
|
||||
}
|
||||
cost
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn base_request() -> Tripo3dH3p1ImageToMeshRequest {
|
||||
Tripo3dH3p1ImageToMeshRequest {
|
||||
image_url: "https://example.com/input.jpg".to_string(),
|
||||
face_limit: None,
|
||||
texture: None,
|
||||
pbr: None,
|
||||
model_seed: None,
|
||||
texture_seed: None,
|
||||
texture_quality: None,
|
||||
geometry_quality: None,
|
||||
texture_alignment: None,
|
||||
auto_size: None,
|
||||
orientation: None,
|
||||
quad: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_is_standard_textures() {
|
||||
assert_eq!(base_request().calculate_cost_in_cents(), 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_texture_requires_pbr_off_too() {
|
||||
let mut req = base_request();
|
||||
req.texture = Some(false);
|
||||
req.pbr = Some(false);
|
||||
assert_eq!(req.calculate_cost_in_cents(), 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn texture_off_but_pbr_default_bills_textured_tier() {
|
||||
// `pbr` defaults to true and implies texturing.
|
||||
let mut req = base_request();
|
||||
req.texture = Some(false);
|
||||
assert_eq!(req.calculate_cost_in_cents(), 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detailed_texture() {
|
||||
let mut req = base_request();
|
||||
req.texture_quality = Some(Tripo3dH3p1ImageTextureQuality::Detailed);
|
||||
assert_eq!(req.calculate_cost_in_cents(), 40);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detailed_geometry_adds_twenty() {
|
||||
let mut req = base_request();
|
||||
req.geometry_quality = Some(Tripo3dH3p1ImageGeometryQuality::Detailed);
|
||||
assert_eq!(req.calculate_cost_in_cents(), 30 + 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quad_adds_five() {
|
||||
let mut req = base_request();
|
||||
req.quad = Some(true);
|
||||
assert_eq!(req.calculate_cost_in_cents(), 30 + 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn everything_stacks() {
|
||||
let mut req = base_request();
|
||||
req.texture_quality = Some(Tripo3dH3p1ImageTextureQuality::Detailed);
|
||||
req.geometry_quality = Some(Tripo3dH3p1ImageGeometryQuality::Detailed);
|
||||
req.quad = Some(true);
|
||||
// 40 + 20 + 5 = 65
|
||||
assert_eq!(req.calculate_cost_in_cents(), 65);
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod api;
|
||||
pub mod cost;
|
||||
pub mod raw_request;
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Over-the-wire input shape for `tripo3d/h3.1/image-to-3d`.
|
||||
/// fal's schema: <https://fal.ai/models/tripo3d/h3.1/image-to-3d/api>
|
||||
#[derive(Debug, Serialize, Deserialize, Default)]
|
||||
pub struct Tripo3dH3p1ImageToMeshInput {
|
||||
/// URL of the input image.
|
||||
pub image_url: String,
|
||||
|
||||
/// Range 1000-2000000. Adaptive when omitted.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub face_limit: Option<u32>,
|
||||
|
||||
/// Enable texture generation. fal default: true.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub texture: Option<bool>,
|
||||
|
||||
/// Enable PBR materials (implies texture). fal default: true.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub pbr: Option<bool>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model_seed: Option<i64>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub texture_seed: Option<i64>,
|
||||
|
||||
/// Options: "standard", "detailed". fal default: "standard".
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub texture_quality: Option<String>,
|
||||
|
||||
/// Options: "standard", "detailed". fal default: "standard".
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub geometry_quality: Option<String>,
|
||||
|
||||
/// Options: "original_image", "geometry". fal default: "original_image".
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub texture_alignment: Option<String>,
|
||||
|
||||
/// Auto-scale the model to real-world dimensions (meters). fal default: false.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub auto_size: Option<bool>,
|
||||
|
||||
/// Options: "default", "align_image". fal default: "default".
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub orientation: Option<String>,
|
||||
|
||||
/// Generate quad mesh topology instead of triangles. fal default: false.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub quad: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Tripo3dH3p1ImageToMeshOutput {}
|
||||
@@ -1,3 +1,6 @@
|
||||
pub mod image;
|
||||
pub mod multiview;
|
||||
pub mod part;
|
||||
pub mod sketch;
|
||||
pub mod text;
|
||||
pub mod topology;
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
pub mod tripo3d_h3p1_multiview_to_mesh;
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
use crate::error::fal_error_plus::FalErrorPlus;
|
||||
use crate::requests::api::mesh::image::tripo3d_h3p1_image_to_mesh::api::{
|
||||
Tripo3dH3p1ImageGeometryQuality, Tripo3dH3p1ImageTextureQuality, Tripo3dH3p1Orientation,
|
||||
Tripo3dH3p1TextureAlignment,
|
||||
};
|
||||
use crate::requests::api::mesh::multiview::tripo3d_h3p1_multiview_to_mesh::raw_request::{
|
||||
Tripo3dH3p1MultiviewToMeshInput, Tripo3dH3p1MultiviewToMeshOutput,
|
||||
};
|
||||
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
|
||||
|
||||
/// Tripo3D H3.1 multiview-to-3D: reconstructs a mesh from 2-4 views of the
|
||||
/// same object. Shares its option enums with the single-image binding.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Tripo3dH3p1MultiviewToMeshRequest {
|
||||
/// 2 to 4 image URLs of the same object from different angles.
|
||||
/// Order: [front, left, back, right].
|
||||
pub image_urls: Vec<String>,
|
||||
|
||||
/// Maximum face count. Range 1000-2000000; adaptive when `None`.
|
||||
pub face_limit: Option<u32>,
|
||||
|
||||
/// Enable texture generation. fal's default is true when `None`.
|
||||
/// Disabling drops to the cheaper no-texture price tier.
|
||||
pub texture: Option<bool>,
|
||||
|
||||
/// Enable PBR materials (implies texture). fal's default is true when `None`.
|
||||
pub pbr: Option<bool>,
|
||||
|
||||
/// Seed for geometry reproducibility.
|
||||
pub model_seed: Option<i64>,
|
||||
|
||||
/// Seed for texture reproducibility.
|
||||
pub texture_seed: Option<i64>,
|
||||
|
||||
/// Texture quality. `Detailed` bills at the HD-texture price tier.
|
||||
pub texture_quality: Option<Tripo3dH3p1ImageTextureQuality>,
|
||||
|
||||
/// Geometry quality. `Detailed` adds cost.
|
||||
pub geometry_quality: Option<Tripo3dH3p1ImageGeometryQuality>,
|
||||
|
||||
/// How textures align to the source. fal's default is `OriginalImage`.
|
||||
pub texture_alignment: Option<Tripo3dH3p1TextureAlignment>,
|
||||
|
||||
/// Auto-scale the model to real-world dimensions (meters). fal default: false.
|
||||
pub auto_size: Option<bool>,
|
||||
|
||||
/// Model orientation. fal's default is `Default` when `None`.
|
||||
pub orientation: Option<Tripo3dH3p1Orientation>,
|
||||
|
||||
/// Generate quad mesh topology instead of triangles (returns FBX).
|
||||
/// Adds cost. fal's default is false when `None`.
|
||||
pub quad: Option<bool>,
|
||||
}
|
||||
|
||||
impl FalEndpoint for Tripo3dH3p1MultiviewToMeshRequest {
|
||||
const ENDPOINT: &str = "tripo3d/h3.1/multiview-to-3d";
|
||||
|
||||
type RawRequest = Tripo3dH3p1MultiviewToMeshInput;
|
||||
type RawResponse = Tripo3dH3p1MultiviewToMeshOutput;
|
||||
|
||||
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
|
||||
Ok(Self::RawRequest {
|
||||
image_urls: self.image_urls.clone(),
|
||||
face_limit: self.face_limit,
|
||||
texture: self.texture,
|
||||
pbr: self.pbr,
|
||||
model_seed: self.model_seed,
|
||||
texture_seed: self.texture_seed,
|
||||
texture_quality: self.texture_quality.map(|q| q.to_str().to_string()),
|
||||
geometry_quality: self.geometry_quality.map(|q| q.to_str().to_string()),
|
||||
texture_alignment: self.texture_alignment.map(|a| a.to_str().to_string()),
|
||||
auto_size: self.auto_size,
|
||||
orientation: self.orientation.map(|o| o.to_str().to_string()),
|
||||
quad: self.quad,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[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, JUNO_AT_LAKE_IMAGE_URL};
|
||||
|
||||
fn base_request() -> Tripo3dH3p1MultiviewToMeshRequest {
|
||||
Tripo3dH3p1MultiviewToMeshRequest {
|
||||
image_urls: vec![
|
||||
"https://example.com/front.jpg".to_string(),
|
||||
"https://example.com/left.jpg".to_string(),
|
||||
],
|
||||
face_limit: None,
|
||||
texture: None,
|
||||
pbr: None,
|
||||
model_seed: None,
|
||||
texture_seed: None,
|
||||
texture_quality: None,
|
||||
geometry_quality: None,
|
||||
texture_alignment: None,
|
||||
auto_size: None,
|
||||
orientation: None,
|
||||
quad: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn test_multiview_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 = Tripo3dH3p1MultiviewToMeshRequest {
|
||||
image_urls: vec![
|
||||
ERNEST_SCARED_STUPID_IMAGE_URL.to_string(),
|
||||
JUNO_AT_LAKE_IMAGE_URL.to_string(),
|
||||
],
|
||||
..base_request()
|
||||
};
|
||||
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_multiview_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 = Tripo3dH3p1MultiviewToMeshRequest {
|
||||
image_urls: vec![
|
||||
ERNEST_SCARED_STUPID_IMAGE_URL.to_string(),
|
||||
JUNO_AT_LAKE_IMAGE_URL.to_string(),
|
||||
],
|
||||
..base_request()
|
||||
};
|
||||
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_core_fields() {
|
||||
let request = Tripo3dH3p1MultiviewToMeshRequest {
|
||||
texture_quality: Some(Tripo3dH3p1ImageTextureQuality::Detailed),
|
||||
quad: Some(true),
|
||||
..base_request()
|
||||
};
|
||||
let json = serde_json::to_value(request.to_raw_request().unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
json,
|
||||
serde_json::json!({
|
||||
"image_urls": ["https://example.com/front.jpg", "https://example.com/left.jpg"],
|
||||
"texture_quality": "detailed",
|
||||
"quad": true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_request_omits_unset_optionals() {
|
||||
let json = serde_json::to_value(base_request().to_raw_request().unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
json,
|
||||
serde_json::json!({
|
||||
"image_urls": ["https://example.com/front.jpg", "https://example.com/left.jpg"],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_path_is_canonical() {
|
||||
assert_eq!(Tripo3dH3p1MultiviewToMeshRequest::ENDPOINT, "tripo3d/h3.1/multiview-to-3d");
|
||||
}
|
||||
|
||||
// NB: Pricing tests are in cost.rs
|
||||
}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
use crate::requests::api::mesh::image::tripo3d_h3p1_image_to_mesh::api::{
|
||||
Tripo3dH3p1ImageGeometryQuality, Tripo3dH3p1ImageTextureQuality,
|
||||
};
|
||||
use crate::requests::api::mesh::multiview::tripo3d_h3p1_multiview_to_mesh::api::Tripo3dH3p1MultiviewToMeshRequest;
|
||||
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
|
||||
|
||||
impl FalRequestCostCalculator for Tripo3dH3p1MultiviewToMeshRequest {
|
||||
fn calculate_cost_in_cents(&self) -> UsdCents {
|
||||
// Pricing (see https://fal.ai/models/tripo3d/h3.1/multiview-to-3d):
|
||||
// "Your request will cost $0.10 (without textures), $0.20 (with standard
|
||||
// textures), or $0.30 (with HD textures), plus an additional $0.20 for
|
||||
// detailed geometry and $0.05 for quad mesh if selected."
|
||||
//
|
||||
// Same tiers as text-to-3d (NOT the +10¢ image-to-3d tiers).
|
||||
// `pbr` implies texturing and ALSO defaults to true, so the cheap
|
||||
// untextured tier only applies when both `texture` and `pbr` are
|
||||
// explicitly disabled. Anything else may texture server-side; bill the
|
||||
// textured tier so we never undercharge.
|
||||
let texture_enabled = self.texture.unwrap_or(true) || self.pbr.unwrap_or(true);
|
||||
let mut cost: u64 = if !texture_enabled {
|
||||
10
|
||||
} else {
|
||||
match self.texture_quality {
|
||||
Some(Tripo3dH3p1ImageTextureQuality::Detailed) => 30,
|
||||
None | Some(Tripo3dH3p1ImageTextureQuality::Standard) => 20,
|
||||
}
|
||||
};
|
||||
if matches!(self.geometry_quality, Some(Tripo3dH3p1ImageGeometryQuality::Detailed)) {
|
||||
cost += 20;
|
||||
}
|
||||
if self.quad.unwrap_or(false) {
|
||||
cost += 5;
|
||||
}
|
||||
cost
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn base_request() -> Tripo3dH3p1MultiviewToMeshRequest {
|
||||
Tripo3dH3p1MultiviewToMeshRequest {
|
||||
image_urls: vec![
|
||||
"https://example.com/front.jpg".to_string(),
|
||||
"https://example.com/left.jpg".to_string(),
|
||||
],
|
||||
face_limit: None,
|
||||
texture: None,
|
||||
pbr: None,
|
||||
model_seed: None,
|
||||
texture_seed: None,
|
||||
texture_quality: None,
|
||||
geometry_quality: None,
|
||||
texture_alignment: None,
|
||||
auto_size: None,
|
||||
orientation: None,
|
||||
quad: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_is_standard_textures() {
|
||||
assert_eq!(base_request().calculate_cost_in_cents(), 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_texture_requires_pbr_off_too() {
|
||||
let mut req = base_request();
|
||||
req.texture = Some(false);
|
||||
req.pbr = Some(false);
|
||||
assert_eq!(req.calculate_cost_in_cents(), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn texture_off_but_pbr_default_bills_textured_tier() {
|
||||
// `pbr` defaults to true and implies texturing.
|
||||
let mut req = base_request();
|
||||
req.texture = Some(false);
|
||||
assert_eq!(req.calculate_cost_in_cents(), 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detailed_texture() {
|
||||
let mut req = base_request();
|
||||
req.texture_quality = Some(Tripo3dH3p1ImageTextureQuality::Detailed);
|
||||
assert_eq!(req.calculate_cost_in_cents(), 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_count_does_not_change_cost() {
|
||||
let mut req = base_request();
|
||||
req.image_urls.push("https://example.com/back.jpg".to_string());
|
||||
req.image_urls.push("https://example.com/right.jpg".to_string());
|
||||
assert_eq!(req.calculate_cost_in_cents(), 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn everything_stacks() {
|
||||
let mut req = base_request();
|
||||
req.texture_quality = Some(Tripo3dH3p1ImageTextureQuality::Detailed);
|
||||
req.geometry_quality = Some(Tripo3dH3p1ImageGeometryQuality::Detailed);
|
||||
req.quad = Some(true);
|
||||
// 30 + 20 + 5 = 55
|
||||
assert_eq!(req.calculate_cost_in_cents(), 55);
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod api;
|
||||
pub mod cost;
|
||||
pub mod raw_request;
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Over-the-wire input shape for `tripo3d/h3.1/multiview-to-3d`.
|
||||
/// fal's schema: <https://fal.ai/models/tripo3d/h3.1/multiview-to-3d/api>
|
||||
#[derive(Debug, Serialize, Deserialize, Default)]
|
||||
pub struct Tripo3dH3p1MultiviewToMeshInput {
|
||||
/// 2 to 4 image URLs of the same object from different angles.
|
||||
/// Order: [front, left, back, right].
|
||||
pub image_urls: Vec<String>,
|
||||
|
||||
/// Range 1000-2000000. Adaptive when omitted.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub face_limit: Option<u32>,
|
||||
|
||||
/// Enable texture generation. fal default: true.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub texture: Option<bool>,
|
||||
|
||||
/// Enable PBR materials (implies texture). fal default: true.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub pbr: Option<bool>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model_seed: Option<i64>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub texture_seed: Option<i64>,
|
||||
|
||||
/// Options: "standard", "detailed". fal default: "standard".
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub texture_quality: Option<String>,
|
||||
|
||||
/// Options: "standard", "detailed". fal default: "standard".
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub geometry_quality: Option<String>,
|
||||
|
||||
/// Options: "original_image", "geometry". fal default: "original_image".
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub texture_alignment: Option<String>,
|
||||
|
||||
/// Auto-scale the model to real-world dimensions (meters). fal default: false.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub auto_size: Option<bool>,
|
||||
|
||||
/// Options: "default", "align_image". fal default: "default".
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub orientation: Option<String>,
|
||||
|
||||
/// Generate quad mesh topology instead of triangles. fal default: false.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub quad: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Tripo3dH3p1MultiviewToMeshOutput {}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user