mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-20 17:02:53 -04:00
FAL polling APIs + additional API bindings (#1501)
This commit is contained in:
@@ -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;
|
||||
@@ -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>,
|
||||
}
|
||||
+17
@@ -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()
|
||||
}
|
||||
+20
@@ -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) }
|
||||
}
|
||||
+15
@@ -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()
|
||||
}
|
||||
+15
@@ -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()
|
||||
}
|
||||
+15
@@ -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()
|
||||
}
|
||||
+49
@@ -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>,
|
||||
}
|
||||
+134
@@ -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
|
||||
}
|
||||
+162
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod api;
|
||||
pub mod cost;
|
||||
pub mod raw_request;
|
||||
+32
@@ -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;
|
||||
+115
@@ -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
|
||||
}
|
||||
+122
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod api;
|
||||
pub mod cost;
|
||||
pub mod raw_request;
|
||||
+23
@@ -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;
|
||||
+294
@@ -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
|
||||
}
|
||||
+110
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod api;
|
||||
pub mod cost;
|
||||
pub mod raw_request;
|
||||
+51
@@ -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;
|
||||
+275
@@ -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
|
||||
}
|
||||
+120
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod api;
|
||||
pub mod cost;
|
||||
pub mod raw_request;
|
||||
+44
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user