curl --request POST \
--url https://apis.viggle.ai/v1/scenes/custom-pano \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/json' \
--data '
{
"sessionId": "living-room-01",
"panoramaUri": "<string>",
"panoramaC2w": [
[
1,
0,
0,
0
],
[
0,
1,
0,
0
],
[
0,
0,
1,
0
],
[
0,
0,
0,
1
]
],
"metersPerUnit": 1,
"spzOutputUrl": "<string>",
"outputUris": {
"spz": "<string>",
"preview": "<string>",
"panorama": "<string>"
}
}
'import requests
url = "https://apis.viggle.ai/v1/scenes/custom-pano"
payload = {
"sessionId": "living-room-01",
"panoramaUri": "<string>",
"panoramaC2w": [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]],
"metersPerUnit": 1,
"spzOutputUrl": "<string>",
"outputUris": {
"spz": "<string>",
"preview": "<string>",
"panorama": "<string>"
}
}
headers = {
"Authorization": "Bearer <token>",
"Content-Type": "application/json"
}
response = requests.post(url, json=payload, headers=headers)
print(response.text)const options = {
method: 'POST',
headers: {Authorization: 'Bearer <token>', 'Content-Type': 'application/json'},
body: JSON.stringify({
sessionId: 'living-room-01',
panoramaUri: '<string>',
panoramaC2w: [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]],
metersPerUnit: 1,
spzOutputUrl: '<string>',
outputUris: {spz: '<string>', preview: '<string>', panorama: '<string>'}
})
};
fetch('https://apis.viggle.ai/v1/scenes/custom-pano', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://apis.viggle.ai/v1/scenes/custom-pano",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_POSTFIELDS => json_encode([
'sessionId' => 'living-room-01',
'panoramaUri' => '<string>',
'panoramaC2w' => [
[
1,
0,
0,
0
],
[
0,
1,
0,
0
],
[
0,
0,
1,
0
],
[
0,
0,
0,
1
]
],
'metersPerUnit' => 1,
'spzOutputUrl' => '<string>',
'outputUris' => [
'spz' => '<string>',
'preview' => '<string>',
'panorama' => '<string>'
]
]),
CURLOPT_HTTPHEADER => [
"Authorization: Bearer <token>",
"Content-Type: application/json"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://apis.viggle.ai/v1/scenes/custom-pano"
payload := strings.NewReader("{\n \"sessionId\": \"living-room-01\",\n \"panoramaUri\": \"<string>\",\n \"panoramaC2w\": [\n [\n 1,\n 0,\n 0,\n 0\n ],\n [\n 0,\n 1,\n 0,\n 0\n ],\n [\n 0,\n 0,\n 1,\n 0\n ],\n [\n 0,\n 0,\n 0,\n 1\n ]\n ],\n \"metersPerUnit\": 1,\n \"spzOutputUrl\": \"<string>\",\n \"outputUris\": {\n \"spz\": \"<string>\",\n \"preview\": \"<string>\",\n \"panorama\": \"<string>\"\n }\n}")
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("Authorization", "Bearer <token>")
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}HttpResponse<String> response = Unirest.post("https://apis.viggle.ai/v1/scenes/custom-pano")
.header("Authorization", "Bearer <token>")
.header("Content-Type", "application/json")
.body("{\n \"sessionId\": \"living-room-01\",\n \"panoramaUri\": \"<string>\",\n \"panoramaC2w\": [\n [\n 1,\n 0,\n 0,\n 0\n ],\n [\n 0,\n 1,\n 0,\n 0\n ],\n [\n 0,\n 0,\n 1,\n 0\n ],\n [\n 0,\n 0,\n 0,\n 1\n ]\n ],\n \"metersPerUnit\": 1,\n \"spzOutputUrl\": \"<string>\",\n \"outputUris\": {\n \"spz\": \"<string>\",\n \"preview\": \"<string>\",\n \"panorama\": \"<string>\"\n }\n}")
.asString();require 'uri'
require 'net/http'
url = URI("https://apis.viggle.ai/v1/scenes/custom-pano")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
request["Authorization"] = 'Bearer <token>'
request["Content-Type"] = 'application/json'
request.body = "{\n \"sessionId\": \"living-room-01\",\n \"panoramaUri\": \"<string>\",\n \"panoramaC2w\": [\n [\n 1,\n 0,\n 0,\n 0\n ],\n [\n 0,\n 1,\n 0,\n 0\n ],\n [\n 0,\n 0,\n 1,\n 0\n ],\n [\n 0,\n 0,\n 0,\n 1\n ]\n ],\n \"metersPerUnit\": 1,\n \"spzOutputUrl\": \"<string>\",\n \"outputUris\": {\n \"spz\": \"<string>\",\n \"preview\": \"<string>\",\n \"panorama\": \"<string>\"\n }\n}"
response = http.request(request)
puts response.read_body{
"id": "<string>",
"sessionId": "<string>",
"operation": "pano",
"status": "processing",
"createdAt": 123,
"completedAt": 123,
"result": {
"operation": "pano",
"sessionId": "<string>",
"spz": {
"url": "<string>"
},
"preview": {
"url": "<string>"
},
"panorama": {
"url": "<string>"
},
"outputBatch": {
"batchId": "<string>",
"createdAt": 123,
"manifest": {
"url": "<string>"
},
"kind": "initial",
"views": [
{
"index": 1,
"url": "<string>",
"c2w": [
[
1,
0,
0,
0
],
[
0,
1,
0,
0
],
[
0,
0,
1,
0
],
[
0,
0,
0,
1
]
],
"intrinsic": [
123
]
}
]
},
"referenceViewCount": 123,
"newViewCount": 123,
"seconds": {}
},
"error": {
"code": "UNAUTHENTICATED",
"message": "<string>",
"retryable": true,
"request_id": "<string>",
"details": {},
"remediation": {
"action": "<string>",
"retry_after_ms": 1
}
}
}{
"error": {
"code": "UNAUTHENTICATED",
"message": "<string>",
"retryable": true,
"request_id": "<string>",
"details": {},
"remediation": {
"action": "<string>",
"retry_after_ms": 1
}
}
}{
"error": {
"code": "UNAUTHENTICATED",
"message": "<string>",
"retryable": true,
"request_id": "<string>",
"details": {},
"remediation": {
"action": "<string>",
"retry_after_ms": 1
}
}
}{
"error": {
"code": "UNAUTHENTICATED",
"message": "<string>",
"retryable": true,
"request_id": "<string>",
"details": {},
"remediation": {
"action": "<string>",
"retry_after_ms": 1
}
}
}{
"error": {
"code": "UNAUTHENTICATED",
"message": "<string>",
"retryable": true,
"request_id": "<string>",
"details": {},
"remediation": {
"action": "<string>",
"retry_after_ms": 1
}
}
}Create Scene from Custom Panorama
Build a 3D scene from your own 360° panorama, placed at a pose and scale in your world.
curl --request POST \
--url https://apis.viggle.ai/v1/scenes/custom-pano \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/json' \
--data '
{
"sessionId": "living-room-01",
"panoramaUri": "<string>",
"panoramaC2w": [
[
1,
0,
0,
0
],
[
0,
1,
0,
0
],
[
0,
0,
1,
0
],
[
0,
0,
0,
1
]
],
"metersPerUnit": 1,
"spzOutputUrl": "<string>",
"outputUris": {
"spz": "<string>",
"preview": "<string>",
"panorama": "<string>"
}
}
'import requests
url = "https://apis.viggle.ai/v1/scenes/custom-pano"
payload = {
"sessionId": "living-room-01",
"panoramaUri": "<string>",
"panoramaC2w": [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]],
"metersPerUnit": 1,
"spzOutputUrl": "<string>",
"outputUris": {
"spz": "<string>",
"preview": "<string>",
"panorama": "<string>"
}
}
headers = {
"Authorization": "Bearer <token>",
"Content-Type": "application/json"
}
response = requests.post(url, json=payload, headers=headers)
print(response.text)const options = {
method: 'POST',
headers: {Authorization: 'Bearer <token>', 'Content-Type': 'application/json'},
body: JSON.stringify({
sessionId: 'living-room-01',
panoramaUri: '<string>',
panoramaC2w: [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]],
metersPerUnit: 1,
spzOutputUrl: '<string>',
outputUris: {spz: '<string>', preview: '<string>', panorama: '<string>'}
})
};
fetch('https://apis.viggle.ai/v1/scenes/custom-pano', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://apis.viggle.ai/v1/scenes/custom-pano",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_POSTFIELDS => json_encode([
'sessionId' => 'living-room-01',
'panoramaUri' => '<string>',
'panoramaC2w' => [
[
1,
0,
0,
0
],
[
0,
1,
0,
0
],
[
0,
0,
1,
0
],
[
0,
0,
0,
1
]
],
'metersPerUnit' => 1,
'spzOutputUrl' => '<string>',
'outputUris' => [
'spz' => '<string>',
'preview' => '<string>',
'panorama' => '<string>'
]
]),
CURLOPT_HTTPHEADER => [
"Authorization: Bearer <token>",
"Content-Type: application/json"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://apis.viggle.ai/v1/scenes/custom-pano"
payload := strings.NewReader("{\n \"sessionId\": \"living-room-01\",\n \"panoramaUri\": \"<string>\",\n \"panoramaC2w\": [\n [\n 1,\n 0,\n 0,\n 0\n ],\n [\n 0,\n 1,\n 0,\n 0\n ],\n [\n 0,\n 0,\n 1,\n 0\n ],\n [\n 0,\n 0,\n 0,\n 1\n ]\n ],\n \"metersPerUnit\": 1,\n \"spzOutputUrl\": \"<string>\",\n \"outputUris\": {\n \"spz\": \"<string>\",\n \"preview\": \"<string>\",\n \"panorama\": \"<string>\"\n }\n}")
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("Authorization", "Bearer <token>")
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}HttpResponse<String> response = Unirest.post("https://apis.viggle.ai/v1/scenes/custom-pano")
.header("Authorization", "Bearer <token>")
.header("Content-Type", "application/json")
.body("{\n \"sessionId\": \"living-room-01\",\n \"panoramaUri\": \"<string>\",\n \"panoramaC2w\": [\n [\n 1,\n 0,\n 0,\n 0\n ],\n [\n 0,\n 1,\n 0,\n 0\n ],\n [\n 0,\n 0,\n 1,\n 0\n ],\n [\n 0,\n 0,\n 0,\n 1\n ]\n ],\n \"metersPerUnit\": 1,\n \"spzOutputUrl\": \"<string>\",\n \"outputUris\": {\n \"spz\": \"<string>\",\n \"preview\": \"<string>\",\n \"panorama\": \"<string>\"\n }\n}")
.asString();require 'uri'
require 'net/http'
url = URI("https://apis.viggle.ai/v1/scenes/custom-pano")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
request["Authorization"] = 'Bearer <token>'
request["Content-Type"] = 'application/json'
request.body = "{\n \"sessionId\": \"living-room-01\",\n \"panoramaUri\": \"<string>\",\n \"panoramaC2w\": [\n [\n 1,\n 0,\n 0,\n 0\n ],\n [\n 0,\n 1,\n 0,\n 0\n ],\n [\n 0,\n 0,\n 1,\n 0\n ],\n [\n 0,\n 0,\n 0,\n 1\n ]\n ],\n \"metersPerUnit\": 1,\n \"spzOutputUrl\": \"<string>\",\n \"outputUris\": {\n \"spz\": \"<string>\",\n \"preview\": \"<string>\",\n \"panorama\": \"<string>\"\n }\n}"
response = http.request(request)
puts response.read_body{
"id": "<string>",
"sessionId": "<string>",
"operation": "pano",
"status": "processing",
"createdAt": 123,
"completedAt": 123,
"result": {
"operation": "pano",
"sessionId": "<string>",
"spz": {
"url": "<string>"
},
"preview": {
"url": "<string>"
},
"panorama": {
"url": "<string>"
},
"outputBatch": {
"batchId": "<string>",
"createdAt": 123,
"manifest": {
"url": "<string>"
},
"kind": "initial",
"views": [
{
"index": 1,
"url": "<string>",
"c2w": [
[
1,
0,
0,
0
],
[
0,
1,
0,
0
],
[
0,
0,
1,
0
],
[
0,
0,
0,
1
]
],
"intrinsic": [
123
]
}
]
},
"referenceViewCount": 123,
"newViewCount": 123,
"seconds": {}
},
"error": {
"code": "UNAUTHENTICATED",
"message": "<string>",
"retryable": true,
"request_id": "<string>",
"details": {},
"remediation": {
"action": "<string>",
"retry_after_ms": 1
}
}
}{
"error": {
"code": "UNAUTHENTICATED",
"message": "<string>",
"retryable": true,
"request_id": "<string>",
"details": {},
"remediation": {
"action": "<string>",
"retry_after_ms": 1
}
}
}{
"error": {
"code": "UNAUTHENTICATED",
"message": "<string>",
"retryable": true,
"request_id": "<string>",
"details": {},
"remediation": {
"action": "<string>",
"retry_after_ms": 1
}
}
}{
"error": {
"code": "UNAUTHENTICATED",
"message": "<string>",
"retryable": true,
"request_id": "<string>",
"details": {},
"remediation": {
"action": "<string>",
"retry_after_ms": 1
}
}
}{
"error": {
"code": "UNAUTHENTICATED",
"message": "<string>",
"retryable": true,
"request_id": "<string>",
"details": {},
"remediation": {
"action": "<string>",
"retry_after_ms": 1
}
}
}panoramaUri, but you also say where the panorama was captured in your own world: panoramaC2w is its camera pose and metersPerUnit the scale between the scene model’s frame and yours. Every camera in the result — the batch views now, and the new views you generate later — comes back in your world frame, so the scene lines up with your existing content.
The task is a pano task: Get Scene Task reports operation: "pano" and returns the same result.
Request parameters
Sendapplication/json, up to 1 MiB. Unknown fields are rejected.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
sessionId | string | Yes | — | Session to build the scene in — see Sessions. |
panoramaUri | string | Yes | — | URL of the 360° panorama image. Must follow the URL rules. |
panoramaC2w | number[4][4] | Yes | — | Camera-to-world matrix of the panorama in your world — see Coordinate frame. |
metersPerUnit | number | No | 1 | Scale applied to the scene model’s positions before panoramaC2w moves them into your world — see Coordinate frame. At least 0.05. |
spzOutputUrl | string | No | — | Your own upload URL for the .spz scene — see Upload to your own storage. |
outputUris | object | No | — | Your own upload URLs per file: spz, preview, panorama. Can’t be combined with spzOutputUrl. |
prompt, referenceImages, and quality are not accepted here.
Coordinate frame
panoramaC2w is a 4 × 4 camera-to-world matrix, written as four rows:
- The last row must be
[0, 0, 0, 1]. - The upper-left 3 × 3 rotation must be orthonormal (no scaling or shear) and a proper rotation (determinant
+1, no mirroring), within a tolerance of1e-4. - The translation (the first three values of the fourth column) is the panorama’s position in your world, in your units.
- The scene must fit the range an
.spzcan store: the largest absolute translation component plus25 × metersPerUnit(the scene’s radius) must be below4000.
p in the scene model’s frame lands at R · (metersPerUnit · p) + t in yours, where R and t are the rotation and translation of panoramaC2w. The cameras in the result are converted the same way.
Response parameters
Returns200 OK with a Scene Task in processing state, operation set to pano:
{
"id": "scn_0199f0c2-6a1e-7c3d-9b2a-4f5e6d7c8b9a.atrium-01",
"sessionId": "atrium-01",
"operation": "pano",
"status": "processing",
"createdAt": 1791856740000,
"completedAt": null,
"result": null,
"error": null
}
Common errors
| Error | Cause |
|---|---|
400 INVALID_REQUEST | A field is missing, invalid, or unknown; panoramaC2w isn’t a valid pose; metersPerUnit is below 0.05; the scene would fall outside the coordinate range; or a URL breaks the URL rules. |
401 UNAUTHENTICATED | Missing or invalid API key. |
409 INVALID_REQUEST | The session already has a task processing; see error.details.running_task_id. |
503 SERVICE_BUSY | The scene model is temporarily unavailable. Retryable. |
Examples
A panorama captured 1.5 units above the origin, turned 90° about the y axis, at the default scale:curl -X POST https://apis.viggle.ai/v1/scenes/custom-pano \
-H "Authorization: Bearer $VIGGLE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"sessionId": "atrium-01",
"panoramaUri": "https://example.com/atrium-360.jpg",
"panoramaC2w": [[0, 0, 1, 0], [0, 1, 0, 1.5], [-1, 0, 0, 0], [0, 0, 0, 1]]
}'
import os, requests
response = requests.post(
"https://apis.viggle.ai/v1/scenes/custom-pano",
headers={"Authorization": f"Bearer {os.environ['VIGGLE_API_KEY']}"},
json={
"sessionId": "atrium-01",
"panoramaUri": "https://example.com/atrium-360.jpg",
"panoramaC2w": [[0, 0, 1, 0], [0, 1, 0, 1.5], [-1, 0, 0, 0], [0, 0, 0, 1]],
},
)
response.raise_for_status()
task = response.json()
const response = await fetch("https://apis.viggle.ai/v1/scenes/custom-pano", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.VIGGLE_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
sessionId: "atrium-01",
panoramaUri: "https://example.com/atrium-360.jpg",
panoramaC2w: [[0, 0, 1, 0], [0, 1, 0, 1.5], [-1, 0, 0, 0], [0, 0, 0, 1]],
}),
});
const task = await response.json();
Authorizations
Server-side SDK clients use a project API key. Remote MCP clients use an OAuth access token. Never expose a project API key in browser code.
Headers
Optional caller-supplied correlation ID, up to 128 characters. The service returns the effective value in the response header for tracing and support.
1 - 128Optional source-channel label, up to 128 characters, used to attribute traffic to an SDK, integration, product surface, or internal workflow.
1 - 128Body
Body of POST /v1/scenes/custom-pano. The panorama is placed at
panoramaC2w in the caller's world: a model-frame point p lands
at R · (metersPerUnit · p) + t. The rotation must be orthonormal
and not mirrored (tolerance 1e-4), and the largest absolute
translation component plus 25 × metersPerUnit must be below
4000.
Session chosen by the caller, scoped to the caller's account. A
session holds one scene and its image batches, and runs one task at
a time: a submission while a task is still processing answers
409 with that task in error.details.running_task_id.
1 - 128^[A-Za-z0-9_-]+$"living-room-01"
URL of the 360° panorama.
4 × 4 camera-to-world matrix as four rows of four finite numbers; the last row must be [0, 0, 0, 1].
4 elements4 elements[
[1, 0, 0, 0],
[0, 1, 0, 0],
[0, 0, 1, 0],
[0, 0, 0, 1]
]
Scale applied to model-frame positions before panoramaC2w.
x >= 0.05Same as outputUris.spz.
The caller's own upload URLs; Viggle PUTs each finished file to
its URL. Null entries are ignored. An .spz sent here is not kept
by Viggle, and the result's spz.url is this URL; a preview or
panorama sent here is also kept, and the result links to Viggle's
copy. Each URL must be http(s), without credentials, on a host
that resolves only to public addresses.
Show child attributes
Show child attributes
Response
Scene task started.
A scene task (scn_ prefix), returned by every /v1/scenes
submission and by GET /v1/scenes/{task_id}. ready, failed, and
cancelled are terminal. A task stays readable for about 72 hours
after it finishes.
Task ID, scn_{runId}.{sessionId}.
A custom-pano task reports pano.
pano, generate, reconstruct processing, ready, failed, cancelled Start time, Unix milliseconds.
End time, Unix milliseconds; null while processing.
Set only when ready.
Show child attributes
Show child attributes
Set only when failed. code is INVALID_REQUEST (not
retryable — the model rejected the input, such as a batch from
another session) or PROCESSING_FAILED (retryable).
Show child attributes
Show child attributes

