mesh and splat omni endpoints (#1752)

This commit is contained in:
Brandon Thomas authored and GitHub committed 2026-07-07 19:25:49 -04:00
1 parent 9cf1b8e066
commit 5bda2a6cb4
212 files changed
+10676 -15

No files matched your search

@@ -1,3 +1,5 @@
pub mod omni_gen_audio_cost_and_generate_request;
pub mod omni_gen_image_cost_and_generate_request;
pub mod omni_gen_mesh_cost_and_generate_request;
pub mod omni_gen_splat_cost_and_generate_request;
pub mod omni_gen_video_cost_and_generate_request;
@@ -0,0 +1,52 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use enums::common::generation::common_mesh_model::CommonMeshModel;
use enums::common::generation::common_mesh_output_type::CommonMeshOutputType;
use enums::common::generation::common_polygon_type::CommonPolygonType;
use tokens::tokens::media_files::MediaFileToken;
/// Shared request body for both the mesh cost estimate and mesh generation endpoints.
#[derive(Clone, Serialize, Deserialize, ToSchema, Debug)]
pub struct OmniGenMeshCostAndGenerateRequest {
/// REQUIRED (even if marked optional)
/// Idempotency token to prevent duplicate requests.
pub idempotency_token: Option<String>,
/// REQUIRED (even if marked optional)
/// Which model to use.
pub model: Option<CommonMeshModel>,
/// The text prompt for the mesh generation (text-to-3D).
pub prompt: Option<String>,
/// Reference images (optional).
/// The primary/front input image, or the sketch image for sketch-to-3D.
pub reference_image_media_tokens: Option<Vec<MediaFileToken>>,
/// Front view image (optional; multi-view input).
/// Can be used instead of `reference_image_media_tokens[0]` when the other
/// side views are supplied.
pub front_image_media_token: Option<MediaFileToken>,
/// Back view image (optional; multi-view input).
pub back_image_media_token: Option<MediaFileToken>,
/// Left view image (optional; multi-view input).
pub left_image_media_token: Option<MediaFileToken>,
/// Right view image (optional; multi-view input).
pub right_image_media_token: Option<MediaFileToken>,
/// The mesh output type to generate (normal, low-poly, or geometry-only).
pub mesh_output_type: Option<CommonMeshOutputType>,
/// The polygon type to use for the generated mesh (triangle or quad).
pub polygon_type: Option<CommonPolygonType>,
/// Target face count for the generated mesh.
pub face_count: Option<u64>,
/// Whether to generate PBR (physically based rendering) materials.
pub enable_pbr: Option<bool>,
}
@@ -0,0 +1,34 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use enums::common::generation::common_splat_model::CommonSplatModel;
use tokens::tokens::media_files::MediaFileToken;
/// Shared request body for both the splat cost estimate and splat generation endpoints.
#[derive(Clone, Serialize, Deserialize, ToSchema, Debug)]
pub struct OmniGenSplatCostAndGenerateRequest {
/// REQUIRED (even if marked optional)
/// Idempotency token to prevent duplicate requests.
pub idempotency_token: Option<String>,
/// REQUIRED (even if marked optional)
/// Which model to use.
pub model: Option<CommonSplatModel>,
/// The text prompt for the splat generation.
pub prompt: Option<String>,
/// Reference images (optional).
/// NOTE: Spherical/panorama coordinates are not yet supported per-image;
/// multiple images are treated as multi-view input of the same world.
pub reference_image_media_tokens: Option<Vec<MediaFileToken>>,
/// Reference video (optional).
pub reference_video_media_token: Option<MediaFileToken>,
/// Whether the single reference image is a 360-degree panorama.
pub is_panoramic: Option<bool>,
/// Whether to disable prompt recaptioning.
pub disable_recaption: Option<bool>,
}
@@ -1,3 +1,5 @@
pub mod omni_gen_audio_cost_response;
pub mod omni_gen_image_cost_response;
pub mod omni_gen_mesh_cost_response;
pub mod omni_gen_splat_cost_response;
pub mod omni_gen_video_cost_response;
@@ -0,0 +1,32 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
/// Response body for the omni-gen mesh cost estimate endpoint.
#[derive(Serialize, Deserialize, ToSchema)]
pub struct OmniGenMeshCostResponse {
pub success: bool,
/// Estimated cost in credits.
pub cost_in_credits: Option<u64>,
/// Estimated cost in USD cents.
pub cost_in_usd_cents: Option<u64>,
/// Whether the generation is free for this user/plan.
pub is_free: bool,
/// Whether the user has unlimited generations.
pub is_unlimited: bool,
/// Whether the user is rate-limited.
pub is_rate_limited: bool,
/// Whether the output will have a watermark.
pub has_watermark: bool,
/// Whether failures are refunded.
/// True: 100% yes
/// False: 100% no
/// None: Unknown or variable
pub failures_are_refunded: Option<bool>,
}
@@ -0,0 +1,32 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
/// Response body for the omni-gen splat cost estimate endpoint.
#[derive(Serialize, Deserialize, ToSchema)]
pub struct OmniGenSplatCostResponse {
pub success: bool,
/// Estimated cost in credits.
pub cost_in_credits: Option<u64>,
/// Estimated cost in USD cents.
pub cost_in_usd_cents: Option<u64>,
/// Whether the generation is free for this user/plan.
pub is_free: bool,
/// Whether the user has unlimited generations.
pub is_unlimited: bool,
/// Whether the user is rate-limited.
pub is_rate_limited: bool,
/// Whether the output will have a watermark.
pub has_watermark: bool,
/// Whether failures are refunded.
/// True: 100% yes
/// False: 100% no
/// None: Unknown or variable
pub failures_are_refunded: Option<bool>,
}
@@ -1,3 +1,5 @@
pub mod omni_gen_audio_generate_response;
pub mod omni_gen_image_generate_response;
pub mod omni_gen_mesh_generate_response;
pub mod omni_gen_splat_generate_response;
pub mod omni_gen_video_generate_response;
@@ -0,0 +1,16 @@
use serde_derive::{Deserialize, Serialize};
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use utoipa::ToSchema;
/// Response body for the omni-gen mesh generation endpoint.
#[derive(Serialize, Deserialize, ToSchema)]
pub struct OmniGenMeshGenerateResponse {
pub success: bool,
pub inference_job_token: InferenceJobToken,
/// All job tokens created by this request (including the primary).
/// For single-job requests this will contain just one element matching
/// `inference_job_token`. For batch requests this will contain all of them.
pub all_job_tokens: Vec<InferenceJobToken>,
}
@@ -0,0 +1,16 @@
use serde_derive::{Deserialize, Serialize};
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use utoipa::ToSchema;
/// Response body for the omni-gen splat generation endpoint.
#[derive(Serialize, Deserialize, ToSchema)]
pub struct OmniGenSplatGenerateResponse {
pub success: bool,
pub inference_job_token: InferenceJobToken,
/// All job tokens created by this request (including the primary).
/// For single-job requests this will contain just one element matching
/// `inference_job_token`. For batch requests this will contain all of them.
pub all_job_tokens: Vec<InferenceJobToken>,
}
@@ -1,3 +1,5 @@
pub mod omni_gen_audio_models;
pub mod omni_gen_image_models;
pub mod omni_gen_mesh_models;
pub mod omni_gen_splat_models;
pub mod omni_gen_video_models;
@@ -0,0 +1,113 @@
use enums::common::generation::common_mesh_model::CommonMeshModel;
use enums::common::generation::common_mesh_output_type::CommonMeshOutputType;
use enums::common::generation::common_polygon_type::CommonPolygonType;
use enums::common::generation::model_creator::ModelCreator;
use enums::common::generation_provider::GenerationProvider;
use serde_derive::Serialize;
use utoipa::ToSchema;
/// Mesh model to default to if none is specified
const DEFAULT_MESH_MODEL : CommonMeshModel = CommonMeshModel::Hunyuan3d3;
/// Response body for the mesh models endpoint.
#[derive(Serialize, ToSchema, Clone)]
pub struct OmniGenMeshModelsResponse {
pub success: bool,
/// A list of all models: details, features, and capabilities
pub models: Vec<OmniGenMeshModelDetails>,
/// Provider-by-provider model offering and capability list,
/// with possible capability overrides (future)
pub providers: Vec<OmniGenMeshModelProviderDetails>,
}
#[derive(Serialize, ToSchema, Clone)]
pub struct OmniGenMeshModelProviderDetails {
pub provider: GenerationProvider,
pub models: Vec<OmniGenMeshProviderModelDetails>,
}
#[derive(Serialize, ToSchema, Clone)]
pub struct OmniGenMeshProviderModelDetails {
pub model: CommonMeshModel,
#[serde(skip_serializing_if = "Option::is_none")]
pub overrides: Option<OmniGenMeshModelDetails>,
}
#[derive(Serialize, ToSchema, Clone)]
pub struct OmniGenMeshModelDetails {
pub model: CommonMeshModel,
#[serde(skip_serializing_if = "Option::is_none")]
pub model_creator: Option<ModelCreator>,
#[serde(skip_serializing_if = "Option::is_none")]
pub full_name: Option<String>,
/// Additional details about the model.
#[serde(skip_serializing_if = "Option::is_none")]
pub extra_info: Option<String>,
/// Additional details about the model. (Brief; only a few words.)
#[serde(skip_serializing_if = "Option::is_none")]
pub extra_info_short: Option<String>,
/// Whether text-to-3D prompting is supported.
#[serde(skip_serializing_if = "Option::is_none")]
pub text_prompt_supported: Option<bool>,
/// Whether image-to-3D input is supported.
#[serde(skip_serializing_if = "Option::is_none")]
pub image_input_supported: Option<bool>,
/// Whether sketch-to-3D input is supported.
#[serde(skip_serializing_if = "Option::is_none")]
pub sketch_input_supported: Option<bool>,
/// Whether multi-view (front/back/left/right) image input is supported.
#[serde(skip_serializing_if = "Option::is_none")]
pub multi_view_supported: Option<bool>,
/// The mesh output types the model supports.
#[serde(skip_serializing_if = "Option::is_none")]
pub mesh_output_types: Option<Vec<CommonMeshOutputType>>,
/// The polygon types the model supports.
#[serde(skip_serializing_if = "Option::is_none")]
pub polygon_types: Option<Vec<CommonPolygonType>>,
/// Whether a target face count can be specified.
#[serde(skip_serializing_if = "Option::is_none")]
pub face_count_supported: Option<bool>,
/// Whether PBR (physically based rendering) material generation is supported.
#[serde(skip_serializing_if = "Option::is_none")]
pub pbr_supported: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub is_disabled: Option<bool>,
}
impl Default for OmniGenMeshModelDetails {
fn default() -> Self {
Self {
model: DEFAULT_MESH_MODEL,
model_creator: None,
full_name: None,
extra_info: None,
extra_info_short: None,
text_prompt_supported: None,
image_input_supported: None,
sketch_input_supported: None,
multi_view_supported: None,
mesh_output_types: None,
polygon_types: None,
face_count_supported: None,
pbr_supported: None,
is_disabled: None,
}
}
}
@@ -0,0 +1,98 @@
use enums::common::generation::common_splat_model::CommonSplatModel;
use enums::common::generation::model_creator::ModelCreator;
use enums::common::generation_provider::GenerationProvider;
use serde_derive::Serialize;
use utoipa::ToSchema;
/// Splat model to default to if none is specified
const DEFAULT_SPLAT_MODEL : CommonSplatModel = CommonSplatModel::Marble1p1;
/// Response body for the splat models endpoint.
#[derive(Serialize, ToSchema, Clone)]
pub struct OmniGenSplatModelsResponse {
pub success: bool,
/// A list of all models: details, features, and capabilities
pub models: Vec<OmniGenSplatModelDetails>,
/// Provider-by-provider model offering and capability list,
/// with possible capability overrides (future)
pub providers: Vec<OmniGenSplatModelProviderDetails>,
}
#[derive(Serialize, ToSchema, Clone)]
pub struct OmniGenSplatModelProviderDetails {
pub provider: GenerationProvider,
pub models: Vec<OmniGenSplatProviderModelDetails>,
}
#[derive(Serialize, ToSchema, Clone)]
pub struct OmniGenSplatProviderModelDetails {
pub model: CommonSplatModel,
#[serde(skip_serializing_if = "Option::is_none")]
pub overrides: Option<OmniGenSplatModelDetails>,
}
#[derive(Serialize, ToSchema, Clone)]
pub struct OmniGenSplatModelDetails {
pub model: CommonSplatModel,
#[serde(skip_serializing_if = "Option::is_none")]
pub model_creator: Option<ModelCreator>,
#[serde(skip_serializing_if = "Option::is_none")]
pub full_name: Option<String>,
/// Additional details about the model.
#[serde(skip_serializing_if = "Option::is_none")]
pub extra_info: Option<String>,
/// Additional details about the model. (Brief; only a few words.)
#[serde(skip_serializing_if = "Option::is_none")]
pub extra_info_short: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub text_prompt_supported: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub image_references_supported: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub image_references_max: Option<u16>,
/// Whether a reference video input is supported.
#[serde(skip_serializing_if = "Option::is_none")]
pub video_reference_supported: Option<bool>,
/// Whether a 360-degree panorama reference image is supported.
#[serde(skip_serializing_if = "Option::is_none")]
pub panorama_supported: Option<bool>,
/// Whether the "disable recaption" toggle is supported.
#[serde(skip_serializing_if = "Option::is_none")]
pub disable_recaption_supported: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub is_disabled: Option<bool>,
}
impl Default for OmniGenSplatModelDetails {
fn default() -> Self {
Self {
model: DEFAULT_SPLAT_MODEL,
model_creator: None,
full_name: None,
extra_info: None,
extra_info_short: None,
text_prompt_supported: None,
image_references_supported: None,
image_references_max: None,
video_reference_supported: None,
panorama_supported: None,
disable_recaption_supported: None,
is_disabled: None,
}
}
}
@@ -0,0 +1 @@
pub mod omni_gen_mesh;
@@ -0,0 +1,21 @@
use crate::credentials::storyteller_credential_set::StorytellerCredentialSet;
use crate::error::storyteller_error::StorytellerError;
use crate::utils::api_host::ApiHost;
use crate::utils::basic_json_post_request::basic_json_post_request;
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_mesh_cost_and_generate_request::OmniGenMeshCostAndGenerateRequest;
use artcraft_api_defs::omni_gen::generate_response::omni_gen_mesh_generate_response::OmniGenMeshGenerateResponse;
pub const OMNI_GEN_MESH_GENERATE_PATH: &str = "/v1/omni_gen/generate/mesh";
pub async fn omni_gen_mesh_generate(
api_host: &ApiHost,
maybe_creds: Option<&StorytellerCredentialSet>,
request: OmniGenMeshCostAndGenerateRequest,
) -> Result<OmniGenMeshGenerateResponse, StorytellerError> {
Ok(basic_json_post_request(
api_host,
OMNI_GEN_MESH_GENERATE_PATH,
maybe_creds,
request,
).await?)
}
@@ -1,3 +1,5 @@
pub mod audio;
pub mod image;
pub mod mesh;
pub mod splat;
pub mod video;
@@ -0,0 +1 @@
pub mod omni_gen_splat;
@@ -0,0 +1,21 @@
use crate::credentials::storyteller_credential_set::StorytellerCredentialSet;
use crate::error::storyteller_error::StorytellerError;
use crate::utils::api_host::ApiHost;
use crate::utils::basic_json_post_request::basic_json_post_request;
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_splat_cost_and_generate_request::OmniGenSplatCostAndGenerateRequest;
use artcraft_api_defs::omni_gen::generate_response::omni_gen_splat_generate_response::OmniGenSplatGenerateResponse;
pub const OMNI_GEN_SPLAT_GENERATE_PATH: &str = "/v1/omni_gen/generate/splat";
pub async fn omni_gen_splat_generate(
api_host: &ApiHost,
maybe_creds: Option<&StorytellerCredentialSet>,
request: OmniGenSplatCostAndGenerateRequest,
) -> Result<OmniGenSplatGenerateResponse, StorytellerError> {
Ok(basic_json_post_request(
api_host,
OMNI_GEN_SPLAT_GENERATE_PATH,
maybe_creds,
request,
).await?)
}
@@ -6,9 +6,11 @@ pub mod router_aspect_ratio;
pub mod router_audio_model;
pub mod router_bitrate;
pub mod router_image_model;
pub mod router_mesh_model;
pub mod router_provider;
pub mod router_quality;
pub mod router_resolution;
pub mod router_splat_model;
pub mod router_video_model;
pub mod video_list_ref;
pub mod video_ref;
@@ -0,0 +1,62 @@
use serde_derive::{Deserialize, Serialize};
/// Common mesh (3D object) models supported by the router.
/// Not all models are available through all providers.
#[derive(Copy, Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RouterMeshModel {
#[serde(rename = "hunyuan_3d_2p0")]
Hunyuan3d2p0,
#[serde(rename = "hunyuan_3d_2p1")]
Hunyuan3d2p1,
#[serde(rename = "hunyuan_3d_3")]
Hunyuan3d3,
/// Hunyuan 3D v3 in sketch-to-3D mode. Same underlying model as
/// [`Self::Hunyuan3d3`], but takes a sketch image plus a prompt as input.
#[serde(rename = "hunyuan_3d_3_sketch")]
Hunyuan3d3Sketch,
}
#[cfg(test)]
mod tests {
use super::*;
// NB: These strings must match `CommonMeshModel` — the two enums convert
// via serde string round-trip.
#[test]
fn all_variants_serialize_to_common_mesh_model_strings() {
assert_serde_round_trip(RouterMeshModel::Hunyuan3d2p0, "hunyuan_3d_2p0");
assert_serde_round_trip(RouterMeshModel::Hunyuan3d2p1, "hunyuan_3d_2p1");
assert_serde_round_trip(RouterMeshModel::Hunyuan3d3, "hunyuan_3d_3");
assert_serde_round_trip(RouterMeshModel::Hunyuan3d3Sketch, "hunyuan_3d_3_sketch");
}
#[test]
fn round_trips_through_common_mesh_model() {
use enums::common::generation::common_mesh_model::CommonMeshModel;
let cases = [
(RouterMeshModel::Hunyuan3d2p0, CommonMeshModel::Hunyuan3d2p0),
(RouterMeshModel::Hunyuan3d2p1, CommonMeshModel::Hunyuan3d2p1),
(RouterMeshModel::Hunyuan3d3, CommonMeshModel::Hunyuan3d3),
(RouterMeshModel::Hunyuan3d3Sketch, CommonMeshModel::Hunyuan3d3Sketch),
];
for (router_model, expected_common) in cases {
let json = serde_json::to_string(&router_model).unwrap();
let common: CommonMeshModel = serde_json::from_str(&json)
.unwrap_or_else(|e| panic!("CommonMeshModel failed to parse {json}: {e}"));
assert_eq!(common, expected_common, "for {router_model:?}");
}
}
fn assert_serde_round_trip(model: RouterMeshModel, expected: &str) {
let json = serde_json::to_string(&model).unwrap();
assert_eq!(json, format!("\"{expected}\""));
let parsed: RouterMeshModel = serde_json::from_str(&json).unwrap();
// RouterMeshModel isn't PartialEq, so round-trip back to the wire form.
assert_eq!(serde_json::to_string(&parsed).unwrap(), json);
}
}
@@ -9,4 +9,5 @@ pub enum RouterProvider {
GmiCloud,
GrokApi,
Seedance2Pro,
WorldLabs,
}
@@ -5,9 +5,68 @@ use serde_derive::{Deserialize, Serialize};
#[derive(Copy, Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RouterSplatModel {
/// Deprecated upstream; treated as [`Self::Marble1p0Draft`].
#[serde(rename = "marble_0p1_mini")]
Marble0p1Mini,
/// Deprecated upstream; treated as [`Self::Marble1p0`].
#[serde(rename = "marble_0p1_plus")]
Marble0p1Plus,
#[serde(rename = "marble_1p0")]
Marble1p0,
#[serde(rename = "marble_1p0_draft")]
Marble1p0Draft,
#[serde(rename = "marble_1p1")]
Marble1p1,
#[serde(rename = "marble_1p1_plus")]
Marble1p1Plus,
}
#[cfg(test)]
mod tests {
use super::*;
// NB: These strings must match `CommonSplatModel` — the two enums convert
// via serde string round-trip.
#[test]
fn all_variants_serialize_to_common_splat_model_strings() {
assert_serde_round_trip(RouterSplatModel::Marble0p1Mini, "marble_0p1_mini");
assert_serde_round_trip(RouterSplatModel::Marble0p1Plus, "marble_0p1_plus");
assert_serde_round_trip(RouterSplatModel::Marble1p0, "marble_1p0");
assert_serde_round_trip(RouterSplatModel::Marble1p0Draft, "marble_1p0_draft");
assert_serde_round_trip(RouterSplatModel::Marble1p1, "marble_1p1");
assert_serde_round_trip(RouterSplatModel::Marble1p1Plus, "marble_1p1_plus");
}
#[test]
fn round_trips_through_common_splat_model() {
use enums::common::generation::common_splat_model::CommonSplatModel;
let cases = [
(RouterSplatModel::Marble0p1Mini, CommonSplatModel::Marble0p1Mini),
(RouterSplatModel::Marble0p1Plus, CommonSplatModel::Marble0p1Plus),
(RouterSplatModel::Marble1p0, CommonSplatModel::Marble1p0),
(RouterSplatModel::Marble1p0Draft, CommonSplatModel::Marble1p0Draft),
(RouterSplatModel::Marble1p1, CommonSplatModel::Marble1p1),
(RouterSplatModel::Marble1p1Plus, CommonSplatModel::Marble1p1Plus),
];
for (router_model, expected_common) in cases {
let json = serde_json::to_string(&router_model).unwrap();
let common: CommonSplatModel = serde_json::from_str(&json)
.unwrap_or_else(|e| panic!("CommonSplatModel failed to parse {json}: {e}"));
assert_eq!(common, expected_common, "for {router_model:?}");
}
}
fn assert_serde_round_trip(model: RouterSplatModel, expected: &str) {
let json = serde_json::to_string(&model).unwrap();
assert_eq!(json, format!("\"{expected}\""));
let parsed: RouterSplatModel = serde_json::from_str(&json).unwrap();
// RouterSplatModel isn't PartialEq, so round-trip back to the wire form.
assert_eq!(serde_json::to_string(&parsed).unwrap(), json);
}
}
@@ -0,0 +1,7 @@
use tokens::tokens::media_files::MediaFileToken;
#[derive(Clone, Debug)]
pub enum VideoRef {
MediaFileToken(MediaFileToken),
Url(String),
}
@@ -8,3 +8,4 @@ pub mod router_fal_client;
pub mod router_gmicloud_client;
pub mod router_grok_api_client;
pub mod router_seedance2pro_client;
pub mod router_worldlabs_client;
@@ -3,6 +3,7 @@ use crate::client::router_fal_client::RouterFalClient;
use crate::client::router_gmicloud_client::RouterGmiCloudClient;
use crate::client::router_grok_api_client::RouterGrokApiClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::client::router_worldlabs_client::RouterWorldLabsClient;
use crate::errors::client_error::{ClientError, ClientType};
pub struct MultiRouterClient {
@@ -11,6 +12,7 @@ pub struct MultiRouterClient {
pub(crate) gmicloud_client: Option<RouterGmiCloudClient>,
pub(crate) grok_api_client: Option<RouterGrokApiClient>,
pub(crate) seedance2pro_client: Option<RouterSeedance2ProClient>,
pub(crate) worldlabs_client: Option<RouterWorldLabsClient>,
}
impl MultiRouterClient {
@@ -38,4 +40,9 @@ impl MultiRouterClient {
self.seedance2pro_client.as_ref()
.ok_or(ClientError::ClientNotConfigured(ClientType::Seedance2Pro))
}
pub fn get_worldlabs_client_ref(&self) -> Result<&RouterWorldLabsClient, ClientError> {
self.worldlabs_client.as_ref()
.ok_or(ClientError::ClientNotConfigured(ClientType::WorldLabs))
}
}
@@ -4,6 +4,7 @@ use crate::client::router_fal_client::RouterFalClient;
use crate::client::router_gmicloud_client::RouterGmiCloudClient;
use crate::client::router_grok_api_client::RouterGrokApiClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::client::router_worldlabs_client::RouterWorldLabsClient;
pub struct MultiRouterClientBuilder {
artcraft_client: Option<RouterArtcraftClient>,
@@ -11,6 +12,7 @@ pub struct MultiRouterClientBuilder {
gmicloud_client: Option<RouterGmiCloudClient>,
grok_api_client: Option<RouterGrokApiClient>,
seedance2pro_client: Option<RouterSeedance2ProClient>,
worldlabs_client: Option<RouterWorldLabsClient>,
}
impl MultiRouterClientBuilder {
@@ -21,6 +23,7 @@ impl MultiRouterClientBuilder {
gmicloud_client: None,
grok_api_client: None,
seedance2pro_client: None,
worldlabs_client: None,
}
}
@@ -49,6 +52,11 @@ impl MultiRouterClientBuilder {
self
}
pub fn set_worldlabs_client(mut self, client: RouterWorldLabsClient) -> Self {
self.worldlabs_client = Some(client);
self
}
pub fn build(self) -> MultiRouterClient {
MultiRouterClient {
artcraft_client: self.artcraft_client,
@@ -56,6 +64,7 @@ impl MultiRouterClientBuilder {
gmicloud_client: self.gmicloud_client,
grok_api_client: self.grok_api_client,
seedance2pro_client: self.seedance2pro_client,
worldlabs_client: self.worldlabs_client,
}
}
}
@@ -4,6 +4,7 @@ use crate::client::router_fal_client::RouterFalClient;
use crate::client::router_gmicloud_client::RouterGmiCloudClient;
use crate::client::router_grok_api_client::RouterGrokApiClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::client::router_worldlabs_client::RouterWorldLabsClient;
use crate::errors::client_error::{ClientError, ClientType};
pub enum RouterClient {
@@ -13,6 +14,7 @@ pub enum RouterClient {
GmiCloud(RouterGmiCloudClient),
GrokApi(RouterGrokApiClient),
Seedance2Pro(RouterSeedance2ProClient),
WorldLabs(RouterWorldLabsClient),
}
impl RouterClient {
@@ -55,4 +57,12 @@ impl RouterClient {
_ => Err(ClientError::ClientNotConfigured(ClientType::Seedance2Pro)),
}
}
pub fn get_worldlabs_client_ref(&self) -> Result<&RouterWorldLabsClient, ClientError> {
match self {
RouterClient::WorldLabs(client) => Ok(client),
RouterClient::Multi(multi) => multi.get_worldlabs_client_ref(),
_ => Err(ClientError::ClientNotConfigured(ClientType::WorldLabs)),
}
}
}
@@ -0,0 +1,16 @@
use worldlabs_api_client::credentials::world_labs_api_creds::WorldLabsApiCreds;
/// A client for the official World Labs (Marble) API.
pub struct RouterWorldLabsClient {
pub(crate) creds: WorldLabsApiCreds,
}
impl RouterWorldLabsClient {
pub fn new(creds: WorldLabsApiCreds) -> Self {
Self { creds }
}
pub fn new_from_raw_key(api_key: &str) -> Self {
Self::new(WorldLabsApiCreds::new(api_key.to_string()))
}
}
@@ -10,6 +10,7 @@ pub enum ClientType {
GmiCloud,
GrokApi,
Seedance2Pro,
WorldLabs,
}
impl Display for ClientType {
@@ -20,6 +21,7 @@ impl Display for ClientType {
Self::GmiCloud => write!(f, "GmiCloud"),
Self::GrokApi => write!(f, "GrokApi"),
Self::Seedance2Pro => write!(f, "Seedance2Pro"),
Self::WorldLabs => write!(f, "WorldLabs"),
}
}
}
@@ -5,6 +5,7 @@ use fal_client::error::fal_error_plus::FalErrorPlus;
use gmicloud_client::error::gmicloud_error::GmiCloudError;
use grok_api_client::error::grok_error::GrokError;
use seedance2pro_client::error::seedance2pro_error::Seedance2ProError;
use worldlabs_api_client::error::world_labs_error::WorldLabsError;
#[derive(Debug)]
pub enum ProviderError {
@@ -13,6 +14,7 @@ pub enum ProviderError {
GmiCloud(GmiCloudError),
Grok(GrokError),
Seedance2Pro(Seedance2ProError),
WorldLabs(WorldLabsError),
}
impl Error for ProviderError {}
@@ -25,6 +27,7 @@ impl Display for ProviderError {
Self::GmiCloud(e) => write!(f, "GmiCloud provider error: {}", e),
Self::Grok(e) => write!(f, "Grok provider error: {}", e),
Self::Seedance2Pro(e) => write!(f, "Seedance2Pro provider error: {}", e),
Self::WorldLabs(e) => write!(f, "WorldLabs provider error: {}", e),
}
}
}
@@ -58,3 +61,9 @@ impl From<Seedance2ProError> for ProviderError {
Self::Seedance2Pro(error)
}
}
impl From<WorldLabsError> for ProviderError {
fn from(error: WorldLabsError) -> Self {
Self::WorldLabs(error)
}
}
@@ -0,0 +1,266 @@
use enums::common::generation::common_mesh_output_type::CommonMeshOutputType;
use enums::common::generation::common_polygon_type::CommonPolygonType;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::router_mesh_model::RouterMeshModel;
use crate::api::router_provider::RouterProvider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_mesh::mesh_generation_draft_or_request::MeshGenerationDraftOrRequest;
use crate::generate::generate_mesh::providers::artcraft::hunyuan3d_3::build::build_artcraft_hunyuan3d_3;
use crate::generate::generate_mesh::providers::artcraft::hunyuan3d_3_sketch::build::build_artcraft_hunyuan3d_3_sketch;
use crate::generate::generate_mesh::providers::artcraft::hunyuan_3d_2p0::build::build_artcraft_hunyuan_3d_2p0;
use crate::generate::generate_mesh::providers::artcraft::hunyuan_3d_2p1::build::build_artcraft_hunyuan_3d_2p1;
use crate::generate::generate_mesh::providers::fal::hunyuan3d_3::build::build_fal_hunyuan3d_3;
use crate::generate::generate_mesh::providers::fal::hunyuan3d_3_sketch::build::build_fal_hunyuan3d_3_sketch;
use crate::generate::generate_mesh::providers::fal::hunyuan_3d_2p0::build::build_fal_hunyuan_3d_2p0;
use crate::generate::generate_mesh::providers::fal::hunyuan_3d_2p1::build::build_fal_hunyuan_3d_2p1;
/// RouterProvider-agnostic mesh (3D object) generation request. Distilled by
/// `build2()` into a `MeshGenerationDraftOrRequest` for the selected
/// (provider, model) pair.
#[derive(Clone, Debug)]
pub struct GenerateMeshRequestBuilder {
/// Which model to use.
pub model: RouterMeshModel,
/// Which provider to use.
pub provider: RouterProvider,
/// The text prompt (text-to-3D, or the sketch description for sketch-to-3D).
pub prompt: Option<String>,
/// Reference images (optional).
/// The primary/front input image, or the sketch image for sketch-to-3D.
pub reference_images: Option<ImageListRef>,
/// Front view image (optional; multi-view input).
/// Can be used instead of `reference_images[0]` when the other side views
/// are supplied.
pub front_image: Option<ImageRef>,
/// Back view image (optional; multi-view input).
pub back_image: Option<ImageRef>,
/// Left view image (optional; multi-view input).
pub left_image: Option<ImageRef>,
/// Right view image (optional; multi-view input).
pub right_image: Option<ImageRef>,
/// The mesh output type to generate (normal, low-poly, or geometry-only).
pub mesh_output_type: Option<CommonMeshOutputType>,
/// The polygon type to use for the generated mesh (triangle or quad).
pub polygon_type: Option<CommonPolygonType>,
/// Target face count for the generated mesh.
pub face_count: Option<u64>,
/// Whether to generate PBR (physically based rendering) materials.
pub enable_pbr: Option<bool>,
/// If the request is a mismatch with the (model/provider), how to mitigate it.
pub request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy,
/// Some providers support idempotency.
/// If not supplied, we'll generate one for the required providers.
pub idempotency_token: Option<String>,
}
impl Default for GenerateMeshRequestBuilder {
fn default() -> Self {
Self {
model: RouterMeshModel::Hunyuan3d3,
provider: RouterProvider::Artcraft,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
prompt: None,
reference_images: None,
front_image: None,
back_image: None,
left_image: None,
right_image: None,
mesh_output_type: None,
polygon_type: None,
face_count: None,
enable_pbr: None,
idempotency_token: None,
}
}
}
impl GenerateMeshRequestBuilder {
pub fn build2(self) -> Result<MeshGenerationDraftOrRequest, ArtcraftRouterError> {
match (self.provider, self.model) {
// Artcraft
(RouterProvider::Artcraft, RouterMeshModel::Hunyuan3d2p0) => build_artcraft_hunyuan_3d_2p0(self),
(RouterProvider::Artcraft, RouterMeshModel::Hunyuan3d2p1) => build_artcraft_hunyuan_3d_2p1(self),
(RouterProvider::Artcraft, RouterMeshModel::Hunyuan3d3) => build_artcraft_hunyuan3d_3(self),
(RouterProvider::Artcraft, RouterMeshModel::Hunyuan3d3Sketch) => build_artcraft_hunyuan3d_3_sketch(self),
// Fal
(RouterProvider::Fal, RouterMeshModel::Hunyuan3d2p0) => build_fal_hunyuan_3d_2p0(self),
(RouterProvider::Fal, RouterMeshModel::Hunyuan3d2p1) => build_fal_hunyuan_3d_2p1(self),
(RouterProvider::Fal, RouterMeshModel::Hunyuan3d3) => build_fal_hunyuan3d_3(self),
(RouterProvider::Fal, RouterMeshModel::Hunyuan3d3Sketch) => build_fal_hunyuan3d_3_sketch(self),
_ => self.unsupported_provider_and_model(),
}
}
pub fn get_or_generate_idempotency_token(&self) -> String {
self.idempotency_token.clone()
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string())
}
fn unsupported_provider_and_model(&self) -> Result<MeshGenerationDraftOrRequest, ArtcraftRouterError> {
Err(ArtcraftRouterError::UnsupportedProviderAndModelForNewApi(
format!("Mesh generation for model `{:?}` is not supported for provider {:?}", self.model, self.provider)
))
}
}
#[cfg(test)]
mod tests {
use crate::generate::generate_mesh::mesh_generation_request::MeshGenerationRequest;
use super::*;
const IMAGE_URL: &str = "https://example.com/front.png";
mod dispatch_tests {
use super::*;
#[test]
fn artcraft_models_dispatch() {
let cases = [
RouterMeshModel::Hunyuan3d2p0,
RouterMeshModel::Hunyuan3d2p1,
RouterMeshModel::Hunyuan3d3,
RouterMeshModel::Hunyuan3d3Sketch,
];
for model in cases {
let result = builder_with_token_image(RouterProvider::Artcraft, model).build2()
.unwrap_or_else(|e| panic!("build should succeed for {model:?}: {e}"));
let request = expect_request(result);
let matches = matches!(
(model, &request),
(RouterMeshModel::Hunyuan3d2p0, MeshGenerationRequest::ArtcraftHunyuan3d2p0(_))
| (RouterMeshModel::Hunyuan3d2p1, MeshGenerationRequest::ArtcraftHunyuan3d2p1(_))
| (RouterMeshModel::Hunyuan3d3, MeshGenerationRequest::ArtcraftHunyuan3d3(_))
| (RouterMeshModel::Hunyuan3d3Sketch, MeshGenerationRequest::ArtcraftHunyuan3d3Sketch(_))
);
assert!(matches, "unexpected dispatch for {model:?}: {request:?}");
}
}
#[test]
fn fal_hunyuan3d_3_with_image_dispatches_to_image_endpoint() {
let result = builder_with_url_image(RouterProvider::Fal, RouterMeshModel::Hunyuan3d3)
.build2().expect("build");
assert!(matches!(
expect_request(result),
MeshGenerationRequest::FalHunyuan3d3Image(_)
));
}
#[test]
fn fal_hunyuan3d_3_with_prompt_only_dispatches_to_text_endpoint() {
let builder = GenerateMeshRequestBuilder {
provider: RouterProvider::Fal,
model: RouterMeshModel::Hunyuan3d3,
prompt: Some("a red ceramic teapot".to_string()),
..Default::default()
};
let result = builder.build2().expect("build");
assert!(matches!(
expect_request(result),
MeshGenerationRequest::FalHunyuan3d3Text(_)
));
}
#[test]
fn fal_hunyuan3d_3_sketch_dispatches() {
let builder = GenerateMeshRequestBuilder {
prompt: Some("a red ceramic teapot".to_string()),
..builder_with_url_image(RouterProvider::Fal, RouterMeshModel::Hunyuan3d3Sketch)
};
let result = builder.build2().expect("build");
assert!(matches!(
expect_request(result),
MeshGenerationRequest::FalHunyuan3d3Sketch(_)
));
}
#[test]
fn fal_hunyuan_3d_2p0_dispatches() {
let result = builder_with_url_image(RouterProvider::Fal, RouterMeshModel::Hunyuan3d2p0)
.build2().expect("build");
assert!(matches!(
expect_request(result),
MeshGenerationRequest::FalHunyuan3d2p0(_)
));
}
#[test]
fn fal_hunyuan_3d_2p1_dispatches() {
let result = builder_with_url_image(RouterProvider::Fal, RouterMeshModel::Hunyuan3d2p1)
.build2().expect("build");
assert!(matches!(
expect_request(result),
MeshGenerationRequest::FalHunyuan3d2p1(_)
));
}
}
mod unsupported_combo_tests {
use super::*;
#[test]
fn non_mesh_providers_are_unsupported() {
for provider in [
RouterProvider::GmiCloud,
RouterProvider::GrokApi,
RouterProvider::Seedance2Pro,
RouterProvider::WorldLabs,
] {
let result = builder_with_url_image(provider, RouterMeshModel::Hunyuan3d3).build2();
assert!(
matches!(result, Err(ArtcraftRouterError::UnsupportedProviderAndModelForNewApi(_))),
"expected unsupported error for {provider:?}",
);
}
}
}
// ── Helpers ──
fn builder_with_url_image(provider: RouterProvider, model: RouterMeshModel) -> GenerateMeshRequestBuilder {
GenerateMeshRequestBuilder {
provider,
model,
reference_images: Some(ImageListRef::Urls(vec![IMAGE_URL.to_string()])),
..Default::default()
}
}
fn builder_with_token_image(provider: RouterProvider, model: RouterMeshModel) -> GenerateMeshRequestBuilder {
use tokens::tokens::media_files::MediaFileToken;
GenerateMeshRequestBuilder {
provider,
model,
prompt: Some("a red ceramic teapot".to_string()),
reference_images: Some(ImageListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_test123".to_string()),
])),
..Default::default()
}
}
fn expect_request(result: MeshGenerationDraftOrRequest) -> MeshGenerationRequest {
match result {
MeshGenerationDraftOrRequest::Request(request) => request,
MeshGenerationDraftOrRequest::Draft(draft) => panic!("expected Request, got Draft: {draft:?}"),
}
}
}
@@ -0,0 +1,51 @@
use std::fmt::Debug;
use std::sync::Arc;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
#[derive(Clone, Debug)]
pub struct ArtcraftMeshResponsePayload {
pub inference_job_token: InferenceJobToken,
pub all_inference_job_tokens: Vec<InferenceJobToken>,
}
#[derive(Clone, Debug)]
pub struct FalMeshResponsePayload {
pub request_id: Option<String>,
pub gateway_request_id: Option<String>,
/// Queue-mode status URL (fal's queue/polling flow). `None` for webhook
/// dispatch — the webhook callback drives status updates instead.
pub maybe_status_url: Option<String>,
/// Queue-mode response URL (fal's queue/polling flow). `None` for webhook
/// dispatch.
pub maybe_response_url: Option<String>,
/// The outbound request that was sent to Fal.
/// Stored as a trait object so any Request type can be captured.
/// Use `format!("{:?}", ...)` or `format!("{:#?}", ...)` to print.
pub maybe_outbound_request: Option<Arc<dyn Debug + Send + Sync>>,
}
#[derive(Clone, Debug)]
pub enum GenerateMeshResponse {
Artcraft(ArtcraftMeshResponsePayload),
Fal(FalMeshResponsePayload),
}
impl GenerateMeshResponse {
pub fn get_artcraft_payload(&self) -> Option<ArtcraftMeshResponsePayload> {
match self {
Self::Artcraft(p) => Some(p.clone()),
_ => None,
}
}
pub fn get_fal_payload(&self) -> Option<FalMeshResponsePayload> {
match self {
Self::Fal(p) => Some(p.clone()),
_ => None,
}
}
}
@@ -0,0 +1,16 @@
/// The result of estimating the cost of a mesh generation plan.
#[derive(Clone, Debug)]
pub struct MeshGenerationCostEstimate {
pub cost_in_credits: Option<u64>,
pub cost_in_usd_cents: Option<u64>,
pub is_free: bool,
pub is_unlimited: bool,
pub is_rate_limited: bool,
pub has_watermark: bool,
/// Whether failures are refunded.
/// True: 100% yes
/// False: 100% no
/// None: Unknown or variable
pub failures_are_refunded: Option<bool>,
}
@@ -0,0 +1,35 @@
use crate::api::router_provider::RouterProvider;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_mesh::mesh_generation_cost_estimate::MeshGenerationCostEstimate;
use crate::generate::generate_mesh::mesh_generation_draft_context::MeshGenerationDraftContext;
use crate::generate::generate_mesh::mesh_generation_request::MeshGenerationRequest;
/**
* Wrapper for all mesh generation draft requests.
*
* No mesh provider currently needs a draft phase: fal takes media URLs
* directly and Artcraft takes media tokens, so `build2()` always returns a
* `Request`. The enum exists so the mesh pipeline matches the audio/image
* draft-or-request shape and providers that need pre-upload can slot in
* later.
*/
#[derive(Clone, Debug)]
pub enum MeshGenerationDraftRequest {}
impl MeshGenerationDraftRequest {
pub fn get_provider(&self) -> RouterProvider {
match *self {}
}
/// Return a cost estimate to fulfill the request.
pub fn estimate_cost(&self) -> Result<MeshGenerationCostEstimate, ArtcraftRouterError> {
match *self {}
}
/// Finalize the draft request before generation
/// This may involve uploading media to the provider.
pub async fn finalize(self, _draft_context: MeshGenerationDraftContext<'_>) -> Result<MeshGenerationRequest, ArtcraftRouterError> {
match self {}
}
}
@@ -0,0 +1,25 @@
use std::collections::HashMap;
use std::fmt::Debug;
use tokens::tokens::media_files::MediaFileToken;
use crate::client::router_client::RouterClient;
#[derive(Clone, Default)]
pub struct MeshGenerationDraftContext<'a> {
/// Optional: the router client, needed for providers that require authentication.
pub client: Option<&'a RouterClient>,
/// Optional context: a map of Media File Tokens to their ArtCraft URLs
/// Only needed if we have to fetch these assets and upload them to another provider.
pub media_file_to_artcraft_url_map: Option<&'a HashMap<MediaFileToken, String>>,
}
impl Debug for MeshGenerationDraftContext<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MeshGenerationDraftContext")
.field("client", &self.client.is_some())
.field("media_file_to_artcraft_url_map", &self.media_file_to_artcraft_url_map)
.finish()
}
}
@@ -0,0 +1,28 @@
use crate::api::router_provider::RouterProvider;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_mesh::mesh_generation_cost_estimate::MeshGenerationCostEstimate;
use crate::generate::generate_mesh::mesh_generation_draft::MeshGenerationDraftRequest;
use crate::generate::generate_mesh::mesh_generation_request::MeshGenerationRequest;
#[derive(Debug, Clone)]
pub enum MeshGenerationDraftOrRequest {
Draft(MeshGenerationDraftRequest),
Request(MeshGenerationRequest),
}
impl MeshGenerationDraftOrRequest {
pub fn get_provider(&self) -> RouterProvider {
match self {
Self::Draft(draft) => draft.get_provider(),
Self::Request(request) => request.get_provider(),
}
}
pub fn estimate_cost(&self) -> Result<MeshGenerationCostEstimate, ArtcraftRouterError> {
match self {
MeshGenerationDraftOrRequest::Draft(draft) => draft.estimate_cost(),
MeshGenerationDraftOrRequest::Request(request) => request.estimate_cost(),
}
}
}
@@ -0,0 +1,113 @@
use crate::api::router_provider::RouterProvider;
use crate::client::router_client::RouterClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_mesh::generate_mesh_response::GenerateMeshResponse;
use crate::generate::generate_mesh::mesh_generation_cost_estimate::MeshGenerationCostEstimate;
use crate::generate::generate_mesh::providers::artcraft::hunyuan3d_3::cost::ArtcraftHunyuan3d3CostState;
use crate::generate::generate_mesh::providers::artcraft::hunyuan3d_3::request::ArtcraftHunyuan3d3RequestState;
use crate::generate::generate_mesh::providers::artcraft::hunyuan3d_3_sketch::cost::ArtcraftHunyuan3d3SketchCostState;
use crate::generate::generate_mesh::providers::artcraft::hunyuan3d_3_sketch::request::ArtcraftHunyuan3d3SketchRequestState;
use crate::generate::generate_mesh::providers::artcraft::hunyuan_3d_2p0::cost::ArtcraftHunyuan3d2p0CostState;
use crate::generate::generate_mesh::providers::artcraft::hunyuan_3d_2p0::request::ArtcraftHunyuan3d2p0RequestState;
use crate::generate::generate_mesh::providers::artcraft::hunyuan_3d_2p1::cost::ArtcraftHunyuan3d2p1CostState;
use crate::generate::generate_mesh::providers::artcraft::hunyuan_3d_2p1::request::ArtcraftHunyuan3d2p1RequestState;
use crate::generate::generate_mesh::providers::fal::hunyuan3d_3::cost::{
FalHunyuan3d3ImageCostState, FalHunyuan3d3TextCostState,
};
use crate::generate::generate_mesh::providers::fal::hunyuan3d_3::request::{
FalHunyuan3d3ImageRequestState, FalHunyuan3d3TextRequestState,
};
use crate::generate::generate_mesh::providers::fal::hunyuan3d_3_sketch::cost::FalHunyuan3d3SketchCostState;
use crate::generate::generate_mesh::providers::fal::hunyuan3d_3_sketch::request::FalHunyuan3d3SketchRequestState;
use crate::generate::generate_mesh::providers::fal::hunyuan_3d_2p0::cost::FalHunyuan3d2p0CostState;
use crate::generate::generate_mesh::providers::fal::hunyuan_3d_2p0::request::FalHunyuan3d2p0RequestState;
use crate::generate::generate_mesh::providers::fal::hunyuan_3d_2p1::cost::FalHunyuan3d2p1CostState;
use crate::generate::generate_mesh::providers::fal::hunyuan_3d_2p1::request::FalHunyuan3d2p1RequestState;
#[derive(Clone, Debug)]
pub enum MeshGenerationRequest {
ArtcraftHunyuan3d2p0(ArtcraftHunyuan3d2p0RequestState),
ArtcraftHunyuan3d2p1(ArtcraftHunyuan3d2p1RequestState),
ArtcraftHunyuan3d3(ArtcraftHunyuan3d3RequestState),
ArtcraftHunyuan3d3Sketch(ArtcraftHunyuan3d3SketchRequestState),
FalHunyuan3d2p0(FalHunyuan3d2p0RequestState),
FalHunyuan3d2p1(FalHunyuan3d2p1RequestState),
FalHunyuan3d3Image(FalHunyuan3d3ImageRequestState),
FalHunyuan3d3Text(FalHunyuan3d3TextRequestState),
FalHunyuan3d3Sketch(FalHunyuan3d3SketchRequestState),
}
impl MeshGenerationRequest {
pub fn get_provider(&self) -> RouterProvider {
match self {
Self::ArtcraftHunyuan3d2p0(_) => RouterProvider::Artcraft,
Self::ArtcraftHunyuan3d2p1(_) => RouterProvider::Artcraft,
Self::ArtcraftHunyuan3d3(_) => RouterProvider::Artcraft,
Self::ArtcraftHunyuan3d3Sketch(_) => RouterProvider::Artcraft,
Self::FalHunyuan3d2p0(_) => RouterProvider::Fal,
Self::FalHunyuan3d2p1(_) => RouterProvider::Fal,
Self::FalHunyuan3d3Image(_) => RouterProvider::Fal,
Self::FalHunyuan3d3Text(_) => RouterProvider::Fal,
Self::FalHunyuan3d3Sketch(_) => RouterProvider::Fal,
}
}
/// Return a cost estimate to fulfill the request.
pub fn estimate_cost(&self) -> Result<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()),
}
}
/// Send the mesh generation request
/// 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
},
}
}
}
@@ -0,0 +1,8 @@
pub mod generate_mesh_request_builder;
pub mod generate_mesh_response;
pub mod mesh_generation_cost_estimate;
pub mod mesh_generation_draft;
pub mod mesh_generation_draft_context;
pub mod mesh_generation_draft_or_request;
pub mod mesh_generation_request;
pub mod providers;
@@ -0,0 +1,135 @@
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_mesh_cost_and_generate_request::OmniGenMeshCostAndGenerateRequest;
use enums::common::generation::common_mesh_model::CommonMeshModel as CommonMeshModelEnum;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder;
use crate::generate::generate_mesh::providers::artcraft::resolve::{
resolve_image_list_ref, resolve_image_ref,
};
/// Build an `OmniGenMeshCostAndGenerateRequest` from the builder. The
/// Artcraft provider is a thin forwarding shim: the storyteller-web omni
/// mesh endpoint owns per-model validation and option mapping, so the
/// builder's options are passed through as-is.
pub fn build_artcraft_omni_mesh_request(
mut builder: GenerateMeshRequestBuilder,
model: CommonMeshModelEnum,
) -> Result<OmniGenMeshCostAndGenerateRequest, ArtcraftRouterError> {
let reference_image_media_tokens = resolve_image_list_ref(builder.reference_images.take())?;
let front_image_media_token = resolve_image_ref(builder.front_image.take())?;
let back_image_media_token = resolve_image_ref(builder.back_image.take())?;
let left_image_media_token = resolve_image_ref(builder.left_image.take())?;
let right_image_media_token = resolve_image_ref(builder.right_image.take())?;
let idempotency_token = builder.get_or_generate_idempotency_token();
Ok(OmniGenMeshCostAndGenerateRequest {
idempotency_token: Some(idempotency_token),
model: Some(model),
prompt: builder.prompt.take(),
reference_image_media_tokens,
front_image_media_token,
back_image_media_token,
left_image_media_token,
right_image_media_token,
mesh_output_type: builder.mesh_output_type,
polygon_type: builder.polygon_type,
face_count: builder.face_count,
enable_pbr: builder.enable_pbr,
})
}
#[cfg(test)]
mod tests {
use enums::common::generation::common_mesh_output_type::CommonMeshOutputType;
use enums::common::generation::common_polygon_type::CommonPolygonType;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::router_mesh_model::RouterMeshModel;
use crate::api::router_provider::RouterProvider;
use crate::errors::client_error::ClientError;
use super::*;
#[test]
fn all_fields_are_forwarded() {
let builder = GenerateMeshRequestBuilder {
prompt: Some("a red ceramic teapot".to_string()),
reference_images: Some(ImageListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_ref".to_string()),
])),
front_image: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_front".to_string()))),
back_image: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_back".to_string()))),
left_image: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_left".to_string()))),
right_image: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_right".to_string()))),
mesh_output_type: Some(CommonMeshOutputType::LowPoly),
polygon_type: Some(CommonPolygonType::Quad),
face_count: Some(100_000),
enable_pbr: Some(true),
idempotency_token: Some("test-token".to_string()),
..base_builder()
};
let request = build_artcraft_omni_mesh_request(builder, CommonMeshModelEnum::Hunyuan3d3)
.expect("build");
assert_eq!(request.idempotency_token.as_deref(), Some("test-token"));
assert_eq!(request.model, Some(CommonMeshModelEnum::Hunyuan3d3));
assert_eq!(request.prompt.as_deref(), Some("a red ceramic teapot"));
assert_eq!(request.reference_image_media_tokens.as_ref().map(|t| t.len()), Some(1));
assert_eq!(request.front_image_media_token.as_ref().map(|t| t.as_str()), Some("mf_front"));
assert_eq!(request.back_image_media_token.as_ref().map(|t| t.as_str()), Some("mf_back"));
assert_eq!(request.left_image_media_token.as_ref().map(|t| t.as_str()), Some("mf_left"));
assert_eq!(request.right_image_media_token.as_ref().map(|t| t.as_str()), Some("mf_right"));
assert_eq!(request.mesh_output_type, Some(CommonMeshOutputType::LowPoly));
assert_eq!(request.polygon_type, Some(CommonPolygonType::Quad));
assert_eq!(request.face_count, Some(100_000));
assert_eq!(request.enable_pbr, Some(true));
}
#[test]
fn idempotency_token_is_generated_when_missing() {
let request = build_artcraft_omni_mesh_request(base_builder(), CommonMeshModelEnum::Hunyuan3d3)
.expect("build");
assert!(request.idempotency_token.is_some());
assert!(!request.idempotency_token.unwrap().is_empty());
}
#[test]
fn reference_image_url_references_are_rejected() {
let builder = GenerateMeshRequestBuilder {
reference_images: Some(ImageListRef::Urls(vec!["https://example.com/a.png".to_string()])),
..base_builder()
};
let result = build_artcraft_omni_mesh_request(builder, CommonMeshModelEnum::Hunyuan3d3);
assert!(matches!(
result,
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
));
}
#[test]
fn side_image_url_references_are_rejected() {
let builder = GenerateMeshRequestBuilder {
back_image: Some(ImageRef::Url("https://example.com/back.png".to_string())),
..base_builder()
};
let result = build_artcraft_omni_mesh_request(builder, CommonMeshModelEnum::Hunyuan3d3);
assert!(matches!(
result,
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
));
}
// ── Helpers ──
fn base_builder() -> GenerateMeshRequestBuilder {
GenerateMeshRequestBuilder {
model: RouterMeshModel::Hunyuan3d3,
provider: RouterProvider::Artcraft,
prompt: Some("a red ceramic teapot".to_string()),
..Default::default()
}
}
}
@@ -0,0 +1,14 @@
use enums::common::generation::common_mesh_model::CommonMeshModel as CommonMeshModelEnum;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder;
use crate::generate::generate_mesh::mesh_generation_draft_or_request::MeshGenerationDraftOrRequest;
use crate::generate::generate_mesh::mesh_generation_request::MeshGenerationRequest;
use crate::generate::generate_mesh::providers::artcraft::build_common::build_artcraft_omni_mesh_request;
use crate::generate::generate_mesh::providers::artcraft::hunyuan3d_3::request::ArtcraftHunyuan3d3RequestState;
pub fn build_artcraft_hunyuan3d_3(builder: GenerateMeshRequestBuilder) -> Result<MeshGenerationDraftOrRequest, ArtcraftRouterError> {
let request = build_artcraft_omni_mesh_request(builder, CommonMeshModelEnum::Hunyuan3d3)?;
let state = ArtcraftHunyuan3d3RequestState { request };
Ok(MeshGenerationDraftOrRequest::Request(MeshGenerationRequest::ArtcraftHunyuan3d3(state)))
}
@@ -0,0 +1,225 @@
use enums::common::generation::common_mesh_output_type::CommonMeshOutputType;
use crate::generate::generate_mesh::mesh_generation_cost_estimate::MeshGenerationCostEstimate;
use crate::generate::generate_mesh::providers::artcraft::hunyuan3d_3::request::ArtcraftHunyuan3d3RequestState;
const NORMAL_COST_IN_USD_CENTS: u64 = 49;
const LOW_POLY_COST_IN_USD_CENTS: u64 = 59;
const GEOMETRY_COST_IN_USD_CENTS: u64 = 30;
const ADD_ON_COST_IN_USD_CENTS: u64 = 20;
/// Hunyuan 3D v3 via Artcraft: flat base price by output type, plus a flat
/// add-on for each extra option (PBR materials, custom face count, and
/// multi-view side images).
#[derive(Clone, Debug)]
pub struct ArtcraftHunyuan3d3CostState {
pub cost_in_usd_cents: u64,
}
impl ArtcraftHunyuan3d3CostState {
pub fn from_request(request: &ArtcraftHunyuan3d3RequestState) -> Self {
let request = &request.request;
let mut cost = match request.mesh_output_type {
None | Some(CommonMeshOutputType::Normal) => NORMAL_COST_IN_USD_CENTS,
Some(CommonMeshOutputType::LowPoly) => LOW_POLY_COST_IN_USD_CENTS,
Some(CommonMeshOutputType::Geometry) => GEOMETRY_COST_IN_USD_CENTS,
};
if request.enable_pbr.unwrap_or(false) {
cost += ADD_ON_COST_IN_USD_CENTS;
}
if request.face_count.is_some() {
cost += ADD_ON_COST_IN_USD_CENTS;
}
let uses_multi_view = request.back_image_media_token.is_some()
|| request.left_image_media_token.is_some()
|| request.right_image_media_token.is_some();
if uses_multi_view {
cost += ADD_ON_COST_IN_USD_CENTS;
}
Self { cost_in_usd_cents: cost }
}
pub fn estimate_cost(&self) -> MeshGenerationCostEstimate {
MeshGenerationCostEstimate {
cost_in_credits: Some(self.cost_in_usd_cents),
cost_in_usd_cents: Some(self.cost_in_usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
}
#[cfg(test)]
mod tests {
use enums::common::generation::common_polygon_type::CommonPolygonType;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::router_mesh_model::RouterMeshModel;
use crate::api::router_provider::RouterProvider;
use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder;
use super::*;
mod base_costs {
use super::*;
#[test]
fn default_output_type_is_forty_nine_cents() {
assert_eq!(estimate_usd_cents(base_builder()), 49);
}
#[test]
fn normal_is_forty_nine_cents() {
let builder = GenerateMeshRequestBuilder {
mesh_output_type: Some(CommonMeshOutputType::Normal),
..base_builder()
};
assert_eq!(estimate_usd_cents(builder), 49);
}
#[test]
fn low_poly_is_fifty_nine_cents() {
let builder = GenerateMeshRequestBuilder {
mesh_output_type: Some(CommonMeshOutputType::LowPoly),
..base_builder()
};
assert_eq!(estimate_usd_cents(builder), 59);
}
#[test]
fn geometry_is_thirty_cents() {
let builder = GenerateMeshRequestBuilder {
mesh_output_type: Some(CommonMeshOutputType::Geometry),
..base_builder()
};
assert_eq!(estimate_usd_cents(builder), 30);
}
#[test]
fn text_mode_prices_the_same_as_image_mode() {
// Text-only request (no image references).
let text_builder = GenerateMeshRequestBuilder {
model: RouterMeshModel::Hunyuan3d3,
provider: RouterProvider::Artcraft,
prompt: Some("a red ceramic teapot".to_string()),
..Default::default()
};
assert_eq!(estimate_usd_cents(text_builder), 49);
}
#[test]
fn polygon_type_does_not_change_the_price() {
let builder = GenerateMeshRequestBuilder {
polygon_type: Some(CommonPolygonType::Quad),
..base_builder()
};
assert_eq!(estimate_usd_cents(builder), 49);
}
}
mod add_ons {
use super::*;
#[test]
fn pbr_adds_twenty_cents() {
let builder = GenerateMeshRequestBuilder {
enable_pbr: Some(true),
..base_builder()
};
assert_eq!(estimate_usd_cents(builder), 49 + 20);
}
#[test]
fn pbr_false_adds_nothing() {
let builder = GenerateMeshRequestBuilder {
enable_pbr: Some(false),
..base_builder()
};
assert_eq!(estimate_usd_cents(builder), 49);
}
#[test]
fn face_count_adds_twenty_cents() {
let builder = GenerateMeshRequestBuilder {
face_count: Some(100_000),
..base_builder()
};
assert_eq!(estimate_usd_cents(builder), 49 + 20);
}
#[test]
fn each_side_image_adds_twenty_cents_at_most_once() {
for side in ["back", "left", "right"] {
let mut builder = base_builder();
let image = Some(ImageRef::MediaFileToken(MediaFileToken::new(format!("mf_{side}"))));
match side {
"back" => builder.back_image = image,
"left" => builder.left_image = image,
_ => builder.right_image = image,
}
assert_eq!(estimate_usd_cents(builder), 49 + 20, "for {side} image");
}
let builder = GenerateMeshRequestBuilder {
back_image: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_back".to_string()))),
left_image: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_left".to_string()))),
right_image: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_right".to_string()))),
..base_builder()
};
assert_eq!(estimate_usd_cents(builder), 49 + 20);
}
#[test]
fn all_add_ons_stack() {
let builder = GenerateMeshRequestBuilder {
mesh_output_type: Some(CommonMeshOutputType::LowPoly),
enable_pbr: Some(true),
face_count: Some(100_000),
back_image: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_back".to_string()))),
..base_builder()
};
assert_eq!(estimate_usd_cents(builder), 59 + 20 + 20 + 20);
}
#[test]
fn geometry_with_all_add_ons() {
let builder = GenerateMeshRequestBuilder {
mesh_output_type: Some(CommonMeshOutputType::Geometry),
enable_pbr: Some(true),
face_count: Some(10_000),
left_image: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_left".to_string()))),
..base_builder()
};
assert_eq!(estimate_usd_cents(builder), 30 + 20 + 20 + 20);
}
}
// ── Helpers ──
fn base_builder() -> GenerateMeshRequestBuilder {
GenerateMeshRequestBuilder {
model: RouterMeshModel::Hunyuan3d3,
provider: RouterProvider::Artcraft,
reference_images: Some(ImageListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_front".to_string()),
])),
..Default::default()
}
}
fn estimate_usd_cents(builder: GenerateMeshRequestBuilder) -> u64 {
builder.build2()
.expect("build should succeed")
.estimate_cost()
.expect("estimate should succeed")
.cost_in_usd_cents
.expect("cost should be present")
}
}
@@ -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 ArtcraftHunyuan3d3RequestState {
pub request: OmniGenMeshCostAndGenerateRequest,
}
impl ArtcraftHunyuan3d3RequestState {
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::Hunyuan3d3,
provider: RouterProvider::Artcraft,
prompt: Some("A red ceramic teapot with a curved spout.".to_string()),
..Default::default()
}).await;
assert!(matches!(response, GenerateMeshResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
// ── Helpers ──
async fn run_pipeline(builder: GenerateMeshRequestBuilder) -> GenerateMeshResponse {
let client = get_artcraft_client();
let draft_or_request = builder.build2().expect("build2 should succeed");
let request = match draft_or_request {
MeshGenerationDraftOrRequest::Request(r) => r,
_ => panic!("expected Request variant (Artcraft skips draft)"),
};
let response = request.send_request(&client).await.expect("send_request should succeed");
match &response {
GenerateMeshResponse::Artcraft(p) => {
println!("inference_job_token={:?}", p.inference_job_token);
println!("all_inference_job_tokens={:?}", p.all_inference_job_tokens);
}
other => println!("response: {:?}", other),
}
response
}
}
@@ -0,0 +1,14 @@
use enums::common::generation::common_mesh_model::CommonMeshModel as CommonMeshModelEnum;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder;
use crate::generate::generate_mesh::mesh_generation_draft_or_request::MeshGenerationDraftOrRequest;
use crate::generate::generate_mesh::mesh_generation_request::MeshGenerationRequest;
use crate::generate::generate_mesh::providers::artcraft::build_common::build_artcraft_omni_mesh_request;
use crate::generate::generate_mesh::providers::artcraft::hunyuan3d_3_sketch::request::ArtcraftHunyuan3d3SketchRequestState;
pub fn build_artcraft_hunyuan3d_3_sketch(builder: GenerateMeshRequestBuilder) -> Result<MeshGenerationDraftOrRequest, ArtcraftRouterError> {
let request = build_artcraft_omni_mesh_request(builder, CommonMeshModelEnum::Hunyuan3d3Sketch)?;
let state = ArtcraftHunyuan3d3SketchRequestState { request };
Ok(MeshGenerationDraftOrRequest::Request(MeshGenerationRequest::ArtcraftHunyuan3d3Sketch(state)))
}
@@ -0,0 +1,115 @@
use crate::generate::generate_mesh::mesh_generation_cost_estimate::MeshGenerationCostEstimate;
use crate::generate::generate_mesh::providers::artcraft::hunyuan3d_3_sketch::request::ArtcraftHunyuan3d3SketchRequestState;
const BASE_COST_IN_USD_CENTS: u64 = 49;
const ADD_ON_COST_IN_USD_CENTS: u64 = 20;
/// Hunyuan 3D v3 sketch-to-3D via Artcraft: flat base price plus a flat
/// add-on for each extra option (PBR materials, custom face count).
#[derive(Clone, Debug)]
pub struct ArtcraftHunyuan3d3SketchCostState {
pub cost_in_usd_cents: u64,
}
impl ArtcraftHunyuan3d3SketchCostState {
pub fn from_request(request: &ArtcraftHunyuan3d3SketchRequestState) -> Self {
let request = &request.request;
let mut cost = BASE_COST_IN_USD_CENTS;
if request.enable_pbr.unwrap_or(false) {
cost += ADD_ON_COST_IN_USD_CENTS;
}
if request.face_count.is_some() {
cost += ADD_ON_COST_IN_USD_CENTS;
}
Self { cost_in_usd_cents: cost }
}
pub fn estimate_cost(&self) -> MeshGenerationCostEstimate {
MeshGenerationCostEstimate {
cost_in_credits: Some(self.cost_in_usd_cents),
cost_in_usd_cents: Some(self.cost_in_usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
}
#[cfg(test)]
mod tests {
use tokens::tokens::media_files::MediaFileToken;
use crate::api::image_list_ref::ImageListRef;
use crate::api::router_mesh_model::RouterMeshModel;
use crate::api::router_provider::RouterProvider;
use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder;
#[test]
fn base_cost_is_forty_nine_cents() {
assert_eq!(estimate_usd_cents(base_builder()), 49);
}
#[test]
fn pbr_adds_twenty_cents() {
let builder = GenerateMeshRequestBuilder {
enable_pbr: Some(true),
..base_builder()
};
assert_eq!(estimate_usd_cents(builder), 49 + 20);
}
#[test]
fn pbr_false_adds_nothing() {
let builder = GenerateMeshRequestBuilder {
enable_pbr: Some(false),
..base_builder()
};
assert_eq!(estimate_usd_cents(builder), 49);
}
#[test]
fn face_count_adds_twenty_cents() {
let builder = GenerateMeshRequestBuilder {
face_count: Some(100_000),
..base_builder()
};
assert_eq!(estimate_usd_cents(builder), 49 + 20);
}
#[test]
fn all_add_ons_stack() {
let builder = GenerateMeshRequestBuilder {
enable_pbr: Some(true),
face_count: Some(100_000),
..base_builder()
};
assert_eq!(estimate_usd_cents(builder), 49 + 20 + 20);
}
// ── Helpers ──
fn base_builder() -> GenerateMeshRequestBuilder {
GenerateMeshRequestBuilder {
model: RouterMeshModel::Hunyuan3d3Sketch,
provider: RouterProvider::Artcraft,
prompt: Some("a red ceramic teapot".to_string()),
reference_images: Some(ImageListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_sketch".to_string()),
])),
..Default::default()
}
}
fn estimate_usd_cents(builder: GenerateMeshRequestBuilder) -> u64 {
builder.build2()
.expect("build should succeed")
.estimate_cost()
.expect("estimate should succeed")
.cost_in_usd_cents
.expect("cost should be present")
}
}
@@ -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 ArtcraftHunyuan3d3SketchRequestState {
pub request: OmniGenMeshCostAndGenerateRequest,
}
impl ArtcraftHunyuan3d3SketchRequestState {
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_2p0::request::ArtcraftHunyuan3d2p0RequestState;
pub fn build_artcraft_hunyuan_3d_2p0(builder: GenerateMeshRequestBuilder) -> Result<MeshGenerationDraftOrRequest, ArtcraftRouterError> {
let request = build_artcraft_omni_mesh_request(builder, CommonMeshModelEnum::Hunyuan3d2p0)?;
let state = ArtcraftHunyuan3d2p0RequestState { request };
Ok(MeshGenerationDraftOrRequest::Request(MeshGenerationRequest::ArtcraftHunyuan3d2p0(state)))
}
@@ -0,0 +1,93 @@
use enums::common::generation::common_mesh_output_type::CommonMeshOutputType;
use crate::generate::generate_mesh::mesh_generation_cost_estimate::MeshGenerationCostEstimate;
use crate::generate::generate_mesh::providers::artcraft::hunyuan_3d_2p0::request::ArtcraftHunyuan3d2p0RequestState;
const TEXTURED_COST_IN_USD_CENTS: u64 = 63;
const GEOMETRY_COST_IN_USD_CENTS: u64 = 21;
/// Hunyuan 3D 2.0 via Artcraft: flat price by output type. Geometry-only
/// (white mesh) is cheaper; every other output type prices as textured.
#[derive(Clone, Debug)]
pub struct ArtcraftHunyuan3d2p0CostState {
pub cost_in_usd_cents: u64,
}
impl ArtcraftHunyuan3d2p0CostState {
pub fn from_request(request: &ArtcraftHunyuan3d2p0RequestState) -> Self {
let cost = match request.request.mesh_output_type {
Some(CommonMeshOutputType::Geometry) => GEOMETRY_COST_IN_USD_CENTS,
None | Some(_) => TEXTURED_COST_IN_USD_CENTS,
};
Self { cost_in_usd_cents: cost }
}
pub fn estimate_cost(&self) -> MeshGenerationCostEstimate {
MeshGenerationCostEstimate {
cost_in_credits: Some(self.cost_in_usd_cents),
cost_in_usd_cents: Some(self.cost_in_usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
}
#[cfg(test)]
mod tests {
use tokens::tokens::media_files::MediaFileToken;
use crate::api::image_list_ref::ImageListRef;
use crate::api::router_mesh_model::RouterMeshModel;
use crate::api::router_provider::RouterProvider;
use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder;
use super::*;
#[test]
fn default_output_type_is_sixty_three_cents() {
assert_eq!(estimate_usd_cents(base_builder()), 63);
}
#[test]
fn normal_is_sixty_three_cents() {
let builder = GenerateMeshRequestBuilder {
mesh_output_type: Some(CommonMeshOutputType::Normal),
..base_builder()
};
assert_eq!(estimate_usd_cents(builder), 63);
}
#[test]
fn geometry_is_twenty_one_cents() {
let builder = GenerateMeshRequestBuilder {
mesh_output_type: Some(CommonMeshOutputType::Geometry),
..base_builder()
};
assert_eq!(estimate_usd_cents(builder), 21);
}
// ── Helpers ──
fn base_builder() -> GenerateMeshRequestBuilder {
GenerateMeshRequestBuilder {
model: RouterMeshModel::Hunyuan3d2p0,
provider: RouterProvider::Artcraft,
reference_images: Some(ImageListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_front".to_string()),
])),
..Default::default()
}
}
fn estimate_usd_cents(builder: GenerateMeshRequestBuilder) -> u64 {
builder.build2()
.expect("build should succeed")
.estimate_cost()
.expect("estimate should succeed")
.cost_in_usd_cents
.expect("cost should be present")
}
}
@@ -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 ArtcraftHunyuan3d2p0RequestState {
pub request: OmniGenMeshCostAndGenerateRequest,
}
impl ArtcraftHunyuan3d2p0RequestState {
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_2p1::request::ArtcraftHunyuan3d2p1RequestState;
pub fn build_artcraft_hunyuan_3d_2p1(builder: GenerateMeshRequestBuilder) -> Result<MeshGenerationDraftOrRequest, ArtcraftRouterError> {
let request = build_artcraft_omni_mesh_request(builder, CommonMeshModelEnum::Hunyuan3d2p1)?;
let state = ArtcraftHunyuan3d2p1RequestState { request };
Ok(MeshGenerationDraftOrRequest::Request(MeshGenerationRequest::ArtcraftHunyuan3d2p1(state)))
}
@@ -0,0 +1,93 @@
use enums::common::generation::common_mesh_output_type::CommonMeshOutputType;
use crate::generate::generate_mesh::mesh_generation_cost_estimate::MeshGenerationCostEstimate;
use crate::generate::generate_mesh::providers::artcraft::hunyuan_3d_2p1::request::ArtcraftHunyuan3d2p1RequestState;
const TEXTURED_COST_IN_USD_CENTS: u64 = 117;
const GEOMETRY_COST_IN_USD_CENTS: u64 = 39;
/// Hunyuan 3D 2.1 via Artcraft: flat price by output type. Geometry-only
/// (white mesh) is cheaper; every other output type prices as textured.
#[derive(Clone, Debug)]
pub struct ArtcraftHunyuan3d2p1CostState {
pub cost_in_usd_cents: u64,
}
impl ArtcraftHunyuan3d2p1CostState {
pub fn from_request(request: &ArtcraftHunyuan3d2p1RequestState) -> Self {
let cost = match request.request.mesh_output_type {
Some(CommonMeshOutputType::Geometry) => GEOMETRY_COST_IN_USD_CENTS,
None | Some(_) => TEXTURED_COST_IN_USD_CENTS,
};
Self { cost_in_usd_cents: cost }
}
pub fn estimate_cost(&self) -> MeshGenerationCostEstimate {
MeshGenerationCostEstimate {
cost_in_credits: Some(self.cost_in_usd_cents),
cost_in_usd_cents: Some(self.cost_in_usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
}
#[cfg(test)]
mod tests {
use tokens::tokens::media_files::MediaFileToken;
use crate::api::image_list_ref::ImageListRef;
use crate::api::router_mesh_model::RouterMeshModel;
use crate::api::router_provider::RouterProvider;
use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder;
use super::*;
#[test]
fn default_output_type_is_one_seventeen_cents() {
assert_eq!(estimate_usd_cents(base_builder()), 117);
}
#[test]
fn normal_is_one_seventeen_cents() {
let builder = GenerateMeshRequestBuilder {
mesh_output_type: Some(CommonMeshOutputType::Normal),
..base_builder()
};
assert_eq!(estimate_usd_cents(builder), 117);
}
#[test]
fn geometry_is_thirty_nine_cents() {
let builder = GenerateMeshRequestBuilder {
mesh_output_type: Some(CommonMeshOutputType::Geometry),
..base_builder()
};
assert_eq!(estimate_usd_cents(builder), 39);
}
// ── Helpers ──
fn base_builder() -> GenerateMeshRequestBuilder {
GenerateMeshRequestBuilder {
model: RouterMeshModel::Hunyuan3d2p1,
provider: RouterProvider::Artcraft,
reference_images: Some(ImageListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_front".to_string()),
])),
..Default::default()
}
}
fn estimate_usd_cents(builder: GenerateMeshRequestBuilder) -> u64 {
builder.build2()
.expect("build should succeed")
.estimate_cost()
.expect("estimate should succeed")
.cost_in_usd_cents
.expect("cost should be present")
}
}
@@ -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 ArtcraftHunyuan3d2p1RequestState {
pub request: OmniGenMeshCostAndGenerateRequest,
}
impl ArtcraftHunyuan3d2p1RequestState {
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateMeshResponse, ArtcraftRouterError> {
send_artcraft_omni_mesh_request(&self.request, client).await
}
}
@@ -0,0 +1,7 @@
pub(crate) mod build_common;
pub(crate) mod request_common;
pub(crate) mod resolve;
pub mod hunyuan3d_3;
pub mod hunyuan3d_3_sketch;
pub mod hunyuan_3d_2p0;
pub mod hunyuan_3d_2p1;
@@ -0,0 +1,29 @@
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_mesh_cost_and_generate_request::OmniGenMeshCostAndGenerateRequest;
use artcraft_client::endpoints::omni_gen::generate::mesh::omni_gen_mesh::omni_gen_mesh_generate;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_mesh::generate_mesh_response::{
ArtcraftMeshResponsePayload, GenerateMeshResponse,
};
/// Send a mesh generation request to the Artcraft omni-gen endpoint.
/// All Artcraft model request states delegate to this function.
pub async fn send_artcraft_omni_mesh_request(
request: &OmniGenMeshCostAndGenerateRequest,
client: &RouterArtcraftClient,
) -> Result<GenerateMeshResponse, ArtcraftRouterError> {
let response = omni_gen_mesh_generate(
&client.api_host,
Some(&client.credentials),
request.clone(),
)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Storyteller(err)))?;
Ok(GenerateMeshResponse::Artcraft(ArtcraftMeshResponsePayload {
inference_job_token: response.inference_job_token,
all_inference_job_tokens: response.all_job_tokens,
}))
}
@@ -0,0 +1,34 @@
//! Shared resolve helpers for the Artcraft mesh provider.
//!
//! Artcraft only accepts media file tokens — raw URLs are rejected.
use tokens::tokens::media_files::MediaFileToken;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
pub(super) fn resolve_image_list_ref(
image_list_ref: Option<ImageListRef>,
) -> Result<Option<Vec<MediaFileToken>>, ArtcraftRouterError> {
match image_list_ref {
None => Ok(None),
Some(ImageListRef::MediaFileTokens(tokens)) => Ok(Some(tokens)),
Some(ImageListRef::Urls(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
}
}
}
pub(super) fn resolve_image_ref(
image_ref: Option<ImageRef>,
) -> Result<Option<MediaFileToken>, ArtcraftRouterError> {
match image_ref {
None => Ok(None),
Some(ImageRef::MediaFileToken(token)) => Ok(Some(token)),
Some(ImageRef::Url(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
}
}
}
@@ -0,0 +1,330 @@
use enums::common::generation::common_mesh_output_type::CommonMeshOutputType;
use enums::common::generation::common_polygon_type::CommonPolygonType;
use fal_client::requests::api::mesh::image::hunyuan3d_3_image_to_mesh::api::{
Hunyuan3d3ImageToMeshGenerateType, Hunyuan3d3ImageToMeshPolygonType, Hunyuan3d3ImageToMeshRequest,
};
use fal_client::requests::api::mesh::text::hunyuan3d_3_text_to_mesh::api::{
Hunyuan3d3TextToMeshGenerateType, Hunyuan3d3TextToMeshPolygonType, Hunyuan3d3TextToMeshRequest,
};
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder;
use crate::generate::generate_mesh::mesh_generation_draft_or_request::MeshGenerationDraftOrRequest;
use crate::generate::generate_mesh::mesh_generation_request::MeshGenerationRequest;
use crate::generate::generate_mesh::providers::fal::hunyuan3d_3::request::{
FalHunyuan3d3ImageRequestState, FalHunyuan3d3TextRequestState,
};
use crate::generate::generate_mesh::providers::fal::resolve::{
plan_face_count, plan_primary_image_url, plan_side_image_url,
};
use crate::generate::generate_mesh::providers::reject_unsupported::{
reject_unsupported_image_ref, reject_unsupported_option,
};
/// Hunyuan 3D v3 combines fal's image-to-3d and text-to-3d endpoints under a
/// single router model. The request shape picks the endpoint: any image input
/// (reference images or `front_image`) dispatches to image-to-3d; otherwise a
/// prompt dispatches to text-to-3d.
pub fn build_fal_hunyuan3d_3(builder: GenerateMeshRequestBuilder) -> Result<MeshGenerationDraftOrRequest, ArtcraftRouterError> {
let state = build_fal_hunyuan3d_3_state(builder)?;
let request = match state {
FalHunyuan3d3State::Image(state) => MeshGenerationRequest::FalHunyuan3d3Image(state),
FalHunyuan3d3State::Text(state) => MeshGenerationRequest::FalHunyuan3d3Text(state),
};
Ok(MeshGenerationDraftOrRequest::Request(request))
}
/// The endpoint selected by the request shape.
#[derive(Clone, Debug)]
pub(crate) enum FalHunyuan3d3State {
Image(FalHunyuan3d3ImageRequestState),
Text(FalHunyuan3d3TextRequestState),
}
pub(crate) fn build_fal_hunyuan3d_3_state(
mut builder: GenerateMeshRequestBuilder,
) -> Result<FalHunyuan3d3State, ArtcraftRouterError> {
let strategy = builder.request_mismatch_mitigation_strategy;
let maybe_front_url = plan_primary_image_url(
builder.reference_images.take(),
builder.front_image.take(),
strategy,
)?;
let face_count = plan_face_count(builder.face_count, strategy)?;
match maybe_front_url {
Some(image_url) => {
// Image mode. The image endpoint has no prompt parameter.
reject_unsupported_option("prompt", builder.prompt.as_ref(), strategy)?;
let request = Hunyuan3d3ImageToMeshRequest {
image_url,
back_image_url: plan_side_image_url(builder.back_image.take())?,
left_image_url: plan_side_image_url(builder.left_image.take())?,
right_image_url: plan_side_image_url(builder.right_image.take())?,
face_count,
generate_type: builder.mesh_output_type.map(to_image_generate_type),
polygon_type: builder.polygon_type.map(to_image_polygon_type),
enable_pbr: builder.enable_pbr,
};
Ok(FalHunyuan3d3State::Image(FalHunyuan3d3ImageRequestState { request }))
}
None => {
// Text mode. Multi-view side images only make sense alongside a front
// image, so they're unsupported here.
reject_unsupported_image_ref("back_image", builder.back_image.as_ref(), strategy)?;
reject_unsupported_image_ref("left_image", builder.left_image.as_ref(), strategy)?;
reject_unsupported_image_ref("right_image", builder.right_image.as_ref(), strategy)?;
let prompt = builder.prompt.take().ok_or_else(|| {
ArtcraftRouterError::InvalidInput(
"Hunyuan 3D v3 requires an input image or a prompt".to_string(),
)
})?;
let request = Hunyuan3d3TextToMeshRequest {
prompt,
face_count,
generate_type: builder.mesh_output_type.map(to_text_generate_type),
polygon_type: builder.polygon_type.map(to_text_polygon_type),
enable_pbr: builder.enable_pbr,
};
Ok(FalHunyuan3d3State::Text(FalHunyuan3d3TextRequestState { request }))
}
}
}
// ── Enum mapping helpers ──
fn to_image_generate_type(output_type: CommonMeshOutputType) -> Hunyuan3d3ImageToMeshGenerateType {
match output_type {
CommonMeshOutputType::Normal => Hunyuan3d3ImageToMeshGenerateType::Normal,
CommonMeshOutputType::LowPoly => Hunyuan3d3ImageToMeshGenerateType::LowPoly,
CommonMeshOutputType::Geometry => Hunyuan3d3ImageToMeshGenerateType::Geometry,
}
}
fn to_image_polygon_type(polygon_type: CommonPolygonType) -> Hunyuan3d3ImageToMeshPolygonType {
match polygon_type {
CommonPolygonType::Triangle => Hunyuan3d3ImageToMeshPolygonType::Triangle,
CommonPolygonType::Quad => Hunyuan3d3ImageToMeshPolygonType::Quadrilateral,
}
}
fn to_text_generate_type(output_type: CommonMeshOutputType) -> Hunyuan3d3TextToMeshGenerateType {
match output_type {
CommonMeshOutputType::Normal => Hunyuan3d3TextToMeshGenerateType::Normal,
CommonMeshOutputType::LowPoly => Hunyuan3d3TextToMeshGenerateType::LowPoly,
CommonMeshOutputType::Geometry => Hunyuan3d3TextToMeshGenerateType::Geometry,
}
}
fn to_text_polygon_type(polygon_type: CommonPolygonType) -> Hunyuan3d3TextToMeshPolygonType {
match polygon_type {
CommonPolygonType::Triangle => Hunyuan3d3TextToMeshPolygonType::Triangle,
CommonPolygonType::Quad => Hunyuan3d3TextToMeshPolygonType::Quadrilateral,
}
}
#[cfg(test)]
mod tests {
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::router_mesh_model::RouterMeshModel;
use crate::api::router_provider::RouterProvider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::client_error::ClientError;
use super::*;
const FRONT_URL: &str = "https://example.com/front.png";
const BACK_URL: &str = "https://example.com/back.png";
mod shape_dispatch {
use super::*;
#[test]
fn reference_image_dispatches_to_image_mode() {
let state = build_fal_hunyuan3d_3_state(image_builder()).expect("build");
let image = expect_image(state);
assert_eq!(image.request.image_url, FRONT_URL);
}
#[test]
fn front_image_dispatches_to_image_mode() {
let builder = GenerateMeshRequestBuilder {
reference_images: None,
front_image: Some(ImageRef::Url(FRONT_URL.to_string())),
..base_builder()
};
let state = build_fal_hunyuan3d_3_state(builder).expect("build");
let image = expect_image(state);
assert_eq!(image.request.image_url, FRONT_URL);
}
#[test]
fn prompt_only_dispatches_to_text_mode() {
let state = build_fal_hunyuan3d_3_state(text_builder()).expect("build");
let text = expect_text(state);
assert_eq!(text.request.prompt, "a red ceramic teapot");
}
#[test]
fn neither_image_nor_prompt_is_rejected() {
let result = build_fal_hunyuan3d_3_state(base_builder());
assert!(matches!(result, Err(ArtcraftRouterError::InvalidInput(_))));
}
}
mod image_mode {
use super::*;
#[test]
fn side_images_map_to_multi_view_params() {
let builder = GenerateMeshRequestBuilder {
back_image: Some(ImageRef::Url(BACK_URL.to_string())),
left_image: Some(ImageRef::Url("https://example.com/left.png".to_string())),
right_image: Some(ImageRef::Url("https://example.com/right.png".to_string())),
..image_builder()
};
let image = expect_image(build_fal_hunyuan3d_3_state(builder).expect("build"));
assert_eq!(image.request.back_image_url.as_deref(), Some(BACK_URL));
assert_eq!(image.request.left_image_url.as_deref(), Some("https://example.com/left.png"));
assert_eq!(image.request.right_image_url.as_deref(), Some("https://example.com/right.png"));
}
#[test]
fn options_map_through() {
let builder = GenerateMeshRequestBuilder {
mesh_output_type: Some(CommonMeshOutputType::LowPoly),
polygon_type: Some(CommonPolygonType::Quad),
face_count: Some(100_000),
enable_pbr: Some(true),
..image_builder()
};
let image = expect_image(build_fal_hunyuan3d_3_state(builder).expect("build"));
assert!(matches!(image.request.generate_type, Some(Hunyuan3d3ImageToMeshGenerateType::LowPoly)));
assert!(matches!(image.request.polygon_type, Some(Hunyuan3d3ImageToMeshPolygonType::Quadrilateral)));
assert_eq!(image.request.face_count, Some(100_000));
assert_eq!(image.request.enable_pbr, Some(true));
}
#[test]
fn prompt_with_image_errors_out_under_error_out() {
let builder = GenerateMeshRequestBuilder {
prompt: Some("a red ceramic teapot".to_string()),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..image_builder()
};
assert!(build_fal_hunyuan3d_3_state(builder).is_err());
}
#[test]
fn prompt_with_image_is_dropped_under_lenient_strategies() {
let builder = GenerateMeshRequestBuilder {
prompt: Some("a red ceramic teapot".to_string()),
..image_builder()
};
assert!(matches!(
build_fal_hunyuan3d_3_state(builder).expect("build"),
FalHunyuan3d3State::Image(_)
));
}
#[test]
fn media_tokens_are_rejected() {
use tokens::tokens::media_files::MediaFileToken;
let builder = GenerateMeshRequestBuilder {
reference_images: Some(ImageListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_test123".to_string()),
])),
..base_builder()
};
assert!(matches!(
build_fal_hunyuan3d_3_state(builder),
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
));
}
}
mod text_mode {
use super::*;
#[test]
fn options_map_through() {
let builder = GenerateMeshRequestBuilder {
mesh_output_type: Some(CommonMeshOutputType::Geometry),
polygon_type: Some(CommonPolygonType::Triangle),
face_count: Some(40_000),
enable_pbr: Some(false),
..text_builder()
};
let text = expect_text(build_fal_hunyuan3d_3_state(builder).expect("build"));
assert!(matches!(text.request.generate_type, Some(Hunyuan3d3TextToMeshGenerateType::Geometry)));
assert!(matches!(text.request.polygon_type, Some(Hunyuan3d3TextToMeshPolygonType::Triangle)));
assert_eq!(text.request.face_count, Some(40_000));
assert_eq!(text.request.enable_pbr, Some(false));
}
#[test]
fn side_images_without_front_error_out_under_error_out() {
let builder = GenerateMeshRequestBuilder {
back_image: Some(ImageRef::Url(BACK_URL.to_string())),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..text_builder()
};
assert!(build_fal_hunyuan3d_3_state(builder).is_err());
}
#[test]
fn side_images_without_front_are_dropped_under_lenient_strategies() {
let builder = GenerateMeshRequestBuilder {
back_image: Some(ImageRef::Url(BACK_URL.to_string())),
..text_builder()
};
assert!(matches!(
build_fal_hunyuan3d_3_state(builder).expect("build"),
FalHunyuan3d3State::Text(_)
));
}
}
// ── Helpers ──
fn base_builder() -> GenerateMeshRequestBuilder {
GenerateMeshRequestBuilder {
model: RouterMeshModel::Hunyuan3d3,
provider: RouterProvider::Fal,
..Default::default()
}
}
fn image_builder() -> GenerateMeshRequestBuilder {
GenerateMeshRequestBuilder {
reference_images: Some(ImageListRef::Urls(vec![FRONT_URL.to_string()])),
..base_builder()
}
}
fn text_builder() -> GenerateMeshRequestBuilder {
GenerateMeshRequestBuilder {
prompt: Some("a red ceramic teapot".to_string()),
..base_builder()
}
}
fn expect_image(state: FalHunyuan3d3State) -> FalHunyuan3d3ImageRequestState {
match state {
FalHunyuan3d3State::Image(image) => image,
FalHunyuan3d3State::Text(text) => panic!("expected image mode, got text: {text:?}"),
}
}
fn expect_text(state: FalHunyuan3d3State) -> FalHunyuan3d3TextRequestState {
match state {
FalHunyuan3d3State::Text(text) => text,
FalHunyuan3d3State::Image(image) => panic!("expected text mode, got image: {image:?}"),
}
}
}
@@ -0,0 +1,164 @@
use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator;
use crate::generate::generate_mesh::mesh_generation_cost_estimate::MeshGenerationCostEstimate;
use crate::generate::generate_mesh::providers::fal::hunyuan3d_3::request::{
FalHunyuan3d3ImageRequestState, FalHunyuan3d3TextRequestState,
};
#[derive(Clone, Debug)]
pub struct FalHunyuan3d3ImageCostState {
pub cost_in_usd_cents: u64,
}
impl FalHunyuan3d3ImageCostState {
pub fn from_request(request: &FalHunyuan3d3ImageRequestState) -> Self {
// Cost math is owned by fal_client's per-endpoint
// `FalRequestCostCalculator` implementations. The router state just
// forwards the result so router cost ≡ fal_client cost by construction.
Self {
cost_in_usd_cents: request.request.calculate_cost_in_cents(),
}
}
pub fn estimate_cost(&self) -> MeshGenerationCostEstimate {
fal_mesh_cost_estimate(self.cost_in_usd_cents)
}
}
#[derive(Clone, Debug)]
pub struct FalHunyuan3d3TextCostState {
pub cost_in_usd_cents: u64,
}
impl FalHunyuan3d3TextCostState {
pub fn from_request(request: &FalHunyuan3d3TextRequestState) -> Self {
Self {
cost_in_usd_cents: request.request.calculate_cost_in_cents(),
}
}
pub fn estimate_cost(&self) -> MeshGenerationCostEstimate {
fal_mesh_cost_estimate(self.cost_in_usd_cents)
}
}
pub(crate) fn fal_mesh_cost_estimate(cost_in_usd_cents: u64) -> MeshGenerationCostEstimate {
MeshGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
cost_in_usd_cents: Some(cost_in_usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
#[cfg(test)]
mod tests {
use enums::common::generation::common_mesh_output_type::CommonMeshOutputType;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::router_mesh_model::RouterMeshModel;
use crate::api::router_provider::RouterProvider;
use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder;
const FRONT_URL: &str = "https://example.com/front.png";
mod image_mode_costs {
use super::*;
#[test]
fn base_cost_table() {
let cases = [
(None, 38),
(Some(CommonMeshOutputType::Normal), 38),
(Some(CommonMeshOutputType::LowPoly), 45),
(Some(CommonMeshOutputType::Geometry), 23),
];
for (output_type, expected) in cases {
let builder = GenerateMeshRequestBuilder {
mesh_output_type: output_type,
..image_builder()
};
assert_eq!(estimate_usd_cents(builder), expected, "for {output_type:?}");
}
}
#[test]
fn add_ons_stack() {
let builder = GenerateMeshRequestBuilder {
mesh_output_type: Some(CommonMeshOutputType::LowPoly),
enable_pbr: Some(true),
face_count: Some(100_000),
back_image: Some(ImageRef::Url("https://example.com/back.png".to_string())),
..image_builder()
};
// LowPoly(45) + PBR(15) + face_count(15) + multi_view(15)
assert_eq!(estimate_usd_cents(builder), 90);
}
}
mod text_mode_costs {
use super::*;
#[test]
fn base_cost_table() {
let cases = [
(None, 38),
(Some(CommonMeshOutputType::Normal), 38),
(Some(CommonMeshOutputType::LowPoly), 45),
(Some(CommonMeshOutputType::Geometry), 23),
];
for (output_type, expected) in cases {
let builder = GenerateMeshRequestBuilder {
mesh_output_type: output_type,
..text_builder()
};
assert_eq!(estimate_usd_cents(builder), expected, "for {output_type:?}");
}
}
#[test]
fn add_ons_stack() {
let builder = GenerateMeshRequestBuilder {
mesh_output_type: Some(CommonMeshOutputType::Geometry),
enable_pbr: Some(true),
face_count: Some(40_000),
..text_builder()
};
// Geometry(23) + PBR(15) + face_count(15)
assert_eq!(estimate_usd_cents(builder), 53);
}
}
// ── Helpers ──
fn image_builder() -> GenerateMeshRequestBuilder {
GenerateMeshRequestBuilder {
model: RouterMeshModel::Hunyuan3d3,
provider: RouterProvider::Fal,
reference_images: Some(ImageListRef::Urls(vec![FRONT_URL.to_string()])),
..Default::default()
}
}
fn text_builder() -> GenerateMeshRequestBuilder {
GenerateMeshRequestBuilder {
model: RouterMeshModel::Hunyuan3d3,
provider: RouterProvider::Fal,
prompt: Some("a red ceramic teapot".to_string()),
..Default::default()
}
}
fn estimate_usd_cents(builder: GenerateMeshRequestBuilder) -> u64 {
builder.build2()
.expect("build should succeed")
.estimate_cost()
.expect("estimate should succeed")
.cost_in_usd_cents
.expect("cost should be present")
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -0,0 +1,141 @@
use std::fmt::Debug;
use std::sync::Arc;
use fal_client::requests::api::mesh::image::hunyuan3d_3_image_to_mesh::api::Hunyuan3d3ImageToMeshRequest;
use fal_client::requests::api::mesh::text::hunyuan3d_3_text_to_mesh::api::Hunyuan3d3TextToMeshRequest;
use fal_client::requests::traits::fal_endpoint_trait::FalEndpoint;
use crate::client::router_fal_client::RouterFalClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_mesh::generate_mesh_response::{
FalMeshResponsePayload, GenerateMeshResponse,
};
#[derive(Clone, Debug)]
pub struct FalHunyuan3d3ImageRequestState {
/// Final materialized request; ready to fire.
pub request: Hunyuan3d3ImageToMeshRequest,
}
impl FalHunyuan3d3ImageRequestState {
pub async fn send(&self, client: &RouterFalClient) -> Result<GenerateMeshResponse, ArtcraftRouterError> {
send_fal_mesh_request(&self.request, client).await
}
}
#[derive(Clone, Debug)]
pub struct FalHunyuan3d3TextRequestState {
/// Final materialized request; ready to fire.
pub request: Hunyuan3d3TextToMeshRequest,
}
impl FalHunyuan3d3TextRequestState {
pub async fn send(&self, client: &RouterFalClient) -> Result<GenerateMeshResponse, ArtcraftRouterError> {
send_fal_mesh_request(&self.request, client).await
}
}
/// Send a mesh request to fal using the client's dispatch mode (webhook or
/// queue). Shared by all Fal mesh request states.
pub(crate) async fn send_fal_mesh_request<R>(
request: &R,
client: &RouterFalClient,
) -> Result<GenerateMeshResponse, ArtcraftRouterError>
where
R: FalEndpoint + Clone + Debug + Send + Sync + 'static,
{
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let payload = if let Some(webhook_url) = &client.webhook_url {
let response = request.send_webhook_request(&client.api_key, webhook_url).await?;
FalMeshResponsePayload {
request_id: response.request_id,
gateway_request_id: response.gateway_request_id,
maybe_status_url: None,
maybe_response_url: None,
maybe_outbound_request: Some(outbound),
}
} else {
let response = request.send_queue_request(&client.api_key).await?;
FalMeshResponsePayload {
request_id: Some(response.request_id),
gateway_request_id: None,
maybe_status_url: Some(response.status_url),
maybe_response_url: Some(response.response_url),
maybe_outbound_request: Some(outbound),
}
};
Ok(GenerateMeshResponse::Fal(payload))
}
#[cfg(test)]
mod tests {
use fal_client::creds::fal_api_key::FalApiKey;
use test_data::web::image_urls::ERNEST_SCARED_STUPID_IMAGE_URL;
use crate::api::image_list_ref::ImageListRef;
use crate::api::router_mesh_model::RouterMeshModel;
use crate::api::router_provider::RouterProvider;
use crate::client::router_client::RouterClient;
use crate::client::router_fal_client::RouterFalClient;
use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder;
use crate::generate::generate_mesh::generate_mesh_response::GenerateMeshResponse;
use crate::generate::generate_mesh::mesh_generation_draft_or_request::MeshGenerationDraftOrRequest;
#[tokio::test]
#[ignore] // requires real API key, incurs cost
async fn send_hunyuan3d_3_image_to_mesh() {
let response = run_pipeline(GenerateMeshRequestBuilder {
reference_images: Some(ImageListRef::Urls(vec![ERNEST_SCARED_STUPID_IMAGE_URL.to_string()])),
..hunyuan3d_3_builder()
}).await;
let payload = response.get_fal_payload().expect("expected Fal payload");
assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some());
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // requires real API key, incurs cost
async fn send_hunyuan3d_3_text_to_mesh() {
let response = run_pipeline(GenerateMeshRequestBuilder {
prompt: Some("A velociraptor with an open mouth full of sharp teeth.".to_string()),
..hunyuan3d_3_builder()
}).await;
let payload = response.get_fal_payload().expect("expected Fal payload");
assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some());
assert_eq!(1, 2, "Inspect output above");
}
// ── Helpers ──
fn hunyuan3d_3_builder() -> GenerateMeshRequestBuilder {
GenerateMeshRequestBuilder {
model: RouterMeshModel::Hunyuan3d3,
provider: RouterProvider::Fal,
..Default::default()
}
}
pub(crate) fn get_fal_client() -> RouterClient {
let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")
.expect("Failed to read fal_api_key.txt");
let api_key = FalApiKey::from_str(secret.trim());
let webhook_url = "https://example.com/fal-webhook-test".to_string();
RouterClient::Fal(RouterFalClient::new_with_webhook(api_key, webhook_url))
}
pub(crate) async fn run_pipeline(builder: GenerateMeshRequestBuilder) -> GenerateMeshResponse {
let client = get_fal_client();
let draft_or_request = builder.build2().expect("build2 should succeed");
let request = match draft_or_request {
MeshGenerationDraftOrRequest::Request(r) => r,
_ => panic!("expected Request variant (Fal mesh skips draft)"),
};
let response = request.send_request(&client).await.expect("send_request should succeed");
println!("response: {:?}", response);
response
}
}
@@ -0,0 +1,154 @@
use fal_client::requests::api::mesh::sketch::hunyuan3d_3_sketch_to_mesh::api::Hunyuan3d3SketchToMeshRequest;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder;
use crate::generate::generate_mesh::mesh_generation_draft_or_request::MeshGenerationDraftOrRequest;
use crate::generate::generate_mesh::mesh_generation_request::MeshGenerationRequest;
use crate::generate::generate_mesh::providers::fal::hunyuan3d_3_sketch::request::FalHunyuan3d3SketchRequestState;
use crate::generate::generate_mesh::providers::fal::resolve::{
plan_face_count, plan_primary_image_url,
};
use crate::generate::generate_mesh::providers::reject_unsupported::{
reject_unsupported_image_ref, reject_unsupported_option,
};
pub fn build_fal_hunyuan3d_3_sketch(builder: GenerateMeshRequestBuilder) -> Result<MeshGenerationDraftOrRequest, ArtcraftRouterError> {
let state = build_fal_hunyuan3d_3_sketch_state(builder)?;
Ok(MeshGenerationDraftOrRequest::Request(MeshGenerationRequest::FalHunyuan3d3Sketch(state)))
}
pub(crate) fn build_fal_hunyuan3d_3_sketch_state(
mut builder: GenerateMeshRequestBuilder,
) -> Result<FalHunyuan3d3SketchRequestState, ArtcraftRouterError> {
let strategy = builder.request_mismatch_mitigation_strategy;
// fal's sketch-to-3d schema has no generate_type/polygon_type parameters,
// and no multi-view side images.
reject_unsupported_option("mesh_output_type", builder.mesh_output_type.as_ref(), strategy)?;
reject_unsupported_option("polygon_type", builder.polygon_type.as_ref(), strategy)?;
reject_unsupported_image_ref("back_image", builder.back_image.as_ref(), strategy)?;
reject_unsupported_image_ref("left_image", builder.left_image.as_ref(), strategy)?;
reject_unsupported_image_ref("right_image", builder.right_image.as_ref(), strategy)?;
let image_url = plan_primary_image_url(
builder.reference_images.take(),
builder.front_image.take(),
strategy,
)?
.ok_or_else(|| ArtcraftRouterError::InvalidInput(
"A sketch image is required for Hunyuan 3D v3 sketch-to-3D".to_string(),
))?;
let prompt = builder.prompt.take().ok_or_else(|| {
ArtcraftRouterError::InvalidInput(
"A prompt is required for Hunyuan 3D v3 sketch-to-3D".to_string(),
)
})?;
let request = Hunyuan3d3SketchToMeshRequest {
image_url,
prompt,
face_count: plan_face_count(builder.face_count, strategy)?,
enable_pbr: builder.enable_pbr,
};
Ok(FalHunyuan3d3SketchRequestState { request })
}
#[cfg(test)]
mod tests {
use enums::common::generation::common_mesh_output_type::CommonMeshOutputType;
use enums::common::generation::common_polygon_type::CommonPolygonType;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::router_mesh_model::RouterMeshModel;
use crate::api::router_provider::RouterProvider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use super::*;
const SKETCH_URL: &str = "https://example.com/sketch.png";
#[test]
fn sketch_and_prompt_pass_through() {
let state = build_fal_hunyuan3d_3_sketch_state(base_builder()).expect("build");
assert_eq!(state.request.image_url, SKETCH_URL);
assert_eq!(state.request.prompt, "a red ceramic teapot");
}
#[test]
fn missing_sketch_is_rejected() {
let builder = GenerateMeshRequestBuilder { reference_images: None, ..base_builder() };
assert!(matches!(
build_fal_hunyuan3d_3_sketch_state(builder),
Err(ArtcraftRouterError::InvalidInput(_))
));
}
#[test]
fn missing_prompt_is_rejected() {
let builder = GenerateMeshRequestBuilder { prompt: None, ..base_builder() };
assert!(matches!(
build_fal_hunyuan3d_3_sketch_state(builder),
Err(ArtcraftRouterError::InvalidInput(_))
));
}
#[test]
fn face_count_and_pbr_pass_through() {
let builder = GenerateMeshRequestBuilder {
face_count: Some(100_000),
enable_pbr: Some(true),
..base_builder()
};
let state = build_fal_hunyuan3d_3_sketch_state(builder).expect("build");
assert_eq!(state.request.face_count, Some(100_000));
assert_eq!(state.request.enable_pbr, Some(true));
}
#[test]
fn unsupported_options_error_out() {
let cases: Vec<GenerateMeshRequestBuilder> = vec![
GenerateMeshRequestBuilder {
mesh_output_type: Some(CommonMeshOutputType::LowPoly),
..base_builder()
},
GenerateMeshRequestBuilder {
polygon_type: Some(CommonPolygonType::Quad),
..base_builder()
},
GenerateMeshRequestBuilder {
back_image: Some(ImageRef::Url("https://example.com/back.png".to_string())),
..base_builder()
},
];
for mut builder in cases {
builder.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut;
assert!(build_fal_hunyuan3d_3_sketch_state(builder).is_err());
}
}
#[test]
fn unsupported_options_are_dropped_under_lenient_strategies() {
let builder = GenerateMeshRequestBuilder {
mesh_output_type: Some(CommonMeshOutputType::LowPoly),
polygon_type: Some(CommonPolygonType::Quad),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
..base_builder()
};
assert!(build_fal_hunyuan3d_3_sketch_state(builder).is_ok());
}
// ── Helpers ──
fn base_builder() -> GenerateMeshRequestBuilder {
GenerateMeshRequestBuilder {
model: RouterMeshModel::Hunyuan3d3Sketch,
provider: RouterProvider::Fal,
prompt: Some("a red ceramic teapot".to_string()),
reference_images: Some(ImageListRef::Urls(vec![SKETCH_URL.to_string()])),
..Default::default()
}
}
}
@@ -0,0 +1,87 @@
use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator;
use crate::generate::generate_mesh::mesh_generation_cost_estimate::MeshGenerationCostEstimate;
use crate::generate::generate_mesh::providers::fal::hunyuan3d_3::cost::fal_mesh_cost_estimate;
use crate::generate::generate_mesh::providers::fal::hunyuan3d_3_sketch::request::FalHunyuan3d3SketchRequestState;
#[derive(Clone, Debug)]
pub struct FalHunyuan3d3SketchCostState {
pub cost_in_usd_cents: u64,
}
impl FalHunyuan3d3SketchCostState {
pub fn from_request(request: &FalHunyuan3d3SketchRequestState) -> Self {
// Cost math is owned by fal_client's per-endpoint
// `FalRequestCostCalculator` implementations. The router state just
// forwards the result so router cost ≡ fal_client cost by construction.
Self {
cost_in_usd_cents: request.request.calculate_cost_in_cents(),
}
}
pub fn estimate_cost(&self) -> MeshGenerationCostEstimate {
fal_mesh_cost_estimate(self.cost_in_usd_cents)
}
}
#[cfg(test)]
mod tests {
use crate::api::image_list_ref::ImageListRef;
use crate::api::router_mesh_model::RouterMeshModel;
use crate::api::router_provider::RouterProvider;
use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder;
#[test]
fn base_cost_is_thirty_eight_cents() {
assert_eq!(estimate_usd_cents(base_builder()), 38);
}
#[test]
fn pbr_adds_fifteen_cents() {
let builder = GenerateMeshRequestBuilder {
enable_pbr: Some(true),
..base_builder()
};
assert_eq!(estimate_usd_cents(builder), 38 + 15);
}
#[test]
fn face_count_adds_fifteen_cents() {
let builder = GenerateMeshRequestBuilder {
face_count: Some(100_000),
..base_builder()
};
assert_eq!(estimate_usd_cents(builder), 38 + 15);
}
#[test]
fn all_add_ons_stack() {
let builder = GenerateMeshRequestBuilder {
enable_pbr: Some(true),
face_count: Some(100_000),
..base_builder()
};
assert_eq!(estimate_usd_cents(builder), 38 + 15 + 15);
}
// ── Helpers ──
fn base_builder() -> GenerateMeshRequestBuilder {
GenerateMeshRequestBuilder {
model: RouterMeshModel::Hunyuan3d3Sketch,
provider: RouterProvider::Fal,
prompt: Some("a red ceramic teapot".to_string()),
reference_images: Some(ImageListRef::Urls(vec!["https://example.com/sketch.png".to_string()])),
..Default::default()
}
}
fn estimate_usd_cents(builder: GenerateMeshRequestBuilder) -> u64 {
builder.build2()
.expect("build should succeed")
.estimate_cost()
.expect("estimate should succeed")
.cost_in_usd_cents
.expect("cost should be present")
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -0,0 +1,68 @@
use fal_client::requests::api::mesh::sketch::hunyuan3d_3_sketch_to_mesh::api::Hunyuan3d3SketchToMeshRequest;
use crate::client::router_fal_client::RouterFalClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_mesh::generate_mesh_response::GenerateMeshResponse;
use crate::generate::generate_mesh::providers::fal::hunyuan3d_3::request::send_fal_mesh_request;
#[derive(Clone, Debug)]
pub struct FalHunyuan3d3SketchRequestState {
/// Final materialized request; ready to fire.
pub request: Hunyuan3d3SketchToMeshRequest,
}
impl FalHunyuan3d3SketchRequestState {
pub async fn send(&self, client: &RouterFalClient) -> Result<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::mesh_generation_draft_or_request::MeshGenerationDraftOrRequest;
#[tokio::test]
#[ignore] // requires real API key, incurs cost
async fn send_sketch_to_mesh() {
let builder = GenerateMeshRequestBuilder {
model: RouterMeshModel::Hunyuan3d3Sketch,
provider: RouterProvider::Fal,
prompt: Some("A cartoon character, colorful, plastic material".to_string()),
reference_images: Some(ImageListRef::Urls(vec![ERNEST_SCARED_STUPID_IMAGE_URL.to_string()])),
..Default::default()
};
let client = get_fal_client();
let draft_or_request = builder.build2().expect("build2 should succeed");
let request = match draft_or_request {
MeshGenerationDraftOrRequest::Request(r) => r,
_ => panic!("expected Request variant (Fal mesh skips draft)"),
};
let response = request.send_request(&client).await.expect("send_request should succeed");
println!("response: {:?}", response);
let payload = response.get_fal_payload().expect("expected Fal payload");
assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some());
assert_eq!(1, 2, "Inspect output above");
}
// ── Helpers ──
fn get_fal_client() -> RouterClient {
let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")
.expect("Failed to read fal_api_key.txt");
let api_key = FalApiKey::from_str(secret.trim());
let webhook_url = "https://example.com/fal-webhook-test".to_string();
RouterClient::Fal(RouterFalClient::new_with_webhook(api_key, webhook_url))
}
}
@@ -0,0 +1,219 @@
use fal_client::requests::api::mesh::image::hunyuan_3d_2p0_image_to_mesh::api::Hunyuan3d2p0ImageToMeshRequest;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder;
use crate::generate::generate_mesh::mesh_generation_draft_or_request::MeshGenerationDraftOrRequest;
use crate::generate::generate_mesh::mesh_generation_request::MeshGenerationRequest;
use crate::generate::generate_mesh::providers::fal::hunyuan_3d_2p0::request::FalHunyuan3d2p0RequestState;
use crate::generate::generate_mesh::providers::fal::resolve::{
plan_primary_image_url, plan_textured_mesh,
};
use crate::generate::generate_mesh::providers::reject_unsupported::{
reject_unsupported_image_ref, reject_unsupported_option,
};
pub fn build_fal_hunyuan_3d_2p0(builder: GenerateMeshRequestBuilder) -> Result<MeshGenerationDraftOrRequest, ArtcraftRouterError> {
let state = build_fal_hunyuan_3d_2p0_state(builder)?;
Ok(MeshGenerationDraftOrRequest::Request(MeshGenerationRequest::FalHunyuan3d2p0(state)))
}
pub(crate) fn build_fal_hunyuan_3d_2p0_state(
mut builder: GenerateMeshRequestBuilder,
) -> Result<FalHunyuan3d2p0RequestState, ArtcraftRouterError> {
let strategy = builder.request_mismatch_mitigation_strategy;
// Hunyuan 3D 2.0 is image-to-3D only, with a single input image and no
// extra mesh options.
reject_unsupported_option("prompt", builder.prompt.as_ref(), strategy)?;
reject_unsupported_option("polygon_type", builder.polygon_type.as_ref(), strategy)?;
reject_unsupported_option("face_count", builder.face_count.as_ref(), strategy)?;
reject_unsupported_option("enable_pbr", builder.enable_pbr.as_ref(), strategy)?;
reject_unsupported_image_ref("back_image", builder.back_image.as_ref(), strategy)?;
reject_unsupported_image_ref("left_image", builder.left_image.as_ref(), strategy)?;
reject_unsupported_image_ref("right_image", builder.right_image.as_ref(), strategy)?;
let image_url = plan_primary_image_url(
builder.reference_images.take(),
builder.front_image.take(),
strategy,
)?
.ok_or_else(|| ArtcraftRouterError::InvalidInput(
"An input image is required for Hunyuan 3D 2.0".to_string(),
))?;
let request = Hunyuan3d2p0ImageToMeshRequest {
image_url,
textured_mesh: plan_textured_mesh(builder.mesh_output_type, strategy)?,
// Tuning knobs stay at the endpoint's defaults.
guidance_scale: None,
num_inference_steps: None,
octree_resolution: None,
seed: None,
};
Ok(FalHunyuan3d2p0RequestState { request })
}
#[cfg(test)]
mod tests {
use enums::common::generation::common_mesh_output_type::CommonMeshOutputType;
use enums::common::generation::common_polygon_type::CommonPolygonType;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::router_mesh_model::RouterMeshModel;
use crate::api::router_provider::RouterProvider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use super::*;
const IMAGE_URL: &str = "https://example.com/front.png";
mod image_requirements {
use super::*;
#[test]
fn single_image_passes_through() {
let state = build_fal_hunyuan_3d_2p0_state(base_builder()).expect("build");
assert_eq!(state.request.image_url, IMAGE_URL);
}
#[test]
fn missing_image_is_rejected() {
let builder = GenerateMeshRequestBuilder { reference_images: None, ..base_builder() };
assert!(matches!(
build_fal_hunyuan_3d_2p0_state(builder),
Err(ArtcraftRouterError::InvalidInput(_))
));
}
#[test]
fn two_images_error_out() {
let builder = GenerateMeshRequestBuilder {
reference_images: Some(ImageListRef::Urls(vec![
IMAGE_URL.to_string(),
"https://example.com/other.png".to_string(),
])),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..base_builder()
};
assert!(build_fal_hunyuan_3d_2p0_state(builder).is_err());
}
#[test]
fn two_images_keep_the_first_under_lenient_strategies() {
let builder = GenerateMeshRequestBuilder {
reference_images: Some(ImageListRef::Urls(vec![
IMAGE_URL.to_string(),
"https://example.com/other.png".to_string(),
])),
..base_builder()
};
let state = build_fal_hunyuan_3d_2p0_state(builder).expect("build");
assert_eq!(state.request.image_url, IMAGE_URL);
}
}
mod output_type_mapping {
use super::*;
#[test]
fn default_maps_to_textured() {
let state = build_fal_hunyuan_3d_2p0_state(base_builder()).expect("build");
assert_eq!(state.request.textured_mesh, Some(true));
}
#[test]
fn normal_maps_to_textured() {
let builder = GenerateMeshRequestBuilder {
mesh_output_type: Some(CommonMeshOutputType::Normal),
..base_builder()
};
let state = build_fal_hunyuan_3d_2p0_state(builder).expect("build");
assert_eq!(state.request.textured_mesh, Some(true));
}
#[test]
fn geometry_maps_to_white_mesh() {
let builder = GenerateMeshRequestBuilder {
mesh_output_type: Some(CommonMeshOutputType::Geometry),
..base_builder()
};
let state = build_fal_hunyuan_3d_2p0_state(builder).expect("build");
assert_eq!(state.request.textured_mesh, Some(false));
}
#[test]
fn low_poly_errors_out_under_error_out() {
let builder = GenerateMeshRequestBuilder {
mesh_output_type: Some(CommonMeshOutputType::LowPoly),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..base_builder()
};
assert!(build_fal_hunyuan_3d_2p0_state(builder).is_err());
}
#[test]
fn low_poly_upgrades_to_textured_under_lenient_strategies() {
let builder = GenerateMeshRequestBuilder {
mesh_output_type: Some(CommonMeshOutputType::LowPoly),
..base_builder()
};
let state = build_fal_hunyuan_3d_2p0_state(builder).expect("build");
assert_eq!(state.request.textured_mesh, Some(true));
}
}
mod unsupported_options {
use super::*;
#[test]
fn unsupported_options_error_out() {
let cases: Vec<GenerateMeshRequestBuilder> = vec![
GenerateMeshRequestBuilder { prompt: Some("a teapot".to_string()), ..base_builder() },
GenerateMeshRequestBuilder { polygon_type: Some(CommonPolygonType::Quad), ..base_builder() },
GenerateMeshRequestBuilder { face_count: Some(100_000), ..base_builder() },
GenerateMeshRequestBuilder { enable_pbr: Some(true), ..base_builder() },
GenerateMeshRequestBuilder {
back_image: Some(ImageRef::Url("https://example.com/back.png".to_string())),
..base_builder()
},
];
for mut builder in cases {
builder.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut;
assert!(build_fal_hunyuan_3d_2p0_state(builder).is_err());
}
}
#[test]
fn unsupported_options_are_dropped_under_lenient_strategies() {
let builder = GenerateMeshRequestBuilder {
prompt: Some("a teapot".to_string()),
face_count: Some(100_000),
enable_pbr: Some(true),
..base_builder()
};
assert!(build_fal_hunyuan_3d_2p0_state(builder).is_ok());
}
#[test]
fn tuning_knobs_stay_at_endpoint_defaults() {
let state = build_fal_hunyuan_3d_2p0_state(base_builder()).expect("build");
assert!(state.request.guidance_scale.is_none());
assert!(state.request.num_inference_steps.is_none());
assert!(state.request.octree_resolution.is_none());
assert!(state.request.seed.is_none());
}
}
// ── Helpers ──
fn base_builder() -> GenerateMeshRequestBuilder {
GenerateMeshRequestBuilder {
model: RouterMeshModel::Hunyuan3d2p0,
provider: RouterProvider::Fal,
reference_images: Some(ImageListRef::Urls(vec![IMAGE_URL.to_string()])),
..Default::default()
}
}
}
@@ -0,0 +1,75 @@
use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator;
use crate::generate::generate_mesh::mesh_generation_cost_estimate::MeshGenerationCostEstimate;
use crate::generate::generate_mesh::providers::fal::hunyuan3d_3::cost::fal_mesh_cost_estimate;
use crate::generate::generate_mesh::providers::fal::hunyuan_3d_2p0::request::FalHunyuan3d2p0RequestState;
#[derive(Clone, Debug)]
pub struct FalHunyuan3d2p0CostState {
pub cost_in_usd_cents: u64,
}
impl FalHunyuan3d2p0CostState {
pub fn from_request(request: &FalHunyuan3d2p0RequestState) -> Self {
// Cost math is owned by fal_client's per-endpoint
// `FalRequestCostCalculator` implementations. The router state just
// forwards the result so router cost ≡ fal_client cost by construction.
Self {
cost_in_usd_cents: request.request.calculate_cost_in_cents(),
}
}
pub fn estimate_cost(&self) -> MeshGenerationCostEstimate {
fal_mesh_cost_estimate(self.cost_in_usd_cents)
}
}
#[cfg(test)]
mod tests {
use enums::common::generation::common_mesh_output_type::CommonMeshOutputType;
use crate::api::image_list_ref::ImageListRef;
use crate::api::router_mesh_model::RouterMeshModel;
use crate::api::router_provider::RouterProvider;
use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder;
#[test]
fn textured_mesh_is_forty_eight_cents() {
assert_eq!(estimate_usd_cents(base_builder()), 48);
let normal = GenerateMeshRequestBuilder {
mesh_output_type: Some(CommonMeshOutputType::Normal),
..base_builder()
};
assert_eq!(estimate_usd_cents(normal), 48);
}
#[test]
fn white_mesh_is_sixteen_cents() {
let builder = GenerateMeshRequestBuilder {
mesh_output_type: Some(CommonMeshOutputType::Geometry),
..base_builder()
};
assert_eq!(estimate_usd_cents(builder), 16);
}
// ── Helpers ──
fn base_builder() -> GenerateMeshRequestBuilder {
GenerateMeshRequestBuilder {
model: RouterMeshModel::Hunyuan3d2p0,
provider: RouterProvider::Fal,
reference_images: Some(ImageListRef::Urls(vec!["https://example.com/front.png".to_string()])),
..Default::default()
}
}
fn estimate_usd_cents(builder: GenerateMeshRequestBuilder) -> u64 {
builder.build2()
.expect("build should succeed")
.estimate_cost()
.expect("estimate should succeed")
.cost_in_usd_cents
.expect("cost should be present")
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -0,0 +1,67 @@
use fal_client::requests::api::mesh::image::hunyuan_3d_2p0_image_to_mesh::api::Hunyuan3d2p0ImageToMeshRequest;
use crate::client::router_fal_client::RouterFalClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_mesh::generate_mesh_response::GenerateMeshResponse;
use crate::generate::generate_mesh::providers::fal::hunyuan3d_3::request::send_fal_mesh_request;
#[derive(Clone, Debug)]
pub struct FalHunyuan3d2p0RequestState {
/// Final materialized request; ready to fire.
pub request: Hunyuan3d2p0ImageToMeshRequest,
}
impl FalHunyuan3d2p0RequestState {
pub async fn send(&self, client: &RouterFalClient) -> Result<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::mesh_generation_draft_or_request::MeshGenerationDraftOrRequest;
#[tokio::test]
#[ignore] // requires real API key, incurs cost
async fn send_hunyuan_3d_2p0_image_to_mesh() {
let builder = GenerateMeshRequestBuilder {
model: RouterMeshModel::Hunyuan3d2p0,
provider: RouterProvider::Fal,
reference_images: Some(ImageListRef::Urls(vec![ERNEST_SCARED_STUPID_IMAGE_URL.to_string()])),
..Default::default()
};
let client = get_fal_client();
let draft_or_request = builder.build2().expect("build2 should succeed");
let request = match draft_or_request {
MeshGenerationDraftOrRequest::Request(r) => r,
_ => panic!("expected Request variant (Fal mesh skips draft)"),
};
let response = request.send_request(&client).await.expect("send_request should succeed");
println!("response: {:?}", response);
let payload = response.get_fal_payload().expect("expected Fal payload");
assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some());
assert_eq!(1, 2, "Inspect output above");
}
// ── Helpers ──
fn get_fal_client() -> RouterClient {
let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")
.expect("Failed to read fal_api_key.txt");
let api_key = FalApiKey::from_str(secret.trim());
let webhook_url = "https://example.com/fal-webhook-test".to_string();
RouterClient::Fal(RouterFalClient::new_with_webhook(api_key, webhook_url))
}
}
@@ -0,0 +1,146 @@
use fal_client::requests::api::mesh::image::hunyuan_3d_2p1_image_to_mesh::api::Hunyuan3d2p1ImageToMeshRequest;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder;
use crate::generate::generate_mesh::mesh_generation_draft_or_request::MeshGenerationDraftOrRequest;
use crate::generate::generate_mesh::mesh_generation_request::MeshGenerationRequest;
use crate::generate::generate_mesh::providers::fal::hunyuan_3d_2p1::request::FalHunyuan3d2p1RequestState;
use crate::generate::generate_mesh::providers::fal::resolve::{
plan_primary_image_url, plan_textured_mesh,
};
use crate::generate::generate_mesh::providers::reject_unsupported::{
reject_unsupported_image_ref, reject_unsupported_option,
};
pub fn build_fal_hunyuan_3d_2p1(builder: GenerateMeshRequestBuilder) -> Result<MeshGenerationDraftOrRequest, ArtcraftRouterError> {
let state = build_fal_hunyuan_3d_2p1_state(builder)?;
Ok(MeshGenerationDraftOrRequest::Request(MeshGenerationRequest::FalHunyuan3d2p1(state)))
}
pub(crate) fn build_fal_hunyuan_3d_2p1_state(
mut builder: GenerateMeshRequestBuilder,
) -> Result<FalHunyuan3d2p1RequestState, ArtcraftRouterError> {
let strategy = builder.request_mismatch_mitigation_strategy;
// Hunyuan 3D 2.1 is image-to-3D only, with a single input image and no
// extra mesh options.
reject_unsupported_option("prompt", builder.prompt.as_ref(), strategy)?;
reject_unsupported_option("polygon_type", builder.polygon_type.as_ref(), strategy)?;
reject_unsupported_option("face_count", builder.face_count.as_ref(), strategy)?;
reject_unsupported_option("enable_pbr", builder.enable_pbr.as_ref(), strategy)?;
reject_unsupported_image_ref("back_image", builder.back_image.as_ref(), strategy)?;
reject_unsupported_image_ref("left_image", builder.left_image.as_ref(), strategy)?;
reject_unsupported_image_ref("right_image", builder.right_image.as_ref(), strategy)?;
let image_url = plan_primary_image_url(
builder.reference_images.take(),
builder.front_image.take(),
strategy,
)?
.ok_or_else(|| ArtcraftRouterError::InvalidInput(
"An input image is required for Hunyuan 3D 2.1".to_string(),
))?;
let request = Hunyuan3d2p1ImageToMeshRequest {
image_url,
textured_mesh: plan_textured_mesh(builder.mesh_output_type, strategy)?,
// Tuning knobs stay at the endpoint's defaults.
guidance_scale: None,
num_inference_steps: None,
octree_resolution: None,
seed: None,
};
Ok(FalHunyuan3d2p1RequestState { request })
}
#[cfg(test)]
mod tests {
use enums::common::generation::common_mesh_output_type::CommonMeshOutputType;
use crate::api::image_list_ref::ImageListRef;
use crate::api::router_mesh_model::RouterMeshModel;
use crate::api::router_provider::RouterProvider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use super::*;
const IMAGE_URL: &str = "https://example.com/front.png";
#[test]
fn single_image_passes_through() {
let state = build_fal_hunyuan_3d_2p1_state(base_builder()).expect("build");
assert_eq!(state.request.image_url, IMAGE_URL);
assert_eq!(state.request.textured_mesh, Some(true));
}
#[test]
fn missing_image_is_rejected() {
let builder = GenerateMeshRequestBuilder { reference_images: None, ..base_builder() };
assert!(matches!(
build_fal_hunyuan_3d_2p1_state(builder),
Err(ArtcraftRouterError::InvalidInput(_))
));
}
#[test]
fn geometry_maps_to_white_mesh() {
let builder = GenerateMeshRequestBuilder {
mesh_output_type: Some(CommonMeshOutputType::Geometry),
..base_builder()
};
let state = build_fal_hunyuan_3d_2p1_state(builder).expect("build");
assert_eq!(state.request.textured_mesh, Some(false));
}
#[test]
fn low_poly_errors_out_under_error_out() {
let builder = GenerateMeshRequestBuilder {
mesh_output_type: Some(CommonMeshOutputType::LowPoly),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..base_builder()
};
assert!(build_fal_hunyuan_3d_2p1_state(builder).is_err());
}
#[test]
fn low_poly_upgrades_to_textured_under_lenient_strategies() {
let builder = GenerateMeshRequestBuilder {
mesh_output_type: Some(CommonMeshOutputType::LowPoly),
..base_builder()
};
let state = build_fal_hunyuan_3d_2p1_state(builder).expect("build");
assert_eq!(state.request.textured_mesh, Some(true));
}
#[test]
fn unsupported_options_error_out() {
let builder = GenerateMeshRequestBuilder {
prompt: Some("a teapot".to_string()),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..base_builder()
};
assert!(build_fal_hunyuan_3d_2p1_state(builder).is_err());
}
#[test]
fn unsupported_options_are_dropped_under_lenient_strategies() {
let builder = GenerateMeshRequestBuilder {
prompt: Some("a teapot".to_string()),
face_count: Some(100_000),
..base_builder()
};
assert!(build_fal_hunyuan_3d_2p1_state(builder).is_ok());
}
// ── Helpers ──
fn base_builder() -> GenerateMeshRequestBuilder {
GenerateMeshRequestBuilder {
model: RouterMeshModel::Hunyuan3d2p1,
provider: RouterProvider::Fal,
reference_images: Some(ImageListRef::Urls(vec![IMAGE_URL.to_string()])),
..Default::default()
}
}
}
@@ -0,0 +1,75 @@
use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator;
use crate::generate::generate_mesh::mesh_generation_cost_estimate::MeshGenerationCostEstimate;
use crate::generate::generate_mesh::providers::fal::hunyuan3d_3::cost::fal_mesh_cost_estimate;
use crate::generate::generate_mesh::providers::fal::hunyuan_3d_2p1::request::FalHunyuan3d2p1RequestState;
#[derive(Clone, Debug)]
pub struct FalHunyuan3d2p1CostState {
pub cost_in_usd_cents: u64,
}
impl FalHunyuan3d2p1CostState {
pub fn from_request(request: &FalHunyuan3d2p1RequestState) -> Self {
// Cost math is owned by fal_client's per-endpoint
// `FalRequestCostCalculator` implementations. The router state just
// forwards the result so router cost ≡ fal_client cost by construction.
Self {
cost_in_usd_cents: request.request.calculate_cost_in_cents(),
}
}
pub fn estimate_cost(&self) -> MeshGenerationCostEstimate {
fal_mesh_cost_estimate(self.cost_in_usd_cents)
}
}
#[cfg(test)]
mod tests {
use enums::common::generation::common_mesh_output_type::CommonMeshOutputType;
use crate::api::image_list_ref::ImageListRef;
use crate::api::router_mesh_model::RouterMeshModel;
use crate::api::router_provider::RouterProvider;
use crate::generate::generate_mesh::generate_mesh_request_builder::GenerateMeshRequestBuilder;
#[test]
fn textured_mesh_is_ninety_cents() {
assert_eq!(estimate_usd_cents(base_builder()), 90);
let normal = GenerateMeshRequestBuilder {
mesh_output_type: Some(CommonMeshOutputType::Normal),
..base_builder()
};
assert_eq!(estimate_usd_cents(normal), 90);
}
#[test]
fn white_mesh_is_thirty_cents() {
let builder = GenerateMeshRequestBuilder {
mesh_output_type: Some(CommonMeshOutputType::Geometry),
..base_builder()
};
assert_eq!(estimate_usd_cents(builder), 30);
}
// ── Helpers ──
fn base_builder() -> GenerateMeshRequestBuilder {
GenerateMeshRequestBuilder {
model: RouterMeshModel::Hunyuan3d2p1,
provider: RouterProvider::Fal,
reference_images: Some(ImageListRef::Urls(vec!["https://example.com/front.png".to_string()])),
..Default::default()
}
}
fn estimate_usd_cents(builder: GenerateMeshRequestBuilder) -> u64 {
builder.build2()
.expect("build should succeed")
.estimate_cost()
.expect("estimate should succeed")
.cost_in_usd_cents
.expect("cost should be present")
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -0,0 +1,67 @@
use fal_client::requests::api::mesh::image::hunyuan_3d_2p1_image_to_mesh::api::Hunyuan3d2p1ImageToMeshRequest;
use crate::client::router_fal_client::RouterFalClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_mesh::generate_mesh_response::GenerateMeshResponse;
use crate::generate::generate_mesh::providers::fal::hunyuan3d_3::request::send_fal_mesh_request;
#[derive(Clone, Debug)]
pub struct FalHunyuan3d2p1RequestState {
/// Final materialized request; ready to fire.
pub request: Hunyuan3d2p1ImageToMeshRequest,
}
impl FalHunyuan3d2p1RequestState {
pub async fn send(&self, client: &RouterFalClient) -> Result<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::mesh_generation_draft_or_request::MeshGenerationDraftOrRequest;
#[tokio::test]
#[ignore] // requires real API key, incurs cost
async fn send_hunyuan_3d_2p1_image_to_mesh() {
let builder = GenerateMeshRequestBuilder {
model: RouterMeshModel::Hunyuan3d2p1,
provider: RouterProvider::Fal,
reference_images: Some(ImageListRef::Urls(vec![ERNEST_SCARED_STUPID_IMAGE_URL.to_string()])),
..Default::default()
};
let client = get_fal_client();
let draft_or_request = builder.build2().expect("build2 should succeed");
let request = match draft_or_request {
MeshGenerationDraftOrRequest::Request(r) => r,
_ => panic!("expected Request variant (Fal mesh skips draft)"),
};
let response = request.send_request(&client).await.expect("send_request should succeed");
println!("response: {:?}", response);
let payload = response.get_fal_payload().expect("expected Fal payload");
assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some());
assert_eq!(1, 2, "Inspect output above");
}
// ── Helpers ──
fn get_fal_client() -> RouterClient {
let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")
.expect("Failed to read fal_api_key.txt");
let api_key = FalApiKey::from_str(secret.trim());
let webhook_url = "https://example.com/fal-webhook-test".to_string();
RouterClient::Fal(RouterFalClient::new_with_webhook(api_key, webhook_url))
}
}
@@ -0,0 +1,5 @@
pub(crate) mod resolve;
pub mod hunyuan3d_3;
pub mod hunyuan3d_3_sketch;
pub mod hunyuan_3d_2p0;
pub mod hunyuan_3d_2p1;
@@ -0,0 +1,208 @@
//! Shared plan helpers for the Fal mesh provider.
//!
//! Fal only accepts media URLs — media file tokens are rejected. Callers
//! must resolve tokens to URLs before building for the Fal provider.
use enums::common::generation::common_mesh_output_type::CommonMeshOutputType;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
/// Resolve the primary (front) image from `reference_images` and/or
/// `front_image`. More than one candidate rejects under `ErrorOut`; the other
/// strategies keep the first (reference images take precedence over
/// `front_image`).
pub(crate) fn plan_primary_image_url(
reference_images: Option<ImageListRef>,
front_image: Option<ImageRef>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<String>, ArtcraftRouterError> {
let mut candidates: Vec<String> = Vec::new();
match reference_images {
None => {}
Some(ImageListRef::Urls(urls)) => candidates.extend(urls),
Some(ImageListRef::MediaFileTokens(tokens)) if tokens.is_empty() => {}
Some(ImageListRef::MediaFileTokens(_)) => {
return Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls));
}
}
if let Some(front) = front_image {
candidates.push(image_ref_to_url(front)?);
}
if candidates.len() > 1 {
if let RequestMismatchMitigationStrategy::ErrorOut = strategy {
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "reference_images",
value: format!("Expected at most 1 primary image, got {}", candidates.len()),
}));
}
}
Ok(candidates.into_iter().next())
}
/// Resolve an optional side-view image (back/left/right) to a URL.
pub(crate) fn plan_side_image_url(
image_ref: Option<ImageRef>,
) -> Result<Option<String>, ArtcraftRouterError> {
image_ref.map(image_ref_to_url).transpose()
}
/// Narrow a `u64` face count to the `u32` the fal bindings take.
/// Out-of-range values reject under `ErrorOut` and clamp otherwise.
pub(crate) fn plan_face_count(
face_count: Option<u64>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<u32>, ArtcraftRouterError> {
let Some(face_count) = face_count else {
return Ok(None);
};
match u32::try_from(face_count) {
Ok(value) => Ok(Some(value)),
Err(_) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "face_count",
value: format!("{}", face_count),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(u32::MAX)),
},
}
}
/// Map a mesh output type onto the Hunyuan 2.x endpoints' `textured_mesh`
/// flag. Those endpoints only produce textured or white (geometry-only)
/// meshes: `Normal` (or unset) maps to textured, `Geometry` to white, and
/// `LowPoly` rejects under `ErrorOut` or upgrades to textured otherwise.
pub(crate) fn plan_textured_mesh(
mesh_output_type: Option<CommonMeshOutputType>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<bool>, ArtcraftRouterError> {
match mesh_output_type {
None | Some(CommonMeshOutputType::Normal) => Ok(Some(true)),
Some(CommonMeshOutputType::Geometry) => Ok(Some(false)),
Some(CommonMeshOutputType::LowPoly) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "mesh_output_type",
value: format!("{:?}", CommonMeshOutputType::LowPoly),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(true)),
},
}
}
fn image_ref_to_url(image_ref: ImageRef) -> Result<String, ArtcraftRouterError> {
match image_ref {
ImageRef::Url(url) => Ok(url),
ImageRef::MediaFileToken(_) => {
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
}
}
}
#[cfg(test)]
mod tests {
use tokens::tokens::media_files::MediaFileToken;
use super::*;
mod primary_image_tests {
use super::*;
#[test]
fn none_resolves_to_none() {
let result = plan_primary_image_url(None, None, RequestMismatchMitigationStrategy::ErrorOut)
.expect("plan");
assert!(result.is_none());
}
#[test]
fn single_reference_url_is_used() {
let refs = Some(ImageListRef::Urls(vec!["https://example.com/a.png".to_string()]));
let result = plan_primary_image_url(refs, None, RequestMismatchMitigationStrategy::ErrorOut)
.expect("plan");
assert_eq!(result.as_deref(), Some("https://example.com/a.png"));
}
#[test]
fn front_image_is_used_when_no_references() {
let front = Some(ImageRef::Url("https://example.com/front.png".to_string()));
let result = plan_primary_image_url(None, front, RequestMismatchMitigationStrategy::ErrorOut)
.expect("plan");
assert_eq!(result.as_deref(), Some("https://example.com/front.png"));
}
#[test]
fn multiple_candidates_error_out() {
let refs = Some(ImageListRef::Urls(vec!["https://example.com/a.png".to_string()]));
let front = Some(ImageRef::Url("https://example.com/front.png".to_string()));
let result = plan_primary_image_url(refs, front, RequestMismatchMitigationStrategy::ErrorOut);
assert!(result.is_err());
}
#[test]
fn multiple_candidates_keep_the_first_under_lenient_strategies() {
let refs = Some(ImageListRef::Urls(vec![
"https://example.com/a.png".to_string(),
"https://example.com/b.png".to_string(),
]));
let result = plan_primary_image_url(refs, None, RequestMismatchMitigationStrategy::PayMoreUpgrade)
.expect("plan");
assert_eq!(result.as_deref(), Some("https://example.com/a.png"));
}
#[test]
fn media_tokens_are_rejected() {
let refs = Some(ImageListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_test123".to_string()),
]));
let result = plan_primary_image_url(refs, None, RequestMismatchMitigationStrategy::PayMoreUpgrade);
assert!(matches!(
result,
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
));
}
#[test]
fn empty_token_list_resolves_to_none() {
let refs = Some(ImageListRef::MediaFileTokens(vec![]));
let result = plan_primary_image_url(refs, None, RequestMismatchMitigationStrategy::ErrorOut)
.expect("plan");
assert!(result.is_none());
}
}
mod face_count_tests {
use super::*;
#[test]
fn in_range_passes_through() {
let result = plan_face_count(Some(500_000), RequestMismatchMitigationStrategy::ErrorOut)
.expect("plan");
assert_eq!(result, Some(500_000));
}
#[test]
fn overflow_errors_out() {
let result = plan_face_count(Some(u64::MAX), RequestMismatchMitigationStrategy::ErrorOut);
assert!(result.is_err());
}
#[test]
fn overflow_clamps_under_lenient_strategies() {
let result = plan_face_count(Some(u64::MAX), RequestMismatchMitigationStrategy::PayLessDowngrade)
.expect("plan");
assert_eq!(result, Some(u32::MAX));
}
}
}
@@ -0,0 +1,3 @@
pub mod artcraft;
pub mod fal;
pub(crate) mod reject_unsupported;
@@ -0,0 +1,44 @@
//! Shared helpers for rejecting builder options that a model does not support.
//!
//! Per the request-mismatch mitigation strategy: `ErrorOut` rejects the
//! request when an unsupported option is set; the other strategies silently
//! drop the option.
use std::fmt::Debug;
use crate::api::image_ref::ImageRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
/// Reject (under `ErrorOut`) or silently drop a set option the model doesn't
/// support.
pub(crate) fn reject_unsupported_option<T: Debug>(
field: &'static str,
value: Option<&T>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<(), ArtcraftRouterError> {
let Some(value) = value else {
return Ok(());
};
match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field,
value: format!("{:?}", value),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(()),
}
}
/// Reject (under `ErrorOut`) or silently drop a single side-view image for
/// models/endpoints that take none.
pub(crate) fn reject_unsupported_image_ref(
field: &'static str,
image_ref: Option<&ImageRef>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<(), ArtcraftRouterError> {
reject_unsupported_option(field, image_ref, strategy)
}
@@ -3,6 +3,7 @@ use crate::generate::generate_splat::splat_generation_cost_estimate::SplatGenera
use worldlabs_api_client::api::api_types::world_labs_model::WorldLabsModel;
use worldlabs_api_client::pricing::check_pricing::{calculate_cost, InputType};
#[allow(deprecated)] // legacy marble 0.1 pricing intentionally uses the deprecated variants
pub(crate) fn estimate_splat_cost_artcraft_marble_0p1_mini(
plan: &PlanArtcraftMarble0p1Mini,
) -> SplatGenerationCostEstimate {
@@ -3,6 +3,7 @@ use crate::generate::generate_splat::splat_generation_cost_estimate::SplatGenera
use worldlabs_api_client::api::api_types::world_labs_model::WorldLabsModel;
use worldlabs_api_client::pricing::check_pricing::{calculate_cost, InputType};
#[allow(deprecated)] // legacy marble 0.1 pricing intentionally uses the deprecated variants
pub(crate) fn estimate_splat_cost_artcraft_marble_0p1_plus(
plan: &PlanArtcraftMarble0p1Plus,
) -> SplatGenerationCostEstimate {
@@ -27,6 +27,7 @@ pub async fn execute_artcraft_marble_0p1_mini(
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Storyteller(err)))?;
Ok(GenerateSplatResponse::Artcraft(ArtcraftSplatResponsePayload {
inference_job_token: response.inference_job_token,
inference_job_token: response.inference_job_token.clone(),
all_inference_job_tokens: vec![response.inference_job_token],
}))
}
@@ -27,6 +27,7 @@ pub async fn execute_artcraft_marble_0p1_plus(
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Storyteller(err)))?;
Ok(GenerateSplatResponse::Artcraft(ArtcraftSplatResponsePayload {
inference_job_token: response.inference_job_token,
inference_job_token: response.inference_job_token.clone(),
all_inference_job_tokens: vec![response.inference_job_token],
}))
}
@@ -37,6 +37,11 @@ impl GenerateSplatRequest {
match self.model {
RouterSplatModel::Marble0p1Mini => plan_generate_splat_artcraft_marble_0p1_mini(self),
RouterSplatModel::Marble0p1Plus => plan_generate_splat_artcraft_marble_0p1_plus(self),
// The marble 1.x models are only supported by the new
// `GenerateSplatRequestBuilder::build2()` pipeline.
_ => Err(ArtcraftRouterError::UnsupportedModel(
format!("Splat generation for model `{:?}` is not supported by the legacy plan API; use GenerateSplatRequestBuilder::build2()", self.model)
)),
}
}
@@ -0,0 +1,299 @@
use crate::api::image_list_ref::ImageListRef;
use crate::api::router_provider::RouterProvider;
use crate::api::router_splat_model::RouterSplatModel;
use crate::api::video_ref::VideoRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_splat::providers::artcraft::marble_1p0::build::build_artcraft_marble_1p0;
use crate::generate::generate_splat::providers::artcraft::marble_1p0_draft::build::build_artcraft_marble_1p0_draft;
use crate::generate::generate_splat::providers::artcraft::marble_1p1::build::build_artcraft_marble_1p1;
use crate::generate::generate_splat::providers::artcraft::marble_1p1_plus::build::build_artcraft_marble_1p1_plus;
use crate::generate::generate_splat::providers::worldlabs::marble_1p0::build::build_worldlabs_marble_1p0;
use crate::generate::generate_splat::providers::worldlabs::marble_1p0_draft::build::build_worldlabs_marble_1p0_draft;
use crate::generate::generate_splat::providers::worldlabs::marble_1p1::build::build_worldlabs_marble_1p1;
use crate::generate::generate_splat::providers::worldlabs::marble_1p1_plus::build::build_worldlabs_marble_1p1_plus;
use crate::generate::generate_splat::splat_generation_draft_or_request::SplatGenerationDraftOrRequest;
/// RouterProvider-agnostic splat (gaussian world) generation request.
/// Distilled by `build2()` into a `SplatGenerationDraftOrRequest` for the
/// selected (provider, model) pair.
///
/// NB: The deprecated marble 0.1 models are treated as their marble 1.x
/// successors (`Marble0p1Mini` → `Marble1p0Draft`, `Marble0p1Plus` →
/// `Marble1p0`).
#[derive(Clone, Debug)]
pub struct GenerateSplatRequestBuilder {
/// Which model to use.
pub model: RouterSplatModel,
/// Which provider to use.
pub provider: RouterProvider,
/// The text prompt for the splat generation.
pub prompt: Option<String>,
/// Reference images (optional).
/// One image generates from a single view; multiple images are treated as
/// multi-view input of the same world.
pub reference_images: Option<ImageListRef>,
/// Reference video (optional). Cannot be combined with reference images.
pub reference_video: Option<VideoRef>,
/// Whether the single reference image is a 360-degree panorama.
pub is_panoramic: Option<bool>,
/// Whether to disable prompt recaptioning.
pub disable_recaption: Option<bool>,
/// If the request is a mismatch with the (model/provider), how to mitigate it.
pub request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy,
/// Some providers support idempotency.
/// If not supplied, we'll generate one for the required providers.
pub idempotency_token: Option<String>,
}
impl Default for GenerateSplatRequestBuilder {
fn default() -> Self {
Self {
model: RouterSplatModel::Marble1p0Draft,
provider: RouterProvider::Artcraft,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
prompt: None,
reference_images: None,
reference_video: None,
is_panoramic: None,
disable_recaption: None,
idempotency_token: None,
}
}
}
impl GenerateSplatRequestBuilder {
pub fn build2(self) -> Result<SplatGenerationDraftOrRequest, ArtcraftRouterError> {
match (self.provider, self.model) {
// Artcraft
(RouterProvider::Artcraft, RouterSplatModel::Marble1p0 | RouterSplatModel::Marble0p1Plus) => build_artcraft_marble_1p0(self),
(RouterProvider::Artcraft, RouterSplatModel::Marble1p0Draft | RouterSplatModel::Marble0p1Mini) => build_artcraft_marble_1p0_draft(self),
(RouterProvider::Artcraft, RouterSplatModel::Marble1p1) => build_artcraft_marble_1p1(self),
(RouterProvider::Artcraft, RouterSplatModel::Marble1p1Plus) => build_artcraft_marble_1p1_plus(self),
// World Labs
(RouterProvider::WorldLabs, RouterSplatModel::Marble1p0 | RouterSplatModel::Marble0p1Plus) => build_worldlabs_marble_1p0(self),
(RouterProvider::WorldLabs, RouterSplatModel::Marble1p0Draft | RouterSplatModel::Marble0p1Mini) => build_worldlabs_marble_1p0_draft(self),
(RouterProvider::WorldLabs, RouterSplatModel::Marble1p1) => build_worldlabs_marble_1p1(self),
(RouterProvider::WorldLabs, RouterSplatModel::Marble1p1Plus) => build_worldlabs_marble_1p1_plus(self),
_ => self.unsupported_provider_and_model(),
}
}
pub fn get_or_generate_idempotency_token(&self) -> String {
self.idempotency_token.clone()
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string())
}
fn unsupported_provider_and_model(&self) -> Result<SplatGenerationDraftOrRequest, ArtcraftRouterError> {
Err(ArtcraftRouterError::UnsupportedProviderAndModelForNewApi(
format!("Splat generation for model `{:?}` is not supported for provider {:?}", self.model, self.provider)
))
}
}
#[cfg(test)]
mod tests {
use tokens::tokens::media_files::MediaFileToken;
use crate::generate::generate_splat::splat_generation_draft::SplatGenerationDraftRequest;
use crate::generate::generate_splat::splat_generation_request::SplatGenerationRequest;
use super::*;
const IMAGE_URL: &str = "https://example.com/room.png";
mod artcraft_dispatch_tests {
use super::*;
#[test]
fn all_models_dispatch_to_requests() {
let cases = [
RouterSplatModel::Marble1p0,
RouterSplatModel::Marble1p0Draft,
RouterSplatModel::Marble1p1,
RouterSplatModel::Marble1p1Plus,
];
for model in cases {
let result = artcraft_builder(model).build2()
.unwrap_or_else(|e| panic!("build should succeed for {model:?}: {e}"));
let request = expect_request(result);
let matches = matches!(
(model, &request),
(RouterSplatModel::Marble1p0, SplatGenerationRequest::ArtcraftMarble1p0(_))
| (RouterSplatModel::Marble1p0Draft, SplatGenerationRequest::ArtcraftMarble1p0Draft(_))
| (RouterSplatModel::Marble1p1, SplatGenerationRequest::ArtcraftMarble1p1(_))
| (RouterSplatModel::Marble1p1Plus, SplatGenerationRequest::ArtcraftMarble1p1Plus(_))
);
assert!(matches, "unexpected dispatch for {model:?}: {request:?}");
}
}
#[test]
fn legacy_marble_0p1_mini_is_treated_as_marble_1p0_draft() {
let result = artcraft_builder(RouterSplatModel::Marble0p1Mini).build2().expect("build");
assert!(matches!(
expect_request(result),
SplatGenerationRequest::ArtcraftMarble1p0Draft(_)
));
}
#[test]
fn legacy_marble_0p1_plus_is_treated_as_marble_1p0() {
let result = artcraft_builder(RouterSplatModel::Marble0p1Plus).build2().expect("build");
assert!(matches!(
expect_request(result),
SplatGenerationRequest::ArtcraftMarble1p0(_)
));
}
}
mod worldlabs_dispatch_tests {
use super::*;
#[test]
fn text_prompts_dispatch_to_direct_requests() {
let cases = [
RouterSplatModel::Marble1p0,
RouterSplatModel::Marble1p0Draft,
RouterSplatModel::Marble1p1,
RouterSplatModel::Marble1p1Plus,
];
for model in cases {
let result = worldlabs_text_builder(model).build2()
.unwrap_or_else(|e| panic!("build should succeed for {model:?}: {e}"));
let request = expect_request(result);
let matches = matches!(
(model, &request),
(RouterSplatModel::Marble1p0, SplatGenerationRequest::WorldLabsMarble1p0(_))
| (RouterSplatModel::Marble1p0Draft, SplatGenerationRequest::WorldLabsMarble1p0Draft(_))
| (RouterSplatModel::Marble1p1, SplatGenerationRequest::WorldLabsMarble1p1(_))
| (RouterSplatModel::Marble1p1Plus, SplatGenerationRequest::WorldLabsMarble1p1Plus(_))
);
assert!(matches, "unexpected dispatch for {model:?}: {request:?}");
}
}
#[test]
fn media_inputs_dispatch_to_drafts() {
let cases = [
RouterSplatModel::Marble1p0,
RouterSplatModel::Marble1p0Draft,
RouterSplatModel::Marble1p1,
RouterSplatModel::Marble1p1Plus,
];
for model in cases {
let builder = GenerateSplatRequestBuilder {
reference_images: Some(ImageListRef::Urls(vec![IMAGE_URL.to_string()])),
..worldlabs_text_builder(model)
};
let result = builder.build2()
.unwrap_or_else(|e| panic!("build should succeed for {model:?}: {e}"));
let draft = expect_draft(result);
let matches = matches!(
(model, &draft),
(RouterSplatModel::Marble1p0, SplatGenerationDraftRequest::WorldLabsMarble1p0(_))
| (RouterSplatModel::Marble1p0Draft, SplatGenerationDraftRequest::WorldLabsMarble1p0Draft(_))
| (RouterSplatModel::Marble1p1, SplatGenerationDraftRequest::WorldLabsMarble1p1(_))
| (RouterSplatModel::Marble1p1Plus, SplatGenerationDraftRequest::WorldLabsMarble1p1Plus(_))
);
assert!(matches, "unexpected dispatch for {model:?}: {draft:?}");
}
}
#[test]
fn legacy_models_are_treated_as_their_successors() {
let mini = worldlabs_text_builder(RouterSplatModel::Marble0p1Mini).build2().expect("build");
assert!(matches!(
expect_request(mini),
SplatGenerationRequest::WorldLabsMarble1p0Draft(_)
));
let plus = worldlabs_text_builder(RouterSplatModel::Marble0p1Plus).build2().expect("build");
assert!(matches!(
expect_request(plus),
SplatGenerationRequest::WorldLabsMarble1p0(_)
));
}
#[test]
fn media_token_inputs_dispatch_to_drafts() {
let builder = GenerateSplatRequestBuilder {
prompt: None,
reference_images: Some(ImageListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_test123".to_string()),
])),
..worldlabs_text_builder(RouterSplatModel::Marble1p0)
};
let result = builder.build2().expect("build");
assert!(matches!(result, SplatGenerationDraftOrRequest::Draft(_)));
}
}
mod unsupported_combo_tests {
use super::*;
#[test]
fn non_splat_providers_are_unsupported() {
for provider in [
RouterProvider::Fal,
RouterProvider::GmiCloud,
RouterProvider::GrokApi,
RouterProvider::Seedance2Pro,
] {
let result = GenerateSplatRequestBuilder {
provider,
model: RouterSplatModel::Marble1p0,
prompt: Some("a cozy cabin".to_string()),
..Default::default()
}.build2();
assert!(
matches!(result, Err(ArtcraftRouterError::UnsupportedProviderAndModelForNewApi(_))),
"expected unsupported error for {provider:?}",
);
}
}
}
// ── Helpers ──
fn artcraft_builder(model: RouterSplatModel) -> GenerateSplatRequestBuilder {
GenerateSplatRequestBuilder {
provider: RouterProvider::Artcraft,
model,
prompt: Some("a cozy cabin in the snowy mountains".to_string()),
..Default::default()
}
}
fn worldlabs_text_builder(model: RouterSplatModel) -> GenerateSplatRequestBuilder {
GenerateSplatRequestBuilder {
provider: RouterProvider::WorldLabs,
model,
prompt: Some("a cozy cabin in the snowy mountains".to_string()),
..Default::default()
}
}
fn expect_request(result: SplatGenerationDraftOrRequest) -> SplatGenerationRequest {
match result {
SplatGenerationDraftOrRequest::Request(request) => request,
SplatGenerationDraftOrRequest::Draft(draft) => panic!("expected Request, got Draft: {draft:?}"),
}
}
fn expect_draft(result: SplatGenerationDraftOrRequest) -> SplatGenerationDraftRequest {
match result {
SplatGenerationDraftOrRequest::Draft(draft) => draft,
SplatGenerationDraftOrRequest::Request(request) => panic!("expected Draft, got Request: {request:?}"),
}
}
}
@@ -3,17 +3,34 @@ use tokens::tokens::generic_inference_jobs::InferenceJobToken;
#[derive(Clone, Debug)]
pub struct ArtcraftSplatResponsePayload {
pub inference_job_token: InferenceJobToken,
pub all_inference_job_tokens: Vec<InferenceJobToken>,
}
#[derive(Clone, Debug)]
pub struct WorldLabsSplatResponsePayload {
/// The World Labs operation ID; poll it for generation status.
pub operation_id: String,
pub done: bool,
}
#[derive(Clone, Debug)]
pub enum GenerateSplatResponse {
Artcraft(ArtcraftSplatResponsePayload),
WorldLabs(WorldLabsSplatResponsePayload),
}
impl GenerateSplatResponse {
pub fn get_artcraft_payload(&self) -> Option<ArtcraftSplatResponsePayload> {
match self {
Self::Artcraft(p) => Some(p.clone()),
_ => None,
}
}
pub fn get_worldlabs_payload(&self) -> Option<WorldLabsSplatResponsePayload> {
match self {
Self::WorldLabs(p) => Some(p.clone()),
_ => None,
}
}
}
@@ -1,7 +1,18 @@
// New audio-style pipeline (marble 1.x; use `GenerateSplatRequestBuilder::build2()`).
pub mod generate_splat_request_builder;
pub mod providers;
pub mod splat_generation_draft;
pub mod splat_generation_draft_context;
pub mod splat_generation_draft_or_request;
pub mod splat_generation_request;
// Shared between the new and legacy pipelines.
pub mod generate_splat_response;
pub mod splat_generation_cost_estimate;
// Legacy plan-based pipeline (marble 0.1 only; still used by the desktop app).
pub mod cost;
pub mod execute;
pub mod generate_splat_request;
pub mod generate_splat_response;
pub mod plan;
pub mod splat_generation_cost_estimate;
pub mod splat_generation_plan;
@@ -0,0 +1,115 @@
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_splat_cost_and_generate_request::OmniGenSplatCostAndGenerateRequest;
use enums::common::generation::common_splat_model::CommonSplatModel as CommonSplatModelEnum;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_splat::generate_splat_request_builder::GenerateSplatRequestBuilder;
use crate::generate::generate_splat::providers::artcraft::resolve::{
resolve_image_list_ref, resolve_video_ref,
};
/// Build an `OmniGenSplatCostAndGenerateRequest` from the builder. The
/// Artcraft provider is a thin forwarding shim: the storyteller-web omni
/// splat endpoint owns per-model validation and option mapping, so the
/// builder's options are passed through as-is.
pub fn build_artcraft_omni_splat_request(
mut builder: GenerateSplatRequestBuilder,
model: CommonSplatModelEnum,
) -> Result<OmniGenSplatCostAndGenerateRequest, ArtcraftRouterError> {
let reference_image_media_tokens = resolve_image_list_ref(builder.reference_images.take())?;
let reference_video_media_token = resolve_video_ref(builder.reference_video.take())?;
let idempotency_token = builder.get_or_generate_idempotency_token();
Ok(OmniGenSplatCostAndGenerateRequest {
idempotency_token: Some(idempotency_token),
model: Some(model),
prompt: builder.prompt.take(),
reference_image_media_tokens,
reference_video_media_token,
is_panoramic: builder.is_panoramic,
disable_recaption: builder.disable_recaption,
})
}
#[cfg(test)]
mod tests {
use tokens::tokens::media_files::MediaFileToken;
use crate::api::image_list_ref::ImageListRef;
use crate::api::router_provider::RouterProvider;
use crate::api::router_splat_model::RouterSplatModel;
use crate::api::video_ref::VideoRef;
use crate::errors::client_error::ClientError;
use super::*;
#[test]
fn all_fields_are_forwarded() {
let builder = GenerateSplatRequestBuilder {
prompt: Some("a cozy cabin".to_string()),
reference_images: Some(ImageListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_image".to_string()),
])),
reference_video: Some(VideoRef::MediaFileToken(MediaFileToken::new("mf_video".to_string()))),
is_panoramic: Some(true),
disable_recaption: Some(true),
idempotency_token: Some("test-token".to_string()),
..base_builder()
};
let request = build_artcraft_omni_splat_request(builder, CommonSplatModelEnum::Marble1p0)
.expect("build");
assert_eq!(request.idempotency_token.as_deref(), Some("test-token"));
assert_eq!(request.model, Some(CommonSplatModelEnum::Marble1p0));
assert_eq!(request.prompt.as_deref(), Some("a cozy cabin"));
assert_eq!(request.reference_image_media_tokens.as_ref().map(|t| t.len()), Some(1));
assert_eq!(request.reference_video_media_token.as_ref().map(|t| t.as_str()), Some("mf_video"));
assert_eq!(request.is_panoramic, Some(true));
assert_eq!(request.disable_recaption, Some(true));
}
#[test]
fn idempotency_token_is_generated_when_missing() {
let request = build_artcraft_omni_splat_request(base_builder(), CommonSplatModelEnum::Marble1p0Draft)
.expect("build");
assert!(request.idempotency_token.is_some());
assert!(!request.idempotency_token.unwrap().is_empty());
}
#[test]
fn image_url_references_are_rejected() {
let builder = GenerateSplatRequestBuilder {
reference_images: Some(ImageListRef::Urls(vec!["https://example.com/a.png".to_string()])),
..base_builder()
};
let result = build_artcraft_omni_splat_request(builder, CommonSplatModelEnum::Marble1p0);
assert!(matches!(
result,
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
));
}
#[test]
fn video_url_references_are_rejected() {
let builder = GenerateSplatRequestBuilder {
reference_video: Some(VideoRef::Url("https://example.com/a.mp4".to_string())),
..base_builder()
};
let result = build_artcraft_omni_splat_request(builder, CommonSplatModelEnum::Marble1p0);
assert!(matches!(
result,
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
));
}
// ── Helpers ──
fn base_builder() -> GenerateSplatRequestBuilder {
GenerateSplatRequestBuilder {
model: RouterSplatModel::Marble1p0,
provider: RouterProvider::Artcraft,
prompt: Some("a cozy cabin".to_string()),
..Default::default()
}
}
}
@@ -0,0 +1,64 @@
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_splat_cost_and_generate_request::OmniGenSplatCostAndGenerateRequest;
use worldlabs_api_client::pricing::check_pricing::InputType;
use crate::generate::generate_splat::splat_generation_cost_estimate::SplatGenerationCostEstimate;
/// Flat prices for a splat model by input type, in USD cents.
#[derive(Copy, Clone, Debug)]
pub(crate) struct ArtcraftSplatPriceTable {
pub image_panorama: u64,
pub text: u64,
pub image_non_panorama: u64,
pub multi_image: u64,
pub video: u64,
}
impl ArtcraftSplatPriceTable {
pub(crate) fn cost_in_usd_cents(&self, input_type: InputType) -> u64 {
match input_type {
InputType::ImagePanorama => self.image_panorama,
InputType::Text => self.text,
InputType::ImageNonPanorama => self.image_non_panorama,
InputType::MultiImage => self.multi_image,
InputType::Video => self.video,
}
}
}
/// Derive the pricing input type from a forwarded omni splat request.
/// Mirrors the request-assembly derivation: video wins over images, two or
/// more images are multi-image, one image is panorama/non-panorama, and
/// anything else prices as text.
pub(crate) fn derive_input_type_for_pricing(
request: &OmniGenSplatCostAndGenerateRequest,
) -> InputType {
let image_count = request.reference_image_media_tokens.as_ref()
.map(|tokens| tokens.len())
.unwrap_or(0);
if request.reference_video_media_token.is_some() {
InputType::Video
} else if image_count >= 2 {
InputType::MultiImage
} else if image_count == 1 {
if request.is_panoramic == Some(true) {
InputType::ImagePanorama
} else {
InputType::ImageNonPanorama
}
} else {
InputType::Text
}
}
pub(crate) fn artcraft_splat_cost_estimate(cost_in_usd_cents: u64) -> SplatGenerationCostEstimate {
SplatGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
cost_in_usd_cents: Some(cost_in_usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
@@ -0,0 +1,14 @@
use enums::common::generation::common_splat_model::CommonSplatModel as CommonSplatModelEnum;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_splat::generate_splat_request_builder::GenerateSplatRequestBuilder;
use crate::generate::generate_splat::providers::artcraft::build_common::build_artcraft_omni_splat_request;
use crate::generate::generate_splat::providers::artcraft::marble_1p0::request::ArtcraftMarble1p0RequestState;
use crate::generate::generate_splat::splat_generation_draft_or_request::SplatGenerationDraftOrRequest;
use crate::generate::generate_splat::splat_generation_request::SplatGenerationRequest;
pub fn build_artcraft_marble_1p0(builder: GenerateSplatRequestBuilder) -> Result<SplatGenerationDraftOrRequest, ArtcraftRouterError> {
let request = build_artcraft_omni_splat_request(builder, CommonSplatModelEnum::Marble1p0)?;
let state = ArtcraftMarble1p0RequestState { request };
Ok(SplatGenerationDraftOrRequest::Request(SplatGenerationRequest::ArtcraftMarble1p0(state)))
}
@@ -0,0 +1,120 @@
use worldlabs_api_client::pricing::check_pricing::InputType;
use crate::generate::generate_splat::providers::artcraft::cost_common::{
artcraft_splat_cost_estimate, derive_input_type_for_pricing, ArtcraftSplatPriceTable,
};
use crate::generate::generate_splat::providers::artcraft::marble_1p0::request::ArtcraftMarble1p0RequestState;
use crate::generate::generate_splat::splat_generation_cost_estimate::SplatGenerationCostEstimate;
const PRICES: ArtcraftSplatPriceTable = ArtcraftSplatPriceTable {
image_panorama: 156,
text: 165,
image_non_panorama: 165,
multi_image: 167,
video: 167,
};
/// Marble 1.0 via Artcraft is flat priced by input type.
#[derive(Clone, Debug)]
pub struct ArtcraftMarble1p0CostState {
pub input_type: InputType,
}
impl ArtcraftMarble1p0CostState {
pub fn from_request(request: &ArtcraftMarble1p0RequestState) -> Self {
Self { input_type: derive_input_type_for_pricing(&request.request) }
}
pub fn estimate_cost(&self) -> SplatGenerationCostEstimate {
artcraft_splat_cost_estimate(PRICES.cost_in_usd_cents(self.input_type))
}
}
#[cfg(test)]
mod tests {
use tokens::tokens::media_files::MediaFileToken;
use crate::api::image_list_ref::ImageListRef;
use crate::api::router_provider::RouterProvider;
use crate::api::router_splat_model::RouterSplatModel;
use crate::api::video_ref::VideoRef;
use crate::generate::generate_splat::generate_splat_request_builder::GenerateSplatRequestBuilder;
#[test]
fn text_is_165_cents() {
assert_eq!(estimate_usd_cents(text_builder()), 165);
}
#[test]
fn single_image_is_165_cents() {
assert_eq!(estimate_usd_cents(single_image_builder(false)), 165);
}
#[test]
fn panoramic_image_is_156_cents() {
assert_eq!(estimate_usd_cents(single_image_builder(true)), 156);
}
#[test]
fn multi_image_is_167_cents() {
assert_eq!(estimate_usd_cents(multi_image_builder()), 167);
}
#[test]
fn video_is_167_cents() {
assert_eq!(estimate_usd_cents(video_builder()), 167);
}
// ── Helpers ──
fn base_builder() -> GenerateSplatRequestBuilder {
GenerateSplatRequestBuilder {
model: RouterSplatModel::Marble1p0,
provider: RouterProvider::Artcraft,
..Default::default()
}
}
fn text_builder() -> GenerateSplatRequestBuilder {
GenerateSplatRequestBuilder {
prompt: Some("a cozy cabin in the snowy mountains".to_string()),
..base_builder()
}
}
fn single_image_builder(is_panoramic: bool) -> GenerateSplatRequestBuilder {
GenerateSplatRequestBuilder {
reference_images: Some(ImageListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_image_1".to_string()),
])),
is_panoramic: Some(is_panoramic),
..base_builder()
}
}
fn multi_image_builder() -> GenerateSplatRequestBuilder {
GenerateSplatRequestBuilder {
reference_images: Some(ImageListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_image_1".to_string()),
MediaFileToken::new("mf_image_2".to_string()),
])),
..base_builder()
}
}
fn video_builder() -> GenerateSplatRequestBuilder {
GenerateSplatRequestBuilder {
reference_video: Some(VideoRef::MediaFileToken(MediaFileToken::new("mf_video".to_string()))),
..base_builder()
}
}
fn estimate_usd_cents(builder: GenerateSplatRequestBuilder) -> u64 {
builder.build2()
.expect("build should succeed")
.estimate_cost()
.expect("estimate should succeed")
.cost_in_usd_cents
.expect("cost should be present")
}
}
@@ -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_splat_cost_and_generate_request::OmniGenSplatCostAndGenerateRequest;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_splat::generate_splat_response::GenerateSplatResponse;
use crate::generate::generate_splat::providers::artcraft::request_common::send_artcraft_omni_splat_request;
#[derive(Clone, Debug)]
pub struct ArtcraftMarble1p0RequestState {
pub request: OmniGenSplatCostAndGenerateRequest,
}
impl ArtcraftMarble1p0RequestState {
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateSplatResponse, ArtcraftRouterError> {
send_artcraft_omni_splat_request(&self.request, client).await
}
}
@@ -0,0 +1,14 @@
use enums::common::generation::common_splat_model::CommonSplatModel as CommonSplatModelEnum;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_splat::generate_splat_request_builder::GenerateSplatRequestBuilder;
use crate::generate::generate_splat::providers::artcraft::build_common::build_artcraft_omni_splat_request;
use crate::generate::generate_splat::providers::artcraft::marble_1p0_draft::request::ArtcraftMarble1p0DraftRequestState;
use crate::generate::generate_splat::splat_generation_draft_or_request::SplatGenerationDraftOrRequest;
use crate::generate::generate_splat::splat_generation_request::SplatGenerationRequest;
pub fn build_artcraft_marble_1p0_draft(builder: GenerateSplatRequestBuilder) -> Result<SplatGenerationDraftOrRequest, ArtcraftRouterError> {
let request = build_artcraft_omni_splat_request(builder, CommonSplatModelEnum::Marble1p0Draft)?;
let state = ArtcraftMarble1p0DraftRequestState { request };
Ok(SplatGenerationDraftOrRequest::Request(SplatGenerationRequest::ArtcraftMarble1p0Draft(state)))
}
@@ -0,0 +1,120 @@
use worldlabs_api_client::pricing::check_pricing::InputType;
use crate::generate::generate_splat::providers::artcraft::cost_common::{
artcraft_splat_cost_estimate, derive_input_type_for_pricing, ArtcraftSplatPriceTable,
};
use crate::generate::generate_splat::providers::artcraft::marble_1p0_draft::request::ArtcraftMarble1p0DraftRequestState;
use crate::generate::generate_splat::splat_generation_cost_estimate::SplatGenerationCostEstimate;
const PRICES: ArtcraftSplatPriceTable = ArtcraftSplatPriceTable {
image_panorama: 16,
text: 24,
image_non_panorama: 24,
multi_image: 26,
video: 26,
};
/// Marble 1.0 Draft via Artcraft is flat priced by input type.
#[derive(Clone, Debug)]
pub struct ArtcraftMarble1p0DraftCostState {
pub input_type: InputType,
}
impl ArtcraftMarble1p0DraftCostState {
pub fn from_request(request: &ArtcraftMarble1p0DraftRequestState) -> Self {
Self { input_type: derive_input_type_for_pricing(&request.request) }
}
pub fn estimate_cost(&self) -> SplatGenerationCostEstimate {
artcraft_splat_cost_estimate(PRICES.cost_in_usd_cents(self.input_type))
}
}
#[cfg(test)]
mod tests {
use tokens::tokens::media_files::MediaFileToken;
use crate::api::image_list_ref::ImageListRef;
use crate::api::router_provider::RouterProvider;
use crate::api::router_splat_model::RouterSplatModel;
use crate::api::video_ref::VideoRef;
use crate::generate::generate_splat::generate_splat_request_builder::GenerateSplatRequestBuilder;
#[test]
fn text_is_24_cents() {
assert_eq!(estimate_usd_cents(text_builder()), 24);
}
#[test]
fn single_image_is_24_cents() {
assert_eq!(estimate_usd_cents(single_image_builder(false)), 24);
}
#[test]
fn panoramic_image_is_16_cents() {
assert_eq!(estimate_usd_cents(single_image_builder(true)), 16);
}
#[test]
fn multi_image_is_26_cents() {
assert_eq!(estimate_usd_cents(multi_image_builder()), 26);
}
#[test]
fn video_is_26_cents() {
assert_eq!(estimate_usd_cents(video_builder()), 26);
}
// ── Helpers ──
fn base_builder() -> GenerateSplatRequestBuilder {
GenerateSplatRequestBuilder {
model: RouterSplatModel::Marble1p0Draft,
provider: RouterProvider::Artcraft,
..Default::default()
}
}
fn text_builder() -> GenerateSplatRequestBuilder {
GenerateSplatRequestBuilder {
prompt: Some("a cozy cabin in the snowy mountains".to_string()),
..base_builder()
}
}
fn single_image_builder(is_panoramic: bool) -> GenerateSplatRequestBuilder {
GenerateSplatRequestBuilder {
reference_images: Some(ImageListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_image_1".to_string()),
])),
is_panoramic: Some(is_panoramic),
..base_builder()
}
}
fn multi_image_builder() -> GenerateSplatRequestBuilder {
GenerateSplatRequestBuilder {
reference_images: Some(ImageListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_image_1".to_string()),
MediaFileToken::new("mf_image_2".to_string()),
])),
..base_builder()
}
}
fn video_builder() -> GenerateSplatRequestBuilder {
GenerateSplatRequestBuilder {
reference_video: Some(VideoRef::MediaFileToken(MediaFileToken::new("mf_video".to_string()))),
..base_builder()
}
}
fn estimate_usd_cents(builder: GenerateSplatRequestBuilder) -> u64 {
builder.build2()
.expect("build should succeed")
.estimate_cost()
.expect("estimate should succeed")
.cost_in_usd_cents
.expect("cost should be present")
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -0,0 +1,58 @@
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_splat_cost_and_generate_request::OmniGenSplatCostAndGenerateRequest;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_splat::generate_splat_response::GenerateSplatResponse;
use crate::generate::generate_splat::providers::artcraft::request_common::send_artcraft_omni_splat_request;
#[derive(Clone, Debug)]
pub struct ArtcraftMarble1p0DraftRequestState {
pub request: OmniGenSplatCostAndGenerateRequest,
}
impl ArtcraftMarble1p0DraftRequestState {
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateSplatResponse, ArtcraftRouterError> {
send_artcraft_omni_splat_request(&self.request, client).await
}
}
#[cfg(test)]
mod tests {
use crate::api::router_provider::RouterProvider;
use crate::api::router_splat_model::RouterSplatModel;
use crate::generate::generate_splat::generate_splat_request_builder::GenerateSplatRequestBuilder;
use crate::generate::generate_splat::generate_splat_response::GenerateSplatResponse;
use crate::generate::generate_splat::splat_generation_draft_or_request::SplatGenerationDraftOrRequest;
use crate::test_helpers::get_artcraft_client;
#[tokio::test]
#[ignore] // sends a real generation to the Artcraft backend, incurs cost
async fn text_to_world() {
let builder = GenerateSplatRequestBuilder {
model: RouterSplatModel::Marble1p0Draft,
provider: RouterProvider::Artcraft,
prompt: Some("A cozy cabin in the snowy mountains".to_string()),
..Default::default()
};
let client = get_artcraft_client();
let draft_or_request = builder.build2().expect("build2 should succeed");
let request = match draft_or_request {
SplatGenerationDraftOrRequest::Request(r) => r,
_ => panic!("expected Request variant (Artcraft skips draft)"),
};
let response = request.send_request(&client).await.expect("send_request should succeed");
match &response {
GenerateSplatResponse::Artcraft(p) => {
println!("inference_job_token={:?}", p.inference_job_token);
println!("all_inference_job_tokens={:?}", p.all_inference_job_tokens);
}
other => println!("response: {:?}", other),
}
assert!(matches!(response, GenerateSplatResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
}
@@ -0,0 +1,14 @@
use enums::common::generation::common_splat_model::CommonSplatModel as CommonSplatModelEnum;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_splat::generate_splat_request_builder::GenerateSplatRequestBuilder;
use crate::generate::generate_splat::providers::artcraft::build_common::build_artcraft_omni_splat_request;
use crate::generate::generate_splat::providers::artcraft::marble_1p1::request::ArtcraftMarble1p1RequestState;
use crate::generate::generate_splat::splat_generation_draft_or_request::SplatGenerationDraftOrRequest;
use crate::generate::generate_splat::splat_generation_request::SplatGenerationRequest;
pub fn build_artcraft_marble_1p1(builder: GenerateSplatRequestBuilder) -> Result<SplatGenerationDraftOrRequest, ArtcraftRouterError> {
let request = build_artcraft_omni_splat_request(builder, CommonSplatModelEnum::Marble1p1)?;
let state = ArtcraftMarble1p1RequestState { request };
Ok(SplatGenerationDraftOrRequest::Request(SplatGenerationRequest::ArtcraftMarble1p1(state)))
}
@@ -0,0 +1,120 @@
use worldlabs_api_client::pricing::check_pricing::InputType;
use crate::generate::generate_splat::providers::artcraft::cost_common::{
artcraft_splat_cost_estimate, derive_input_type_for_pricing, ArtcraftSplatPriceTable,
};
use crate::generate::generate_splat::providers::artcraft::marble_1p1::request::ArtcraftMarble1p1RequestState;
use crate::generate::generate_splat::splat_generation_cost_estimate::SplatGenerationCostEstimate;
const PRICES: ArtcraftSplatPriceTable = ArtcraftSplatPriceTable {
image_panorama: 156,
text: 165,
image_non_panorama: 165,
multi_image: 167,
video: 167,
};
/// Marble 1.1 via Artcraft is flat priced by input type.
#[derive(Clone, Debug)]
pub struct ArtcraftMarble1p1CostState {
pub input_type: InputType,
}
impl ArtcraftMarble1p1CostState {
pub fn from_request(request: &ArtcraftMarble1p1RequestState) -> Self {
Self { input_type: derive_input_type_for_pricing(&request.request) }
}
pub fn estimate_cost(&self) -> SplatGenerationCostEstimate {
artcraft_splat_cost_estimate(PRICES.cost_in_usd_cents(self.input_type))
}
}
#[cfg(test)]
mod tests {
use tokens::tokens::media_files::MediaFileToken;
use crate::api::image_list_ref::ImageListRef;
use crate::api::router_provider::RouterProvider;
use crate::api::router_splat_model::RouterSplatModel;
use crate::api::video_ref::VideoRef;
use crate::generate::generate_splat::generate_splat_request_builder::GenerateSplatRequestBuilder;
#[test]
fn text_is_165_cents() {
assert_eq!(estimate_usd_cents(text_builder()), 165);
}
#[test]
fn single_image_is_165_cents() {
assert_eq!(estimate_usd_cents(single_image_builder(false)), 165);
}
#[test]
fn panoramic_image_is_156_cents() {
assert_eq!(estimate_usd_cents(single_image_builder(true)), 156);
}
#[test]
fn multi_image_is_167_cents() {
assert_eq!(estimate_usd_cents(multi_image_builder()), 167);
}
#[test]
fn video_is_167_cents() {
assert_eq!(estimate_usd_cents(video_builder()), 167);
}
// ── Helpers ──
fn base_builder() -> GenerateSplatRequestBuilder {
GenerateSplatRequestBuilder {
model: RouterSplatModel::Marble1p1,
provider: RouterProvider::Artcraft,
..Default::default()
}
}
fn text_builder() -> GenerateSplatRequestBuilder {
GenerateSplatRequestBuilder {
prompt: Some("a cozy cabin in the snowy mountains".to_string()),
..base_builder()
}
}
fn single_image_builder(is_panoramic: bool) -> GenerateSplatRequestBuilder {
GenerateSplatRequestBuilder {
reference_images: Some(ImageListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_image_1".to_string()),
])),
is_panoramic: Some(is_panoramic),
..base_builder()
}
}
fn multi_image_builder() -> GenerateSplatRequestBuilder {
GenerateSplatRequestBuilder {
reference_images: Some(ImageListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_image_1".to_string()),
MediaFileToken::new("mf_image_2".to_string()),
])),
..base_builder()
}
}
fn video_builder() -> GenerateSplatRequestBuilder {
GenerateSplatRequestBuilder {
reference_video: Some(VideoRef::MediaFileToken(MediaFileToken::new("mf_video".to_string()))),
..base_builder()
}
}
fn estimate_usd_cents(builder: GenerateSplatRequestBuilder) -> u64 {
builder.build2()
.expect("build should succeed")
.estimate_cost()
.expect("estimate should succeed")
.cost_in_usd_cents
.expect("cost should be present")
}
}
@@ -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_splat_cost_and_generate_request::OmniGenSplatCostAndGenerateRequest;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_splat::generate_splat_response::GenerateSplatResponse;
use crate::generate::generate_splat::providers::artcraft::request_common::send_artcraft_omni_splat_request;
#[derive(Clone, Debug)]
pub struct ArtcraftMarble1p1RequestState {
pub request: OmniGenSplatCostAndGenerateRequest,
}
impl ArtcraftMarble1p1RequestState {
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateSplatResponse, ArtcraftRouterError> {
send_artcraft_omni_splat_request(&self.request, client).await
}
}
@@ -0,0 +1,14 @@
use enums::common::generation::common_splat_model::CommonSplatModel as CommonSplatModelEnum;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_splat::generate_splat_request_builder::GenerateSplatRequestBuilder;
use crate::generate::generate_splat::providers::artcraft::build_common::build_artcraft_omni_splat_request;
use crate::generate::generate_splat::providers::artcraft::marble_1p1_plus::request::ArtcraftMarble1p1PlusRequestState;
use crate::generate::generate_splat::splat_generation_draft_or_request::SplatGenerationDraftOrRequest;
use crate::generate::generate_splat::splat_generation_request::SplatGenerationRequest;
pub fn build_artcraft_marble_1p1_plus(builder: GenerateSplatRequestBuilder) -> Result<SplatGenerationDraftOrRequest, ArtcraftRouterError> {
let request = build_artcraft_omni_splat_request(builder, CommonSplatModelEnum::Marble1p1Plus)?;
let state = ArtcraftMarble1p1PlusRequestState { request };
Ok(SplatGenerationDraftOrRequest::Request(SplatGenerationRequest::ArtcraftMarble1p1Plus(state)))
}
Loaded 100 of 212 files, more files were not shown because too many files have changed in this diff. Show more