Compare commits

..
Author SHA1 Message Date
Brandon Thomas fd8fe7f3db fix tests 2026-07-08 16:17:12 -04:00
Brandon Thomas 54b172bf6f test on api client 2026-07-08 16:08:44 -04:00
Brandon Thomas d7868c7da0 don't validate cost estimates 2026-07-08 15:44:56 -04:00
BFlat d538bc2a68 Add 3D Object & 3D World generation to the webapp (#1756)
Adds two new create pages for 3D generation, 3D Object (meshes) and 3D
World (gaussian splats), wired up to the new omni-gen mesh/splat
endpoints, plus the navigation, icons, and polish to go with them.

### New create pages
- 3D Object (`/create-object`): turn a prompt or image into a 3D model.
- 3D World (`/create-world`): turn a prompt or image into an explorable
world.
- Both mirror the existing Create Image/Video experience: prompt box
with model selector and per-model options in the bottom bar, and a live
feed of in-progress, completed, and failed generations.
- Options adapt to the selected model, e.g. output type, polygon type,
geometry/texture quality, face count, PBR and texture toggles,
multi-view reference angles (front/back/left/right), mesh-file input,
reference image/video, panorama, and prompt-only where supported.
- Multi-view and reference slots support the same Upload /
Pick-from-library flow (desktop hover and mobile) as the image rows.
- Completed cards show the model's cover screenshot; clicking one opens
it in the interactive 3D viewer.

### Navigation and discovery
- Sidebar reorganized into two groups: Create (Image, Video, Audio, 3D
Object, 3D World) and a new Studio group (Edit Image, Edit 3D, Edit
Video, BG Change, Moodboard).
- Added 3D Object and 3D World cards to the Home page.
- Added breadcrumbs for the new pages.
- Added a My Library button to the top bar, next to the credits and
task-queue chips.

### Model icons
- Restored the Hunyuan (Tencent) and World Labs provider icons.
- Added Tripo, Meshy, and Rodin (Hyper3D) icons.
- Applied to both the webapp and desktop app.

### Fixes and polish
- Fixed the 3D-model icons showing a generic placeholder (missing cyan
color plus creator mappings).
- Fixed the Upload / Pick-from-library hover popover being clipped
inside glass panels.
- Reference-image rows in the 3D pages now align their fields to the
right with the title on the left, matching the other create pages.
- Prevented 3D-model cover screenshots from showing up as their own "3D
object" cards across the 3D feeds, the Library page, and the gallery
picker.
2026-07-08 23:03:11 +07:00
BFlat bec8c7ede4 Add Create Audio pages (Suno + Seed Audio) with waveform gallery playback to desktop and webapp (#1748)
Frontend for the new omni audio API
(`/v1/omni_gen/{models,generate,cost}/audio`).

### New pages
- **Webapp**: `/create-audio` page (desktop layout + mobile
Generate/History tabs with settings drawers), added to sidebar.
- **Desktop**: new `AUDIO` tab ("Create Audio") with `PromptBoxAudio`,
registered in the app menu / TopBar. Audio enqueues over HTTP and polls
the jobs feed (Tauri task queue untouched).

### Models & promptbox
- Suno Music / Remix / Sounds / Sample + Seed Audio 1.0, fetched live
from `GET /v1/omni_gen/models/audio` with per-model capability gating.
- Always-visible **style prompt** row (Suno tags), instrumental /
keep-lyrics / loop toggles, BPM + musical key popover (Suno Sounds),
sample rate + speed/volume/pitch tuning popover (Seed Audio).
- Audio/image reference uploads with server rules mirrored client-side
(remix/sample require exactly 1 audio track; Seed Audio can't combine
audio + image refs).
- One request can return multiple job tokens (Suno multi-clip) - every
token becomes its own pending card.

### Audio playback in galleries
- New **`@storyteller/ui-audio-player`** lib (wavesurfer.js v7):
waveform + seek, play/pause, volume, duration - only one player plays at
a time.
- Lazy waveform decode (IntersectionObserver + peaks cache) and a
no-waveform `<audio>` fallback if CORS/decode fails.
- Audio cards/rows added once in the shared `ui-generation-list`, so
both hosts' grid + list views get them.
- Audio playback in both lightboxes (webapp lightbox + desktop
lightbox-modal); Library gets an **Audio** filter tab and includes audio
in "All"; downloads keep the right extension.

### Shared plumbing
- `OmniGenApi` gains audio types + `getAudioModels` / `generateAudio` /
`estimateAudioCost`.
- `useOmniGenAudioModels`, request builder, enqueue + cost hook shared
via `@storyteller/omni-gen` (used by both hosts).
- `useGenerationJobs` moved from the webapp into `ui-generation-list`
(with re-export shim) and taught the `audio_generation` category - the
desktop audio feed reuses it.
- Suno model names/descriptions/creator icon added to `model-list`.
2026-07-08 18:18:03 +07:00
Brandon Thomas 0866e63dd4 claude instruction 2026-07-08 02:26:52 -04:00
Brandon Thomas 78a5c4ff87 handle many more mesh models (#1755) 2026-07-08 01:02:37 -04:00
Brandon Thomas d53fad0072 user input errors on seedance (#1754) 2026-07-07 23:58:24 -04:00
Brandon Thomas 2e5e9443a3 fix models (#1753) 2026-07-07 20:29:24 -04:00
278 changed files with 20226 additions and 802 deletions
+17
View File
@@ -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.
@@ -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>,
}
@@ -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,
@@ -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();
@@ -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(),
}
}
@@ -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,
}
}
}
@@ -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,
})
}
@@ -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)))
}
@@ -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")
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -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
}
}
@@ -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)))
}
@@ -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")
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -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
}
}
@@ -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)))
}
@@ -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")
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -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
}
}
@@ -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)))
}
@@ -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")
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -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
}
}
@@ -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)))
}
@@ -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")
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -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
}
}
@@ -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;
@@ -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))
}
}
}
@@ -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)))
}
@@ -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")
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -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
}
}
@@ -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)))
}
@@ -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")
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -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
}
}
@@ -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()
}
}
}
@@ -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")
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -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");
}
}
@@ -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:?}"),
}
}
}
@@ -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")
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -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
}
}
@@ -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:?}"),
}
}
}
@@ -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")
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -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
}
}
@@ -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()
}
}
}
@@ -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")
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -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");
}
}
@@ -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:?}"),
}
}
}
@@ -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")
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -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
}
}
@@ -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;
@@ -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(
@@ -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:?}"),
}
}
}
@@ -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")
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -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
}
}
@@ -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:?}"),
}
}
}
@@ -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")
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -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
}
}
@@ -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
}
@@ -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()
};
@@ -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),
}
}
}
@@ -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
}
@@ -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);
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -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 {}
@@ -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
}
@@ -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);
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -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 {}
@@ -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
}
@@ -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);
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -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;
@@ -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
}
@@ -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);
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -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 {}
@@ -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
}
@@ -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);
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -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;
@@ -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
}
@@ -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);
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -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