beeble client (#1487)

This commit is contained in:
Brandon Thomas authored and GitHub committed 2026-05-08 12:49:15 -04:00
1 parent 58b64b694c
commit c78442cf71
25 files changed
+967 -1

No files matched your search

Generated
+16
View File
@@ -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"
+2
View File
@@ -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;
+2 -1
View File
@@ -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";