beeble client (#1487)
This commit is contained in:
1 parent
58b64b694c
commit
c78442cf71
25 files changed
+967
-1
No files matched your search
Generated
+16
@@ -1404,6 +1404,22 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "beeble_client"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"errors",
|
||||
"log",
|
||||
"reqwest 0.12.23",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"test_data",
|
||||
"tokio",
|
||||
"workspace-build-acceleration",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bigdecimal"
|
||||
version = "0.3.1"
|
||||
|
||||
@@ -16,6 +16,7 @@ members = [
|
||||
"crates/api_clients/grok_client",
|
||||
"crates/api_clients/midjourney_client",
|
||||
"crates/api_clients/muapi_client",
|
||||
"crates/api_clients/beeble_client",
|
||||
"crates/api_clients/openai_sora_client",
|
||||
"crates/api_clients/openart_client",
|
||||
"crates/api_clients/rootly_client",
|
||||
@@ -244,6 +245,7 @@ fal_client = { path = "crates/api_clients/fal_client" }
|
||||
ffmpeg_utils = { path = "crates/lib/ffmpeg_utils" }
|
||||
file_to_base64_url = { path = "crates/lib/files/file_to_base64_url" }
|
||||
filesys = { path = "crates/lib/files/filesys" }
|
||||
beeble_client = { path = "crates/api_clients/beeble_client" }
|
||||
grok_client = { path = "crates/api_clients/grok_client" }
|
||||
rootly_client = { path = "crates/api_clients/rootly_client" }
|
||||
rootly_config = { path = "crates/schema/config/rootly_config" }
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# beeble_client
|
||||
|
||||
HTTP client for the Beeble SwitchX compositing API.
|
||||
|
||||
## API Reference
|
||||
|
||||
- Docs: https://developer.beeble.ai/docs
|
||||
- OpenAPI: https://api.beeble.ai/developer-api-docs/openapi.json
|
||||
- Auth: `x-api-key` header
|
||||
|
||||
## Endpoints
|
||||
|
||||
- `create_upload_url` — POST /v1/uploads — get a presigned PUT URL for uploading media
|
||||
- `start_generation` — POST /v1/switchx/generations — start an image or video compositing job
|
||||
- `get_job_status` — GET /v1/switchx/generations/{job_id} — poll job status until completed/failed
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
cargo test -p beeble_client # run non-ignored tests (API shape only)
|
||||
cargo test -p beeble_client -- --ignored # run live API tests (requires key, incurs costs)
|
||||
```
|
||||
|
||||
Live tests require: `/Users/bt/Artcraft/credentials/beeble_api_key.txt`
|
||||
@@ -0,0 +1,31 @@
|
||||
[package]
|
||||
name = "beeble_client"
|
||||
edition = "2024"
|
||||
version = "0.0.1"
|
||||
publish = false
|
||||
|
||||
[package.metadata.cargo-udeps.ignore]
|
||||
normal = ["workspace-build-acceleration"]
|
||||
|
||||
[lib]
|
||||
name = "beeble_client"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
# Workspace hack (faster compile times)
|
||||
workspace-build-acceleration.workspace = true
|
||||
|
||||
# Internal
|
||||
errors.workspace = true
|
||||
|
||||
# External
|
||||
log.workspace = true
|
||||
reqwest.workspace = true
|
||||
serde.workspace = true
|
||||
serde_derive.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
anyhow.workspace = true
|
||||
test_data.workspace = true
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
@@ -0,0 +1,24 @@
|
||||
use std::fmt;
|
||||
|
||||
/// Holds the API key for authenticating with the Beeble API.
|
||||
/// Sent as the `x-api-key` header.
|
||||
#[derive(Clone)]
|
||||
pub struct BeebleApiKey {
|
||||
pub(crate) api_key: String,
|
||||
}
|
||||
|
||||
impl BeebleApiKey {
|
||||
pub fn new(api_key: String) -> Self {
|
||||
Self { api_key }
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for BeebleApiKey {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
if self.api_key.len() > 0 {
|
||||
write!(f, "BeebleApiKey(***)")
|
||||
} else {
|
||||
write!(f, "BeebleApiKey(EMPTY)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod beeble_api_key;
|
||||
@@ -0,0 +1,26 @@
|
||||
use std::error::Error;
|
||||
use std::fmt::{Display, Formatter};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum BeebleClientError {
|
||||
/// No API key is present.
|
||||
NoApiKeyPresent,
|
||||
|
||||
/// An error was encountered in building the reqwest client.
|
||||
ReqwestClientError(reqwest::Error),
|
||||
|
||||
/// A serialization error with the request.
|
||||
SerializationError(serde_json::Error),
|
||||
}
|
||||
|
||||
impl Error for BeebleClientError {}
|
||||
|
||||
impl Display for BeebleClientError {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::NoApiKeyPresent => write!(f, "No API key present."),
|
||||
Self::ReqwestClientError(err) => write!(f, "Reqwest client error: {}", err),
|
||||
Self::SerializationError(err) => write!(f, "Serialization error: {}", err),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
use crate::error::beeble_client_error::BeebleClientError;
|
||||
use crate::error::beeble_generic_api_error::BeebleGenericApiError;
|
||||
use crate::error::beeble_specific_api_error::BeebleSpecificApiError;
|
||||
use std::error::Error;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum BeebleError {
|
||||
Client(BeebleClientError),
|
||||
ApiSpecific(BeebleSpecificApiError),
|
||||
ApiGeneric(BeebleGenericApiError),
|
||||
}
|
||||
|
||||
impl Error for BeebleError {}
|
||||
|
||||
impl std::fmt::Display for BeebleError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Client(e) => write!(f, "BeebleClientError: {:?}", e),
|
||||
Self::ApiSpecific(e) => write!(f, "BeebleSpecificApiError: {:?}", e),
|
||||
Self::ApiGeneric(e) => write!(f, "BeebleGenericApiError: {:?}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<BeebleClientError> for BeebleError {
|
||||
fn from(error: BeebleClientError) -> Self {
|
||||
Self::Client(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<BeebleSpecificApiError> for BeebleError {
|
||||
fn from(error: BeebleSpecificApiError) -> Self {
|
||||
Self::ApiSpecific(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<BeebleGenericApiError> for BeebleError {
|
||||
fn from(error: BeebleGenericApiError) -> Self {
|
||||
Self::ApiGeneric(error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
use std::error::Error;
|
||||
use std::fmt::{Display, Formatter};
|
||||
|
||||
/// Catch-all for API errors that don't fit a specific variant.
|
||||
#[derive(Debug)]
|
||||
pub enum BeebleGenericApiError {
|
||||
/// An HTTP transport error from reqwest.
|
||||
ReqwestError(reqwest::Error),
|
||||
|
||||
/// Failed to parse the response body as JSON.
|
||||
SerdeResponseParseError(serde_json::Error, String),
|
||||
|
||||
/// The API returned a non-success status code.
|
||||
UncategorizedBadResponseWithStatusAndBody {
|
||||
status_code: reqwest::StatusCode,
|
||||
body: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl Error for BeebleGenericApiError {}
|
||||
|
||||
impl Display for BeebleGenericApiError {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::ReqwestError(err) => write!(f, "Beeble reqwest error: {}", err),
|
||||
Self::SerdeResponseParseError(err, body) => {
|
||||
write!(f, "Beeble response parse error: {} (body: {})", err, body)
|
||||
}
|
||||
Self::UncategorizedBadResponseWithStatusAndBody { status_code, body } => {
|
||||
write!(f, "Beeble API error: status={}, body={}", status_code, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
use std::error::Error;
|
||||
use std::fmt::{Display, Formatter};
|
||||
|
||||
/// Well-known API errors from the Beeble service.
|
||||
#[derive(Debug)]
|
||||
pub enum BeebleSpecificApiError {
|
||||
/// 401 Unauthorized — invalid or missing API key.
|
||||
Unauthorized,
|
||||
|
||||
/// 402 Payment Required — insufficient credits.
|
||||
InsufficientCredits,
|
||||
|
||||
/// 409 Conflict — idempotency key reused with different parameters.
|
||||
IdempotencyConflict,
|
||||
|
||||
/// 429 Too Many Requests — rate limit exceeded.
|
||||
RateLimited,
|
||||
|
||||
/// The generation job failed with a reported error message.
|
||||
GenerationFailed(String),
|
||||
}
|
||||
|
||||
impl Error for BeebleSpecificApiError {}
|
||||
|
||||
impl Display for BeebleSpecificApiError {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Unauthorized => write!(f, "Beeble API: Unauthorized (invalid or missing API key)"),
|
||||
Self::InsufficientCredits => write!(f, "Beeble API: Insufficient credits"),
|
||||
Self::IdempotencyConflict => write!(f, "Beeble API: Idempotency conflict"),
|
||||
Self::RateLimited => write!(f, "Beeble API: Rate limited"),
|
||||
Self::GenerationFailed(msg) => write!(f, "Beeble API: Generation failed: {}", msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod beeble_client_error;
|
||||
pub mod beeble_error;
|
||||
pub mod beeble_generic_api_error;
|
||||
pub mod beeble_specific_api_error;
|
||||
@@ -0,0 +1,7 @@
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_utils;
|
||||
|
||||
// Public API
|
||||
pub mod creds;
|
||||
pub mod error;
|
||||
pub mod requests;
|
||||
@@ -0,0 +1,126 @@
|
||||
use log::info;
|
||||
|
||||
use crate::creds::beeble_api_key::BeebleApiKey;
|
||||
use crate::error::beeble_client_error::BeebleClientError;
|
||||
use crate::error::beeble_error::BeebleError;
|
||||
use crate::error::beeble_generic_api_error::BeebleGenericApiError;
|
||||
use crate::error::beeble_specific_api_error::BeebleSpecificApiError;
|
||||
use crate::requests::create_upload_url::request_types::*;
|
||||
|
||||
const BEEBLE_API_BASE_URL: &str = "https://api.beeble.ai/v1";
|
||||
|
||||
// ── Public args ──
|
||||
|
||||
pub struct CreateUploadUrlArgs {
|
||||
pub api_key: BeebleApiKey,
|
||||
pub filename: String,
|
||||
}
|
||||
|
||||
// ── Public response ──
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CreateUploadUrlSuccess {
|
||||
/// Upload ID (upload_...).
|
||||
pub id: String,
|
||||
/// Presigned PUT URL for uploading. Expires after 1 hour.
|
||||
pub upload_url: String,
|
||||
/// beeble:// URI to reference this upload in generation calls.
|
||||
pub beeble_uri: String,
|
||||
}
|
||||
|
||||
// ── Implementation ──
|
||||
|
||||
/// Create a presigned upload URL for a media file.
|
||||
///
|
||||
/// After receiving the URL, PUT your file bytes directly to `upload_url`.
|
||||
/// Then use `beeble_uri` as the source_uri, reference_image_uri, or alpha_uri
|
||||
/// in generation calls.
|
||||
pub async fn create_upload_url(args: CreateUploadUrlArgs) -> Result<CreateUploadUrlSuccess, BeebleError> {
|
||||
let url = format!("{}/uploads", BEEBLE_API_BASE_URL);
|
||||
|
||||
info!("Creating Beeble upload URL for filename: {}", args.filename);
|
||||
|
||||
let request_body = CreateUploadUrlRequestBody {
|
||||
filename: args.filename,
|
||||
};
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.build()
|
||||
.map_err(|err| BeebleClientError::ReqwestClientError(err))?;
|
||||
|
||||
let response = client.post(&url)
|
||||
.header("x-api-key", &args.api_key.api_key)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&request_body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| BeebleGenericApiError::ReqwestError(err))?;
|
||||
|
||||
let status = response.status();
|
||||
let response_body = response.text()
|
||||
.await
|
||||
.map_err(|err| BeebleGenericApiError::ReqwestError(err))?;
|
||||
|
||||
info!("Beeble create upload URL response: status={}", status);
|
||||
|
||||
if status == reqwest::StatusCode::UNAUTHORIZED {
|
||||
return Err(BeebleSpecificApiError::Unauthorized.into());
|
||||
}
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(BeebleGenericApiError::UncategorizedBadResponseWithStatusAndBody {
|
||||
status_code: status,
|
||||
body: response_body,
|
||||
}.into());
|
||||
}
|
||||
|
||||
let parsed: CreateUploadUrlResponseBody = serde_json::from_str(&response_body)
|
||||
.map_err(|err| BeebleGenericApiError::SerdeResponseParseError(err, response_body.clone()))?;
|
||||
|
||||
Ok(CreateUploadUrlSuccess {
|
||||
id: parsed.id,
|
||||
upload_url: parsed.upload_url,
|
||||
beeble_uri: parsed.beeble_uri,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_utils::get_test_api_key::get_test_api_key;
|
||||
use errors::AnyhowResult;
|
||||
|
||||
#[test]
|
||||
fn request_body_serializes() {
|
||||
let body = CreateUploadUrlRequestBody {
|
||||
filename: "test_video.mp4".to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&body).unwrap();
|
||||
assert!(json.contains("test_video.mp4"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_body_deserializes() {
|
||||
let json = r#"{"id":"upload_abc123","upload_url":"https://example.com/put","beeble_uri":"beeble://upload_abc123/video.mp4"}"#;
|
||||
let parsed: CreateUploadUrlResponseBody = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(parsed.id, "upload_abc123");
|
||||
assert_eq!(parsed.beeble_uri, "beeble://upload_abc123/video.mp4");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually test — requires real API key
|
||||
async fn test_create_upload_url() -> AnyhowResult<()> {
|
||||
let api_key = get_test_api_key()?;
|
||||
let result = create_upload_url(CreateUploadUrlArgs {
|
||||
api_key,
|
||||
filename: "test_image.png".to_string(),
|
||||
}).await?;
|
||||
|
||||
println!("Upload ID: {}", result.id);
|
||||
println!("Upload URL: {}", result.upload_url);
|
||||
println!("Beeble URI: {}", result.beeble_uri);
|
||||
assert!(result.id.starts_with("upload_"));
|
||||
assert!(result.beeble_uri.starts_with("beeble://"));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod create_upload_url;
|
||||
mod request_types;
|
||||
@@ -0,0 +1,13 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct CreateUploadUrlRequestBody {
|
||||
pub filename: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct CreateUploadUrlResponseBody {
|
||||
pub id: String,
|
||||
pub upload_url: String,
|
||||
pub beeble_uri: String,
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
use log::info;
|
||||
|
||||
use crate::creds::beeble_api_key::BeebleApiKey;
|
||||
use crate::error::beeble_client_error::BeebleClientError;
|
||||
use crate::error::beeble_error::BeebleError;
|
||||
use crate::error::beeble_generic_api_error::BeebleGenericApiError;
|
||||
use crate::error::beeble_specific_api_error::BeebleSpecificApiError;
|
||||
use crate::requests::start_generation::request_types::GenerationJobResponseBody;
|
||||
use crate::requests::start_generation::start_generation::{map_job_response, StartGenerationSuccess};
|
||||
|
||||
const BEEBLE_API_BASE_URL: &str = "https://api.beeble.ai/v1";
|
||||
|
||||
// ── Public args ──
|
||||
|
||||
pub struct GetJobStatusArgs {
|
||||
pub api_key: BeebleApiKey,
|
||||
/// The job ID (swx_...) to poll.
|
||||
pub job_id: String,
|
||||
}
|
||||
|
||||
// ── Implementation ──
|
||||
|
||||
/// Poll the status of a SwitchX generation job.
|
||||
///
|
||||
/// Returns the same response shape as start_generation.
|
||||
/// Poll until `status` is "completed" or "failed".
|
||||
pub async fn get_job_status(args: GetJobStatusArgs) -> Result<StartGenerationSuccess, BeebleError> {
|
||||
let url = format!("{}/switchx/generations/{}", BEEBLE_API_BASE_URL, args.job_id);
|
||||
|
||||
info!("Polling Beeble job status: job_id={}", args.job_id);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.build()
|
||||
.map_err(|err| BeebleClientError::ReqwestClientError(err))?;
|
||||
|
||||
let response = client.get(&url)
|
||||
.header("x-api-key", &args.api_key.api_key)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| BeebleGenericApiError::ReqwestError(err))?;
|
||||
|
||||
let status = response.status();
|
||||
let response_body = response.text()
|
||||
.await
|
||||
.map_err(|err| BeebleGenericApiError::ReqwestError(err))?;
|
||||
|
||||
info!("Beeble get job status response: status={}", status);
|
||||
|
||||
match status.as_u16() {
|
||||
401 => return Err(BeebleSpecificApiError::Unauthorized.into()),
|
||||
429 => return Err(BeebleSpecificApiError::RateLimited.into()),
|
||||
_ if !status.is_success() => {
|
||||
return Err(BeebleGenericApiError::UncategorizedBadResponseWithStatusAndBody {
|
||||
status_code: status,
|
||||
body: response_body,
|
||||
}.into());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let parsed: GenerationJobResponseBody = serde_json::from_str(&response_body)
|
||||
.map_err(|err| BeebleGenericApiError::SerdeResponseParseError(err, response_body.clone()))?;
|
||||
|
||||
Ok(map_job_response(parsed))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_utils::get_test_api_key::get_test_api_key;
|
||||
|
||||
const TEST_JOB_ID: &str = "swx_uZAcwMjRZpDesqfp3pKkF";
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually test — requires real API key
|
||||
async fn test_poll_known_job() -> errors::AnyhowResult<()> {
|
||||
let api_key = get_test_api_key()?;
|
||||
let result = get_job_status(GetJobStatusArgs {
|
||||
api_key,
|
||||
job_id: TEST_JOB_ID.to_string(),
|
||||
}).await?;
|
||||
|
||||
println!("Job ID: {}", result.id);
|
||||
println!("Status: {}", result.status);
|
||||
println!("Progress: {:?}", result.progress);
|
||||
println!("Generation type: {:?}", result.generation_type);
|
||||
println!("Alpha mode: {:?}", result.alpha_mode);
|
||||
println!("Error: {:?}", result.error);
|
||||
println!("Created at: {:?}", result.created_at);
|
||||
if let Some(output) = &result.output {
|
||||
println!("Render URL: {:?}", output.render);
|
||||
println!("Source URL: {:?}", output.source);
|
||||
println!("Alpha URL: {:?}", output.alpha);
|
||||
}
|
||||
|
||||
assert_eq!(result.id, TEST_JOB_ID);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod get_job_status;
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod create_upload_url;
|
||||
pub mod get_job_status;
|
||||
pub mod start_generation;
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod start_generation;
|
||||
pub(crate) mod request_types;
|
||||
@@ -0,0 +1,55 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct StartGenerationRequestBody {
|
||||
pub generation_type: String,
|
||||
pub source_uri: String,
|
||||
pub alpha_mode: String,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub prompt: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reference_image_uri: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub alpha_uri: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_resolution: Option<u16>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub callback_url: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub idempotency_key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
pub struct GenerationJobResponseBody {
|
||||
pub id: String,
|
||||
pub status: String,
|
||||
pub progress: Option<u8>,
|
||||
pub generation_type: Option<String>,
|
||||
pub alpha_mode: Option<String>,
|
||||
pub output: Option<GenerationOutput>,
|
||||
pub error: Option<String>,
|
||||
pub created_at: Option<String>,
|
||||
pub modified_at: Option<String>,
|
||||
pub completed_at: Option<String>,
|
||||
pub webhook: Option<WebhookStatus>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
pub struct GenerationOutput {
|
||||
pub render: Option<String>,
|
||||
pub source: Option<String>,
|
||||
pub alpha: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
pub struct WebhookStatus {
|
||||
pub status: Option<String>,
|
||||
pub attempts: Option<u32>,
|
||||
pub last_error: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
use log::info;
|
||||
|
||||
use crate::creds::beeble_api_key::BeebleApiKey;
|
||||
use crate::error::beeble_client_error::BeebleClientError;
|
||||
use crate::error::beeble_error::BeebleError;
|
||||
use crate::error::beeble_generic_api_error::BeebleGenericApiError;
|
||||
use crate::error::beeble_specific_api_error::BeebleSpecificApiError;
|
||||
use crate::requests::start_generation::request_types::*;
|
||||
|
||||
const BEEBLE_API_BASE_URL: &str = "https://api.beeble.ai/v1";
|
||||
|
||||
// ── Public types ──
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct StartGenerationArgs {
|
||||
pub api_key: BeebleApiKey,
|
||||
pub request: StartGenerationRequest,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct StartGenerationRequest {
|
||||
/// "image" or "video".
|
||||
pub generation_type: BeebleGenerationType,
|
||||
/// Source image/video URI. Supports beeble://, https://, data: base64.
|
||||
pub source_uri: String,
|
||||
/// Alpha matte mode.
|
||||
pub alpha_mode: BeebleAlphaMode,
|
||||
|
||||
/// Text description of desired output (max 2000 chars).
|
||||
pub prompt: Option<String>,
|
||||
/// URI for style transfer reference image.
|
||||
pub reference_image_uri: Option<String>,
|
||||
/// Custom alpha matte URI. Required for Custom and Select modes.
|
||||
pub alpha_uri: Option<String>,
|
||||
/// Maximum output resolution: 720 or 1080. Default 1080.
|
||||
pub max_resolution: Option<u16>,
|
||||
/// HTTPS webhook URL for completion notification.
|
||||
pub callback_url: Option<String>,
|
||||
/// Safe retry key preventing duplicate jobs (max 256 chars).
|
||||
pub idempotency_key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub enum BeebleGenerationType {
|
||||
Image,
|
||||
Video,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub enum BeebleAlphaMode {
|
||||
/// Automatic alpha detection.
|
||||
Auto,
|
||||
/// Fill the entire frame.
|
||||
Fill,
|
||||
/// Use a custom alpha matte (requires alpha_uri).
|
||||
Custom,
|
||||
/// Select regions from the source (requires alpha_uri).
|
||||
Select,
|
||||
}
|
||||
|
||||
// ── Public response ──
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StartGenerationSuccess {
|
||||
/// Job identifier (swx_...).
|
||||
pub id: String,
|
||||
/// in_queue, processing, completed, or failed.
|
||||
pub status: String,
|
||||
/// 0-100 progress percentage.
|
||||
pub progress: Option<u8>,
|
||||
pub generation_type: Option<String>,
|
||||
pub alpha_mode: Option<String>,
|
||||
pub output: Option<GenerationOutputResult>,
|
||||
pub error: Option<String>,
|
||||
pub created_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GenerationOutputResult {
|
||||
/// URL to the rendered output. Expires after 72 hours.
|
||||
pub render: Option<String>,
|
||||
/// URL to the source. Expires after 72 hours.
|
||||
pub source: Option<String>,
|
||||
/// URL to the alpha matte. Expires after 72 hours.
|
||||
pub alpha: Option<String>,
|
||||
}
|
||||
|
||||
// ── Implementation ──
|
||||
|
||||
/// Start a SwitchX compositing generation job.
|
||||
pub async fn start_generation(args: StartGenerationArgs) -> Result<StartGenerationSuccess, BeebleError> {
|
||||
let url = format!("{}/switchx/generations", BEEBLE_API_BASE_URL);
|
||||
let req = args.request;
|
||||
|
||||
info!("Starting Beeble generation: type={:?}, alpha_mode={:?}", req.generation_type, req.alpha_mode);
|
||||
|
||||
let request_body = StartGenerationRequestBody {
|
||||
generation_type: match req.generation_type {
|
||||
BeebleGenerationType::Image => "image".to_string(),
|
||||
BeebleGenerationType::Video => "video".to_string(),
|
||||
},
|
||||
source_uri: req.source_uri,
|
||||
alpha_mode: match req.alpha_mode {
|
||||
BeebleAlphaMode::Auto => "auto".to_string(),
|
||||
BeebleAlphaMode::Fill => "fill".to_string(),
|
||||
BeebleAlphaMode::Custom => "custom".to_string(),
|
||||
BeebleAlphaMode::Select => "select".to_string(),
|
||||
},
|
||||
prompt: req.prompt,
|
||||
reference_image_uri: req.reference_image_uri,
|
||||
alpha_uri: req.alpha_uri,
|
||||
max_resolution: req.max_resolution,
|
||||
callback_url: req.callback_url,
|
||||
idempotency_key: req.idempotency_key,
|
||||
};
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.build()
|
||||
.map_err(|err| BeebleClientError::ReqwestClientError(err))?;
|
||||
|
||||
let response = client.post(&url)
|
||||
.header("x-api-key", &args.api_key.api_key)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&request_body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| BeebleGenericApiError::ReqwestError(err))?;
|
||||
|
||||
let status = response.status();
|
||||
let response_body = response.text()
|
||||
.await
|
||||
.map_err(|err| BeebleGenericApiError::ReqwestError(err))?;
|
||||
|
||||
info!("Beeble start generation response: status={}", status);
|
||||
|
||||
match status.as_u16() {
|
||||
401 => return Err(BeebleSpecificApiError::Unauthorized.into()),
|
||||
402 => return Err(BeebleSpecificApiError::InsufficientCredits.into()),
|
||||
409 => return Err(BeebleSpecificApiError::IdempotencyConflict.into()),
|
||||
429 => return Err(BeebleSpecificApiError::RateLimited.into()),
|
||||
_ if !status.is_success() => {
|
||||
return Err(BeebleGenericApiError::UncategorizedBadResponseWithStatusAndBody {
|
||||
status_code: status,
|
||||
body: response_body,
|
||||
}.into());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let parsed: GenerationJobResponseBody = serde_json::from_str(&response_body)
|
||||
.map_err(|err| BeebleGenericApiError::SerdeResponseParseError(err, response_body.clone()))?;
|
||||
|
||||
Ok(map_job_response(parsed))
|
||||
}
|
||||
|
||||
pub(crate) fn map_job_response(parsed: GenerationJobResponseBody) -> StartGenerationSuccess {
|
||||
StartGenerationSuccess {
|
||||
id: parsed.id,
|
||||
status: parsed.status,
|
||||
progress: parsed.progress,
|
||||
generation_type: parsed.generation_type,
|
||||
alpha_mode: parsed.alpha_mode,
|
||||
output: parsed.output.map(|o| GenerationOutputResult {
|
||||
render: o.render,
|
||||
source: o.source,
|
||||
alpha: o.alpha,
|
||||
}),
|
||||
error: parsed.error,
|
||||
created_at: parsed.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::requests::start_generation::request_types::GenerationJobResponseBody;
|
||||
|
||||
#[test]
|
||||
fn request_body_serializes_minimal() {
|
||||
let body = StartGenerationRequestBody {
|
||||
generation_type: "image".to_string(),
|
||||
source_uri: "beeble://upload_abc/photo.jpg".to_string(),
|
||||
alpha_mode: "auto".to_string(),
|
||||
prompt: None,
|
||||
reference_image_uri: None,
|
||||
alpha_uri: None,
|
||||
max_resolution: None,
|
||||
callback_url: None,
|
||||
idempotency_key: None,
|
||||
};
|
||||
let json = serde_json::to_string(&body).unwrap();
|
||||
assert!(json.contains("\"generation_type\":\"image\""));
|
||||
assert!(json.contains("\"alpha_mode\":\"auto\""));
|
||||
// Optional fields should not be present
|
||||
assert!(!json.contains("prompt"));
|
||||
assert!(!json.contains("reference_image_uri"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_body_serializes_full() {
|
||||
let body = StartGenerationRequestBody {
|
||||
generation_type: "video".to_string(),
|
||||
source_uri: "https://example.com/video.mp4".to_string(),
|
||||
alpha_mode: "custom".to_string(),
|
||||
prompt: Some("A sunny beach".to_string()),
|
||||
reference_image_uri: Some("beeble://upload_ref/ref.jpg".to_string()),
|
||||
alpha_uri: Some("beeble://upload_alpha/mask.png".to_string()),
|
||||
max_resolution: Some(1080),
|
||||
callback_url: Some("https://example.com/webhook".to_string()),
|
||||
idempotency_key: Some("my-key-123".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&body).unwrap();
|
||||
assert!(json.contains("\"prompt\":\"A sunny beach\""));
|
||||
assert!(json.contains("\"max_resolution\":1080"));
|
||||
assert!(json.contains("\"idempotency_key\":\"my-key-123\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_body_deserializes_queued() {
|
||||
let json = r#"{
|
||||
"id": "swx_abc123",
|
||||
"status": "in_queue",
|
||||
"progress": null,
|
||||
"generation_type": "image",
|
||||
"alpha_mode": "auto",
|
||||
"output": null,
|
||||
"error": null,
|
||||
"created_at": "2026-05-08T00:00:00Z",
|
||||
"modified_at": "2026-05-08T00:00:00Z",
|
||||
"completed_at": null,
|
||||
"webhook": null
|
||||
}"#;
|
||||
let parsed: GenerationJobResponseBody = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(parsed.id, "swx_abc123");
|
||||
assert_eq!(parsed.status, "in_queue");
|
||||
assert!(parsed.output.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_body_deserializes_completed() {
|
||||
let json = r#"{
|
||||
"id": "swx_abc123",
|
||||
"status": "completed",
|
||||
"progress": 100,
|
||||
"generation_type": "image",
|
||||
"alpha_mode": "auto",
|
||||
"output": {
|
||||
"render": "https://cdn.beeble.ai/render.png",
|
||||
"source": "https://cdn.beeble.ai/source.png",
|
||||
"alpha": "https://cdn.beeble.ai/alpha.png"
|
||||
},
|
||||
"error": null,
|
||||
"created_at": "2026-05-08T00:00:00Z",
|
||||
"modified_at": "2026-05-08T00:00:01Z",
|
||||
"completed_at": "2026-05-08T00:00:01Z",
|
||||
"webhook": null
|
||||
}"#;
|
||||
let parsed: GenerationJobResponseBody = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(parsed.status, "completed");
|
||||
assert_eq!(parsed.progress, Some(100));
|
||||
let output = parsed.output.unwrap();
|
||||
assert!(output.render.unwrap().contains("render.png"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually test — requires real API key and incurs costs
|
||||
async fn test_start_image_generation() -> errors::AnyhowResult<()> {
|
||||
use crate::test_utils::get_test_api_key::get_test_api_key;
|
||||
|
||||
let api_key = get_test_api_key()?;
|
||||
let result = start_generation(StartGenerationArgs {
|
||||
api_key,
|
||||
request: StartGenerationRequest {
|
||||
generation_type: BeebleGenerationType::Image,
|
||||
source_uri: "https://images.unsplash.com/photo-1506748686214-e9df14d4d9d0?w=800".to_string(),
|
||||
alpha_mode: BeebleAlphaMode::Auto,
|
||||
prompt: Some("A tropical beach at sunset".to_string()),
|
||||
reference_image_uri: None,
|
||||
alpha_uri: None,
|
||||
max_resolution: Some(720),
|
||||
callback_url: None,
|
||||
idempotency_key: None,
|
||||
},
|
||||
}).await?;
|
||||
|
||||
println!("Job ID: {}", result.id);
|
||||
println!("Status: {}", result.status);
|
||||
println!("Progress: {:?}", result.progress);
|
||||
assert!(result.id.starts_with("swx_"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Helpers for end-to-end tests ──
|
||||
|
||||
async fn download_bytes(url: &str) -> errors::AnyhowResult<Vec<u8>> {
|
||||
let response = reqwest::get(url).await?;
|
||||
let bytes = response.bytes().await?;
|
||||
Ok(bytes.to_vec())
|
||||
}
|
||||
|
||||
fn guess_content_type(filename: &str) -> &'static str {
|
||||
if filename.ends_with(".mp4") { "video/mp4" }
|
||||
else if filename.ends_with(".mov") { "video/quicktime" }
|
||||
else if filename.ends_with(".png") { "image/png" }
|
||||
else if filename.ends_with(".jpg") || filename.ends_with(".jpeg") { "image/jpeg" }
|
||||
else if filename.ends_with(".webp") { "image/webp" }
|
||||
else { "application/octet-stream" }
|
||||
}
|
||||
|
||||
async fn upload_asset_bytes(
|
||||
api_key: &crate::creds::beeble_api_key::BeebleApiKey,
|
||||
filename: &str,
|
||||
bytes: Vec<u8>,
|
||||
) -> errors::AnyhowResult<crate::requests::create_upload_url::create_upload_url::CreateUploadUrlSuccess> {
|
||||
use crate::requests::create_upload_url::create_upload_url::{
|
||||
create_upload_url, CreateUploadUrlArgs,
|
||||
};
|
||||
|
||||
// 1. Get presigned upload URL.
|
||||
let upload = create_upload_url(CreateUploadUrlArgs {
|
||||
api_key: api_key.clone(),
|
||||
filename: filename.to_string(),
|
||||
}).await.map_err(|e| anyhow::anyhow!("create_upload_url failed: {:?}", e))?;
|
||||
|
||||
println!("Upload ID: {}", upload.id);
|
||||
println!("Upload URL: {}", upload.upload_url);
|
||||
println!("Beeble URI: {}", upload.beeble_uri);
|
||||
|
||||
// 2. PUT file bytes to the presigned URL with correct Content-Type.
|
||||
let content_type = guess_content_type(filename);
|
||||
println!("Uploading {} bytes with Content-Type: {}", bytes.len(), content_type);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let put_response = client.put(&upload.upload_url)
|
||||
.header("Content-Type", content_type)
|
||||
.body(bytes)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let put_status = put_response.status();
|
||||
let put_body = put_response.text().await.unwrap_or_default();
|
||||
println!("PUT upload status: {} body: {}", put_status, put_body);
|
||||
assert!(put_status.is_success(), "PUT upload failed: {} body: {}", put_status, put_body);
|
||||
|
||||
Ok(upload)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually test — requires real API key, downloads files, incurs costs
|
||||
async fn test_end_to_end_video_generation() -> errors::AnyhowResult<()> {
|
||||
use crate::test_utils::get_test_api_key::get_test_api_key;
|
||||
use test_data::web::image_urls::FOREST_BACKDROP_IMAGE_URL;
|
||||
use test_data::web::video_urls::ANGRY_SHIBA_VIDEO_URL;
|
||||
|
||||
let api_key = get_test_api_key()?;
|
||||
|
||||
// 1. Download the source video.
|
||||
println!("Downloading video...");
|
||||
let video_bytes = download_bytes(ANGRY_SHIBA_VIDEO_URL).await?;
|
||||
println!("Downloaded video: {} bytes", video_bytes.len());
|
||||
|
||||
// 2. Download the reference image.
|
||||
println!("Downloading image...");
|
||||
let image_bytes = download_bytes(FOREST_BACKDROP_IMAGE_URL).await?;
|
||||
println!("Downloaded image: {} bytes", image_bytes.len());
|
||||
|
||||
// 3. Upload the video.
|
||||
println!("Uploading video...");
|
||||
let video_upload = upload_asset_bytes(&api_key, "shiba.mp4", video_bytes).await?;
|
||||
|
||||
// 4. Upload the reference image.
|
||||
println!("Uploading image...");
|
||||
let image_upload = upload_asset_bytes(&api_key, "forest.jpg", image_bytes).await?;
|
||||
|
||||
// 5. Start generation.
|
||||
println!("Starting generation...");
|
||||
let result = start_generation(StartGenerationArgs {
|
||||
api_key,
|
||||
request: StartGenerationRequest {
|
||||
generation_type: BeebleGenerationType::Video,
|
||||
source_uri: video_upload.beeble_uri,
|
||||
alpha_mode: BeebleAlphaMode::Auto,
|
||||
prompt: Some("Place the dog in a forest setting".to_string()),
|
||||
reference_image_uri: Some(image_upload.beeble_uri),
|
||||
alpha_uri: None,
|
||||
max_resolution: Some(720),
|
||||
callback_url: None,
|
||||
idempotency_key: None,
|
||||
},
|
||||
}).await.map_err(|e| anyhow::anyhow!("start_generation failed: {:?}", e))?;
|
||||
|
||||
println!("Job ID: {}", result.id);
|
||||
println!("Status: {}", result.status);
|
||||
println!("Progress: {:?}", result.progress);
|
||||
assert!(result.id.starts_with("swx_"));
|
||||
|
||||
// NB: We don't poll to completion here — that would take too long for a test.
|
||||
// Use get_job_status to poll manually if needed.
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
use errors::AnyhowResult;
|
||||
use std::fs::read_to_string;
|
||||
|
||||
use crate::creds::beeble_api_key::BeebleApiKey;
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn get_test_api_key() -> AnyhowResult<BeebleApiKey> {
|
||||
let api_key = read_to_string("/Users/bt/Artcraft/credentials/beeble_api_key.txt")?;
|
||||
let api_key = api_key.trim().to_string();
|
||||
Ok(BeebleApiKey::new(api_key))
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod get_test_api_key;
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod character_tokens;
|
||||
pub mod image_media_tokens;
|
||||
pub mod image_urls;
|
||||
pub mod image_urls;
|
||||
pub mod video_urls;
|
||||
@@ -0,0 +1,5 @@
|
||||
|
||||
|
||||
// media token: m_fz9r14yhfe05ffg9wqda5291p7mp75
|
||||
pub const ANGRY_SHIBA_VIDEO_URL : &str = "https://cdn-2.fakeyou.com/media/q/0/1/e/9/q01e9xsye0scamh5mrskr2emsv401x14/artcraft_q01e9xsye0scamh5mrskr2emsv401x14.mp4";
|
||||
|
||||
Reference in new issue
Block a user