Marble 2 beta

Generate, pose, and edit a world image

This tutorial chains three Marble 2 tasks into one reproducible workflow:

atlasTextToImage → images2PosedRGBD → atlasMasked

You will generate a world image, estimate its camera, and then edit a masked region while preserving the unmasked scene. The intermediate image and camera are explicit artifacts, which makes the workflow easy to inspect, retry, and automate with a coding agent.

Access-dependent tutorial: all three task endpoints must appear in the API reference for your selected account. At the time of writing, atlasTextToImage is available to enabled Marble 2 beta accounts and atlasMasked remains an internal preview. If atlasMasked is not enabled for your account, submitting it returns HTTP 404. Complete the first four steps and stop before the masked edit.

Before you start

Create a project API key with tasks.create, operations.read, assets.create, and assets.read, then configure:

bash
set -o pipefail
export WLT_API_BASE_URL="https://api.atlas-beta.worldlabs.ai/api/v2"export WLT_API_KEY="wl_live_replace_with_your_key"export WLT_POLL_TIMEOUT_SECS="${WLT_POLL_TIMEOUT_SECS:-3600}"

The examples use curl, jq, OpenSSL, and ImageMagick. Install the dependencies once:

bash
# macOSbrew install imagemagick jq openssl
# Ubuntu or Debiansudo apt-get updatesudo apt-get install -y imagemagick jq openssl

ImageMagick 7 calls its CLI magick; some Linux packages still ship ImageMagick 6 as convert. Select whichever command is installed:

bash
if command -v magick >/dev/null 2>&1; then  IMAGE_MAGICK=(magick)elif command -v convert >/dev/null 2>&1; then  IMAGE_MAGICK=(convert)else  printf 'Install ImageMagick before continuing.\n' >&2  return 1 2>/dev/null || exit 1fi

Keep every JSON response while developing; operation IDs are the audit trail between stages.

Define helpers that retry transient API failures, generate a unique idempotency key for each intentional submission, print progress, and stop polling after the overall timeout. Automatic retries of one submission reuse the same key.

bash
request_json_with_retry() {  local response_file attempt delay started_at deadline remaining max_time  local http_code curl_rc retryable  response_file=$(mktemp) || return 1  attempt=1  delay=1  started_at=$SECONDS  deadline=$((started_at + ${WLT_HTTP_RETRY_TIMEOUT_SECS:-90}))
  while true; do    remaining=$((deadline - SECONDS))    if (( remaining <= 0 )); then      printf 'HTTP retry window expired.\n' >&2      rm -f "$response_file"      return 28    fi
    max_time=45    (( remaining < max_time )) && max_time=$remaining    : > "$response_file"    http_code=$(curl --silent --show-error \      --output "$response_file" \      --write-out '%{http_code}' \      --connect-timeout 10 \      --max-time "$max_time" \      "$@")    curl_rc=$?
    if (( curl_rc == 0 )) && [[ "$http_code" == 2* ]]; then      cat "$response_file"      rm -f "$response_file"      return 0    fi
    retryable=false    if (( curl_rc != 0 )); then      retryable=true    else      case "$http_code" in        408|429|500|502|503|504) retryable=true ;;      esac    fi
    if [[ "$retryable" != true ]] || (( attempt >= 6 )); then      if (( curl_rc == 0 )); then        printf 'HTTP %s\n' "$http_code" >&2      fi      if [[ -s "$response_file" ]]; then        jq . "$response_file" >&2 2>/dev/null || cat "$response_file" >&2      fi      rm -f "$response_file"      (( curl_rc != 0 )) && return "$curl_rc"      return 22    fi
    remaining=$((deadline - SECONDS))    if (( remaining <= 0 )); then      printf 'HTTP retry window expired after attempt %s.\n' "$attempt" >&2      rm -f "$response_file"      return 28    fi    (( delay > remaining )) && delay=$remaining    printf 'Transient request failure (HTTP %s, curl %s); retrying in %ss.\n' \      "${http_code:-000}" "$curl_rc" "$delay" >&2    sleep "$delay"    (( delay < 8 )) && delay=$((delay * 2))    (( delay > 8 )) && delay=8    attempt=$((attempt + 1))  done}
wait_for_operation() {  local operation_id="$1"  local started_at=$SECONDS  local timeout_secs="${WLT_POLL_TIMEOUT_SECS:-3600}"  local operation state phase progress elapsed request_retry_window
  while (( SECONDS - started_at < timeout_secs )); do    request_retry_window=$((timeout_secs - (SECONDS - started_at)))    (( request_retry_window > 90 )) && request_retry_window=90    operation=$(WLT_HTTP_RETRY_TIMEOUT_SECS="$request_retry_window" \      request_json_with_retry \        --request POST "$WLT_API_BASE_URL/operations/${operation_id}:wait" \        --header "WLT-Api-Key: $WLT_API_KEY" \        --header "Content-Type: application/json" \        --data '{ "timeoutSecs": 30 }') || return
    elapsed=$((SECONDS - started_at))    state=$(printf '%s' "$operation" | jq -r \      '.metadata.state // (if .done then "DONE" else "PENDING" end)')    phase=$(printf '%s' "$operation" | jq -r '.metadata.phase // empty')    progress=$(printf '%s' "$operation" | jq -r \      'if .metadata.progressPercent == null then empty else "\(.metadata.progressPercent)%" end')
    printf '[%4ss] %s' "$elapsed" "$state" >&2    [ -z "$phase" ] || printf ' · %s' "$phase" >&2    [ -z "$progress" ] || printf ' · %s' "$progress" >&2    printf '\n' >&2
    if [ "$(printf '%s' "$operation" | jq -r '.done')" = "true" ]; then      if [ "$(printf '%s' "$operation" | jq -r '.error // empty')" ]; then        printf '%s\n' "$operation" | jq '.error' >&2        return 1      fi      printf '%s\n' "$operation" | jq -e '.response'      return    fi    sleep 1  done
  printf 'Stopped after %ss; operation %s is still running.\n' \    "$timeout_secs" "$operation_id" >&2  printf 'Resume with: wait_for_operation %q\n' "$operation_id" >&2  return 124}
run_task() {  local task="$1"  local request_file="$2"  local idempotency_key operation operation_id operation_file  idempotency_key="tutorial-${task}-$(openssl rand -hex 12)" || return
  operation=$(request_json_with_retry \    --request POST "$WLT_API_BASE_URL/tasks:$task" \    --header "WLT-Api-Key: $WLT_API_KEY" \    --header "Idempotency-Key: $idempotency_key" \    --header "Content-Type: application/json" \    --data-binary "@$request_file") || return  operation_id=$(printf '%s' "$operation" | jq -er '.id') || return  operation_file="${request_file%.json}-operation.json"  printf '%s\n' "$operation" | jq '.' > "$operation_file"  printf 'Submitted %s as %s (saved in %s).\n' \    "$task" "$operation_id" "$operation_file" >&2
  wait_for_operation "$operation_id"}

The request helper retries transport failures and transient HTTP responses such as 429, 500, 502, 503, and 504 with exponential delay capped at eight seconds. Each response is kept in a temporary file so failed response bodies cannot be combined with successful JSON from a later attempt. If polling hits the overall timeout, the operation continues server-side and the printed wait_for_operation command resumes watching it without submitting another task. See Operations and Errors and retries.

1. Generate a world image

Ask for a coherent environment with a wide, eye-level view and visible depth cues:

bash
jq -n '{  prompt: "A sunlit glass observatory in an alpine meadow, cinematic architectural photograph",  aspectRatio: "16:9",  numSamples: 1}' > 01-text-to-image.json
run_task atlasTextToImage 01-text-to-image.json \  > 01-text-to-image-response.json &&SOURCE_IMAGE_URL=$(jq -er '.frames[0].imageAsset.url' \  01-text-to-image-response.json) &&curl --fail-with-body --location "$SOURCE_IMAGE_URL" --output generated.png

Generated glass observatory
Actual atlasTextToImage output from the prompt above

One sample is intentional. Multiple atlasTextToImage samples are variations, not synchronized views of one scene, so do not treat them as a multi-view capture.

Read Text to image for prompting and model-quality guidance.

2. Prepare a supported inpainting frame

atlasMasked accepts 1280 × 720 cameras. Resize and center-crop before estimating the pose so every later artifact shares the same aspect ratio:

bash
"${IMAGE_MAGICK[@]}" generated.png \  -resize '1280x720^' -gravity center -extent 1280x720 \  prepared-scene.png

Create an 8-bit grayscale mask on the same 1280 × 720 pixel grid. White keeps the original pixels; black marks pixels the model should replace. This sample regenerates a band at the top of the image:

bash
"${IMAGE_MAGICK[@]}" -size 1280x720 xc:white -colorspace Gray \  -fill black -draw 'rectangle 0,0 1279,179' mask.png

Inpainting region over the prepared image
The purple overlay visualizes the black 0-value region in mask.png; the rest of the image stays fixed

For object removal, paint the object black in every affected view and include a small margin around it.

For multiview edits with existing posed RGBD inputs, use the open-source Marble Multiview Inpainting tools to project an anchor edit into the other views. See Prepare inputs locally for the input format, preview checks, and upload handoff. The single-image example here does not require these tools.

3. Upload the prepared image and mask

Inline asset creation is convenient for PNG files smaller than 10 MiB. For larger inputs, use the upload flow in Assets.

bash
create_inline_asset() {  local file="$1"  local display_name="$2"
  base64 < "$file" | tr -d '\n' | \    jq -Rs --arg displayName "$display_name" '{      asset: {        displayName: $displayName,        base64: ("data:image/png;base64," + .)      }    }' | \    curl --fail-with-body --silent --show-error \      --request POST "$WLT_API_BASE_URL/assets" \      --header "WLT-Api-Key: $WLT_API_KEY" \      --header "Content-Type: application/json" \      --data-binary @-}
create_inline_asset prepared-scene.png generated-courtyard.png \  > 02-image-asset.json &&create_inline_asset mask.png generated-courtyard-mask.png \  > 02-mask-asset.json &&
IMAGE_ASSET_ID=$(jq -er '.name | split("/")[-1]' 02-image-asset.json) &&MASK_ASSET_ID=$(jq -er '.name | split("/")[-1]' 02-mask-asset.json)

The base64 bytes travel through standard input instead of a shell argument, so multi-megabyte images do not hit the operating system's argument-size limit.

4. Estimate the camera

Run images2PosedRGBD on the prepared image. A single frame selects the single-image reconstruction path:

bash
jq -n --arg asset "$IMAGE_ASSET_ID" '{  frames: [{ imageAsset: { assetId: $asset } }],  targetResolution: [1280, 720]}' > 03-pose-request.json
run_task images2PosedRGBD 03-pose-request.json > 03-pose-response.json &&jq -e '.frames[0].camera' 03-pose-response.json > estimated-camera.json &&DEPTH_URL=$(jq -er '.frames[0].depth.depthAsset.url' \  03-pose-response.json) &&curl --fail-with-body --location "$DEPTH_URL" --output estimated-depth.exr

Estimated scene depth
The images2PosedRGBD result includes the recovered camera and this depth estimate

estimated-depth.exr is the generated linear-depth map in world units. Keep the EXR for geometry processing; the image above is a normalized visualization of this kind of depth data, not the raw floating-point values. The response also includes per-pixel confidence at .frames[0].depth.confidenceAsset.url.

If Three.js EXRLoader reads the EXR for CPU geometry processing, apply the EXR row mapping before matching depth pixels with the image or confidence map. The downloaded EXR itself does not need to be rewritten.

The request puts the returned image, depth, confidence, and camera on the same 1280 × 720 crop required by atlasMasked. Copy the camera without changing its intrinsics:

bash
jq . estimated-camera.json > inpaint-camera.json

Do not resize the posed image or depth after this point. A client-side intrinsics scale would miss the center-crop offset already applied by the posing task.

5. Complete the masked region

First confirm that atlasMasked appears under Task endpoints in the live API reference. If it is absent, the HTTP 404 response is the expected access-gate behavior, not a problem with your request. Ask your World Labs contact for preview access or stop here.

If it is available, combine the prepared RGB asset, mask asset, and normalized camera:

bash
jq -n \  --arg image "$IMAGE_ASSET_ID" \  --arg mask "$MASK_ASSET_ID" \  --slurpfile camera inpaint-camera.json '{    contextFrames: [{      imageAsset: { assetId: $image },      maskAsset: { assetId: $mask },      camera: $camera[0]    }],    targetCameras: [$camera[0]],    prompt: "A vivid ribbon of green and violet aurora arcs across the alpine sky above the glass observatory; preserve the roofline",    enhancePrompt: false,    returnDepth: false  }' > 04-inpaint-request.json
run_task atlasMasked 04-inpaint-request.json > 04-inpaint-response.json &&jq '.frames[0]' 04-inpaint-response.json

This request sets enhancePrompt: false, so the model receives the prompt exactly as written and reads it as a caption of the finished image, not as instructions. To keep part of the scene unchanged, leave it white in the mask. For your own edits, leave enhancement on and write the change as an instruction, as described in Write the prompt.

Completed observatory image
Actual atlasMasked output using the generated image, estimated camera, and mask shown above

The successful response preserves the camera with the completed image. Keep the response, request, and all three operation IDs together so you can trace exactly which generated image and pose produced the edit.

Extend the workflow

  • Set returnDepth: true only after adding at least two translated context views; pure-rotation cameras cannot establish reliable metric scale.
  • Feed multiple real, overlapping captures to images2PosedRGBD instead of independent text-to-image variations when you need a multi-view edit.
  • Continue with atlasChisel only after preparing 1280 x 720 depth-only frames and cameras together; posed RGBD output cannot be passed through verbatim. Omit its RGB, mask, and depth confidence assets.
  • Validate every request against the live API reference, because preview schemas can change during the Marble 2 beta.