remove worldlabs

This commit is contained in:
Hanashi 2026-09-07 09:21:57 -04:00
parent 4693f661ec
commit 6f249e1a3b
197 changed files with 60 additions and 10050 deletions

60
Cargo.lock generated
View File

@ -272,7 +272,6 @@ dependencies = [
"uuid_utils",
"web_base64",
"workspace-build-acceleration",
"worldlabs_consumer_client",
]
[[package]]
@ -5682,7 +5681,6 @@ dependencies = [
"url_utils",
"uuid 1.22.0",
"workspace-build-acceleration",
"worldlabs_api_client",
"wreq",
]
@ -9239,64 +9237,6 @@ dependencies = [
name = "workspace-build-acceleration"
version = "0.1.0"
[[package]]
name = "worldlabs_api_client"
version = "0.0.1"
dependencies = [
"anyhow",
"chrono",
"env",
"env_logger",
"errors",
"filesys",
"log",
"serde",
"serde_json",
"strum",
"test_data",
"tokio",
"uuid 1.22.0",
"workspace-build-acceleration",
"wreq",
]
[[package]]
name = "worldlabs_consumer_client"
version = "0.0.1"
dependencies = [
"anyhow",
"base64 0.22.1",
"bstr",
"byteorder",
"chrono",
"cloudflare_errors",
"cloudflare_mitigation",
"env",
"env_logger",
"errors",
"filesys",
"futures",
"http_headers",
"indexmap 2.13.0",
"jwt_light",
"log",
"log4rs",
"once_cell",
"regex",
"scraper",
"serde",
"serde_json",
"sha2 0.11.0-rc.5",
"test_data",
"test_utils",
"tokio",
"url",
"uuid 1.22.0",
"workspace-build-acceleration",
"wreq",
"wreq-util",
]
[[package]]
name = "wreq"
version = "6.0.0-rc.28"

View File

@ -16,8 +16,6 @@ members = [
"crates/api_clients/midjourney_client",
"crates/router",
"crates/api_clients/seedance2pro_client",
"crates/api_clients/worldlabs_api_client",
"crates/api_clients/worldlabs_consumer_client",
# Desktop apps
"crates/artcraftx",
@ -130,8 +128,6 @@ test_utils = { path = "crates/testing/test_utils" }
url_utils = { path = "crates/lib/url_utils" }
uuid_utils = { path = "crates/lib/uuid_utils" }
web_base64 = { path = "crates/lib/data/web_base64" }
worldlabs_api_client = { path = "crates/api_clients/worldlabs_api_client" }
worldlabs_consumer_client = { path = "crates/api_clients/worldlabs_consumer_client" }
# External libs
# Centrally pinned versions so that we can uniformly upgrade/downgrade versions across the workspace

View File

@ -1,37 +0,0 @@
[package]
name = "worldlabs_api_client"
edition = "2024"
version = "0.0.1"
publish = false
[package.metadata.cargo-udeps.ignore]
normal = ["workspace-build-acceleration"]
[lib]
name = "worldlabs_api_client"
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
strum.workspace = true
test_data.workspace = true

View File

@ -1,9 +0,0 @@
/// 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
}
}

View File

@ -1,4 +0,0 @@
pub mod media_asset_id;
pub mod operation_id;
pub mod world_id;
pub mod world_labs_model;

View File

@ -1,9 +0,0 @@
/// 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
}
}

View File

@ -1,13 +0,0 @@
/// Strongly-typed World ID
#[derive(Clone, Debug)]
pub struct WorldId(pub String);
impl WorldId {
pub fn from_str(s: &str) -> Self {
Self(s.to_string())
}
pub fn as_str(&self) -> &str {
&self.0
}
}

View File

@ -1,190 +0,0 @@
/// The available World Labs generation models.
#[derive(Clone, Copy, Debug)]
pub enum WorldLabsModel {
/// `Marble 0.1-mini` is good for quick drafts
/// Generation time: 30-45 seconds
/// Cost: 150-330 credits
#[deprecated(note="Marble 0.1-mini is deprecated. Use `marble-1.0-draft` instead.")]
Marble0p1Mini,
/// `Marble 0.1-plus` is best for final renders
/// Generation time: ~5 minutes,
/// Cost: 1500-1600 credits
#[deprecated(note="Marble 0.1-plus is deprecated. Use `marble-1.0` instead.")]
Marble0p1Plus,
/// marble-1.0
Marble1p0,
/// marble-1.0-draft
Marble1p0Draft,
/// marble-1.1
Marble1p1,
/// marble-1.1-plus
Marble1p1Plus,
}
impl WorldLabsModel {
/// Returns the official API name string used in HTTP requests.
pub fn get_model_api_name_str(&self) -> &'static str {
match self {
Self::Marble0p1Mini => "Marble 0.1-mini",
Self::Marble0p1Plus => "Marble 0.1-plus",
Self::Marble1p0 => "marble-1.0",
Self::Marble1p0Draft => "marble-1.0-draft",
Self::Marble1p1 => "marble-1.1",
Self::Marble1p1Plus => "marble-1.1-plus",
}
}
pub fn is_deprecated(&self) -> bool {
match self {
Self::Marble0p1Mini | Self::Marble0p1Plus => true,
_ => false,
}
}
pub fn to_new_value(self) -> Self {
match self {
Self::Marble0p1Mini => Self::Marble1p0Draft,
Self::Marble0p1Plus => Self::Marble1p0,
_ => self,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
mod api_name {
use super::*;
#[allow(deprecated)]
#[test]
fn marble_0p1_mini() {
assert_eq!(WorldLabsModel::Marble0p1Mini.get_model_api_name_str(), "Marble 0.1-mini");
}
#[allow(deprecated)]
#[test]
fn marble_0p1_plus() {
assert_eq!(WorldLabsModel::Marble0p1Plus.get_model_api_name_str(), "Marble 0.1-plus");
}
#[test]
fn marble_1p0() {
assert_eq!(WorldLabsModel::Marble1p0.get_model_api_name_str(), "marble-1.0");
}
#[test]
fn marble_1p0_draft() {
assert_eq!(WorldLabsModel::Marble1p0Draft.get_model_api_name_str(), "marble-1.0-draft");
}
#[test]
fn marble_1p1() {
assert_eq!(WorldLabsModel::Marble1p1.get_model_api_name_str(), "marble-1.1");
}
#[test]
fn marble_1p1_plus() {
assert_eq!(WorldLabsModel::Marble1p1Plus.get_model_api_name_str(), "marble-1.1-plus");
}
}
mod is_deprecated {
use super::*;
#[allow(deprecated)]
#[test]
fn marble_0p1_mini_is_deprecated() {
assert!(WorldLabsModel::Marble0p1Mini.is_deprecated());
}
#[allow(deprecated)]
#[test]
fn marble_0p1_plus_is_deprecated() {
assert!(WorldLabsModel::Marble0p1Plus.is_deprecated());
}
#[test]
fn marble_1p0_is_not_deprecated() {
assert!(!WorldLabsModel::Marble1p0.is_deprecated());
}
#[test]
fn marble_1p0_draft_is_not_deprecated() {
assert!(!WorldLabsModel::Marble1p0Draft.is_deprecated());
}
#[test]
fn marble_1p1_is_not_deprecated() {
assert!(!WorldLabsModel::Marble1p1.is_deprecated());
}
#[test]
fn marble_1p1_plus_is_not_deprecated() {
assert!(!WorldLabsModel::Marble1p1Plus.is_deprecated());
}
}
mod to_new_value {
use super::*;
#[allow(deprecated)]
#[test]
fn marble_0p1_mini_maps_to_1p0_draft() {
let new = WorldLabsModel::Marble0p1Mini.to_new_value();
assert_eq!(new.get_model_api_name_str(), "marble-1.0-draft");
assert!(!new.is_deprecated());
}
#[allow(deprecated)]
#[test]
fn marble_0p1_plus_maps_to_1p0() {
let new = WorldLabsModel::Marble0p1Plus.to_new_value();
assert_eq!(new.get_model_api_name_str(), "marble-1.0");
assert!(!new.is_deprecated());
}
#[test]
fn marble_1p0_stays_as_1p0() {
let new = WorldLabsModel::Marble1p0.to_new_value();
assert_eq!(new.get_model_api_name_str(), "marble-1.0");
}
#[test]
fn marble_1p0_draft_stays_as_1p0_draft() {
let new = WorldLabsModel::Marble1p0Draft.to_new_value();
assert_eq!(new.get_model_api_name_str(), "marble-1.0-draft");
}
#[test]
fn marble_1p1_stays_as_1p1() {
let new = WorldLabsModel::Marble1p1.to_new_value();
assert_eq!(new.get_model_api_name_str(), "marble-1.1");
}
#[test]
fn marble_1p1_plus_stays_as_1p1_plus() {
let new = WorldLabsModel::Marble1p1Plus.to_new_value();
assert_eq!(new.get_model_api_name_str(), "marble-1.1-plus");
}
#[test]
fn new_models_are_not_deprecated_after_conversion() {
for model in [
WorldLabsModel::Marble1p0,
WorldLabsModel::Marble1p0Draft,
WorldLabsModel::Marble1p1,
WorldLabsModel::Marble1p1Plus,
] {
let new = model.to_new_value();
assert!(!new.is_deprecated(), "{:?} should not be deprecated after to_new_value", model);
}
}
}
}

View File

@ -1,2 +0,0 @@
pub mod api_types;
pub mod requests;

View File

@ -1,102 +0,0 @@
use crate::api::api_types::world_id::WorldId;
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 WorldId,
pub request_timeout: Option<Duration>,
}
pub struct DeleteWorldResponse {
pub world_id: WorldId,
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.as_str());
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: WorldId(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 = WorldId::from_str("REPLACE_WITH_REAL_ID");
let response = delete_world(DeleteWorldArgs {
creds: &creds,
world_id: &world_id,
request_timeout: None,
}).await.unwrap();
println!("World ID: {}", response.world_id.as_str());
println!("Deleted: {}", response.deleted);
assert_eq!(1, 2);
}
}

View File

@ -1,7 +0,0 @@
use serde::Deserialize;
#[derive(Deserialize)]
pub(crate) struct RawResponse {
pub world_id: String,
pub deleted: bool,
}

View File

@ -1,2 +0,0 @@
pub mod delete_world;
pub(crate) mod http_request;

View File

@ -1,449 +0,0 @@
use crate::api::api_types::operation_id::OperationId;
use crate::api::api_types::world_labs_model::WorldLabsModel;
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: WorldLabsModel,
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 model_name = args.model
.to_new_value()
.get_model_api_name_str()
.to_string();
let payload = RawRequest {
world_prompt: args.world_prompt,
display_name: args.display_name,
model: Some(model_name),
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::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::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_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: WorldLabsModel::Marble0p1Mini,
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: WorldLabsModel::Marble0p1Mini,
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);
}
/// End-to-end: upload a safe image step by step (prepare → upload →
/// generate_world), printing each step's result for debugging.
///
/// Uses a synthetic gradient PNG from test_data/.
#[tokio::test]
#[ignore]
async fn test_generate_world_for_safe_image() {
setup_test_logging(LevelFilter::Debug);
let creds = get_test_api_key().unwrap();
// --- Read file ---
let file_bytes = file_read_bytes("/Users/bt/dev/storyteller/artcraft/test_data/image/mochi.jpg")
.expect("failed to read safe test image");
println!("Read {} bytes from safe test image", file_bytes.len());
// --- Step 1: prepare_upload ---
println!("\n=== Step 1: prepare_upload ===");
let prepare_result = prepare_upload(PrepareUploadArgs {
creds: &creds,
file_name: "mochi.jpg",
kind: MediaAssetKind::Image,
request_timeout: None,
}).await;
println!("prepare_upload result: {:?}", prepare_result.as_ref().map(|r| &r.media_asset_id));
let prepare_response = match prepare_result {
Ok(r) => {
println!(" Media Asset ID: {}", r.media_asset_id.as_str());
println!(" Upload URL: {}", r.upload_url);
println!(" Required headers: {:?}", r.required_headers);
r
}
Err(err) => {
println!(" ERROR at prepare_upload: {:?}", err);
panic!("prepare_upload failed: {:?}", err);
}
};
// --- Step 2: upload_to_signed_url ---
println!("\n=== Step 2: upload_to_signed_url ===");
let upload_result = upload_to_signed_url(UploadToSignedUrlArgs {
upload_url: &prepare_response.upload_url,
file_bytes,
required_headers: &prepare_response.required_headers,
content_type: "image/jpeg",
request_timeout: None,
}).await;
match &upload_result {
Ok(()) => println!(" Upload succeeded."),
Err(err) => {
println!(" ERROR at upload_to_signed_url: {:?}", err);
panic!("upload_to_signed_url failed: {:?}", err);
}
}
// --- Step 3: generate_world ---
println!("\n=== Step 3: generate_world ===");
let generate_result = generate_world(GenerateWorldArgs {
creds: &creds,
world_prompt: WorldPrompt::Image {
image_prompt: ContentReference::MediaAsset {
media_asset_id: prepare_response.media_asset_id.as_str().to_string(),
},
text_prompt: Some("A colorful gradient landscape".to_string()),
is_pano: None,
disable_recaption: None,
},
display_name: None,
model: WorldLabsModel::Marble0p1Mini,
seed: None,
tags: None,
permission: None,
request_timeout: None,
}).await;
match &generate_result {
Ok(r) => {
println!(" SUCCESS — safe image");
println!(" Operation ID: {}", r.operation_id.as_str());
println!(" Done: {}", r.done);
}
Err(err) => {
println!(" ERROR at generate_world: {:?}", err);
}
}
assert_eq!(1, 2, "Inspect output above");
}
/// End-to-end: upload an NSFW image step by step (prepare → upload →
/// generate_world), printing each step's result for debugging.
///
/// We expect the NSFW rejection to happen at one of these steps.
/// This test helps us identify *which* step returns the moderation error
/// and what the error response body looks like.
///
/// NB: Place the NSFW test image at `test_data/images/nsfw_test_image.png`.
/// This file is NOT checked into git.
#[tokio::test]
#[ignore]
async fn test_generate_world_for_nsfw_image() {
setup_test_logging(LevelFilter::Debug);
let creds = get_test_api_key().unwrap();
// --- Read file ---
let file_bytes = file_read_bytes("/Users/bt/Pictures/nsfw_test/nsfw_test_1.jpg")
.expect("failed to read NSFW test image");
println!("Read {} bytes from NSFW test image", file_bytes.len());
// --- Step 1: prepare_upload ---
println!("\n=== Step 1: prepare_upload ===");
let prepare_result = prepare_upload(PrepareUploadArgs {
creds: &creds,
file_name: "nsfw_test_1.jpg",
kind: MediaAssetKind::Image,
request_timeout: None,
}).await;
let prepare_response = match prepare_result {
Ok(r) => {
println!(" OK — Media Asset ID: {}", r.media_asset_id.as_str());
println!(" Upload URL: {}", r.upload_url);
r
}
Err(err) => {
println!(" NSFW CAUGHT AT STEP 1 (prepare_upload)!");
println!(" Error (Debug): {:?}", err);
println!(" Error (Display): {}", err);
assert_eq!(1, 2, "NSFW caught at prepare_upload — inspect output above");
unreachable!();
}
};
// --- Step 2: upload_to_signed_url ---
println!("\n=== Step 2: upload_to_signed_url ===");
let upload_result = upload_to_signed_url(UploadToSignedUrlArgs {
upload_url: &prepare_response.upload_url,
file_bytes,
required_headers: &prepare_response.required_headers,
content_type: "image/jpeg",
request_timeout: None,
}).await;
match &upload_result {
Ok(()) => println!(" OK — upload succeeded."),
Err(err) => {
println!(" NSFW CAUGHT AT STEP 2 (upload_to_signed_url)!");
println!(" Error (Debug): {:?}", err);
println!(" Error (Display): {}", err);
assert_eq!(1, 2, "NSFW caught at upload — inspect output above");
}
}
// --- Step 3: generate_world ---
println!("\n=== Step 3: generate_world ===");
let generate_result = generate_world(GenerateWorldArgs {
creds: &creds,
world_prompt: WorldPrompt::Image {
image_prompt: ContentReference::MediaAsset {
media_asset_id: prepare_response.media_asset_id.as_str().to_string(),
},
text_prompt: None,
is_pano: None,
disable_recaption: None,
},
display_name: None,
model: WorldLabsModel::Marble0p1Mini,
seed: None,
tags: None,
permission: None,
request_timeout: None,
}).await;
match &generate_result {
Ok(r) => {
println!(" UNEXPECTED SUCCESS — NSFW image was NOT rejected at generate_world!");
println!(" Operation ID: {}", r.operation_id.as_str());
println!(" Done: {}", r.done);
}
Err(err) => {
println!(" NSFW CAUGHT AT STEP 3 (generate_world)!");
println!(" Error (Debug): {:?}", err);
println!(" Error (Display): {}", err);
}
}
assert_eq!(1, 2, "Inspect output above");
}
/// Marble 1.1: generate world from a mountain/tree image via URI.
#[tokio::test]
#[ignore]
async fn test_generate_world_marble_1p1_image_uri() {
setup_test_logging(LevelFilter::Debug);
let creds = get_test_api_key().unwrap();
let image_url = test_data::web::image_urls::MOUNTAIN_TREE_IMAGE_URL;
println!("=== generate_world with Marble 1.1 ===");
println!("Image URL: {}", image_url);
let result = generate_world(GenerateWorldArgs {
creds: &creds,
world_prompt: WorldPrompt::Image {
image_prompt: ContentReference::Uri {
uri: image_url.to_string(),
},
text_prompt: Some("Mountain landscape with a tree".to_string()),
is_pano: None,
disable_recaption: None,
},
display_name: None,
model: WorldLabsModel::Marble1p1,
seed: None,
tags: None,
permission: None,
request_timeout: None,
}).await;
match &result {
Ok(r) => {
println!("SUCCESS — Marble 1.1");
println!(" Operation ID: {}", r.operation_id.as_str());
println!(" Done: {}", r.done);
}
Err(err) => {
println!("ERROR — Marble 1.1");
println!(" Error (Debug): {:?}", err);
println!(" Error (Display): {}", err);
}
}
assert_eq!(1, 2, "Inspect output above");
}
/// Marble 1.1-plus: generate world from a wide fall mountains image via URI.
#[tokio::test]
#[ignore]
async fn test_generate_world_marble_1p1_plus_image_uri() {
setup_test_logging(LevelFilter::Debug);
let creds = get_test_api_key().unwrap();
let image_url = test_data::web::image_urls::SUPER_WIDE_FALL_MOUNTAINS_IMAGE_URL;
println!("=== generate_world with Marble 1.1-plus ===");
println!("Image URL: {}", image_url);
let result = generate_world(GenerateWorldArgs {
creds: &creds,
world_prompt: WorldPrompt::Image {
image_prompt: ContentReference::Uri {
uri: image_url.to_string(),
},
text_prompt: Some("Autumn mountain panorama".to_string()),
is_pano: None,
disable_recaption: None,
},
display_name: None,
model: WorldLabsModel::Marble1p1Plus,
seed: None,
tags: None,
permission: None,
request_timeout: None,
}).await;
match &result {
Ok(r) => {
println!("SUCCESS — Marble 1.1-plus");
println!(" Operation ID: {}", r.operation_id.as_str());
println!(" Done: {}", r.done);
}
Err(err) => {
println!("ERROR — Marble 1.1-plus");
println!(" Error (Debug): {:?}", err);
println!(" Error (Display): {}", err);
}
}
assert_eq!(1, 2, "Inspect output above");
}
}

View File

@ -1,114 +0,0 @@
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>,
}

View File

@ -1,2 +0,0 @@
pub mod generate_world;
pub mod http_request;

View File

@ -1,112 +0,0 @@
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);
}
}

View File

@ -1,14 +0,0 @@
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>,
}

View File

@ -1,2 +0,0 @@
pub mod get_media_asset;
pub(crate) mod http_request;

View File

@ -1,253 +0,0 @@
use crate::api::api_types::operation_id::OperationId;
use crate::api::api_types::world_id::WorldId;
use crate::api::requests::get_operation::http_request::{RawResponse, RawWorld};
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, warn};
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>,
/// Structured World object, present when done=true and no error.
pub world: Option<World>,
}
#[derive(Debug, Clone)]
pub struct OperationError {
pub code: Option<i32>,
pub message: Option<String>,
}
/// A completed World returned from an operation.
#[derive(Debug, Clone)]
pub struct World {
pub world_id: WorldId,
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 assets: Option<WorldAssets>,
}
/// Assets associated with a completed World.
#[derive(Debug, Clone)]
pub struct WorldAssets {
pub caption: Option<String>,
pub thumbnail_url: Option<String>,
pub pano_url: Option<String>,
pub collider_mesh_url: Option<String>,
pub splats: Option<SplatAssets>,
}
/// Splat (3D Gaussian) assets for a World.
#[derive(Debug, Clone)]
pub struct SplatAssets {
/// Low-res 100k splat URL
pub spz_url_100k: Option<String>,
/// Medium-res 500k splat URL
pub spz_url_500k: Option<String>,
/// Full resolution splat URL
pub spz_url_full_res: Option<String>,
pub metric_scale_factor: Option<f64>,
pub ground_plane_offset: Option<f64>,
}
/// GET /marble/v1/operations/{operation_id}
///
/// Poll the status of an async operation.
/// When `done` is true, `world` contains the structured 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()))?;
let world = raw.response.and_then(|raw_world| {
match try_parse_world(raw_world) {
Some(w) => Some(w),
None => {
warn!("Operation response present but missing world_id — skipping world parse");
None
}
}
});
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,
world,
})
}
/// Try to parse a RawWorld into a strongly-typed World.
/// Returns None if world_id is missing (not a valid World).
fn try_parse_world(raw: RawWorld) -> Option<World> {
let world_id = raw.world_id?;
let assets = raw.assets.map(|a| {
let splats = a.splats.map(|s| {
let semantics = s.semantics_metadata;
SplatAssets {
spz_url_100k: s.spz_urls.as_ref().and_then(|u| u.low.clone()),
spz_url_500k: s.spz_urls.as_ref().and_then(|u| u.medium.clone()),
spz_url_full_res: s.spz_urls.as_ref().and_then(|u| u.full_res.clone()),
metric_scale_factor: semantics.as_ref().and_then(|m| m.metric_scale_factor),
ground_plane_offset: semantics.as_ref().and_then(|m| m.ground_plane_offset),
}
});
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),
splats,
}
});
Some(World {
world_id: WorldId(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,
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_operation() {
setup_test_logging(LevelFilter::Debug);
let creds = get_test_api_key().unwrap();
//let operation_id = OperationId("1fab3bf1-05a1-4929-907e-c6df07c539e2".to_string());
let operation_id = OperationId("a0894fa6-594d-4680-8f3a-5109f22664b1".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!("Updated at: {:?}", response.updated_at);
println!("Expires at: {:?}", response.expires_at);
println!("Error: {:?}", response.error);
println!("Metadata: {:?}", response.metadata);
match &response.world {
None => println!("World: None (not yet complete)"),
Some(world) => {
println!("World:");
println!(" World ID: {}", world.world_id.as_str());
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);
match &world.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);
match &assets.splats {
None => println!(" Splats: None"),
Some(splats) => {
println!(" Splats:");
println!(" SPZ 100k: {:?}", splats.spz_url_100k);
println!(" SPZ 500k: {:?}", splats.spz_url_500k);
println!(" SPZ full res: {:?}", splats.spz_url_full_res);
println!(" Metric scale factor: {:?}", splats.metric_scale_factor);
println!(" Ground plane offset: {:?}", splats.ground_plane_offset);
}
}
}
}
}
}
assert_eq!(1, 2);
}
}

View File

@ -1,82 +0,0 @@
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<RawWorld>,
}
#[derive(Debug, Clone, Deserialize)]
pub(crate) struct RawOperationError {
pub code: Option<i32>,
pub message: Option<String>,
}
/// The World object embedded in `response` when the operation completes.
/// All fields are optional to tolerate partial/unexpected shapes.
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub(crate) struct RawWorld {
pub world_id: Option<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 assets: Option<RawWorldAssets>,
pub world_prompt: Option<serde_json::Value>,
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
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)]
#[allow(dead_code)]
pub(crate) struct RawImagery {
pub pano_url: Option<String>,
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
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)]
#[allow(dead_code)]
pub(crate) struct RawSpzUrls {
#[serde(rename = "100k")]
pub low: Option<String>,
#[serde(rename = "500k")]
pub medium: Option<String>,
pub full_res: Option<String>,
}

View File

@ -1,2 +0,0 @@
pub mod get_operation;
pub(crate) mod http_request;

View File

@ -1,163 +0,0 @@
use crate::api::api_types::world_id::WorldId;
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 WorldId,
pub request_timeout: Option<Duration>,
}
pub struct GetWorldResponse {
pub world_id: WorldId,
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.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_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: WorldId(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 = WorldId::from_str("0048d009-1c7a-4e13-9881-07e9b6ff32e1");
//let world_id = WorldId::from_str("c8a89fb1-f8b4-44c6-ae50-525f7205c65f");
let response = get_world(GetWorldArgs {
creds: &creds,
world_id: &world_id,
request_timeout: None,
}).await.unwrap();
println!("World ID: {}", response.world_id.as_str());
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);
}
}

View File

@ -1,64 +0,0 @@
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>,
}

View File

@ -1,2 +0,0 @@
pub mod get_world;
pub(crate) mod http_request;

View File

@ -1,41 +0,0 @@
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>>,
}

View File

@ -1,170 +0,0 @@
use crate::api::api_types::world_id::WorldId;
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: WorldId,
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: WorldId(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.as_str());
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);
}
}

View File

@ -1,2 +0,0 @@
pub mod list_worlds;
pub(crate) mod http_request;

View File

@ -1,8 +0,0 @@
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;

View File

@ -1,34 +0,0 @@
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>>,
}

View File

@ -1,2 +0,0 @@
pub mod prepare_upload;
pub(crate) mod http_request;

View File

@ -1,151 +0,0 @@
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");
}
}

View File

@ -1 +0,0 @@
pub mod upload_to_signed_url;

View File

@ -1,66 +0,0 @@
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.
}

View File

@ -1 +0,0 @@
pub mod world_labs_api_creds;

View File

@ -1,16 +0,0 @@
/// 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
}
}

View File

@ -1,195 +0,0 @@
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());
},
_ => {},
}
// Check for NSFW content policy rejection (403 with nsfw/sexuality keywords).
if let Some(nsfw_error) = check_nsfw_content_rejection(status_code, maybe_body) {
return Err(nsfw_error.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())
}
/// Check if a 403 response is an NSFW content policy rejection.
/// Returns `Some(WorldLabsSpecificApiError)` if it matches, `None` otherwise.
fn check_nsfw_content_rejection(
status_code: StatusCode,
maybe_body: Option<&str>,
) -> Option<WorldLabsSpecificApiError> {
if status_code != StatusCode::FORBIDDEN {
return None;
}
let body = maybe_body?;
let body_lower = body.to_lowercase();
if body_lower.contains("nsfw") || body_lower.contains("sexuality") {
Some(WorldLabsSpecificApiError::NsfwContentPolicyRejected {
message: Some(body.to_string()),
})
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn success_200_returns_ok() {
let result = filter_world_labs_http_error(StatusCode::OK, None);
assert!(result.is_ok());
}
#[test]
fn success_200_with_body_returns_ok() {
let result = filter_world_labs_http_error(StatusCode::OK, Some("{}"));
assert!(result.is_ok());
}
// --- NSFW 403 ---
#[test]
fn nsfw_403_real_response() {
let body = r#"{"detail":"NSFW Content is not permitted by policy. Sexuality"}"#;
let result = filter_world_labs_http_error(StatusCode::FORBIDDEN, Some(body));
let err = result.unwrap_err();
assert!(err.is_403_forbidden());
assert!(matches!(err, WorldLabsError::ApiSpecific(
WorldLabsSpecificApiError::NsfwContentPolicyRejected { .. }
)));
}
#[test]
fn nsfw_403_preserves_message() {
let body = r#"{"detail":"NSFW Content is not permitted by policy. Sexuality"}"#;
let result = filter_world_labs_http_error(StatusCode::FORBIDDEN, Some(body));
let err = result.unwrap_err();
if let WorldLabsError::ApiSpecific(WorldLabsSpecificApiError::NsfwContentPolicyRejected { message }) = err {
assert_eq!(message.as_deref(), Some(body));
} else {
panic!("expected NsfwContentPolicyRejected, got: {:?}", err);
}
}
#[test]
fn nsfw_403_case_insensitive_nsfw() {
let body = r#"{"detail":"nsfw content detected"}"#;
let result = filter_world_labs_http_error(StatusCode::FORBIDDEN, Some(body));
assert!(matches!(result.unwrap_err(), WorldLabsError::ApiSpecific(
WorldLabsSpecificApiError::NsfwContentPolicyRejected { .. }
)));
}
#[test]
fn nsfw_403_case_insensitive_sexuality() {
let body = r#"{"detail":"Rejected due to Sexuality content"}"#;
let result = filter_world_labs_http_error(StatusCode::FORBIDDEN, Some(body));
assert!(matches!(result.unwrap_err(), WorldLabsError::ApiSpecific(
WorldLabsSpecificApiError::NsfwContentPolicyRejected { .. }
)));
}
// --- Non-NSFW 403 ---
#[test]
fn non_nsfw_403_is_generic_error() {
let body = r#"{"detail":"Forbidden: invalid API key"}"#;
let result = filter_world_labs_http_error(StatusCode::FORBIDDEN, Some(body));
let err = result.unwrap_err();
assert!(err.is_403_forbidden());
assert!(matches!(err, WorldLabsError::ApiGeneric(
WorldLabsGenericApiError::UncategorizedBadResponseWithStatusAndBody { .. }
)));
}
#[test]
fn non_nsfw_403_no_body_is_generic_error() {
let result = filter_world_labs_http_error(StatusCode::FORBIDDEN, None);
let err = result.unwrap_err();
assert!(err.is_403_forbidden());
assert!(matches!(err, WorldLabsError::ApiGeneric(
WorldLabsGenericApiError::UncategorizedBadResponseWithStatusAndBody { .. }
)));
}
// --- 402 Payment Required ---
#[test]
fn payment_required_402() {
let result = filter_world_labs_http_error(StatusCode::PAYMENT_REQUIRED, Some("{}"));
let err = result.unwrap_err();
assert!(matches!(err, WorldLabsError::ApiSpecific(
WorldLabsSpecificApiError::InsufficientCredits
)));
assert!(!err.is_403_forbidden());
}
// --- Other errors ---
#[test]
fn server_error_500_is_generic() {
let body = r#"{"detail":"Internal server error"}"#;
let result = filter_world_labs_http_error(StatusCode::INTERNAL_SERVER_ERROR, Some(body));
let err = result.unwrap_err();
assert!(!err.is_403_forbidden());
assert!(matches!(err, WorldLabsError::ApiGeneric(
WorldLabsGenericApiError::UncategorizedBadResponseWithStatusAndBody { .. }
)));
}
#[test]
fn bad_request_400_is_generic() {
let body = r#"{"detail":"Bad request"}"#;
let result = filter_world_labs_http_error(StatusCode::BAD_REQUEST, Some(body));
assert!(matches!(result.unwrap_err(), WorldLabsError::ApiGeneric(
WorldLabsGenericApiError::UncategorizedBadResponseWithStatusAndBody { .. }
)));
}
#[test]
fn nsfw_keyword_in_non_403_is_not_nsfw_error() {
// Even if the body mentions "nsfw", a 400 should NOT be classified as NSFW rejection.
let body = r#"{"detail":"nsfw check failed"}"#;
let result = filter_world_labs_http_error(StatusCode::BAD_REQUEST, Some(body));
assert!(matches!(result.unwrap_err(), WorldLabsError::ApiGeneric(
WorldLabsGenericApiError::UncategorizedBadResponseWithStatusAndBody { .. }
)));
}
#[test]
fn error_500_no_body() {
let result = filter_world_labs_http_error(StatusCode::INTERNAL_SERVER_ERROR, None);
assert!(matches!(result.unwrap_err(), WorldLabsError::ApiGeneric(
WorldLabsGenericApiError::UncategorizedBadResponseWithStatusAndBody { body, .. }
) if body.is_empty()));
}
}

View File

@ -1,5 +0,0 @@
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;

View File

@ -1,23 +0,0 @@
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),
}
}
}

View File

@ -1,51 +0,0 @@
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 WorldLabsError {
pub fn is_403_forbidden(&self) -> bool {
match self {
Self::ApiSpecific(e) => e.is_403_forbidden(),
Self::ApiGeneric(e) => e.is_403_forbidden(),
Self::Client(_) => false,
}
}
}
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)
}
}

View File

@ -1,50 +0,0 @@
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 WorldLabsGenericApiError {
pub fn is_403_forbidden(&self) -> bool {
match self {
Self::UncategorizedBadResponseWithStatusAndBody { status_code, .. }
| Self::GoogleUploadFailed { status_code, .. } => {
*status_code == StatusCode::FORBIDDEN
}
_ => false,
}
}
}
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),
}
}
}

View File

@ -1,39 +0,0 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
#[derive(Debug)]
pub enum WorldLabsSpecificApiError {
/// There aren't sufficient funds for generation.
InsufficientCredits,
/// The content was rejected by WorldLabs' NSFW / content policy filter.
NsfwContentPolicyRejected {
message: Option<String>,
},
}
impl WorldLabsSpecificApiError {
pub fn is_403_forbidden(&self) -> bool {
match self {
Self::NsfwContentPolicyRejected { .. } => true,
_ => false,
}
}
}
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"),
Self::NsfwContentPolicyRejected { message } => {
write!(f, "NSFW content policy rejection")?;
if let Some(msg) = message {
write!(f, ": {}", msg)?;
}
Ok(())
}
}
}
}

View File

@ -1,9 +0,0 @@
#[cfg(test)]
pub (crate) mod test_utils;
pub mod api;
pub mod credentials;
pub mod error;
pub mod pricing;
pub mod recipes;

View File

@ -1,226 +0,0 @@
use crate::api::api_types::world_labs_model::WorldLabsModel;
/// The type of input used to generate a world.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[cfg_attr(test, derive(strum::EnumIter, strum::Display))]
pub enum InputType {
Text,
ImageNonPanorama,
ImagePanorama,
MultiImage,
Video,
}
/// Cost breakdown for a World Labs generation request.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct WorldLabsCost {
pub worldlabs_credits: u32,
pub us_dollar_cents: u32,
}
/// Calculate the cost of a World Labs generation request.
pub fn calculate_cost(model: WorldLabsModel, input_type: InputType) -> WorldLabsCost {
let worldlabs_credits = credits_for(model, input_type);
let us_dollar_cents = credits_to_us_dollar_cents(worldlabs_credits);
WorldLabsCost { worldlabs_credits, us_dollar_cents }
}
/// Returns the credit cost for a given model and input type,
/// based on the official pricing table at https://docs.worldlabs.ai/api/pricing
fn credits_for(model: WorldLabsModel, input_type: InputType) -> u32 {
match (model, input_type) {
// Marble 0.1-mini
(WorldLabsModel::Marble0p1Mini, InputType::ImagePanorama) => 150,
(WorldLabsModel::Marble0p1Mini, InputType::Text) => 230,
(WorldLabsModel::Marble0p1Mini, InputType::ImageNonPanorama) => 230,
(WorldLabsModel::Marble0p1Mini, InputType::MultiImage) => 250,
(WorldLabsModel::Marble0p1Mini, InputType::Video) => 250,
// Marble 0.1-plus
(WorldLabsModel::Marble0p1Plus, InputType::ImagePanorama) => 1500,
(WorldLabsModel::Marble0p1Plus, InputType::Text) => 1580,
(WorldLabsModel::Marble0p1Plus, InputType::ImageNonPanorama) => 1580,
(WorldLabsModel::Marble0p1Plus, InputType::MultiImage) => 1600,
(WorldLabsModel::Marble0p1Plus, InputType::Video) => 1600,
// Marble 1.0 (successor to 0.1-plus)
(WorldLabsModel::Marble1p0, InputType::ImagePanorama) => 1500,
(WorldLabsModel::Marble1p0, InputType::Text) => 1580,
(WorldLabsModel::Marble1p0, InputType::ImageNonPanorama) => 1580,
(WorldLabsModel::Marble1p0, InputType::MultiImage) => 1600,
(WorldLabsModel::Marble1p0, InputType::Video) => 1600,
// Marble 1.0-draft (successor to 0.1-mini)
(WorldLabsModel::Marble1p0Draft, InputType::ImagePanorama) => 150,
(WorldLabsModel::Marble1p0Draft, InputType::Text) => 230,
(WorldLabsModel::Marble1p0Draft, InputType::ImageNonPanorama) => 230,
(WorldLabsModel::Marble1p0Draft, InputType::MultiImage) => 250,
(WorldLabsModel::Marble1p0Draft, InputType::Video) => 250,
// Marble 1.1
(WorldLabsModel::Marble1p1, InputType::ImagePanorama) => 1500,
(WorldLabsModel::Marble1p1, InputType::Text) => 1580,
(WorldLabsModel::Marble1p1, InputType::ImageNonPanorama) => 1580,
(WorldLabsModel::Marble1p1, InputType::MultiImage) => 1600,
(WorldLabsModel::Marble1p1, InputType::Video) => 1600,
// TODO: Looks like there's no way for us to know what the price will be
// for us to bill this accurately, we'll have to bill this after the fact.
// also - they don't have an API for us to ascertain this??! Wat.
// Marble 1.1-plus (range pricing — we use the max to charge enough upfront)
// Actual range: 1500–3000 / 1580–3080 / 1600–3100
(WorldLabsModel::Marble1p1Plus, InputType::ImagePanorama) => 3000,
(WorldLabsModel::Marble1p1Plus, InputType::Text) => 3080,
(WorldLabsModel::Marble1p1Plus, InputType::ImageNonPanorama) => 3080,
(WorldLabsModel::Marble1p1Plus, InputType::MultiImage) => 3100,
(WorldLabsModel::Marble1p1Plus, InputType::Video) => 3100,
}
}
/// Converts credits to US dollar cents.
/// Rate: $1.00 USD per 1,250 credits → 100 cents per 1,250 credits.
fn credits_to_us_dollar_cents(credits: u32) -> u32 {
// (credits * 100) / 1250 = (credits * 4) / 50 = (credits * 2) / 25
// Use u64 to avoid overflow on large values.
((credits as u64 * 100) / 1250) as u32
}
#[cfg(test)]
mod tests {
use super::*;
use strum::IntoEnumIterator;
#[test]
fn test_pricing_table() {
let models = [
WorldLabsModel::Marble0p1Mini,
WorldLabsModel::Marble0p1Plus,
WorldLabsModel::Marble1p0,
WorldLabsModel::Marble1p0Draft,
WorldLabsModel::Marble1p1,
WorldLabsModel::Marble1p1Plus,
];
println!("\n{:<20} {:<20} {:>10} {:>12}", "Model", "Input Type", "Credits", "USD Cents");
println!("{}", "-".repeat(65));
for model in &models {
for input_type in InputType::iter() {
let cost = calculate_cost(*model, input_type);
println!(
"{:<20} {:<20} {:>10} {:>12}",
model.get_model_api_name_str(),
input_type,
cost.worldlabs_credits,
cost.us_dollar_cents,
);
}
}
}
#[test]
fn test_mini_credits() {
assert_eq!(calculate_cost(WorldLabsModel::Marble0p1Mini, InputType::Text).worldlabs_credits, 230);
assert_eq!(calculate_cost(WorldLabsModel::Marble0p1Mini, InputType::ImageNonPanorama).worldlabs_credits, 230);
assert_eq!(calculate_cost(WorldLabsModel::Marble0p1Mini, InputType::ImagePanorama).worldlabs_credits, 150);
assert_eq!(calculate_cost(WorldLabsModel::Marble0p1Mini, InputType::MultiImage).worldlabs_credits, 250);
assert_eq!(calculate_cost(WorldLabsModel::Marble0p1Mini, InputType::Video).worldlabs_credits, 250);
}
#[test]
fn test_plus_credits() {
assert_eq!(calculate_cost(WorldLabsModel::Marble0p1Plus, InputType::Text).worldlabs_credits, 1580);
assert_eq!(calculate_cost(WorldLabsModel::Marble0p1Plus, InputType::ImageNonPanorama).worldlabs_credits, 1580);
assert_eq!(calculate_cost(WorldLabsModel::Marble0p1Plus, InputType::ImagePanorama).worldlabs_credits, 1500);
assert_eq!(calculate_cost(WorldLabsModel::Marble0p1Plus, InputType::MultiImage).worldlabs_credits, 1600);
assert_eq!(calculate_cost(WorldLabsModel::Marble0p1Plus, InputType::Video).worldlabs_credits, 1600);
}
#[test]
fn test_marble_1p0_draft_credits() {
// Same as Marble 0.1-mini (its successor)
assert_eq!(calculate_cost(WorldLabsModel::Marble1p0Draft, InputType::Text).worldlabs_credits, 230);
assert_eq!(calculate_cost(WorldLabsModel::Marble1p0Draft, InputType::ImageNonPanorama).worldlabs_credits, 230);
assert_eq!(calculate_cost(WorldLabsModel::Marble1p0Draft, InputType::ImagePanorama).worldlabs_credits, 150);
assert_eq!(calculate_cost(WorldLabsModel::Marble1p0Draft, InputType::MultiImage).worldlabs_credits, 250);
assert_eq!(calculate_cost(WorldLabsModel::Marble1p0Draft, InputType::Video).worldlabs_credits, 250);
}
#[test]
fn test_marble_1p0_credits() {
// Standard model, same pricing as Marble 0.1-plus
assert_eq!(calculate_cost(WorldLabsModel::Marble1p0, InputType::Text).worldlabs_credits, 1580);
assert_eq!(calculate_cost(WorldLabsModel::Marble1p0, InputType::ImageNonPanorama).worldlabs_credits, 1580);
assert_eq!(calculate_cost(WorldLabsModel::Marble1p0, InputType::ImagePanorama).worldlabs_credits, 1500);
assert_eq!(calculate_cost(WorldLabsModel::Marble1p0, InputType::MultiImage).worldlabs_credits, 1600);
assert_eq!(calculate_cost(WorldLabsModel::Marble1p0, InputType::Video).worldlabs_credits, 1600);
}
#[test]
fn test_marble_1p1_credits() {
// Standard model, same pricing tier as 1.0
assert_eq!(calculate_cost(WorldLabsModel::Marble1p1, InputType::Text).worldlabs_credits, 1580);
assert_eq!(calculate_cost(WorldLabsModel::Marble1p1, InputType::ImageNonPanorama).worldlabs_credits, 1580);
assert_eq!(calculate_cost(WorldLabsModel::Marble1p1, InputType::ImagePanorama).worldlabs_credits, 1500);
assert_eq!(calculate_cost(WorldLabsModel::Marble1p1, InputType::MultiImage).worldlabs_credits, 1600);
assert_eq!(calculate_cost(WorldLabsModel::Marble1p1, InputType::Video).worldlabs_credits, 1600);
}
#[test]
fn test_marble_1p1_plus_credits() {
// Plus model uses max of the range pricing (1500–3000, 1580–3080, 1600–3100)
assert_eq!(calculate_cost(WorldLabsModel::Marble1p1Plus, InputType::Text).worldlabs_credits, 3080);
assert_eq!(calculate_cost(WorldLabsModel::Marble1p1Plus, InputType::ImageNonPanorama).worldlabs_credits, 3080);
assert_eq!(calculate_cost(WorldLabsModel::Marble1p1Plus, InputType::ImagePanorama).worldlabs_credits, 3000);
assert_eq!(calculate_cost(WorldLabsModel::Marble1p1Plus, InputType::MultiImage).worldlabs_credits, 3100);
assert_eq!(calculate_cost(WorldLabsModel::Marble1p1Plus, InputType::Video).worldlabs_credits, 3100);
}
#[test]
fn test_marble_1p1_plus_is_more_expensive_than_standard() {
for input_type in InputType::iter() {
let standard = calculate_cost(WorldLabsModel::Marble1p1, input_type);
let plus = calculate_cost(WorldLabsModel::Marble1p1Plus, input_type);
assert!(
plus.worldlabs_credits > standard.worldlabs_credits,
"1.1-plus should cost more than 1.1 for {:?}: {} vs {}",
input_type, plus.worldlabs_credits, standard.worldlabs_credits,
);
}
}
#[test]
fn test_dollar_conversion() {
// 1250 credits = $1.00 = 100 cents
assert_eq!(credits_to_us_dollar_cents(1250), 100);
// 150 credits = $0.12 = 12 cents
assert_eq!(credits_to_us_dollar_cents(150), 12);
// 230 credits = $0.184 = 18 cents (truncated)
assert_eq!(credits_to_us_dollar_cents(230), 18);
// 1580 credits = $1.264 = 126 cents (truncated)
assert_eq!(credits_to_us_dollar_cents(1580), 126);
// 1600 credits = $1.28 = 128 cents
assert_eq!(credits_to_us_dollar_cents(1600), 128);
}
#[test]
fn test_all_combinations_have_costs() {
let models = [
WorldLabsModel::Marble0p1Mini,
WorldLabsModel::Marble0p1Plus,
WorldLabsModel::Marble1p0,
WorldLabsModel::Marble1p0Draft,
WorldLabsModel::Marble1p1,
WorldLabsModel::Marble1p1Plus,
];
for model in &models {
for input_type in InputType::iter() {
let cost = calculate_cost(*model, input_type);
assert!(cost.worldlabs_credits > 0, "credits should be > 0 for {:?} {:?}", model, input_type);
assert!(cost.us_dollar_cents > 0, "dollar cents should be > 0 for {:?} {:?}", model, input_type);
}
}
}
}

View File

@ -1 +0,0 @@
pub mod check_pricing;

View File

@ -1 +0,0 @@
pub mod upload_image_and_create_world_with_retry;

View File

@ -1,166 +0,0 @@
use crate::api::api_types::media_asset_id::MediaAssetId;
use crate::api::api_types::operation_id::OperationId;
use crate::api::api_types::world_labs_model::WorldLabsModel;
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: WorldLabsModel,
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: WorldLabsModel::Marble0p1Plus,
}).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);
}
}

View File

@ -1,10 +0,0 @@
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))
}

View File

@ -1,2 +0,0 @@
pub mod get_test_api_key;
pub mod setup_test_logging;

View File

@ -1,52 +0,0 @@
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",
}
}

View File

@ -1,54 +0,0 @@
[package]
name = "worldlabs_consumer_client"
edition = "2024"
version = "0.0.1"
publish = false
[package.metadata.cargo-udeps.ignore]
normal = ["workspace-build-acceleration"]
[lib]
name = "worldlabs_consumer_client"
path = "src/lib.rs"
[dependencies]
# Workspace hack (faster compile times)
workspace-build-acceleration.workspace = true
# Internal
cloudflare_errors.workspace = true
cloudflare_mitigation.workspace = true
errors.workspace = true
filesys.workspace = true
http_headers.workspace = true
jwt_light.workspace = true
# External
anyhow.workspace = true
base64.workspace = true
bstr.workspace = true
byteorder.workspace = true
chrono.workspace = true
futures.workspace = true
indexmap.workspace = true
log.workspace = true
once_cell.workspace = true
regex.workspace = true
scraper = "0.24.0"
serde.workspace = true
serde_json.workspace = true
sha2.workspace = true
tokio = { version = "1", features = ["full"] }
url.workspace = true
uuid.workspace = true
wreq-util.workspace = true
wreq.workspace = true
[dev-dependencies]
filesys.workspace = true
test_data.workspace = true
test_utils.workspace = true
log4rs = "1.4.0"
env_logger.workspace = true
env.workspace = true
chrono.workspace = true

View File

@ -1,26 +0,0 @@
use uuid::Uuid;
/// Type for Image Input Object Ids
/// These are used to refer to worlds
/// These appear to be bare UUIDs.
#[derive(Clone, Debug)]
pub struct ImageInputObjectId(pub String);
impl ImageInputObjectId {
pub fn generate_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()
}
}

View File

@ -1,26 +0,0 @@
use uuid::Uuid;
/// Type for "meta" World IDs (not sure what to term this).
/// These are in the first run patch request
/// These appear to be bare UUIDs.
#[derive(Clone, Debug)]
pub struct MetaWorldObjectId(pub String);
impl MetaWorldObjectId {
pub fn generate_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()
}
}

View File

@ -1,8 +0,0 @@
pub mod image_input_object_id;
pub mod meta_world_object_id;
pub mod pano_object_id;
pub mod run_object;
pub mod run_object_id;
pub mod upload_mime_type;
pub mod upload_object_id;
pub mod world_id;

View File

@ -1,26 +0,0 @@
use uuid::Uuid;
/// Type for Pano Object Ids
/// These are used to refer to worlds
/// These appear to be bare UUIDs.
#[derive(Clone, Debug)]
pub struct PanoObjectId(pub String);
impl PanoObjectId {
pub fn generate_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()
}
}

View File

@ -1,79 +0,0 @@
use std::collections::HashMap;
use chrono::Utc;
use serde::{Deserialize, Serialize};
use serde_json::Value;
const RUN_MIME_TYPE: &str = "application/run+json";
const RUN_VERSION : &str = "0.0.1";
/// World Labs' API is very frontend driven
/// They send whole objects back to the frontend that the frontend is in charge of mutatingbjects or PATCH them back to the server (yuck):
/// - request #1 - create run
/// - request #6 - patch run after image upload
/// Gotta go fast?
#[derive(Serialize, Deserialize, Clone)]
pub struct RunObject {
/// The primary ID of the run.
/// This won't exist until we post it to the server.
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
pub metadata: RunObjectMetadata,
pub mime_type: String,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct RunObjectMetadata {
pub version: String,
#[serde(rename = "createdAt")]
pub created_at: u64,
#[serde(rename = "updatedAt")]
pub updated_at: u64,
#[serde(rename = "usesAdvancedEditing")]
pub uses_advanced_editing: bool,
#[serde(rename = "draftMode")]
pub draft_mode: bool,
/// Polymorphic set of ID-to-object mappings.
pub nodes: HashMap<String, Value>,
}
impl Default for RunObject {
fn default() -> Self {
let now = Utc::now();
let now = now.timestamp().unsigned_abs() * 1000; // Millisecond resolution
Self {
id: None,
metadata: RunObjectMetadata{
version: RUN_VERSION.to_string(),
created_at: now,
updated_at: now,
uses_advanced_editing: false,
draft_mode: false,
nodes: HashMap::new(),
},
mime_type: RUN_MIME_TYPE.to_string(),
}
}
}
#[cfg(test)]
mod tests {
use crate::api::api_types::run_object::RunObject;
#[test]
fn default_runobject() {
let object = RunObject::default();
assert!(object.id.is_none());
assert_eq!(&object.metadata.version, "0.0.1");
assert!(object.metadata.created_at > 1705433415549);
assert!(object.metadata.updated_at > 1705433415549);
assert_eq!(&object.mime_type, "application/run+json");
}
}

View File

@ -1,15 +0,0 @@
/// Type for Object IDs.
/// These are used to create inference runs and refer to the result
/// These appear to be bare UUIDs.
#[derive(Clone, Debug)]
pub struct RunObjectId(pub String);
impl RunObjectId {
pub fn from_str(s: &str) -> Self {
Self(s.to_string())
}
pub fn to_string(&self) -> String {
self.0.clone()
}
}

View File

@ -1,21 +0,0 @@
use serde::Serialize;
#[derive(Clone, Copy, Debug, Serialize)]
pub enum UploadMimeType {
#[serde(rename = "image/jpeg")]
ImageJpeg,
}
impl UploadMimeType {
pub fn content_type(&self) -> &'static str {
match self {
Self::ImageJpeg => "image/jpeg",
}
}
pub fn extension_without_period(&self) -> &'static str {
match self {
Self::ImageJpeg => "jpeg",
}
}
}

View File

@ -1,15 +0,0 @@
/// Type for Upload IDs.
/// These are used to upload images.
/// These appear to be bare UUIDs.
#[derive(Clone, Debug)]
pub struct UploadObjectId(pub String);
impl UploadObjectId {
pub fn as_str(&self) -> &str {
&self.0
}
pub fn to_string(&self) -> String {
self.0.clone()
}
}

View File

@ -1,26 +0,0 @@
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()
}
}

View File

@ -1,6 +0,0 @@
/// Referer header value
pub(crate) const REFERER_VALUE: &str = "https://marble.worldlabs.ai/";
/// Origin header value
pub(crate) const ORIGIN_VALUE: &str = "https://marble.worldlabs.ai/";

View File

@ -1 +0,0 @@
pub (crate) mod common_header_values;

View File

@ -1,5 +0,0 @@
pub mod api_types;
pub mod requests;
pub (crate) mod common;
pub mod utils;

View File

@ -1,154 +0,0 @@
use crate::api::api_types::upload_mime_type::UploadMimeType;
use crate::api::common::common_header_values::{ORIGIN_VALUE, REFERER_VALUE};
use crate::credentials::world_labs_bearer_token::WorldLabsBearerToken;
use crate::credentials::world_labs_cookies::WorldLabsCookies;
use crate::credentials::worldlabs_refresh_token::WorldLabsRefreshToken;
use crate::error::world_labs_client_error::WorldLabsClientError;
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 http_headers::names::{PRIORITY, SEC_FETCH_DEST, SEC_FETCH_MODE, SEC_FETCH_SITE, SEC_GPC};
use http_headers::values::accept::ACCEPT_ALL;
use http_headers::values::accept_encoding::ACCEPT_ENCODING_GZIP_ETC;
use http_headers::values::accept_language::ACCEPT_LANGUAGE_EN_US;
use http_headers::values::cache_control::CACHE_CONTROL_NO_CACHE;
use http_headers::values::connection::CONNECTION_KEEP_ALIVE;
use http_headers::values::pragma::PRAGMA_NO_CACHE;
use http_headers::values::priority::{PRIORITY_4, PRIORITY_6};
use http_headers::values::sec::{SEC_FETCH_DEST_EMPTY, SEC_FETCH_MODE_CORS, SEC_FETCH_SITE_CROSS_SITE};
use http_headers::values::te::TE_TRAILERS;
use log::{debug, error};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::time::Duration;
use wreq::header::{ACCEPT, ACCEPT_ENCODING, ACCEPT_LANGUAGE, AUTHORIZATION, CACHE_CONTROL, CONNECTION, CONTENT_LENGTH, CONTENT_TYPE, ORIGIN, PRAGMA, REFERER, TE};
use wreq::multipart::Form;
use wreq::Client;
use wreq_util::Emulation;
/// NB: key is the public API key for WorldLabs
/// https://docs.cloud.google.com/identity-platform/docs/use-rest-api
const URL : &str = "https://securetoken.googleapis.com/v1/token?key=AIzaSyAA88yObUN-0pnXAxcgOrT2exZxFWLW1BI";
pub struct GoogleRefreshTokenArgs<'a> {
pub refresh_token: &'a WorldLabsRefreshToken,
pub request_timeout: Option<Duration>,
}
pub struct GoogleRefreshTokenResponse {
pub bearer_token: WorldLabsBearerToken,
pub refresh_token: WorldLabsRefreshToken,
}
/// Refresh Access Token for WorldLabs using Google.
pub async fn google_refresh_token(args: GoogleRefreshTokenArgs<'_>) -> Result<GoogleRefreshTokenResponse, WorldLabsError> {
let client = Client::builder()
.emulation(Emulation::Firefox143)
.build()
.map_err(|err| WorldLabsClientError::WreqClientError(err))?;
debug!("Requesting URL: {}", URL);
let mut request_builder = client.post(URL)
.header(ACCEPT, ACCEPT_ALL)
.header(ACCEPT_LANGUAGE, ACCEPT_LANGUAGE_EN_US)
.header(ACCEPT_ENCODING, ACCEPT_ENCODING_GZIP_ETC)
.header(REFERER, REFERER_VALUE)
.header(CONNECTION, CONNECTION_KEEP_ALIVE)
.header(SEC_FETCH_DEST, SEC_FETCH_DEST_EMPTY)
.header(SEC_FETCH_MODE, SEC_FETCH_MODE_CORS)
.header(SEC_FETCH_SITE, SEC_FETCH_SITE_CROSS_SITE)
.header(PRIORITY, PRIORITY_4)
.header(TE, TE_TRAILERS);
let mut form = HashMap::new();
form.insert("grant_type", "refresh_token".to_string());
form.insert("refresh_token", args.refresh_token.to_raw_string());
if let Some(timeout) = args.request_timeout {
request_builder = request_builder.timeout(timeout);
}
let http_request = request_builder
.form(&form)
.build()
.map_err(|err| {
error!("Error building request: {:?}", err);
WorldLabsClientError::WreqClientError(err)
})?;
let response = client.execute(http_request)
.await
.map_err(|err| {
error!("Error during request execution: {:?}", 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!("Request returned an error (code {}) : {:?}", status.as_u16(), response_body);
if response_body.contains("SignatureDoesNotMatch") || response_body.contains("does not match the signature") {
return Err(WorldLabsSpecificApiError::GoogleUploadSignatureDoesNotMatch.into());
} else {
return Err(WorldLabsGenericApiError::GoogleUploadFailed { status_code: status, body: response_body }.into());
}
}
debug!("Response body (200): {}", response_body);
let response : RawResponse = serde_json::from_str(&response_body)
.map_err(|err| WorldLabsGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.to_string()))?;
let bearer_token = WorldLabsBearerToken::new(response.access_token);
let refresh_token = WorldLabsRefreshToken::new(response.refresh_token);
Ok(GoogleRefreshTokenResponse {
bearer_token,
refresh_token,
})
}
#[derive(Deserialize)]
struct RawResponse {
pub access_token: String,
pub refresh_token: String,
// eg. "3600"
pub expires_in: Option<String>,
pub id_token: Option<String>,
pub user_id: Option<String>,
}
#[cfg(test)]
mod tests {
use crate::api::requests::google_refresh_token::google_refresh_token::{google_refresh_token, GoogleRefreshTokenArgs};
use crate::credentials::worldlabs_refresh_token::WorldLabsRefreshToken;
use crate::test_utils::setup_test_logging::setup_test_logging;
use errors::AnyhowResult;
use log::LevelFilter;
#[tokio::test]
#[ignore] // Client side tests only
async fn test_requests() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Debug);
let refresh_token = "AMf-vByUgA0J9S93vULECe-sD50cbR85AFVYF60gKAmLFF1MBYwXlKRJCsv7z3oSpLtP0ApyOU5fAl52qHWM2U0yKgEu5gQ5UCUDt5SEG4UBRHWvHEmLLcu-Zl04Fq4ljocSEO3qJzuX1wyshmptZxlDPRkjdLYgyn-Kp03woI2yRPKLuQF5hQtABHabsNUh9Zhs129sgxuUlOuw4zsFI4L8UdE3bEuF3k6Mic7KlE3440YwyQ8Qtmk9zHtXIx2ob56N-1WTGSqwMUuQ1mX4OOvLve9bw2zDW3UzchomTWkh732886RPrP-DNRkPbO3FejUEMxuYQFWYX3EEeIEdfNGXFNGPL1dNniSXlSaKadpX699Ishjviv-x9o-pPQa3zoAj3NzWLyd8mn5rDBh9qAO_iDETaeo01Sjhb4mGYfAWZ-g4S6nW778";
let refresh_token = WorldLabsRefreshToken::new(refresh_token.to_string());
let result = google_refresh_token(GoogleRefreshTokenArgs {
refresh_token: &refresh_token,
request_timeout: None,
}).await?;
Ok(())
}
}

View File

@ -1,91 +0,0 @@
use crate::api::api_types::upload_mime_type::UploadMimeType;
use crate::api::common::common_header_values::{ORIGIN_VALUE, REFERER_VALUE};
use crate::credentials::world_labs_bearer_token::WorldLabsBearerToken;
use crate::credentials::world_labs_cookies::WorldLabsCookies;
use crate::error::world_labs_client_error::WorldLabsClientError;
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 http_headers::names::{PRIORITY, SEC_FETCH_DEST, SEC_FETCH_MODE, SEC_FETCH_SITE, SEC_GPC};
use http_headers::values::accept::ACCEPT_ALL;
use http_headers::values::cache_control::CACHE_CONTROL_NO_CACHE;
use http_headers::values::connection::CONNECTION_KEEP_ALIVE;
use http_headers::values::pragma::PRAGMA_NO_CACHE;
use http_headers::values::priority::PRIORITY_6;
use http_headers::values::sec::{SEC_FETCH_DEST_EMPTY, SEC_FETCH_MODE_CORS, SEC_FETCH_SITE_CROSS_SITE};
use http_headers::values::te::TE_TRAILERS;
use log::{debug, error};
use serde::Serialize;
use std::time::Duration;
use wreq::header::{ACCEPT, ACCEPT_ENCODING, ACCEPT_LANGUAGE, AUTHORIZATION, CACHE_CONTROL, CONNECTION, CONTENT_LENGTH, CONTENT_TYPE, ORIGIN, PRAGMA, REFERER, TE};
use wreq::Client;
use wreq_util::Emulation;
pub struct GoogleUploadImageArgs<'a> {
pub upload_url: &'a str,
pub upload_mime_type: UploadMimeType,
pub file_bytes: Vec<u8>,
pub request_timeout: Option<Duration>,
}
/// Marble Image-to-World
/// Upload the binary to GCP using the signed URL
/// Request #4 (of ~10)
pub async fn google_upload_image(args: GoogleUploadImageArgs<'_>) -> Result<(), WorldLabsError> {
let client = Client::builder()
.emulation(Emulation::Firefox143)
.build()
.map_err(|err| WorldLabsClientError::WreqClientError(err))?;
let content_type = args.upload_mime_type.content_type();
debug!("Requesting URL: {}", args.upload_url);
let mut request_builder = client.put(args.upload_url)
.header(CONTENT_TYPE, content_type)
.header("x-goog-content-length-range", "0,1048576000");
if let Some(timeout) = args.request_timeout {
request_builder = request_builder.timeout(timeout);
}
let http_request = request_builder
.body(args.file_bytes)
.build()
.map_err(|err| {
error!("Error building request: {:?}", err);
WorldLabsClientError::WreqClientError(err)
})?;
let response = client.execute(http_request)
.await
.map_err(|err| {
error!("Error during request execution: {:?}", 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!("Request returned an error (code {}) : {:?}", status.as_u16(), response_body);
if response_body.contains("SignatureDoesNotMatch") || response_body.contains("does not match the signature") {
return Err(WorldLabsSpecificApiError::GoogleUploadSignatureDoesNotMatch.into());
} else {
return Err(WorldLabsGenericApiError::GoogleUploadFailed { status_code: status, body: response_body }.into());
}
}
Ok(())
}
#[cfg(test)]
mod tests {
}

View File

@ -1 +0,0 @@
pub mod google_upload_image;

View File

@ -1,5 +0,0 @@
pub mod google_upload;
pub mod objects;
pub mod recaption;
pub mod worlds;
pub mod google_refresh_token;

View File

@ -1,147 +0,0 @@
use crate::api::api_types::run_object_id::RunObjectId;
use crate::api::api_types::upload_mime_type::UploadMimeType;
use crate::api::api_types::upload_object_id::UploadObjectId;
use crate::api::common::common_header_values::{ORIGIN_VALUE, REFERER_VALUE};
use crate::credentials::world_labs_bearer_token::WorldLabsBearerToken;
use crate::credentials::world_labs_cookies::WorldLabsCookies;
use crate::error::world_labs_client_error::WorldLabsClientError;
use crate::error::world_labs_error::WorldLabsError;
use crate::error::world_labs_generic_api_error::WorldLabsGenericApiError;
use chrono::Utc;
use http_headers::names::{PRIORITY, SEC_FETCH_DEST, SEC_FETCH_MODE, SEC_FETCH_SITE, SEC_GPC};
use http_headers::values::accept::ACCEPT_ALL;
use http_headers::values::cache_control::CACHE_CONTROL_NO_CACHE;
use http_headers::values::connection::CONNECTION_KEEP_ALIVE;
use http_headers::values::content_type::CONTENT_TYPE_APPLICATION_JSON;
use http_headers::values::pragma::PRAGMA_NO_CACHE;
use http_headers::values::priority::PRIORITY_4;
use http_headers::values::sec::{SEC_FETCH_DEST_EMPTY, SEC_FETCH_MODE_CORS, SEC_FETCH_SITE_CROSS_SITE};
use http_headers::values::te::TE_TRAILERS;
use log::{debug, error, info};
use serde::{Deserialize, Serialize};
use std::time::Duration;
use uuid::Uuid;
use wreq::header::{ACCEPT, ACCEPT_ENCODING, ACCEPT_LANGUAGE, AUTHORIZATION, CACHE_CONTROL, CONNECTION, CONTENT_LENGTH, CONTENT_TYPE, ORIGIN, PRAGMA, REFERER, TE};
use wreq::Client;
use wreq_util::Emulation;
const BASE_URL : &str = "https://api.worldlabs.ai/api/v1/objects";
// Note: WorldLabs is phasing out the old URL scheme:
// const BASE_URL : &str = "https://marble2-kgw-prod-iac1.wlt-ai.art/api/v1/objects";
fn get_url(upload_id: &UploadObjectId) -> String {
format!("{}/{}:upload", BASE_URL, upload_id.0)
}
pub struct BeginObjectImageUploadArgs<'a> {
pub cookies: &'a WorldLabsCookies,
pub bearer_token: &'a WorldLabsBearerToken,
pub upload_mime_type: UploadMimeType,
pub upload_id: &'a UploadObjectId,
pub request_timeout: Option<Duration>,
}
pub struct BeginObjectImageUploadResponse {
pub upload_url: String,
}
/// Marble Image-to-World
/// This request follows object attachment and signals upload. It returns the upload URL.
/// Request #3 (of ~10)
pub async fn begin_object_image_upload(args: BeginObjectImageUploadArgs<'_>) -> Result<BeginObjectImageUploadResponse, WorldLabsError> {
let client = Client::builder()
.emulation(Emulation::Firefox143)
.build()
.map_err(|err| WorldLabsClientError::WreqClientError(err))?;
let url = get_url(args.upload_id);
debug!("Requesting URL: {}", url);
let mut request_builder = client.post(url)
.header(ACCEPT, ACCEPT_ALL)
.header(ACCEPT_LANGUAGE, "en-US,en;q=0.5")
.header(ACCEPT_ENCODING, "gzip, deflate, br, zstd")
.header(REFERER, REFERER_VALUE)
.header(CONTENT_TYPE, CONTENT_TYPE_APPLICATION_JSON)
.header(AUTHORIZATION, args.bearer_token.to_bearer_token_header_string())
.header(ORIGIN, ORIGIN_VALUE)
.header(SEC_GPC, "1")
.header(CONNECTION, CONNECTION_KEEP_ALIVE)
.header(SEC_FETCH_DEST, SEC_FETCH_DEST_EMPTY)
.header(SEC_FETCH_MODE, SEC_FETCH_MODE_CORS)
.header(SEC_FETCH_SITE, SEC_FETCH_SITE_CROSS_SITE)
.header(PRIORITY, PRIORITY_4)
.header(PRAGMA, PRAGMA_NO_CACHE)
.header(CACHE_CONTROL, CACHE_CONTROL_NO_CACHE)
.header(CONTENT_LENGTH, 0)
.header(TE, TE_TRAILERS);
if let Some(timeout) = args.request_timeout {
request_builder = request_builder.timeout(timeout);
}
let http_request = request_builder
.build()
.map_err(|err| {
error!("Error building request: {:?}", err);
WorldLabsClientError::WreqClientError(err)
})?;
let response = client.execute(http_request)
.await
.map_err(|err| {
error!("Error during request execution: {:?}", 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)
})?;
// TODO: Handle errors (Cloudflare, Grok, etc.)
if !status.is_success() {
error!("Request returned an error (code {}) : {:?}", status.as_u16(), response_body);
//return Err(classify_general_http_status_code_and_body(status, response_body));
return Err(WorldLabsGenericApiError::UncategorizedBadResponseWithStatusAndBody { status_code: status, body: response_body}.into())
}
debug!("Response body (200) (ready for Google upload): {}", response_body);
let response : RawResponse = serde_json::from_str(&response_body)
.map_err(|err| WorldLabsGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.to_string()))?;
info!("Curl command: {}", response.curl_example);
Ok(BeginObjectImageUploadResponse {
upload_url: response.upload_url,
})
}
#[derive(Deserialize)]
struct RawResponse {
/// eg. "https://storage.googleapis.com/wlt-training-gsc-marble-assets-prod/temp/objects/{...}",
pub upload_url: String,
/// Just for debugging, the server reports a test cURL command
pub curl_example: String,
}
#[cfg(test)]
mod tests {
use crate::api::api_types::upload_object_id::UploadObjectId;
use crate::api::requests::objects::begin_object_image_upload::{get_url, BASE_URL};
#[test]
fn test_get_url() {
let upload_id = UploadObjectId("foo-bar-baz-bin".to_string());
let expected = format!("{}/foo-bar-baz-bin:upload", BASE_URL);
assert_eq!(get_url(&upload_id), expected);
}
}

View File

@ -1,124 +0,0 @@
use crate::api::api_types::run_object::RunObject;
use crate::api::api_types::run_object_id::RunObjectId;
use crate::api::common::common_header_values::{ORIGIN_VALUE, REFERER_VALUE};
use crate::credentials::world_labs_bearer_token::WorldLabsBearerToken;
use crate::credentials::world_labs_cookies::WorldLabsCookies;
use crate::error::world_labs_client_error::WorldLabsClientError;
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 chrono::Utc;
use http_headers::names::{PRIORITY, SEC_FETCH_DEST, SEC_FETCH_MODE, SEC_FETCH_SITE, SEC_GPC};
use http_headers::values::accept::ACCEPT_ALL;
use http_headers::values::cache_control::CACHE_CONTROL_NO_CACHE;
use http_headers::values::connection::CONNECTION_KEEP_ALIVE;
use http_headers::values::content_type::CONTENT_TYPE_APPLICATION_JSON;
use http_headers::values::pragma::PRAGMA_NO_CACHE;
use http_headers::values::priority::PRIORITY_HIGHEST;
use http_headers::values::sec::{SEC_FETCH_DEST_EMPTY, SEC_FETCH_MODE_CORS, SEC_FETCH_SITE_CROSS_SITE};
use http_headers::values::te::TE_TRAILERS;
use log::{debug, error};
use serde::{Deserialize, Serialize};
use std::time::Duration;
use wreq::header::{ACCEPT, ACCEPT_ENCODING, ACCEPT_LANGUAGE, AUTHORIZATION, CACHE_CONTROL, CONNECTION, CONTENT_TYPE, ORIGIN, PRAGMA, REFERER, TE};
use wreq::Client;
use wreq_util::Emulation;
const URL : &str = "https://api.worldlabs.ai/api/v1/objects";
// Note: WorldLabs is phasing out the old URL scheme:
// const URL : &str = "https://marble2-kgw-prod-iac1.wlt-ai.art/api/v1/objects";
pub struct CreateRunObjectArgs<'a> {
pub cookies: &'a WorldLabsCookies,
pub bearer_token: &'a WorldLabsBearerToken,
pub request_timeout: Option<Duration>,
}
pub struct CreateRunObjectResponse {
pub id: RunObjectId,
pub run_object: RunObject,
}
/// Marble Image-to-World
/// This request starts the process: it initializes a new world object inference and returns the object id.
/// Request #1 (of ~10)
pub async fn create_run_object(args: CreateRunObjectArgs<'_>) -> Result<CreateRunObjectResponse, WorldLabsError> {
let client = Client::builder()
.emulation(Emulation::Firefox143)
.build()
.map_err(|err| WorldLabsClientError::WreqClientError(err))?;
debug!("Requesting URL: {}", URL);
let mut request_builder = client.post(URL)
.header(ACCEPT, ACCEPT_ALL)
.header(ACCEPT_LANGUAGE, "en-US,en;q=0.5")
.header(ACCEPT_ENCODING, "gzip, deflate, br, zstd")
.header(REFERER, REFERER_VALUE)
.header(CONTENT_TYPE, CONTENT_TYPE_APPLICATION_JSON)
.header(AUTHORIZATION, args.bearer_token.to_bearer_token_header_string())
.header(ORIGIN, ORIGIN_VALUE)
.header(SEC_GPC, "1")
.header(CONNECTION, CONNECTION_KEEP_ALIVE)
.header(SEC_FETCH_DEST, SEC_FETCH_DEST_EMPTY)
.header(SEC_FETCH_MODE, SEC_FETCH_MODE_CORS)
.header(SEC_FETCH_SITE, SEC_FETCH_SITE_CROSS_SITE)
.header(PRIORITY, PRIORITY_HIGHEST)
.header(PRAGMA, PRAGMA_NO_CACHE)
.header(CACHE_CONTROL, CACHE_CONTROL_NO_CACHE)
.header(TE, TE_TRAILERS);
if let Some(timeout) = args.request_timeout {
request_builder = request_builder.timeout(timeout);
}
let new_run_object = RunObject::default();
let http_request = request_builder.json(&new_run_object)
.build()
.map_err(|err| {
error!("Error building request: {:?}", err);
WorldLabsClientError::WreqClientError(err)
})?;
let response = client.execute(http_request)
.await
.map_err(|err| {
error!("Error during request execution: {:?}", 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)
})?;
// TODO: Handle errors (Cloudflare, Grok, etc.)
if !status.is_success() {
error!("Request returned an error (code {}) : {:?}", status.as_u16(), response_body);
//return Err(classify_general_http_status_code_and_body(status, response_body));
return Err(WorldLabsGenericApiError::UncategorizedBadResponseWithStatusAndBody { status_code: status, body: response_body}.into())
}
debug!("Response body (200): {}", response_body);
let response : RunObject = serde_json::from_str(&response_body)
.map_err(|err| WorldLabsGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.to_string()))?;
let run_id = match &response.id {
Some(id) => RunObjectId(id.to_string()),
None => {
return Err(WorldLabsSpecificApiError::NoRunIdInResponse { response_body }.into())
}
};
Ok(CreateRunObjectResponse {
id: run_id,
run_object: response,
})
}

View File

@ -1,155 +0,0 @@
use crate::api::api_types::run_object_id::RunObjectId;
use crate::api::api_types::upload_mime_type::UploadMimeType;
use crate::api::api_types::upload_object_id::UploadObjectId;
use crate::api::common::common_header_values::{ORIGIN_VALUE, REFERER_VALUE};
use crate::credentials::world_labs_bearer_token::WorldLabsBearerToken;
use crate::credentials::world_labs_cookies::WorldLabsCookies;
use crate::error::world_labs_client_error::WorldLabsClientError;
use crate::error::world_labs_error::WorldLabsError;
use crate::error::world_labs_generic_api_error::WorldLabsGenericApiError;
use chrono::Utc;
use http_headers::names::{PRIORITY, SEC_FETCH_DEST, SEC_FETCH_MODE, SEC_FETCH_SITE, SEC_GPC};
use http_headers::values::accept::ACCEPT_ALL;
use http_headers::values::cache_control::CACHE_CONTROL_NO_CACHE;
use http_headers::values::connection::CONNECTION_KEEP_ALIVE;
use http_headers::values::content_type::CONTENT_TYPE_APPLICATION_JSON;
use http_headers::values::pragma::PRAGMA_NO_CACHE;
use http_headers::values::priority::PRIORITY_4;
use http_headers::values::sec::{SEC_FETCH_DEST_EMPTY, SEC_FETCH_MODE_CORS, SEC_FETCH_SITE_CROSS_SITE};
use http_headers::values::te::TE_TRAILERS;
use log::{debug, error, info};
use serde::{Deserialize, Serialize};
use std::time::Duration;
use uuid::Uuid;
use wreq::header::{ACCEPT, ACCEPT_ENCODING, ACCEPT_LANGUAGE, AUTHORIZATION, CACHE_CONTROL, CONNECTION, CONTENT_TYPE, ORIGIN, PRAGMA, REFERER, TE};
use wreq::Client;
use wreq_util::Emulation;
const URL : &str = "https://api.worldlabs.ai/api/v1/objects";
// Note: WorldLabs is phasing out the old URL scheme:
// const URL : &str = "https://marble2-kgw-prod-iac1.wlt-ai.art/api/v1/objects";
pub struct CreateUploadObjectArgs<'a> {
pub cookies: &'a WorldLabsCookies,
pub bearer_token: &'a WorldLabsBearerToken,
pub upload_mime_type: UploadMimeType,
pub request_timeout: Option<Duration>,
}
pub struct CreateUploadObjectResponse {
pub id: UploadObjectId,
}
/// Marble Image-to-World
/// This request follows object initialization and prepares for image upload.
/// Request #2 (of ~10)
pub async fn create_upload_object(args: CreateUploadObjectArgs<'_>) -> Result<CreateUploadObjectResponse, WorldLabsError> {
let client = Client::builder()
.emulation(Emulation::Firefox143)
.build()
.map_err(|err| WorldLabsClientError::WreqClientError(err))?;
debug!("Requesting URL: {}", URL);
let mut request_builder = client.post(URL)
.header(ACCEPT, ACCEPT_ALL)
.header(ACCEPT_LANGUAGE, "en-US,en;q=0.5")
.header(ACCEPT_ENCODING, "gzip, deflate, br, zstd")
.header(REFERER, REFERER_VALUE)
.header(CONTENT_TYPE, CONTENT_TYPE_APPLICATION_JSON)
.header(AUTHORIZATION, args.bearer_token.to_bearer_token_header_string())
.header(ORIGIN, ORIGIN_VALUE)
.header(SEC_GPC, "1")
.header(CONNECTION, CONNECTION_KEEP_ALIVE)
.header(SEC_FETCH_DEST, SEC_FETCH_DEST_EMPTY)
.header(SEC_FETCH_MODE, SEC_FETCH_MODE_CORS)
.header(SEC_FETCH_SITE, SEC_FETCH_SITE_CROSS_SITE)
.header(PRIORITY, PRIORITY_4)
.header(PRAGMA, PRAGMA_NO_CACHE)
.header(CACHE_CONTROL, CACHE_CONTROL_NO_CACHE)
.header(TE, TE_TRAILERS);
if let Some(timeout) = args.request_timeout {
request_builder = request_builder.timeout(timeout);
}
let request_payload = RawRequest::for_mime_type(args.upload_mime_type);
let http_request = request_builder.json(&request_payload)
.build()
.map_err(|err| {
error!("Error building request: {:?}", err);
WorldLabsClientError::WreqClientError(err)
})?;
let response = client.execute(http_request)
.await
.map_err(|err| {
error!("Error during request execution: {:?}", 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)
})?;
// TODO: Handle errors (Cloudflare, Grok, etc.)
if !status.is_success() {
error!("Request returned an error (code {}) : {:?}", status.as_u16(), response_body);
//return Err(classify_general_http_status_code_and_body(status, response_body));
return Err(WorldLabsGenericApiError::UncategorizedBadResponseWithStatusAndBody { status_code: status, body: response_body}.into())
}
debug!("Response body (200): {}", response_body);
let response : RawResponse = serde_json::from_str(&response_body)
.map_err(|err| WorldLabsGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.to_string()))?;
Ok(CreateUploadObjectResponse {
id: UploadObjectId(response.id),
})
}
#[derive(Serialize)]
struct RawRequest {
pub display_name: String,
pub mime_type: String,
}
impl RawRequest {
pub fn for_mime_type(mime_type: UploadMimeType) -> Self {
let uuid = Uuid::new_v4().to_string();
let extension = mime_type.extension_without_period();
let display_name = format!("uploaded_image_{uuid}.{extension}");
Self {
display_name,
mime_type: mime_type.content_type().to_string(),
}
}
}
#[derive(Deserialize)]
struct RawResponse {
pub id: String,
}
#[cfg(test)]
mod tests {
use crate::api::api_types::upload_mime_type::UploadMimeType;
use crate::api::requests::objects::create_upload_object::RawRequest;
#[test]
fn test_request() {
let request = RawRequest::for_mime_type(UploadMimeType::ImageJpeg);
// The display name embeds a random uuid, eg. "uploaded_image_{uuid}.jpeg"
assert!(request.display_name.starts_with("uploaded_image_"));
assert!(request.display_name.ends_with(".jpeg"));
assert_eq!(request.mime_type, "image/jpeg");
}
}

View File

@ -1,172 +0,0 @@
use crate::api::api_types::run_object_id::RunObjectId;
use crate::api::api_types::upload_mime_type::UploadMimeType;
use crate::api::api_types::upload_object_id::UploadObjectId;
use crate::api::common::common_header_values::{ORIGIN_VALUE, REFERER_VALUE};
use crate::credentials::world_labs_bearer_token::WorldLabsBearerToken;
use crate::credentials::world_labs_cookies::WorldLabsCookies;
use crate::error::world_labs_client_error::WorldLabsClientError;
use crate::error::world_labs_error::WorldLabsError;
use crate::error::world_labs_generic_api_error::WorldLabsGenericApiError;
use chrono::Utc;
use http_headers::names::{PRIORITY, SEC_FETCH_DEST, SEC_FETCH_MODE, SEC_FETCH_SITE, SEC_GPC};
use http_headers::values::accept::ACCEPT_ALL;
use http_headers::values::cache_control::CACHE_CONTROL_NO_CACHE;
use http_headers::values::connection::CONNECTION_KEEP_ALIVE;
use http_headers::values::content_type::CONTENT_TYPE_APPLICATION_JSON;
use http_headers::values::pragma::PRAGMA_NO_CACHE;
use http_headers::values::priority::PRIORITY_4;
use http_headers::values::sec::{SEC_FETCH_DEST_EMPTY, SEC_FETCH_MODE_CORS, SEC_FETCH_SITE_CROSS_SITE};
use http_headers::values::te::TE_TRAILERS;
use log::{debug, error};
use serde::{Deserialize, Serialize};
use std::time::Duration;
use uuid::Uuid;
use wreq::header::{ACCEPT, ACCEPT_ENCODING, ACCEPT_LANGUAGE, AUTHORIZATION, CACHE_CONTROL, CONNECTION, CONTENT_LENGTH, CONTENT_TYPE, ORIGIN, PRAGMA, REFERER, TE};
use wreq::Client;
use wreq_util::Emulation;
const BASE_URL : &str = "https://api.worldlabs.ai/api/v1/objects";
// Note: WorldLabs is phasing out the old URL scheme:
// const BASE_URL : &str = "https://marble2-kgw-prod-iac1.wlt-ai.art/api/v1/objects";
fn get_url(upload_id: &UploadObjectId) -> String {
format!("{}/{}:complete", BASE_URL, upload_id.0)
}
pub struct FinalizeObjectImageUploadArgs<'a> {
pub cookies: &'a WorldLabsCookies,
pub bearer_token: &'a WorldLabsBearerToken,
pub upload_id: &'a UploadObjectId,
pub request_timeout: Option<Duration>,
}
pub struct FinalizeObjectImageUploadResponse {
pub object_url: String,
pub updated_at: u64,
}
/// Marble Image-to-World
/// Mark the upload as complete.
/// Request #5 (of ~10)
pub async fn finalize_object_image_upload(args: FinalizeObjectImageUploadArgs<'_>) -> Result<FinalizeObjectImageUploadResponse, WorldLabsError> {
let client = Client::builder()
.emulation(Emulation::Firefox143)
.build()
.map_err(|err| WorldLabsClientError::WreqClientError(err))?;
let url = get_url(args.upload_id);
debug!("Requesting URL: {}", url);
let mut request_builder = client.post(url)
.header(ACCEPT, ACCEPT_ALL)
.header(ACCEPT_LANGUAGE, "en-US,en;q=0.5")
.header(ACCEPT_ENCODING, "gzip, deflate, br, zstd")
.header(REFERER, REFERER_VALUE)
.header(AUTHORIZATION, args.bearer_token.to_bearer_token_header_string())
.header(ORIGIN, ORIGIN_VALUE)
.header(SEC_GPC, "1")
.header(CONNECTION, CONNECTION_KEEP_ALIVE)
.header(SEC_FETCH_DEST, SEC_FETCH_DEST_EMPTY)
.header(SEC_FETCH_MODE, SEC_FETCH_MODE_CORS)
.header(SEC_FETCH_SITE, SEC_FETCH_SITE_CROSS_SITE)
.header(PRIORITY, PRIORITY_4)
.header(PRAGMA, PRAGMA_NO_CACHE)
.header(CACHE_CONTROL, CACHE_CONTROL_NO_CACHE)
.header(CONTENT_LENGTH, 0)
.header(TE, TE_TRAILERS);
if let Some(timeout) = args.request_timeout {
request_builder = request_builder.timeout(timeout);
}
let http_request = request_builder
.build()
.map_err(|err| {
error!("Error building request: {:?}", err);
WorldLabsClientError::WreqClientError(err)
})?;
let response = client.execute(http_request)
.await
.map_err(|err| {
error!("Error during request execution: {:?}", 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)
})?;
// TODO: Handle errors (Cloudflare, Grok, etc.)
if !status.is_success() {
error!("Request returned an error (code {}) : {:?}", status.as_u16(), response_body);
//return Err(classify_general_http_status_code_and_body(status, response_body));
return Err(WorldLabsGenericApiError::UncategorizedBadResponseWithStatusAndBody { status_code: status, body: response_body}.into())
}
debug!("Response body (200): {}", response_body);
let response : RawResponse = serde_json::from_str(&response_body)
.map_err(|err| WorldLabsGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.to_string()))?;
Ok(FinalizeObjectImageUploadResponse {
object_url: response.object_uri,
updated_at: response.updated_at,
})
}
#[derive(Deserialize)]
struct RawResponse {
/// eg. "https://cdn.marble.worldlabs.ai/object/foo-bar-baz-bin/asset.jpg"
pub object_uri: String,
/// Seconds-resolution timestamp.
/// eg. `1765745771`
pub updated_at: u64,
}
#[cfg(test)]
mod tests {
use crate::api::api_types::upload_object_id::UploadObjectId;
use crate::api::requests::objects::finalize_object_image_upload::{finalize_object_image_upload, get_url, FinalizeObjectImageUploadArgs, BASE_URL};
use crate::test_utils::get_test_bearer_token::get_test_bearer_token;
use crate::test_utils::get_test_cookies::get_typed_test_cookies;
use crate::test_utils::setup_test_logging::setup_test_logging;
use log::LevelFilter;
#[test]
fn test_get_url() {
let upload_id = UploadObjectId("foo-bar-baz-bin".to_string());
let expected = format!("{}/foo-bar-baz-bin:complete", BASE_URL);
assert_eq!(get_url(&upload_id), expected);
}
#[tokio::test]
#[ignore] // Client side tests only
async fn test_requests() {
setup_test_logging(LevelFilter::Debug);
let cookies = get_typed_test_cookies().unwrap();
let bearer_token = get_test_bearer_token().unwrap();
let upload_id = UploadObjectId("09d86ef8-edcd-42d9-9b95-8efd28be0bee".to_string());
let response = finalize_object_image_upload(FinalizeObjectImageUploadArgs {
cookies: &cookies,
bearer_token: &bearer_token,
upload_id: &upload_id,
request_timeout: None,
}).await.unwrap();
println!("Object URL: {}", response.object_url);
assert_eq!(1, 2);
}
}

View File

@ -1,6 +0,0 @@
pub mod create_upload_object;
pub mod begin_object_image_upload;
pub mod create_run_object;
pub mod finalize_object_image_upload;
pub mod update_run_object_with_upload;
pub mod update_run_object_with_world;

View File

@ -1,523 +0,0 @@
use crate::api::api_types::image_input_object_id::ImageInputObjectId;
use crate::api::api_types::meta_world_object_id::MetaWorldObjectId;
use crate::api::api_types::pano_object_id::PanoObjectId;
use crate::api::api_types::run_object_id::RunObjectId;
use crate::api::api_types::upload_mime_type::UploadMimeType;
use crate::api::api_types::upload_object_id::UploadObjectId;
use crate::api::common::common_header_values::{ORIGIN_VALUE, REFERER_VALUE};
use crate::api::utils::upload_id_to_image_url::upload_id_to_image_url;
use crate::credentials::world_labs_bearer_token::WorldLabsBearerToken;
use crate::credentials::world_labs_cookies::WorldLabsCookies;
use crate::error::world_labs_client_error::WorldLabsClientError;
use crate::error::world_labs_error::WorldLabsError;
use crate::error::world_labs_generic_api_error::WorldLabsGenericApiError;
use chrono::Utc;
use http_headers::names::{PRIORITY, SEC_FETCH_DEST, SEC_FETCH_MODE, SEC_FETCH_SITE, SEC_GPC};
use http_headers::values::accept::ACCEPT_ALL;
use http_headers::values::cache_control::CACHE_CONTROL_NO_CACHE;
use http_headers::values::connection::CONNECTION_KEEP_ALIVE;
use http_headers::values::content_type::CONTENT_TYPE_APPLICATION_JSON;
use http_headers::values::pragma::PRAGMA_NO_CACHE;
use http_headers::values::priority::PRIORITY_4;
use http_headers::values::sec::{SEC_FETCH_DEST_EMPTY, SEC_FETCH_MODE_CORS, SEC_FETCH_SITE_CROSS_SITE};
use http_headers::values::te::TE_TRAILERS;
use indexmap::IndexMap;
use log::{debug, error};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::time::Duration;
use wreq::header::{ACCEPT, ACCEPT_ENCODING, ACCEPT_LANGUAGE, AUTHORIZATION, CACHE_CONTROL, CONNECTION, CONTENT_TYPE, ORIGIN, PRAGMA, REFERER, TE};
use wreq::Client;
use wreq_util::Emulation;
const BASE_URL : &str = "https://api.worldlabs.ai/api/v1/objects";
// Note: WorldLabs is phasing out the old URL scheme:
// const BASE_URL : &str = "https://marble2-kgw-prod-iac1.wlt-ai.art/api/v1/objects";
fn get_url(run_id: &RunObjectId) -> String {
format!("{}/{}", BASE_URL, run_id.0)
}
pub struct UpdateRunObjectWithUploadArgs<'a> {
pub cookies: &'a WorldLabsCookies,
pub bearer_token: &'a WorldLabsBearerToken,
pub payload_args: UpdateRunObjectWithUploadPayloadArgs<'a>,
pub request_timeout: Option<Duration>,
}
pub struct UpdateRunObjectWithUploadPayloadArgs<'a> {
pub run_id: &'a RunObjectId,
pub run_created_at_timestamp: u64,
// eg. https://cdn.marble.worldlabs.ai/object/13ca760c-8ef9-4bf5-acc3-5a507fad3abf/asset.jpg
pub image_upload_url: &'a str,
pub image_input_id: &'a ImageInputObjectId,
pub pano_id: &'a PanoObjectId,
pub meta_world_id: &'a MetaWorldObjectId,
}
pub struct UpdateRunObjectWithUploadResponse {
pub image_input_id: ImageInputObjectId,
pub pano_id: PanoObjectId,
pub meta_world_id: MetaWorldObjectId,
pub run_updated_timestamp: u64,
}
/// Marble Image-to-World
/// This request patches an object. Seems to update it with the attached files.
/// Request #6 (of ~10)
pub async fn update_run_object_with_upload(args: UpdateRunObjectWithUploadArgs<'_>) -> Result<UpdateRunObjectWithUploadResponse, WorldLabsError> {
let client = Client::builder()
.emulation(Emulation::Firefox143)
.build()
.map_err(|err| WorldLabsClientError::WreqClientError(err))?;
let url = get_url(args.payload_args.run_id);
debug!("Requesting URL: {}", url);
let mut request_builder = client.patch(url)
.header(ACCEPT, ACCEPT_ALL)
.header(ACCEPT_LANGUAGE, "en-US,en;q=0.5")
.header(ACCEPT_ENCODING, "gzip, deflate, br, zstd")
.header(REFERER, REFERER_VALUE)
.header(CONTENT_TYPE, CONTENT_TYPE_APPLICATION_JSON)
.header(AUTHORIZATION, args.bearer_token.to_bearer_token_header_string())
.header(ORIGIN, ORIGIN_VALUE)
.header(SEC_GPC, "1")
.header(CONNECTION, CONNECTION_KEEP_ALIVE)
.header(SEC_FETCH_DEST, SEC_FETCH_DEST_EMPTY)
.header(SEC_FETCH_MODE, SEC_FETCH_MODE_CORS)
.header(SEC_FETCH_SITE, SEC_FETCH_SITE_CROSS_SITE)
.header(PRIORITY, PRIORITY_4)
.header(PRAGMA, PRAGMA_NO_CACHE)
.header(CACHE_CONTROL, CACHE_CONTROL_NO_CACHE)
.header(TE, TE_TRAILERS);
if let Some(timeout) = args.request_timeout {
request_builder = request_builder.timeout(timeout);
}
let payload = RawRequest::from_args(&args.payload_args);
let updated_at = payload.object.metadata.updated_at;
let http_request = request_builder.json(&payload)
.build()
.map_err(|err| {
error!("Error building request: {:?}", err);
WorldLabsClientError::WreqClientError(err)
})?;
let response = client.execute(http_request)
.await
.map_err(|err| {
error!("Error during request execution: {:?}", 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)
})?;
// TODO: Handle errors (Cloudflare, Grok, etc.)
if !status.is_success() {
error!("Request returned an error (code {}) : {:?}", status.as_u16(), response_body);
//return Err(classify_general_http_status_code_and_body(status, response_body));
return Err(WorldLabsGenericApiError::UncategorizedBadResponseWithStatusAndBody { status_code: status, body: response_body}.into())
}
debug!("Response body (200): {}", response_body);
let _response : RawResponse = serde_json::from_str(&response_body)
.map_err(|err| WorldLabsGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.to_string()))?;
Ok(UpdateRunObjectWithUploadResponse {
image_input_id: args.payload_args.image_input_id.clone(),
pano_id: args.payload_args.pano_id.clone(),
meta_world_id: args.payload_args.meta_world_id.clone(),
run_updated_timestamp: updated_at,
})
}
#[derive(Serialize)]
struct RawRequest {
pub object: ObjectDef,
pub update_mask: Vec<String>,
}
impl Default for RawRequest {
fn default() -> Self {
let now = Utc::now();
let now = now.timestamp_millis().unsigned_abs();
Self {
object: ObjectDef {
metadata: ObjectMetadata {
version: "0.0.1".to_string(),
created_at: now,
updated_at: now,
uses_advanced_editing: false,
draft_mode: false,
nodes: IndexMap::new(),
}
},
update_mask: vec!["metadata".to_string()]
}
}
}
impl RawRequest {
pub fn from_args(args: &UpdateRunObjectWithUploadPayloadArgs) -> Self {
let mut request = RawRequest::default();
request.add_image_input_node({
let mut node = ImageInputNode::default();
node.id = args.image_input_id.to_string();
node.source.image_url = args.image_upload_url.to_string();
node
});
request.add_pano_node({
let mut node = PanoNode::default();
node.id = args.pano_id.to_string();
node.parent_id= Some(args.image_input_id.to_string());
node
});
request.add_world_node({
let mut node = WorldNode::default();
node.id = args.meta_world_id.to_string();
node.parent_id= Some(args.pano_id.to_string());
node
});
request.set_updated_now();
request
}
pub fn add_image_input_node(&mut self, mut node: ImageInputNode) {
node.created_at = self.object.metadata.updated_at;
self.object.metadata.nodes.insert(node.id.clone(), NodeValue::ImageInput(node));
}
pub fn add_pano_node(&mut self, mut node: PanoNode) {
node.created_at = self.object.metadata.updated_at;
self.object.metadata.nodes.insert(node.id.clone(), NodeValue::Pano(node));
}
pub fn add_world_node(&mut self, mut node: WorldNode) {
node.created_at = self.object.metadata.updated_at;
self.object.metadata.nodes.insert(node.id.clone(), NodeValue::World(node));
}
pub fn set_updated_now(&mut self) {
let now = Utc::now();
let now = now.timestamp_millis().unsigned_abs();
self.set_updated_at(now);
}
pub fn set_updated_at(&mut self, updated_at: u64) {
self.object.metadata.updated_at = updated_at;
for (_key, node) in self.object.metadata.nodes.iter_mut() {
match node {
NodeValue::ImageInput(value) => value.created_at = updated_at,
NodeValue::Pano(value) => value.created_at = updated_at,
NodeValue::World(value) => value.created_at = updated_at,
}
}
}
}
#[derive(Serialize)]
struct ObjectDef {
pub metadata: ObjectMetadata,
}
#[derive(Serialize)]
struct ObjectMetadata {
pub version: String,
#[serde(rename = "createdAt")]
pub created_at: u64,
#[serde(rename = "updatedAt")]
pub updated_at: u64,
#[serde(rename = "usesAdvancedEditing")]
pub uses_advanced_editing: bool,
#[serde(rename = "draftMode")]
pub draft_mode: bool,
/// Polymorphic set of ID-to-object mappings.
/// NB: IndexMap maintains insertion order.
pub nodes: IndexMap<String, NodeValue>,
}
#[derive(Serialize)]
#[serde(untagged)]
//#[serde(tag = "type", content = "value")]
enum NodeValue {
ImageInput(ImageInputNode),
Pano(PanoNode),
World(WorldNode),
}
#[derive(Serialize, Default)]
struct ImageInputNode {
pub id: String,
pub r#type: TypeInput,
#[serde(rename = "parentId")]
pub parent_id: Option<String>,
pub source: ImageInputNodeSource,
#[serde(rename = "createdAt")]
pub created_at: u64,
}
#[derive(Serialize, Default)]
struct ImageInputNodeSource {
pub r#type: TypeImage,
pub image_url: String,
}
#[derive(Serialize, Default)]
enum TypeInput {
#[serde(rename = "input")]
#[default]
Input
}
#[derive(Serialize, Default)]
enum TypeImage {
#[serde(rename = "image")]
#[default]
Image
}
#[derive(Serialize, Default)]
enum StatusPending {
#[serde(rename = "pending")]
#[default]
Pending
}
impl ImageInputNode {
pub fn with_id(id: &str) -> Self{
Self {
id: id.to_string(),
..Default::default()
}
}
}
#[derive(Serialize, Default)]
struct PanoNode {
pub id: String,
pub r#type: TypePano,
#[serde(rename = "parentId")]
pub parent_id: Option<String>,
pub source: PanoNodeSource,
#[serde(rename = "createdAt")]
pub created_at: u64,
}
#[derive(Serialize, Default)]
struct PanoNodeSource {
pub r#type: TypeGenerateWorld,
pub world_id: StatusPending,
pub status: StatusPending,
}
#[derive(Serialize, Default)]
enum TypePano {
#[serde(rename = "pano")]
#[default]
Pano
}
#[derive(Serialize, Default)]
enum TypeGenerateWorld {
#[serde(rename = "generate_world")]
#[default]
GenerateWorld
}
impl PanoNode {
pub fn with_id(id: &str) -> Self{
Self {
id: id.to_string(),
..Default::default()
}
}
}
#[derive(Serialize, Default)]
struct WorldNode {
pub id: String,
pub r#type: TypeWorld,
#[serde(rename = "parentId")]
pub parent_id: Option<String>,
pub source: WorldNodeSource,
#[serde(rename = "createdAt")]
pub created_at: u64,
}
#[derive(Serialize, Default)]
struct WorldNodeSource {
pub r#type: TypeGenerateWorld,
pub world_id: StatusPending,
pub posed_cubemaps_url: String,
pub minimap_url: String,
pub minimap_metadata: String,
pub status: StatusPending,
}
#[derive(Serialize, Default)]
enum TypeWorld {
#[serde(rename = "world")]
#[default]
World
}
impl WorldNode {
pub fn with_id(id: &str) -> Self{
Self {
id: id.to_string(),
..Default::default()
}
}
}
#[derive(Deserialize)]
struct RawResponse {
pub id: String,
}
#[cfg(test)]
mod tests {
use crate::api::api_types::image_input_object_id::ImageInputObjectId;
use crate::api::api_types::meta_world_object_id::MetaWorldObjectId;
use crate::api::api_types::pano_object_id::PanoObjectId;
use crate::api::api_types::run_object_id::RunObjectId;
use crate::api::requests::objects::update_run_object_with_upload::{RawRequest, UpdateRunObjectWithUploadPayloadArgs};
use serde_json::{json, Value};
/// Any timestamp from `Utc::now()` should exceed this (2023-11-14 in epoch millis).
const MIN_PLAUSIBLE_EPOCH_MILLIS: u64 = 1_700_000_000_000;
#[test]
fn request_default() {
let request = RawRequest::default();
assert_eq!(request.object.metadata.version, "0.0.1");
assert!(request.object.metadata.created_at > 10000);
assert!(request.object.metadata.updated_at > 10000);
}
#[test]
fn json() {
let run_id = RunObjectId::from_str("79795b32-e44d-4333-bac1-05b2c9a3ea12");
let image_input_id = ImageInputObjectId::from_str("bc17252b-811e-49d7-be3e-b7c538df9d30");
let pano_id = PanoObjectId::from_str("82f6b488-8afe-4311-9022-80ad5789ad92");
let world_id = MetaWorldObjectId::from_str("f08ec501-601c-47c6-a104-402d1820b8f0");
let args = UpdateRunObjectWithUploadPayloadArgs {
run_created_at_timestamp: 1234,
image_upload_url: "https://todo.com",
run_id: &run_id,
image_input_id: &image_input_id,
pano_id: &pano_id,
meta_world_id: &world_id,
};
let request = RawRequest::from_args(&args);
// The `createdAt` / `updatedAt` timestamps come from `Utc::now()` at build time,
// so they're nondeterministic. Sanity-check them, then zero them out before
// comparing the rest of the document.
assert!(request.object.metadata.created_at > MIN_PLAUSIBLE_EPOCH_MILLIS);
assert!(request.object.metadata.updated_at >= request.object.metadata.created_at);
let mut actual = serde_json::to_value(&request).unwrap();
zero_out_timestamps(&mut actual);
let expected = json!({
"object": {
"metadata": {
"version": "0.0.1",
"createdAt": 0,
"updatedAt": 0,
"usesAdvancedEditing": false,
"draftMode": false,
"nodes": {
"bc17252b-811e-49d7-be3e-b7c538df9d30": {
"id": "bc17252b-811e-49d7-be3e-b7c538df9d30",
"type": "input",
"parentId": null,
"source": {
"type": "image",
"image_url": "https://todo.com"
},
"createdAt": 0
},
"82f6b488-8afe-4311-9022-80ad5789ad92": {
"id": "82f6b488-8afe-4311-9022-80ad5789ad92",
"type": "pano",
"parentId": "bc17252b-811e-49d7-be3e-b7c538df9d30",
"source": {
"type": "generate_world",
"world_id": "pending",
"status": "pending"
},
"createdAt": 0
},
"f08ec501-601c-47c6-a104-402d1820b8f0": {
"id": "f08ec501-601c-47c6-a104-402d1820b8f0",
"type": "world",
"parentId": "82f6b488-8afe-4311-9022-80ad5789ad92",
"source": {
"type": "generate_world",
"world_id": "pending",
"posed_cubemaps_url": "",
"minimap_url": "",
"minimap_metadata": "",
"status": "pending"
},
"createdAt": 0
}
}
}
},
"update_mask": [
"metadata"
]
});
assert_eq!(actual, expected);
}
/// Recursively replaces nondeterministic `createdAt` / `updatedAt` values with 0.
fn zero_out_timestamps(value: &mut Value) {
match value {
Value::Object(map) => {
for (key, child) in map.iter_mut() {
if key == "createdAt" || key == "updatedAt" {
*child = json!(0);
} else {
zero_out_timestamps(child);
}
}
},
Value::Array(items) => {
for item in items.iter_mut() {
zero_out_timestamps(item);
}
},
_ => {},
}
}
}

View File

@ -1,540 +0,0 @@
use crate::api::api_types::image_input_object_id::ImageInputObjectId;
use crate::api::api_types::pano_object_id::PanoObjectId;
use crate::api::api_types::run_object_id::RunObjectId;
use crate::api::api_types::upload_mime_type::UploadMimeType;
use crate::api::api_types::upload_object_id::UploadObjectId;
use crate::api::api_types::meta_world_object_id::MetaWorldObjectId;
use crate::api::common::common_header_values::{ORIGIN_VALUE, REFERER_VALUE};
use crate::api::utils::upload_id_to_image_url::upload_id_to_image_url;
use crate::credentials::world_labs_bearer_token::WorldLabsBearerToken;
use crate::credentials::world_labs_cookies::WorldLabsCookies;
use crate::error::world_labs_client_error::WorldLabsClientError;
use crate::error::world_labs_error::WorldLabsError;
use crate::error::world_labs_generic_api_error::WorldLabsGenericApiError;
use chrono::Utc;
use http_headers::names::{PRIORITY, SEC_FETCH_DEST, SEC_FETCH_MODE, SEC_FETCH_SITE, SEC_GPC};
use http_headers::values::accept::ACCEPT_ALL;
use http_headers::values::cache_control::CACHE_CONTROL_NO_CACHE;
use http_headers::values::connection::CONNECTION_KEEP_ALIVE;
use http_headers::values::content_type::CONTENT_TYPE_APPLICATION_JSON;
use http_headers::values::pragma::PRAGMA_NO_CACHE;
use http_headers::values::priority::PRIORITY_4;
use http_headers::values::sec::{SEC_FETCH_DEST_EMPTY, SEC_FETCH_MODE_CORS, SEC_FETCH_SITE_CROSS_SITE};
use http_headers::values::te::TE_TRAILERS;
use indexmap::IndexMap;
use log::{debug, error};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::time::Duration;
use wreq::header::{ACCEPT, ACCEPT_ENCODING, ACCEPT_LANGUAGE, AUTHORIZATION, CACHE_CONTROL, CONNECTION, CONTENT_TYPE, ORIGIN, PRAGMA, REFERER, TE};
use wreq::Client;
use wreq_util::Emulation;
use crate::api::api_types::world_id::WorldObjectId;
const BASE_URL : &str = "https://api.worldlabs.ai/api/v1/objects";
// Note: WorldLabs is phasing out the old URL scheme:
// const BASE_URL : &str = "https://marble2-kgw-prod-iac1.wlt-ai.art/api/v1/objects";
fn get_url(run_id: &RunObjectId) -> String {
format!("{}/{}", BASE_URL, run_id.0)
}
pub struct UpdateRunObjectWithWorldArgs<'a> {
pub cookies: &'a WorldLabsCookies,
pub bearer_token: &'a WorldLabsBearerToken,
pub payload_args: UpdateRunObjectWithWorldPayloadArgs<'a>,
pub request_timeout: Option<Duration>,
}
pub struct UpdateRunObjectWithWorldPayloadArgs<'a> {
pub run_id: &'a RunObjectId,
pub run_created_at_timestamp: u64,
pub first_patch_timestamp: u64,
// eg. https://cdn.marble.worldlabs.ai/object/13ca760c-8ef9-4bf5-acc3-5a507fad3abf/asset.jpg
pub image_upload_url: &'a str,
pub image_input_id: &'a ImageInputObjectId,
pub pano_id: &'a PanoObjectId,
pub meta_world_id: &'a MetaWorldObjectId,
pub world_id: &'a WorldObjectId,
pub title: &'a str,
pub text_prompt: &'a str,
}
pub struct UpdateRunObjectWithWorldResponse {
//pub image_input_id: ImageInputObjectId,
//pub pano_id: PanoObjectId,
//pub meta_world_id: MetaWorldObjectId,
//pub world_id: WorldObjectId,
}
/// Marble Image-to-World
/// This request patches the run object a second time. It updates
/// more fields - prompt, world_id, etc.
/// Request #9 (of ~10)
pub async fn update_run_object_with_world(args: UpdateRunObjectWithWorldArgs<'_>) -> Result<UpdateRunObjectWithWorldResponse, WorldLabsError> {
let client = Client::builder()
.emulation(Emulation::Firefox143)
.build()
.map_err(|err| WorldLabsClientError::WreqClientError(err))?;
let url = get_url(args.payload_args.run_id);
debug!("Requesting URL: {}", url);
let mut request_builder = client.patch(url)
.header(ACCEPT, ACCEPT_ALL)
.header(ACCEPT_LANGUAGE, "en-US,en;q=0.5")
.header(ACCEPT_ENCODING, "gzip, deflate, br, zstd")
.header(REFERER, REFERER_VALUE)
.header(CONTENT_TYPE, CONTENT_TYPE_APPLICATION_JSON)
.header(AUTHORIZATION, args.bearer_token.to_bearer_token_header_string())
.header(ORIGIN, ORIGIN_VALUE)
.header(SEC_GPC, "1")
.header(CONNECTION, CONNECTION_KEEP_ALIVE)
.header(SEC_FETCH_DEST, SEC_FETCH_DEST_EMPTY)
.header(SEC_FETCH_MODE, SEC_FETCH_MODE_CORS)
.header(SEC_FETCH_SITE, SEC_FETCH_SITE_CROSS_SITE)
.header(PRIORITY, PRIORITY_4)
.header(PRAGMA, PRAGMA_NO_CACHE)
.header(CACHE_CONTROL, CACHE_CONTROL_NO_CACHE)
.header(TE, TE_TRAILERS);
if let Some(timeout) = args.request_timeout {
request_builder = request_builder.timeout(timeout);
}
let payload = RawRequest::from_args(&args.payload_args);
let http_request = request_builder.json(&payload)
.build()
.map_err(|err| {
error!("Error building request: {:?}", err);
WorldLabsClientError::WreqClientError(err)
})?;
let response = client.execute(http_request)
.await
.map_err(|err| {
error!("Error during request execution: {:?}", 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)
})?;
// TODO: Handle errors (Cloudflare, Grok, etc.)
if !status.is_success() {
error!("Request returned an error (code {}) : {:?}", status.as_u16(), response_body);
//return Err(classify_general_http_status_code_and_body(status, response_body));
return Err(WorldLabsGenericApiError::UncategorizedBadResponseWithStatusAndBody { status_code: status, body: response_body}.into())
}
debug!("Response body (200): {}", response_body);
let _response : RawResponse = serde_json::from_str(&response_body)
.map_err(|err| WorldLabsGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.to_string()))?;
Ok(UpdateRunObjectWithWorldResponse {
//image_input_id: args.payload_args.image_input_id.clone(),
//pano_id: args.payload_args.pano_id.clone(),
//meta_world_id: args.payload_args.meta_world_id.clone(),
})
}
#[derive(Serialize)]
struct RawRequest {
pub object: ObjectDef,
pub update_mask: Vec<String>,
}
impl Default for RawRequest {
fn default() -> Self {
let now = Utc::now();
let now = now.timestamp_millis().unsigned_abs();
Self {
object: ObjectDef {
metadata: ObjectMetadata {
version: "0.0.1".to_string(),
name: None,
created_at: now,
updated_at: now,
uses_advanced_editing: false,
draft_mode: false,
nodes: IndexMap::new(),
}
},
update_mask: vec!["metadata".to_string()]
}
}
}
impl RawRequest {
pub fn from_args(args: &UpdateRunObjectWithWorldPayloadArgs) -> Self {
let mut request = RawRequest::default();
request.add_image_input_node({
let mut node = ImageInputNode::default();
node.id = args.image_input_id.to_string();
node.source.image_url = args.image_upload_url.to_string();
node.title = args.title.to_string();
node.caption = args.text_prompt.to_string();
node.created_at = args.first_patch_timestamp;
node
});
request.add_pano_node({
let mut node = PanoNode::default();
node.id = args.pano_id.to_string();
node.parent_id = Some(args.image_input_id.to_string());
node.source.world_id = Some(args.world_id.to_string());
node.created_at = args.first_patch_timestamp;
node
});
request.add_world_node({
let mut node = WorldNode::default();
node.id = args.meta_world_id.to_string();
node.parent_id= Some(args.pano_id.to_string());
node.source.world_id = Some(args.world_id.to_string());
node.created_at = args.first_patch_timestamp;
node
});
let now = Utc::now();
let now = now.timestamp_millis().unsigned_abs();
request.object.metadata.updated_at = now;
request.object.metadata.name = Some(args.title.to_string());
request
}
pub fn add_image_input_node(&mut self, mut node: ImageInputNode) {
node.created_at = self.object.metadata.updated_at;
self.object.metadata.nodes.insert(node.id.clone(), NodeValue::ImageInput(node));
}
pub fn add_pano_node(&mut self, mut node: PanoNode) {
node.created_at = self.object.metadata.updated_at;
self.object.metadata.nodes.insert(node.id.clone(), NodeValue::Pano(node));
}
pub fn add_world_node(&mut self, mut node: WorldNode) {
node.created_at = self.object.metadata.updated_at;
self.object.metadata.nodes.insert(node.id.clone(), NodeValue::World(node));
}
}
#[derive(Serialize)]
struct ObjectDef {
pub metadata: ObjectMetadata,
}
#[derive(Serialize)]
struct ObjectMetadata {
pub version: String,
// NB: Not null in request, just for builder
pub name: Option<String>,
#[serde(rename = "createdAt")]
pub created_at: u64,
#[serde(rename = "updatedAt")]
pub updated_at: u64,
#[serde(rename = "usesAdvancedEditing")]
pub uses_advanced_editing: bool,
#[serde(rename = "draftMode")]
pub draft_mode: bool,
/// Polymorphic set of ID-to-object mappings.
/// NB: IndexMap maintains insertion order.
pub nodes: IndexMap<String, NodeValue>,
}
#[derive(Serialize)]
#[serde(untagged)]
//#[serde(tag = "type", content = "value")]
enum NodeValue {
ImageInput(ImageInputNode),
Pano(PanoNode),
World(WorldNode),
}
#[derive(Serialize, Default)]
struct ImageInputNode {
pub id: String,
pub r#type: TypeInput,
#[serde(rename = "parentId")]
pub parent_id: Option<String>,
pub source: ImageInputNodeSource,
#[serde(rename = "createdAt")]
pub created_at: u64,
pub title: String,
pub caption: String,
}
#[derive(Serialize, Default)]
struct ImageInputNodeSource {
pub r#type: TypeImage,
pub image_url: String,
}
#[derive(Serialize, Default)]
enum TypeInput {
#[serde(rename = "input")]
#[default]
Input
}
#[derive(Serialize, Default)]
enum TypeImage {
#[serde(rename = "image")]
#[default]
Image
}
#[derive(Serialize, Default)]
enum StatusPending {
#[serde(rename = "pending")]
#[default]
Pending
}
impl ImageInputNode {
pub fn with_id(id: &str) -> Self{
Self {
id: id.to_string(),
..Default::default()
}
}
}
#[derive(Serialize, Default)]
struct PanoNode {
pub id: String,
pub r#type: TypePano,
#[serde(rename = "parentId")]
pub parent_id: Option<String>,
pub source: PanoNodeSource,
#[serde(rename = "createdAt")]
pub created_at: u64,
}
#[derive(Serialize, Default)]
struct PanoNodeSource {
pub r#type: TypeGenerateWorld,
// NB: Not null in request, just for builder
pub world_id: Option<String>,
pub status: StatusPending,
}
#[derive(Serialize, Default)]
enum TypePano {
#[serde(rename = "pano")]
#[default]
Pano
}
#[derive(Serialize, Default)]
enum TypeGenerateWorld {
#[serde(rename = "generate_world")]
#[default]
GenerateWorld
}
impl PanoNode {
pub fn with_id(id: &str) -> Self{
Self {
id: id.to_string(),
..Default::default()
}
}
}
#[derive(Serialize, Default)]
struct WorldNode {
pub id: String,
pub r#type: TypeWorld,
#[serde(rename = "parentId")]
pub parent_id: Option<String>,
pub source: WorldNodeSource,
#[serde(rename = "createdAt")]
pub created_at: u64,
}
#[derive(Serialize, Default)]
struct WorldNodeSource {
pub r#type: TypeGenerateWorld,
// NB: Not null in request, just for
pub world_id: Option<String>,
pub posed_cubemaps_url: String,
pub minimap_url: String,
pub minimap_metadata: String,
pub status: StatusPending,
}
#[derive(Serialize, Default)]
enum TypeWorld {
#[serde(rename = "world")]
#[default]
World
}
impl WorldNode {
pub fn with_id(id: &str) -> Self{
Self {
id: id.to_string(),
..Default::default()
}
}
}
#[derive(Deserialize)]
struct RawResponse {
pub id: String,
}
#[cfg(test)]
mod tests {
use crate::api::api_types::image_input_object_id::ImageInputObjectId;
use crate::api::api_types::pano_object_id::PanoObjectId;
use crate::api::api_types::run_object_id::RunObjectId;
use crate::api::api_types::meta_world_object_id::MetaWorldObjectId;
use crate::api::api_types::world_id::WorldObjectId;
use crate::api::requests::objects::update_run_object_with_world::{RawRequest, UpdateRunObjectWithWorldPayloadArgs};
use serde_json::{json, Value};
/// Any timestamp from `Utc::now()` should exceed this (2023-11-14 in epoch millis).
const MIN_PLAUSIBLE_EPOCH_MILLIS: u64 = 1_700_000_000_000;
#[test]
fn request_default() {
let request = RawRequest::default();
assert_eq!(request.object.metadata.version, "0.0.1");
assert!(request.object.metadata.created_at > 10000);
assert!(request.object.metadata.updated_at > 10000);
}
#[test]
fn json() {
let run_id = RunObjectId::from_str("79795b32-e44d-4333-bac1-05b2c9a3ea12");
let image_input_id = ImageInputObjectId::from_str("bc17252b-811e-49d7-be3e-b7c538df9d30");
let pano_id = PanoObjectId::from_str("82f6b488-8afe-4311-9022-80ad5789ad92");
let meta_world_id = MetaWorldObjectId::from_str("f08ec501-601c-47c6-a104-402d1820b8f0");
let world_id = WorldObjectId::from_str("d60cfa21-5506-43de-9a9c-0707fc17a5ec");
let title = "Cows Grazing in Indoor Pasture";
let caption = "The scene is a surreal indoor agricultural environment, depicted with a realistic and slightly eerie tone, as rows of bright lights illuminate a vast, artificial pasture. The overall mood is quiet and contemplative, emphasizing the unusual juxtaposition of natural elements within a manufactured setting. Large herds of cattle are distributed across the lush, green grassy floor, peacefully grazing on the abundant vegetation. The entire space is encased within a massive, warehouse-like structure, supported by countless concrete columns that extend from the floor to the ceiling in an orderly grid. Above the columns, a network of metal beams forms the infrastructure of the roof, from which an extensive array of industrial lights is suspended. These lights are arranged in long, parallel lines, creating a striking visual perspective that converges towards a distant vanishing point at the center of the structure. The dense, vibrant green grass covers every inch of the ground, appearing soft and uniform, without any discernible paths or bare patches. Each cow is distinct, varying in color from black to brown and white, and each is engaged in individual grazing or resting. The concrete columns maintain a consistent light gray color and texture, creating a rhythmic architectural presence throughout the vast indoor landscape. To the right, another cluster of columns continues the architectural pattern, while the grassy expanse remains uninterrupted, extending toward the edges of the structure. ";
let args = UpdateRunObjectWithWorldPayloadArgs {
run_created_at_timestamp: 1765756772176,
first_patch_timestamp: 1765756773647,
image_upload_url: "https://cdn.marble.worldlabs.ai/object/13ca760c-8ef9-4bf5-acc3-5a507fad3abf/asset.jpg",
run_id: &run_id,
image_input_id: &image_input_id,
pano_id: &pano_id,
meta_world_id: &meta_world_id,
world_id: &world_id,
title,
text_prompt: caption,
};
let request = RawRequest::from_args(&args);
// The `createdAt` / `updatedAt` timestamps come from `Utc::now()` at build time,
// so they're nondeterministic. Sanity-check them, then zero them out before
// comparing the rest of the document.
assert!(request.object.metadata.created_at > MIN_PLAUSIBLE_EPOCH_MILLIS);
assert!(request.object.metadata.updated_at >= request.object.metadata.created_at);
let mut actual = serde_json::to_value(&request).unwrap();
zero_out_timestamps(&mut actual);
let expected = json!({
"object": {
"metadata": {
"version": "0.0.1",
"name": title,
"createdAt": 0,
"updatedAt": 0,
"usesAdvancedEditing": false,
"draftMode": false,
"nodes": {
"bc17252b-811e-49d7-be3e-b7c538df9d30": {
"id": "bc17252b-811e-49d7-be3e-b7c538df9d30",
"type": "input",
"parentId": null,
"source": {
"type": "image",
"image_url": "https://cdn.marble.worldlabs.ai/object/13ca760c-8ef9-4bf5-acc3-5a507fad3abf/asset.jpg"
},
"createdAt": 0,
"title": title,
"caption": caption
},
"82f6b488-8afe-4311-9022-80ad5789ad92": {
"id": "82f6b488-8afe-4311-9022-80ad5789ad92",
"type": "pano",
"parentId": "bc17252b-811e-49d7-be3e-b7c538df9d30",
"source": {
"type": "generate_world",
"world_id": "d60cfa21-5506-43de-9a9c-0707fc17a5ec",
"status": "pending"
},
"createdAt": 0
},
"f08ec501-601c-47c6-a104-402d1820b8f0": {
"id": "f08ec501-601c-47c6-a104-402d1820b8f0",
"type": "world",
"parentId": "82f6b488-8afe-4311-9022-80ad5789ad92",
"source": {
"type": "generate_world",
"world_id": "d60cfa21-5506-43de-9a9c-0707fc17a5ec",
"posed_cubemaps_url": "",
"minimap_url": "",
"minimap_metadata": "",
"status": "pending"
},
"createdAt": 0
}
}
}
},
"update_mask": [
"metadata"
]
});
assert_eq!(actual, expected);
}
/// Recursively replaces nondeterministic `createdAt` / `updatedAt` values with 0.
fn zero_out_timestamps(value: &mut Value) {
match value {
Value::Object(map) => {
for (key, child) in map.iter_mut() {
if key == "createdAt" || key == "updatedAt" {
*child = json!(0);
} else {
zero_out_timestamps(child);
}
}
},
Value::Array(items) => {
for item in items.iter_mut() {
zero_out_timestamps(item);
}
},
_ => {},
}
}
}

View File

@ -1 +0,0 @@
pub mod recaption_image;

View File

@ -1,174 +0,0 @@
use crate::api::api_types::run_object_id::RunObjectId;
use crate::api::api_types::upload_mime_type::UploadMimeType;
use crate::api::api_types::upload_object_id::UploadObjectId;
use crate::api::common::common_header_values::{ORIGIN_VALUE, REFERER_VALUE};
use crate::api::utils::upload_id_to_image_url::upload_id_to_image_url;
use crate::credentials::world_labs_bearer_token::WorldLabsBearerToken;
use crate::credentials::world_labs_cookies::WorldLabsCookies;
use crate::error::world_labs_client_error::WorldLabsClientError;
use crate::error::world_labs_error::WorldLabsError;
use crate::error::world_labs_generic_api_error::WorldLabsGenericApiError;
use http_headers::names::{PRIORITY, SEC_FETCH_DEST, SEC_FETCH_MODE, SEC_FETCH_SITE, SEC_GPC};
use http_headers::values::accept::ACCEPT_ALL;
use http_headers::values::cache_control::CACHE_CONTROL_NO_CACHE;
use http_headers::values::connection::CONNECTION_KEEP_ALIVE;
use http_headers::values::content_type::CONTENT_TYPE_APPLICATION_JSON;
use http_headers::values::pragma::PRAGMA_NO_CACHE;
use http_headers::values::priority::PRIORITY_4;
use http_headers::values::sec::{SEC_FETCH_DEST_EMPTY, SEC_FETCH_MODE_CORS, SEC_FETCH_SITE_CROSS_SITE};
use http_headers::values::te::TE_TRAILERS;
use log::{debug, error};
use serde::{Deserialize, Serialize};
use std::time::Duration;
use wreq::header::{ACCEPT, ACCEPT_ENCODING, ACCEPT_LANGUAGE, AUTHORIZATION, CACHE_CONTROL, CONNECTION, CONTENT_TYPE, ORIGIN, PRAGMA, REFERER, TE};
use wreq::Client;
use wreq_util::Emulation;
const URL : &str = "https://api.worldlabs.ai/api/v1/recaption2";
// Note: WorldLabs is phasing out the old URL scheme:
// const URL : &str = "https://marble2-kgw-prod-iac1.wlt-ai.art/api/v1/recaption2";
pub struct RecaptionImageArgs<'a> {
pub cookies: &'a WorldLabsCookies,
pub bearer_token: &'a WorldLabsBearerToken,
pub upload_id: &'a UploadObjectId,
pub upload_mime_type: UploadMimeType,
pub run_id: &'a RunObjectId,
pub request_timeout: Option<Duration>,
}
pub struct RecaptionImageResponse {
pub title: String,
pub caption: String,
}
/// Marble Image-to-World
/// This request generates captions and titles for images. It's a VLM.
/// The prompts are then used downstream to create the scenes.
/// Request #6 (of ~10)
pub async fn recaption_image(args: RecaptionImageArgs<'_>) -> Result<RecaptionImageResponse, WorldLabsError> {
let client = Client::builder()
.emulation(Emulation::Firefox143)
.build()
.map_err(|err| WorldLabsClientError::WreqClientError(err))?;
debug!("Requesting URL: {}", URL);
let mut request_builder = client.post(URL)
.header(ACCEPT, ACCEPT_ALL)
.header(ACCEPT_LANGUAGE, "en-US,en;q=0.5")
.header(ACCEPT_ENCODING, "gzip, deflate, br, zstd")
.header(REFERER, REFERER_VALUE)
.header(CONTENT_TYPE, CONTENT_TYPE_APPLICATION_JSON)
.header(AUTHORIZATION, args.bearer_token.to_bearer_token_header_string())
.header(ORIGIN, ORIGIN_VALUE)
.header(SEC_GPC, "1")
.header(CONNECTION, CONNECTION_KEEP_ALIVE)
.header(SEC_FETCH_DEST, SEC_FETCH_DEST_EMPTY)
.header(SEC_FETCH_MODE, SEC_FETCH_MODE_CORS)
.header(SEC_FETCH_SITE, SEC_FETCH_SITE_CROSS_SITE)
.header(PRIORITY, PRIORITY_4)
.header(PRAGMA, PRAGMA_NO_CACHE)
.header(CACHE_CONTROL, CACHE_CONTROL_NO_CACHE)
.header(TE, TE_TRAILERS);
if let Some(timeout) = args.request_timeout {
request_builder = request_builder.timeout(timeout);
}
let request_payload = RawRequest::for_image_and_run(args.upload_id, args.upload_mime_type, args.run_id);
let http_request = request_builder.json(&request_payload)
.build()
.map_err(|err| {
error!("Error building request: {:?}", err);
WorldLabsClientError::WreqClientError(err)
})?;
let response = client.execute(http_request)
.await
.map_err(|err| {
error!("Error during request execution: {:?}", 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)
})?;
// TODO: Handle errors (Cloudflare, Grok, etc.)
if !status.is_success() {
error!("Request returned an error (code {}) : {:?}", status.as_u16(), response_body);
//return Err(classify_general_http_status_code_and_body(status, response_body));
return Err(WorldLabsGenericApiError::UncategorizedBadResponseWithStatusAndBody { status_code: status, body: response_body}.into())
}
debug!("Response body (200): {}", response_body);
let response : RawResponse = serde_json::from_str(&response_body)
.map_err(|err| WorldLabsGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.to_string()))?;
Ok(RecaptionImageResponse {
title: response.title,
caption: response.caption,
})
}
#[derive(Serialize)]
struct RawRequest {
pub world_prompt: WorldPrompt,
pub user_caption: String,
pub context: Context,
}
#[derive(Serialize)]
struct WorldPrompt {
pub r#type: String,
pub image_prompt: ImagePrompt,
pub text_prompt: String,
}
#[derive(Serialize)]
struct ImagePrompt {
pub uri: String,
}
#[derive(Serialize)]
struct Context {
pub run_id: String,
}
impl RawRequest {
pub fn for_image_and_run(image_upload_id: &UploadObjectId, image_mime_type: UploadMimeType, run_id: &RunObjectId) -> Self {
let image_url = upload_id_to_image_url(image_upload_id, image_mime_type);
Self {
world_prompt: WorldPrompt {
r#type: "image".to_string(),
image_prompt: ImagePrompt {
uri: image_url,
},
text_prompt: "".to_string(), // NB: empty
},
user_caption: "".to_string(), // NB: empty
context: Context {
run_id: run_id.0.clone(),
}
}
}
}
#[derive(Deserialize)]
struct RawResponse {
pub caption: String,
pub title: String,
}
#[cfg(test)]
mod tests {
}

View File

@ -1,186 +0,0 @@
use crate::api::api_types::meta_world_object_id::MetaWorldObjectId;
use crate::api::api_types::run_object_id::RunObjectId;
use crate::api::api_types::upload_mime_type::UploadMimeType;
use crate::api::api_types::upload_object_id::UploadObjectId;
use crate::api::api_types::world_id::WorldObjectId;
use crate::api::common::common_header_values::{ORIGIN_VALUE, REFERER_VALUE};
use crate::api::utils::upload_id_to_image_url::upload_id_to_image_url;
use crate::credentials::world_labs_bearer_token::WorldLabsBearerToken;
use crate::credentials::world_labs_cookies::WorldLabsCookies;
use crate::error::filter_world_labs_http_error::filter_world_labs_http_error;
use crate::error::world_labs_client_error::WorldLabsClientError;
use crate::error::world_labs_error::WorldLabsError;
use crate::error::world_labs_generic_api_error::WorldLabsGenericApiError;
use http_headers::names::{PRIORITY, SEC_FETCH_DEST, SEC_FETCH_MODE, SEC_FETCH_SITE, SEC_GPC};
use http_headers::values::accept::ACCEPT_ALL;
use http_headers::values::cache_control::CACHE_CONTROL_NO_CACHE;
use http_headers::values::connection::CONNECTION_KEEP_ALIVE;
use http_headers::values::content_type::CONTENT_TYPE_APPLICATION_JSON;
use http_headers::values::pragma::PRAGMA_NO_CACHE;
use http_headers::values::priority::PRIORITY_4;
use http_headers::values::sec::{SEC_FETCH_DEST_EMPTY, SEC_FETCH_MODE_CORS, SEC_FETCH_SITE_CROSS_SITE};
use http_headers::values::te::TE_TRAILERS;
use log::{debug, error};
use serde::{Deserialize, Serialize};
use std::time::Duration;
use wreq::header::{ACCEPT, ACCEPT_ENCODING, ACCEPT_LANGUAGE, AUTHORIZATION, CACHE_CONTROL, CONNECTION, CONTENT_TYPE, ORIGIN, PRAGMA, REFERER, TE};
use wreq::Client;
use wreq_util::Emulation;
const URL : &str = "https://api.worldlabs.ai/api/v1/worlds";
// Note: WorldLabs is phasing out the old URL scheme:
//const URL : &str = "https://marble2-kgw-prod-iac1.wlt-ai.art/api/v1/worlds";
pub struct CreateWorldArgs<'a> {
pub cookies: &'a WorldLabsCookies,
pub bearer_token: &'a WorldLabsBearerToken,
// eg. https://cdn.marble.worldlabs.ai/object/13ca760c-8ef9-4bf5-acc3-5a507fad3abf/asset.jpg
pub image_upload_url: &'a str,
pub text_prompt: &'a str,
pub request_timeout: Option<Duration>,
}
pub struct CreateWorldResponse {
pub world_id: WorldObjectId,
}
/// Marble Image-to-World
/// This request starts world generation
/// Request #7 (of ~10)
pub async fn create_world(args: CreateWorldArgs<'_>) -> Result<CreateWorldResponse, WorldLabsError> {
let client = Client::builder()
.emulation(Emulation::Firefox143)
.build()
.map_err(|err| WorldLabsClientError::WreqClientError(err))?;
debug!("Requesting URL: {}", URL);
let mut request_builder = client.post(URL)
.header(ACCEPT, ACCEPT_ALL)
.header(ACCEPT_LANGUAGE, "en-US,en;q=0.5")
.header(ACCEPT_ENCODING, "gzip, deflate, br, zstd")
.header(REFERER, REFERER_VALUE)
.header(CONTENT_TYPE, CONTENT_TYPE_APPLICATION_JSON)
.header(AUTHORIZATION, args.bearer_token.to_bearer_token_header_string())
.header(ORIGIN, ORIGIN_VALUE)
.header(SEC_GPC, "1")
.header(CONNECTION, CONNECTION_KEEP_ALIVE)
.header(SEC_FETCH_DEST, SEC_FETCH_DEST_EMPTY)
.header(SEC_FETCH_MODE, SEC_FETCH_MODE_CORS)
.header(SEC_FETCH_SITE, SEC_FETCH_SITE_CROSS_SITE)
.header(PRIORITY, PRIORITY_4)
.header(PRAGMA, PRAGMA_NO_CACHE)
.header(CACHE_CONTROL, CACHE_CONTROL_NO_CACHE)
.header(TE, TE_TRAILERS);
if let Some(timeout) = args.request_timeout {
request_builder = request_builder.timeout(timeout);
}
let mut payload = RawRequest::default();
payload.generation_input.prompt.image_prompt.uri = Some(args.image_upload_url.to_string());
payload.generation_input.prompt.text_prompt = Some(args.text_prompt.to_string());
let http_request = request_builder.json(&payload)
.build()
.map_err(|err| {
error!("Error building request: {:?}", err);
WorldLabsClientError::WreqClientError(err)
})?;
let response = client.execute(http_request)
.await
.map_err(|err| {
error!("Error during request execution: {:?}", 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!("Request returned an error (code {}) : {:?}", status.as_u16(), response_body);
}
filter_world_labs_http_error(status, Some(&response_body))?;
debug!("Response body (200): {}", response_body);
let response : RawResponse = serde_json::from_str(&response_body)
.map_err(|err| WorldLabsGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.to_string()))?;
Ok(CreateWorldResponse {
world_id: WorldObjectId(response.id),
})
}
#[derive(Serialize)]
struct RawRequest {
pub permission: Permission,
pub generation_input: GenerationInput,
}
#[derive(Serialize)]
struct Permission {
pub public: bool,
}
#[derive(Serialize)]
struct GenerationInput {
pub model: String,
pub prompt: Prompt,
}
#[derive(Serialize)]
struct Prompt {
pub r#type: String,
pub image_prompt: ImagePrompt,
// NB: Not null in request, just for building
pub text_prompt: Option<String>,
}
#[derive(Serialize)]
struct ImagePrompt {
// NB: Not null in request, just for building
pub uri: Option<String>,
}
impl Default for RawRequest {
fn default() -> Self {
Self {
permission: Permission {
public: true,
},
generation_input: GenerationInput {
model: "Marble 0.1-plus".to_string(),
prompt: Prompt {
r#type: "image".to_string(),
image_prompt: ImagePrompt {
uri: None,
},
text_prompt: None,
},
},
}
}
}
#[derive(Deserialize)]
struct RawResponse {
pub id: String,
}
#[cfg(test)]
mod tests {
}

View File

@ -1,2 +0,0 @@
pub mod create_world;
pub mod poll_world_status;

View File

@ -1,212 +0,0 @@
use crate::api::api_types::run_object_id::RunObjectId;
use crate::api::api_types::upload_mime_type::UploadMimeType;
use crate::api::api_types::upload_object_id::UploadObjectId;
use crate::api::api_types::world_id::WorldObjectId;
use crate::api::common::common_header_values::{ORIGIN_VALUE, REFERER_VALUE};
use crate::credentials::world_labs_bearer_token::WorldLabsBearerToken;
use crate::credentials::world_labs_cookies::WorldLabsCookies;
use crate::error::world_labs_client_error::WorldLabsClientError;
use crate::error::world_labs_error::WorldLabsError;
use crate::error::world_labs_generic_api_error::WorldLabsGenericApiError;
use chrono::Utc;
use http_headers::names::{PRIORITY, SEC_FETCH_DEST, SEC_FETCH_MODE, SEC_FETCH_SITE, SEC_GPC};
use http_headers::values::accept::ACCEPT_ALL;
use http_headers::values::cache_control::CACHE_CONTROL_NO_CACHE;
use http_headers::values::connection::CONNECTION_KEEP_ALIVE;
use http_headers::values::content_type::CONTENT_TYPE_APPLICATION_JSON;
use http_headers::values::pragma::PRAGMA_NO_CACHE;
use http_headers::values::priority::PRIORITY_4;
use http_headers::values::sec::{SEC_FETCH_DEST_EMPTY, SEC_FETCH_MODE_CORS, SEC_FETCH_SITE_CROSS_SITE};
use http_headers::values::te::TE_TRAILERS;
use log::{debug, error, info};
use serde::{Deserialize, Serialize};
use std::time::Duration;
use uuid::Uuid;
use wreq::header::{ACCEPT, ACCEPT_ENCODING, ACCEPT_LANGUAGE, AUTHORIZATION, CACHE_CONTROL, CONNECTION, CONTENT_LENGTH, CONTENT_TYPE, ORIGIN, PRAGMA, REFERER, TE};
use wreq::Client;
use wreq_util::Emulation;
const BASE_URL : &str = "https://api.worldlabs.ai/api/v1/worlds";
// Note: WorldLabs is phasing out the old URL scheme:
// const BASE_URL : &str = "https://marble2-kgw-prod-iac1.wlt-ai.art/api/v1/worlds";
fn get_url(world_id: &WorldObjectId) -> String {
format!("{}/{}", BASE_URL, world_id.0)
}
pub struct PollWorldStatusArgs<'a> {
pub cookies: &'a WorldLabsCookies,
pub bearer_token: &'a WorldLabsBearerToken,
pub world_id: &'a WorldObjectId,
pub request_timeout: Option<Duration>,
}
pub struct PollWorldStatusResponse {
pub is_complete: bool,
pub spz_splat_url: Option<String>,
}
/// Marble Image-to-World
/// Poll the world generation status
/// Request #10 (of ~10)
pub async fn poll_world_status(args: PollWorldStatusArgs<'_>) -> Result<PollWorldStatusResponse, WorldLabsError> {
let client = Client::builder()
.emulation(Emulation::Firefox143)
.build()
.map_err(|err| WorldLabsClientError::WreqClientError(err))?;
let url = get_url(args.world_id);
info!("Polling World: {}", url);
let mut request_builder = client.get(url)
.header(ACCEPT, ACCEPT_ALL)
.header(ACCEPT_LANGUAGE, "en-US,en;q=0.5")
.header(ACCEPT_ENCODING, "gzip, deflate, br, zstd")
.header(REFERER, REFERER_VALUE)
.header(AUTHORIZATION, args.bearer_token.to_bearer_token_header_string())
.header(ORIGIN, ORIGIN_VALUE)
.header(SEC_GPC, "1")
.header(CONNECTION, CONNECTION_KEEP_ALIVE)
.header(SEC_FETCH_DEST, SEC_FETCH_DEST_EMPTY)
.header(SEC_FETCH_MODE, SEC_FETCH_MODE_CORS)
.header(SEC_FETCH_SITE, SEC_FETCH_SITE_CROSS_SITE)
.header(PRIORITY, PRIORITY_4)
.header(PRAGMA, PRAGMA_NO_CACHE)
.header(CACHE_CONTROL, CACHE_CONTROL_NO_CACHE)
.header(CONTENT_LENGTH, 0)
.header(TE, TE_TRAILERS);
if let Some(timeout) = args.request_timeout {
request_builder = request_builder.timeout(timeout);
}
let http_request = request_builder
.build()
.map_err(|err| {
error!("Error building request: {:?}", err);
WorldLabsClientError::WreqClientError(err)
})?;
let response = client.execute(http_request)
.await
.map_err(|err| {
error!("Error during request execution: {:?}", 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)
})?;
// TODO: Handle errors (Cloudflare, Grok, etc.)
if !status.is_success() {
error!("Request returned an error (code {}) : {:?}", status.as_u16(), response_body);
//return Err(classify_general_http_status_code_and_body(status, response_body));
return Err(WorldLabsGenericApiError::UncategorizedBadResponseWithStatusAndBody { status_code: status, body: response_body}.into())
}
debug!("Response body (200): {}", response_body);
let response : RawResponse = serde_json::from_str(&response_body)
.map_err(|err| WorldLabsGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.to_string()))?;
debug!("World status: {}", response.status);
let maybe_spz_url = response.generation_output
.map(|out| out.spz_urls.full_res);
Ok(PollWorldStatusResponse {
is_complete: maybe_spz_url.is_some(),
spz_splat_url: maybe_spz_url,
})
}
#[derive(Deserialize)]
struct RawResponse {
/// eg. "INITIALIZING", "SUCCEEDED", etc.
pub status: String,
pub generation_output: Option<GenerationOutput>,
}
#[derive(Deserialize)]
struct GenerationOutput {
pub spz_urls: SpzUrls,
}
#[derive(Deserialize)]
struct SpzUrls {
// eg. https://cdn.marble.worldlabs.ai/d60cfa21-5506-43de-9a9c-0707fc17a5ec/1002dd35-fcad-4eeb-ba15-0d113c1778d8_ceramic.spz
pub full_res: String,
}
#[cfg(test)]
mod tests {
use crate::api::api_types::world_id::WorldObjectId;
use crate::api::requests::worlds::poll_world_status::{get_url, poll_world_status, PollWorldStatusArgs, BASE_URL};
use crate::test_utils::get_test_bearer_token::get_test_bearer_token;
use crate::test_utils::get_test_cookies::get_typed_test_cookies;
use crate::test_utils::setup_test_logging::setup_test_logging;
use log::LevelFilter;
#[test]
fn test_get_url() {
let world_id = WorldObjectId("d60cfa21-5506-43de-9a9c-0707fc17a5ec".to_string());
let expected = format!("{}/d60cfa21-5506-43de-9a9c-0707fc17a5ec", BASE_URL);
assert_eq!(get_url(&world_id), expected);
}
#[tokio::test]
#[ignore] // Client side tests only
async fn test_requests() {
setup_test_logging(LevelFilter::Debug);
let cookies = get_typed_test_cookies().unwrap();
let bearer_token = get_test_bearer_token().unwrap();
let world_id = WorldObjectId("403c379b-b7d2-4449-8f1e-ebdb56142604".to_string());
let response = poll_world_status(PollWorldStatusArgs {
cookies: &cookies,
bearer_token: &bearer_token,
world_id: &world_id,
request_timeout: None,
}).await.unwrap();
println!("Is complete: {}", response.is_complete);
println!("Spz Url: {}", response.spz_splat_url.unwrap_or_else(|| "incomplete".to_string()));
assert_eq!(1, 2);
}
#[tokio::test]
#[ignore] // Client side tests only
async fn test_request_2() {
setup_test_logging(LevelFilter::Debug);
let cookies = get_typed_test_cookies().unwrap();
let bearer_token = get_test_bearer_token().unwrap();
let world_id = WorldObjectId("e739b11e-6039-49c7-b73d-b79e1b28e295".to_string());
let response = poll_world_status(PollWorldStatusArgs {
cookies: &cookies,
bearer_token: &bearer_token,
world_id: &world_id,
request_timeout: None,
}).await.unwrap();
println!("Is complete: {}", response.is_complete);
println!("Spz Url: {}", response.spz_splat_url.unwrap_or_else(|| "incomplete".to_string()));
assert_eq!(1, 2);
}
}

View File

@ -1 +0,0 @@
pub mod upload_id_to_image_url;

View File

@ -1,38 +0,0 @@
use crate::api::api_types::upload_object_id::UploadObjectId;
use crate::api::api_types::upload_mime_type::UploadMimeType;
pub fn upload_id_to_image_url(upload_id: &UploadObjectId, upload_mime_type: UploadMimeType) -> String {
let extension_without_period = match upload_mime_type {
UploadMimeType::ImageJpeg => "jpg",
};
let upload_id_str = upload_id.as_str();
format!("https://cdn.marble.worldlabs.ai/object/{upload_id_str}/asset.{extension_without_period}")
}
#[cfg(test)]
mod tests {
use super::upload_id_to_image_url;
use crate::api::api_types::upload_object_id::UploadObjectId;
use crate::api::api_types::upload_mime_type::UploadMimeType;
// llm tests... sigh
#[test]
fn builds_jpeg_url_with_jpg_extension() {
let id = UploadObjectId("abc123".to_string());
let url = upload_id_to_image_url(&id, UploadMimeType::ImageJpeg);
assert_eq!(
url,
"https://cdn.marble.worldlabs.ai/object/abc123/asset.jpg"
);
}
// llm tests... sigh
#[test]
fn preserves_exact_upload_id_in_url() {
let raw_id = "550e8400-e29b-41d4-a716-446655440000";
let id = UploadObjectId(raw_id.to_string());
let url = upload_id_to_image_url(&id, UploadMimeType::ImageJpeg);
assert!(url.contains(raw_id));
assert!(url.ends_with("/asset.jpg"));
}
}

View File

@ -1,10 +0,0 @@
/// Firefox 139 (2025) on macOS, which matches wreq's Emulation::Firefox139
pub const FIREFOX_139_MAC_USER_AGENT : &str =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:139.0) Gecko/20100101 Firefox/139.0";
pub const FIREFOX_143_MAC_USER_AGENT : &str =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:143.0) Gecko/20100101 Firefox/143.0";
pub const FIREFOX_144_MAC_USER_AGENT_UNSUPPORTED_TLS_FINGERPRINTING : &str =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:144.0) Gecko/20100101 Firefox/144.0";

View File

@ -1 +0,0 @@
pub mod browser_user_agents;

View File

@ -1,101 +0,0 @@
use base64::Engine;
use chrono::{DateTime, Utc};
use jwt_light::common_claims::CommonClaims;
use jwt_light::error::JwtError;
use jwt_light::parse_jwt_claims_trait::ParseJwtClaims;
use serde_json::{Map, Value};
#[derive(Clone, Debug)]
pub struct JwtClaims {
/// From the "iat" field
pub created: DateTime<Utc>,
/// From the "exp" field
pub expiration: DateTime<Utc>,
/// From the "user_id" field
pub user_id: Option<String>,
/// From the "email" field
pub email: Option<String>,
}
impl ParseJwtClaims for JwtClaims {
fn extract_claims(common_claims: CommonClaims, extra_claims: Map<String, Value>) -> Result<Self, JwtError> {
let user_id = extra_claims.get("user_id")
.map(|val| val.as_str())
.flatten()
.map(|val| val.to_string());
let mut email = extra_claims.get("email")
.map(|val| val.as_str())
.flatten()
.map(|val| val.to_string());
if email.is_none() {
// Parse out email from this field:
// "firebase": {
// "identities": {
// "email": [
// "sabalid234@crsay.com"
// ]
// },
// },
email = extra_claims.get("firebase")
.and_then(|val| val.as_object())
.and_then(|fb| fb.get("identities"))
.and_then(|val| val.as_object())
.and_then(|id| id.get("email"))
.and_then(|emails| emails.as_array())
.and_then(|emails| emails.get(0))
.and_then(|email| email.as_str())
.map(|email| email.to_string());
}
Ok(Self {
created: common_claims.created,
expiration: common_claims.expiration,
user_id,
email,
})
}
}
#[cfg(test)]
mod tests {
use crate::credentials::jwt_claims::JwtClaims;
use jwt_light::parse_jwt_claims_trait::ParseJwtClaims;
#[test]
fn test_token() {
/*
{
"iss": "https://securetoken.google.com/wlt-training-gsc",
"aud": "wlt-training-gsc",
"auth_time": 1766002923,
"user_id": "CArJJ2PQFFd8l2Rf8uptZhl86d33",
"sub": "CArJJ2PQFFd8l2Rf8uptZhl86d33",
"iat": 1766099113,
"exp": 1766102713,
"email": "sabalid234@crsay.com",
"email_verified": true,
"firebase": {
"identities": {
"email": [
"sabalid234@crsay.com"
]
},
"sign_in_provider": "password"
}
}
*/
let jwt = "eyJhbGciOiJSUzI1NiIsImtpZCI6IjM4MTFiMDdmMjhiODQxZjRiNDllNDgyNTg1ZmQ2NmQ1NWUzOGRiNWQiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL3NlY3VyZXRva2VuLmdvb2dsZS5jb20vd2x0LXRyYWluaW5nLWdzYyIsImF1ZCI6IndsdC10cmFpbmluZy1nc2MiLCJhdXRoX3RpbWUiOjE3NjYwMDI5MjMsInVzZXJfaWQiOiJDQXJKSjJQUUZGZDhsMlJmOHVwdFpobDg2ZDMzIiwic3ViIjoiQ0FySkoyUFFGRmQ4bDJSZjh1cHRaaGw4NmQzMyIsImlhdCI6MTc2NjA5OTExMywiZXhwIjoxNzY2MTAyNzEzLCJlbWFpbCI6InNhYmFsaWQyMzRAY3JzYXkuY29tIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsImZpcmViYXNlIjp7ImlkZW50aXRpZXMiOnsiZW1haWwiOlsic2FiYWxpZDIzNEBjcnNheS5jb20iXX0sInNpZ25faW5fcHJvdmlkZXIiOiJwYXNzd29yZCJ9fQ.byDW2QiRfajxDgQMD89YNdlRxO5rsHves3nwK_Ry5ZLFThARbdU9-wTgbCz1mzz9P3amUkM_XM9E4rEAnj2u-kCv5iW_FIfRDeJ0Z8FlwqxCt6ekvJgwYE80BUIedEvTiNpP_AilThz1ILqAu3gVQ2GXUcolquvS9i12iGTl-thJH6gnZgv3UdP2Vrr5ZP9VhKgMi5gQER5umFQ4Ud64kD9P9SLttRI2RJdolavJengQBAfec8hOEk6uikw20rEBeT_xzl8TpLNG6i4SIGYqJQHfRALGNniYoU9RlIoFR9VGeyk_6BFFGRd8I_EgL-fxnfmpV8w-riXHlOUg6uNRgw";
let claims = JwtClaims::parse_claims(jwt).unwrap();
assert_eq!(claims.user_id, Some("CArJJ2PQFFd8l2Rf8uptZhl86d33".to_string()));
assert_eq!(claims.email, Some("sabalid234@crsay.com".to_string()));
assert_eq!(claims.created.timestamp(), 1766099113);
assert_eq!(claims.expiration.timestamp(), 1766102713);
}
}

View File

@ -1,4 +0,0 @@
pub mod world_labs_bearer_token;
pub mod world_labs_cookies;
pub mod worldlabs_refresh_token;
pub mod jwt_claims;

View File

@ -1,69 +0,0 @@
use crate::credentials::jwt_claims::JwtClaims;
use crate::error::world_labs_client_error::WorldLabsClientError;
use crate::error::world_labs_error::WorldLabsError;
use jwt_light::parse_jwt_claims_trait::ParseJwtClaims;
use log::error;
/*
This is issued by Google, even for email+password login:
https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key={ENTROPY}
Value:
{
"kind": "identitytoolkit#VerifyPasswordResponse",
"localId": "{{WorldLabsUserId}}",
"email": "{{email@whatever.com}}",
"displayName": "",
"idToken": "{{BEARER_TOKEN}}",
"registered": true,
"refreshToken": "{{REFRESH_TOKEN}}",
"expiresIn": "3600"
}
*/
#[derive(Clone)]
pub struct WorldLabsBearerToken {
/// The bearer token
/// Without "Authorization:" header name and without "Bearer(space)" prefix.
bearer_token: String,
}
impl WorldLabsBearerToken {
pub fn new(mut bearer_token: String) -> Self {
if bearer_token.starts_with("Bearer") {
bearer_token = bearer_token
.trim_start_matches("Bearer")
.trim()
.to_string();
}
Self { bearer_token }
}
pub fn as_raw_str(&self) -> &str {
&self.bearer_token
}
pub fn as_raw_bytes(&self) -> &[u8] {
self.bearer_token.as_bytes()
}
pub fn to_raw_string(&self) -> String {
self.bearer_token.clone()
}
pub fn to_bearer_token_header_string(&self) -> String {
format!("Bearer {}", self.bearer_token)
}
pub fn parse_jwt_claims(&self) -> Result<JwtClaims, WorldLabsError> {
JwtClaims::parse_claims(&self.bearer_token)
.map_err(|err| {
error!("Failed to parse bearer token into JWT claims: {}", err);
WorldLabsError::Client(WorldLabsClientError::FailedToParseJwtClaims(err))
})
}
}

View File

@ -1,24 +0,0 @@
#[derive(Clone)]
pub struct WorldLabsCookies {
/// The entire cookies header (for now)
cookies: String,
}
impl WorldLabsCookies {
pub fn new(cookies: String) -> Self {
Self { cookies }
}
pub fn as_str(&self) -> &str {
&self.cookies
}
pub fn as_bytes(&self) -> &[u8] {
self.cookies.as_bytes()
}
pub fn to_string(&self) -> String {
self.cookies.clone()
}
}

View File

@ -1,25 +0,0 @@
#[derive(Clone)]
pub struct WorldLabsRefreshToken {
/// The refresh token
/// Used to get a new bearer token
refresh_token: String,
}
impl WorldLabsRefreshToken {
pub fn new(refresh_token: String) -> Self {
Self { refresh_token }
}
pub fn as_raw_str(&self) -> &str {
&self.refresh_token
}
pub fn as_raw_bytes(&self) -> &[u8] {
self.refresh_token.as_bytes()
}
pub fn to_raw_string(&self) -> String {
self.refresh_token.clone()
}
}

View File

@ -1,32 +0,0 @@
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 cloudflare_errors::filter_cloudflare_errors::filter_cloudflare_errors;
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(());
}
/// Early and easy to detect errors.
match status_code {
StatusCode::PAYMENT_REQUIRED => {
// {"detail":"Insufficient credits for product 'Marble 0.1-plus'. Please add credits to your account."}
return Err(WorldLabsSpecificApiError::InsufficientCredits.into());
},
_ => {}, // Fall Through.
}
if let Some(body) = maybe_body {
filter_cloudflare_errors(status_code.as_u16(), body)?;
return Err(WorldLabsGenericApiError::UncategorizedBadResponseWithStatusAndBody {
status_code,
body: body.to_string(),
}.into());
}
Err(WorldLabsGenericApiError::UncategorizedBadResponseWithStatus(status_code).into())
}

View File

@ -1,5 +0,0 @@
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;

View File

@ -1,158 +0,0 @@
use base64::DecodeError;
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),
/// Failed to parse JWT claims.
FailedToParseJwtClaims(jwt_light::error::JwtError),
/// Error serializing a message to send the websocket
WebsocketRequestSerializationError(serde_json::Error),
/// Error locking the websocket for sending/receiving
WebsocketLockError,
/// Error reading from a websocket.
WebsocketReadError(wreq::Error),
/// Error sending to an open websocket.
WebsocketSendError(wreq::Error),
/// Can't open a local file for uploading.
CannotOpenLocalFileForUpload(std::io::Error),
/// Can't read a local file for uploading.
CannotReadLocalFileForUpload(std::io::Error),
/// Couldn't write to the file
CannotOpenLocalFileForWriting(std::io::Error),
/// Couldn't write to the file
CannotWriteLocalFile(std::io::Error),
/// File for upload has an invalid path.
FileForUploadHasInvalidPath,
/// Error parsing HTML
HtmlParsingError,
/// Error decoding verification token
FailedToDecodeVerificationToken(DecodeError),
/// Verification token bytes are invalid
InvalidVerificationTokenBytes,
/// Our script logic is out of date
ScriptLogicOutOfDate,
/// Our script logic is out of date (SVG)
ScriptSvgLogicOutOfDate,
/// Our script logic is out of date (script 1)
Script1LogicOutOfDate,
/// Our script logic is out of date (script 2)
Script2LogicOutOfDate,
/// Our script logic is out of date (xsid script)
ScriptXsidLogicOutOfDate,
/// Signature algorithm isn't working with inputs
BadSignatureInputs,
/// Something is broken with timeout math
TimeoutMathBroken,
/// Can't make request because cookies aren't present
NoCookiesPresent,
/// Unknown error generating video
ErrorGeneratingVideo,
// /// An error reading the file for upload.
// FileForUploadReadError(std::io::Error),
//
// /// The file path provided for upload is invalid.
// FileForUploadHasInvalidPath,
//
// /// Something is wrong with the JWT bearer token.
// /// This error originates on our end as we try to parse the JWT.
// LocalJwtClaimsParseError(String),
//
// /// There was an error constructing the form-multipart request.
// MultipartFormError(wreq::Error),
//
// /// We haven't received a bearer token yet
// /// This is our own internal application state error, not something Sora returns.
// /// We know our client can't make the request, so we preemptively fail it.
// NoBearerTokenForRequest,
//
// /// A sentinel token is not present in the client, which is required for some requests.
// NoSentinelTokenForRequest,
//
// /// Issue with using the SoraCredentialBuilder.
// SoraCredentialBuilderError(&'static str),
//
// /// Error parsing a request URL.
// UrlParseError(url::ParseError),
//
// /// Error serializing the sentinel store token to JSON (typically for persistent storage).
// CouldNotSerializeSentinelTokenStore(serde_json::Error),
//
// /// Error deserializing the sentinel token from JSON (typically for persistent storage).
// CouldNotDeserializeSentinelTokenStore {
// error: serde_json::Error,
// raw_json: String
// },
}
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::FailedToParseJwtClaims(err) => write!(f, "Failed to parse JWT claims: {}", err),
Self::WebsocketRequestSerializationError(err) => write!(f, "Websocket request serialization error: {}", err),
Self::WebsocketLockError => write!(f, "Websocket lock error"),
Self::WebsocketReadError(err) => write!(f, "Websocket read error: {}", err),
Self::WebsocketSendError(err) => write!(f, "Websocket send error: {}", err),
Self::CannotOpenLocalFileForUpload(err) => write!(f, "Cannot open local file for upload: {}", err),
Self::CannotReadLocalFileForUpload(err) => write!(f, "Cannot read local file for upload: {}", err),
Self::CannotOpenLocalFileForWriting(err) => write!(f, "Cannot open local file for writing: {}", err),
Self::CannotWriteLocalFile(err) => write!(f, "Cannot write local file: {}", err),
Self::FileForUploadHasInvalidPath => write!(f, "File for upload has invalid path"),
Self::HtmlParsingError => write!(f, "Html parsing error"),
Self::FailedToDecodeVerificationToken(err) => write!(f, "Failed to decode verification token: {}", err),
Self::InvalidVerificationTokenBytes => write!(f, "Invalid verification token bytes"),
Self::ScriptLogicOutOfDate => write!(f, "Script logic out of date"),
Self::ScriptSvgLogicOutOfDate => write!(f, "Script logic out of date (SVG parsing)"),
Self::Script1LogicOutOfDate => write!(f, "Script logic out of date (script 1)"),
Self::Script2LogicOutOfDate => write!(f, "Script logic out of date (script 2)"),
Self::ScriptXsidLogicOutOfDate => write!(f, "Script logic out of date (xsid script)"),
Self::BadSignatureInputs => write!(f, "Bad signature inputs"),
Self::TimeoutMathBroken => write!(f, "Timeout math is broken"),
Self::NoCookiesPresent => write!(f, "No cookies present"),
Self::ErrorGeneratingVideo => write!(f, "Error generating video"),
//Self::FileForUploadReadError(err) => write!(f, "Error reading file for upload: {}", err),
//Self::FileForUploadHasInvalidPath => write!(f, "The file path provided for upload is invalid."),
//Self::LocalJwtClaimsParseError(msg) => write!(f, "Local JWT claims parse error: {}", msg),
//Self::MultipartFormError(err) => write!(f, "Multipart form error: {}", err),
//Self::NoBearerTokenForRequest => write!(f, "No bearer token available. The client needs a bearer token to make the request."),
//Self::NoSentinelTokenForRequest => write!(f, "No sentinel token available. The client needs a sentinel token to make the request."),
//Self::SoraCredentialBuilderError(msg) => write!(f, "Sora Credential Builder error: {}", msg),
//Self::UrlParseError(err) => write!(f, "URL parse error: {}", err),
//Self::CouldNotSerializeSentinelToken(err) => write!(f, "Could not serialize sentinel token to JSON: {}", err),
//Self::CouldNotSerializeSentinelTokenStore(err) => write!(f, "Could not serialize sentinel token store to JSON: {}", err),
//Self::CouldNotDeserializeSentinelTokenStore { error, raw_json} => {
// write!(f, "Could not deserialize sentinel token store from JSON: {:?}, raw JSON: {}", error, raw_json)
//},
}
}
}

View File

@ -1,59 +0,0 @@
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 cloudflare_errors::cloudflare_error::CloudflareError;
use std::error::Error;
#[derive(Debug)]
pub enum WorldLabsError {
Client(WorldLabsClientError),
ApiSpecific(WorldLabsSpecificApiError),
ApiGeneric(WorldLabsGenericApiError),
}
impl WorldLabsError {
pub fn is_world_labs_having_downtime_issues(&self) -> bool {
match self {
Self::ApiGeneric(WorldLabsGenericApiError::CloudflareError(CloudflareError::BadGateway502)) => true,
Self::ApiGeneric(WorldLabsGenericApiError::CloudflareError(CloudflareError::GatewayTimeout504)) => true,
Self::ApiGeneric(WorldLabsGenericApiError::CloudflareError(CloudflareError::TimeoutOccurred524)) => true,
_ => false,
}
}
}
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)
}
}
impl From<CloudflareError> for WorldLabsError {
fn from(error: CloudflareError) -> Self {
Self::ApiGeneric(WorldLabsGenericApiError::CloudflareError(error))
}
}

View File

@ -1,89 +0,0 @@
use cloudflare_errors::cloudflare_error::CloudflareError;
use std::error::Error;
use std::fmt::{Display, Formatter};
use wreq::StatusCode;
#[derive(Debug)]
pub enum WorldLabsGenericApiError {
/// Unknown error occurred when uploading to Google GCP
GoogleUploadFailed {
status_code: StatusCode,
body: String,
},
/// Specific Cloudflare errors.
CloudflareError(CloudflareError),
/// The downloaded index.html did not include the expected data.
/// This is necessary for handshakes between client and server.
IndexHtmlDidNotIncludeExpectedData {
message: String,
},
/// The downloaded javascript did not include the expected data.
/// This is necessary for handshakes between client and server.
ScriptBodyDidNotIncludeExpectedData {
message: String,
},
/// Error creating the websocket due to 401 Unauthorized, likely an authentication error with Grok.
/// Unfortunately we can't read the response body text to diagnose more because wreq has some API design issues.
LikelyWebsocketAuthentication401,
/// We received a 403 on websocket upgrade, likely from Cloudflare.
/// Unfortunately we can't read the response body text to diagnose more because wreq has some API design issues.
LikelyWebsocketCloudflare403,
/// Unknown status code on websocket upgrade.
UnexpectedWebsocketUpgradeStatusCode(StatusCode),
/// serde_json::Error, likely from JSON deserialization schema mismatch.
/// Includes the original body.
SerdeResponseParseErrorWithBody(serde_json::Error, String),
/// serde_json::Error, likely from JSON deserialization schema mismatch.
/// Includes the original body.
/// Specifically, this is for non-200 (error) responses.
SerdeParseErrorWithBodyOnNon200(serde_json::Error, String),
/// An uncategorized bad HTTP response from Grok.
UncategorizedBadResponseWithStatus(StatusCode),
/// An uncategorized bad HTTP response from Grok.
UncategorizedBadResponseWithStatusAndBody {
status_code: StatusCode,
body: String,
},
/// Our previous upload failed
UploadFailed,
/// An error upgrading the connection to a websocket.
WreqWebsocketUpgradeError(wreq::Error),
/// 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, "Google world upload failed with HTTP {}: {:?}", status_code, body),
Self::CloudflareError(err) => write!(f, "Cloudflare error: {}", err),
Self::IndexHtmlDidNotIncludeExpectedData { message } => write!(f, "IndexHtmlDidNotIncludeExpectedData ( message: {} )", message),
Self::ScriptBodyDidNotIncludeExpectedData { message } => write!(f, "ScriptBodyDidNotIncludeExpectedData ( message: {} )", message),
Self::LikelyWebsocketAuthentication401 => write!(f, "Likely websocket authentication 401 error (probably Grok authentication issue with cookies)"),
Self::LikelyWebsocketCloudflare403 => write!(f, "Likely Cloudflare 403 error on websocket upgrade"),
Self::UnexpectedWebsocketUpgradeStatusCode(status) => write!(f, "Unexpected websocket upgrade status code: {}", status),
Self::SerdeResponseParseErrorWithBody(err, body) => write!(f, "Failed to parse response body: {:?}. Body: {}", err, body),
Self::SerdeParseErrorWithBodyOnNon200(err, body) => write!(f, "Failed to parse non-200 response body: {:?}. Body: {}", err, body),
Self::UncategorizedBadResponseWithStatus(status) => write!(f, "Uncategorized with status code: {}", status),
Self::UncategorizedBadResponseWithStatusAndBody { status_code, body } => write!(f, "Uncategorized bad response: status code {}, body: {}", status_code, body),
Self::UploadFailed => write!(f, "Upload failed"),
Self::WreqWebsocketUpgradeError(err) => write!(f, "Websocket upgrade error: {}", err),
Self::WreqError(err) => write!(f, "Wreq client error: {}", err),
}
}
}

View File

@ -1,89 +0,0 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
#[derive(Debug)]
pub enum WorldLabsSpecificApiError {
/// If the API drops run ids (unlikely)
NoRunIdInResponse { response_body: String },
/// The Google GCP upload failed because the signature does not match
GoogleUploadSignatureDoesNotMatch,
/// Grok is detecting and blocking our bot, likely due to new secrets use.
AutomationBlocked,
/// Too many videos are being generated.
TooManyVideos,
/// There aren't sufficient funds for generation.
InsufficientCredits,
// /// Failed to generate a bearer token despite the `200 OK` response.
// /// This has been generated by sending old/expired session cookies or empty cookies.
// FailedToGenerateBearerToken,
//
// /// The endpoint told us we used an invalid JWT.
// InvalidJwt,
//
// /// Sora has responded with "sentinel block".
// /// This usually means we need to refresh our sentinel token.
// SentinelBlockError,
//
// /// Sora requires users to set up an account username before they can generate images.
// /// This requires the user to set up that username.
// SoraUsernameNotYetCreated,
//
// /// Sora has responded with "token expired".
// /// This (probably?) means we need to refresh our JWT bearer token.
// TokenExpiredError,
//
// /// We're sending too many tasks to Sora.
// /// Sora is telling us to cool it.
// TooManyConcurrentTasks,
//
// // TODO: Try to make concrete error types for these distinct cases.
// /// Unauthorized, cookie and/or bearer token expired. We'll need to ask for a refreshed login.
// ///
// /// Example message, e.g. from the upload endpoint:
// /// {
// /// "error": {
// /// "message": "Your authentication token has expired. Please try signing in again.",
// /// "type": "invalid_request_error",
// /// "param": null,
// /// "code": "token_expired"
// /// }
// /// }
// UnauthorizedCookieOrBearerExpired,
//
// /// The Sora sentinel token response is missing fields
// SentinelResponseIsMissingFields {
// missing_token: bool,
// missing_turnstile: bool,
// raw_response: String,
// },
}
impl Error for WorldLabsSpecificApiError {}
impl Display for WorldLabsSpecificApiError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::NoRunIdInResponse { response_body } => write!(f, "no run.id found in response: {}", response_body),
Self::GoogleUploadSignatureDoesNotMatch => write!(f, "Google upload signature does not match"),
Self::AutomationBlocked => write!(f, "Automation blocked"),
Self::TooManyVideos => write!(f, "Too many videos are being generated"),
Self::InsufficientCredits => write!(f, "Insufficient credits"),
//Self::FailedToGenerateBearerToken => write!(f, "Failed to generate bearer token. Probably invalid or expired session cookies."),
//Self::InvalidJwt => write!(f, "Invalid JWT token. Please refresh the token."),
//Self::SentinelBlockError => write!(f, "Request blocked by Sentinel Token Block. Please refresh the sentinel token."),
//Self::SoraUsernameNotYetCreated => write!(f, "Sora username not yet created. Please create a username in the Sora app or website."),
//Self::TokenExpiredError => write!(f, "JWT token has expired. Please refresh the token."),
//Self::TooManyConcurrentTasks => write!(f, "Too many concurrent tasks. Please wait."),
//Self::UnauthorizedCookieOrBearerExpired => write!(f, "Unauthorized: cookie and/or bearer token expired"),
//Self::SentinelResponseIsMissingFields { missing_token: token_missing, missing_turnstile: turnstile_missing, raw_response } => {
// write!(f, "Sentinel response is missing fields. token_missing: {}, turnstile_missing: {}. Raw response: {}", token_missing, turnstile_missing, raw_response)
//},
}
}
}

View File

@ -1,9 +0,0 @@
#[cfg(test)]
pub (crate) mod test_utils;
pub mod api;
pub mod client;
pub mod credentials;
pub mod error;
pub mod recipes;

View File

@ -1 +0,0 @@
pub mod upload_image_and_create_world_with_retry;

View File

@ -1,346 +0,0 @@
use crate::api::api_types::image_input_object_id::ImageInputObjectId;
use crate::api::api_types::meta_world_object_id::MetaWorldObjectId;
use crate::api::api_types::pano_object_id::PanoObjectId;
use crate::api::api_types::run_object_id::RunObjectId;
use crate::api::api_types::upload_mime_type::UploadMimeType;
use crate::api::api_types::world_id::WorldObjectId;
use crate::api::requests::google_refresh_token::google_refresh_token::{google_refresh_token, GoogleRefreshTokenArgs};
use crate::api::requests::google_upload::google_upload_image::{google_upload_image, GoogleUploadImageArgs};
use crate::api::requests::objects::begin_object_image_upload::{begin_object_image_upload, BeginObjectImageUploadArgs};
use crate::api::requests::objects::create_run_object::{create_run_object, CreateRunObjectArgs};
use crate::api::requests::objects::create_upload_object::{create_upload_object, CreateUploadObjectArgs};
use crate::api::requests::objects::finalize_object_image_upload::{finalize_object_image_upload, FinalizeObjectImageUploadArgs};
use crate::api::requests::objects::update_run_object_with_upload::{update_run_object_with_upload, UpdateRunObjectWithUploadArgs, UpdateRunObjectWithUploadPayloadArgs};
use crate::api::requests::objects::update_run_object_with_world::{update_run_object_with_world, UpdateRunObjectWithWorldArgs, UpdateRunObjectWithWorldPayloadArgs};
use crate::api::requests::recaption::recaption_image::{recaption_image, RecaptionImageArgs};
use crate::api::requests::worlds::create_world::{create_world, CreateWorldArgs};
use crate::credentials::world_labs_bearer_token::WorldLabsBearerToken;
use crate::credentials::world_labs_cookies::WorldLabsCookies;
use crate::credentials::worldlabs_refresh_token::WorldLabsRefreshToken;
use crate::error::world_labs_client_error::WorldLabsClientError;
use crate::error::world_labs_error::WorldLabsError;
use crate::error::world_labs_generic_api_error::WorldLabsGenericApiError;
use anyhow::bail;
use chrono::{TimeDelta, Utc};
use filesys::file_read_bytes::file_read_bytes;
use log::{error, info, warn};
use serde::Deserialize;
use std::path::PathBuf;
use std::time::Duration;
use uuid::Uuid;
pub struct UploadImageAndCreateWorldWithRetryArgs<'a> {
pub file: FileBytesOrPath,
pub cookies: &'a WorldLabsCookies,
pub bearer_token: &'a WorldLabsBearerToken,
pub refresh_token: &'a WorldLabsRefreshToken,
pub individual_request_timeout: Option<Duration>,
}
pub enum FileBytesOrPath {
Bytes(Vec<u8>),
Path(PathBuf),
}
pub struct UploadImageAndCreateWorldWithRetryResponse {
pub run_id: RunObjectId,
pub world_id: WorldObjectId,
pub image_upload_url: String,
/// If the tokens were renewed, this is them.
pub maybe_new_access_tokens: Option<NewAccessTokens>,
}
pub struct NewAccessTokens {
pub bearer_token: WorldLabsBearerToken,
pub refresh_token: WorldLabsRefreshToken,
}
/// Marble Image-to-World
/// Mark the upload as complete.
/// Request #5 (of ~10)
pub async fn upload_image_and_create_world_with_retry(args: UploadImageAndCreateWorldWithRetryArgs<'_>) -> Result<UploadImageAndCreateWorldWithRetryResponse, WorldLabsError> {
info!("Checking to see if bearer token needs refresh (fails open) ...");
let mut maybe_refresh_bearer = false;
match args.bearer_token.parse_jwt_claims() {
Err(err) => {
warn!("Failed to parse bearer_token jwt claims (failing open) : {}", err);
}
Ok(jwt) => {
let now = Utc::now();
if now > jwt.expiration {
info!("Bearer is expired.");
maybe_refresh_bearer = true;
}
let sooner_expiry = jwt.expiration
.checked_sub_signed(TimeDelta::minutes(30))
.unwrap_or(jwt.expiration);
if now > sooner_expiry {
info!("Bearer will expire soon, so we're renewing it in advance.");
maybe_refresh_bearer = true;
}
}
};
let mut maybe_new_access_tokens = None;
if maybe_refresh_bearer {
info!("Refreshing bearer token...");
let updated = google_refresh_token(GoogleRefreshTokenArgs {
refresh_token: &args.refresh_token,
request_timeout: args.individual_request_timeout,
}).await?;
info!("Bearer token refreshed!");
maybe_new_access_tokens = Some(NewAccessTokens {
bearer_token: updated.bearer_token,
refresh_token: updated.refresh_token,
});
}
let use_bearer_token = maybe_new_access_tokens
.as_ref()
.map(|tokens| &tokens.bearer_token)
.unwrap_or_else(|| args.bearer_token);
info!("Checking file input...");
let file_bytes = match args.file {
FileBytesOrPath::Bytes(bytes) => {
info!("File bytes provided");
bytes
}
FileBytesOrPath::Path(path) => {
info!("File path provided");
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());
}
}
}
};
info!("Request #1 of 10: create run object ...");
let response = create_run_object(CreateRunObjectArgs {
cookies: args.cookies,
bearer_token: use_bearer_token,
request_timeout: args.individual_request_timeout,
}).await?;
let run_object_id = response.id;
let run_object = response.run_object;
info!("Object id: {}", run_object_id.0);
// TODO - multiple types.
let upload_mime_type= UploadMimeType::ImageJpeg;
info!("Request #2 of 10: begin image attachment ...");
let response = create_upload_object(CreateUploadObjectArgs {
cookies: &args.cookies,
bearer_token: &use_bearer_token,
upload_mime_type,
request_timeout: args.individual_request_timeout,
}).await?;
let upload_id = response.id;
info!("Upload id: {}", upload_id.0);
info!("Request #3 of 10: begin image upload ...");
let response = begin_object_image_upload(BeginObjectImageUploadArgs {
cookies: &args.cookies,
bearer_token: &use_bearer_token,
upload_mime_type,
request_timeout: args.individual_request_timeout,
upload_id: &upload_id,
}).await?;
let google_upload_url = response.upload_url;
info!("Request #4 of 10: upload to Google GCP ...");
info!("Google upload URL: {:?}", &google_upload_url);
let _response = google_upload_image(GoogleUploadImageArgs {
upload_url: &google_upload_url,
upload_mime_type,
file_bytes,
request_timeout: args.individual_request_timeout,
}).await?;
info!("Request #5 of 10: finalize object upload ...");
let response = finalize_object_image_upload(FinalizeObjectImageUploadArgs {
cookies: &args.cookies,
bearer_token: &use_bearer_token,
upload_id: &upload_id,
request_timeout: args.individual_request_timeout,
}).await?;
let image_url = response.object_url;
info!("Object/image URL: {image_url}");
info!("Request #6 of 10: Patch run with new info (first time) ...");
let image_input_id = ImageInputObjectId::generate_new();
let pano_id = PanoObjectId::generate_new();
let meta_world_id = MetaWorldObjectId::generate_new();
let response = update_run_object_with_upload(UpdateRunObjectWithUploadArgs {
cookies: &args.cookies,
bearer_token: &use_bearer_token,
payload_args: UpdateRunObjectWithUploadPayloadArgs {
run_id: &run_object_id,
run_created_at_timestamp: run_object.metadata.created_at,
image_upload_url: &image_url,
image_input_id: &image_input_id,
pano_id: &pano_id,
meta_world_id: &meta_world_id,
},
request_timeout: args.individual_request_timeout,
}).await?;
let run_first_updated_at = response.run_updated_timestamp;
info!("Request #7 of 10: captioning with VLM ...");
let response = recaption_image(RecaptionImageArgs {
cookies: &args.cookies,
bearer_token: &use_bearer_token,
upload_id: &upload_id,
upload_mime_type,
run_id: &run_object_id,
request_timeout: args.individual_request_timeout,
}).await?;
info!("Title: {}", response.title);
info!("Caption: {}", response.caption);
let title = response.title;
let text_prompt = response.caption;
info!("Request #8 of 10: create world ...");
let response = create_world(CreateWorldArgs {
cookies: &args.cookies,
bearer_token: &use_bearer_token,
text_prompt: &text_prompt,
image_upload_url: &image_url,
request_timeout: args.individual_request_timeout,
}).await?;
let world_id = response.world_id;
info!("World ID: {}", &world_id.0);
info!("Request #9 of 10: patch run with new info (second time) ...");
let response = update_run_object_with_world(UpdateRunObjectWithWorldArgs {
cookies: &args.cookies,
bearer_token: &use_bearer_token,
payload_args: UpdateRunObjectWithWorldPayloadArgs {
run_id: &run_object_id,
run_created_at_timestamp: run_object.metadata.created_at,
first_patch_timestamp: run_first_updated_at,
image_upload_url: &image_url,
image_input_id: &image_input_id,
pano_id: &pano_id,
meta_world_id: &meta_world_id,
world_id: &world_id,
title: &title,
text_prompt: &text_prompt,
},
request_timeout: args.individual_request_timeout,
}).await?;
Ok(UploadImageAndCreateWorldWithRetryResponse {
run_id: run_object_id,
image_upload_url: image_url,
world_id,
maybe_new_access_tokens,
})
}
#[cfg(test)]
mod tests {
use crate::recipes::upload_image_and_create_world_with_retry::{upload_image_and_create_world_with_retry, FileBytesOrPath, UploadImageAndCreateWorldWithRetryArgs};
use crate::test_utils::get_test_bearer_token::get_test_bearer_token;
use crate::test_utils::get_test_cookies::get_typed_test_cookies;
use crate::test_utils::get_test_refresh_token::get_typed_test_refresh_token;
use crate::test_utils::setup_test_logging::setup_test_logging;
use filesys::file_read_bytes::file_read_bytes;
use log::LevelFilter;
#[tokio::test]
#[ignore] // Client side tests only
async fn test_requests() {
setup_test_logging(LevelFilter::Debug);
let cookies = get_typed_test_cookies().unwrap();
let bearer_token = get_test_bearer_token().unwrap();
let refresh_token = get_typed_test_refresh_token().unwrap();
//let file_path = "/home/bt/Pictures/locations/island.jpg";
let file_path = "/Users/bt/Pictures/Midjourney/jeep.jpeg";
let file_bytes = file_read_bytes(file_path).unwrap();
println!("File bytes len: {}", file_bytes.len());
let results = upload_image_and_create_world_with_retry(UploadImageAndCreateWorldWithRetryArgs {
cookies: &cookies,
bearer_token: &bearer_token,
refresh_token: &refresh_token,
individual_request_timeout: None,
file: FileBytesOrPath::Bytes(file_bytes),
}).await.unwrap();
println!("Object ID: {}", results.run_id.0);
println!("World ID: {}", results.world_id.0);
println!("Upload URL: {}", results.image_upload_url);
println!("Has new access tokens: {}", results.maybe_new_access_tokens.is_some());
assert_eq!(1, 2);
}
#[tokio::test]
#[ignore] // Client side tests only
async fn test_requests_2() {
setup_test_logging(LevelFilter::Debug);
let cookies = get_typed_test_cookies().unwrap();
let bearer_token = get_test_bearer_token().unwrap();
let refresh_token = get_typed_test_refresh_token().unwrap();
//let file_path = "/home/bt/Pictures/locations/island.jpg";
let file_path = "/Users/bt/Pictures/Midjourney/train_ghibli.jpeg";
let file_bytes = file_read_bytes(file_path).unwrap();
println!("File bytes len: {}", file_bytes.len());
let results = upload_image_and_create_world_with_retry(UploadImageAndCreateWorldWithRetryArgs {
cookies: &cookies,
bearer_token: &bearer_token,
refresh_token: &refresh_token,
individual_request_timeout: None,
file: FileBytesOrPath::Bytes(file_bytes),
}).await.unwrap();
println!("Upload URL: {}", results.image_upload_url);
println!("Object ID: {}", results.run_id.0);
}
}

View File

@ -1,17 +0,0 @@
use crate::credentials::world_labs_bearer_token::WorldLabsBearerToken;
use errors::AnyhowResult;
use std::fs::read_to_string;
#[cfg(test)]
pub (crate) fn get_test_bearer_token_raw() -> AnyhowResult<String> {
let cookies = read_to_string("/Users/bt/Artcraft/credentials/worldlabs_bearer.txt")?;
//let cookies = read_to_string("/home/bt/Artcraft/credentials/world_labs_bearer.txt")?;
let cookies = cookies.trim().to_string();
Ok(cookies)
}
#[cfg(test)]
pub (crate) fn get_test_bearer_token() -> AnyhowResult<WorldLabsBearerToken> {
let cookies = get_test_bearer_token_raw()?;
Ok(WorldLabsBearerToken::new(cookies))
}

View File

@ -1,17 +0,0 @@
use crate::credentials::world_labs_cookies::WorldLabsCookies;
use errors::AnyhowResult;
use std::fs::read_to_string;
#[cfg(test)]
pub (crate) fn get_test_cookies() -> AnyhowResult<String> {
//let cookies = read_to_string("/home/bt/Artcraft/credentials/worldlabs_cookies.txt")?;
let cookies = read_to_string("/Users/bt/Artcraft/credentials/worldlabs_cookies.txt")?;
let cookies = cookies.trim().to_string();
Ok(cookies)
}
#[cfg(test)]
pub (crate) fn get_typed_test_cookies() -> AnyhowResult<WorldLabsCookies> {
let cookies = get_test_cookies()?;
Ok(WorldLabsCookies::new(cookies))
}

View File

@ -1,17 +0,0 @@
use crate::credentials::worldlabs_refresh_token::WorldLabsRefreshToken;
use errors::AnyhowResult;
use std::fs::read_to_string;
#[cfg(test)]
pub (crate) fn get_test_refresh_token() -> AnyhowResult<String> {
//let cookies = read_to_string("/home/bt/Artcraft/credentials/worldlabs_cookies.txt")?;
let token = read_to_string("/Users/bt/Artcraft/credentials/worldlabs_refresh.txt")?;
let token = token.trim().to_string();
Ok(token)
}
#[cfg(test)]
pub (crate) fn get_typed_test_refresh_token() -> AnyhowResult<WorldLabsRefreshToken> {
let token = get_test_refresh_token()?;
Ok(WorldLabsRefreshToken::new(token))
}

Some files were not shown because too many files have changed in this diff Show More