From a654e9ada1155f3f99dee60a2fbfa35ea74632fd Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 9 Mar 2026 10:12:13 -0400 Subject: [PATCH] Worldlabs api improvements (#1118) --- Cargo.lock | 1 + crates/api_clients/world_labs_api/Cargo.toml | 1 + .../world_labs_api/src/api/api_types/mod.rs | 1 + .../src/api/api_types/world_id.rs | 19 +-- .../src/api/api_types/world_labs_model.rs | 23 +++ .../api/requests/delete_world/delete_world.rs | 15 +- .../requests/generate_world/generate_world.rs | 9 +- .../requests/get_operation/get_operation.rs | 139 +++++++++++++++++- .../requests/get_operation/http_request.rs | 65 +++++++- .../src/api/requests/get_world/get_world.rs | 17 ++- .../api/requests/list_worlds/list_worlds.rs | 7 +- crates/api_clients/world_labs_api/src/lib.rs | 1 + .../src/pricing/check_pricing.rs | 133 +++++++++++++++++ .../world_labs_api/src/pricing/mod.rs | 1 + ...pload_image_and_create_world_with_retry.rs | 5 +- 15 files changed, 388 insertions(+), 49 deletions(-) create mode 100644 crates/api_clients/world_labs_api/src/api/api_types/world_labs_model.rs create mode 100644 crates/api_clients/world_labs_api/src/pricing/check_pricing.rs create mode 100644 crates/api_clients/world_labs_api/src/pricing/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 6a74db7c0..e0d440b10 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15020,6 +15020,7 @@ dependencies = [ "log", "serde", "serde_json", + "strum 0.28.0", "tokio", "uuid 1.18.1", "workspace-build-acceleration", diff --git a/crates/api_clients/world_labs_api/Cargo.toml b/crates/api_clients/world_labs_api/Cargo.toml index 937355869..e693bbd18 100644 --- a/crates/api_clients/world_labs_api/Cargo.toml +++ b/crates/api_clients/world_labs_api/Cargo.toml @@ -33,3 +33,4 @@ filesys.workspace = true env_logger.workspace = true env.workspace = true chrono.workspace = true +strum.workspace = true diff --git a/crates/api_clients/world_labs_api/src/api/api_types/mod.rs b/crates/api_clients/world_labs_api/src/api/api_types/mod.rs index 1ea69e2a9..6b2a76cbd 100644 --- a/crates/api_clients/world_labs_api/src/api/api_types/mod.rs +++ b/crates/api_clients/world_labs_api/src/api/api_types/mod.rs @@ -1,3 +1,4 @@ pub mod media_asset_id; pub mod operation_id; pub mod world_id; +pub mod world_labs_model; diff --git a/crates/api_clients/world_labs_api/src/api/api_types/world_id.rs b/crates/api_clients/world_labs_api/src/api/api_types/world_id.rs index 5d6b32b16..2e2985852 100644 --- a/crates/api_clients/world_labs_api/src/api/api_types/world_id.rs +++ b/crates/api_clients/world_labs_api/src/api/api_types/world_id.rs @@ -1,17 +1,8 @@ -use uuid::Uuid; - -/// Type for World IDs. -/// These are used to refer to worlds -/// These appear to be bare UUIDs. +/// Strongly-typed World ID #[derive(Clone, Debug)] -pub struct WorldObjectId(pub String); - -impl WorldObjectId { - pub fn new() -> Self { - let uuid = Uuid::new_v4().to_string(); - Self(uuid) - } +pub struct WorldId(pub String); +impl WorldId { pub fn from_str(s: &str) -> Self { Self(s.to_string()) } @@ -19,8 +10,4 @@ impl WorldObjectId { pub fn as_str(&self) -> &str { &self.0 } - - pub fn to_string(&self) -> String { - self.0.clone() - } } diff --git a/crates/api_clients/world_labs_api/src/api/api_types/world_labs_model.rs b/crates/api_clients/world_labs_api/src/api/api_types/world_labs_model.rs new file mode 100644 index 000000000..7b7be76eb --- /dev/null +++ b/crates/api_clients/world_labs_api/src/api/api_types/world_labs_model.rs @@ -0,0 +1,23 @@ +/// 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 + Marble0p1Mini, + + /// `Marble 0.1-plus` is best for final renders + /// Generation time: ~5 minutes, + /// Cost: 1500-1600 credits + Marble0p1Plus, +} + +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", + } + } +} diff --git a/crates/api_clients/world_labs_api/src/api/requests/delete_world/delete_world.rs b/crates/api_clients/world_labs_api/src/api/requests/delete_world/delete_world.rs index 5f87db1d4..c481ce818 100644 --- a/crates/api_clients/world_labs_api/src/api/requests/delete_world/delete_world.rs +++ b/crates/api_clients/world_labs_api/src/api/requests/delete_world/delete_world.rs @@ -1,3 +1,4 @@ +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; @@ -11,12 +12,12 @@ const BASE_URL: &str = "https://api.worldlabs.ai/marble/v1/worlds"; pub struct DeleteWorldArgs<'a> { pub creds: &'a WorldLabsApiCreds, - pub world_id: &'a str, + pub world_id: &'a WorldId, pub request_timeout: Option, } pub struct DeleteWorldResponse { - pub world_id: String, + pub world_id: WorldId, pub deleted: bool, } @@ -26,7 +27,7 @@ pub struct DeleteWorldResponse { pub async fn delete_world(args: DeleteWorldArgs<'_>) -> Result { let client = Client::new(); - let url = format!("{}/{}", BASE_URL, args.world_id); + let url = format!("{}/{}", BASE_URL, args.world_id.as_str()); debug!("Requesting URL: {}", url); @@ -65,7 +66,7 @@ pub async fn delete_world(args: DeleteWorldArgs<'_>) -> Result { pub creds: &'a WorldLabsApiCreds, pub world_prompt: WorldPrompt, pub display_name: Option, - pub model: Option, + pub model: WorldLabsModel, pub seed: Option, pub tags: Option>, pub permission: Option, @@ -35,7 +36,7 @@ pub async fn generate_world(args: GenerateWorldArgs<'_>) -> Result, pub error: Option, pub metadata: Option, - /// When done=true and no error, contains the World object. - pub response: Option, + /// Structured World object, present when done=true and no error. + pub world: Option, } #[derive(Debug, Clone)] @@ -34,10 +35,47 @@ pub struct OperationError { pub message: Option, } +/// A completed World returned from an operation. +#[derive(Debug, Clone)] +pub struct World { + pub world_id: WorldId, + pub display_name: Option, + pub world_marble_url: Option, + pub created_at: Option, + pub updated_at: Option, + pub model: Option, + pub status: Option, + pub tags: Option>, + pub assets: Option, +} + +/// Assets associated with a completed World. +#[derive(Debug, Clone)] +pub struct WorldAssets { + pub caption: Option, + pub thumbnail_url: Option, + pub pano_url: Option, + pub collider_mesh_url: Option, + pub splats: Option, +} + +/// Splat (3D Gaussian) assets for a World. +#[derive(Debug, Clone)] +pub struct SplatAssets { + /// Low-res 100k splat URL + pub spz_url_100k: Option, + /// Medium-res 500k splat URL + pub spz_url_500k: Option, + /// Full resolution splat URL + pub spz_url_full_res: Option, + pub metric_scale_factor: Option, + pub ground_plane_offset: Option, +} + /// GET /marble/v1/operations/{operation_id} /// /// Poll the status of an async operation. -/// When `done` is true, `response` contains the World object. +/// When `done` is true, `world` contains the structured World object. pub async fn get_operation(args: GetOperationArgs<'_>) -> Result { let client = Client::new(); @@ -79,6 +117,16 @@ pub async fn get_operation(args: GetOperationArgs<'_>) -> Result 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), @@ -90,7 +138,46 @@ pub async fn get_operation(args: GetOperationArgs<'_>) -> Result Option { + 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, }) } @@ -108,7 +195,6 @@ mod tests { let creds = get_test_api_key().unwrap(); - // Use a known operation_id from a previous generate_world call let operation_id = OperationId("1fab3bf1-05a1-4929-907e-c6df07c539e2".to_string()); let response = get_operation(GetOperationArgs { @@ -120,9 +206,46 @@ mod tests { 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); - println!("Response: {:?}", response.response); + + 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); } diff --git a/crates/api_clients/world_labs_api/src/api/requests/get_operation/http_request.rs b/crates/api_clients/world_labs_api/src/api/requests/get_operation/http_request.rs index a9bb8bdfc..97a710938 100644 --- a/crates/api_clients/world_labs_api/src/api/requests/get_operation/http_request.rs +++ b/crates/api_clients/world_labs_api/src/api/requests/get_operation/http_request.rs @@ -9,7 +9,7 @@ pub(crate) struct RawResponse { pub expires_at: Option, pub error: Option, pub metadata: Option, - pub response: Option, + pub response: Option, } #[derive(Debug, Clone, Deserialize)] @@ -17,3 +17,66 @@ pub(crate) struct RawOperationError { pub code: Option, pub message: Option, } + +/// 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, + pub display_name: Option, + pub world_marble_url: Option, + pub created_at: Option, + pub updated_at: Option, + pub model: Option, + pub status: Option, + pub tags: Option>, + pub assets: Option, + pub world_prompt: Option, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub(crate) struct RawWorldAssets { + pub caption: Option, + pub thumbnail_url: Option, + pub imagery: Option, + pub mesh: Option, + pub splats: Option, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub(crate) struct RawImagery { + pub pano_url: Option, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub(crate) struct RawMesh { + pub collider_mesh_url: Option, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub(crate) struct RawSplats { + pub spz_urls: Option, + pub semantics_metadata: Option, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub(crate) struct RawSemanticsMetadata { + pub metric_scale_factor: Option, + pub ground_plane_offset: Option, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub(crate) struct RawSpzUrls { + #[serde(rename = "100k")] + pub low: Option, + #[serde(rename = "500k")] + pub medium: Option, + pub full_res: Option, +} diff --git a/crates/api_clients/world_labs_api/src/api/requests/get_world/get_world.rs b/crates/api_clients/world_labs_api/src/api/requests/get_world/get_world.rs index 6b8493a32..1bccc5928 100644 --- a/crates/api_clients/world_labs_api/src/api/requests/get_world/get_world.rs +++ b/crates/api_clients/world_labs_api/src/api/requests/get_world/get_world.rs @@ -1,3 +1,4 @@ +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; @@ -11,12 +12,12 @@ const BASE_URL: &str = "https://api.worldlabs.ai/marble/v1/worlds"; pub struct GetWorldArgs<'a> { pub creds: &'a WorldLabsApiCreds, - pub world_id: &'a str, + pub world_id: &'a WorldId, pub request_timeout: Option, } pub struct GetWorldResponse { - pub world_id: String, + pub world_id: WorldId, pub display_name: Option, pub world_marble_url: Option, pub created_at: Option, @@ -45,7 +46,7 @@ pub struct WorldAssets { pub async fn get_world(args: GetWorldArgs<'_>) -> Result { let client = Client::new(); - let url = format!("{}/{}", BASE_URL, args.world_id); + let url = format!("{}/{}", BASE_URL, args.world_id.as_str()); debug!("Requesting URL: {}", url); @@ -97,7 +98,7 @@ pub async fn get_world(args: GetWorldArgs<'_>) -> Result, pub world_marble_url: Option, pub created_at: Option, @@ -104,7 +105,7 @@ pub async fn list_worlds(args: ListWorldsArgs<'_>) -> Result 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, + } +} + +/// 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, + ]; + + 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_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, + ]; + + 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); + } + } + } +} diff --git a/crates/api_clients/world_labs_api/src/pricing/mod.rs b/crates/api_clients/world_labs_api/src/pricing/mod.rs new file mode 100644 index 000000000..b53ea4037 --- /dev/null +++ b/crates/api_clients/world_labs_api/src/pricing/mod.rs @@ -0,0 +1 @@ +pub mod check_pricing; diff --git a/crates/api_clients/world_labs_api/src/recipes/upload_image_and_create_world_with_retry.rs b/crates/api_clients/world_labs_api/src/recipes/upload_image_and_create_world_with_retry.rs index 091c5a533..62e259369 100644 --- a/crates/api_clients/world_labs_api/src/recipes/upload_image_and_create_world_with_retry.rs +++ b/crates/api_clients/world_labs_api/src/recipes/upload_image_and_create_world_with_retry.rs @@ -1,5 +1,6 @@ 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}; @@ -16,7 +17,7 @@ pub struct UploadImageAndCreateWorldArgs<'a> { pub creds: &'a WorldLabsApiCreds, pub file: FileBytesOrPath, pub text_prompt: Option, - pub model: Option, + pub model: WorldLabsModel, pub individual_request_timeout: Option, } @@ -145,7 +146,7 @@ mod tests { individual_request_timeout: None, file: FileBytesOrPath::Bytes(file_bytes), text_prompt: None, - model: None, + model: WorldLabsModel::Marble0p1Plus, }).await.unwrap(); println!("Operation ID: {}", results.operation_id.as_str());