use std::{collections::HashSet, fmt, sync::Arc, time::Duration};
use reqwest::{StatusCode, header::HeaderValue};
use serde_json::{Value, json};
use tokio::time::Instant;
use uuid::Uuid;
use crate::{
Client, Typed,
api::types,
client::decode,
error::{Error, JobError, Result},
transport::{Attempts, IDEMPOTENCY_KEY},
};
#[derive(Debug, Clone)]
pub struct Generation {
endpoint: &'static str,
operation: &'static str,
kind: Option<&'static str>,
body: Value,
}
impl Generation {
pub fn json(endpoint: &str, body: Value) -> Result<Self> {
let (endpoint, operation, kind) = match endpoint.trim_end_matches('/') {
"/generate/image" => ("/generate/image", "generate_image", Some("image")),
"/generate/video" => ("/generate/video", "generate_video", Some("video")),
"/generate/audio" => ("/generate/audio", "generate_audio", Some("audio")),
"/generate/3d" => ("/generate/3d", "generate3_d", Some("3d")),
"/restore/video" => ("/restore/video", "restore_video", None),
"/remove-background/video" => {
("/remove-background/video", "remove_background_video", None)
}
other => {
return Err(Error::InvalidRequest(format!(
"{other} does not answer with a job; use the generated method for it"
)));
}
};
Ok(Self {
endpoint,
operation,
kind,
body,
})
}
pub fn endpoint(&self) -> &str {
self.endpoint
}
pub fn body(&self) -> &Value {
&self.body
}
}
pub fn image(model: impl Into<String>, prompt: impl Into<String>) -> Generation {
simple(
"/generate/image",
"generate_image",
Some("image"),
model,
prompt,
)
}
pub fn video(model: impl Into<String>, prompt: impl Into<String>) -> Generation {
simple(
"/generate/video",
"generate_video",
Some("video"),
model,
prompt,
)
}
pub fn audio(model: impl Into<String>, prompt: impl Into<String>) -> Generation {
simple(
"/generate/audio",
"generate_audio",
Some("audio"),
model,
prompt,
)
}
fn simple(
endpoint: &'static str,
operation: &'static str,
kind: Option<&'static str>,
model: impl Into<String>,
prompt: impl Into<String>,
) -> Generation {
Generation {
endpoint,
operation,
kind,
body: json!({ "model": model.into(), "prompt": prompt.into() }),
}
}
impl Generation {
pub fn set(mut self, field: &str, value: impl serde::Serialize) -> Self {
let value = serde_json::to_value(value).unwrap_or(Value::Null);
if let Value::Object(map) = &mut self.body {
map.insert(field.to_owned(), value);
}
self
}
}
pub trait IntoGeneration {
fn into_generation(self) -> Result<Generation>;
}
impl IntoGeneration for Generation {
fn into_generation(self) -> Result<Generation> {
Ok(self)
}
}
impl IntoGeneration for Result<Generation> {
fn into_generation(self) -> Result<Generation> {
self
}
}
macro_rules! generation {
($ty:ident, $endpoint:literal) => {
impl IntoGeneration for types::$ty {
fn into_generation(self) -> Result<Generation> {
let body = serde_json::to_value(&self)
.map_err(|err| Error::InvalidRequest(err.to_string()))?;
Generation::json($endpoint, body)
}
}
impl IntoGeneration for types::builder::$ty {
fn into_generation(self) -> Result<Generation> {
types::$ty::try_from(self)
.map_err(|err| Error::InvalidRequest(err.to_string()))?
.into_generation()
}
}
};
}
generation!(GenerateImageRequest, "/generate/image");
generation!(GenerateVideoRequest, "/generate/video");
generation!(GenerateAudioRequest, "/generate/audio");
generation!(Generate3DRequest, "/generate/3d");
generation!(RestoreVideoRequest, "/restore/video");
generation!(RemoveBackgroundVideoRequest, "/remove-background/video");
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
pub struct SubmitOptions {
pub idempotency_key: Option<String>,
}
impl SubmitOptions {
pub fn idempotency_key(mut self, key: impl Into<String>) -> Self {
self.idempotency_key = Some(key.into());
self
}
}
pub type StatusCallback = Arc<dyn Fn(&types::Job) + Send + Sync>;
#[derive(Clone)]
#[non_exhaustive]
pub struct WaitOptions {
pub timeout: Duration,
pub poll_window: Duration,
pub on_status: Option<StatusCallback>,
}
impl Default for WaitOptions {
fn default() -> Self {
Self {
timeout: Duration::from_secs(30 * 60),
poll_window: Duration::from_secs(300),
on_status: None,
}
}
}
impl fmt::Debug for WaitOptions {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("WaitOptions")
.field("timeout", &self.timeout)
.field("poll_window", &self.poll_window)
.field("on_status", &self.on_status.is_some())
.finish()
}
}
impl WaitOptions {
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
pub fn poll_window(mut self, window: Duration) -> Self {
self.poll_window = window;
self
}
pub fn on_status(mut self, callback: impl Fn(&types::Job) + Send + Sync + 'static) -> Self {
self.on_status = Some(Arc::new(callback));
self
}
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct Media {
pub asset_id: Uuid,
pub url: String,
pub modality: String,
pub content_type: Option<String>,
pub expires_at: chrono::DateTime<chrono::Utc>,
}
impl Media {
fn from_asset(asset: &types::Asset) -> Self {
Self {
asset_id: asset.id,
url: asset.signed_url.clone(),
modality: asset.modality.to_string(),
content_type: asset.mime_type.clone(),
expires_at: asset.expires_at,
}
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct GenerationResult {
pub job: types::Job,
pub media: Vec<Media>,
}
impl GenerationResult {
pub fn url(&self) -> Option<&str> {
self.media.first().map(|m| m.url.as_str())
}
}
#[derive(Debug, Clone)]
pub struct JobHandle {
client: Client,
job: types::Job,
}
impl JobHandle {
pub fn id(&self) -> Uuid {
self.job.id
}
pub fn job(&self) -> &types::Job {
&self.job
}
pub async fn refresh(&mut self) -> Result<&types::Job> {
self.job = self.client.get_job().id(self.job.id).send().typed().await?;
Ok(&self.job)
}
pub async fn cancel(&self) -> Result<types::Job> {
self.client
.cancel_job()
.id(self.job.id)
.send()
.typed()
.await
}
pub async fn wait(&self) -> Result<types::Job> {
self.client
.wait_job(self.job.id, WaitOptions::default())
.await
}
pub async fn wait_with(&self, options: WaitOptions) -> Result<types::Job> {
self.client.wait_job(self.job.id, options).await
}
pub async fn result(&self) -> Result<GenerationResult> {
self.result_with(WaitOptions::default()).await
}
pub async fn result_with(&self, options: WaitOptions) -> Result<GenerationResult> {
let job = self.wait_with(options).await?;
let media = self.client.job_media(&job).await;
Ok(GenerationResult { job, media })
}
pub async fn events(
&self,
) -> Result<impl futures_util::Stream<Item = Result<crate::JobEvent>> + Send + 'static> {
self.client.job_events(self.job.id).await
}
}
type Seen = (String, Option<f64>, Option<String>, Option<String>);
const TERMINAL_FAILURES: [&str; 2] = ["failed", "canceled"];
impl Client {
pub async fn submit(&self, request: impl IntoGeneration) -> Result<JobHandle> {
self.submit_with(request, SubmitOptions::default()).await
}
pub async fn submit_with(
&self,
request: impl IntoGeneration,
options: SubmitOptions,
) -> Result<JobHandle> {
let generation = request.into_generation()?;
let mut builder = self
.http()
.post(self.url(generation.endpoint))
.json(&generation.body);
if let Some(key) = &options.idempotency_key {
let value = HeaderValue::from_str(key).map_err(|_| {
Error::InvalidRequest(format!(
"idempotency key {key:?} is not a valid header value"
))
})?;
builder = builder.header(IDEMPOTENCY_KEY, value);
}
let request = builder.build().map_err(Error::transport)?;
let response =
crate::transport::execute(self.http(), self.inner(), request, generation.operation)
.await
.map_err(Error::transport)?;
let retried = response
.extensions()
.get::<Attempts>()
.is_some_and(|attempts| attempts.0 > 0);
if response.status().is_success() {
let job: types::Job = decode(response).await?;
return Ok(self.handle(job));
}
let error = Error::from_response(response).await;
if retried
&& let Error::Api(api) = &error
&& api.is_duplicate_submission()
&& let Some(job_id) = api.job_id
{
let job = self.get_job().id(job_id).send().typed().await?;
return Ok(self.handle(job));
}
Err(error)
}
pub async fn subscribe(&self, request: impl IntoGeneration) -> Result<GenerationResult> {
self.submit(request).await?.result().await
}
pub async fn job(&self, job_id: Uuid) -> Result<JobHandle> {
let job = self.get_job().id(job_id).send().typed().await?;
Ok(self.handle(job))
}
fn handle(&self, job: types::Job) -> JobHandle {
JobHandle {
client: self.clone(),
job,
}
}
pub async fn quote(&self, request: impl IntoGeneration) -> Result<types::JobCostQuote> {
let generation = request.into_generation()?;
let kind = generation.kind.ok_or_else(|| {
Error::InvalidRequest(format!("{} cannot be quoted", generation.endpoint))
})?;
let field = if kind == "3d" { "three_d" } else { kind };
let body = json!({ "kind": kind, field: generation.body });
self.send_json(
self.http().post(self.url("/jobs/cost")).json(&body),
"quote_job_cost",
)
.await
}
pub async fn wait_job(&self, job_id: Uuid, options: WaitOptions) -> Result<types::Job> {
let deadline = Instant::now() + options.timeout;
let mut last: Option<Seen> = None;
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return Err(Error::Timeout {
job_id: Some(job_id),
});
}
let window = options
.poll_window
.min(Duration::from_secs(900))
.min(remaining)
.as_secs()
.max(1);
let request = self
.http()
.get(self.url(&format!("/jobs/{job_id}/wait")))
.query(&[("timeout_seconds", window)])
.timeout(Duration::from_secs(window + 30))
.build()
.map_err(Error::transport)?;
let response =
crate::transport::execute(self.http(), self.inner(), request, "wait_for_job")
.await
.map_err(Error::transport)?;
if response.status() == StatusCode::REQUEST_TIMEOUT {
continue; }
if !response.status().is_success() {
return Err(Error::from_response(response).await);
}
let job: types::Job = decode(response).await?;
let seen = (
job.status.clone(),
job.progress,
job.status_detail.clone(),
job.status_message.clone(),
);
if last.as_ref() != Some(&seen) {
if let Some(callback) = &options.on_status {
callback(&job);
}
last = Some(seen);
}
match job.status.as_str() {
"succeeded" => return Ok(job),
status if TERMINAL_FAILURES.contains(&status) => {
return Err(Error::Job(Box::new(JobError::from_job(&job))));
}
_ => {}
}
}
}
pub async fn job_media(&self, job: &types::Job) -> Vec<Media> {
let listed = self
.list_assets()
.job_id(job.id.to_string())
.limit(100u64)
.send()
.typed()
.await
.map(|page| page.items)
.unwrap_or_default();
let assets: Vec<&types::Asset> = if listed.is_empty() {
job.asset.iter().collect()
} else {
listed.iter().collect()
};
let mut seen = HashSet::new();
assets
.into_iter()
.filter(|asset| seen.insert(asset.id))
.map(Media::from_asset)
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builders_convert_and_validate() {
let ok = types::GenerateVideoRequest::builder()
.model("seedance-2.5")
.prompt("a red fox")
.into_generation()
.unwrap();
assert_eq!(ok.endpoint(), "/generate/video");
assert_eq!(ok.body()["model"], "seedance-2.5");
let missing = types::GenerateAudioRequest::builder()
.model("eleven-v3")
.into_generation();
assert!(matches!(missing, Err(Error::InvalidRequest(_))));
}
#[test]
fn raw_json_rejects_non_job_endpoints() {
assert!(Generation::json("/generate/video", json!({})).is_ok());
assert!(Generation::json("/generate/set", json!({})).is_err());
}
}