Skip to content

Commit c1a7e13

Browse files
committed
fix(ai-worldlabs): reject empty worlds and settled wait:false jobs
Guard poll payloads and Spark setup errors. Cover seed photos, the splat allowlist, and plan/recorder failures in tests.
1 parent 5fb6dfe commit c1a7e13

16 files changed

Lines changed: 741 additions & 330 deletions

File tree

docs/adapters/worldlabs.md

Lines changed: 10 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ keywords:
1212
- adapter
1313
---
1414

15-
You want a finished 3D world from a prompt, an image, or a video. World Labs Marble generates splat files, a mesh, and a viewer URL. Generation takes a few minutes.
15+
You want a finished 3D world from a prompt, an image, or a video. World Labs Marble generates splat files, a mesh, and a viewer URL. Generation often takes several minutes.
1616

1717
Use `worldlabsWorld()` with `generateWorld()`. This adapter does not support `chat()`.
1818

@@ -45,12 +45,13 @@ const apiKey = process.env.WORLDLABS_API_KEY ?? ''
4545
const world = await generateWorld({
4646
adapter: worldlabsWorld('marble-1.1', { apiKey }),
4747
prompt: 'A mystical forest with glowing mushrooms',
48+
timeout: 12 * 60 * 1000,
4849
})
4950
```
5051

51-
`world.url` is the Marble viewer URL (`https://marble.worldlabs.ai/world/{id}`). `world.assets` has splat, mesh, panorama, and thumbnail links when the job finishes.
52+
`world.url` is the Marble viewer URL (`https://marble.worldlabs.ai/world/{id}`). Do not iframe it. `world.assets` is optional. Splat, mesh, panorama, and thumbnail links appear only when the World Labs response includes them. Those URLs are often signed CDN links.
5253

53-
World Labs bills in credits per generation. The call waits until the world is ready (about 5 minutes). Pass a long `timeout` on serverless, or set `wait: false` and poll later.
54+
World Labs bills in credits per generation. The call waits until the world is ready (often several minutes). Pass a long `timeout` on serverless, or set `wait: false` and poll later.
5455

5556
## Models
5657

@@ -64,8 +65,8 @@ const adapter = worldlabsWorld('marble-1.1')
6465

6566
| Id | Notes |
6667
| --- | --- |
67-
| `marble-1.1-plus` | Largest outdoor and indoor worlds. Uses more credits. |
68-
| `marble-1.1` | Default Marble 1.1 |
68+
| `marble-1.1-plus` | Dynamic world sizing |
69+
| `marble-1.1` | World Labs default model id |
6970
| `marble-1.0` | Marble 1.0 |
7071
| `marble-1.0-draft` | Faster draft quality |
7172

@@ -97,23 +98,12 @@ Pass only one of `image`, `images`, or `video`.
9798
| `video` | One video. Same source fields as `image` |
9899
| `isPano` | `auto`, `true`, or `false` for a single image |
99100
| `wait` | Default `true`. Set `false` to return `operationId` at once |
101+
| `pollIntervalMs` | Poll delay when `wait` is true. Default 2000 |
100102

101-
Optional metadata: `displayName`, `seed`, `tags`.
103+
Optional metadata: `displayName`, `seed`, `tags`, `disableRecaption`, `permission`.
102104

103-
When `wait` is `false`, `world.status` is `waiting` and `world.operationId` is set. Call `generateWorld` again with `wait: true` only starts a new job. Poll the World Labs operations API with that id, or keep `wait` at the default.
104-
105-
## Custom endpoint
106-
107-
```ts
108-
import { worldlabsWorld } from '@tanstack/ai-worldlabs'
109-
110-
const apiKey = process.env.WORLDLABS_API_KEY ?? ''
111-
const adapter = worldlabsWorld('marble-1.1', {
112-
apiKey,
113-
baseUrl: 'https://api.worldlabs.ai',
114-
})
115-
```
105+
When `wait` is `false`, `world.status` is `waiting` and `world.operationId` is set. This adapter cannot resume that id. Call `generateWorld` again with `wait: true` only starts a new job. Poll `GET /marble/v1/operations/{operationId}` yourself, or keep `wait` at the default.
116106

117107
## What you have now
118108

119-
A server call that generates a Marble world and returns the viewer URL plus asset links. The media example loads `world.assets.splats.spzUrls` in Spark so you can walk the scene in the page. You can also open `world.url`. Splat export needs a World Labs plan that returns those files.
109+
A server call that generates a Marble world and returns the viewer URL plus optional asset links. The media example picks one SPZ (prefers `500k`) and loads it in Spark through `/api/marble-splat`. If no splat URL is present, it shows a thumbnail and an Open in Marble link.

docs/config.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -621,7 +621,7 @@
621621
"label": "World Generation",
622622
"to": "media/world-generation",
623623
"addedAt": "2026-09-04",
624-
"updatedAt": "2026-09-15"
624+
"updatedAt": "2026-09-17"
625625
},
626626
{
627627
"label": "Generation Hooks",
@@ -1026,7 +1026,7 @@
10261026
"label": "World Labs",
10271027
"to": "adapters/worldlabs",
10281028
"addedAt": "2026-09-14",
1029-
"updatedAt": "2026-09-15"
1029+
"updatedAt": "2026-09-17"
10301030
},
10311031
{
10321032
"label": "OpenRouter Adapter",

docs/media/world-generation.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ keywords:
1818

1919
# World Generation (Experimental)
2020

21-
You want a world from a prompt. Some providers open a live session you can steer. Other providers generate a finished 3D world you can view and download.
21+
You want a world from a prompt. Some providers open a live session you can steer. Other providers generate a finished 3D world. Open `world.url` to view it. Asset URLs on `world.assets` are often signed.
2222

2323
Call `generateWorld()` on the server with a world adapter. Reactor mints a session token. World Labs returns a Marble viewer URL after the job finishes.
2424

@@ -161,7 +161,7 @@ await reactor.sendCommand('start', {})
161161

162162
Happy Oyster (`happy-oyster-adventure`, `happy-oyster-director`) uses `createWorld` and `startTravel` after connect. See the [Reactor adapter](../adapters/reactor) for every id. Helios also works with `generateLiveVideo()` and `reactorVideo()`. See [Live Generation](./live-generation).
163163

164-
A full app lives in [`examples/ts-react-media`](https://github.com/TanStack/ai/tree/main/examples/ts-react-media). Open the World tab. Pick a Reactor model for a live stream, or a Marble model for a finished 3D world. Marble worlds play in the page with Spark.
164+
A full app lives in [`examples/ts-react-media`](https://github.com/TanStack/ai/tree/main/examples/ts-react-media). Open the World tab. Pick a Reactor model for a live stream, or a Marble model for a finished 3D world. If splat files are present, the example loads one SPZ in Spark. If not, it shows a thumbnail and an Open in Marble link.
165165

166166
## Finished 3D worlds (World Labs)
167167

@@ -178,8 +178,8 @@ const world = await generateWorld({
178178
})
179179
```
180180

181-
`world.url` is the Marble viewer. `world.assets` has splat, mesh, and panorama URLs. See the [World Labs adapter](../adapters/worldlabs) for image and video inputs, models, and `wait: false`.
181+
`world.url` is the Marble viewer. Do not iframe it. `world.assets` is optional and holds splat, mesh, and panorama URLs when the provider returns them. See the [World Labs adapter](../adapters/worldlabs) for image and video inputs, models, and `wait: false`. `wait: false` returns `operationId`. This SDK cannot resume that id.
182182

183183
## What you have now
184184

185-
A server route that mints a live world session, or a server call that returns a finished Marble world. For Reactor, connect in the browser and change the prompt to steer the stream. The media example can save a Reactor session as MP4 when the model has recording enabled, and download Marble splat files when the plan returns them.
185+
A server route that mints a live world session, or a server call that returns a finished Marble world. For Reactor, connect in the browser and change the prompt to steer the stream. The media example can save a Reactor session as MP4 when recording is enabled for that model or plan, and download Marble files when the response includes them.

examples/ts-react-media/package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
"dev": "vite dev --port 3000",
77
"build": "vite build",
88
"serve": "vite preview",
9-
"test": "exit 0",
9+
"test": "vitest run",
10+
"test:lib": "vitest run",
1011
"test:types": "tsc"
1112
},
1213
"dependencies": {
@@ -41,6 +42,7 @@
4142
"@types/three": "0.180.0",
4243
"@vitejs/plugin-react": "^5.2.0",
4344
"typescript": "5.9.3",
44-
"vite": "^8.2.1"
45+
"vite": "^8.2.1",
46+
"vitest": "^4.1.10"
4547
}
4648
}

examples/ts-react-media/src/components/MarbleViewer.tsx

Lines changed: 96 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useEffect, useRef, useState } from 'react'
22
import { Loader2 } from 'lucide-react'
3+
import type { Object3D, PerspectiveCamera, Scene, WebGLRenderer } from 'three'
34
import { marbleSplatProxyPath, splatFileName } from '@/lib/marble-splat'
45

56
type MarbleViewerProps = {
@@ -8,73 +9,129 @@ type MarbleViewerProps = {
89
groundPlaneOffset?: number
910
}
1011

12+
type ViewerRuntime = {
13+
scene: Scene
14+
camera: PerspectiveCamera
15+
webgl: WebGLRenderer
16+
controls: { update: (camera: Object3D) => void }
17+
}
18+
1119
export function MarbleViewer({
1220
splatUrl,
1321
metricScaleFactor,
1422
groundPlaneOffset,
1523
}: MarbleViewerProps) {
1624
const containerRef = useRef<HTMLDivElement>(null)
25+
const runtimeRef = useRef<ViewerRuntime | null>(null)
26+
const splatRef = useRef<{ dispose: () => void } | null>(null)
27+
const [runtimeReady, setRuntimeReady] = useState(0)
1728
const [loading, setLoading] = useState(true)
1829
const [error, setError] = useState<string | null>(null)
1930

2031
useEffect(() => {
21-
const container = containerRef.current
22-
if (!container) return
32+
const host = containerRef.current
33+
if (!host) return
34+
const root = host
2335

24-
let disposed = false
25-
let renderer: import('three').WebGLRenderer | null = null
26-
let splat: { dispose: () => void } | null = null
27-
let resizeObserver: ResizeObserver | null = null
36+
let cancelled = false
37+
let webgl: WebGLRenderer | undefined
38+
let resizeObserver: ResizeObserver | undefined
2839

29-
async function start() {
40+
void (async () => {
3041
const THREE = await import('three')
3142
const spark = await import('@sparkjsdev/spark')
32-
if (disposed || !container) return
33-
const host = container
43+
if (cancelled) return
3444

3545
const scene = new THREE.Scene()
3646
const camera = new THREE.PerspectiveCamera(70, 1, 0.05, 8000)
3747
camera.position.set(0, 1.6, 0)
3848

39-
const webgl = new THREE.WebGLRenderer({ antialias: true })
49+
webgl = new THREE.WebGLRenderer({ antialias: true })
4050
webgl.setClearColor(0x000000, 1)
4151
webgl.setPixelRatio(Math.min(window.devicePixelRatio, 2))
42-
webgl.domElement.tabIndex = 0
43-
webgl.domElement.className = 'block h-full w-full outline-none'
44-
host.appendChild(webgl.domElement)
45-
renderer = webgl
52+
const canvas = webgl.domElement
53+
canvas.tabIndex = 0
54+
canvas.className = 'block h-full w-full outline-none'
55+
root.appendChild(canvas)
4656

4757
const sparkRenderer = new spark.SparkRenderer({ renderer: webgl })
4858
scene.add(sparkRenderer)
49-
const controls = new spark.SparkControls({ canvas: webgl.domElement })
59+
const controls = new spark.SparkControls({ canvas })
5060
controls.fpsMovement.enable = false
51-
webgl.domElement.addEventListener('pointerdown', () => {
52-
webgl.domElement.focus()
61+
canvas.addEventListener('pointerdown', () => {
62+
canvas.focus()
5363
})
54-
webgl.domElement.addEventListener('focus', () => {
64+
canvas.addEventListener('focus', () => {
5565
controls.fpsMovement.enable = true
5666
})
57-
webgl.domElement.addEventListener('blur', () => {
67+
canvas.addEventListener('blur', () => {
5868
controls.fpsMovement.enable = false
5969
})
70+
canvas.addEventListener('webglcontextlost', (event) => {
71+
event.preventDefault()
72+
setError('The 3D viewer lost the GPU context')
73+
})
6074

6175
function resize() {
62-
const width = host.clientWidth || 1
63-
const height = host.clientHeight || 1
76+
if (!webgl) return
77+
const width = root.clientWidth || 1
78+
const height = root.clientHeight || 1
6479
camera.aspect = width / height
6580
camera.updateProjectionMatrix()
6681
webgl.setSize(width, height, false)
6782
}
6883
resize()
6984
resizeObserver = new ResizeObserver(resize)
70-
resizeObserver.observe(host)
85+
resizeObserver.observe(root)
86+
87+
runtimeRef.current = { scene, camera, webgl, controls }
88+
webgl.setAnimationLoop(() => {
89+
const runtime = runtimeRef.current
90+
if (!runtime) return
91+
runtime.controls.update(runtime.camera)
92+
runtime.webgl.render(runtime.scene, runtime.camera)
93+
})
94+
setRuntimeReady((count) => count + 1)
95+
})().catch((caught: unknown) => {
96+
if (cancelled) return
97+
setLoading(false)
98+
setError(caught instanceof Error ? caught.message : String(caught))
99+
})
71100

72-
const response = await fetch(marbleSplatProxyPath(splatUrl))
73-
if (!response.ok) {
74-
throw new Error('Splat download failed')
101+
return () => {
102+
cancelled = true
103+
resizeObserver?.disconnect()
104+
splatRef.current?.dispose()
105+
splatRef.current = null
106+
if (webgl) {
107+
webgl.setAnimationLoop(null)
108+
webgl.dispose()
109+
webgl.domElement.remove()
75110
}
111+
runtimeRef.current = null
112+
}
113+
}, [])
114+
115+
useEffect(() => {
116+
const runtime = runtimeRef.current
117+
if (!runtimeReady || !runtime) return
118+
119+
let cancelled = false
120+
const abort = new AbortController()
121+
setLoading(true)
122+
setError(null)
123+
124+
splatRef.current?.dispose()
125+
splatRef.current = null
126+
127+
void (async () => {
128+
const spark = await import('@sparkjsdev/spark')
129+
const response = await fetch(marbleSplatProxyPath(splatUrl), {
130+
signal: abort.signal,
131+
})
132+
if (!response.ok) throw new Error('Splat download failed')
76133
const fileBytes = await response.arrayBuffer()
77-
if (disposed) return
134+
if (cancelled) return
78135

79136
const mesh = new spark.SplatMesh({
80137
fileBytes,
@@ -88,35 +145,27 @@ export function MarbleViewer({
88145
if (groundPlaneOffset != null) {
89146
mesh.position.y = -groundPlaneOffset
90147
}
91-
scene.add(mesh)
92-
splat = mesh
148+
runtime.scene.add(mesh)
149+
splatRef.current = mesh
93150
await mesh.initialized
94-
if (disposed) return
151+
if (cancelled) {
152+
mesh.dispose()
153+
return
154+
}
95155
setLoading(false)
96-
97-
webgl.setAnimationLoop(() => {
98-
controls.update(camera)
99-
webgl.render(scene, camera)
100-
})
101-
}
102-
103-
start().catch((caught: unknown) => {
104-
if (disposed) return
156+
})().catch((caught: unknown) => {
157+
if (cancelled || abort.signal.aborted) return
105158
setLoading(false)
106159
setError(caught instanceof Error ? caught.message : String(caught))
107160
})
108161

109162
return () => {
110-
disposed = true
111-
resizeObserver?.disconnect()
112-
if (renderer) {
113-
renderer.setAnimationLoop(null)
114-
renderer.dispose()
115-
renderer.domElement.remove()
116-
}
117-
splat?.dispose()
163+
cancelled = true
164+
abort.abort()
165+
splatRef.current?.dispose()
166+
splatRef.current = null
118167
}
119-
}, [splatUrl, metricScaleFactor, groundPlaneOffset])
168+
}, [runtimeReady, splatUrl, metricScaleFactor, groundPlaneOffset])
120169

121170
return (
122171
<div className="relative aspect-video w-full bg-black">

0 commit comments

Comments
 (0)