use std::sync::Arc;
use std::time::{Duration, Instant};
use async_trait::async_trait;
use base64::Engine;
use serde::Deserialize;
use crate::image_gen::{
GeneratedImage, ImageGenError, ImageGenProvider, ImageGenRequest, ImageGenResult, ImageSink,
ImageSize,
};
pub const FAL_DEFAULT_BASE: &str = "https://queue.fal.run";
const POLL_INTERVAL: Duration = Duration::from_millis(3000);
const MAX_WAIT: Duration = Duration::from_millis(175_000);
pub struct FalImageProvider {
client: reqwest::Client,
base_url: String,
api_key: String,
store: Arc<dyn ImageSink>,
}
impl FalImageProvider {
pub fn new(
base_url: String,
api_key: String,
store: Arc<dyn ImageSink>,
) -> Result<Self, ImageGenError> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.connect_timeout(Duration::from_secs(10))
.build()
.map_err(|e| ImageGenError::Transport(e.to_string()))?;
Ok(Self {
client,
base_url,
api_key,
store,
})
}
fn auth(&self) -> String {
format!("Key {}", self.api_key)
}
fn submit_url(&self, model: &str) -> String {
format!(
"{}/{}",
self.base_url.trim_end_matches('/'),
model.trim_start_matches('/')
)
}
async fn authed_get<T: for<'de> serde::Deserialize<'de>>(
&self,
url: &str,
) -> Result<T, ImageGenError> {
let resp = self
.client
.get(url)
.header(reqwest::header::AUTHORIZATION, self.auth())
.send()
.await
.map_err(|e| ImageGenError::Transport(e.to_string()))?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(ImageGenError::Http {
status: status.as_u16(),
body,
});
}
resp.json::<T>()
.await
.map_err(|e| ImageGenError::BadResponse(format!("invalid JSON: {e}")))
}
}
#[async_trait]
impl ImageGenProvider for FalImageProvider {
fn name(&self) -> &'static str {
"fal"
}
async fn generate(&self, req: &ImageGenRequest) -> Result<ImageGenResult, ImageGenError> {
let model = req.model.as_deref().ok_or(ImageGenError::MissingModel)?;
let submit_resp = self
.client
.post(self.submit_url(model))
.header(reqwest::header::AUTHORIZATION, self.auth())
.json(&build_input(req))
.send()
.await
.map_err(|e| ImageGenError::Transport(e.to_string()))?;
let status = submit_resp.status();
if !status.is_success() {
let body = submit_resp.text().await.unwrap_or_default();
return Err(ImageGenError::Http {
status: status.as_u16(),
body,
});
}
let submit: FalSubmit = submit_resp
.json()
.await
.map_err(|e| ImageGenError::BadResponse(format!("submit parse: {e}")))?;
let status_url = submit
.status_url
.ok_or_else(|| ImageGenError::BadResponse("submit returned no status_url".into()))?;
let response_url = submit
.response_url
.ok_or_else(|| ImageGenError::BadResponse("submit returned no response_url".into()))?;
let deadline = Instant::now() + MAX_WAIT;
loop {
let st: FalStatus = self.authed_get(&status_url).await?;
match st.status.as_str() {
"COMPLETED" => {
if let Some(err) = st.error {
return Err(ImageGenError::BadResponse(format!("fal job failed: {err}")));
}
break;
}
"FAILED" | "ERROR" => {
return Err(ImageGenError::BadResponse(format!(
"fal job failed: {}",
st.error.unwrap_or_else(|| "unknown".into())
)));
}
_ => {}
}
if Instant::now() >= deadline {
return Err(ImageGenError::Timeout {
context: "fal generation".into(),
});
}
tokio::time::sleep(POLL_INTERVAL).await;
}
let result: FalResult = self.authed_get(&response_url).await?;
let images = normalize_fal_images(&result.images, self.store.as_ref())?;
Ok(ImageGenResult {
images,
provider: "fal".into(),
model: model.into(),
revised_prompt: result.revised_prompt,
})
}
}
#[derive(Deserialize)]
struct FalSubmit {
#[serde(default)]
#[allow(dead_code)]
request_id: Option<String>,
#[serde(default)]
status_url: Option<String>,
#[serde(default)]
response_url: Option<String>,
}
#[derive(Deserialize)]
struct FalStatus {
status: String,
#[serde(default)]
error: Option<String>,
#[serde(default)]
#[allow(dead_code)]
error_type: Option<String>,
}
#[derive(Deserialize)]
struct FalResult {
#[serde(default)]
images: Vec<FalImage>,
#[serde(default)]
revised_prompt: Option<String>,
}
#[derive(Deserialize)]
struct FalImage {
url: Option<String>,
#[serde(default, rename = "b64_json")]
b64_json: Option<String>,
#[serde(default)]
width: Option<u32>,
#[serde(default)]
height: Option<u32>,
}
fn build_input(req: &ImageGenRequest) -> serde_json::Value {
let mut input = serde_json::json!({
"prompt": req.prompt,
"num_images": req.n.clamp(1, 8),
});
if let Some(size) = req.size {
let (w, h) = match size {
ImageSize::Square1024 => (1024, 1024),
ImageSize::Landscape1792 => (1792, 1024),
ImageSize::Portrait1792 => (1024, 1792),
};
input["image_size"] = serde_json::json!({ "width": w, "height": h });
}
if let Some(ref url) = req.reference_image_url {
input["image_url"] = serde_json::json!(url);
}
input
}
fn normalize_fal_images(
images: &[FalImage],
store: &dyn ImageSink,
) -> Result<Vec<GeneratedImage>, ImageGenError> {
if images.is_empty() {
return Err(ImageGenError::BadResponse(
"fal result has no images".into(),
));
}
images
.iter()
.map(|img| match (&img.url, &img.b64_json) {
(Some(url), _) => Ok(GeneratedImage {
url: url.clone(),
width: img.width,
height: img.height,
}),
(None, Some(b64)) => {
let bytes = base64::engine::general_purpose::STANDARD
.decode(b64)
.map_err(|e| ImageGenError::Base64(e.to_string()))?;
let url = store.save(bytes, "png")?;
Ok(GeneratedImage {
url,
width: img.width,
height: img.height,
})
}
(None, None) => Err(ImageGenError::BadResponse(
"fal image has neither url nor b64_json".into(),
)),
})
.collect()
}
#[cfg(test)]
mod tests {
use parking_lot::Mutex;
use super::*;
use crate::image_gen::ImageSink;
struct FakeSink {
saved: Mutex<Vec<(Vec<u8>, String)>>,
}
impl ImageSink for FakeSink {
fn save(&self, bytes: Vec<u8>, ext: &str) -> Result<String, ImageGenError> {
let n = self.saved.lock().len();
let url = format!("/api/images/fal-{n}.{ext}");
self.saved.lock().push((bytes, ext.into()));
Ok(url)
}
}
fn sink() -> FakeSink {
FakeSink {
saved: Mutex::new(vec![]),
}
}
fn img(url: Option<&str>, b64: Option<&str>) -> FalImage {
FalImage {
url: url.map(Into::into),
b64_json: b64.map(Into::into),
width: Some(1024),
height: Some(1024),
}
}
#[test]
fn build_input_maps_prompt_num_size_and_reference() {
let req = ImageGenRequest {
prompt: "a cat".into(),
model: Some("fal-ai/flux/dev".into()),
n: 3,
size: Some(ImageSize::Landscape1792),
quality: None,
reference_image_url: Some("https://x/ref.png".into()),
};
let input = build_input(&req);
assert_eq!(input["prompt"], "a cat");
assert_eq!(input["num_images"], 3);
assert_eq!(input["image_size"]["width"], 1792);
assert_eq!(input["image_size"]["height"], 1024);
assert_eq!(input["image_url"], "https://x/ref.png");
}
#[test]
fn url_images_pass_through_with_dimensions() {
let s = sink();
let r = normalize_fal_images(&[img(Some("https://fal/cdn/1.png"), None)], &s).unwrap();
assert_eq!(r[0].url, "https://fal/cdn/1.png");
assert_eq!(r[0].width, Some(1024));
assert!(s.saved.lock().is_empty());
}
#[test]
fn b64_images_are_persisted() {
let s = sink();
let b64 = base64::engine::general_purpose::STANDARD.encode(b"falimg");
let r = normalize_fal_images(&[img(None, Some(&b64))], &s).unwrap();
assert!(r[0].url.starts_with("/api/images/fal-0.png"));
assert_eq!(s.saved.lock()[0].0, b"falimg");
}
#[test]
fn empty_images_is_an_error() {
let s = sink();
assert!(normalize_fal_images(&[], &s).is_err());
}
#[test]
fn submit_url_places_model_directly_in_path() {
let p = FalImageProvider {
client: reqwest::Client::new(),
base_url: "https://queue.fal.run".into(),
api_key: "k".into(),
store: Arc::new(crate::image_gen::FsImageStore::new(
std::path::PathBuf::from("/tmp"),
"/api/images/".into(),
)),
};
assert_eq!(
p.submit_url("fal-ai/flux/schnell"),
"https://queue.fal.run/fal-ai/flux/schnell"
);
assert_eq!(
p.submit_url("/fal-ai/flux/schnell"),
"https://queue.fal.run/fal-ai/flux/schnell"
);
}
}