Compare commits

...
Author SHA1 Message Date
Brandon Thomas d14629225d stronger typing 2026-03-09 08:55:09 -04:00
Brandon Thomas 298e7df459 test improvements 2026-03-09 01:10:58 -04:00
Brandon Thomas d1b9331df1 clean up api 2026-03-09 01:02:54 -04:00
Brandon Thomas 164dbdde33 overhaul API 2026-03-09 00:33:26 -04:00
Brandon Thomas 910a31dc90 cargo 2026-03-08 23:59:28 -04:00
Brandon Thomas 2db6b28704 Adding a second WorldLabs API - this time, we're going to use the official one. 2026-03-08 23:49:23 -04:00
46 changed files with 1874 additions and 0 deletions
Generated
+19
View File
@@ -15007,6 +15007,25 @@ dependencies = [
"zstd-sys",
]
[[package]]
name = "world_labs_api"
version = "0.0.1"
dependencies = [
"anyhow",
"chrono",
"env",
"env_logger 0.11.9",
"errors",
"filesys",
"log",
"serde",
"serde_json",
"tokio",
"uuid 1.18.1",
"workspace-build-acceleration",
"wreq",
]
[[package]]
name = "world_labs_client"
version = "0.0.1"
+2
View File
@@ -19,6 +19,7 @@ members = [
"crates/api_clients/seedance2pro",
"crates/api_clients/runwayml_client",
"crates/api_clients/thumbnail_generator",
"crates/api_clients/world_labs_api",
"crates/api_clients/world_labs_client",
# CLI tools for operations, etc.
@@ -248,6 +249,7 @@ tokens = { path = "crates/schema/public/tokens" }
url_utils = { path = "crates/lib/url_utils" }
users = { path = "crates/lib/users" }
web_base64 = { path = "crates/lib/data/web_base64" }
world_labs_api = { path = "crates/api_clients/world_labs_api" }
world_labs_client = { path = "crates/api_clients/world_labs_client" }
# External libs
@@ -0,0 +1,35 @@
[package]
name = "world_labs_api"
edition = "2024"
version = "0.0.1"
publish = false
[package.metadata.cargo-udeps.ignore]
normal = ["workspace-build-acceleration"]
[lib]
name = "world_labs_api"
path = "src/lib.rs"
[dependencies]
# Workspace hack (faster compile times)
workspace-build-acceleration.workspace = true
# Internal
errors.workspace = true
filesys.workspace = true
# External
anyhow.workspace = true
log.workspace = true
serde.workspace = true
serde_json.workspace = true
tokio = { version = "1", features = ["full"] }
uuid.workspace = true
wreq.workspace = true
[dev-dependencies]
filesys.workspace = true
env_logger.workspace = true
env.workspace = true
chrono.workspace = true
@@ -0,0 +1,9 @@
/// ID for a media asset in the World Labs API.
#[derive(Clone, Debug)]
pub struct MediaAssetId(pub String);
impl MediaAssetId {
pub fn as_str(&self) -> &str {
&self.0
}
}
@@ -0,0 +1,3 @@
pub mod media_asset_id;
pub mod operation_id;
pub mod world_id;
@@ -0,0 +1,9 @@
/// ID for an async operation returned by the World Labs API.
#[derive(Clone, Debug)]
pub struct OperationId(pub String);
impl OperationId {
pub fn as_str(&self) -> &str {
&self.0
}
}
@@ -0,0 +1,26 @@
use uuid::Uuid;
/// Type for World IDs.
/// These are used to refer to worlds
/// These appear to be bare UUIDs.
#[derive(Clone, Debug)]
pub struct WorldObjectId(pub String);
impl WorldObjectId {
pub fn new() -> Self {
let uuid = Uuid::new_v4().to_string();
Self(uuid)
}
pub fn from_str(s: &str) -> Self {
Self(s.to_string())
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn to_string(&self) -> String {
self.0.clone()
}
}
@@ -0,0 +1,2 @@
pub mod api_types;
pub mod requests;
@@ -0,0 +1,101 @@
use crate::api::requests::delete_world::http_request::RawResponse;
use crate::credentials::world_labs_api_creds::WorldLabsApiCreds;
use crate::error::filter_world_labs_http_error::filter_world_labs_http_error;
use crate::error::world_labs_error::WorldLabsError;
use crate::error::world_labs_generic_api_error::WorldLabsGenericApiError;
use log::{debug, error};
use std::time::Duration;
use wreq::Client;
const BASE_URL: &str = "https://api.worldlabs.ai/marble/v1/worlds";
pub struct DeleteWorldArgs<'a> {
pub creds: &'a WorldLabsApiCreds,
pub world_id: &'a str,
pub request_timeout: Option<Duration>,
}
pub struct DeleteWorldResponse {
pub world_id: String,
pub deleted: bool,
}
/// DELETE /marble/v1/worlds/{world_id}
///
/// Permanently delete a world and its assets. Only the owner can delete.
pub async fn delete_world(args: DeleteWorldArgs<'_>) -> Result<DeleteWorldResponse, WorldLabsError> {
let client = Client::new();
let url = format!("{}/{}", BASE_URL, args.world_id);
debug!("Requesting URL: {}", url);
let mut request_builder = client.delete(&url)
.header("WLT-Api-Key", args.creds.api_key());
if let Some(timeout) = args.request_timeout {
request_builder = request_builder.timeout(timeout);
}
let response = request_builder.send()
.await
.map_err(|err| {
error!("Error during delete_world request: {:?}", err);
WorldLabsGenericApiError::WreqError(err)
})?;
let status = response.status();
let response_body = response.text()
.await
.map_err(|err| {
error!("Error reading response body: {:?}", err);
WorldLabsGenericApiError::WreqError(err)
})?;
if !status.is_success() {
error!("delete_world returned error (code {}): {:?}", status.as_u16(), response_body);
}
filter_world_labs_http_error(status, Some(&response_body))?;
debug!("Response body (200): {}", response_body);
let raw: RawResponse = serde_json::from_str(&response_body)
.map_err(|err| WorldLabsGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.to_string()))?;
Ok(DeleteWorldResponse {
world_id: raw.world_id,
deleted: raw.deleted,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_utils::get_test_api_key::get_test_api_key;
use crate::test_utils::setup_test_logging::setup_test_logging;
use log::LevelFilter;
#[tokio::test]
#[ignore]
async fn test_delete_world() {
setup_test_logging(LevelFilter::Debug);
let creds = get_test_api_key().unwrap();
// Use a known world_id to delete
let world_id = "REPLACE_WITH_REAL_ID";
let response = delete_world(DeleteWorldArgs {
creds: &creds,
world_id,
request_timeout: None,
}).await.unwrap();
println!("World ID: {}", response.world_id);
println!("Deleted: {}", response.deleted);
assert_eq!(1, 2);
}
}
@@ -0,0 +1,7 @@
use serde::Deserialize;
#[derive(Deserialize)]
pub(crate) struct RawResponse {
pub world_id: String,
pub deleted: bool,
}
@@ -0,0 +1,2 @@
pub mod delete_world;
pub(crate) mod http_request;
@@ -0,0 +1,153 @@
use crate::api::api_types::operation_id::OperationId;
use crate::api::requests::generate_world::http_request::{RawRequest, RawResponse, WorldPrompt, Permission};
use crate::credentials::world_labs_api_creds::WorldLabsApiCreds;
use crate::error::filter_world_labs_http_error::filter_world_labs_http_error;
use crate::error::world_labs_error::WorldLabsError;
use crate::error::world_labs_generic_api_error::WorldLabsGenericApiError;
use log::{debug, error};
use std::time::Duration;
use wreq::Client;
const URL: &str = "https://api.worldlabs.ai/marble/v1/worlds:generate";
pub struct GenerateWorldArgs<'a> {
pub creds: &'a WorldLabsApiCreds,
pub world_prompt: WorldPrompt,
pub display_name: Option<String>,
pub model: Option<String>,
pub seed: Option<u32>,
pub tags: Option<Vec<String>>,
pub permission: Option<Permission>,
pub request_timeout: Option<Duration>,
}
pub struct GenerateWorldResponse {
pub operation_id: OperationId,
pub done: bool,
}
/// POST /marble/v1/worlds:generate
///
/// Start world generation. Returns an operation_id to poll with get_operation.
pub async fn generate_world(args: GenerateWorldArgs<'_>) -> Result<GenerateWorldResponse, WorldLabsError> {
let client = Client::new();
let payload = RawRequest {
world_prompt: args.world_prompt,
display_name: args.display_name,
model: args.model,
seed: args.seed,
tags: args.tags,
permission: args.permission,
};
debug!("Requesting URL: {}", URL);
let mut request_builder = client.post(URL)
.header("WLT-Api-Key", args.creds.api_key())
.header("Content-Type", "application/json")
.json(&payload);
if let Some(timeout) = args.request_timeout {
request_builder = request_builder.timeout(timeout);
}
let response = request_builder.send()
.await
.map_err(|err| {
error!("Error during generate_world request: {:?}", err);
WorldLabsGenericApiError::WreqError(err)
})?;
let status = response.status();
let response_body = response.text()
.await
.map_err(|err| {
error!("Error reading response body: {:?}", err);
WorldLabsGenericApiError::WreqError(err)
})?;
if !status.is_success() {
error!("generate_world returned error (code {}): {:?}", status.as_u16(), response_body);
}
filter_world_labs_http_error(status, Some(&response_body))?;
debug!("Response body (200): {}", response_body);
let raw: RawResponse = serde_json::from_str(&response_body)
.map_err(|err| WorldLabsGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.to_string()))?;
Ok(GenerateWorldResponse {
operation_id: OperationId(raw.operation_id),
done: raw.done.unwrap_or(false),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::requests::generate_world::http_request::{ContentReference, WorldPrompt};
use crate::test_utils::get_test_api_key::get_test_api_key;
use crate::test_utils::setup_test_logging::setup_test_logging;
use log::LevelFilter;
#[tokio::test]
#[ignore]
async fn test_generate_world_text_prompt() {
setup_test_logging(LevelFilter::Debug);
let creds = get_test_api_key().unwrap();
let response = generate_world(GenerateWorldArgs {
creds: &creds,
world_prompt: WorldPrompt::Text {
text_prompt: Some("A cozy cabin in the snowy mountains".to_string()),
disable_recaption: None,
},
display_name: Some("Test World".to_string()),
model: Some("Marble 0.1-mini".to_string()),
seed: None,
tags: None,
permission: None,
request_timeout: None,
}).await.unwrap();
println!("Operation ID: {}", response.operation_id.as_str());
println!("Done: {}", response.done);
assert_eq!(1, 2);
}
#[tokio::test]
#[ignore]
async fn test_generate_world_image_uri() {
setup_test_logging(LevelFilter::Debug);
let creds = get_test_api_key().unwrap();
let response = generate_world(GenerateWorldArgs {
creds: &creds,
world_prompt: WorldPrompt::Image {
image_prompt: ContentReference::Uri {
uri: "https://upload.wikimedia.org/wikipedia/commons/thumb/4/47/PNG_transparency_demonstration_1.png/280px-PNG_transparency_demonstration_1.png".to_string(),
},
text_prompt: None,
is_pano: None,
disable_recaption: None,
},
display_name: None,
model: Some("Marble 0.1-mini".to_string()),
seed: None,
tags: None,
permission: None,
request_timeout: None,
}).await.unwrap();
println!("Operation ID: {}", response.operation_id.as_str());
println!("Done: {}", response.done);
assert_eq!(1, 2);
}
}
@@ -0,0 +1,114 @@
use serde::{Deserialize, Serialize};
// ── Request types ──
#[derive(Serialize)]
pub(crate) struct RawRequest {
pub world_prompt: WorldPrompt,
#[serde(skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub seed: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tags: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub permission: Option<Permission>,
}
#[derive(Clone, Debug, Serialize)]
pub struct Permission {
#[serde(skip_serializing_if = "Option::is_none")]
pub public: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub allow_id_access: Option<bool>,
}
/// The world prompt — a tagged union describing the input.
///
/// Discriminated on the `type` field:
/// - `"text"` — text-only prompt
/// - `"image"` — single image (+ optional text)
/// - `"multi-image"` — multiple images with optional azimuth
/// - `"video"` — video input (+ optional text)
#[derive(Clone, Debug, Serialize)]
#[serde(tag = "type", rename_all = "kebab-case")]
pub enum WorldPrompt {
Text {
#[serde(skip_serializing_if = "Option::is_none")]
text_prompt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
disable_recaption: Option<bool>,
},
Image {
image_prompt: ContentReference,
#[serde(skip_serializing_if = "Option::is_none")]
text_prompt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
is_pano: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
disable_recaption: Option<bool>,
},
MultiImage {
multi_image_prompt: Vec<SphericallyLocatedContent>,
#[serde(skip_serializing_if = "Option::is_none")]
text_prompt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
reconstruct_images: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
disable_recaption: Option<bool>,
},
Video {
video_prompt: ContentReference,
#[serde(skip_serializing_if = "Option::is_none")]
text_prompt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
disable_recaption: Option<bool>,
},
}
/// A content reference — tagged union on `source`.
#[derive(Clone, Debug, Serialize)]
#[serde(tag = "source", rename_all = "snake_case")]
pub enum ContentReference {
Uri {
uri: String,
},
MediaAsset {
media_asset_id: String,
},
DataBase64 {
data_base64: String,
#[serde(skip_serializing_if = "Option::is_none")]
extension: Option<String>,
},
}
/// A spherically-located content item for multi-image prompts.
#[derive(Clone, Debug, Serialize)]
pub struct SphericallyLocatedContent {
pub content: ContentReference,
#[serde(skip_serializing_if = "Option::is_none")]
pub azimuth: Option<f64>,
}
// ── Response types ──
#[derive(Deserialize)]
#[allow(dead_code)]
pub(crate) struct RawResponse {
pub operation_id: String,
pub done: Option<bool>,
pub created_at: Option<String>,
pub updated_at: Option<String>,
pub expires_at: Option<String>,
pub error: Option<RawOperationError>,
}
#[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)]
pub(crate) struct RawOperationError {
pub code: Option<i32>,
pub message: Option<String>,
}
@@ -0,0 +1,2 @@
pub mod generate_world;
pub(crate) mod http_request;
@@ -0,0 +1,112 @@
use crate::api::requests::get_media_asset::http_request::RawResponse;
use crate::credentials::world_labs_api_creds::WorldLabsApiCreds;
use crate::error::filter_world_labs_http_error::filter_world_labs_http_error;
use crate::error::world_labs_error::WorldLabsError;
use crate::error::world_labs_generic_api_error::WorldLabsGenericApiError;
use log::{debug, error};
use std::time::Duration;
use wreq::Client;
const BASE_URL: &str = "https://api.worldlabs.ai/marble/v1/media-assets";
pub struct GetMediaAssetArgs<'a> {
pub creds: &'a WorldLabsApiCreds,
pub media_asset_id: &'a str,
pub request_timeout: Option<Duration>,
}
pub struct GetMediaAssetResponse {
pub media_asset_id: String,
pub file_name: String,
pub kind: String,
pub extension: Option<String>,
pub created_at: Option<String>,
pub updated_at: Option<String>,
}
/// GET /marble/v1/media-assets/{media_asset_id}
///
/// Get info about a previously uploaded media asset.
pub async fn get_media_asset(args: GetMediaAssetArgs<'_>) -> Result<GetMediaAssetResponse, WorldLabsError> {
let client = Client::new();
let url = format!("{}/{}", BASE_URL, args.media_asset_id);
debug!("Requesting URL: {}", url);
let mut request_builder = client.get(&url)
.header("WLT-Api-Key", args.creds.api_key());
if let Some(timeout) = args.request_timeout {
request_builder = request_builder.timeout(timeout);
}
let response = request_builder.send()
.await
.map_err(|err| {
error!("Error during get_media_asset request: {:?}", err);
WorldLabsGenericApiError::WreqError(err)
})?;
let status = response.status();
let response_body = response.text()
.await
.map_err(|err| {
error!("Error reading response body: {:?}", err);
WorldLabsGenericApiError::WreqError(err)
})?;
if !status.is_success() {
error!("get_media_asset returned error (code {}): {:?}", status.as_u16(), response_body);
}
filter_world_labs_http_error(status, Some(&response_body))?;
debug!("Response body (200): {}", response_body);
let raw: RawResponse = serde_json::from_str(&response_body)
.map_err(|err| WorldLabsGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.to_string()))?;
Ok(GetMediaAssetResponse {
media_asset_id: raw.media_asset_id,
file_name: raw.file_name,
kind: raw.kind,
extension: raw.extension,
created_at: raw.created_at,
updated_at: raw.updated_at,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_utils::get_test_api_key::get_test_api_key;
use crate::test_utils::setup_test_logging::setup_test_logging;
use log::LevelFilter;
#[tokio::test]
#[ignore]
async fn test_get_media_asset() {
setup_test_logging(LevelFilter::Debug);
let creds = get_test_api_key().unwrap();
// Use a known media_asset_id from a previous prepare_upload call
let media_asset_id = "REPLACE_WITH_REAL_ID";
let response = get_media_asset(GetMediaAssetArgs {
creds: &creds,
media_asset_id,
request_timeout: None,
}).await.unwrap();
println!("Media asset ID: {}", response.media_asset_id);
println!("File name: {}", response.file_name);
println!("Kind: {}", response.kind);
println!("Extension: {:?}", response.extension);
println!("Created at: {:?}", response.created_at);
assert_eq!(1, 2);
}
}
@@ -0,0 +1,14 @@
use serde::Deserialize;
use std::collections::HashMap;
#[derive(Deserialize)]
#[allow(dead_code)]
pub(crate) struct RawResponse {
pub media_asset_id: String,
pub file_name: String,
pub kind: String,
pub extension: Option<String>,
pub metadata: Option<HashMap<String, String>>,
pub created_at: Option<String>,
pub updated_at: Option<String>,
}
@@ -0,0 +1,2 @@
pub mod get_media_asset;
pub(crate) mod http_request;
@@ -0,0 +1,129 @@
use crate::api::api_types::operation_id::OperationId;
use crate::api::requests::get_operation::http_request::RawResponse;
use crate::credentials::world_labs_api_creds::WorldLabsApiCreds;
use crate::error::filter_world_labs_http_error::filter_world_labs_http_error;
use crate::error::world_labs_error::WorldLabsError;
use crate::error::world_labs_generic_api_error::WorldLabsGenericApiError;
use log::{debug, error};
use std::time::Duration;
use wreq::Client;
const BASE_URL: &str = "https://api.worldlabs.ai/marble/v1/operations";
pub struct GetOperationArgs<'a> {
pub creds: &'a WorldLabsApiCreds,
pub operation_id: &'a OperationId,
pub request_timeout: Option<Duration>,
}
pub struct GetOperationResponse {
pub operation_id: String,
pub done: bool,
pub created_at: Option<String>,
pub updated_at: Option<String>,
pub expires_at: Option<String>,
pub error: Option<OperationError>,
pub metadata: Option<serde_json::Value>,
/// When done=true and no error, contains the World object.
pub response: Option<serde_json::Value>,
}
#[derive(Debug, Clone)]
pub struct OperationError {
pub code: Option<i32>,
pub message: Option<String>,
}
/// GET /marble/v1/operations/{operation_id}
///
/// Poll the status of an async operation.
/// When `done` is true, `response` contains the World object.
pub async fn get_operation(args: GetOperationArgs<'_>) -> Result<GetOperationResponse, WorldLabsError> {
let client = Client::new();
let url = format!("{}/{}", BASE_URL, args.operation_id.as_str());
debug!("Requesting URL: {}", url);
let mut request_builder = client.get(&url)
.header("WLT-Api-Key", args.creds.api_key());
if let Some(timeout) = args.request_timeout {
request_builder = request_builder.timeout(timeout);
}
let response = request_builder.send()
.await
.map_err(|err| {
error!("Error during get_operation request: {:?}", err);
WorldLabsGenericApiError::WreqError(err)
})?;
let status = response.status();
let response_body = response.text()
.await
.map_err(|err| {
error!("Error reading response body: {:?}", err);
WorldLabsGenericApiError::WreqError(err)
})?;
if !status.is_success() {
error!("get_operation returned error (code {}): {:?}", status.as_u16(), response_body);
}
filter_world_labs_http_error(status, Some(&response_body))?;
debug!("Response body (200): {}", response_body);
let raw: RawResponse = serde_json::from_str(&response_body)
.map_err(|err| WorldLabsGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.to_string()))?;
Ok(GetOperationResponse {
operation_id: raw.operation_id,
done: raw.done.unwrap_or(false),
created_at: raw.created_at,
updated_at: raw.updated_at,
expires_at: raw.expires_at,
error: raw.error.map(|e| OperationError {
code: e.code,
message: e.message,
}),
metadata: raw.metadata,
response: raw.response,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_utils::get_test_api_key::get_test_api_key;
use crate::test_utils::setup_test_logging::setup_test_logging;
use log::LevelFilter;
#[tokio::test]
#[ignore]
async fn test_get_operation() {
setup_test_logging(LevelFilter::Debug);
let creds = get_test_api_key().unwrap();
// Use a known operation_id from a previous generate_world call
let operation_id = OperationId("1fab3bf1-05a1-4929-907e-c6df07c539e2".to_string());
let response = get_operation(GetOperationArgs {
creds: &creds,
operation_id: &operation_id,
request_timeout: None,
}).await.unwrap();
println!("Operation ID: {}", response.operation_id);
println!("Done: {}", response.done);
println!("Created at: {:?}", response.created_at);
println!("Error: {:?}", response.error);
println!("Metadata: {:?}", response.metadata);
println!("Response: {:?}", response.response);
assert_eq!(1, 2);
}
}
@@ -0,0 +1,19 @@
use serde::Deserialize;
#[derive(Deserialize)]
pub(crate) struct RawResponse {
pub operation_id: String,
pub done: Option<bool>,
pub created_at: Option<String>,
pub updated_at: Option<String>,
pub expires_at: Option<String>,
pub error: Option<RawOperationError>,
pub metadata: Option<serde_json::Value>,
pub response: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Deserialize)]
pub(crate) struct RawOperationError {
pub code: Option<i32>,
pub message: Option<String>,
}
@@ -0,0 +1,2 @@
pub mod get_operation;
pub(crate) mod http_request;
@@ -0,0 +1,162 @@
use crate::api::requests::get_world::http_request::RawResponse;
use crate::credentials::world_labs_api_creds::WorldLabsApiCreds;
use crate::error::filter_world_labs_http_error::filter_world_labs_http_error;
use crate::error::world_labs_error::WorldLabsError;
use crate::error::world_labs_generic_api_error::WorldLabsGenericApiError;
use log::{debug, error};
use std::time::Duration;
use wreq::Client;
const BASE_URL: &str = "https://api.worldlabs.ai/marble/v1/worlds";
pub struct GetWorldArgs<'a> {
pub creds: &'a WorldLabsApiCreds,
pub world_id: &'a str,
pub request_timeout: Option<Duration>,
}
pub struct GetWorldResponse {
pub world_id: String,
pub display_name: Option<String>,
pub world_marble_url: Option<String>,
pub created_at: Option<String>,
pub updated_at: Option<String>,
pub model: Option<String>,
/// e.g. "SUCCEEDED", "PENDING", "FAILED", "RUNNING"
pub status: Option<String>,
pub tags: Option<Vec<String>>,
pub is_public: Option<bool>,
pub assets: Option<WorldAssets>,
}
pub struct WorldAssets {
pub caption: Option<String>,
pub thumbnail_url: Option<String>,
pub pano_url: Option<String>,
pub collider_mesh_url: Option<String>,
pub spz_url_100k: Option<String>,
pub spz_url_500k: Option<String>,
pub spz_url_full_res: Option<String>,
}
/// GET /marble/v1/worlds/{world_id}
///
/// Retrieve a world by its ID.
pub async fn get_world(args: GetWorldArgs<'_>) -> Result<GetWorldResponse, WorldLabsError> {
let client = Client::new();
let url = format!("{}/{}", BASE_URL, args.world_id);
debug!("Requesting URL: {}", url);
let mut request_builder = client.get(&url)
.header("WLT-Api-Key", args.creds.api_key());
if let Some(timeout) = args.request_timeout {
request_builder = request_builder.timeout(timeout);
}
let response = request_builder.send()
.await
.map_err(|err| {
error!("Error during get_world request: {:?}", err);
WorldLabsGenericApiError::WreqError(err)
})?;
let status = response.status();
let response_body = response.text()
.await
.map_err(|err| {
error!("Error reading response body: {:?}", err);
WorldLabsGenericApiError::WreqError(err)
})?;
if !status.is_success() {
error!("get_world returned error (code {}): {:?}", status.as_u16(), response_body);
}
filter_world_labs_http_error(status, Some(&response_body))?;
debug!("Response body (200): {}", response_body);
let raw: RawResponse = serde_json::from_str(&response_body)
.map_err(|err| WorldLabsGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.to_string()))?;
let assets = raw.assets.map(|a| {
let spz_urls = a.splats.and_then(|s| s.spz_urls);
WorldAssets {
caption: a.caption,
thumbnail_url: a.thumbnail_url,
pano_url: a.imagery.and_then(|i| i.pano_url),
collider_mesh_url: a.mesh.and_then(|m| m.collider_mesh_url),
spz_url_100k: spz_urls.as_ref().and_then(|u| u.low.clone()),
spz_url_500k: spz_urls.as_ref().and_then(|u| u.medium.clone()),
spz_url_full_res: spz_urls.as_ref().and_then(|u| u.full_res.clone()),
}
});
Ok(GetWorldResponse {
world_id: raw.world_id,
display_name: raw.display_name,
world_marble_url: raw.world_marble_url,
created_at: raw.created_at,
updated_at: raw.updated_at,
model: raw.model,
status: raw.status,
tags: raw.tags,
is_public: raw.permission.and_then(|p| p.public),
assets,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_utils::get_test_api_key::get_test_api_key;
use crate::test_utils::setup_test_logging::setup_test_logging;
use log::LevelFilter;
#[tokio::test]
#[ignore]
async fn test_get_world() {
setup_test_logging(LevelFilter::Debug);
let creds = get_test_api_key().unwrap();
// Use a known world_id from a previous generation
let world_id = "0048d009-1c7a-4e13-9881-07e9b6ff32e1";
//let world_id = "c8a89fb1-f8b4-44c6-ae50-525f7205c65f";
let response = get_world(GetWorldArgs {
creds: &creds,
world_id,
request_timeout: None,
}).await.unwrap();
println!("World ID: {}", response.world_id);
println!("Display name: {:?}", response.display_name);
println!("Marble URL: {:?}", response.world_marble_url);
println!("Created at: {:?}", response.created_at);
println!("Updated at: {:?}", response.updated_at);
println!("Model: {:?}", response.model);
println!("Status: {:?}", response.status);
println!("Tags: {:?}", response.tags);
println!("Is public: {:?}", response.is_public);
match &response.assets {
None => println!("Assets: None"),
Some(assets) => {
println!("Assets:");
println!(" Caption: {:?}", assets.caption);
println!(" Thumbnail URL: {:?}", assets.thumbnail_url);
println!(" Pano URL: {:?}", assets.pano_url);
println!(" Collider mesh URL: {:?}", assets.collider_mesh_url);
println!(" SPZ 100k: {:?}", assets.spz_url_100k);
println!(" SPZ 500k: {:?}", assets.spz_url_500k);
println!(" SPZ full res: {:?}", assets.spz_url_full_res);
}
}
assert_eq!(1, 2);
}
}
@@ -0,0 +1,64 @@
use serde::Deserialize;
#[derive(Debug, Deserialize)]
pub(crate) struct RawResponse {
pub world_id: String,
pub display_name: Option<String>,
pub world_marble_url: Option<String>,
pub created_at: Option<String>,
pub updated_at: Option<String>,
pub model: Option<String>,
pub status: Option<String>,
pub tags: Option<Vec<String>>,
pub permission: Option<RawPermission>,
pub assets: Option<RawWorldAssets>,
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub(crate) struct RawPermission {
pub public: Option<bool>,
pub allow_id_access: Option<bool>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct RawWorldAssets {
pub caption: Option<String>,
pub thumbnail_url: Option<String>,
pub imagery: Option<RawImagery>,
pub mesh: Option<RawMesh>,
pub splats: Option<RawSplats>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct RawImagery {
pub pano_url: Option<String>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct RawMesh {
pub collider_mesh_url: Option<String>,
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub(crate) struct RawSplats {
pub spz_urls: Option<RawSpzUrls>,
pub semantics_metadata: Option<RawSemanticsMetadata>,
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub(crate) struct RawSemanticsMetadata {
pub metric_scale_factor: Option<f64>,
pub ground_plane_offset: Option<f64>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct RawSpzUrls {
#[serde(rename = "100k")]
pub low: Option<String>,
#[serde(rename = "500k")]
pub medium: Option<String>,
pub full_res: Option<String>,
}
@@ -0,0 +1,2 @@
pub mod get_world;
pub(crate) mod http_request;
@@ -0,0 +1,41 @@
use serde::{Deserialize, Serialize};
#[derive(Serialize)]
pub(crate) struct RawRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub page_size: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub page_token: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tags: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub is_public: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub created_after: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub created_before: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sort_by: Option<String>,
}
#[derive(Deserialize)]
pub(crate) struct RawResponse {
pub worlds: Vec<RawWorldSummary>,
pub next_page_token: Option<String>,
}
#[derive(Deserialize)]
pub(crate) struct RawWorldSummary {
pub world_id: String,
pub display_name: Option<String>,
pub world_marble_url: Option<String>,
pub created_at: Option<String>,
pub updated_at: Option<String>,
pub model: Option<String>,
pub status: Option<String>,
pub tags: Option<Vec<String>>,
}
@@ -0,0 +1,169 @@
use crate::api::requests::list_worlds::http_request::{RawRequest, RawResponse};
use crate::credentials::world_labs_api_creds::WorldLabsApiCreds;
use crate::error::filter_world_labs_http_error::filter_world_labs_http_error;
use crate::error::world_labs_error::WorldLabsError;
use crate::error::world_labs_generic_api_error::WorldLabsGenericApiError;
use log::{debug, error};
use std::time::Duration;
use wreq::Client;
const URL: &str = "https://api.worldlabs.ai/marble/v1/worlds:list";
pub struct ListWorldsArgs<'a> {
pub creds: &'a WorldLabsApiCreds,
/// 1-100, default 20
pub page_size: Option<u32>,
/// Opaque cursor from a previous response
pub page_token: Option<String>,
/// "SUCCEEDED", "PENDING", "FAILED", "RUNNING"
pub status: Option<String>,
/// "Marble 0.1-mini" or "Marble 0.1-plus"
pub model: Option<String>,
pub tags: Option<Vec<String>>,
pub is_public: Option<bool>,
/// ISO 8601 datetime
pub created_after: Option<String>,
/// ISO 8601 datetime
pub created_before: Option<String>,
/// "created_at" or "updated_at"
pub sort_by: Option<String>,
pub request_timeout: Option<Duration>,
}
pub struct ListWorldsResponse {
pub worlds: Vec<WorldSummary>,
pub next_page_token: Option<String>,
}
pub struct WorldSummary {
pub world_id: String,
pub display_name: Option<String>,
pub world_marble_url: Option<String>,
pub created_at: Option<String>,
pub updated_at: Option<String>,
pub model: Option<String>,
/// e.g. "SUCCEEDED", "PENDING", "FAILED", "RUNNING"
pub status: Option<String>,
pub tags: Option<Vec<String>>,
}
/// POST /marble/v1/worlds:list
///
/// List worlds with optional filtering and pagination.
pub async fn list_worlds(args: ListWorldsArgs<'_>) -> Result<ListWorldsResponse, WorldLabsError> {
let client = Client::new();
let payload = RawRequest {
page_size: args.page_size,
page_token: args.page_token,
status: args.status,
model: args.model,
tags: args.tags,
is_public: args.is_public,
created_after: args.created_after,
created_before: args.created_before,
sort_by: args.sort_by,
};
debug!("Requesting URL: {}", URL);
let mut request_builder = client.post(URL)
.header("WLT-Api-Key", args.creds.api_key())
.header("Content-Type", "application/json")
.json(&payload);
if let Some(timeout) = args.request_timeout {
request_builder = request_builder.timeout(timeout);
}
let response = request_builder.send()
.await
.map_err(|err| {
error!("Error during list_worlds request: {:?}", err);
WorldLabsGenericApiError::WreqError(err)
})?;
let status = response.status();
let response_body = response.text()
.await
.map_err(|err| {
error!("Error reading response body: {:?}", err);
WorldLabsGenericApiError::WreqError(err)
})?;
if !status.is_success() {
error!("list_worlds returned error (code {}): {:?}", status.as_u16(), response_body);
}
filter_world_labs_http_error(status, Some(&response_body))?;
debug!("Response body (200): {}", response_body);
let raw: RawResponse = serde_json::from_str(&response_body)
.map_err(|err| WorldLabsGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.to_string()))?;
let worlds = raw.worlds.into_iter().map(|w| WorldSummary {
world_id: w.world_id,
display_name: w.display_name,
world_marble_url: w.world_marble_url,
created_at: w.created_at,
updated_at: w.updated_at,
model: w.model,
status: w.status,
tags: w.tags,
}).collect();
Ok(ListWorldsResponse {
worlds,
next_page_token: raw.next_page_token,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_utils::get_test_api_key::get_test_api_key;
use crate::test_utils::setup_test_logging::setup_test_logging;
use log::LevelFilter;
#[tokio::test]
#[ignore]
async fn test_list_worlds() {
setup_test_logging(LevelFilter::Debug);
let creds = get_test_api_key().unwrap();
let response = list_worlds(ListWorldsArgs {
creds: &creds,
page_size: Some(5),
page_token: None,
status: None,
model: None,
tags: None,
is_public: None,
created_after: None,
created_before: None,
sort_by: None,
request_timeout: None,
}).await.unwrap();
println!("Worlds count: {}", response.worlds.len());
println!("Next page token: {:?}", response.next_page_token);
println!();
for (i, world) in response.worlds.iter().enumerate() {
println!("--- World {} ---", i + 1);
println!(" World ID: {}", world.world_id);
println!(" Display name: {:?}", world.display_name);
println!(" Marble URL: {:?}", world.world_marble_url);
println!(" Created at: {:?}", world.created_at);
println!(" Updated at: {:?}", world.updated_at);
println!(" Model: {:?}", world.model);
println!(" Status: {:?}", world.status);
println!(" Tags: {:?}", world.tags);
}
assert_eq!(1, 2);
}
}
@@ -0,0 +1,2 @@
pub mod list_worlds;
pub(crate) mod http_request;
@@ -0,0 +1,8 @@
pub mod delete_world;
pub mod generate_world;
pub mod get_media_asset;
pub mod get_operation;
pub mod get_world;
pub mod list_worlds;
pub mod prepare_upload;
pub mod upload_to_signed_url;
@@ -0,0 +1,34 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Serialize)]
pub(crate) struct RawRequest {
pub file_name: String,
pub kind: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub extension: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<HashMap<String, String>>,
}
#[derive(Deserialize)]
pub(crate) struct RawResponse {
pub media_asset: RawMediaAsset,
pub upload_info: RawUploadInfo,
}
#[derive(Deserialize)]
#[allow(dead_code)]
pub(crate) struct RawMediaAsset {
pub media_asset_id: String,
pub file_name: String,
pub kind: String,
pub extension: Option<String>,
}
#[derive(Deserialize)]
pub(crate) struct RawUploadInfo {
pub upload_url: String,
pub upload_method: String,
pub required_headers: Option<HashMap<String, String>>,
}
@@ -0,0 +1,2 @@
pub mod prepare_upload;
pub(crate) mod http_request;
@@ -0,0 +1,151 @@
use crate::api::api_types::media_asset_id::MediaAssetId;
use crate::api::requests::prepare_upload::http_request::{RawRequest, RawResponse};
use crate::credentials::world_labs_api_creds::WorldLabsApiCreds;
use crate::error::filter_world_labs_http_error::filter_world_labs_http_error;
use crate::error::world_labs_error::WorldLabsError;
use crate::error::world_labs_generic_api_error::WorldLabsGenericApiError;
use log::{debug, error};
use std::collections::HashMap;
use std::time::Duration;
use wreq::Client;
const URL: &str = "https://api.worldlabs.ai/marble/v1/media-assets:prepare_upload";
/// The kind of media asset to upload.
#[derive(Clone, Copy, Debug)]
pub enum MediaAssetKind {
Image,
Video,
}
impl MediaAssetKind {
fn as_str(&self) -> &'static str {
match self {
Self::Image => "image",
Self::Video => "video",
}
}
}
pub struct PrepareUploadArgs<'a> {
pub creds: &'a WorldLabsApiCreds,
pub file_name: &'a str,
pub kind: MediaAssetKind,
pub request_timeout: Option<Duration>,
}
pub struct PrepareUploadResponse {
pub media_asset_id: MediaAssetId,
pub upload_url: String,
pub upload_method: String,
pub required_headers: HashMap<String, String>,
}
/// Parse the file extension from a file name, defaulting to "png".
fn extension_from_file_name(file_name: &str) -> String {
file_name
.rsplit_once('.')
.map(|(_, ext)| ext.to_lowercase())
.unwrap_or_else(|| "png".to_string())
}
/// POST /marble/v1/media-assets:prepare_upload
///
/// Get a signed upload URL and media_asset_id for uploading a file.
/// The extension is parsed from `file_name`; defaults to "png" if none found.
pub async fn prepare_upload(args: PrepareUploadArgs<'_>) -> Result<PrepareUploadResponse, WorldLabsError> {
let client = Client::new();
let extension = extension_from_file_name(args.file_name);
let payload = RawRequest {
file_name: args.file_name.to_string(),
kind: args.kind.as_str().to_string(),
extension: Some(extension),
metadata: None,
};
debug!("Requesting URL: {}", URL);
let mut request_builder = client.post(URL)
.header("WLT-Api-Key", args.creds.api_key())
.header("Content-Type", "application/json")
.json(&payload);
if let Some(timeout) = args.request_timeout {
request_builder = request_builder.timeout(timeout);
}
let response = request_builder.send()
.await
.map_err(|err| {
error!("Error during prepare_upload request: {:?}", err);
WorldLabsGenericApiError::WreqError(err)
})?;
let status = response.status();
let response_body = response.text()
.await
.map_err(|err| {
error!("Error reading response body: {:?}", err);
WorldLabsGenericApiError::WreqError(err)
})?;
if !status.is_success() {
error!("prepare_upload returned error (code {}): {:?}", status.as_u16(), response_body);
}
filter_world_labs_http_error(status, Some(&response_body))?;
debug!("Response body (200): {}", response_body);
let raw: RawResponse = serde_json::from_str(&response_body)
.map_err(|err| WorldLabsGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.to_string()))?;
Ok(PrepareUploadResponse {
media_asset_id: MediaAssetId(raw.media_asset.media_asset_id),
upload_url: raw.upload_info.upload_url,
upload_method: raw.upload_info.upload_method,
required_headers: raw.upload_info.required_headers.unwrap_or_default(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_utils::get_test_api_key::get_test_api_key;
use crate::test_utils::setup_test_logging::setup_test_logging;
use log::LevelFilter;
#[tokio::test]
#[ignore]
async fn test_prepare_upload() {
setup_test_logging(LevelFilter::Debug);
let creds = get_test_api_key().unwrap();
let response = prepare_upload(PrepareUploadArgs {
creds: &creds,
file_name: "test_image.jpg",
kind: MediaAssetKind::Image,
request_timeout: None,
}).await.unwrap();
println!("Media asset ID: {}", response.media_asset_id.as_str());
println!("Upload URL: {}", response.upload_url);
println!("Upload method: {}", response.upload_method);
println!("Required headers: {:?}", response.required_headers);
assert_eq!(1, 2);
}
#[test]
fn test_extension_parsing() {
assert_eq!(extension_from_file_name("photo.jpg"), "jpg");
assert_eq!(extension_from_file_name("image.PNG"), "png");
assert_eq!(extension_from_file_name("video.mp4"), "mp4");
assert_eq!(extension_from_file_name("no_extension"), "png");
assert_eq!(extension_from_file_name("multi.dots.webp"), "webp");
}
}
@@ -0,0 +1 @@
pub mod upload_to_signed_url;
@@ -0,0 +1,66 @@
use crate::error::world_labs_error::WorldLabsError;
use crate::error::world_labs_generic_api_error::WorldLabsGenericApiError;
use log::{debug, error};
use std::collections::HashMap;
use std::time::Duration;
use wreq::Client;
pub struct UploadToSignedUrlArgs<'a> {
pub upload_url: &'a str,
pub file_bytes: Vec<u8>,
pub required_headers: &'a HashMap<String, String>,
pub content_type: &'a str,
pub request_timeout: Option<Duration>,
}
/// PUT {upload_url}
///
/// Upload raw file bytes to the signed URL returned by prepare_upload.
/// Include any required_headers from the prepare_upload response.
pub async fn upload_to_signed_url(args: UploadToSignedUrlArgs<'_>) -> Result<(), WorldLabsError> {
let client = Client::new();
debug!("Uploading to signed URL: {}", args.upload_url);
let mut request_builder = client.put(args.upload_url)
.header("Content-Type", args.content_type)
.body(args.file_bytes);
for (key, value) in args.required_headers {
request_builder = request_builder.header(key.as_str(), value.as_str());
}
if let Some(timeout) = args.request_timeout {
request_builder = request_builder.timeout(timeout);
}
let response = request_builder.send()
.await
.map_err(|err| {
error!("Error during signed URL upload: {:?}", err);
WorldLabsGenericApiError::WreqError(err)
})?;
let status = response.status();
if !status.is_success() {
let body = response.text()
.await
.unwrap_or_default();
error!("Signed URL upload failed (code {}): {:?}", status.as_u16(), body);
return Err(WorldLabsGenericApiError::GoogleUploadFailed {
status_code: status,
body,
}.into());
}
debug!("Upload to signed URL succeeded");
Ok(())
}
#[cfg(test)]
mod tests {
// Upload tests are covered by the end-to-end recipe test,
// since this endpoint requires a fresh signed URL from prepare_upload.
}
@@ -0,0 +1 @@
pub mod world_labs_api_creds;
@@ -0,0 +1,16 @@
/// Credentials for the official World Labs API.
/// Uses a simple API key for authentication via the `WLT-Api-Key` header.
#[derive(Clone)]
pub struct WorldLabsApiCreds {
api_key: String,
}
impl WorldLabsApiCreds {
pub fn new(api_key: String) -> Self {
Self { api_key }
}
pub fn api_key(&self) -> &str {
&self.api_key
}
}
@@ -0,0 +1,30 @@
use crate::error::world_labs_error::WorldLabsError;
use crate::error::world_labs_generic_api_error::WorldLabsGenericApiError;
use crate::error::world_labs_specific_api_error::WorldLabsSpecificApiError;
use wreq::StatusCode;
/// Detect error HTTP responses and coerce the response as a Rust Error.
pub fn filter_world_labs_http_error(status_code: StatusCode, maybe_body: Option<&str>) -> Result<(), WorldLabsError> {
if status_code.is_success() {
return Ok(());
}
match status_code {
StatusCode::PAYMENT_REQUIRED => {
return Err(WorldLabsSpecificApiError::InsufficientCredits.into());
},
_ => {},
}
if let Some(body) = maybe_body {
return Err(WorldLabsGenericApiError::UncategorizedBadResponseWithStatusAndBody {
status_code,
body: body.to_string(),
}.into());
}
Err(WorldLabsGenericApiError::UncategorizedBadResponseWithStatusAndBody {
status_code,
body: String::new(),
}.into())
}
@@ -0,0 +1,5 @@
pub mod world_labs_client_error;
pub mod world_labs_error;
pub mod world_labs_generic_api_error;
pub mod world_labs_specific_api_error;
pub (crate) mod filter_world_labs_http_error;
@@ -0,0 +1,23 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
#[derive(Debug)]
pub enum WorldLabsClientError {
/// An error was encountered in building the Wreq client
WreqClientError(wreq::Error),
/// Can't read a local file for uploading.
CannotReadLocalFileForUpload(std::io::Error),
}
impl Error for WorldLabsClientError {}
impl Display for WorldLabsClientError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::WreqClientError(err) => write!(f, "Wreq client error (during client creation): {}", err),
Self::CannotReadLocalFileForUpload(err) => write!(f, "Cannot read local file for upload: {}", err),
}
}
}
@@ -0,0 +1,41 @@
use crate::error::world_labs_client_error::WorldLabsClientError;
use crate::error::world_labs_generic_api_error::WorldLabsGenericApiError;
use crate::error::world_labs_specific_api_error::WorldLabsSpecificApiError;
use std::error::Error;
#[derive(Debug)]
pub enum WorldLabsError {
Client(WorldLabsClientError),
ApiSpecific(WorldLabsSpecificApiError),
ApiGeneric(WorldLabsGenericApiError),
}
impl Error for WorldLabsError {}
impl std::fmt::Display for WorldLabsError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Client(e) => write!(f, "WorldLabsClientError: {:?}", e),
Self::ApiSpecific(e) => write!(f, "WorldLabsSpecificApiError: {:?}", e),
Self::ApiGeneric(e) => write!(f, "WorldLabsGenericApiError: {:?}", e),
}
}
}
impl From<WorldLabsClientError> for WorldLabsError {
fn from(error: WorldLabsClientError) -> Self {
Self::Client(error)
}
}
impl From<WorldLabsSpecificApiError> for WorldLabsError {
fn from(error: WorldLabsSpecificApiError) -> Self {
Self::ApiSpecific(error)
}
}
impl From<WorldLabsGenericApiError> for WorldLabsError {
fn from(error: WorldLabsGenericApiError) -> Self {
Self::ApiGeneric(error)
}
}
@@ -0,0 +1,38 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
use wreq::StatusCode;
#[derive(Debug)]
pub enum WorldLabsGenericApiError {
/// Unknown error occurred when uploading to signed URL
GoogleUploadFailed {
status_code: StatusCode,
body: String,
},
/// serde_json::Error, likely from JSON deserialization schema mismatch.
/// Includes the original body.
SerdeResponseParseErrorWithBody(serde_json::Error, String),
/// An uncategorized bad HTTP response.
UncategorizedBadResponseWithStatusAndBody {
status_code: StatusCode,
body: String,
},
/// An uncaught error from the API client.
WreqError(wreq::Error),
}
impl Error for WorldLabsGenericApiError {}
impl Display for WorldLabsGenericApiError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::GoogleUploadFailed { status_code, body } => write!(f, "Signed URL upload failed with HTTP {}: {:?}", status_code, body),
Self::SerdeResponseParseErrorWithBody(err, body) => write!(f, "Failed to parse response body: {:?}. Body: {}", err, body),
Self::UncategorizedBadResponseWithStatusAndBody { status_code, body } => write!(f, "Uncategorized bad response: status code {}, body: {}", status_code, body),
Self::WreqError(err) => write!(f, "Wreq client error: {}", err),
}
}
}
@@ -0,0 +1,18 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
#[derive(Debug)]
pub enum WorldLabsSpecificApiError {
/// There aren't sufficient funds for generation.
InsufficientCredits,
}
impl Error for WorldLabsSpecificApiError {}
impl Display for WorldLabsSpecificApiError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::InsufficientCredits => write!(f, "Insufficient credits"),
}
}
}
@@ -0,0 +1,8 @@
#[cfg(test)]
pub (crate) mod test_utils;
pub mod api;
pub mod credentials;
pub mod error;
pub mod recipes;
@@ -0,0 +1 @@
pub mod upload_image_and_create_world_with_retry;
@@ -0,0 +1,165 @@
use crate::api::api_types::media_asset_id::MediaAssetId;
use crate::api::api_types::operation_id::OperationId;
use crate::api::requests::generate_world::generate_world::{generate_world, GenerateWorldArgs};
use crate::api::requests::generate_world::http_request::{ContentReference, WorldPrompt};
use crate::api::requests::prepare_upload::prepare_upload::{prepare_upload, PrepareUploadArgs, MediaAssetKind};
use crate::api::requests::upload_to_signed_url::upload_to_signed_url::{upload_to_signed_url, UploadToSignedUrlArgs};
use crate::credentials::world_labs_api_creds::WorldLabsApiCreds;
use crate::error::world_labs_client_error::WorldLabsClientError;
use crate::error::world_labs_error::WorldLabsError;
use filesys::file_read_bytes::file_read_bytes;
use log::{error, info};
use std::path::PathBuf;
use std::time::Duration;
pub struct UploadImageAndCreateWorldArgs<'a> {
pub creds: &'a WorldLabsApiCreds,
pub file: FileBytesOrPath,
pub text_prompt: Option<String>,
pub model: Option<String>,
pub individual_request_timeout: Option<Duration>,
}
pub enum FileBytesOrPath {
Bytes(Vec<u8>),
Path(PathBuf),
}
pub struct UploadImageAndCreateWorldResponse {
pub operation_id: OperationId,
pub media_asset_id: MediaAssetId,
}
/// Official World Labs API: Upload an image and generate a world.
///
/// Flow (3 steps):
/// 1. prepare_upload — get media_asset_id + signed upload URL
/// 2. upload_to_signed_url — PUT file bytes to signed URL
/// 3. generate_world — start world generation with media_asset source
pub async fn upload_image_and_create_world(args: UploadImageAndCreateWorldArgs<'_>) -> Result<UploadImageAndCreateWorldResponse, WorldLabsError> {
info!("Checking file input...");
let file_bytes = match args.file {
FileBytesOrPath::Bytes(bytes) => {
info!("File bytes provided ({} bytes)", bytes.len());
bytes
}
FileBytesOrPath::Path(path) => {
info!("File path provided: {:?}", path);
match file_read_bytes(&path) {
Ok(bytes) => bytes,
Err(err) => {
error!("Error reading file bytes from path: {:?} - error: {:?}", path, err);
return Err(WorldLabsClientError::CannotReadLocalFileForUpload(err).into());
}
}
}
};
// Step 1: Prepare upload
info!("Step 1 of 3: prepare_upload ...");
let prepare_response = prepare_upload(PrepareUploadArgs {
creds: args.creds,
file_name: "upload.jpg",
kind: MediaAssetKind::Image,
request_timeout: args.individual_request_timeout,
}).await?;
let media_asset_id = prepare_response.media_asset_id;
let upload_url = prepare_response.upload_url;
let required_headers = prepare_response.required_headers;
info!("Media asset ID: {}", media_asset_id.as_str());
info!("Upload URL: {}", upload_url);
// Step 2: Upload to signed URL
info!("Step 2 of 3: upload_to_signed_url ...");
upload_to_signed_url(UploadToSignedUrlArgs {
upload_url: &upload_url,
file_bytes,
required_headers: &required_headers,
content_type: "image/jpeg",
request_timeout: args.individual_request_timeout,
}).await?;
info!("Upload complete.");
// Step 3: Generate world
info!("Step 3 of 3: generate_world ...");
let world_prompt = WorldPrompt::Image {
image_prompt: ContentReference::MediaAsset {
media_asset_id: media_asset_id.as_str().to_string(),
},
text_prompt: args.text_prompt,
is_pano: None,
disable_recaption: None,
};
let generate_response = generate_world(GenerateWorldArgs {
creds: args.creds,
world_prompt,
display_name: None,
model: args.model,
seed: None,
tags: None,
permission: None,
request_timeout: args.individual_request_timeout,
}).await?;
info!("Operation ID: {}", generate_response.operation_id.as_str());
info!("Done: {}", generate_response.done);
Ok(UploadImageAndCreateWorldResponse {
operation_id: generate_response.operation_id,
media_asset_id,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_utils::get_test_api_key::get_test_api_key;
use crate::test_utils::setup_test_logging::setup_test_logging;
use filesys::file_read_bytes::file_read_bytes;
use log::LevelFilter;
#[tokio::test]
#[ignore]
async fn test_upload_and_generate() {
setup_test_logging(LevelFilter::Debug);
let creds = get_test_api_key().unwrap();
//let file_path = "/Users/bt/Pictures/Midjourney/moon_door.png";
let file_path = "/Users/bt/Pictures/Midjourney/meadow_mirror.png";
let file_bytes = file_read_bytes(file_path).unwrap();
println!("File bytes len: {}", file_bytes.len());
let results = upload_image_and_create_world(UploadImageAndCreateWorldArgs {
creds: &creds,
individual_request_timeout: None,
file: FileBytesOrPath::Bytes(file_bytes),
text_prompt: None,
model: None,
}).await.unwrap();
println!("Operation ID: {}", results.operation_id.as_str());
println!("Media Asset ID: {}", results.media_asset_id.as_str());
/*
00:55:36.237287 [DEBUG] - Requesting URL: https://api.worldlabs.ai/marble/v1/worlds:generate
00:55:45.047221 [DEBUG] - Response body (200): {"operation_id":"1fab3bf1-05a1-4929-907e-c6df07c539e2","created_at":"2026-03-09T04:55:37Z","updated_at":"2026-03-09T04:55:37Z","expires_at":"2026-03-09T05:55:37Z","done":false,"error":null,"metadata":null,"response":null}
00:55:45.047756 [INFO] - Operation ID: 1fab3bf1-05a1-4929-907e-c6df07c539e2
00:55:45.047839 [INFO] - Done: false
Operation ID: 1fab3bf1-05a1-4929-907e-c6df07c539e2
Media Asset ID: bc57b2bc-542e-416d-8089-924294152f0d
*/
assert_eq!(1, 2);
}
}
@@ -0,0 +1,10 @@
use crate::credentials::world_labs_api_creds::WorldLabsApiCreds;
use anyhow::Result as AnyhowResult;
const API_KEY_FILE: &str = "/Users/bt/Artcraft/credentials/world_labs_api_key.txt";
#[cfg(test)]
pub(crate) fn get_test_api_key() -> AnyhowResult<WorldLabsApiCreds> {
let api_key = std::fs::read_to_string(API_KEY_FILE)?.trim().to_string();
Ok(WorldLabsApiCreds::new(api_key))
}
@@ -0,0 +1,2 @@
pub mod get_test_api_key;
pub mod setup_test_logging;
@@ -0,0 +1,52 @@
use chrono::Local;
use env_logger::Builder;
use log::{debug, error, info, trace, warn, LevelFilter};
use std::io::Write;
const RUST_LOG : &str = "RUST_LOG";
pub fn setup_test_logging(level: LevelFilter) {
let level_str = get_level_str(level);
println!("Previous log level: {:?}", env::var(RUST_LOG));
let is_unsafe = env::set_var(RUST_LOG, level_str);
if is_unsafe.is_none() {
println!("Unsafely setting Rust Log Level...");
unsafe { std::env::set_var(RUST_LOG, level_str) }
}
println!("Log level: {:?}", env::var(RUST_LOG));
Builder::new()
.is_test(true)
.format(|buf, record| {
writeln!(buf,
"{} [{}] - {}",
Local::now().format("%H:%M:%S%.6f"),
record.level(),
record.args()
)
})
.filter(None, level)
.filter_level(level)
.init();
trace!("Test trace log");
debug!("Test debug log");
info!("Test info log");
warn!("Test warn log");
error!("Test error log");
}
fn get_level_str(level: LevelFilter) -> &'static str {
match level {
LevelFilter::Error => "error",
LevelFilter::Warn => "warn",
LevelFilter::Info => "info",
LevelFilter::Debug => "debug",
LevelFilter::Trace => "trace",
LevelFilter::Off => "off",
}
}