FAL polling APIs + additional API bindings (#1501)

This commit is contained in:
Brandon Thomas
2026-05-11 21:12:10 -04:00
committed by GitHub
parent 25b2be92c8
commit 0efafe8043
43 changed files with 2459 additions and 2 deletions
@@ -0,0 +1,34 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
use reqwest::StatusCode;
#[derive(Debug)]
pub enum FalGenericApiError {
/// serde_json::Error, likely from JSON deserialization schema mismatch.
/// Includes the original body.
SerdeResponseParseErrorWithBody {
error: serde_json::Error,
body: String,
},
/// An uncategorized bad HTTP response.
UncategorizedBadResponseWithStatusAndBody {
status_code: StatusCode,
body: String,
},
}
impl Error for FalGenericApiError {}
impl Display for FalGenericApiError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::SerdeResponseParseErrorWithBody { error, body } => {
write!(f, "Failed to parse response body: {:?}. Body: {}", error, body)
}
Self::UncategorizedBadResponseWithStatusAndBody { status_code, body } => {
write!(f, "Uncategorized bad response: status code {}, body: {}", status_code, body)
}
}
}
}
@@ -0,0 +1,19 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
#[derive(Debug)]
pub enum FalSpecificApiError {
/// The job is still in progress. The response URL is not ready yet.
/// Contains the detail message from the FAL API (e.g. "Request is still in progress").
IncompleteJob(String),
}
impl Error for FalSpecificApiError {}
impl Display for FalSpecificApiError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::IncompleteJob(detail) => write!(f, "Job is incomplete: {}", detail),
}
}
}
@@ -0,0 +1,18 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
#[derive(Debug)]
pub enum FalClientError {
/// The URL provided was invalid or pointed to an unexpected host.
InvalidUrl(String),
}
impl Error for FalClientError {}
impl Display for FalClientError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidUrl(msg) => write!(f, "Invalid URL: {}", msg),
}
}
}
@@ -1,4 +1,7 @@
use crate::error::api_generic_error::FalGenericApiError;
use crate::error::api_specific_error::FalSpecificApiError;
use crate::error::classify_fal_error::classify_fal_error;
use crate::error::client_error::FalClientError;
use crate::error::fal_error::FalError;
use std::error::Error;
use std::fmt::{Display, Formatter};
@@ -6,12 +9,27 @@ use std::fmt::{Display, Formatter};
/// Additional errors that aren't included in `crate::error::fal_error::FalError`.
#[derive(Debug)]
pub enum FalErrorPlus {
// =============== Newer error types ===============
/// A client-side error (invalid input, bad URL, etc.).
ClientError(FalClientError),
/// A generic API error (bad response, parse failure, etc.).
ApiGeneric(FalGenericApiError),
/// A specific, categorized API error (incomplete job, etc.).
ApiSpecific(FalSpecificApiError),
// =============== Older error types (gradually replace these) ===============
/// An error arising in the `fal` crate.
FalError(crate::error::fal_error::FalError),
/// The fal API key is invalid.
FalApiKeyError(String),
/// The fal account has a billing issue
FalBillingError(String),
/// Error with an invalid polling URL.
InvalidPollingUrl(String),
/// Another error we didn't handle.
AnyhowError(anyhow::Error),
/// URL parse errors.
@@ -25,9 +43,13 @@ pub enum FalErrorPlus {
impl Display for FalErrorPlus {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::ClientError(err) => write!(f, "FalErrorPlus::ClientError: {}", err),
Self::ApiGeneric(err) => write!(f, "FalErrorPlus::ApiGeneric: {}", err),
Self::ApiSpecific(err) => write!(f, "FalErrorPlus::ApiSpecific: {}", err),
Self::FalError(err) => write!(f, "FalErrorPlus::FalError: {:?}", err),
Self::FalApiKeyError(reason) => write!(f, "FalErrorPlus::FalApiKeyError: {}", reason),
Self::FalBillingError(reason) => write!(f, "FalErrorPlus::FalBillingError: {}", reason),
Self::InvalidPollingUrl(url) => write!(f, "FalErrorPlus::InvalidPollingUrl: {}", url),
Self::AnyhowError(err) => write!(f, "FalErrorPlus::AnyhowError: {:?}", err),
Self::UrlParseError(err) => write!(f, "FalErrorPlus::UrlParseError: {:?}", err),
Self::UnhandledEndpoint(endpoint) => write!(f, "FalErrorPlus::UnhandledEndpoint: {:?}", endpoint),
@@ -1,3 +1,6 @@
pub mod api_generic_error;
pub mod api_specific_error;
pub mod classify_fal_error;
pub mod client_error;
pub mod fal_error;
pub mod fal_error_plus;
+1
View File
@@ -2,6 +2,7 @@
pub mod creds;
pub mod error;
pub mod polling;
pub mod requests;
pub mod utils;
pub mod webhook_payload;
@@ -0,0 +1,2 @@
pub mod poll_job_status;
pub mod poll_job_response;
@@ -0,0 +1,3 @@
pub mod poll_job_response;
pub mod raw_response;
pub mod success_case_extractors;
@@ -0,0 +1,428 @@
use crate::creds::fal_api_key::FalApiKey;
use crate::error::api_generic_error::FalGenericApiError;
use crate::error::api_specific_error::FalSpecificApiError;
use crate::error::client_error::FalClientError;
use crate::error::fal_error_plus::FalErrorPlus;
use crate::polling::poll_job_response::raw_response::RawIncompleteJobResponse;
use crate::polling::poll_job_response::success_case_extractors::{
extract_contents_from_response, PollResponseExtractedContents,
};
use log::info;
use serde_json::Value;
use url::Url;
const EXPECTED_HOST: &str = "queue.fal.run";
pub struct PollJobResponseArgs<'a> {
/// This is the "response" URL (not the "status" URL).
/// This fetches the actual results of a completed job.
/// If the job is not complete, this call will fail in error.
pub response_url: &'a str,
pub api_key: &'a FalApiKey,
}
/// Parsed response from fetching a completed FAL job's results.
#[derive(Debug)]
pub struct PollJobResponse {
/// The raw JSON payload as a serde_json::Value, for full access.
pub payload: Value,
/// Extracted known content fields (images, video, model_glb, thumbnail).
/// `None` if no recognized content keys were found in the payload.
pub extracted_contents: Option<PollResponseExtractedContents>,
/// The raw JSON response body, preserved for debugging.
pub raw_body: String,
}
/// Fetch the results of a completed FAL job.
///
/// The `response_url` must point to `queue.fal.run`.
///
/// Returns `FalErrorPlus::ApiSpecific(IncompleteJob)` if the job is still
/// in progress (FAL returns HTTP 400 with `"detail": "Request is still in progress"`).
pub async fn poll_job_response(args: PollJobResponseArgs<'_>) -> Result<PollJobResponse, FalErrorPlus> {
let parsed = Url::parse(args.response_url)?;
let host = parsed.host_str().unwrap_or("");
if host != EXPECTED_HOST {
return Err(FalErrorPlus::ClientError(FalClientError::InvalidUrl(format!(
"Expected host '{}' but got '{}' in response URL: {}",
EXPECTED_HOST,
host,
args.response_url,
))));
}
info!("Polling FAL job response: {}", args.response_url);
let response = reqwest::Client::new()
.get(args.response_url)
.header("Authorization", format!("Key {}", args.api_key.0))
.send()
.await?;
let http_status = response.status();
let body = response.text().await?;
if !http_status.is_success() {
return Err(classify_error_response(http_status, &body));
}
let payload: Value = serde_json::from_str(&body)
.map_err(|err| FalErrorPlus::ApiGeneric(
FalGenericApiError::SerdeResponseParseErrorWithBody {
error: err,
body: body.clone(),
},
))?;
let extracted_contents = extract_contents_from_response(&payload);
Ok(PollJobResponse {
payload,
extracted_contents,
raw_body: body,
})
}
// ── Helpers ──
/// Classify a non-2xx response. If it's a 400 with "Request is still in progress",
/// return the specific `IncompleteJob` error. Otherwise fall through to a generic error.
fn classify_error_response(
status_code: reqwest::StatusCode,
body: &str,
) -> FalErrorPlus {
if status_code == reqwest::StatusCode::BAD_REQUEST {
if let Ok(raw) = serde_json::from_str::<RawIncompleteJobResponse>(body) {
if let Some(detail) = raw.detail {
if detail.contains("still in progress") {
return FalErrorPlus::ApiSpecific(FalSpecificApiError::IncompleteJob(detail));
}
}
}
}
FalErrorPlus::ApiGeneric(
FalGenericApiError::UncategorizedBadResponseWithStatusAndBody {
status_code,
body: body.to_string(),
},
)
}
#[cfg(test)]
mod tests {
use super::*;
// ── Unit tests (no network) ──
#[tokio::test]
async fn rejects_wrong_host() {
let api_key = FalApiKey::from_str("test-key");
let args = PollJobResponseArgs {
response_url: "https://evil.example.com/fal-ai/flux/requests/abc123",
api_key: &api_key,
};
let result = poll_job_response(args).await;
assert!(result.is_err());
let err = format!("{}", result.unwrap_err());
assert!(err.contains("evil.example.com"), "error should mention the bad host: {}", err);
}
#[tokio::test]
async fn rejects_invalid_url() {
let api_key = FalApiKey::from_str("test-key");
let args = PollJobResponseArgs {
response_url: "not a url at all",
api_key: &api_key,
};
let result = poll_job_response(args).await;
assert!(result.is_err());
}
mod classify_error {
use super::*;
#[test]
fn incomplete_job_400() {
let body = r#"{"cancel_url":"https://queue.fal.run/fal-ai/hunyuan3d-v3/requests/abc/cancel","detail":"Request is still in progress","request_id":"abc","response_url":"https://queue.fal.run/fal-ai/hunyuan3d-v3/requests/abc","status_url":"https://queue.fal.run/fal-ai/hunyuan3d-v3/requests/abc/status"}"#;
let err = classify_error_response(reqwest::StatusCode::BAD_REQUEST, body);
assert!(matches!(err, FalErrorPlus::ApiSpecific(FalSpecificApiError::IncompleteJob(_))));
}
#[test]
fn other_400_is_generic() {
let body = r#"{"detail":"Some other error"}"#;
let err = classify_error_response(reqwest::StatusCode::BAD_REQUEST, body);
assert!(matches!(err, FalErrorPlus::ApiGeneric(FalGenericApiError::UncategorizedBadResponseWithStatusAndBody { .. })));
}
#[test]
fn non_400_is_generic() {
let body = "Internal Server Error";
let err = classify_error_response(reqwest::StatusCode::INTERNAL_SERVER_ERROR, body);
assert!(matches!(err, FalErrorPlus::ApiGeneric(FalGenericApiError::UncategorizedBadResponseWithStatusAndBody { .. })));
}
}
mod extract_payloads {
use super::*;
#[test]
fn extract_image_payload() {
let json = r#"{"images":[{"url":"https://v3b.fal.media/files/b/img.jpg","width":1024,"height":768,"content_type":"image/jpeg"}],"timings":{"inference":1.19},"seed":1248735483,"has_nsfw_concepts":[false],"prompt":"a giant robot"}"#;
let value: Value = serde_json::from_str(json).unwrap();
let extracted = extract_contents_from_response(&value).unwrap();
let images = extracted.images.unwrap();
assert_eq!(images.len(), 1);
assert_eq!(images[0].url.as_deref(), Some("https://v3b.fal.media/files/b/img.jpg"));
assert_eq!(images[0].width, Some(1024));
assert_eq!(images[0].height, Some(768));
}
#[test]
fn extract_glb_mesh_payload() {
let json = r#"{"model_glb":{"url":"https://v3b.fal.media/files/b/model.glb","content_type":"model/gltf-binary","file_name":"model.glb","file_size":33352724},"thumbnail":{"url":"https://v3b.fal.media/files/b/preview.png","content_type":"image/png","file_name":"preview.png","file_size":99797},"model_urls":{"glb":{"url":"https://v3b.fal.media/files/b/model.glb"},"fbx":null,"obj":{"url":"https://v3b.fal.media/files/b/model.obj"},"usdz":null},"seed":null}"#;
let value: Value = serde_json::from_str(json).unwrap();
let extracted = extract_contents_from_response(&value).unwrap();
let glb = extracted.model_glb.unwrap();
assert_eq!(glb.url.as_deref(), Some("https://v3b.fal.media/files/b/model.glb"));
assert_eq!(glb.file_size, Some(33352724));
let thumb = extracted.thumbnail.unwrap();
assert_eq!(thumb.url.as_deref(), Some("https://v3b.fal.media/files/b/preview.png"));
assert!(extracted.images.is_none());
assert!(extracted.video.is_none());
}
#[test]
fn no_known_keys_returns_none() {
let json = r#"{"some_unknown_field": "value"}"#;
let value: Value = serde_json::from_str(json).unwrap();
assert!(extract_contents_from_response(&value).is_none());
}
#[test]
fn parse_real_image_response_offline() {
let json = r#"{"images":[{"url":"https://v3b.fal.media/files/b/0a99d392/qzx8aXgWTbf5tVcYEF0S8.jpg","width":1024,"height":768,"content_type":"image/jpeg"}],"timings":{"inference":1.1978995269964798},"seed":1248735483,"has_nsfw_concepts":[false],"prompt":"a giant robot fighting a dragon in a futuristic city"}"#;
let value: Value = serde_json::from_str(json).unwrap();
let extracted = extract_contents_from_response(&value).expect("should extract contents");
// Should have images, not video or mesh
assert!(extracted.video.is_none());
assert!(extracted.model_glb.is_none());
assert!(extracted.thumbnail.is_none());
let images = extracted.images.expect("should have images");
assert_eq!(images.len(), 1);
let image = &images[0];
assert_eq!(image.url.as_deref(), Some("https://v3b.fal.media/files/b/0a99d392/qzx8aXgWTbf5tVcYEF0S8.jpg"));
assert_eq!(image.width, Some(1024));
assert_eq!(image.height, Some(768));
assert_eq!(image.content_type.as_deref(), Some("image/jpeg"));
// Verify raw payload also has seed, prompt, timings
assert_eq!(value.get("seed").and_then(|v| v.as_u64()), Some(1248735483));
assert_eq!(value.get("prompt").and_then(|v| v.as_str()), Some("a giant robot fighting a dragon in a futuristic city"));
let inference_time = value.get("timings").and_then(|v| v.get("inference")).and_then(|v| v.as_f64()).unwrap();
assert!((inference_time - 1.198).abs() < 0.001);
}
#[test]
fn parse_real_mesh_response_offline() {
let json = r#"{"model_glb":{"url":"https://v3b.fal.media/files/b/0a99d693/98becABi5mxR6w5Svy4oB_model.glb","content_type":"model/gltf-binary","file_name":"model.glb","file_size":33352724},"thumbnail":{"url":"https://v3b.fal.media/files/b/0a99d693/_wB78l-a_WmrlAzaKyTWy_preview.png","content_type":"image/png","file_name":"preview.png","file_size":99797},"model_urls":{"glb":{"url":"https://v3b.fal.media/files/b/0a99d693/98becABi5mxR6w5Svy4oB_model.glb","content_type":"model/gltf-binary","file_name":"model.glb","file_size":33352724},"fbx":null,"obj":{"url":"https://v3b.fal.media/files/b/0a99d693/_sxg6RiOiz_N9Eut1uFQo_model.obj","content_type":"text/plain","file_name":"model.obj","file_size":26194790},"usdz":null},"seed":null}"#;
let value: Value = serde_json::from_str(json).unwrap();
let extracted = extract_contents_from_response(&value).expect("should extract contents");
// Should have model_glb + thumbnail, not images or video
assert!(extracted.images.is_none());
assert!(extracted.video.is_none());
let glb = extracted.model_glb.expect("should have model_glb");
assert_eq!(glb.url.as_deref(), Some("https://v3b.fal.media/files/b/0a99d693/98becABi5mxR6w5Svy4oB_model.glb"));
assert_eq!(glb.content_type.as_deref(), Some("model/gltf-binary"));
assert_eq!(glb.file_name.as_deref(), Some("model.glb"));
assert_eq!(glb.file_size, Some(33352724));
let thumbnail = extracted.thumbnail.expect("should have thumbnail");
assert_eq!(thumbnail.url.as_deref(), Some("https://v3b.fal.media/files/b/0a99d693/_wB78l-a_WmrlAzaKyTWy_preview.png"));
assert_eq!(thumbnail.content_type.as_deref(), Some("image/png"));
assert_eq!(thumbnail.file_name.as_deref(), Some("preview.png"));
assert_eq!(thumbnail.file_size, Some(99797));
// Verify raw payload also has model_urls with OBJ
let obj = value.get("model_urls").and_then(|v| v.get("obj")).unwrap();
assert_eq!(obj.get("url").and_then(|v| v.as_str()), Some("https://v3b.fal.media/files/b/0a99d693/_sxg6RiOiz_N9Eut1uFQo_model.obj"));
assert_eq!(obj.get("content_type").and_then(|v| v.as_str()), Some("text/plain"));
assert_eq!(obj.get("file_name").and_then(|v| v.as_str()), Some("model.obj"));
assert_eq!(obj.get("file_size").and_then(|v| v.as_u64()), Some(26194790));
// FBX and USDZ are null
assert!(value.get("model_urls").and_then(|v| v.get("fbx")).and_then(|v| v.as_object()).is_none());
assert!(value.get("model_urls").and_then(|v| v.get("usdz")).and_then(|v| v.as_object()).is_none());
}
#[test]
fn parse_real_kling3_video_response_offline() {
let json = r#"{"video":{"url":"https://v3b.fal.media/files/b/0a99d90a/v1tQEfvnY2bVcn38IvmfD_output.mp4","content_type":"video/mp4","file_name":"output.mp4","file_size":3096422}}"#;
let value: Value = serde_json::from_str(json).unwrap();
let extracted = extract_contents_from_response(&value).expect("should extract contents");
// Should have video, not images or mesh
assert!(extracted.image.is_none());
assert!(extracted.images.is_none());
assert!(extracted.model_glb.is_none());
assert!(extracted.thumbnail.is_none());
let video = extracted.video.expect("should have video");
assert_eq!(video.url.as_deref(), Some("https://v3b.fal.media/files/b/0a99d90a/v1tQEfvnY2bVcn38IvmfD_output.mp4"));
assert_eq!(video.content_type.as_deref(), Some("video/mp4"));
assert_eq!(video.file_name.as_deref(), Some("output.mp4"));
assert_eq!(video.file_size, Some(3096422));
}
#[test]
fn parse_real_nano_banana_pro_edit_response_offline() {
let json = r#"{"images":[{"url":"https://v3b.fal.media/files/b/0a99d90e/IXN1B_4MHnozvltxEHfvX_BwTMO9xM.png","content_type":"image/png","file_name":"IXN1B_4MHnozvltxEHfvX_BwTMO9xM.png","file_size":null,"width":null,"height":null},{"url":"https://v3b.fal.media/files/b/0a99d90e/aj_Xu0cIUoVSZ6qSSxaZf_wy3l60NO.png","content_type":"image/png","file_name":"aj_Xu0cIUoVSZ6qSSxaZf_wy3l60NO.png","file_size":null,"width":null,"height":null}],"description":"**Developing Spooky Atmosphere**\n\ntest description"}"#;
let value: Value = serde_json::from_str(json).unwrap();
let extracted = extract_contents_from_response(&value).expect("should extract contents");
// Should have images, not video or mesh
assert!(extracted.image.is_none());
assert!(extracted.video.is_none());
assert!(extracted.model_glb.is_none());
assert!(extracted.thumbnail.is_none());
let images = extracted.images.expect("should have images");
assert_eq!(images.len(), 2);
assert_eq!(images[0].url.as_deref(), Some("https://v3b.fal.media/files/b/0a99d90e/IXN1B_4MHnozvltxEHfvX_BwTMO9xM.png"));
assert_eq!(images[0].content_type.as_deref(), Some("image/png"));
assert!(images[0].width.is_none(), "nano banana pro edit returns null width");
assert!(images[0].height.is_none(), "nano banana pro edit returns null height");
assert!(images[0].file_size.is_none(), "nano banana pro edit returns null file_size");
assert_eq!(images[1].url.as_deref(), Some("https://v3b.fal.media/files/b/0a99d90e/aj_Xu0cIUoVSZ6qSSxaZf_wy3l60NO.png"));
assert_eq!(images[1].content_type.as_deref(), Some("image/png"));
}
#[test]
fn parse_real_flux_angle_lora_response_offline() {
let json = r#"{"images":[{"url":"https://v3b.fal.media/files/b/0a99d915/_61CGILQXk8A7A6FrAbdu_uytUbw2B.png","content_type":"image/png","file_name":"_61CGILQXk8A7A6FrAbdu_uytUbw2B.png","file_size":null,"width":1024,"height":1024}],"seed":225652535,"prompt":"<sks> front-right quarter view elevated shot medium shot"}"#;
let value: Value = serde_json::from_str(json).unwrap();
let extracted = extract_contents_from_response(&value).expect("should extract contents");
// Should have images, not video or mesh
assert!(extracted.image.is_none());
assert!(extracted.video.is_none());
assert!(extracted.model_glb.is_none());
assert!(extracted.thumbnail.is_none());
let images = extracted.images.expect("should have images");
assert_eq!(images.len(), 1);
let image = &images[0];
assert_eq!(image.url.as_deref(), Some("https://v3b.fal.media/files/b/0a99d915/_61CGILQXk8A7A6FrAbdu_uytUbw2B.png"));
assert_eq!(image.content_type.as_deref(), Some("image/png"));
assert_eq!(image.width, Some(1024));
assert_eq!(image.height, Some(1024));
assert!(image.file_size.is_none());
// Verify raw payload fields
assert_eq!(value.get("seed").and_then(|v| v.as_u64()), Some(225652535));
assert_eq!(value.get("prompt").and_then(|v| v.as_str()), Some("<sks> front-right quarter view elevated shot medium shot"));
}
#[test]
fn parse_real_remove_bg_response_offline() {
let json = r#"{"image":{"url":"https://v3b.fal.media/files/b/0a99d916/J7OyBamMKRI_RUbkUxnJE_f5b8faa705534b19ad87bc3d48b5c6c5.png","content_type":"image/png","file_name":"f5b8faa705534b19ad87bc3d48b5c6c5.png","file_size":496291,"width":1000,"height":664}}"#;
let value: Value = serde_json::from_str(json).unwrap();
let extracted = extract_contents_from_response(&value).expect("should extract contents");
// Should have single image, not images (plural), video, or mesh
assert!(extracted.images.is_none());
assert!(extracted.video.is_none());
assert!(extracted.model_glb.is_none());
assert!(extracted.thumbnail.is_none());
let image = extracted.image.expect("should have single image");
assert_eq!(image.url.as_deref(), Some("https://v3b.fal.media/files/b/0a99d916/J7OyBamMKRI_RUbkUxnJE_f5b8faa705534b19ad87bc3d48b5c6c5.png"));
assert_eq!(image.content_type.as_deref(), Some("image/png"));
assert_eq!(image.file_name.as_deref(), Some("f5b8faa705534b19ad87bc3d48b5c6c5.png"));
assert_eq!(image.file_size, Some(496291));
assert_eq!(image.width, Some(1000));
assert_eq!(image.height, Some(664));
}
}
// ── Live tests ──
#[tokio::test]
#[ignore] // requires real API key
async fn poll_completed_image_job() {
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 args = PollJobResponseArgs {
response_url: "https://queue.fal.run/fal-ai/flux/requests/019e18d8-8c36-7bc1-aa77-2bc2f70268c6",
api_key: &api_key,
};
let result = poll_job_response(args).await.expect("poll should succeed");
let extracted = result.extracted_contents.expect("should have extracted contents");
// Should have images, not video or mesh
assert!(extracted.video.is_none());
assert!(extracted.model_glb.is_none());
assert!(extracted.thumbnail.is_none());
let images = extracted.images.expect("should have images");
assert_eq!(images.len(), 1);
let image = &images[0];
assert_eq!(image.url.as_deref(), Some("https://v3b.fal.media/files/b/0a99d392/qzx8aXgWTbf5tVcYEF0S8.jpg"));
assert_eq!(image.width, Some(1024));
assert_eq!(image.height, Some(768));
assert_eq!(image.content_type.as_deref(), Some("image/jpeg"));
}
#[tokio::test]
#[ignore] // requires real API key
async fn poll_completed_mesh_job() {
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 args = PollJobResponseArgs {
response_url: "https://queue.fal.run/fal-ai/hunyuan3d-v3/requests/019e194b-f69a-77b1-bada-3f56d7d3c87d",
api_key: &api_key,
};
let result = poll_job_response(args).await.expect("poll should succeed");
let extracted = result.extracted_contents.expect("should have extracted contents");
// Should have model_glb + thumbnail, not images or video
assert!(extracted.images.is_none());
assert!(extracted.video.is_none());
let glb = extracted.model_glb.expect("should have model_glb");
assert_eq!(glb.url.as_deref(), Some("https://v3b.fal.media/files/b/0a99d693/98becABi5mxR6w5Svy4oB_model.glb"));
assert_eq!(glb.content_type.as_deref(), Some("model/gltf-binary"));
assert_eq!(glb.file_name.as_deref(), Some("model.glb"));
assert_eq!(glb.file_size, Some(33352724));
let thumbnail = extracted.thumbnail.expect("should have thumbnail");
assert_eq!(thumbnail.url.as_deref(), Some("https://v3b.fal.media/files/b/0a99d693/_wB78l-a_WmrlAzaKyTWy_preview.png"));
assert_eq!(thumbnail.content_type.as_deref(), Some("image/png"));
assert_eq!(thumbnail.file_name.as_deref(), Some("preview.png"));
assert_eq!(thumbnail.file_size, Some(99797));
}
}
@@ -0,0 +1,12 @@
use serde::Deserialize;
/// Raw JSON response returned by the FAL queue response endpoint
/// when the job is still in progress (HTTP 400).
#[derive(Debug, Deserialize)]
pub struct RawIncompleteJobResponse {
pub detail: Option<String>,
pub request_id: Option<String>,
pub response_url: Option<String>,
pub status_url: Option<String>,
pub cancel_url: Option<String>,
}
@@ -0,0 +1,17 @@
use serde::Deserialize;
use serde_json::Value;
#[derive(Debug, Clone, Deserialize)]
pub struct PollResponseSingleImageData {
pub url: Option<String>,
pub width: Option<u64>,
pub height: Option<u64>,
pub content_type: Option<String>,
pub file_name: Option<String>,
pub file_size: Option<u64>,
}
pub fn extract_image(value: &Value) -> Option<PollResponseSingleImageData> {
let image = value.get("image")?;
serde_json::from_value(image.clone()).ok()
}
@@ -0,0 +1,20 @@
use serde::Deserialize;
use serde_json::Value;
#[derive(Debug, Clone, Deserialize)]
pub struct PollResponseImageEntry {
pub url: Option<String>,
pub width: Option<u64>,
pub height: Option<u64>,
pub content_type: Option<String>,
pub file_name: Option<String>,
pub file_size: Option<u64>,
}
pub fn extract_images(value: &Value) -> Option<Vec<PollResponseImageEntry>> {
let arr = value.get("images")?.as_array()?;
let images: Vec<PollResponseImageEntry> = arr.iter()
.filter_map(|v| serde_json::from_value(v.clone()).ok())
.collect();
if images.is_empty() { None } else { Some(images) }
}
@@ -0,0 +1,15 @@
use serde::Deserialize;
use serde_json::Value;
#[derive(Debug, Clone, Deserialize)]
pub struct PollResponseModelGlbData {
pub url: Option<String>,
pub content_type: Option<String>,
pub file_name: Option<String>,
pub file_size: Option<u64>,
}
pub fn extract_model_glb(value: &Value) -> Option<PollResponseModelGlbData> {
let model_glb = value.get("model_glb")?;
serde_json::from_value(model_glb.clone()).ok()
}
@@ -0,0 +1,15 @@
use serde::Deserialize;
use serde_json::Value;
#[derive(Debug, Clone, Deserialize)]
pub struct PollResponseThumbnailData {
pub url: Option<String>,
pub content_type: Option<String>,
pub file_name: Option<String>,
pub file_size: Option<u64>,
}
pub fn extract_thumbnail(value: &Value) -> Option<PollResponseThumbnailData> {
let thumbnail = value.get("thumbnail")?;
serde_json::from_value(thumbnail.clone()).ok()
}
@@ -0,0 +1,15 @@
use serde::Deserialize;
use serde_json::Value;
#[derive(Debug, Clone, Deserialize)]
pub struct PollResponseVideoData {
pub url: Option<String>,
pub content_type: Option<String>,
pub file_name: Option<String>,
pub file_size: Option<u64>,
}
pub fn extract_video(value: &Value) -> Option<PollResponseVideoData> {
let video = value.get("video")?;
serde_json::from_value(video.clone()).ok()
}
@@ -0,0 +1,49 @@
pub mod extract_image;
pub mod extract_images;
pub mod extract_model_glb;
pub mod extract_thumbnail;
pub mod extract_video;
use serde_json::Value;
use crate::polling::poll_job_response::success_case_extractors::extract_image::PollResponseSingleImageData;
use crate::polling::poll_job_response::success_case_extractors::extract_images::PollResponseImageEntry;
use crate::polling::poll_job_response::success_case_extractors::extract_model_glb::PollResponseModelGlbData;
use crate::polling::poll_job_response::success_case_extractors::extract_thumbnail::PollResponseThumbnailData;
use crate::polling::poll_job_response::success_case_extractors::extract_video::PollResponseVideoData;
/// Extracted content fields from a successful job response.
/// Multiple fields can be populated simultaneously (e.g. model_glb + thumbnail).
#[derive(Debug, Clone)]
pub struct PollResponseExtractedContents {
/// Parsed from `payload.image` (single image result, e.g. background removal).
pub image: Option<PollResponseSingleImageData>,
/// Parsed from `payload.images` (batch image results).
pub images: Option<Vec<PollResponseImageEntry>>,
pub video: Option<PollResponseVideoData>,
pub model_glb: Option<PollResponseModelGlbData>,
pub thumbnail: Option<PollResponseThumbnailData>,
}
/// Try to extract known content keys from a success response payload.
pub fn extract_contents_from_response(value: &Value) -> Option<PollResponseExtractedContents> {
let image = extract_image::extract_image(value);
let images = extract_images::extract_images(value);
let video = extract_video::extract_video(value);
let model_glb = extract_model_glb::extract_model_glb(value);
let thumbnail = extract_thumbnail::extract_thumbnail(value);
if image.is_none() && images.is_none() && video.is_none()
&& model_glb.is_none() && thumbnail.is_none()
{
return None;
}
Some(PollResponseExtractedContents {
image,
images,
video,
model_glb,
thumbnail,
})
}
@@ -0,0 +1,2 @@
pub mod poll_job_status;
pub mod raw_response;
@@ -0,0 +1,245 @@
use crate::creds::fal_api_key::FalApiKey;
use crate::error::api_generic_error::FalGenericApiError;
use crate::error::client_error::FalClientError;
use crate::error::fal_error_plus::FalErrorPlus;
use crate::polling::poll_job_status::raw_response::RawPollJobStatusResponse;
use log::info;
use url::Url;
const EXPECTED_HOST: &str = "queue.fal.run";
pub struct PollJobStatusArgs<'a> {
/// This is the "status" URL, not the "response" URL.
/// This only gives us the progress of the job, not details of the final results upon completion.
pub status_url: &'a str,
pub api_key: &'a FalApiKey,
}
/// The parsed status of a FAL queue job.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FalJobStatus {
/// Request is queued, waiting for a runner.
InQueue,
/// A runner is actively processing the request.
InProgress,
/// Processing is complete; results are available at the response URL.
Completed,
/// A status value we haven't mapped yet.
Unknown(String),
}
/// Parsed response from polling a FAL job's status.
#[derive(Debug)]
pub struct PollJobStatusResponse {
/// The job's current status.
pub status: FalJobStatus,
/// The request ID for this job.
pub request_id: Option<String>,
/// URL to retrieve the completed result.
pub response_url: Option<String>,
/// Queue position (only present when status is InQueue).
pub maybe_queue_position: Option<u64>,
/// Inference time in seconds (only present when status is Completed).
pub maybe_inference_time: Option<f64>,
/// The raw JSON response body, preserved for debugging or further parsing.
pub raw_body: String,
}
/// Poll the status of a queued FAL job.
///
/// The `status_url` must point to `queue.fal.run`.
pub async fn poll_job_status(args: PollJobStatusArgs<'_>) -> Result<PollJobStatusResponse, FalErrorPlus> {
let parsed = Url::parse(args.status_url)?;
let host = parsed.host_str().unwrap_or("");
if host != EXPECTED_HOST {
return Err(FalErrorPlus::ClientError(FalClientError::InvalidUrl(format!(
"Expected host '{}' but got '{}' in status URL: {}",
EXPECTED_HOST,
host,
args.status_url,
))));
}
info!("Polling FAL job status: {}", args.status_url);
let response = reqwest::Client::new()
.get(args.status_url)
.header("Authorization", format!("Key {}", args.api_key.0))
.send()
.await?;
let http_status = response.status();
let body = response.text().await?;
if !http_status.is_success() {
return Err(FalErrorPlus::ApiGeneric(
FalGenericApiError::UncategorizedBadResponseWithStatusAndBody {
status_code: http_status,
body,
},
));
}
let raw: RawPollJobStatusResponse = serde_json::from_str(&body)
.map_err(|err| FalErrorPlus::ApiGeneric(
FalGenericApiError::SerdeResponseParseErrorWithBody {
error: err,
body: body.clone(),
},
))?;
let status = match raw.status.as_str() {
"IN_QUEUE" => FalJobStatus::InQueue,
"IN_PROGRESS" => FalJobStatus::InProgress,
"COMPLETED" => FalJobStatus::Completed,
other => FalJobStatus::Unknown(other.to_string()),
};
let maybe_inference_time = raw.metrics
.and_then(|m| m.inference_time);
Ok(PollJobStatusResponse {
status,
request_id: raw.request_id,
response_url: raw.response_url,
maybe_queue_position: raw.queue_position,
maybe_inference_time,
raw_body: body,
})
}
#[cfg(test)]
mod tests {
use super::*;
// ── Unit tests (no network) ──
#[tokio::test]
async fn rejects_wrong_host() {
let api_key = FalApiKey::from_str("test-key");
let args = PollJobStatusArgs {
status_url: "https://evil.example.com/fal-ai/flux/requests/abc123",
api_key: &api_key,
};
let result = poll_job_status(args).await;
assert!(result.is_err());
let err = format!("{}", result.unwrap_err());
assert!(err.contains("evil.example.com"), "error should mention the bad host: {}", err);
}
#[tokio::test]
async fn rejects_invalid_url() {
let api_key = FalApiKey::from_str("test-key");
let args = PollJobStatusArgs {
status_url: "not a url at all",
api_key: &api_key,
};
let result = poll_job_status(args).await;
assert!(result.is_err());
}
#[test]
fn accepts_valid_host() {
let parsed = Url::parse("https://queue.fal.run/fal-ai/flux/requests/019e18d8-8c36-7bc1-aa77-2bc2f70268c6").unwrap();
assert_eq!(parsed.host_str(), Some(EXPECTED_HOST));
}
// ── Parsing tests ──
mod parsing {
use super::*;
use crate::polling::poll_job_status::raw_response::RawPollJobStatusResponse;
#[test]
fn parse_completed_response() {
let json = r#"{"status":"COMPLETED","request_id":"019e194b-f69a-77b1-bada-3f56d7d3c87d","response_url":"https://queue.fal.run/fal-ai/hunyuan3d-v3/requests/019e194b-f69a-77b1-bada-3f56d7d3c87d","status_url":"https://queue.fal.run/fal-ai/hunyuan3d-v3/requests/019e194b-f69a-77b1-bada-3f56d7d3c87d/status","cancel_url":"https://queue.fal.run/fal-ai/hunyuan3d-v3/requests/019e194b-f69a-77b1-bada-3f56d7d3c87d/cancel","logs":null,"metrics":{"inference_time":126.701406955719}}"#;
let raw: RawPollJobStatusResponse = serde_json::from_str(json).unwrap();
assert_eq!(raw.status, "COMPLETED");
assert_eq!(raw.request_id.as_deref(), Some("019e194b-f69a-77b1-bada-3f56d7d3c87d"));
assert!(raw.response_url.is_some());
assert!(raw.queue_position.is_none());
assert!((raw.metrics.unwrap().inference_time.unwrap() - 126.7).abs() < 1.0);
}
#[test]
fn parse_in_queue_response() {
let json = r#"{"status":"IN_QUEUE","request_id":"019e1951-c778-7120-9c82-16efcf40ec82","response_url":"https://queue.fal.run/fal-ai/hunyuan3d-v3/requests/019e1951-c778-7120-9c82-16efcf40ec82","status_url":"https://queue.fal.run/fal-ai/hunyuan3d-v3/requests/019e1951-c778-7120-9c82-16efcf40ec82/status","cancel_url":"https://queue.fal.run/fal-ai/hunyuan3d-v3/requests/019e1951-c778-7120-9c82-16efcf40ec82/cancel","logs":null,"metrics":{},"queue_position":0}"#;
let raw: RawPollJobStatusResponse = serde_json::from_str(json).unwrap();
assert_eq!(raw.status, "IN_QUEUE");
assert_eq!(raw.queue_position, Some(0));
assert!(raw.metrics.unwrap().inference_time.is_none());
}
#[test]
fn status_enum_mapping() {
assert_eq!(
match "IN_QUEUE" { "IN_QUEUE" => FalJobStatus::InQueue, _ => unreachable!() },
FalJobStatus::InQueue
);
assert_eq!(
match "IN_PROGRESS" { "IN_PROGRESS" => FalJobStatus::InProgress, _ => unreachable!() },
FalJobStatus::InProgress
);
assert_eq!(
match "COMPLETED" { "COMPLETED" => FalJobStatus::Completed, _ => unreachable!() },
FalJobStatus::Completed
);
}
#[test]
fn unknown_status_preserved() {
let status = match "SOMETHING_NEW" {
"IN_QUEUE" => FalJobStatus::InQueue,
"IN_PROGRESS" => FalJobStatus::InProgress,
"COMPLETED" => FalJobStatus::Completed,
other => FalJobStatus::Unknown(other.to_string()),
};
assert_eq!(status, FalJobStatus::Unknown("SOMETHING_NEW".to_string()));
}
}
// ── Live tests ──
#[tokio::test]
#[ignore] // requires real API key
async fn poll_single_image_job() {
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 args = PollJobStatusArgs {
status_url: "https://queue.fal.run/fal-ai/flux/requests/019e18d8-8c36-7bc1-aa77-2bc2f70268c6",
api_key: &api_key,
};
let result = poll_job_status(args).await.expect("poll should succeed");
println!("Status: {:?}", result.status);
println!("Response URL: {:?}", result.response_url);
println!("Inference time: {:?}", result.maybe_inference_time);
assert_eq!(result.status, FalJobStatus::Completed);
assert!(result.response_url.is_some());
}
#[tokio::test]
#[ignore] // requires real API key
async fn poll_single_mesh_status_job() {
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 args = PollJobStatusArgs {
status_url: "https://queue.fal.run/fal-ai/hunyuan3d-v3/requests/019e194b-f69a-77b1-bada-3f56d7d3c87d/status",
api_key: &api_key,
};
let result = poll_job_status(args).await.expect("poll should succeed");
println!("Status: {:?}", result.status);
println!("Response URL: {:?}", result.response_url);
println!("Inference time: {:?}", result.maybe_inference_time);
println!("Raw body: {}", result.raw_body);
assert_eq!(result.status, FalJobStatus::Completed);
}
}
@@ -0,0 +1,29 @@
use serde::Deserialize;
/// Raw JSON response from the FAL queue status endpoint.
///
/// See: https://fal.ai/docs/documentation/model-apis/inference/queue
#[derive(Debug, Deserialize)]
pub struct RawPollJobStatusResponse {
pub status: String,
pub request_id: Option<String>,
pub response_url: Option<String>,
pub status_url: Option<String>,
pub cancel_url: Option<String>,
pub queue_position: Option<u64>,
pub logs: Option<Vec<RawPollJobStatusLog>>,
pub metrics: Option<RawPollJobStatusMetrics>,
}
#[derive(Debug, Deserialize)]
pub struct RawPollJobStatusLog {
pub message: Option<String>,
pub level: Option<String>,
pub source: Option<String>,
pub timestamp: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct RawPollJobStatusMetrics {
pub inference_time: Option<f64>,
}
@@ -0,0 +1,134 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::mesh::image::hunyuan3d_3_image_to_mesh::raw_request::{
Hunyuan3d3ImageToMeshInput, Hunyuan3d3ImageToMeshOutput,
};
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
#[derive(Clone, Debug)]
pub struct Hunyuan3d3ImageToMeshRequest {
/// URL of the input image.
pub image_url: String,
/// Optional back-view image URL for multi-view generation.
pub back_image_url: Option<String>,
/// Optional left-view image URL for multi-view generation.
pub left_image_url: Option<String>,
/// Optional right-view image URL for multi-view generation.
pub right_image_url: Option<String>,
/// Target face count for the output mesh.
pub face_count: Option<u32>,
/// Generation type.
pub generate_type: Option<Hunyuan3d3ImageToMeshGenerateType>,
/// Polygon type for the output mesh.
pub polygon_type: Option<Hunyuan3d3ImageToMeshPolygonType>,
/// Whether to enable PBR (physically-based rendering) materials.
pub enable_pbr: Option<bool>,
}
#[derive(Copy, Clone, Debug)]
pub enum Hunyuan3d3ImageToMeshGenerateType {
Normal,
LowPoly,
Geometry,
}
#[derive(Copy, Clone, Debug)]
pub enum Hunyuan3d3ImageToMeshPolygonType {
Triangle,
Quadrilateral,
}
impl FalEndpoint for Hunyuan3d3ImageToMeshRequest {
const ENDPOINT: &str = "fal-ai/hunyuan3d-v3/image-to-3d";
type RawRequest = Hunyuan3d3ImageToMeshInput;
type RawResponse = Hunyuan3d3ImageToMeshOutput;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
let generate_type = self.generate_type.map(|t| match t {
Hunyuan3d3ImageToMeshGenerateType::Normal => "Normal",
Hunyuan3d3ImageToMeshGenerateType::LowPoly => "LowPoly",
Hunyuan3d3ImageToMeshGenerateType::Geometry => "Geometry",
}.to_string());
let polygon_type = self.polygon_type.map(|t| match t {
Hunyuan3d3ImageToMeshPolygonType::Triangle => "triangle",
Hunyuan3d3ImageToMeshPolygonType::Quadrilateral => "quadrilateral",
}.to_string());
Ok(Self::RawRequest {
input_image_url: self.image_url.clone(),
back_image_url: self.back_image_url.clone(),
left_image_url: self.left_image_url.clone(),
right_image_url: self.right_image_url.clone(),
face_count: self.face_count,
generate_type,
polygon_type,
enable_pbr: self.enable_pbr,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
use errors::AnyhowResult;
use std::fs::read_to_string;
use test_data::web::image_urls::ERNEST_SCARED_STUPID_IMAGE_URL;
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_image_to_mesh_webhook() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Hunyuan3d3ImageToMeshRequest {
image_url: ERNEST_SCARED_STUPID_IMAGE_URL.to_string(),
back_image_url: None,
left_image_url: None,
right_image_url: None,
face_count: None,
generate_type: None,
polygon_type: None,
enable_pbr: None,
};
let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?;
println!("Webhook result: {:?}", result);
assert!(result.request_id.is_some() || result.gateway_request_id.is_some());
Ok(())
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_image_to_mesh_queue() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal.api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Hunyuan3d3ImageToMeshRequest {
image_url: ERNEST_SCARED_STUPID_IMAGE_URL.to_string(),
back_image_url: None,
left_image_url: None,
right_image_url: None,
face_count: None,
generate_type: None,
polygon_type: None,
enable_pbr: None,
};
let result = request.send_queue_request(&api_key).await?;
println!("Queue result — request_id: {}", result.request_id);
assert!(!result.request_id.is_empty());
Ok(())
}
// NB: Pricing tests are in cost.rs
}
@@ -0,0 +1,162 @@
use crate::requests::api::mesh::image::hunyuan3d_3_image_to_mesh::api::{
Hunyuan3d3ImageToMeshGenerateType, Hunyuan3d3ImageToMeshRequest,
};
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
impl FalRequestCostCalculator for Hunyuan3d3ImageToMeshRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
// Base cost by generation type:
// Normal: $0.375 → 38¢ (rounded up)
// LowPoly: $0.45 → 45¢
// Geometry: $0.225 → 23¢ (rounded up)
//
// Add-ons (each +$0.15 = 15¢):
// - PBR materials enabled
// - Custom face count specified
// - Multi-view images (any of back/left/right)
let mut cost: u64 = match self.generate_type {
None => 38,
Some(Hunyuan3d3ImageToMeshGenerateType::Normal) => 38,
Some(Hunyuan3d3ImageToMeshGenerateType::Geometry) => 23,
Some(Hunyuan3d3ImageToMeshGenerateType::LowPoly) => 45,
};
if self.enable_pbr.unwrap_or(false) {
cost += 15;
}
if self.face_count.is_some() {
cost += 15;
}
let use_multi_view = self.left_image_url.is_some()
|| self.right_image_url.is_some()
|| self.back_image_url.is_some();
if use_multi_view {
cost += 15;
}
cost
}
}
#[cfg(test)]
mod tests {
use super::*;
fn base_request() -> Hunyuan3d3ImageToMeshRequest {
Hunyuan3d3ImageToMeshRequest {
image_url: "https://example.com/image.jpg".to_string(),
back_image_url: None,
left_image_url: None,
right_image_url: None,
face_count: None,
generate_type: None,
polygon_type: None,
enable_pbr: None,
}
}
mod base_costs {
use super::*;
#[test]
fn default_generate_type() {
assert_eq!(base_request().calculate_cost_in_cents(), 38);
}
#[test]
fn normal() {
let mut req = base_request();
req.generate_type = Some(Hunyuan3d3ImageToMeshGenerateType::Normal);
assert_eq!(req.calculate_cost_in_cents(), 38);
}
#[test]
fn low_poly() {
let mut req = base_request();
req.generate_type = Some(Hunyuan3d3ImageToMeshGenerateType::LowPoly);
assert_eq!(req.calculate_cost_in_cents(), 45);
}
#[test]
fn geometry() {
let mut req = base_request();
req.generate_type = Some(Hunyuan3d3ImageToMeshGenerateType::Geometry);
assert_eq!(req.calculate_cost_in_cents(), 23);
}
}
mod add_ons {
use super::*;
#[test]
fn pbr_adds_fifteen() {
let mut req = base_request();
req.enable_pbr = Some(true);
assert_eq!(req.calculate_cost_in_cents(), 38 + 15);
}
#[test]
fn pbr_false_no_extra() {
let mut req = base_request();
req.enable_pbr = Some(false);
assert_eq!(req.calculate_cost_in_cents(), 38);
}
#[test]
fn face_count_adds_fifteen() {
let mut req = base_request();
req.face_count = Some(50_000);
assert_eq!(req.calculate_cost_in_cents(), 38 + 15);
}
#[test]
fn back_image_adds_fifteen() {
let mut req = base_request();
req.back_image_url = Some("https://example.com/back.jpg".to_string());
assert_eq!(req.calculate_cost_in_cents(), 38 + 15);
}
#[test]
fn left_image_adds_fifteen() {
let mut req = base_request();
req.left_image_url = Some("https://example.com/left.jpg".to_string());
assert_eq!(req.calculate_cost_in_cents(), 38 + 15);
}
#[test]
fn right_image_adds_fifteen() {
let mut req = base_request();
req.right_image_url = Some("https://example.com/right.jpg".to_string());
assert_eq!(req.calculate_cost_in_cents(), 38 + 15);
}
#[test]
fn multi_view_only_adds_fifteen_once() {
let mut req = base_request();
req.back_image_url = Some("https://example.com/back.jpg".to_string());
req.left_image_url = Some("https://example.com/left.jpg".to_string());
req.right_image_url = Some("https://example.com/right.jpg".to_string());
assert_eq!(req.calculate_cost_in_cents(), 38 + 15);
}
#[test]
fn all_add_ons_stack() {
let mut req = base_request();
req.generate_type = Some(Hunyuan3d3ImageToMeshGenerateType::LowPoly);
req.enable_pbr = Some(true);
req.face_count = Some(100_000);
req.back_image_url = Some("https://example.com/back.jpg".to_string());
// LowPoly(45) + PBR(15) + face_count(15) + multi_view(15) = 90
assert_eq!(req.calculate_cost_in_cents(), 90);
}
#[test]
fn geometry_with_all_add_ons() {
let mut req = base_request();
req.generate_type = Some(Hunyuan3d3ImageToMeshGenerateType::Geometry);
req.enable_pbr = Some(true);
req.face_count = Some(10_000);
req.left_image_url = Some("https://example.com/left.jpg".to_string());
// Geometry(23) + PBR(15) + face_count(15) + multi_view(15) = 68
assert_eq!(req.calculate_cost_in_cents(), 68);
}
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -0,0 +1,32 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Hunyuan3d3ImageToMeshInput {
pub input_image_url: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub back_image_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub left_image_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub right_image_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub face_count: Option<u32>,
/// Options: "Normal", "LowPoly", "Geometry"
#[serde(skip_serializing_if = "Option::is_none")]
pub generate_type: Option<String>,
/// Options: "triangle", "quadrilateral"
#[serde(skip_serializing_if = "Option::is_none")]
pub polygon_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub enable_pbr: Option<bool>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Hunyuan3d3ImageToMeshOutput {}
@@ -0,0 +1 @@
pub mod hunyuan3d_3_image_to_mesh;
@@ -0,0 +1,2 @@
pub mod image;
pub mod text;
@@ -0,0 +1,115 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::mesh::text::hunyuan3d_3_text_to_mesh::raw_request::{
Hunyuan3d3TextToMeshInput, Hunyuan3d3TextToMeshOutput,
};
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
#[derive(Clone, Debug)]
pub struct Hunyuan3d3TextToMeshRequest {
/// Text prompt describing the 3D object to generate.
pub prompt: String,
/// Target face count for the output mesh.
pub face_count: Option<u32>,
/// Generation type.
pub generate_type: Option<Hunyuan3d3TextToMeshGenerateType>,
/// Polygon type for the output mesh.
pub polygon_type: Option<Hunyuan3d3TextToMeshPolygonType>,
/// Whether to enable PBR (physically-based rendering) materials.
pub enable_pbr: Option<bool>,
}
#[derive(Copy, Clone, Debug)]
pub enum Hunyuan3d3TextToMeshGenerateType {
Normal,
LowPoly,
Geometry,
}
#[derive(Copy, Clone, Debug)]
pub enum Hunyuan3d3TextToMeshPolygonType {
Triangle,
Quadrilateral,
}
impl FalEndpoint for Hunyuan3d3TextToMeshRequest {
const ENDPOINT: &str = "fal-ai/hunyuan3d-v3/text-to-3d";
type RawRequest = Hunyuan3d3TextToMeshInput;
type RawResponse = Hunyuan3d3TextToMeshOutput;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
let generate_type = self.generate_type.map(|t| match t {
Hunyuan3d3TextToMeshGenerateType::Normal => "Normal",
Hunyuan3d3TextToMeshGenerateType::LowPoly => "LowPoly",
Hunyuan3d3TextToMeshGenerateType::Geometry => "Geometry",
}.to_string());
let polygon_type = self.polygon_type.map(|t| match t {
Hunyuan3d3TextToMeshPolygonType::Triangle => "triangle",
Hunyuan3d3TextToMeshPolygonType::Quadrilateral => "quadrilateral",
}.to_string());
Ok(Self::RawRequest {
prompt: self.prompt.clone(),
face_count: self.face_count,
generate_type,
polygon_type,
enable_pbr: self.enable_pbr,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
use errors::AnyhowResult;
use std::fs::read_to_string;
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_text_to_mesh_webhook() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Hunyuan3d3TextToMeshRequest {
prompt: "A velociraptor with an open mouth full of sharp teeth. Large claws, ready to strike.".to_string(),
face_count: None,
generate_type: None,
polygon_type: None,
enable_pbr: None,
};
let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?;
println!("Webhook result: {:?}", result);
assert!(result.request_id.is_some() || result.gateway_request_id.is_some());
Ok(())
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_text_to_mesh_queue() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Hunyuan3d3TextToMeshRequest {
prompt: "A velociraptor with an open mouth full of sharp teeth. Large claws, ready to strike.".to_string(),
face_count: None,
generate_type: None,
polygon_type: None,
enable_pbr: None,
};
let result = request.send_queue_request(&api_key).await?;
println!("Queue result — request_id: {}", result.request_id);
assert!(!result.request_id.is_empty());
Ok(())
}
// NB: Pricing tests are in cost.rs
}
@@ -0,0 +1,122 @@
use crate::requests::api::mesh::text::hunyuan3d_3_text_to_mesh::api::{
Hunyuan3d3TextToMeshGenerateType, Hunyuan3d3TextToMeshRequest,
};
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
impl FalRequestCostCalculator for Hunyuan3d3TextToMeshRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
// Base cost by generation type:
// Normal: $0.375 → 38¢ (rounded up)
// LowPoly: $0.45 → 45¢
// Geometry: $0.225 → 23¢ (rounded up)
//
// Add-ons (each +$0.15 = 15¢):
// - PBR materials enabled
// - Custom face count specified
//
// Note: text-to-mesh has no multi-view add-on (no image inputs).
let mut cost: u64 = match self.generate_type {
None => 38,
Some(Hunyuan3d3TextToMeshGenerateType::Normal) => 38,
Some(Hunyuan3d3TextToMeshGenerateType::Geometry) => 23,
Some(Hunyuan3d3TextToMeshGenerateType::LowPoly) => 45,
};
if self.enable_pbr.unwrap_or(false) {
cost += 15;
}
if self.face_count.is_some() {
cost += 15;
}
cost
}
}
#[cfg(test)]
mod tests {
use super::*;
fn base_request() -> Hunyuan3d3TextToMeshRequest {
Hunyuan3d3TextToMeshRequest {
prompt: "test".to_string(),
face_count: None,
generate_type: None,
polygon_type: None,
enable_pbr: None,
}
}
mod base_costs {
use super::*;
#[test]
fn default_generate_type() {
assert_eq!(base_request().calculate_cost_in_cents(), 38);
}
#[test]
fn normal() {
let mut req = base_request();
req.generate_type = Some(Hunyuan3d3TextToMeshGenerateType::Normal);
assert_eq!(req.calculate_cost_in_cents(), 38);
}
#[test]
fn low_poly() {
let mut req = base_request();
req.generate_type = Some(Hunyuan3d3TextToMeshGenerateType::LowPoly);
assert_eq!(req.calculate_cost_in_cents(), 45);
}
#[test]
fn geometry() {
let mut req = base_request();
req.generate_type = Some(Hunyuan3d3TextToMeshGenerateType::Geometry);
assert_eq!(req.calculate_cost_in_cents(), 23);
}
}
mod add_ons {
use super::*;
#[test]
fn pbr_adds_fifteen() {
let mut req = base_request();
req.enable_pbr = Some(true);
assert_eq!(req.calculate_cost_in_cents(), 38 + 15);
}
#[test]
fn pbr_false_no_extra() {
let mut req = base_request();
req.enable_pbr = Some(false);
assert_eq!(req.calculate_cost_in_cents(), 38);
}
#[test]
fn face_count_adds_fifteen() {
let mut req = base_request();
req.face_count = Some(50_000);
assert_eq!(req.calculate_cost_in_cents(), 38 + 15);
}
#[test]
fn all_add_ons_stack() {
let mut req = base_request();
req.generate_type = Some(Hunyuan3d3TextToMeshGenerateType::LowPoly);
req.enable_pbr = Some(true);
req.face_count = Some(100_000);
// LowPoly(45) + PBR(15) + face_count(15) = 75
assert_eq!(req.calculate_cost_in_cents(), 75);
}
#[test]
fn geometry_with_all_add_ons() {
let mut req = base_request();
req.generate_type = Some(Hunyuan3d3TextToMeshGenerateType::Geometry);
req.enable_pbr = Some(true);
req.face_count = Some(10_000);
// Geometry(23) + PBR(15) + face_count(15) = 53
assert_eq!(req.calculate_cost_in_cents(), 53);
}
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -0,0 +1,23 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Hunyuan3d3TextToMeshInput {
pub prompt: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub face_count: Option<u32>,
/// Options: "Normal", "LowPoly", "Geometry"
#[serde(skip_serializing_if = "Option::is_none")]
pub generate_type: Option<String>,
/// Options: "triangle", "quadrilateral"
#[serde(skip_serializing_if = "Option::is_none")]
pub polygon_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub enable_pbr: Option<bool>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Hunyuan3d3TextToMeshOutput {}
@@ -0,0 +1 @@
pub mod hunyuan3d_3_text_to_mesh;
@@ -1 +1,3 @@
pub mod image;
pub mod image;
pub mod mesh;
pub mod video;
@@ -0,0 +1,294 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::video::image::kling_3p0_standard_image_to_video::raw_request::{
Kling3p0StandardImageToVideoInput, Kling3p0StandardImageToVideoOutput,
};
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
#[derive(Clone, Debug)]
pub struct Kling3p0StandardImageToVideoRequest {
/// Text prompt describing the video to generate.
pub prompt: String,
/// Starting frame image URL.
pub image_url: String,
/// Optional end frame image URL.
pub end_image_url: Option<String>,
/// Whether to generate audio. Defaults to true if not specified.
pub generate_audio: Option<bool>,
/// Optional negative prompt.
pub negative_prompt: Option<String>,
/// Video duration.
pub duration: Option<Kling3p0StandardImageToVideoDuration>,
/// Aspect ratio.
pub aspect_ratio: Option<Kling3p0StandardImageToVideoAspectRatio>,
/// Shot type for multi-shot generation.
pub shot_type: Option<Kling3p0StandardImageToVideoShotType>,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Kling3p0StandardImageToVideoDuration {
ThreeSeconds,
FourSeconds,
FiveSeconds,
SixSeconds,
SevenSeconds,
EightSeconds,
NineSeconds,
TenSeconds,
ElevenSeconds,
TwelveSeconds,
ThirteenSeconds,
FourteenSeconds,
FifteenSeconds,
}
impl Kling3p0StandardImageToVideoDuration {
pub fn to_seconds(&self) -> u64 {
match self {
Self::ThreeSeconds => 3,
Self::FourSeconds => 4,
Self::FiveSeconds => 5,
Self::SixSeconds => 6,
Self::SevenSeconds => 7,
Self::EightSeconds => 8,
Self::NineSeconds => 9,
Self::TenSeconds => 10,
Self::ElevenSeconds => 11,
Self::TwelveSeconds => 12,
Self::ThirteenSeconds => 13,
Self::FourteenSeconds => 14,
Self::FifteenSeconds => 15,
}
}
fn to_str(&self) -> &'static str {
match self {
Self::ThreeSeconds => "3",
Self::FourSeconds => "4",
Self::FiveSeconds => "5",
Self::SixSeconds => "6",
Self::SevenSeconds => "7",
Self::EightSeconds => "8",
Self::NineSeconds => "9",
Self::TenSeconds => "10",
Self::ElevenSeconds => "11",
Self::TwelveSeconds => "12",
Self::ThirteenSeconds => "13",
Self::FourteenSeconds => "14",
Self::FifteenSeconds => "15",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Kling3p0StandardImageToVideoAspectRatio {
Square,
SixteenByNine,
NineBySixteen,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Kling3p0StandardImageToVideoShotType {
Customize,
Intelligent,
}
impl FalEndpoint for Kling3p0StandardImageToVideoRequest {
const ENDPOINT: &str = "fal-ai/kling-video/v3/standard/image-to-video";
type RawRequest = Kling3p0StandardImageToVideoInput;
type RawResponse = Kling3p0StandardImageToVideoOutput;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
let duration = self.duration.map(|d| d.to_str().to_string());
let aspect_ratio = self.aspect_ratio.map(|ar| match ar {
Kling3p0StandardImageToVideoAspectRatio::Square => "1:1",
Kling3p0StandardImageToVideoAspectRatio::SixteenByNine => "16:9",
Kling3p0StandardImageToVideoAspectRatio::NineBySixteen => "9:16",
}.to_string());
let shot_type = self.shot_type.map(|st| match st {
Kling3p0StandardImageToVideoShotType::Customize => "customize",
Kling3p0StandardImageToVideoShotType::Intelligent => "intelligent",
}.to_string());
Ok(Self::RawRequest {
prompt: self.prompt.clone(),
image_url: self.image_url.clone(),
end_image_url: self.end_image_url.clone(),
generate_audio: self.generate_audio,
negative_prompt: self.negative_prompt.clone(),
duration,
aspect_ratio,
shot_type,
cfg_scale: None,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
use errors::AnyhowResult;
use std::fs::read_to_string;
use test_data::web::image_urls::TREX_SKELETON_IMAGE_URL;
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_image_to_video_webhook() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Kling3p0StandardImageToVideoRequest {
image_url: TREX_SKELETON_IMAGE_URL.to_string(),
prompt: "the t-rex skeleton gets off the podium and begins walking toward the camera".to_string(),
duration: Some(Kling3p0StandardImageToVideoDuration::FiveSeconds),
aspect_ratio: Some(Kling3p0StandardImageToVideoAspectRatio::SixteenByNine),
generate_audio: Some(true),
negative_prompt: None,
end_image_url: None,
shot_type: None,
};
let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?;
println!("Webhook result: {:?}", result);
assert!(result.request_id.is_some() || result.gateway_request_id.is_some());
Ok(())
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_image_to_video_queue() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Kling3p0StandardImageToVideoRequest {
image_url: TREX_SKELETON_IMAGE_URL.to_string(),
prompt: "the skeleton comes alive and roars at the camera".to_string(),
duration: Some(Kling3p0StandardImageToVideoDuration::ThreeSeconds),
aspect_ratio: Some(Kling3p0StandardImageToVideoAspectRatio::SixteenByNine),
generate_audio: Some(false),
negative_prompt: None,
end_image_url: None,
shot_type: None,
};
let result = request.send_queue_request(&api_key).await?;
println!("Queue result — request_id: {}", result.request_id);
assert!(!result.request_id.is_empty());
Ok(())
}
#[tokio::test]
#[ignore] // manually run — fires a real API request per variant (expensive)
async fn test_all_aspect_ratios() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let aspect_ratios = [
Kling3p0StandardImageToVideoAspectRatio::Square,
Kling3p0StandardImageToVideoAspectRatio::SixteenByNine,
Kling3p0StandardImageToVideoAspectRatio::NineBySixteen,
];
for ar in aspect_ratios {
println!("--- aspect ratio: {:?} ---", ar);
let request = Kling3p0StandardImageToVideoRequest {
image_url: TREX_SKELETON_IMAGE_URL.to_string(),
prompt: "the skeleton comes alive and roars at the camera".to_string(),
duration: Some(Kling3p0StandardImageToVideoDuration::ThreeSeconds),
aspect_ratio: Some(ar),
generate_audio: Some(false),
negative_prompt: None,
end_image_url: None,
shot_type: None,
};
let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?;
println!("result: {:?}", result);
}
Ok(())
}
#[tokio::test]
#[ignore] // manually run — fires a real API request per variant (expensive)
async fn test_all_durations() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let durations = [
Kling3p0StandardImageToVideoDuration::ThreeSeconds,
Kling3p0StandardImageToVideoDuration::FourSeconds,
Kling3p0StandardImageToVideoDuration::FiveSeconds,
Kling3p0StandardImageToVideoDuration::SixSeconds,
Kling3p0StandardImageToVideoDuration::SevenSeconds,
Kling3p0StandardImageToVideoDuration::EightSeconds,
Kling3p0StandardImageToVideoDuration::NineSeconds,
Kling3p0StandardImageToVideoDuration::TenSeconds,
Kling3p0StandardImageToVideoDuration::ElevenSeconds,
Kling3p0StandardImageToVideoDuration::TwelveSeconds,
Kling3p0StandardImageToVideoDuration::ThirteenSeconds,
Kling3p0StandardImageToVideoDuration::FourteenSeconds,
Kling3p0StandardImageToVideoDuration::FifteenSeconds,
];
for dur in durations {
println!("--- duration: {:?} ---", dur);
let request = Kling3p0StandardImageToVideoRequest {
image_url: TREX_SKELETON_IMAGE_URL.to_string(),
prompt: "the skeleton slowly turns its head and opens its jaw".to_string(),
duration: Some(dur),
aspect_ratio: Some(Kling3p0StandardImageToVideoAspectRatio::SixteenByNine),
generate_audio: Some(false),
negative_prompt: None,
end_image_url: None,
shot_type: None,
};
let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?;
println!("result: {:?}", result);
}
Ok(())
}
#[tokio::test]
#[ignore] // manually run — fires a real API request per variant (expensive)
async fn test_all_shot_types() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let shot_types = [
Kling3p0StandardImageToVideoShotType::Customize,
Kling3p0StandardImageToVideoShotType::Intelligent,
];
for st in shot_types {
println!("--- shot type: {:?} ---", st);
let request = Kling3p0StandardImageToVideoRequest {
image_url: TREX_SKELETON_IMAGE_URL.to_string(),
prompt: "the skeleton lurches forward dramatically".to_string(),
duration: Some(Kling3p0StandardImageToVideoDuration::FiveSeconds),
aspect_ratio: Some(Kling3p0StandardImageToVideoAspectRatio::SixteenByNine),
generate_audio: Some(true),
negative_prompt: None,
end_image_url: None,
shot_type: Some(st),
};
let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?;
println!("result: {:?}", result);
}
Ok(())
}
// NB: Pricing tests are in cost.rs
}
@@ -0,0 +1,110 @@
use crate::requests::api::video::image::kling_3p0_standard_image_to_video::api::{
Kling3p0StandardImageToVideoDuration, Kling3p0StandardImageToVideoRequest,
};
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
impl FalRequestCostCalculator for Kling3p0StandardImageToVideoRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
// Same pricing as text-to-video for Kling 3.0 Standard:
// Audio off: $0.168/second
// Audio on: $0.252/second
let generate_audio = self.generate_audio.unwrap_or(true);
let duration_secs = self.duration
.unwrap_or(Kling3p0StandardImageToVideoDuration::FiveSeconds)
.to_seconds();
// Rate in tenths-of-cents per second, rounded up
let rate = if generate_audio { 252u64 } else { 168u64 };
(rate * duration_secs + 9) / 10
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::requests::api::video::image::kling_3p0_standard_image_to_video::api::{
Kling3p0StandardImageToVideoAspectRatio, Kling3p0StandardImageToVideoDuration,
};
fn make_request(
generate_audio: Option<bool>,
duration: Option<Kling3p0StandardImageToVideoDuration>,
) -> Kling3p0StandardImageToVideoRequest {
Kling3p0StandardImageToVideoRequest {
prompt: "test".to_string(),
image_url: "https://example.com/image.jpg".to_string(),
end_image_url: None,
generate_audio,
negative_prompt: None,
duration,
aspect_ratio: Some(Kling3p0StandardImageToVideoAspectRatio::SixteenByNine),
shot_type: None,
}
}
mod audio_off {
use super::*;
// Audio off: $0.168/sec → rate = 168 tenths-of-cents/sec
#[test]
fn five_seconds() {
// (168 * 5 + 9) / 10 = 84
assert_eq!(make_request(Some(false), Some(Kling3p0StandardImageToVideoDuration::FiveSeconds)).calculate_cost_in_cents(), 84);
}
#[test]
fn ten_seconds() {
// (168 * 10 + 9) / 10 = 168
assert_eq!(make_request(Some(false), Some(Kling3p0StandardImageToVideoDuration::TenSeconds)).calculate_cost_in_cents(), 168);
}
#[test]
fn fifteen_seconds() {
// (168 * 15 + 9) / 10 = 252
assert_eq!(make_request(Some(false), Some(Kling3p0StandardImageToVideoDuration::FifteenSeconds)).calculate_cost_in_cents(), 252);
}
}
mod audio_on {
use super::*;
// Audio on: $0.252/sec → rate = 252 tenths-of-cents/sec
#[test]
fn five_seconds() {
// (252 * 5 + 9) / 10 = 126
assert_eq!(make_request(Some(true), Some(Kling3p0StandardImageToVideoDuration::FiveSeconds)).calculate_cost_in_cents(), 126);
}
#[test]
fn ten_seconds() {
// (252 * 10 + 9) / 10 = 252
assert_eq!(make_request(Some(true), Some(Kling3p0StandardImageToVideoDuration::TenSeconds)).calculate_cost_in_cents(), 252);
}
}
mod defaults {
use super::*;
#[test]
fn default_audio_is_on() {
// generate_audio=None defaults to true (audio on)
// duration=5s: (252 * 5 + 9) / 10 = 126
assert_eq!(make_request(None, Some(Kling3p0StandardImageToVideoDuration::FiveSeconds)).calculate_cost_in_cents(), 126);
}
#[test]
fn default_duration_is_five_seconds() {
// duration=None defaults to 5s
// audio off: (168 * 5 + 9) / 10 = 84
assert_eq!(make_request(Some(false), None).calculate_cost_in_cents(), 84);
}
#[test]
fn both_defaults() {
// audio on + 5s: (252 * 5 + 9) / 10 = 126
assert_eq!(make_request(None, None).calculate_cost_in_cents(), 126);
}
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -0,0 +1,51 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Kling3p0StandardImageToVideoInput {
pub prompt: String,
/// Starting frame image URL.
pub image_url: String,
/// Optional end frame image URL.
#[serde(skip_serializing_if = "Option::is_none")]
pub end_image_url: Option<String>,
/// Aspect ratio.
/// Possible values: "16:9", "9:16", "1:1"
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
/// Whether to generate audio alongside the video.
#[serde(skip_serializing_if = "Option::is_none")]
pub generate_audio: Option<bool>,
/// Optional negative prompt.
#[serde(skip_serializing_if = "Option::is_none")]
pub negative_prompt: Option<String>,
/// Duration in seconds (as a string).
/// Options: "3" through "15"
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<String>,
/// Shot type for multi-shot video generation.
/// Possible values: "customize", "intelligent"
#[serde(skip_serializing_if = "Option::is_none")]
pub shot_type: Option<String>,
/// The CFG (Classifier Free Guidance) scale.
/// Default value: 0.5
#[serde(skip_serializing_if = "Option::is_none")]
pub cfg_scale: Option<f32>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Kling3p0StandardImageToVideoVideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Kling3p0StandardImageToVideoOutput {
pub video: Kling3p0StandardImageToVideoVideoFile,
}
@@ -0,0 +1 @@
pub mod kling_3p0_standard_image_to_video;
@@ -0,0 +1,2 @@
pub mod image;
pub mod text;
@@ -0,0 +1,275 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::video::text::kling_3p0_standard_text_to_video::raw_request::{
Kling3p0StandardTextToVideoInput, Kling3p0StandardTextToVideoOutput,
};
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
#[derive(Clone, Debug)]
pub struct Kling3p0StandardTextToVideoRequest {
/// Text prompt describing the video to generate.
pub prompt: String,
/// Whether to generate audio. Defaults to true if not specified.
pub generate_audio: Option<bool>,
/// Optional negative prompt.
pub negative_prompt: Option<String>,
/// Video duration.
pub duration: Option<Kling3p0StandardTextToVideoDuration>,
/// Aspect ratio.
pub aspect_ratio: Option<Kling3p0StandardTextToVideoAspectRatio>,
/// Shot type for multi-shot generation.
pub shot_type: Option<Kling3p0StandardTextToVideoShotType>,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Kling3p0StandardTextToVideoDuration {
ThreeSeconds,
FourSeconds,
FiveSeconds,
SixSeconds,
SevenSeconds,
EightSeconds,
NineSeconds,
TenSeconds,
ElevenSeconds,
TwelveSeconds,
ThirteenSeconds,
FourteenSeconds,
FifteenSeconds,
}
impl Kling3p0StandardTextToVideoDuration {
pub fn to_seconds(&self) -> u64 {
match self {
Self::ThreeSeconds => 3,
Self::FourSeconds => 4,
Self::FiveSeconds => 5,
Self::SixSeconds => 6,
Self::SevenSeconds => 7,
Self::EightSeconds => 8,
Self::NineSeconds => 9,
Self::TenSeconds => 10,
Self::ElevenSeconds => 11,
Self::TwelveSeconds => 12,
Self::ThirteenSeconds => 13,
Self::FourteenSeconds => 14,
Self::FifteenSeconds => 15,
}
}
fn to_str(&self) -> &'static str {
match self {
Self::ThreeSeconds => "3",
Self::FourSeconds => "4",
Self::FiveSeconds => "5",
Self::SixSeconds => "6",
Self::SevenSeconds => "7",
Self::EightSeconds => "8",
Self::NineSeconds => "9",
Self::TenSeconds => "10",
Self::ElevenSeconds => "11",
Self::TwelveSeconds => "12",
Self::ThirteenSeconds => "13",
Self::FourteenSeconds => "14",
Self::FifteenSeconds => "15",
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Kling3p0StandardTextToVideoAspectRatio {
Square,
SixteenByNine,
NineBySixteen,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Kling3p0StandardTextToVideoShotType {
Customize,
Intelligent,
}
impl FalEndpoint for Kling3p0StandardTextToVideoRequest {
const ENDPOINT: &str = "fal-ai/kling-video/v3/standard/text-to-video";
type RawRequest = Kling3p0StandardTextToVideoInput;
type RawResponse = Kling3p0StandardTextToVideoOutput;
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
let duration = self.duration.map(|d| d.to_str().to_string());
let aspect_ratio = self.aspect_ratio.map(|ar| match ar {
Kling3p0StandardTextToVideoAspectRatio::Square => "1:1",
Kling3p0StandardTextToVideoAspectRatio::SixteenByNine => "16:9",
Kling3p0StandardTextToVideoAspectRatio::NineBySixteen => "9:16",
}.to_string());
let shot_type = self.shot_type.map(|st| match st {
Kling3p0StandardTextToVideoShotType::Customize => "customize",
Kling3p0StandardTextToVideoShotType::Intelligent => "intelligent",
}.to_string());
Ok(Self::RawRequest {
prompt: self.prompt.clone(),
generate_audio: self.generate_audio,
negative_prompt: self.negative_prompt.clone(),
duration,
aspect_ratio,
shot_type,
cfg_scale: None,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
use errors::AnyhowResult;
use std::fs::read_to_string;
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_text_to_video_webhook() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Kling3p0StandardTextToVideoRequest {
prompt: "a golden retriever puppy chases butterflies through a sunlit meadow, cinematic slow motion".to_string(),
generate_audio: Some(true),
negative_prompt: None,
duration: Some(Kling3p0StandardTextToVideoDuration::FiveSeconds),
aspect_ratio: Some(Kling3p0StandardTextToVideoAspectRatio::SixteenByNine),
shot_type: None,
};
let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?;
println!("Webhook result: {:?}", result);
assert!(result.request_id.is_some() || result.gateway_request_id.is_some());
Ok(())
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_text_to_video_queue() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let request = Kling3p0StandardTextToVideoRequest {
prompt: "a wave crashes against a rocky shoreline at sunset".to_string(),
generate_audio: Some(false),
negative_prompt: None,
duration: Some(Kling3p0StandardTextToVideoDuration::ThreeSeconds),
aspect_ratio: Some(Kling3p0StandardTextToVideoAspectRatio::SixteenByNine),
shot_type: None,
};
let result = request.send_queue_request(&api_key).await?;
println!("Queue result — request_id: {}", result.request_id);
assert!(!result.request_id.is_empty());
Ok(())
}
#[tokio::test]
#[ignore] // manually run — fires a real API request per variant (expensive)
async fn test_all_aspect_ratios() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let aspect_ratios = [
Kling3p0StandardTextToVideoAspectRatio::Square,
Kling3p0StandardTextToVideoAspectRatio::SixteenByNine,
Kling3p0StandardTextToVideoAspectRatio::NineBySixteen,
];
for ar in aspect_ratios {
println!("--- aspect ratio: {:?} ---", ar);
let request = Kling3p0StandardTextToVideoRequest {
prompt: "a wave crashes against a rocky shoreline at sunset".to_string(),
generate_audio: Some(true),
negative_prompt: None,
duration: Some(Kling3p0StandardTextToVideoDuration::ThreeSeconds),
aspect_ratio: Some(ar),
shot_type: None,
};
let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?;
println!("result: {:?}", result);
}
Ok(())
}
#[tokio::test]
#[ignore] // manually run — fires a real API request per variant (expensive)
async fn test_all_durations() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let durations = [
Kling3p0StandardTextToVideoDuration::ThreeSeconds,
Kling3p0StandardTextToVideoDuration::FourSeconds,
Kling3p0StandardTextToVideoDuration::FiveSeconds,
Kling3p0StandardTextToVideoDuration::SixSeconds,
Kling3p0StandardTextToVideoDuration::SevenSeconds,
Kling3p0StandardTextToVideoDuration::EightSeconds,
Kling3p0StandardTextToVideoDuration::NineSeconds,
Kling3p0StandardTextToVideoDuration::TenSeconds,
Kling3p0StandardTextToVideoDuration::ElevenSeconds,
Kling3p0StandardTextToVideoDuration::TwelveSeconds,
Kling3p0StandardTextToVideoDuration::ThirteenSeconds,
Kling3p0StandardTextToVideoDuration::FourteenSeconds,
Kling3p0StandardTextToVideoDuration::FifteenSeconds,
];
for dur in durations {
println!("--- duration: {:?} ---", dur);
let request = Kling3p0StandardTextToVideoRequest {
prompt: "a candle flame flickers in a dark room".to_string(),
generate_audio: Some(false),
negative_prompt: None,
duration: Some(dur),
aspect_ratio: Some(Kling3p0StandardTextToVideoAspectRatio::SixteenByNine),
shot_type: None,
};
let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?;
println!("result: {:?}", result);
}
Ok(())
}
#[tokio::test]
#[ignore] // manually run — fires a real API request per variant (expensive)
async fn test_all_shot_types() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let shot_types = [
Kling3p0StandardTextToVideoShotType::Customize,
Kling3p0StandardTextToVideoShotType::Intelligent,
];
for st in shot_types {
println!("--- shot type: {:?} ---", st);
let request = Kling3p0StandardTextToVideoRequest {
prompt: "a bird takes flight from a tree branch".to_string(),
generate_audio: Some(true),
negative_prompt: None,
duration: Some(Kling3p0StandardTextToVideoDuration::FiveSeconds),
aspect_ratio: Some(Kling3p0StandardTextToVideoAspectRatio::SixteenByNine),
shot_type: Some(st),
};
let result = request.send_webhook_request(&api_key, "https://example.com/webhook").await?;
println!("result: {:?}", result);
}
Ok(())
}
// NB: Pricing tests are in cost.rs
}
@@ -0,0 +1,120 @@
use crate::requests::api::video::text::kling_3p0_standard_text_to_video::api::{
Kling3p0StandardTextToVideoDuration, Kling3p0StandardTextToVideoRequest,
};
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
impl FalRequestCostCalculator for Kling3p0StandardTextToVideoRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
// Kling 3.0 Standard pricing:
// Audio off: $0.168/second
// Audio on: $0.252/second
let generate_audio = self.generate_audio.unwrap_or(true);
let duration_secs = self.duration
.unwrap_or(Kling3p0StandardTextToVideoDuration::FiveSeconds)
.to_seconds();
// Rate in tenths-of-cents per second, rounded up
let rate = if generate_audio { 252u64 } else { 168u64 };
(rate * duration_secs + 9) / 10
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::requests::api::video::text::kling_3p0_standard_text_to_video::api::{
Kling3p0StandardTextToVideoAspectRatio, Kling3p0StandardTextToVideoDuration,
};
fn make_request(
generate_audio: Option<bool>,
duration: Option<Kling3p0StandardTextToVideoDuration>,
) -> Kling3p0StandardTextToVideoRequest {
Kling3p0StandardTextToVideoRequest {
prompt: "test".to_string(),
generate_audio,
negative_prompt: None,
duration,
aspect_ratio: Some(Kling3p0StandardTextToVideoAspectRatio::SixteenByNine),
shot_type: None,
}
}
mod audio_off {
use super::*;
// Audio off: $0.168/sec → rate = 168 tenths-of-cents/sec
#[test]
fn three_seconds() {
// (168 * 3 + 9) / 10 = 51
assert_eq!(make_request(Some(false), Some(Kling3p0StandardTextToVideoDuration::ThreeSeconds)).calculate_cost_in_cents(), 51);
}
#[test]
fn five_seconds() {
// (168 * 5 + 9) / 10 = 84
assert_eq!(make_request(Some(false), Some(Kling3p0StandardTextToVideoDuration::FiveSeconds)).calculate_cost_in_cents(), 84);
}
#[test]
fn ten_seconds() {
// (168 * 10 + 9) / 10 = 168
assert_eq!(make_request(Some(false), Some(Kling3p0StandardTextToVideoDuration::TenSeconds)).calculate_cost_in_cents(), 168);
}
#[test]
fn fifteen_seconds() {
// (168 * 15 + 9) / 10 = 252
assert_eq!(make_request(Some(false), Some(Kling3p0StandardTextToVideoDuration::FifteenSeconds)).calculate_cost_in_cents(), 252);
}
}
mod audio_on {
use super::*;
// Audio on: $0.252/sec → rate = 252 tenths-of-cents/sec
#[test]
fn five_seconds() {
// (252 * 5 + 9) / 10 = 126
assert_eq!(make_request(Some(true), Some(Kling3p0StandardTextToVideoDuration::FiveSeconds)).calculate_cost_in_cents(), 126);
}
#[test]
fn ten_seconds() {
// (252 * 10 + 9) / 10 = 252
assert_eq!(make_request(Some(true), Some(Kling3p0StandardTextToVideoDuration::TenSeconds)).calculate_cost_in_cents(), 252);
}
#[test]
fn fifteen_seconds() {
// (252 * 15 + 9) / 10 = 378
assert_eq!(make_request(Some(true), Some(Kling3p0StandardTextToVideoDuration::FifteenSeconds)).calculate_cost_in_cents(), 378);
}
}
mod defaults {
use super::*;
#[test]
fn default_audio_is_on() {
// generate_audio=None defaults to true (audio on)
// duration=5s default: (252 * 5 + 9) / 10 = 126
assert_eq!(make_request(None, Some(Kling3p0StandardTextToVideoDuration::FiveSeconds)).calculate_cost_in_cents(), 126);
}
#[test]
fn default_duration_is_five_seconds() {
// duration=None defaults to 5s
// audio off: (168 * 5 + 9) / 10 = 84
assert_eq!(make_request(Some(false), None).calculate_cost_in_cents(), 84);
}
#[test]
fn both_defaults() {
// audio on + 5s: (252 * 5 + 9) / 10 = 126
assert_eq!(make_request(None, None).calculate_cost_in_cents(), 126);
}
}
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod cost;
pub mod raw_request;
@@ -0,0 +1,44 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Kling3p0StandardTextToVideoInput {
pub prompt: String,
/// Aspect ratio.
/// Possible values: "16:9", "9:16", "1:1"
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
/// Whether to generate audio alongside the video.
#[serde(skip_serializing_if = "Option::is_none")]
pub generate_audio: Option<bool>,
/// Optional negative prompt.
#[serde(skip_serializing_if = "Option::is_none")]
pub negative_prompt: Option<String>,
/// Duration in seconds (as a string).
/// Options: "3" through "15"
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<String>,
/// Shot type for multi-shot video generation.
/// Possible values: "customize", "intelligent"
#[serde(skip_serializing_if = "Option::is_none")]
pub shot_type: Option<String>,
/// The CFG (Classifier Free Guidance) scale.
/// Default value: 0.5
#[serde(skip_serializing_if = "Option::is_none")]
pub cfg_scale: Option<f32>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Kling3p0StandardTextToVideoVideoFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Kling3p0StandardTextToVideoOutput {
pub video: Kling3p0StandardTextToVideoVideoFile,
}
@@ -0,0 +1 @@
pub mod kling_3p0_standard_text_to_video;
@@ -153,7 +153,11 @@ impl<Params: Serialize, Response: DeserializeOwned> FalRequest<Params, Response>
return Err(error.into());
}
let payload: QueueResponse = response.error_for_status()?.json().await?;
let body = response.text().await?;
println!("Queue response: {}", body);
let payload: QueueResponse = serde_json::from_str(&body)?;
Ok(payload)
}